Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
38 changes: 38 additions & 0 deletions packages/btcindexer/src/bitcoin-merkle-tree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, it, assert } from "vitest";
import { Transaction } from "bitcoinjs-lib";
import { BitcoinMerkleTree } from "./bitcoin-merkle-tree";

// data from https://github.com/gonative-cc/sui-bitcoin-spv/blob/master/tests/test_helpers/proof.py
const rawTxHex = [
"01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff07044c86041b0152ffffffff014034152a01000000434104216220ab283b5e2871c332de670d163fb1b7e509fd67db77997c5568e7c25afd988f19cd5cc5aec6430866ec64b5214826b28e0f7a86458073ff933994b47a5cac00000000",
"01000000042a40ae58b06c3a61ae55dbee05cab546e80c508f71f24ef0cdc9749dac91ea5f000000004a49304602210089c685b37903c4aa62d984929afeaca554d1641f9a668398cd228fb54588f06b0221008a5cfbc5b0a38ba78c4f4341e53272b9cd0e377b2fb740106009b8d7fa693f0b01ffffffff7b999491e30af112b11105cb053bc3633a8a87f44740eb158849a76891ff228b00000000494830450221009a4aa8663ff4017063d2020519f2eade5b4e3e30be69bf9a62b4e6472d1747b2022021ee3b3090b8ce439dbf08a5df31e2dc23d68073ebda45dc573e8a4f74f5cdfc01ffffffffdea82ec2f9e88e0241faa676c13d093030b17c479770c6cc83239436a4327d49000000004a493046022100c29d9de71a34707c52578e355fa0fdc2bb69ce0a957e6b591658a02b1e039d69022100f82c8af79c166a822d305f0832fb800786d831aea419069b3aed97a6edf8f02101fffffffff3e7987da9981c2ae099f97a551783e1b21669ba0bf3aca8fe12896add91a11a0000000049483045022100e332c81781b281a3b35cf75a5a204a2be451746dad8147831255291ebac2604d02205f889a2935270d1bf1ef47db773d68c4d5c6a51bb51f082d3e1c491de63c345601ffffffff0100c817a8040000001976a91420420e56079150b50fb0617dce4c374bd61eccea88ac00000000",
"010000000265a7293b2d69ba51d554cd32ac7586f7fbeaeea06835f26e03a2feab6aec375f000000004a493046022100922361eaafe316003087d355dd3c0ef3d9f44edae661c212a28a91e020408008022100c9b9c84d53d82c0ba9208f695c79eb42a453faea4d19706a8440e1d05e6cff7501fffffffff6971f00725d17c1c531088144b45ed795a307a22d51ca377c6f7f93675bb03a000000008b483045022100d060f2b2f4122edac61a25ea06396fe9135affdabc66d350b5ae1813bc6bf3f302205d8363deef2101fc9f3d528a8b3907e9d29c40772e587dcea12838c574cb80f801410449fce4a25c972a43a6bc67456407a0d4ced782d4cf8c0a35a130d5f65f0561e9f35198349a7c0b4ec79a15fead66bd7642f17cc8c40c5df95f15ac7190c76442ffffffff0200f2052a010000001976a914c3f537bc307c7eda43d86b55695e46047b770ea388ac00cf7b05000000001976a91407bef290008c089a60321b21b1df2d7f2202f40388ac00000000",
"01000000014ab7418ecda2b2531eef0145d4644a4c82a7da1edd285d1aab1ec0595ac06b69000000008c493046022100a796490f89e0ef0326e8460edebff9161da19c36e00c7408608135f72ef0e03e0221009e01ef7bc17cddce8dfda1f1a6d3805c51f9ab2f8f2145793d8e85e0dd6e55300141043e6d26812f24a5a9485c9d40b8712215f0c3a37b0334d76b2c24fcafa587ae5258853b6f49ceeb29cd13ebb76aa79099fad84f516bbba47bd170576b121052f1ffffffff0200a24a04000000001976a9143542e17b6229a25d5b76909f9d28dd6ed9295b2088ac003fab01000000001976a9149cea2b6e3e64ad982c99ebba56a882b9e8a816fe88ac00000000",
];
const transactions = rawTxHex.map((hex) => Transaction.fromHex(hex));

