Skip to content

Commit 975b38d

Browse files
authored
quantum phase 2: finalize manual verification and auto-apply lifecycle gas floor — adds fork-side key_id == 0 invariant on bootstrap requests, moves KeyVault lifecycle-selector rejection into cast send pre-build (matching both Solidity and raw-hex sel (#3)
<!-- Thank you for your Pull Request. Please provide a description above and review the requirements below. Bug fixes and new features should include tests. Contributors guide: https://github.com/foundry-rs/foundry/blob/HEAD/CONTRIBUTING.md The contributors guide includes instructions for running rustfmt and building the documentation. --> <!-- ** Please select "Allow edits from maintainers" in the PR Options ** --> ## Motivation <!-- Explain the context and why you're making that change. What is the problem you're trying to solve? In some cases there is not a problem and this can be thought of as being the motivation for your change. --> ## Solution <!-- Summarize the solution and provide any necessary context needed to understand the code change. --> ## PR Checklist - [ ] Added Tests - [ ] Added Documentation - [ ] Breaking changes
1 parent 2352a2f commit 975b38d

6 files changed

Lines changed: 433 additions & 12 deletions

File tree

crates/cast/src/cmd/send.rs

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@ use std::{path::PathBuf, str::FromStr, time::Duration};
33
use alloy_consensus::{SignableTransaction, Signed};
44
use alloy_ens::NameOrAddress;
55
use alloy_network::{Ethereum, EthereumWallet, Network};
6-
use alloy_primitives::{Address, hex};
6+
use alloy_primitives::{Address, U256, hex};
77
use alloy_provider::{Provider, ProviderBuilder as AlloyProviderBuilder};
88
use alloy_signer::{Signature, Signer};
99
use clap::Parser;
1010
use eyre::{Result, eyre};
1111
use foundry_cli::{opts::TransactionOpts, utils::LoadConfig};
1212
use foundry_common::{
13-
FoundryTransactionBuilder, QUANTUM_BOOTSTRAP_SELECTOR, QUANTUM_KEYVAULT_ADDRESS,
14-
derive_primary_pubkey, parse_seed_file, sign_quantum_transaction_request,
13+
DetachedCosigner, FoundryTransactionBuilder, QUANTUM_ADD_KEY_SELECTOR,
14+
QUANTUM_BOOTSTRAP_SELECTOR, QUANTUM_KEYVAULT_ADDRESS, QUANTUM_LIFECYCLE_GAS_FLOOR,
15+
QUANTUM_REMOVE_KEY_SELECTOR, QUANTUM_SEND_LIFECYCLE_REJECTION_MESSAGE,
16+
QUANTUM_UPDATE_KEY_AUTH_SELECTOR, derive_primary_pubkey, parse_seed_file,
17+
sign_quantum_transaction_request_with_cosigner,
1518
fmt::{UIfmt, UIfmtReceiptExt},
1619
provider::ProviderBuilder,
1720
};
@@ -153,10 +156,33 @@ impl SendTxArgs {
153156
})?;
154157
let primary_seed = parse_seed_file(seed_path)?;
155158

156-
if quantum_send_requests_bootstrap(to.as_ref(), sig.as_deref())
157-
&& tx.quantum.init_primary_pubkey.is_none()
159+
let cosigner = tx
160+
.quantum
161+
.cosigner_artifact
162+
.as_deref()
163+
.map(DetachedCosigner::from_artifact_file)
164+
.transpose()?;
165+
166+
// Fail closed before any RPC simulation: ordinary `cast send` must not accept
167+
// unsupported KeyVault lifecycle selectors (addKey / removeKey / updateKeyAuth).
168+
// Only `bootstrapKey()` is supported from this path in v1.
169+
if quantum_destination_is_keyvault(to.as_ref())
170+
&& quantum_input_is_unsupported_lifecycle(sig.as_deref())
158171
{
159-
tx.quantum.init_primary_pubkey = Some(derive_primary_pubkey(primary_seed));
172+
return Err(eyre!(QUANTUM_SEND_LIFECYCLE_REJECTION_MESSAGE));
173+
}
174+
175+
if quantum_send_requests_bootstrap(to.as_ref(), sig.as_deref()) {
176+
if tx.quantum.init_primary_pubkey.is_none() {
177+
tx.quantum.init_primary_pubkey = Some(derive_primary_pubkey(primary_seed));
178+
}
179+
// Bootstrap/lifecycle calls cannot be simulated via `eth_estimateGas` because
180+
// the validator-published bootstrap transient state is absent. Apply the fixed
181+
// lifecycle gas floor when the caller did not override it, mirroring
182+
// `quantum-send-tx`'s LIFECYCLE_GAS_FLOOR.
183+
if tx.gas_limit.is_none() {
184+
tx.gas_limit = Some(U256::from(QUANTUM_LIFECYCLE_GAS_FLOOR));
185+
}
160186
}
161187

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

176202
let (tx_request, _) = builder.build(sender).await?;
177-
let payload = sign_quantum_transaction_request(tx_request, primary_seed)?;
203+
let payload = sign_quantum_transaction_request_with_cosigner(
204+
tx_request,
205+
primary_seed,
206+
cosigner,
207+
)?;
178208

179209
let timeout = send_tx.timeout.unwrap_or(config.transaction_timeout);
180210
let cast = CastTxSender::new(&provider);
@@ -443,6 +473,22 @@ fn quantum_input_is_bootstrap(input: Option<&str>) -> bool {
443473
.starts_with(&hex::encode(QUANTUM_BOOTSTRAP_SELECTOR))
444474
}
445475

476+
fn quantum_input_is_unsupported_lifecycle(input: Option<&str>) -> bool {
477+
let Some(input) = input else { return false };
478+
let trimmed = input.trim();
479+
if trimmed.starts_with("addKey(")
480+
|| trimmed.starts_with("removeKey(")
481+
|| trimmed.starts_with("updateKeyAuth(")
482+
{
483+
return true;
484+
}
485+
let hex_body = trimmed.trim_start_matches("0x").to_ascii_lowercase();
486+
let add = hex::encode(QUANTUM_ADD_KEY_SELECTOR);
487+
let remove = hex::encode(QUANTUM_REMOVE_KEY_SELECTOR);
488+
let update = hex::encode(QUANTUM_UPDATE_KEY_AUTH_SELECTOR);
489+
hex_body.starts_with(&add) || hex_body.starts_with(&remove) || hex_body.starts_with(&update)
490+
}
491+
446492
pub(crate) async fn cast_send<N: Network, P: Provider<N>>(
447493
provider: P,
448494
tx: N::TransactionRequest,

crates/cli/src/opts/quantum.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,17 @@ pub struct QuantumOpts {
5858
value_name = "HEX_BYTES"
5959
)]
6060
pub init_cosigner_pubkey: Option<Bytes>,
61+
62+
/// Path to a v1 detached cosigner artifact JSON.
63+
///
64+
/// The artifact must carry a matching `signing_hash` for the Quantum request being signed.
65+
/// Supported schemes are `p256` and `ecdsa`.
66+
#[arg(
67+
id = "quantum_cosigner_artifact",
68+
long = "quantum.cosigner-artifact",
69+
value_name = "PATH"
70+
)]
71+
pub cosigner_artifact: Option<PathBuf>,
6172
}
6273

