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.
This commit is contained in:
Slyghtning 2025-06-27 10:56:28 +02:00
parent 4931fb9ccd
commit 581761a12f
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
7 changed files with 529 additions and 46 deletions

View file

@ -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.

View file

@ -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()

View file

@ -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)
})
}
}

View file

@ -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.

View file

@ -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(),

View file

@ -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
}

View file

@ -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)
})
}
}