From 780584993be914f282f14bde6a9ce01f8efd6709 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 3 Dec 2025 14:10:10 +0100 Subject: [PATCH] staticaddr: unit test CalculateWithdrawalTxValues --- staticaddr/withdraw/manager_test.go | 238 ++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) diff --git a/staticaddr/withdraw/manager_test.go b/staticaddr/withdraw/manager_test.go index 5bf939cc..4ffd4e1e 100644 --- a/staticaddr/withdraw/manager_test.go +++ b/staticaddr/withdraw/manager_test.go @@ -4,11 +4,19 @@ import ( "context" "testing" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/funding" "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/stretchr/testify/require" ) @@ -368,3 +376,233 @@ func TestSignMusig2Tx_MissingOutpointInDepositMap(t *testing.T) { // Expect an error indicating the missing outpoint. require.ErrorContains(t, err, "tx outpoint not in deposit index map") } + +// TestCalculateWithdrawalTxValues tests various edge cases in withdrawal +// transaction value calculations. +func TestCalculateWithdrawalTxValues(t *testing.T) { + t.Parallel() + + // Create a taproot address for withdrawal. + taprootAddr, err := btcutil.NewAddressTaproot( + make([]byte, 32), &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + + // Standard fee rate for testing. + feeRate := chainfee.SatPerKWeight(1000) + + // Helper to create deposits. + createDeposit := func(value btcutil.Amount, idx uint32) *deposit.Deposit { + hash := chainhash.Hash{} + hash[0] = byte(idx) + return &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: hash, + Index: idx, + }, + Value: value, + } + } + + tests := []struct { + name string + deposits []*deposit.Deposit + localAmount btcutil.Amount + feeRate chainfee.SatPerKWeight + withdrawAddr btcutil.Address + commitmentType lnrpc.CommitmentType + expectedErr string + expectDustFee bool // change is dust, given to miners + }{ + { + name: "neither address nor commitment type specified", + deposits: []*deposit.Deposit{ + createDeposit(100000, 0), + }, + localAmount: 0, + feeRate: feeRate, + withdrawAddr: nil, + commitmentType: lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, + expectedErr: "either address or commitment type must be specified", + }, + { + name: "change is dust - given to miners", + deposits: []*deposit.Deposit{ + createDeposit(100000, 0), + }, + // Set localAmount such that change after feeWithChange + // would be dust, but change after feeWithoutChange >= 0. + // This triggers case: change-feeWithoutChange >= 0 + localAmount: 99300, // Leaves ~700 sats which is dust + feeRate: feeRate, + withdrawAddr: taprootAddr, + commitmentType: lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, + expectedErr: "", + expectDustFee: true, + }, + { + name: "insufficient funds after dust and fee", + deposits: []*deposit.Deposit{ + createDeposit(1000, 0), + }, + localAmount: 900, + feeRate: feeRate, + withdrawAddr: taprootAddr, + commitmentType: lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, + expectedErr: "doesn't cover for fees", + }, + { + name: "negative change after fees", + deposits: []*deposit.Deposit{ + createDeposit(10000, 0), + }, + localAmount: 15000, + feeRate: feeRate, + withdrawAddr: taprootAddr, + commitmentType: lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, + expectedErr: "doesn't cover for fees", + }, + { + name: "min channel size guard - below minimum", + deposits: []*deposit.Deposit{ + createDeposit(funding.MinChanFundingSize-10, 0), + }, + localAmount: 0, + feeRate: feeRate, + withdrawAddr: nil, + commitmentType: lnrpc.CommitmentType_SIMPLE_TAPROOT, + expectedErr: "is lower than the minimum channel " + + "funding size", + }, + { + name: "min channel size guard - exactly minimum", + deposits: []*deposit.Deposit{ + createDeposit(funding.MinChanFundingSize+1000, 0), + }, + localAmount: 0, + feeRate: feeRate, + withdrawAddr: nil, + commitmentType: lnrpc.CommitmentType_SIMPLE_TAPROOT, + expectedErr: "", + }, + { + name: "withdrawal amount below dust limit", + deposits: []*deposit.Deposit{ + createDeposit(400, 0), + }, + localAmount: 0, + feeRate: feeRate, + withdrawAddr: taprootAddr, + commitmentType: lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, + expectedErr: "below dust limit", + }, + { + name: "change higher than input value", + deposits: []*deposit.Deposit{ + createDeposit(10000, 0), + createDeposit(5000, 1), + }, + localAmount: 5000, + feeRate: chainfee.SatPerKWeight(100), + withdrawAddr: taprootAddr, + commitmentType: lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, + expectedErr: "change amount", + }, + { + name: "successful withdrawal with change", + deposits: []*deposit.Deposit{ + createDeposit(100000, 0), + }, + localAmount: 50000, + feeRate: feeRate, + withdrawAddr: taprootAddr, + commitmentType: lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, + expectedErr: "", + }, + { + name: "successful withdrawal no change", + deposits: []*deposit.Deposit{ + createDeposit(100000, 0), + }, + localAmount: 0, + feeRate: feeRate, + withdrawAddr: taprootAddr, + commitmentType: lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, + expectedErr: "", + }, + { + name: "successful channel open above min size", + deposits: []*deposit.Deposit{ + createDeposit(funding.MinChanFundingSize*2, 0), + }, + localAmount: 0, + feeRate: feeRate, + withdrawAddr: nil, + commitmentType: lnrpc.CommitmentType_SIMPLE_TAPROOT, + expectedErr: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + withdrawAmt, changeAmt, err := CalculateWithdrawalTxValues( + tc.deposits, tc.localAmount, tc.feeRate, + tc.withdrawAddr, tc.commitmentType, + ) + + if tc.expectedErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, tc.expectedErr) + return + } + + require.NoError(t, err) + require.Greater(t, withdrawAmt, btcutil.Amount(0)) + require.GreaterOrEqual(t, changeAmt, btcutil.Amount(0)) + + // Verify that withdrawal amount meets dust threshold. + dustLimit := lnwallet.DustLimitForSize(input.P2TRSize) + require.GreaterOrEqual(t, withdrawAmt, dustLimit) + + // If this is a channel open, verify min channel size. + if tc.commitmentType != lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE { + require.GreaterOrEqual( + t, withdrawAmt, funding.MinChanFundingSize, + ) + } + + // If expecting dust to be given to miners, verify + // changeAmt is 0. + if tc.expectDustFee { + require.Equal(t, btcutil.Amount(0), changeAmt, + "change should be 0 when dust is given to miners") + } + + // Verify total accounting: inputs = withdrawal + change + fees. + totalInputs := btcutil.Amount(0) + for _, d := range tc.deposits { + totalInputs += d.Value + } + + hasChange := changeAmt > 0 + weight, err := WithdrawalTxWeight( + len(tc.deposits), tc.withdrawAddr, + tc.commitmentType, hasChange, + ) + require.NoError(t, err) + + fee := tc.feeRate.FeeForWeight(weight) + + // When dust is given to miners, the "fee" includes both + // the transaction fee and the dust amount. + if tc.expectDustFee { + // Total should equal withdrawal + implicit fee (including dust) + implicitFee := totalInputs - withdrawAmt - changeAmt + require.Greater(t, implicitFee, fee, + "implicit fee should be greater than tx fee when dust is given to miners") + } else { + require.Equal(t, totalInputs, withdrawAmt+changeAmt+fee) + } + }) + } +}