diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index c2d2533c5c..d5a1dc5c85 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -1820,6 +1820,57 @@ func (tc *TbtcChain) SubmitMovingFundsProofWithReimbursement( return err } +func (tc *TbtcChain) SubmitMovedFundsSweepProofWithReimbursement( + transaction *bitcoin.Transaction, + proof *bitcoin.SpvProof, + mainUTXO bitcoin.UnspentTransactionOutput, +) error { + bitcoinTxInfo := tbtcabi.BitcoinTxInfo3{ + Version: transaction.SerializeVersion(), + InputVector: transaction.SerializeInputs(), + OutputVector: transaction.SerializeOutputs(), + Locktime: transaction.SerializeLocktime(), + } + movedFundsSweepProof := tbtcabi.BitcoinTxProof2{ + MerkleProof: proof.MerkleProof, + TxIndexInBlock: big.NewInt(int64(proof.TxIndexInBlock)), + BitcoinHeaders: proof.BitcoinHeaders, + CoinbasePreimage: proof.CoinbasePreimage, + CoinbaseProof: proof.CoinbaseProof, + } + utxo := tbtcabi.BitcoinTxUTXO2{ + TxHash: mainUTXO.Outpoint.TransactionHash, + TxOutputIndex: mainUTXO.Outpoint.OutputIndex, + TxOutputValue: uint64(mainUTXO.Value), + } + + gasEstimate, err := tc.maintainerProxy.SubmitMovedFundsSweepProofGasEstimate( + bitcoinTxInfo, + movedFundsSweepProof, + utxo, + ) + if err != nil { + return err + } + + // The original estimate for this contract call is too low and the call + // fails on reimbursing the submitter. Example: + // 0xe27a92883e0e64da8a3a54a15a260ea2f4d3d48470129ac5c09bfe9637d7e114 + // Here we add a 20% margin to overcome the gas problems. + gasEstimateWithMargin := float64(gasEstimate) * float64(1.2) + + _, err = tc.maintainerProxy.SubmitMovedFundsSweepProof( + bitcoinTxInfo, + movedFundsSweepProof, + utxo, + ethutil.TransactionOptions{ + GasLimit: uint64(gasEstimateWithMargin), + }, + ) + + return err +} + func (tc *TbtcChain) ValidateMovedFundsSweepProposal( walletPublicKeyHash [20]byte, proposal *tbtc.MovedFundsSweepProposal, diff --git a/pkg/maintainer/spv/chain.go b/pkg/maintainer/spv/chain.go index 949486c16e..1c810bc494 100644 --- a/pkg/maintainer/spv/chain.go +++ b/pkg/maintainer/spv/chain.go @@ -57,6 +57,13 @@ type Chain interface { redeemerOutputScript bitcoin.Script, ) (*tbtc.RedemptionRequest, bool, error) + // GetMovedFundsSweepRequest gets the on-chain moved funds sweep request for + // the given moving funds transaction hash and output index. + GetMovedFundsSweepRequest( + movingFundsTxHash bitcoin.Hash, + movingFundsTxOutpointIndex uint32, + ) (*tbtc.MovedFundsSweepRequest, error) + // SubmitRedemptionProofWithReimbursement submits the redemption proof // via MaintainerProxy. The caller is reimbursed. SubmitRedemptionProofWithReimbursement( @@ -75,6 +82,14 @@ type Chain interface { walletPublicKeyHash [20]byte, ) error + // SubmitMovedFundsSweepProofWithReimbursement submits the moved funds sweep + // proof via MaintainerProxy. The caller is reimbursed. + SubmitMovedFundsSweepProofWithReimbursement( + transaction *bitcoin.Transaction, + proof *bitcoin.SpvProof, + mainUTXO bitcoin.UnspentTransactionOutput, + ) error + // PastDepositRevealedEvents fetches past deposit reveal 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 86044c885b..35eec53b89 100644 --- a/pkg/maintainer/spv/chain_test.go +++ b/pkg/maintainer/spv/chain_test.go @@ -37,6 +37,12 @@ type submittedMovingFundsProof struct { walletPublicKeyHash [20]byte } +type submittedMovedFundsSweepProof struct { + transaction *bitcoin.Transaction + proof *bitcoin.SpvProof + mainUTXO bitcoin.UnspentTransactionOutput +} + type localChain struct { mutex sync.Mutex @@ -44,9 +50,11 @@ type localChain struct { wallets map[[20]byte]*tbtc.WalletChainData depositRequests map[[32]byte]*tbtc.DepositChainRequest pendingRedemptionRequests map[[32]byte]*tbtc.RedemptionRequest + movedFundsSweepRequests map[[32]byte]*tbtc.MovedFundsSweepRequest submittedRedemptionProofs []*submittedRedemptionProof submittedDepositSweepProofs []*submittedDepositSweepProof submittedMovingFundsProofs []*submittedMovingFundsProof + submittedMovedFundsSweepProofs []*submittedMovedFundsSweepProof pastRedemptionRequestedEvents map[[32]byte][]*tbtc.RedemptionRequestedEvent pastDepositRevealedEvents map[[32]byte][]*tbtc.DepositRevealedEvent pastMovingFundsCommitmentSubmittedEvents map[[32]byte][]*tbtc.MovingFundsCommitmentSubmittedEvent @@ -62,6 +70,7 @@ func newLocalChain() *localChain { wallets: make(map[[20]byte]*tbtc.WalletChainData), depositRequests: make(map[[32]byte]*tbtc.DepositChainRequest), pendingRedemptionRequests: make(map[[32]byte]*tbtc.RedemptionRequest), + movedFundsSweepRequests: make(map[[32]byte]*tbtc.MovedFundsSweepRequest), submittedRedemptionProofs: make([]*submittedRedemptionProof, 0), submittedDepositSweepProofs: make([]*submittedDepositSweepProof, 0), submittedMovingFundsProofs: make([]*submittedMovingFundsProof, 0), @@ -304,6 +313,33 @@ func (lc *localChain) getSubmittedMovingFundsProofs() []*submittedMovingFundsPro return lc.submittedMovingFundsProofs } +func (lc *localChain) SubmitMovedFundsSweepProofWithReimbursement( + transaction *bitcoin.Transaction, + proof *bitcoin.SpvProof, + mainUTXO bitcoin.UnspentTransactionOutput, +) error { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.submittedMovedFundsSweepProofs = append( + lc.submittedMovedFundsSweepProofs, + &submittedMovedFundsSweepProof{ + transaction: transaction, + proof: proof, + mainUTXO: mainUTXO, + }, + ) + + return nil +} + +func (lc *localChain) getSubmittedMovedFundsSweepProofs() []*submittedMovedFundsSweepProof { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + return lc.submittedMovedFundsSweepProofs +} + func (lc *localChain) Ready() (bool, error) { panic("unsupported") } @@ -596,6 +632,57 @@ func buildPastMovingFundsCommitmentSubmittedEventsKey( return sha256.Sum256(buffer.Bytes()), nil } +func buildMovedFundsSweepRequestKey( + movingFundsTxHash bitcoin.Hash, + movingFundsTxOutpointIndex uint32, +) [32]byte { + var buffer bytes.Buffer + + buffer.Write(movingFundsTxHash[:]) + + outputIndex := make([]byte, 4) + binary.BigEndian.PutUint32(outputIndex, movingFundsTxOutpointIndex) + buffer.Write(outputIndex) + + return sha256.Sum256(buffer.Bytes()) +} + +func (lc *localChain) setMovedFundsSweepRequest( + movingFundsTxHash bitcoin.Hash, + movingFundsTxOutpointIndex uint32, + request *tbtc.MovedFundsSweepRequest, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + requestKey := buildMovedFundsSweepRequestKey( + movingFundsTxHash, + movingFundsTxOutpointIndex, + ) + + lc.movedFundsSweepRequests[requestKey] = request +} + +func (lc *localChain) GetMovedFundsSweepRequest( + movingFundsTxHash bitcoin.Hash, + movingFundsTxOutpointIndex uint32, +) (*tbtc.MovedFundsSweepRequest, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + requestKey := buildMovedFundsSweepRequestKey( + movingFundsTxHash, + movingFundsTxOutpointIndex, + ) + + request, ok := lc.movedFundsSweepRequests[requestKey] + if !ok { + return nil, fmt.Errorf("request not found") + } + + return request, nil +} + type mockBlockCounter struct { mutex sync.Mutex currentBlock uint64 diff --git a/pkg/maintainer/spv/moved_funds_sweep.go b/pkg/maintainer/spv/moved_funds_sweep.go new file mode 100644 index 0000000000..a58a1a390f --- /dev/null +++ b/pkg/maintainer/spv/moved_funds_sweep.go @@ -0,0 +1,341 @@ +package spv + +import ( + "bytes" + "fmt" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// SubmitMovedFundsSweepProof prepares moved funds sweep proof for the given +// transaction and submits it to the on-chain contract. If the number of +// required confirmations is `0`, an error is returned. +func SubmitMovedFundsSweepProof( + transactionHash bitcoin.Hash, + requiredConfirmations uint, + btcChain bitcoin.Chain, + spvChain Chain, +) error { + return submitMovedFundsSweepProof( + transactionHash, + requiredConfirmations, + btcChain, + spvChain, + bitcoin.AssembleSpvProof, + ) +} + +func submitMovedFundsSweepProof( + transactionHash bitcoin.Hash, + requiredConfirmations uint, + btcChain bitcoin.Chain, + spvChain Chain, + spvProofAssembler spvProofAssembler, +) error { + if requiredConfirmations == 0 { + return fmt.Errorf( + "provided required confirmations count must be greater than 0", + ) + } + + transaction, proof, err := spvProofAssembler( + transactionHash, + requiredConfirmations, + btcChain, + ) + if err != nil { + return fmt.Errorf( + "failed to assemble transaction spv proof: [%v]", + err, + ) + } + + mainUTXO, err := parseMovedFundsSweepTransactionInputs( + btcChain, + transaction, + ) + if err != nil { + return fmt.Errorf( + "error while parsing transaction inputs: [%v]", + err, + ) + } + + if err := spvChain.SubmitMovedFundsSweepProofWithReimbursement( + transaction, + proof, + mainUTXO, + ); err != nil { + return fmt.Errorf( + "failed to submit moved funds sweep proof with reimbursement: [%v]", + err, + ) + } + + return nil +} + +// parseMovedFundsSweepTransactionInputs parses the transaction's inputs and returns +// the wallet's main UTXO. +func parseMovedFundsSweepTransactionInputs( + btcChain bitcoin.Chain, + transaction *bitcoin.Transaction, +) (bitcoin.UnspentTransactionOutput, error) { + // Perform a sanity check: a moved funds sweep transaction must have one or + // two inputs. + if len(transaction.Inputs) != 1 && len(transaction.Inputs) != 2 { + return bitcoin.UnspentTransactionOutput{}, fmt.Errorf( + "moved funds sweep transaction has incorrect number of inputs", + ) + } + + // If the transaction has only one input, it means the wallet does not have + // the main UTXO yet. Return zero-filled value. + if len(transaction.Inputs) == 1 { + return bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{}, + OutputIndex: 0, + }, + Value: 0, + }, nil + } + + // If the transaction has two inputs, the second input is the wallet's main + // UTXO. + input := transaction.Inputs[1] + + // Get data of the input transaction whose output is spent by the moved + // funds sweep transaction. + inputTx, err := btcChain.GetTransaction(input.Outpoint.TransactionHash) + if err != nil { + return bitcoin.UnspentTransactionOutput{}, fmt.Errorf( + "cannot get input transaction data: [%v]", + err, + ) + } + + // Get the specific output spent by the moved funds sweep transaction. + spentOutput := inputTx.Outputs[input.Outpoint.OutputIndex] + + // Build the main UTXO object based on available data. + mainUtxo := bitcoin.UnspentTransactionOutput{ + Outpoint: input.Outpoint, + Value: spentOutput.Value, + } + + return mainUtxo, nil +} + +func getUnprovenMovedFundsSweepTransactions( + historyDepth uint64, + transactionLimit int, + btcChain bitcoin.Chain, + spvChain Chain, +) ( + []*bitcoin.Transaction, + error, +) { + blockCounter, err := spvChain.BlockCounter() + if err != nil { + return nil, fmt.Errorf("failed to get block counter: [%v]", err) + } + + currentBlock, err := blockCounter.CurrentBlock() + if err != nil { + return nil, fmt.Errorf("failed to get current block: [%v]", err) + } + + // Calculate the starting block of the range in which the events will be + // searched for. + startBlock := currentBlock - historyDepth + + events, err := + spvChain.PastMovingFundsCommitmentSubmittedEvents( + &tbtc.MovingFundsCommitmentSubmittedEventFilter{ + StartBlock: startBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to get past moving funds commitment submitted events: [%v]", + err, + ) + } + + // Any wallet that was among target wallets recently could have created + // a moved funds sweep transaction. One target wallet can appear in multiple + // events as multiple source wallets can use the same target wallets. Store + // wallet public key hashes in a map to get rid of duplicates. + walletPublicKeyHashes := make(map[[20]byte]bool) + + for _, event := range events { + for _, targetWallet := range event.TargetWallets { + walletPublicKeyHashes[targetWallet] = true + } + } + + unprovenMovedFundsSweepTransactions := []*bitcoin.Transaction{} + + for walletPublicKeyHash := range walletPublicKeyHashes { + wallet, err := spvChain.GetWallet(walletPublicKeyHash) + if err != nil { + return nil, fmt.Errorf("failed to get wallet: [%v]", err) + } + + if wallet.State != tbtc.StateLive && + wallet.State != tbtc.StateMovingFunds { + // The wallet can only submit moved funds sweep proof if it's `Live` + // or `MovingFunds`. If the state is different skip it. + logger.Infof( + "skipped proving moved funds sweep transactions for wallet [%x] "+ + "because of wallet state [%v]", + walletPublicKeyHash, + wallet.State, + ) + continue + } + + if wallet.PendingMovedFundsSweepRequestsCount == 0 { + // If the wallet does not have any pending moved funds sweep + // requests skip it. + logger.Infof( + "skipped proving moved funds sweep transactions for wallet [%x] "+ + "because it has no pending moved funds sweep requests", + walletPublicKeyHash, + ) + continue + } + + // When wallet makes a moved funds sweep transaction, it transfers + // funds to itself. Therefore we can search all the transactions that + // pay to the wallet's public key hash. + walletTransactions, err := btcChain.GetTransactionsForPublicKeyHash( + walletPublicKeyHash, + transactionLimit, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to get transactions for wallet: [%v]", + err, + ) + } + + for _, transaction := range walletTransactions { + isUnproven, err := + isUnprovenMovedFundsSweepTransaction( + transaction, + walletPublicKeyHash, + btcChain, + spvChain, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to check if transaction is an unproven moved "+ + "funds sweep transaction: [%v]", + err, + ) + } + + if isUnproven { + unprovenMovedFundsSweepTransactions = append( + unprovenMovedFundsSweepTransactions, + transaction, + ) + + // A wallet can have only one unproven moved funds sweep + // transaction at a time. If we found such transaction, we don't + // have to look at this wallet's transactions anymore. + break + } + } + } + + return unprovenMovedFundsSweepTransactions, nil +} + +func isUnprovenMovedFundsSweepTransaction( + transaction *bitcoin.Transaction, + walletPublicKeyHash [20]byte, + btcChain bitcoin.Chain, + spvChain Chain, +) (bool, error) { + // A moved funds sweep transaction must have one or two inputs. + if len(transaction.Inputs) != 1 && len(transaction.Inputs) != 2 { + return false, nil + } + + // A moved funds sweep transaction must have exactly one output. + if len(transaction.Outputs) != 1 { + return false, nil + } + + // The first input must point to a pending moved funds sweep request. + requestTransactionHash := transaction.Inputs[0].Outpoint.TransactionHash + requestOutputIndex := transaction.Inputs[0].Outpoint.OutputIndex + + movedFundsSweepRequest, err := spvChain.GetMovedFundsSweepRequest( + requestTransactionHash, + requestOutputIndex, + ) + if err != nil { + return false, fmt.Errorf( + "failed to get moved funds sweep request: [%v]", + err, + ) + } + + if movedFundsSweepRequest.State != tbtc.MovedFundsStatePending { + return false, nil + } + + // If there is the second input it must refer to the current wallet's main + // UTXO. + if len(transaction.Inputs) == 2 { + fundingTransactionHash := transaction.Inputs[1].Outpoint.TransactionHash + fundingOutpointIndex := transaction.Inputs[1].Outpoint.OutputIndex + + isMainUtxo, err := isInputCurrentWalletsMainUTXO( + fundingTransactionHash, + fundingOutpointIndex, + walletPublicKeyHash, + btcChain, + spvChain, + ) + if err != nil { + return false, fmt.Errorf( + "failed to check if input is the main UTXO: [%v]", + err, + ) + } + + // The input is not the current main UTXO of the wallet. + // The transaction cannot be an unproven moved funds sweep transaction. + if !isMainUtxo { + return false, nil + } + } + + // If the transaction is a moved funds sweep transaction the output must + // transfer funds to the wallet itself. + output := transaction.Outputs[0] + + p2pkh, err := bitcoin.PayToPublicKeyHash(walletPublicKeyHash) + if err != nil { + return false, fmt.Errorf( + "failed to compute p2pkh script for transaction output: [%v]", + err, + ) + } + p2wpkh, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + return false, fmt.Errorf( + "failed to compute p2wpkh script for transaction output: [%v]", + err, + ) + } + + return bytes.Equal(output.PublicKeyScript, p2pkh) || + bytes.Equal(output.PublicKeyScript, p2wpkh), nil +} diff --git a/pkg/maintainer/spv/moved_funds_sweep_test.go b/pkg/maintainer/spv/moved_funds_sweep_test.go new file mode 100644 index 0000000000..a830cedd4c --- /dev/null +++ b/pkg/maintainer/spv/moved_funds_sweep_test.go @@ -0,0 +1,385 @@ +package spv + +import ( + "encoding/hex" + "fmt" + "sort" + "testing" + + "github.com/go-test/deep" + + "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +func TestSubmitMovedFundsSweepProof(t *testing.T) { + bytesFromHex := func(str string) []byte { + value, err := hex.DecodeString(str) + if err != nil { + t.Fatal(err) + } + + return value + } + + txFromHex := func(str string) *bitcoin.Transaction { + transaction := new(bitcoin.Transaction) + err := transaction.Deserialize(bytesFromHex(str)) + if err != nil { + t.Fatal(err) + } + + return transaction + } + + txHashFromHex := func(str string) bitcoin.Hash { + hash, err := bitcoin.NewHashFromString(str, bitcoin.ReversedByteOrder) + if err != nil { + t.Fatal(err) + } + + return hash + } + + requiredConfirmations := uint(6) + + var tests = map[string]struct { + movedFundsSweepTx *bitcoin.Transaction + inputTxs []*bitcoin.Transaction + expectedMainUtxo *bitcoin.UnspentTransactionOutput + }{ + "wallet has no main UTXO": { + // Transaction for a wallet no main UTXO: https://live.blockcypher.com/btc-testnet/tx/a586427d66f8ccca1ed8f7e40a2c82aae99a1f85dfce62ffe2f3657350b6fd84/ + movedFundsSweepTx: txFromHex("010000000001019b1b33bdd3c44404544991889d63afe6caa875983b705106f1d988251d1459200000000000ffffffff011c700000000000001600148db50eb52063ea9d98b3eac91489a90f738986f6024730440220242dbac95ab8e632cd2791e99d3048b96e6e042bcd902f30fbae7e942a24ea3e02201b7416e6d7d36ea142521eb80e0bc29d118f62ab6b8a64a062cc5812cfdb8c89012103989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d900000000"), + // No need for additional transaction data. + inputTxs: []*bitcoin.Transaction{}, + // Zero-filled main UTXO. + expectedMainUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{}, + OutputIndex: 0, + }, + Value: 0, + }, + }, + "wallet has main UTXO": { + // Transaction for a wallet with main UTXO: https://live.blockcypher.com/btc-testnet/tx/fc78f52ab4094b5c0bf8a782750c24f31b5db2667425fbddccc29d64f89baf9b/ + movedFundsSweepTx: txFromHex("0100000000010218201d563e43a926f5f9fd4498af5c513a3ea284373308aeda39b0a0d57585780000000000ffffffff84fdb6507365f3e2ff62cedf851f9ae9aa822c0ae4f7d81ecaccf8667d4286a50000000000ffffffff0104a60000000000001600148db50eb52063ea9d98b3eac91489a90f738986f60248304502210089dfa958867b2265d0fc08d996af82a9a731bd972f20e0530d37937f38d9ec1002200cbc820a696b99747aed39aeed5d848367773cf6d4e24aaa12fe2ad714a1ff99012103989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d902473044022063dc201589b1f7810247eaa569baf5e3dda8717a10e77a2ad95661fef643bdc602203bee4dd0c4a24291523bb7542394df4e6008c0c7ddfdd30c41d55036b32a8999012103989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d900000000"), + // Transaction that created the main UTXO: https://live.blockcypher.com/btc-testnet/tx/a586427d66f8ccca1ed8f7e40a2c82aae99a1f85dfce62ffe2f3657350b6fd84/ + inputTxs: []*bitcoin.Transaction{ + txFromHex("010000000001019b1b33bdd3c44404544991889d63afe6caa875983b705106f1d988251d1459200000000000ffffffff011c700000000000001600148db50eb52063ea9d98b3eac91489a90f738986f6024730440220242dbac95ab8e632cd2791e99d3048b96e6e042bcd902f30fbae7e942a24ea3e02201b7416e6d7d36ea142521eb80e0bc29d118f62ab6b8a64a062cc5812cfdb8c89012103989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d900000000"), + }, + // Wallet's main UTXO. + expectedMainUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: txHashFromHex("a586427d66f8ccca1ed8f7e40a2c82aae99a1f85dfce62ffe2f3657350b6fd84"), + OutputIndex: 0, + }, + Value: 28700, + }, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + btcChain := newLocalBitcoinChain() + spvChain := newLocalChain() + + for _, inputTransaction := range test.inputTxs { + err := btcChain.BroadcastTransaction(inputTransaction) + if err != nil { + t.Fatal(err) + } + } + + // Just a mock proof. + proof := &bitcoin.SpvProof{ + MerkleProof: []byte{0x01}, + TxIndexInBlock: 2, + BitcoinHeaders: []byte{0x03}, + } + + mockSpvProofAssembler := func( + hash bitcoin.Hash, + confirmations uint, + btcChain bitcoin.Chain, + ) (*bitcoin.Transaction, *bitcoin.SpvProof, error) { + if hash == test.movedFundsSweepTx.Hash() && confirmations == requiredConfirmations { + return test.movedFundsSweepTx, proof, nil + } + + return nil, nil, fmt.Errorf("error while assembling spv proof") + } + + err := submitMovedFundsSweepProof( + test.movedFundsSweepTx.Hash(), + requiredConfirmations, + btcChain, + spvChain, + mockSpvProofAssembler, + ) + if err != nil { + t.Fatal(err) + } + + submittedProofs := spvChain.getSubmittedMovedFundsSweepProofs() + + testutils.AssertIntsEqual(t, "proofs count", 1, len(submittedProofs)) + + submittedProof := submittedProofs[0] + + expectedTransactionHash := test.movedFundsSweepTx.Hash() + actualTransactionHash := submittedProof.transaction.Hash() + testutils.AssertBytesEqual(t, expectedTransactionHash[:], actualTransactionHash[:]) + + if diff := deep.Equal(proof, submittedProof.proof); diff != nil { + t.Errorf("invalid proof: %v", diff) + } + + if diff := deep.Equal(*test.expectedMainUtxo, submittedProof.mainUTXO); diff != nil { + t.Errorf("invalid main UTXO: %v", diff) + } + }) + } +} + +func TestGetUnprovenMovedFundsSweepTransactions(t *testing.T) { + bytesFromHex := func(str string) []byte { + value, err := hex.DecodeString(str) + if err != nil { + t.Fatal(err) + } + + return value + } + + bytes20FromHex := func(str string) [20]byte { + var value [20]byte + copy(value[:], bytesFromHex(str)) + return value + } + + txFromHex := func(str string) *bitcoin.Transaction { + transaction := new(bitcoin.Transaction) + err := transaction.Deserialize(bytesFromHex(str)) + if err != nil { + t.Fatal(err) + } + + return transaction + } + + hashFromString := func(str string) bitcoin.Hash { + hash, err := bitcoin.NewHashFromString( + str, + bitcoin.ReversedByteOrder, + ) + if err != nil { + t.Fatal(err) + } + + return hash + } + + // Set an arbitrary history depth and transaction limit. + historyDepth := uint64(5) + transactionLimit := 10 + + btcChain := newLocalBitcoinChain() + spvChain := newLocalChain() + + // Set a predictable current block. + currentBlock := uint64(1000) + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + spvChain.setBlockCounter(blockCounter) + + mainUtxoHash := func(hashHex string, outputIndex uint32, value int64) [32]byte { + hash, err := bitcoin.NewHashFromString(hashHex, bitcoin.ReversedByteOrder) + if err != nil { + t.Fatal(err) + } + + return spvChain.ComputeMainUtxoHash( + &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: hash, + OutputIndex: outputIndex, + }, + Value: value, + }, + ) + } + + setMovedFundsSweepRequests := func(requests []struct { + hash string + index uint32 + state tbtc.MovedFundsSweepRequestState + }) { + for _, request := range requests { + spvChain.setMovedFundsSweepRequest( + hashFromString(request.hash), + request.index, + &tbtc.MovedFundsSweepRequest{ + State: request.state, + }, + ) + } + } + + // Define wallets being actors of this scenario: + wallets := []struct { + walletPublicKeyHash [20]byte + data *tbtc.WalletChainData + transactions []*bitcoin.Transaction + }{ + { + // Wallet 1: Random wallet that was listed as a target wallet, but + // hasn't performed any moved funds sweep transactions. + walletPublicKeyHash: bytes20FromHex("3091d288521caec06ea912eacfd733edc5a36d6e"), + data: &tbtc.WalletChainData{ + State: tbtc.StateLive, + }, + }, + + { + // Wallet 2: https://live.blockcypher.com/btc-testnet/address/tb1q3k6sadfqv04fmx9naty3fzdfpaecnphkfm3cf3/ + walletPublicKeyHash: bytes20FromHex("8db50eb52063ea9d98b3eac91489a90f738986f6"), + data: &tbtc.WalletChainData{ + // Simulate the wallet has no main UTXO. + MainUtxoHash: [32]byte{}, + State: tbtc.StateLive, + PendingMovedFundsSweepRequestsCount: 1, + }, + transactions: []*bitcoin.Transaction{ + // Transaction 1: Moved funds sweep transaction: https://live.blockcypher.com/btc-testnet/tx/a586427d66f8ccca1ed8f7e40a2c82aae99a1f85dfce62ffe2f3657350b6fd84/ + txFromHex("010000000001019b1b33bdd3c44404544991889d63afe6caa875983b705106f1d988251d1459200000000000ffffffff011c700000000000001600148db50eb52063ea9d98b3eac91489a90f738986f6024730440220242dbac95ab8e632cd2791e99d3048b96e6e042bcd902f30fbae7e942a24ea3e02201b7416e6d7d36ea142521eb80e0bc29d118f62ab6b8a64a062cc5812cfdb8c89012103989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d900000000"), + }, + }, + { + // Wallet 3: https://live.blockcypher.com/btc-testnet/address/tb1q0tpdjdu2r3r7tzwlhqy4e2276g2q6fexsz4j0m/ + walletPublicKeyHash: bytes20FromHex("7ac2d9378a1c47e589dfb8095ca95ed2140d2726"), + data: &tbtc.WalletChainData{ + // Make the main UTXO point to Transaction 1. + MainUtxoHash: mainUtxoHash( + "28f5aad58758acc861893a24edf3a339f8257fcde502a4b8add605e74a7d5f7d", + 0, + 873510, + ), + State: tbtc.StateMovingFunds, + PendingMovedFundsSweepRequestsCount: 1, + }, + transactions: []*bitcoin.Transaction{ + // Transaction 1: Moved funds sweep transaction: https://live.blockcypher.com/btc-testnet/tx/f97ed3704f59bf5ed828d90f04598ea6c1c65a7957befa1f1c175a142c17fff9/ + txFromHex("01000000027d5f7d4ae705d6adb8a402e5cd7f25f839a3f3ed243a8961c8ac5887d5aaf528010000006b483045022100ff95e465ae7f632026e30dfe6c53df8f445066d735f60e3ec411fc1f753aa8860220740aa810b18d4ae90653db147b35c83827b942177d74a418aa6d48d387550725012102ee067a0273f2e3ba88d23140a24fdb290f27bbcd0f94117a9c65be3911c5c04effffffff7d5f7d4ae705d6adb8a402e5cd7f25f839a3f3ed243a8961c8ac5887d5aaf528000000006a473044022058901f5a01c214c3d8ddb2246876a6f96646826a87a9669eacd0d36bac73225202206c19cc3fc2e899b36d2e8f2e6e6bdaa135e051a14b98990184b9cbcd5a4a1ab8012102ee067a0273f2e3ba88d23140a24fdb290f27bbcd0f94117a9c65be3911c5c04effffffff0132dd2700000000001976a9147ac2d9378a1c47e589dfb8095ca95ed2140d272688ac00000000"), + + // Transaction 2: Transaction that created the main UTXO: https://live.blockcypher.com/btc-testnet/tx/28f5aad58758acc861893a24edf3a339f8257fcde502a4b8add605e74a7d5f7d/ + txFromHex("01000000000101d914a2171f2fb236e85abca59da852e06747df559b7c66e3dbff842642e55c4b0100000000ffffffff0226540d00000000001976a9147ac2d9378a1c47e589dfb8095ca95ed2140d272688ac4ca81a00000000001976a9147ac2d9378a1c47e589dfb8095ca95ed2140d272688ac024730440220529e25602583815c9ec4d0567d30f917323e26dbcc53a60d1883235a357d56d602204fd018078511e6de40b906874cf4531ee4edc652a6948667ba228b6034d9541a012102ee067a0273f2e3ba88d23140a24fdb290f27bbcd0f94117a9c65be3911c5c04e00000000"), + }, + }, + } + + // Record wallet data on both chains. + for _, wallet := range wallets { + spvChain.setWallet(wallet.walletPublicKeyHash, wallet.data) + + for _, transaction := range wallet.transactions { + err := btcChain.BroadcastTransaction(transaction) + if err != nil { + t.Fatal(err) + } + } + } + + setMovedFundsSweepRequests( + []struct { + hash string + index uint32 + state tbtc.MovedFundsSweepRequestState + }{ + // Moved funds sweep request from Wallet 2. + { + "2059141d2588d9f10651703b9875a8cae6af639d889149540444c4d3bd331b9b", + 0, + tbtc.MovedFundsStatePending, + }, + // Wallet main UTXO transaction from Wallet 3. + { + "28f5aad58758acc861893a24edf3a339f8257fcde502a4b8add605e74a7d5f7d", + 0, + tbtc.MovedFundsStateUnknown, + }, + // Moved funds sweep request from Wallet 3. + { + "28f5aad58758acc861893a24edf3a339f8257fcde502a4b8add605e74a7d5f7d", + 1, + tbtc.MovedFundsStatePending, + }, + }, + ) + + // Add moving funds commitment submitted events for the wallets. + // The block number field is just to make them distinguishable while reading. + events := []*tbtc.MovingFundsCommitmentSubmittedEvent{ + { + WalletPublicKeyHash: bytes20FromHex("92a6ec889a8fa34f731e639edede4c75e184307c"), + TargetWallets: [][20]byte{ + wallets[0].walletPublicKeyHash, + wallets[1].walletPublicKeyHash, + }, + BlockNumber: 100, + }, + { + WalletPublicKeyHash: bytes20FromHex("c7302d75072d78be94eb8d36c4b77583c7abb06e"), + TargetWallets: [][20]byte{ + wallets[0].walletPublicKeyHash, + wallets[1].walletPublicKeyHash, + wallets[2].walletPublicKeyHash, + }, + BlockNumber: 200, + }, + } + + for _, event := range events { + err := spvChain.addPastMovingFundsCommitmentSubmittedEvent( + &tbtc.MovingFundsCommitmentSubmittedEventFilter{ + StartBlock: currentBlock - historyDepth, + }, + event, + ) + if err != nil { + t.Fatal(err) + } + } + + transactions, err := getUnprovenMovedFundsSweepTransactions( + historyDepth, + transactionLimit, + btcChain, + spvChain, + ) + if err != nil { + t.Fatal(err) + } + + transactionsHashes := make([]bitcoin.Hash, len(transactions)) + for i, transaction := range transactions { + transactionsHashes[i] = transaction.Hash() + } + + expectedTransactionsHashes := []bitcoin.Hash{ + wallets[1].transactions[0].Hash(), // Wallet 2 - Transaction 1 + wallets[2].transactions[0].Hash(), // Wallet 3 - Transaction 1 + } + + // The order of returned transaction hashes is random. Sort them before + // comparing. + sort.Slice(transactionsHashes, func(i, j int) bool { + return transactionsHashes[i].String() < transactionsHashes[j].String() + }) + + if diff := deep.Equal(expectedTransactionsHashes, transactionsHashes); diff != nil { + t.Errorf("invalid unproven transaction hashes: %v", diff) + } +} diff --git a/pkg/maintainer/spv/moving_funds_test.go b/pkg/maintainer/spv/moving_funds_test.go index 639b0ddc5b..c5f1332b95 100644 --- a/pkg/maintainer/spv/moving_funds_test.go +++ b/pkg/maintainer/spv/moving_funds_test.go @@ -38,7 +38,7 @@ func TestSubmitMovingFundsProof(t *testing.T) { spvChain := newLocalChain() // Take an arbitrary moving funds transaction: - // https://live.blockcypher.com/btc-testnet/tx/e6218018ed1874e73b78e16a8cf4f5016cbc666a3f9179557a84083e3e66ff7c + // https://live.blockcypher.com/btc-testnet/tx/e6218018ed1874e73b78e16a8cf4f5016cbc666a3f9179557a84083e3e66ff7c/ movingFundsTransaction := txFromHex("0100000000010180653f6e07dabddae14cf08d45475388343763100e4548914d811f373465a42e0100000000ffffffff031c160900000000001976a9142cd680318747b720d67bf4246eb7403b476adb3488ac1d160900000000001600148900de8fc6e4cd1db4c7ab0759d28503b4cb0ab11c160900000000001976a914af7a841e055fc19bf31acf4cbed5ef548a2cc45388ac0247304402202d615c196548b6cb4f1cd1f44b559cd348ce2cb8bd90356be9883a7460d7c8aa0220675e7b67e4d96a6180f7adb5ecb9ab962275d39742009911980e19e734523ff4012102ee067a0273f2e3ba88d23140a24fdb290f27bbcd0f94117a9c65be3911c5c04e00000000") // Take the transaction that is the moving funds transaction input. It is // necessary as the tested function logic fetches its data to determine @@ -172,7 +172,7 @@ func TestGetUnprovenMovingFundsTransactions(t *testing.T) { transactions []*bitcoin.Transaction }{ { - // Wallet 1: https://live.blockcypher.com/btc-testnet/address/tb1q3k6sadfqv04fmx9naty3fzdfpaecnphkfm3cf3 + // Wallet 1: https://live.blockcypher.com/btc-testnet/address/tb1q3k6sadfqv04fmx9naty3fzdfpaecnphkfm3cf3/ walletPublicKeyHash: bytes20FromHex("8db50eb52063ea9d98b3eac91489a90f738986f6"), data: &tbtc.WalletChainData{ // Make the main UTXO point to Transaction 1. @@ -214,7 +214,7 @@ func TestGetUnprovenMovingFundsTransactions(t *testing.T) { // Transaction 1: Creation of wallet's main UTXO https://live.blockcypher.com/btc-testnet/tx/89c1e51322878df5417652643a2cbc4bcc3b2ecaff371c3e03b7b9b285d5e3f8/ txFromHex("02000000014ff17f9f98c5f9c516a94b1c08eabd0ad04fd04e1e3b9485493592e7fc76a7ab000000006a47304402202d3356f81c1d488ec7a9c2917bdb9c6060b9307eca2dcf5e876792e2ba6db85c022016dbb59e1104ef311c20235c1c1894be94ef0c317c4f3b0d13d31c67c592001301210291936829fd41e5217272a8141313ceb754d65787dc07ccb9a9e9a384ef243645feffffff025108ee16020000001600141ea512aa81a96d5ffa2f0d3e37803d9912c89e7e5a7a1600000000001600147ac2d9378a1c47e589dfb8095ca95ed2140d2726e2792100"), - // Transaction 2: MovingFunds: https://live.blockcypher.com/btc-testnet/tx/d078c00d7e78509062fccdecaf85580efe6e2826d8db77341fbc1097ca2955e5 + // Transaction 2: MovingFunds: https://live.blockcypher.com/btc-testnet/tx/d078c00d7e78509062fccdecaf85580efe6e2826d8db77341fbc1097ca2955e5/ txFromHex("01000000000101f8e3d585b2b9b7033e1c37ffca2e3bcc4bbc2c3a64527641f58d872213e5c1890100000000ffffffff0132571600000000001976a9142cd680318747b720d67bf4246eb7403b476adb3488ac024830450221008b6b3fa3eaf4b46268c3bfb718cf8391afc7879ec1949e465c04fa206235a3f202205f9d00ebba7cdb29414b0dad752cf489710ab60dc06c7b20a99c0d9d3fce8c3c012102ee067a0273f2e3ba88d23140a24fdb290f27bbcd0f94117a9c65be3911c5c04e00000000"), }, }, diff --git a/pkg/maintainer/spv/spv.go b/pkg/maintainer/spv/spv.go index 0ebc089822..ef1bef69e2 100644 --- a/pkg/maintainer/spv/spv.go +++ b/pkg/maintainer/spv/spv.go @@ -56,6 +56,10 @@ var proofTypes = map[tbtc.WalletActionType]struct { unprovenTransactionsGetter: getUnprovenMovingFundsTransactions, transactionProofSubmitter: SubmitMovingFundsProof, }, + tbtc.ActionMovedFundsSweep: { + unprovenTransactionsGetter: getUnprovenMovedFundsSweepTransactions, + transactionProofSubmitter: SubmitMovedFundsSweepProof, + }, } type spvMaintainer struct { diff --git a/pkg/tbtcpg/chain.go b/pkg/tbtcpg/chain.go index e4f189b2a9..c4b29d4a1d 100644 --- a/pkg/tbtcpg/chain.go +++ b/pkg/tbtcpg/chain.go @@ -148,6 +148,8 @@ type Chain interface { err error, ) + // GetMovedFundsSweepRequest gets the on-chain moved funds sweep request for + // the given moving funds transaction hash and output index. GetMovedFundsSweepRequest( movingFundsTxHash bitcoin.Hash, movingFundsTxOutpointIndex uint32,