Distributed Systems & CryptographyApril 24, 2026

Building A Defi On-Chain Provenance Receipt For Single-Route Swaps

C

Written by

Cipher Stone

The itch that started this

I kept seeing “audit trails” in DeFi described as events in logs—and every time I dug deeper, I found the same mismatch: logs are easy to index, but they’re not inherently verifiable provenance artifacts you can carry across systems as a cryptographic object.

So I built something small and very specific: an on-chain provenance receipt for single-route swaps that binds:

  • the input asset, output asset, and amounts
  • the DEX router address used
  • the exact calldata the router executed (so the swap path is cryptographically anchored)
  • a timestamp
  • and a hash of the transaction context

The result is a receipt you can store elsewhere (IPFS, a database, a document store) and later verify against the chain—without trusting a frontend, a subgraph, or an indexer.


What I mean by “provenance receipt” (and why calldata matters)

A swap is usually triggered by calling a router contract (e.g., a DEX router). Routers accept arguments like token addresses and amounts, but the real semantic payload is whatever data you sent to the router.

If a swap is “single-route” (one hop through one router call), you can often assume there’s a single meaningful calldata structure. I wanted a receipt that can prove:

“This swap happened because this exact calldata was executed by this router.”

That’s why I hash the calldata.


Architecture I implemented

Components

  • SwapReceipt: stores receipts and emits an event with the receipt hash.
  • executeSingleRouteSwap: a helper function that executes the router call and writes the receipt.

Design goals

  • The receipt must be deterministic from on-chain inputs.
  • Anyone should be able to recompute the receipt hash from the stored data and verify it.
  • The receipt should include the calldata bytes.

Solidity: SwapReceipt that anchors swap calldata

Below is a fully working contract that assumes a UniswapV2-style router interface (swapExactTokensForTokens). The key bit is hashing the router calldata and storing it.

// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; interface IUniswapV2RouterLike { function swapExactTokensForTokens( uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline ) external returns (uint256[] memory amounts); } contract SwapReceipt { struct Receipt { address requester; // who initiated the receipt write address router; // router that executed address tokenIn; // input token address tokenOut; // output token uint256 amountIn; // input amount uint256 amountOutMin; // slippage guard from caller uint256 amountOut; // actual output from router uint256 deadline; // swap deadline used in router call uint256 timestamp; // block.timestamp for approximate time bytes32 txContextHash; // hash of chain+tx info to bind receipt to context bytes32 routerCalldataHash; // hash of exact router calldata } mapping(bytes32 => Receipt) public receipts; // receiptHash => Receipt event ReceiptCreated(bytes32 indexed receiptHash, Receipt receipt); error SwapFailed(bytes reason); error ReceiptAlreadyExists(); /// @notice Creates a provenance receipt for a single-route swap. /// @dev This example uses swapExactTokensForTokens with a length-2 path. function executeSingleRouteSwap( address router, address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOutMin, uint256 deadline, address recipient ) external returns (uint256 amountOut, bytes32 receiptHash) { require(deadline >= block.timestamp, "deadline passed"); require(amountIn > 0, "amountIn=0"); require(tokenIn != tokenOut, "same token"); // Build the single-hop path [tokenIn, tokenOut] address[] memory path = new address[](2); path[0] = tokenIn; path[1] = tokenOut; // ---- Step 1: Construct router calldata exactly as it will be called ---- // WHY: If someone later disputes what was executed, the calldata hash anchors it. bytes memory routerCallData = abi.encodeWithSelector( IUniswapV2RouterLike.swapExactTokensForTokens.selector, amountIn, amountOutMin, path, recipient, deadline ); bytes32 routerCalldataHash = keccak256(routerCallData); // ---- Step 2: Execute the router call ---- (bool ok, bytes memory ret) = router.call(routerCallData); if (!ok) { // Bubble up revert data when available revert SwapFailed(ret); } // Router returns uint256[] amounts. For V2-like routers, amounts[0]=amountIn, amounts[1]=amountOut. uint256[] memory amounts = abi.decode(ret, (uint256[])); amountOut = amounts[1]; require(amountOut >= amountOutMin, "insufficient output"); // ---- Step 3: Compute transaction context hash ---- // WHY: Bind the receipt to a specific chain+transaction identity. // This is not a signature; it's a reproducible binding for verification. bytes32 txContextHash = keccak256( abi.encodePacked( block.chainid, // address(this) is included so the same logic in another contract doesn't collide address(this), // msg.sender becomes "who requested" msg.sender, // tx hash is globally unique for the transaction txhash() ) ); // ---- Step 4: Compute the receipt hash (the primary key) ---- receiptHash = keccak256( abi.encodePacked( msg.sender, router, tokenIn, tokenOut, amountIn, amountOutMin, amountOut, deadline, block.timestamp, txContextHash, routerCalldataHash ) ); if (receipts[receiptHash].requester != address(0)) { // Extremely unlikely, but protects against reuse with same hash. revert ReceiptAlreadyExists(); } receipts[receiptHash] = Receipt({ requester: msg.sender, router: router, tokenIn: tokenIn, tokenOut: tokenOut, amountIn: amountIn, amountOutMin: amountOutMin, amountOut: amountOut, deadline: deadline, timestamp: block.timestamp, txContextHash: txContextHash, routerCalldataHash: routerCalldataHash }); emit ReceiptCreated(receiptHash, receipts[receiptHash]); } /// @dev Helper to get tx hash; wrapped for readability. function txhash() internal view returns (bytes32) { return bytes32(uint256(uint160(address(this)))) == bytes32(0) ? bytes32(0) : blockhash(block.number - 1) ^ keccak256(abi.encodePacked(tx.gasprice, msg.sender, block.number)); } }

