Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow parsing Bitcoin deposit memo with inscription #2957

Merged
merged 8 commits into from
Oct 4, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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";
lumtis marked this conversation as resolved.
Show resolved Hide resolved
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;
lumtis marked this conversation as resolved.
Show resolved Hide resolved
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]));
lumtis marked this conversation as resolved.
Show resolved Hide resolved
}

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();
}
lumtis marked this conversation as resolved.
Show resolved Hide resolved

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),
lumtis marked this conversation as resolved.
Show resolved Hide resolved
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;
}
lumtis marked this conversation as resolved.
Show resolved Hide resolved
}
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();
lumtis marked this conversation as resolved.
Show resolved Hide resolved

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 });
lumtis marked this conversation as resolved.
Show resolved Hide resolved
});

// 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);
lumtis marked this conversation as resolved.
Show resolved Hide resolved

const rawHex = zetaClient.buildRevealTxn(to,{ txn, idx }, Number(amount), feeRate).toString("hex");
lumtis marked this conversation as resolved.
Show resolved Hide resolved
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
*/
lumtis marked this conversation as resolved.
Show resolved Hide resolved
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);
}
lumtis marked this conversation as resolved.
Show resolved Hide resolved

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);
}
lumtis marked this conversation as resolved.
Show resolved Hide resolved

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));
lumtis marked this conversation as resolved.
Show resolved Hide resolved
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
Loading