Verifying ERC-20 Transfers with MPT
This blog explains how Mirage's escrow contract validates an ERC‑20 Transfer event using a Merkle Patricia Trie (MPT) proof anchored to a recent Ethereum block hash. It also describes the proof format, the verification steps, and how to build the proof off‑chain. This is based on the commit I made to Mirage's escrow repository.
TL;DR
- Anchor to a recent block: 
collect()accepts atargetBlockNumberand an RLP‑encoded block header. It checks the block is within the last 256 blocks and thatkeccak256(blockHeader) == blockhash(targetBlockNumber)and that the block header’s encodednumberequalstargetBlockNumber. - Get receipts root: The contract extracts 
receiptsRootfrom the block header. - Verify receipt inclusion (MPT): Given a receipt, the MPT proof nodes and the RLP‑encoded transaction index (
receiptPath), the contract proves the receipt is in the block’s receipts trie rooted atreceiptsRoot. - Validate the 
Transferlog: The contract navigates to the target log by index and enforces: correct token emitter,Transfer(address,address,uint256)signature, from = executor EOA, to = expected recipient, amount = expected amount. - Payout: If everything checks, the bonded executor receives 
bond + reward + payment. 
What We Verify
Anchoring
- The provided RLP header must hash to the canonical block hash of 
targetBlockNumber(within the last 256 blocks) and encode the same block number. - The receipt must be included in the block’s receipts trie (MPT) under the correct key (tx index), matching the provided receipt bytes exactly.
 
Semantic checks on the event
- The log’s emitter is the expected ERC‑20 token contract.
 - The first topic equals 
keccak256("Transfer(address,address,uint256)"). - The 
fromtopic equals the executor EOA supplied tocollect(). - The 
totopic equals the expected recipient configured at deployment. - The event 
data(amount) equals the expected amount configured at deployment. 
If any check fails, the call reverts with a descriptive error.
Public Interface
Constructor
constructor(address _tokenContract, address _expectedRecipient, uint256 _expectedAmount)
- Sets the token that must emit the event, the expected recipient, and the expected transfer amount.
 - Fixes 
maxBlockLookback = 256to align with the EVM’sblockhashavailability. 
Proof Shape
Escrow.sol defines the exact proof payload:
struct ReceiptProof {
    bytes   blockHeader;   // RLP-encoded block header
    bytes   receiptRlp;    // RLP-encoded target receipt (typed or legacy)
    bytes   proofNodes;    // Concatenated MPT nodes (each node is RLP-encoded)
    bytes   receiptPath;   // RLP-encoded transaction index (key)
    uint256 logIndex;      // Index of target log within the receipt’s logs array
}
Collection Entry Point
function collect(ReceiptProof calldata proof, uint256 targetBlockNumber, address executorEOA) external
Pre‑conditions
- Caller is the currently bonded executor and the escrow is funded.
 targetBlockNumberis not in the future and is withinmaxBlockLookbackblocks ofblock.number.
Checks performed (in order)
Block anchoring
bytes32 targetHash = blockhash(targetBlockNumber); must be non‑zero.require(keccak256(proof.blockHeader) == targetHash, "Block header hash mismatch");require(BlockHeaderParser.extractBlockNumber(proof.blockHeader) == targetBlockNumber, "Header block number mismatch");
Receipts root
bytes32 receiptsRoot = BlockHeaderParser.extractReceiptsRoot(proof.blockHeader);
Receipt inclusion
require(MPTVerifier.verifyReceiptProof(proof.receiptRlp, proof.proofNodes, proof.receiptPath, receiptsRoot), "Invalid receipt MPT proof");
Transfer event
require(ReceiptValidator.validateTransferInReceipt(proof.receiptRlp, proof.logIndex, tokenContract, executorEOA, expectedRecipient, expectedAmount), "Invalid Transfer event");
Settlement
- Transfer 
bond + currentRewardAmount + currentPaymentAmountto the bonded executor. 
- Transfer 
 
Receipts Trie Proof (MPT)
Key (path): the RLP‑encoded transaction index bytes, not a hash. The MPT verifier treats this byte string as a sequence of nibbles, as per Ethereum’s hex‑prefix encoding rules.
Proof nodes: proofNodes is a concatenation of RLP‑encoded trie nodes, ordered from root → leaf. For each node:
- The verifier checks 
keccak256(node) == expectedHashat that depth. - It decodes whether the node is a Branch (17 elements) or Leaf/Extension (2 elements).
 - It advances the path by comparing the appropriate nibbles and follows the embedded reference (either an inline child or a 32‑byte hash to the next node).
 - On Leaf, it verifies 
keccak256(nodeValue) == keccak256(receiptRlp)to ensure the exact receipt bytes are proven. 
If at any step the path diverges, an expected hash does not match, or the final value differs from receiptRlp, verification fails.
Receipt & Log Validation
ReceiptValidator supports typed receipts (EIP‑2718). If the first byte < 0x80, the code treats it as the type prefix and skips it before parsing the RLP list.
Receipt layout parsed:
[status, cumulativeGasUsed, logsBloom, logs[]](post‑Byzantium) — the parser skips tologs[].It then moves to the
logIndex‑th entry and validates the target log:Emitter: the log’s
addressmust equaltokenContract.Topics:
topics[0] == keccak256("Transfer(address,address,uint256)")topics[1] == bytes32(uint256(uint160(executorEOA)))(thefrom)topics[2] == bytes32(uint256(uint160(expectedRecipient)))(theto)
Data: RLP byte string parsed as a big‑endian
uint256must equalexpectedAmount.
Any mismatch reverts with a clear reason (wrong signature, emitter, from/to, or amount).
To better understand the mathematics behind the mathematics Merkle Patricia Trie (MPT) proof used in verifying the ERC-20 transfer events read this