Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 53 additions & 7 deletions crates/cast/src/cmd/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@ use std::{path::PathBuf, str::FromStr, time::Duration};
use alloy_consensus::{SignableTransaction, Signed};
use alloy_ens::NameOrAddress;
use alloy_network::{Ethereum, EthereumWallet, Network};
use alloy_primitives::{Address, hex};
use alloy_primitives::{Address, U256, hex};
use alloy_provider::{Provider, ProviderBuilder as AlloyProviderBuilder};
use alloy_signer::{Signature, Signer};
use clap::Parser;
use eyre::{Result, eyre};
use foundry_cli::{opts::TransactionOpts, utils::LoadConfig};
use foundry_common::{
FoundryTransactionBuilder, QUANTUM_BOOTSTRAP_SELECTOR, QUANTUM_KEYVAULT_ADDRESS,
derive_primary_pubkey, parse_seed_file, sign_quantum_transaction_request,
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,
fmt::{UIfmt, UIfmtReceiptExt},
provider::ProviderBuilder,
};
Expand Down Expand Up @@ -153,10 +156,33 @@ impl SendTxArgs {
})?;
let primary_seed = parse_seed_file(seed_path)?;

if quantum_send_requests_bootstrap(to.as_ref(), sig.as_deref())
&& tx.quantum.init_primary_pubkey.is_none()
let cosigner = tx
.quantum
.cosigner_artifact
.as_deref()
.map(DetachedCosigner::from_artifact_file)
.transpose()?;

// 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())
{
tx.quantum.init_primary_pubkey = Some(derive_primary_pubkey(primary_seed));
return Err(eyre!(QUANTUM_SEND_LIFECYCLE_REJECTION_MESSAGE));
}

if quantum_send_requests_bootstrap(to.as_ref(), sig.as_deref()) {
Comment thread
eacet marked this conversation as resolved.
if tx.quantum.init_primary_pubkey.is_none() {
tx.quantum.init_primary_pubkey = Some(derive_primary_pubkey(primary_seed));
}
// Bootstrap/lifecycle calls cannot be simulated via `eth_estimateGas` because
// the validator-published bootstrap transient state is absent. Apply the fixed
// lifecycle gas floor when the caller did not override it, mirroring
// `quantum-send-tx`'s LIFECYCLE_GAS_FLOOR.
if tx.gas_limit.is_none() {
tx.gas_limit = Some(U256::from(QUANTUM_LIFECYCLE_GAS_FLOOR));
}
}

let config = send_tx.eth.load_config()?;
Expand All @@ -174,7 +200,11 @@ impl SendTxArgs {
.await?;

let (tx_request, _) = builder.build(sender).await?;
let payload = sign_quantum_transaction_request(tx_request, primary_seed)?;
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);
Expand Down Expand Up @@ -443,6 +473,22 @@ fn quantum_input_is_bootstrap(input: Option<&str>) -> bool {
.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(")
{
return true;
}
let hex_body = trimmed.trim_start_matches("0x").to_ascii_lowercase();
Comment thread
eacet marked this conversation as resolved.
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);
hex_body.starts_with(&add) || hex_body.starts_with(&remove) || hex_body.starts_with(&update)
}

pub(crate) async fn cast_send<N: Network, P: Provider<N>>(
provider: P,
tx: N::TransactionRequest,
Expand Down
15 changes: 15 additions & 0 deletions crates/cli/src/opts/quantum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ pub struct QuantumOpts {
value_name = "HEX_BYTES"
)]
pub init_cosigner_pubkey: Option<Bytes>,

/// Path to a v1 detached cosigner artifact JSON.
///
/// The artifact must carry a matching `signing_hash` for the Quantum request being signed.
/// Supported schemes are `p256` and `ecdsa`.
#[arg(
id = "quantum_cosigner_artifact",
long = "quantum.cosigner-artifact",
value_name = "PATH"
)]
pub cosigner_artifact: Option<PathBuf>,
}

impl QuantumOpts {
Expand All @@ -69,6 +80,7 @@ impl QuantumOpts {
|| self.primary_seed_file.is_some()
|| self.init_primary_pubkey.is_some()
|| self.init_cosigner_pubkey.is_some()
|| self.cosigner_artifact.is_some()
}

/// Returns the resolved key ID for the Phase 0 seam.
Expand Down Expand Up @@ -123,6 +135,8 @@ mod tests {
"0x010203",
"--quantum.init-cosigner-pubkey",
"0x0405",
"--quantum.cosigner-artifact",
"./cosigner.json",
])
.unwrap();

Expand All @@ -131,5 +145,6 @@ 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")));
}
}
36 changes: 36 additions & 0 deletions crates/common/src/transactions/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,14 @@ impl QuantumWriteRequestV1 {
);
}

if self.is_bootstrap_call() {
ensure!(
self.key_id == 0,
"Quantum bootstrap requests must use key_id = 0 (account-lane primary); got key_id = {}",
self.key_id
);
}

Ok(())
}

Expand Down Expand Up @@ -986,6 +994,34 @@ mod tests {
assert_eq!(err.to_string(), "Quantum bootstrap remains primary-only in v1");
}

#[test]
fn quantum_request_rejects_bootstrap_with_nonzero_key_id() {
let bootstrap_tx = TransactionRequest::default()
.with_to(QUANTUM_KEYVAULT_ADDRESS)
.with_nonce(0)
.with_gas_limit(21_000)
.with_max_fee_per_gas(1_000_000_000u128)
.with_max_priority_fee_per_gas(1_000_000u128)
.with_input(Bytes::from(QUANTUM_BOOTSTRAP_SELECTOR.to_vec()))
.with_chain_id(1337);

let err = QuantumWriteRequestV1::from_transaction_request(
&bootstrap_tx,
QuantumWriteRequestInputsV1 {
sender: Address::repeat_byte(0x11),
key_id: 7,
nonce_key: None,
bootstrap: None,
},
)
.unwrap_err();

assert_eq!(
err.to_string(),
"Quantum bootstrap requests must use key_id = 0 (account-lane primary); got key_id = 7"
);
}

#[test]
fn quantum_request_preserves_create_as_single_call() {
let mut create_tx = TransactionRequest::default()
Expand Down
Loading
Loading