Skip to content

Commit 1f2417c

Browse files
committed
loopd: fractional static address swap amount
1 parent 6dc77ad commit 1f2417c

File tree

2 files changed

+176
-35
lines changed

2 files changed

+176
-35
lines changed

cmd/loop/staticaddr.go

Lines changed: 72 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/hex"
66
"errors"
77
"fmt"
8+
"sort"
89
"strconv"
910
"strings"
1011

@@ -14,6 +15,8 @@ import (
1415
"github.com/lightninglabs/loop/staticaddr/deposit"
1516
"github.com/lightninglabs/loop/staticaddr/loopin"
1617
"github.com/lightninglabs/loop/swapserverrpc"
18+
"github.com/lightningnetwork/lnd/input"
19+
"github.com/lightningnetwork/lnd/lnwallet"
1720
"github.com/lightningnetwork/lnd/routing/route"
1821
"github.com/urfave/cli"
1922
)
@@ -477,6 +480,13 @@ var staticAddressLoopInCommand = cli.Command{
477480
"The client can retry the swap with adjusted " +
478481
"parameters after the payment timed out.",
479482
},
483+
cli.IntFlag{
484+
Name: "amount",
485+
Usage: "the number of satoshis that should be " +
486+
"swapped from the selected deposits. If there" +
487+
"is change it is sent back to the static " +
488+
"address.",
489+
},
480490
lastHopFlag,
481491
labelFlag,
482492
routeHintsFlag,
@@ -502,11 +512,14 @@ func staticAddressLoopIn(ctx *cli.Context) error {
502512
ctxb = context.Background()
503513
isAllSelected = ctx.IsSet("all")
504514
isUtxoSelected = ctx.IsSet("utxo")
515+
isAmountSelected bool
516+
selectedAmount = ctx.Int64("amount")
505517
label = ctx.String("static-loop-in")
506518
hints []*swapserverrpc.RouteHint
507519
lastHop []byte
508520
paymentTimeoutSeconds = uint32(loopin.DefaultPaymentTimeoutSeconds)
509521
)
522+
isAmountSelected = selectedAmount > 0
510523

