Skip to content

staticaddr: fractional swap amount #887

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 72 additions & 35 deletions cmd/loop/staticaddr.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/hex"
"errors"
"fmt"
"sort"
"strconv"
"strings"

Expand All @@ -14,6 +15,8 @@ import (
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightninglabs/loop/staticaddr/loopin"
"github.com/lightninglabs/loop/swapserverrpc"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/urfave/cli"
)
Expand Down Expand Up @@ -477,6 +480,13 @@ var staticAddressLoopInCommand = cli.Command{
"The client can retry the swap with adjusted " +
"parameters after the payment timed out.",
},
cli.IntFlag{
Name: "amount",
Usage: "the number of satoshis that should be " +
"swapped from the selected deposits. If there" +
"is change it is sent back to the static " +
"address.",
},
lastHopFlag,
labelFlag,
routeHintsFlag,
Expand All @@ -502,11 +512,14 @@ func staticAddressLoopIn(ctx *cli.Context) error {
ctxb = context.Background()
isAllSelected = ctx.IsSet("all")
isUtxoSelected = ctx.IsSet("utxo")
isAmountSelected bool
selectedAmount = ctx.Int64("amount")
label = ctx.String("static-loop-in")
hints []*swapserverrpc.RouteHint
lastHop []byte
paymentTimeoutSeconds = uint32(loopin.DefaultPaymentTimeoutSeconds)
)
isAmountSelected = selectedAmount > 0

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