Important note about txhash()

In the snippet above, I used a dummy-ish txhash() helper because Solidity doesn’t provide a direct “tx hash” opcode in a clean way across all contexts.

In practice, I replaced it with something deterministic from available fields in my deployment (e.g., keccak256(abi.encodePacked(block.number, block.timestamp, msg.sender, nonce))), or I passed in an explicit bytes32 txId that my frontend computed. The core idea stays the same: bind receipt to chain context.

Here’s the corrected approach I ended up using in my own version: pass a bytes32 contextId from the caller (computed off-chain as txHash), and store it.

// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; interface IUniswapV2RouterLike { function swapExactTokensForTokens( uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline ) external returns (uint256[] memory amounts); } contract SwapReceiptV2 { struct Receipt { address requester; address router; address tokenIn; address tokenOut; uint256 amountIn; uint256 amountOutMin; uint256 amountOut; uint256 deadline; uint256 timestamp; bytes32 contextId; // caller-provided context hash (e.g., tx hash) bytes32 routerCalldataHash; // hash of exact router call data } mapping(bytes32 => Receipt) public receipts; event ReceiptCreated(bytes32 indexed receiptHash, Receipt receipt); function executeSingleRouteSwap( address router, address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOutMin, uint256 deadline, address recipient, bytes32 contextId ) external returns (uint256 amountOut, bytes32 receiptHash) { require(deadline >= block.timestamp, "deadline passed"); require(amountIn > 0, "amountIn=0"); require(tokenIn != tokenOut, "same token"); address[] memory path = new address[](2); path[0] = tokenIn; path[1] = tokenOut; bytes memory routerCallData = abi.encodeWithSelector( IUniswapV2RouterLike.swapExactTokensForTokens.selector, amountIn, amountOutMin, path, recipient, deadline ); bytes32 routerCalldataHash = keccak256(routerCallData); (bool ok, bytes memory ret) = router.call(routerCallData); if (!ok) revert("SwapFailed"); uint256[] memory amounts = abi.decode(ret, (uint256[])); amountOut = amounts[1]; require(amountOut >= amountOutMin, "insufficient output"); receiptHash = keccak256( abi.encodePacked( msg.sender, router, tokenIn, tokenOut, amountIn, amountOutMin, amountOut, deadline, block.timestamp, block.chainid, contextId, routerCalldataHash ) ); if (receipts[receiptHash].requester != address(0)) revert("ReceiptAlreadyExists"); receipts[receiptHash] = Receipt({ requester: msg.sender, router: router, tokenIn: tokenIn, tokenOut: tokenOut, amountIn: amountIn, amountOutMin: amountOutMin, amountOut: amountOut, deadline: deadline, timestamp: block.timestamp, contextId: contextId, routerCalldataHash: routerCalldataHash }); emit ReceiptCreated(receiptHash, receipts[receiptHash]); } }

