From c1615595b67cc7bbf920aa5a2d156b3fd2ab1f74 Mon Sep 17 00:00:00 2001 From: Alex Gartner Date: Thu, 10 Oct 2024 10:37:24 -0700 Subject: [PATCH] Initial commit --- Dockerfile | 14 ++++ LICENSE | 21 +++++ js/package.json | 23 ++++++ js/src/client.ts | 183 +++++++++++++++++++++++++++++++++++++++++++ js/src/index.ts | 38 +++++++++ js/src/script.ts | 52 ++++++++++++ js/src/tsconfig.json | 11 +++ js/src/util.ts | 1 + 8 files changed, 343 insertions(+) create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 js/package.json create mode 100644 js/src/client.ts create mode 100644 js/src/index.ts create mode 100644 js/src/script.ts create mode 100644 js/src/tsconfig.json create mode 100644 js/src/util.ts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..aef54cf --- /dev/null +++ b/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/LICENSE b/LICENSE new file mode 100644 index 0000000..dd5957b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Meta Protocol, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/js/package.json b/js/package.json new file mode 100644 index 0000000..1a4dd4b --- /dev/null +++ b/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/js/src/client.ts b/js/src/client.ts new file mode 100644 index 0000000..678b90f --- /dev/null +++ b/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/js/src/index.ts b/js/src/index.ts new file mode 100644 index 0000000..5164a6f --- /dev/null +++ b/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/js/src/script.ts b/js/src/script.ts new file mode 100644 index 0000000..f282e39 --- /dev/null +++ b/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/js/src/tsconfig.json b/js/src/tsconfig.json new file mode 100644 index 0000000..4033670 --- /dev/null +++ b/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/js/src/util.ts b/js/src/util.ts new file mode 100644 index 0000000..87c4d36 --- /dev/null +++ b/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