From e7959f042b3bbe19a3ef976c562137ad884ab418 Mon Sep 17 00:00:00 2001 From: Erhan Acet Date: Fri, 17 Apr 2026 13:03:48 +0300 Subject: [PATCH 01/16] quantum phase 4: lock parity with tests, fixtures, docs, and CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pin composite cosigned RLP envelope against drift with golden-hex assertion in `detached_p256_cosigner_is_attached_to_composite_signature`: raw_len=2563, primary_only_len=2495, raw_hash=0x8c6ef4e5…. Any change to field ordering, scheme-byte placement, or cosigner layout flips the constants and fails the regression. - Rewrite `README.md` Quantum-first (mirroring tempo-foundry layout): intro, Installation, and Changeset listing `cast send --quantum`, `cast quantum {bootstrap,add-key,remove-key,update-key-auth}`, `cast call` lifecycle rejection, `forge create --quantum`, and `forge script` paths. Upstream Foundry README preserved below. - Rename `ci-tempo.yml` → `ci-quantum.yml` and rebuild job structure: `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`, Quantum-scoped package tests (`-p foundry-common -p foundry-primitives -p foundry-cli`), and Quantum fixture regression (`generated_fixture_matches_checked_in_phase0_example`, `detached_p256_cosigner_is_attached_to_composite_signature`). Inline comment documents why the broader workspace test is intentionally not gated (pre-existing upstream tests hit external networks). Retains QUANTUM_FOUNDRY_BASE_COMMIT and QUANTUM_HARNESS_COMMIT env metadata. - Register `ci-quantum.yml` and `README.md` in `docs/dev/quantum-adapter-touchpoints.md`. - Fix pre-existing `include_str!` path (`../../../` → `../../../../`) that pointed at `crates/testdata/` instead of repo-root `testdata/`; add missing `use alloy_network::ReceiptResponse as _;`; cluster of clippy fixes surfaced by the first-time `-D warnings` gate (const fn, use_self, redundant_clone, bool::then, format! inline args, unwrap-after-is_none pattern, too_many_arguments). No behavior changes. --- .github/workflows/ci-quantum.yml | 59 ++++++++ .github/workflows/ci-tempo.yml | 138 ------------------ README.md | 60 ++++++++ crates/cast/src/cmd/call.rs | 7 +- crates/cast/src/cmd/quantum.rs | 22 +-- crates/cast/src/cmd/send.rs | 30 +--- crates/cli/src/opts/quantum.rs | 7 +- crates/common/src/transactions/quantum.rs | 73 +++++---- .../src/transactions/quantum_lifecycle.rs | 3 +- crates/forge/src/cmd/create.rs | 16 +- crates/primitives/src/network/quantum.rs | 21 +-- crates/primitives/src/transaction/envelope.rs | 2 +- crates/wallets/src/wallet_multi/mod.rs | 2 + docs/dev/quantum-adapter-touchpoints.md | 6 +- 14 files changed, 205 insertions(+), 241 deletions(-) create mode 100644 .github/workflows/ci-quantum.yml delete mode 100644 .github/workflows/ci-tempo.yml diff --git a/.github/workflows/ci-quantum.yml b/.github/workflows/ci-quantum.yml new file mode 100644 index 0000000000000..18b924d94ce20 --- /dev/null +++ b/.github/workflows/ci-quantum.yml @@ -0,0 +1,59 @@ +name: CI Quantum + +permissions: {} + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUSTC_WRAPPER: "sccache" + QUANTUM_FOUNDRY_BASE_COMMIT: "f1abb2ca347187bb6dea8c3881ca44ce50aab1e7" + QUANTUM_HARNESS_COMMIT: "8f3612c60f9fa66ea3a09eab99a2e0802f373673" + +jobs: + quantum-check: + runs-on: depot-ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # master + with: + toolchain: nightly + components: rustfmt, clippy + + - uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9 + - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2 + + - name: Format check + run: cargo fmt --all --check + + - name: Clippy + run: cargo clippy --workspace --all-targets -- -D warnings + + # Quantum-scoped package tests lock the adapter, envelope, and lifecycle + # contracts. The broader `cargo test --workspace` target is intentionally + # not gated in CI because it inherits upstream Foundry tests that hit + # external networks (Etherscan, live mainnet RPC, flaky fork endpoints) + # and fail in isolated CI environments regardless of this fork's changes. + - name: Quantum package tests + run: cargo test -p foundry-common -p foundry-primitives -p foundry-cli + + # Targeted Quantum regression: pinned golden fixture and composite RLP envelope. + - name: Quantum fixture regression + run: | + cargo test -p foundry-common --lib \ + transactions::quantum::tests::generated_fixture_matches_checked_in_phase0_example \ + transactions::quantum::tests::detached_p256_cosigner_is_attached_to_composite_signature \ + -- --exact diff --git a/.github/workflows/ci-tempo.yml b/.github/workflows/ci-tempo.yml deleted file mode 100644 index c12a69edc0d0d..0000000000000 --- a/.github/workflows/ci-tempo.yml +++ /dev/null @@ -1,138 +0,0 @@ -name: CI Tempo - -permissions: {} - -on: - push: - branches: [master] - pull_request: - workflow_dispatch: - inputs: - network: - description: "Tempo network to check" - required: true - type: choice - options: - - testnet - - devnet - - mainnet - - all - scripts: - description: "Which scripts to run" - required: false - type: choice - default: "both" - options: - - check - - deploy - - both - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -env: - CARGO_TERM_COLOR: always - RUSTC_WRAPPER: "sccache" - QUANTUM_FOUNDRY_BASE_COMMIT: "f1abb2ca347187bb6dea8c3881ca44ce50aab1e7" - QUANTUM_HARNESS_COMMIT: "8f3612c60f9fa66ea3a09eab99a2e0802f373673" - -jobs: - sanity-check: - runs-on: depot-ubuntu-latest - timeout-minutes: 60 - permissions: - contents: read - steps: - # Checkout the repository - - uses: actions/checkout@v6 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # master - with: - toolchain: stable - - - uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9 - - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2 - - # Build and install binaries - - name: Build and install Foundry binaries - run: | - cargo build --profile dev --locked -p forge -p cast -p anvil -p chisel - echo "${{ github.workspace }}/target/debug" >> "$GITHUB_PATH" - - # TODO(upstream): re-enable when mpp is supported https://github.com/foundry-rs/foundry/pull/14192 - # - name: Run MPP e2e test - # if: github.event_name == 'push' || github.event_name == 'pull_request' - # env: - # TEMPO_KEYS_TOML_B64: ${{ secrets.TEMPO_KEYS_TOML_B64 }} - # MPP_API_KEY: ${{ secrets.MPP_API_KEY }} - # MPP_DEPOSIT: "1000000" - # run: | - # if [ -z "${TEMPO_KEYS_TOML_B64:-}" ]; then - # echo "::warning::TEMPO_KEYS_TOML_B64 secret not set, skipping MPP e2e" - # exit 0 - # fi - # mkdir -p ~/.tempo/wallet - # echo "$TEMPO_KEYS_TOML_B64" | tr -d '[:space:]' | base64 -d > ~/.tempo/wallet/keys.toml - # ./.github/scripts/tempo-mpp.sh "$(which cast | xargs dirname)" - - # TODO(upstream): re-enable when flaky devnet faucet is fixed - # - name: Run Tempo check on devnet - # if: | - # github.event_name == 'pull_request' || - # github.event.inputs.network == 'devnet' || - # github.event.inputs.network == 'all' - # env: - # ETH_RPC_URL: ${{ secrets.TEMPO_DEVNET_RPC_URL }} - # SCRIPTS: ${{ github.event.inputs.scripts || 'both' }} - # run: | - # if [ "$SCRIPTS" = "check" ] || [ "$SCRIPTS" = "both" ]; then - # ./.github/scripts/tempo-check.sh - # fi - # if [ "$SCRIPTS" = "deploy" ] || [ "$SCRIPTS" = "both" ]; then - # ./.github/scripts/tempo-deploy.sh - # fi - - # - name: Run Tempo wallet tests on devnet - # if: | - # github.event_name == 'pull_request' || - # github.event.inputs.network == 'devnet' || - # github.event.inputs.network == 'all' - # env: - # ETH_RPC_URL: ${{ secrets.TEMPO_DEVNET_RPC_URL }} - # run: ./.github/scripts/tempo-wallet.sh - - - name: Run Tempo check on testnet - if: | - github.event.inputs.network == 'testnet' || - github.event.inputs.network == 'all' - env: - ETH_RPC_URL: ${{ secrets.TEMPO_TESTNET_RPC_URL }} - VERIFIER_URL: ${{ secrets.VERIFIER_URL }} - SCRIPTS: ${{ github.event.inputs.scripts || 'both' }} - run: | - if [ "$SCRIPTS" = "check" ] || [ "$SCRIPTS" = "both" ]; then - ./.github/scripts/tempo-check.sh - fi - if [ "$SCRIPTS" = "deploy" ] || [ "$SCRIPTS" = "both" ]; then - ./.github/scripts/tempo-deploy.sh - fi - - - name: Run Tempo check on mainnet - if: | - github.event.inputs.network == 'mainnet' || - github.event.inputs.network == 'all' - env: - ETH_RPC_URL: ${{ secrets.TEMPO_MAINNET_RPC_URL }} - TEMPO_FEE_TOKEN: "0x20c0000000000000000000000000000000000000" - VERIFIER_URL: ${{ secrets.VERIFIER_URL }} - PRIVATE_KEY: ${{ secrets.THROW_AWAY_MAINNET_PKEY }} - SCRIPTS: ${{ github.event.inputs.scripts || 'both' }} - run: | - if [ "$SCRIPTS" = "check" ] || [ "$SCRIPTS" = "both" ]; then - ./.github/scripts/tempo-check.sh - fi - if [ "$SCRIPTS" = "deploy" ] || [ "$SCRIPTS" = "both" ]; then - ./.github/scripts/tempo-deploy.sh - fi diff --git a/README.md b/README.md index c9f0a45c57b0a..04c6f10cd0dcc 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,63 @@ +
+
+ +# Quantum Foundry + +Quantum is a post-quantum-ready EVM execution environment with a dedicated native transaction type (`0x7A`), explicit account lanes, and an ML-DSA-44 primary signer with optional detached classical cosigners. + +`Quantum Foundry` is a custom fork of [Foundry](https://github.com/foundry-rs/foundry) that integrates Quantum's native envelope, KeyVault lifecycle UX, and detached-cosigner contract directly into the familiar Foundry developer workflow. + +This fork is a drop-in replacement for upstream Foundry while Quantum-specific features are being stabilized; it tracks Foundry commit `f1abb2ca347187bb6dea8c3881ca44ce50aab1e7` and the Quantum harness commit `8f3612c60f9fa66ea3a09eab99a2e0802f373673`. See [`docs/dev/quantum-phase0-implementation-note.md`](./docs/dev/quantum-phase0-implementation-note.md) for the frozen RPC, signer, and ABI contract. + +## Installation + +Build from source in this repository: + +```sh +cargo build --release -p forge -p cast -p anvil -p chisel +``` + +The `target/release` binaries are drop-in replacements for upstream `forge`, `cast`, `anvil`, and `chisel`. + +## Changeset + +Key Quantum extensions on top of upstream Foundry: + +- In `cast send`: + - `--quantum`: opt into the explicit Quantum adapter path (selection is explicit in v1, not inferred from chain ID). + - `--quantum.sender
`: explicit Quantum account-lane address. Quantum writes never auto-derive the sender from the signing key. + - `--quantum.key-id `: account-lane key ID; defaults to `0` for ordinary v1 flows. + - `--quantum.primary-seed-file `: canonical v1 ML-DSA-44 signer seed (single 32-byte hex seed, with or without `0x`). + - `--quantum.cosigner-artifact `: optional detached v1 cosigner artifact JSON; schemes `p256` and `ecdsa` are supported and the artifact's `signing_hash` must match the fork-computed Quantum signing hash byte-for-byte. + - KeyVault lifecycle selectors (`bootstrapKey`, `addKey`, `removeKey`, `updateKeyAuth`) are rejected by the `cast send` pre-build guard and must be submitted through `cast quantum` instead. + +- In `cast quantum` (new subcommand group for KeyVault lifecycle UX): + - `cast quantum bootstrap`: primary-only `bootstrapKey()` through the shared `0x7A` signing pipeline. + - `cast quantum add-key`: `addKey(...)` with `--auth-key-id` (signer lane) distinct from `--target-key-id` (entry being added) so the two lanes cannot be confused. + - `cast quantum remove-key`: `removeKey(uint32)`. + - `cast quantum update-key-auth`: `updateKeyAuth(...)` with the same auth-lane / target-lane separation. + - All lifecycle writes auto-apply the fixed `QUANTUM_LIFECYCLE_GAS_FLOOR` (2,100,000) because validator-published transient state cannot be reproduced by `eth_estimateGas`. + +- In `cast call`: + - Fails closed on KeyVault lifecycle selectors with the frozen `QUANTUM_CALL_LIFECYCLE_REJECTION_MESSAGE`, preserving ordinary read paths on standard RPC simulation. + +- In `forge create`: + - `--quantum` and the `--quantum.*` flags route CREATE through the shared Quantum adapter with the same explicit sender / key-id / seed / cosigner contract as `cast send`. + +- In `forge script`: + - Scripted broadcast routes through the shared Quantum adapter for supported write shapes and fails closed on unsupported Quantum script shapes with a stable rejection message. + +- Additionally: + - A shared `QuantumWriteRequestV1` write contract with fail-closed v1 validation: `nonce_key` must be `0`, multi-call bundles are rejected, and lifecycle-selector misuse is caught before signing. + - Detached cosigner artifact v1 (`version = 1`, `scheme`, `signing_hash`, `public_key`, `signature`) with composite-signature RLP layout using scheme bytes `0x01` (ML-DSA), `0x02` (P256), `0x03` (ECDSA). + - KeyVault lifecycle calldata builders derived from a shared `sol!` interface whose selectors are asserted byte-for-byte against the Phase 0 frozen constants. + - A pinned golden fixture (`testdata/fixtures/quantum/phase0/raw-send-primary.json`) that locks the raw `0x7A` envelope bytes end-to-end. + +See [`docs/dev/quantum-adapter-touchpoints.md`](./docs/dev/quantum-adapter-touchpoints.md) for the full manifest of Quantum-modified files. + +
+
+
Foundry banner diff --git a/crates/cast/src/cmd/call.rs b/crates/cast/src/cmd/call.rs index acc47289024fb..208d41f1ea881 100644 --- a/crates/cast/src/cmd/call.rs +++ b/crates/cast/src/cmd/call.rs @@ -518,11 +518,8 @@ impl CallArgs { 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 - } + (trimmed.len() >= 8 && trimmed.chars().all(|c| c.is_ascii_hexdigit())) + .then_some(trimmed) }) && let Ok(bytes) = hex::decode(hex_body) { return Ok(Some(bytes)); diff --git a/crates/cast/src/cmd/quantum.rs b/crates/cast/src/cmd/quantum.rs index 867aa2945058f..51214bb6bd17f 100644 --- a/crates/cast/src/cmd/quantum.rs +++ b/crates/cast/src/cmd/quantum.rs @@ -265,14 +265,16 @@ async fn submit_lifecycle( // 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(), - )); + match common.tx.quantum.sender { + None => common.tx.quantum.sender = Some(common.sender), + Some(quantum_sender) if quantum_sender != common.sender => { + return Err(eyre!( + "--sender and --quantum.sender must match; got {} and {}", + common.sender, + quantum_sender, + )); + } + Some(_) => {} } if common.tx.quantum.primary_seed_file.is_none() { common.tx.quantum.primary_seed_file = Some(common.primary_seed_file.clone()); @@ -398,9 +400,7 @@ mod tests { "--target-key-id", "2", ]); - let QuantumSubcommand::RemoveKey(r) = args.command else { - panic!("expected remove-key") - }; + let QuantumSubcommand::RemoveKey(r) = args.command else { panic!("expected remove-key") }; assert_eq!(r.target_key_id, 2); } diff --git a/crates/cast/src/cmd/send.rs b/crates/cast/src/cmd/send.rs index c2f0c98e61355..8b2627e64b260 100644 --- a/crates/cast/src/cmd/send.rs +++ b/crates/cast/src/cmd/send.rs @@ -13,10 +13,11 @@ use foundry_common::{ DetachedCosigner, FoundryTransactionBuilder, QUANTUM_ADD_KEY_SELECTOR, QUANTUM_BOOTSTRAP_SELECTOR, QUANTUM_KEYVAULT_ADDRESS, QUANTUM_LIFECYCLE_GAS_FLOOR, QUANTUM_REMOVE_KEY_SELECTOR, QUANTUM_SEND_LIFECYCLE_REJECTION_MESSAGE, - QUANTUM_UPDATE_KEY_AUTH_SELECTOR, derive_primary_pubkey, parse_seed_file, - sign_quantum_transaction_request_with_cosigner, + QUANTUM_UPDATE_KEY_AUTH_SELECTOR, derive_primary_pubkey, fmt::{UIfmt, UIfmtReceiptExt}, + parse_seed_file, provider::ProviderBuilder, + sign_quantum_transaction_request_with_cosigner, }; use foundry_primitives::QuantumNetwork; use foundry_wallets::{TempoAccessKeyConfig, WalletSigner}; @@ -113,18 +114,8 @@ impl SendTxArgs { } async fn run_quantum(self) -> Result<()> { - let Self { - to, - mut sig, - args, - data, - send_tx, - command, - unlocked, - force: _, - mut tx, - path, - } = self; + let Self { to, mut sig, args, data, send_tx, command, unlocked, force: _, mut tx, path } = + self; if unlocked { return Err(eyre!("the Quantum adapter path does not support --unlocked")); @@ -200,11 +191,8 @@ impl SendTxArgs { .await?; let (tx_request, _) = builder.build(sender).await?; - let payload = sign_quantum_transaction_request_with_cosigner( - tx_request, - primary_seed, - cosigner, - )?; + let payload = + sign_quantum_transaction_request_with_cosigner(tx_request, primary_seed, cosigner)?; let timeout = send_tx.timeout.unwrap_or(config.transaction_timeout); let cast = CastTxSender::new(&provider); @@ -442,9 +430,7 @@ fn validate_quantum_sender(cli_from: Option
, quantum_sender: Address) - if let Some(from) = cli_from && from != quantum_sender { - eyre::bail!( - "--from must match --quantum.sender when using the Quantum adapter path" - ) + eyre::bail!("--from must match --quantum.sender when using the Quantum adapter path") } Ok(()) diff --git a/crates/cli/src/opts/quantum.rs b/crates/cli/src/opts/quantum.rs index d25ee01de713f..3030e0f126ff3 100644 --- a/crates/cli/src/opts/quantum.rs +++ b/crates/cli/src/opts/quantum.rs @@ -73,7 +73,7 @@ pub struct QuantumOpts { impl QuantumOpts { /// Returns `true` if any Quantum-specific option is set. - pub fn is_quantum(&self) -> bool { + pub const fn is_quantum(&self) -> bool { self.enabled || self.sender.is_some() || self.key_id.is_some() @@ -145,6 +145,9 @@ mod tests { assert_eq!(opts.primary_seed_file.as_deref(), Some(std::path::Path::new("./seed.hex"))); assert_eq!(opts.init_primary_pubkey, Some(Bytes::from(vec![0x01, 0x02, 0x03]))); assert_eq!(opts.init_cosigner_pubkey, Some(Bytes::from(vec![0x04, 0x05]))); - assert_eq!(opts.cosigner_artifact.as_deref(), Some(std::path::Path::new("./cosigner.json"))); + assert_eq!( + opts.cosigner_artifact.as_deref(), + Some(std::path::Path::new("./cosigner.json")) + ); } } diff --git a/crates/common/src/transactions/quantum.rs b/crates/common/src/transactions/quantum.rs index 1b2cbe9d2f4df..3a6de879955a1 100644 --- a/crates/common/src/transactions/quantum.rs +++ b/crates/common/src/transactions/quantum.rs @@ -8,8 +8,7 @@ use ml_dsa::{KeyGen, MlDsa44, signature::Keypair}; use serde::{Deserialize, Serialize}; use crate::{ - QUANTUM_BOOTSTRAP_SELECTOR, QUANTUM_KEYVAULT_ADDRESS, QUANTUM_TX_TYPE_ID, - QuantumWriteRequestV1, + QUANTUM_BOOTSTRAP_SELECTOR, QUANTUM_KEYVAULT_ADDRESS, QUANTUM_TX_TYPE_ID, QuantumWriteRequestV1, }; use foundry_primitives::QuantumTransactionRequest; @@ -30,13 +29,11 @@ pub const PHASE0_TX_SPAMMER_EVIDENCE_COMMIT: &str = "2c25f14a44b8cc88fc41a65f521 pub const QUANTUM_ADD_KEY_SELECTOR: [u8; 4] = [0x32, 0xbc, 0x29, 0x19]; 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"; +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"; +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. /// @@ -46,11 +43,8 @@ pub const QUANTUM_CALL_LIFECYCLE_REJECTION_MESSAGE: &str = /// in `quantum-eth2/bin/send-tx/src/main.rs`. pub const QUANTUM_LIFECYCLE_GAS_FLOOR: u64 = 2_100_000; -pub const QUANTUM_SEND_UNSUPPORTED_LIFECYCLE_SELECTORS: [[u8; 4]; 3] = [ - QUANTUM_ADD_KEY_SELECTOR, - QUANTUM_REMOVE_KEY_SELECTOR, - QUANTUM_UPDATE_KEY_AUTH_SELECTOR, -]; +pub const QUANTUM_SEND_UNSUPPORTED_LIFECYCLE_SELECTORS: [[u8; 4]; 3] = + [QUANTUM_ADD_KEY_SELECTOR, QUANTUM_REMOVE_KEY_SELECTOR, 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, @@ -89,7 +83,7 @@ pub enum DetachedCosignerScheme { } impl DetachedCosignerScheme { - pub fn as_str(self) -> &'static str { + pub const fn as_str(self) -> &'static str { match self { Self::P256 => QUANTUM_DETACHED_SCHEME_P256, Self::Ecdsa => QUANTUM_DETACHED_SCHEME_ECDSA, @@ -129,10 +123,7 @@ impl DetachedCosigner { /// Parse and validate a v1 detached artifact loaded from disk. pub fn from_artifact_file(path: &Path) -> Result { let bytes = fs::read(path).map_err(|err| { - eyre::eyre!( - "failed to read Quantum detached artifact `{}`: {err}", - path.display() - ) + eyre::eyre!("failed to read Quantum detached artifact `{}`: {err}", path.display()) })?; Self::from_artifact_json(&bytes) } @@ -180,9 +171,8 @@ impl DetachedCosigner { fn parse_hex_bytes(value: &str, field: &str) -> Result> { let trimmed = value.trim(); let hex = trimmed.strip_prefix("0x").or_else(|| trimmed.strip_prefix("0X")).unwrap_or(trimmed); - alloy_primitives::hex::decode(hex).map_err(|err| { - eyre::eyre!("Quantum detached artifact `{field}` is not valid hex: {err}") - }) + alloy_primitives::hex::decode(hex) + .map_err(|err| eyre::eyre!("Quantum detached artifact `{field}` is not valid hex: {err}")) } fn parse_hex_b256(value: &str, field: &str) -> Result { @@ -399,7 +389,7 @@ impl QuantumSigned { } impl SigningKeySignature { - fn wire_size(&self) -> usize { + const fn wire_size(&self) -> usize { match self { Self::MlDsa44 { .. } => 1 + ML_DSA_SIGNATURE_BYTES, Self::P256 { .. } | Self::Ecdsa { .. } => 1 + DETACHED_CLASSICAL_SIGNATURE_BYTES, @@ -474,7 +464,7 @@ fn option_as_list_length(value: Option<&T>) -> usize { mod tests { use std::path::PathBuf; - use alloy_primitives::U256; + use alloy_primitives::{U256, b256}; use serde_json::Value; use super::*; @@ -613,7 +603,7 @@ mod tests { DetachedArtifactV1 { version: QUANTUM_DETACHED_ARTIFACT_VERSION, scheme: scheme.to_string(), - signing_hash: format!("{:#x}", signing_hash), + signing_hash: format!("{signing_hash:#x}"), public_key: format!("0x{}", alloy_primitives::hex::encode([0x11u8; 33])), signature: format!( "0x{}", @@ -650,14 +640,12 @@ mod tests { let seed = parse_seed_file(&fixture_seed_path()).unwrap(); let request = simple_transfer_request(derive_address_from_seed(seed)); let wrong_hash = B256::repeat_byte(0xaa); - let cosigner = DetachedCosigner::from_artifact(artifact_for( - QUANTUM_DETACHED_SCHEME_P256, - wrong_hash, - )) - .unwrap(); + let cosigner = + DetachedCosigner::from_artifact(artifact_for(QUANTUM_DETACHED_SCHEME_P256, wrong_hash)) + .unwrap(); - let err = sign_quantum_write_request_with_cosigner(request, seed, Some(cosigner)) - .unwrap_err(); + let err = + sign_quantum_write_request_with_cosigner(request, seed, Some(cosigner)).unwrap_err(); assert!(err.to_string().contains("signing_hash does not match")); } @@ -673,12 +661,9 @@ mod tests { signing_hash, )) .unwrap(); - let signed = sign_quantum_write_request_with_cosigner( - request.clone(), - seed, - Some(cosigner.clone()), - ) - .unwrap(); + let signed = + sign_quantum_write_request_with_cosigner(request.clone(), seed, Some(cosigner.clone())) + .unwrap(); let raw = &signed.raw_transaction; assert_eq!(raw[0], QUANTUM_TX_TYPE_ID); @@ -689,6 +674,16 @@ mod tests { let primary_only = sign_quantum_write_request(request, seed).unwrap(); assert!(signed.raw_transaction.len() > primary_only.raw_transaction.len()); + + // Pinned golden values protect the composite RLP envelope against drift: + // any change to field ordering, scheme-byte placement, or cosigner layout + // flips these constants and fails the regression. + assert_eq!(signed.raw_transaction.len(), 2563); + assert_eq!(primary_only.raw_transaction.len(), 2495); + assert_eq!( + keccak256(&signed.raw_transaction), + b256!("8c6ef4e59a3ea673f21c2c7e87e1f02337a77d3aedea6a09862244da7034149a"), + ); } #[test] @@ -707,11 +702,9 @@ mod tests { sign_quantum_write_request_with_cosigner(request, seed, Some(cosigner.clone())) .unwrap(); - assert!(signed - .raw_transaction - .windows(1 + DETACHED_CLASSICAL_SIGNATURE_BYTES) - .any(|window| window[0] == QUANTUM_ECDSA_SCHEME - && window[1..] == cosigner.signature)); + assert!(signed.raw_transaction.windows(1 + DETACHED_CLASSICAL_SIGNATURE_BYTES).any( + |window| { window[0] == QUANTUM_ECDSA_SCHEME && window[1..] == cosigner.signature } + )); } #[test] diff --git a/crates/common/src/transactions/quantum_lifecycle.rs b/crates/common/src/transactions/quantum_lifecycle.rs index 09dd4c16c73f6..0dae460e73e3d 100644 --- a/crates/common/src/transactions/quantum_lifecycle.rs +++ b/crates/common/src/transactions/quantum_lifecycle.rs @@ -145,8 +145,7 @@ mod tests { scope_data: Bytes::from_static(&[0xcc; 8]), }; let calldata = encode_add_key_calldata(&inputs); - let decoded = - KeyVaultLifecycle::addKeyCall::abi_decode_raw(&calldata[4..]).unwrap(); + 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); diff --git a/crates/forge/src/cmd/create.rs b/crates/forge/src/cmd/create.rs index f24f7a58b2bf6..12d620ceb0c96 100644 --- a/crates/forge/src/cmd/create.rs +++ b/crates/forge/src/cmd/create.rs @@ -19,9 +19,9 @@ use foundry_common::{ DetachedCosigner, FoundryTransactionBuilder, compile::{self}, fmt::parse_tokens, - parse_seed_file, sign_quantum_transaction_request_with_cosigner, + parse_seed_file, provider::ProviderBuilder, - shell, + shell, sign_quantum_transaction_request_with_cosigner, }; use foundry_compilers::{ ArtifactId, artifacts::BytecodeObject, info::ContractInfo, utils::canonicalize, @@ -769,9 +769,8 @@ impl CreateArgs { return Ok(()); } - let primary_seed = primary_seed.ok_or_else(|| { - eyre!("--quantum.primary-seed-file is required for Quantum writes") - })?; + let primary_seed = primary_seed + .ok_or_else(|| eyre!("--quantum.primary-seed-file is required for Quantum writes"))?; let raw_tx = sign_quantum_transaction_request_with_cosigner( deployer.tx.clone(), @@ -793,7 +792,8 @@ impl CreateArgs { )); } - let address = receipt.contract_address().ok_or_else(|| eyre!("contract was not deployed"))?; + let address = + receipt.contract_address().ok_or_else(|| eyre!("contract was not deployed"))?; let tx_hash = receipt.transaction_hash(); if shell::is_json() { @@ -886,9 +886,7 @@ fn validate_quantum_sender(cli_from: Option
, quantum_sender: Address) - if let Some(from) = cli_from && from != quantum_sender { - eyre::bail!( - "--from must match --quantum.sender when using the Quantum adapter path" - ) + eyre::bail!("--from must match --quantum.sender when using the Quantum adapter path") } Ok(()) diff --git a/crates/primitives/src/network/quantum.rs b/crates/primitives/src/network/quantum.rs index ed7dc645588fe..eec9cdbf31f1a 100644 --- a/crates/primitives/src/network/quantum.rs +++ b/crates/primitives/src/network/quantum.rs @@ -48,7 +48,7 @@ impl Typed2718 for QuantumTxType { impl From for u8 { fn from(value: QuantumTxType) -> Self { - value as u8 + value as Self } } @@ -114,8 +114,8 @@ impl From for QuantumTransactionRequest { sender: Some(value.sender), key_id: Some(value.key_id), nonce_key: Some(value.nonce_key), - init_primary_pubkey: value.init_primary_pubkey.clone(), - init_cosigner_pubkey: value.init_cosigner_pubkey.clone(), + init_primary_pubkey: value.init_primary_pubkey, + init_cosigner_pubkey: value.init_cosigner_pubkey, } } } @@ -462,19 +462,20 @@ struct QuantumTxEnvelopeSerde { } impl QuantumTxEnvelope { - pub fn sender(&self) -> Address { + pub const fn sender(&self) -> Address { self.sender } - pub fn key_id(&self) -> u32 { + pub const fn key_id(&self) -> u32 { self.key_id } - pub fn nonce_key(&self) -> U256 { + pub const fn nonce_key(&self) -> U256 { self.nonce_key } - pub fn from_signed_parts( + #[allow(clippy::too_many_arguments)] + pub const fn from_signed_parts( chain_id: ChainId, sender: Address, nonce_key: U256, @@ -1029,13 +1030,14 @@ impl RecommendedFillers for QuantumNetwork { #[cfg(test)] mod tests { + use alloy_network::ReceiptResponse as _; use alloy_provider::network::eip2718::Decodable2718 as _; use super::*; fn raw_fixture() -> serde_json::Value { serde_json::from_str(include_str!( - "../../../testdata/fixtures/quantum/phase0/raw-send-primary.json" + "../../../../testdata/fixtures/quantum/phase0/raw-send-primary.json" )) .unwrap() } @@ -1048,7 +1050,8 @@ mod tests { let tx = QuantumTxEnvelope::decode_2718(&mut bytes.as_slice()).unwrap(); assert_eq!(tx.ty(), QUANTUM_TX_TYPE_ID); - assert_eq!(tx.sender(), value["sender"].as_str().unwrap().parse().unwrap()); + let expected_sender: Address = value["sender"].as_str().unwrap().parse().unwrap(); + assert_eq!(tx.sender(), expected_sender); assert_eq!(tx.key_id(), value["key_id"].as_u64().unwrap() as u32); assert_eq!(tx.nonce_key(), U256::ZERO); } diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index e8132b8b7b9f3..3f326186d3d56 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -238,7 +238,7 @@ fn quantum_to_tx_env(tx: &QuantumTxEnvelope, caller: Address) -> TxEnv { data: tx.input().clone(), nonce: tx.nonce(), chain_id: tx.chain_id(), - access_list: tx.access_list().cloned().unwrap_or_default().into(), + access_list: tx.access_list().cloned().unwrap_or_default(), gas_priority_fee: tx.max_priority_fee_per_gas(), ..Default::default() } diff --git a/crates/wallets/src/wallet_multi/mod.rs b/crates/wallets/src/wallet_multi/mod.rs index a6d85581e19f0..2afccc39c4dec 100644 --- a/crates/wallets/src/wallet_multi/mod.rs +++ b/crates/wallets/src/wallet_multi/mod.rs @@ -471,6 +471,7 @@ impl MultiWalletOpts { Ok(None) } + #[allow(clippy::missing_const_for_fn)] pub fn turnkey_signers(&self) -> Result>> { #[cfg(feature = "turnkey")] if self.turnkey { @@ -486,6 +487,7 @@ impl MultiWalletOpts { } /// Returns the Turnkey address if `--turnkey` flag is set and `TURNKEY_ADDRESS` is available. + #[allow(clippy::missing_const_for_fn)] pub fn turnkey_address(&self) -> Option { #[cfg(feature = "turnkey")] if self.turnkey { diff --git a/docs/dev/quantum-adapter-touchpoints.md b/docs/dev/quantum-adapter-touchpoints.md index a1cca969590bc..ea62e7943d30f 100644 --- a/docs/dev/quantum-adapter-touchpoints.md +++ b/docs/dev/quantum-adapter-touchpoints.md @@ -16,8 +16,10 @@ Use this together with `quantum-phase0-implementation-note.md` when widening the These files are already intentionally diverged from upstream as part of the Phase 0 seam spike or the landed Phase 1 adapter work. -- `.github/workflows/ci-tempo.yml` - - carries the frozen Quantum fork base and harness commit in CI metadata +- `.github/workflows/ci-quantum.yml` + - Quantum-named workflow running `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, and a targeted Quantum fixture regression job; carries the frozen Quantum fork base and harness commit in CI metadata +- `README.md` + - Quantum-first project intro on top of the preserved upstream Foundry README, listing the `cast send --quantum`, `cast quantum` lifecycle subcommands, `cast call` lifecycle rejection, `forge create --quantum`, and scripted broadcast changeset - `docs/dev/README.md` - indexes the Quantum implementation note and this touchpoint manifest - `docs/dev/quantum-phase0-implementation-note.md` From f06ab82d91bc310ef59accb7483d419e59e2dfef Mon Sep 17 00:00:00 2001 From: Erhan Acet Date: Fri, 17 Apr 2026 14:08:29 +0300 Subject: [PATCH 02/16] Set logo --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 04c6f10cd0dcc..f9966b84620d0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,18 @@