describe("BitcoinMerkleTree", () => {
const tree = new BitcoinMerkleTree(transactions);

it("should calculate the correct Merkle root", () => {
const expectedRootHex = "701179cb9a9e0fe709cc96261b6b943b31362b61dacba94b03f9b71a06cc2eff";
const expectedRoot = Buffer.from(expectedRootHex, "hex"); // little-endian

assert.isTrue(tree.getRoot().equals(expectedRoot), "Merkle root dosent match");
});

it("should generate the correct Merkle proof", () => {
const targetTx = transactions[1];
const proof = tree.getProof(targetTx);

const expectedProofHex = [
"a2fff7e7aa4ffd33f8a05b3a9b6f3cba22826c0232c4784a2aca1c4fe47597f9",
"9013cd2f322864fe9efd45955aacb36ee21efc4f49a4e2aa393a9ba029f0e6b8",
];
const expectedProof = expectedProofHex.map((hex) => Buffer.from(hex, "hex"));

assert.strictEqual(proof.length, 2, "Proof should have 2 elements");
assert.isTrue(proof[0].equals(expectedProof[0]), "First proof element is incorrect");
assert.isTrue(proof[1].equals(expectedProof[1]), "Second proof element is incorrect");
});
});
93 changes: 93 additions & 0 deletions packages/btcindexer/src/bitcoin-merkle-tree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { createHash } from "crypto";
import { Transaction } from "bitcoinjs-lib";

function sha256(data: Buffer): Buffer {
return createHash("sha256").update(data).digest();
}

interface MerkleNode {
hash: Buffer;
preimage: Buffer;
}

export class BitcoinMerkleTree {
private tree: MerkleNode[][];
private readonly root: Buffer;

constructor(transactions: Transaction[]) {
if (!transactions || transactions.length === 0) {
throw new Error("Cannot construct Merkle tree from empty transaction list.");
}
const leafNodes: MerkleNode[] = transactions.map((tx) => {
return {
hash: tx.getHash(),
preimage: sha256(tx.toBuffer()),
};
});

this.tree = [leafNodes];
this.buildTree();
this.root = this.tree[this.tree.length - 1][0].hash;
}

private buildTree(): void {
let currentLevel = this.tree[0];
while (currentLevel.length > 1) {
const nextLevel: MerkleNode[] = [];

if (currentLevel.length % 2 === 1) {
currentLevel.push(currentLevel[currentLevel.length - 1]);
}

for (let i = 0; i < currentLevel.length; i += 2) {
const left = currentLevel[i];
const right = currentLevel[i + 1];

const combined = Buffer.concat([left.hash, right.hash]);
nextLevel.push({
hash: sha256(sha256(combined)),
preimage: sha256(combined),
});
}
currentLevel = nextLevel;
this.tree.push(currentLevel);
}
}

public getRoot(bigEndian = false): Buffer {
return bigEndian ? Buffer.from(this.root).reverse() : this.root;
}

public getProof(targetTx: Transaction): Buffer[] {
const proof: Buffer[] = [];
const targetHash = targetTx.getHash();

let targetIndex = this.tree[0].findIndex((node) => node.hash.equals(targetHash));
if (targetIndex === -1) {
throw new Error("Target leaf not found in the tree.");
}

for (let level = 0; level < this.tree.length - 1; level++) {
const currentLevelNodes = this.tree[level];
let siblingIndex: number;

const isRightNode = targetIndex % 2 === 1;
const isLastNodeOnLevel = targetIndex === currentLevelNodes.length - 1;
const levelHasOddNodes = currentLevelNodes.length % 2 === 1;

if (isLastNodeOnLevel && levelHasOddNodes) {
siblingIndex = targetIndex;
} else if (isRightNode) {
siblingIndex = targetIndex - 1;
} else {
siblingIndex = targetIndex + 1;
}

const siblingNode = currentLevelNodes[siblingIndex];
proof.push(siblingNode.preimage);

targetIndex = Math.floor(targetIndex / 2);
}
return proof;
}
}
47 changes: 24 additions & 23 deletions packages/btcindexer/src/btcindexer.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { describe, it, assert, vi, expect } from "vitest";
import { Indexer, storageFromEnv } from "../src/btcindexer";
import { Block, networks, Transaction } from "bitcoinjs-lib";

