diff --git a/pkg/bitcoin/estimator.go b/pkg/bitcoin/estimator.go index 1853a885f0..5bbd39e871 100644 --- a/pkg/bitcoin/estimator.go +++ b/pkg/bitcoin/estimator.go @@ -17,6 +17,10 @@ var signaturePlaceholder = make([]byte, 72) // Compressed public keys have always 33 bytes. var publicKeyPlaceholder = make([]byte, 33) +// Taproot key-path signatures use the 64-byte BIP-340 Schnorr encoding when +// SIGHASH_DEFAULT is used. +var taprootKeyPathSignaturePlaceholder = make([]byte, 64) + // TransactionSizeEstimator is a component allowing to estimate the size // of a Bitcoin transaction of the provided shape, without constructing it. type TransactionSizeEstimator struct { @@ -75,6 +79,40 @@ func (tse *TransactionSizeEstimator) AddPublicKeyHashInputs( return tse } +// AddPublicKeyScriptInput adds an input spending an output locked by the +// provided direct-key public key script. P2PKH, P2WPKH, and P2TR key-path +// spends are supported. If the estimator already errored out during previous +// actions, this method does nothing. +func (tse *TransactionSizeEstimator) AddPublicKeyScriptInput( + publicKeyScript Script, +) *TransactionSizeEstimator { + if tse.err != nil { + return tse + } + + switch GetScriptType(publicKeyScript) { + case P2PKHScript: + return tse.AddPublicKeyHashInputs(1, false) + case P2WPKHScript: + return tse.AddPublicKeyHashInputs(1, true) + case P2TRScript: + tse.internal.AddTxIn( + wire.NewTxIn( + wire.NewOutPoint((*chainhash.Hash)(&[32]byte{}), 0), + nil, + wire.TxWitness{taprootKeyPathSignaturePlaceholder}, + ), + ) + default: + tse.err = fmt.Errorf( + "unsupported direct-key input script type: [%v]", + GetScriptType(publicKeyScript), + ) + } + + return tse +} + // AddScriptHashInputs adds the provided count of P2WSH (isWitness is true) // or P2SH (isWitness is false) inputs to the estimation. The redeemScriptLength // argument should be used to pass the byte size of the plain-text redeem @@ -201,6 +239,28 @@ func (tse *TransactionSizeEstimator) AddScriptHashOutputs( return tse } +// AddOutputScript adds an output locked by the provided public key script. If +// the estimator already errored out during previous actions, this method does +// nothing. +func (tse *TransactionSizeEstimator) AddOutputScript( + publicKeyScript Script, +) *TransactionSizeEstimator { + if tse.err != nil { + return tse + } + + if len(publicKeyScript) == 0 { + tse.err = fmt.Errorf("output public key script is empty") + return tse + } + + tse.internal.AddTxOut( + wire.NewTxOut(0, append([]byte(nil), publicKeyScript...)), + ) + + return tse +} + // VirtualSize returns the virtual size of the transaction whose shape was // provided to the estimator. If any errors occurred while building the // transaction shape, the first error will be returned. diff --git a/pkg/bitcoin/estimator_test.go b/pkg/bitcoin/estimator_test.go index f202ea97dd..92ca6cdf0d 100644 --- a/pkg/bitcoin/estimator_test.go +++ b/pkg/bitcoin/estimator_test.go @@ -8,6 +8,15 @@ import ( ) func TestTransactionSizeEstimator_VirtualSize(t *testing.T) { + taprootScript, err := PayToTaproot([32]byte{0x01}) + if err != nil { + t.Fatal(err) + } + witnessPublicKeyHashScript, err := PayToWitnessPublicKeyHash([20]byte{0x02}) + if err != nil { + t.Fatal(err) + } + var tests = map[string]struct { estimator *TransactionSizeEstimator expectedVirtualSize int @@ -63,6 +72,28 @@ func TestTransactionSizeEstimator_VirtualSize(t *testing.T) { AddScriptHashOutputs(1, true), expectedVirtualSize: 250, }, + "1 P2TR key-path input and 1 P2TR output": { + estimator: NewTransactionSizeEstimator(). + AddPublicKeyScriptInput(taprootScript). + AddOutputScript(taprootScript), + expectedVirtualSize: 111, + }, + "2 P2TR key-path inputs and 1 P2TR output": { + estimator: NewTransactionSizeEstimator(). + AddPublicKeyScriptInput(taprootScript). + AddPublicKeyScriptInput(taprootScript). + AddOutputScript(taprootScript), + expectedVirtualSize: 169, + }, + "1 P2TR input and mixed P2TR and P2WPKH outputs": { + estimator: NewTransactionSizeEstimator(). + AddPublicKeyScriptInput(taprootScript). + AddOutputScript(taprootScript). + AddOutputScript(taprootScript). + AddOutputScript(witnessPublicKeyHashScript). + AddOutputScript(witnessPublicKeyHashScript), + expectedVirtualSize: 216, + }, } for testName, test := range tests { diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index f426789a6f..eb733e1952 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -1927,7 +1927,7 @@ func pastNewWalletRegisteredEvents( bridge any, pastLegacyEvents pastNewWalletRegisteredEventsFn, ) ([]*tbtc.NewWalletRegisteredEvent, error) { - convertedEvents, err := pastNewWalletRegisteredV2Events( + v2Events, err := pastNewWalletRegisteredV2Events( startBlock, endBlock, walletID, @@ -1939,28 +1939,63 @@ func pastNewWalletRegisteredEvents( return nil, err } - // Fallback for legacy deployments that do not emit NewWalletRegisteredV2. - if len(convertedEvents) == 0 && len(walletID) == 0 { - legacyEvents, err := pastLegacyEvents( - startBlock, - endBlock, - ecdsaWalletID, - walletPublicKeyHash, - ) - if err != nil { - return nil, err + legacyEvents, err := pastLegacyEvents( + startBlock, + endBlock, + ecdsaWalletID, + walletPublicKeyHash, + ) + if err != nil { + return nil, err + } + + convertedEvents := make( + []*tbtc.NewWalletRegisteredEvent, + 0, + len(v2Events)+len(legacyEvents), + ) + seenRegistrations := make(map[[20]byte]struct{}) + + appendUnique := func(event *tbtc.NewWalletRegisteredEvent) { + // The Bridge keys wallet state by public key hash. Compatibility legacy + // events cannot carry a FROST wallet's canonical x-only wallet ID, so + // the public key hash is the common identity available in both event + // versions. + if _, exists := seenRegistrations[event.WalletPublicKeyHash]; exists { + return } - for _, event := range legacyEvents { - convertedEvent := &tbtc.NewWalletRegisteredEvent{ - WalletID: tbtc.DeriveLegacyWalletID(event.WalletPubKeyHash), - EcdsaWalletID: event.EcdsaWalletID, - WalletPublicKeyHash: event.WalletPubKeyHash, - BlockNumber: event.Raw.BlockNumber, - } + seenRegistrations[event.WalletPublicKeyHash] = struct{}{} + convertedEvents = append(convertedEvents, event) + } + + // V2 events are appended first so they win over compatibility legacy + // events emitted for the same registration. + for _, event := range v2Events { + appendUnique(event) + } + + for _, event := range legacyEvents { + // A genuine legacy ECDSA registration always carries a non-zero ECDSA + // wallet ID. A zero value can only be a compatibility event for a + // scheme whose canonical wallet ID is not present in this legacy event; + // synthesizing a padded-PKH ID would invent the wrong identity. + if event.EcdsaWalletID == [32]byte{} { + continue + } + + convertedEvent := &tbtc.NewWalletRegisteredEvent{ + WalletID: tbtc.DeriveLegacyWalletID(event.WalletPubKeyHash), + EcdsaWalletID: event.EcdsaWalletID, + WalletPublicKeyHash: event.WalletPubKeyHash, + BlockNumber: event.Raw.BlockNumber, + } - convertedEvents = append(convertedEvents, convertedEvent) + if len(walletID) > 0 && !containsWalletID(walletID, convertedEvent.WalletID) { + continue } + + appendUnique(convertedEvent) } sort.SliceStable( @@ -1973,6 +2008,16 @@ func pastNewWalletRegisteredEvents( return convertedEvents, nil } +func containsWalletID(walletIDs [][32]byte, walletID [32]byte) bool { + for _, candidate := range walletIDs { + if candidate == walletID { + return true + } + } + + return false +} + func pastNewWalletRegisteredV2Events( startBlock uint64, endBlock *uint64, diff --git a/pkg/chain/ethereum/tbtc_test.go b/pkg/chain/ethereum/tbtc_test.go index 18954b333d..600aa809f6 100644 --- a/pkg/chain/ethereum/tbtc_test.go +++ b/pkg/chain/ethereum/tbtc_test.go @@ -713,20 +713,26 @@ func (m *pastNewWalletRegisteredV2EventsWrongSignatureBridgeMock) PastNewWalletR return nil, nil } -func TestPastNewWalletRegisteredEvents_UsesV2EventsWhenAvailable(t *testing.T) { +func TestPastNewWalletRegisteredEvents_MergesLegacyAndV2Events(t *testing.T) { startBlock := uint64(500) endBlock := uint64(700) + expectedWalletPublicKeyHashA := [20]byte{0x11} + expectedWalletPublicKeyHashB := [20]byte{0x22} + expectedWalletPublicKeyHashC := [20]byte{0x33} + // Wallet A is FROST-shaped: its canonical x-only wallet ID differs from + // the padded public key hash carried by the compatibility legacy event. expectedWalletIDA := [32]byte{0xaa} expectedWalletIDB := [32]byte{0xbb} + expectedWalletIDC := tbtcpkg.DeriveLegacyWalletID( + expectedWalletPublicKeyHashC, + ) - expectedECDSAWalletIDA := [32]byte{0xa1} + expectedECDSAWalletIDA := [32]byte{} expectedECDSAWalletIDB := [32]byte{0xb1} + expectedECDSAWalletIDC := [32]byte{0xc1} - expectedWalletPublicKeyHashA := [20]byte{0x11} - expectedWalletPublicKeyHashB := [20]byte{0x22} - - legacyFallbackCalled := false + legacyEventsCalled := false actualEvents, err := pastNewWalletRegisteredEvents( startBlock, @@ -768,37 +774,140 @@ func TestPastNewWalletRegisteredEvents_UsesV2EventsWhenAvailable(t *testing.T) { }, }, func(uint64, *uint64, [][32]byte, [][20]byte) ([]*tbtcabi.BridgeNewWalletRegistered, error) { - legacyFallbackCalled = true - return nil, nil + legacyEventsCalled = true + return []*tbtcabi.BridgeNewWalletRegistered{ + { + EcdsaWalletID: expectedECDSAWalletIDC, + WalletPubKeyHash: expectedWalletPublicKeyHashC, + Raw: types.Log{BlockNumber: 550}, + }, + // This compatibility event represents the same registration + // as the V2 event at block 600 and must be deduplicated. + { + EcdsaWalletID: expectedECDSAWalletIDA, + WalletPubKeyHash: expectedWalletPublicKeyHashA, + Raw: types.Log{BlockNumber: 600}, + }, + }, nil }, ) if err != nil { t.Fatalf("unexpected error: [%v]", err) } - if legacyFallbackCalled { - t.Fatal("legacy fallback should not be called when v2 events are present") + if !legacyEventsCalled { + t.Fatal("legacy events should be queried when v2 events are present") } - if len(actualEvents) != 2 { + if len(actualEvents) != 3 { t.Fatalf("unexpected events count: [%v]", len(actualEvents)) } // Expect ascending block order after conversion. - if actualEvents[0].BlockNumber != 600 || actualEvents[1].BlockNumber != 650 { + if actualEvents[0].BlockNumber != 550 || + actualEvents[1].BlockNumber != 600 || + actualEvents[2].BlockNumber != 650 { t.Fatalf( - "unexpected event ordering by block: [%v], [%v]", + "unexpected event ordering by block: [%v], [%v], [%v]", actualEvents[0].BlockNumber, actualEvents[1].BlockNumber, + actualEvents[2].BlockNumber, ) } - if actualEvents[0].WalletID != expectedWalletIDA || - actualEvents[1].WalletID != expectedWalletIDB { + if actualEvents[0].WalletID != expectedWalletIDC || + actualEvents[1].WalletID != expectedWalletIDA || + actualEvents[2].WalletID != expectedWalletIDB { t.Fatal("unexpected wallet IDs in converted events") } } +func TestPastNewWalletRegisteredEvents_DeduplicatesRegistrationsByPublicKeyHash( + t *testing.T, +) { + walletPublicKeyHash := [20]byte{0x11} + v2WalletID := [32]byte{0xaa} + legacyECDSAWalletID := [32]byte{0xbb} + legacyWalletID := tbtcpkg.DeriveLegacyWalletID(walletPublicKeyHash) + + if v2WalletID == legacyWalletID { + t.Fatal("test requires distinct V2 and legacy wallet IDs") + } + + legacyEventsCalled := false + + actualEvents, err := pastNewWalletRegisteredEvents( + 1, + nil, + nil, + nil, + nil, + &pastNewWalletRegisteredV2EventsBridgeMock{ + pastEvents: func( + uint64, + *uint64, + [][32]byte, + [][32]byte, + [][20]byte, + ) ([]*tbtcabi.BridgeNewWalletRegisteredV2, error) { + return []*tbtcabi.BridgeNewWalletRegisteredV2{ + { + WalletID: v2WalletID, + WalletPubKeyHash: walletPublicKeyHash, + Raw: types.Log{BlockNumber: 200}, + }, + }, nil + }, + }, + func( + uint64, + *uint64, + [][32]byte, + [][20]byte, + ) ([]*tbtcabi.BridgeNewWalletRegistered, error) { + legacyEventsCalled = true + return []*tbtcabi.BridgeNewWalletRegistered{ + { + EcdsaWalletID: legacyECDSAWalletID, + WalletPubKeyHash: walletPublicKeyHash, + Raw: types.Log{BlockNumber: 100}, + }, + }, nil + }, + ) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if !legacyEventsCalled { + t.Fatal("legacy registrations should be queried alongside V2 registrations") + } + + if len(actualEvents) != 1 { + t.Fatalf( + "registrations sharing a public key hash were not deduplicated: [%v]", + len(actualEvents), + ) + } + + actualEvent := actualEvents[0] + if actualEvent.WalletID != v2WalletID { + t.Fatalf( + "V2 registration did not win deduplication\nexpected wallet ID: [%x]\nactual wallet ID: [%x]", + v2WalletID, + actualEvent.WalletID, + ) + } + + if actualEvent.WalletPublicKeyHash != walletPublicKeyHash { + t.Fatalf( + "unexpected wallet public key hash\nexpected: [%x]\nactual: [%x]", + walletPublicKeyHash, + actualEvent.WalletPublicKeyHash, + ) + } +} + func TestPastNewWalletRegisteredEvents_FallsBackToLegacyWhenV2Empty(t *testing.T) { expectedECDSAWalletID := [32]byte{0xdd} expectedWalletPublicKeyHash := [20]byte{0xee} @@ -855,12 +964,10 @@ func TestPastNewWalletRegisteredEvents_FallsBackToLegacyWhenV2Empty(t *testing.T } } -func TestPastNewWalletRegisteredEvents_DoesNotFallbackWithWalletIDFilter(t *testing.T) { - legacyFallbackCalled := false - - walletIDFilter := [][32]byte{ - {0x1}, - } +func TestPastNewWalletRegisteredEvents_FiltersLegacyEventsByWalletID(t *testing.T) { + matchingWalletPublicKeyHash := [20]byte{0x01} + matchingWalletID := tbtcpkg.DeriveLegacyWalletID(matchingWalletPublicKeyHash) + walletIDFilter := [][32]byte{matchingWalletID} actualEvents, err := pastNewWalletRegisteredEvents( 1, @@ -880,20 +987,82 @@ func TestPastNewWalletRegisteredEvents_DoesNotFallbackWithWalletIDFilter(t *test }, }, func(uint64, *uint64, [][32]byte, [][20]byte) ([]*tbtcabi.BridgeNewWalletRegistered, error) { - legacyFallbackCalled = true - return nil, nil + return []*tbtcabi.BridgeNewWalletRegistered{ + { + EcdsaWalletID: [32]byte{0x11}, + WalletPubKeyHash: matchingWalletPublicKeyHash, + Raw: types.Log{BlockNumber: 100}, + }, + { + EcdsaWalletID: [32]byte{0x22}, + WalletPubKeyHash: [20]byte{0x02}, + Raw: types.Log{BlockNumber: 101}, + }, + }, nil }, ) if err != nil { t.Fatalf("unexpected error: [%v]", err) } - if legacyFallbackCalled { - t.Fatal("legacy fallback should be skipped when walletID filter is provided") + if len(actualEvents) != 1 { + t.Fatalf("unexpected events count: [%v]", len(actualEvents)) + } + + if actualEvents[0].WalletID != matchingWalletID { + t.Fatalf("unexpected wallet ID: [%x]", actualEvents[0].WalletID) + } +} + +func TestPastNewWalletRegisteredEvents_DoesNotSynthesizeIDForZeroECDSAWalletID( + t *testing.T, +) { + walletPublicKeyHash := [20]byte{0x01} + synthesizedWalletID := tbtcpkg.DeriveLegacyWalletID(walletPublicKeyHash) + canonicalWalletID := [32]byte{0xaa} + + actualEvents, err := pastNewWalletRegisteredEvents( + 1, + nil, + [][32]byte{synthesizedWalletID}, + nil, + nil, + &pastNewWalletRegisteredV2EventsBridgeMock{ + pastEvents: func( + _ uint64, + _ *uint64, + walletIDs [][32]byte, + _ [][32]byte, + _ [][20]byte, + ) ([]*tbtcabi.BridgeNewWalletRegisteredV2, error) { + if len(walletIDs) != 1 || walletIDs[0] != synthesizedWalletID { + t.Fatalf("unexpected V2 wallet ID filter: [%x]", walletIDs) + } + + // The actual V2 registration uses a different canonical ID, so + // the filtered V2 query correctly returns no events. + if canonicalWalletID == synthesizedWalletID { + t.Fatal("test requires distinct canonical and synthesized IDs") + } + return nil, nil + }, + }, + func(uint64, *uint64, [][32]byte, [][20]byte) ([]*tbtcabi.BridgeNewWalletRegistered, error) { + return []*tbtcabi.BridgeNewWalletRegistered{ + { + EcdsaWalletID: [32]byte{}, + WalletPubKeyHash: walletPublicKeyHash, + Raw: types.Log{BlockNumber: 100}, + }, + }, nil + }, + ) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) } if len(actualEvents) != 0 { - t.Fatalf("unexpected events count: [%v]", len(actualEvents)) + t.Fatalf("unexpected synthesized legacy events: [%v]", actualEvents) } } diff --git a/pkg/maintainer/spv/chain.go b/pkg/maintainer/spv/chain.go index a3444fd349..f89ee17299 100644 --- a/pkg/maintainer/spv/chain.go +++ b/pkg/maintainer/spv/chain.go @@ -97,6 +97,13 @@ type Chain interface { filter *tbtc.DepositRevealedEventFilter, ) ([]*tbtc.DepositRevealedEvent, error) + // PastTaprootDepositRevealedEvents fetches past Taproot deposit reveal + // events according to the provided filter or unfiltered if the filter is + // nil. Returned events are sorted by block number in ascending order. + PastTaprootDepositRevealedEvents( + filter *tbtc.DepositRevealedEventFilter, + ) ([]*tbtc.TaprootDepositRevealedEvent, error) + // PastRedemptionRequestedEvents fetches past redemption requested events according // to the provided filter or unfiltered if the filter is nil. Returned // events are sorted by the block number in the ascending order, i.e. the diff --git a/pkg/maintainer/spv/chain_test.go b/pkg/maintainer/spv/chain_test.go index 99cb4b4013..7b417b2fa0 100644 --- a/pkg/maintainer/spv/chain_test.go +++ b/pkg/maintainer/spv/chain_test.go @@ -57,6 +57,7 @@ type localChain struct { submittedMovedFundsSweepProofs []*submittedMovedFundsSweepProof pastRedemptionRequestedEvents map[[32]byte][]*tbtc.RedemptionRequestedEvent pastDepositRevealedEvents map[[32]byte][]*tbtc.DepositRevealedEvent + pastTaprootDepositRevealedEvents map[[32]byte][]*tbtc.TaprootDepositRevealedEvent pastMovingFundsCommitmentSubmittedEvents map[[32]byte][]*tbtc.MovingFundsCommitmentSubmittedEvent txProofDifficultyFactor *big.Int @@ -76,6 +77,7 @@ func newLocalChain() *localChain { submittedMovingFundsProofs: make([]*submittedMovingFundsProof, 0), pastRedemptionRequestedEvents: make(map[[32]byte][]*tbtc.RedemptionRequestedEvent), pastDepositRevealedEvents: make(map[[32]byte][]*tbtc.DepositRevealedEvent), + pastTaprootDepositRevealedEvents: make(map[[32]byte][]*tbtc.TaprootDepositRevealedEvent), pastMovingFundsCommitmentSubmittedEvents: make(map[[32]byte][]*tbtc.MovingFundsCommitmentSubmittedEvent), } } @@ -480,6 +482,40 @@ func (lc *localChain) addPastDepositRevealedEvent( return nil } +func (lc *localChain) PastTaprootDepositRevealedEvents( + filter *tbtc.DepositRevealedEventFilter, +) ([]*tbtc.TaprootDepositRevealedEvent, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + eventsKey, err := buildPastDepositRevealedEventsKey(filter) + if err != nil { + return nil, err + } + + return lc.pastTaprootDepositRevealedEvents[eventsKey], nil +} + +func (lc *localChain) addPastTaprootDepositRevealedEvent( + filter *tbtc.DepositRevealedEventFilter, + event *tbtc.TaprootDepositRevealedEvent, +) error { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + eventsKey, err := buildPastDepositRevealedEventsKey(filter) + if err != nil { + return err + } + + lc.pastTaprootDepositRevealedEvents[eventsKey] = append( + lc.pastTaprootDepositRevealedEvents[eventsKey], + event, + ) + + return nil +} + func buildPastDepositRevealedEventsKey( filter *tbtc.DepositRevealedEventFilter, ) ([32]byte, error) { diff --git a/pkg/maintainer/spv/deposit_sweep.go b/pkg/maintainer/spv/deposit_sweep.go index 4e1d52eb8f..7b4bbdf916 100644 --- a/pkg/maintainer/spv/deposit_sweep.go +++ b/pkg/maintainer/spv/deposit_sweep.go @@ -264,12 +264,9 @@ func getUnprovenDepositSweepTransactions( // searched for. startBlock := currentBlock - historyDepth - events, err := - spvChain.PastDepositRevealedEvents( - &tbtc.DepositRevealedEventFilter{ - StartBlock: startBlock, - }, - ) + filter := &tbtc.DepositRevealedEventFilter{StartBlock: startBlock} + + events, err := spvChain.PastDepositRevealedEvents(filter) if err != nil { return nil, fmt.Errorf( "failed to get past deposit revealed events: [%v]", @@ -277,13 +274,35 @@ func getUnprovenDepositSweepTransactions( ) } - // There will often be multiple events emitted for a single wallet. Prepare - // a list of unique wallet public key hashes. + taprootEvents, err := spvChain.PastTaprootDepositRevealedEvents(filter) + if err != nil { + return nil, fmt.Errorf( + "failed to get past Taproot deposit revealed events: [%v]", + err, + ) + } + + // There will often be multiple events emitted for a single wallet, and a + // Taproot reveal may have a compatibility legacy reveal. Prepare one list + // of unique wallet public key hashes across both event streams. walletPublicKeyHashes := uniqueWalletPublicKeyHashes( events, ) + seenWallets := make(map[[20]byte]struct{}, len(walletPublicKeyHashes)) + for _, walletPublicKeyHash := range walletPublicKeyHashes { + seenWallets[walletPublicKeyHash] = struct{}{} + } + for _, walletPublicKeyHash := range uniqueWalletPublicKeyHashes(taprootEvents) { + if _, exists := seenWallets[walletPublicKeyHash]; exists { + continue + } + + seenWallets[walletPublicKeyHash] = struct{}{} + walletPublicKeyHashes = append(walletPublicKeyHashes, walletPublicKeyHash) + } unprovenDepositSweepTransactions := []*bitcoin.Transaction{} + seenTransactions := make(map[bitcoin.Hash]struct{}) for _, walletPublicKeyHash := range walletPublicKeyHashes { wallet, err := spvChain.GetWallet(walletPublicKeyHash) @@ -304,9 +323,11 @@ func getUnprovenDepositSweepTransactions( continue } - walletTransactions, err := btcChain.GetTransactionsForPublicKeyHash( + walletTransactions, err := getWalletTransactions( walletPublicKeyHash, + wallet, transactionLimit, + btcChain, ) if err != nil { return nil, fmt.Errorf( @@ -316,6 +337,11 @@ func getUnprovenDepositSweepTransactions( } for _, transaction := range walletTransactions { + transactionHash := transaction.Hash() + if _, exists := seenTransactions[transactionHash]; exists { + continue + } + isUnproven, err := isUnprovenDepositSweepTransaction( transaction, @@ -332,6 +358,7 @@ func getUnprovenDepositSweepTransactions( } if isUnproven { + seenTransactions[transactionHash] = struct{}{} unprovenDepositSweepTransactions = append( unprovenDepositSweepTransactions, transaction, diff --git a/pkg/maintainer/spv/deposit_sweep_test.go b/pkg/maintainer/spv/deposit_sweep_test.go index 69cf92736e..71244c9dac 100644 --- a/pkg/maintainer/spv/deposit_sweep_test.go +++ b/pkg/maintainer/spv/deposit_sweep_test.go @@ -560,3 +560,148 @@ func TestGetUnprovenDepositSweepTransactions(t *testing.T) { t.Errorf("invalid unproven transaction hashes: %v", diff) } } + +func TestGetUnprovenDepositSweepTransactions_DiscoversTaprootWallet(t *testing.T) { + testGetUnprovenDepositSweepTransactionsDiscoversTaprootWallet(t, true) +} + +func TestGetUnprovenDepositSweepTransactions_DiscoversTaprootOnlyWallet( + t *testing.T, +) { + testGetUnprovenDepositSweepTransactionsDiscoversTaprootWallet(t, false) +} + +func testGetUnprovenDepositSweepTransactionsDiscoversTaprootWallet( + t *testing.T, + includeCompatibilityReveal bool, +) { + t.Helper() + + historyDepth := uint64(5) + currentBlock := uint64(1000) + filter := &tbtc.DepositRevealedEventFilter{ + StartBlock: currentBlock - historyDepth, + } + + btcChain := newLocalBitcoinChain() + spvChain := newLocalChain() + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + spvChain.setBlockCounter(blockCounter) + + walletPublicKeyHash := [20]byte{0x01} + walletID := [32]byte{0x02} + walletOutputScript, err := bitcoin.PayToTaproot(walletID) + if err != nil { + t.Fatal(err) + } + spvChain.setWallet( + walletPublicKeyHash, + &tbtc.WalletChainData{ + WalletID: walletID, + State: tbtc.StateLive, + }, + ) + + depositOutputScript, err := bitcoin.PayToTaproot([32]byte{0x03}) + if err != nil { + t.Fatal(err) + } + depositTransaction := &bitcoin.Transaction{ + Version: 2, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: depositOutputScript, + }, + }, + } + sweepTransaction := &bitcoin.Transaction{ + Version: 2, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: depositTransaction.Hash(), + OutputIndex: 0, + }, + Witness: [][]byte{make([]byte, 64)}, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 99000, + PublicKeyScript: walletOutputScript, + }, + }, + } + + for _, transaction := range []*bitcoin.Transaction{ + depositTransaction, + sweepTransaction, + } { + if err := btcChain.BroadcastTransaction(transaction); err != nil { + t.Fatal(err) + } + } + spvChain.setDepositRequest( + depositTransaction.Hash(), + 0, + &tbtc.DepositChainRequest{ + RevealedAt: time.Unix(100000, 0), + SweptAt: time.Unix(0, 0), + }, + ) + + // A compatibility legacy event may identify the same wallet as the native + // Taproot event. Discovery must work without that compatibility event and, + // when it is present, still query the wallet only once. + if includeCompatibilityReveal { + if err := spvChain.addPastDepositRevealedEvent( + filter, + &tbtc.DepositRevealedEvent{ + WalletPublicKeyHash: walletPublicKeyHash, + BlockNumber: currentBlock - 2, + }, + ); err != nil { + t.Fatal(err) + } + } else { + // The local chain requires an explicit result for legacy event queries. + // Register an empty result so this case contains only the Taproot reveal. + eventsKey, err := buildPastDepositRevealedEventsKey(filter) + if err != nil { + t.Fatal(err) + } + spvChain.pastDepositRevealedEvents[eventsKey] = nil + } + if err := spvChain.addPastTaprootDepositRevealedEvent( + filter, + &tbtc.TaprootDepositRevealedEvent{ + WalletPublicKeyHash: walletPublicKeyHash, + BlockNumber: currentBlock - 1, + }, + ); err != nil { + t.Fatal(err) + } + + transactions, err := getUnprovenDepositSweepTransactions( + historyDepth, + 10, + btcChain, + spvChain, + ) + if err != nil { + t.Fatal(err) + } + + if len(transactions) != 1 { + t.Fatalf("expected one Taproot sweep transaction, got [%d]", len(transactions)) + } + if transactions[0].Hash() != sweepTransaction.Hash() { + t.Fatalf( + "unexpected sweep transaction\nexpected: [%x]\nactual: [%x]", + sweepTransaction.Hash(), + transactions[0].Hash(), + ) + } +} diff --git a/pkg/maintainer/spv/redemptions.go b/pkg/maintainer/spv/redemptions.go index 1c6f2516dd..813343a24a 100644 --- a/pkg/maintainer/spv/redemptions.go +++ b/pkg/maintainer/spv/redemptions.go @@ -214,12 +214,8 @@ func getWalletTransactions( transactionLimit int, btcChain bitcoin.Chain, ) ([]*bitcoin.Transaction, error) { - scriptChain, ok := btcChain.(transactionsForPublicKeyScriptsChain) - if !ok { - return btcChain.GetTransactionsForPublicKeyHash( - walletPublicKeyHash, - transactionLimit, - ) + if wallet == nil { + return nil, fmt.Errorf("wallet chain data is nil") } publicKeyScripts, err := walletPublicKeyScripts( @@ -230,6 +226,24 @@ func getWalletTransactions( return nil, err } + scriptChain, ok := btcChain.(transactionsForPublicKeyScriptsChain) + if !ok { + if len(publicKeyScripts) == 1 && + bitcoin.GetScriptType(publicKeyScripts[0]) == bitcoin.P2TRScript { + return nil, fmt.Errorf( + "bitcoin chain does not support transaction lookup by " + + "public key script required for P2TR wallet", + ) + } + + // Older bitcoin.Chain implementations can still discover legacy + // P2PKH/P2WPKH wallet transactions by public key hash. + return btcChain.GetTransactionsForPublicKeyHash( + walletPublicKeyHash, + transactionLimit, + ) + } + return scriptChain.GetTransactionsForPublicKeyScripts( publicKeyScripts, transactionLimit, diff --git a/pkg/maintainer/spv/redemptions_test.go b/pkg/maintainer/spv/redemptions_test.go index 761bccb2ff..4f2b6c3ad4 100644 --- a/pkg/maintainer/spv/redemptions_test.go +++ b/pkg/maintainer/spv/redemptions_test.go @@ -10,6 +10,12 @@ import ( "testing" ) +// publicKeyHashOnlyBitcoinChain exposes only the base bitcoin.Chain interface, +// hiding the local chain's optional public-key-script transaction lookup. +type publicKeyHashOnlyBitcoinChain struct { + bitcoin.Chain +} + func TestSubmitRedemptionProof(t *testing.T) { bytesFromHex := func(str string) []byte { value, err := hex.DecodeString(str) @@ -593,6 +599,67 @@ func TestGetUnprovenRedemptionTransactions_TaprootWallet(t *testing.T) { } } +func TestGetWalletTransactions_WithoutPublicKeyScriptLookup(t *testing.T) { + t.Run("rejects P2TR wallet", func(t *testing.T) { + btcChain := &publicKeyHashOnlyBitcoinChain{ + Chain: newLocalBitcoinChain(), + } + + _, err := getWalletTransactions( + [20]byte{0x01}, + &tbtc.WalletChainData{WalletID: [32]byte{0x02}}, + 10, + btcChain, + ) + if err == nil { + t.Fatal("expected unsupported P2TR transaction lookup error") + } + + expectedError := "bitcoin chain does not support transaction lookup by " + + "public key script required for P2TR wallet" + if err.Error() != expectedError { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + expectedError, + err, + ) + } + }) + + t.Run("retains public key hash fallback for legacy wallet", func(t *testing.T) { + localChain := newLocalBitcoinChain() + walletPublicKeyHash := [20]byte{0x03} + walletOutputScript, err := bitcoin.PayToWitnessPublicKeyHash( + walletPublicKeyHash, + ) + if err != nil { + t.Fatal(err) + } + + transaction := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{ + {PublicKeyScript: walletOutputScript}, + }, + } + if err := localChain.BroadcastTransaction(transaction); err != nil { + t.Fatal(err) + } + + transactions, err := getWalletTransactions( + walletPublicKeyHash, + &tbtc.WalletChainData{}, + 10, + &publicKeyHashOnlyBitcoinChain{Chain: localChain}, + ) + if err != nil { + t.Fatal(err) + } + if len(transactions) != 1 || transactions[0].Hash() != transaction.Hash() { + t.Fatalf("legacy public key hash fallback returned wrong transactions") + } + }) +} + func TestGetUnprovenRedemptionTransactions_TaprootWalletIgnoresLegacyAliases( t *testing.T, ) { diff --git a/pkg/tbtc/deposit_sweep.go b/pkg/tbtc/deposit_sweep.go index 3fd95aa032..bf1961f661 100644 --- a/pkg/tbtc/deposit_sweep.go +++ b/pkg/tbtc/deposit_sweep.go @@ -60,6 +60,10 @@ type DepositSweepProposal struct { } SweepTxFee *big.Int DepositsRevealBlocks []*big.Int + // MainUtxoHash is the wallet main UTXO snapshot used to size a Taproot + // sweep. A zero value means the proposal was sized without a main UTXO. + // Taproot execution fails closed if the current hash no longer matches. + MainUtxoHash [32]byte } func (dsp *DepositSweepProposal) ActionType() WalletActionType { @@ -175,6 +179,19 @@ func (dsa *depositSweepAction) execute() error { ) } + if err := validateDepositSweepMainUtxoSnapshot( + dsa.chain, + dsa.proposal, + validatedDeposits, + walletMainUtxo, + ); err != nil { + if dsa.metricsRecorder != nil { + dsa.metricsRecorder.IncrementCounter("deposit_sweep_executions_failed_total", 1) + dsa.metricsRecorder.RecordDuration("deposit_sweep_execution_duration_seconds", time.Since(executionStartTime)) + } + return fmt.Errorf("wallet main UTXO snapshot validation failed: [%v]", err) + } + err = EnsureWalletSyncedBetweenChainsForPublicKey( dsa.wallet().publicKey, walletMainUtxo, @@ -272,6 +289,40 @@ func (dsa *depositSweepAction) execute() error { return nil } +func validateDepositSweepMainUtxoSnapshot( + chain interface { + ComputeMainUtxoHash( + mainUtxo *bitcoin.UnspentTransactionOutput, + ) [32]byte + }, + proposal *DepositSweepProposal, + deposits []*Deposit, + mainUtxo *bitcoin.UnspentTransactionOutput, +) error { + if proposal == nil { + return fmt.Errorf("deposit sweep proposal is nil") + } + if len(deposits) == 0 || !deposits[0].IsTaproot() { + return nil + } + + currentMainUtxoHash := [32]byte{} + if mainUtxo != nil { + currentMainUtxoHash = chain.ComputeMainUtxoHash(mainUtxo) + } + + if proposal.MainUtxoHash != currentMainUtxoHash { + return fmt.Errorf( + "wallet main UTXO changed after Taproot sweep fee sizing: "+ + "expected [0x%x], current [0x%x]", + proposal.MainUtxoHash, + currentMainUtxoHash, + ) + } + + return nil +} + // ValidateDepositSweepProposal checks the deposit sweep proposal with on-chain // validation rules and verifies transactions on the Bitcoin chain. func ValidateDepositSweepProposal( diff --git a/pkg/tbtc/deposit_sweep_test.go b/pkg/tbtc/deposit_sweep_test.go index ac03321cbf..89cb87a60e 100644 --- a/pkg/tbtc/deposit_sweep_test.go +++ b/pkg/tbtc/deposit_sweep_test.go @@ -249,6 +249,179 @@ func TestDepositSweepAction_Execute(t *testing.T) { } } +func TestDepositSweepAction_Execute_MainUtxoSnapshotMismatchRecordsMetrics( + t *testing.T, +) { + scenarios, err := test.LoadDepositSweepTestScenarios() + if err != nil { + t.Fatal(err) + } + + scenario := scenarios[0] + if scenario.WalletMainUtxo == nil { + t.Fatal("test scenario must have a wallet main UTXO") + } + + hostChain := &depositSweepSnapshotChain{localChain: Connect()} + bitcoinChain := newLocalBitcoinChain() + for _, transaction := range scenario.InputTransactions { + if err := bitcoinChain.BroadcastTransaction(transaction); err != nil { + t.Fatal(err) + } + } + + wallet := wallet{publicKey: scenario.WalletPublicKey} + walletPublicKeyHash := bitcoin.PublicKeyHash(wallet.publicKey) + deposit := scenario.Deposits[0] + fundingTxHash := deposit.Utxo.Outpoint.TransactionHash + fundingOutputIndex := deposit.Utxo.Outpoint.OutputIndex + + revealBlock := uint64(100) + filter := &DepositRevealedEventFilter{ + StartBlock: revealBlock, + EndBlock: &revealBlock, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + } + if err := hostChain.setPastDepositRevealedEvents( + filter, + []*DepositRevealedEvent{}, + ); err != nil { + t.Fatal(err) + } + + var walletXOnlyPublicKey [32]byte + walletXBytes := wallet.publicKey.X.Bytes() + copy(walletXOnlyPublicKey[len(walletXOnlyPublicKey)-len(walletXBytes):], walletXBytes) + taprootEvent := &TaprootDepositRevealedEvent{ + FundingTxHash: fundingTxHash, + FundingOutputIndex: fundingOutputIndex, + Depositor: deposit.Depositor, + Amount: uint64(deposit.Utxo.Value), + BlindingFactor: deposit.BlindingFactor, + WalletPublicKeyHash: deposit.WalletPublicKeyHash, + WalletXOnlyPublicKey: walletXOnlyPublicKey, + RefundPublicKeyHash: deposit.RefundPublicKeyHash, + RefundXOnlyPublicKey: [32]byte{0x01}, + RefundLocktime: deposit.RefundLocktime, + Vault: deposit.Vault, + BlockNumber: revealBlock, + } + if err := hostChain.setPastTaprootDepositRevealedEvents( + filter, + []*TaprootDepositRevealedEvent{taprootEvent}, + ); err != nil { + t.Fatal(err) + } + + hostChain.setDepositRequest( + fundingTxHash, + fundingOutputIndex, + &DepositChainRequest{ + Depositor: deposit.Depositor, + Amount: uint64(deposit.Utxo.Value), + Vault: deposit.Vault, + ExtraData: deposit.ExtraData, + }, + ) + + proposal := &DepositSweepProposal{ + DepositsKeys: []struct { + FundingTxHash bitcoin.Hash + FundingOutputIndex uint32 + }{ + { + FundingTxHash: fundingTxHash, + FundingOutputIndex: fundingOutputIndex, + }, + }, + SweepTxFee: big.NewInt(scenario.Fee), + DepositsRevealBlocks: []*big.Int{big.NewInt(int64(revealBlock))}, + // MainUtxoHash deliberately remains zero to represent a proposal made + // before the wallet's previous sweep was proven. + } + + hostChain.setWallet(walletPublicKeyHash, &WalletChainData{ + MainUtxoHash: hostChain.ComputeMainUtxoHash(scenario.WalletMainUtxo), + }) + + action := newDepositSweepAction( + logger.With(), + hostChain, + bitcoinChain, + wallet, + newMockWalletSigningExecutor(), + proposal, + 100, + 100+depositSweepProposalValidityBlocks, + func(ctx context.Context, blockHeight uint64) error { return nil }, + ) + action.requiredFundingTxConfirmations = 1 + + metrics := &depositSweepMetricsRecorder{ + counters: make(map[string]float64), + durations: make(map[string][]time.Duration), + } + action.setMetricsRecorder(metrics) + + err = action.execute() + if err == nil || !strings.Contains( + err.Error(), + "wallet main UTXO snapshot validation failed", + ) { + t.Fatalf("unexpected execution error: [%v]", err) + } + + if actual := metrics.counters["deposit_sweep_executions_total"]; actual != 1 { + t.Errorf("unexpected execution attempts count: [%v]", actual) + } + if actual := metrics.counters["deposit_sweep_executions_failed_total"]; actual != 1 { + t.Errorf("unexpected failed executions count: [%v]", actual) + } + if actual := metrics.counters["deposit_sweep_executions_success_total"]; actual != 0 { + t.Errorf("unexpected successful executions count: [%v]", actual) + } + if actual := len(metrics.durations["deposit_sweep_execution_duration_seconds"]); actual != 1 { + t.Errorf("unexpected execution durations count: [%v]", actual) + } + if actual := len(metrics.durations["deposit_sweep_tx_signing_duration_seconds"]); actual != 0 { + t.Errorf("unexpected signing durations count: [%v]", actual) + } +} + +type depositSweepMetricsRecorder struct { + counters map[string]float64 + durations map[string][]time.Duration +} + +func (dsmr *depositSweepMetricsRecorder) IncrementCounter( + name string, + value float64, +) { + dsmr.counters[name] += value +} + +func (dsmr *depositSweepMetricsRecorder) RecordDuration( + name string, + duration time.Duration, +) { + dsmr.durations[name] = append(dsmr.durations[name], duration) +} + +type depositSweepSnapshotChain struct { + *localChain +} + +func (dssc *depositSweepSnapshotChain) ValidateTaprootDepositSweepProposal( + walletPublicKeyHash [20]byte, + proposal *DepositSweepProposal, + depositsExtraInfo []struct { + *Deposit + FundingTx *bitcoin.Transaction + }, +) error { + return nil +} + func TestAssembleDepositSweepTransaction(t *testing.T) { scenarios, err := test.LoadDepositSweepTestScenarios() if err != nil { @@ -331,6 +504,76 @@ func TestAssembleDepositSweepTransaction(t *testing.T) { } } +func TestValidateDepositSweepMainUtxoSnapshot(t *testing.T) { + hostChain := Connect() + mainUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0xaa}, + OutputIndex: 1, + }, + Value: 100000, + } + mainUtxoHash := hostChain.ComputeMainUtxoHash(mainUtxo) + + walletKey := [32]byte{0x01} + refundKey := [32]byte{0x02} + taprootDeposit := &Deposit{ + WalletXOnlyPublicKey: &walletKey, + RefundXOnlyPublicKey: &refundKey, + } + + tests := map[string]struct { + proposal *DepositSweepProposal + deposits []*Deposit + mainUtxo *bitcoin.UnspentTransactionOutput + wantErr bool + }{ + "unchanged first sweep": { + proposal: &DepositSweepProposal{}, + deposits: []*Deposit{taprootDeposit}, + }, + "unchanged existing main UTXO": { + proposal: &DepositSweepProposal{MainUtxoHash: mainUtxoHash}, + deposits: []*Deposit{taprootDeposit}, + mainUtxo: mainUtxo, + }, + "main UTXO registered after proposal": { + proposal: &DepositSweepProposal{}, + deposits: []*Deposit{taprootDeposit}, + mainUtxo: mainUtxo, + wantErr: true, + }, + "main UTXO changed after proposal": { + proposal: &DepositSweepProposal{MainUtxoHash: [32]byte{0xff}}, + deposits: []*Deposit{taprootDeposit}, + mainUtxo: mainUtxo, + wantErr: true, + }, + "legacy sweep does not require snapshot": { + proposal: &DepositSweepProposal{}, + deposits: []*Deposit{{}}, + mainUtxo: mainUtxo, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + err := validateDepositSweepMainUtxoSnapshot( + hostChain, + test.proposal, + test.deposits, + test.mainUtxo, + ) + if test.wantErr && err == nil { + t.Fatal("expected main UTXO snapshot mismatch") + } + if !test.wantErr && err != nil { + t.Fatalf("unexpected snapshot validation error: [%v]", err) + } + }) + } +} + func TestAssembleDepositSweepTransaction_TaprootDeposit(t *testing.T) { hexToSlice := func(hexString string) []byte { bytes, err := hex.DecodeString(hexString) diff --git a/pkg/tbtc/gen/pb/message.pb.go b/pkg/tbtc/gen/pb/message.pb.go index 7496ad009d..496987b39d 100644 --- a/pkg/tbtc/gen/pb/message.pb.go +++ b/pkg/tbtc/gen/pb/message.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.28.0 -// protoc v3.19.4 +// protoc v6.33.4 // source: pkg/tbtc/gen/pb/message.proto package pb @@ -280,6 +280,7 @@ type DepositSweepProposal struct { DepositsKeys []*DepositSweepProposal_DepositKey `protobuf:"bytes,1,rep,name=depositsKeys,proto3" json:"depositsKeys,omitempty"` SweepTxFee []byte `protobuf:"bytes,2,opt,name=sweepTxFee,proto3" json:"sweepTxFee,omitempty"` DepositsRevealBlocks []uint64 `protobuf:"varint,3,rep,packed,name=depositsRevealBlocks,proto3" json:"depositsRevealBlocks,omitempty"` + MainUtxoHash []byte `protobuf:"bytes,4,opt,name=mainUtxoHash,proto3" json:"mainUtxoHash,omitempty"` } func (x *DepositSweepProposal) Reset() { @@ -335,6 +336,13 @@ func (x *DepositSweepProposal) GetDepositsRevealBlocks() []uint64 { return nil } +func (x *DepositSweepProposal) GetMainUtxoHash() []byte { + if x != nil { + return x.MainUtxoHash + } + return nil +} + type RedemptionProposal struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -600,7 +608,7 @@ var file_pkg_tbtc_gen_pb_message_proto_rawDesc = []byte{ 0x22, 0x2d, 0x0a, 0x11, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x61, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, - 0x99, 0x02, 0x0a, 0x14, 0x44, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x53, 0x77, 0x65, 0x65, 0x70, + 0xbd, 0x02, 0x0a, 0x14, 0x44, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x53, 0x77, 0x65, 0x65, 0x70, 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x61, 0x6c, 0x12, 0x49, 0x0a, 0x0c, 0x64, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x73, 0x4b, 0x65, 0x79, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x74, 0x62, 0x74, 0x63, 0x2e, 0x44, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x53, 0x77, 0x65, @@ -611,39 +619,41 @@ var file_pkg_tbtc_gen_pb_message_proto_rawDesc = []byte{ 0x46, 0x65, 0x65, 0x12, 0x32, 0x0a, 0x14, 0x64, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x73, 0x52, 0x65, 0x76, 0x65, 0x61, 0x6c, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x04, 0x52, 0x14, 0x64, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x73, 0x52, 0x65, 0x76, 0x65, 0x61, - 0x6c, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x73, 0x1a, 0x62, 0x0a, 0x0a, 0x44, 0x65, 0x70, 0x6f, 0x73, - 0x69, 0x74, 0x4b, 0x65, 0x79, 0x12, 0x24, 0x0a, 0x0d, 0x66, 0x75, 0x6e, 0x64, 0x69, 0x6e, 0x67, - 0x54, 0x78, 0x48, 0x61, 0x73, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0d, 0x66, 0x75, - 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x54, 0x78, 0x48, 0x61, 0x73, 0x68, 0x12, 0x2e, 0x0a, 0x12, 0x66, - 0x75, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x49, 0x6e, 0x64, 0x65, - 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x12, 0x66, 0x75, 0x6e, 0x64, 0x69, 0x6e, 0x67, - 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x22, 0x76, 0x0a, 0x12, 0x52, - 0x65, 0x64, 0x65, 0x6d, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x61, - 0x6c, 0x12, 0x36, 0x0a, 0x16, 0x72, 0x65, 0x64, 0x65, 0x65, 0x6d, 0x65, 0x72, 0x73, 0x4f, 0x75, - 0x74, 0x70, 0x75, 0x74, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, - 0x0c, 0x52, 0x16, 0x72, 0x65, 0x64, 0x65, 0x65, 0x6d, 0x65, 0x72, 0x73, 0x4f, 0x75, 0x74, 0x70, - 0x75, 0x74, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x73, 0x12, 0x28, 0x0a, 0x0f, 0x72, 0x65, 0x64, - 0x65, 0x6d, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x78, 0x46, 0x65, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x65, 0x64, 0x65, 0x6d, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x78, - 0x46, 0x65, 0x65, 0x22, 0x67, 0x0a, 0x13, 0x4d, 0x6f, 0x76, 0x69, 0x6e, 0x67, 0x46, 0x75, 0x6e, - 0x64, 0x73, 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x61, 0x6c, 0x12, 0x24, 0x0a, 0x0d, 0x74, 0x61, - 0x72, 0x67, 0x65, 0x74, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, - 0x0c, 0x52, 0x0d, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x73, - 0x12, 0x2a, 0x0a, 0x10, 0x6d, 0x6f, 0x76, 0x69, 0x6e, 0x67, 0x46, 0x75, 0x6e, 0x64, 0x73, 0x54, - 0x78, 0x46, 0x65, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x10, 0x6d, 0x6f, 0x76, 0x69, - 0x6e, 0x67, 0x46, 0x75, 0x6e, 0x64, 0x73, 0x54, 0x78, 0x46, 0x65, 0x65, 0x22, 0xa3, 0x01, 0x0a, - 0x17, 0x4d, 0x6f, 0x76, 0x65, 0x64, 0x46, 0x75, 0x6e, 0x64, 0x73, 0x53, 0x77, 0x65, 0x65, 0x70, - 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x61, 0x6c, 0x12, 0x2c, 0x0a, 0x11, 0x6d, 0x6f, 0x76, 0x69, - 0x6e, 0x67, 0x46, 0x75, 0x6e, 0x64, 0x73, 0x54, 0x78, 0x48, 0x61, 0x73, 0x68, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0c, 0x52, 0x11, 0x6d, 0x6f, 0x76, 0x69, 0x6e, 0x67, 0x46, 0x75, 0x6e, 0x64, 0x73, - 0x54, 0x78, 0x48, 0x61, 0x73, 0x68, 0x12, 0x3a, 0x0a, 0x18, 0x6d, 0x6f, 0x76, 0x69, 0x6e, 0x67, - 0x46, 0x75, 0x6e, 0x64, 0x73, 0x54, 0x78, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x49, 0x6e, 0x64, - 0x65, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x18, 0x6d, 0x6f, 0x76, 0x69, 0x6e, 0x67, - 0x46, 0x75, 0x6e, 0x64, 0x73, 0x54, 0x78, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x49, 0x6e, 0x64, - 0x65, 0x78, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x46, 0x65, 0x65, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x73, 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x46, - 0x65, 0x65, 0x42, 0x06, 0x5a, 0x04, 0x2e, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, + 0x6c, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x6d, 0x61, 0x69, 0x6e, 0x55, + 0x74, 0x78, 0x6f, 0x48, 0x61, 0x73, 0x68, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0c, 0x6d, + 0x61, 0x69, 0x6e, 0x55, 0x74, 0x78, 0x6f, 0x48, 0x61, 0x73, 0x68, 0x1a, 0x62, 0x0a, 0x0a, 0x44, + 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x4b, 0x65, 0x79, 0x12, 0x24, 0x0a, 0x0d, 0x66, 0x75, 0x6e, + 0x64, 0x69, 0x6e, 0x67, 0x54, 0x78, 0x48, 0x61, 0x73, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x0d, 0x66, 0x75, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x54, 0x78, 0x48, 0x61, 0x73, 0x68, 0x12, + 0x2e, 0x0a, 0x12, 0x66, 0x75, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, + 0x49, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x12, 0x66, 0x75, 0x6e, + 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x22, + 0x76, 0x0a, 0x12, 0x52, 0x65, 0x64, 0x65, 0x6d, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x72, 0x6f, + 0x70, 0x6f, 0x73, 0x61, 0x6c, 0x12, 0x36, 0x0a, 0x16, 0x72, 0x65, 0x64, 0x65, 0x65, 0x6d, 0x65, + 0x72, 0x73, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x73, 0x18, + 0x01, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x16, 0x72, 0x65, 0x64, 0x65, 0x65, 0x6d, 0x65, 0x72, 0x73, + 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x73, 0x12, 0x28, 0x0a, + 0x0f, 0x72, 0x65, 0x64, 0x65, 0x6d, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x78, 0x46, 0x65, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x65, 0x64, 0x65, 0x6d, 0x70, 0x74, 0x69, + 0x6f, 0x6e, 0x54, 0x78, 0x46, 0x65, 0x65, 0x22, 0x67, 0x0a, 0x13, 0x4d, 0x6f, 0x76, 0x69, 0x6e, + 0x67, 0x46, 0x75, 0x6e, 0x64, 0x73, 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x61, 0x6c, 0x12, 0x24, + 0x0a, 0x0d, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x73, 0x18, + 0x01, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0d, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x57, 0x61, 0x6c, + 0x6c, 0x65, 0x74, 0x73, 0x12, 0x2a, 0x0a, 0x10, 0x6d, 0x6f, 0x76, 0x69, 0x6e, 0x67, 0x46, 0x75, + 0x6e, 0x64, 0x73, 0x54, 0x78, 0x46, 0x65, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x10, + 0x6d, 0x6f, 0x76, 0x69, 0x6e, 0x67, 0x46, 0x75, 0x6e, 0x64, 0x73, 0x54, 0x78, 0x46, 0x65, 0x65, + 0x22, 0xa3, 0x01, 0x0a, 0x17, 0x4d, 0x6f, 0x76, 0x65, 0x64, 0x46, 0x75, 0x6e, 0x64, 0x73, 0x53, + 0x77, 0x65, 0x65, 0x70, 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x61, 0x6c, 0x12, 0x2c, 0x0a, 0x11, + 0x6d, 0x6f, 0x76, 0x69, 0x6e, 0x67, 0x46, 0x75, 0x6e, 0x64, 0x73, 0x54, 0x78, 0x48, 0x61, 0x73, + 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x6d, 0x6f, 0x76, 0x69, 0x6e, 0x67, 0x46, + 0x75, 0x6e, 0x64, 0x73, 0x54, 0x78, 0x48, 0x61, 0x73, 0x68, 0x12, 0x3a, 0x0a, 0x18, 0x6d, 0x6f, + 0x76, 0x69, 0x6e, 0x67, 0x46, 0x75, 0x6e, 0x64, 0x73, 0x54, 0x78, 0x4f, 0x75, 0x74, 0x70, 0x75, + 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x18, 0x6d, 0x6f, + 0x76, 0x69, 0x6e, 0x67, 0x46, 0x75, 0x6e, 0x64, 0x73, 0x54, 0x78, 0x4f, 0x75, 0x74, 0x70, 0x75, + 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x77, 0x65, 0x65, 0x70, 0x54, + 0x78, 0x46, 0x65, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x73, 0x77, 0x65, 0x65, + 0x70, 0x54, 0x78, 0x46, 0x65, 0x65, 0x42, 0x06, 0x5a, 0x04, 0x2e, 0x2f, 0x70, 0x62, 0x62, 0x06, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/pkg/tbtc/gen/pb/message.proto b/pkg/tbtc/gen/pb/message.proto index e26d31ab46..057d6681eb 100644 --- a/pkg/tbtc/gen/pb/message.proto +++ b/pkg/tbtc/gen/pb/message.proto @@ -36,6 +36,7 @@ message DepositSweepProposal { repeated DepositKey depositsKeys = 1; bytes sweepTxFee = 2; repeated uint64 depositsRevealBlocks = 3; + bytes mainUtxoHash = 4; } message RedemptionProposal { diff --git a/pkg/tbtc/marshaling.go b/pkg/tbtc/marshaling.go index babbfb34f7..e90ee03377 100644 --- a/pkg/tbtc/marshaling.go +++ b/pkg/tbtc/marshaling.go @@ -318,6 +318,7 @@ func (dsp *DepositSweepProposal) Marshal() ([]byte, error) { DepositsKeys: depositsKeys, SweepTxFee: dsp.SweepTxFee.Bytes(), DepositsRevealBlocks: depositsRevealBlocks, + MainUtxoHash: append([]byte{}, dsp.MainUtxoHash[:]...), }, ) } @@ -365,6 +366,13 @@ func (dsp *DepositSweepProposal) Unmarshal(bytes []byte) error { dsp.DepositsKeys = depositsKeys dsp.SweepTxFee = new(big.Int).SetBytes(pbMsg.SweepTxFee) dsp.DepositsRevealBlocks = depositsRevealBlocks + if len(pbMsg.MainUtxoHash) != 0 && len(pbMsg.MainUtxoHash) != 32 { + return fmt.Errorf( + "failed to unmarshal main UTXO hash: invalid length [%v]", + len(pbMsg.MainUtxoHash), + ) + } + copy(dsp.MainUtxoHash[:], pbMsg.MainUtxoHash) return nil } diff --git a/pkg/tbtc/marshaling_test.go b/pkg/tbtc/marshaling_test.go index c1e750f9ec..c13dab5b1e 100644 --- a/pkg/tbtc/marshaling_test.go +++ b/pkg/tbtc/marshaling_test.go @@ -167,6 +167,7 @@ func TestCoordinationMessage_MarshalingRoundtrip(t *testing.T) { big.NewInt(100), big.NewInt(300), }, + MainUtxoHash: [32]byte{0xaa, 0xbb, 0xcc}, }, }, "with redemption proposal": { diff --git a/pkg/tbtcpg/deposit_sweep.go b/pkg/tbtcpg/deposit_sweep.go index c3cb873be2..2630a9dec1 100644 --- a/pkg/tbtcpg/deposit_sweep.go +++ b/pkg/tbtcpg/deposit_sweep.go @@ -105,6 +105,7 @@ type DepositReference struct { FundingOutputIndex uint32 RevealBlock uint64 Vault *chain.Address + IsTaproot bool } // Deposit holds some detailed data about a deposit. @@ -352,6 +353,7 @@ func findDeposits( FundingTxHash: event.FundingTxHash, FundingOutputIndex: event.FundingOutputIndex, RevealBlock: event.BlockNumber, + IsTaproot: event.IsTaproot, }, WalletPublicKeyHash: event.WalletPublicKeyHash, DepositKey: hexutils.Encode(depositKey.Bytes()), @@ -549,6 +551,7 @@ func (dst *DepositSweepTask) FindDepositsToSweep( FundingOutputIndex: deposit.FundingOutputIndex, RevealBlock: deposit.RevealBlock, Vault: deposit.Vault, + IsTaproot: deposit.IsTaproot, } } @@ -566,7 +569,34 @@ func (dst *DepositSweepTask) ProposeDepositsSweep( return nil, fmt.Errorf("deposits list is empty") } + isTaproot := deposits[0].IsTaproot + for i, deposit := range deposits[1:] { + if deposit.IsTaproot != isTaproot { + return nil, fmt.Errorf( + "cannot mix legacy and Taproot deposits in one sweep proposal (deposit [%d])", + i+1, + ) + } + } + taskLogger.Infof("preparing a deposit sweep proposal") + mainUtxoHash := [32]byte{} + if isTaproot { + walletData, err := dst.chain.GetWallet(walletPublicKeyHash) + if err != nil { + return nil, fmt.Errorf( + "cannot get wallet data for Taproot sweep main UTXO snapshot: [%w]", + err, + ) + } + if walletData == nil { + return nil, fmt.Errorf( + "cannot get wallet data for Taproot sweep main UTXO snapshot: wallet data is nil", + ) + } + + mainUtxoHash = walletData.MainUtxoHash + } // Estimate fee if it's missing. if fee <= 0 { @@ -577,10 +607,17 @@ func (dst *DepositSweepTask) ProposeDepositsSweep( return nil, fmt.Errorf("cannot get deposit tx max fee: [%w]", err) } + hasMainUtxo := true + if isTaproot { + hasMainUtxo = mainUtxoHash != [32]byte{} + } + estimatedFee, _, err := estimateDepositsSweepFee( dst.btcChain, len(deposits), perDepositMaxFee, + isTaproot, + hasMainUtxo, ) if err != nil { return nil, fmt.Errorf("cannot estimate sweep transaction fee: [%v]", err) @@ -613,6 +650,7 @@ func (dst *DepositSweepTask) ProposeDepositsSweep( DepositsKeys: depositsKeys, SweepTxFee: big.NewInt(fee), DepositsRevealBlocks: depositsRevealBlocks, + MainUtxoHash: mainUtxoHash, } taskLogger.Infof("validating the deposit sweep proposal") @@ -690,6 +728,8 @@ func EstimateDepositsSweepFee( btcChain, depositsCountKey, perDepositMaxFee, + false, + true, ) if err != nil { return nil, fmt.Errorf( @@ -715,15 +755,39 @@ func estimateDepositsSweepFee( btcChain bitcoin.Chain, depositsCount int, perDepositMaxFee uint64, + isTaproot bool, + hasMainUtxo bool, ) (int64, int64, error) { - transactionSize, err := bitcoin.NewTransactionSizeEstimator(). - // 1 P2WPKH main UTXO input. - AddPublicKeyHashInputs(1, true). - // depositsCount P2WSH deposit inputs. - AddScriptHashInputs(depositsCount, depositScriptByteSize, true). - // 1 P2WPKH output. - AddPublicKeyHashOutputs(1, true). - VirtualSize() + sizeEstimator := bitcoin.NewTransactionSizeEstimator() + if isTaproot { + taprootOutputScript, err := bitcoin.PayToTaproot([32]byte{}) + if err != nil { + return 0, 0, fmt.Errorf( + "cannot construct Taproot output script placeholder: [%v]", + err, + ) + } + + inputsCount := depositsCount + if hasMainUtxo { + inputsCount++ + } + + for i := 0; i < inputsCount; i++ { + sizeEstimator.AddPublicKeyScriptInput(taprootOutputScript) + } + sizeEstimator.AddOutputScript(taprootOutputScript) + } else { + sizeEstimator. + // 1 P2WPKH main UTXO input. + AddPublicKeyHashInputs(1, true). + // depositsCount P2WSH deposit inputs. + AddScriptHashInputs(depositsCount, depositScriptByteSize, true). + // 1 P2WPKH output. + AddPublicKeyHashOutputs(1, true) + } + + transactionSize, err := sizeEstimator.VirtualSize() if err != nil { return 0, 0, fmt.Errorf("cannot estimate transaction virtual size: [%v]", err) } diff --git a/pkg/tbtcpg/deposit_sweep_test.go b/pkg/tbtcpg/deposit_sweep_test.go index 9873f39bdb..2bf3c6edf1 100644 --- a/pkg/tbtcpg/deposit_sweep_test.go +++ b/pkg/tbtcpg/deposit_sweep_test.go @@ -1,6 +1,7 @@ package tbtcpg_test import ( + "math/big" "reflect" "testing" "time" @@ -458,6 +459,207 @@ func TestDepositSweepTask_ProposeDepositsSweep(t *testing.T) { } } +func TestDepositSweepTask_TaprootMarkerControlsFirstSweepFeeEstimation(t *testing.T) { + currentBlock := uint64(300000) + filterStartBlock := currentBlock - tbtcpg.DepositSweepLookBackBlocks + revealBlock := uint64(290000) + walletPublicKeyHash := hexToByte20( + "7670343fc00ccc2d0cd65360e6ad400697ea0fed", + ) + + tbtcChain := tbtcpg.NewLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + tbtcChain.SetBlockCounter(blockCounter) + tbtcChain.SetDepositMinAge(3600) + tbtcChain.SetDepositParameters(0, 0, 2500, 0) + tbtcChain.SetWallet(walletPublicKeyHash, &tbtc.WalletChainData{}) + btcChain.SetEstimateSatPerVByteFee(1, 20) + + fundingTxHash := setupVaultGroupingDeposit( + t, + tbtcChain, + btcChain, + walletPublicKeyHash, + filterStartBlock, + "7711111111111111111111111111111111111111111111111111111111111111", + 0, + revealBlock, + nil, + ) + + legacyEvent := &tbtc.DepositRevealedEvent{ + BlockNumber: revealBlock, + WalletPublicKeyHash: walletPublicKeyHash, + FundingTxHash: fundingTxHash, + FundingOutputIndex: 0, + } + taprootEvent := &tbtc.TaprootDepositRevealedEvent{ + BlockNumber: revealBlock, + WalletPublicKeyHash: walletPublicKeyHash, + FundingTxHash: fundingTxHash, + FundingOutputIndex: 0, + } + + broadFilter := &tbtc.DepositRevealedEventFilter{ + StartBlock: filterStartBlock, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + } + if err := tbtcChain.AddPastTaprootDepositRevealedEvent( + broadFilter, + taprootEvent, + ); err != nil { + t.Fatal(err) + } + + task := tbtcpg.NewDepositSweepTask(tbtcChain, btcChain) + deposits, err := task.FindDepositsToSweep( + &testutils.MockLogger{}, + walletPublicKeyHash, + 1, + ) + if err != nil { + t.Fatal(err) + } + if len(deposits) != 1 { + t.Fatalf("expected one deposit, got [%d]", len(deposits)) + } + if !deposits[0].IsTaproot { + t.Fatal("Taproot marker was lost while building the deposit reference") + } + + // With no main UTXO, the first-sweep P2TR shape is 111 vbytes and costs + // 2220 sats at 20 sat/vbyte. Including a nonexistent P2TR main-UTXO input + // would produce 169 vbytes and exceed the 2500-sat per-deposit maximum. + expectedProposal := &tbtc.DepositSweepProposal{ + DepositsKeys: []struct { + FundingTxHash bitcoin.Hash + FundingOutputIndex uint32 + }{ + { + FundingTxHash: fundingTxHash, + FundingOutputIndex: 0, + }, + }, + SweepTxFee: big.NewInt(2220), + DepositsRevealBlocks: []*big.Int{big.NewInt(int64(revealBlock))}, + } + + exactFilter := &tbtc.DepositRevealedEventFilter{ + StartBlock: revealBlock, + EndBlock: &revealBlock, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + } + if err := tbtcChain.AddPastDepositRevealedEvent( + exactFilter, + legacyEvent, + ); err != nil { + t.Fatal(err) + } + if err := tbtcChain.AddPastTaprootDepositRevealedEvent( + exactFilter, + taprootEvent, + ); err != nil { + t.Fatal(err) + } + if err := tbtcChain.SetDepositSweepProposalValidationResult( + walletPublicKeyHash, + expectedProposal, + nil, + true, + ); err != nil { + t.Fatal(err) + } + + proposal, err := task.ProposeDepositsSweep( + &testutils.MockLogger{}, + walletPublicKeyHash, + deposits, + 0, + ) + if err != nil { + t.Fatal(err) + } + if diff := deep.Equal(expectedProposal, proposal); diff != nil { + t.Errorf("invalid Taproot deposit sweep proposal: %v", diff) + } + + // An existing main UTXO adds a second P2TR input to the sweep. At 20 + // sat/vbyte, the 169-vbyte transaction costs 3380 sats. This exercises the + // hasMainUtxo fee-estimation branch rather than just snapshot propagation. + mainUtxoHash := [32]byte{0xaa, 0xbb, 0xcc} + tbtcChain.SetWallet( + walletPublicKeyHash, + &tbtc.WalletChainData{MainUtxoHash: mainUtxoHash}, + ) + tbtcChain.SetDepositParameters(0, 0, 4000, 0) + expectedEstimatedProposalWithMainUtxo := &tbtc.DepositSweepProposal{ + DepositsKeys: expectedProposal.DepositsKeys, + SweepTxFee: big.NewInt(3380), + DepositsRevealBlocks: expectedProposal.DepositsRevealBlocks, + MainUtxoHash: mainUtxoHash, + } + if err := tbtcChain.SetDepositSweepProposalValidationResult( + walletPublicKeyHash, + expectedEstimatedProposalWithMainUtxo, + nil, + true, + ); err != nil { + t.Fatal(err) + } + + proposal, err = task.ProposeDepositsSweep( + &testutils.MockLogger{}, + walletPublicKeyHash, + deposits, + 0, + ) + if err != nil { + t.Fatal(err) + } + if proposal.SweepTxFee.Cmp(expectedEstimatedProposalWithMainUtxo.SweepTxFee) != 0 { + t.Fatalf( + "unexpected Taproot sweep fee with main UTXO\nexpected: [%v]\nactual: [%v]", + expectedEstimatedProposalWithMainUtxo.SweepTxFee, + proposal.SweepTxFee, + ) + } + if diff := deep.Equal(expectedEstimatedProposalWithMainUtxo, proposal); diff != nil { + t.Errorf("invalid Taproot sweep proposal with main UTXO: %v", diff) + } + + // Even when the fee is supplied explicitly, capture the main UTXO state + // that execution must revalidate before signing. + expectedProposalWithMainUtxo := &tbtc.DepositSweepProposal{ + DepositsKeys: expectedProposal.DepositsKeys, + SweepTxFee: big.NewInt(2000), + DepositsRevealBlocks: expectedProposal.DepositsRevealBlocks, + MainUtxoHash: mainUtxoHash, + } + if err := tbtcChain.SetDepositSweepProposalValidationResult( + walletPublicKeyHash, + expectedProposalWithMainUtxo, + nil, + true, + ); err != nil { + t.Fatal(err) + } + + proposal, err = task.ProposeDepositsSweep( + &testutils.MockLogger{}, + walletPublicKeyHash, + deposits, + 2000, + ) + if err != nil { + t.Fatal(err) + } + if diff := deep.Equal(expectedProposalWithMainUtxo, proposal); diff != nil { + t.Errorf("invalid Taproot main UTXO snapshot: %v", diff) + } +} + // setupVaultGroupingDeposit registers a single deposit in the mock chains // with the given vault and returns the funding tx hash used. func setupVaultGroupingDeposit( diff --git a/pkg/tbtcpg/internal/test/tbtcpgtest.go b/pkg/tbtcpg/internal/test/tbtcpgtest.go index b03cc40468..6bb58eeba0 100644 --- a/pkg/tbtcpg/internal/test/tbtcpgtest.go +++ b/pkg/tbtcpg/internal/test/tbtcpgtest.go @@ -92,6 +92,7 @@ func (psts *ProposeSweepTestScenario) DepositsReferences() []*tbtcpg.DepositRefe FundingOutputIndex: d.FundingOutputIndex, RevealBlock: d.RevealBlock, Vault: d.Vault, + IsTaproot: d.IsTaproot, } } diff --git a/pkg/tbtcpg/moving_funds.go b/pkg/tbtcpg/moving_funds.go index 544ef3187c..cd0a3464ca 100644 --- a/pkg/tbtcpg/moving_funds.go +++ b/pkg/tbtcpg/moving_funds.go @@ -602,9 +602,32 @@ func (mft *MovingFundsTask) ProposeMovingFunds( ) } - estimatedFee, err := EstimateMovingFundsFee( + sourceWalletOutputScript, err := movingFundsMainUTXOOutputScript( mft.btcChain, - len(targetWallets), + mainUTXO, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot resolve moving funds source wallet output: [%w]", + err, + ) + } + + targetWalletOutputScripts, err := movingFundsTargetWalletOutputScripts( + mft.chain, + targetWallets, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot resolve moving funds target wallet outputs: [%w]", + err, + ) + } + + estimatedFee, err := EstimateMovingFundsFeeForScripts( + mft.btcChain, + sourceWalletOutputScript, + targetWalletOutputScripts, txMaxTotalFee, ) if err != nil { @@ -646,16 +669,124 @@ func (mft *MovingFundsTask) ActionType() tbtc.WalletActionType { return tbtc.ActionMovingFunds } -// EstimateMovingFundsFee estimates fee for the moving funds transaction that -// moves funds from the source wallet to target wallets. +func movingFundsMainUTXOOutputScript( + btcChain bitcoin.Chain, + mainUTXO *bitcoin.UnspentTransactionOutput, +) (bitcoin.Script, error) { + if mainUTXO == nil || mainUTXO.Outpoint == nil { + return nil, fmt.Errorf("main UTXO is nil") + } + + transaction, err := btcChain.GetTransaction( + mainUTXO.Outpoint.TransactionHash, + ) + if err != nil { + return nil, fmt.Errorf("cannot get main UTXO transaction: [%w]", err) + } + if transaction == nil { + return nil, fmt.Errorf("main UTXO transaction is nil") + } + + outputIndex := mainUTXO.Outpoint.OutputIndex + if outputIndex >= uint32(len(transaction.Outputs)) { + return nil, fmt.Errorf( + "main UTXO output index [%d] out of range for transaction with [%d] outputs", + outputIndex, + len(transaction.Outputs), + ) + } + if transaction.Outputs[outputIndex] == nil { + return nil, fmt.Errorf("main UTXO transaction output [%d] is nil", outputIndex) + } + + return transaction.Outputs[outputIndex].PublicKeyScript, nil +} + +func movingFundsTargetWalletOutputScripts( + chain Chain, + targetWallets [][20]byte, +) ([]bitcoin.Script, error) { + outputScripts := make([]bitcoin.Script, len(targetWallets)) + + for i, targetWallet := range targetWallets { + walletData, err := chain.GetWallet(targetWallet) + if err != nil { + return nil, fmt.Errorf( + "cannot get target wallet [%d] chain data: [%w]", + i, + err, + ) + } + if walletData == nil { + return nil, fmt.Errorf("target wallet [%d] chain data is nil", i) + } + + outputScript, err := tbtc.WalletOutputScript( + targetWallet, + walletData.WalletID, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot compute target wallet [%d] output script: [%w]", + i, + err, + ) + } + + outputScripts[i] = outputScript + } + + return outputScripts, nil +} + +// EstimateMovingFundsFee estimates a legacy P2WPKH moving-funds transaction +// fee. Call EstimateMovingFundsFeeForScripts when the source or any target may +// use another output script type. func EstimateMovingFundsFee( btcChain bitcoin.Chain, targetWalletsCount int, txMaxTotalFee uint64, ) (int64, error) { + if targetWalletsCount <= 0 { + return 0, fmt.Errorf("target wallets count must be positive") + } + + legacyWalletOutputScript, err := bitcoin.PayToWitnessPublicKeyHash([20]byte{}) + if err != nil { + return 0, fmt.Errorf("cannot construct legacy wallet output script: [%v]", err) + } + + targetWalletOutputScripts := make([]bitcoin.Script, targetWalletsCount) + for i := range targetWalletOutputScripts { + targetWalletOutputScripts[i] = legacyWalletOutputScript + } + + return EstimateMovingFundsFeeForScripts( + btcChain, + legacyWalletOutputScript, + targetWalletOutputScripts, + txMaxTotalFee, + ) +} + +// EstimateMovingFundsFeeForScripts estimates fee for a moving-funds +// transaction using the resolved source and target wallet output scripts. +func EstimateMovingFundsFeeForScripts( + btcChain bitcoin.Chain, + sourceWalletOutputScript bitcoin.Script, + targetWalletOutputScripts []bitcoin.Script, + txMaxTotalFee uint64, +) (int64, error) { + if len(targetWalletOutputScripts) == 0 { + return 0, fmt.Errorf("target wallet output scripts list is empty") + } + sizeEstimator := bitcoin.NewTransactionSizeEstimator(). - AddPublicKeyHashInputs(1, true). - AddPublicKeyHashOutputs(targetWalletsCount, true) + AddPublicKeyScriptInput(sourceWalletOutputScript) + + for _, targetWalletOutputScript := range targetWalletOutputScripts { + sizeEstimator.AddOutputScript(targetWalletOutputScript) + } transactionSize, err := sizeEstimator.VirtualSize() if err != nil { diff --git a/pkg/tbtcpg/moving_funds_test.go b/pkg/tbtcpg/moving_funds_test.go index 228ea01fe8..ba980ccb49 100644 --- a/pkg/tbtcpg/moving_funds_test.go +++ b/pkg/tbtcpg/moving_funds_test.go @@ -589,8 +589,10 @@ func TestMovingFundsAction_ProposeMovingFunds(t *testing.T) { txMaxTotalFee := uint64(6000) var tests = map[string]struct { - fee int64 - expectedProposal *tbtc.MovingFundsProposal + fee int64 + sourceTaproot bool + taprootTargetsCount int + expectedProposal *tbtc.MovingFundsProposal }{ "fee provided": { fee: 10000, @@ -606,6 +608,15 @@ func TestMovingFundsAction_ProposeMovingFunds(t *testing.T) { MovingFundsTxFee: big.NewInt(4300), }, }, + "fee estimated from mixed Taproot and legacy scripts": { + fee: 0, + sourceTaproot: true, + taprootTargetsCount: 2, + expectedProposal: &tbtc.MovingFundsProposal{ + TargetWallets: targetWallets, + MovingFundsTxFee: big.NewInt(4625), + }, + }, } for testName, test := range tests { @@ -627,6 +638,39 @@ func TestMovingFundsAction_ProposeMovingFunds(t *testing.T) { MovingFundsRequestedAt: time.Now().Add(-25 * time.Hour), }, ) + for i, targetWallet := range targetWallets { + walletData := &tbtc.WalletChainData{} + if i < test.taprootTargetsCount { + walletData.WalletID[0] = byte(i + 1) + } + tbtcChain.SetWallet(targetWallet, walletData) + } + + var sourceWalletOutputScript bitcoin.Script + var err error + if test.sourceTaproot { + sourceWalletOutputScript, err = bitcoin.PayToTaproot([32]byte{0xff}) + } else { + sourceWalletOutputScript, err = bitcoin.PayToWitnessPublicKeyHash( + walletPublicKeyHash, + ) + } + if err != nil { + t.Fatal(err) + } + sourceTransactionOutputs := make([]*bitcoin.TransactionOutput, 12) + for i := range sourceTransactionOutputs { + sourceTransactionOutputs[i] = &bitcoin.TransactionOutput{} + } + sourceTransactionOutputs[walletMainUtxo.Outpoint.OutputIndex] = + &bitcoin.TransactionOutput{ + Value: walletMainUtxo.Value, + PublicKeyScript: sourceWalletOutputScript, + } + btcChain.SetTransaction( + walletMainUtxo.Outpoint.TransactionHash, + &bitcoin.Transaction{Outputs: sourceTransactionOutputs}, + ) // Simulate the wallet was not chosen as a target wallet for another // moving funds wallet. @@ -651,7 +695,7 @@ func TestMovingFundsAction_ProposeMovingFunds(t *testing.T) { 0, ) - err := tbtcChain.SetMovingFundsProposalValidationResult( + err = tbtcChain.SetMovingFundsProposalValidationResult( walletPublicKeyHash, walletMainUtxo, test.expectedProposal, @@ -681,18 +725,55 @@ func TestMovingFundsAction_ProposeMovingFunds(t *testing.T) { } } -func TestEstimateMovingFundsFee(t *testing.T) { +func TestEstimateMovingFundsFeeForScripts(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 { + sourceScript bitcoin.Script + targetScripts []bitcoin.Script txMaxTotalFee uint64 expectedFee uint64 expectedError error }{ - "estimated fee correct": { + "legacy scripts": { + sourceScript: legacyWalletScript, + targetScripts: []bitcoin.Script{ + legacyWalletScript, + legacyWalletScript, + legacyWalletScript, + legacyWalletScript, + }, txMaxTotalFee: 6000, expectedFee: 3248, expectedError: nil, }, + "mixed Taproot and legacy scripts": { + sourceScript: taprootWalletScript, + targetScripts: []bitcoin.Script{ + taprootWalletScript, + taprootWalletScript, + legacyWalletScript, + legacyWalletScript, + }, + txMaxTotalFee: 6000, + expectedFee: 3456, + expectedError: nil, + }, "estimated fee too high": { + sourceScript: legacyWalletScript, + targetScripts: []bitcoin.Script{ + legacyWalletScript, + legacyWalletScript, + legacyWalletScript, + legacyWalletScript, + }, txMaxTotalFee: 3000, expectedFee: 0, expectedError: tbtcpg.ErrFeeTooHigh, @@ -704,11 +785,10 @@ func TestEstimateMovingFundsFee(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() btcChain.SetEstimateSatPerVByteFee(1, 16) - targetWalletsCount := 4 - - actualFee, err := tbtcpg.EstimateMovingFundsFee( + actualFee, err := tbtcpg.EstimateMovingFundsFeeForScripts( btcChain, - targetWalletsCount, + test.sourceScript, + test.targetScripts, test.txMaxTotalFee, ) @@ -728,6 +808,18 @@ func TestEstimateMovingFundsFee(t *testing.T) { } } +func TestEstimateMovingFundsFee(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + btcChain.SetEstimateSatPerVByteFee(1, 16) + + actualFee, err := tbtcpg.EstimateMovingFundsFee(btcChain, 4, 6000) + if err != nil { + t.Fatal(err) + } + + testutils.AssertUintsEqual(t, "fee", 3248, uint64(actualFee)) +} + type walletInfo struct { publicKeyHash [20]byte state tbtc.WalletState