diff --git a/changelog.md b/changelog.md index dc83cb839b..78b9ab0b59 100644 --- a/changelog.md +++ b/changelog.md @@ -16,6 +16,7 @@ * [2911](https://github.com/zeta-chain/node/pull/2911) - add chain static information for btc testnet4 * [2904](https://github.com/zeta-chain/node/pull/2904) - integrate authenticated calls smart contract functionality into protocol * [2919](https://github.com/zeta-chain/node/pull/2919) - add inbound sender to revert context +* [2957](https://github.com/zeta-chain/node/pull/2957) - enable Bitcoin inscription support on testnet ### Refactor diff --git a/contrib/localnet/bitcoin-sidecar/Dockerfile b/contrib/localnet/bitcoin-sidecar/Dockerfile new file mode 100644 index 0000000000..aef54cf56d --- /dev/null +++ b/contrib/localnet/bitcoin-sidecar/Dockerfile @@ -0,0 +1,14 @@ +FROM node:18.20.4 as builder + +WORKDIR /home/zeta/node + +COPY bitcoin-sidecar/js/* . + +RUN npm install && npm install typescript -g && tsc + +FROM node:alpine + +COPY --from=builder /home/zeta/node/dist ./dist +COPY --from=builder /home/zeta/node/node_modules ./node_modules + +CMD ["node", "dist/index.js"] \ No newline at end of file diff --git a/contrib/localnet/bitcoin-sidecar/js/package.json b/contrib/localnet/bitcoin-sidecar/js/package.json new file mode 100644 index 0000000000..1a4dd4b90a --- /dev/null +++ b/contrib/localnet/bitcoin-sidecar/js/package.json @@ -0,0 +1,23 @@ +{ + "name": "zeta-btc-client", + "version": "0.0.1", + "description": "The Zetachain BTC client", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "bip32": "^4.0.0", + "bitcoinjs-lib": "^6.1.6", + "ecpair": "^2.1.0", + "express": "^4.19.2", + "randombytes": "^2.1.0", + "tiny-secp256k1": "^2.2.3" + }, + "devDependencies": { + "@types/node": "^20.14.11", + "typescript": "^5.5.3" + } +} \ No newline at end of file diff --git a/contrib/localnet/bitcoin-sidecar/js/src/client.ts b/contrib/localnet/bitcoin-sidecar/js/src/client.ts new file mode 100644 index 0000000000..678b90f6da --- /dev/null +++ b/contrib/localnet/bitcoin-sidecar/js/src/client.ts @@ -0,0 +1,183 @@ +import { initEccLib, payments, Psbt } from "bitcoinjs-lib"; +import { bitcoin, Network, regtest } from "bitcoinjs-lib/src/networks"; +import BIP32Factory, { BIP32Interface } from 'bip32'; +import * as ecc from 'tiny-secp256k1'; +import randomBytes from "randombytes"; +import { ScriptBuilder } from "./script"; +import { Taptree } from "bitcoinjs-lib/src/types"; +import { toXOnly } from "./util"; + +const LEAF_VERSION_TAPSCRIPT = 0xc0; + +initEccLib(ecc); +const bip32 = BIP32Factory(ecc); +const rng = randomBytes; + +/// The evm address type, a 20 bytes hex string +export type Address = String; +export type BtcAddress = String; + +/// The BTC transaction hash returned +export type BtcTxnHash = String; +export interface BtcInput { + txn: BtcTxnHash, + idx: number, +} + +/** + * The example client for interacting with ZetaChain in BTC. There are currently two ways + * of calling a smart contract on ZetaChain from BTC: + * + * - Using OP_RETURN + * - Using Witness + * + * The method used is now based on the data size. Within 80 bytes, `OP_RETURN` is used, else + * the data is written to Witness. + * + * This class handles only the case where data is more than 80 bytes. + */ +export class ZetaBtcClient { + /** The BTC network interracting with */ + readonly network: Network; + + private reveal: RevealTxnBuilder | null; + + private constructor(network: Network) { + this.network = network; + } + + public static regtest(): ZetaBtcClient { + return new ZetaBtcClient(regtest); + } + + public static mainnet(): ZetaBtcClient { + return new ZetaBtcClient(bitcoin); + } + + /** + * Call a target address and passing the data call. + * + * @param address The target zetachain evm address + * @param calldata The calldata that will be invoked on Zetachain + */ + public call( + address: Address, + calldata: Buffer, + ): Address { + if (calldata.length <= 80) { + throw Error("Use op return instead"); + } + + if (address.startsWith("0x")) { + address = address.substring(2); + } + + return this.callWithWitness(Buffer.concat([Buffer.from(address, "hex"), calldata])); + } + + private callWithWitness( + data: Buffer, + ): Address { + const internalKey = bip32.fromSeed(rng(64), this.network); + + const leafScript = this.genLeafScript(internalKey.publicKey, data); + + const scriptTree: Taptree = { output: leafScript }; + + const { address: commitAddress } = payments.p2tr({ + internalPubkey: toXOnly(internalKey.publicKey), + scriptTree, + network: this.network, + }); + + this.reveal = new RevealTxnBuilder(internalKey, leafScript, this.network); + + return commitAddress; + } + + public buildRevealTxn(to: string, commitTxn: BtcInput, commitAmount: number, feeRate: number): Buffer { + if (this.reveal === null) { + throw new Error("commit txn not built yet"); + } + + this.reveal.with_commit_tx(to, commitTxn, commitAmount, feeRate); + return this.reveal.dump(); + } + + private genLeafScript(publicKey: Buffer, data: Buffer,): Buffer { + const builder = ScriptBuilder.new(publicKey); + builder.pushData(data); + return builder.build(); + } +} + +class RevealTxnBuilder { + private psbt: Psbt; + private key: BIP32Interface; + private leafScript: Buffer; + private network: Network + + constructor(key: BIP32Interface, leafScript: Buffer, network: Network) { + this.psbt = new Psbt({ network });; + this.key = key; + this.leafScript = leafScript; + this.network = network; + } + + public with_commit_tx(to: string, commitTxn: BtcInput, commitAmount: number, feeRate: number): RevealTxnBuilder { + const scriptTree: Taptree = { output: this.leafScript }; + + const { output, witness } = payments.p2tr({ + internalPubkey: toXOnly(this.key.publicKey), + scriptTree, + redeem: { + output: this.leafScript, + redeemVersion: LEAF_VERSION_TAPSCRIPT, + }, + network: this.network, + }); + + this.psbt.addInput({ + hash: commitTxn.txn.toString(), + index: commitTxn.idx, + witnessUtxo: { value: commitAmount, script: output! }, + tapLeafScript: [ + { + leafVersion: LEAF_VERSION_TAPSCRIPT, + script: this.leafScript, + controlBlock: witness![witness!.length - 1], + }, + ], + }); + + this.psbt.addOutput({ + value: commitAmount - this.estimateFee(to, commitAmount, feeRate), + address: to, + }); + + this.psbt.signAllInputs(this.key); + this.psbt.finalizeAllInputs(); + + return this; + } + + public dump(): Buffer { + return this.psbt.extractTransaction(true).toBuffer(); + } + + private estimateFee(to: string, amount: number, feeRate: number): number { + const cloned = this.psbt.clone(); + + cloned.addOutput({ + value: amount, + address: to, + }); + + // should have a way to avoid signing but just providing mocked signautre + cloned.signAllInputs(this.key); + cloned.finalizeAllInputs(); + + const size = cloned.extractTransaction().virtualSize(); + return size * feeRate; + } +} \ No newline at end of file diff --git a/contrib/localnet/bitcoin-sidecar/js/src/index.ts b/contrib/localnet/bitcoin-sidecar/js/src/index.ts new file mode 100644 index 0000000000..5164a6f148 --- /dev/null +++ b/contrib/localnet/bitcoin-sidecar/js/src/index.ts @@ -0,0 +1,38 @@ +import { ZetaBtcClient } from "./client"; +import express, { Request, Response } from 'express'; + +const app = express(); +const PORT = process.env.PORT || 3000; +let zetaClient = ZetaBtcClient.regtest(); + +app.use(express.json()); + +// Middleware to parse URL-encoded bodies +app.use(express.urlencoded({ extended: true })); + +// Route to handle JSON POST requests +app.post('/commit', (req: Request, res: Response) => { + const memo: string = req.body.memo; + const address = zetaClient.call("", Buffer.from(memo, "hex")); + res.json({ address }); +}); + +// Route to handle URL-encoded POST requests +app.post('/reveal', (req: Request, res: Response) => { + const { txn, idx, amount, feeRate, to } = req.body; + console.log(txn, idx, amount, feeRate); + + const rawHex = zetaClient.buildRevealTxn(to,{ txn, idx }, Number(amount), feeRate).toString("hex"); + zetaClient = ZetaBtcClient.regtest(); + res.json({ rawHex }); +}); + +// Start the server +app.listen(PORT, () => { + console.log(`Server is running on http://localhost:${PORT}`); +}); + +/** + * curl --request POST --header "Content-Type: application/json" --data '{"memo":"72f080c854647755d0d9e6f6821f6931f855b9acffd53d87433395672756d58822fd143360762109ab898626556b1c3b8d3096d2361f1297df4a41c1b429471a9aa2fc9be5f27c13b3863d6ac269e4b587d8389f8fd9649859935b0d48dea88cdb40f20c"}' http://localhost:3000/commit + * curl --request POST --header "Content-Type: application/json" --data '{"txn": "7a57f987a3cb605896a5909d9ef2bf7afbf0c78f21e4118b85d00d9e4cce0c2c", "idx": 0, "amount": 1000, "feeRate": 10}' http://localhost:3000/reveal + */ \ No newline at end of file diff --git a/contrib/localnet/bitcoin-sidecar/js/src/script.ts b/contrib/localnet/bitcoin-sidecar/js/src/script.ts new file mode 100644 index 0000000000..f282e39f01 --- /dev/null +++ b/contrib/localnet/bitcoin-sidecar/js/src/script.ts @@ -0,0 +1,52 @@ +import { opcodes, script, Stack } from "bitcoinjs-lib"; +import { toXOnly } from "./util"; + +const MAX_SCRIPT_ELEMENT_SIZE = 520; + +/** The tapscript builder for zetaclient spending script */ +export class ScriptBuilder { + private script: Stack; + + private constructor(initialScript: Stack) { + this.script = initialScript; + } + + public static new(publicKey: Buffer): ScriptBuilder { + const stack = [ + toXOnly(publicKey), + opcodes.OP_CHECKSIG, + ]; + return new ScriptBuilder(stack); + } + + public pushData(data: Buffer) { + if (data.length <= 80) { + throw new Error("data length should be more than 80 bytes"); + } + + this.script.push( + opcodes.OP_FALSE, + opcodes.OP_IF + ); + + const chunks = chunkBuffer(data, MAX_SCRIPT_ELEMENT_SIZE); + for (const chunk of chunks) { + this.script.push(chunk); + } + + this.script.push(opcodes.OP_ENDIF); + } + + public build(): Buffer { + return script.compile(this.script); + } +} + +function chunkBuffer(buffer: Buffer, chunkSize: number): Buffer[] { + const chunks = []; + for (let i = 0; i < buffer.length; i += chunkSize) { + const chunk = buffer.slice(i, i + chunkSize); + chunks.push(chunk); + } + return chunks; +} \ No newline at end of file diff --git a/contrib/localnet/bitcoin-sidecar/js/src/tsconfig.json b/contrib/localnet/bitcoin-sidecar/js/src/tsconfig.json new file mode 100644 index 0000000000..4033670b3d --- /dev/null +++ b/contrib/localnet/bitcoin-sidecar/js/src/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "commonjs", + "esModuleInterop": true, + "target": "es6", + "moduleResolution": "node", + "sourceMap": true, + "outDir": "dist" + }, + "lib": ["es2015"] +} \ No newline at end of file diff --git a/contrib/localnet/bitcoin-sidecar/js/src/util.ts b/contrib/localnet/bitcoin-sidecar/js/src/util.ts new file mode 100644 index 0000000000..87c4d36d0f --- /dev/null +++ b/contrib/localnet/bitcoin-sidecar/js/src/util.ts @@ -0,0 +1 @@ +export const toXOnly = pubKey => (pubKey.length === 32 ? pubKey : pubKey.slice(1, 33)); \ No newline at end of file diff --git a/contrib/localnet/docker-compose.yml b/contrib/localnet/docker-compose.yml index 2c2efbd87f..7044390647 100644 --- a/contrib/localnet/docker-compose.yml +++ b/contrib/localnet/docker-compose.yml @@ -227,6 +227,19 @@ services: -rpcauth=smoketest:63acf9b8dccecce914d85ff8c044b78b$$5892f9bbc84f4364e79f0970039f88bdd823f168d4acc76099ab97b14a766a99 -txindex=1 + bitcoin-node-sidecar: + build: + dockerfile: ./bitcoin-sidecar/Dockerfile + container_name: bitcoin-node-sidecar + hostname: bitcoin-node-sidecar + networks: + mynetwork: + ipv4_address: 172.20.0.111 + environment: + - PORT=8000 + ports: + - "8000:8000" + solana: image: solana-local:latest container_name: solana diff --git a/e2e/e2etests/e2etests.go b/e2e/e2etests/e2etests.go index 29f5a3857a..004271c696 100644 --- a/e2e/e2etests/e2etests.go +++ b/e2e/e2etests/e2etests.go @@ -82,6 +82,7 @@ const ( TestBitcoinWithdrawP2SHName = "bitcoin_withdraw_p2sh" TestBitcoinWithdrawInvalidAddressName = "bitcoin_withdraw_invalid" TestBitcoinWithdrawRestrictedName = "bitcoin_withdraw_restricted" + TestExtractBitcoinInscriptionMemoName = "bitcoin_memo_from_inscription" /* Application tests @@ -451,6 +452,13 @@ var AllE2ETests = []runner.E2ETest{ /* Bitcoin tests */ + runner.NewE2ETest( + TestExtractBitcoinInscriptionMemoName, + "extract memo from BTC inscription", []runner.ArgDefinition{ + {Description: "amount in btc", DefaultValue: "0.1"}, + }, + TestExtractBitcoinInscriptionMemo, + ), runner.NewE2ETest( TestBitcoinDepositName, "deposit Bitcoin into ZEVM", diff --git a/e2e/e2etests/test_extract_bitcoin_inscription_memo.go b/e2e/e2etests/test_extract_bitcoin_inscription_memo.go new file mode 100644 index 0000000000..eedc24b577 --- /dev/null +++ b/e2e/e2etests/test_extract_bitcoin_inscription_memo.go @@ -0,0 +1,57 @@ +package e2etests + +import ( + "encoding/hex" + + "github.com/btcsuite/btcd/btcjson" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/require" + + "github.com/zeta-chain/node/e2e/runner" + btcobserver "github.com/zeta-chain/node/zetaclient/chains/bitcoin/observer" +) + +func TestExtractBitcoinInscriptionMemo(r *runner.E2ERunner, args []string) { + r.SetBtcAddress(r.Name, false) + + // obtain some initial fund + stop := r.MineBlocksIfLocalBitcoin() + defer stop() + r.Logger.Info("Mined blocks") + + // list deployer utxos + utxos, err := r.ListDeployerUTXOs() + require.NoError(r, err) + + amount := parseFloat(r, args[0]) + // this is just some random test memo for inscription + memo, err := hex.DecodeString( + "72f080c854647755d0d9e6f6821f6931f855b9acffd53d87433395672756d58822fd143360762109ab898626556b1c3b8d3096d2361f1297df4a41c1b429471a9aa2fc9be5f27c13b3863d6ac269e4b587d8389f8fd9649859935b0d48dea88cdb40f20c", + ) + require.NoError(r, err) + + txid := r.InscribeToTSSFromDeployerWithMemo(amount, utxos, memo) + + _, err = r.GenerateToAddressIfLocalBitcoin(6, r.BTCDeployerAddress) + require.NoError(r, err) + + rawtx, err := r.BtcRPCClient.GetRawTransactionVerbose(txid) + require.NoError(r, err) + r.Logger.Info("obtained reveal txn id %s", txid) + + dummyCoinbaseTxn := rawtx + events, err := btcobserver.FilterAndParseIncomingTx( + r.BtcRPCClient, + []btcjson.TxRawResult{*dummyCoinbaseTxn, *rawtx}, + 0, + r.BTCTSSAddress.String(), + log.Logger, + r.BitcoinParams, + ) + require.NoError(r, err) + + require.Equal(r, 1, len(events)) + event := events[0] + + require.Equal(r, event.MemoBytes, memo) +} diff --git a/e2e/runner/bitcoin.go b/e2e/runner/bitcoin.go index 6fbd6b40d7..d2c04fccf0 100644 --- a/e2e/runner/bitcoin.go +++ b/e2e/runner/bitcoin.go @@ -2,7 +2,9 @@ package runner import ( "bytes" + "encoding/hex" "fmt" + "net/http" "sort" "time" @@ -177,9 +179,17 @@ func (r *E2ERunner) SendToTSSFromDeployerWithMemo( amount float64, inputUTXOs []btcjson.ListUnspentResult, memo []byte, +) (*chainhash.Hash, error) { + return r.sendToAddrFromDeployerWithMemo(amount, r.BTCTSSAddress, inputUTXOs, memo) +} + +func (r *E2ERunner) sendToAddrFromDeployerWithMemo( + amount float64, + to btcutil.Address, + inputUTXOs []btcjson.ListUnspentResult, + memo []byte, ) (*chainhash.Hash, error) { btcRPC := r.BtcRPCClient - to := r.BTCTSSAddress btcDeployerAddress := r.BTCDeployerAddress require.NotNil(r, r.BTCDeployerAddress, "btcDeployerAddress is nil") @@ -288,6 +298,49 @@ func (r *E2ERunner) SendToTSSFromDeployerWithMemo( return txid, nil } +// InscribeToTSSFromDeployerWithMemo creates an inscription that is sent to the tss address with the corresponding memo +func (r *E2ERunner) InscribeToTSSFromDeployerWithMemo( + amount float64, + inputUTXOs []btcjson.ListUnspentResult, + memo []byte, +) *chainhash.Hash { + // TODO: replace builder with Go function to enable instructions + // https://github.com/zeta-chain/node/issues/2759 + builder := InscriptionBuilder{sidecarURL: "http://bitcoin-node-sidecar:8000", client: http.Client{}} + + address, err := builder.GenerateCommitAddress(memo) + require.NoError(r, err) + r.Logger.Info("received inscription commit address %s", address) + + receiver, err := chains.DecodeBtcAddress(address, r.GetBitcoinChainID()) + require.NoError(r, err) + + txnHash, err := r.sendToAddrFromDeployerWithMemo(amount, receiver, inputUTXOs, []byte(constant.DonationMessage)) + require.NoError(r, err) + r.Logger.Info("obtained inscription commit txn hash %s", txnHash.String()) + + // sendToAddrFromDeployerWithMemo makes sure index is 0 + outpointIdx := 0 + hexTx, err := builder.GenerateRevealTxn(r.BTCTSSAddress.String(), txnHash.String(), outpointIdx, amount) + require.NoError(r, err) + + // Decode the hex string into raw bytes + rawTxBytes, err := hex.DecodeString(hexTx) + require.NoError(r, err) + + // Deserialize the raw bytes into a wire.MsgTx structure + msgTx := wire.NewMsgTx(wire.TxVersion) + err = msgTx.Deserialize(bytes.NewReader(rawTxBytes)) + require.NoError(r, err) + r.Logger.Info("recovered inscription reveal txn %s", hexTx) + + txid, err := r.BtcRPCClient.SendRawTransaction(msgTx, true) + require.NoError(r, err) + r.Logger.Info("txid: %+v", txid) + + return txid +} + // GetBitcoinChainID gets the bitcoin chain ID from the network params func (r *E2ERunner) GetBitcoinChainID() int64 { chainID, err := chains.BitcoinChainIDFromNetworkName(r.BitcoinParams.Name) diff --git a/e2e/runner/bitcoin_inscription.go b/e2e/runner/bitcoin_inscription.go new file mode 100644 index 0000000000..6f90068905 --- /dev/null +++ b/e2e/runner/bitcoin_inscription.go @@ -0,0 +1,119 @@ +package runner + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/pkg/errors" +) + +type commitResponse struct { + Address string `json:"address"` +} + +type revealResponse struct { + RawHex string `json:"rawHex"` +} + +type revealRequest struct { + Txn string `json:"txn"` + Idx int `json:"idx"` + Amount int `json:"amount"` + FeeRate int `json:"feeRate"` + To string `json:"to"` +} + +// InscriptionBuilder is a util struct that help create inscription commit and reveal transactions +type InscriptionBuilder struct { + sidecarURL string + client http.Client +} + +// GenerateCommitAddress generates a commit p2tr address that one can send funds to this address +func (r *InscriptionBuilder) GenerateCommitAddress(memo []byte) (string, error) { + // Create the payload + postData := map[string]string{ + "memo": hex.EncodeToString(memo), + } + + // Convert the payload to JSON + jsonData, err := json.Marshal(postData) + if err != nil { + return "", err + } + + postURL := r.sidecarURL + "/commit" + req, err := http.NewRequest("POST", postURL, bytes.NewBuffer(jsonData)) + if err != nil { + return "", errors.Wrap(err, "cannot create commit request") + } + req.Header.Set("Content-Type", "application/json") + + // Send the request + resp, err := r.client.Do(req) + if err != nil { + return "", errors.Wrap(err, "cannot send to sidecar") + } + defer resp.Body.Close() + + // Read the response body + var response commitResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return "", err + } + + fmt.Print("raw commit response ", response.Address) + + return response.Address, nil +} + +// GenerateRevealTxn creates the corresponding reveal txn to the commit txn. +func (r *InscriptionBuilder) GenerateRevealTxn(to string, txnHash string, idx int, amount float64) (string, error) { + postData := revealRequest{ + Txn: txnHash, + Idx: idx, + Amount: int(amount * 100000000), + FeeRate: 10, + To: to, + } + + // Convert the payload to JSON + jsonData, err := json.Marshal(postData) + if err != nil { + return "", err + } + + postURL := r.sidecarURL + "/reveal" + req, err := http.NewRequest("POST", postURL, bytes.NewBuffer(jsonData)) + if err != nil { + return "", errors.Wrap(err, "cannot create reveal request") + } + req.Header.Set("Content-Type", "application/json") + + // Send the request + resp, err := r.client.Do(req) + if err != nil { + return "", errors.Wrap(err, "cannot send reveal to sidecar") + } + defer resp.Body.Close() + + // Read the response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", errors.Wrap(err, "cannot read reveal response body") + } + + // Parse the JSON response + var response revealResponse + if err := json.Unmarshal(body, &response); err != nil { + return "", errors.Wrap(err, "cannot parse reveal response body") + } + + // Access the "address" field + return response.RawHex, nil +} diff --git a/zetaclient/chains/bitcoin/observer/inbound.go b/zetaclient/chains/bitcoin/observer/inbound.go index e75d9cc1a0..d31715db9a 100644 --- a/zetaclient/chains/bitcoin/observer/inbound.go +++ b/zetaclient/chains/bitcoin/observer/inbound.go @@ -431,10 +431,27 @@ func (ob *Observer) DoesInboundContainsRestrictedAddress(inTx *BTCInboundEvent) return false } -// GetBtcEvent either returns a valid BTCInboundEvent or nil +// GetBtcEvent returns a valid BTCInboundEvent or nil +// it uses witness data to extract the sender address, except for mainnet +func GetBtcEvent( + rpcClient interfaces.BTCRPCClient, + tx btcjson.TxRawResult, + tssAddress string, + blockNumber uint64, + logger zerolog.Logger, + netParams *chaincfg.Params, + depositorFee float64, +) (*BTCInboundEvent, error) { + if netParams.Name == chaincfg.MainNetParams.Name { + return GetBtcEventWithoutWitness(rpcClient, tx, tssAddress, blockNumber, logger, netParams, depositorFee) + } + return GetBtcEventWithWitness(rpcClient, tx, tssAddress, blockNumber, logger, netParams, depositorFee) +} + +// GetBtcEventWithoutWitness either returns a valid BTCInboundEvent or nil // Note: the caller should retry the tx on error (e.g., GetSenderAddressByVin failed) // TODO(revamp): simplify this function -func GetBtcEvent( +func GetBtcEventWithoutWitness( rpcClient interfaces.BTCRPCClient, tx btcjson.TxRawResult, tssAddress string, diff --git a/zetaclient/chains/bitcoin/observer/inbound_test.go b/zetaclient/chains/bitcoin/observer/inbound_test.go index 2b7a333501..8b01e222a1 100644 --- a/zetaclient/chains/bitcoin/observer/inbound_test.go +++ b/zetaclient/chains/bitcoin/observer/inbound_test.go @@ -189,13 +189,13 @@ func TestGetSenderAddressByVin(t *testing.T) { }) } -func TestGetBtcEvent(t *testing.T) { +func TestGetBtcEventWithoutWitness(t *testing.T) { // load archived inbound P2WPKH raw result // https://mempool.space/tx/847139aa65aa4a5ee896375951cbf7417cfc8a4d6f277ec11f40cd87319f04aa txHash := "847139aa65aa4a5ee896375951cbf7417cfc8a4d6f277ec11f40cd87319f04aa" chain := chains.BitcoinMainnet - // GetBtcEvent arguments + // GetBtcEventWithoutWitness arguments tx := testutils.LoadBTCInboundRawResult(t, TestDataDir, chain.ChainId, txHash, false) tssAddress := testutils.TSSAddressBTCMainnet blockNumber := uint64(835640) @@ -227,7 +227,15 @@ func TestGetBtcEvent(t *testing.T) { rpcClient := testrpc.CreateBTCRPCAndLoadTx(t, TestDataDir, chain.ChainId, preHash) // get BTC event - event, err := observer.GetBtcEvent(rpcClient, *tx, tssAddress, blockNumber, log.Logger, net, depositorFee) + event, err := observer.GetBtcEventWithoutWitness( + rpcClient, + *tx, + tssAddress, + blockNumber, + log.Logger, + net, + depositorFee, + ) require.NoError(t, err) require.Equal(t, eventExpected, event) }) @@ -243,7 +251,15 @@ func TestGetBtcEvent(t *testing.T) { rpcClient := testrpc.CreateBTCRPCAndLoadTx(t, TestDataDir, chain.ChainId, preHash) // get BTC event - event, err := observer.GetBtcEvent(rpcClient, *tx, tssAddress, blockNumber, log.Logger, net, depositorFee) + event, err := observer.GetBtcEventWithoutWitness( + rpcClient, + *tx, + tssAddress, + blockNumber, + log.Logger, + net, + depositorFee, + ) require.NoError(t, err) require.Equal(t, eventExpected, event) }) @@ -259,7 +275,15 @@ func TestGetBtcEvent(t *testing.T) { rpcClient := testrpc.CreateBTCRPCAndLoadTx(t, TestDataDir, chain.ChainId, preHash) // get BTC event - event, err := observer.GetBtcEvent(rpcClient, *tx, tssAddress, blockNumber, log.Logger, net, depositorFee) + event, err := observer.GetBtcEventWithoutWitness( + rpcClient, + *tx, + tssAddress, + blockNumber, + log.Logger, + net, + depositorFee, + ) require.NoError(t, err) require.Equal(t, eventExpected, event) }) @@ -275,7 +299,15 @@ func TestGetBtcEvent(t *testing.T) { rpcClient := testrpc.CreateBTCRPCAndLoadTx(t, TestDataDir, chain.ChainId, preHash) // get BTC event - event, err := observer.GetBtcEvent(rpcClient, *tx, tssAddress, blockNumber, log.Logger, net, depositorFee) + event, err := observer.GetBtcEventWithoutWitness( + rpcClient, + *tx, + tssAddress, + blockNumber, + log.Logger, + net, + depositorFee, + ) require.NoError(t, err) require.Equal(t, eventExpected, event) }) @@ -291,7 +323,15 @@ func TestGetBtcEvent(t *testing.T) { rpcClient := testrpc.CreateBTCRPCAndLoadTx(t, TestDataDir, chain.ChainId, preHash) // get BTC event - event, err := observer.GetBtcEvent(rpcClient, *tx, tssAddress, blockNumber, log.Logger, net, depositorFee) + event, err := observer.GetBtcEventWithoutWitness( + rpcClient, + *tx, + tssAddress, + blockNumber, + log.Logger, + net, + depositorFee, + ) require.NoError(t, err) require.Equal(t, eventExpected, event) }) @@ -303,7 +343,15 @@ func TestGetBtcEvent(t *testing.T) { // get BTC event rpcClient := mocks.NewBTCRPCClient(t) - event, err := observer.GetBtcEvent(rpcClient, *tx, tssAddress, blockNumber, log.Logger, net, depositorFee) + event, err := observer.GetBtcEventWithoutWitness( + rpcClient, + *tx, + tssAddress, + blockNumber, + log.Logger, + net, + depositorFee, + ) require.NoError(t, err) require.Nil(t, event) }) @@ -315,13 +363,29 @@ func TestGetBtcEvent(t *testing.T) { // modify the tx to have Vout[0] a P2SH output tx.Vout[0].ScriptPubKey.Hex = strings.Replace(tx.Vout[0].ScriptPubKey.Hex, "0014", "a914", 1) - event, err := observer.GetBtcEvent(rpcClient, *tx, tssAddress, blockNumber, log.Logger, net, depositorFee) + event, err := observer.GetBtcEventWithoutWitness( + rpcClient, + *tx, + tssAddress, + blockNumber, + log.Logger, + net, + depositorFee, + ) require.NoError(t, err) require.Nil(t, event) // append 1 byte to script to make it longer than 22 bytes tx.Vout[0].ScriptPubKey.Hex = tx.Vout[0].ScriptPubKey.Hex + "00" - event, err = observer.GetBtcEvent(rpcClient, *tx, tssAddress, blockNumber, log.Logger, net, depositorFee) + event, err = observer.GetBtcEventWithoutWitness( + rpcClient, + *tx, + tssAddress, + blockNumber, + log.Logger, + net, + depositorFee, + ) require.NoError(t, err) require.Nil(t, event) }) @@ -333,7 +397,15 @@ func TestGetBtcEvent(t *testing.T) { // get BTC event rpcClient := mocks.NewBTCRPCClient(t) - event, err := observer.GetBtcEvent(rpcClient, *tx, tssAddress, blockNumber, log.Logger, net, depositorFee) + event, err := observer.GetBtcEventWithoutWitness( + rpcClient, + *tx, + tssAddress, + blockNumber, + log.Logger, + net, + depositorFee, + ) require.NoError(t, err) require.Nil(t, event) }) @@ -345,7 +417,15 @@ func TestGetBtcEvent(t *testing.T) { // get BTC event rpcClient := mocks.NewBTCRPCClient(t) - event, err := observer.GetBtcEvent(rpcClient, *tx, tssAddress, blockNumber, log.Logger, net, depositorFee) + event, err := observer.GetBtcEventWithoutWitness( + rpcClient, + *tx, + tssAddress, + blockNumber, + log.Logger, + net, + depositorFee, + ) require.NoError(t, err) require.Nil(t, event) }) @@ -357,7 +437,15 @@ func TestGetBtcEvent(t *testing.T) { // get BTC event rpcClient := mocks.NewBTCRPCClient(t) - event, err := observer.GetBtcEvent(rpcClient, *tx, tssAddress, blockNumber, log.Logger, net, depositorFee) + event, err := observer.GetBtcEventWithoutWitness( + rpcClient, + *tx, + tssAddress, + blockNumber, + log.Logger, + net, + depositorFee, + ) require.NoError(t, err) require.Nil(t, event) }) @@ -369,7 +457,15 @@ func TestGetBtcEvent(t *testing.T) { // get BTC event rpcClient := mocks.NewBTCRPCClient(t) - event, err := observer.GetBtcEvent(rpcClient, *tx, tssAddress, blockNumber, log.Logger, net, depositorFee) + event, err := observer.GetBtcEventWithoutWitness( + rpcClient, + *tx, + tssAddress, + blockNumber, + log.Logger, + net, + depositorFee, + ) require.NoError(t, err) require.Nil(t, event) }) @@ -392,7 +488,15 @@ func TestGetBtcEvent(t *testing.T) { rpcClient.On("GetRawTransaction", mock.Anything).Return(btcutil.NewTx(msgTx), nil) // get BTC event - event, err := observer.GetBtcEvent(rpcClient, *tx, tssAddress, blockNumber, log.Logger, net, depositorFee) + event, err := observer.GetBtcEventWithoutWitness( + rpcClient, + *tx, + tssAddress, + blockNumber, + log.Logger, + net, + depositorFee, + ) require.NoError(t, err) require.Nil(t, event) }) @@ -417,7 +521,15 @@ func TestGetBtcEventErrors(t *testing.T) { // get BTC event rpcClient := mocks.NewBTCRPCClient(t) - event, err := observer.GetBtcEvent(rpcClient, *tx, tssAddress, blockNumber, log.Logger, net, depositorFee) + event, err := observer.GetBtcEventWithoutWitness( + rpcClient, + *tx, + tssAddress, + blockNumber, + log.Logger, + net, + depositorFee, + ) require.Error(t, err) require.Nil(t, event) }) @@ -429,7 +541,15 @@ func TestGetBtcEventErrors(t *testing.T) { // get BTC event rpcClient := mocks.NewBTCRPCClient(t) - event, err := observer.GetBtcEvent(rpcClient, *tx, tssAddress, blockNumber, log.Logger, net, depositorFee) + event, err := observer.GetBtcEventWithoutWitness( + rpcClient, + *tx, + tssAddress, + blockNumber, + log.Logger, + net, + depositorFee, + ) require.ErrorContains(t, err, "no input found") require.Nil(t, event) }) @@ -443,8 +563,94 @@ func TestGetBtcEventErrors(t *testing.T) { rpcClient.On("GetRawTransaction", mock.Anything).Return(nil, errors.New("rpc error")) // get BTC event - event, err := observer.GetBtcEvent(rpcClient, *tx, tssAddress, blockNumber, log.Logger, net, depositorFee) + event, err := observer.GetBtcEventWithoutWitness( + rpcClient, + *tx, + tssAddress, + blockNumber, + log.Logger, + net, + depositorFee, + ) require.ErrorContains(t, err, "error getting sender address") require.Nil(t, event) }) } + +func TestGetBtcEvent(t *testing.T) { + t.Run("should not decode inbound event with witness with mainnet chain", func(t *testing.T) { + // load archived inbound P2WPKH raw result + // https://mempool.space/tx/847139aa65aa4a5ee896375951cbf7417cfc8a4d6f277ec11f40cd87319f04aa + chain := chains.BitcoinMainnet + tssAddress := testutils.TSSAddressBTCMainnet + blockNumber := uint64(835640) + net := &chaincfg.MainNetParams + // 2.992e-05, see avgFeeRate https://mempool.space/api/v1/blocks/835640 + depositorFee := bitcoin.DepositorFee(22 * clientcommon.BTCOutboundGasPriceMultiplier) + txHash2 := "37777defed8717c581b4c0509329550e344bdc14ac38f71fc050096887e535c8" + tx := testutils.LoadBTCInboundRawResult(t, TestDataDir, chain.ChainId, txHash2, false) + rpcClient := mocks.NewBTCRPCClient(t) + // get BTC event + event, err := observer.GetBtcEvent( + rpcClient, + *tx, + tssAddress, + blockNumber, + log.Logger, + net, + depositorFee, + ) + require.NoError(t, err) + require.Equal(t, (*observer.BTCInboundEvent)(nil), event) + }) + + t.Run("should support legacy BTC inbound event parsing for mainnet", func(t *testing.T) { + // load archived inbound P2WPKH raw result + // https://mempool.space/tx/847139aa65aa4a5ee896375951cbf7417cfc8a4d6f277ec11f40cd87319f04aa + txHash := "847139aa65aa4a5ee896375951cbf7417cfc8a4d6f277ec11f40cd87319f04aa" + chain := chains.BitcoinMainnet + + // GetBtcEventWithoutWitness arguments + tx := testutils.LoadBTCInboundRawResult(t, TestDataDir, chain.ChainId, txHash, false) + tssAddress := testutils.TSSAddressBTCMainnet + blockNumber := uint64(835640) + net := &chaincfg.MainNetParams + + // fee rate of above tx is 28 sat/vB + depositorFee := bitcoin.DepositorFee(28 * clientcommon.BTCOutboundGasPriceMultiplier) + + // expected result + memo, err := hex.DecodeString(tx.Vout[1].ScriptPubKey.Hex[4:]) + require.NoError(t, err) + eventExpected := &observer.BTCInboundEvent{ + FromAddress: "bc1q68kxnq52ahz5vd6c8czevsawu0ux9nfrzzrh6e", + ToAddress: tssAddress, + Value: tx.Vout[0].Value - depositorFee, // 6192 sataoshis + DepositorFee: depositorFee, + MemoBytes: memo, + BlockNumber: blockNumber, + TxHash: tx.Txid, + } + + // https://mempool.space/tx/c5d224963832fc0b9a597251c2342a17b25e481a88cc9119008e8f8296652697 + preHash := "c5d224963832fc0b9a597251c2342a17b25e481a88cc9119008e8f8296652697" + tx.Vin[0].Txid = preHash + tx.Vin[0].Vout = 2 + eventExpected.FromAddress = "bc1q68kxnq52ahz5vd6c8czevsawu0ux9nfrzzrh6e" + // load previous raw tx so so mock rpc client can return it + rpcClient := testrpc.CreateBTCRPCAndLoadTx(t, TestDataDir, chain.ChainId, preHash) + + // get BTC event + event, err := observer.GetBtcEvent( + rpcClient, + *tx, + tssAddress, + blockNumber, + log.Logger, + net, + depositorFee, + ) + require.NoError(t, err) + require.Equal(t, eventExpected, event) + }) +} diff --git a/zetaclient/chains/bitcoin/observer/witness.go b/zetaclient/chains/bitcoin/observer/witness.go index 696629b59a..86b22f95cf 100644 --- a/zetaclient/chains/bitcoin/observer/witness.go +++ b/zetaclient/chains/bitcoin/observer/witness.go @@ -58,7 +58,7 @@ func GetBtcEventWithWitness( memo = candidate logger.Debug().Msgf("GetBtcEventWithWitness: found inscription memo %s in tx %s", hex.EncodeToString(memo), tx.Txid) } else { - return nil, errors.Errorf("error getting memo for inbound: %s", tx.Txid) + return nil, nil } // event found, get sender address