@@ -4,11 +4,19 @@ import (
44 "context"
55 "testing"
66
7+ "github.com/btcsuite/btcd/btcutil"
8+ "github.com/btcsuite/btcd/chaincfg"
9+ "github.com/btcsuite/btcd/chaincfg/chainhash"
710 "github.com/btcsuite/btcd/txscript"
811 "github.com/btcsuite/btcd/wire"
12+ "github.com/lightninglabs/loop/staticaddr/deposit"
913 "github.com/lightninglabs/loop/swapserverrpc"
1014 "github.com/lightninglabs/loop/test"
15+ "github.com/lightningnetwork/lnd/funding"
1116 "github.com/lightningnetwork/lnd/input"
17+ "github.com/lightningnetwork/lnd/lnrpc"
18+ "github.com/lightningnetwork/lnd/lnwallet"
19+ "github.com/lightningnetwork/lnd/lnwallet/chainfee"
1220 "github.com/stretchr/testify/require"
1321)
1422
@@ -368,3 +376,233 @@ func TestSignMusig2Tx_MissingOutpointInDepositMap(t *testing.T) {
368376 // Expect an error indicating the missing outpoint.
369377 require .ErrorContains (t , err , "tx outpoint not in deposit index map" )
370378}
379+
380+ // TestCalculateWithdrawalTxValues tests various edge cases in withdrawal
381+ // transaction value calculations.
382+ func TestCalculateWithdrawalTxValues (t * testing.T ) {
383+ t .Parallel ()
384+
385+ // Create a taproot address for withdrawal.
386+ taprootAddr , err := btcutil .NewAddressTaproot (
387+ make ([]byte , 32 ), & chaincfg .RegressionNetParams ,
388+ )
389+ require .NoError (t , err )
390+
391+ // Standard fee rate for testing.
392+ feeRate := chainfee .SatPerKWeight (1000 )
393+
394+ // Helper to create deposits.
395+ createDeposit := func (value btcutil.Amount , idx uint32 ) * deposit.Deposit {
396+ hash := chainhash.Hash {}
397+ hash [0 ] = byte (idx )
398+ return & deposit.Deposit {
399+ OutPoint : wire.OutPoint {
400+ Hash : hash ,
401+ Index : idx ,
402+ },
403+ Value : value ,
404+ }
405+ }
406+
407+ tests := []struct {
408+ name string
409+ deposits []* deposit.Deposit
410+ localAmount btcutil.Amount
411+ feeRate chainfee.SatPerKWeight
412+ withdrawAddr btcutil.Address
413+ commitmentType lnrpc.CommitmentType
414+ expectedErr string
415+ expectDustFee bool // change is dust, given to miners
416+ }{
417+ {
418+ name : "neither address nor commitment type specified" ,
419+ deposits : []* deposit.Deposit {
420+ createDeposit (100000 , 0 ),
421+ },
422+ localAmount : 0 ,
423+ feeRate : feeRate ,
424+ withdrawAddr : nil ,
425+ commitmentType : lnrpc .CommitmentType_UNKNOWN_COMMITMENT_TYPE ,
426+ expectedErr : "either address or commitment type must be specified" ,
427+ },
428+ {
429+ name : "change is dust - given to miners" ,
430+ deposits : []* deposit.Deposit {
431+ createDeposit (100000 , 0 ),
432+ },
433+ // Set localAmount such that change after feeWithChange
434+ // would be dust, but change after feeWithoutChange >= 0.
435+ // This triggers case: change-feeWithoutChange >= 0
436+ localAmount : 99300 , // Leaves ~700 sats which is dust
437+ feeRate : feeRate ,
438+ withdrawAddr : taprootAddr ,
439+ commitmentType : lnrpc .CommitmentType_UNKNOWN_COMMITMENT_TYPE ,
440+ expectedErr : "" ,
441+ expectDustFee : true ,
442+ },
443+ {
444+ name : "insufficient funds after dust and fee" ,
445+ deposits : []* deposit.Deposit {
446+ createDeposit (1000 , 0 ),
447+ },
448+ localAmount : 900 ,
449+ feeRate : feeRate ,
450+ withdrawAddr : taprootAddr ,
451+ commitmentType : lnrpc .CommitmentType_UNKNOWN_COMMITMENT_TYPE ,
452+ expectedErr : "doesn't cover for fees" ,
453+ },
454+ {
455+ name : "negative change after fees" ,
456+ deposits : []* deposit.Deposit {
457+ createDeposit (10000 , 0 ),
458+ },
459+ localAmount : 15000 ,
460+ feeRate : feeRate ,
461+ withdrawAddr : taprootAddr ,
462+ commitmentType : lnrpc .CommitmentType_UNKNOWN_COMMITMENT_TYPE ,
463+ expectedErr : "doesn't cover for fees" ,
464+ },
465+ {
466+ name : "min channel size guard - below minimum" ,
467+ deposits : []* deposit.Deposit {
468+ createDeposit (funding .MinChanFundingSize - 10 , 0 ),
469+ },
470+ localAmount : 0 ,
471+ feeRate : feeRate ,
472+ withdrawAddr : nil ,
473+ commitmentType : lnrpc .CommitmentType_SIMPLE_TAPROOT ,
474+ expectedErr : "is lower than the minimum channel " +
475+ "funding size" ,
476+ },
477+ {
478+ name : "min channel size guard - exactly minimum" ,
479+ deposits : []* deposit.Deposit {
480+ createDeposit (funding .MinChanFundingSize + 1000 , 0 ),
481+ },
482+ localAmount : 0 ,
483+ feeRate : feeRate ,
484+ withdrawAddr : nil ,
485+ commitmentType : lnrpc .CommitmentType_SIMPLE_TAPROOT ,
486+ expectedErr : "" ,
487+ },
488+ {
489+ name : "withdrawal amount below dust limit" ,
490+ deposits : []* deposit.Deposit {
491+ createDeposit (400 , 0 ),
492+ },
493+ localAmount : 0 ,
494+ feeRate : feeRate ,
495+ withdrawAddr : taprootAddr ,
496+ commitmentType : lnrpc .CommitmentType_UNKNOWN_COMMITMENT_TYPE ,
497+ expectedErr : "below dust limit" ,
498+ },
499+ {
500+ name : "change higher than input value" ,
501+ deposits : []* deposit.Deposit {
502+ createDeposit (10000 , 0 ),
503+ createDeposit (5000 , 1 ),
504+ },
505+ localAmount : 5000 ,
506+ feeRate : chainfee .SatPerKWeight (100 ),
507+ withdrawAddr : taprootAddr ,
508+ commitmentType : lnrpc .CommitmentType_UNKNOWN_COMMITMENT_TYPE ,
509+ expectedErr : "change amount" ,
510+ },
511+ {
512+ name : "successful withdrawal with change" ,
513+ deposits : []* deposit.Deposit {
514+ createDeposit (100000 , 0 ),
515+ },
516+ localAmount : 50000 ,
517+ feeRate : feeRate ,
518+ withdrawAddr : taprootAddr ,
519+ commitmentType : lnrpc .CommitmentType_UNKNOWN_COMMITMENT_TYPE ,
520+ expectedErr : "" ,
521+ },
522+ {
523+ name : "successful withdrawal no change" ,
524+ deposits : []* deposit.Deposit {
525+ createDeposit (100000 , 0 ),
526+ },
527+ localAmount : 0 ,
528+ feeRate : feeRate ,
529+ withdrawAddr : taprootAddr ,
530+ commitmentType : lnrpc .CommitmentType_UNKNOWN_COMMITMENT_TYPE ,
531+ expectedErr : "" ,
532+ },
533+ {
534+ name : "successful channel open above min size" ,
535+ deposits : []* deposit.Deposit {
536+ createDeposit (funding .MinChanFundingSize * 2 , 0 ),
537+ },
538+ localAmount : 0 ,
539+ feeRate : feeRate ,
540+ withdrawAddr : nil ,
541+ commitmentType : lnrpc .CommitmentType_SIMPLE_TAPROOT ,
542+ expectedErr : "" ,
543+ },
544+ }
545+
546+ for _ , tc := range tests {
547+ t .Run (tc .name , func (t * testing.T ) {
548+ withdrawAmt , changeAmt , err := CalculateWithdrawalTxValues (
549+ tc .deposits , tc .localAmount , tc .feeRate ,
550+ tc .withdrawAddr , tc .commitmentType ,
551+ )
552+
553+ if tc .expectedErr != "" {
554+ require .Error (t , err )
555+ require .ErrorContains (t , err , tc .expectedErr )
556+ return
557+ }
558+
559+ require .NoError (t , err )
560+ require .Greater (t , withdrawAmt , btcutil .Amount (0 ))
561+ require .GreaterOrEqual (t , changeAmt , btcutil .Amount (0 ))
562+
563+ // Verify that withdrawal amount meets dust threshold.
564+ dustLimit := lnwallet .DustLimitForSize (input .P2TRSize )
565+ require .GreaterOrEqual (t , withdrawAmt , dustLimit )
566+
567+ // If this is a channel open, verify min channel size.
568+ if tc .commitmentType != lnrpc .CommitmentType_UNKNOWN_COMMITMENT_TYPE {
569+ require .GreaterOrEqual (
570+ t , withdrawAmt , funding .MinChanFundingSize ,
571+ )
572+ }
573+
574+ // If expecting dust to be given to miners, verify
575+ // changeAmt is 0.
576+ if tc .expectDustFee {
577+ require .Equal (t , btcutil .Amount (0 ), changeAmt ,
578+ "change should be 0 when dust is given to miners" )
579+ }
580+
581+ // Verify total accounting: inputs = withdrawal + change + fees.
582+ totalInputs := btcutil .Amount (0 )
583+ for _ , d := range tc .deposits {
584+ totalInputs += d .Value
585+ }
586+
587+ hasChange := changeAmt > 0
588+ weight , err := WithdrawalTxWeight (
589+ len (tc .deposits ), tc .withdrawAddr ,
590+ tc .commitmentType , hasChange ,
591+ )
592+ require .NoError (t , err )
593+
594+ fee := tc .feeRate .FeeForWeight (weight )
595+
596+ // When dust is given to miners, the "fee" includes both
597+ // the transaction fee and the dust amount.
598+ if tc .expectDustFee {
599+ // Total should equal withdrawal + implicit fee (including dust)
600+ implicitFee := totalInputs - withdrawAmt - changeAmt
601+ require .Greater (t , implicitFee , fee ,
602+ "implicit fee should be greater than tx fee when dust is given to miners" )
603+ } else {
604+ require .Equal (t , totalInputs , withdrawAmt + changeAmt + fee )
605+ }
606+ })
607+ }
608+ }
0 commit comments