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: backport Bitcoin inscription support #2825

Merged
merged 6 commits into from
Sep 4, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion changelog.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CHANGELOG

## Unreleased
## v20.0.0
lumtis marked this conversation as resolved.
Show resolved Hide resolved

### Features

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 @@ -73,6 +73,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 @@ -398,6 +399,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