diff --git a/crates/cast/src/cmd/call.rs b/crates/cast/src/cmd/call.rs
index acc47289024fb..f41fe3a1d9cf9 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,
@@ -217,15 +217,19 @@ pub enum CallSubcommands {
impl CallArgs {
pub async fn run(self) -> Result<()> {
+ // `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`. 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. 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;
}
- // `cast call` is a read path and must stay on ordinary RPC simulation.
- // Quantum write-only options (`--quantum*`) and KeyVault lifecycle
- // selectors cannot be simulated; reject them with an actionable error
- // instead of letting them reach `eth_call`.
- self.reject_quantum_read_path_misuse()?;
if self.tx.tempo.is_tempo() {
self.run_with_network::
().await
} else {
@@ -251,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,
@@ -274,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();
@@ -297,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?
@@ -491,12 +506,33 @@ 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"
);
}
+ // Only reject KeyVault lifecycle selectors when the destination is the
+ // KeyVault precompile. An unrelated contract with a colliding selector
+ // must not be blocked.
+ if !self.destination_is_keyvault(resolved_to) {
+ 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)
@@ -506,6 +542,19 @@ impl CallArgs {
Ok(())
}
+ 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)) => {
+ 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>> {
@@ -518,11 +567,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));
@@ -649,6 +695,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::*;
@@ -855,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}"
@@ -869,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}"
@@ -885,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}"
@@ -900,7 +957,36 @@ 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]
+ 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(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]
@@ -934,4 +1020,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 867aa2945058f..7e4410cc4fa1a 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"
));
@@ -195,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
@@ -262,29 +274,122 @@ async fn submit_lifecycle(
calldata: Bytes,
is_bootstrap: bool,
) -> Result<()> {
- // Set the quantum sender on the shared TransactionOpts so the wallet glue
- // finds it. The sender is the account being mutated, on whose behalf the
- // ML-DSA signer produces the primary signature.
- if common.tx.quantum.sender.is_none() {
- common.tx.quantum.sender = Some(common.sender);
- } else if common.tx.quantum.sender != Some(common.sender) {
+ // 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!(
- "--sender and --quantum.sender must match; got {} and {}",
+ "--from must match --sender when using the Quantum lifecycle path; got {} and {}",
+ from,
common.sender,
- common.tx.quantum.sender.unwrap(),
));
}
- if common.tx.quantum.primary_seed_file.is_none() {
- common.tx.quantum.primary_seed_file = Some(common.primary_seed_file.clone());
+
+ // `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.quantum.cosigner_artifact.is_none()
- && let Some(ref p) = common.cosigner_artifact
- {
- common.tx.quantum.cosigner_artifact = Some(p.clone());
+ 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.
+ 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(_) => {}
+ }
+ 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(_) => {}
+ }
+ // 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(_) => {}
+ }
+ 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
+ // 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"));
+ }
+
+ // 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.
let cosigner = common
+ .tx
+ .quantum
.cosigner_artifact
.as_deref()
.map(DetachedCosigner::from_artifact_file)
@@ -398,9 +503,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..f538c7a671483 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"));
@@ -141,6 +132,21 @@ 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.
+ 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);
}
@@ -156,25 +162,84 @@ 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()) {
- if tx.quantum.init_primary_pubkey.is_none() {
- tx.quantum.init_primary_pubkey = Some(derive_primary_pubkey(primary_seed));
+ 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
+ // 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 {
+ // 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
@@ -185,26 +250,16 @@ 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?;
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,47 +497,42 @@ 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(())
}
-fn quantum_send_requests_bootstrap(to: Option<&NameOrAddress>, input: Option<&str>) -> bool {
- quantum_destination_is_keyvault(to) && quantum_input_is_bootstrap(input)
+fn strip_hex_prefix(value: &str) -> &str {
+ value.strip_prefix("0x").or_else(|| value.strip_prefix("0X")).unwrap_or(value)
}
-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,
- }
+/// 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 };
- input.starts_with("bootstrapKey(")
- || input
- .trim()
- .trim_start_matches("0x")
- .starts_with(&hex::encode(QUANTUM_BOOTSTRAP_SELECTOR))
+ if function_name_prefix(input) == "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 {
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 = 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);
@@ -520,10 +570,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/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..e452181f0c72c 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 {
@@ -264,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)?;
@@ -399,7 +401,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 +476,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::*;
@@ -571,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();
@@ -613,7 +638,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 +675,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 +696,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 +709,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 +737,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..8103786be85f5 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,
@@ -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
@@ -136,6 +143,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")
@@ -690,7 +706,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);
@@ -698,7 +720,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?);
@@ -716,14 +738,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);
}
@@ -769,9 +788,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 +811,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 +905,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..aa153971b96d1 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,
}
}
}
@@ -379,15 +379,29 @@ 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 {
- 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> {
@@ -408,6 +422,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,
@@ -462,19 +521,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,
@@ -524,8 +584,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) {
@@ -541,8 +601,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) {
@@ -552,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())
}
@@ -576,11 +642,23 @@ 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)?;
- let init_primary_pubkey = decode_optional_list_bytes(buf)?;
- let init_cosigner_pubkey = 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)?;
+ // `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(
@@ -824,12 +902,31 @@ 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()
+ || (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()
+ || (self.sender_sig.len() == 1 && self.sender_sig[0] == alloy_rlp::EMPTY_LIST_CODE)
+ {
+ return Err(alloy_consensus::crypto::RecoveryError::new());
+ }
Ok(self.sender)
}
}
@@ -1029,13 +1126,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,11 +1146,106 @@ 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);
}
+ #[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
+ // 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());
+
+ 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]
+ 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 {
@@ -1075,6 +1268,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(
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/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",
]
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`
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'."