Check warning on line 3 in packages/btcindexer/src/btcindexer.test.ts

View workflow job for this annotation

GitHub Actions / test

'Transaction' is defined but never used. Allowed unused vars must match /^_/u
import { MerkleTree } from "merkletreejs";
import SHA256 from "crypto-js/sha256";

Check warning on line 4 in packages/btcindexer/src/btcindexer.test.ts

View workflow job for this annotation

GitHub Actions / test

'SHA256' is defined but never used. Allowed unused vars must match /^_/u
import { SuiClient, SuiClientCfg } from "./sui_client";
import { Deposit, ProofResult } from "./models";
import { BitcoinMerkleTree } from "./bitcoin-merkle-tree"; // Import our new tree

Check warning on line 7 in packages/btcindexer/src/btcindexer.test.ts

View workflow job for this annotation

GitHub Actions / test

'BitcoinMerkleTree' is defined but never used. Allowed unused vars must match /^_/u
import { createHash } from "crypto";

function sha256(data: Buffer): Buffer {

Check warning on line 10 in packages/btcindexer/src/btcindexer.test.ts

View workflow job for this annotation

GitHub Actions / test

'sha256' is defined but never used. Allowed unused vars must match /^_/u
return createHash("sha256").update(data).digest();
}

interface TxInfo {
id: string;
Expand Down Expand Up @@ -102,30 +107,23 @@
return { mockEnv, indexer };
}

function checkTxProof(
proofResult: ProofResult | null,
targetTx: Transaction,
block: Block,
expected: boolean,
) {
assert(proofResult);
assert(block.merkleRoot);
function checkTxProof(proofResult: ProofResult | null, block: Block) {
assert(proofResult, "Proof result should not be null");
assert(block.merkleRoot, "Block must have a Merkle root");

const expectedRootBigEndian = Buffer.from(block.merkleRoot).reverse().toString("hex");
assert.equal(
proofResult.merkleRoot,
expectedRootBigEndian,
"Generated Merkle root should match the block header",
"Generated Merkle root must match the block header's root",
);

const isProofValid = MerkleTree.verify(
proofResult.proofPath,
Buffer.from(targetTx.getHash()).reverse(), // target leaf must be big-endian
Buffer.from(proofResult.merkleRoot, "hex"),
SHA256,
{ isBitcoinTree: true },
);
assert.equal(isProofValid, expected);
assert(Array.isArray(proofResult.proofPath));
assert(proofResult.proofPath.length > 0);
for (const element of proofResult.proofPath) {
assert(Buffer.isBuffer(element));
assert.equal(element.length, 32);
}
}

describe("Indexer.findNbtcDeposits", () => {
Expand Down Expand Up @@ -183,8 +181,10 @@

const tree = indexer.constructMerkleTree(block);
assert(tree);
const proofResult = indexer.getTxProof(tree, targetTx);
checkTxProof(proofResult, targetTx, block, true);
const proofPath = indexer.getTxProof(tree, targetTx);
assert(proofPath);
const merkleRoot = tree.getRoot(true).toString("hex");
checkTxProof({ proofPath, merkleRoot }, block);
});

it("should generate a valid proof for a block with an odd number of transactions (3 txs)", () => {
Expand All @@ -197,8 +197,10 @@

const tree = indexer.constructMerkleTree(block);
assert(tree);
const proofResult = indexer.getTxProof(tree, targetTx);
checkTxProof(proofResult, targetTx, block, true);
const proofPath = indexer.getTxProof(tree, targetTx);
assert(proofPath);
const merkleRoot = tree.getRoot(true).toString("hex");
checkTxProof({ proofPath, merkleRoot }, block);
});
});

Expand Down Expand Up @@ -262,7 +264,6 @@
expect(() => {
Block.fromHex(rawBlockHex);
}).not.toThrow();
console.log("fuck yeah");

