Is your feature request related to a problem? Please describe.
Yes. The current MEV Shield protocol requires two independently signed extrinsics for a direct, self-paid submission:
- the encrypted inner extrinsic is signed at nonce
n + 1; and
- the
MevShield.submit_encrypted carrier is signed at nonce n.
This is explicit in the canonical Rust SDK on main / runtime v452: submit_shielded signs the inner and then calls submit for the outer. The proposer then puts both the wrapper and decrypted signed inner extrinsic in the block and applies them separately.
For an injected wallet or Ledger this means two approvals for one intended trade. It also creates two nonce/fee surfaces. This is not only an SDK presentation problem: PR #3100 explicitly has to prove affordability for both the inner declared-fee reserve and the outer carrier fee.
The common workaround is a funded carrier relay: the user signs the inner extrinsic once and a service signs/pays for submit_encrypted. That can hide the second prompt, but it is not a general protocol solution. It requires a funded online key, fee subsidy/reimbursement policy, rate limiting and relay availability. A dapp that does not subsidize transaction fees cannot offer this UX to every wallet.
The desired invariant is:
A user signs one ordinary, mortal Substrate extrinsic; that same signer is the only possible origin of the decrypted call and pays the complete protocol fee. No relay, delegated proxy, second account or second signature is required.
This should work through the standard wallet signPayload path, including Ledger-backed wallets. It must not require signRaw, custom wallet APIs, exported key material or a backend signer.
Describe the solution you'd like
Add an additive direct-origin MEV Shield mode (name illustrative: submit_encrypted_call) whose signed outer extrinsic authorizes an encrypted RuntimeCall, rather than encrypting a second signed extrinsic.
The existing signed-inner mode can remain for relayed or compatibility use cases. The new mode should be deliberately narrower at first; an allowlist of slippage-protected staking/swap calls is preferable to making every RuntimeCall shieldable before extension parity is proven.
Suggested wire contract
The browser/SDK constructs a versioned envelope similar to:
plaintext = SCALE({ random_salt, inner_call })
commitment = blake2_256(
"bittensor.mev-shield.direct.v1" ||
genesis_hash || outer_signer || random_salt || SCALE(inner_call)
)
ciphertext = ML-KEM-768 + XChaCha20-Poly1305(plaintext, authenticated_context)
submit_encrypted_call {
version,
key_hash,
call_class,
commitment,
declared_inner_weight,
ciphertext
}
The random salt is important: an unsalted public hash of a low-entropy swap call can be dictionary-guessed. AEAD associated data should bind at least the version, genesis hash, signer, key id/hash, commitment, call class and declared weight.
The outer extrinsic is signed normally, so the existing signature, genesis/spec/transaction-version, mortal-era, metadata-hash and nonce checks protect the authorization and consume exactly one account nonce. The decrypted payload must not be allowed to choose an origin; the runtime derives it only from the verified outer signer.
Suggested block flow
- The proposer recognizes the direct shield envelope and decrypts
{ salt, inner_call } before inclusion.
- It verifies the commitment and declared call class/weight locally and accounts for wrapper + reveal size.
- It pushes the signed wrapper and an immediately adjacent, non-gossiped reveal (illustrative:
execute_revealed_direct { id, salt, inner_call }).
- During deterministic block execution, the wrapper records a one-shot authorization
{ id, signer, commitment, class, weight/fee budget }.
- The reveal is accepted only from
Local/InBlock, consumes that authorization exactly once, recomputes the salted commitment, validates the allowed call and dispatches it as the stored signer.
- Validators need no decryption key: the signed commitment plus revealed preimage is enough to verify that the author did not replace the call.
The proposer should decrypt and preflight before pushing the wrapper. A malformed/undecryptable envelope should not be included as a wrapper-only success. The runtime should reject a reveal without its matching earlier wrapper, prevent duplicate reveals, and either enforce adjacency or otherwise make it impossible to interleave a transaction inside the wrapper/reveal pair.
Transaction-extension parity is a release blocker
The direct reveal must not just call inner_call.dispatch(RawOrigin::Signed(who)) and call the work done.
That is what the dormant queue currently does (process_pending_extrinsics), but a raw call does not automatically reproduce the full signed-extrinsic pipeline. Current call-dependent policy includes fee-payer resolution and SubtensorTransactionExtension, including coldkey-swap guards, rate limits, commitment policy, weight checks, serving checks and EVM-key checks. The runtime extension tuple is listed here.
For the direct mode:
- signer/spec/genesis/mortality/metadata/nonce checks should run once on the outer extrinsic;
- inner-call-dependent safety checks and the base call filter must run against the revealed call and stored signer;
- sudo/mandatory/operational calls should be rejected unless explicitly designed and audited;
- declared weight must be checked against
inner_call.get_dispatch_info() before dispatch; and
- proxy/multisig semantics should either have explicit parity tests or remain out of the initial allowlist.
A maintainable implementation would extract the call-dependent checks into a reusable validator used by both SubtensorTransactionExtension and the shield reveal, rather than copying a second list that can drift.
One user-funded fee
The signed wrapper should prepay the complete bounded cost once:
- carrier dispatch and ciphertext validation;
- proposer ML-KEM/AEAD work (already benchmarked into
submit_encrypted);
- revealed call dispatch and call-dependent extension weight; and
- the extra revealed bytes included in the block.
The reveal itself should not need a funded signer. Its work has already been charged/reserved to the outer signer. Under-declared weight/length must fail closed. If a maximum is prepaid, unused weight can be refunded after reveal; PR #2587 explored this accounting for the encrypted queue. A conservative no-refund first version is safer than undercharging, but the quote and maximum must be explicit to clients.
This is one user payment for the combined work, not a fee subsidy: neither the block author, a dapp backend nor a proxy wallet pays on the user's behalf.
Wallet and browser behavior
Encryption can remain entirely on the user device. A browser WASM implementation of ML-KEM/XChaCha is a reasonable client implementation, but WASM alone cannot fix today's two approvals: the second authorization exists because the wire format contains a signed inner extrinsic and a separately signed carrier.
The direct mode lets the client encrypt first and request one standard outer signature, so it is transport-compatible with Ledger-backed wallets. There is an unavoidable review caveat that should be documented rather than hidden: an unmodified hardware wallet cannot decode an encrypted inner call, so it can verify/sign the MEV Shield envelope but cannot independently display the secret amount/limit. A public, runtime-checked call_class can at least make “shielded swap” distinguishable. Device-verified display of all private parameters would require wallet/firmware support and is outside this protocol change.
Acceptance criteria / regression tests
- A mock injected signer observes exactly one
signPayload request and no signRaw request.
- Success consumes one nonce, not
n and n + 1; failure after inclusion also has normal one-transaction nonce semantics.
- Only the outer signer can become the inner origin. Altering signer, call, salt, commitment, class, weight, genesis context, key id or ciphertext fails closed.
- The reveal is one-shot, cannot gossip as a normal unsigned transaction, cannot precede its wrapper and cannot be replayed across blocks/chains.
- The signer is charged once for the complete bounded wrapper + reveal + inner cost. No other account needs a balance.
- Scheduled/disputed coldkey swaps, rate-limited calls, base-filtered calls and other relevant guards produce the same result as the equivalent clear signed extrinsic.
- An under-declared or disallowed call never dispatches. Ciphertext size, KEM/AEAD parsing, block size and block weight remain bounded.
- The proposer does not include wrapper-only successes for ciphertext it cannot decrypt or structurally validate.
- Inner dispatch failure is reported as the logical shielded transaction result and cannot be mistaken for success merely because the outer wrapper succeeded.
- Existing
submit_encrypted(signed_inner) behavior remains compatible until clients have migrated.
Describe alternatives you've considered
Funded relay / backend proxy wallet
This removes the second user prompt by moving the carrier signature and fee to an online service. It does not remove the second signer/fee from the protocol, requires a funded hot key and makes liveness/rate limits a service concern. It is unsuitable for dapps that will not subsidize user fees.
Persistent Substrate proxy plus RealPaysFee
This requires an on-chain delegation, another managed key and careful proxy filtering. It expands standing authority and fee-drain risk, and is not a universal “connect any wallet and swap” path.
A second local/browser account
This moves the operational burden to the user and still requires funding/key lifecycle management. It is not an acceptable wallet UX or safety model.
Only enabling store_encrypted
store_encrypted is useful design evidence because it already binds a queued call to the signed submitter (storage path). It is not currently a working Finney path: the runtime config sets ExtrinsicDecryptor = (), whose implementation always returns "ExtrinsicDecryptor not implemented" (definition). More importantly, simply decoding and raw-dispatching a RuntimeCall would leave the extension-parity and deterministic key-release problems above unresolved.
Restoring the original execute_revealed unchanged
PR #2219 is useful prior art for commitment + reveal + user-paid outer fee, but its reveal carried a separate MultiSignature, manually checked/incremented another nonce and directly dispatched the call. It therefore retained a second authorization and did not provide the current full signed-extrinsic extension semantics. The useful part to recover is the consensus-visible commitment/reveal binding, not the old detached signature.
Threshold encryption / IBE
Threshold key release can improve the threat model against a single block author and may be valuable independently. It does not by itself reduce wallet approvals if the encrypted plaintext remains a fully signed inner extrinsic. Authorization/origin/fee semantics and encryption key-release semantics should be treated as separable layers.
Additional context
Relevant implementation/history:
Likely implementation boundary:
pallets/shield: versioned direct envelope, salted commitment, one-shot reveal authorization, safe call filter, events/errors, declared-weight checks and tests.
runtime: reusable inner-call policy validation, combined user-paid fee/weight policy, short mortality and explicit exclusions for unsupported call classes.
RaoFoundation/polkadot-sdk basic authorship + shield runtime API: decrypt/preflight the direct envelope, account for both encoded objects, and append wrapper/reveal as a pair.
- Rust/Python/JS SDKs: encrypt
RuntimeCall rather than a signed extrinsic, request exactly one standard payload signature, quote the combined fee and resolve the inner outcome.
- Zombienet/E2E: wrong-key rotation, malformed ciphertext, one-signature/one-nonce/one-payer accounting, extension parity, wrapper/reveal ordering, dispatch failure and finality/reorg receipts.
This request is intentionally not asking for a relay, fee sponsorship or a wallet-specific integration. It asks for a runtime-authorized direct mode where the outer signature is the sole user authorization, origin binding and fee payment for the shielded call.
Is your feature request related to a problem? Please describe.
Yes. The current MEV Shield protocol requires two independently signed extrinsics for a direct, self-paid submission:
n + 1; andMevShield.submit_encryptedcarrier is signed at noncen.This is explicit in the canonical Rust SDK on
main/ runtime v452:submit_shieldedsigns the inner and then callssubmitfor the outer. The proposer then puts both the wrapper and decrypted signed inner extrinsic in the block and applies them separately.For an injected wallet or Ledger this means two approvals for one intended trade. It also creates two nonce/fee surfaces. This is not only an SDK presentation problem: PR #3100 explicitly has to prove affordability for both the inner declared-fee reserve and the outer carrier fee.
The common workaround is a funded carrier relay: the user signs the inner extrinsic once and a service signs/pays for
submit_encrypted. That can hide the second prompt, but it is not a general protocol solution. It requires a funded online key, fee subsidy/reimbursement policy, rate limiting and relay availability. A dapp that does not subsidize transaction fees cannot offer this UX to every wallet.The desired invariant is:
This should work through the standard wallet
signPayloadpath, including Ledger-backed wallets. It must not requiresignRaw, custom wallet APIs, exported key material or a backend signer.Describe the solution you'd like
Add an additive direct-origin MEV Shield mode (name illustrative:
submit_encrypted_call) whose signed outer extrinsic authorizes an encryptedRuntimeCall, rather than encrypting a second signed extrinsic.The existing signed-inner mode can remain for relayed or compatibility use cases. The new mode should be deliberately narrower at first; an allowlist of slippage-protected staking/swap calls is preferable to making every
RuntimeCallshieldable before extension parity is proven.Suggested wire contract
The browser/SDK constructs a versioned envelope similar to:
The random salt is important: an unsalted public hash of a low-entropy swap call can be dictionary-guessed. AEAD associated data should bind at least the version, genesis hash, signer, key id/hash, commitment, call class and declared weight.
The outer extrinsic is signed normally, so the existing signature, genesis/spec/transaction-version, mortal-era, metadata-hash and nonce checks protect the authorization and consume exactly one account nonce. The decrypted payload must not be allowed to choose an origin; the runtime derives it only from the verified outer signer.
Suggested block flow
{ salt, inner_call }before inclusion.execute_revealed_direct { id, salt, inner_call }).{ id, signer, commitment, class, weight/fee budget }.Local/InBlock, consumes that authorization exactly once, recomputes the salted commitment, validates the allowed call and dispatches it as the stored signer.The proposer should decrypt and preflight before pushing the wrapper. A malformed/undecryptable envelope should not be included as a wrapper-only success. The runtime should reject a reveal without its matching earlier wrapper, prevent duplicate reveals, and either enforce adjacency or otherwise make it impossible to interleave a transaction inside the wrapper/reveal pair.
Transaction-extension parity is a release blocker
The direct reveal must not just call
inner_call.dispatch(RawOrigin::Signed(who))and call the work done.That is what the dormant queue currently does (
process_pending_extrinsics), but a raw call does not automatically reproduce the full signed-extrinsic pipeline. Current call-dependent policy includes fee-payer resolution andSubtensorTransactionExtension, including coldkey-swap guards, rate limits, commitment policy, weight checks, serving checks and EVM-key checks. The runtime extension tuple is listed here.For the direct mode:
inner_call.get_dispatch_info()before dispatch; andA maintainable implementation would extract the call-dependent checks into a reusable validator used by both
SubtensorTransactionExtensionand the shield reveal, rather than copying a second list that can drift.One user-funded fee
The signed wrapper should prepay the complete bounded cost once:
submit_encrypted);The reveal itself should not need a funded signer. Its work has already been charged/reserved to the outer signer. Under-declared weight/length must fail closed. If a maximum is prepaid, unused weight can be refunded after reveal; PR #2587 explored this accounting for the encrypted queue. A conservative no-refund first version is safer than undercharging, but the quote and maximum must be explicit to clients.
This is one user payment for the combined work, not a fee subsidy: neither the block author, a dapp backend nor a proxy wallet pays on the user's behalf.
Wallet and browser behavior
Encryption can remain entirely on the user device. A browser WASM implementation of ML-KEM/XChaCha is a reasonable client implementation, but WASM alone cannot fix today's two approvals: the second authorization exists because the wire format contains a signed inner extrinsic and a separately signed carrier.
The direct mode lets the client encrypt first and request one standard outer signature, so it is transport-compatible with Ledger-backed wallets. There is an unavoidable review caveat that should be documented rather than hidden: an unmodified hardware wallet cannot decode an encrypted inner call, so it can verify/sign the MEV Shield envelope but cannot independently display the secret amount/limit. A public, runtime-checked
call_classcan at least make “shielded swap” distinguishable. Device-verified display of all private parameters would require wallet/firmware support and is outside this protocol change.Acceptance criteria / regression tests
signPayloadrequest and nosignRawrequest.nandn + 1; failure after inclusion also has normal one-transaction nonce semantics.submit_encrypted(signed_inner)behavior remains compatible until clients have migrated.Describe alternatives you've considered
Funded relay / backend proxy wallet
This removes the second user prompt by moving the carrier signature and fee to an online service. It does not remove the second signer/fee from the protocol, requires a funded hot key and makes liveness/rate limits a service concern. It is unsuitable for dapps that will not subsidize user fees.
Persistent Substrate proxy plus
RealPaysFeeThis requires an on-chain delegation, another managed key and careful proxy filtering. It expands standing authority and fee-drain risk, and is not a universal “connect any wallet and swap” path.
A second local/browser account
This moves the operational burden to the user and still requires funding/key lifecycle management. It is not an acceptable wallet UX or safety model.
Only enabling
store_encryptedstore_encryptedis useful design evidence because it already binds a queued call to the signed submitter (storage path). It is not currently a working Finney path: the runtime config setsExtrinsicDecryptor = (), whose implementation always returns"ExtrinsicDecryptor not implemented"(definition). More importantly, simply decoding and raw-dispatching aRuntimeCallwould leave the extension-parity and deterministic key-release problems above unresolved.Restoring the original
execute_revealedunchangedPR #2219 is useful prior art for commitment + reveal + user-paid outer fee, but its reveal carried a separate
MultiSignature, manually checked/incremented another nonce and directly dispatched the call. It therefore retained a second authorization and did not provide the current full signed-extrinsic extension semantics. The useful part to recover is the consensus-visible commitment/reveal binding, not the old detached signature.Threshold encryption / IBE
Threshold key release can improve the threat model against a single block author and may be valuable independently. It does not by itself reduce wallet approvals if the encrypted plaintext remains a fully signed inner extrinsic. Authorization/origin/fee semantics and encryption key-release semantics should be treated as separable layers.
Additional context
Relevant implementation/history:
sdk/bittensor-core/src/client.rspallets/shield/src/lib.rsbasic_authorship.rsruntime/src/check_mortality.rsLikely implementation boundary:
pallets/shield: versioned direct envelope, salted commitment, one-shot reveal authorization, safe call filter, events/errors, declared-weight checks and tests.runtime: reusable inner-call policy validation, combined user-paid fee/weight policy, short mortality and explicit exclusions for unsupported call classes.RaoFoundation/polkadot-sdkbasic authorship + shield runtime API: decrypt/preflight the direct envelope, account for both encoded objects, and append wrapper/reveal as a pair.RuntimeCallrather than a signed extrinsic, request exactly one standard payload signature, quote the combined fee and resolve the inner outcome.This request is intentionally not asking for a relay, fee sponsorship or a wallet-specific integration. It asks for a runtime-authorized direct mode where the outer signature is the sole user authorization, origin binding and fee payment for the shielded call.