Skip to content

Commit dd1a2de

Browse files
committed
multi: add flat fee percentage to autoloop
1 parent c778124 commit dd1a2de

File tree

7 files changed

+491
-163
lines changed

7 files changed

+491
-163
lines changed

liquidity/fees.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ const (
3737
// defaultSweepFeeRateLimit is the default limit we place on estimated
3838
// sweep fees, (750 * 4 /1000 = 3 sat/vByte).
3939
defaultSweepFeeRateLimit = chainfee.SatPerKWeight(750)
40+
41+
// minerMultiplier is a multiplier we use to scale our miner fee to
42+
// ensure that we will still be able to complete our swap in the case
43+
// of a severe fee spike.
44+
minerMultiplier = 100
4045
)
4146

4247
var (
@@ -55,6 +60,10 @@ var (
5560
// ErrZeroPrepay is returned if a zero maximum prepay is set.
5661
ErrZeroPrepay = errors.New("maximum prepay must be non-zero")
5762

63+
// ErrInvalidPPM is returned is the parts per million for a fee rate
64+
// are invalid.
65+
ErrInvalidPPM = errors.New("invalid ppm")
66+
5867
// ErrInvalidSweepFeeRateLimit is returned if an invalid sweep fee limit
5968
// is set.
6069
ErrInvalidSweepFeeRateLimit = fmt.Errorf("sweep fee rate limit must "+
@@ -224,3 +233,144 @@ func (f *FeeCategoryLimit) loopOutFees(amount btcutil.Amount,
224233

225234
return prepayMaxFee, routeMaxFee, f.MaximumMinerFee
226235
}
236+
237+
// Compile time assertion that FeePortion implements FeeLimit interface.
238+
var _ FeeLimit = (*FeePortion)(nil)
239+
240+
// FeePortion is a fee limitation which limits fees to a set portion of
241+
// the swap amount.
242+
type FeePortion struct {
243+
// PartsPerMillion is the total portion of the swap amount that the
244+
// swap may consume.
245+
PartsPerMillion uint64
246+
}
247+
248+
// NewFeePortion creates a fee limit based on a flat percentage of swap amount.
249+
func NewFeePortion(ppm uint64) *FeePortion {
250+
return &FeePortion{
251+
PartsPerMillion: ppm,
252+
}
253+
}
254+
255+
// String returns a string representation of the fee limit.
256+
func (f *FeePortion) String() string {
257+
return fmt.Sprintf("parts per million: %v", f.PartsPerMillion)
258+
}
259+
260+
// validate returns an error if the values provided are invalid.
261+
func (f *FeePortion) validate() error {
262+
if f.PartsPerMillion <= 0 {
263+
return ErrInvalidPPM
264+
}
265+
266+
return nil
267+
}
268+
269+
// mayLoopOut checks our estimated loop out sweep fee against our sweep limit.
270+
// For fee percentage, we do not check anything because we need the full quote
271+
// to determine whether we can perform a swap.
272+
func (f *FeePortion) mayLoopOut(_ chainfee.SatPerKWeight) error {
273+
return nil
274+
}
275+
276+
// loopOutLimits checks whether the quote provided is within our fee
277+
// limits for the swap amount.
278+
func (f *FeePortion) loopOutLimits(swapAmt btcutil.Amount,
279+
quote *loop.LoopOutQuote) error {
280+
281+
// First, check whether any of the individual fee categories provided
282+
// by the server are more than our total limit. We do this so that we
283+
// can provide more specific reasons for not executing swaps.
284+
feeLimit := ppmToSat(swapAmt, f.PartsPerMillion)
285+
minerFee := scaleMinerFee(quote.MinerFee)
286+
287+
if minerFee > feeLimit {
288+
log.Debugf("miner fee: %v greater than fee limit: %v, at "+
289+
"%v ppm", minerFee, feeLimit, f.PartsPerMillion)
290+
291+
return newReasonError(ReasonMinerFee)
292+
}
293+
294+
if quote.SwapFee > feeLimit {
295+
log.Debugf("swap fee: %v greater than fee limit: %v, at "+
296+
"%v ppm", quote.SwapFee, feeLimit, f.PartsPerMillion)
297+
298+
return newReasonError(ReasonSwapFee)
299+
}
300+
301+
if quote.PrepayAmount > feeLimit {
302+
log.Debugf("prepay amount: %v greater than fee limit: %v, at "+
303+
"%v ppm", quote.PrepayAmount, feeLimit, f.PartsPerMillion)
304+
305+
return newReasonError(ReasonPrepay)
306+
}
307+
308+
// If our miner and swap fee equal our limit, we will have nothing left
309+
// for off-chain fees, so we fail out early.
310+
if minerFee+quote.SwapFee >= feeLimit {
311+
log.Debugf("no budget for off-chain routing with miner fee: "+
312+
"%v, swap fee: %v and fee limit: %v, at %v ppm",
313+
minerFee, quote.SwapFee, feeLimit, f.PartsPerMillion)
314+
315+
return newReasonError(ReasonFeePPMInsufficient)
316+
}
317+
318+
prepay, route, miner := f.loopOutFees(swapAmt, quote)
319+
320+
// Calculate the worst case fees that we could pay for this swap,
321+
// ensuring that we are within our fee limit even if the swap fails.
322+
fees := worstCaseOutFees(
323+
prepay, route, quote.SwapFee, miner, quote.PrepayAmount,
324+
)
325+
326+
if fees > feeLimit {
327+
log.Debugf("total fees for swap: %v > fee limit: %v, at "+
328+
"%v ppm", fees, feeLimit, f.PartsPerMillion)
329+
330+
return newReasonError(ReasonFeePPMInsufficient)
331+
}
332+
333+
return nil
334+
}
335+
336+
// loopOutFees return the maximum prepay and invoice routing fees for a swap
337+
// amount and quote. Note that the fee portion implementation just returns
338+
// the quote's miner fee, assuming that this value has already been validated.
339+
// We also assume that the quote's minerfee + swapfee < fee limit, so that we
340+
// have some fees left for off-chain routing.
341+
func (f *FeePortion) loopOutFees(amount btcutil.Amount,
342+
quote *loop.LoopOutQuote) (btcutil.Amount, btcutil.Amount,
343+
btcutil.Amount) {
344+
345+
// Calculate the total amount we can spend in fees, and subtract the
346+
// amounts provided by the quote to get the total available for
347+
// off-chain fees.
348+
feeLimit := ppmToSat(amount, f.PartsPerMillion)
349+
minerFee := scaleMinerFee(quote.MinerFee)
350+
351+
available := feeLimit - minerFee - quote.SwapFee
352+
353+
prepayMaxFee, routeMaxFee := splitOffChain(
354+
available, quote.PrepayAmount, amount,
355+
)
356+
357+
return prepayMaxFee, routeMaxFee, minerFee
358+
}
359+
360+
// splitOffChain takes an available fee budget and divides it among our prepay
361+
// and swap payments proportional to their volume.
362+
func splitOffChain(available, prepayAmt,
363+
swapAmt btcutil.Amount) (btcutil.Amount, btcutil.Amount) {
364+
365+
total := swapAmt + prepayAmt
366+
367+
prepayMaxFee := available * prepayAmt / total
368+
routeMaxFee := available * swapAmt / total
369+
370+
return prepayMaxFee, routeMaxFee
371+
}
372+
373+
// scaleMinerFee scales our miner fee by our constant multiplier.
374+
func scaleMinerFee(estimate btcutil.Amount) btcutil.Amount {
375+
return estimate * btcutil.Amount(minerMultiplier)
376+
}

liquidity/liquidity_test.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,19 @@ func newTestConfig() (*Config, *test.LndMockServices) {
151151
}, lnd
152152
}
153153

154+
// testPPMFees calculates the split of fees between prepay and swap invoice
155+
// for the swap amount and ppm, relying on the test quote.
156+
func testPPMFees(ppm uint64, quote *loop.LoopOutQuote,
157+
swapAmount btcutil.Amount) (btcutil.Amount, btcutil.Amount) {
158+
159+
feeTotal := ppmToSat(swapAmount, ppm)
160+
feeAvailable := feeTotal - scaleMinerFee(quote.MinerFee) - quote.SwapFee
161+
162+
return splitOffChain(
163+
feeAvailable, quote.PrepayAmount, swapAmount,
164+
)
165+
}
166+
154167
// TestParameters tests getting and setting of parameters for our manager.
155168
func TestParameters(t *testing.T) {
156169
cfg, _ := newTestConfig()
@@ -1269,6 +1282,147 @@ func TestSizeRestrictions(t *testing.T) {
12691282
}
12701283
}
12711284

1285+
// TestFeePercentage tests use of a flat fee percentage to limit the fees we
1286+
// pay for swaps. Our test is setup to require a 7500 sat swap, and we test
1287+
// this amount against various fee percentages and server quotes.
1288+
func TestFeePercentage(t *testing.T) {
1289+
var (
1290+
okPPM uint64 = 30000
1291+
okQuote = &loop.LoopOutQuote{
1292+
SwapFee: 15,
1293+
PrepayAmount: 30,
1294+
MinerFee: 1,
1295+
}
1296+
1297+
rec = loop.OutRequest{
1298+
Amount: 7500,
1299+
OutgoingChanSet: loopdb.ChannelSet{chanID1.ToUint64()},
1300+
MaxMinerFee: scaleMinerFee(okQuote.MinerFee),
1301+
MaxSwapFee: okQuote.SwapFee,
1302+
MaxPrepayAmount: okQuote.PrepayAmount,
1303+
SweepConfTarget: loop.DefaultSweepConfTarget,
1304+
Initiator: autoloopSwapInitiator,
1305+
}
1306+
)
1307+
1308+
rec.MaxPrepayRoutingFee, rec.MaxSwapRoutingFee = testPPMFees(
1309+
okPPM, okQuote, 7500,
1310+
)
1311+
1312+
tests := []struct {
1313+
name string
1314+
feePPM uint64
1315+
quote *loop.LoopOutQuote
1316+
suggestions *Suggestions
1317+
}{
1318+
{
1319+
// With our limit set to 3% of swap amount 7500, we
1320+
// have a total budget of 225 sat.
1321+
name: "fees ok",
1322+
feePPM: okPPM,
1323+
quote: okQuote,
1324+
suggestions: &Suggestions{
1325+
OutSwaps: []loop.OutRequest{
1326+
rec,
1327+
},
1328+
DisqualifiedChans: noneDisqualified,
1329+
DisqualifiedPeers: noPeersDisqualified,
1330+
},
1331+
},
1332+
{
1333+
name: "swap fee too high",
1334+
feePPM: 20000,
1335+
quote: &loop.LoopOutQuote{
1336+
SwapFee: 300,
1337+
PrepayAmount: 30,
1338+
MinerFee: 1,
1339+
},
1340+
suggestions: &Suggestions{
1341+
DisqualifiedChans: map[lnwire.ShortChannelID]Reason{
1342+
chanID1: ReasonSwapFee,
1343+
},
1344+
DisqualifiedPeers: noPeersDisqualified,
1345+
},
1346+
},
1347+
{
1348+
name: "miner fee too high",
1349+
feePPM: 20000,
1350+
quote: &loop.LoopOutQuote{
1351+
SwapFee: 80,
1352+
PrepayAmount: 30,
1353+
MinerFee: 300,
1354+
},
1355+
suggestions: &Suggestions{
1356+
DisqualifiedChans: map[lnwire.ShortChannelID]Reason{
1357+
chanID1: ReasonMinerFee,
1358+
},
1359+
DisqualifiedPeers: noPeersDisqualified,
1360+
},
1361+
},
1362+
{
1363+
name: "miner and swap too high",
1364+
feePPM: 20000,
1365+
quote: &loop.LoopOutQuote{
1366+
SwapFee: 60,
1367+
PrepayAmount: 30,
1368+
MinerFee: 1,
1369+
},
1370+
suggestions: &Suggestions{
1371+
DisqualifiedChans: map[lnwire.ShortChannelID]Reason{
1372+
chanID1: ReasonFeePPMInsufficient,
1373+
},
1374+
DisqualifiedPeers: noPeersDisqualified,
1375+
},
1376+
},
1377+
{
1378+
name: "prepay too high",
1379+
feePPM: 30000,
1380+
quote: &loop.LoopOutQuote{
1381+
SwapFee: 75,
1382+
PrepayAmount: 300,
1383+
MinerFee: 1,
1384+
},
1385+
suggestions: &Suggestions{
1386+
DisqualifiedChans: map[lnwire.ShortChannelID]Reason{
1387+
chanID1: ReasonPrepay,
1388+
},
1389+
DisqualifiedPeers: noPeersDisqualified,
1390+
},
1391+
},
1392+
}
1393+
1394+
for _, testCase := range tests {
1395+
testCase := testCase
1396+
1397+
t.Run(testCase.name, func(t *testing.T) {
1398+
cfg, lnd := newTestConfig()
1399+
1400+
cfg.LoopOutQuote = func(_ context.Context,
1401+
_ *loop.LoopOutQuoteRequest) (*loop.LoopOutQuote,
1402+
error) {
1403+
1404+
return testCase.quote, nil
1405+
1406+
}
1407+
1408+
lnd.Channels = []lndclient.ChannelInfo{
1409+
channel1,
1410+
}
1411+
1412+
params := defaultParameters
1413+
params.FeeLimit = NewFeePortion(testCase.feePPM)
1414+
params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{
1415+
chanID1: chanRule,
1416+
}
1417+
1418+
testSuggestSwaps(
1419+
t, newSuggestSwapsSetup(cfg, lnd, params),
1420+
testCase.suggestions, nil,
1421+
)
1422+
})
1423+
}
1424+
}
1425+
12721426
// testSuggestSwapsSetup contains the elements that are used to create a
12731427
// suggest swaps test.
12741428
type testSuggestSwapsSetup struct {

liquidity/reasons.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ const (
6161
// from budget elapsed, because we still have some budget available,
6262
// but we have allocated it to other swaps.
6363
ReasonBudgetInsufficient
64+
65+
// ReasonFeePPMInsufficient indicates that the fees a swap would require
66+
// are greater than the portion of swap amount allocated to fees.
67+
ReasonFeePPMInsufficient
6468
)
6569

6670
// String returns a string representation of a reason.
@@ -105,6 +109,9 @@ func (r Reason) String() string {
105109
case ReasonBudgetInsufficient:
106110
return "budget insufficient"
107111

112+
case ReasonFeePPMInsufficient:
113+
return "fee portion insufficient"
114+
108115
default:
109116
return "unknown"
110117
}

loopd/swapclient_server.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -859,6 +859,9 @@ func rpcAutoloopReason(reason liquidity.Reason) (looprpc.AutoReason, error) {
859859
case liquidity.ReasonBudgetInsufficient:
860860
return looprpc.AutoReason_AUTO_REASON_BUDGET_INSUFFICIENT, nil
861861

862+
case liquidity.ReasonFeePPMInsufficient:
863+
return looprpc.AutoReason_AUTO_REASON_SWAP_FEE, nil
864+
862865
default:
863866
return 0, fmt.Errorf("unknown autoloop reason: %v", reason)
864867
}

0 commit comments

Comments
 (0)