assert.equal(
block.getId(),
Expand Down
37 changes: 18 additions & 19 deletions packages/btcindexer/src/btcindexer.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { PutBlocks } from "./api/put-blocks";
import { address, networks, Block, Transaction } from "bitcoinjs-lib";
import { OP_RETURN } from "./opcodes";
import { MerkleTree } from "merkletreejs";
import SHA256 from "crypto-js/sha256";
import { BitcoinMerkleTree } from "./bitcoin-merkle-tree";
import SuiClient, { suiClientFromEnv } from "./sui_client";
import {
Deposit,
ProofResult,

Check warning on line 8 in packages/btcindexer/src/btcindexer.ts

View workflow job for this annotation

GitHub Actions / test

'ProofResult' is defined but never used. Allowed unused vars must match /^_/u
PendingTx,
BlockRecord,
Storage,
Expand Down Expand Up @@ -244,15 +243,18 @@
if (!targetTx || txIndex === undefined || txIndex === -1) continue;

const proof = this.getTxProof(merkleTree, targetTx);

// soundness check
const calculatedRoot = merkleTree.getRoot();
if (
!proof ||
(block.merkleRoot !== undefined &&
proof.merkleRoot !==
Buffer.from(block.merkleRoot).reverse().toString("hex"))
(block.merkleRoot !== undefined && !block.merkleRoot.equals(calculatedRoot))
) {
console.warn(
`WARN: Failed to generate a valid merkle proof for TX ${txInfo.tx_id}. Skipping`,
`WARN: Failed to generate a valid merkle proof for TX ${txInfo.tx_id}. Root mismatch.`,
`Block root: ${block.merkleRoot?.toString(
"hex",
)}, Calculated: ${calculatedRoot.toString("hex")}`,
);
continue;
}
Expand All @@ -261,7 +263,7 @@
tx: targetTx,
blockHeight: txInfo.block_height,
txIndex: txIndex,
proof: proof,
proof: { proofPath: proof, merkleRoot: calculatedRoot.toString("hex") },
});
processedTxIds.push({ tx_id: txInfo.tx_id, success: true });
} catch (e) {
Expand Down Expand Up @@ -299,23 +301,20 @@
}
}

constructMerkleTree(block: Block): MerkleTree | null {
constructMerkleTree(block: Block): BitcoinMerkleTree | null {
if (!block.transactions || block.transactions.length === 0) {
return null;
}
// NOTE: `tx.getHash()` from `bitcoinjs-lib` returns numbers as a bytes in the little-endian
// format - same as Bitcoin Core
// However, the MerkleTree from the `merkletreejs` library expects its leaves to be in the
// big-endian format. So we reverse each hash to convert them big-endian.
const leaves = block.transactions.map((tx) => Buffer.from(tx.getHash()).reverse());
return new MerkleTree(leaves, SHA256, { isBitcoinTree: true });
return new BitcoinMerkleTree(block.transactions);
}

getTxProof(tree: MerkleTree, targetTx: Transaction): ProofResult | null {
const targetLeaf = Buffer.from(targetTx.getHash()).reverse();
const proofPath = tree.getProof(targetLeaf).map((p) => p.data);
const merkleRoot = tree.getRoot().toString("hex");
return { proofPath, merkleRoot };
getTxProof(tree: BitcoinMerkleTree, targetTx: Transaction): Buffer[] | null {
try {
return tree.getProof(targetTx);
} catch (e) {
console.error(`Failed to get proof:`, e);
return null;
}
}

async updateConfirmationsAndFinalize(latestHeight: number): Promise<void> {
Expand Down
4 changes: 1 addition & 3 deletions packages/btcindexer/src/sui_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,7 @@ export class SuiClient {
const target = `${this.nbtcPkg}::${this.nbtcModule}::mint` as const;

for (const args of mintArgs) {
const proofLittleEndian = args.proof.proofPath.map((p) =>
Array.from(Buffer.from(p).reverse()),
);
const proofLittleEndian = args.proof.proofPath.map((p) => Array.from(p));
const txBytes = Array.from(args.tx.toBuffer());

tx.moveCall({
Expand Down
Loading