Skip to content

Commit d9f47a7

Browse files
authored
feat(cast): accept multiple 7702 authorizations (#12627)
There can be multiple 7702 authorizations provided in a single transaction. We allow at max one unsigned/address as authorization since otherwise it'd be signing multiple authorizations for delegating same account which will just make the last one execute and all the previous ones will essentially be noop. So, for multiple authorizations, the expectation is that signed authorizations will be provided as the args.
1 parent 0584a58 commit d9f47a7

File tree

4 files changed

+142
-33
lines changed

4 files changed

+142
-33
lines changed

crates/cast/src/cmd/send.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ impl SendTxArgs {
126126
{
127127
// ensure we don't violate settings for transactions that can't be CREATE: 7702 and 4844
128128
// which require mandatory target
129-
if to.is_none() && tx.auth.is_some() {
129+
if to.is_none() && !tx.auth.is_empty() {
130130
return Err(eyre!(
131131
"EIP-7702 transactions can't be CREATE transactions and require a destination address"
132132
));

crates/cast/src/tx.rs

Lines changed: 53 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ pub struct CastTxBuilder<P, S> {
142142
/// Whether the transaction should be sent as a legacy transaction.
143143
legacy: bool,
144144
blob: bool,
145-
auth: Option<CliAuthorizationList>,
145+
auth: Vec<CliAuthorizationList>,
146146
chain: Chain,
147147
etherscan_api_key: Option<String>,
148148
access_list: Option<Option<AccessList>>,
@@ -158,7 +158,7 @@ impl<P: Provider<AnyNetwork>> CastTxBuilder<P, InitState> {
158158
let chain = utils::get_chain(config.chain, &provider).await?;
159159
let etherscan_api_key = config.get_etherscan_api_key(Some(chain));
160160
// mark it as legacy if requested or the chain is legacy and no 7702 is provided.
161-
let legacy = tx_opts.legacy || (chain.is_legacy() && tx_opts.auth.is_none());
161+
let legacy = tx_opts.legacy || (chain.is_legacy() && tx_opts.auth.is_empty());
162162

163163
if let Some(gas_limit) = tx_opts.gas_limit {
164164
tx.set_gas_limit(gas_limit.to());
@@ -252,7 +252,7 @@ impl<P: Provider<AnyNetwork>> CastTxBuilder<P, ToState> {
252252

253253
if self.state.to.is_none() && code.is_none() {
254254
let has_value = self.tx.value.is_some_and(|v| !v.is_zero());
255-
let has_auth = self.auth.is_some();
255+
let has_auth = !self.auth.is_empty();
256256
// We only allow user to omit the recipient address if transaction is an EIP-7702 tx
257257
// without a value.
258258
if !has_auth || has_value {
@@ -334,14 +334,18 @@ impl<P: Provider<AnyNetwork>> CastTxBuilder<P, InputState> {
334334

335335
if !unsigned {
336336
self.resolve_auth(sender, tx_nonce).await?;
337-
} else if self.auth.is_some() {
338-
let Some(CliAuthorizationList::Signed(signed_auth)) = self.auth.take() else {
339-
eyre::bail!(
340-
"SignedAuthorization needs to be provided for generating unsigned 7702 txs"
341-
)
342-
};
337+
} else if !self.auth.is_empty() {
338+
let mut signed_auths = Vec::with_capacity(self.auth.len());
339+
for auth in std::mem::take(&mut self.auth) {
340+
let CliAuthorizationList::Signed(signed_auth) = auth else {
341+
eyre::bail!(
342+
"SignedAuthorization needs to be provided for generating unsigned 7702 txs"
343+
)
344+
};
345+
signed_auths.push(signed_auth);
346+
}
343347

344-
self.tx.set_authorization_list(vec![signed_auth]);
348+
self.tx.set_authorization_list(signed_auths);
345349
}
346350

347351
if let Some(access_list) = match self.access_list.take() {
@@ -410,29 +414,49 @@ impl<P: Provider<AnyNetwork>> CastTxBuilder<P, InputState> {
410414
}
411415
}
412416

413-
/// Parses the passed --auth value and sets the authorization list on the transaction.
417+
/// Parses the passed --auth values and sets the authorization list on the transaction.
414418
async fn resolve_auth(&mut self, sender: SenderKind<'_>, tx_nonce: u64) -> Result<()> {
415-
let Some(auth) = self.auth.take() else { return Ok(()) };
416-
417-
let auth = match auth {
418-
CliAuthorizationList::Address(address) => {
419-
let auth = Authorization {
420-
chain_id: U256::from(self.chain.id()),
421-
nonce: tx_nonce + 1,
422-
address,
423-
};
419+
if self.auth.is_empty() {
420+
return Ok(());
421+
}
424422

425-
let Some(signer) = sender.as_signer() else {
426-
eyre::bail!("No signer available to sign authorization");
427-
};
428-
let signature = signer.sign_hash(&auth.signature_hash()).await?;
423+
let auths = std::mem::take(&mut self.auth);
424+
425+
// Validate that at most one address-based auth is provided (multiple addresses are
426+
// almost always unintended).
427+
let address_auth_count =
428+
auths.iter().filter(|a| matches!(a, CliAuthorizationList::Address(_))).count();
429+
if address_auth_count > 1 {
430+
eyre::bail!(
431+
"Multiple address-based authorizations provided. Only one address can be specified; \
432+
use pre-signed authorizations (hex-encoded) for multiple authorizations."
433+
);
434+
}
429435

430-
auth.into_signed(signature)
431-
}
432-
CliAuthorizationList::Signed(auth) => auth,
433-
};
436+
let mut signed_auths = Vec::with_capacity(auths.len());
437+
438+
for auth in auths {
439+
let signed_auth = match auth {
440+
CliAuthorizationList::Address(address) => {
441+
let auth = Authorization {
442+
chain_id: U256::from(self.chain.id()),
443+
nonce: tx_nonce + 1,
444+
address,
445+
};
446+
447+
let Some(signer) = sender.as_signer() else {
448+
eyre::bail!("No signer available to sign authorization");
449+
};
450+
let signature = signer.sign_hash(&auth.signature_hash()).await?;
451+
452+
auth.into_signed(signature)
453+
}
454+
CliAuthorizationList::Signed(auth) => auth,
455+
};
456+
signed_auths.push(signed_auth);
457+
}
434458

435-
self.tx.set_authorization_list(vec![auth]);
459+
self.tx.set_authorization_list(signed_auths);
436460

437461
Ok(())
438462
}

crates/cast/tests/cli/main.rs

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
use alloy_chains::NamedChain;
44
use alloy_hardforks::EthereumHardfork;
55
use alloy_network::{TransactionBuilder, TransactionResponse};
6-
use alloy_primitives::{B256, Bytes, address, b256, hex};
6+
use alloy_primitives::{B256, Bytes, U256, address, b256, hex};
77
use alloy_provider::{Provider, ProviderBuilder};
8-
use alloy_rpc_types::{BlockNumberOrTag, Index, TransactionRequest};
8+
use alloy_rpc_types::{Authorization, BlockNumberOrTag, Index, TransactionRequest};
9+
use alloy_signer::Signer;
10+
use alloy_signer_local::PrivateKeySigner;
911
use anvil::NodeConfig;
1012
use foundry_test_utils::{
1113
rpc::{
@@ -2576,6 +2578,89 @@ casttest!(send_eip7702, async |_prj, cmd| {
25762578
"#]]);
25772579
});
25782580

2581+
casttest!(send_eip7702_multiple_auth, async |_prj, cmd| {
2582+
let (_api, handle) =
2583+
anvil::spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into()))).await;
2584+
let endpoint = handle.http_endpoint();
2585+
2586+
// Create a pre-signed authorization using a different signer (account index 1)
2587+
let signer: PrivateKeySigner =
2588+
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d".parse().unwrap();
2589+
// Anvil default chain_id is 31337
2590+
let auth = Authorization {
2591+
chain_id: U256::from(31337),
2592+
// Delegate to account index 2
2593+
address: address!("0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"),
2594+
nonce: 0,
2595+
};
2596+
let signature = signer.sign_hash(&auth.signature_hash()).await.unwrap();
2597+
let signed_auth = auth.into_signed(signature);
2598+
let encoded_auth = hex::encode_prefixed(alloy_rlp::encode(&signed_auth));
2599+
2600+
// Send transaction with multiple --auth flags: one address and one pre-signed authorization
2601+
let output = cmd
2602+
.args([
2603+
"send",
2604+
"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
2605+
"--auth",
2606+
"0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
2607+
"--auth",
2608+
&encoded_auth,
2609+
"--private-key",
2610+
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
2611+
"--rpc-url",
2612+
&endpoint,
2613+
"--gas-limit",
2614+
"100000",
2615+
"--json",
2616+
])
2617+
.assert_success()
2618+
.get_output()
2619+
.stdout_lossy();
2620+
2621+
// Extract transaction hash from JSON output
2622+
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
2623+
let tx_hash = json["transactionHash"].as_str().unwrap();
2624+
2625+
// Use cast tx to verify multiple authorizations were included
2626+
let tx_output = cmd
2627+
.cast_fuse()
2628+
.args(["tx", tx_hash, "--rpc-url", &endpoint, "--json"])
2629+
.assert_success()
2630+
.get_output()
2631+
.stdout_lossy();
2632+
2633+
let tx_json: serde_json::Value = serde_json::from_str(&tx_output).unwrap();
2634+
let auth_list = tx_json["authorizationList"].as_array().unwrap();
2635+
2636+
// Verify we have 2 authorizations
2637+
assert_eq!(auth_list.len(), 2, "Expected 2 authorizations in the transaction");
2638+
});
2639+
2640+
// Test that multiple address-based authorizations are rejected
2641+
casttest!(send_eip7702_multiple_address_auth_rejected, async |_prj, cmd| {
2642+
let (_api, handle) =
2643+
anvil::spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into()))).await;
2644+
let endpoint = handle.http_endpoint();
2645+
2646+
cmd.args([
2647+
"send",
2648+
"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
2649+
"--auth",
2650+
"0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
2651+
"--auth",
2652+
"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
2653+
"--private-key",
2654+
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
2655+
"--rpc-url",
2656+
&endpoint,
2657+
]);
2658+
cmd.assert_failure().stderr_eq(str![[r#"
2659+
Error: Multiple address-based authorizations provided. Only one address can be specified; use pre-signed authorizations (hex-encoded) for multiple authorizations.
2660+
2661+
"#]]);
2662+
});
2663+
25792664
casttest!(send_sync, async |_prj, cmd| {
25802665
let (_api, handle) = anvil::spawn(NodeConfig::test()).await;
25812666
let endpoint = handle.http_endpoint();

crates/cli/src/opts/transaction.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ pub struct TransactionOpts {
8888
///
8989
/// Can be either a hex-encoded signed authorization or an address.
9090
#[arg(long, conflicts_with_all = &["legacy", "blob"])]
91-
pub auth: Option<CliAuthorizationList>,
91+
pub auth: Vec<CliAuthorizationList>,
9292

9393
/// EIP-2930 access list.
9494
///

0 commit comments

Comments
 (0)