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..30cf18e1ac --- /dev/null +++ b/e2e/e2etests/test_extract_bitcoin_inscription_memo.go @@ -0,0 +1,60 @@ +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" + zetabitcoin "github.com/zeta-chain/node/zetaclient/chains/bitcoin" + 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 + depositorFee := zetabitcoin.DefaultDepositorFee + events, err := btcobserver.FilterAndParseIncomingTx( + r.BtcRPCClient, + []btcjson.TxRawResult{*dummyCoinbaseTxn, *rawtx}, + 0, + r.BTCTSSAddress.String(), + log.Logger, + r.BitcoinParams, + depositorFee, + ) + 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..409913a4a7 100644 --- a/zetaclient/chains/bitcoin/observer/inbound.go +++ b/zetaclient/chains/bitcoin/observer/inbound.go @@ -302,7 +302,7 @@ func (ob *Observer) CheckReceiptForBtcTxHash(ctx context.Context, txHash string, } // #nosec G115 always positive - event, err := GetBtcEvent( + event, err := GetBtcEventWithWitness( ob.btcClient, *tx, tss, @@ -369,7 +369,7 @@ func FilterAndParseIncomingTx( return nil, errors.Wrapf(err, "error calculating depositor fee for inbound %s", tx.Txid) } - event, err := GetBtcEvent(rpcClient, tx, tssAddress, blockNumber, logger, netParams, depositorFee) + event, err := GetBtcEventWithWitness(rpcClient, tx, tssAddress, blockNumber, logger, netParams, depositorFee) if err != nil { // unable to parse the tx, the caller should retry return nil, errors.Wrapf(err, "error getting btc event for tx %s in block %d", tx.Txid, blockNumber)