From b44baaf86e1835176d4054f23bff8a93b24694a5 Mon Sep 17 00:00:00 2001 From: Erhan Acet Date: Fri, 17 Apr 2026 09:01:28 +0300 Subject: [PATCH] =?UTF-8?q?quantum=20phase=203:=20add=20KeyVault=20lifecyc?= =?UTF-8?q?le=20UX=20and=20read-path=20compatibility=20=E2=80=94=20introdu?= =?UTF-8?q?ces=20`cast=20quantum`=20subcommand=20group=20(bootstrap=20/=20?= =?UTF-8?q?add-key=20/=20remove-key=20/=20update-key-auth)=20that=20reuses?= =?UTF-8?q?=20the=20shared=20`CastTxBuilder`=20+=20ML-DSA=20signing=20pipe?= =?UTF-8?q?line,=20routes=20through=20`QuantumWriteRequestV1`,=20and=20aut?= =?UTF-8?q?o-applies=20`QUANTUM=5FLIFECYCLE=5FGAS=5FFLOOR`;=20distinguishe?= =?UTF-8?q?s=20`--auth-key-id`=20(signer=20lane)=20from=20`--target-key-id?= =?UTF-8?q?`=20(key=20entry=20mutated).=20Adds=20shared=20`sol!`-backed=20?= =?UTF-8?q?KeyVault=20lifecycle=20calldata=20builders=20in=20`foundry-comm?= =?UTF-8?q?on`=20with=20byte-matched=20Phase=200=20selector=20tests.=20Pre?= =?UTF-8?q?serves=20`cast=20call`=20behavior=20for=20ordinary=20read=20pat?= =?UTF-8?q?hs=20while=20failing=20closed=20on=20all=20four=20lifecycle=20s?= =?UTF-8?q?electors=20with=20the=20frozen=20unsupported-simulation=20error?= =?UTF-8?q?.=20Moves=20CLI-policy=20lifecycle=20rejection=20out=20of=20the?= =?UTF-8?q?=20shared=20write-request=20validator=20(blocked=20`cast=20quan?= =?UTF-8?q?tum`=20from=20signing=20its=20own=20legitimate=20payloads)=20in?= =?UTF-8?q?to=20the=20`cast=20send`=20pre-build=20guard=20only.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/cast/src/args.rs | 1 + crates/cast/src/cmd/call.rs | 110 ++++- crates/cast/src/cmd/mod.rs | 1 + crates/cast/src/cmd/quantum.rs | 458 ++++++++++++++++++ crates/cast/src/opts.rs | 10 +- crates/common/src/transactions/mod.rs | 2 + crates/common/src/transactions/quantum.rs | 49 +- .../src/transactions/quantum_lifecycle.rs | 158 ++++++ docs/dev/quantum-adapter-touchpoints.md | 6 + 9 files changed, 776 insertions(+), 19 deletions(-) create mode 100644 crates/cast/src/cmd/quantum.rs create mode 100644 crates/common/src/transactions/quantum_lifecycle.rs diff --git a/crates/cast/src/args.rs b/crates/cast/src/args.rs index ca15566aa1ee3..11750266c84df 100644 --- a/crates/cast/src/args.rs +++ b/crates/cast/src/args.rs @@ -572,6 +572,7 @@ pub async fn run_command(args: CastArgs) -> Result<()> { } CastSubcommand::Run(cmd) => cmd.run().await?, CastSubcommand::SendTx(cmd) => cmd.run().await?, + CastSubcommand::Quantum(cmd) => cmd.run().await?, CastSubcommand::BatchMakeTx(cmd) => cmd.run().await?, CastSubcommand::BatchSend(cmd) => cmd.run().await?, CastSubcommand::Tx { tx_hash, from, nonce, field, raw, rpc, to_request, network } => { diff --git a/crates/cast/src/cmd/call.rs b/crates/cast/src/cmd/call.rs index 3637f166a53df..acc47289024fb 100644 --- a/crates/cast/src/cmd/call.rs +++ b/crates/cast/src/cmd/call.rs @@ -20,10 +20,10 @@ use foundry_cli::{ utils::{LoadConfig, TraceResult, parse_ether_value}, }; use foundry_common::{ - FoundryTransactionBuilder, + FoundryTransactionBuilder, QUANTUM_CALL_LIFECYCLE_REJECTION_MESSAGE, abi::{encode_function_args, get_func}, provider::{ProviderBuilder, curl_transport::generate_curl_command}, - sh_println, shell, + quantum_call_is_unsupported_lifecycle_calldata, sh_println, shell, }; use foundry_compilers::artifacts::EvmVersion; use foundry_config::{ @@ -221,6 +221,11 @@ impl CallArgs { if self.rpc.curl { return self.run_curl().await; } + // `cast call` is a read path and must stay on ordinary RPC simulation. + // Quantum write-only options (`--quantum*`) and KeyVault lifecycle + // selectors cannot be simulated; reject them with an actionable error + // instead of letting them reach `eth_call`. + self.reject_quantum_read_path_misuse()?; if self.tx.tempo.is_tempo() { self.run_with_network::().await } else { @@ -484,6 +489,51 @@ impl CallArgs { Ok(()) } + /// Fail closed when `cast call` is invoked with Quantum write-only options or + /// against a KeyVault lifecycle selector. + fn reject_quantum_read_path_misuse(&self) -> Result<()> { + if self.tx.quantum.is_quantum() { + eyre::bail!( + "`cast call` does not support Quantum write-only flags (--quantum*); use `cast call` without them for reads, or `cast quantum` / `cast send --quantum` for writes" + ); + } + let calldata = self.read_path_calldata()?; + if let Some(bytes) = calldata + && quantum_call_is_unsupported_lifecycle_calldata(&bytes) + { + eyre::bail!(QUANTUM_CALL_LIFECYCLE_REJECTION_MESSAGE); + } + Ok(()) + } + + /// Decode the `--data`/`sig` input for read-path selector checks. Returns + /// `None` when no call input is provided. + fn read_path_calldata(&self) -> Result>> { + if let Some(data) = &self.data { + let trimmed = data.trim().trim_start_matches("0x"); + return Ok(Some(hex::decode(trimmed)?)); + } + if let Some(sig) = &self.sig { + let trimmed = sig.trim(); + if let Some(hex_body) = trimmed.strip_prefix("0x").or_else(|| { + // Some callers pass a bare hex string without `0x`; treat short + // inputs that start with a known selector as already-encoded. + if trimmed.len() >= 8 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) { + Some(trimmed) + } else { + None + } + }) && let Ok(bytes) = hex::decode(hex_body) + { + return Ok(Some(bytes)); + } + if let Ok(func) = get_func(trimmed) { + return Ok(Some(encode_function_args(&func, &self.args)?)); + } + } + Ok(None) + } + /// Parse state overrides from command line arguments. pub fn get_state_overrides(&self) -> eyre::Result> { // Early return if no override set - @@ -797,6 +847,62 @@ mod tests { assert_eq!(args.args, vec!["-999999"]); } + #[test] + fn cast_call_rejects_keyvault_bootstrap_selector() { + let args = CallArgs::parse_from([ + "foundry-cli", + "0x0000000000000000000000000000000000001000", + "--data", + "0x5e8e7a13", + ]); + let err = args.reject_quantum_read_path_misuse().unwrap_err(); + assert!( + err.to_string().contains("cannot be simulated via eth_call"), + "unexpected error: {err}" + ); + } + + #[test] + fn cast_call_rejects_keyvault_add_key_selector_via_sig() { + let args = CallArgs::parse_from([ + "foundry-cli", + "0x0000000000000000000000000000000000001000", + "0x32bc2919", + ]); + let err = args.reject_quantum_read_path_misuse().unwrap_err(); + assert!( + err.to_string().contains("cannot be simulated via eth_call"), + "unexpected error: {err}" + ); + } + + #[test] + fn cast_call_rejects_quantum_write_flags() { + let args = CallArgs::parse_from([ + "foundry-cli", + "--quantum", + "0xDeaDBeeFcAfEbAbEfAcEfEeDcBaDbEeFcAfEbAbE", + "balanceOf(address)", + "0x000000000000000000000000000000000000dEaD", + ]); + let err = args.reject_quantum_read_path_misuse().unwrap_err(); + assert!( + err.to_string().contains("does not support Quantum write-only flags"), + "unexpected error: {err}" + ); + } + + #[test] + fn cast_call_allows_ordinary_view_call() { + let args = CallArgs::parse_from([ + "foundry-cli", + "0xDeaDBeeFcAfEbAbEfAcEfEeDcBaDbEeFcAfEbAbE", + "balanceOf(address)", + "0x000000000000000000000000000000000000dEaD", + ]); + args.reject_quantum_read_path_misuse().expect("ordinary reads must not be rejected"); + } + #[test] fn test_transaction_opts_with_trace() { // Test that transaction options are correctly parsed when using --trace diff --git a/crates/cast/src/cmd/mod.rs b/crates/cast/src/cmd/mod.rs index 66f88a68f40a1..7ac0096d8f367 100644 --- a/crates/cast/src/cmd/mod.rs +++ b/crates/cast/src/cmd/mod.rs @@ -23,6 +23,7 @@ pub mod interface; pub mod keychain; pub mod logs; pub mod mktx; +pub mod quantum; pub mod rpc; pub mod run; pub mod send; diff --git a/crates/cast/src/cmd/quantum.rs b/crates/cast/src/cmd/quantum.rs new file mode 100644 index 0000000000000..867aa2945058f --- /dev/null +++ b/crates/cast/src/cmd/quantum.rs @@ -0,0 +1,458 @@ +use std::{path::PathBuf, time::Duration}; + +use alloy_ens::NameOrAddress; +use alloy_primitives::{Address, Bytes, U256, hex}; +use alloy_provider::Provider; +use clap::{Parser, Subcommand}; +use eyre::{Result, eyre}; +use foundry_cli::{opts::TransactionOpts, utils::LoadConfig}; +use foundry_common::{ + DetachedCosigner, QUANTUM_KEYVAULT_ADDRESS, QUANTUM_LIFECYCLE_GAS_FLOOR, QuantumAddKeyInputs, + QuantumUpdateKeyAuthInputs, derive_primary_pubkey, encode_add_key_calldata, + encode_bootstrap_calldata, encode_remove_key_calldata, encode_update_key_auth_calldata, + parse_seed_file, provider::ProviderBuilder, sign_quantum_transaction_request_with_cosigner, +}; +use foundry_primitives::QuantumNetwork; + +use crate::tx::{CastTxBuilder, CastTxSender, SendTxOpts}; + +/// `cast quantum ` — KeyVault lifecycle UX for Quantum. +/// +/// These commands build native `0x7A` envelopes through the shared Quantum +/// signing pipeline used by `cast send --quantum`. Lifecycle writes are +/// intentionally separate from ordinary `cast send` so the CLI makes the +/// distinction between the **auth lane** (the `key_id` that signs) and the +/// **target key** (the key entry being mutated) impossible to confuse. +/// +/// Lifecycle writes use the fixed `QUANTUM_LIFECYCLE_GAS_FLOOR` by default +/// because the validator-published transient state cannot be simulated by +/// `eth_estimateGas`. +#[derive(Debug, Parser)] +pub struct QuantumArgs { + #[command(subcommand)] + pub command: QuantumSubcommand, +} + +#[derive(Debug, Subcommand)] +pub enum QuantumSubcommand { + /// Bootstrap a fresh Quantum account via `KeyVault.bootstrapKey()`. + /// + /// Bootstrap is primary-only in v1: exactly the ML-DSA key derived from the + /// provided seed is registered on lane `key_id = 0`. A detached cosigner + /// artifact is not accepted for bootstrap. + Bootstrap(BootstrapArgs), + + /// Add a key to an existing Quantum account via `KeyVault.addKey(...)`. + /// + /// The auth lane used to sign (`--auth-key-id`) is explicit and separate + /// from the target lane being added (`--target-key-id`). + #[command(name = "add-key")] + AddKey(AddKeyArgs), + + /// Remove a key from an existing Quantum account via `KeyVault.removeKey(uint32)`. + #[command(name = "remove-key")] + RemoveKey(RemoveKeyArgs), + + /// Update authorization on an existing key via `KeyVault.updateKeyAuth(...)`. + #[command(name = "update-key-auth")] + UpdateKeyAuth(UpdateKeyAuthArgs), +} + +/// Common inputs shared by every lifecycle write. +#[derive(Debug, Parser, Clone)] +pub struct LifecycleCommonOpts { + /// Quantum sender / account-lane address being mutated. + #[arg(long = "sender", value_name = "ADDRESS")] + pub sender: Address, + + /// Auth-lane `key_id` used to sign the lifecycle transaction. + /// + /// Distinct from the target key being added, removed, or updated. + /// Defaults to `0`. + #[arg(long = "auth-key-id", value_name = "KEY_ID", default_value_t = 0)] + pub auth_key_id: u32, + + /// Path to the canonical v1 ML-DSA signer seed file. + #[arg(long = "primary-seed-file", value_name = "PATH")] + pub primary_seed_file: PathBuf, + + /// Optional v1 detached cosigner artifact JSON. + /// + /// Supported schemes are `p256` and `ecdsa`. The artifact's `signing_hash` + /// must match the fork-computed Quantum signing hash byte-for-byte. + #[arg(long = "cosigner-artifact", value_name = "PATH")] + pub cosigner_artifact: Option, + + /// Shared `cast send` flow options (rpc, wallet, confirmations, timeouts). + #[command(flatten)] + pub send_tx: SendTxOpts, + + /// Shared transaction options (gas limit, fees, nonce, value, access list). + /// + /// Value is ignored for KeyVault lifecycle writes. + #[command(flatten)] + pub tx: TransactionOpts, +} + +#[derive(Debug, Parser)] +pub struct BootstrapArgs { + #[command(flatten)] + pub common: LifecycleCommonOpts, +} + +#[derive(Debug, Parser)] +pub struct AddKeyArgs { + /// Target key ID slot being added. + #[arg(long = "target-key-id", value_name = "KEY_ID")] + pub target_key_id: u32, + + /// New key public-key bytes (hex). + #[arg(long = "pubkey", value_name = "HEX_BYTES")] + pub pubkey: Bytes, + + /// New key scheme identifier (1=ML-DSA-44, 2=P256, 3=ECDSA). + #[arg(long = "scheme", value_name = "U8")] + pub scheme: u8, + + /// Auth-proof bytes accompanying the new key (hex). + #[arg(long = "auth-proof", value_name = "HEX_BYTES", default_value = "0x")] + pub auth_proof: Bytes, + + /// Required cosigner scheme for the new key (0=none, 2=P256, 3=ECDSA). + #[arg(long = "cosigner-scheme", value_name = "U8", default_value_t = 0)] + pub cosigner_scheme: u8, + + /// Scoped-permissions flag byte. + #[arg(long = "scoped-permissions", value_name = "U8", default_value_t = 0)] + pub scoped_permissions: u8, + + /// Scope-data bytes (hex, ABI-opaque; empty by default). + #[arg(long = "scope-data", value_name = "HEX_BYTES", default_value = "0x")] + pub scope_data: Bytes, + + #[command(flatten)] + pub common: LifecycleCommonOpts, +} + +#[derive(Debug, Parser)] +pub struct RemoveKeyArgs { + /// Target key ID slot being removed. + #[arg(long = "target-key-id", value_name = "KEY_ID")] + pub target_key_id: u32, + + #[command(flatten)] + pub common: LifecycleCommonOpts, +} + +#[derive(Debug, Parser)] +pub struct UpdateKeyAuthArgs { + /// Target key ID slot whose authorization is being updated. + #[arg(long = "target-key-id", value_name = "KEY_ID")] + pub target_key_id: u32, + + /// Auth-proof bytes (hex). + #[arg(long = "auth-proof", value_name = "HEX_BYTES", default_value = "0x")] + pub auth_proof: Bytes, + + /// Key scheme identifier (1=ML-DSA-44, 2=P256, 3=ECDSA). + #[arg(long = "scheme", value_name = "U8")] + pub scheme: u8, + + /// Scope-data bytes (hex, ABI-opaque; empty by default). + #[arg(long = "scope-data", value_name = "HEX_BYTES", default_value = "0x")] + pub scope_data: Bytes, + + /// Scoped-permissions flag byte. + #[arg(long = "scoped-permissions", value_name = "U8", default_value_t = 0)] + pub scoped_permissions: u8, + + #[command(flatten)] + pub common: LifecycleCommonOpts, +} + +impl QuantumArgs { + pub async fn run(self) -> Result<()> { + match self.command { + QuantumSubcommand::Bootstrap(args) => run_bootstrap(args).await, + QuantumSubcommand::AddKey(args) => run_add_key(args).await, + QuantumSubcommand::RemoveKey(args) => run_remove_key(args).await, + QuantumSubcommand::UpdateKeyAuth(args) => run_update_key_auth(args).await, + } + } +} + +async fn run_bootstrap(args: BootstrapArgs) -> Result<()> { + let BootstrapArgs { mut common } = args; + if common.cosigner_artifact.is_some() { + return Err(eyre!( + "Quantum v1 bootstrap is primary-only; cosigner artifact is not supported" + )); + } + if common.auth_key_id != 0 { + return Err(eyre!( + "bootstrap requires --auth-key-id 0; lane 0 is the only valid bootstrap lane in v1" + )); + } + + // Populate the bootstrap `init_primary_pubkey` field if the caller did not + // provide one. Mirrors `cast send` bootstrap behavior. + let primary_seed = parse_seed_file(&common.primary_seed_file)?; + if common.tx.quantum.init_primary_pubkey.is_none() { + common.tx.quantum.init_primary_pubkey = Some(derive_primary_pubkey(primary_seed)); + } + + submit_lifecycle(common, encode_bootstrap_calldata(), true).await +} + +async fn run_add_key(args: AddKeyArgs) -> Result<()> { + let AddKeyArgs { + target_key_id, + pubkey, + scheme, + auth_proof, + cosigner_scheme, + scoped_permissions, + scope_data, + common, + } = args; + let calldata = encode_add_key_calldata(&QuantumAddKeyInputs { + target_key_id, + pubkey, + scheme, + auth_proof, + cosigner_scheme, + scoped_permissions, + scope_data, + }); + submit_lifecycle(common, calldata, false).await +} + +async fn run_remove_key(args: RemoveKeyArgs) -> Result<()> { + let RemoveKeyArgs { target_key_id, common } = args; + let calldata = encode_remove_key_calldata(target_key_id); + submit_lifecycle(common, calldata, false).await +} + +async fn run_update_key_auth(args: UpdateKeyAuthArgs) -> Result<()> { + let UpdateKeyAuthArgs { + target_key_id, + auth_proof, + scheme, + scope_data, + scoped_permissions, + common, + } = args; + let calldata = encode_update_key_auth_calldata(&QuantumUpdateKeyAuthInputs { + target_key_id, + auth_proof, + scheme, + scope_data, + scoped_permissions, + }); + submit_lifecycle(common, calldata, false).await +} + +/// Shared lifecycle submission path: reuse `CastTxBuilder` to fill fees and +/// nonce, then sign via the fork's ML-DSA signer and broadcast via +/// `eth_sendRawTransaction`. Bypasses the ordinary-send lifecycle guard +/// intentionally — the caller has already built the canonical KeyVault +/// calldata via the shared lifecycle core. +async fn submit_lifecycle( + mut common: LifecycleCommonOpts, + calldata: Bytes, + is_bootstrap: bool, +) -> Result<()> { + // Set the quantum sender on the shared TransactionOpts so the wallet glue + // finds it. The sender is the account being mutated, on whose behalf the + // ML-DSA signer produces the primary signature. + if common.tx.quantum.sender.is_none() { + common.tx.quantum.sender = Some(common.sender); + } else if common.tx.quantum.sender != Some(common.sender) { + return Err(eyre!( + "--sender and --quantum.sender must match; got {} and {}", + common.sender, + common.tx.quantum.sender.unwrap(), + )); + } + if common.tx.quantum.primary_seed_file.is_none() { + common.tx.quantum.primary_seed_file = Some(common.primary_seed_file.clone()); + } + if common.tx.quantum.cosigner_artifact.is_none() + && let Some(ref p) = common.cosigner_artifact + { + common.tx.quantum.cosigner_artifact = Some(p.clone()); + } + + let primary_seed = parse_seed_file(&common.primary_seed_file)?; + let cosigner = common + .cosigner_artifact + .as_deref() + .map(DetachedCosigner::from_artifact_file) + .transpose()?; + + // KeyVault lifecycle writes cannot be simulated via `eth_estimateGas` because + // the validator-published bootstrap transient state is absent. Apply the fixed + // lifecycle gas floor when the caller did not override it. + if common.tx.gas_limit.is_none() { + common.tx.gas_limit = Some(U256::from(QUANTUM_LIFECYCLE_GAS_FLOOR)); + } + + let config = common.send_tx.eth.load_config()?; + let provider = ProviderBuilder::::from_config(&config)?.build()?; + + if let Some(interval) = common.send_tx.poll_interval { + provider.client().set_poll_interval(Duration::from_secs(interval)); + } + + // Destination is always the canonical KeyVault precompile address. + let to = Some(NameOrAddress::Address(QUANTUM_KEYVAULT_ADDRESS)); + // Hex-encode calldata; `CastTxBuilder::with_code_sig_and_args` decodes raw hex + // when the `sig` string starts with `0x` and no parentheses. + let data_hex = format!("0x{}", hex::encode(&calldata)); + + let builder = CastTxBuilder::new(&provider, common.tx.clone(), &config) + .await? + .with_to(to) + .await? + .with_code_sig_and_args(None, Some(data_hex), Vec::new()) + .await?; + + let (tx_request, _) = builder.build(common.sender).await?; + let payload = + sign_quantum_transaction_request_with_cosigner(tx_request, primary_seed, cosigner)?; + + // `is_bootstrap` is currently informational; the on-chain selector determines + // the validator-side path. Retained so the caller's intent is explicit and + // can be validated in the future. + let _ = is_bootstrap; + + let timeout = common.send_tx.timeout.unwrap_or(config.transaction_timeout); + let cast = CastTxSender::new(&provider); + let pending = cast.send_raw(&payload.raw_transaction).await?; + let tx_hash = *pending.inner().tx_hash(); + cast.print_tx_result(tx_hash, common.send_tx.cast_async, common.send_tx.confirmations, timeout) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + #[test] + fn quantum_command_clap_shape_is_valid() { + QuantumArgs::command().debug_assert(); + } + + #[test] + fn bootstrap_args_parse() { + let args = QuantumArgs::parse_from([ + "cast-quantum", + "bootstrap", + "--sender", + "0x47872C3e8676384B80648D95bEaC2c0C348eF272", + "--primary-seed-file", + "/tmp/seed.hex", + "--rpc-url", + "http://localhost:18545", + ]); + assert!(matches!(args.command, QuantumSubcommand::Bootstrap(_))); + } + + #[test] + fn add_key_args_parse_with_scoped_permissions() { + let args = QuantumArgs::parse_from([ + "cast-quantum", + "add-key", + "--sender", + "0x47872C3e8676384B80648D95bEaC2c0C348eF272", + "--primary-seed-file", + "/tmp/seed.hex", + "--target-key-id", + "1", + "--pubkey", + "0xaabbcc", + "--scheme", + "2", + "--scoped-permissions", + "1", + "--scope-data", + "0x1234", + ]); + let QuantumSubcommand::AddKey(a) = args.command else { panic!("expected add-key") }; + assert_eq!(a.target_key_id, 1); + assert_eq!(a.scheme, 2); + assert_eq!(a.scoped_permissions, 1); + assert_eq!(a.scope_data.as_ref(), &[0x12, 0x34]); + } + + #[test] + fn remove_key_args_parse() { + let args = QuantumArgs::parse_from([ + "cast-quantum", + "remove-key", + "--sender", + "0x47872C3e8676384B80648D95bEaC2c0C348eF272", + "--primary-seed-file", + "/tmp/seed.hex", + "--target-key-id", + "2", + ]); + let QuantumSubcommand::RemoveKey(r) = args.command else { + panic!("expected remove-key") + }; + assert_eq!(r.target_key_id, 2); + } + + #[test] + fn update_key_auth_args_parse() { + let args = QuantumArgs::parse_from([ + "cast-quantum", + "update-key-auth", + "--sender", + "0x47872C3e8676384B80648D95bEaC2c0C348eF272", + "--primary-seed-file", + "/tmp/seed.hex", + "--target-key-id", + "3", + "--scheme", + "1", + "--auth-proof", + "0x1122", + "--scope-data", + "0x", + "--scoped-permissions", + "2", + ]); + let QuantumSubcommand::UpdateKeyAuth(u) = args.command else { + panic!("expected update-key-auth") + }; + assert_eq!(u.target_key_id, 3); + assert_eq!(u.scheme, 1); + assert_eq!(u.auth_proof.as_ref(), &[0x11, 0x22]); + assert_eq!(u.scoped_permissions, 2); + } + + #[test] + fn auth_key_id_defaults_to_zero_but_is_distinct_from_target_key_id() { + let args = QuantumArgs::parse_from([ + "cast-quantum", + "add-key", + "--sender", + "0x47872C3e8676384B80648D95bEaC2c0C348eF272", + "--primary-seed-file", + "/tmp/seed.hex", + "--target-key-id", + "5", + "--pubkey", + "0xaa", + "--scheme", + "2", + "--auth-key-id", + "0", + ]); + let QuantumSubcommand::AddKey(a) = args.command else { panic!("expected add-key") }; + assert_eq!(a.common.auth_key_id, 0); + assert_eq!(a.target_key_id, 5); + } +} diff --git a/crates/cast/src/opts.rs b/crates/cast/src/opts.rs index c7d9dcfc897f4..3ae29cef23262 100644 --- a/crates/cast/src/opts.rs +++ b/crates/cast/src/opts.rs @@ -4,9 +4,9 @@ use crate::cmd::{ constructor_args::ConstructorArgsArgs, create2::Create2Args, creation_code::CreationCodeArgs, da_estimate::DAEstimateArgs, erc20::Erc20Subcommand, estimate::EstimateArgs, find_block::FindBlockArgs, interface::InterfaceArgs, keychain::KeychainSubcommand, - logs::LogsArgs, mktx::MakeTxArgs, rpc::RpcArgs, run::RunArgs, send::SendTxArgs, - storage::StorageArgs, tip20::Tip20Subcommand, trace::TraceArgs, txpool::TxPoolSubcommands, - wallet::WalletSubcommands, + logs::LogsArgs, mktx::MakeTxArgs, quantum::QuantumArgs, rpc::RpcArgs, run::RunArgs, + send::SendTxArgs, storage::StorageArgs, tip20::Tip20Subcommand, trace::TraceArgs, + txpool::TxPoolSubcommands, wallet::WalletSubcommands, }; use alloy_ens::NameOrAddress; use alloy_primitives::{Address, B256, Selector, U256}; @@ -556,6 +556,10 @@ pub enum CastSubcommand { #[command(name = "send", visible_alias = "s")] SendTx(SendTxArgs), + /// Quantum-native KeyVault lifecycle UX (bootstrap / add-key / remove-key / update-key-auth). + #[command(name = "quantum")] + Quantum(QuantumArgs), + /// Build and sign a batch transaction (Tempo). #[command(name = "batch-mktx", visible_alias = "bm")] BatchMakeTx(BatchMakeTxArgs), diff --git a/crates/common/src/transactions/mod.rs b/crates/common/src/transactions/mod.rs index c3330fcc4180a..409653c7b9368 100644 --- a/crates/common/src/transactions/mod.rs +++ b/crates/common/src/transactions/mod.rs @@ -3,9 +3,11 @@ mod broadcast; mod builder; mod quantum; +mod quantum_lifecycle; mod receipt; pub use broadcast::*; pub use builder::*; pub use quantum::*; +pub use quantum_lifecycle::*; pub use receipt::*; diff --git a/crates/common/src/transactions/quantum.rs b/crates/common/src/transactions/quantum.rs index 23d7ebbe8f316..1b2cbe9d2f4df 100644 --- a/crates/common/src/transactions/quantum.rs +++ b/crates/common/src/transactions/quantum.rs @@ -32,6 +32,11 @@ pub const QUANTUM_REMOVE_KEY_SELECTOR: [u8; 4] = [0xc9, 0x8f, 0x21, 0xf4]; pub const QUANTUM_UPDATE_KEY_AUTH_SELECTOR: [u8; 4] = [0x89, 0x08, 0x15, 0x4b]; pub const QUANTUM_SEND_LIFECYCLE_REJECTION_MESSAGE: &str = "KeyVault lifecycle operations beyond bootstrapKey() require explicit lifecycle transaction submission"; +/// Stable rejection message surfaced when a caller tries to simulate a KeyVault +/// lifecycle selector through `cast call` / `eth_call`. Matches the Phase 0 frozen +/// contract in `docs/dev/quantum-phase0-implementation-note.md`. +pub const QUANTUM_CALL_LIFECYCLE_REJECTION_MESSAGE: &str = + "KeyVault lifecycle operations (bootstrap/addKey/removeKey/updateKeyAuth) cannot be simulated via eth_call; use explicit lifecycle transaction submission"; /// Fixed gas limit for Quantum KeyVault bootstrap and lifecycle transactions. /// @@ -47,6 +52,26 @@ pub const QUANTUM_SEND_UNSUPPORTED_LIFECYCLE_SELECTORS: [[u8; 4]; 3] = [ QUANTUM_UPDATE_KEY_AUTH_SELECTOR, ]; +/// All KeyVault lifecycle selectors that are unsupported through `cast call` / +/// `eth_call`. `bootstrapKey()` is included here because, unlike ordinary sends, +/// bootstrap can never be simulated from the read path. +pub const QUANTUM_CALL_UNSUPPORTED_LIFECYCLE_SELECTORS: [[u8; 4]; 4] = [ + QUANTUM_BOOTSTRAP_SELECTOR, + QUANTUM_ADD_KEY_SELECTOR, + QUANTUM_REMOVE_KEY_SELECTOR, + QUANTUM_UPDATE_KEY_AUTH_SELECTOR, +]; + +/// Returns `true` if the given calldata targets a KeyVault lifecycle selector +/// that must not be simulated through `cast call` / `eth_call`. +pub fn quantum_call_is_unsupported_lifecycle_calldata(input: &[u8]) -> bool { + if input.len() < 4 { + return false; + } + let selector = [input[0], input[1], input[2], input[3]]; + QUANTUM_CALL_UNSUPPORTED_LIFECYCLE_SELECTORS.contains(&selector) +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct DetachedArtifactV1 { pub version: u8, @@ -344,16 +369,7 @@ pub fn quantum_is_unsupported_lifecycle_calldata(input: &[u8]) -> bool { } fn validate_quantum_write_request(request: &QuantumWriteRequestV1) -> Result<()> { - request.validate_v1()?; - - if let TxKind::Call(to) = request.call.kind - && to == QUANTUM_KEYVAULT_ADDRESS - && quantum_is_unsupported_lifecycle_calldata(&request.call.input) - { - bail!(QUANTUM_SEND_LIFECYCLE_REJECTION_MESSAGE); - } - - Ok(()) + request.validate_v1() } impl QuantumSigned { @@ -501,9 +517,13 @@ mod tests { } #[test] - fn quantum_payload_rejects_non_bootstrap_lifecycle_selectors() { + fn quantum_shared_signer_accepts_lifecycle_selectors_for_cast_quantum_path() { + // The shared signer must sign KeyVault lifecycle selectors — those are the + // legitimate payload of `cast quantum add-key / remove-key / update-key-auth`. + // CLI-surface policy (rejecting lifecycle selectors on `cast send`) is enforced + // at the `cast send` pre-build guard, not at the shared write-request validator. let seed = parse_seed_file(&fixture_seed_path()).unwrap(); - let err = sign_quantum_write_request( + let payload = sign_quantum_write_request( QuantumWriteRequestV1 { sender: Address::repeat_byte(0x22), key_id: 0, @@ -522,9 +542,10 @@ mod tests { }, seed, ) - .unwrap_err(); + .unwrap(); - assert_eq!(err.to_string(), QUANTUM_SEND_LIFECYCLE_REJECTION_MESSAGE); + assert_eq!(payload.sender, Address::repeat_byte(0x22)); + assert_eq!(&payload.raw_transaction[0..1], &[QUANTUM_TX_TYPE_ID]); } #[test] diff --git a/crates/common/src/transactions/quantum_lifecycle.rs b/crates/common/src/transactions/quantum_lifecycle.rs new file mode 100644 index 0000000000000..09dd4c16c73f6 --- /dev/null +++ b/crates/common/src/transactions/quantum_lifecycle.rs @@ -0,0 +1,158 @@ +use alloy_primitives::Bytes; +use alloy_sol_types::{SolCall, sol}; + +sol! { + #[sol(abi)] + interface KeyVaultLifecycle { + function bootstrapKey(); + function addKey( + uint32 keyId, + bytes pubkey, + uint8 scheme, + bytes authProof, + uint8 cosignerScheme, + uint8 scopedPermissions, + bytes scopeData, + ); + function removeKey(uint32 keyId); + function updateKeyAuth( + uint32 keyId, + bytes authProof, + uint8 scheme, + bytes scopeData, + uint8 scopedPermissions, + ); + } +} + +/// Inputs for a KeyVault `addKey` lifecycle write. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QuantumAddKeyInputs { + pub target_key_id: u32, + pub pubkey: Bytes, + pub scheme: u8, + pub auth_proof: Bytes, + pub cosigner_scheme: u8, + pub scoped_permissions: u8, + pub scope_data: Bytes, +} + +/// Inputs for a KeyVault `updateKeyAuth` lifecycle write. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QuantumUpdateKeyAuthInputs { + pub target_key_id: u32, + pub auth_proof: Bytes, + pub scheme: u8, + pub scope_data: Bytes, + pub scoped_permissions: u8, +} + +/// Build calldata for `bootstrapKey()`. +pub fn encode_bootstrap_calldata() -> Bytes { + KeyVaultLifecycle::bootstrapKeyCall {}.abi_encode().into() +} + +/// Build calldata for `addKey(...)`. +pub fn encode_add_key_calldata(inputs: &QuantumAddKeyInputs) -> Bytes { + KeyVaultLifecycle::addKeyCall { + keyId: inputs.target_key_id, + pubkey: inputs.pubkey.to_vec().into(), + scheme: inputs.scheme, + authProof: inputs.auth_proof.to_vec().into(), + cosignerScheme: inputs.cosigner_scheme, + scopedPermissions: inputs.scoped_permissions, + scopeData: inputs.scope_data.to_vec().into(), + } + .abi_encode() + .into() +} + +/// Build calldata for `removeKey(uint32)`. +pub fn encode_remove_key_calldata(target_key_id: u32) -> Bytes { + KeyVaultLifecycle::removeKeyCall { keyId: target_key_id }.abi_encode().into() +} + +/// Build calldata for `updateKeyAuth(...)`. +pub fn encode_update_key_auth_calldata(inputs: &QuantumUpdateKeyAuthInputs) -> Bytes { + KeyVaultLifecycle::updateKeyAuthCall { + keyId: inputs.target_key_id, + authProof: inputs.auth_proof.to_vec().into(), + scheme: inputs.scheme, + scopeData: inputs.scope_data.to_vec().into(), + scopedPermissions: inputs.scoped_permissions, + } + .abi_encode() + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + QUANTUM_ADD_KEY_SELECTOR, QUANTUM_BOOTSTRAP_SELECTOR, QUANTUM_REMOVE_KEY_SELECTOR, + QUANTUM_UPDATE_KEY_AUTH_SELECTOR, + }; + + #[test] + fn bootstrap_calldata_matches_frozen_selector() { + let calldata = encode_bootstrap_calldata(); + assert_eq!(&calldata[..4], &QUANTUM_BOOTSTRAP_SELECTOR); + assert_eq!(calldata.len(), 4); + } + + #[test] + fn remove_key_calldata_matches_frozen_selector() { + let calldata = encode_remove_key_calldata(7); + assert_eq!(&calldata[..4], &QUANTUM_REMOVE_KEY_SELECTOR); + assert_eq!(calldata.len(), 4 + 32); + } + + #[test] + fn add_key_calldata_matches_frozen_selector() { + let calldata = encode_add_key_calldata(&QuantumAddKeyInputs { + target_key_id: 1, + pubkey: Bytes::from_static(&[1, 2, 3]), + scheme: 1, + auth_proof: Bytes::from_static(&[4, 5]), + cosigner_scheme: 0, + scoped_permissions: 0, + scope_data: Bytes::new(), + }); + assert_eq!(&calldata[..4], &QUANTUM_ADD_KEY_SELECTOR); + } + + #[test] + fn update_key_auth_calldata_matches_frozen_selector() { + let calldata = encode_update_key_auth_calldata(&QuantumUpdateKeyAuthInputs { + target_key_id: 2, + auth_proof: Bytes::from_static(&[9, 9]), + scheme: 1, + scope_data: Bytes::new(), + scoped_permissions: 0, + }); + assert_eq!(&calldata[..4], &QUANTUM_UPDATE_KEY_AUTH_SELECTOR); + } + + #[test] + fn round_trip_add_key_decodes_to_inputs() { + let inputs = QuantumAddKeyInputs { + target_key_id: 5, + pubkey: Bytes::from_static(&[0xaa; 33]), + scheme: 2, + auth_proof: Bytes::from_static(&[0xbb; 64]), + cosigner_scheme: 2, + scoped_permissions: 1, + scope_data: Bytes::from_static(&[0xcc; 8]), + }; + let calldata = encode_add_key_calldata(&inputs); + let decoded = + KeyVaultLifecycle::addKeyCall::abi_decode_raw(&calldata[4..]).unwrap(); + assert_eq!(decoded.keyId, inputs.target_key_id); + assert_eq!(decoded.pubkey.as_ref(), inputs.pubkey.as_ref()); + assert_eq!(decoded.scheme, inputs.scheme); + assert_eq!(decoded.authProof.as_ref(), inputs.auth_proof.as_ref()); + assert_eq!(decoded.cosignerScheme, inputs.cosigner_scheme); + assert_eq!(decoded.scopedPermissions, inputs.scoped_permissions); + assert_eq!(decoded.scopeData.as_ref(), inputs.scope_data.as_ref()); + } +} diff --git a/docs/dev/quantum-adapter-touchpoints.md b/docs/dev/quantum-adapter-touchpoints.md index a29fe1c2434a3..a1cca969590bc 100644 --- a/docs/dev/quantum-adapter-touchpoints.md +++ b/docs/dev/quantum-adapter-touchpoints.md @@ -38,6 +38,12 @@ These files are already intentionally diverged from upstream as part of the Phas - wires `--network quantum` through `cast` read/decode dispatch and fails closed on raw paths that still need a real `QuantumNetwork` adapter - `crates/cast/src/cmd/da_estimate.rs` - rejects `--network quantum` explicitly instead of silently falling back to Ethereum behavior +- `crates/cast/src/cmd/call.rs` + - fails closed on KeyVault lifecycle selectors so `cast call` keeps pure read paths on standard RPC simulation while rejecting unsupported lifecycle simulations with the frozen error message +- `crates/cast/src/cmd/quantum.rs` + - `cast quantum` subcommand group (`bootstrap`, `add-key`, `remove-key`, `update-key-auth`) that builds KeyVault lifecycle calldata via the shared core, routes through `CastTxBuilder`, signs with the fork's ML-DSA signer, and broadcasts via `eth_sendRawTransaction`; distinguishes `--auth-key-id` (signer lane) from `--target-key-id` (key entry being mutated) and auto-applies `QUANTUM_LIFECYCLE_GAS_FLOOR` +- `crates/common/src/transactions/quantum_lifecycle.rs` + - shared KeyVault lifecycle calldata builders (`encode_bootstrap_calldata`, `encode_add_key_calldata`, `encode_remove_key_calldata`, `encode_update_key_auth_calldata`) whose `sol!`-derived selectors are asserted byte-for-byte against the Phase 0 frozen selectors ## Reserved Phase 1 Adapter Patch Points