Skip to content

Proving a Transaction's Inclusion in a Block⚓︎

Each Symbol block records its transactions in a Merkle tree whose root, the transactionsHash, is stored in the block header. A transaction can be verified against this root to prove it was included in a block without having to download all the block's transactions.

This tutorial shows how to fetch a Merkle proof from the API and verify that a specific transaction is part of a block.

Prerequisites⚓︎

Before you start:

This tutorial only reads data from the network. No account or XYM balance is required.

Full Code⚓︎

import json
import os
import urllib.request

from symbolchain.CryptoTypes import Hash256
from symbolchain.symbol.Merkle import MerklePart, prove_merkle

NODE_URL = os.getenv(
    'NODE_URL', 'https://reference.symboltest.net:3001')
print(f'Using node {NODE_URL}')

TX_HASH = os.getenv('TRANSACTION_HASH',
    '99011A8DBC086E0C359E9D8A38FEC6714C33726FCD0C1B5C0F772A82400D808B')
print(f'Transaction hash: {TX_HASH}')

try:
    # Fetch the confirmed transaction to get its block height
    tx_path = f'/transactions/confirmed/{TX_HASH}'
    print(f'Fetching transaction from {tx_path}')
    with urllib.request.urlopen(f'{NODE_URL}{tx_path}') as response:
        tx_data = json.loads(response.read().decode())

    print(json.dumps(tx_data['meta'], indent=2))
    block_height = tx_data['meta']['height']
    merkle_component_hash = Hash256(
        tx_data['meta']['merkleComponentHash'])

    # Fetch the block header to get the transactions hash
    block_path = f'/blocks/{block_height}'
    print(f'Fetching block from {block_path}')
    with urllib.request.urlopen(f'{NODE_URL}{block_path}') as response:
        block_data = json.loads(response.read().decode())

    print(json.dumps({
        'height': block_data['block']['height'],
        'transactionsHash': block_data['block']['transactionsHash'],
    }, indent=2))
    transactions_hash = Hash256(
        block_data['block']['transactionsHash'])

    # Fetch the merkle proof path for the transaction
    merkle_path = (f'/blocks/{block_height}'
        f'/transactions/{TX_HASH}/merkle')
    print('Fetching merkle proof:')
    print(f'  {merkle_path}')
    with urllib.request.urlopen(f'{NODE_URL}{merkle_path}') as response:
        merkle_data = json.loads(response.read().decode())

    print(json.dumps(merkle_data, indent=2))

    # Convert the API response to the format expected by the SDK
    merkle_proof_path = [
        MerklePart(Hash256(part['hash']), part['position'] == 'left')
        for part in merkle_data['merklePath']
    ]
    print(f'  Merkle path length: {len(merkle_proof_path)}')

    # Verify that the transaction is included in the block
    is_proven = prove_merkle(
        merkle_component_hash, merkle_proof_path,
        transactions_hash)

    if is_proven:
        print(
            f'Transaction {TX_HASH[:16]}...'
            f' proven in block {block_height}')
    else:
        raise RuntimeError(
            f'Transaction {TX_HASH[:16]}...'
            f' NOT proven in block {block_height}')

except Exception as e:
    print(e)

Download source

import { Hash256 } from 'symbol-sdk';
import { proveMerkle } from 'symbol-sdk/symbol';

const NODE_URL = process.env.NODE_URL ||
    'https://reference.symboltest.net:3001';
console.log('Using node', NODE_URL);

const TX_HASH = process.env.TRANSACTION_HASH ||
    '99011A8DBC086E0C359E9D8A38FEC6714C33726FCD0C1B5C0F772A82400D808B';
console.log('Transaction hash:', TX_HASH);

try {
    // Fetch the confirmed transaction to get its block height
    const txPath = `/transactions/confirmed/${TX_HASH}`;
    console.log('Fetching transaction from', txPath);
    const txResponse = await fetch(`${NODE_URL}${txPath}`);
    const txData = await txResponse.json();

    console.log(JSON.stringify(txData.meta, undefined, 2));
    const blockHeight = txData.meta.height;
    const merkleComponentHash = new Hash256(
        txData.meta.merkleComponentHash);

    // Fetch the block header to get the transactions hash
    const blockPath = `/blocks/${blockHeight}`;
    console.log('Fetching block from', blockPath);
    const blockResponse = await fetch(`${NODE_URL}${blockPath}`);
    const blockData = await blockResponse.json();

    console.log(JSON.stringify({
        height: blockData.block.height,
        transactionsHash: blockData.block.transactionsHash,
    }, undefined, 2));
    const transactionsHash = new Hash256(
        blockData.block.transactionsHash);

    // Fetch the merkle proof path for the transaction
    const merklePath = `/blocks/${blockHeight}`
        + `/transactions/${TX_HASH}/merkle`;
    console.log('Fetching merkle proof:');
    console.log(`  ${merklePath}`);
    const merkleResponse = await fetch(`${NODE_URL}${merklePath}`);
    const merkleData = await merkleResponse.json();

    console.log(JSON.stringify(merkleData, undefined, 2));

    // Convert the API response to the format expected by the SDK
    const merkleProofPath = merkleData.merklePath.map(
        part => ({
            hash: new Hash256(part.hash),
            isLeft: part.position === 'left',
        }));
    console.log('  Merkle path length:', merkleProofPath.length);

    // Verify that the transaction is included in the block
    const isProven = proveMerkle(
        merkleComponentHash, merkleProofPath, transactionsHash);

    if (isProven) {
        console.log(
            `Transaction ${TX_HASH.slice(0, 16)}...`
            + ` proven in block ${blockHeight}`);
    } else {
        throw new Error(
            `Transaction ${TX_HASH.slice(0, 16)}...`
            + ` NOT proven in block ${blockHeight}`);
    }
} catch (e) {
    console.error(e.message);
}

