From 581761a12facb122057d64d9f8bb2a787d84eaa0 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 27 Jun 2025 10:56:28 +0200 Subject: [PATCH] staticaddr: arbitrary loop-in amount In this commit we add a new function SelectDeposits to the loop-in manager. It coin-selects deposits that meet an arbitrary swap amount provided by the client. We have to ensure that the server creates the correct change outputs for the htlc- and sweepless sweep transactions. --- interface.go | 5 + staticaddr/loopin/actions.go | 24 ++- staticaddr/loopin/deduce_amount_test.go | 85 ++++++++ staticaddr/loopin/interface.go | 5 + staticaddr/loopin/loopin.go | 59 +++++- staticaddr/loopin/manager.go | 262 +++++++++++++++++++++--- staticaddr/loopin/manager_test.go | 135 ++++++++++++ 7 files changed, 529 insertions(+), 46 deletions(-) create mode 100644 staticaddr/loopin/deduce_amount_test.go create mode 100644 staticaddr/loopin/manager_test.go diff --git a/interface.go b/interface.go index b31c24f9..1ca1fda3 100644 --- a/interface.go +++ b/interface.go @@ -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. diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 6299c167..1cd7d509 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -68,9 +68,18 @@ func (f *FSM) InitHtlcAction(ctx context.Context, } // Calculate the swap invoice amount. The server needs to pay us the - // sum of all deposits minus the fees that the server charges for the - // swap. - swapInvoiceAmt := f.loopIn.TotalDepositAmount() - f.loopIn.QuotedSwapFee + // swap amount minus the fees that the server charges for the swap. The + // swap amount is either the total value of the selected deposits, or + // the selected amount if a specific amount was requested. + swapAmount := f.loopIn.TotalDepositAmount() + var hasChange bool + if f.loopIn.SelectedAmount > 0 { + swapAmount = f.loopIn.SelectedAmount + remainingAmount := f.loopIn.TotalDepositAmount() - swapAmount + hasChange = remainingAmount > 0 && remainingAmount < + f.loopIn.TotalDepositAmount() + } + swapInvoiceAmt := swapAmount - f.loopIn.QuotedSwapFee // Generate random preimage. var swapPreimage lntypes.Preimage @@ -120,6 +129,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context, loopInReq := &swapserverrpc.ServerStaticAddressLoopInRequest{ SwapHash: f.loopIn.SwapHash[:], DepositOutpoints: f.loopIn.DepositOutpoints, + Amount: uint64(f.loopIn.SelectedAmount), HtlcClientPubKey: f.loopIn.ClientPubkey.SerializeCompressed(), SwapInvoice: f.loopIn.SwapInvoice, ProtocolVersion: version.CurrentRPCProtocolVersion(), @@ -204,7 +214,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context, // We need to defend against the server setting high fees for the htlc // tx since we might have to sweep the timeout path. We maximally allow // a configured percentage of the swap value to be spent on fees. - amt := float64(f.loopIn.TotalDepositAmount()) + amt := float64(swapAmount) maxHtlcTxFee := btcutil.Amount(amt * f.cfg.MaxStaticAddrHtlcFeePercentage) @@ -212,7 +222,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context, f.cfg.MaxStaticAddrHtlcBackupFeePercentage) feeRate := chainfee.SatPerKWeight(loopInResp.StandardHtlcInfo.FeeRate) - fee := feeRate.FeeForWeight(f.loopIn.htlcWeight()) + fee := feeRate.FeeForWeight(f.loopIn.htlcWeight(hasChange)) if fee > maxHtlcTxFee { // Abort the swap by pushing empty sigs to the server. pushEmptySigs() @@ -225,7 +235,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context, f.loopIn.HtlcTxFeeRate = feeRate highFeeRate := chainfee.SatPerKWeight(loopInResp.HighFeeHtlcInfo.FeeRate) - fee = highFeeRate.FeeForWeight(f.loopIn.htlcWeight()) + fee = highFeeRate.FeeForWeight(f.loopIn.htlcWeight(hasChange)) if fee > maxHtlcTxBackupFee { // Abort the swap by pushing empty sigs to the server. pushEmptySigs() @@ -241,7 +251,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context, extremelyHighFeeRate := chainfee.SatPerKWeight( loopInResp.ExtremeFeeHtlcInfo.FeeRate, ) - fee = extremelyHighFeeRate.FeeForWeight(f.loopIn.htlcWeight()) + fee = extremelyHighFeeRate.FeeForWeight(f.loopIn.htlcWeight(hasChange)) if fee > maxHtlcTxBackupFee { // Abort the swap by pushing empty sigs to the server. pushEmptySigs() diff --git a/staticaddr/loopin/deduce_amount_test.go b/staticaddr/loopin/deduce_amount_test.go new file mode 100644 index 00000000..a78a6405 --- /dev/null +++ b/staticaddr/loopin/deduce_amount_test.go @@ -0,0 +1,85 @@ +package loopin + +import ( + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/stretchr/testify/require" +) + +// TestDeduceSwapAmount covers all validation branches of DeduceSwapAmount. +func TestDeduceSwapAmount(t *testing.T) { + dl := lnwallet.DustLimitForSize(input.P2TRSize) + + tests := []struct { + name string + total btcutil.Amount + selectAmt btcutil.Amount + wantAmt btcutil.Amount + wantErr string + }{ + { + name: "negative selected amount", + total: dl * 10, + selectAmt: -1, + wantErr: "negative", + }, + { + name: "selected is dust (>0 < dust)", + total: dl * 10, + selectAmt: dl - 1, + wantErr: "is dust", + }, + { + name: "total deposit is dust", + total: dl - 1, + selectAmt: 0, + wantErr: "total deposit value", + }, + { + name: "selected exceeds total", + total: dl * 5, + selectAmt: dl*5 + 1, + wantErr: "exceeds total", + }, + { + name: "leaves dust change", + total: dl*5 + (dl - 1), + selectAmt: dl * 5, + wantErr: "leaves dust change", + }, + { + name: "selected zero => swap total", + total: dl * 7, + selectAmt: 0, + wantAmt: dl * 7, + }, + { + name: "selected equals total", + total: dl * 9, + selectAmt: dl * 9, + wantAmt: dl * 9, + }, + { + name: "selected and remaining both >= dust", + total: dl*10 + dl, // 11*dust + selectAmt: dl * 10, // remaining = dust + wantAmt: dl * 10, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + amt, err := DeduceSwapAmount(tc.total, tc.selectAmt) + if tc.wantErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, tc.wantErr) + return + } + require.NoError(t, err) + require.Equal(t, tc.wantAmt, amt) + }) + } +} diff --git a/staticaddr/loopin/interface.go b/staticaddr/loopin/interface.go index cf95f838..3dd8cfbc 100644 --- a/staticaddr/loopin/interface.go +++ b/staticaddr/loopin/interface.go @@ -53,6 +53,11 @@ type DepositManager interface { // outpoints. DepositsForOutpoints(ctx context.Context, outpoints []string) ( []*deposit.Deposit, error) + + // GetActiveDepositsInState returns all active deposits in the given + // state. + GetActiveDepositsInState(stateFilter fsm.StateType) ([]*deposit.Deposit, + error) } // StaticAddressLoopInStore provides access to the static address loop-in DB. diff --git a/staticaddr/loopin/loopin.go b/staticaddr/loopin/loopin.go index aa7c3a2d..1e198f4f 100644 --- a/staticaddr/loopin/loopin.go +++ b/staticaddr/loopin/loopin.go @@ -1,6 +1,7 @@ package loopin import ( + "bytes" "context" "errors" "fmt" @@ -91,8 +92,15 @@ type StaticAddressLoopIn struct { // The outpoints in the format txid:vout that are part of the loop-in // swap. + // TODO(hieblmi): Replace this with a getter method that fetches the + // outpoints from the deposits. DepositOutpoints []string + // SelectedAmount is the amount that the user selected for the swap. If + // the user did not select an amount, the amount of all deposits is + // used. + SelectedAmount btcutil.Amount + // state is the current state of the swap. state fsm.StateType @@ -283,14 +291,25 @@ func (l *StaticAddressLoopIn) createHtlcTx(chainParams *chaincfg.Params, }) } + // Determine the swap amount. If the user selected a specific amount, we + // use that and use the difference to the total deposit amount as the + // change. + var ( + swapAmt = l.TotalDepositAmount() + changeAmount btcutil.Amount + ) + if l.SelectedAmount > 0 { + swapAmt = l.SelectedAmount + changeAmount = l.TotalDepositAmount() - l.SelectedAmount + } + // Calculate htlc tx fee for server provided fee rate. - weight := l.htlcWeight() + hasChange := changeAmount > 0 + weight := l.htlcWeight(hasChange) fee := feeRate.FeeForWeight(weight) // Check if the server breaches our fee limits. - amt := float64(l.TotalDepositAmount()) - feeLimit := btcutil.Amount(amt * maxFeePercentage) - + feeLimit := btcutil.Amount(float64(swapAmt) * maxFeePercentage) if fee > feeLimit { return nil, fmt.Errorf("htlc tx fee %v exceeds max fee %v", fee, feeLimit) @@ -308,12 +327,20 @@ func (l *StaticAddressLoopIn) createHtlcTx(chainParams *chaincfg.Params, // Create the sweep output sweepOutput := &wire.TxOut{ - Value: int64(l.TotalDepositAmount()) - int64(fee), + Value: int64(swapAmt - fee), PkScript: pkscript, } msgTx.AddTxOut(sweepOutput) + // We expect change to be sent back to our static address output script. + if changeAmount > 0 { + msgTx.AddTxOut(&wire.TxOut{ + Value: int64(changeAmount), + PkScript: l.AddressParams.PkScript, + }) + } + return msgTx, nil } @@ -325,7 +352,7 @@ func (l *StaticAddressLoopIn) isHtlcTimedOut(height int32) bool { } // htlcWeight returns the weight for the htlc transaction. -func (l *StaticAddressLoopIn) htlcWeight() lntypes.WeightUnit { +func (l *StaticAddressLoopIn) htlcWeight(hasChange bool) lntypes.WeightUnit { var weightEstimator input.TxWeightEstimator for i := 0; i < len(l.Deposits); i++ { weightEstimator.AddTaprootKeySpendInput( @@ -335,6 +362,10 @@ func (l *StaticAddressLoopIn) htlcWeight() lntypes.WeightUnit { weightEstimator.AddP2WSHOutput() + if hasChange { + weightEstimator.AddP2TROutput() + } + return weightEstimator.Weight() } @@ -373,11 +404,25 @@ func (l *StaticAddressLoopIn) createHtlcSweepTx(ctx context.Context, return nil, err } + // Check if the htlc tx has a change output. If so we need to select the + // non-change output index to construct the sweep with. + htlcInputIndex := uint32(0) + if len(htlcTx.TxOut) == 2 { + // If the first htlc tx output matches our static address + // script we need to select the second output to sweep from. + if bytes.Equal( + htlcTx.TxOut[0].PkScript, l.AddressParams.PkScript, + ) { + + htlcInputIndex = 1 + } + } + // Add the htlc input. sweepTx.AddTxIn(&wire.TxIn{ PreviousOutPoint: wire.OutPoint{ Hash: htlcTx.TxHash(), - Index: 0, + Index: htlcInputIndex, }, SignatureScript: htlc.SigScript, Sequence: htlc.SuccessSequence(), diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index 553efd50..13ef8be0 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -4,10 +4,12 @@ import ( "bytes" "context" "fmt" + "sort" "sync/atomic" "time" "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" + "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" @@ -19,7 +21,9 @@ import ( "github.com/lightninglabs/loop/labels" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/routing/route" ) @@ -29,6 +33,10 @@ const ( SwapNotFinishedMsg = "swap not finished yet" ) +var ( + dustLimit = lnwallet.DustLimitForSize(input.P2TRSize) +) + // Config contains the services required for the loop-in manager. type Config struct { // Server is the client that is used to communicate with the static @@ -280,6 +288,14 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context, return err } + deposits, err := m.cfg.DepositManager.DepositsForOutpoints( + ctx, loopIn.DepositOutpoints, + ) + if err != nil { + return err + } + loopIn.Deposits = deposits + reader := bytes.NewReader(req.SweepTxPsbt) sweepPacket, err := psbt.NewFromRawBytes(reader, false) if err != nil { @@ -305,6 +321,34 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context, len(req.PrevoutInfo), len(sweepTx.TxIn)) } + // If the user selected an amount that is less than the total deposit + // amount we'll check that the server sends us the correct change amount + // back to our static address. + totalDepositAmount := loopIn.TotalDepositAmount() + changeAmt := totalDepositAmount - loopIn.SelectedAmount + if changeAmt > 0 && changeAmt < totalDepositAmount { + var foundChange bool + changePkScript := loopIn.AddressParams.PkScript + + for _, out := range sweepTx.TxOut { + if out.Value == int64(changeAmt) && + bytes.Equal(out.PkScript, changePkScript) { + + foundChange = true + break + } + } + + if !foundChange { + return fmt.Errorf("expected change output to our "+ + "static address, total_deposit_amount=%v, "+ + "selected_amount=%v, "+ + "expected_change_amount=%v ", + totalDepositAmount, loopIn.SelectedAmount, + changeAmt) + } + } + // Check if all the deposits requested are part of the loop-in and // find them in the requested sweep. depositToIdxMap, err := mapDepositsToIndices(req, loopIn, sweepTx) @@ -531,29 +575,80 @@ func (m *Manager) DeliverLoopInRequest(ctx context.Context, func (m *Manager) initiateLoopIn(ctx context.Context, req *loop.StaticAddressLoopInRequest) (*StaticAddressLoopIn, error) { - // Validate the loop-in request. - if len(req.DepositOutpoints) == 0 { - return nil, fmt.Errorf("no deposit outpoints provided") - } - - // Retrieve all deposits referenced by the outpoints and ensure that - // they are in state Deposited. - deposits, active := m.cfg.DepositManager.AllStringOutpointsActiveDeposits( //nolint:lll - req.DepositOutpoints, deposit.Deposited, + var ( + err error + selectedOutpoints = req.DepositOutpoints + selectedDeposits []*deposit.Deposit ) - if !active { - return nil, fmt.Errorf("one or more deposits are not in "+ - "state %s", deposit.Deposited) + + // Determine which deposits to use for the loop-in swap. If none are + // selected by the client, we will coin-select them based on the amount. + switch { + case len(selectedOutpoints) == 0 && req.SelectedAmount == 0: + return nil, fmt.Errorf("neither deposit outpoints nor amount " + + "provided") + + case len(selectedOutpoints) > 0: + // Retrieve all deposits referenced by the outpoints and ensure + // that they are in state Deposited. + var active bool + selectedDeposits, active = m.cfg.DepositManager. + AllStringOutpointsActiveDeposits( + selectedOutpoints, deposit.Deposited, + ) + if !active { + return nil, fmt.Errorf("one or more deposits are not in "+ + "state %s", deposit.Deposited) + } + + case len(selectedOutpoints) == 0: + // If an amount was provided, we'll coin-select deposits to + // cover for the amount. + allDeposits, err := m.cfg.DepositManager. + GetActiveDepositsInState(deposit.Deposited) + if err != nil { + return nil, fmt.Errorf("unable to retrieve all "+ + "deposits: %w", err) + } + + // TODO(hieblmi): add params to deposit for multi-address + // support. + params, err := m.cfg.AddressManager.GetStaticAddressParameters( + ctx, + ) + if err != nil { + return nil, fmt.Errorf("unable to retrieve static "+ + "address parameters: %w", err) + } + + selectedDeposits, err = SelectDeposits( + req.SelectedAmount, allDeposits, params.Expiry, + m.currentHeight.Load(), + ) + if err != nil { + return nil, fmt.Errorf("unable to select deposits: %w", + err) + } + + selectedOutpoints = make([]string, 0, len(selectedDeposits)) + for _, deposit := range selectedDeposits { + selectedOutpoints = append(selectedOutpoints, + deposit.String()) + } } - // Calculate the total deposit amount. - tmp := &StaticAddressLoopIn{ - Deposits: deposits, + // Calculate the total deposit amount and check if the selected amount + // would leave a dust output. + swapAmount, err := DeduceSwapAmount( + sumOfDeposits(selectedDeposits), req.SelectedAmount, + ) + if err != nil { + return nil, fmt.Errorf("unable to determine swap amount: %w", + err) } - totalDepositAmount := tmp.TotalDepositAmount() // Check that the label is valid. - err := labels.Validate(req.Label) + err = labels.Validate(req.Label) if err != nil { return nil, fmt.Errorf("invalid label: %w", err) } @@ -577,7 +672,7 @@ func (m *Manager) initiateLoopIn(ctx context.Context, // Because the Private flag is set, we'll generate our own set // of hop hints. req.RouteHints, err = loop.SelectHopHints( - ctx, m.cfg.LndClient, totalDepositAmount, + ctx, m.cfg.LndClient, swapAmount, loop.DefaultMaxHopHints, includeNodes, ) if err != nil { @@ -586,19 +681,19 @@ func (m *Manager) initiateLoopIn(ctx context.Context, } } - // Request current server loop in terms and use these to calculate the - // swap fee that we should subtract from the swap amount in the payment - // request that we send to the server. We pass nil as optional route - // hints as hop hint selection when generating invoices with private - // channels is an LND side black box feature. Advanced users will quote - // directly anyway and there they have the option to add specific route - // hints. + // Request the current server loop in terms and use these to calculate + // the swap fee that we should subtract from the swap amount in the + // payment request that we send to the server. We pass nil as optional + // route hints as hop hint selection when generating invoices with + // private channels is an LND side black box feature. Advanced users + // will quote directly anyway, and there they are able to add specific + // route hints. // The quote call will also request a probe from the server to ensure - // feasibility of a loop-in for the totalDepositAmount. - numDeposits := uint32(len(deposits)) + // feasibility of a loop-in for the selected. + numDeposits := uint32(len(selectedDeposits)) quote, err := m.cfg.QuoteGetter.GetLoopInQuote( - ctx, totalDepositAmount, m.cfg.NodePubkey, req.LastHop, - req.RouteHints, req.Initiator, numDeposits, + ctx, swapAmount, m.cfg.NodePubkey, req.LastHop, req.RouteHints, + req.Initiator, numDeposits, ) if err != nil { return nil, fmt.Errorf("unable to get loop in quote: %w", err) @@ -619,8 +714,9 @@ func (m *Manager) initiateLoopIn(ctx context.Context, } swap := &StaticAddressLoopIn{ - DepositOutpoints: req.DepositOutpoints, - Deposits: deposits, + SelectedAmount: req.SelectedAmount, + DepositOutpoints: selectedOutpoints, + Deposits: selectedDeposits, Label: req.Label, Initiator: req.Initiator, InitiationTime: time.Now(), @@ -710,9 +806,102 @@ func (m *Manager) GetAllSwaps(ctx context.Context) ([]*StaticAddressLoopIn, return swaps, 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(targetAmount btcutil.Amount, deposits []*deposit.Deposit, + csvExpiry uint32, blockHeight uint32) ([]*deposit.Deposit, 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 { + iExp := uint32(deposits[i].ConfirmationHeight) + + csvExpiry - blockHeight + jExp := uint32(deposits[j].ConfirmationHeight) + + csvExpiry - blockHeight + + return iExp < jExp + } + 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 []*deposit.Deposit + var selectedAmount btcutil.Amount + for _, deposit := range deposits { + selectedDeposits = append(selectedDeposits, deposit) + selectedAmount += deposit.Value + if selectedAmount == targetAmount { + return selectedDeposits, nil + } + if selectedAmount > targetAmount { + if selectedAmount-targetAmount >= dustLimit { + return selectedDeposits, nil + } + } + } + + return nil, fmt.Errorf("not enough deposits to cover "+ + "requested amount or prevent dust change, have %d but need %d", + selectedAmount, targetAmount) +} + +// DeduceSwapAmount calculates the swap amount based on the selected amount and +// the total deposit amount. It checks if the selected amount leaves a dust +// change output or exceeds the total deposits value. Note that if the selected +// amount is 0, the swap amount is the total deposit value. If the selected +// amount is equal to the total deposit value, the total deposit value will be +// swapped. +func DeduceSwapAmount(totalDepositAmount btcutil.Amount, + selectedAmount btcutil.Amount) (btcutil.Amount, error) { + + // If the selected amount leaves a dust change output or exceeds the + // total deposits value, we return an error. + swapAmount := selectedAmount + remainingAmount := totalDepositAmount - selectedAmount + switch { + case selectedAmount < 0: + return 0, fmt.Errorf("selected amount %v is negative", + selectedAmount) + + case selectedAmount > 0 && selectedAmount < dustLimit: + return 0, fmt.Errorf("selected amount %v is dust, "+ + "need at least %v", selectedAmount, dustLimit) + + case totalDepositAmount < dustLimit: + return 0, fmt.Errorf("total deposit value %v is dust, "+ + "need at least %v", totalDepositAmount, dustLimit) + + case remainingAmount < 0: + return 0, fmt.Errorf("selected amount %v exceeds total "+ + "deposit value %v", selectedAmount, totalDepositAmount) + + case remainingAmount > 0 && remainingAmount < dustLimit: + return 0, fmt.Errorf("selected amount %v leaves dust change "+ + "%v", selectedAmount, remainingAmount) + + default: + // If the remaining amount is 0 or equal or greater than the + // dust limit, we can proceed with the swap. + } + + // If the client didn't select an amount, we quote for the total + // deposits value. + if selectedAmount == 0 { + swapAmount = totalDepositAmount + } + + return swapAmount, nil +} + // mapDepositsToIndices maps the deposit outpoints to their respective indices // in the sweep transaction. -func mapDepositsToIndices(req *swapserverrpc.ServerStaticLoopInSweepNotification, //nolint:lll +func mapDepositsToIndices( + req *swapserverrpc.ServerStaticLoopInSweepNotification, loopIn *StaticAddressLoopIn, sweepTx *wire.MsgTx) (map[string]int, error) { @@ -755,3 +944,12 @@ func mapDepositsToIndices(req *swapserverrpc.ServerStaticLoopInSweepNotification } return depositToIdxMap, nil } + +func sumOfDeposits(deposits []*deposit.Deposit) btcutil.Amount { + sum := btcutil.Amount(0) + for _, d := range deposits { + sum += d.Value + } + + return sum +} diff --git a/staticaddr/loopin/manager_test.go b/staticaddr/loopin/manager_test.go new file mode 100644 index 00000000..79c1a127 --- /dev/null +++ b/staticaddr/loopin/manager_test.go @@ -0,0 +1,135 @@ +package loopin + +import ( + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/stretchr/testify/require" +) + +type testCase struct { + name string + deposits []*deposit.Deposit + targetValue btcutil.Amount + csvExpiry uint32 + blockHeight uint32 + expected []*deposit.Deposit + 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) { + d1, d2, d3, d4 := &deposit.Deposit{ + Value: 1_000_000, + ConfirmationHeight: 1000, + }, &deposit.Deposit{ + Value: 2_000_000, + ConfirmationHeight: 2000, + }, &deposit.Deposit{ + Value: 3_000_000, + ConfirmationHeight: 3000, + }, &deposit.Deposit{ + Value: 3_000_000, + ConfirmationHeight: 3001, + } + d1.Hash = chainhash.Hash{1} + d1.Index = 0 + d2.Hash = chainhash.Hash{2} + d2.Index = 0 + d3.Hash = chainhash.Hash{3} + d3.Index = 0 + d4.Hash = chainhash.Hash{4} + d4.Index = 0 + + testCases := []testCase{ + { + name: "single deposit exact target", + deposits: []*deposit.Deposit{d1}, + targetValue: 1_000_000, + expected: []*deposit.Deposit{d1}, + expectedErr: "", + }, + { + name: "prefer larger deposit when both cover", + deposits: []*deposit.Deposit{d1, d2}, + targetValue: 1_000_000, + expected: []*deposit.Deposit{d2}, + expectedErr: "", + }, + { + name: "prefer largest among three when one is enough", + deposits: []*deposit.Deposit{d1, d2, d3}, + targetValue: 1_000_000, + expected: []*deposit.Deposit{d3}, + expectedErr: "", + }, + { + name: "single deposit insufficient by 1", + deposits: []*deposit.Deposit{d1}, + targetValue: 1_000_001, + expected: []*deposit.Deposit{}, + expectedErr: "not enough deposits to cover", + }, + { + name: "target leaves exact dust limit change", + deposits: []*deposit.Deposit{d1}, + targetValue: 1_000_000 - dustLimit, + expected: []*deposit.Deposit{d1}, + expectedErr: "", + }, + { + name: "target leaves dust change (just over)", + deposits: []*deposit.Deposit{d1}, + targetValue: 1_000_000 - dustLimit + 1, + expected: []*deposit.Deposit{}, + expectedErr: "not enough deposits to cover", + }, + { + name: "all deposits exactly match target", + deposits: []*deposit.Deposit{d1, d2, d3}, + targetValue: d1.Value + d2.Value + d3.Value, + expected: []*deposit.Deposit{d1, d2, d3}, + expectedErr: "", + }, + { + name: "sum minus dust limit is allowed (change == dust)", + deposits: []*deposit.Deposit{d1, d2, d3}, + targetValue: d1.Value + d2.Value + d3.Value - dustLimit, + expected: []*deposit.Deposit{d1, d2, d3}, + expectedErr: "", + }, + { + name: "sum minus dust limit plus 1 is not allowed (dust change)", + deposits: []*deposit.Deposit{d1, d2, d3}, + targetValue: d1.Value + d2.Value + d3.Value - dustLimit + 1, + expected: []*deposit.Deposit{}, + expectedErr: "not enough deposits to cover", + }, + { + name: "tie by value, prefer earlier expiry", + deposits: []*deposit.Deposit{d3, d4}, + targetValue: d4.Value - dustLimit, // d3/d4 have the + // same value but different expiration. + expected: []*deposit.Deposit{d3}, + expectedErr: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + selectedDeposits, err := SelectDeposits( + tc.targetValue, tc.deposits, tc.csvExpiry, + tc.blockHeight, + ) + if tc.expectedErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tc.expectedErr) + } + require.ElementsMatch(t, tc.expected, selectedDeposits) + }) + } +}