linera-io/linera-protocol · error

receipt inclusion proof failed

Error message

receipt inclusion proof failed

What it means

Step 2 of process_deposit verifies a Merkle-Patricia trie inclusion proof: proof_nodes must prove that receipt_rlp at tx_index is included under the receipts_root decoded from the block header. The panic means the MPT proof did not verify — wrong node set, wrong tx_index, tampered receipt bytes, or a proof generated against a different block's receipts root. The transaction aborts and no deposit is minted.

Source

Thrown at linera-bridge/contracts/evm-bridge/src/contract.rs:225

        if self.state.rpc_endpoint.get().is_empty() {
            log::warn!("rpc_endpoint is empty — skipping block finality verification.");
        } else if !self
            .state
            .verified_block_hashes
            .contains(&block_hash.0)
            .await
            .expect("failed to check verified block hashes")
        {
            self.verify_block_hash(block_hash.0).await;
        }

        // 2. Verify receipt inclusion via MPT proof
        let proof_bytes: Vec<Bytes> = proof_nodes
            .iter()
            .map(|n| Bytes::copy_from_slice(n))
            .collect();
        proof::verify_receipt_inclusion(receipts_root, tx_index, receipt_rlp, &proof_bytes)
            .expect("receipt inclusion proof failed");

        // 3. Decode receipt logs and parse the deposit event
        let logs = proof::decode_receipt_logs(receipt_rlp).expect("failed to decode receipt logs");
        // `log_index` is a u64 but indexes a Vec (usize). On wasm32 `usize` is
        // 32-bit, so an unchecked `as usize` cast would truncate — letting
        // `log_index` and `log_index + 2^32` select the same log while hashing
        // to different `DepositKey`s (replay-guard bypass → double mint). A
        // checked cast rejects any value that does not fit `usize`; the full
        // u64 is preserved for the `DepositKey` below.
        let log_index_usize = usize::try_from(log_index).expect("log_index out of range");
        assert!(
            log_index_usize < logs.len(),
            "log_index {} out of range (receipt has {} logs)",
            log_index,
            logs.len()
        );
        let bridge_contract_bytes =
            self.state.bridge_contract_address.get().expect(

View on GitHub (pinned to 6c226ddcb3)

Solutions

  1. Verify the proof off-chain first with the same trie logic (e.g. alloy-trie / ethers MPT verifier) using the header's receiptsRoot, tx_index, and receipt_rlp you are about to submit
  2. Ensure receipt_rlp is the exact bytes returned by the RPC (eth_getTransactionReceipt), not a re-encoding
  3. Re-derive tx_index from the block body and confirm proof_nodes came from the same block hash
  4. Handle reorgs: re-fetch header and proof together after finality and resubmit as one consistent set

Example fix

// before
submit(ProcessDeposit { block_header_rlp, receipt_rlp, proof_nodes, tx_index, log_index });
// transaction aborts: receipt inclusion proof failed

// after — verify MPT inclusion off-chain exactly like the contract does
use alloy_trie::proof::ProofVerification;
let root = header.receipts_root;
let key = alloy_rlp::encode_fixed_size(&tx_index).to_vec();
ProofVerification::new(root, key, receipt_rlp.clone(), proof_nodes.clone())
    .verify()
    .context("proof invalid — do not submit")?;
submit(ProcessDeposit { block_header_rlp, receipt_rlp, proof_nodes, tx_index, log_index });
Defensive patterns

Strategy: validation

Validate before calling

// Verify MPT inclusion off-chain with the same parameters before submitting:
use alloy_trie::proof::ProofVerification;
fn inclusion_proof_valid(root: B256, tx_index: u64, receipt_rlp: &[u8], nodes: &[[u8; 33]]) -> bool {
    let key = alloy_rlp::encode(tx_index);
    let proof: Vec<alloy_primitives::Bytes> = nodes.iter().map(|n| Bytes::copy_from_slice(n)).collect();
    ProofVerification::new(root, key.to_vec(), receipt_rlp.to_vec(), proof).verify().is_ok()
}

Try / catch

// On contract rejection, the relayer should not blindly retry: re-fetch the
// full tuple (header, receipt, proof, tx_index) from the source chain after
// any reorg, re-verify locally, then resubmit as one consistent set.

Prevention

When it happens

Trigger: Relayer submits proof_nodes for the wrong transaction index or a different block; receipt_rlp is re-encoded non-canonically so its hash no longer matches the leaf in the proof; nodes are missing (truncated proof array) or in reversed order; the header belongs to a different block than the receipts.

Common situations: Relayers that assemble proofs from eth_getProof or manual trie walks and mismatch block/tx indices after reorgs; non-canonical RLP re-encoding (e.g. integers encoded with leading zeros) when receipts are round-tripped through a store; concurrent relayers mixing proofs from competing forks.

Related errors


AI-assisted analysis of linera-io/linera-protocol@6c226ddcb3 (2026-08-22). Data as JSON: /api/errors/2e7ce19be71ca95a. Report an issue: GitHub.