Skip to content

Commit

Permalink
feat: allow parsing Bitcoin deposit memo with inscription (#2957)
Browse files Browse the repository at this point in the history
* inscription support

* disable for mainnet

* format

* fix unit tests

* changelog

* fix filename

* fix the error for BTC donations
  • Loading branch information
lumtis authored Oct 4, 2024
1 parent e124d34 commit 1a32f05
Show file tree
Hide file tree
Showing 16 changed files with 818 additions and 22 deletions.
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions contrib/localnet/bitcoin-sidecar/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
23 changes: 23 additions & 0 deletions contrib/localnet/bitcoin-sidecar/js/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
183 changes: 183 additions & 0 deletions contrib/localnet/bitcoin-sidecar/js/src/client.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
38 changes: 38 additions & 0 deletions contrib/localnet/bitcoin-sidecar/js/src/index.ts
Original file line number Diff line number Diff line change
@@ -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
*/
52 changes: 52 additions & 0 deletions contrib/localnet/bitcoin-sidecar/js/src/script.ts
Original file line number Diff line number Diff line change
@@ -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;
}
11 changes: 11 additions & 0 deletions contrib/localnet/bitcoin-sidecar/js/src/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"target": "es6",
"moduleResolution": "node",
"sourceMap": true,
"outDir": "dist"
},
"lib": ["es2015"]
}
1 change: 1 addition & 0 deletions contrib/localnet/bitcoin-sidecar/js/src/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const toXOnly = pubKey => (pubKey.length === 32 ? pubKey : pubKey.slice(1, 33));
13 changes: 13 additions & 0 deletions contrib/localnet/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions e2e/e2etests/e2etests.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ const (
TestBitcoinWithdrawP2SHName = "bitcoin_withdraw_p2sh"
TestBitcoinWithdrawInvalidAddressName = "bitcoin_withdraw_invalid"
TestBitcoinWithdrawRestrictedName = "bitcoin_withdraw_restricted"
TestExtractBitcoinInscriptionMemoName = "bitcoin_memo_from_inscription"

/*
Application tests
Expand Down Expand Up @@ -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",
Expand Down
Loading

0 comments on commit 1a32f05

Please sign in to comment.