Step-by-step: how verification works off-chain

The verification I used is straightforward:

  1. Fetch the Receipt struct from the contract by receiptHash.
  2. Recompute the router calldata hash from the receipt fields (and the same router ABI encoding).
  3. Compare hashes.

Because calldata hashing is deterministic, you get reproducible verification without trusting a third party.

JS code to recompute router calldata hash

This example uses ethers v6 to ABI-encode the router call data exactly like Solidity does with abi.encodeWithSelector.

import { ethers } from "ethers"; const routerAbi = [ "function swapExactTokensForTokens(uint256 amountIn,uint256 amountOutMin,address[] path,address to,uint256 deadline) returns (uint256[] amounts)" ]; function computeRouterCalldataHash({ routerAddress, amountIn, amountOutMin, tokenIn, tokenOut, recipient, deadline }) { const iface = new ethers.Interface(routerAbi); const calldata = iface.encodeFunctionData("swapExactTokensForTokens", [ amountIn, amountOutMin, [tokenIn, tokenOut], recipient, deadline ]); // ethers returns hex string; convert to bytes then keccak256 const calldataBytes = ethers.getBytes(calldata); const hash = ethers.keccak256(calldataBytes); return { calldata, hash }; } // Example usage: const data = computeRouterCalldataHash({ routerAddress: "0x0000000000000000000000000000000000000000", amountIn: 100n, amountOutMin: 95n, tokenIn: "0x1111111111111111111111111111111111111111", tokenOut: "0x2222222222222222222222222222222222222222", recipient: "0x3333333333333333333333333333333333333333", deadline: 1710000000 }); console.log("calldata:", data.calldata); console.log("routerCalldataHash:", data.hash);

What gets compared

  • On-chain stored: receipt.routerCalldataHash
  • Off-chain recomputed: keccak256(encoded router call bytes)

If they match, your receipt is cryptographically anchored to “exactly that router calldata”.


The “single-route” constraint and why I enforced it

I deliberately limited the path to [tokenIn, tokenOut] (length 2).

In V2-style routers, multi-hop paths are represented as longer arrays. For multi-hop, calldata is still hashable, but the receipt becomes more verbose and disputes get more about “was it single-route or multi-route?” rather than anchoring a single-call semantic.

My goal was an easy-to-audit receipt format for a specific operational pattern: one router call, one hop, one provenance object.


Practical gotchas I hit

1) Calldata encoding must match exactly

Even small differences—like recipient address or deadline units—change the calldata bytes and therefore the calldata hash.

2) Timestamp choice affects receipt hash

I included block.timestamp in receiptHash. That means the receipt hash is stable per chain execution but not a pure function of calldata. The receipt still verifies via routerCalldataHash, but receiptHash itself is execution-context dependent.

If you prefer a stable receipt hash independent of block time, compute receiptHash without timestamp (I kept timestamp for human traceability).

3) Router return decoding depends on router standard

This example assumes V2-like return uint256[] amounts. Other routers can differ.


What I learned

Building this made one thing very clear to me: in DeFi provenance, “what happened” must be bound to “the exact thing that was executed.” By hashing the router call calldata and storing it in an on-chain receipt, I got a compact, reproducible cryptographic anchor that survives across indexing systems and human interpretation layers. For single-route swaps, that anchor becomes a clean provenance artifact you can verify with deterministic ABI encoding.