Building A Defi Merkle Receipt Ledger For Cross-Chain Trade Reconciliation
Written by
Cipher Stone
The problem I ran into
I got tired of “trust me” reconciliation when integrating a small DeFi setup across two chains. The rough situation was:
- Chain A executes a trade and emits an event.
- Chain B needs to record exactly which trade happened on Chain A.
- Later, anyone should be able to verify reconciliation without re-running the original cross-chain logic.
In other words, I wanted a digital provenance trail: a compact receipt that proves “this specific trade event was included” and can be checked deterministically later.
My solution ended up being a tiny ledger that builds Merkle receipts (a Merkle tree is a hash tree that lets you prove membership with a short proof) from trade event payloads, and then stores the Merkle root in Chain B.
Below is the working code that:
- Creates “trade receipts” from event payloads,
- Builds a Merkle tree of those receipts,
- Generates Merkle proofs,
- Verifies the proofs against the stored root.
This is niche by design: it specifically targets cross-chain trade reconciliation using “event payload receipts” rather than recalculating trade outcomes.
What I built: a Merkle Receipt Ledger
Receipt format (what exactly gets hashed)
Instead of hashing human-readable strings or JSON blobs directly, I used a stable “receipt payload”:
chainId: source chain identifiertxHash: transaction hash where the event was emittedlogIndex: event log index inside the transactionmaker,taker: addresses involvedamountIn,amountOut: integer amounts (as strings to avoid float surprises)timestamp: integer seconds
I then hash the receipt payload using SHA-256. (You could use Keccak-256 in Ethereum land, but SHA-256 keeps the demo dependency-light and deterministic.)
Ledger format (what gets stored)
The ledger only stores the Merkle root for a batch of receipts:
- On Chain B, you would store
merkleRootForBatchN. - Later, verification uses a Merkle proof: “this receipt hash is a member of that root”.
Working code (end-to-end)
1) Create receipts, build Merkle tree, generate proof
import hashlib import json from dataclasses import dataclass from typing import List, Tuple, Dict, Any def sha256(data: bytes) -> bytes: return hashlib.sha256(data).digest() def canonical_json(obj: Any) -> str: """ Deterministically serialize JSON: - keys sorted - no extra whitespace """ return json.dumps(obj, sort_keys=True, separators=(",", ":")) def receipt_hash(receipt: Dict[str, Any]) -> bytes: """ Hash a stable, canonical payload representation. """ payload = canonical_json(receipt).encode("utf-8") return sha256(payload) def merkle_parent(left: bytes, right: bytes) -> bytes: """ Create parent hash. I sort the pair lexicographically so the tree is "order-insensitive" for the two children positions. This reduces proof complexity. """ if left <= right: return sha256(left + right) return sha256(right + left) def build_merkle_tree(leaves: List[bytes]) -> List[List[bytes]]: """ Returns the whole tree as levels: levels[0] = leaves levels[-1] = [root] """ if not leaves: raise ValueError("Cannot build a Merkle tree with zero leaves") levels = [leaves[:]] while len(levels[-1]) > 1: cur = levels[-1] nxt = [] # If odd number of nodes, duplicate last node (common Merkle practice) if len(cur) % 2 == 1: cur = cur + [cur[-1]] for i in range(0, len(cur), 2): nxt.append(merkle_parent(cur[i], cur[i + 1])) levels.append(nxt) return levels def merkle_root(leaves: List[bytes]) -> bytes: tree = build_merkle_tree(leaves) return tree[-1][0] def merkle_proof(levels: List[List[bytes]], index: int) -> List[Tuple[bytes, str]]: """ Produces proof for leaves[index]. Each proof step is (sibling_hash, direction). direction is 'left' or 'right' describing where the sibling sits relative to the current node. We still use merkle_parent which sorts pairs, so direction is mostly informational. I keep it because it helps debugging. """ proof = [] idx = index for level in levels[:-1]: nodes = level # handle odd duplication by mirroring the duplication behavior if len(nodes) % 2 == 1: nodes = nodes + [nodes[-1]] # sibling index if idx % 2 == 0: sib_idx = idx + 1 direction = "right" else: sib_idx = idx - 1 direction = "left" proof.append((level[sib_idx] if sib_idx < len(level) else level[-1], direction)) idx //= 2 return proof def verify_merkle_proof(leaf: bytes, proof: List[Tuple[bytes, str]], root: bytes) -> bool: """ Recompute the path to the root from the leaf. """ computed = leaf for sibling, _direction in proof: computed = merkle_parent(computed, sibling) return computed == root def hex_bytes(b: bytes) -> str: return "0x" + b.hex() @dataclass class TradeEvent: chainId: int txHash: str logIndex: int maker: str taker: str amountIn: str amountOut: str timestamp: int def make_receipt(event: TradeEvent) -> Dict[str, Any]: # Stable payload: exact fields included in the receipt return { "chainId": event.chainId, "txHash": event.txHash, "logIndex": event.logIndex, "maker": event.maker.lower(), "taker": event.taker.lower(), "amountIn": event.amountIn, "amountOut": event.amountOut, "timestamp": event.timestamp, } def demo(): # Pretend these are extracted from Chain A events. events = [ TradeEvent( chainId=1, txHash="0xaaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999", logIndex=12, maker="0x1111111111111111111111111111111111111111", taker="0x2222222222222222222222222222222222222222", amountIn="1000000000000000000", amountOut="2468000000000000000", timestamp=1700000000, ), TradeEvent( chainId=1, txHash="0x9999888877776666555544443333222211110000ffffeeeeddddccccbbbbaaaa", logIndex=3, maker="0x3333333333333333333333333333333333333333", taker="0x4444444444444444444444444444444444444444", amountIn="250000000000000000", amountOut="618250000000000000", timestamp=1700000042, ), TradeEvent( chainId=1, txHash="0x0123012301230123012301230123012301230123012301230123012301230123", logIndex=0, maker="0x5555555555555555555555555555555555555555", taker="0x6666666666666666666666666666666666666666", amountIn="700000000000000000", amountOut="1736000000000000000", timestamp=1700000100, ), ] receipts = [make_receipt(e) for e in events] leaves = [receipt_hash(r) for r in receipts] root = merkle_root(leaves) print("Merkle root:", hex_bytes(root)) # Generate proof for the second receipt target_index = 1 proof = merkle_proof(build_merkle_tree(leaves), target_index) leaf = leaves[target_index] ok = verify_merkle_proof(leaf, proof, root) print("\nTarget receipt hash:", hex_bytes(leaf)) print("Proof steps:", len(proof)) print("Verification result:", ok) # Show the proof data lengths (not printing raw hashes for brevity) for i, (sib, direction) in enumerate(proof): print(f" step {i}: sibling_len={len(sib)} direction={direction}") if __name__ == "__main__": demo()
What happens when I run this
- It turns each
TradeEventinto a deterministic receipt payload. - It hashes each receipt payload into a Merkle leaf.
- It builds a Merkle tree by hashing pairs of nodes until one root remains.
- It generates a proof for the second receipt leaf.
- It verifies the proof by recomputing the hashes up to the root.
You should see a Merkle root, then Verification result: True.
2) A minimal “receipt ledger” contract (conceptual storage)
On Chain B you typically don’t need to store every receipt—just the batch root(s). Here’s a minimal Solidity contract that stores roots by batch id.
This doesn’t implement verification on-chain (that’s more gas), but it shows the core provenance object you’d reference later.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract MerkleReceiptLedger { mapping(uint256 => bytes32) public merkleRootByBatch; event BatchRootStored(uint256 indexed batchId, bytes32 merkleRoot); function storeBatchRoot(uint256 batchId, bytes32 merkleRoot) external { merkleRootByBatch[batchId] = merkleRoot; emit BatchRootStored(batchId, merkleRoot); } }
Why this matters for reconciliation
- Chain A: produce receipts from trade events.
- Off-chain: build Merkle tree and compute
merkleRoot. - Chain B: store
merkleRootfor batch N. - Later: any party verifies a specific trade receipt against batch root N with a Merkle proof.
That’s a compact trust model: you’re trusting “the correct receipts were batched,” and you can cryptographically validate membership once the root is on-chain.
3) Putting it together: “receipt reconciliation” flow
Here’s the reconciliation workflow I used:
- Extract events on Chain A (off-chain or via an indexer).
- Convert each event to a receipt payload using the stable schema.
- Build a Merkle tree from those receipt hashes.
- Submit the Merkle root to Chain B for batch N.
- When reconciling a particular trade:
- recompute the receipt hash for that trade,
- provide a Merkle proof for that leaf,
- verify it against Chain B’s stored root.
The key improvement vs “replay the cross-chain logic” is: you don’t need to trust that Chain B’s verifier reconstructed the trade correctly. You only need to trust the batching step, and membership becomes provable.
Common footguns I hit (and how I avoided them)
1) Hashing non-canonical JSON
At first I hashed raw Python dict string output, and two equivalent objects produced different hashes. Switching to canonical JSON with sorted keys fixed it.
2) Tree ordering inconsistencies
Some Merkle implementations treat left/right as meaningful; others sort pairs. I sorted the pair inside merkle_parent() so proofs don’t depend on child position. This made the demo easier to reason about and debug.
3) Integer amounts as floats
I forced amounts to be strings so the receipt is immune to float rounding.
Conclusion
I built a cross-chain trade reconciliation ledger where each trade event becomes a deterministic “receipt,” and batches of receipts are anchored by a Merkle root stored on the destination chain. The working Python code shows the full pipeline—hashing receipts, building a Merkle tree, generating proofs, and verifying membership against the root—so reconciliation can be validated cryptographically instead of re-executed or trusted blindly.