From 4cddce1f4d5b29723d9ef881b706bac98c55e1b1 Mon Sep 17 00:00:00 2001 From: Thomas Marchand Date: Wed, 23 Oct 2024 13:19:36 +0100 Subject: [PATCH] feat: get block height proof --- src/BitcoinProvider.ts | 5 ++- src/BitcoinRpcProvider.ts | 10 +++-- src/BitcoinTypes.ts | 13 ++++++ src/UtuProvider.ts | 64 ++++++++++++++++++++++++++++ src/tests/BitcoinRpcProvider.test.ts | 59 +++++++++++++++++++++++++ 5 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 src/UtuProvider.ts diff --git a/src/BitcoinProvider.ts b/src/BitcoinProvider.ts index d24f393..2758212 100644 --- a/src/BitcoinProvider.ts +++ b/src/BitcoinProvider.ts @@ -1,8 +1,9 @@ -import { BlockHeader } from "@/BitcoinTypes"; +import { BlockHeader, Block, RawTransaction } from "@/BitcoinTypes"; export interface BitcoinProvider { getBlockHeader(blockHash: string): Promise; + getBlock(blockHash: string): Promise; getBlockHash(blockHeight: number): Promise; getTxOutProof(txids: string[], blockHash?: string): Promise; - getRawTransaction(txid: string, verbose?: boolean): Promise; + getRawTransaction(txid: string, verbose?: boolean): Promise; } diff --git a/src/BitcoinRpcProvider.ts b/src/BitcoinRpcProvider.ts index 671001d..784908c 100644 --- a/src/BitcoinRpcProvider.ts +++ b/src/BitcoinRpcProvider.ts @@ -1,6 +1,6 @@ import fetch from "cross-fetch"; import { BitcoinProvider } from "@/BitcoinProvider"; -import { BlockHeader, RawTransaction } from "@/BitcoinTypes"; +import { BlockHeader, RawTransaction, Block } from "@/BitcoinTypes"; interface RpcConfig { url: string; @@ -59,14 +59,18 @@ export class BitcoinRpcProvider implements BitcoinProvider { return this.callRpcJson("getblockheader", [blockHash, true]); } + async getBlock(blockHash: string, verbosity: number = 1): Promise { + return this.callRpcJson("getblock", [blockHash, verbosity]); + } + async getBlockHash(blockHeight: number): Promise { return this.callRpcJson("getblockhash", [blockHeight]); } async getTxOutProof(txids: string[], blockHash?: string): Promise { - const params = [txids]; + const params: [string[], string?] = [txids]; if (blockHash) { - params.push([blockHash]); + params.push(blockHash); } return this.callRpcJson("gettxoutproof", params); } diff --git a/src/BitcoinTypes.ts b/src/BitcoinTypes.ts index b9ed50d..8316632 100644 --- a/src/BitcoinTypes.ts +++ b/src/BitcoinTypes.ts @@ -16,6 +16,13 @@ export interface BlockHeader { nextblockhash?: string; } +export interface Block extends BlockHeader { + strippedsize: number; + size: number; + weight: number; + tx: string[]; +} + export interface RawTransaction { txid: string; hash: string; @@ -51,3 +58,9 @@ export interface RawTransaction { time: number; blocktime: number; } + +export interface BlockHeightProof { + blockHeader: BlockHeader; + rawCoinbaseTx: string; + merkleProof: string[]; +} diff --git a/src/UtuProvider.ts b/src/UtuProvider.ts new file mode 100644 index 0000000..8e4700f --- /dev/null +++ b/src/UtuProvider.ts @@ -0,0 +1,64 @@ +import { BitcoinProvider } from "./BitcoinProvider"; +import { BlockHeightProof } from "@/BitcoinTypes"; + +export interface UtuProviderResult { + inclusionProof: string; + bitcoinRelayerTx?: string; +} + +export class UtuProvider { + private bitcoinProvider: BitcoinProvider; + + constructor(bitcoinProvider: BitcoinProvider) { + this.bitcoinProvider = bitcoinProvider; + } + + async getBlockHeightProof(height: number): Promise { + const blockHash = await this.bitcoinProvider.getBlockHash(height); + const block = await this.bitcoinProvider.getBlock(blockHash); + const coinbaseTransactionHash = block.tx[0]; + const fullProof = await this.bitcoinProvider.getTxOutProof( + [coinbaseTransactionHash], + blockHash + ); + const rawTransaction = await this.bitcoinProvider.getRawTransaction( + coinbaseTransactionHash + ); + + const parsePartialMerkleTree = (proofHex: string): string[] => { + const proofBytes = Buffer.from(proofHex, "hex"); + let offset = 80 + 4; // Skip 80-byte block header and 4-byte total transactions amount + + // Read number of hashes (varint) + let numHashes = 0; + let shift = 0; + while (true) { + const byte = proofBytes[offset++]; + numHashes |= (byte & 0x7f) << shift; + if (!(byte & 0x80)) break; + shift += 7; + } + + // Extract hashes + const hashes: string[] = []; + for (let i = 0; i < numHashes; i++) { + const hash = proofBytes + .subarray(offset + i * 32, offset + (i + 1) * 32) + .reverse() + .toString("hex"); + hashes.push(hash); + } + + return hashes; + }; + + const leftMerkleBranch = parsePartialMerkleTree(fullProof); + + return { + // because block extends BlockHeader + blockHeader: block, + rawCoinbaseTx: rawTransaction.hex, + merkleProof: leftMerkleBranch, + }; + } +} diff --git a/src/tests/BitcoinRpcProvider.test.ts b/src/tests/BitcoinRpcProvider.test.ts index d4b3550..f5fa8b9 100644 --- a/src/tests/BitcoinRpcProvider.test.ts +++ b/src/tests/BitcoinRpcProvider.test.ts @@ -148,4 +148,63 @@ describe("BitcoinRpcProvider", () => { "1MejoVXRvsmwyDpTpkw3VJ82NsjjT8SyEw" ); }); + + it("should get block", async () => { + const mockBlockHash = + "00000000000000d0dfd4c9d588d325dce4f32c1b31b7c0064cba7025a9b9adcc"; + const mockBlockData = { + hash: "00000000000000d0dfd4c9d588d325dce4f32c1b31b7c0064cba7025a9b9adcc", + confirmations: 639148, + height: 227836, + version: 2, + versionHex: "00000002", + merkleroot: + "38a2518423d8ea76e716d1dc86d742b9e7f3febda7bf9a3e18bcd6c8ad55ff45", + time: 1364140204, + mediantime: 1364138296, + nonce: 30275792, + bits: "1a02816e", + difficulty: 6695826.282596251, + chainwork: + "000000000000000000000000000000000000000000000030f64e660f4b573ba8", + nTx: 100, + previousblockhash: + "00000000000001aa077d7aa84c532a4d69bdbff519609d1da0835261b7a74eb6", + nextblockhash: + "000000000000002579bc6db5a836a81d3a217b549721a0ef1facdf8f069ce0cb", + strippedsize: 39628, + size: 39628, + weight: 158512, + tx: [ + "0f3601a5da2f516fa9d3f80c9bf6e530f1afb0c90da73e8f8ad0630c5483afe5", + // ... other transaction IDs ... + "13aa4bb9a1664275a481766b7fb9ea07c7e60b1a8adb5bdff08db8eccc614e53", + ], + }; + + (fetch as jest.MockedFunction).mockResolvedValueOnce({ + json: jest.fn().mockResolvedValue({ result: mockBlockData }), + } as any); + + const result = await provider.getBlock(mockBlockHash); + expect(result).toEqual(mockBlockData); + expect(result.hash).toBe(mockBlockHash); + expect(result.height).toBe(227836); + expect(result.version).toBe(2); + expect(result.merkleroot).toBe( + "38a2518423d8ea76e716d1dc86d742b9e7f3febda7bf9a3e18bcd6c8ad55ff45" + ); + expect(result.time).toBe(1364140204); + expect(result.nonce).toBe(30275792); + expect(result.bits).toBe("1a02816e"); + expect(result.difficulty).toBe(6695826.282596251); + expect(result.nTx).toBe(100); + expect(result.tx).toHaveLength(2); + expect(result.tx[0]).toBe( + "0f3601a5da2f516fa9d3f80c9bf6e530f1afb0c90da73e8f8ad0630c5483afe5" + ); + expect(result.tx[1]).toBe( + "13aa4bb9a1664275a481766b7fb9ea07c7e60b1a8adb5bdff08db8eccc614e53" + ); + }); });