Polkadot XCMP MMD — Minimal POC


Forum background: XCMP Design Discussion (Polkadot).


Problem and motivation

HRMP stores message payloads on the relay chain, which is expensive (storage + execution).

XCMP (MMD approach) replaces that with:


How MMD XCMP replaces HRMP (conceptually)

HRMP:

MMD XCMP:

Minimal POC semantics:


POC Spec (high level)

This is the minimal spec that the implementation follows.

POC design revamp (vs forum MMD)

Must-haves (even for minimal POC)

Current Beefy-(MMR) implementation on the relay chain that we rely on

Relay chain runtimes configure pallet_beefy_mmr::LeafExtra = H256 and set:

Important: this defines the proof format and hashing. Our verifier must match it exactly.

“Matryoshka” proof stack (minimal POC variant, simplified)

Smallest → largest commitments

  1. XcmpOutboxMmr (source chain, global, append-only): each drained outbound page becomes one leaf containing (dest_para_id, payload_hash), where payload_hash = Keccak256(page_bytes). The global mmr_leaf_index is the only monotonic identifier (“nonce”).
  2. Source parachain header: the header digest commits the rolling XcmpOutboxMmrRoot (bagged MMR root after the block’s appends; empty blocks carry-forward the last root).
  3. ParaHeadsRoot (relay snapshot): a chosen relay MMR leaf exposes leaf_extra = ParaHeadsRoot, a binary Merkle root over SCALE((para_id_u32, head_bytes)) entries (sorted by para_id). The relevant entry includes the source head_bytes whose digest contains the xmmd item carrying XcmpOutboxMmrRoot.
  4. Relay MMR root (implicit root anchor): the relay-parent state trie root already carried in destination PersistedValidationData as relay_parent_storage_root. Under that root, the relay runtime stores the current MMR root in pallet_mmr::RootHash (same value BEEFY logs as ConsensusLog::MmrRoot for that block). Two supported ways to obtain mmr_root in the verifier (pick one; Appendix A):
    • Option A — relayer StorageProof: the relayer includes a proof for Mmr::RootHash in submit_xcmp_mmd (no collator / inherent changes).
    • Option B — collator relay proof extension: the destination runtime implements KeyToIncludeInRelayProof so the collator merges the Mmr::RootHash key into the same inherent relay proof already stored as ParachainSystem::RelayStateProof; the inbox pallet reconstructs RelayChainStateProof and reads the value (small runtime change; no extra proof in the extrinsic).
      In both cases the value is decoded from the verified trie — no relay header bytes and no explicit relay_mmr_root scalar in calldata. Because the relay MMR is append-only, historical relay leaves still verify under later RootHash values once you have a wide enough LeafProof.

Destination verifies nested proofs

  1. Obtain mmr_root = Mmr::RootHash read under ValidationData.relay_parent_storage_root (Option A: verify relay_mmr_root_proof in the extrinsic; Option B: read from RelayChainStateProof rebuilt from RelayStateProof + ValidationData — Appendix A). Same trie / hasher stack as Cumulus RelayChainStateProof::new.
  2. Verify a single relay MMR leaf proof at relay_mmr_leaf_index against mmr_root, then decode the leaf to obtain leaf_extra = ParaHeadsRoot.
  3. Verify binary_merkle_tree::MerkleProof for SCALE((source, head_bytes)) against ParaHeadsRoot.
  4. Decode head_bytes as the source parachain header, then read DigestItem::PreRuntime(*b"xmmd", …)XcmpOutboxMmrRoot.
  5. Verify outbox MMR leaf proof (single leaf) for leaf (dest, payload_hash) at mmr_leaf_index against XcmpOutboxMmrRoot.
  6. Check Keccak256(payload) == payload_hash (relayer supplies bytes).
  7. Replay protection: reject if seen((source, mmr_leaf_index)).
  8. POC execution: emit event / enqueue bytes / XCM execution

POC Implementation (low level)

Commitments, identifiers, and proof types (concrete)

Source: XcmpMmdOutbox Pallet

How to integrate with the current XcmpQueue

One-block dataflow

ParachainSystem::on_finalize
  └─ take_outbound_messages (wrapper)
       ├─ XcmpQueue::take_outbound_messages
       └─ for each (dest, data): note_outbound → push leaf on XcmpOutboxMmr

XcmpMmdOutbox::on_finalize   // after ParachainSystem
  └─ XcmpOutboxMmrRoot = bag_peaks (current MMR root)
  └─ deposit_log(PreRuntime, (version, XcmpOutboxMmrRoot))

Critical: in construct_runtime!, place XcmpMmdOutbox after ParachainSystem.

Destination: XcmpMmdInbox Pallet

Submission model: permissionless extrinsic

Anyone can be a relayer. The destination chain exposes an extrinsic, e.g.:

What the extrinsic must carry (per message)

Off-chain relayer tool

Where the relayer gets the full payload (XCM bytes)

On-chain we only commit payload_hash. The verifier checks that submitted Vec<u8> matches that hash; it does not reconstruct the message from the chain.

The relayer obtains the original page bytes from a data-availability path, for example:

How the relayer generates the outbox MMR proof

To submit outbox_mmr_proof for a historical mmr_leaf_index, the relayer needs a way to obtain a single-leaf sp_mmr_primitives::LeafProof<H256> against the XcmpOutboxMmrRoot committed in the source header.

Preferred (POC-friendly): expose a runtime API that returns the outbox leaf + proof at a given block:

The relayer calls this against a full/archive source node (historical state), then submits that proof bundle to the destination.