+

+ + + + tempo combomark + + +

+ +
+
+ # Quantum Foundry Quantum is a post-quantum-ready EVM execution environment with a dedicated native transaction type (`0x7A`), explicit account lanes, and an ML-DSA-44 primary signer with optional detached classical cosigners. From c401b43647da7a8fc83f384d28ed87a4f3575646 Mon Sep 17 00:00:00 2001 From: Erhan Acet Date: Fri, 17 Apr 2026 14:20:23 +0300 Subject: [PATCH 03/16] address PR review comments --- crates/cast/src/cmd/quantum.rs | 15 +++++++++++++++ crates/cast/src/cmd/send.rs | 16 ++++++++++------ crates/primitives/src/network/quantum.rs | 7 +++++-- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/crates/cast/src/cmd/quantum.rs b/crates/cast/src/cmd/quantum.rs index 51214bb6bd17f..def52fa0bd88e 100644 --- a/crates/cast/src/cmd/quantum.rs +++ b/crates/cast/src/cmd/quantum.rs @@ -276,6 +276,17 @@ async fn submit_lifecycle( } Some(_) => {} } + match common.tx.quantum.key_id { + None => common.tx.quantum.key_id = Some(common.auth_key_id), + Some(quantum_key_id) if quantum_key_id != common.auth_key_id => { + return Err(eyre!( + "--auth-key-id and --quantum.key-id must match; got {} and {}", + common.auth_key_id, + quantum_key_id, + )); + } + Some(_) => {} + } if common.tx.quantum.primary_seed_file.is_none() { common.tx.quantum.primary_seed_file = Some(common.primary_seed_file.clone()); } @@ -286,7 +297,11 @@ async fn submit_lifecycle( } let primary_seed = parse_seed_file(&common.primary_seed_file)?; + // Read the merged cosigner path so `--quantum.cosigner-artifact` and + // `--cosigner-artifact` are both honored consistently. let cosigner = common + .tx + .quantum .cosigner_artifact .as_deref() .map(DetachedCosigner::from_artifact_file) diff --git a/crates/cast/src/cmd/send.rs b/crates/cast/src/cmd/send.rs index 8b2627e64b260..5dfccdfbdf185 100644 --- a/crates/cast/src/cmd/send.rs +++ b/crates/cast/src/cmd/send.rs @@ -450,13 +450,17 @@ fn quantum_destination_is_keyvault(to: Option<&NameOrAddress>) -> bool { } } +fn strip_hex_prefix(value: &str) -> &str { + value.strip_prefix("0x").or_else(|| value.strip_prefix("0X")).unwrap_or(value) +} + fn quantum_input_is_bootstrap(input: Option<&str>) -> bool { let Some(input) = input else { return false }; - input.starts_with("bootstrapKey(") - || input - .trim() - .trim_start_matches("0x") - .starts_with(&hex::encode(QUANTUM_BOOTSTRAP_SELECTOR)) + if input.starts_with("bootstrapKey(") { + return true; + } + let hex_body = strip_hex_prefix(input.trim()).to_ascii_lowercase(); + hex_body.starts_with(&hex::encode(QUANTUM_BOOTSTRAP_SELECTOR)) } fn quantum_input_is_unsupported_lifecycle(input: Option<&str>) -> bool { @@ -468,7 +472,7 @@ fn quantum_input_is_unsupported_lifecycle(input: Option<&str>) -> bool { { return true; } - let hex_body = trimmed.trim_start_matches("0x").to_ascii_lowercase(); + let hex_body = strip_hex_prefix(trimmed).to_ascii_lowercase(); let add = hex::encode(QUANTUM_ADD_KEY_SELECTOR); let remove = hex::encode(QUANTUM_REMOVE_KEY_SELECTOR); let update = hex::encode(QUANTUM_UPDATE_KEY_AUTH_SELECTOR); diff --git a/crates/primitives/src/network/quantum.rs b/crates/primitives/src/network/quantum.rs index eec9cdbf31f1a..5cd99d10311f5 100644 --- a/crates/primitives/src/network/quantum.rs +++ b/crates/primitives/src/network/quantum.rs @@ -381,13 +381,16 @@ fn encode_empty_list(out: &mut dyn BufMut) { fn encode_optional_list_bytes(value: Option<&Bytes>, out: &mut dyn BufMut) { match value { - Some(value) => value.as_ref().encode(out), + // Preserve the raw RLP list framing produced by `decode_list_bytes`. + // `<[u8] as Encodable>::encode` would re-wrap with a string header and + // break decode→encode idempotence (changes tx hash). + Some(value) => out.put_slice(value.as_ref()), None => encode_empty_list(out), } } fn optional_list_bytes_length(value: Option<&Bytes>) -> usize { - value.map_or(1, Encodable::length) + value.map_or(1, |v| v.len()) } fn decode_optional_list_bytes(buf: &mut &[u8]) -> alloy_rlp::Result> { From 4ff1cccfd1bf1ee6e2595f88bd6b0d3ccd06ffbd Mon Sep 17 00:00:00 2001 From: Erhan Acet Date: Fri, 17 Apr 2026 14:36:00 +0300 Subject: [PATCH 04/16] address PR review comments (followup: cargo test filter placement, bootstrap cosigner guard) --- .github/workflows/ci-quantum.yml | 8 +++++--- crates/cast/src/cmd/quantum.rs | 5 ++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-quantum.yml b/.github/workflows/ci-quantum.yml index 18b924d94ce20..1a77b24065d59 100644 --- a/.github/workflows/ci-quantum.yml +++ b/.github/workflows/ci-quantum.yml @@ -51,9 +51,11 @@ jobs: run: cargo test -p foundry-common -p foundry-primitives -p foundry-cli # Targeted Quantum regression: pinned golden fixture and composite RLP envelope. + # Multiple filters must follow `--` (cargo test accepts only a single + # positional TESTNAME before `--`; libtest then applies all post-`--` + # filters as an OR under `--exact`). - name: Quantum fixture regression run: | - cargo test -p foundry-common --lib \ + cargo test -p foundry-common --lib -- --exact \ transactions::quantum::tests::generated_fixture_matches_checked_in_phase0_example \ - transactions::quantum::tests::detached_p256_cosigner_is_attached_to_composite_signature \ - -- --exact + transactions::quantum::tests::detached_p256_cosigner_is_attached_to_composite_signature diff --git a/crates/cast/src/cmd/quantum.rs b/crates/cast/src/cmd/quantum.rs index def52fa0bd88e..df2874d6b290e 100644 --- a/crates/cast/src/cmd/quantum.rs +++ b/crates/cast/src/cmd/quantum.rs @@ -183,7 +183,10 @@ impl QuantumArgs { async fn run_bootstrap(args: BootstrapArgs) -> Result<()> { let BootstrapArgs { mut common } = args; - if common.cosigner_artifact.is_some() { + // v1 bootstrap is primary-only: reject cosigner supplied via either the + // lifecycle-specific `--cosigner-artifact` or the shared + // `--quantum.cosigner-artifact` flag. + if common.cosigner_artifact.is_some() || common.tx.quantum.cosigner_artifact.is_some() { return Err(eyre!( "Quantum v1 bootstrap is primary-only; cosigner artifact is not supported" )); From 7036bb3a0aac41034aa3fc5606f6d26ced69da6f Mon Sep 17 00:00:00 2001 From: Erhan Acet Date: Fri, 17 Apr 2026 14:59:27 +0300 Subject: [PATCH 05/16] bump rustls-webpki 0.103.11 -> 0.103.12 to address RUSTSEC-2026-0098/0099 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6aff119ef02c0..5712ef881cb12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9991,9 +9991,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.11" +version = "0.103.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" dependencies = [ "aws-lc-rs", "ring", From c10fe50fbac7117cdc2c2931c1703fdc4143a4c6 Mon Sep 17 00:00:00 2001 From: Erhan Acet Date: Fri, 17 Apr 2026 18:57:14 +0300 Subject: [PATCH 06/16] chore: retrigger CI (depot queue + stale graphite mergeability_check) From 5659686cb8ae2027db16cf10176872625b12ec2a Mon Sep 17 00:00:00 2001 From: Erhan Acet Date: Mon, 20 Apr 2026 10:03:28 +0300 Subject: [PATCH 07/16] address PR review findings: lifecycle fence, auth-list, bootstrap cosigner, value, and call guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses five findings from thoughts/shared/reviews/2026-04-20-foundry-fork-review-findings.md. Finding 1 (High) — `cast send --quantum` lifecycle fence bypass via name/ENS: the pre-check inspected the unresolved NameOrAddress, so a name that resolves to the KeyVault slipped past the "use cast quantum" guard and skipped the bootstrap gas-floor. Destination is now resolved against the provider up front, and the fence + bootstrap gas-floor block observe the resolved Address. Finding 2 (High) — `--auth` silently dropped on Quantum 0x7A: the Quantum envelope does not carry EIP-7702 authorization lists (`QuantumTxEnvelope::authorization_list()` returns None). `cast send --quantum` now rejects non-empty `--auth` with an explicit error instead of building a signed envelope that omits the auth list. Finding 3 (Medium) — bootstrapKey cosigner policy inconsistency: `cast quantum bootstrap` rejects cosigner artifacts but `cast send --quantum` to bootstrapKey() silently attached one, producing an envelope shape the dedicated lifecycle CLI refuses. `cast send --quantum` now rejects `--quantum.cosigner-artifact` when the call is bootstrapKey(), aligning both sanctioned entry points. Finding 4 (Medium) — `cast quantum` lifecycle --value claimed ignored but preserved: help text promised value was ignored, but builder.rs copied value straight into QuantumSingleCall. `submit_lifecycle` now rejects non-zero `--value` explicitly rather than silently forwarding ETH to bootstrapKey()/addKey()/etc. Finding 5 (Medium) — `cast call` guard rejected by selector alone: any unrelated contract whose selector collided with a KeyVault lifecycle selector was blocked on the read path. The guard now requires `to == QUANTUM_KEYVAULT_ADDRESS` in addition to the selector match. Drops `quantum_send_requests_bootstrap` and `quantum_destination_is_keyvault` helpers (no longer needed after the resolution-first rewrite). Adds regression `cast_call_allows_colliding_selector_on_non_keyvault_destination` pinning finding 5. --- crates/cast/src/cmd/call.rs | 35 ++++++++++++++- crates/cast/src/cmd/quantum.rs | 8 ++++ crates/cast/src/cmd/send.rs | 81 ++++++++++++++++++++-------------- 3 files changed, 91 insertions(+), 33 deletions(-) diff --git a/crates/cast/src/cmd/call.rs b/crates/cast/src/cmd/call.rs index 208d41f1ea881..302d3201a4175 100644 --- a/crates/cast/src/cmd/call.rs +++ b/crates/cast/src/cmd/call.rs @@ -20,7 +20,7 @@ use foundry_cli::{ utils::{LoadConfig, TraceResult, parse_ether_value}, }; use foundry_common::{ - FoundryTransactionBuilder, QUANTUM_CALL_LIFECYCLE_REJECTION_MESSAGE, + FoundryTransactionBuilder, QUANTUM_CALL_LIFECYCLE_REJECTION_MESSAGE, QUANTUM_KEYVAULT_ADDRESS, abi::{encode_function_args, get_func}, provider::{ProviderBuilder, curl_transport::generate_curl_command}, quantum_call_is_unsupported_lifecycle_calldata, sh_println, shell, @@ -497,6 +497,14 @@ impl CallArgs { "`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" ); } + // Only reject KeyVault lifecycle selectors when the destination is the + // KeyVault precompile. An unrelated contract with a colliding selector + // must not be blocked. ENS/name destinations are checked via their + // literal address form; a name that resolves to the KeyVault will fail + // naturally at `eth_call`, which is acceptable for the read path. + if !self.destination_is_keyvault() { + return Ok(()); + } let calldata = self.read_path_calldata()?; if let Some(bytes) = calldata && quantum_call_is_unsupported_lifecycle_calldata(&bytes) @@ -506,6 +514,16 @@ impl CallArgs { Ok(()) } + fn destination_is_keyvault(&self) -> bool { + match self.to.as_ref() { + Some(NameOrAddress::Address(addr)) => *addr == QUANTUM_KEYVAULT_ADDRESS, + Some(NameOrAddress::Name(name)) => { + Address::from_str(name).ok() == Some(QUANTUM_KEYVAULT_ADDRESS) + } + None => false, + } + } + /// Decode the `--data`/`sig` input for read-path selector checks. Returns /// `None` when no call input is provided. fn read_path_calldata(&self) -> Result>> { @@ -900,6 +918,21 @@ mod tests { args.reject_quantum_read_path_misuse().expect("ordinary reads must not be rejected"); } + #[test] + fn cast_call_allows_colliding_selector_on_non_keyvault_destination() { + // Unrelated contract with a selector that happens to collide with a + // KeyVault lifecycle selector (0x32bc2919 = addKey) must NOT be + // rejected on the read path when `to` is not the KeyVault address. + let args = CallArgs::parse_from([ + "foundry-cli", + "0xDeaDBeeFcAfEbAbEfAcEfEeDcBaDbEeFcAfEbAbE", + "--data", + "0x32bc2919", + ]); + args.reject_quantum_read_path_misuse() + .expect("colliding selector on non-KeyVault destination 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/quantum.rs b/crates/cast/src/cmd/quantum.rs index df2874d6b290e..8f1603a24048c 100644 --- a/crates/cast/src/cmd/quantum.rs +++ b/crates/cast/src/cmd/quantum.rs @@ -299,6 +299,14 @@ async fn submit_lifecycle( common.tx.quantum.cosigner_artifact = Some(p.clone()); } + // The `cast quantum` help contract says value is ignored for KeyVault + // lifecycle writes. Reject non-zero `--value` explicitly rather than + // silently zero it: forwarding ETH to `bootstrapKey()`/`addKey()`/etc. is + // almost always an operator mistake. + if common.tx.value.is_some_and(|v| !v.is_zero()) { + return Err(eyre!("KeyVault lifecycle writes do not accept `--value`; remove the flag")); + } + let primary_seed = parse_seed_file(&common.primary_seed_file)?; // Read the merged cosigner path so `--quantum.cosigner-artifact` and // `--cosigner-artifact` are both honored consistently. diff --git a/crates/cast/src/cmd/send.rs b/crates/cast/src/cmd/send.rs index 5dfccdfbdf185..ddc209b8f81ba 100644 --- a/crates/cast/src/cmd/send.rs +++ b/crates/cast/src/cmd/send.rs @@ -147,23 +147,61 @@ impl SendTxArgs { })?; let primary_seed = parse_seed_file(seed_path)?; - let cosigner = tx - .quantum - .cosigner_artifact - .as_deref() - .map(DetachedCosigner::from_artifact_file) - .transpose()?; + // Quantum v1 does not carry EIP-7702 authorization lists in the signed + // envelope (`QuantumTxEnvelope::authorization_list()` returns `None`). + // Reject `--auth` explicitly so callers do not believe a 7702 auth is + // being broadcast when it would be silently dropped. + if !tx.auth.is_empty() { + return Err(eyre!( + "the Quantum adapter path does not support EIP-7702 `--auth`; the v1 envelope does not carry authorization lists" + )); + } + + 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)); + } + + // Resolve the destination up front so the lifecycle fence and bootstrap + // gas-floor block observe the true destination address, not an + // unresolved name/ENS target. A name that resolves to the KeyVault + // would otherwise slip past the literal-address check below. + let resolved_to = match to { + Some(to) => Some(to.resolve(&provider).await?), + None => None, + }; // Fail closed before any RPC simulation: ordinary `cast send` must not accept // unsupported KeyVault lifecycle selectors (addKey / removeKey / updateKeyAuth). // Only `bootstrapKey()` is supported from this path in v1. - if quantum_destination_is_keyvault(to.as_ref()) - && quantum_input_is_unsupported_lifecycle(sig.as_deref()) - { + let destination_is_keyvault = resolved_to == Some(QUANTUM_KEYVAULT_ADDRESS); + if destination_is_keyvault && quantum_input_is_unsupported_lifecycle(sig.as_deref()) { return Err(eyre!(QUANTUM_SEND_LIFECYCLE_REJECTION_MESSAGE)); } - if quantum_send_requests_bootstrap(to.as_ref(), sig.as_deref()) { + let is_bootstrap = destination_is_keyvault && quantum_input_is_bootstrap(sig.as_deref()); + + // v1 bootstrap is primary-only: `cast quantum bootstrap` rejects any + // cosigner artifact, and `cast send --quantum` must enforce the same + // invariant so both sanctioned entry points produce the same envelope + // shape. Cosigner attachment is a signature-side artifact, so this + // cannot be caught by `QuantumWriteRequestV1::validate_v1`. + if is_bootstrap && tx.quantum.cosigner_artifact.is_some() { + return Err(eyre!( + "Quantum v1 bootstrap is primary-only; cosigner artifact is not supported" + )); + } + + let cosigner = tx + .quantum + .cosigner_artifact + .as_deref() + .map(DetachedCosigner::from_artifact_file) + .transpose()?; + + if is_bootstrap { if tx.quantum.init_primary_pubkey.is_none() { tx.quantum.init_primary_pubkey = Some(derive_primary_pubkey(primary_seed)); } @@ -176,16 +214,9 @@ impl SendTxArgs { } } - 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) + .with_to(resolved_to.map(NameOrAddress::Address)) .await? .with_code_sig_and_args(None, sig, args) .await?; @@ -436,20 +467,6 @@ fn validate_quantum_sender(cli_from: Option
, quantum_sender: Address) - Ok(()) } -fn quantum_send_requests_bootstrap(to: Option<&NameOrAddress>, input: Option<&str>) -> bool { - quantum_destination_is_keyvault(to) && quantum_input_is_bootstrap(input) -} - -fn quantum_destination_is_keyvault(to: Option<&NameOrAddress>) -> bool { - match to { - Some(NameOrAddress::Address(addr)) => *addr == QUANTUM_KEYVAULT_ADDRESS, - Some(NameOrAddress::Name(name)) => { - Address::from_str(name).ok() == Some(QUANTUM_KEYVAULT_ADDRESS) - } - None => false, - } -} - fn strip_hex_prefix(value: &str) -> &str { value.strip_prefix("0x").or_else(|| value.strip_prefix("0X")).unwrap_or(value) } From 0ea6e2d1950b0a3fffb06b46fbf4d4dcf1d24305 Mon Sep 17 00:00:00 2001 From: Erhan Acet Date: Mon, 20 Apr 2026 12:09:12 +0300 Subject: [PATCH 08/16] address review findings from 2026-04-20 foundry fork review - envelope round-trip corruption: store semantic pubkey bytes in QuantumTxEnvelope so sign-verify-resubmit does not double-wrap init_primary_pubkey / init_cosigner_pubkey through the list-string framing - cast send bare-name bypass: reject bootstrapKey / unsupported lifecycle names even without '(...)' or '0x' selector prefix - cast call bare-name bypass: apply the same bare-name rejection on the read path - cast quantum: verify caller-supplied init_primary_pubkey matches derived pubkey - cast quantum: enforce --from vs --sender consistency before broadcast - forge create --quantum: explicitly reject --auth since the 0x7a envelope does not carry 7702 authorization lists - CI: include cast in the quantum package test gate --- .github/workflows/ci-quantum.yml | 2 +- crates/cast/src/cmd/call.rs | 45 +++++++++++++-- crates/cast/src/cmd/quantum.rs | 39 ++++++++++++- crates/cast/src/cmd/send.rs | 48 ++++++++++++++-- crates/forge/src/cmd/create.rs | 9 +++ crates/primitives/src/network/quantum.rs | 73 ++++++++++++++++++++++-- 6 files changed, 195 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci-quantum.yml b/.github/workflows/ci-quantum.yml index 1a77b24065d59..7e2632a641e29 100644 --- a/.github/workflows/ci-quantum.yml +++ b/.github/workflows/ci-quantum.yml @@ -48,7 +48,7 @@ jobs: # external networks (Etherscan, live mainnet RPC, flaky fork endpoints) # and fail in isolated CI environments regardless of this fork's changes. - name: Quantum package tests - run: cargo test -p foundry-common -p foundry-primitives -p foundry-cli + run: cargo test -p foundry-common -p foundry-primitives -p foundry-cli -p cast # Targeted Quantum regression: pinned golden fixture and composite RLP envelope. # Multiple filters must follow `--` (cargo test accepts only a single diff --git a/crates/cast/src/cmd/call.rs b/crates/cast/src/cmd/call.rs index 302d3201a4175..384a047512960 100644 --- a/crates/cast/src/cmd/call.rs +++ b/crates/cast/src/cmd/call.rs @@ -217,15 +217,18 @@ pub enum CallSubcommands { impl CallArgs { pub async fn run(self) -> Result<()> { - // Handle --curl mode early, before any provider interaction - 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`. + // instead of letting them reach `eth_call`. Run the guard before the + // `--curl` branch so curl-mode emission cannot bypass the fail-closed + // policy. The guard is pure local validation and does not contact the + // provider. self.reject_quantum_read_path_misuse()?; + // Handle --curl mode early, before any provider interaction + if self.rpc.curl { + return self.run_curl().await; + } if self.tx.tempo.is_tempo() { self.run_with_network::().await } else { @@ -505,6 +508,15 @@ impl CallArgs { if !self.destination_is_keyvault() { return Ok(()); } + // Bare function names (no parentheses, no hex) are resolved later via + // Etherscan in `parse_function_args`. Catch lifecycle names locally so + // they cannot bypass the deterministic rejection by falling through to + // ABI lookup or `eth_call`. + if let Some(sig) = &self.sig + && quantum_call_sig_is_unsupported_lifecycle_bare_name(sig) + { + eyre::bail!(QUANTUM_CALL_LIFECYCLE_REJECTION_MESSAGE); + } let calldata = self.read_path_calldata()?; if let Some(bytes) = calldata && quantum_call_is_unsupported_lifecycle_calldata(&bytes) @@ -664,6 +676,17 @@ fn address_slot_value_override(address_override: &str) -> Result<(Address, U256, )) } +/// Return `true` when `sig` names a KeyVault lifecycle selector without +/// parentheses. `cast call` would otherwise resolve these bare names through +/// Etherscan in `parse_function_args`, bypassing the deterministic rejection. +fn quantum_call_sig_is_unsupported_lifecycle_bare_name(sig: &str) -> bool { + let trimmed = sig.trim(); + if trimmed.contains('(') { + return false; + } + matches!(trimmed, "bootstrapKey" | "addKey" | "removeKey" | "updateKeyAuth") +} + #[cfg(test)] mod tests { use super::*; @@ -964,4 +987,16 @@ mod tests { assert_eq!(args.tx.value, Some(U256::from(1000000000000000000u64))); assert_eq!(args.tx.blob_gas_price, Some(U256::from(10000000000u64))); } + + #[test] + fn bare_lifecycle_names_match_read_path_rejection() { + assert!(quantum_call_sig_is_unsupported_lifecycle_bare_name("bootstrapKey")); + assert!(quantum_call_sig_is_unsupported_lifecycle_bare_name("addKey")); + assert!(quantum_call_sig_is_unsupported_lifecycle_bare_name("removeKey")); + assert!(quantum_call_sig_is_unsupported_lifecycle_bare_name("updateKeyAuth")); + // Parenthesized signatures are handled by `read_path_calldata`, not here. + assert!(!quantum_call_sig_is_unsupported_lifecycle_bare_name("addKey(uint32)")); + // Unrelated bare names must not be treated as lifecycle calls. + assert!(!quantum_call_sig_is_unsupported_lifecycle_bare_name("balanceOf")); + } } diff --git a/crates/cast/src/cmd/quantum.rs b/crates/cast/src/cmd/quantum.rs index 8f1603a24048c..7c0aa725d6f7a 100644 --- a/crates/cast/src/cmd/quantum.rs +++ b/crates/cast/src/cmd/quantum.rs @@ -198,10 +198,19 @@ async fn run_bootstrap(args: BootstrapArgs) -> Result<()> { } // Populate the bootstrap `init_primary_pubkey` field if the caller did not - // provide one. Mirrors `cast send` bootstrap behavior. + // provide one. Mirrors `cast send` bootstrap behavior. If the caller did + // provide one, it must match the key derived from the signing seed — a + // mismatch would initialize a key the caller cannot sign with. 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)); + let derived = derive_primary_pubkey(primary_seed); + match common.tx.quantum.init_primary_pubkey.as_ref() { + None => common.tx.quantum.init_primary_pubkey = Some(derived), + Some(provided) if provided != &derived => { + return Err(eyre!( + "--quantum.init-primary-pubkey does not match the public key derived from --primary-seed-file; omit the flag to auto-fill" + )); + } + Some(_) => {} } submit_lifecycle(common, encode_bootstrap_calldata(), true).await @@ -265,6 +274,21 @@ async fn submit_lifecycle( calldata: Bytes, is_bootstrap: bool, ) -> Result<()> { + // Fail closed on a mismatched `--from`. `cast send --quantum` and + // `forge create --quantum` both reject a `--from` that disagrees with the + // quantum sender; `cast quantum` must enforce the same invariant so an + // operator cannot think they are acting as one account while the command + // actually signs for another. + if let Some(from) = common.send_tx.eth.wallet.from + && from != common.sender + { + return Err(eyre!( + "--from must match --sender when using the Quantum lifecycle path; got {} and {}", + from, + common.sender, + )); + } + // 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. @@ -307,6 +331,15 @@ async fn submit_lifecycle( return Err(eyre!("KeyVault lifecycle writes do not accept `--value`; remove the flag")); } + // Quantum v1 does not carry EIP-7702 authorization lists in the signed + // 0x7a envelope. Reject `--auth` explicitly so callers do not believe a + // 7702 auth is being broadcast when it would be silently dropped. + if !common.tx.auth.is_empty() { + return Err(eyre!( + "the Quantum adapter path does not support EIP-7702 `--auth`; the v1 envelope does not carry authorization lists" + )); + } + let primary_seed = parse_seed_file(&common.primary_seed_file)?; // Read the merged cosigner path so `--quantum.cosigner-artifact` and // `--cosigner-artifact` are both honored consistently. diff --git a/crates/cast/src/cmd/send.rs b/crates/cast/src/cmd/send.rs index ddc209b8f81ba..5fdcdb53eb214 100644 --- a/crates/cast/src/cmd/send.rs +++ b/crates/cast/src/cmd/send.rs @@ -183,6 +183,16 @@ impl SendTxArgs { let is_bootstrap = destination_is_keyvault && quantum_input_is_bootstrap(sig.as_deref()); + // `bootstrapKey()` is non-payable: forwarding ETH to it is an operator + // mistake. The dedicated `cast quantum bootstrap` UX rejects `--value` + // for all lifecycle writes; mirror the invariant here so both sanctioned + // entry points behave the same for the bootstrap selector. + if is_bootstrap && tx.value.is_some_and(|v| !v.is_zero()) { + return Err(eyre!( + "Quantum bootstrap does not accept `--value`; bootstrapKey() is non-payable" + )); + } + // v1 bootstrap is primary-only: `cast quantum bootstrap` rejects any // cosigner artifact, and `cast send --quantum` must enforce the same // invariant so both sanctioned entry points produce the same envelope @@ -471,9 +481,19 @@ fn strip_hex_prefix(value: &str) -> &str { value.strip_prefix("0x").or_else(|| value.strip_prefix("0X")).unwrap_or(value) } +/// Return the portion of `sig` before the first `(`, with surrounding +/// whitespace stripped. Used to match bare function names (no parentheses) the +/// same way signatures like `bootstrapKey()` are matched — callers like +/// `parse_function_args` fall through to Etherscan lookup for bare names, so +/// the lifecycle fence must recognize them locally to avoid being bypassed. +fn function_name_prefix(sig: &str) -> &str { + let trimmed = sig.trim(); + trimmed.split_once('(').map_or(trimmed, |(name, _)| name.trim()) +} + fn quantum_input_is_bootstrap(input: Option<&str>) -> bool { let Some(input) = input else { return false }; - if input.starts_with("bootstrapKey(") { + if function_name_prefix(input) == "bootstrapKey" { return true; } let hex_body = strip_hex_prefix(input.trim()).to_ascii_lowercase(); @@ -483,10 +503,7 @@ fn quantum_input_is_bootstrap(input: Option<&str>) -> bool { fn quantum_input_is_unsupported_lifecycle(input: Option<&str>) -> bool { let Some(input) = input else { return false }; let trimmed = input.trim(); - if trimmed.starts_with("addKey(") - || trimmed.starts_with("removeKey(") - || trimmed.starts_with("updateKeyAuth(") - { + if matches!(function_name_prefix(trimmed), "addKey" | "removeKey" | "updateKeyAuth") { return true; } let hex_body = strip_hex_prefix(trimmed).to_ascii_lowercase(); @@ -527,10 +544,29 @@ where mod tests { use clap::CommandFactory; - use super::SendTxArgs; + use super::{SendTxArgs, quantum_input_is_bootstrap, quantum_input_is_unsupported_lifecycle}; #[test] fn send_command_clap_shape_is_valid() { SendTxArgs::command().debug_assert(); } + + #[test] + fn bare_lifecycle_names_trip_the_fence() { + assert!(quantum_input_is_bootstrap(Some("bootstrapKey"))); + assert!(quantum_input_is_unsupported_lifecycle(Some("addKey"))); + assert!(quantum_input_is_unsupported_lifecycle(Some("removeKey"))); + assert!(quantum_input_is_unsupported_lifecycle(Some("updateKeyAuth"))); + // Unrelated bare names must not be treated as lifecycle calls. + assert!(!quantum_input_is_bootstrap(Some("transfer"))); + assert!(!quantum_input_is_unsupported_lifecycle(Some("transfer"))); + } + + #[test] + fn parenthesized_lifecycle_names_still_trip_the_fence() { + assert!(quantum_input_is_bootstrap(Some("bootstrapKey()"))); + assert!(quantum_input_is_unsupported_lifecycle(Some( + "addKey(uint32,bytes,uint8,bytes,uint8,uint8,bytes)" + ))); + } } diff --git a/crates/forge/src/cmd/create.rs b/crates/forge/src/cmd/create.rs index 12d620ceb0c96..7638718b80039 100644 --- a/crates/forge/src/cmd/create.rs +++ b/crates/forge/src/cmd/create.rs @@ -136,6 +136,15 @@ impl CreateArgs { .ok_or_else(|| eyre!("--quantum.sender is required for Quantum writes"))?; validate_quantum_sender(self.eth.wallet.from, sender)?; + // Quantum v1 does not carry EIP-7702 authorization lists in the signed + // 0x7a envelope. Reject `--auth` explicitly so callers do not believe a + // 7702 auth is being broadcast when it would be silently dropped. + if !self.tx.auth.is_empty() { + return Err(eyre!( + "the Quantum adapter path does not support EIP-7702 `--auth`; the v1 envelope does not carry authorization lists" + )); + } + let primary_seed = if self.broadcast { let path = self.tx.quantum.primary_seed_file.as_ref().ok_or_else(|| { eyre!("--quantum.primary-seed-file is required for Quantum writes") diff --git a/crates/primitives/src/network/quantum.rs b/crates/primitives/src/network/quantum.rs index 5cd99d10311f5..74794996ff050 100644 --- a/crates/primitives/src/network/quantum.rs +++ b/crates/primitives/src/network/quantum.rs @@ -411,6 +411,51 @@ fn decode_list_bytes(buf: &mut &[u8]) -> alloy_rlp::Result { Ok(raw) } +/// Encode an optional pubkey as `list(string(bytes))`, matching the signing-path +/// encoding in `QuantumWriteRequestV1`. Paired with `decode_option_pubkey_list`, +/// which strips the outer list + inner string framing so stored bytes carry +/// pubkey payload only. +fn encode_option_pubkey_list(value: Option<&Bytes>, out: &mut dyn BufMut) { + match value { + Some(value) => { + let payload_length = value.length(); + RlpHeader { list: true, payload_length }.encode(out); + value.encode(out); + } + None => encode_empty_list(out), + } +} + +fn option_pubkey_list_length(value: Option<&Bytes>) -> usize { + match value { + Some(value) => { + let payload_length = value.length(); + alloy_rlp::length_of_length(payload_length) + payload_length + } + None => 1, + } +} + +fn decode_option_pubkey_list(buf: &mut &[u8]) -> alloy_rlp::Result> { + let header = RlpHeader::decode(buf)?; + if !header.list { + return Err(alloy_rlp::Error::UnexpectedString); + } + if header.payload_length == 0 { + return Ok(None); + } + let start_len = buf.len(); + let pubkey = Bytes::decode(buf)?; + let consumed = start_len - buf.len(); + if consumed != header.payload_length { + return Err(alloy_rlp::Error::ListLengthMismatch { + expected: header.payload_length, + got: consumed, + }); + } + Ok(Some(pubkey)) +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct QuantumTxEnvelope { chain_id: ChainId, @@ -528,8 +573,8 @@ impl QuantumTxEnvelope { + self.access_list.length() + 1 + 1 - + optional_list_bytes_length(self.init_primary_pubkey.as_ref()) - + optional_list_bytes_length(self.init_cosigner_pubkey.as_ref()) + + option_pubkey_list_length(self.init_primary_pubkey.as_ref()) + + option_pubkey_list_length(self.init_cosigner_pubkey.as_ref()) } fn encode_fields(&self, out: &mut dyn BufMut) { @@ -545,8 +590,8 @@ impl QuantumTxEnvelope { self.access_list.encode(out); encode_empty_list(out); encode_empty_list(out); - encode_optional_list_bytes(self.init_primary_pubkey.as_ref(), out); - encode_optional_list_bytes(self.init_cosigner_pubkey.as_ref(), out); + encode_option_pubkey_list(self.init_primary_pubkey.as_ref(), out); + encode_option_pubkey_list(self.init_cosigner_pubkey.as_ref(), out); } fn encode_inner(&self, out: &mut dyn BufMut) { @@ -582,8 +627,8 @@ impl QuantumTxEnvelope { let access_list = AccessList::decode(buf)?; let _fee_payer = decode_optional_list_bytes(buf)?; let _fee_payer_key_id = decode_optional_list_bytes(buf)?; - let init_primary_pubkey = decode_optional_list_bytes(buf)?; - let init_cosigner_pubkey = decode_optional_list_bytes(buf)?; + let init_primary_pubkey = decode_option_pubkey_list(buf)?; + let init_cosigner_pubkey = decode_option_pubkey_list(buf)?; let sender_sig = decode_list_bytes(buf)?; let fee_payer_sig = decode_optional_list_bytes(buf)?; @@ -1059,6 +1104,22 @@ mod tests { assert_eq!(tx.nonce_key(), U256::ZERO); } + #[test] + fn envelope_bootstrap_pubkey_round_trips_to_request_as_semantic_bytes() { + // Exercise the decoded envelope path by round-tripping the phase-0 + // fixture, which has `init_primary_pubkey = None`, and confirm the + // request view also sees `None` (not the raw RLP empty-list marker). + let fixture = raw_fixture(); + let raw = fixture["raw_transaction"].as_str().unwrap(); + let bytes = alloy_primitives::hex::decode(raw).unwrap(); + let decoded = QuantumTxEnvelope::decode_2718(&mut bytes.as_slice()).unwrap(); + assert!(decoded.init_primary_pubkey.is_none()); + + let request: QuantumTransactionRequest = decoded.into(); + assert!(request.init_primary_pubkey.is_none()); + assert!(request.init_cosigner_pubkey.is_none()); + } + #[test] fn round_trips_quantum_transaction_request_json() { let request = QuantumTransactionRequest { From 8a61f4d04a6e019482263a87cbc9d80f0bf39362 Mon Sep 17 00:00:00 2001 From: Erhan Acet Date: Mon, 20 Apr 2026 13:06:31 +0300 Subject: [PATCH 09/16] address 2026-04-20 foundry fork review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cast send --quantum and shared signer now reject a caller-supplied init_primary_pubkey that does not match the key derived from the signing seed, matching the dedicated lifecycle command. - forge create --quantum rejects the legacy-fee path up front so --legacy or legacy-marked chains fail with a clear invariant instead of failing late in EIP-1559 signing. - Quantum envelope decoder now requires the reserved fee-payer placeholder fields to be empty lists; any non-empty value is rejected so decode→re-encode cannot silently change the tx hash. Co-Authored-By: Claude Opus 4.7 --- crates/cast/src/cmd/send.rs | 15 +++++++-- crates/common/src/transactions/quantum.rs | 39 +++++++++++++++++++++-- crates/forge/src/cmd/create.rs | 23 +++++++------ crates/primitives/src/network/quantum.rs | 39 +++++++++++++++++++++-- 4 files changed, 100 insertions(+), 16 deletions(-) diff --git a/crates/cast/src/cmd/send.rs b/crates/cast/src/cmd/send.rs index 5fdcdb53eb214..2a9a908de2bdc 100644 --- a/crates/cast/src/cmd/send.rs +++ b/crates/cast/src/cmd/send.rs @@ -212,8 +212,19 @@ impl SendTxArgs { .transpose()?; if is_bootstrap { - if tx.quantum.init_primary_pubkey.is_none() { - tx.quantum.init_primary_pubkey = Some(derive_primary_pubkey(primary_seed)); + // Mirror the dedicated `cast quantum bootstrap` invariant: a + // caller-supplied `init_primary_pubkey` must match the key derived + // from the signing seed, otherwise the operator initializes a key + // they cannot sign with. Auto-fill when omitted. + let derived = derive_primary_pubkey(primary_seed); + match tx.quantum.init_primary_pubkey.as_ref() { + None => tx.quantum.init_primary_pubkey = Some(derived), + Some(provided) if provided != &derived => { + return Err(eyre!( + "--quantum.init-primary-pubkey does not match the public key derived from --quantum.primary-seed-file; omit the flag to auto-fill" + )); + } + Some(_) => {} } // Bootstrap/lifecycle calls cannot be simulated via `eth_estimateGas` because // the validator-published bootstrap transient state is absent. Apply the fixed diff --git a/crates/common/src/transactions/quantum.rs b/crates/common/src/transactions/quantum.rs index 3a6de879955a1..e452181f0c72c 100644 --- a/crates/common/src/transactions/quantum.rs +++ b/crates/common/src/transactions/quantum.rs @@ -254,8 +254,20 @@ pub fn sign_quantum_transaction_request_with_cosigner( primary_seed: [u8; ML_DSA_SEED_BYTES], cosigner: Option, ) -> Result { - if tx.init_primary_pubkey.is_none() && quantum_transaction_request_is_bootstrap(&tx) { - tx.init_primary_pubkey = Some(derive_primary_pubkey(primary_seed)); + // For bootstrap writes, a caller-supplied `init_primary_pubkey` must match + // the key derived from the signing seed; otherwise the operator would + // initialize a key they cannot sign with. Auto-fill when omitted. + if quantum_transaction_request_is_bootstrap(&tx) { + let derived = derive_primary_pubkey(primary_seed); + match tx.init_primary_pubkey.as_ref() { + None => tx.init_primary_pubkey = Some(derived), + Some(provided) if provided != &derived => { + bail!( + "Quantum bootstrap init_primary_pubkey does not match the public key derived from the signing seed" + ); + } + Some(_) => {} + } } let request = QuantumWriteRequestV1::from_quantum_transaction_request(&tx)?; @@ -561,6 +573,29 @@ mod tests { assert_eq!(payload.sender, Address::repeat_byte(0x22)); } + #[test] + fn quantum_transaction_request_bootstrap_rejects_mismatched_primary_pubkey() { + let seed = parse_seed_file(&fixture_seed_path()).unwrap(); + let request = QuantumTransactionRequest { + inner: alloy_rpc_types::TransactionRequest::default() + .with_chain_id(1337) + .with_nonce(0) + .with_to(QUANTUM_KEYVAULT_ADDRESS) + .with_gas_limit(21_000) + .with_max_fee_per_gas(1) + .with_max_priority_fee_per_gas(1) + .with_input(Bytes::from(QUANTUM_BOOTSTRAP_SELECTOR.to_vec())), + sender: Some(Address::repeat_byte(0x22)), + key_id: Some(0), + nonce_key: Some(U256::ZERO), + init_primary_pubkey: Some(Bytes::from_static(&[0xAAu8; 32])), + init_cosigner_pubkey: None, + }; + + let err = sign_quantum_transaction_request(request, seed).unwrap_err(); + assert!(err.to_string().contains("init_primary_pubkey"), "unexpected error: {err}"); + } + #[test] fn generated_fixture_is_valid_json_shape() { let fixture = canonical_phase0_fixture(); diff --git a/crates/forge/src/cmd/create.rs b/crates/forge/src/cmd/create.rs index 7638718b80039..602321ad579ad 100644 --- a/crates/forge/src/cmd/create.rs +++ b/crates/forge/src/cmd/create.rs @@ -699,7 +699,13 @@ impl CreateArgs { e } })?; - let is_legacy = self.tx.legacy || Chain::from(chain).is_legacy(); + // Quantum signing requires EIP-1559 fee fields; reject the legacy-fee + // path up front instead of failing late in signing. + if self.tx.legacy || Chain::from(chain).is_legacy() { + eyre::bail!( + "forge create --quantum requires EIP-1559 fees; legacy-fee chains and --legacy are not supported" + ); + } deployer.tx.set_from(deployer_address); deployer.tx.set_chain_id(chain); @@ -707,7 +713,7 @@ impl CreateArgs { deployer.tx.set_create(); } - self.tx.apply::(&mut deployer.tx, is_legacy); + self.tx.apply::(&mut deployer.tx, false); if self.tx.nonce.is_none() { deployer.tx.set_nonce(provider.get_transaction_count(deployer_address).await?); @@ -725,14 +731,11 @@ impl CreateArgs { deployer.tx.set_gas_limit(provider.estimate_gas(deployer.tx.clone()).await?); } - if is_legacy { - if self.tx.gas_price.is_none() { - deployer.tx.set_gas_price(provider.get_gas_price().await?); - } - } else if self.tx.gas_price.is_none() || self.tx.priority_gas_price.is_none() { - let estimate = provider.estimate_eip1559_fees().await.wrap_err( - "Failed to estimate EIP1559 fees. This chain might not support EIP1559, try adding --legacy to your command.", - )?; + if self.tx.gas_price.is_none() || self.tx.priority_gas_price.is_none() { + let estimate = provider + .estimate_eip1559_fees() + .await + .wrap_err("Failed to estimate EIP1559 fees; Quantum requires an EIP-1559 chain")?; if self.tx.priority_gas_price.is_none() { deployer.tx.set_max_priority_fee_per_gas(estimate.max_priority_fee_per_gas); } diff --git a/crates/primitives/src/network/quantum.rs b/crates/primitives/src/network/quantum.rs index 74794996ff050..5349c59dae39a 100644 --- a/crates/primitives/src/network/quantum.rs +++ b/crates/primitives/src/network/quantum.rs @@ -379,6 +379,17 @@ fn encode_empty_list(out: &mut dyn BufMut) { RlpHeader { list: true, payload_length: 0 }.encode(out); } +fn decode_empty_list(buf: &mut &[u8]) -> alloy_rlp::Result<()> { + let header = RlpHeader::decode(buf)?; + if !header.list { + return Err(alloy_rlp::Error::UnexpectedString); + } + if header.payload_length != 0 { + return Err(alloy_rlp::Error::Custom("expected empty list for reserved field")); + } + Ok(()) +} + fn encode_optional_list_bytes(value: Option<&Bytes>, out: &mut dyn BufMut) { match value { // Preserve the raw RLP list framing produced by `decode_list_bytes`. @@ -625,8 +636,11 @@ impl QuantumTxEnvelope { let gas_limit = u64::decode(buf)?; let call = decode_single_call_list(buf)?; let access_list = AccessList::decode(buf)?; - let _fee_payer = decode_optional_list_bytes(buf)?; - let _fee_payer_key_id = decode_optional_list_bytes(buf)?; + // Reserved fee-payer placeholders are always encoded as empty lists. + // Reject non-empty values so decode→re-encode cannot silently change + // the envelope hash. + decode_empty_list(buf)?; + decode_empty_list(buf)?; let init_primary_pubkey = decode_option_pubkey_list(buf)?; let init_cosigner_pubkey = decode_option_pubkey_list(buf)?; let sender_sig = decode_list_bytes(buf)?; @@ -1142,6 +1156,27 @@ mod tests { assert_eq!(decoded, request); } + #[test] + fn decode_empty_list_rejects_non_empty_reserved_placeholder() { + // The canonical encoder writes reserved fee-payer placeholder fields + // as empty lists. `decode_empty_list` must reject any other list so + // decode→re-encode cannot silently change the envelope hash. + let empty = [alloy_rlp::EMPTY_LIST_CODE]; + assert!(decode_empty_list(&mut empty.as_ref()).is_ok()); + + // `list(string(0x80))`: outer list header `0xc1` followed by empty string `0x80`. + let non_empty = [0xc1u8, 0x80u8]; + let err = decode_empty_list(&mut non_empty.as_ref()) + .expect_err("non-empty list must be rejected"); + assert!(format!("{err}").contains("reserved"), "unexpected error: {err}"); + + // A string (non-list) must also be rejected. + let string_bytes = [0x80u8]; + let err = + decode_empty_list(&mut string_bytes.as_ref()).expect_err("string must be rejected"); + assert!(matches!(err, alloy_rlp::Error::UnexpectedString)); + } + #[test] fn deserializes_pq_receipt_type() { let receipt: QuantumTxReceipt = serde_json::from_str( From 6804b0cb458b3b38c15cd1f1637d1831d89f5720 Mon Sep 17 00:00:00 2001 From: Erhan Acet Date: Mon, 20 Apr 2026 13:49:07 +0300 Subject: [PATCH 10/16] =?UTF-8?q?address=202026-04-20=20foundry=20fork=20r?= =?UTF-8?q?eview=20=E2=80=94=20cross-surface=20fences?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from thoughts/shared/reviews/2026-04-20-foundry-fork-review-findings.md: - cast call: resolve name/ENS destination before the KeyVault lifecycle fence so a name that resolves to QUANTUM_KEYVAULT_ADDRESS cannot bypass the fail-closed check and reach eth_call. - cast quantum: reject conflicting --primary-seed-file and --cosigner-artifact vs the shared --quantum.* forms instead of silently preferring one side. - cast quantum: reject --browser, Tempo, blob/EIP-4844, and --legacy flags up front on the lifecycle path; mirrors the guards in cast send --quantum and forge create --quantum. - cast send --quantum: reject --legacy up front; matches the early clear rejection already implemented on forge create --quantum. --- crates/cast/src/cmd/call.rs | 65 +++++++++++++++++++++++++--------- crates/cast/src/cmd/quantum.rs | 56 +++++++++++++++++++++++++---- crates/cast/src/cmd/send.rs | 8 +++++ 3 files changed, 107 insertions(+), 22 deletions(-) diff --git a/crates/cast/src/cmd/call.rs b/crates/cast/src/cmd/call.rs index 384a047512960..f41fe3a1d9cf9 100644 --- a/crates/cast/src/cmd/call.rs +++ b/crates/cast/src/cmd/call.rs @@ -223,8 +223,9 @@ impl CallArgs { // instead of letting them reach `eth_call`. Run the guard before the // `--curl` branch so curl-mode emission cannot bypass the fail-closed // policy. The guard is pure local validation and does not contact the - // provider. - self.reject_quantum_read_path_misuse()?; + // provider. Name/ENS destinations are re-checked against their resolved + // address later in `run_with_network`; `run_curl` rejects names outright. + self.reject_quantum_read_path_misuse(None)?; // Handle --curl mode early, before any provider interaction if self.rpc.curl { return self.run_curl().await; @@ -254,8 +255,20 @@ impl CallArgs { let state_overrides = self.get_state_overrides()?; let block_overrides = self.get_block_overrides()?; + let provider = ProviderBuilder::::from_config(&config)?.build()?; + + // Resolve the destination up front so the lifecycle fence observes the + // true address, not an unresolved name/ENS target that happens to map + // to `QUANTUM_KEYVAULT_ADDRESS`. Without this, a name resolving to the + // KeyVault would bypass the fail-closed check and reach `eth_call`. + let resolved_to = match self.to.clone() { + Some(to) => Some(to.resolve(&provider).await?), + None => None, + }; + self.reject_quantum_read_path_misuse(resolved_to)?; + let Self { - to, + to: _, mut sig, mut args, mut tx, @@ -277,7 +290,6 @@ impl CallArgs { sig = Some(data); } - let provider = ProviderBuilder::::from_config(&config)?.build()?; let sender = SenderKind::from_wallet_opts(wallet).await?; let from = sender.address(); @@ -300,7 +312,7 @@ impl CallArgs { let (tx, func) = CastTxBuilder::new(&provider, tx, &config) .await? - .with_to(to) + .with_to(resolved_to.map(NameOrAddress::Address)) .await? .with_code_sig_and_args(code, sig, args) .await? @@ -494,7 +506,13 @@ impl CallArgs { /// 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<()> { + /// + /// `resolved_to` lets callers pass a post-name-resolution destination so a + /// name that resolves to `QUANTUM_KEYVAULT_ADDRESS` cannot bypass the + /// lifecycle fence by slipping through ENS/Etherscan lookup before + /// `eth_call`. When `None`, only literal-address forms of the destination + /// are checked (suitable for the early, pre-provider guard). + fn reject_quantum_read_path_misuse(&self, resolved_to: Option
) -> 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" @@ -502,10 +520,8 @@ impl CallArgs { } // Only reject KeyVault lifecycle selectors when the destination is the // KeyVault precompile. An unrelated contract with a colliding selector - // must not be blocked. ENS/name destinations are checked via their - // literal address form; a name that resolves to the KeyVault will fail - // naturally at `eth_call`, which is acceptable for the read path. - if !self.destination_is_keyvault() { + // must not be blocked. + if !self.destination_is_keyvault(resolved_to) { return Ok(()); } // Bare function names (no parentheses, no hex) are resolved later via @@ -526,7 +542,10 @@ impl CallArgs { Ok(()) } - fn destination_is_keyvault(&self) -> bool { + fn destination_is_keyvault(&self, resolved_to: Option
) -> bool { + if resolved_to == Some(QUANTUM_KEYVAULT_ADDRESS) { + return true; + } match self.to.as_ref() { Some(NameOrAddress::Address(addr)) => *addr == QUANTUM_KEYVAULT_ADDRESS, Some(NameOrAddress::Name(name)) => { @@ -893,7 +912,7 @@ mod tests { "--data", "0x5e8e7a13", ]); - let err = args.reject_quantum_read_path_misuse().unwrap_err(); + let err = args.reject_quantum_read_path_misuse(None).unwrap_err(); assert!( err.to_string().contains("cannot be simulated via eth_call"), "unexpected error: {err}" @@ -907,7 +926,7 @@ mod tests { "0x0000000000000000000000000000000000001000", "0x32bc2919", ]); - let err = args.reject_quantum_read_path_misuse().unwrap_err(); + let err = args.reject_quantum_read_path_misuse(None).unwrap_err(); assert!( err.to_string().contains("cannot be simulated via eth_call"), "unexpected error: {err}" @@ -923,7 +942,7 @@ mod tests { "balanceOf(address)", "0x000000000000000000000000000000000000dEaD", ]); - let err = args.reject_quantum_read_path_misuse().unwrap_err(); + let err = args.reject_quantum_read_path_misuse(None).unwrap_err(); assert!( err.to_string().contains("does not support Quantum write-only flags"), "unexpected error: {err}" @@ -938,7 +957,7 @@ mod tests { "balanceOf(address)", "0x000000000000000000000000000000000000dEaD", ]); - args.reject_quantum_read_path_misuse().expect("ordinary reads must not be rejected"); + args.reject_quantum_read_path_misuse(None).expect("ordinary reads must not be rejected"); } #[test] @@ -952,10 +971,24 @@ mod tests { "--data", "0x32bc2919", ]); - args.reject_quantum_read_path_misuse() + args.reject_quantum_read_path_misuse(None) .expect("colliding selector on non-KeyVault destination must not be rejected"); } + #[test] + fn cast_call_rejects_resolved_keyvault_name_for_lifecycle_selector() { + // A name destination that resolves to QUANTUM_KEYVAULT_ADDRESS must be + // rejected when the caller passes the resolved address into the fence. + // This protects against name/ENS destinations bypassing the read-path + // guard and reaching `eth_call` with an unsupported lifecycle selector. + let args = CallArgs::parse_from(["foundry-cli", "keyvault.eth", "--data", "0x32bc2919"]); + let err = args.reject_quantum_read_path_misuse(Some(QUANTUM_KEYVAULT_ADDRESS)).unwrap_err(); + assert!( + err.to_string().contains("cannot be simulated via eth_call"), + "unexpected error: {err}" + ); + } + #[test] fn test_transaction_opts_with_trace() { // Test that transaction options are correctly parsed when using --trace diff --git a/crates/cast/src/cmd/quantum.rs b/crates/cast/src/cmd/quantum.rs index 7c0aa725d6f7a..7e4410cc4fa1a 100644 --- a/crates/cast/src/cmd/quantum.rs +++ b/crates/cast/src/cmd/quantum.rs @@ -289,6 +289,29 @@ async fn submit_lifecycle( )); } + // `LifecycleCommonOpts` flattens the full `SendTxOpts`/`TransactionOpts` + // surfaces so every lifecycle subcommand shares the same RPC/wallet flags. + // Flags that the Quantum v1 envelope cannot carry must be rejected up + // front; otherwise the CLI advertises options that are silently dropped. + // Mirrors the guards in `cast send --quantum` (crates/cast/src/cmd/send.rs). + if common.send_tx.browser.browser { + return Err(eyre!("the Quantum lifecycle path does not support browser signing")); + } + if common.tx.tempo.is_tempo() { + return Err(eyre!("Quantum lifecycle and Tempo options cannot be combined")); + } + if common.tx.blob || common.tx.eip4844 || common.tx.blob_gas_price.is_some() { + return Err(eyre!("the Quantum lifecycle path does not support blob transactions")); + } + // Quantum signing requires EIP-1559 fee fields; reject the legacy-fee path + // up front instead of failing late in request construction. Mirrors + // `forge create --quantum` at crates/forge/src/cmd/create.rs. + if common.tx.legacy { + return Err(eyre!( + "the Quantum lifecycle path requires EIP-1559 fees; --legacy is not supported" + )); + } + // 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. @@ -314,13 +337,34 @@ async fn submit_lifecycle( } Some(_) => {} } - if common.tx.quantum.primary_seed_file.is_none() { - common.tx.quantum.primary_seed_file = Some(common.primary_seed_file.clone()); + // Lifecycle-specific flags and the shared `--quantum.*` forms set the same + // underlying signing material. Reject conflicting values explicitly rather + // than silently preferring one side — a divergence in signing inputs is + // almost always an operator mistake, and the two flag families had + // inconsistent precedence that could silently ignore either side. + match common.tx.quantum.primary_seed_file.as_ref() { + None => common.tx.quantum.primary_seed_file = Some(common.primary_seed_file.clone()), + Some(quantum_seed) if quantum_seed != &common.primary_seed_file => { + return Err(eyre!( + "--primary-seed-file and --quantum.primary-seed-file must match; got {} and {}", + common.primary_seed_file.display(), + quantum_seed.display(), + )); + } + Some(_) => {} } - if common.tx.quantum.cosigner_artifact.is_none() - && let Some(ref p) = common.cosigner_artifact - { - common.tx.quantum.cosigner_artifact = Some(p.clone()); + match (common.cosigner_artifact.as_ref(), common.tx.quantum.cosigner_artifact.as_ref()) { + (Some(lifecycle), None) => { + common.tx.quantum.cosigner_artifact = Some(lifecycle.clone()); + } + (Some(lifecycle), Some(quantum)) if lifecycle != quantum => { + return Err(eyre!( + "--cosigner-artifact and --quantum.cosigner-artifact must match; got {} and {}", + lifecycle.display(), + quantum.display(), + )); + } + _ => {} } // The `cast quantum` help contract says value is ignored for KeyVault diff --git a/crates/cast/src/cmd/send.rs b/crates/cast/src/cmd/send.rs index 2a9a908de2bdc..f6e3ca8919a3e 100644 --- a/crates/cast/src/cmd/send.rs +++ b/crates/cast/src/cmd/send.rs @@ -132,6 +132,14 @@ impl SendTxArgs { if path.is_some() { return Err(eyre!("the Quantum adapter path does not support blob data")); } + // Quantum signing requires EIP-1559 fee fields; reject the legacy-fee + // path up front instead of failing late in request construction. + // Mirrors `forge create --quantum` at crates/forge/src/cmd/create.rs. + if tx.legacy { + return Err(eyre!( + "the Quantum adapter path requires EIP-1559 fees; --legacy is not supported" + )); + } if let Some(data) = data { sig = Some(data); } From 8483d7daca21f2a846765371d993158fd46b3ecf Mon Sep 17 00:00:00 2001 From: Erhan Acet Date: Mon, 20 Apr 2026 14:45:09 +0300 Subject: [PATCH 11/16] reject blob flags on all Quantum write paths cast send --quantum and forge create --quantum applied --blob / --eip4844 / --blob-gas-price through the shared TransactionOpts, but the Quantum transaction builder leaves blob setters on default no-op implementations, so those flags were silently dropped rather than producing a deterministic rejection. Mirrors the cast quantum lifecycle guard. --- crates/cast/src/cmd/send.rs | 7 +++++++ crates/forge/src/cmd/create.rs | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/crates/cast/src/cmd/send.rs b/crates/cast/src/cmd/send.rs index f6e3ca8919a3e..f538c7a671483 100644 --- a/crates/cast/src/cmd/send.rs +++ b/crates/cast/src/cmd/send.rs @@ -132,6 +132,13 @@ impl SendTxArgs { if path.is_some() { return Err(eyre!("the Quantum adapter path does not support blob data")); } + // Blob flags are applied through the shared `TransactionOpts`, but the + // Quantum transaction builder leaves blob setters on their default + // no-op implementations, so these flags would be silently dropped + // rather than encoded into the 0x7A envelope. + if tx.blob || tx.eip4844 || tx.blob_gas_price.is_some() { + return Err(eyre!("the Quantum adapter path does not support blob transactions")); + } // Quantum signing requires EIP-1559 fee fields; reject the legacy-fee // path up front instead of failing late in request construction. // Mirrors `forge create --quantum` at crates/forge/src/cmd/create.rs. diff --git a/crates/forge/src/cmd/create.rs b/crates/forge/src/cmd/create.rs index 602321ad579ad..8103786be85f5 100644 --- a/crates/forge/src/cmd/create.rs +++ b/crates/forge/src/cmd/create.rs @@ -128,6 +128,13 @@ impl CreateArgs { if self.tx.tempo.is_tempo() { return Err(eyre!("Quantum and Tempo options cannot be combined")); } + // Blob flags are applied through the shared `TransactionOpts`, but the + // Quantum transaction builder leaves blob setters on their default + // no-op implementations, so these flags would be silently dropped + // rather than encoded into the 0x7A envelope. + if self.tx.blob || self.tx.eip4844 || self.tx.blob_gas_price.is_some() { + return Err(eyre!("the Quantum adapter path does not support blob transactions")); + } let sender = self .tx From 33e3e71287ccdeb4672567e6986e62c0fff4b03e Mon Sep 17 00:00:00 2001 From: Erhan Acet Date: Mon, 20 Apr 2026 15:44:14 +0300 Subject: [PATCH 12/16] document Quantum recover_signer semantics and reject empty sender_sig ML-DSA signatures are not recoverable; authoritative verification requires the sender's registered pubkey from KeyVault state, which only the Quantum node can resolve. `SignerRecoverable` for `QuantumTxEnvelope` therefore returns the declared sender, matching the precedent set by non-ECDSA envelopes like `OpTxEnvelope::Deposit`. Add a comment block that makes the rationale explicit, plus a cheap structural guard that rejects envelopes with an empty `sender_sig` so trivially malformed inputs cannot masquerade as recovered signers on consumers such as `cast decode_raw_transaction` and Anvil mempool admission. Cover the guard with a focused unit test. Co-Authored-By: Claude Opus 4.7 --- crates/primitives/src/network/quantum.rs | 44 ++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/crates/primitives/src/network/quantum.rs b/crates/primitives/src/network/quantum.rs index 5349c59dae39a..b2835efe56915 100644 --- a/crates/primitives/src/network/quantum.rs +++ b/crates/primitives/src/network/quantum.rs @@ -887,12 +887,27 @@ impl Decodable2718 for QuantumTxEnvelope { } } +// ML-DSA signatures are not recoverable the way secp256k1 is: verification +// requires the sender's registered post-quantum pubkey, which lives in the +// Quantum KeyVault on-chain state and is only reachable from the execution +// node. The envelope therefore carries `sender` explicitly and signature +// verification is performed at execution time by the node. These impls +// return the declared sender — matching the upstream precedent for other +// non-ECDSA envelopes such as `OpTxEnvelope::Deposit` — after a cheap +// structural check that `sender_sig` is non-empty, which rejects trivially +// malformed envelopes without pretending to do cryptographic verification. impl SignerRecoverable for QuantumTxEnvelope { fn recover_signer(&self) -> Result { + if self.sender_sig.is_empty() { + return Err(alloy_consensus::crypto::RecoveryError::new()); + } Ok(self.sender) } fn recover_signer_unchecked(&self) -> Result { + if self.sender_sig.is_empty() { + return Err(alloy_consensus::crypto::RecoveryError::new()); + } Ok(self.sender) } } @@ -1118,6 +1133,35 @@ mod tests { assert_eq!(tx.nonce_key(), U256::ZERO); } + #[test] + fn recover_signer_rejects_empty_sender_sig() { + // ML-DSA signatures are not recoverable, so `recover_signer` returns + // the declared sender — but it must refuse envelopes whose sender + // signature payload is structurally empty. Authoritative verification + // still happens at execution by the Quantum node via KeyVault state. + let envelope = QuantumTxEnvelope::from_signed_parts( + 1337, + Address::repeat_byte(0x11), + U256::ZERO, + 0, + 0, + 1, + 1, + 21_000, + TxKind::Call(Address::repeat_byte(0x22)), + U256::ZERO, + Bytes::new(), + AccessList::default(), + None, + None, + Bytes::new(), + None, + ); + + assert!(envelope.recover_signer().is_err()); + assert!(envelope.recover_signer_unchecked().is_err()); + } + #[test] fn envelope_bootstrap_pubkey_round_trips_to_request_as_semantic_bytes() { // Exercise the decoded envelope path by round-tripping the phase-0 From ef038249f2f9fc85ed334f6baa94f79bbc4de9ef Mon Sep 17 00:00:00 2001 From: Erhan Acet Date: Tue, 21 Apr 2026 09:36:44 +0300 Subject: [PATCH 13/16] docs(foundryup): publish quantum install path and devnet endpoints Add a quantum-foundry install flow that coexists with upstream Foundry: foundryup and install scripts default to ~/.foundry-quantum/ (not ~/.foundry/), foundryup -U self-updates from multivmlabs/quantum-foundry, and --network quantum points at the fork's GitHub Releases. Extend the binary-download fast-path to accept multivmlabs/quantum-foundry and tempoxyz/tempo-foundry as known release repos. Document the install path in the root README and foundryup README, add a Devnet section with the public RPC, chain ID, and explorer, and demote "build from source" to a collapsible for contributors and unsupported platforms. Co-Authored-By: Claude Opus 4.7 --- README.md | 29 ++++++++++++++++++++++++++++- foundryup/README.md | 11 +++++++++++ foundryup/foundryup | 30 +++++++++++++++++++----------- foundryup/install | 15 ++++++++++----- 4 files changed, 68 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index f9966b84620d0..251920a55e41f 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,15 @@ This fork is a drop-in replacement for upstream Foundry while Quantum-specific f ## Installation -Build from source in this repository: +```sh +curl -L https://raw.githubusercontent.com/multivmlabs/quantum-foundry/HEAD/foundryup/install | bash +foundryup --network quantum +``` + +This installs the Quantum-enabled `forge`, `cast`, `anvil`, and `chisel` from this fork's GitHub Releases into `~/.foundry-quantum/bin/`. The separate directory means quantum-foundry coexists with an existing upstream Foundry install at `~/.foundry/` — neither installer overwrites the other's binaries. If both are on your `PATH`, the one listed earlier wins for commands like `forge` and `cast`. + +
+Building from source (contributors and unsupported platforms) ```sh cargo build --release -p forge -p cast -p anvil -p chisel @@ -31,6 +39,25 @@ cargo build --release -p forge -p cast -p anvil -p chisel The `target/release` binaries are drop-in replacements for upstream `forge`, `cast`, `anvil`, and `chisel`. +
+ +## Devnet + +Quantum's public devnet is available for early testing. **Note: the devnet is unstable — state may be wiped, chain ID may change, and downtime should be expected. A public testnet is targeted for mid-2026.** + +| Property | Value | +| ------------------ | ------------------------------------- | +| **Network Name** | Quantum Devnet | +| **Chain ID** | `1337` | +| **HTTP URL** | `https://devnet2.rpc.quantum.systems` | +| **Block Explorer** | `https://quantumscan.org/` | + +Example: + +```sh +cast block-number --rpc-url https://devnet2.rpc.quantum.systems +``` + ## Changeset Key Quantum extensions on top of upstream Foundry: diff --git a/foundryup/README.md b/foundryup/README.md index 8f4a6139b86ef..cc68dfc246552 100644 --- a/foundryup/README.md +++ b/foundryup/README.md @@ -10,6 +10,17 @@ Update or revert to a specific Foundry branch with ease. curl -L https://foundry.paradigm.xyz | bash ``` +### Installing for the Quantum network + +To install `foundryup` from this fork (required to run `foundryup --network quantum`): + +```sh +curl -L https://raw.githubusercontent.com/multivmlabs/quantum-foundry/HEAD/foundryup/install | bash +foundryup --network quantum +``` + +Quantum-foundry installs into `~/.foundry-quantum/` (not `~/.foundry/`) so it can coexist with an existing upstream Foundry install without overwriting its `foundryup`, `forge`, `cast`, `anvil`, or `chisel` binaries. If both are on your `PATH`, the directory listed earlier wins. + ## Usage To install the **nightly** version: diff --git a/foundryup/foundryup b/foundryup/foundryup index 5aadb284917bb..876f4a3bde6b8 100755 --- a/foundryup/foundryup +++ b/foundryup/foundryup @@ -6,11 +6,11 @@ set -eo pipefail FOUNDRYUP_INSTALLER_VERSION="1.6.1" BASE_DIR=${XDG_CONFIG_HOME:-$HOME} -FOUNDRY_DIR=${FOUNDRY_DIR:-"$BASE_DIR/.foundry"} +FOUNDRY_DIR=${FOUNDRY_DIR:-"$BASE_DIR/.foundry-quantum"} FOUNDRY_VERSIONS_DIR="$FOUNDRY_DIR/versions" FOUNDRY_BIN_DIR="$FOUNDRY_DIR/bin" FOUNDRY_MAN_DIR="$FOUNDRY_DIR/share/man/man1" -FOUNDRY_BIN_URL="https://raw.githubusercontent.com/foundry-rs/foundry/HEAD/foundryup/foundryup" +FOUNDRY_BIN_URL="https://raw.githubusercontent.com/multivmlabs/quantum-foundry/HEAD/foundryup/foundryup" FOUNDRY_BIN_PATH="$FOUNDRY_BIN_DIR/foundryup" FOUNDRYUP_JOBS="" FOUNDRYUP_IGNORE_VERIFICATION=false @@ -102,16 +102,24 @@ main() { exit 0 fi - # If Tempo network is set, use the Tempo fork of Foundry - if [[ "$FOUNDRYUP_NETWORK" == "tempo" ]]; then - FOUNDRYUP_REPO="tempoxyz/tempo-foundry" - else - # Default to Foundry repo - FOUNDRYUP_REPO=${FOUNDRYUP_REPO:-foundry-rs/foundry} - fi + # Select the Foundry distribution repo based on the target network. + case "$FOUNDRYUP_NETWORK" in + quantum) + FOUNDRYUP_REPO="multivmlabs/quantum-foundry" + ;; + tempo) + FOUNDRYUP_REPO="tempoxyz/tempo-foundry" + ;; + *) + FOUNDRYUP_REPO=${FOUNDRYUP_REPO:-foundry-rs/foundry} + ;; + esac # Install by downloading binaries - if [[ "$FOUNDRYUP_REPO" == "foundry-rs/foundry" && -z "$FOUNDRYUP_BRANCH" && -z "$FOUNDRYUP_COMMIT" ]]; then + if [[ ( "$FOUNDRYUP_REPO" == "foundry-rs/foundry" \ + || "$FOUNDRYUP_REPO" == "tempoxyz/tempo-foundry" \ + || "$FOUNDRYUP_REPO" == "multivmlabs/quantum-foundry" ) \ + && -z "$FOUNDRYUP_BRANCH" && -z "$FOUNDRYUP_COMMIT" ]]; then FOUNDRYUP_VERSION=${FOUNDRYUP_VERSION:-stable} FOUNDRYUP_TAG=$FOUNDRYUP_VERSION @@ -456,7 +464,7 @@ OPTIONS: -p, --path Build and install a local repository -j, --jobs Number of CPUs to use for building Foundry (default: all CPUs) -f, --force Skip SHA verification for downloaded binaries (INSECURE - use with caution) - -n, --network Install binaries for a specific network (e.g., tempo) + -n, --network Install binaries for a specific network (e.g., quantum, tempo) --arch Install a specific architecture (supports amd64 and arm64) --platform Install a specific platform (supports win32, linux, darwin and alpine) EOF diff --git a/foundryup/install b/foundryup/install index cd5bd1125a89f..5a8e970f8c56f 100755 --- a/foundryup/install +++ b/foundryup/install @@ -1,14 +1,16 @@ #!/usr/bin/env bash set -eo pipefail -echo "Installing foundryup..." +echo "Installing foundryup (quantum-foundry)..." +# Quantum-foundry installs into its own directory so it can coexist with an +# existing upstream Foundry install at ~/.foundry without overwriting binaries. BASE_DIR="${XDG_CONFIG_HOME:-$HOME}" -FOUNDRY_DIR="${FOUNDRY_DIR:-"$BASE_DIR/.foundry"}" +FOUNDRY_DIR="${FOUNDRY_DIR:-"$BASE_DIR/.foundry-quantum"}" FOUNDRY_BIN_DIR="$FOUNDRY_DIR/bin" FOUNDRY_MAN_DIR="$FOUNDRY_DIR/share/man/man1" -BIN_URL="https://raw.githubusercontent.com/foundry-rs/foundry/HEAD/foundryup/foundryup" +BIN_URL="https://raw.githubusercontent.com/multivmlabs/quantum-foundry/HEAD/foundryup/foundryup" BIN_PATH="$FOUNDRY_BIN_DIR/foundryup" # Create the .foundry bin directory and foundryup binary if it doesn't exist. @@ -59,6 +61,9 @@ if [[ "$OSTYPE" =~ ^darwin ]] && [[ ! -f /usr/local/opt/libusb/lib/libusb-1.0.0. fi echo -echo "Detected your preferred shell is $PREF_SHELL and added foundryup to PATH." +echo "Detected your preferred shell is $PREF_SHELL and added $FOUNDRY_BIN_DIR to PATH." echo "Run 'source $PROFILE' or start a new terminal session to use foundryup." -echo "Then, simply run 'foundryup' to install Foundry." +echo "Then run 'foundryup --network quantum' to install quantum-foundry's forge, cast, anvil, and chisel into $FOUNDRY_BIN_DIR." +echo +echo "Note: quantum-foundry installs into $FOUNDRY_DIR so it does not overwrite an existing upstream Foundry install at ~/.foundry." +echo "If both upstream and quantum Foundry are on your PATH, the one listed first wins for commands like 'forge' and 'cast'." From 3d6dbdab07846a6c282da4bd590f0ebf242e6e8b Mon Sep 17 00:00:00 2001 From: Erhan Acet Date: Tue, 21 Apr 2026 13:41:32 +0300 Subject: [PATCH 14/16] chore(deps): bump thin-vec to 0.2.16 and drop stale bincode advisory ignore Fixes `deny / cargo deny check` on PR #5: - GHSA-xphw-cqx3-667j: thin-vec 0.2.15 has a double-drop on panic during `IntoIter`/`clear`; bump to 0.2.16 via `cargo update`. - RUSTSEC-2025-0141: bincode is no longer in the dependency tree, so the ignore entry now fails `advisory-not-detected`. Removed. --- Cargo.lock | 4 ++-- deny.toml | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5712ef881cb12..ab10f0c148d0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11471,9 +11471,9 @@ dependencies = [ [[package]] name = "thin-vec" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da322882471314edc77fa5232c587bcb87c9df52bfd0d7d4826f8868ead61899" +checksum = "259cdf8ed4e4aca6f1e9d011e10bd53f524a2d0637d7b28450f6c64ac298c4c6" [[package]] name = "thiserror" diff --git a/deny.toml b/deny.toml index a78f212fc8720..8607a0c9e6641 100644 --- a/deny.toml +++ b/deny.toml @@ -7,8 +7,6 @@ yanked = "warn" ignore = [ # https://rustsec.org/advisories/RUSTSEC-2024-0436 paste! is unmaintained "RUSTSEC-2024-0436", - # https://rustsec.org/advisories/RUSTSEC-2025-0141 bincode is unmaintained - "RUSTSEC-2025-0141", # https://rustsec.org/advisories/RUSTSEC-2026-0097 rand is unsound with a custom logger "RUSTSEC-2026-0097", ] From 1ac1f2c42dceea802685598b581ba8fc87f38698 Mon Sep 17 00:00:00 2001 From: Erhan Acet Date: Tue, 28 Apr 2026 09:39:33 +0300 Subject: [PATCH 15/16] address PR review comments --- crates/primitives/src/network/quantum.rs | 74 +++++++++++++++++++++++- 1 file changed, 71 insertions(+), 3 deletions(-) diff --git a/crates/primitives/src/network/quantum.rs b/crates/primitives/src/network/quantum.rs index b2835efe56915..aa153971b96d1 100644 --- a/crates/primitives/src/network/quantum.rs +++ b/crates/primitives/src/network/quantum.rs @@ -612,8 +612,14 @@ impl QuantumTxEnvelope { } fn inner_length(&self) -> usize { + // `sender_sig` is stored as the raw RLP list bytes produced by + // `decode_list_bytes`, and `encode_inner` writes it back unchanged via + // `put_slice`. Use the raw byte length here so the outer list header + // matches what is actually emitted; `Bytes::length()` would re-add an + // RLP string header and break decode→encode byte stability (and the + // resulting tx hash). self.encoded_fields_length() - + self.sender_sig.length() + + self.sender_sig.len() + optional_list_bytes_length(self.fee_payer_sig.as_ref()) } @@ -644,6 +650,15 @@ impl QuantumTxEnvelope { let init_primary_pubkey = decode_option_pubkey_list(buf)?; let init_cosigner_pubkey = decode_option_pubkey_list(buf)?; let sender_sig = decode_list_bytes(buf)?; + // `decode_list_bytes` returns the raw outer-list framing, so an empty + // RLP list (`0xc0`) is stored as `Bytes([0xc0])` rather than empty + // bytes. The composite signature payload is non-empty by construction, + // so reject the empty-list marker here — otherwise the structural + // `is_empty()` guard in `recover_signer` would let a structurally + // empty signature through. + if sender_sig.len() == 1 && sender_sig[0] == alloy_rlp::EMPTY_LIST_CODE { + return Err(alloy_rlp::Error::Custom("sender_sig must be non-empty")); + } let fee_payer_sig = decode_optional_list_bytes(buf)?; Ok(Self::from_signed_parts( @@ -898,14 +913,18 @@ impl Decodable2718 for QuantumTxEnvelope { // malformed envelopes without pretending to do cryptographic verification. impl SignerRecoverable for QuantumTxEnvelope { fn recover_signer(&self) -> Result { - if self.sender_sig.is_empty() { + if self.sender_sig.is_empty() + || (self.sender_sig.len() == 1 && self.sender_sig[0] == alloy_rlp::EMPTY_LIST_CODE) + { return Err(alloy_consensus::crypto::RecoveryError::new()); } Ok(self.sender) } fn recover_signer_unchecked(&self) -> Result { - if self.sender_sig.is_empty() { + if self.sender_sig.is_empty() + || (self.sender_sig.len() == 1 && self.sender_sig[0] == alloy_rlp::EMPTY_LIST_CODE) + { return Err(alloy_consensus::crypto::RecoveryError::new()); } Ok(self.sender) @@ -1133,6 +1152,26 @@ mod tests { assert_eq!(tx.nonce_key(), U256::ZERO); } + #[test] + fn phase0_raw_transaction_round_trips_byte_for_byte() { + // Decode → re-encode must reproduce the exact wire bytes (and hash) so + // the envelope's outer list length is consistent with its body. This + // is the regression for the `inner_length` vs `encode_inner` mismatch + // on `sender_sig` framing. + let value = raw_fixture(); + let raw = value["raw_transaction"].as_str().unwrap(); + let expected_bytes = alloy_primitives::hex::decode(raw).unwrap(); + let expected_hash: B256 = value["raw_transaction_hash"].as_str().unwrap().parse().unwrap(); + + let tx = QuantumTxEnvelope::decode_2718(&mut expected_bytes.as_slice()).unwrap(); + + let mut reencoded = Vec::with_capacity(expected_bytes.len()); + tx.encode_2718(&mut reencoded); + assert_eq!(reencoded, expected_bytes, "decode→encode is not byte-stable"); + assert_eq!(keccak256(&reencoded), expected_hash); + assert_eq!(*tx.tx_hash(), expected_hash); + } + #[test] fn recover_signer_rejects_empty_sender_sig() { // ML-DSA signatures are not recoverable, so `recover_signer` returns @@ -1160,6 +1199,35 @@ mod tests { assert!(envelope.recover_signer().is_err()); assert!(envelope.recover_signer_unchecked().is_err()); + + let mut envelope = envelope; + envelope.sender_sig = Bytes::from_static(&[alloy_rlp::EMPTY_LIST_CODE]); + assert!(envelope.recover_signer().is_err()); + assert!(envelope.recover_signer_unchecked().is_err()); + } + + #[test] + fn decode_rejects_empty_list_sender_sig() { + // `decode_list_bytes` returns the raw outer-list framing, so a wire + // envelope carrying `sender_sig = 0xc0` (empty RLP list) would land + // in the field as `Bytes([0xc0])` and bypass the `is_empty()` guard + // in `recover_signer`. Decode must reject the empty-list marker so + // a structurally empty signature cannot reach the recovery path. + let mut fixture_envelope = { + let bytes = + alloy_primitives::hex::decode(raw_fixture()["raw_transaction"].as_str().unwrap()) + .unwrap(); + QuantumTxEnvelope::decode_2718(&mut bytes.as_slice()).unwrap() + }; + fixture_envelope.sender_sig = Bytes::from_static(&[alloy_rlp::EMPTY_LIST_CODE]); + fixture_envelope.hash = OnceLock::new(); + + let mut buf = Vec::new(); + fixture_envelope.encode_2718(&mut buf); + + let err = QuantumTxEnvelope::decode_2718(&mut buf.as_slice()) + .expect_err("decode must reject empty-list sender_sig"); + assert!(matches!(err, Eip2718Error::RlpError(_)), "unexpected error: {err}"); } #[test] From e1c9ba4a8826d507f5d36ffce8d6d423c0c4d7eb Mon Sep 17 00:00:00 2001 From: Erhan Acet Date: Tue, 28 Apr 2026 09:55:35 +0300 Subject: [PATCH 16/16] bump rustls-webpki 0.103.12 -> 0.103.13 to address RUSTSEC-2026-0104 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ab10f0c148d0c..2e99519799591 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9991,9 +9991,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring",