Skip to content

Pallet Mev-Shield#2219

Merged
sam0x17 merged 32 commits intodevnet-readyfrom
pallet-shield
Nov 25, 2025
Merged

Pallet Mev-Shield#2219
sam0x17 merged 32 commits intodevnet-readyfrom
pallet-shield

Conversation

@JohnReedV
Copy link
Contributor

@JohnReedV JohnReedV commented Nov 18, 2025

Summary

This PR introduces MEV‑Shield, an opt‑in encrypted transaction path that makes MEV‑sensitive calls (e.g. staking/unstaking) opaque to mempool observers. Validators rotate per‑block ML‑KEM‑768 keys, announce the next public key on‑chain, and decrypt/execute wrapped calls only in the last portion of each block. Plain extrinsics continue to behave exactly as before.


What’s changing

1. MEV‑Shield authoring hooks & timing

For authority nodes we attach MEV‑Shield to consensus authoring:

  • Derive per‑slot timing from the consensus slot duration:
let slot_ms = u64::try_from(slot_duration.as_millis()).unwrap_or(u64::MAX);

let announce_at_ms_raw = slot_ms.saturating_mul(7).saturating_div(12);
let decrypt_window_ms   = slot_ms.saturating_mul(3).saturating_div(12);
let announce_at_ms      = announce_at_ms_raw.min(slot_ms.saturating_sub(decrypt_window_ms));

let timing = author::TimeParams { slot_ms, announce_at_ms, decrypt_window_ms };
  • Start MEV‑Shield background tasks only when role.is_authority():
let mev_ctx = author::spawn_author_tasks::<Block, _, _>(..., timing.clone());
proposer::spawn_revealer::<Block, _, _>(..., mev_ctx.clone());

2. Per‑slot ML‑KEM keys and announce_next_key

We add per‑slot key management for authorities plus a pallet entrypoint for the “next” key:

  • New structs:
pub struct TimeParams {
    pub slot_ms: u64,
    pub announce_at_ms: u64,
    pub decrypt_window_ms: u64,
}

pub struct ShieldKeys {
    pub current_sk: Vec<u8>,
    pub current_pk: Vec<u8>,
    pub current_fp: [u8; 32],
    pub next_sk: Vec<u8>,
    pub next_pk: Vec<u8>,
    pub next_fp: [u8; 32],
}

pub struct ShieldContext {
    pub keys: Arc<Mutex<ShieldKeys>>,
    pub timing: TimeParams,
}
  • ShieldKeys manages (current, next) ML‑KEM‑768 keypairs; roll_for_next_slot moves next → current and generates a fresh next.

  • spawn_author_tasks:

    • Requires a local Aura sr25519 key; if none is present the node logs a warning and does not announce MEV‑Shield keys.
    • On each self‑authored block (BlockOrigin::Own):
      • Sleeps announce_at_ms.
      • Reads next_pk from ShieldContext.
      • Submits a signed announce_next_key { public_key: next_pk } extrinsic via submit_announce_extrinsic, with simple stale‑nonce retry.
      • Sleeps the remaining tail of the slot and rolls keys for the next slot.
  • The pallet maintains on‑chain key state:

#[pallet::storage]
pub type CurrentKey<T> = StorageValue<_, BoundedVec<u8, ConstU32<2048>>, OptionQuery>;
#[pallet::storage]
pub type NextKey<T>    = StorageValue<_, BoundedVec<u8, ConstU32<2048>>, OptionQuery>;

and rolls NextKey → CurrentKey in on_initialize.

  • announce_next_key is an Aura‑validator‑only call that also enforces the Kyber‑768 public key length:
#[pallet::call_index(0)]
pub fn announce_next_key(
    origin: OriginFor<T>,
    public_key: BoundedVec<u8, ConstU32<2048>>,
) -> DispatchResult {
    T::AuthorityOrigin::ensure_validator(origin)?;

    const KYBER_PK_LEN: usize = 1184;
    ensure!(public_key.len() == KYBER_PK_LEN, Error::<T>::BadPublicKeyLen);

    NextKey::<T>::put(public_key);
    Ok(())
}

3. Encrypted wrappers, revealer, and execute_revealed

