From 7474ab5e649a953803fcf60373a0ed99f53bef51 Mon Sep 17 00:00:00 2001 From: Charlie Chen <34498985+ws4charlie@users.noreply.github.com> Date: Mon, 4 Nov 2024 10:07:12 -0600 Subject: [PATCH] refactor: replace docker-based inscription builder sidecar with Golang implementation (#3082) * initiate bitcoin inscription builder using golang * use the tokenizer in the upgraded btcd package * add changelog entry * fix CI unit test failure * replace 80 with btcd defined constant; update comments * replace hardcoded number with btcd defined constant * fix coderabbit comment and tidy go mod * remove randomness in E2E fee estimation --- changelog.md | 1 + cmd/zetae2e/local/local.go | 1 + contrib/localnet/docker-compose.yml | 12 - e2e/e2etests/e2etests.go | 18 +- ...oin_std_memo_inscribed_deposit_and_call.go | 64 +++++ .../test_extract_bitcoin_inscription_memo.go | 57 ---- e2e/runner/accounting.go | 2 +- e2e/runner/bitcoin.go | 54 ++-- e2e/runner/bitcoin_inscription.go | 259 +++++++++++++----- go.mod | 4 +- go.sum | 2 + zetaclient/chains/bitcoin/observer/witness.go | 3 +- zetaclient/chains/bitcoin/tokenizer.go | 162 ----------- zetaclient/chains/bitcoin/tx_script.go | 6 +- zetaclient/chains/bitcoin/tx_script_test.go | 4 +- 15 files changed, 304 insertions(+), 345 deletions(-) create mode 100644 e2e/e2etests/test_bitcoin_std_memo_inscribed_deposit_and_call.go delete mode 100644 e2e/e2etests/test_extract_bitcoin_inscription_memo.go delete mode 100644 zetaclient/chains/bitcoin/tokenizer.go diff --git a/changelog.md b/changelog.md index e29e6d26af..4ba4b6fcf7 100644 --- a/changelog.md +++ b/changelog.md @@ -42,6 +42,7 @@ * [2899](https://github.com/zeta-chain/node/pull/2899) - remove btc deposit fee v1 and improve unit tests * [2952](https://github.com/zeta-chain/node/pull/2952) - add error_message to cctx.status * [3039](https://github.com/zeta-chain/node/pull/3039) - use `btcd` native APIs to handle Bitcoin Taproot address +* [3082](https://github.com/zeta-chain/node/pull/3082) - replace docker-based bitcoin sidecar inscription build with Golang implementation ### Tests diff --git a/cmd/zetae2e/local/local.go b/cmd/zetae2e/local/local.go index e1d579cb0a..986e9e4689 100644 --- a/cmd/zetae2e/local/local.go +++ b/cmd/zetae2e/local/local.go @@ -310,6 +310,7 @@ func localE2ETest(cmd *cobra.Command, _ []string) { e2etests.TestBitcoinStdMemoDepositAndCallName, e2etests.TestBitcoinStdMemoDepositAndCallRevertName, e2etests.TestBitcoinStdMemoDepositAndCallRevertOtherAddressName, + e2etests.TestBitcoinStdMemoInscribedDepositAndCallName, e2etests.TestBitcoinWithdrawSegWitName, e2etests.TestBitcoinWithdrawInvalidAddressName, e2etests.TestZetaWithdrawBTCRevertName, diff --git a/contrib/localnet/docker-compose.yml b/contrib/localnet/docker-compose.yml index eb6453052f..4f36583d91 100644 --- a/contrib/localnet/docker-compose.yml +++ b/contrib/localnet/docker-compose.yml @@ -227,18 +227,6 @@ services: -rpcauth=smoketest:63acf9b8dccecce914d85ff8c044b78b$$5892f9bbc84f4364e79f0970039f88bdd823f168d4acc76099ab97b14a766a99 -txindex=1 - bitcoin-node-sidecar: - image: ghcr.io/zeta-chain/node-localnet-bitcoin-sidecar:e0205d7 - 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 5f6adf9b87..9f86120bb0 100644 --- a/e2e/e2etests/e2etests.go +++ b/e2e/e2etests/e2etests.go @@ -81,6 +81,7 @@ const ( TestBitcoinStdMemoDepositAndCallName = "bitcoin_std_memo_deposit_and_call" TestBitcoinStdMemoDepositAndCallRevertName = "bitcoin_std_memo_deposit_and_call_revert" TestBitcoinStdMemoDepositAndCallRevertOtherAddressName = "bitcoin_std_memo_deposit_and_call_revert_other_address" + TestBitcoinStdMemoInscribedDepositAndCallName = "bitcoin_std_memo_inscribed_deposit_and_call" TestBitcoinWithdrawSegWitName = "bitcoin_withdraw_segwit" TestBitcoinWithdrawTaprootName = "bitcoin_withdraw_taproot" TestBitcoinWithdrawMultipleName = "bitcoin_withdraw_multiple" @@ -89,7 +90,6 @@ const ( TestBitcoinWithdrawP2SHName = "bitcoin_withdraw_p2sh" TestBitcoinWithdrawInvalidAddressName = "bitcoin_withdraw_invalid" TestBitcoinWithdrawRestrictedName = "bitcoin_withdraw_restricted" - TestExtractBitcoinInscriptionMemoName = "bitcoin_memo_from_inscription" /* Application tests @@ -497,13 +497,6 @@ var AllE2ETests = []runner.E2ETest{ }, TestBitcoinDonation, ), - 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", @@ -559,6 +552,15 @@ var AllE2ETests = []runner.E2ETest{ }, TestBitcoinStdMemoDepositAndCallRevertOtherAddress, ), + runner.NewE2ETest( + TestBitcoinStdMemoInscribedDepositAndCallName, + "deposit Bitcoin into ZEVM and call a contract with inscribed standard memo", + []runner.ArgDefinition{ + {Description: "amount in btc", DefaultValue: "0.1"}, + {Description: "fee rate", DefaultValue: "10"}, + }, + TestBitcoinStdMemoInscribedDepositAndCall, + ), runner.NewE2ETest( TestBitcoinWithdrawSegWitName, "withdraw BTC from ZEVM to a SegWit address", diff --git a/e2e/e2etests/test_bitcoin_std_memo_inscribed_deposit_and_call.go b/e2e/e2etests/test_bitcoin_std_memo_inscribed_deposit_and_call.go new file mode 100644 index 0000000000..c9a5d7af31 --- /dev/null +++ b/e2e/e2etests/test_bitcoin_std_memo_inscribed_deposit_and_call.go @@ -0,0 +1,64 @@ +package e2etests + +import ( + "math/big" + + "github.com/stretchr/testify/require" + + "github.com/zeta-chain/node/e2e/runner" + "github.com/zeta-chain/node/e2e/utils" + "github.com/zeta-chain/node/pkg/memo" + testcontract "github.com/zeta-chain/node/testutil/contracts" + crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" + zetabitcoin "github.com/zeta-chain/node/zetaclient/chains/bitcoin" +) + +func TestBitcoinStdMemoInscribedDepositAndCall(r *runner.E2ERunner, args []string) { + // ARRANGE + // Given BTC address + r.SetBtcAddress(r.Name, false) + + // Start mining blocks + stop := r.MineBlocksIfLocalBitcoin() + defer stop() + + // Given amount to send and fee rate + require.Len(r, args, 2) + amount := parseFloat(r, args[0]) + feeRate := parseInt(r, args[1]) + + // deploy an example contract in ZEVM + contractAddr, _, contract, err := testcontract.DeployExample(r.ZEVMAuth, r.ZEVMClient) + require.NoError(r, err) + + // create a standard memo > 80 bytes + memo := &memo.InboundMemo{ + Header: memo.Header{ + Version: 0, + EncodingFmt: memo.EncodingFmtCompactShort, + OpCode: memo.OpCodeDepositAndCall, + }, + FieldsV0: memo.FieldsV0{ + Receiver: contractAddr, + Payload: []byte("for use case that passes a large memo > 80 bytes, inscripting the memo is the way to go"), + }, + } + memoBytes, err := memo.EncodeToBytes() + require.NoError(r, err) + + // ACT + // Send BTC to TSS address with memo + txHash, depositAmount := r.InscribeToTSSFromDeployerWithMemo(amount, memoBytes, int64(feeRate)) + + // ASSERT + // wait for the cctx to be mined + cctx := utils.WaitCctxMinedByInboundHash(r.Ctx, txHash.String(), r.CctxClient, r.Logger, r.CctxTimeout) + r.Logger.CCTX(*cctx, "bitcoin_std_memo_inscribed_deposit_and_call") + utils.RequireCCTXStatus(r, cctx, crosschaintypes.CctxStatus_OutboundMined) + + // check if example contract has been called, 'bar' value should be set to correct amount + depositFeeSats, err := zetabitcoin.GetSatoshis(zetabitcoin.DefaultDepositorFee) + require.NoError(r, err) + receiveAmount := depositAmount - depositFeeSats + utils.MustHaveCalledExampleContract(r, contract, big.NewInt(receiveAmount)) +} diff --git a/e2e/e2etests/test_extract_bitcoin_inscription_memo.go b/e2e/e2etests/test_extract_bitcoin_inscription_memo.go deleted file mode 100644 index eedc24b577..0000000000 --- a/e2e/e2etests/test_extract_bitcoin_inscription_memo.go +++ /dev/null @@ -1,57 +0,0 @@ -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/accounting.go b/e2e/runner/accounting.go index 92e120b7fd..c47883c3f0 100644 --- a/e2e/runner/accounting.go +++ b/e2e/runner/accounting.go @@ -122,7 +122,7 @@ func (r *E2ERunner) CheckBtcTSSBalance() error { ) } // #nosec G115 test - always in range - r.Logger.Print( + r.Logger.Info( "BTC: Balance (%d) >= ZRC20 TotalSupply (%d)", int64(tssTotalBalance*1e8), zrc20Supply.Int64()-10000000, diff --git a/e2e/runner/bitcoin.go b/e2e/runner/bitcoin.go index 3d65589fa5..047e97139b 100644 --- a/e2e/runner/bitcoin.go +++ b/e2e/runner/bitcoin.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/hex" "fmt" - "net/http" "sort" "time" @@ -331,44 +330,47 @@ func (r *E2ERunner) sendToAddrFromDeployerWithMemo( // 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()) + feeRate int64, +) (*chainhash.Hash, int64) { + // list deployer utxos + utxos, err := r.ListDeployerUTXOs() require.NoError(r, err) - txnHash, err := r.sendToAddrFromDeployerWithMemo(amount, receiver, inputUTXOs, []byte(constant.DonationMessage)) + // generate commit address + builder := NewTapscriptSpender(r.BitcoinParams) + receiver, err := builder.GenerateCommitAddress(memo) require.NoError(r, err) - r.Logger.Info("obtained inscription commit txn hash %s", txnHash.String()) + r.Logger.Info("received inscription commit address: %s", receiver) - // sendToAddrFromDeployerWithMemo makes sure index is 0 - outpointIdx := 0 - hexTx, err := builder.GenerateRevealTxn(r.BTCTSSAddress.String(), txnHash.String(), outpointIdx, amount) + // send funds to the commit address + commitTxHash, err := r.sendToAddrFromDeployerWithMemo(amount, receiver, utxos, nil) require.NoError(r, err) + r.Logger.Info("obtained inscription commit txn hash: %s", commitTxHash.String()) - // Decode the hex string into raw bytes - rawTxBytes, err := hex.DecodeString(hexTx) + // parameters to build the reveal transaction + commitOutputIdx := uint32(0) + commitAmount, err := zetabitcoin.GetSatoshis(amount) require.NoError(r, err) - // Deserialize the raw bytes into a wire.MsgTx structure - msgTx := wire.NewMsgTx(wire.TxVersion) - err = msgTx.Deserialize(bytes.NewReader(rawTxBytes)) + // build the reveal transaction to spend above funds + revealTx, err := builder.BuildRevealTxn( + r.BTCTSSAddress, + wire.OutPoint{ + Hash: *commitTxHash, + Index: commitOutputIdx, + }, + commitAmount, + feeRate, + ) require.NoError(r, err) - r.Logger.Info("recovered inscription reveal txn %s", hexTx) - txid, err := r.BtcRPCClient.SendRawTransaction(msgTx, true) + // submit the reveal transaction + txid, err := r.BtcRPCClient.SendRawTransaction(revealTx, true) require.NoError(r, err) - r.Logger.Info("txid: %+v", txid) + r.Logger.Info("reveal txid: %s", txid.String()) - return txid + return txid, revealTx.TxOut[0].Value } // GetBitcoinChainID gets the bitcoin chain ID from the network params diff --git a/e2e/runner/bitcoin_inscription.go b/e2e/runner/bitcoin_inscription.go index 6f90068905..5ff237391a 100644 --- a/e2e/runner/bitcoin_inscription.go +++ b/e2e/runner/bitcoin_inscription.go @@ -1,119 +1,234 @@ package runner import ( - "bytes" - "encoding/hex" - "encoding/json" "fmt" - "io" - "net/http" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/mempool" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" "github.com/pkg/errors" ) -type commitResponse struct { - Address string `json:"address"` -} +// TapscriptSpender is a utility struct that helps create Taproot address and reveal transaction +type TapscriptSpender struct { + // internalKey is a local-generated private key used for signing the Taproot script path. + internalKey *btcec.PrivateKey -type revealResponse struct { - RawHex string `json:"rawHex"` -} + // taprootOutputKey is the Taproot output key derived from the internal key and the merkle root. + // It is used to create Taproot addresses that can be funded. + taprootOutputKey *btcec.PublicKey + + // taprootOutputAddr is the Taproot address derived from the taprootOutputKey. + taprootOutputAddr *btcutil.AddressTaproot + + // tapLeaf represents the Taproot leaf node script (tapscript) that contains the embedded inscription data. + tapLeaf txscript.TapLeaf + + // ctrlBlockBytes contains the control block data required for spending the Taproot output via the script path. + // This includes the internal key and proof for the tapLeaf used to authenticate spending. + ctrlBlockBytes []byte -type revealRequest struct { - Txn string `json:"txn"` - Idx int `json:"idx"` - Amount int `json:"amount"` - FeeRate int `json:"feeRate"` - To string `json:"to"` + net *chaincfg.Params } -// InscriptionBuilder is a util struct that help create inscription commit and reveal transactions -type InscriptionBuilder struct { - sidecarURL string - client http.Client +// NewTapscriptSpender creates a new NewTapscriptSpender instance +func NewTapscriptSpender(net *chaincfg.Params) *TapscriptSpender { + return &TapscriptSpender{ + net: net, + } } -// 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), +// GenerateCommitAddress generates a Taproot commit address for the given receiver and payload +func (s *TapscriptSpender) GenerateCommitAddress(memo []byte) (*btcutil.AddressTaproot, error) { + // OP_RETURN is a better choice for memo <= 80 bytes + if len(memo) <= txscript.MaxDataCarrierSize { + return nil, fmt.Errorf("OP_RETURN is a better choice for memo <= 80 bytes") } - // Convert the payload to JSON - jsonData, err := json.Marshal(postData) + // generate internal private key, leaf script and Taproot output key + err := s.genTaprootLeafAndKeys(memo) if err != nil { - return "", err + return nil, errors.Wrap(err, "genTaprootLeafAndKeys failed") } - postURL := r.sidecarURL + "/commit" - req, err := http.NewRequest("POST", postURL, bytes.NewBuffer(jsonData)) + return s.taprootOutputAddr, nil +} + +// BuildRevealTxn returns a signed reveal transaction that spends the commit transaction +func (s *TapscriptSpender) BuildRevealTxn( + to btcutil.Address, + commitTxn wire.OutPoint, + commitAmount int64, + feeRate int64, +) (*wire.MsgTx, error) { + // Step 1: create tx message + revealTx := wire.NewMsgTx(2) + + // Step 2: add input (the commit tx) + outpoint := wire.NewOutPoint(&commitTxn.Hash, commitTxn.Index) + revealTx.AddTxIn(wire.NewTxIn(outpoint, nil, nil)) + + // Step 3: add output (to TSS) + pkScript, err := txscript.PayToAddrScript(to) if err != nil { - return "", errors.Wrap(err, "cannot create commit request") + return nil, errors.Wrap(err, "failed to create receiver pkScript") } - req.Header.Set("Content-Type", "application/json") - - // Send the request - resp, err := r.client.Do(req) + fee, err := s.estimateFee(revealTx, to, commitAmount, feeRate) if err != nil { - return "", errors.Wrap(err, "cannot send to sidecar") + return nil, errors.Wrap(err, "failed to estimate fee for reveal txn") } - defer resp.Body.Close() + revealTx.AddTxOut(wire.NewTxOut(commitAmount-fee, pkScript)) - // Read the response body - var response commitResponse - err = json.NewDecoder(resp.Body).Decode(&response) + // Step 4: compute the sighash for the P2TR input to be spent using script path + commitScript, err := txscript.PayToAddrScript(s.taprootOutputAddr) if err != nil { - return "", err + return nil, errors.Wrap(err, "failed to create commit pkScript") + } + prevOutFetcher := txscript.NewCannedPrevOutputFetcher(commitScript, commitAmount) + sigHashes := txscript.NewTxSigHashes(revealTx, prevOutFetcher) + sigHash, err := txscript.CalcTapscriptSignaturehash( + sigHashes, + txscript.SigHashDefault, + revealTx, + int(commitTxn.Index), + prevOutFetcher, + s.tapLeaf, + ) + if err != nil { + return nil, errors.Wrap(err, "failed to calculate tapscript sighash") } - fmt.Print("raw commit response ", response.Address) + // Step 5: sign the sighash with the internal key + sig, err := schnorr.Sign(s.internalKey, sigHash) + if err != nil { + return nil, errors.Wrap(err, "failed to sign sighash") + } + revealTx.TxIn[0].Witness = wire.TxWitness{sig.Serialize(), s.tapLeaf.Script, s.ctrlBlockBytes} - return response.Address, nil + return revealTx, 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, +// genTaprootLeafAndKeys generates internal private key, leaf script and Taproot output key +func (s *TapscriptSpender) genTaprootLeafAndKeys(data []byte) error { + // generate an internal private key + internalKey, err := btcec.NewPrivateKey() + if err != nil { + return errors.Wrap(err, "failed to generate internal private key") } - // Convert the payload to JSON - jsonData, err := json.Marshal(postData) + // generate the leaf script + leafScript, err := genLeafScript(internalKey.PubKey(), data) if err != nil { - return "", err + return errors.Wrap(err, "failed to generate leaf script") } - postURL := r.sidecarURL + "/reveal" - req, err := http.NewRequest("POST", postURL, bytes.NewBuffer(jsonData)) + // assemble Taproot tree + tapLeaf := txscript.NewBaseTapLeaf(leafScript) + tapScriptTree := txscript.AssembleTaprootScriptTree(tapLeaf) + + // compute the Taproot output key and address + tapScriptRoot := tapScriptTree.RootNode.TapHash() + taprootOutputKey := txscript.ComputeTaprootOutputKey(internalKey.PubKey(), tapScriptRoot[:]) + taprootOutputAddr, err := btcutil.NewAddressTaproot(schnorr.SerializePubKey(taprootOutputKey), s.net) if err != nil { - return "", errors.Wrap(err, "cannot create reveal request") + return errors.Wrap(err, "failed to create Taproot address") } - req.Header.Set("Content-Type", "application/json") - // Send the request - resp, err := r.client.Do(req) + // construct the control block for the Taproot leaf script. + ctrlBlock := tapScriptTree.LeafMerkleProofs[0].ToControlBlock(internalKey.PubKey()) + ctrlBlockBytes, err := ctrlBlock.ToBytes() if err != nil { - return "", errors.Wrap(err, "cannot send reveal to sidecar") + return errors.Wrap(err, "failed to serialize control block") } - defer resp.Body.Close() - // Read the response body - body, err := io.ReadAll(resp.Body) + // save generated keys, script and control block for later use + s.internalKey = internalKey + s.taprootOutputKey = taprootOutputKey + s.taprootOutputAddr = taprootOutputAddr + s.tapLeaf = tapLeaf + s.ctrlBlockBytes = ctrlBlockBytes + + return nil +} + +// estimateFee estimates the tx fee based given fee rate and estimated tx virtual size +func (s *TapscriptSpender) estimateFee( + tx *wire.MsgTx, + to btcutil.Address, + amount int64, + feeRate int64, +) (int64, error) { + txCopy := tx.Copy() + + // add output to the copied transaction + pkScript, err := txscript.PayToAddrScript(to) if err != nil { - return "", errors.Wrap(err, "cannot read reveal response body") + return 0, err } + txCopy.AddTxOut(wire.NewTxOut(amount, pkScript)) + + // create 64-byte fake Schnorr signature + sigBytes := make([]byte, 64) + + // set the witness for the first input + txWitness := wire.TxWitness{sigBytes, s.tapLeaf.Script, s.ctrlBlockBytes} + txCopy.TxIn[0].Witness = txWitness + + // calculate the fee based on the estimated virtual size + fee := mempool.GetTxVirtualSize(btcutil.NewTx(txCopy)) * feeRate + + return fee, nil +} + +//================================================================================================= +//================================================================================================= + +// LeafScriptBuilder represents a builder for Taproot leaf scripts +type LeafScriptBuilder struct { + script txscript.ScriptBuilder +} + +// NewLeafScriptBuilder initializes a new LeafScriptBuilder with a public key and `OP_CHECKSIG` +func NewLeafScriptBuilder(pubKey *btcec.PublicKey) *LeafScriptBuilder { + builder := txscript.NewScriptBuilder() + builder.AddData(schnorr.SerializePubKey(pubKey)) + builder.AddOp(txscript.OP_CHECKSIG) + + return &LeafScriptBuilder{script: *builder} +} - // Parse the JSON response - var response revealResponse - if err := json.Unmarshal(body, &response); err != nil { - return "", errors.Wrap(err, "cannot parse reveal response body") +// PushData adds a large data to the Taproot leaf script following OP_FALSE and OP_IF structure +func (b *LeafScriptBuilder) PushData(data []byte) { + // start the inscription envelope + b.script.AddOp(txscript.OP_FALSE) + b.script.AddOp(txscript.OP_IF) + + // break data into chunks and push each one + dataLen := len(data) + for i := 0; i < dataLen; i += txscript.MaxScriptElementSize { + if dataLen-i >= txscript.MaxScriptElementSize { + b.script.AddData(data[i : i+txscript.MaxScriptElementSize]) + } else { + b.script.AddData(data[i:]) + } } - // Access the "address" field - return response.RawHex, nil + // end the inscription envelope + b.script.AddOp(txscript.OP_ENDIF) +} + +// Script returns the current script +func (b *LeafScriptBuilder) Script() ([]byte, error) { + return b.script.Script() +} + +// genLeafScript creates a Taproot leaf script using provided pubkey and data +func genLeafScript(pubKey *btcec.PublicKey, data []byte) ([]byte, error) { + builder := NewLeafScriptBuilder(pubKey) + builder.PushData(data) + return builder.Script() } diff --git a/go.mod b/go.mod index 13a35b26bc..c92e2d2767 100644 --- a/go.mod +++ b/go.mod @@ -334,14 +334,16 @@ require ( require ( github.com/bnb-chain/tss-lib v1.5.0 + github.com/montanaflynn/stats v0.7.1 github.com/showa-93/go-mask v0.6.2 github.com/tonkeeper/tongo v1.9.3 github.com/zeta-chain/protocol-contracts-solana/go-idl v0.0.0-20241025181051-d8d49e4fc85b ) require ( + github.com/aead/siphash v1.0.1 // indirect github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect - github.com/montanaflynn/stats v0.7.1 // indirect + github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23 // indirect github.com/oasisprotocol/curve25519-voi v0.0.0-20220328075252-7dd334e3daae // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect diff --git a/go.sum b/go.sum index fec35684fa..404160ddd4 100644 --- a/go.sum +++ b/go.sum @@ -1417,6 +1417,7 @@ github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ github.com/adlio/schema v1.1.13/go.mod h1:L5Z7tw+7lRK1Fnpi/LT/ooCP1elkXn0krMWBQHUhEDE= github.com/adlio/schema v1.3.3 h1:oBJn8I02PyTB466pZO1UZEn1TV5XLlifBSyMrmHl/1I= github.com/adlio/schema v1.3.3/go.mod h1:1EsRssiv9/Ce2CMzq5DoL7RiMshhuigQxrR4DMV9fHg= +github.com/aead/siphash v1.0.1 h1:FwHfE/T45KPKYuuSAKyyvE+oPWcaQ+CUmFW0bPlM+kg= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= @@ -2987,6 +2988,7 @@ github.com/kisielk/errcheck v1.6.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/errcheck v1.6.2/go.mod h1:nXw/i/MfnvRHqXa7XXmQMUB0oNFGuBrNI8d8NLy0LPw= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kkHAIKE/contextcheck v1.1.3/go.mod h1:PG/cwd6c0705/LM0KTr1acO2gORUxkSVWyLJOFW5qoo= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23 h1:FOOIBWrEkLgmlgGfMuZT83xIwfPDxEI2OHu6xUmJMFE= github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= github.com/klauspost/compress v1.4.0/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= diff --git a/zetaclient/chains/bitcoin/observer/witness.go b/zetaclient/chains/bitcoin/observer/witness.go index 22ce75719b..9625ad3caa 100644 --- a/zetaclient/chains/bitcoin/observer/witness.go +++ b/zetaclient/chains/bitcoin/observer/witness.go @@ -6,6 +6,7 @@ import ( "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" "github.com/pkg/errors" "github.com/rs/zerolog" @@ -104,7 +105,7 @@ func ParseScriptFromWitness(witness []string, logger zerolog.Logger) []byte { // If there are at least two witness elements, and the first byte of // the last element is 0x50, this last element is called annex a // and is removed from the witness stack. - if length >= 2 && len(lastElement) > 0 && lastElement[0] == 0x50 { + if length >= 2 && len(lastElement) > 0 && lastElement[0] == txscript.TaprootAnnexTag { // account for the extra item removed from the end witness = witness[:length-1] } diff --git a/zetaclient/chains/bitcoin/tokenizer.go b/zetaclient/chains/bitcoin/tokenizer.go deleted file mode 100644 index 5708bfa250..0000000000 --- a/zetaclient/chains/bitcoin/tokenizer.go +++ /dev/null @@ -1,162 +0,0 @@ -package bitcoin - -import ( - "encoding/binary" - "fmt" - - "github.com/btcsuite/btcd/txscript" -) - -func newScriptTokenizer(script []byte) scriptTokenizer { - return scriptTokenizer{ - script: script, - offset: 0, - } -} - -// scriptTokenizer is supposed to be replaced by txscript.ScriptTokenizer. However, -// it seems currently the btcsuite version does not have ScriptTokenizer. A simplified -// version of that is implemented here. This is fully compatible with txscript.ScriptTokenizer -// one should consider upgrading txscript and remove this implementation -type scriptTokenizer struct { - script []byte - offset int - op byte - data []byte - err error -} - -// Done returns true when either all opcodes have been exhausted or a parse -// failure was encountered and therefore the state has an associated error. -func (t *scriptTokenizer) Done() bool { - return t.err != nil || t.offset >= len(t.script) -} - -// Data returns the data associated with the most recently successfully parsed -// opcode. -func (t *scriptTokenizer) Data() []byte { - return t.data -} - -// Err returns any errors currently associated with the tokenizer. This will -// only be non-nil in the case a parsing error was encountered. -func (t *scriptTokenizer) Err() error { - return t.err -} - -// Opcode returns the current opcode associated with the tokenizer. -func (t *scriptTokenizer) Opcode() byte { - return t.op -} - -// Next attempts to parse the next opcode and returns whether or not it was -// successful. It will not be successful if invoked when already at the end of -// the script, a parse failure is encountered, or an associated error already -// exists due to a previous parse failure. -// -// In the case of a true return, the parsed opcode and data can be obtained with -// the associated functions and the offset into the script will either point to -// the next opcode or the end of the script if the final opcode was parsed. -// -// In the case of a false return, the parsed opcode and data will be the last -// successfully parsed values (if any) and the offset into the script will -// either point to the failing opcode or the end of the script if the function -// was invoked when already at the end of the script. -// -// Invoking this function when already at the end of the script is not -// considered an error and will simply return false. -func (t *scriptTokenizer) Next() bool { - if t.Done() { - return false - } - - op := t.script[t.offset] - - // Only the following op_code will be encountered: - // OP_PUSHDATA*, OP_DATA_*, OP_CHECKSIG, OP_IF, OP_ENDIF, OP_FALSE - switch { - // No additional data. Note that some of the opcodes, notably OP_1NEGATE, - // OP_0, and OP_[1-16] represent the data themselves. - case op == txscript.OP_FALSE || op == txscript.OP_IF || op == txscript.OP_CHECKSIG || op == txscript.OP_ENDIF: - t.offset++ - t.op = op - t.data = nil - return true - - // Data pushes of specific lengths -- OP_DATA_[1-75]. - case op >= txscript.OP_DATA_1 && op <= txscript.OP_DATA_75: - script := t.script[t.offset:] - - // The length should be: int(op) - txscript.OP_DATA_1 + 2, i.e. op is txscript.OP_DATA_10, that means - // the data length should be 10, which is txscript.OP_DATA_10 - txscript.OP_DATA_1 + 1. - // Here, 2 instead of 1 because `script` also includes the opcode which means it contains one more byte. - // Since txscript.OP_DATA_1 is 1, then length is just int(op) - 1 + 2 = int(op) + 1 - length := int(op) + 1 - if len(script) < length { - t.err = fmt.Errorf("opcode %d detected, but script only %d bytes remaining", op, len(script)) - return false - } - - // Move the offset forward and set the opcode and data accordingly. - t.offset += length - t.op = op - t.data = script[1:length] - return true - - case op > txscript.OP_PUSHDATA4: - t.err = fmt.Errorf("unexpected op code %d", op) - return false - - // Data pushes with parsed lengths -- OP_PUSHDATA{1,2,4}. - default: - var length int - switch op { - case txscript.OP_PUSHDATA1: - length = 1 - case txscript.OP_PUSHDATA2: - length = 2 - case txscript.OP_PUSHDATA4: - length = 4 - default: - t.err = fmt.Errorf("unexpected op code %d", op) - return false - } - - script := t.script[t.offset+1:] - if len(script) < length { - t.err = fmt.Errorf("opcode %d requires %d bytes, only %d remaining", op, length, len(script)) - return false - } - - // Next -length bytes are little endian length of data. - var dataLen int - switch length { - case 1: - dataLen = int(script[0]) - case 2: - dataLen = int(binary.LittleEndian.Uint16(script[:length])) - case 4: - dataLen = int(binary.LittleEndian.Uint32(script[:length])) - default: - t.err = fmt.Errorf("invalid opcode length %d", length) - return false - } - - // Move to the beginning of the data. - script = script[length:] - - // Disallow entries that do not fit script or were sign extended. - if dataLen > len(script) || dataLen < 0 { - t.err = fmt.Errorf("opcode %d pushes %d bytes, only %d remaining", op, dataLen, len(script)) - return false - } - - // Move the offset forward and set the opcode and data accordingly. - // 1 is the opcode size, which is just 1 byte. int(op) is the opcode value, - // it should not be mixed with the size. - t.offset += 1 + length + dataLen - t.op = op - t.data = script[:dataLen] - return true - } -} diff --git a/zetaclient/chains/bitcoin/tx_script.go b/zetaclient/chains/bitcoin/tx_script.go index 6f394ef81d..816251024a 100644 --- a/zetaclient/chains/bitcoin/tx_script.go +++ b/zetaclient/chains/bitcoin/tx_script.go @@ -216,7 +216,7 @@ func DecodeOpReturnMemo(scriptHex string) ([]byte, bool, error) { // OP_ENDIF // There are no content-type or any other attributes, it's just raw bytes. func DecodeScript(script []byte) ([]byte, bool, error) { - t := newScriptTokenizer(script) + t := txscript.MakeScriptTokenizer(0, script) if err := checkInscriptionEnvelope(&t); err != nil { return nil, false, errors.Wrap(err, "checkInscriptionEnvelope: unable to check the envelope") @@ -306,7 +306,7 @@ func DecodeTSSVout(vout btcjson.Vout, receiverExpected string, chain chains.Chai return receiverVout, amount, nil } -func decodeInscriptionPayload(t *scriptTokenizer) ([]byte, error) { +func decodeInscriptionPayload(t *txscript.ScriptTokenizer) ([]byte, error) { if !t.Next() || t.Opcode() != txscript.OP_FALSE { return nil, fmt.Errorf("OP_FALSE not found") } @@ -335,7 +335,7 @@ func decodeInscriptionPayload(t *scriptTokenizer) ([]byte, error) { // checkInscriptionEnvelope decodes the envelope for the script monitoring. The format is // OP_PUSHBYTES_32 <32 bytes> OP_CHECKSIG -func checkInscriptionEnvelope(t *scriptTokenizer) error { +func checkInscriptionEnvelope(t *txscript.ScriptTokenizer) error { if !t.Next() || t.Opcode() != txscript.OP_DATA_32 { return fmt.Errorf("cannot obtain public key bytes op %d or err %s", t.Opcode(), t.Err()) } diff --git a/zetaclient/chains/bitcoin/tx_script_test.go b/zetaclient/chains/bitcoin/tx_script_test.go index 394a5d8608..6c4724eb9a 100644 --- a/zetaclient/chains/bitcoin/tx_script_test.go +++ b/zetaclient/chains/bitcoin/tx_script_test.go @@ -659,8 +659,8 @@ func TestDecodeScript(t *testing.T) { }) t.Run("decode error due to missing data for public key", func(t *testing.T) { - // missing OP_ENDIF at the end - data := "2001a7bae79bd61c2368fe41a565061d6cf22b4f509fbc1652caea06d98b8fd0" + // require OP_DATA_32 but OP_DATA_31 is given + data := "1f01a7bae79bd61c2368fe41a565061d6cf22b4f509fbc1652caea06d98b8fd0" script, _ := hex.DecodeString(data) memo, isFound, err := bitcoin.DecodeScript(script)