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
- 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
- Ensure receipt_rlp is the exact bytes returned by the RPC (eth_getTransactionReceipt), not a re-encoding
- Re-derive tx_index from the block body and confirm proof_nodes came from the same block hash
- 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
- Treat header, receipt, proof, and indices as one atomic bundle fetched in a single pass
- Never re-encode receipt bytes; forward RPC-returned bytes verbatim
- Log the receiptsRoot/key/leaf hash of failing proofs to diff against the contract's view
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
- failed to query chain ID from RPC endpoint
- invalid block header RLP
- failed to decode receipt logs
- failed to parse DepositInitiated event
- deposit amount exceeds u128
AI-assisted analysis of linera-io/linera-protocol@6c226ddcb3 (2026-08-22).
Data as JSON: /api/errors/2e7ce19be71ca95a.
Report an issue: GitHub.