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
2 changes: 1 addition & 1 deletion crates/cast/src/cmd/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ impl SendTxArgs {
{
// ensure we don't violate settings for transactions that can't be CREATE: 7702 and 4844
// which require mandatory target
if to.is_none() && tx.auth.is_some() {
if to.is_none() && !tx.auth.is_empty() {
return Err(eyre!(
"EIP-7702 transactions can't be CREATE transactions and require a destination address"
));
Expand Down
82 changes: 53 additions & 29 deletions crates/cast/src/tx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ pub struct CastTxBuilder<P, S> {
/// Whether the transaction should be sent as a legacy transaction.
legacy: bool,
blob: bool,
auth: Option<CliAuthorizationList>,
auth: Vec<CliAuthorizationList>,
chain: Chain,
etherscan_api_key: Option<String>,
access_list: Option<Option<AccessList>>,
Expand All @@ -158,7 +158,7 @@ impl<P: Provider<AnyNetwork>> CastTxBuilder<P, InitState> {
let chain = utils::get_chain(config.chain, &provider).await?;
let etherscan_api_key = config.get_etherscan_api_key(Some(chain));
// mark it as legacy if requested or the chain is legacy and no 7702 is provided.
let legacy = tx_opts.legacy || (chain.is_legacy() && tx_opts.auth.is_none());
let legacy = tx_opts.legacy || (chain.is_legacy() && tx_opts.auth.is_empty());

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

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

if !unsigned {
self.resolve_auth(sender, tx_nonce).await?;
} else if self.auth.is_some() {
let Some(CliAuthorizationList::Signed(signed_auth)) = self.auth.take() else {
eyre::bail!(
"SignedAuthorization needs to be provided for generating unsigned 7702 txs"
)
};
} else if !self.auth.is_empty() {
let mut signed_auths = Vec::with_capacity(self.auth.len());
for auth in std::mem::take(&mut self.auth) {
let CliAuthorizationList::Signed(signed_auth) = auth else {
eyre::bail!(
"SignedAuthorization needs to be provided for generating unsigned 7702 txs"
)
};
signed_auths.push(signed_auth);
}

self.tx.set_authorization_list(vec![signed_auth]);
self.tx.set_authorization_list(signed_auths);
}

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

/// Parses the passed --auth value and sets the authorization list on the transaction.
/// Parses the passed --auth values and sets the authorization list on the transaction.
async fn resolve_auth(&mut self, sender: SenderKind<'_>, tx_nonce: u64) -> Result<()> {
let Some(auth) = self.auth.take() else { return Ok(()) };

let auth = match auth {
CliAuthorizationList::Address(address) => {
let auth = Authorization {
chain_id: U256::from(self.chain.id()),
nonce: tx_nonce + 1,
address,
};
if self.auth.is_empty() {
return Ok(());
}

let Some(signer) = sender.as_signer() else {
eyre::bail!("No signer available to sign authorization");
};
let signature = signer.sign_hash(&auth.signature_hash()).await?;
let auths = std::mem::take(&mut self.auth);

// Validate that at most one address-based auth is provided (multiple addresses are
// almost always unintended).
let address_auth_count =
auths.iter().filter(|a| matches!(a, CliAuthorizationList::Address(_))).count();
if address_auth_count > 1 {
eyre::bail!(
"Multiple address-based authorizations provided. Only one address can be specified; \
use pre-signed authorizations (hex-encoded) for multiple authorizations."
);
}

auth.into_signed(signature)
}
CliAuthorizationList::Signed(auth) => auth,
};
let mut signed_auths = Vec::with_capacity(auths.len());

for auth in auths {
let signed_auth = match auth {
CliAuthorizationList::Address(address) => {
let auth = Authorization {
chain_id: U256::from(self.chain.id()),
nonce: tx_nonce + 1,
address,
};

let Some(signer) = sender.as_signer() else {
eyre::bail!("No signer available to sign authorization");
};
let signature = signer.sign_hash(&auth.signature_hash()).await?;

auth.into_signed(signature)
}
CliAuthorizationList::Signed(auth) => auth,
};
signed_auths.push(signed_auth);
}

self.tx.set_authorization_list(vec![auth]);
self.tx.set_authorization_list(signed_auths);

Ok(())
}
Expand Down
89 changes: 87 additions & 2 deletions crates/cast/tests/cli/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
use alloy_chains::NamedChain;
use alloy_hardforks::EthereumHardfork;
use alloy_network::{TransactionBuilder, TransactionResponse};
use alloy_primitives::{B256, Bytes, address, b256, hex};
use alloy_primitives::{B256, Bytes, U256, address, b256, hex};
use alloy_provider::{Provider, ProviderBuilder};
use alloy_rpc_types::{BlockNumberOrTag, Index, TransactionRequest};
use alloy_rpc_types::{Authorization, BlockNumberOrTag, Index, TransactionRequest};
use alloy_signer::Signer;
use alloy_signer_local::PrivateKeySigner;
use anvil::NodeConfig;
use foundry_test_utils::{
rpc::{
Expand Down Expand Up @@ -2576,6 +2578,89 @@ casttest!(send_eip7702, async |_prj, cmd| {
"#]]);
});

casttest!(send_eip7702_multiple_auth, async |_prj, cmd| {
let (_api, handle) =
anvil::spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into()))).await;
let endpoint = handle.http_endpoint();

// Create a pre-signed authorization using a different signer (account index 1)
let signer: PrivateKeySigner =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d".parse().unwrap();
// Anvil default chain_id is 31337
let auth = Authorization {
chain_id: U256::from(31337),
// Delegate to account index 2
address: address!("0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"),
nonce: 0,
};
let signature = signer.sign_hash(&auth.signature_hash()).await.unwrap();
let signed_auth = auth.into_signed(signature);
let encoded_auth = hex::encode_prefixed(alloy_rlp::encode(&signed_auth));

// Send transaction with multiple --auth flags: one address and one pre-signed authorization
let output = cmd
.args([
"send",
"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
"--auth",
"0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
"--auth",
&encoded_auth,
"--private-key",
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
"--rpc-url",
&endpoint,
"--gas-limit",
"100000",
"--json",
])
.assert_success()
.get_output()
.stdout_lossy();

// Extract transaction hash from JSON output
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
let tx_hash = json["transactionHash"].as_str().unwrap();

// Use cast tx to verify multiple authorizations were included
let tx_output = cmd
.cast_fuse()
.args(["tx", tx_hash, "--rpc-url", &endpoint, "--json"])
.assert_success()
.get_output()
.stdout_lossy();

let tx_json: serde_json::Value = serde_json::from_str(&tx_output).unwrap();
let auth_list = tx_json["authorizationList"].as_array().unwrap();

// Verify we have 2 authorizations
assert_eq!(auth_list.len(), 2, "Expected 2 authorizations in the transaction");
});

// Test that multiple address-based authorizations are rejected
casttest!(send_eip7702_multiple_address_auth_rejected, async |_prj, cmd| {
let (_api, handle) =
anvil::spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into()))).await;
let endpoint = handle.http_endpoint();

cmd.args([
"send",
"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
"--auth",
"0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
"--auth",
"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
"--private-key",
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
"--rpc-url",
&endpoint,
]);
cmd.assert_failure().stderr_eq(str![[r#"
Error: Multiple address-based authorizations provided. Only one address can be specified; use pre-signed authorizations (hex-encoded) for multiple authorizations.

"#]]);
});

casttest!(send_sync, async |_prj, cmd| {
let (_api, handle) = anvil::spawn(NodeConfig::test()).await;
let endpoint = handle.http_endpoint();
Expand Down
2 changes: 1 addition & 1 deletion crates/cli/src/opts/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ pub struct TransactionOpts {
///
/// Can be either a hex-encoded signed authorization or an address.
#[arg(long, conflicts_with_all = &["legacy", "blob"])]
pub auth: Option<CliAuthorizationList>,
pub auth: Vec<CliAuthorizationList>,

/// EIP-2930 access list.
///
Expand Down
Loading