511524
// Validate our label early so that we can fail before getting a quote.
512525
if err := labels.Validate(label); err != nil {
@@ -541,7 +554,9 @@ func staticAddressLoopIn(ctx *cli.Context) error {
541554
return err
542555
}
543556

544-
if len(depositList.FilteredDeposits) == 0 {
557+
allDeposits := depositList.FilteredDeposits
558+
559+
if len(allDeposits) == 0 {
545560
errString := fmt.Sprintf("no confirmed deposits available, "+
546561
"deposits need at least %v confirmations",
547562
deposit.MinConfs)
@@ -551,17 +566,25 @@ func staticAddressLoopIn(ctx *cli.Context) error {
551566

552567
var depositOutpoints []string
553568
switch {
554-
case isAllSelected == isUtxoSelected:
555-
return errors.New("must select either all or some utxos")
569+
case isAllSelected && isUtxoSelected:
570+
return errors.New("cannot select all and specific utxos")
556571

557572
case isAllSelected:
558-
depositOutpoints = depositsToOutpoints(
559-
depositList.FilteredDeposits,
560-
)
573+
depositOutpoints = depositsToOutpoints(allDeposits)
561574

562575
case isUtxoSelected:
563576
depositOutpoints = ctx.StringSlice("utxo")
564577

578+
case isAmountSelected:
579+
// If there's only a swap amount specified we'll coin-select
580+
// deposits to cover the swap amount.
581+
depositOutpoints, err = selectDeposits(
582+
allDeposits, selectedAmount,
583+
)
584+
if err != nil {
585+
return err
586+
}
587+
565588
default:
566589
return fmt.Errorf("unknown quote request")
567590
}
@@ -571,6 +594,7 @@ func staticAddressLoopIn(ctx *cli.Context) error {
571594
}
572595

573596
quoteReq := &looprpc.QuoteRequest{
597+
Amt: selectedAmount,
574598
LoopInRouteHints: hints,
575599
LoopInLastHop: lastHop,
576600
Private: ctx.Bool(privateFlag.Name),
@@ -583,15 +607,6 @@ func staticAddressLoopIn(ctx *cli.Context) error {
583607

584608
limits := getInLimits(quote)
585609

586-
// populate the quote request with the sum of selected deposits and
587-
// prompt the user for acceptance.
588-
quoteReq.Amt, err = sumDeposits(
589-
depositOutpoints, depositList.FilteredDeposits,
590-
)
591-
if err != nil {
592-
return err
593-
}
594-
595610
if !(ctx.Bool("force") || ctx.Bool("f")) {
596611
err = displayInDetails(quoteReq, quote, ctx.Bool("verbose"))
597612
if err != nil {
@@ -604,6 +619,7 @@ func staticAddressLoopIn(ctx *cli.Context) error {
604619
}
605620

606621
req := &looprpc.StaticAddressLoopInRequest{
622+
Amount: quoteReq.Amt,
607623
Outpoints: depositOutpoints,
608624
MaxSwapFeeSatoshis: int64(limits.maxSwapFee),
609625
LastHop: lastHop,
@@ -624,6 +640,47 @@ func staticAddressLoopIn(ctx *cli.Context) error {
624640
return nil
625641
}
626642

643+
// selectDeposits sorts the deposits by amount in descending order, then by
644+
// blocks-until-expiry in ascending order. It then selects the deposits that
645+
// are needed to cover the amount requested without leaving a dust change. It
646+
// returns an error if the sum of deposits minus dust is less than the requested
647+
// amount.
648+
func selectDeposits(deposits []*looprpc.Deposit, targetAmount int64) ([]string,
649+
error) {
650+
651+
// Sort the deposits by amount in descending order, then by
652+
// blocks-until-expiry in ascending order.
653+
sort.Slice(deposits, func(i, j int) bool {
654+
if deposits[i].Value == deposits[j].Value {
655+
return deposits[i].BlocksUntilExpiry <
656+
deposits[j].BlocksUntilExpiry
657+
}
658+
return deposits[i].Value > deposits[j].Value
659+
})
660+
661+
// Select the deposits that are needed to cover the swap amount without
662+
// leaving a dust change.
663+
var selectedDeposits []string
664+
var selectedAmount int64
665+
dustLimit := lnwallet.DustLimitForSize(input.P2TRSize)
666+
for _, deposit := range deposits {
667+
selectedDeposits = append(selectedDeposits, deposit.Outpoint)
668+
selectedAmount += deposit.Value
669+
if selectedAmount == targetAmount {
670+
return selectedDeposits, nil
671+
}
672+
if selectedAmount > targetAmount {
673+
if selectedAmount-targetAmount >= int64(dustLimit) {
674+
return selectedDeposits, nil
675+
}
676+
}
677+
}
678+
679+
return nil, fmt.Errorf("not enough deposits to cover "+
680+
"requested amount, selected %d but need %d",
681+
selectedAmount, targetAmount)
682+
}
683+
627684
func containsDuplicates(outpoints []string) bool {
628685
found := make(map[string]struct{})
629686
for _, outpoint := range outpoints {
@@ -636,26 +693,6 @@ func containsDuplicates(outpoints []string) bool {
636693
return false
637694
}
638695

639-
func sumDeposits(outpoints []string, deposits []*looprpc.Deposit) (int64,
640-
error) {
641-
642-
var sum int64
643-
depositMap := make(map[string]*looprpc.Deposit)
644-
for _, deposit := range deposits {
645-
depositMap[deposit.Outpoint] = deposit
646-
}
647-
648-
for _, outpoint := range outpoints {
649-
if _, ok := depositMap[outpoint]; !ok {
650-
return 0, fmt.Errorf("deposit %v not found", outpoint)
651-
}
652-
653-
sum += depositMap[outpoint].Value
654-
}
655-
656-
return sum, nil
657-
}
658-
659696
func depositsToOutpoints(deposits []*looprpc.Deposit) []string {
660697
outpoints := make([]string, 0, len(deposits))
661698
for _, deposit := range deposits {

cmd/loop/staticaddr_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package main
2+
3+
import (
4+
"testing"
5+
6+
"github.com/lightninglabs/loop/looprpc"
7+
"github.com/lightningnetwork/lnd/input"
8+
"github.com/lightningnetwork/lnd/lnwallet"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
type testCase struct {
13+
deposits []*looprpc.Deposit
14+
targetValue int64
15+
expected []string
16+
expectedErr string
17+
}
18+
19+
// TestSelectDeposits tests the selectDeposits function, which selects
20+
// deposits that can cover a target value, while respecting the dust limit.
21+
func TestSelectDeposits(t *testing.T) {
22+
dustLimit := lnwallet.DustLimitForSize(input.P2TRSize)
23+
d1, d2, d3 := &looprpc.Deposit{
24+
Value: 1_000_000,
25+
Outpoint: "1",
26+
}, &looprpc.Deposit{
27+
Value: 2_000_000,
28+
Outpoint: "2",
29+
}, &looprpc.Deposit{
30+
Value: 3_000_000,
31+
Outpoint: "3",
32+
}
33+
34+
testCases := []testCase{
35+
{
36+
deposits: []*looprpc.Deposit{d1},
37+
targetValue: 1_000_000,
38+
expected: []string{"1"},
39+
expectedErr: "",
40+
},
41+
{
42+
deposits: []*looprpc.Deposit{d1, d2},
43+
targetValue: 1_000_000,
44+
expected: []string{"2"},
45+
expectedErr: "",
46+
},
47+
{
48+
deposits: []*looprpc.Deposit{d1, d2, d3},
49+
targetValue: 1_000_000,
50+
expected: []string{"3"},
51+
expectedErr: "",
52+
},
53+
{
54+
deposits: []*looprpc.Deposit{d1},
55+
targetValue: 1_000_001,
56+
expected: []string{},
57+
expectedErr: "not enough deposits to cover",
58+
},
59+
{
60+
deposits: []*looprpc.Deposit{d1},
61+
targetValue: int64(1_000_000 - dustLimit),
62+
expected: []string{"1"},
63+
expectedErr: "",
64+
},
65+
{
66+
deposits: []*looprpc.Deposit{d1},
67+
targetValue: int64(1_000_000 - dustLimit + 1),
68+
expected: []string{},
69+
expectedErr: "not enough deposits to cover",
70+
},
71+
{
72+
deposits: []*looprpc.Deposit{d1, d2, d3},
73+
targetValue: d1.Value + d2.Value + d3.Value,
74+
expected: []string{"1", "2", "3"},
75+
expectedErr: "",
76+
},
77+
{
78+
deposits: []*looprpc.Deposit{d1, d2, d3},
79+
targetValue: d1.Value + d2.Value + d3.Value -
80+
int64(dustLimit),
81+
expected: []string{"1", "2", "3"},
82+
expectedErr: "",
83+
},
84+
{
85+
deposits: []*looprpc.Deposit{d1, d2, d3},
86+
targetValue: d1.Value + d2.Value + d3.Value -
87+
int64(dustLimit) + 1,
88+
expected: []string{},
89+
expectedErr: "not enough deposits to cover",
90+
},
91+
}
92+
93+
for _, tc := range testCases {
94+
selectedDeposits, err := selectDeposits(
95+
tc.deposits, tc.targetValue,
96+
)
97+
if tc.expectedErr == "" {
98+
require.NoError(t, err)
99+
} else {
100+
require.ErrorContains(t, err, tc.expectedErr)
101+
}
102+
require.ElementsMatch(t, tc.expected, selectedDeposits)
103+
}
104+
}

0 commit comments

Comments
 (0)