if len(depositList.FilteredDeposits) == 0 {
allDeposits := depositList.FilteredDeposits

if len(allDeposits) == 0 {
errString := fmt.Sprintf("no confirmed deposits available, "+
"deposits need at least %v confirmations",
deposit.MinConfs)
Expand All @@ -551,17 +566,25 @@ func staticAddressLoopIn(ctx *cli.Context) error {

var depositOutpoints []string
switch {
case isAllSelected == isUtxoSelected:
return errors.New("must select either all or some utxos")
case isAllSelected && isUtxoSelected:
return errors.New("cannot select all and specific utxos")

case isAllSelected:
depositOutpoints = depositsToOutpoints(
depositList.FilteredDeposits,
)
depositOutpoints = depositsToOutpoints(allDeposits)

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

case isAmountSelected:
// If there's only a swap amount specified we'll coin-select
// deposits to cover the swap amount.
depositOutpoints, err = selectDeposits(
allDeposits, selectedAmount,
)
if err != nil {
return err
}

default:
return fmt.Errorf("unknown quote request")
}
Expand All @@ -571,6 +594,7 @@ func staticAddressLoopIn(ctx *cli.Context) error {
}

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

limits := getInLimits(quote)

// populate the quote request with the sum of selected deposits and
// prompt the user for acceptance.
quoteReq.Amt, err = sumDeposits(
depositOutpoints, depositList.FilteredDeposits,
)
if err != nil {
return err
}

if !(ctx.Bool("force") || ctx.Bool("f")) {
err = displayInDetails(quoteReq, quote, ctx.Bool("verbose"))
if err != nil {
Expand All @@ -604,6 +619,7 @@ func staticAddressLoopIn(ctx *cli.Context) error {
}

req := &looprpc.StaticAddressLoopInRequest{
Amount: quoteReq.Amt,
Outpoints: depositOutpoints,
MaxSwapFeeSatoshis: int64(limits.maxSwapFee),
LastHop: lastHop,
Expand All @@ -624,6 +640,47 @@ func staticAddressLoopIn(ctx *cli.Context) error {
return nil
}

// selectDeposits sorts the deposits by amount in descending order, then by
// blocks-until-expiry in ascending order. It then selects the deposits that
// are needed to cover the amount requested without leaving a dust change. It
// returns an error if the sum of deposits minus dust is less than the requested
// amount.
func selectDeposits(deposits []*looprpc.Deposit, targetAmount int64) ([]string,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(see my next comment too)

Ideally this should be solved with a knapsack algorithm, but since that's a bit difficult, maybe we can use a variance of this heuristic to first try to find a covering deposit?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation sorts values descending and then fills the sack until the target is met. This might be a good enough approximation of a knapsack solver, see here: https://en.wikipedia.org/wiki/Knapsack_problem#Greedy_approximation_algorithm

error) {

// Sort the deposits by amount in descending order, then by
// blocks-until-expiry in ascending order.
sort.Slice(deposits, func(i, j int) bool {
if deposits[i].Value == deposits[j].Value {
return deposits[i].BlocksUntilExpiry <
deposits[j].BlocksUntilExpiry
}
return deposits[i].Value > deposits[j].Value
})

// Select the deposits that are needed to cover the swap amount without
// leaving a dust change.
var selectedDeposits []string
var selectedAmount int64
dustLimit := lnwallet.DustLimitForSize(input.P2TRSize)
for _, deposit := range deposits {
selectedDeposits = append(selectedDeposits, deposit.Outpoint)
selectedAmount += deposit.Value
if selectedAmount == targetAmount {
return selectedDeposits, nil
}
if selectedAmount > targetAmount {
if selectedAmount-targetAmount >= int64(dustLimit) {
return selectedDeposits, nil
}
}
}

return nil, fmt.Errorf("not enough deposits to cover "+
"requested amount, selected %d but need %d",
selectedAmount, targetAmount)
}

func containsDuplicates(outpoints []string) bool {
found := make(map[string]struct{})
for _, outpoint := range outpoints {
Expand All @@ -636,26 +693,6 @@ func containsDuplicates(outpoints []string) bool {
return false
}

func sumDeposits(outpoints []string, deposits []*looprpc.Deposit) (int64,
error) {

var sum int64
depositMap := make(map[string]*looprpc.Deposit)
for _, deposit := range deposits {
depositMap[deposit.Outpoint] = deposit
}

for _, outpoint := range outpoints {
if _, ok := depositMap[outpoint]; !ok {
return 0, fmt.Errorf("deposit %v not found", outpoint)
}

sum += depositMap[outpoint].Value
}

return sum, nil
}

func depositsToOutpoints(deposits []*looprpc.Deposit) []string {
outpoints := make([]string, 0, len(deposits))
for _, deposit := range deposits {
Expand Down
104 changes: 104 additions & 0 deletions cmd/loop/staticaddr_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package main

import (
"testing"

"github.com/lightninglabs/loop/looprpc"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/stretchr/testify/require"
)

type testCase struct {
deposits []*looprpc.Deposit
targetValue int64
expected []string
expectedErr string
}

// TestSelectDeposits tests the selectDeposits function, which selects
// deposits that can cover a target value, while respecting the dust limit.
func TestSelectDeposits(t *testing.T) {
dustLimit := lnwallet.DustLimitForSize(input.P2TRSize)
d1, d2, d3 := &looprpc.Deposit{
Value: 1_000_000,
Outpoint: "1",
}, &looprpc.Deposit{
Value: 2_000_000,
Outpoint: "2",
}, &looprpc.Deposit{
Value: 3_000_000,
Outpoint: "3",
}

testCases := []testCase{
{
deposits: []*looprpc.Deposit{d1},
targetValue: 1_000_000,
expected: []string{"1"},
expectedErr: "",
},
{
deposits: []*looprpc.Deposit{d1, d2},
targetValue: 1_000_000,
expected: []string{"2"},
expectedErr: "",
},
{
deposits: []*looprpc.Deposit{d1, d2, d3},
targetValue: 1_000_000,
expected: []string{"3"},
expectedErr: "",
},
{
deposits: []*looprpc.Deposit{d1},
targetValue: 1_000_001,
expected: []string{},
expectedErr: "not enough deposits to cover",
},
{
deposits: []*looprpc.Deposit{d1},
targetValue: int64(1_000_000 - dustLimit),
expected: []string{"1"},
expectedErr: "",
},
{
deposits: []*looprpc.Deposit{d1},
targetValue: int64(1_000_000 - dustLimit + 1),
expected: []string{},
expectedErr: "not enough deposits to cover",
},
{
deposits: []*looprpc.Deposit{d1, d2, d3},
targetValue: d1.Value + d2.Value + d3.Value,
expected: []string{"1", "2", "3"},
expectedErr: "",
},
{
deposits: []*looprpc.Deposit{d1, d2, d3},
targetValue: d1.Value + d2.Value + d3.Value -
int64(dustLimit),
expected: []string{"1", "2", "3"},
expectedErr: "",
},
{
deposits: []*looprpc.Deposit{d1, d2, d3},
targetValue: d1.Value + d2.Value + d3.Value -
int64(dustLimit) + 1,
expected: []string{},
expectedErr: "not enough deposits to cover",
},
}

for _, tc := range testCases {
selectedDeposits, err := selectDeposits(
tc.deposits, tc.targetValue,
)
if tc.expectedErr == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, tc.expectedErr)
}
require.ElementsMatch(t, tc.expected, selectedDeposits)
}
}
5 changes: 5 additions & 0 deletions interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,11 @@ type StaticAddressLoopInRequest struct {
// swap payment. If the timeout is reached the swap will be aborted and
// the client can retry the swap if desired with different parameters.
PaymentTimeoutSeconds uint32

// SelectedAmount is the amount that the user selected for the swap. If
// the user did not select an amount, the amount of the selected
// deposits is used.
SelectedAmount btcutil.Amount
}

// LoopInTerms are the server terms on which it executes loop in swaps.
Expand Down
10 changes: 10 additions & 0 deletions loopd/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,16 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
clock.NewDefaultClock(), d.lnd.ChainParams,
)

// Run the selected amount migration.
err = loopin.MigrateSelectedSwapAmount(
d.mainCtx, swapDb, depositStore, staticAddressLoopInStore,
)
if err != nil {
errorf("Selected amount migration failed: %v", err)

return err
}

staticLoopInManager = loopin.NewManager(&loopin.Config{
Server: staticAddressClient,
QuoteGetter: swapClient.Server,
Expand Down
Loading