Skip to content

MEV V2 infrastructure#2533

Draft
shamil-gadelshin wants to merge 4 commits intodevnet-readyfrom
mev-shield-v2-on-initialize
Draft

MEV V2 infrastructure#2533
shamil-gadelshin wants to merge 4 commits intodevnet-readyfrom
mev-shield-v2-on-initialize

Conversation

@shamil-gadelshin
Copy link
Collaborator

This PR introduces infrastructure for the upcoming MEV Shield V2 feature. The idea is to accumulate encrypted extrinsics in a queue and decrypt them in the next block during the on_initialize phase. Encryption and decryption are
outside the scope of this PR and abstracted via the ExtrinsicDecryptor trait. Protective limits include encrypted call size (const), queue size, weight for extrinsic execution, and lifetime in the queue.

PR features

  • Add on_initialize hook to process pending encrypted extrinsics with configurable weight limits, extrinsic lifetime, and queue size
  • Add store_encrypted extrinsic for queuing encrypted calls for deferred execution
  • Add three root-gated configuration extrinsics: set_max_pending_extrinsics_number, set_on_initialize_weight, and set_stored_extrinsic_lifetime
  • Introduce ExtrinsicDecryptor trait for decrypting stored extrinsics before dispatch, with expiration, weight budgeting, and event logging

Details

New Storage Items

  • PendingExtrinsics — map of queued encrypted extrinsics awaiting execution
  • NextPendingExtrinsicIndex / PendingExtrinsicCount — auto-increment index and live count for the queue
  • MaxPendingExtrinsicsLimit — configurable max queue depth (default: 100)
  • OnInitializeWeight — configurable max weight budget for on_initialize processing (default: 500B ref_time, capped at 2T)
  • ExtrinsicLifetime — configurable max age in blocks before expiration (default: 10)

New Extrinsics

  • store_encrypted — signed extrinsic to queue an encrypted call
  • set_max_pending_extrinsics_number — root-only, sets queue limit
  • set_on_initialize_weight — root-only, sets weight budget with absolute max guard
  • set_stored_extrinsic_lifetime — root-only, sets expiration window

Processing Logic (on_initialize)

Iterates pending extrinsics in insertion order. For each entry:

  1. Expired entries are removed and ExtrinsicExpired is emitted
  2. Decryption failures are removed and ExtrinsicDecodeFailed is emitted
  3. If dispatching would exceed the weight budget, processing stops (ExtrinsicPostponed)
  4. Otherwise, the call is dispatched from the submitter's signed origin

Other Changes

  • Added RuntimeCall and ExtrinsicDecryptor associated types to pallet_shield::Config
  • Disambiguated T::RuntimeCall in check_mortality.rs to resolve ambiguity between frame_system::Config and pallet_shield::Config
  • Comprehensive tests for all new extrinsics, storage limits, expiration, weight budgeting, and edge cases

Unit Test Plan

  • Unit tests for store_encrypted(success, queue full, event emission)
  • Unit tests for set_max_pending_extrinsics_number (root-only, value persistence)
  • Unit tests for set_on_initialize_weight (root-only, absolute max rejection)
  • Unit tests for set_stored_extrinsic_lifetime (root-only, value persistence)
  • Unit tests for on_initialize processing (dispatch, expiration, weight limits, decode failures)

Benchmarks

To be added after the initial review.

@shamil-gadelshin shamil-gadelshin self-assigned this Mar 24, 2026
@shamil-gadelshin shamil-gadelshin added the skip-cargo-audit This PR fails cargo audit but needs to be merged anyway label Mar 24, 2026
Comment on lines +168 to +172
/// Storage map for encrypted extrinsics to be executed in on_initialize.
/// Uses u32 index for O(1) insertion and removal.
#[pallet::storage]
pub type PendingExtrinsics<T: Config> =
StorageMap<_, Identity, u32, PendingExtrinsic<T>, OptionQuery>;
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is maybe one of the rare case where we could use a CountedStorageMap

Comment on lines +122 to +123
/// Maximum size of a single encoded call.
pub type MaxCallSize = ConstU32<8192>;
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not sure if this is the correct naming here because it seems to be the ciphertext, which may be bigger than the actual call, maybe something like MaxCiphertextSize or MaxEncryptedCallSize ?

fn remove_pending_extrinsic<T: Config>(index: u32, weight: &mut Weight) {
PendingExtrinsics::<T>::remove(index);
PendingExtrinsicCount::<T>::mutate(|c| *c = c.saturating_sub(1));
*weight = weight.saturating_add(T::DbWeight::get().writes(2));
Copy link
Collaborator

Choose a reason for hiding this comment

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

mutate has a read too

Comment on lines +162 to +163
/// The encoded call data.
pub call: BoundedVec<u8, MaxCallSize>,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same here, probably more explicit name like encrypted_call?

Comment on lines +481 to +490
let call = match T::ExtrinsicDecryptor::decrypt(&pending.call) {
Ok(call) => call,
Err(_) => {
remove_pending_extrinsic::<T>(index, &mut weight);

Self::deposit_event(Event::ExtrinsicDecodeFailed { index });

continue;
}
};
Copy link
Collaborator

Choose a reason for hiding this comment

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

let Ok() cleaner here, no?

}

#[test]
fn on_initialize_handles_dispatch_failure() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

test name is on_initialize_handles_dispatch_failure but we check success on multiple calls?

Copy link
Collaborator

@l0r1s l0r1s left a comment

Choose a reason for hiding this comment

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

Overall the design is good. My only concern is that the inner call is never charged for, the user gets free execution for whatever is inside. This could probably be fixed by implementing an additional check when trying to execute the call similar to what the ChargeTransactionPayment extension does and reject it if fee can't be paid, could even be done through a DispatchExtension.

Copy link
Contributor

@JohnReedV JohnReedV left a comment

Choose a reason for hiding this comment

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

Looks good to me. Didn't see anything in addition to Loris’s comments.

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