diff --git a/pkg/tbtcpg/moved_funds_sweep.go b/pkg/tbtcpg/moved_funds_sweep.go index 628669ed2a..e9f0fa5352 100644 --- a/pkg/tbtcpg/moved_funds_sweep.go +++ b/pkg/tbtcpg/moved_funds_sweep.go @@ -332,14 +332,50 @@ func (mfst *MovedFundsSweepTask) ProposeMovedFundsSweep( ) } - estimatedFee, err := EstimateMovedFundsSweepFee( + movedFundsOutputScript, err := movedFundsSweepOutputScript( mfst.btcChain, + movingFundsTxHash, + movingFundsTxOutputIndex, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot resolve moved funds output script: [%w]", + err, + ) + } + + walletData, err := mfst.chain.GetWallet(walletPublicKeyHash) + if err != nil { + return nil, fmt.Errorf( + "cannot get wallet chain data: [%w]", + err, + ) + } + if walletData == nil { + return nil, fmt.Errorf("wallet chain data is nil") + } + + walletOutputScript, err := tbtc.WalletOutputScript( + walletPublicKeyHash, + walletData.WalletID, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot compute wallet output script: [%w]", + err, + ) + } + + estimatedFee, err := EstimateMovedFundsSweepFeeForScripts( + mfst.btcChain, + movedFundsOutputScript, + walletOutputScript, hasMainUtxo, sweepTxMaxTotalFee, ) if err != nil { return nil, fmt.Errorf( - "cannot estimate moving funds transaction fee: [%w]", + "cannot estimate moved funds sweep transaction fee: [%w]", err, ) } @@ -372,25 +408,79 @@ func (mfst *MovedFundsSweepTask) ProposeMovedFundsSweep( return proposal, nil } -// EstimateMovedFundsSweepFee estimates fee for the moved funds sweep transaction -// that merges the received main UTXO from the source wallets with the current -// wallet's main UTXO. +func movedFundsSweepOutputScript( + btcChain bitcoin.Chain, + movingFundsTxHash bitcoin.Hash, + movingFundsTxOutputIndex uint32, +) (bitcoin.Script, error) { + transaction, err := btcChain.GetTransaction(movingFundsTxHash) + if err != nil { + return nil, fmt.Errorf("cannot get moving funds transaction: [%w]", err) + } + if transaction == nil { + return nil, fmt.Errorf("moving funds transaction is nil") + } + + if movingFundsTxOutputIndex >= uint32(len(transaction.Outputs)) { + return nil, fmt.Errorf( + "moving funds output index [%d] out of range for transaction with [%d] outputs", + movingFundsTxOutputIndex, + len(transaction.Outputs), + ) + } + + output := transaction.Outputs[movingFundsTxOutputIndex] + if output == nil { + return nil, fmt.Errorf( + "moving funds transaction output [%d] is nil", + movingFundsTxOutputIndex, + ) + } + + return output.PublicKeyScript, nil +} + +// EstimateMovedFundsSweepFee estimates a legacy P2WPKH moved-funds sweep fee. +// Call EstimateMovedFundsSweepFeeForScripts when either the moved-funds output +// or the wallet output may use another script type. func EstimateMovedFundsSweepFee( btcChain bitcoin.Chain, hasMainUtxo bool, sweepTxMaxTotalFee uint64, ) (int64, error) { - // The transaction always has an input coming from the moved funds - // transferred by the source wallet. Additionally, it may have the second - // input which is the wallet main UTXO. - inputCount := 1 - if hasMainUtxo { - inputCount++ + legacyWalletOutputScript, err := bitcoin.PayToWitnessPublicKeyHash([20]byte{}) + if err != nil { + return 0, fmt.Errorf("cannot construct legacy wallet output script: [%v]", err) } + return EstimateMovedFundsSweepFeeForScripts( + btcChain, + legacyWalletOutputScript, + legacyWalletOutputScript, + hasMainUtxo, + sweepTxMaxTotalFee, + ) +} + +// EstimateMovedFundsSweepFeeForScripts estimates fee for a moved-funds sweep +// transaction using the resolved moved-funds input and wallet output scripts. +func EstimateMovedFundsSweepFeeForScripts( + btcChain bitcoin.Chain, + movedFundsOutputScript bitcoin.Script, + walletOutputScript bitcoin.Script, + hasMainUtxo bool, + sweepTxMaxTotalFee uint64, +) (int64, error) { sizeEstimator := bitcoin.NewTransactionSizeEstimator(). - AddPublicKeyHashInputs(inputCount, true). - AddPublicKeyHashOutputs(1, true) + AddPublicKeyScriptInput(movedFundsOutputScript) + + // The transaction may have a second input spending the wallet's main UTXO. + // That UTXO is locked using the same script as the new wallet output. + if hasMainUtxo { + sizeEstimator.AddPublicKeyScriptInput(walletOutputScript) + } + + sizeEstimator.AddOutputScript(walletOutputScript) transactionSize, err := sizeEstimator.VirtualSize() if err != nil { diff --git a/pkg/tbtcpg/moved_funds_sweep_test.go b/pkg/tbtcpg/moved_funds_sweep_test.go index e0b1963381..a3454244ac 100644 --- a/pkg/tbtcpg/moved_funds_sweep_test.go +++ b/pkg/tbtcpg/moved_funds_sweep_test.go @@ -346,28 +346,40 @@ func TestMovedFundsSweepAction_ProposeMovedFundsSweep(t *testing.T) { movingFundsTxOutputIndex := uint32(1) - hasMainUtxo := true - var tests = map[string]struct { fee int64 + walletID [32]byte + hasMainUtxo bool expectedProposal *tbtc.MovedFundsSweepProposal }{ "fee provided": { - fee: 10000, + fee: 10000, + hasMainUtxo: true, expectedProposal: &tbtc.MovedFundsSweepProposal{ MovingFundsTxHash: movingFundsTxHash, MovingFundsTxOutputIndex: movingFundsTxOutputIndex, SweepTxFee: big.NewInt(10000), }, }, - "fee estimated": { - fee: 0, // trigger fee estimation + "legacy fee estimated": { + fee: 0, // trigger fee estimation + hasMainUtxo: true, expectedProposal: &tbtc.MovedFundsSweepProposal{ MovingFundsTxHash: movingFundsTxHash, MovingFundsTxOutputIndex: movingFundsTxOutputIndex, SweepTxFee: big.NewInt(4450), }, }, + "fresh FROST wallet fee estimated": { + fee: 0, // trigger fee estimation + walletID: [32]byte{0x01}, + hasMainUtxo: false, + expectedProposal: &tbtc.MovedFundsSweepProposal{ + MovingFundsTxHash: movingFundsTxHash, + MovingFundsTxOutputIndex: movingFundsTxOutputIndex, + SweepTxFee: big.NewInt(2775), + }, + }, } for testName, test := range tests { @@ -391,7 +403,28 @@ func TestMovedFundsSweepAction_ProposeMovedFundsSweep(t *testing.T) { 0, ) - err := tbtcChain.SetMovedFundsSweepProposalValidationResult( + walletOutputScript, err := tbtc.WalletOutputScript( + walletPublicKeyHash, + test.walletID, + ) + if err != nil { + t.Fatal(err) + } + + tbtcChain.SetWallet( + walletPublicKeyHash, + &tbtc.WalletChainData{WalletID: test.walletID}, + ) + movingFundsTxOutputs := make([]*bitcoin.TransactionOutput, 2) + movingFundsTxOutputs[movingFundsTxOutputIndex] = &bitcoin.TransactionOutput{ + PublicKeyScript: walletOutputScript, + } + btcChain.SetTransaction( + movingFundsTxHash, + &bitcoin.Transaction{Outputs: movingFundsTxOutputs}, + ) + + err = tbtcChain.SetMovedFundsSweepProposalValidationResult( walletPublicKeyHash, test.expectedProposal, true, @@ -407,7 +440,7 @@ func TestMovedFundsSweepAction_ProposeMovedFundsSweep(t *testing.T) { walletPublicKeyHash, movingFundsTxHash, movingFundsTxOutputIndex, - hasMainUtxo, + test.hasMainUtxo, test.fee, ) if err != nil { @@ -421,30 +454,63 @@ func TestMovedFundsSweepAction_ProposeMovedFundsSweep(t *testing.T) { } } -func TestEstimateMovedFundsSweepFee(t *testing.T) { +func TestEstimateMovedFundsSweepFeeForScripts(t *testing.T) { + legacyWalletScript, err := bitcoin.PayToWitnessPublicKeyHash([20]byte{0x01}) + if err != nil { + t.Fatal(err) + } + taprootWalletScript, err := bitcoin.PayToTaproot([32]byte{0x02}) + if err != nil { + t.Fatal(err) + } + var tests = map[string]struct { - sweepTxMaxTotalFee uint64 - hasMainUtxo bool - expectedFee uint64 - expectedError error + movedFundsOutputScript bitcoin.Script + walletOutputScript bitcoin.Script + sweepTxMaxTotalFee uint64 + hasMainUtxo bool + expectedFee uint64 + expectedError error }{ - "estimated fee correct, one input": { - sweepTxMaxTotalFee: 3000, - hasMainUtxo: false, - expectedFee: 1760, - expectedError: nil, + "legacy scripts, one input": { + movedFundsOutputScript: legacyWalletScript, + walletOutputScript: legacyWalletScript, + sweepTxMaxTotalFee: 3000, + hasMainUtxo: false, + expectedFee: 1760, + expectedError: nil, + }, + "legacy scripts, two inputs": { + movedFundsOutputScript: legacyWalletScript, + walletOutputScript: legacyWalletScript, + sweepTxMaxTotalFee: 3000, + hasMainUtxo: true, + expectedFee: 2848, + expectedError: nil, }, - "estimated fee correct, two inputs": { - sweepTxMaxTotalFee: 3000, - hasMainUtxo: true, - expectedFee: 2848, - expectedError: nil, + "Taproot scripts, one input": { + movedFundsOutputScript: taprootWalletScript, + walletOutputScript: taprootWalletScript, + sweepTxMaxTotalFee: 3000, + hasMainUtxo: false, + expectedFee: 1776, + expectedError: nil, }, - "estimated fee too high": { - sweepTxMaxTotalFee: 2500, - hasMainUtxo: true, - expectedFee: 0, - expectedError: tbtcpg.ErrSweepTxFeeTooHigh, + "Taproot scripts, two inputs": { + movedFundsOutputScript: taprootWalletScript, + walletOutputScript: taprootWalletScript, + sweepTxMaxTotalFee: 3000, + hasMainUtxo: true, + expectedFee: 2704, + expectedError: nil, + }, + "estimated Taproot fee too high": { + movedFundsOutputScript: taprootWalletScript, + walletOutputScript: taprootWalletScript, + sweepTxMaxTotalFee: 2700, + hasMainUtxo: true, + expectedFee: 0, + expectedError: tbtcpg.ErrSweepTxFeeTooHigh, }, } @@ -453,8 +519,10 @@ func TestEstimateMovedFundsSweepFee(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() btcChain.SetEstimateSatPerVByteFee(1, 16) - actualFee, err := tbtcpg.EstimateMovedFundsSweepFee( + actualFee, err := tbtcpg.EstimateMovedFundsSweepFeeForScripts( btcChain, + test.movedFundsOutputScript, + test.walletOutputScript, test.hasMainUtxo, test.sweepTxMaxTotalFee, ) @@ -475,6 +543,18 @@ func TestEstimateMovedFundsSweepFee(t *testing.T) { } } +func TestEstimateMovedFundsSweepFee(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + btcChain.SetEstimateSatPerVByteFee(1, 16) + + actualFee, err := tbtcpg.EstimateMovedFundsSweepFee(btcChain, true, 3000) + if err != nil { + t.Fatal(err) + } + + testutils.AssertUintsEqual(t, "fee", 2848, uint64(actualFee)) +} + func hashFromString(str string) bitcoin.Hash { hash, err := bitcoin.NewHashFromString( str, diff --git a/pkg/tbtcpg/redemptions.go b/pkg/tbtcpg/redemptions.go index 59dec7a29c..bd6f974004 100644 --- a/pkg/tbtcpg/redemptions.go +++ b/pkg/tbtcpg/redemptions.go @@ -217,8 +217,31 @@ func (rt *RedemptionTask) ProposeRedemption( if fee <= 0 { taskLogger.Infof("estimating redemption transaction fee") - estimatedFee, err := EstimateRedemptionFee( + walletChainData, err := rt.chain.GetWallet(walletPublicKeyHash) + if err != nil { + return nil, fmt.Errorf( + "cannot get redeeming wallet's chain data: [%w]", + err, + ) + } + if walletChainData == nil { + return nil, fmt.Errorf("redeeming wallet's chain data is nil") + } + + walletOutputScript, err := tbtc.WalletOutputScript( + walletPublicKeyHash, + walletChainData.WalletID, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot compute redeeming wallet's output script: [%w]", + err, + ) + } + + estimatedFee, err := EstimateRedemptionFeeForWalletScript( rt.btcChain, + walletOutputScript, redeemersOutputScripts, ) if err != nil { @@ -466,17 +489,37 @@ redemptionRequestedLoop: return result, nil } -// EstimateRedemptionFee estimates fee for the redemption transaction that pays -// the provided redeemers output scripts. +// EstimateRedemptionFee estimates fee for a legacy P2WPKH redemption +// transaction that pays the provided redeemers output scripts. Call +// EstimateRedemptionFeeForWalletScript when the redeeming wallet may use +// another output script type. func EstimateRedemptionFee( btcChain bitcoin.Chain, redeemersOutputScripts []bitcoin.Script, +) (int64, error) { + legacyWalletOutputScript, err := bitcoin.PayToWitnessPublicKeyHash([20]byte{}) + if err != nil { + return 0, fmt.Errorf("cannot construct legacy wallet output script: [%v]", err) + } + + return EstimateRedemptionFeeForWalletScript( + btcChain, + legacyWalletOutputScript, + redeemersOutputScripts, + ) +} + +// EstimateRedemptionFeeForWalletScript estimates fee for a redemption +// transaction using the resolved redeeming wallet output script for both the +// main UTXO input and the possible change output. +func EstimateRedemptionFeeForWalletScript( + btcChain bitcoin.Chain, + walletOutputScript bitcoin.Script, + redeemersOutputScripts []bitcoin.Script, ) (int64, error) { sizeEstimator := bitcoin.NewTransactionSizeEstimator(). - // 1 P2WPKH main UTXO input. - AddPublicKeyHashInputs(1, true). - // 1 P2WPKH change output. - AddPublicKeyHashOutputs(1, true) + AddPublicKeyScriptInput(walletOutputScript). + AddOutputScript(walletOutputScript) for _, script := range redeemersOutputScripts { switch bitcoin.GetScriptType(script) { diff --git a/pkg/tbtcpg/redemptions_test.go b/pkg/tbtcpg/redemptions_test.go index 140a5ae64e..027aae84bf 100644 --- a/pkg/tbtcpg/redemptions_test.go +++ b/pkg/tbtcpg/redemptions_test.go @@ -42,6 +42,27 @@ func TestEstimateRedemptionFee(t *testing.T) { expectedFee := 4000 // transactionVirtualSize * satPerVByteFee = 250 * 16 = 4000 testutils.AssertIntsEqual(t, "fee", expectedFee, int(actualFee)) + + taprootWalletOutputScript, err := bitcoin.PayToTaproot([32]byte{0x01}) + if err != nil { + t.Fatal(err) + } + btcChain.SetEstimateSatPerVByteFee(1, 1) + + actualTaprootFee, err := tbtcpg.EstimateRedemptionFeeForWalletScript( + btcChain, + taprootWalletOutputScript, + redeemersOutputScripts, + ) + if err != nil { + t.Fatal(err) + } + + // The P2TR main UTXO input and change output make this transaction one + // virtual byte larger than the legacy P2WPKH shape. At the minimum relay + // rate, the old shape would underpay by exactly one satoshi. + expectedTaprootFee := 251 // transactionVirtualSize * satPerVByteFee = 251 * 1 = 251 + testutils.AssertIntsEqual(t, "Taproot fee", expectedTaprootFee, int(actualTaprootFee)) } func TestRedemptionAction_FindPendingRedemptions(t *testing.T) { @@ -245,6 +266,7 @@ func TestRedemptionAction_ProposeRedemption(t *testing.T) { var tests = map[string]struct { fee int64 + walletID [32]byte expectedProposal *tbtc.RedemptionProposal }{ "fee provided": { @@ -261,6 +283,14 @@ func TestRedemptionAction_ProposeRedemption(t *testing.T) { RedemptionTxFee: big.NewInt(4300), }, }, + "fee estimated for FROST wallet": { + fee: 0, // trigger fee estimation + walletID: [32]byte{0x01}, + expectedProposal: &tbtc.RedemptionProposal{ + RedeemersOutputScripts: redeemersOutputScripts, + RedemptionTxFee: big.NewInt(4325), + }, + }, } for testName, test := range tests { @@ -269,6 +299,10 @@ func TestRedemptionAction_ProposeRedemption(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() btcChain.SetEstimateSatPerVByteFee(1, 25) + tbtcChain.SetWallet( + walletPublicKeyHash, + &tbtc.WalletChainData{WalletID: test.walletID}, + ) for _, script := range redeemersOutputScripts { tbtcChain.SetPendingRedemptionRequest(