Distributed Systems & CryptographyJune 23, 2026

Building A Merkleized “Receipt Log” For Defi Flashloan Arbitrage

C

Written by

Cipher Stone

The problem I ran into (and why it’s weirdly hard)

On a weekend of tinkering with DeFi flashloan arbitrage (a flash loan is a loan that must be borrowed and repaid within the same transaction), I wanted a simple guarantee:

“Given an arbitrage execution, I should be able to prove which swaps happened and with what amounts—later, without storing an entire verbose log off-chain.”

The ugly part is that swap traces and event logs are messy:

  • Event ordering can be tricky.
  • Some fields are large or nested.
  • You may want a compact “receipt” you can hand to a third party (for digital provenance) without revealing everything in raw form.

So I built a Merkleized receipt log: for each flashloan execution, I hash each meaningful step into a leaf, build a Merkle tree over those leaves, and publish the Merkle root on-chain. Later I can prove that “step X happened with parameters Y” using a Merkle proof.

This post is about the specific mechanism: a Merkle root committed per execution, plus a small Solidity verifier that checks inclusion proofs.


What I implemented

  • Off-chain (Node.js): take an array of step objects (e.g., swap path and amounts), canonicalize them, hash them into leaves, compute a Merkle tree, and produce:
    • root (published on-chain)
    • leaf and proof for any step
  • On-chain (Solidity): store the root per execution id, and verify Merkle proofs for step inclusion.

Key idea: Merkle trees let you prove membership in a set using only:

  • the target leaf hash
  • the Merkle proof (a small list of sibling hashes)
  • the expected root

Format for receipt steps (canonical hashing)

I wanted the hashing to be stable, meaning the same semantic step always results in the same hash.

Each receipt step is a plain object like:

  • type: "SWAP"
  • fromToken: ERC-20 address
  • toToken: ERC-20 address
  • amountIn: stringified integer
  • amountOutMin: stringified integer
  • poolFee: number
  • nonce: integer to distinguish otherwise-equal swaps

I used a deterministic JSON canonicalization approach and hashed like:

  • leaf = keccak256(abi.encodePacked(type, fromToken, toToken, ...))

This matters: if you hash JSON text directly without canonicalization, key ordering differences can break proofs.


Off-chain code: build the Merkle tree and proofs (Node.js)

1) Install dependencies

npm init -y npm i ethers merkletreejs keccak256 bn.js

2) Merkle builder script

// merkle_receipt.js const { ethers } = require("ethers"); const keccak256 = require("keccak256"); const { MerkleTree } = require("merkletreejs"); function solidityLeafHash(step) { // step fields must be deterministic and typed // We'll ABI-encode the fields in a Solidity-compatible way and keccak256 it. // Using ethers' defaultAbiCoder ensures types are encoded correctly. const abi = new ethers.AbiCoder(); const encoded = abi.encode( ["string", "address", "address", "uint256", "uint256", "uint24", "uint256"], [ step.type, step.fromToken, step.toToken, step.amountIn, step.amountOutMin, step.poolFee, step.nonce, ] ); return Buffer.from(keccak256(encoded)); } function buildReceiptMerkle(steps) { const leaves = steps.map(solidityLeafHash); // Sort pairs to make the tree deterministic independent of index ordering choices. // (This is a common pattern; the verifier must match this behavior.) const tree = new MerkleTree(leaves, keccak256, { sortPairs: true }); const root = tree.getHexRoot(); return { tree, leaves, root }; } // Demo: simulate a flashloan execution receipt with three steps. const steps = [ { type: "SWAP", fromToken: "0x6B175474E89094C44Da98b954EedeAC495271d0F", // DAI toToken: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC amountIn: "1000000000000000000", // 1.0 DAI in wei-like units amountOutMin: "998000000", // example "minimum" (demo values) poolFee: 3000, nonce: 1 }, { type: "SWAP", fromToken: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC toToken: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", // WETH amountIn: "1000000000", amountOutMin: "0", poolFee: 500, nonce: 2 }, { type: "SWAP", fromToken: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", // WETH toToken: "0x6B175474E89094C44Da98b954EedeAC495271d0F", // DAI amountIn: "50000000000000000", // 0.05 WETH amountOutMin: "480000000000000000", // 0.48 DAI poolFee: 3000, nonce: 3 } ]; const { tree, root } = buildReceiptMerkle(steps); console.log("Merkle root:", root); // Pick the second step to generate a proof const targetIndex = 1; const leaf = solidityLeafHash(steps[targetIndex]); const proof = tree.getHexProof(leaf); console.log("Target step index:", targetIndex); console.log("Leaf hash:", "0x" + leaf.toString("hex")); console.log("Proof:", proof);