We add a full encrypted wrapper pipeline:

  • Encrypted submissions on‑chain

    #[freeze_struct("66e393c88124f360")]
    pub struct Submission<AccountId, BlockNumber, Hash> {
        pub author: AccountId,
        pub commitment: Hash,
        pub ciphertext: BoundedVec<u8, ConstU32<8192>>,
        pub submitted_in: BlockNumber,
    }
    
    #[pallet::call_index(1)]
    pub fn submit_encrypted(
        origin: OriginFor<T>,
        commitment: T::Hash,
        ciphertext: BoundedVec<u8, ConstU32<8192>>,
    ) -> DispatchResult
    • commitment = blake2_256(signer || nonce (u32 LE) || SCALE(call)).
    • ciphertext = [u16 kem_len] || kem_ct || nonce24 || aead_ct where aead_ct is XChaCha20‑Poly1305 over signer || nonce || SCALE(call) || sig_kind || signature.
    • Stores a Submission keyed by id = hash(author, commitment, ciphertext) and emits EncryptedSubmitted { id, who }.
  • Runtime execution of revealed calls

    #[pallet::call_index(2)]
    pub fn execute_revealed(
        origin: OriginFor<T>,
        id: T::Hash,
        signer: T::AccountId,
        nonce: T::Nonce,
        call: Box<<T as Config>::RuntimeCall>,
        signature: MultiSignature,
    ) -> DispatchResultWithPostInfo
    • Looks up and consumes the Submission with id id, failing with MissingSubmission if absent.
    • Rebuilds the raw payload (signer || nonce (u32 LE) || SCALE(call)) and checks:
      • commitment matches the stored commitment,
      • signature verifies against "mev-shield:v1" || genesis_hash || payload,
      • nonce equals System::account_nonce(&signer) (then bumps it).
    • Dispatches the inner call as a signed call from signer.
    • Emits DecryptedExecuted on success or DecryptedRejected on error; execute_revealed itself is fee‑free (only submit_encrypted pays).
  • Unsigned validation

    • ValidateUnsigned only accepts execute_revealed when:
      • source ∈ { Local, InBlock }, and
      • the transaction is tagged with provides(id) and propagate(false).
    • This ensures reveals never gossip and can only originate from the author’s own node or be read from an already built block.

4. Tests

The new tests cover the main flows:

  • Key announce & roll

    • authority_can_announce_next_key_and_on_initialize_rolls_it – Aura validator can call announce_next_key; on_initialize promotes NextKey into CurrentKey and clears NextKey.
    • announce_next_key_rejects_non_validator_origins – non‑validators and unsigned origins are rejected with BadOrigin.
  • Wrapper lifecycle

    • submit_encrypted_stores_submission_and_emits_event – stores the expected Submission and emits EncryptedSubmitted { id, who }.
    • execute_revealed_happy_path_verifies_and_executes_inner_call – reconstructs the payload and commitment, signs the domain‑separated message with sr25519, executes execute_revealed, consumes the submission, bumps the signer’s nonce, and emits DecryptedExecuted.
  • Unsigned validation

    • validate_unsigned_accepts_local_source_for_execute_revealed.
    • validate_unsigned_accepts_inblock_source_for_execute_revealed.

@JohnReedV JohnReedV added skip-cargo-audit This PR fails cargo audit but needs to be merged anyway apply-benchmark-patch labels Nov 24, 2025
@JohnReedV JohnReedV marked this pull request as ready for review November 25, 2025 18:56
Comment on lines +37 to +46
let (sk, pk) = MlKem768::generate(&mut OsRng);

let sk_bytes = sk.as_bytes();
let pk_bytes = pk.as_bytes();
let sk_slice: &[u8] = sk_bytes.as_ref();
let pk_slice: &[u8] = pk_bytes.as_ref();

let current_sk = sk_slice.to_vec();
let current_pk = pk_slice.to_vec();
let current_fp = blake2_256(pk_slice);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could probably be extracted as a function, this is repeated 3 times

@JohnReedV JohnReedV changed the title Pallet Shield Pallet Mev-Shield Nov 25, 2025
@sam0x17 sam0x17 merged commit 534baf6 into devnet-ready Nov 25, 2025
35 of 57 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-cargo-audit This PR fails cargo audit but needs to be merged anyway

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants