diff --git a/pkg/maintainer/spv/chain_test.go b/pkg/maintainer/spv/chain_test.go index 7c099fd2b7..4722c2f800 100644 --- a/pkg/maintainer/spv/chain_test.go +++ b/pkg/maintainer/spv/chain_test.go @@ -44,6 +44,7 @@ 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 @@ -62,6 +63,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), @@ -604,11 +606,55 @@ 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) { - panic("unsupported") + 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 { 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..7d703ad101 --- /dev/null +++ b/pkg/maintainer/spv/moved_funds_sweep_test.go @@ -0,0 +1,258 @@ +package spv + +import ( + "encoding/hex" + "testing" + + "github.com/go-test/deep" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +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{ + // Make the main UTXO point to Transaction 1. + MainUtxoHash: mainUtxoHash( + "a586427d66f8ccca1ed8f7e40a2c82aae99a1f85dfce62ffe2f3657350b6fd84", + 0, + 28700, + ), + State: tbtc.StateLive, + PendingMovedFundsSweepRequestsCount: 1, + }, + transactions: []*bitcoin.Transaction{ + // Transaction 1: Moved funds sweep transaction: https://live.blockcypher.com/btc-testnet/tx/fc78f52ab4094b5c0bf8a782750c24f31b5db2667425fbddccc29d64f89baf9b/ + txFromHex("0100000000010218201d563e43a926f5f9fd4498af5c513a3ea284373308aeda39b0a0d57585780000000000ffffffff84fdb6507365f3e2ff62cedf851f9ae9aa822c0ae4f7d81ecaccf8667d4286a50000000000ffffffff0104a60000000000001600148db50eb52063ea9d98b3eac91489a90f738986f60248304502210089dfa958867b2265d0fc08d996af82a9a731bd972f20e0530d37937f38d9ec1002200cbc820a696b99747aed39aeed5d848367773cf6d4e24aaa12fe2ad714a1ff99012103989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d902473044022063dc201589b1f7810247eaa569baf5e3dda8717a10e77a2ad95661fef643bdc602203bee4dd0c4a24291523bb7542394df4e6008c0c7ddfdd30c41d55036b32a8999012103989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d900000000"), + + // Transaction 2: Transaction that created the main UTXO: 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. + { + "788575d5a0b039daae08333784a23e3a515caf9844fdf9f526a9433e561d2018", + 0, + tbtc.MovedFundsStatePending, + }, + // Wallet main UTXO transaction from Wallet 2. + { + "2059141d2588d9f10651703b9875a8cae6af639d889149540444c4d3bd331b9b", + 0, + tbtc.MovedFundsStateUnknown, + }, + // 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 + } + + if diff := deep.Equal(expectedTransactionsHashes, transactionsHashes); diff != nil { + t.Errorf("invalid unproven transaction hashes: %v", diff) + } +}