Download source

The snippet reads the hash of the transaction to prove from the TRANSACTION_HASH environment variable. If not set, a known transaction from block 55 of the Symbol testnet is used as the default.

Code Explanation⚓︎

Fetching the Confirmed Transaction⚓︎

    # Fetch the confirmed transaction to get its block height
    tx_path = f'/transactions/confirmed/{TX_HASH}'
    print(f'Fetching transaction from {tx_path}')
    with urllib.request.urlopen(f'{NODE_URL}{tx_path}') as response:
        tx_data = json.loads(response.read().decode())

    print(json.dumps(tx_data['meta'], indent=2))
    block_height = tx_data['meta']['height']
    merkle_component_hash = Hash256(
        tx_data['meta']['merkleComponentHash'])
    // Fetch the confirmed transaction to get its block height
    const txPath = `/transactions/confirmed/${TX_HASH}`;
    console.log('Fetching transaction from', txPath);
    const txResponse = await fetch(`${NODE_URL}${txPath}`);
    const txData = await txResponse.json();

    console.log(JSON.stringify(txData.meta, undefined, 2));
    const blockHeight = txData.meta.height;
    const merkleComponentHash = new Hash256(
        txData.meta.merkleComponentHash);

The code fetches the confirmed transaction from the /transactions/confirmed/{transactionId} GET endpoint.

The meta.height field is the block height where the transaction was confirmed, needed in the following step to retrieve the block header.

The response also contains the merkleComponentHash, which is the leaf hash used in the block's Merkle tree. For regular transactions, this value equals the transaction hash. For aggregate transactions, it is computed as the SHA3-256 hash of the transaction hash concatenated with the public keys of the cosignatories.

Fetching the Block Header⚓︎

    # Fetch the block header to get the transactions hash
    block_path = f'/blocks/{block_height}'
    print(f'Fetching block from {block_path}')
    with urllib.request.urlopen(f'{NODE_URL}{block_path}') as response:
        block_data = json.loads(response.read().decode())

    print(json.dumps({
        'height': block_data['block']['height'],
        'transactionsHash': block_data['block']['transactionsHash'],
    }, indent=2))
    transactions_hash = Hash256(
        block_data['block']['transactionsHash'])
    // Fetch the block header to get the transactions hash
    const blockPath = `/blocks/${blockHeight}`;
    console.log('Fetching block from', blockPath);
    const blockResponse = await fetch(`${NODE_URL}${blockPath}`);
    const blockData = await blockResponse.json();

    console.log(JSON.stringify({
        height: blockData.block.height,
        transactionsHash: blockData.block.transactionsHash,
    }, undefined, 2));
    const transactionsHash = new Hash256(
        blockData.block.transactionsHash);

The /blocks/{height} GET endpoint returns block metadata, including the transactionsHash field. This hash is the root of the Merkle tree built from the merkleComponentHash of each transaction in the block.

The code wraps the hex string in a Hash256 object, which is the format expected by the function.

Fetching the Merkle Proof Path⚓︎

    # Fetch the merkle proof path for the transaction
    merkle_path = (f'/blocks/{block_height}'
        f'/transactions/{TX_HASH}/merkle')
    print('Fetching merkle proof:')
    print(f'  {merkle_path}')
    with urllib.request.urlopen(f'{NODE_URL}{merkle_path}') as response:
        merkle_data = json.loads(response.read().decode())

    print(json.dumps(merkle_data, indent=2))

    # Convert the API response to the format expected by the SDK
    merkle_proof_path = [
        MerklePart(Hash256(part['hash']), part['position'] == 'left')
        for part in merkle_data['merklePath']
    ]
    print(f'  Merkle path length: {len(merkle_proof_path)}')
    // Fetch the merkle proof path for the transaction
    const merklePath = `/blocks/${blockHeight}`
        + `/transactions/${TX_HASH}/merkle`;
    console.log('Fetching merkle proof:');
    console.log(`  ${merklePath}`);
    const merkleResponse = await fetch(`${NODE_URL}${merklePath}`);
    const merkleData = await merkleResponse.json();

    console.log(JSON.stringify(merkleData, undefined, 2));

    // Convert the API response to the format expected by the SDK
    const merkleProofPath = merkleData.merklePath.map(
        part => ({
            hash: new Hash256(part.hash),
            isLeft: part.position === 'left',
        }));
    console.log('  Merkle path length:', merkleProofPath.length);

