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 atargetBlockNumber
and 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 encodednumber
equalstargetBlockNumber
. - Get receipts root: The contract extracts
receiptsRoot
from 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
Transfer
log: 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
from
topic equals the executor EOA supplied tocollect()
. - The
to
topic 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 = 256
to align with the EVM’sblockhash
availability.
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.
targetBlockNumber
is not in the future and is withinmaxBlockLookback
blocks 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 + currentPaymentAmount
to 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) == expectedHash
at 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
address
must 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
uint256
must 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