Skip to content

Commit 8166d93

Browse files
committed
multi: add opt-in automated swap dispatch to liquidity manager
1 parent fd17580 commit 8166d93

File tree

8 files changed

+625
-12
lines changed

8 files changed

+625
-12
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ require (
1616
github.com/lightningnetwork/lnd/cert v1.0.3
1717
github.com/lightningnetwork/lnd/clock v1.0.1
1818
github.com/lightningnetwork/lnd/queue v1.0.4
19+
github.com/lightningnetwork/lnd/ticker v1.0.0
1920
github.com/stretchr/testify v1.5.1
2021
github.com/urfave/cli v1.20.0
2122
golang.org/x/net v0.0.0-20191002035440-2ec189313ef0

liquidity/autoloop_test.go

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
package liquidity
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/lightninglabs/lndclient"
8+
"github.com/lightninglabs/loop"
9+
"github.com/lightninglabs/loop/labels"
10+
"github.com/lightninglabs/loop/loopdb"
11+
"github.com/lightninglabs/loop/test"
12+
"github.com/lightningnetwork/lnd/lntypes"
13+
"github.com/lightningnetwork/lnd/lnwire"
14+
)
15+
16+
// TestAutoLoopDisabled tests the case where we need to perform a swap, but
17+
// autoloop is not enabled.
18+
func TestAutoLoopDisabled(t *testing.T) {
19+
defer test.Guard(t)()
20+
21+
// Set parameters for a channel that will require a swap.
22+
channels := []lndclient.ChannelInfo{
23+
channel1,
24+
}
25+
26+
params := defaultParameters
27+
params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{
28+
chanID1: chanRule,
29+
}
30+
31+
c := newAutoloopTestCtx(t, params, channels)
32+
c.start()
33+
34+
// We expect a single quote to be required for our swap on channel 1.
35+
// We set its quote to have acceptable fees for our current limit.
36+
quotes := []quoteRequestResp{
37+
{
38+
request: &loop.LoopOutQuoteRequest{
39+
Amount: chan1Rec.Amount,
40+
SweepConfTarget: chan1Rec.SweepConfTarget,
41+
},
42+
quote: testQuote,
43+
},
44+
}
45+
46+
// Trigger an autoloop attempt for our test context with no existing
47+
// loop in/out swaps. We expect a swap for our channel to be suggested,
48+
// but do not expect any swaps to be executed, since autoloop is
49+
// disabled by default.
50+
c.autoloop(1, chan1Rec.Amount+1, nil, quotes, nil)
51+
52+
// Trigger another autoloop, this time setting our server restrictions
53+
// to have a minimum swap amount greater than the amount that we need
54+
// to swap. In this case we don't even expect to get a quote, because
55+
// our suggested swap is beneath the minimum swap size.
56+
c.autoloop(chan1Rec.Amount+1, chan1Rec.Amount+2, nil, nil, nil)
57+
58+
c.stop()
59+
}
60+
61+
// TestAutoLoopEnabled tests enabling the liquidity manger's autolooper. To keep
62+
// the test simple, we do not update actual lnd channel balances, but rather
63+
// run our mock with two channels that will always require a loop out according
64+
// to our rules. This allows us to test the other restrictions placed on the
65+
// autolooper (such as balance, and in-flight swaps) rather than need to worry
66+
// about calculating swap amounts and thresholds.
67+
func TestAutoLoopEnabled(t *testing.T) {
68+
defer test.Guard(t)()
69+
70+
channels := []lndclient.ChannelInfo{
71+
channel1, channel2,
72+
}
73+
74+
// Create a set of parameters with autoloop enabled. The autoloop budget
75+
// is set to allow exactly 2 swaps at the prices that we set in our
76+
// test quotes.
77+
params := Parameters{
78+
AutoOut: true,
79+
AutoFeeBudget: 40066,
80+
AutoFeeStartDate: testTime,
81+
MaxAutoInFlight: 2,
82+
FailureBackOff: time.Hour,
83+
SweepFeeRateLimit: 20000,
84+
SweepConfTarget: 10,
85+
MaximumPrepay: 20000,
86+
MaximumSwapFeePPM: 1000,
87+
MaximumRoutingFeePPM: 1000,
88+
MaximumPrepayRoutingFeePPM: 1000,
89+
MaximumMinerFee: 20000,
90+
ChannelRules: map[lnwire.ShortChannelID]*ThresholdRule{
91+
chanID1: chanRule,
92+
chanID2: chanRule,
93+
},
94+
}
95+
96+
c := newAutoloopTestCtx(t, params, channels)
97+
c.start()
98+
99+
// Calculate our maximum allowed fees and create quotes that fall within
100+
// our budget.
101+
var (
102+
amt = chan1Rec.Amount
103+
104+
maxSwapFee = ppmToSat(amt, params.MaximumSwapFeePPM)
105+
106+
// Create a quote that is within our limits. We do not set miner
107+
// fee because this value is not actually set by the server.
108+
quote1 = &loop.LoopOutQuote{
109+
SwapFee: maxSwapFee,
110+
PrepayAmount: params.MaximumPrepay - 10,
111+
}
112+
113+
quote2 = &loop.LoopOutQuote{
114+
SwapFee: maxSwapFee,
115+
PrepayAmount: params.MaximumPrepay - 20,
116+
}
117+
118+
quoteRequest = &loop.LoopOutQuoteRequest{
119+
Amount: amt,
120+
SweepConfTarget: params.SweepConfTarget,
121+
}
122+
123+
quotes = []quoteRequestResp{
124+
{
125+
request: quoteRequest,
126+
quote: quote1,
127+
},
128+
{
129+
request: quoteRequest,
130+
quote: quote2,
131+
},
132+
}
133+
134+
maxRouteFee = ppmToSat(amt, params.MaximumRoutingFeePPM)
135+
136+
chan1Swap = &loop.OutRequest{
137+
Amount: amt,
138+
MaxSwapRoutingFee: maxRouteFee,
139+
MaxPrepayRoutingFee: ppmToSat(
140+
quote1.PrepayAmount,
141+
params.MaximumPrepayRoutingFeePPM,
142+
),
143+
MaxSwapFee: quote1.SwapFee,
144+
MaxPrepayAmount: quote1.PrepayAmount,
145+
MaxMinerFee: params.MaximumMinerFee,
146+
SweepConfTarget: params.SweepConfTarget,
147+
OutgoingChanSet: loopdb.ChannelSet{chanID1.ToUint64()},
148+
Label: labels.AutoOutLabel(),
149+
}
150+
151+
chan2Swap = &loop.OutRequest{
152+
Amount: amt,
153+
MaxSwapRoutingFee: maxRouteFee,
154+
MaxPrepayRoutingFee: ppmToSat(
155+
quote2.PrepayAmount,
156+
params.MaximumPrepayRoutingFeePPM,
157+
),
158+
MaxSwapFee: quote2.SwapFee,
159+
MaxPrepayAmount: quote2.PrepayAmount,
160+
MaxMinerFee: params.MaximumMinerFee,
161+
SweepConfTarget: params.SweepConfTarget,
162+
OutgoingChanSet: loopdb.ChannelSet{chanID2.ToUint64()},
163+
Label: labels.AutoOutLabel(),
164+
}
165+
166+
loopOuts = []loopOutRequestResp{
167+
{
168+
request: chan1Swap,
169+
response: &loop.LoopOutSwapInfo{
170+
SwapHash: lntypes.Hash{1},
171+
},
172+
},
173+
{
174+
request: chan2Swap,
175+
response: &loop.LoopOutSwapInfo{
176+
SwapHash: lntypes.Hash{2},
177+
},
178+
},
179+
}
180+
)
181+
182+
// Tick our autolooper with no existing swaps, we expect a loop out
183+
// swap to be dispatched for each channel.
184+
c.autoloop(1, amt+1, nil, quotes, loopOuts)
185+
186+
// Tick again with both of our swaps in progress. We haven't shifted our
187+
// channel balances at all, so swaps should still be suggested, but we
188+
// have 2 swaps in flight so we do not expect any suggestion.
189+
existing := []*loopdb.LoopOut{
190+
existingSwapFromRequest(chan1Swap, testTime, nil),
191+
existingSwapFromRequest(chan2Swap, testTime, nil),
192+
}
193+
194+
c.autoloop(1, amt+1, existing, nil, nil)
195+
196+
// Now, we update our channel 2 swap to have failed due to off chain
197+
// failure and our first swap to have succeeded.
198+
now := c.testClock.Now()
199+
failedOffChain := []*loopdb.LoopEvent{
200+
{
201+
SwapStateData: loopdb.SwapStateData{
202+
State: loopdb.StateFailOffchainPayments,
203+
},
204+
Time: now,
205+
},
206+
}
207+
208+
success := []*loopdb.LoopEvent{
209+
{
210+
SwapStateData: loopdb.SwapStateData{
211+
State: loopdb.StateSuccess,
212+
Cost: loopdb.SwapCost{
213+
Server: quote1.SwapFee,
214+
Onchain: params.MaximumMinerFee,
215+
Offchain: maxRouteFee +
216+
chan1Rec.MaxPrepayRoutingFee,
217+
},
218+
},
219+
Time: now,
220+
},
221+
}
222+
223+
quotes = []quoteRequestResp{
224+
{
225+
request: quoteRequest,
226+
quote: quote1,
227+
},
228+
}
229+
230+
loopOuts = []loopOutRequestResp{
231+
{
232+
request: chan1Swap,
233+
response: &loop.LoopOutSwapInfo{
234+
SwapHash: lntypes.Hash{3},
235+
},
236+
},
237+
}
238+
239+
existing = []*loopdb.LoopOut{
240+
existingSwapFromRequest(chan1Swap, testTime, success),
241+
existingSwapFromRequest(chan2Swap, testTime, failedOffChain),
242+
}
243+
244+
// We tick again, this time we expect another swap on channel 1 (which
245+
// still has balances which reflect that we need to swap), but nothing
246+
// for channel 2, since it has had a failure.
247+
c.autoloop(1, amt+1, existing, quotes, loopOuts)
248+
249+
// Now, we progress our time so that we have sufficiently backed off
250+
// for channel 2, and could perform another swap.
251+
c.testClock.SetTime(now.Add(params.FailureBackOff))
252+
253+
// Our existing swaps (1 successful, one pending) have used our budget
254+
// so we no longer expect any swaps to automatically dispatch.
255+
existing = []*loopdb.LoopOut{
256+
existingSwapFromRequest(chan1Swap, testTime, success),
257+
existingSwapFromRequest(chan1Swap, c.testClock.Now(), nil),
258+
existingSwapFromRequest(chan2Swap, testTime, failedOffChain),
259+
}
260+
261+
c.autoloop(1, amt+1, existing, quotes, nil)
262+
263+
c.stop()
264+
}
265+
266+
// existingSwapFromRequest is a helper function which returns the db
267+
// representation of a loop out request with the event set provided.
268+
func existingSwapFromRequest(request *loop.OutRequest, initTime time.Time,
269+
events []*loopdb.LoopEvent) *loopdb.LoopOut {
270+
271+
return &loopdb.LoopOut{
272+
Loop: loopdb.Loop{
273+
Events: events,
274+
},
275+
Contract: &loopdb.LoopOutContract{
276+
SwapContract: loopdb.SwapContract{
277+
AmountRequested: request.Amount,
278+
MaxSwapFee: request.MaxSwapFee,
279+
MaxMinerFee: request.MaxMinerFee,
280+
InitiationTime: initTime,
281+
Label: request.Label,
282+
},
283+
SwapInvoice: "",
284+
MaxSwapRoutingFee: request.MaxSwapRoutingFee,
285+
SweepConfTarget: request.SweepConfTarget,
286+
OutgoingChanSet: request.OutgoingChanSet,
287+
MaxPrepayRoutingFee: request.MaxSwapRoutingFee,
288+
},
289+
}
290+
}

0 commit comments

Comments
 (0)