Storage implication: the source chain must retain enough MMR node/peak data to build proofs for the desired history range. For a minimal POC this can be no pruning; a production design can use a bounded window (older proofs become unavailable unless material is retained elsewhere).

Verification (canonical algorithm)

  1. Resolve mmr_root:
    • Option A: from ValidationData.relay_parent_storage_root + relay_mmr_root_proof (mirror RelayChainStateProof::new, then read Mmr::RootHash).
    • Option B: RelayChainStateProof::new(SelfParaId, relay_parent_storage_root, RelayStateProof::get()) then read_mmr_root_hash() (or equivalent) — the inherent already proved this key if the runtime listed it in KeyToIncludeInRelayProof::keys_to_prove().
  2. Verify the submitted relay MMR leaf proof (single leaf) at relay_mmr_leaf_index against mmr_root; decode the proven leaf and read leaf_extra = ParaHeadsRoot (snapshot for that relay leaf, not “whatever the tip says today”).
  3. Verify the submitted para-heads Merkle proof against that ParaHeadsRoot and decode its leaf as SCALE((source_u32, head_bytes)).
  4. Decode head_bytes as the source parachain header → extract XcmpOutboxMmrRoot digest item (engine_id = *b"xmmd").
  5. Verify the submitted outbox MMR proof (single leaf) for outbox leaf (dest, payload_hash) at mmr_leaf_index against XcmpOutboxMmrRoot.
  6. Check Keccak256(payload) == payload_hash.
  7. Replay protection: seen((source, mmr_leaf_index)) must be false; then mark it seen.
  8. Post-verify: destination execution — feed those bytes into the destination runtime’s normal inbound XCMP dispatch path with the correct sender origin (ParaId::from(source) / Sibling(ParaId)), typically by invoking the configured XcmpMessageHandler (in Cumulus templates this is usually XcmpQueue) using whatever internal hook / helper your runtime exposes for “append sibling message bytes”.

Minimal verifier guards

Appendix A: Relay MMR root (trustless anchor on the destination)

What the parachain already knows

set_validation_data stores relay PersistedValidationData, including:

and persists the relay trie proof bytes as ParachainSystem::RelayStateProof (already verified in set_validation_data via RelayChainStateProof::new against that root).

That storage root is the trust anchor for any relay-chain storage read proven inside the parachain runtime (same pattern as Cumulus RelayChainStateProof, which builds a TrieBackend with HashingFor<RelayBlock> at relay_parent_storage_root).

Where mmr_root lives on the relay chain

pallet_mmr persists the latest MMR root as a normal storage value RootHash, updated as part of block execution. Rococo-style Polkadot SDK relay runtimes also wire pallet_mmr::Config::OnNewRoot = pallet_beefy_mmr::DepositBeefyDigest, which logs the same root into the relay header as ConsensusLog::MmrRoot — useful for light clients, but the parachain POC verifier should not depend on a user-supplied relay header digest (that digest is not bound by relay_parent_storage_root alone).

Storage key (must match the relay runtime)

FRAME’s fixed 32-byte prefix for a storage value is:

concat(twox_128(pallet_name), twox_128(storage_item_name))

So for pallet_mmr::RootHash you need the exact pallet_name bytes from the relay’s construct_runtime! (e.g. Mmr next to pallet_mmr on Rococo), and RootHash as the item name. If the relay renames the pallet, uses a second MMR instance, or you target another relay flavor without pallet_mmr, the key changes or the read does not exist — hard-code / generate the key against the relay runtime you support.

Option A — relayer carries relay_mmr_root_proof (no collator change)

Option B — collator merges the key (KeyToIncludeInRelayProof)

Cumulus merges KeyToIncludeInRelayProof::keys_to_prove() into the same relay proof the collator puts in the inherent (cumulus/client/parachain-inherentcollect_relay_storage_proof). Your destination runtime returns e.g. RelayStorageKey::Top(mmr_root_key_bytes) alongside the static HRMP/DMQ keys.

Reference pattern (test runtime): cumulus/test/runtime/src/lib.rsimpl KeyToIncludeInRelayProof for Runtime { fn keys_to_prove() -> RelayProofRequest { … } }. The SDK parachain template currently returns Default::default() (no extra keys) until you add them.

Then the inbox pallet does RelayChainStateProof::new(para_id, relay_parent_storage_root, RelayStateProof::<T>::get())? and a small helper (not in Cumulus today — you add it) such as read_mmr_root_hash() that reads the key from the verified trie. Extrinsic: omit relay_mmr_root_proof; proof size per block grows slightly for this parachain only (unlike extending Cumulus’ global static key list, which would affect everyone).

Requirement: keys_to_prove() must list the Mmr::RootHash key for every relay runtime / pallet name you support. If the collator omits it, the merged proof has no leaf for that key → read_mmr_root_hash() fails and submit_xcmp_mmd must reject (fail closed). Today’s ParachainSystem inherent does not read this key itself, so the block can still be built; the bug surfaces at your verifier unless you add an explicit inherent-time check.

After mmr_root

Verify relay_mmr_proof (single leaf) against mmr_root, decode leaf_extra = ParaHeadsRoot, and continue the stack as in the main body.

Historical relay leaves

RootHash at the relay parent is the MMR root after that relay block’s MMR update; it commits to all prior relay leaves. Proving an earlier relay leaf under that root is the usual append-only MMR story (wider LeafProof when the leaf is old). You still need an archive-quality relay view (or deep enough MMR proof material) to construct those proofs off-chain.


Appendix B: Where HRMP flows today