diff --git a/.gitignore b/.gitignore index 788edbe04935f..6521843166c87 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /target* /*.sol CLAUDE.md +/thoughts # Foundry artifacts out/ diff --git a/Cargo.lock b/Cargo.lock index f76fd82a086f9..d5f16abbe96b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,7 +23,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -489,7 +489,7 @@ dependencies = [ "ruint", "rustc-hash", "serde", - "sha3", + "sha3 0.10.8", ] [[package]] @@ -799,7 +799,7 @@ dependencies = [ "aws-config", "aws-sdk-kms", "k256", - "spki", + "spki 0.7.3", "thiserror 2.0.18", "tracing", ] @@ -817,7 +817,7 @@ dependencies = [ "async-trait", "gcloud-sdk", "k256", - "spki", + "spki 0.7.3", "thiserror 2.0.18", "tracing", ] @@ -923,7 +923,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "sha3", + "sha3 0.10.8", "syn 2.0.117", "syn-solidity", ] @@ -2358,6 +2358,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -2536,7 +2545,7 @@ version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" dependencies = [ - "darling 0.20.11", + "darling 0.23.0", "ident_case", "prettyplease", "proc-macro2", @@ -2746,6 +2755,7 @@ dependencies = [ "foundry-wallets", "futures", "itertools 0.14.0", + "ml-dsa", "op-alloy-consensus", "op-alloy-flz", "op-alloy-network", @@ -2912,7 +2922,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", "zeroize", ] @@ -3036,9 +3046,15 @@ dependencies = [ [[package]] name = "cmov" -version = "0.5.0-pre.0" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d5ce5728ecb5285a5dd35f02a6a8e34e0828e0b38e8e632e249a3fe3f320211" + +[[package]] +name = "cmov" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5417da527aa9bf6a1e10a781231effd1edd3ee82f27d5f8529ac9b279babce96" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" [[package]] name = "coins-bip32" @@ -3087,7 +3103,7 @@ dependencies = [ "ripemd", "serde", "sha2 0.10.9", - "sha3", + "sha3 0.10.8", "thiserror 1.0.69", ] @@ -3216,7 +3232,7 @@ dependencies = [ "commonware-parallel", "commonware-utils", "crc-fast", - "ctutils", + "ctutils 0.3.1", "ecdsa", "ed25519-consensus", "getrandom 0.2.17", @@ -3398,6 +3414,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const_format" version = "0.2.35" @@ -3613,6 +3635,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctr" version = "0.9.2" @@ -3635,11 +3666,20 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.3.2" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758e5ed90be3c8abff7f9a6f37ab7f6d8c59c2210d448b81f3f508134aec84e4" +checksum = "7c67c81499f542d1dd38c6a2a2fe825f4dd4bca5162965dd2eea0c8119873d3c" dependencies = [ - "cmov", + "cmov 0.4.6", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov 0.5.3", ] [[package]] @@ -3778,11 +3818,21 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid 0.10.2", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -3913,11 +3963,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid", - "crypto-common", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.1", +] + [[package]] name = "dirs" version = "6.0.0" @@ -4027,13 +4087,13 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.10", "digest 0.10.7", "elliptic-curve", "rfc6979", "serdect", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", ] [[package]] @@ -4103,7 +4163,7 @@ dependencies = [ "generic-array", "group", "pem-rfc7468", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", "sec1", "serdect", @@ -4267,7 +4327,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "sha3", + "sha3 0.10.8", "thiserror 1.0.69", "uuid 0.8.2", ] @@ -6092,6 +6152,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -6755,7 +6824,7 @@ dependencies = [ "pem", "serde", "serde_json", - "signature", + "signature 2.2.0", "simple_asn1", ] @@ -6771,7 +6840,7 @@ dependencies = [ "once_cell", "serdect", "sha2 0.10.9", - "signature", + "signature 2.2.0", ] [[package]] @@ -6794,6 +6863,16 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + [[package]] name = "keccak-asm" version = "0.1.6" @@ -6838,7 +6917,7 @@ dependencies = [ "petgraph", "regex", "regex-syntax", - "sha3", + "sha3 0.10.8", "string_cache 0.8.9", "term", "unicode-xid", @@ -7254,6 +7333,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-dsa" +version = "0.1.0-rc.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5b2bb0ad6fa2b40396775bd56f51345171490fef993f46f91a876ecdbdaea55" +dependencies = [ + "const-oid 0.10.2", + "ctutils 0.4.2", + "hybrid-array", + "module-lattice", + "pkcs8 0.11.0-rc.11", + "rand_core 0.10.1", + "sha3 0.11.0", + "signature 3.0.0-rc.10", +] + [[package]] name = "mockall" version = "0.14.0" @@ -7301,6 +7396,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "module-lattice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "164eb3faeaecbd14b0b2a917c1b4d0c035097a9c559b0bed85c2cdd032bc8faa" +dependencies = [ + "hybrid-array", + "num-traits", +] + [[package]] name = "ndk-context" version = "0.1.1" @@ -8149,8 +8254,18 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12922b6296c06eb741b02d7b5161e3aaa22864af38dfa025a1a3ba3f68c84577" +dependencies = [ + "der 0.8.0", + "spki 0.8.0", ] [[package]] @@ -8415,7 +8530,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -8686,6 +8801,12 @@ dependencies = [ "serde", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -10031,9 +10152,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", - "der", + "der 0.7.10", "generic-array", - "pkcs8", + "pkcs8 0.10.2", "serdect", "subtle", "zeroize", @@ -10342,7 +10463,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" dependencies = [ "digest 0.10.7", - "keccak", + "keccak 0.1.6", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.2", + "keccak 0.2.0", ] [[package]] @@ -10417,6 +10548,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f1880df446116126965eeec169136b2e0251dba37c6223bcc819569550edea3" +dependencies = [ + "digest 0.11.2", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -10614,7 +10755,7 @@ dependencies = [ "derive_more", "dunce", "inturn", - "itertools 0.12.1", + "itertools 0.14.0", "itoa", "normalize-path", "once_map", @@ -10649,7 +10790,7 @@ dependencies = [ "alloy-primitives", "bitflags 2.11.0", "bumpalo", - "itertools 0.12.1", + "itertools 0.14.0", "memchr", "num-bigint", "num-rational", @@ -10762,7 +10903,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.0", ] [[package]] @@ -12078,7 +12229,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] diff --git a/crates/cast/Cargo.toml b/crates/cast/Cargo.toml index 16a23081c87c1..904f782fd30a3 100644 --- a/crates/cast/Cargo.toml +++ b/crates/cast/Cargo.toml @@ -55,6 +55,7 @@ alloy-sol-types.workspace = true alloy-transport.workspace = true alloy-ens = { workspace = true, features = ["provider"] } alloy-eips.workspace = true +ml-dsa = "0.1.0-rc.8" tempo-alloy.workspace = true tempo-contracts.workspace = true tempo-primitives.workspace = true diff --git a/crates/cast/src/cmd/send.rs b/crates/cast/src/cmd/send.rs index 1080286605099..6ff39d815f33e 100644 --- a/crates/cast/src/cmd/send.rs +++ b/crates/cast/src/cmd/send.rs @@ -2,7 +2,7 @@ use std::{path::PathBuf, str::FromStr, time::Duration}; use alloy_consensus::{SignableTransaction, Signed}; use alloy_ens::NameOrAddress; -use alloy_network::{Ethereum, EthereumWallet, Network}; +use alloy_network::{Ethereum, EthereumWallet, Network, TransactionBuilder}; use alloy_primitives::Address; use alloy_provider::{Provider, ProviderBuilder as AlloyProviderBuilder}; use alloy_signer::{Signature, Signer}; @@ -19,6 +19,7 @@ use tempo_alloy::TempoNetwork; use crate::{ cmd::tip20::iso4217_warning_message, + quantum::{QuantumWriteContractV1, build_phase0_payload, parse_seed_file}, tx::{self, CastTxBuilder, CastTxSender, SendTxOpts}, }; use tempo_contracts::precompiles::{TIP20_FACTORY_ADDRESS, is_iso4217_currency}; @@ -93,6 +94,10 @@ pub enum SendTxSubcommands { impl SendTxArgs { pub async fn run(self) -> Result<()> { + if self.tx.quantum.is_quantum() { + return self.run_quantum().await; + } + // Resolve the signer early so we know if it's a Tempo access key. let (signer, tempo_access_key) = self.send_tx.eth.wallet.maybe_signer().await?; @@ -103,6 +108,91 @@ impl SendTxArgs { } } + async fn run_quantum(self) -> Result<()> { + let Self { to, mut sig, args, data, send_tx, command, unlocked, force: _, tx, path } = + self; + + if unlocked { + return Err(eyre!("the Phase 0 Quantum seam does not support --unlocked")); + } + if send_tx.browser.browser { + return Err(eyre!("the Phase 0 Quantum seam does not support browser signing")); + } + if tx.tempo.is_tempo() { + return Err(eyre!("Quantum and Tempo options cannot be combined")); + } + if command.is_some() { + return Err(eyre!("the Phase 0 Quantum seam only supports cast send-style call flows")); + } + if path.is_some() { + return Err(eyre!("the Phase 0 Quantum seam does not support blob data")); + } + if let Some(data) = data { + sig = Some(data); + } + + let sender = tx.quantum.sender.ok_or_else(|| { + eyre!("--quantum.sender is required for the Phase 0 Quantum seam") + })?; + let seed_path = tx.quantum.primary_seed_file.as_ref().ok_or_else(|| { + eyre!("--quantum.primary-seed-file is required for the Phase 0 Quantum seam") + })?; + let primary_seed = parse_seed_file(seed_path)?; + + let config = send_tx.eth.load_config()?; + let provider = ProviderBuilder::::from_config(&config)?.build()?; + + if let Some(interval) = send_tx.poll_interval { + provider.client().set_poll_interval(Duration::from_secs(interval)); + } + + let builder = CastTxBuilder::new(&provider, tx.clone(), &config) + .await? + .with_to(to) + .await? + .with_code_sig_and_args(None, sig, args) + .await?; + + let (tx_request, _) = builder.build(sender).await?; + let kind = TransactionBuilder::kind(&tx_request) + .ok_or_else(|| eyre!("Quantum Phase 0 requires an explicit call destination"))?; + let max_fee_per_gas = TransactionBuilder::max_fee_per_gas(&tx_request) + .ok_or_else(|| eyre!("failed to resolve max fee per gas for Quantum transaction"))?; + let max_priority_fee_per_gas = TransactionBuilder::max_priority_fee_per_gas(&tx_request) + .ok_or_else(|| { + eyre!("failed to resolve max priority fee per gas for Quantum transaction") + })?; + let gas_limit = TransactionBuilder::gas_limit(&tx_request) + .ok_or_else(|| eyre!("failed to resolve gas limit for Quantum transaction"))?; + let nonce = TransactionBuilder::nonce(&tx_request) + .ok_or_else(|| eyre!("failed to resolve nonce for Quantum transaction"))?; + let chain_id = TransactionBuilder::chain_id(&tx_request) + .ok_or_else(|| eyre!("failed to resolve chain ID for Quantum transaction"))?; + + let payload = build_phase0_payload(QuantumWriteContractV1 { + sender, + key_id: tx.quantum.resolved_key_id(), + nonce, + chain_id, + max_priority_fee_per_gas, + max_fee_per_gas, + gas_limit, + kind, + value: TransactionBuilder::value(&tx_request).unwrap_or_default(), + input: TransactionBuilder::input(&tx_request).cloned().unwrap_or_default(), + access_list: TransactionBuilder::access_list(&tx_request) + .cloned() + .unwrap_or_default(), + primary_seed, + })?; + + let timeout = 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, send_tx.cast_async, send_tx.confirmations, timeout).await + } + pub async fn run_generic( self, pre_resolved_signer: Option, @@ -354,3 +444,15 @@ where Ok(()) } + +#[cfg(test)] +mod tests { + use clap::CommandFactory; + + use super::SendTxArgs; + + #[test] + fn send_command_clap_shape_is_valid() { + SendTxArgs::command().debug_assert(); + } +} diff --git a/crates/cast/src/lib.rs b/crates/cast/src/lib.rs index ce5572acebc13..4406485a27ca6 100644 --- a/crates/cast/src/lib.rs +++ b/crates/cast/src/lib.rs @@ -65,6 +65,7 @@ pub mod base; pub mod call_spec; pub(crate) mod debug; pub mod errors; +pub mod quantum; mod rlp_converter; pub mod tx; diff --git a/crates/cast/src/quantum.rs b/crates/cast/src/quantum.rs new file mode 100644 index 0000000000000..03776ac386408 --- /dev/null +++ b/crates/cast/src/quantum.rs @@ -0,0 +1,575 @@ +use std::{fs, path::Path}; + +use alloy_eips::eip2930::AccessList; +use alloy_primitives::{Address, B256, Bytes, TxKind, U256, address, keccak256}; +use alloy_rlp::{BufMut, Encodable, Header as RlpHeader}; +use eyre::{Result, bail, ensure}; +use ml_dsa::{KeyGen, MlDsa44}; +use serde::{Deserialize, Serialize}; + +#[cfg(test)] +use ml_dsa::signature::Keypair; + +pub const QUANTUM_TX_TYPE_ID: u8 = 0x7A; +pub const QUANTUM_ML_DSA_SCHEME: u8 = 0x01; +pub const ML_DSA_PUBLIC_KEY_BYTES: usize = 1312; +pub const ML_DSA_SIGNATURE_BYTES: usize = 2420; +pub const ML_DSA_SEED_BYTES: usize = 32; +pub const PHASE0_FOUNDY_BASE_COMMIT: &str = "f1abb2ca347187bb6dea8c3881ca44ce50aab1e7"; +pub const PHASE0_QUANTUM_HARNESS_COMMIT: &str = "8f3612c60f9fa66ea3a09eab99a2e0802f373673"; +pub const PHASE0_TX_SPAMMER_EVIDENCE_COMMIT: &str = "2c25f14a44b8cc88fc41a65f521f1ba8350e7fa4"; +pub const KEYVAULT_ADDRESS: Address = address!("0000000000000000000000000000000000001000"); +pub const LIFECYCLE_REJECTION_MESSAGE: &str = "KeyVault lifecycle operations (bootstrap/addKey/removeKey/updateKeyAuth) cannot be simulated via eth_call; use explicit lifecycle transaction submission"; +pub const LIFECYCLE_SELECTORS: [[u8; 4]; 4] = [ + [0x5e, 0x8e, 0x7a, 0x13], + [0x32, 0xbc, 0x29, 0x19], + [0xc9, 0x8f, 0x21, 0xf4], + [0x89, 0x08, 0x15, 0x4b], +]; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QuantumWriteContractV1 { + pub sender: Address, + pub key_id: u32, + pub nonce: u64, + pub chain_id: u64, + pub max_priority_fee_per_gas: u128, + pub max_fee_per_gas: u128, + pub gas_limit: u64, + pub kind: TxKind, + pub value: U256, + pub input: Bytes, + pub access_list: AccessList, + pub primary_seed: [u8; ML_DSA_SEED_BYTES], +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DetachedArtifactV1 { + pub version: u8, + pub scheme: String, + pub signing_hash: String, + pub public_key: String, + pub signature: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QuantumSignedPayload { + pub raw_transaction: Vec, + pub signing_hash: B256, + pub sender: Address, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuantumPhase0RawFixture { + pub tx_type: String, + pub sender: String, + pub key_id: u32, + pub nonce_key: String, + pub chain_id: u64, + pub signing_hash: String, + pub raw_transaction: String, + pub raw_transaction_hash: String, + pub foundry_base_commit: String, + pub quantum_harness_commit: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct QuantumCall { + to: TxKind, + value: U256, + input: Bytes, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct QuantumTransaction { + chain_id: u64, + sender: Address, + nonce_key: U256, + nonce: u64, + key_id: u32, + max_priority_fee_per_gas: u128, + max_fee_per_gas: u128, + gas_limit: u64, + calls: Vec, + access_list: AccessList, + fee_payer: Option
, + fee_payer_key_id: Option, + init_primary_pubkey: Option, + init_cosigner_pubkey: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct CompositeSignature { + primary: SigningKeySignature, + cosigner: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum SigningKeySignature { + MlDsa44 { + signature: Box<[u8; ML_DSA_SIGNATURE_BYTES]>, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct QuantumSigned { + tx: QuantumTransaction, + sender_sig: CompositeSignature, +} + +pub fn parse_seed_file(path: &Path) -> Result<[u8; ML_DSA_SEED_BYTES]> { + let contents = fs::read_to_string(path)?; + let trimmed = contents.trim(); + let hex = trimmed + .strip_prefix("0x") + .or_else(|| trimmed.strip_prefix("0X")) + .unwrap_or(trimmed); + let bytes = alloy_primitives::hex::decode(hex)?; + let seed = <[u8; ML_DSA_SEED_BYTES]>::try_from(bytes.as_slice()).map_err(|_| { + eyre::eyre!( + "Quantum ML-DSA seed file must contain exactly {ML_DSA_SEED_BYTES} bytes of hex" + ) + })?; + Ok(seed) +} + +pub fn build_phase0_payload(contract: QuantumWriteContractV1) -> Result { + contract.validate_phase0()?; + + let tx = QuantumTransaction { + chain_id: contract.chain_id, + sender: contract.sender, + nonce_key: U256::ZERO, + nonce: contract.nonce, + key_id: contract.key_id, + max_priority_fee_per_gas: contract.max_priority_fee_per_gas, + max_fee_per_gas: contract.max_fee_per_gas, + gas_limit: contract.gas_limit, + calls: vec![QuantumCall { + to: contract.kind, + value: contract.value, + input: contract.input, + }], + access_list: contract.access_list, + fee_payer: None, + fee_payer_key_id: None, + init_primary_pubkey: None, + init_cosigner_pubkey: None, + }; + + let signing_hash = tx.signature_hash(); + let seed = ml_dsa::B32::from(contract.primary_seed); + let keypair = ::from_seed(&seed); + let ml_sig = keypair + .signing_key() + .sign_deterministic(signing_hash.as_ref(), &[]) + .map_err(|err| eyre::eyre!("failed to sign Quantum transaction: {err}"))?; + let encoded_sig = ml_sig.encode(); + let encoded_sig: &[u8] = encoded_sig.as_ref(); + let primary = SigningKeySignature::MlDsa44 { + signature: Box::new( + <[u8; ML_DSA_SIGNATURE_BYTES]>::try_from(encoded_sig) + .expect("ML-DSA signature length is fixed"), + ), + }; + + let signed = QuantumSigned { + tx, + sender_sig: CompositeSignature { + primary, + cosigner: None, + }, + }; + + let mut raw_transaction = Vec::with_capacity(signed.encode_2718_len()); + signed.encode_2718(&mut raw_transaction); + + Ok(QuantumSignedPayload { + raw_transaction, + signing_hash, + sender: contract.sender, + }) +} + +#[cfg(test)] +fn derive_address_from_seed(seed: [u8; ML_DSA_SEED_BYTES]) -> Address { + let keypair = ::from_seed(&ml_dsa::B32::from(seed)); + let verifying_key = keypair.verifying_key().encode(); + let verifying_key: &[u8] = verifying_key.as_ref(); + debug_assert_eq!(verifying_key.len(), ML_DSA_PUBLIC_KEY_BYTES); + Address::from_slice(&keccak256(verifying_key)[12..]) +} + +pub fn make_phase0_fixture(payload: &QuantumSignedPayload, key_id: u32) -> QuantumPhase0RawFixture { + QuantumPhase0RawFixture { + tx_type: format!("0x{QUANTUM_TX_TYPE_ID:02x}"), + sender: format!("{:#x}", payload.sender), + key_id, + nonce_key: "0x0".to_string(), + chain_id: 1337, + signing_hash: format!("{:#x}", payload.signing_hash), + raw_transaction: format!("0x{}", alloy_primitives::hex::encode(&payload.raw_transaction)), + raw_transaction_hash: format!("{:#x}", keccak256(&payload.raw_transaction)), + foundry_base_commit: PHASE0_FOUNDY_BASE_COMMIT.to_string(), + quantum_harness_commit: PHASE0_QUANTUM_HARNESS_COMMIT.to_string(), + } +} + +impl QuantumWriteContractV1 { + fn validate_phase0(&self) -> Result<()> { + ensure!(self.sender != Address::ZERO, "Quantum sender must be explicit and non-zero"); + ensure!(self.key_id == 0, "Phase 0 only supports key_id = 0"); + ensure!(matches!(self.kind, TxKind::Call(_)), "Phase 0 seam only supports single-call send flows"); + ensure!(self.max_priority_fee_per_gas <= self.max_fee_per_gas, "max priority fee must not exceed max fee"); + ensure!(self.access_list.is_empty(), "Phase 0 seam does not support access lists"); + + if let TxKind::Call(to) = self.kind + && to == KEYVAULT_ADDRESS + && is_lifecycle_selector(&self.input) + { + bail!(LIFECYCLE_REJECTION_MESSAGE); + } + + Ok(()) + } +} + +fn is_lifecycle_selector(input: &[u8]) -> bool { + if input.len() < 4 { + return false; + } + let selector = [input[0], input[1], input[2], input[3]]; + LIFECYCLE_SELECTORS.contains(&selector) +} + +impl QuantumTransaction { + fn rlp_encode_fields(&self, out: &mut dyn BufMut) { + self.chain_id.encode(out); + self.sender.encode(out); + self.nonce_key.encode(out); + self.nonce.encode(out); + self.key_id.encode(out); + self.max_priority_fee_per_gas.encode(out); + self.max_fee_per_gas.encode(out); + self.gas_limit.encode(out); + self.calls.encode(out); + self.access_list.encode(out); + encode_option_as_list(self.fee_payer.as_ref(), out); + encode_option_as_list(self.fee_payer_key_id.as_ref(), out); + encode_option_as_list(self.init_primary_pubkey.as_ref(), out); + encode_option_as_list(self.init_cosigner_pubkey.as_ref(), out); + } + + fn rlp_encoded_fields_length(&self) -> usize { + self.chain_id.length() + + self.sender.length() + + self.nonce_key.length() + + self.nonce.length() + + self.key_id.length() + + self.max_priority_fee_per_gas.length() + + self.max_fee_per_gas.length() + + self.gas_limit.length() + + self.calls.length() + + self.access_list.length() + + option_as_list_length(self.fee_payer.as_ref()) + + option_as_list_length(self.fee_payer_key_id.as_ref()) + + option_as_list_length(self.init_primary_pubkey.as_ref()) + + option_as_list_length(self.init_cosigner_pubkey.as_ref()) + } + + fn encode_for_signing(&self, out: &mut dyn BufMut) { + out.put_u8(QUANTUM_TX_TYPE_ID); + let payload_len = self.rlp_encoded_fields_length(); + RlpHeader { + list: true, + payload_length: payload_len, + } + .encode(out); + self.rlp_encode_fields(out); + } + + fn signature_hash(&self) -> B256 { + let mut buf = Vec::new(); + self.encode_for_signing(&mut buf); + keccak256(buf) + } +} + +impl Encodable for QuantumTransaction { + fn encode(&self, out: &mut dyn BufMut) { + let payload_length = self.rlp_encoded_fields_length(); + RlpHeader { + list: true, + payload_length, + } + .encode(out); + self.rlp_encode_fields(out); + } + + fn length(&self) -> usize { + let payload_length = self.rlp_encoded_fields_length(); + alloy_rlp::length_of_length(payload_length) + payload_length + } +} + +impl QuantumSigned { + fn rlp_inner_length(&self) -> usize { + self.tx.rlp_encoded_fields_length() + + self.sender_sig.length() + + option_as_list_length::(None) + } + + fn rlp_encode_inner(&self, out: &mut dyn BufMut) { + self.tx.rlp_encode_fields(out); + self.sender_sig.encode(out); + encode_option_as_list::(None, out); + } + + fn encode_2718_len(&self) -> usize { + let inner_len = self.rlp_inner_length(); + 1 + alloy_rlp::length_of_length(inner_len) + inner_len + } + + fn encode_2718(&self, out: &mut dyn BufMut) { + out.put_u8(QUANTUM_TX_TYPE_ID); + let inner_len = self.rlp_inner_length(); + RlpHeader { + list: true, + payload_length: inner_len, + } + .encode(out); + self.rlp_encode_inner(out); + } +} + +impl Encodable for QuantumCall { + fn encode(&self, out: &mut dyn BufMut) { + let payload_len = self.to.length() + self.value.length() + self.input.length(); + RlpHeader { + list: true, + payload_length: payload_len, + } + .encode(out); + self.to.encode(out); + self.value.encode(out); + self.input.encode(out); + } + + fn length(&self) -> usize { + let payload_len = self.to.length() + self.value.length() + self.input.length(); + alloy_rlp::length_of_length(payload_len) + payload_len + } +} + +impl SigningKeySignature { + fn wire_size(&self) -> usize { + match self { + Self::MlDsa44 { .. } => 1 + ML_DSA_SIGNATURE_BYTES, + } + } +} + +impl Encodable for SigningKeySignature { + fn encode(&self, out: &mut dyn BufMut) { + let mut bytes = Vec::with_capacity(self.wire_size()); + match self { + Self::MlDsa44 { signature } => { + bytes.push(QUANTUM_ML_DSA_SCHEME); + bytes.extend_from_slice(signature.as_ref()); + } + } + bytes.as_slice().encode(out); + } + + fn length(&self) -> usize { + let wire = self.wire_size(); + wire + alloy_rlp::length_of_length(wire) + } +} + +impl Encodable for CompositeSignature { + fn encode(&self, out: &mut dyn BufMut) { + let payload_length = self.primary.length() + option_as_list_length(self.cosigner.as_ref()); + RlpHeader { + list: true, + payload_length, + } + .encode(out); + self.primary.encode(out); + encode_option_as_list(self.cosigner.as_ref(), out); + } + + fn length(&self) -> usize { + let payload_length = self.primary.length() + option_as_list_length(self.cosigner.as_ref()); + alloy_rlp::length_of_length(payload_length) + payload_length + } +} + +fn encode_option_as_list(value: Option<&T>, out: &mut dyn BufMut) { + match value { + Some(value) => { + let payload_length = value.length(); + RlpHeader { + list: true, + payload_length, + } + .encode(out); + value.encode(out); + } + None => { + RlpHeader { + list: true, + payload_length: 0, + } + .encode(out); + } + } +} + +fn option_as_list_length(value: Option<&T>) -> usize { + match value { + Some(value) => { + let payload_length = value.length(); + alloy_rlp::length_of_length(payload_length) + payload_length + } + None => 1, + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use serde_json::Value; + + use super::*; + + fn fixture_seed_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../testdata/fixtures/quantum/phase0/primary-seed.hex") + } + + fn raw_fixture_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../testdata/fixtures/quantum/phase0/raw-send-primary.json") + } + + fn canonical_phase0_fixture() -> QuantumPhase0RawFixture { + let seed = parse_seed_file(&fixture_seed_path()).unwrap(); + let payload = build_phase0_payload(QuantumWriteContractV1 { + sender: derive_address_from_seed(seed), + key_id: 0, + nonce: 0, + chain_id: 1337, + max_priority_fee_per_gas: 1_000_000_000, + max_fee_per_gas: 20_000_000_000, + gas_limit: 21_000, + kind: TxKind::Call(Address::repeat_byte(0x33)), + value: U256::from(1u64), + input: Bytes::new(), + access_list: AccessList::default(), + primary_seed: seed, + }) + .unwrap(); + + make_phase0_fixture(&payload, 0) + } + + #[test] + fn upstream_minimal_body_hashes_match_source_of_truth() { + let tx = QuantumTransaction { + chain_id: 1337, + sender: Address::ZERO, + nonce_key: U256::ZERO, + nonce: 42, + key_id: 0, + max_priority_fee_per_gas: 1_000_000_000, + max_fee_per_gas: 50_000_000_000, + gas_limit: 21_000, + calls: vec![QuantumCall { + to: TxKind::Call(Address::ZERO), + value: U256::from(1_000_000_000_000_000_000u128), + input: Bytes::from_static(b"hello"), + }], + access_list: AccessList::default(), + fee_payer: None, + fee_payer_key_id: None, + init_primary_pubkey: None, + init_cosigner_pubkey: None, + }; + + let mut buf = Vec::new(); + tx.encode(&mut buf); + assert_eq!( + format!("{:#x}", keccak256(&buf)), + "0xd039fbca7d51e653c90e8b84adb8fa6e30929dffa7eb41dbd6ec40594ce3ad4e" + ); + assert_eq!( + format!("{:#x}", tx.signature_hash()), + "0x909fe4db64c4605eb394b9de4d064bce0ab6d718b32050f00d80d7f525753b7d" + ); + } + + #[test] + fn phase0_payload_enforces_explicit_sender() { + let seed = parse_seed_file(&fixture_seed_path()).unwrap(); + let err = build_phase0_payload(QuantumWriteContractV1 { + sender: Address::ZERO, + key_id: 0, + nonce: 0, + chain_id: 1337, + max_priority_fee_per_gas: 1, + max_fee_per_gas: 1, + gas_limit: 21_000, + kind: TxKind::Call(Address::repeat_byte(0x11)), + value: U256::ZERO, + input: Bytes::new(), + access_list: AccessList::default(), + primary_seed: seed, + }) + .unwrap_err(); + + assert!(err.to_string().contains("explicit and non-zero")); + } + + #[test] + fn phase0_payload_rejects_lifecycle_selectors() { + let seed = parse_seed_file(&fixture_seed_path()).unwrap(); + let err = build_phase0_payload(QuantumWriteContractV1 { + sender: Address::repeat_byte(0x22), + key_id: 0, + nonce: 0, + chain_id: 1337, + max_priority_fee_per_gas: 1, + max_fee_per_gas: 1, + gas_limit: 21_000, + kind: TxKind::Call(KEYVAULT_ADDRESS), + value: U256::ZERO, + input: Bytes::from(LIFECYCLE_SELECTORS[0].to_vec()), + access_list: AccessList::default(), + primary_seed: seed, + }) + .unwrap_err(); + + assert_eq!(err.to_string(), LIFECYCLE_REJECTION_MESSAGE); + } + + #[test] + fn generated_fixture_is_valid_json_shape() { + let fixture = canonical_phase0_fixture(); + let value: Value = serde_json::to_value(fixture).unwrap(); + assert_eq!(value["tx_type"], "0x7a"); + assert_eq!(value["key_id"], 0); + assert_eq!(value["nonce_key"], "0x0"); + assert!(value["raw_transaction"].as_str().unwrap().starts_with("0x7a")); + } + + #[test] + fn generated_fixture_matches_checked_in_phase0_example() { + let expected = canonical_phase0_fixture(); + let actual: QuantumPhase0RawFixture = + serde_json::from_str(&fs::read_to_string(raw_fixture_path()).unwrap()).unwrap(); + + assert_eq!(actual, expected); + } +} diff --git a/crates/cli/src/opts/mod.rs b/crates/cli/src/opts/mod.rs index fc619f482d2fb..b5a1e1a48b7fa 100644 --- a/crates/cli/src/opts/mod.rs +++ b/crates/cli/src/opts/mod.rs @@ -4,6 +4,7 @@ mod dependency; mod evm; mod global; mod network; +mod quantum; mod rpc; mod rpc_common; mod tempo; @@ -15,6 +16,7 @@ pub use dependency::*; pub use evm::*; pub use global::*; pub use network::*; +pub use quantum::*; pub use rpc::*; pub use rpc_common::*; pub use tempo::*; diff --git a/crates/cli/src/opts/quantum.rs b/crates/cli/src/opts/quantum.rs new file mode 100644 index 0000000000000..153aed1927419 --- /dev/null +++ b/crates/cli/src/opts/quantum.rs @@ -0,0 +1,79 @@ +use std::path::PathBuf; + +use alloy_primitives::Address; +use clap::Parser; + +/// CLI options for the Phase 0 Quantum seam spike. +#[derive(Clone, Debug, Default, Parser)] +#[command(next_help_heading = "Quantum")] +pub struct QuantumOpts { + /// Enable the Phase 0 native Quantum raw-transaction path. + /// + /// This keeps Quantum selection explicit in v1 instead of inferring it from chain ID. + #[arg(id = "quantum_enabled", long = "quantum")] + pub enabled: bool, + + /// Explicit Quantum sender/account-lane address. + /// + /// Quantum writes never auto-derive the sender from the signing key. + #[arg(id = "quantum_sender", long = "quantum.sender", value_name = "ADDRESS")] + pub sender: Option
, + + /// Quantum account-lane key ID. + /// + /// Defaults to `0` for the Phase 0 seam spike. + #[arg(id = "quantum_key_id", long = "quantum.key-id", value_name = "KEY_ID")] + pub key_id: Option, + + /// Path to the canonical Phase 0 ML-DSA signer seed file. + /// + /// The file must contain a single 32-byte seed as hex, with or without a `0x` prefix. + #[arg( + id = "quantum_primary_seed_file", + long = "quantum.primary-seed-file", + value_name = "PATH" + )] + pub primary_seed_file: Option, +} + +impl QuantumOpts { + /// Returns `true` if any Quantum-specific option is set. + pub fn is_quantum(&self) -> bool { + self.enabled + || self.sender.is_some() + || self.key_id.is_some() + || self.primary_seed_file.is_some() + } + + /// Returns the resolved key ID for the Phase 0 seam. + pub fn resolved_key_id(&self) -> u32 { + self.key_id.unwrap_or(0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_quantum_opts() { + let opts = QuantumOpts::try_parse_from([ + "", + "--quantum", + "--quantum.sender", + "0x000000000000000000000000000000000000dEaD", + "--quantum.key-id", + "7", + "--quantum.primary-seed-file", + "./seed.hex", + ]) + .unwrap(); + + assert!(opts.is_quantum()); + assert_eq!(opts.resolved_key_id(), 7); + assert_eq!( + opts.primary_seed_file.as_deref(), + Some(std::path::Path::new("./seed.hex")) + ); + } +} diff --git a/crates/cli/src/opts/transaction.rs b/crates/cli/src/opts/transaction.rs index 7731422c95df5..fcf94e0c4eabf 100644 --- a/crates/cli/src/opts/transaction.rs +++ b/crates/cli/src/opts/transaction.rs @@ -1,6 +1,6 @@ use std::str::FromStr; -use super::TempoOpts; +use super::{QuantumOpts, TempoOpts}; use crate::utils::{parse_ether_value, parse_json}; use alloy_eips::{eip2930::AccessList, eip7702::SignedAuthorization}; use alloy_network::{Network, TransactionBuilder}; @@ -110,6 +110,9 @@ pub struct TransactionOpts { #[command(flatten)] pub tempo: TempoOpts, + + #[command(flatten)] + pub quantum: QuantumOpts, } impl TransactionOpts { diff --git a/docs/dev/quantum-phase0-implementation-note.md b/docs/dev/quantum-phase0-implementation-note.md new file mode 100644 index 0000000000000..9be4f2375b79d --- /dev/null +++ b/docs/dev/quantum-phase0-implementation-note.md @@ -0,0 +1,264 @@ +# Quantum Phase 0 Implementation Note + +## Purpose + +This note freezes the exact Phase 0 contracts for the Quantum transaction seam spike in the Foundry fork. + +## Frozen Bases + +- Foundry fork base: `f1abb2ca347187bb6dea8c3881ca44ce50aab1e7` +- Quantum harness source-of-truth repo: `https://github.com/multivmlabs/quantum-eth.git` +- Quantum harness commit: `8f3612c60f9fa66ea3a09eab99a2e0802f373673` +- Behavioral evidence repo: `https://github.com/multivmlabs/tx-spammer` +- Behavioral evidence commit: `2c25f14a44b8cc88fc41a65f521f1ba8350e7fa4` + +## Frozen RPC Contract + +- State-changing Quantum submission uses raw `eth_sendRawTransaction`. +- The submitted payload is a native `0x7A` envelope with exactly one call. +- `sender` is explicit and never auto-derived from signer identity. +- `key_id` is explicit and defaults to `0` only for the Phase 0 seam spike. +- `nonce_key` is fixed to `0` in v1. +- Sender-pays only. Fee-payer and sponsorship fields remain out of scope for Phase 0. +- Deploy and future script flows must treat `eth_getTransactionReceipt` as the source of `contractAddress`. +- Ordinary `eth_call` remains the read-path contract. +- KeyVault lifecycle selectors are not simulated through ordinary send/call flows; the stable rejection text is: + +```text +KeyVault lifecycle operations (bootstrap/addKey/removeKey/updateKeyAuth) cannot be simulated via eth_call; use explicit lifecycle transaction submission +``` + +## Frozen KeyVault ABI / Selector Set + +- Precompile address: `0x0000000000000000000000000000000000001000` +- `bootstrapKey()`: `0x5e8e7a13` +- `addKey(uint32,bytes,uint8,bytes,uint8,uint8,bytes)`: `0x32bc2919` +- `removeKey(uint32)`: `0xc98f21f4` +- `updateKeyAuth(uint32,bytes,uint8,bytes,uint8)`: `0x8908154b` + +## Frozen Signer / Operator Contract + +### Shared QuantumWriteContractV1 + +Every state-changing Quantum write surface must normalize into the same internal contract before signing or broadcast: + +- explicit Quantum selection +- explicit `sender` +- auth-lane `key_id` +- `nonce_key = 0` +- one file-backed ML-DSA signer source +- optional bootstrap-only fields +- optional detached artifact for required P256 and ECDSA cosigner flows +- lifecycle target-key / scoped-permission inputs when applicable +- one normalized single-call payload that becomes the native `0x7A` body + +### Phase 0 ML-DSA Source + +The seam spike accepts exactly one primary signer source: + +- `--quantum.primary-seed-file ` + +The file must contain one 32-byte ML-DSA seed encoded as hex, with or without a `0x` prefix. The seed is normalized into bytes before signing. + +The Phase 0 implementation uses the public `ml-dsa` crate directly and mirrors the `quantum-eth2` wrapper behavior for deterministic key expansion, signing, and address derivation, so the seam spike does not depend on a private git dependency. + +### Detached Artifact Contract (Frozen For Phase 1) + +Detached P256 and detached ECDSA flows are frozen as a single versioned artifact schema, even though the Phase 0 code path only proves the primary-only send: + +```json +{ + "version": 1, + "scheme": "p256|ecdsa", + "signing_hash": "0x...", + "public_key": "0x...", + "signature": "0x..." +} +``` + +Rules: + +- the fork owns construction of the request body and canonical signing hash +- detached signers sign exactly that hash +- the artifact `signing_hash` must match the fork-computed hash byte-for-byte +- the schema is shared across `cast send`, `forge create`, and `forge script --broadcast` + +## Phase 0 Seam Choice + +Phase 0 intentionally does **not** add the full `QuantumNetwork` adapter yet. + +Instead, it proves the signer/broadcast seam in `cast send` by: + +- using normal Foundry parsing/fill logic to resolve destination, calldata, gas, nonce, and fees +- normalizing those values into `QuantumWriteContractV1` +- signing a native `0x7A` envelope locally with the ML-DSA seed file +- submitting the encoded raw bytes with `eth_sendRawTransaction` +- reusing Foundry’s existing raw-send receipt polling path + +This is a spike, not the release architecture. The Phase 1 rule remains: + +- `cast send`, `forge create`, and `forge script --broadcast` must converge on one shared Quantum signing and broadcast pipeline + +## Pinned Fixture Sources + +Tracked fixture directory: + +- `testdata/fixtures/quantum/phase0/` + +Phase 0 fixture set: + +- canonical raw `0x7A` submission example with parity metadata +- deploy receipt example showing `status`, `transactionHash`, and `contractAddress` +- lifecycle simulation rejection example with the stable surfaced message + +## Harness Boot Method + +The pinned local harness for manual and CI validation remains the Quantum source-of-truth e2e cluster at commit `8f3612c60f9fa66ea3a09eab99a2e0802f373673`. + +For Phase 0 closure, the smallest reproducible local proof uses the upstream single-node dev harness. It preserves the same `0x7A` transaction and receipt behavior without requiring the full multi-node cluster: + +```bash +# in /Users/ea/repos/quantum-eth2 +cargo run --bin quantum-reth -- node --dev --http --http.port 18545 --http.api all +``` + +Phase 0 validation still relies on the same upstream behavior exercised by: + +- `crates/e2e/tests/transactions.rs` +- `crates/e2e/tests/keyvault_lifecycle.rs` + +The Foundry fork does not redefine the harness contract locally in Phase 0; it freezes the upstream harness commit and fixture expectations before broader adapter work begins. + +## Manual Verification Request + +Request the following proof from the operator / reviewer when closing Phase 0. + +### 1. Prepare the funded deterministic seed + +The upstream dev harness prefunds dev account `0`, whose ML-DSA seed is `sha256("quantum-ml-dsa-dev-0")`. + +```bash +printf '0x4f600dd3c20fb7d9e12a3d51ee15ecc74b92e9c020ad1f795a774e96eb5634f4\n' >/tmp/quantum-dev0.seed +``` + +Frozen addresses used in the proof: + +- sender / dev-0: `0x47872C3e8676384B80648D95bEaC2c0C348eF272` +- recipient example / dev-1: `0x9a9eA6B0e3d2984ddB7e8070f1F8B46Af36BF92C` + +### 2. Bootstrap the sender with the upstream harness tool + +`cast send --quantum` intentionally does not perform KeyVault lifecycle bootstrap in Phase 0, so the sender must be registered first. + +```bash +# in /Users/ea/repos/quantum-eth2 +cargo run --bin quantum-send-tx -- bootstrap \ + --rpc-url http://127.0.0.1:18545 \ + --fill \ + --dev-index 0 +``` + +Expected result: + +- stdout prints a bootstrap tx hash +- stderr includes `status: 0x1` + +Observed locally on 2026-04-15: + +- bootstrap tx hash: `0xc7734c6501fb4074900015ba3e1e5c7e0990da78d25073e7029ce7399fbe73d8` + +### 3. Prove native `0x7A` submission through `cast send --quantum` + +Use `--async` for the manual proof. The Phase 0 seam successfully broadcasts without it, but non-async `cast send` currently trips over Foundry receipt deserialization because the upstream harness returns receipt `type: "Pq"`. + +```bash +# in /Users/ea/repos/quantum-foundry +TX_HASH=$(cargo +nightly run --bin cast -- send \ + 0x9a9eA6B0e3d2984ddB7e8070f1F8B46Af36BF92C \ + --value 2 \ + --async \ + --rpc-url http://127.0.0.1:18545 \ + --quantum \ + --quantum.sender 0x47872C3e8676384B80648D95bEaC2c0C348eF272 \ + --quantum.primary-seed-file /tmp/quantum-dev0.seed) +printf 'TX_HASH=%s\n' "$TX_HASH" +``` + +Expected result: + +- command exits `0` +- stdout is only the transaction hash + +Observed locally on 2026-04-15: + +- async tx hash: `0xa300521dff7d22ed26eca888e19c09711cb524d88877f99b42f4258e81b197fe` + +### 4. Prove the node accepted it as a PQ-native transaction + +Query raw RPC directly so no Ethereum-type deserializer gets in the way. + +```bash +curl -s -H 'content-type: application/json' \ + --data "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getTransactionReceipt\",\"params\":[\"$TX_HASH\"]}" \ + http://127.0.0.1:18545 + +curl -s -H 'content-type: application/json' \ + --data "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getTransactionByHash\",\"params\":[\"$TX_HASH\"]}" \ + http://127.0.0.1:18545 +``` + +Expected receipt assertions: + +- `result.type == "Pq"` +- `result.status == "0x1"` +- `result.from == "0x47872c3e8676384b80648d95beac2c0c348ef272"` +- `result.to == "0x9a9ea6b0e3d2984ddb7e8070f1f8b46af36bf92c"` + +Expected transaction assertions: + +- `result.type == "0x7a"` +- `result.sender == "0x47872c3e8676384b80648d95beac2c0c348ef272"` +- `result.keyId == "0x0"` +- `result.nonceKey == "0x0"` + +### 5. Prove the frozen lifecycle rejection path in the seam + +Provide explicit nonce and gas so the command reaches `QuantumWriteContractV1` validation instead of failing early during RPC gas estimation. + +```bash +cargo +nightly run --bin cast -- send \ + 0x0000000000000000000000000000000000001000 \ + --data 0x5e8e7a13 \ + --async \ + --rpc-url http://127.0.0.1:18545 \ + --nonce 3 \ + --gas-limit 2100000 \ + --gas-price 15 \ + --priority-gas-price 1 \ + --quantum \ + --quantum.sender 0x47872C3e8676384B80648D95bEaC2c0C348eF272 \ + --quantum.primary-seed-file /tmp/quantum-dev0.seed +``` + +Expected result: + +```text +KeyVault lifecycle operations (bootstrap/addKey/removeKey/updateKeyAuth) cannot be simulated via eth_call; use explicit lifecycle transaction submission +``` + +Note: if nonce / gas are omitted, Foundry may fail earlier during gas estimation with a generic RPC revert instead of surfacing the frozen seam-level rejection string. + +## Evidence Status And Remaining Gaps + +Confirmed locally on 2026-04-15: + +- upstream dev harness boot recipe is now concrete and reproducible +- native `cast send --quantum` submission does reach `eth_sendRawTransaction` and lands successfully on the harness +- raw node RPC shows transaction `type: "0x7a"` and receipt `type: "Pq"` +- the Phase 0 local lifecycle rejection path is reproducible with explicit nonce / gas + +Still open after verification: + +- broader golden fixtures are still missing for bootstrap raw shape and lifecycle calldata shape +- non-async `cast send --quantum` is not yet operator-clean because receipt parsing does not recognize upstream receipt `type: "Pq"` +- raw `eth_call` against lifecycle selectors on the live harness currently returns `execution reverted` with data `0x202ce609`, not the friendly frozen rejection string; treat this as an upstream RPC-surfacing mismatch that still needs confirmation before Phase 0 is considered fully evidenced end-to-end diff --git a/testdata/fixtures/quantum/phase0/deploy-receipt.example.json b/testdata/fixtures/quantum/phase0/deploy-receipt.example.json new file mode 100644 index 0000000000000..45133a9dfa74b --- /dev/null +++ b/testdata/fixtures/quantum/phase0/deploy-receipt.example.json @@ -0,0 +1,15 @@ +{ + "source": "quantum-eth2/crates/e2e/tests/transactions.rs", + "quantum_harness_commit": "8f3612c60f9fa66ea3a09eab99a2e0802f373673", + "note": "Pinned receipt-field contract for Phase 0 and later deploy parity checks. Replace with a captured harness receipt if the field contract changes.", + "required_fields": [ + "status", + "transactionHash", + "contractAddress" + ], + "example": { + "status": "0x1", + "transactionHash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "contractAddress": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } +} diff --git a/testdata/fixtures/quantum/phase0/lifecycle-call-rejection.json b/testdata/fixtures/quantum/phase0/lifecycle-call-rejection.json new file mode 100644 index 0000000000000..6a082b1c8180c --- /dev/null +++ b/testdata/fixtures/quantum/phase0/lifecycle-call-rejection.json @@ -0,0 +1,12 @@ +{ + "source": "quantum-eth2/crates/primitives/src/rpc.rs", + "quantum_harness_commit": "8f3612c60f9fa66ea3a09eab99a2e0802f373673", + "precompile": "0x0000000000000000000000000000000000001000", + "selectors": { + "bootstrapKey": "0x5e8e7a13", + "addKey": "0x32bc2919", + "removeKey": "0xc98f21f4", + "updateKeyAuth": "0x8908154b" + }, + "error_substring": "KeyVault lifecycle operations (bootstrap/addKey/removeKey/updateKeyAuth) cannot be simulated via eth_call; use explicit lifecycle transaction submission" +} diff --git a/testdata/fixtures/quantum/phase0/primary-seed.hex b/testdata/fixtures/quantum/phase0/primary-seed.hex new file mode 100644 index 0000000000000..8896b72e68254 --- /dev/null +++ b/testdata/fixtures/quantum/phase0/primary-seed.hex @@ -0,0 +1 @@ +0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f diff --git a/testdata/fixtures/quantum/phase0/raw-send-primary.json b/testdata/fixtures/quantum/phase0/raw-send-primary.json new file mode 100644 index 0000000000000..4ef26c3db9e07 --- /dev/null +++ b/testdata/fixtures/quantum/phase0/raw-send-primary.json @@ -0,0 +1,12 @@ +{ + "tx_type": "0x7a", + "sender": "0x524d855f4516c34fdafa7e75cb56d9462ffa2f28", + "key_id": 0, + "nonce_key": "0x0", + "chain_id": 1337, + "signing_hash": "0xe098220361306c5e56d7aa0c42f464f1e439d0607271bd5d98d2bcab7b2c61e9", + "raw_transaction": "0x7af909c482053994524d855f4516c34fdafa7e75cb56d9462ffa2f28808080843b9aca008504a817c800825208d8d79433333333333333333333333333333333333333330180c0c0c0c0c0f90979b909750131c9d6c967dc5d9a3dd276417a505daa4c86f67423091661bbd2bf22e69bb65b6dd65c9e8e5c2ce9d9a6fc208b15572d617a76041f08e7fb11b535fe3adf3d95da2fc384434af9cc0719b8f2d7a6c60cf2a98ddc63c01c6cdca079a90bb3db5c7f0d01d2362ad0746da14ecb63649a4d47d25b48af6b580d10eef0fca6b3dc39a2a0431503e2c41d7e0ab98ac6491604a7cb9145a4bc1b74003cd741e07d34af584810b29c244a55decb243f3f8b35e04b951f934d1675c63487030de0f2c1f112f403a53cede9e0c575c689e51e105f18609be91716c6240b10b2bf8964f88c38803060d3c74fb9ca47be7161986d61ca8579936b802cfacd95e61c0e12132fc836a49b8f0433312fd040b7c28e4ac9a7b1eab5e35b9d5e191f0fc348bcdfd157cb2714b576ce7c93633ab64eee72fb844568fef4410d69d34900b8e794cbf45f1b74b60e1cd02b0f9cebdfc695f8997fef15d37e87e18fe2b0233fbd3037a3e7d0d218a2ca3f7e9f90f8be1cfbbdf7b1ae3b38e056b7b4f6cbb6a04034454e6b2d630b8904d50aa28fcc8b772eeb2efe7e506e69b508a7bd570007fd879ca974d95b2be555835a9930feed4602fc55467c452bfae48ad419b38dab9f3027ff1b5c01cac390357d0e81542d77e0b86910ccac592c1aaf8eb33ada793dfbcbdbf4beed5670e887af76e1115555c263eafeeb86f2e2742204049deb1c78fbc706991651a05745d8a5f67df53fdbbbe7e2d10c3af91aaa441e7c32115661068099ad7b6a8bb6664e37e0f62dc59dbdfb69381289bbbe0c65f9fc9d3faac2cde5f08b0a863f7939a58e82243cbae744138e3f547d8ee6527ce3297d5129b97ce72f01ad9d8a61272d58a3acbceeac7d910614451040340ea300dc42c0bfc3740300fd8cbc9cb9f9b2472f1871d991c85cd1890d3e4c8cbbdd8b19265cce79b69da2474804099d6751b8f0fc1be94efe3690b349c8f8bb515cd080a14cd74c681e5c5364432b6bed361b1d6f97a5a1d5204e2e3ffc68ae0c527bb4a5c94841498461d7121ed6db541b5de7dfad06fb7375c6d7ba02c57f085d341420ce5ba481e2963b78a80325a6c4d0c79095f1d85b0eeabdd0522701eb7402728f9bdfdf73c3d00edb653e22fd08118a5a329603001b39d99bf707a802c7e706ca1e4c91e2a5bebce3feb27e161898b5fbf3f1662a9238115bb3aa6d0c360dcf5316a0ea7337d8521ce82f1ead62bf8723bcaf26dc4b70cbb8a0b0250bf002c159df8b56f10ce1626a584f7367284d93e70a62dcda9a41ab8b1d618d383e3afb6aac42c22e43a4f797fc31ac4a67cc39e9196d876bd98a14fde34fcfe1a2e0f9a6602e62972f9c81543f1272051fa09a88d05eae882c22f8a9973be5cd9547df86c3ebbafbdcbbcba4554eaeb01795399e57080f3548f84690da97ff6d03230a086ab8a3c40e728ef04111e5ef689b8a2acf67e041db1cb2b7aa4796a6cfdb5c140156710a5bf620eaa93f96534eec6fbe59574a2946d8ebc2536e85d7b377be9b91f0e40f9b49c52b22ccd5b3ce1de9e8275a9d439e4430ad56cf278eb7edc18ba0913744f1f302891d611d3c4bd938d9c6b6d053f7a851f2385c3f103f770f812ddc8f4269ab354b60b0024007d88bcb3ab1d25d80f9a7f1db4f23616aac284365c1867374fbd40adb41f91222f0a06168e1344e2202ef3676da127ccaf86ad8c99bc29494769ba821a6da81e98a2a9476bce2786150b9fa17b069e1beba143b46ff43a3f97148709946a208f7b7d634f0aeb9c3d8f2ea5599294389eb741051224a9efdef3de8cd5eb4971e386479e54634f5e162fbf5c5ba718fe33c401ee4831c8681ded9e6405a42415fbaf8f916a88fcef2b5b1a971b1f1f48a47d71744fe7f1b54c511a8d73d23352748d31859d85c1d56995757824a9cc5d06619759b61215337088efa0f116dbf666d9b51eb1af76e5a1dadeb00087b65e29cf85f107116ae14b3c751f824324d4adb7f1a57e0f842fe029edcba64a1fe1ce37e5735226359f77c484eba7857a44fe41a57c58f3059b2e7f796d3ad96a085231ae34dcb8af5dc779c33fd12a9001fdad7fee8787beea3833bca17d8e242a9dc4809e537f94245bfb174de40d7bc9f79a5a61fd9e56b0e07cfaf6a55a0f110463a91d68dd7b7c17d0d8c14893236cca87fcfe13b2700e4232e57fd77f45750e125b20b054fb2b5ddb3a9bc673afd4c332117cdac4441b91784391662dd577d0af6a7f53dd3dfd1c96191d3fd22426d1d4b6b928f5f315e550eaa02a30e0ddcb1bc87485f30f64f6ad74053d41ba4bf7421f85ee166faa44d61489e2c4fbf4b68d35a6259e278ed362653b157ecb56f80043de1be9ee2afdd5e5891b75cbd1b00b97d2d5f5339c4ca8128422dfbd2f617c670cdd7c2d2ec0b574cbb8579e305da33a93c8a231f36e8993079df849bdf9d6226c44c93c5aeba925168be6e90ac0fcd4721042aa61f82f178038e4b4034aef8bcbe425fd24dcffa71344783fe017efb3021b87b6fab58d48b65b1acaf82610bd6fe91bf3156ac411c1b57ddd6aedcba50ca5ec4a4fb452ab860c7e20c2284c8b8bad1105163cb8da68758e610c06a2e62bb8f689ce2e5239fcb7dda305641026e045867372b96405a4c4dd8f696ca44e953d91cb16a43d44e7a1b555f689fb75559b7353ab3aafec400fe5f521ac617d6cad19b22cec23ae83a67a09d499ef87119be7b9d68a3f2cdbea2fdd5260e63fac9bf8a5695f078decde04d6aed811b041f6a4085941371946e582adde5ba516ca54fc607f975e2121b0965468bf2ce323cf69cd2a3892db13d824f177621fdbac5a85d80dd91408e77f2dcb43ff8e873006f601dbde8e64e9f1a7624637a8263c2841594784bde89bce40e3b7cee48870c0dc107bb82a931eef5050f036ae2618d05959ce9ac295bf1c453598a39a5f04e350b0763049f446d997ff5c1ca47464492441348d15b07d6224530b8d12b0989669a0b4e944ec468e208b8e509a91f15bda48a0959591a29cdf6485be585e3516c558fc2babda9887bd05c35563d18e076ba52f7d69a0edfd566b175c94611b2ca6399e31c4ddad0030f966820071bf5a601606344dd8e61b155b7360687ca2ddfccb816f7d7504c0de06ad5d18d686c5476cd070679a73c0a65460079c6c755dc5dede5b96e46bae1ce1b39dd7d5e2338ef58066321eb14e995928316d868e2791bb6cd2621be5696e4e301423702a96a59ddf0b4025a0b4313b122e2cc0865dc4b6290871ef76e6c81d59ee3058cc8525282b61228373e909ea0abace9f4fa2f32384b75777a8b8d8e9ba4b4c7dd1976a2a3bbc5cad1d9dadfe5eaed11272a33383d44959cb4e5f500000000000000000000000000000000000000000000000000000000000a192733c0c0", + "raw_transaction_hash": "0x8a863bec9666be0c79fee8218acdedb95be4d09636bfd4a7e09e2b8fba7e70e5", + "foundry_base_commit": "f1abb2ca347187bb6dea8c3881ca44ce50aab1e7", + "quantum_harness_commit": "8f3612c60f9fa66ea3a09eab99a2e0802f373673" +}