Skip to content

Commit

Permalink
feat: enable parsing Bitcoin deposit memo with inscription; deposit f…
Browse files Browse the repository at this point in the history
…ee improvement (#2768)

* allow parsing Bitcoin memo iwth inscription; improve Bitcoin depositor fee

* add changelog entry

* corrected PR# and function comment typos

* replace GetRecentBlockhash with GetLatestBlockhash; add check on Vsize; misc

* test: cherry pick E2E bitcoin test for inscription for 19.1.2 (#2769)

* test: cherry pick E2E bitcoin test for inscription

* fix dockerfile

---------

Co-authored-by: Lucas Bertrand <[email protected]>
  • Loading branch information
ws4charlie and lumtis authored Aug 27, 2024
1 parent d85e61f commit bc9f419
Show file tree
Hide file tree
Showing 23 changed files with 1,019 additions and 221 deletions.
19 changes: 19 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
# CHANGELOG

## v19.2.0

### Features

* [2768](https://github.com/zeta-chain/node/pull/2768) - enable parsing Bitcoin deposit memo with inscription; deposit fee improvement

## v19.1.1

Fix release CI build

## v19.1.0

### Breaking Changes

* `zetaclientd` now prompts for a "Solana Relayer Key" password. If you have not configured a solana relayer key, you should enter an empty password.

### Fixes
* [2628](https://github.com/zeta-chain/node/pull/2628) - avoid submitting invalid hashes to outbound tracker

### Features

* [2524](https://github.com/zeta-chain/node/pull/2524) - add inscription envolop parsing
* [2533](https://github.com/zeta-chain/node/pull/2533) - parse memo from both OP_RETURN and inscription
* [2568](https://github.com/zeta-chain/node/pull/2568) - improve AppContext by converging chains, chainParams, enabledChains, and additionalChains into a single zctx.Chain
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 @@ -223,6 +223,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
Loading

0 comments on commit bc9f419

Please sign in to comment.