diff --git a/typescript/src/bitcoin.ts b/typescript/src/bitcoin.ts index 3ef1f7025..98b8e0e27 100644 --- a/typescript/src/bitcoin.ts +++ b/typescript/src/bitcoin.ts @@ -2,6 +2,7 @@ import bcoin, { TX } from "bcoin" import wif from "wif" import bufio from "bufio" import hash160 from "bcrypto/lib/hash160" +import sha256 from "bcrypto/lib/sha256-browser.js" import { BigNumber } from "ethers" import { Hex } from "./hex" import { BitcoinNetwork, toBcoinNetwork } from "./bitcoin-network" @@ -172,6 +173,147 @@ export interface TransactionMerkleBranch { position: number } +/** + * BlockHeader represents the header of a Bitcoin block. For reference, see: + * https://developer.bitcoin.org/reference/block_chain.html#block-headers. + */ +export interface BlockHeader { + /** + * The block version number that indicates which set of block validation rules + * to follow. The field is 4-byte long. + */ + version: number + + /** + * The hash of the previous block's header. The field is 32-byte long. + */ + previousBlockHeaderHash: Hex + + /** + * The hash derived from the hashes of all transactions included in this block. + * The field is 32-byte long. + */ + merkleRootHash: Hex + + /** + * The Unix epoch time when the miner started hashing the header. The field is + * 4-byte long. + */ + time: number + + /** + * Bits that determine the target threshold this block's header hash must be + * less than or equal to. The field is 4-byte long. + */ + bits: number + + /** + * An arbitrary number miners change to modify the header hash in order to + * produce a hash less than or equal to the target threshold. The field is + * 4-byte long. + */ + nonce: number +} + +/** + * Serializes a BlockHeader to the raw representation. + * @param blockHeader - block header. + * @returns Serialized block header. + */ +export function serializeBlockHeader(blockHeader: BlockHeader): Hex { + const buffer = Buffer.alloc(80) + buffer.writeUInt32LE(blockHeader.version, 0) + blockHeader.previousBlockHeaderHash.toBuffer().copy(buffer, 4) + blockHeader.merkleRootHash.toBuffer().copy(buffer, 36) + buffer.writeUInt32LE(blockHeader.time, 68) + buffer.writeUInt32LE(blockHeader.bits, 72) + buffer.writeUInt32LE(blockHeader.nonce, 76) + return Hex.from(buffer) +} + +/** + * Deserializes a block header in the raw representation to BlockHeader. + * @param rawBlockHeader - BlockHeader in the raw format. + * @returns Block header as a BlockHeader. + */ +export function deserializeBlockHeader(rawBlockHeader: Hex): BlockHeader { + const buffer = rawBlockHeader.toBuffer() + const version = buffer.readUInt32LE(0) + const previousBlockHeaderHash = buffer.slice(4, 36) + const merkleRootHash = buffer.slice(36, 68) + const time = buffer.readUInt32LE(68) + const bits = buffer.readUInt32LE(72) + const nonce = buffer.readUInt32LE(76) + + return { + version: version, + previousBlockHeaderHash: Hex.from(previousBlockHeaderHash), + merkleRootHash: Hex.from(merkleRootHash), + time: time, + bits: bits, + nonce: nonce, + } +} + +/** + * Converts a block header's bits into target. + * @param bits - bits from block header. + * @returns Target as a BigNumber. + */ +export function bitsToTarget(bits: number): BigNumber { + // A serialized 80-byte block header stores the `bits` value as a 4-byte + // little-endian hexadecimal value in a slot including bytes 73, 74, 75, and + // 76. This function's input argument is expected to be a numerical + // representation of that 4-byte value reverted to the big-endian order. + // For example, if the `bits` little-endian value in the header is + // `0xcb04041b`, it must be reverted to the big-endian form `0x1b0404cb` and + // turned to a decimal number `453248203` in order to be used as this + // function's input. + // + // The `bits` 4-byte big-endian representation is a compact value that works + // like a base-256 version of scientific notation. It encodes the target + // exponent in the first byte and the target mantissa in the last three bytes. + // Referring to the previous example, if `bits = 453248203`, the hexadecimal + // representation is `0x1b0404cb` so the exponent is `0x1b` while the mantissa + // is `0x0404cb`. + // + // To extract the exponent, we need to shift right by 3 bytes (24 bits), + // extract the last byte of the result, and subtract 3 (because of the + // mantissa length): + // - 0x1b0404cb >>> 24 = 0x0000001b + // - 0x0000001b & 0xff = 0x1b + // - 0x1b - 3 = 24 (decimal) + // + // To extract the mantissa, we just need to take the last three bytes: + // - 0x1b0404cb & 0xffffff = 0x0404cb = 263371 (decimal) + // + // The final difficulty can be computed as mantissa * 256^exponent: + // - 263371 * 256^24 = + // 1653206561150525499452195696179626311675293455763937233695932416 (decimal) + // + // Sources: + // - https://developer.bitcoin.org/reference/block_chain.html#target-nbits + // - https://wiki.bitcoinsv.io/index.php/Target + + const exponent = ((bits >>> 24) & 0xff) - 3 + const mantissa = bits & 0xffffff + + const target = BigNumber.from(mantissa).mul(BigNumber.from(256).pow(exponent)) + return target +} + +/** + * Converts difficulty target to difficulty. + * @param target - difficulty target. + * @returns Difficulty as a BigNumber. + */ +export function targetToDifficulty(target: BigNumber): BigNumber { + const DIFF1_TARGET = BigNumber.from( + "0xffff0000000000000000000000000000000000000000000000000000" + ) + return DIFF1_TARGET.div(target) +} + /** * Represents a Bitcoin client. */ @@ -374,6 +516,26 @@ export function computeHash160(text: string): string { return hash160.digest(Buffer.from(text, "hex")).toString("hex") } +/** + * Computes the double SHA256 for the given text. + * @param text - Text the double SHA256 is computed for. + * @returns Hash as a 32-byte un-prefixed hex string. + */ +export function computeHash256(text: Hex): Hex { + const firstHash: Buffer = sha256.digest(text.toBuffer()) + const secondHash: Buffer = sha256.digest(firstHash) + return Hex.from(secondHash) +} + +/** + * Converts a hash in hex string in little endian to a BigNumber. + * @param hash - Hash in hex-string format. + * @returns BigNumber representation of the hash. + */ +export function hashLEToBigNumber(hash: Hex): BigNumber { + return BigNumber.from(hash.reverse().toPrefixedString()) +} + /** * Encodes a public key hash into a P2PKH/P2WPKH address. * @param publicKeyHash - public key hash that will be encoded. Must be an diff --git a/typescript/src/index.ts b/typescript/src/index.ts index dd109673c..8f5384bde 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -22,6 +22,8 @@ import { getOptimisticMintingRequest, } from "./optimistic-minting" +import { validateTransactionProof } from "./proof" + export const TBTC = { calculateDepositAddress, suggestDepositWallet, @@ -43,6 +45,10 @@ export const OptimisticMinting = { getOptimisticMintingRequest, } +export const Bitcoin = { + validateTransactionProof, +} + export { TransactionHash as BitcoinTransactionHash, Transaction as BitcoinTransaction, diff --git a/typescript/src/proof.ts b/typescript/src/proof.ts index b32cd320b..f07cf8161 100644 --- a/typescript/src/proof.ts +++ b/typescript/src/proof.ts @@ -4,7 +4,16 @@ import { TransactionMerkleBranch, Client as BitcoinClient, TransactionHash, + computeHash256, + deserializeBlockHeader, + bitsToTarget, + targetToDifficulty, + hashLEToBigNumber, + serializeBlockHeader, + BlockHeader, } from "./bitcoin" +import { BigNumber } from "ethers" +import { Hex } from "./hex" /** * Assembles a proof that a given transaction was included in the blockchain and @@ -23,6 +32,7 @@ export async function assembleTransactionProof( const confirmations = await bitcoinClient.getTransactionConfirmations( transactionHash ) + if (confirmations < requiredConfirmations) { throw new Error( "Transaction confirmations number[" + @@ -36,9 +46,14 @@ export async function assembleTransactionProof( const latestBlockHeight = await bitcoinClient.latestBlockHeight() const txBlockHeight = latestBlockHeight - confirmations + 1 + // We subtract `1` from `requiredConfirmations` because the header at + // `txBlockHeight` is already included in the headers chain and is considered + // the first confirmation. So we only need to retrieve `requiredConfirmations - 1` + // subsequent block headers to reach the desired number of confirmations for + // the transaction. const headersChain = await bitcoinClient.getHeadersChain( txBlockHeight, - requiredConfirmations + requiredConfirmations - 1 ) const merkleBranch = await bitcoinClient.getTransactionMerkle( @@ -60,7 +75,7 @@ export async function assembleTransactionProof( /** * Create a proof of transaction inclusion in the block by concatenating * 32-byte-long hash values. The values are converted to little endian form. - * @param txMerkleBranch - Branch of a merkle tree leading to a transaction. + * @param txMerkleBranch - Branch of a Merkle tree leading to a transaction. * @returns Transaction inclusion proof in hexadecimal form. */ function createMerkleProof(txMerkleBranch: TransactionMerkleBranch): string { @@ -70,3 +85,315 @@ function createMerkleProof(txMerkleBranch: TransactionMerkleBranch): string { }) return proof.toString("hex") } + +/** + * Proves that a transaction with the given hash is included in the Bitcoin + * blockchain by validating the transaction's inclusion in the Merkle tree and + * verifying that the block containing the transaction has enough confirmations. + * @param transactionHash The hash of the transaction to be validated. + * @param requiredConfirmations The number of confirmations required for the + * transaction to be considered valid. The transaction has 1 confirmation + * when it is in the block at the current blockchain tip. Every subsequent + * block added to the blockchain is one additional confirmation. + * @param previousDifficulty The difficulty of the previous Bitcoin epoch. + * @param currentDifficulty The difficulty of the current Bitcoin epoch. + * @param bitcoinClient The client for interacting with the Bitcoin blockchain. + * @throws {Error} If the transaction is not included in the Bitcoin blockchain + * or if the block containing the transaction does not have enough + * confirmations. + * @dev The function should be used within a try-catch block. + * @returns An empty return value. + */ +export async function validateTransactionProof( + transactionHash: TransactionHash, + requiredConfirmations: number, + previousDifficulty: BigNumber, + currentDifficulty: BigNumber, + bitcoinClient: BitcoinClient +) { + if (requiredConfirmations < 1) { + throw new Error("The number of required confirmations but at least 1") + } + + const proof = await assembleTransactionProof( + transactionHash, + requiredConfirmations, + bitcoinClient + ) + + const bitcoinHeaders: BlockHeader[] = splitHeaders(proof.bitcoinHeaders) + if (bitcoinHeaders.length != requiredConfirmations) { + throw new Error("Wrong number of confirmations") + } + + const merkleRootHash: Hex = bitcoinHeaders[0].merkleRootHash + const intermediateNodeHashes: Hex[] = splitMerkleProof(proof.merkleProof) + + validateMerkleTree( + transactionHash, + merkleRootHash, + intermediateNodeHashes, + proof.txIndexInBlock + ) + + validateBlockHeadersChain( + bitcoinHeaders, + previousDifficulty, + currentDifficulty + ) +} + +/** + * Validates the Merkle tree by checking if the provided transaction hash, + * Merkle root hash, intermediate node hashes, and transaction index parameters + * produce a valid Merkle proof. + * @param transactionHash The hash of the transaction being validated. + * @param merkleRootHash The Merkle root hash that the intermediate node hashes + * should compute to. + * @param intermediateNodeHashes The Merkle tree intermediate node hashes. + * This is a list of hashes the transaction being validated is paired + * with in the Merkle tree. + * @param transactionIndex The index of the transaction being validated within + * the block, used to determine the path to traverse in the Merkle tree. + * @throws {Error} If the Merkle tree is not valid. + * @returns An empty return value. + */ +function validateMerkleTree( + transactionHash: TransactionHash, + merkleRootHash: Hex, + intermediateNodeHashes: Hex[], + transactionIndex: number +) { + // Shortcut for a block that contains only a single transaction (coinbase). + if ( + transactionHash.reverse().equals(merkleRootHash) && + transactionIndex == 0 && + intermediateNodeHashes.length == 0 + ) { + return + } + + validateMerkleTreeHashes( + transactionHash, + merkleRootHash, + intermediateNodeHashes, + transactionIndex + ) +} + +/** + * Validates the transaction's Merkle proof by traversing the Merkle tree + * starting from the provided transaction hash and using the intermediate node + * hashes to compute the root hash. If the computed root hash does not match the + * merkle root hash, an error is thrown. + * @param transactionHash The hash of the transaction being validated. + * @param merkleRootHash The Merkle root hash that the intermediate nodes should + * compute to. + * @param intermediateNodeHashes The Merkle tree intermediate node hashes. + * This is a list of hashes the transaction being validated is paired + * with in the Merkle tree. + * @param transactionIndex The index of the transaction in the block, used + * to determine the path to traverse in the Merkle tree. + * @throws {Error} If the intermediate nodes are of an invalid length or if the + * computed root hash does not match the merkle root hash parameter. + * @returns An empty return value. + */ +function validateMerkleTreeHashes( + transactionHash: TransactionHash, + merkleRootHash: Hex, + intermediateNodeHashes: Hex[], + transactionIndex: number +) { + // To prove the transaction inclusion in a block we only need the hashes that + // form a path from the transaction being validated to the Merkle root hash. + // If the Merkle tree looks like this: + // + // h_01234567 + // / \ + // h_0123 h_4567 + // / \ / \ + // h_01 h_23 h_45 h_67 + // / \ / \ / \ / \ + // h_0 h_1 h_2 h_3 h_4 h_5 h_6 h_7 + // + // and the transaction hash to be validated is h_5 the following data + // will be used: + // - `transactionHash`: h_5 + // - `merkleRootHash`: h_01234567 + // - `intermediateNodeHashes`: [h_4, h_67, h_0123] + // - `transactionIndex`: 5 + // + // The following calculations will be performed: + // - h_4 and h_5 will be hashed to obtain h_45 + // - h_45 and h_67 will be hashed to obtain h_4567 + // - h_0123 will be hashed with h_4567 to obtain h_1234567 (Merkle root hash). + + // Note that when we move up the Merkle tree calculating parent hashes we need + // to join children hashes. The information which child hash should go first + // is obtained from `transactionIndex`. When `transactionIndex` is odd the + // hash taken from `intermediateNodeHashes` must go first. If it is even the + // hash from previous calculation must go first. The `transactionIndex` is + // divided by `2` after every hash calculation. + + if (intermediateNodeHashes.length === 0) { + throw new Error("Invalid merkle tree") + } + + let idx = transactionIndex + let currentHash = transactionHash.reverse() + + // Move up the Merkle tree hashing current hash value with hashes taken + // from `intermediateNodeHashes`. Use `idx` to determine the order of joining + // children hashes. + for (let i = 0; i < intermediateNodeHashes.length; i++) { + if (idx % 2 === 1) { + // If the current value of idx is odd the hash taken from + // `intermediateNodeHashes` goes before the current hash. + currentHash = computeHash256( + Hex.from(intermediateNodeHashes[i].toString() + currentHash.toString()) + ) + } else { + // If the current value of idx is even the hash taken from the current + // hash goes before the hash taken from `intermediateNodeHashes`. + currentHash = computeHash256( + Hex.from(currentHash.toString() + intermediateNodeHashes[i].toString()) + ) + } + + // Divide the value of `idx` by `2` when we move one level up the Merkle + // tree. + idx = idx >> 1 + } + + // Verify we arrived at the same value of Merkle root hash as the one stored + // in the block header. + if (!currentHash.equals(merkleRootHash)) { + throw new Error( + "Transaction Merkle proof is not valid for provided header and transaction hash" + ) + } +} + +/** + * Validates a chain of consecutive block headers by checking each header's + * difficulty, hash, and continuity with the previous header. This function can + * be used to validate a series of Bitcoin block headers for their validity. + * @param blockHeaders An array of block headers that form the chain to be + * validated. + * @param previousEpochDifficulty The difficulty of the previous Bitcoin epoch. + * @param currentEpochDifficulty The difficulty of the current Bitcoin epoch. + * @dev The block headers must come from Bitcoin epochs with difficulties marked + * by the previous and current difficulties. If a Bitcoin difficulty relay + * is used to provide these values and the relay is up-to-date, only the + * recent block headers will pass validation. Block headers older than the + * current and previous Bitcoin epochs will fail. + * @throws {Error} If any of the block headers are invalid, or if the block + * header chain is not continuous. + * @returns An empty return value. + */ +function validateBlockHeadersChain( + blockHeaders: BlockHeader[], + previousEpochDifficulty: BigNumber, + currentEpochDifficulty: BigNumber +) { + let requireCurrentDifficulty: boolean = false + let previousBlockHeaderHash: Hex = Hex.from("00") + + for (let index = 0; index < blockHeaders.length; index++) { + const currentHeader = blockHeaders[index] + + // Check if the current block header stores the hash of the previously + // processed block header. Skip the check for the first header. + if (index !== 0) { + if ( + !previousBlockHeaderHash.equals(currentHeader.previousBlockHeaderHash) + ) { + throw new Error("Invalid headers chain") + } + } + + const difficultyTarget = bitsToTarget(currentHeader.bits) + + const currentBlockHeaderHash = computeHash256( + serializeBlockHeader(currentHeader) + ) + + // Ensure the header has sufficient work. + if (hashLEToBigNumber(currentBlockHeaderHash).gt(difficultyTarget)) { + throw new Error("Insufficient work in the header") + } + + // Save the current block header hash to compare it with the next block + // header's previous block header hash. + previousBlockHeaderHash = currentBlockHeaderHash + + // Check if the stored block difficulty is equal to previous or current + // difficulties. + const difficulty = targetToDifficulty(difficultyTarget) + + if (previousEpochDifficulty.eq(1) && currentEpochDifficulty.eq(1)) { + // Special case for Bitcoin Testnet. Do not check block's difficulty + // due to required difficulty falling to `1` for Testnet. + continue + } + + if ( + !difficulty.eq(previousEpochDifficulty) && + !difficulty.eq(currentEpochDifficulty) + ) { + throw new Error( + "Header difficulty not at current or previous Bitcoin difficulty" + ) + } + + // Additionally, require the header to be at current difficulty if some + // headers at current difficulty have already been seen. This ensures + // there is at most one switch from previous to current difficulties. + if (requireCurrentDifficulty && !difficulty.eq(currentEpochDifficulty)) { + throw new Error("Header must be at current Bitcoin difficulty") + } + + // If the header is at current difficulty, require the subsequent headers to + // be at current difficulty as well. + requireCurrentDifficulty = difficulty.eq(currentEpochDifficulty) + } +} + +/** + * Splits a given Merkle proof string into an array of intermediate node hashes. + * @param merkleProof A string representation of the Merkle proof. + * @returns An array of intermediate node hashes. + * @throws {Error} If the length of the Merkle proof is not a multiple of 64. + */ +export function splitMerkleProof(merkleProof: string): Hex[] { + if (merkleProof.length % 64 != 0) { + throw new Error("Incorrect length of Merkle proof") + } + + const intermediateNodeHashes: Hex[] = [] + for (let i = 0; i < merkleProof.length; i += 64) { + intermediateNodeHashes.push(Hex.from(merkleProof.slice(i, i + 64))) + } + + return intermediateNodeHashes +} + +/** + * Splits Bitcoin block headers in the raw format into an array of BlockHeaders. + * @param blockHeaders - string that contains block headers in the raw format. + * @returns Array of BlockHeader objects. + */ +export function splitHeaders(blockHeaders: string): BlockHeader[] { + if (blockHeaders.length % 160 !== 0) { + throw new Error("Incorrect length of Bitcoin headers") + } + + const result: BlockHeader[] = [] + for (let i = 0; i < blockHeaders.length; i += 160) { + result.push( + deserializeBlockHeader(Hex.from(blockHeaders.substring(i, i + 160))) + ) + } + + return result +} diff --git a/typescript/test/bitcoin.test.ts b/typescript/test/bitcoin.test.ts index 543095358..7321c99c7 100644 --- a/typescript/test/bitcoin.test.ts +++ b/typescript/test/bitcoin.test.ts @@ -5,9 +5,17 @@ import { decodeBitcoinAddress, isPublicKeyHashLength, locktimeToNumber, + BlockHeader, + serializeBlockHeader, + deserializeBlockHeader, + hashLEToBigNumber, + bitsToTarget, + targetToDifficulty, } from "../src/bitcoin" import { calculateDepositRefundLocktime } from "../src/deposit" import { BitcoinNetwork } from "../src/bitcoin-network" +import { Hex } from "../src/hex" +import { BigNumber } from "ethers" describe("Bitcoin", () => { describe("compressPublicKey", () => { @@ -354,4 +362,106 @@ describe("Bitcoin", () => { }) }) }) + + describe("serializeBlockHeader", () => { + it("calculates correct value", () => { + const blockHeader: BlockHeader = { + version: 536870916, + previousBlockHeaderHash: Hex.from( + "a5a3501e6ba1f3e2a1ee5d29327a549524ed33f272dfef300045660000000000" + ), + merkleRootHash: Hex.from( + "e27d241ca36de831ab17e6729056c14a383e7a3f43d56254f846b49649775112" + ), + time: 1641914003, + bits: 436256810, + nonce: 778087099, + } + + const expectedSerializedBlockHeader = Hex.from( + "04000020a5a3501e6ba1f3e2a1ee5d29327a549524ed33f272dfef30004566000000" + + "0000e27d241ca36de831ab17e6729056c14a383e7a3f43d56254f846b496497751" + + "12939edd612ac0001abbaa602e" + ) + + expect(serializeBlockHeader(blockHeader)).to.be.deep.equal( + expectedSerializedBlockHeader + ) + }) + }) + + describe("deserializeBlockHeader", () => { + it("calculates correct value", () => { + const rawBlockHeader = Hex.from( + "04000020a5a3501e6ba1f3e2a1ee5d29327a549524ed33f272dfef30004566000000" + + "0000e27d241ca36de831ab17e6729056c14a383e7a3f43d56254f846b496497751" + + "12939edd612ac0001abbaa602e" + ) + + const expectedBlockHeader: BlockHeader = { + version: 536870916, + previousBlockHeaderHash: Hex.from( + "a5a3501e6ba1f3e2a1ee5d29327a549524ed33f272dfef300045660000000000" + ), + merkleRootHash: Hex.from( + "e27d241ca36de831ab17e6729056c14a383e7a3f43d56254f846b49649775112" + ), + time: 1641914003, + bits: 436256810, + nonce: 778087099, + } + + expect(deserializeBlockHeader(rawBlockHeader)).to.deep.equal( + expectedBlockHeader + ) + }) + }) + + describe("hashLEToBigNumber", () => { + it("calculates correct value", () => { + const hash = Hex.from( + "31552151fbef8e96a33f979e6253d29edf65ac31b04802319e00000000000000" + ) + const expectedBigNumber = BigNumber.from( + "992983769452983078390935942095592601503357651673709518345521" + ) + expect(hashLEToBigNumber(hash)).to.equal(expectedBigNumber) + }) + }) + + describe("bitsToTarget", () => { + it("calculates correct value for random block header bits", () => { + const difficultyBits = 436256810 + const expectedDifficultyTarget = BigNumber.from( + "1206233370197704583969288378458116959663044038027202007138304" + ) + expect(bitsToTarget(difficultyBits)).to.equal(expectedDifficultyTarget) + }) + + it("calculates correct value for block header with difficulty of 1", () => { + const difficultyBits = 486604799 + const expectedDifficultyTarget = BigNumber.from( + "26959535291011309493156476344723991336010898738574164086137773096960" + ) + expect(bitsToTarget(difficultyBits)).to.equal(expectedDifficultyTarget) + }) + }) + + describe("targetToDifficulty", () => { + it("calculates correct value for random block header bits", () => { + const target = BigNumber.from( + "1206233370197704583969288378458116959663044038027202007138304" + ) + const expectedDifficulty = BigNumber.from("22350181") + expect(targetToDifficulty(target)).to.equal(expectedDifficulty) + }) + + it("calculates correct value for block header with difficulty of 1", () => { + const target = BigNumber.from( + "26959535291011309493156476344723991336010898738574164086137773096960" + ) + const expectedDifficulty = BigNumber.from("1") + expect(targetToDifficulty(target)).to.equal(expectedDifficulty) + }) + }) }) diff --git a/typescript/test/data/proof.ts b/typescript/test/data/proof.ts index 6540d709d..a0acdf37c 100644 --- a/typescript/test/data/proof.ts +++ b/typescript/test/data/proof.ts @@ -336,3 +336,265 @@ export const multipleInputsProofTestData: ProofTestData = { "b58e6cd93b85290a885dd749f4d61c62ed3e031ad9a83746", }, } + +/** + * Represents a set of data used for given transaction proof validation scenario. + */ +export interface TransactionProofData { + requiredConfirmations: number + bitcoinChainData: { + transaction: Transaction + accumulatedTxConfirmations: number + latestBlockHeight: number + headersChain: string + transactionMerkleBranch: TransactionMerkleBranch + previousDifficulty: BigNumber + currentDifficulty: BigNumber + } +} + +/** + * Test data that is based on a random Bitcoin mainnet transaction with all the + * blocks headers from one difficulty epoch + * https://live.blockcypher.com/btc/tx/713525ee9d9ab23433cd6ad470566ba1f47cac2d7f119cc50119128a84d718aa/ + */ +export const transactionConfirmationsInOneEpochData: TransactionProofData = { + requiredConfirmations: 6, + bitcoinChainData: { + transaction: { + transactionHash: TransactionHash.from( + "713525ee9d9ab23433cd6ad470566ba1f47cac2d7f119cc50119128a84d718aa" + ), + inputs: [ + { + transactionHash: TransactionHash.from( + "91b83d443f32d5a1e87a200eac5d3501af0f156bef3a59d5e8805b4679c4a2a5" + ), + outputIndex: 3, + scriptSig: Hex.from( + "473044022008bfea0e9b8e24b0ab04de42db2dd8aea9e6f764f9f94aa88e284d" + + "5c2800706d02200d793f7441ea17802da993914da732e2f4e354e54dd168636b" + + "e73e6b60a39eab012103e356007964fc225a44c38352899c41e6293a97f8d811" + + "5998ae7e97184704c092" + ), + }, + ], + outputs: [ + { + outputIndex: 0, + value: BigNumber.from(5500), + scriptPubKey: Hex.from( + "6a4c5058325b63f33166b9786bdd34b2be8160d5e4fbef9a0a45e773c4201a82" + + "a4b1eb44793a61d19892a7f8aede51b70953a210e9e8dba54375e4a06d95d68f" + + "90aa3c6e8914000bd7e50056000bd775012528" + ), + }, + { + outputIndex: 1, + value: BigNumber.from(48850), + scriptPubKey: Hex.from( + "76a914953490146c3ae270d66e09c4d12df4573d24c75b88ac" + ), + }, + { + outputIndex: 2, + value: BigNumber.from(48850), + scriptPubKey: Hex.from( + "a914352481ec2fecfde0c5cdc635a383c4ac27b9f71e87" + ), + }, + { + outputIndex: 3, + value: BigNumber.from(12614691), + scriptPubKey: Hex.from( + "76a914b00de0cc7b5e518f7d1e43d6e5ecbd52e0cd0c2f88ac" + ), + }, + ], + }, + accumulatedTxConfirmations: 1798, + latestBlockHeight: 777963, + headersChain: + "00e0ff2f5ad9c09e1d8aae777a58bf29c41621eb629032598f7900000000000000000" + + "0004dea17724c3b7e67d4cf1ac41a4c7527b884f7406575eaf5b8efaf2fb12572ecb1" + + "ace86339300717760098100000ff3fd3ab40174610c286e569edd20fa713bd98bab53" + + "bee83050000000000000000002345f5ef807cf75de7b30ccfe493c46c6e07aca044aa" + + "2aa106141637f1bb8500a6ade863393007177fbbd4b300800120646d493817f0ac988" + + "6a0a194ca3a957f70c3eb642ffd05000000000000000000d95674b737f097f042eebe" + + "b970c09b274df7e72a9c202ff2292ed72b056ee90967aee863393007172e2bb92e006" + + "03b27a391d248c258ef628dfb8c710ce44c8017667a07941402000000000000000000" + + "35214e58eb018dea1efa7eaf1b7f19ff2d6f0310c122be6dc8c0258d9524ae9382aee" + + "863393007173e82b2000000002003c7003ff9a79f16d956fc764b43b35080efe3a820" + + "af050000000000000000007808e96809cd46d5898d86faabc8f28a8b6572eb8399796" + + "70b2851d78fc1f75f17b3e86339300717450f17650400e020fb9b6a28bb2e9cea36d3" + + "40588f19ffa4e944b050e73f03000000000000000000bbd7534f2550ee99f31efcd77" + + "564f1b5b3f3966a76847896a8d9f9ee964d670ba2b4e8633930071777b10cfc", + transactionMerkleBranch: { + blockHeight: 776166, + merkle: [ + "f6ce0e34cc5b2a4b8cd4fd02a65d7cf62013206969e8e5cf1df18f994abcf1ff", + "08899ec43299b324583722f3e7d0938446a1f31a6ab34c8e24cb4ea9ba6cd384", + "9677b6075dfa2da8bcc98aa10ae7d30f81e6506215eadd3f3739a5d987e62b35", + "aa6712d8820c06ec8ce99f9c19d580ab54bb45f69b426935153b81e7d412ddba", + "b38be47e1dd9a7324ad81a395a133f26fc88cb736a4998dbba6cbabca10629a8", + "13bdefbf92421aa7861528e16e7046b569d25ee0f4b7649492e42e9ea2331c39", + "df429494c5eef971a7ab80c8a0f7f9cdfa30148afef706f07923bd93d5a7e22a", + "c8a3f1bc73146bd4a1a0e848f2b0b4a21be86e4930f239d856af8e9646014236", + "1f514df87fe2c400e508e01cd8967657ef76db9681f65dc82b0bc6d4004b575f", + "e463950c8efd9114237189f07ddf1cfdb72658bad23bce667c269652bd0ade3c", + "3d7ae6df787807320fdc397a7055e86c932a7c36ab1d1f942b92c53bf2a1d2f9", + ], + position: 17, + }, + previousDifficulty: BigNumber.from(39156400059293), + currentDifficulty: BigNumber.from(39350942467772), + }, +} + +/** + * Test data that is based on a random Bitcoin mainnet transaction with the + * blocks headers spanning two difficulty epochs + * https://live.blockcypher.com/btc/tx/e073636400e132b8c1082133ab2b48866919153998f4f04877b580e9932d5a17/ + */ +export const transactionConfirmationsInTwoEpochsData: TransactionProofData = { + requiredConfirmations: 6, + bitcoinChainData: { + transaction: { + transactionHash: TransactionHash.from( + "e073636400e132b8c1082133ab2b48866919153998f4f04877b580e9932d5a17" + ), + inputs: [ + { + transactionHash: TransactionHash.from( + "f160a6565d07fd2e8f1d0aaaff538f3150b7f9d2bc64f191076f45c92725b990" + ), + outputIndex: 0, + scriptSig: Hex.from(""), + }, + ], + outputs: [ + { + outputIndex: 0, + value: BigNumber.from(38385795), + scriptPubKey: Hex.from( + "00145ade2be870b440e171644f22973db748a2002305" + ), + }, + { + outputIndex: 1, + value: BigNumber.from(2181468), + scriptPubKey: Hex.from( + "76a914dbdbe7f1c2ba3dfe38c32b9261f5d8fcb36b689788ac" + ), + }, + ], + }, + accumulatedTxConfirmations: 3838, + latestBlockHeight: 777979, + headersChain: + "0040f224871a401b605e02c475e05e147bd418e5e2ae9eb599e200000000000000000" + + "000193dc07aea4388a163ed0e3e5234ef54594cfc046bce727d2d6b3445d3ce0e8c44" + + "0dd663e27c07170c0d54de00e0682c9c27df3b2a1b011753c986c290ce22c60d09a05" + + "3707100000000000000000000ddf3b023ed6368bdac8578bd55d0c3fad7f234ae971b" + + "902b155bee7318bf0919b30dd663e27c0717be025f2b00000020514a9bd87c51caedd" + + "45a20c495f0ba1983b6f3f51639050000000000000000001f4c60a97f4127b4f90fbb" + + "7a6a1041881b10d4f7351340b6770301f62b36725ce10dd66320270717c11c5e7b002" + + "0002043e99cc906d52209796ecb37b252e4514f197d727ea701000000000000000000" + + "274ecaf37779be81c23748d33ef4a0cad36a8abd935a11f0e0a71640c6dd1deaf10dd" + + "66320270717846927aa0000c02090a4a88ab1ad55e235932fe0adc7b4c822b4322f58" + + "9305000000000000000000decc945dc9cdf595715ffeee3bffc0ec0c8c5ff77e43b8e" + + "91213e21a9975c99ddc10d663202707179f93251000203229e618c1eb9274a1acbb74" + + "d44bfe9a4ecfae236ea35e8b0300000000000000000029a9f7b4f6671dec5d6ba05ac" + + "b060fcd2ffc6e46a992189c6f60d770d9c5a5cda31cd66320270717542691a2", + transactionMerkleBranch: { + blockHeight: 774142, + merkle: [ + "e80f706f53d5abd77070ea6c8a60c141748400e09fc9b373d5cdb0129cbce5ec", + "20d22506199cf00caf2e32e240c77a23c226d5a74de4dc9150ccd6f5200b4dd7", + "8b446693fadaae7479725f0e98430c24f8bf8936f5a5cab7c725692cd78e61e3", + "93e61f1ac82cf6a66e321c60410ae4bdfcc0ab45b7efd50353d7b08104758403", + "1dc52561092701978f1e48a10bc4da5464e668f0f4b3a940853c941474ee52de", + "84aca5ec5b339b69a50b93d35c2fd7b146c037842ca76b33cbf835b9e6c86f0c", + "ebcd1bb7039d40ac0d477af58964b4582c6741d1c901ab4a2b0de15e600cba69", + "38d458a70805902a52342cfc552d374bdb217cd389e9550adfc4f86df6fdce82", + "07781ff50552aefea962f0f4972fe882cb38a281ebdd533c2886d5137b80fbeb", + "e7e530e181683d272293f19fe18a33f1dc05eded12ec27945b49311b2e14ee42", + ], + position: 262, + }, + previousDifficulty: BigNumber.from(37590453655497), + currentDifficulty: BigNumber.from(39350942467772), + }, +} + +/** + * Test data that is based on a random Bitcoin testnet transaction + * https://live.blockcypher.com/btc-testnet/tx/b78636ae08e6c17261a9f3134109c13c2eb69f6df52e591cc0e0780f5ebf6472/ + */ +export const testnetTransactionData: TransactionProofData = { + requiredConfirmations: 6, + bitcoinChainData: { + transaction: { + transactionHash: TransactionHash.from( + "b78636ae08e6c17261a9f3134109c13c2eb69f6df52e591cc0e0780f5ebf6472" + ), + inputs: [ + { + transactionHash: TransactionHash.from( + "b230eb52608287da6320fa0926b3ada60f8979fa662d878494d11909d9841aba" + ), + outputIndex: 1, + scriptSig: Hex.from(""), + }, + ], + outputs: [ + { + outputIndex: 0, + value: BigNumber.from(1342326), + scriptPubKey: Hex.from( + "0014ffadb0a5ab3f58e651383b478acdc7cd0008e351" + ), + }, + { + outputIndex: 1, + value: BigNumber.from(7218758882), + scriptPubKey: Hex.from( + "00143c258d94e7abf4695585911b0420c24c1c78213e" + ), + }, + ], + }, + accumulatedTxConfirmations: 18, + latestBlockHeight: 2421198, + headersChain: + "000000203528cf6e8112d970a1adeb9743937d2e980afb43cb8ce3600100000000000" + + "0007bacd9aa2249c74fdba75dd651a16755e9b4dc3c1953f2baa01d657f317e3eb936" + + "62f763ffff001d7045e837000040207184a40ae97e64b2bce8fed41f967eac210e036" + + "9a66855bd2b37c86200000000fe261c184d19c15c7b66c284d5f65e79595f65d576cc" + + "40f20cccf0fcbae3c063a866f7639cde2c193ed763b904e000209885f5bb4bc96f8ff" + + "ed3bf31c6f526f1f71fc6dd3f9bb0ed0200000000000000720c67b13ee8805763110f" + + "b345cbfb5369836344e6a990e4ac0c363211362b2c6168f7639cde2c19294a1006000" + + "040200aafa9b9e947a9bd6fe2e9f04dece7753863d59b11e5c63b1500000000000000" + + "7a63f980ffc1f993c0d7dbe0670e71be2eeae8710a7906f758d3b400dd6a1e6b3c69f" + + "7639cde2c1940a3735000008020ba335b0d58de55cf227fdd35ba380a4a288d4f7926" + + "8be6a01800000000000000ffdc211cb41a97249e18a54aa4861a77f43093d6716995a" + + "9f659370ee1cf8aea406af7639cde2c19254197450000002069b318d3a7c7c154651f" + + "23ac4c3a51c7ec5158f40a62783c0400000000000000f452ef784d467c9f541331552" + + "32d005bdd0f2d323933646976ef2b7275206d7ff96ef763ffff001db18d224b", + transactionMerkleBranch: { + blockHeight: 2421181, + merkle: [ + "33610df4f460e1338d9f6a055de18d5c694edf590722211b6feeec77a9479846", + "0fd7e0afdde99bdfbfdc0d0e6f5ccda4cd1873eee315bb989622fd58bd5c4446", + "2d4ab6c53cedc1a447e21ad2f38c6d9d0d9c761426975a65f83fe10f12e3c9e0", + "0eebd6daa03f6db4a27541a91bcf86612c97d100bc37c3eb321d64d943adb2a5", + "b25854f31fc046eb0f53cddbf2b6de3d54d52710acd79a796c78c3be235f031a", + "1fc5ab77039f59ac2494791fc05c75fb53e2dacf57a20f67e7d6727b38778825", + "5b0acfdbb89af64a583a88e92252b8634bd4da06ee102ecd34c2662955e9f1c7", + ], + position: 4, + }, + previousDifficulty: BigNumber.from(1), + currentDifficulty: BigNumber.from(1), + }, +} diff --git a/typescript/test/proof.test.ts b/typescript/test/proof.test.ts index cbb8409ca..68cef604e 100644 --- a/typescript/test/proof.test.ts +++ b/typescript/test/proof.test.ts @@ -1,21 +1,31 @@ import { MockBitcoinClient } from "./utils/mock-bitcoin-client" -import { Transaction } from "./bitcoin" +import { serializeBlockHeader, Transaction, BlockHeader } from "../src/bitcoin" +import { Hex } from "../src/hex" import { singleInputProofTestData, multipleInputsProofTestData, + transactionConfirmationsInOneEpochData, + transactionConfirmationsInTwoEpochsData, + testnetTransactionData, ProofTestData, + TransactionProofData, } from "./data/proof" -import { assembleTransactionProof } from "../src/proof" +import { + assembleTransactionProof, + validateTransactionProof, + splitHeaders, +} from "../src/proof" import { Proof } from "./bitcoin" import { expect } from "chai" -import bcoin from "bcoin" +import * as chai from "chai" +import chaiAsPromised from "chai-as-promised" +chai.use(chaiAsPromised) describe("Proof", () => { describe("assembleTransactionProof", () => { let bitcoinClient: MockBitcoinClient beforeEach(async () => { - bcoin.set("testnet") bitcoinClient = new MockBitcoinClient() }) @@ -106,4 +116,274 @@ describe("Proof", () => { return proof } }) + + describe("validateTransactionProof", () => { + let bitcoinClient: MockBitcoinClient + + beforeEach(async () => { + bitcoinClient = new MockBitcoinClient() + }) + + context("when the transaction proof is correct", () => { + context("when the transaction is from Bitcoin Mainnet", () => { + context( + "when the transaction confirmations span only one epoch", + () => { + it("should not throw", async () => { + await expect( + runProofValidationScenario( + transactionConfirmationsInOneEpochData + ) + ).not.to.be.rejected + }) + } + ) + + context("when the transaction confirmations span two epochs", () => { + it("should not throw", async () => { + await expect( + runProofValidationScenario( + transactionConfirmationsInTwoEpochsData + ) + ).not.to.be.rejected + }) + }) + }) + + context("when the transaction is from Bitcoin Testnet", () => { + it("should not throw", async () => { + await expect(runProofValidationScenario(testnetTransactionData)).not + .to.be.rejected + }) + }) + }) + + context("when the transaction proof is incorrect", () => { + context("when the length of headers chain is incorrect", () => { + it("should throw", async () => { + // Corrupt data by adding additional byte to the headers chain. + const corruptedProofData: TransactionProofData = { + ...transactionConfirmationsInOneEpochData, + bitcoinChainData: { + ...transactionConfirmationsInOneEpochData.bitcoinChainData, + headersChain: + transactionConfirmationsInOneEpochData.bitcoinChainData + .headersChain + "ff", + }, + } + await expect( + runProofValidationScenario(corruptedProofData) + ).to.be.rejectedWith("Incorrect length of Bitcoin headers") + }) + }) + + context( + "when the headers chain contains an incorrect number of headers", + () => { + // Corrupt the data by adding additional 80 bytes to the headers chain. + it("should throw", async () => { + const corruptedProofData: TransactionProofData = { + ...transactionConfirmationsInOneEpochData, + bitcoinChainData: { + ...transactionConfirmationsInOneEpochData.bitcoinChainData, + headersChain: + transactionConfirmationsInOneEpochData.bitcoinChainData + .headersChain + "f".repeat(160), + }, + } + await expect( + runProofValidationScenario(corruptedProofData) + ).to.be.rejectedWith("Wrong number of confirmations") + }) + } + ) + + context("when the merkle proof is of incorrect length", () => { + it("should throw", async () => { + // Corrupt the data by adding a byte to the Merkle proof. + const merkle = [ + ...transactionConfirmationsInOneEpochData.bitcoinChainData + .transactionMerkleBranch.merkle, + ] + merkle[merkle.length - 1] += "ff" + + const corruptedProofData: TransactionProofData = { + ...transactionConfirmationsInOneEpochData, + bitcoinChainData: { + ...transactionConfirmationsInOneEpochData.bitcoinChainData, + transactionMerkleBranch: { + ...transactionConfirmationsInOneEpochData.bitcoinChainData + .transactionMerkleBranch, + merkle: merkle, + }, + }, + } + + await expect( + runProofValidationScenario(corruptedProofData) + ).to.be.rejectedWith("Incorrect length of Merkle proof") + }) + }) + + context("when the merkle proof is empty", () => { + it("should throw", async () => { + // Corrupt the data by making the Merkle proof empty. + const corruptedProofData: TransactionProofData = { + ...transactionConfirmationsInOneEpochData, + bitcoinChainData: { + ...transactionConfirmationsInOneEpochData.bitcoinChainData, + transactionMerkleBranch: { + ...transactionConfirmationsInOneEpochData.bitcoinChainData + .transactionMerkleBranch, + merkle: [], + }, + }, + } + + await expect( + runProofValidationScenario(corruptedProofData) + ).to.be.rejectedWith("Invalid merkle tree") + }) + }) + + context("when the merkle proof contains incorrect hash", () => { + it("should throw", async () => { + // Corrupt the data by changing a byte of one of the hashes in the + // Merkle proof. + const merkle = [ + ...transactionConfirmationsInOneEpochData.bitcoinChainData + .transactionMerkleBranch.merkle, + ] + + merkle[3] = "ff" + merkle[3].slice(2) + + const corruptedProofData: TransactionProofData = { + ...transactionConfirmationsInOneEpochData, + bitcoinChainData: { + ...transactionConfirmationsInOneEpochData.bitcoinChainData, + transactionMerkleBranch: { + ...transactionConfirmationsInOneEpochData.bitcoinChainData + .transactionMerkleBranch, + merkle: merkle, + }, + }, + } + + await expect( + runProofValidationScenario(corruptedProofData) + ).to.be.rejectedWith( + "Transaction Merkle proof is not valid for provided header and transaction hash" + ) + }) + }) + + context("when the block headers do not form a chain", () => { + it("should throw", async () => { + // Corrupt data by modifying previous block header hash of one of the + // headers. + const headers: BlockHeader[] = splitHeaders( + transactionConfirmationsInOneEpochData.bitcoinChainData.headersChain + ) + headers[headers.length - 1].previousBlockHeaderHash = Hex.from( + "ff".repeat(32) + ) + const corruptedHeadersChain: string = headers + .map(serializeBlockHeader) + .join("") + + const corruptedProofData: TransactionProofData = { + ...transactionConfirmationsInOneEpochData, + bitcoinChainData: { + ...transactionConfirmationsInOneEpochData.bitcoinChainData, + headersChain: corruptedHeadersChain, + }, + } + + await expect( + runProofValidationScenario(corruptedProofData) + ).to.be.rejectedWith("Invalid headers chain") + }) + }) + + context("when one of the block headers has insufficient work", () => { + it("should throw", async () => { + // Corrupt data by modifying the nonce of one of the headers, so that + // the resulting hash will be above the required difficulty target. + const headers: BlockHeader[] = splitHeaders( + transactionConfirmationsInOneEpochData.bitcoinChainData.headersChain + ) + headers[headers.length - 1].nonce++ + const corruptedHeadersChain: string = headers + .map(serializeBlockHeader) + .join("") + + const corruptedProofData: TransactionProofData = { + ...transactionConfirmationsInOneEpochData, + bitcoinChainData: { + ...transactionConfirmationsInOneEpochData.bitcoinChainData, + headersChain: corruptedHeadersChain, + }, + } + + await expect( + runProofValidationScenario(corruptedProofData) + ).to.be.rejectedWith("Insufficient work in the header") + }) + }) + + context( + "when some of the block headers are not at current or previous difficulty", + () => { + it("should throw", async () => { + // Corrupt data by setting current difficulty to a different value + // than stored in block headers. + const corruptedProofData: TransactionProofData = { + ...transactionConfirmationsInTwoEpochsData, + bitcoinChainData: { + ...transactionConfirmationsInTwoEpochsData.bitcoinChainData, + currentDifficulty: + transactionConfirmationsInTwoEpochsData.bitcoinChainData.currentDifficulty.add( + 1 + ), + }, + } + + await expect( + runProofValidationScenario(corruptedProofData) + ).to.be.rejectedWith( + "Header difficulty not at current or previous Bitcoin difficulty" + ) + }) + } + ) + }) + + async function runProofValidationScenario(data: TransactionProofData) { + const transactions = new Map() + const transactionHash = data.bitcoinChainData.transaction.transactionHash + transactions.set( + transactionHash.toString(), + data.bitcoinChainData.transaction + ) + bitcoinClient.transactions = transactions + bitcoinClient.latestHeight = data.bitcoinChainData.latestBlockHeight + bitcoinClient.headersChain = data.bitcoinChainData.headersChain + bitcoinClient.transactionMerkle = + data.bitcoinChainData.transactionMerkleBranch + const confirmations = new Map() + confirmations.set( + transactionHash.toString(), + data.bitcoinChainData.accumulatedTxConfirmations + ) + bitcoinClient.confirmations = confirmations + + await validateTransactionProof( + data.bitcoinChainData.transaction.transactionHash, + data.requiredConfirmations, + data.bitcoinChainData.previousDifficulty, + data.bitcoinChainData.currentDifficulty, + bitcoinClient + ) + } + }) })