g4titan

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

  1. Anchor to a recent block: collect() accepts a targetBlockNumber and an RLP‑encoded block header. It checks the block is within the last 256 blocks and that keccak256(blockHeader) == blockhash(targetBlockNumber) and that the block header’s encoded number equals targetBlockNumber.
  2. Get receipts root: The contract extracts receiptsRoot from the block header.
  3. 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 at receiptsRoot.
  4. 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.
  5. Payout: If everything checks, the bonded executor receives bond + reward + payment.

What We Verify

Anchoring

Semantic checks on the event

If any check fails, the call reverts with a descriptive error.

Public Interface

Constructor

constructor(address _tokenContract, address _expectedRecipient, uint256 _expectedAmount)

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

Checks performed (in order)

  1. 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");
  2. Receipts root

    • bytes32 receiptsRoot = BlockHeaderParser.extractReceiptsRoot(proof.blockHeader);
  3. Receipt inclusion

    • require(MPTVerifier.verifyReceiptProof(proof.receiptRlp, proof.proofNodes, proof.receiptPath, receiptsRoot), "Invalid receipt MPT proof");
  4. Transfer event

    • require(ReceiptValidator.validateTransferInReceipt(proof.receiptRlp, proof.logIndex, tokenContract, executorEOA, expectedRecipient, expectedAmount), "Invalid Transfer event");
  5. Settlement

    • Transfer bond + currentRewardAmount + currentPaymentAmount to the bonded executor.

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:

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:

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