The /blocks/{height}/transactions/{hash}/merkle GET endpoint returns a Merkle proof path: the minimum set of intermediate hashes needed to recompute the transactionsHash starting from the merkleComponentHash (one per level of the Merkle tree).

Each item in the path contains:

  • hash: An intermediate hash needed to recompute the next level of the tree.
  • position: Whether this hash sits to the left or right when combined with the previous result.

The code converts each item into a pair of hash and boolean (true if the hash is on the left), to match the format expected by the function.

Verifying the Proof⚓︎

    # Verify that the transaction is included in the block
    is_proven = prove_merkle(
        merkle_component_hash, merkle_proof_path,
        transactions_hash)

    if is_proven:
        print(
            f'Transaction {TX_HASH[:16]}...'
            f' proven in block {block_height}')
    else:
        raise RuntimeError(
            f'Transaction {TX_HASH[:16]}...'
            f' NOT proven in block {block_height}')
    // Verify that the transaction is included in the block
    const isProven = proveMerkle(
        merkleComponentHash, merkleProofPath, transactionsHash);

    if (isProven) {
        console.log(
            `Transaction ${TX_HASH.slice(0, 16)}...`
            + ` proven in block ${blockHeight}`);
    } else {
        throw new Error(
            `Transaction ${TX_HASH.slice(0, 16)}...`
            + ` NOT proven in block ${blockHeight}`);
    }

recomputes the Merkle root by iteratively combining the merkleComponentHash with each intermediate hash in the proof path, following the specified position order. If the computed root matches the block's transactionsHash, the transaction is proven to be part of the block.

Output⚓︎

The following output shows a typical run of the program:

Using node https://reference.symboltest.net:3001
Transaction hash: 99011A8DBC086E0C359E9D8A38FEC6714C33726FCD0C1B5C0F772A82400D808B
Fetching transaction from /transactions/confirmed/99011A8DBC086E0C359E9D8A38FEC6714C33726FCD0C1B5C0F772A82400D808B
{
  "height": "55",
  "hash": "99011A8DBC086E0C359E9D8A38FEC6714C33726FCD0C1B5C0F772A82400D808B",
  "merkleComponentHash": "99011A8DBC086E0C359E9D8A38FEC6714C33726FCD0C1B5C0F772A82400D808B",
  "index": 1,
  "timestamp": "34025525",
  "feeMultiplier": 100
}
Fetching block from /blocks/55
{
  "height": "55",
  "transactionsHash": "058BED8B39E4D0335DA9EE4B29344F8A594A995B151E5CF705FBB9D106B6D52B"
}
Fetching merkle proof:
  /blocks/55/transactions/99011A8DBC086E0C359E9D8A38FEC6714C33726FCD0C1B5C0F772A82400D808B/merkle
{
  "merklePath": [
    {
      "hash": "A40B90776E7BFD66BF65125087327342589B01DFD01D5B00E599342C135AE6AF",
      "position": "left"
    },
    {
      "hash": "A54D55B20DD2CDC183C1F8687701BFCA973F8B8ACA69736EE824A1D960E3E2D6",
      "position": "right"
    },
    {
      "hash": "C779EA8E5EA495E3A24F8460C3222E779B8C80848F7EE513F570C0F1D27A6657",
      "position": "right"
    },
    {
      "hash": "1BEB7949C5A4B58536BF1046E1B0493A53C10A991449FF67D151C39BE10E1437",
      "position": "right"
    }
  ]
}
  Merkle path length: 4
Transaction 99011A8DBC086E0C... proven in block 55

Some highlights from the output:

  • Transaction metadata (lines 5-6): The JSON response from /transactions/confirmed/{transactionId} GET includes the block height and merkleComponentHash needed for the proof.

  • Block transactions hash (lines 15): The JSON response from /blocks/{height} GET includes the transactionsHash, which is the Merkle root for all transactions confirmed in that block.

  • Merkle path length (lines 39): The JSON response from /blocks/{height}/transactions/{hash}/merkle GET contains 4 entries, meaning the tree has 4 levels and the block contains up to 2⁴ = 16 transactions.

  • Proof result (line 40): The computed root matched the transactionsHash, confirming the transaction is genuinely part of block 55.

To inspect the transaction or its block in the explorer, visit the Symbol Testnet Explorer and enter the transaction hash or block height.

Conclusion⚓︎

This tutorial showed how to:

Step Related documentation
Fetch confirmed transaction /transactions/confirmed/{transactionId} GET
Fetch the block header /blocks/{height} GET
Fetch the Merkle proof path /blocks/{height}/transactions/{hash}/merkle GET
Verify the proof

Next Steps⚓︎

The same process can be used to prove receipts by using the /blocks/{height}/statements/{hash}/merkle GET endpoint and verifying against the block receiptsHash instead.