Skip to content

Commit e188763

Browse files
authored
Merge pull request #551 from GeorgeTsagk/recurring-budget
multi: add recurring autoloop budget
2 parents 2fff034 + 1ff2e5c commit e188763

13 files changed

+541
-311
lines changed

cmd/loop/liquidity.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -298,11 +298,10 @@ var setParamsCommand = cli.Command{
298298
"automatically dispatched loop out swaps may " +
299299
"spend",
300300
},
301-
cli.Uint64Flag{
302-
Name: "budgetstart",
303-
Usage: "the start time for the automated loop " +
304-
"out budget, expressed as a unix timestamp " +
305-
"in seconds",
301+
cli.DurationFlag{
302+
Name: "autobudgetrefreshperiod",
303+
Usage: "the time period over which the automated " +
304+
"loop budget is refreshed",
306305
},
307306
cli.Uint64Flag{
308307
Name: "autoinflight",
@@ -440,8 +439,9 @@ func setParams(ctx *cli.Context) error {
440439
flagSet = true
441440
}
442441

443-
if ctx.IsSet("budgetstart") {
444-
params.AutoloopBudgetStartSec = ctx.Uint64("budgetstart")
442+
if ctx.IsSet("autobudgetrefreshperiod") {
443+
params.AutoloopBudgetRefreshPeriodSec =
444+
uint64(ctx.Duration("autobudgetrefreshperiod").Seconds())
445445
flagSet = true
446446
}
447447

docs/autoloop.md

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -157,31 +157,28 @@ The autolooper operates within a set budget, and will stop executing swaps when
157157
this budget is reached. This budget includes the fees paid to the swap server,
158158
on-chain sweep costs and off-chain routing fees. Note that the budget does not
159159
include the actual swap amount, as this balance is simply shifted from off-chain
160-
to on-chain, rather than used up.
160+
to on-chain, rather than used up.
161161

162162
The budget value is expressed in satoshis, and can be set using the `setparams`
163163
loop command:
164164
```
165165
loop setparams --autobudget={budget in satoshis}
166166
```
167167

168-
Your autoloop budget can optionally be paired with a start time, which
169-
determines the time from which we will count autoloop swaps as being part of
170-
the budget. If this value is zero, it will consider all automatically
171-
dispatched swaps as being part of the budget.
172-
173-
The start time is expressed as a unix timestamp, and can be set using the
174-
`setparams` loop command:
168+
Your autoloop budget is refreshed based on a configurable interval. You can
169+
specify how often the budget is going to refresh by using the `setparams` loop
170+
command:
175171
```
176-
loop setparams --budgetstart={start time in seconds}
172+
loop setparams --autobudgetrefreshperiod={duration in seconds}
177173
```
178174

175+
179176
If your autolooper has used up its budget, and you would like to top it up, you
180-
can do so by either increasing the overall budget amount, or by increasing the
181-
start time to the present. For example, if you want to set your autolooper to
182-
have a budget of 100k sats for the month, you could set the following:
177+
can do so by either increasing the overall budget amount, or by decreasing the
178+
refresh interval. For example, if you want to set your autolooper to
179+
have a budget of 100k sats per 7 days (or 604800 seconds), you could set the following:
183180
```
184-
loop setparams --autobudget=100000 --autostart={beginning of month ts}
181+
loop setparams --autobudget=100000 --autobudgetrefreshperiod=604800
185182
```
186183

187184
## Dispatch Control

liquidity/autoloop_test.go

Lines changed: 238 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -95,12 +95,12 @@ func TestAutoLoopEnabled(t *testing.T) {
9595
// autoloop budget is set to allow exactly 2 swaps at the prices
9696
// that we set in our test quotes.
9797
params = Parameters{
98-
Autoloop: true,
99-
AutoFeeBudget: 40066,
100-
AutoFeeStartDate: testTime,
101-
MaxAutoInFlight: 2,
102-
FailureBackOff: time.Hour,
103-
SweepConfTarget: 10,
98+
Autoloop: true,
99+
AutoFeeBudget: 40066,
100+
AutoFeeRefreshPeriod: testBudgetRefresh,
101+
MaxAutoInFlight: 2,
102+
FailureBackOff: time.Hour,
103+
SweepConfTarget: 10,
104104
FeeLimit: NewFeeCategoryLimit(
105105
swapFeePPM, routeFeePPM, prepayFeePPM, maxMiner,
106106
prepayAmount, 20000,
@@ -112,6 +112,7 @@ func TestAutoLoopEnabled(t *testing.T) {
112112
HtlcConfTarget: defaultHtlcConfTarget,
113113
}
114114
)
115+
115116
c := newAutoloopTestCtx(t, params, channels, testRestrictions)
116117
c.start()
117118

@@ -335,13 +336,13 @@ func TestAutoloopAddress(t *testing.T) {
335336
// Create some dummy parameters for autoloop and also specify an
336337
// destination address.
337338
params = Parameters{
338-
Autoloop: true,
339-
AutoFeeBudget: 40066,
340-
DestAddr: addr,
341-
AutoFeeStartDate: testTime,
342-
MaxAutoInFlight: 2,
343-
FailureBackOff: time.Hour,
344-
SweepConfTarget: 10,
339+
Autoloop: true,
340+
AutoFeeBudget: 40066,
341+
DestAddr: addr,
342+
AutoFeeRefreshPeriod: testBudgetRefresh,
343+
MaxAutoInFlight: 2,
344+
FailureBackOff: time.Hour,
345+
SweepConfTarget: 10,
345346
FeeLimit: NewFeeCategoryLimit(
346347
swapFeePPM, routeFeePPM, prepayFeePPM, maxMiner,
347348
prepayAmount, 20000,
@@ -491,12 +492,12 @@ func TestCompositeRules(t *testing.T) {
491492
swapFeePPM, routeFeePPM, prepayFeePPM, maxMiner,
492493
prepayAmount, 20000,
493494
),
494-
Autoloop: true,
495-
AutoFeeBudget: 100000,
496-
AutoFeeStartDate: testTime,
497-
MaxAutoInFlight: 2,
498-
FailureBackOff: time.Hour,
499-
SweepConfTarget: 10,
495+
Autoloop: true,
496+
AutoFeeBudget: 100000,
497+
AutoFeeRefreshPeriod: testBudgetRefresh,
498+
MaxAutoInFlight: 2,
499+
FailureBackOff: time.Hour,
500+
SweepConfTarget: 10,
500501
ChannelRules: map[lnwire.ShortChannelID]*SwapRule{
501502
chanID1: chanRule,
502503
},
@@ -670,13 +671,13 @@ func TestAutoLoopInEnabled(t *testing.T) {
670671
peer2MaxFee = ppmToSat(peer2ExpectedAmt, swapFeePPM)
671672

672673
params = Parameters{
673-
Autoloop: true,
674-
AutoFeeBudget: peer1MaxFee + peer2MaxFee + 1,
675-
AutoFeeStartDate: testTime,
676-
MaxAutoInFlight: 2,
677-
FailureBackOff: time.Hour,
678-
FeeLimit: NewFeePortion(swapFeePPM),
679-
ChannelRules: make(map[lnwire.ShortChannelID]*SwapRule),
674+
Autoloop: true,
675+
AutoFeeBudget: peer1MaxFee + peer2MaxFee + 1,
676+
AutoFeeRefreshPeriod: testBudgetRefresh,
677+
MaxAutoInFlight: 2,
678+
FailureBackOff: time.Hour,
679+
FeeLimit: NewFeePortion(swapFeePPM),
680+
ChannelRules: make(map[lnwire.ShortChannelID]*SwapRule),
680681
PeerRules: map[route.Vertex]*SwapRule{
681682
peer1: rule,
682683
peer2: rule,
@@ -853,12 +854,12 @@ func TestAutoloopBothTypes(t *testing.T) {
853854
loopInMaxFee = ppmToSat(loopInAmount, swapFeePPM)
854855

855856
params = Parameters{
856-
Autoloop: true,
857-
AutoFeeBudget: loopOutMaxFee + loopInMaxFee + 1,
858-
AutoFeeStartDate: testTime,
859-
MaxAutoInFlight: 2,
860-
FailureBackOff: time.Hour,
861-
FeeLimit: NewFeePortion(swapFeePPM),
857+
Autoloop: true,
858+
AutoFeeBudget: loopOutMaxFee + loopInMaxFee + 1,
859+
AutoFeeRefreshPeriod: testBudgetRefresh,
860+
MaxAutoInFlight: 2,
861+
FailureBackOff: time.Hour,
862+
FeeLimit: NewFeePortion(swapFeePPM),
862863
ChannelRules: map[lnwire.ShortChannelID]*SwapRule{
863864
chanID1: outRule,
864865
},
@@ -965,6 +966,211 @@ func TestAutoloopBothTypes(t *testing.T) {
965966
c.stop()
966967
}
967968

969+
// TestAutoLoopRecurringBudget tests that the autolooper will perform swaps that
970+
// respect the fee budget, and that it will refresh the budget based on the
971+
// defined refresh period.
972+
func TestAutoLoopRecurringBudget(t *testing.T) {
973+
defer test.Guard(t)()
974+
975+
var (
976+
channels = []lndclient.ChannelInfo{
977+
channel1, channel2,
978+
}
979+
980+
swapFeePPM uint64 = 1000
981+
routeFeePPM uint64 = 1000
982+
prepayFeePPM uint64 = 1000
983+
prepayAmount = btcutil.Amount(20000)
984+
maxMiner = btcutil.Amount(20000)
985+
986+
params = Parameters{
987+
Autoloop: true,
988+
AutoFeeBudget: 36000,
989+
AutoFeeRefreshPeriod: time.Hour * 3,
990+
MaxAutoInFlight: 2,
991+
FailureBackOff: time.Hour,
992+
SweepConfTarget: 10,
993+
FeeLimit: NewFeeCategoryLimit(
994+
swapFeePPM, routeFeePPM, prepayFeePPM, maxMiner,
995+
prepayAmount, 20000,
996+
),
997+
ChannelRules: map[lnwire.ShortChannelID]*SwapRule{
998+
chanID1: chanRule,
999+
chanID2: chanRule,
1000+
},
1001+
HtlcConfTarget: defaultHtlcConfTarget,
1002+
}
1003+
)
1004+
1005+
c := newAutoloopTestCtx(t, params, channels, testRestrictions)
1006+
c.start()
1007+
1008+
// Calculate our maximum allowed fees and create quotes that fall within
1009+
// our budget.
1010+
var (
1011+
amt = chan1Rec.Amount
1012+
1013+
maxSwapFee = ppmToSat(amt, swapFeePPM)
1014+
1015+
// Create a quote that is within our limits. We do not set miner
1016+
// fee because this value is not actually set by the server.
1017+
quote1 = &loop.LoopOutQuote{
1018+
SwapFee: maxSwapFee,
1019+
PrepayAmount: prepayAmount - 10,
1020+
MinerFee: maxMiner - 10,
1021+
}
1022+
1023+
quote2 = &loop.LoopOutQuote{
1024+
SwapFee: maxSwapFee,
1025+
PrepayAmount: prepayAmount - 20,
1026+
MinerFee: maxMiner - 10,
1027+
}
1028+
1029+
quoteRequest = &loop.LoopOutQuoteRequest{
1030+
Amount: amt,
1031+
SweepConfTarget: params.SweepConfTarget,
1032+
}
1033+
1034+
quotes1 = []quoteRequestResp{
1035+
{
1036+
request: quoteRequest,
1037+
quote: quote1,
1038+
},
1039+
{
1040+
request: quoteRequest,
1041+
quote: quote2,
1042+
},
1043+
}
1044+
1045+
quotes2 = []quoteRequestResp{
1046+
{
1047+
request: quoteRequest,
1048+
quote: quote2,
1049+
},
1050+
}
1051+
1052+
maxRouteFee = ppmToSat(amt, routeFeePPM)
1053+
1054+
chan1Swap = &loop.OutRequest{
1055+
Amount: amt,
1056+
MaxSwapRoutingFee: maxRouteFee,
1057+
MaxPrepayRoutingFee: ppmToSat(
1058+
quote1.PrepayAmount, prepayFeePPM,
1059+
),
1060+
MaxSwapFee: quote1.SwapFee,
1061+
MaxPrepayAmount: quote1.PrepayAmount,
1062+
MaxMinerFee: maxMiner,
1063+
SweepConfTarget: params.SweepConfTarget,
1064+
OutgoingChanSet: loopdb.ChannelSet{chanID1.ToUint64()},
1065+
Label: labels.AutoloopLabel(swap.TypeOut),
1066+
Initiator: autoloopSwapInitiator,
1067+
}
1068+
1069+
chan2Swap = &loop.OutRequest{
1070+
Amount: amt,
1071+
MaxSwapRoutingFee: maxRouteFee,
1072+
MaxPrepayRoutingFee: ppmToSat(
1073+
quote2.PrepayAmount, routeFeePPM,
1074+
),
1075+
MaxSwapFee: quote2.SwapFee,
1076+
MaxPrepayAmount: quote2.PrepayAmount,
1077+
MaxMinerFee: maxMiner,
1078+
SweepConfTarget: params.SweepConfTarget,
1079+
OutgoingChanSet: loopdb.ChannelSet{chanID2.ToUint64()},
1080+
Label: labels.AutoloopLabel(swap.TypeOut),
1081+
Initiator: autoloopSwapInitiator,
1082+
}
1083+
1084+
loopOuts1 = []loopOutRequestResp{
1085+
{
1086+
request: chan1Swap,
1087+
response: &loop.LoopOutSwapInfo{
1088+
SwapHash: lntypes.Hash{1},
1089+
},
1090+
},
1091+
}
1092+
1093+
loopOuts2 = []loopOutRequestResp{
1094+
{
1095+
request: chan2Swap,
1096+
response: &loop.LoopOutSwapInfo{
1097+
SwapHash: lntypes.Hash{1},
1098+
},
1099+
},
1100+
}
1101+
)
1102+
1103+
// Tick our autolooper with no existing swaps, we expect a loop out
1104+
// swap to be dispatched on first channel.
1105+
step := &autoloopStep{
1106+
minAmt: 1,
1107+
maxAmt: amt + 1,
1108+
quotesOut: quotes1,
1109+
expectedOut: loopOuts1,
1110+
}
1111+
c.autoloop(step)
1112+
1113+
existing := []*loopdb.LoopOut{
1114+
existingSwapFromRequest(
1115+
chan1Swap, testTime, []*loopdb.LoopEvent{
1116+
{
1117+
SwapStateData: loopdb.SwapStateData{
1118+
State: loopdb.StateInitiated,
1119+
},
1120+
Time: testTime,
1121+
},
1122+
},
1123+
),
1124+
}
1125+
1126+
step = &autoloopStep{
1127+
minAmt: 1,
1128+
maxAmt: amt + 1,
1129+
quotesOut: quotes2,
1130+
existingOut: existing,
1131+
expectedOut: nil,
1132+
}
1133+
// Tick again, we should expect no loop outs because our budget would be
1134+
// exceeded.
1135+
c.autoloop(step)
1136+
1137+
// Create the existing entry for the first swap, marking its last update
1138+
// with success and a specific timestamp.
1139+
existing2 := []*loopdb.LoopOut{
1140+
existingSwapFromRequest(
1141+
chan1Swap, testTime, []*loopdb.LoopEvent{
1142+
{
1143+
SwapStateData: loopdb.SwapStateData{
1144+
State: loopdb.StateSuccess,
1145+
},
1146+
Time: testTime,
1147+
},
1148+
},
1149+
),
1150+
}
1151+
1152+
// Apply the balance shifts on the channels in order to get the correct
1153+
// recommendations on next tick.
1154+
c.lnd.Channels[0].LocalBalance = 2500
1155+
c.lnd.Channels[0].RemoteBalance = 7500
1156+
1157+
// Advance time to the future, causing a budget refresh.
1158+
c.testClock.SetTime(testTime.Add(time.Hour * 25))
1159+
1160+
step = &autoloopStep{
1161+
minAmt: 1,
1162+
maxAmt: amt + 1,
1163+
quotesOut: quotes2,
1164+
existingOut: existing2,
1165+
expectedOut: loopOuts2,
1166+
}
1167+
1168+
// Tick again, we should expect a loop out to occur on the 2nd channel.
1169+
c.autoloop(step)
1170+
1171+
c.stop()
1172+
}
1173+
9681174
// existingSwapFromRequest is a helper function which returns the db
9691175
// representation of a loop out request with the event set provided.
9701176
func existingSwapFromRequest(request *loop.OutRequest, initTime time.Time,

liquidity/autoloop_testcontext_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,8 @@ func newAutoloopTestCtx(t *testing.T, parameters Parameters,
117117
testCtx.lnd.Channels = channels
118118

119119
cfg := &Config{
120-
AutoloopTicker: ticker.NewForce(DefaultAutoloopTicker),
120+
AutoloopTicker: ticker.NewForce(DefaultAutoloopTicker),
121+
AutoloopBudgetLastRefresh: testBudgetStart,
121122
Restrictions: func(_ context.Context, swapType swap.Type) (*Restrictions,
122123
error) {
123124

0 commit comments

Comments
 (0)