3) Run it

node merkle_receipt.js

You’ll get:

  • a Merkle root
  • a leaf hash for the chosen step
  • a proof array of sibling hashes

That triple is exactly what the Solidity contract needs to verify inclusion.


On-chain code: store roots and verify proofs (Solidity)

Design decisions I made

  • I store bytes32 root keyed by executionId (a uint256).
  • I use sortPairs: true behavior off-chain, so the verifier must match it.
  • I compute proof hashes using the same keccak256(abi.encodePacked(min, max)) rule.

Solidity contract

// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract FlashloanReceiptMerkle { // executionId => Merkle root committing to the receipt steps mapping(uint256 => bytes32) public receiptRoot; event RootCommitted(uint256 indexed executionId, bytes32 root); function commitRoot(uint256 executionId, bytes32 root) external { receiptRoot[executionId] = root; emit RootCommitted(executionId, root); } /// @notice Verifies that a leaf hash is included in the Merkle tree with root=receiptRoot[executionId] /// @param executionId The id we committed the root for /// @param leaf The keccak256 hash of the receipt step (same hashing scheme as off-chain) /// @param proof Array of sibling hashes (hex strings passed as bytes32[]) function verifyStep( uint256 executionId, bytes32 leaf, bytes32[] calldata proof ) external view returns (bool) { bytes32 root = receiptRoot[executionId]; require(root != bytes32(0), "root not set"); bytes32 computed = leaf; // Matches merkletreejs { sortPairs: true } for (uint256 i = 0; i < proof.length; i++) { bytes32 sibling = proof[i]; // sort the pair to be order-independent if (computed <= sibling) { computed = keccak256(abi.encodePacked(computed, sibling)); } else { computed = keccak256(abi.encodePacked(sibling, computed)); } } return computed == root; } }

How the pieces fit together (step-by-step)

Step A: Build receipt steps off-chain

I created the steps array representing the important actions in the flashloan arbitrage execution.

Step B: Hash each step into a leaf

solidityLeafHash(step) does:

  1. ABI-encodes the typed fields (Solidity-compatible)
  2. keccak256 hashes that encoded payload
  3. returns the resulting 32-byte leaf

Step C: Build Merkle tree and publish root

MerkleTree(leaves, keccak256, { sortPairs: true })

  • computes internal nodes by hashing sibling pairs
  • sorts pairs so the proof doesn’t depend on “left vs right”

Then:

  • I commit root on-chain for a given executionId.

Step D: Verify a later proof

To prove a specific step happened:

  • I take its leaf hash and Merkle proof
  • the contract recomputes the path up to the root using the same sorting rule
  • if the computed hash equals the committed root, inclusion is proven

That’s a compact, verifiable provenance artifact for the execution trace.


Practical demo: calling verifyStep (ethers.js snippet)

This assumes you already deployed the contract and have an RPC. The important part is that leaf and proof values must match what the JS script produced.

// verify_demo.js const { ethers } = require("ethers"); const receipt = require("./merkle_receipt_output.json"); // assume you saved root/leaf/proof earlier async function main() { const provider = new ethers.JsonRpcProvider(process.env.RPC_URL); const wallet = new ethers.Wallet(process.env.PRIVATE_KEY, provider); const abi = [ "function verifyStep(uint256 executionId, bytes32 leaf, bytes32[] proof) view returns (bool)", "function commitRoot(uint256 executionId, bytes32 root) external" ]; const contractAddress = process.env.CONTRACT_ADDRESS; const c = new ethers.Contract(contractAddress, abi, wallet); const executionId = 42; const leaf = receipt.leaf; // "0x..." const proof = receipt.proof; // ["0x..", "0x..", ...] const ok = await c.verifyStep(executionId, leaf, proof); console.log("Proof valid:", ok); } main().catch(console.error);

Why this matters beyond “crypto receipts”

In traditional systems, proving “what exactly happened” often means:

  • trusting an off-chain indexer
  • reading raw transaction traces
  • or re-running complex parsing logic later

With a merkleized receipt root, you can:

  • commit a compact authenticity anchor on-chain
  • later verify specific claims (“this swap step with these parameters was part of the execution”) without needing the full trace
  • build digital provenance tooling around executions, not just token balances

That’s a cryptographic trust model that scales with what you choose to prove.


Conclusion

I built a Merkleized “receipt log” for DeFi flashloan arbitrage executions: I canonicalize step data off-chain, hash each step into Merkle leaves, commit the Merkle root on-chain, and verify later inclusion proofs in Solidity. The big takeaway from my tinkering is that reliable digital provenance in DeFi isn’t just about storing events—it’s about committing to a stable, typed digest of execution steps and then verifying targeted claims efficiently.