Building A Defi On-Chain Provenance Receipt For Single-Route Swaps
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:
- Fetch the
Receiptstruct from the contract byreceiptHash. - Recompute the router calldata hash from the receipt fields (and the same router ABI encoding).
- 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.