6374
impl QuantumOpts {
@@ -69,6 +80,7 @@ impl QuantumOpts {
6980
|| self.primary_seed_file.is_some()
7081
|| self.init_primary_pubkey.is_some()
7182
|| self.init_cosigner_pubkey.is_some()
83+
|| self.cosigner_artifact.is_some()
7284
}
7385

7486
/// Returns the resolved key ID for the Phase 0 seam.
@@ -123,6 +135,8 @@ mod tests {
123135
"0x010203",
124136
"--quantum.init-cosigner-pubkey",
125137
"0x0405",
138+
"--quantum.cosigner-artifact",
139+
"./cosigner.json",
126140
])
127141
.unwrap();
128142

@@ -131,5 +145,6 @@ mod tests {
131145
assert_eq!(opts.primary_seed_file.as_deref(), Some(std::path::Path::new("./seed.hex")));
132146
assert_eq!(opts.init_primary_pubkey, Some(Bytes::from(vec![0x01, 0x02, 0x03])));
133147
assert_eq!(opts.init_cosigner_pubkey, Some(Bytes::from(vec![0x04, 0x05])));
148+
assert_eq!(opts.cosigner_artifact.as_deref(), Some(std::path::Path::new("./cosigner.json")));
134149
}
135150
}

crates/common/src/transactions/builder.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,14 @@ impl QuantumWriteRequestV1 {
466466
);
467467
}
468468

469+
if self.is_bootstrap_call() {
470+
ensure!(
471+
self.key_id == 0,
472+
"Quantum bootstrap requests must use key_id = 0 (account-lane primary); got key_id = {}",
473+
self.key_id
474+
);
475+
}
476+
469477
Ok(())
470478
}
471479

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

997+
#[test]
998+
fn quantum_request_rejects_bootstrap_with_nonzero_key_id() {
999+
let bootstrap_tx = TransactionRequest::default()
1000+
.with_to(QUANTUM_KEYVAULT_ADDRESS)
1001+
.with_nonce(0)
1002+
.with_gas_limit(21_000)
1003+
.with_max_fee_per_gas(1_000_000_000u128)
1004+
.with_max_priority_fee_per_gas(1_000_000u128)
1005+
.with_input(Bytes::from(QUANTUM_BOOTSTRAP_SELECTOR.to_vec()))
1006+
.with_chain_id(1337);
1007+
1008+
let err = QuantumWriteRequestV1::from_transaction_request(
1009+
&bootstrap_tx,
1010+
QuantumWriteRequestInputsV1 {
1011+
sender: Address::repeat_byte(0x11),
1012+
key_id: 7,
1013+
nonce_key: None,
1014+
bootstrap: None,
1015+
},
1016+
)
1017+
.unwrap_err();
1018+
1019+
assert_eq!(
1020+
err.to_string(),
1021+
"Quantum bootstrap requests must use key_id = 0 (account-lane primary); got key_id = 7"
1022+
);
1023+
}
1024+
9891025
#[test]
9901026
fn quantum_request_preserves_create_as_single_call() {
9911027
let mut create_tx = TransactionRequest::default()

0 commit comments

Comments
 (0)