diff --git a/crates/cast/src/args.rs b/crates/cast/src/args.rs index 4538512f93c19..5f58d794e9562 100644 --- a/crates/cast/src/args.rs +++ b/crates/cast/src/args.rs @@ -751,6 +751,9 @@ pub async fn run_command(args: CastArgs) -> Result<()> { let auth: SignedAuthorization = serde_json::from_str(&auth)?; sh_println!("{}", auth.recover_authority()?)?; } + CastSubcommand::ValidateAuth(cmd) => { + cmd.run().await?; + } CastSubcommand::TxPool { command } => command.run().await?, CastSubcommand::Erc20Token { command } => command.run().await?, CastSubcommand::DAEstimate(cmd) => { diff --git a/crates/cast/src/cmd/mod.rs b/crates/cast/src/cmd/mod.rs index 782a34071cf71..05a87fbfef70a 100644 --- a/crates/cast/src/cmd/mod.rs +++ b/crates/cast/src/cmd/mod.rs @@ -25,4 +25,5 @@ pub mod run; pub mod send; pub mod storage; pub mod txpool; +pub mod validate_auth; pub mod wallet; diff --git a/crates/cast/src/cmd/validate_auth.rs b/crates/cast/src/cmd/validate_auth.rs new file mode 100644 index 0000000000000..9609489b18720 --- /dev/null +++ b/crates/cast/src/cmd/validate_auth.rs @@ -0,0 +1,159 @@ +use std::collections::HashMap; + +use alloy_consensus::{Transaction, TxEnvelope}; +use alloy_eips::BlockId; +use alloy_network::{AnyTxEnvelope, TransactionResponse}; +use alloy_primitives::{Address, B256}; +use alloy_provider::Provider; +use foundry_cli::{ + opts::RpcOpts, + utils::{self, LoadConfig}, +}; + +#[derive(Debug, clap::Parser)] +pub struct ValidateAuthArgs { + /// Transaction hash. + tx_hash: B256, + + #[command(flatten)] + rpc: RpcOpts, +} + +impl ValidateAuthArgs { + pub async fn run(self) -> eyre::Result<()> { + let config = self.rpc.load_config()?; + let provider = utils::get_provider(&config)?; + + let tx = provider + .get_transaction_by_hash(self.tx_hash) + .await? + .ok_or_else(|| eyre::eyre!("tx not found: {:?}", self.tx_hash))?; + + // Get block info for nonce calculation + let block_number = + tx.block_number.ok_or_else(|| eyre::eyre!("transaction is not yet mined"))?; + let tx_index = + tx.transaction_index.ok_or_else(|| eyre::eyre!("transaction index not available"))?; + + // Fetch the block to get all transactions up to this one + let block = provider + .get_block_by_number(block_number.into()) + .full() + .await? + .ok_or_else(|| eyre::eyre!("block not found: {}", block_number))?; + + // Build a map of address -> running nonce from txs in this block up to and including + // our tx + let mut running_nonces: HashMap
= HashMap::new(); + for block_tx in block.transactions.txns().take((tx_index + 1) as usize) { + let from = block_tx.from(); + let nonce = block_tx.nonce(); + // Track the next expected nonce (current nonce + 1) + running_nonces.insert(from, nonce + 1); + } + + let chain_id = provider.get_chain_id().await?; + + // Extract authorization list from EIP-7702 transaction + let auth_list = match &*tx.inner.inner { + AnyTxEnvelope::Ethereum(TxEnvelope::Eip7702(signed_tx)) => { + signed_tx.tx().authorization_list.clone() + } + _ => { + eyre::bail!("transaction is not an EIP-7702 transaction"); + } + }; + + sh_println!("Transaction: {}", self.tx_hash)?; + sh_println!("Block: {} (tx index: {})", block_number, tx_index)?; + sh_println!()?; + + if auth_list.is_empty() { + sh_println!("Authorization list is empty")?; + } else { + for (i, auth) in auth_list.iter().enumerate() { + let valid_chain = auth.chain_id == chain_id || auth.chain_id == 0; + sh_println!("Authorization #{}", i)?; + sh_println!(" Decoded:")?; + sh_println!(" Chain ID: {}", auth.chain_id,)?; + sh_println!(" Address: {}", auth.address)?; + sh_println!(" Nonce: {}", auth.nonce)?; + sh_println!(" r: {}", auth.r())?; + sh_println!(" s: {}", auth.s())?; + sh_println!(" v: {}", auth.y_parity())?; + + match auth.recover_authority() { + Ok(authority) => { + sh_println!(" Recovered Authority: {}", authority)?; + + sh_println!(" Validation Status:")?; + sh_println!( + " Chain: {}", + if valid_chain { + "VALID".to_string() + } else { + format!("INVALID (expected: 0 or {chain_id})") + } + )?; + + // Get the expected nonce at time of tx execution + let expected_nonce = if let Some(&nonce) = running_nonces.get(&authority) { + nonce + } else { + // Fetch nonce at block - 1 (state before this block) + let prev_block = BlockId::number(block_number - 1); + provider.get_transaction_count(authority).block_id(prev_block).await? + }; + + let valid_nonce = auth.nonce == expected_nonce; + if valid_nonce { + sh_println!(" Nonce: VALID")?; + } else { + sh_println!( + " Nonce: INVALID (expected: {}, got: {})", + expected_nonce, + auth.nonce + )?; + } + + // If authorization was valid, update running nonce for subsequent auths + if valid_chain && valid_nonce { + running_nonces.insert(authority, expected_nonce + 1); + } + + // Check if the authority's code was set to the delegated address + let code = provider.get_code_at(authority).await?; + if code.is_empty() { + sh_println!(" Code Status: No delegation (account has no code)")?; + } else if code.len() == 23 && code[0..3] == [0xef, 0x01, 0x00] { + // EIP-7702 delegation designator: 0xef0100 followed by 20-byte + // address + let delegated_to = Address::from_slice(&code[3..23]); + if delegated_to == auth.address { + sh_println!( + " Code Status: ACTIVE (delegated to {})", + delegated_to + )?; + } else { + sh_println!( + " Code Status: SUPERSEDED (currently delegated to {})", + delegated_to + )?; + } + } else { + sh_println!( + " Code Status: Account has contract code (not a delegation)" + )?; + } + } + Err(e) => { + sh_println!(" Authority: UNKNOWN")?; + sh_println!(" Signature: INVALID ({})", e)?; + } + } + sh_println!()?; + } + } + Ok(()) + } +} diff --git a/crates/cast/src/opts.rs b/crates/cast/src/opts.rs index b52d38c4c73c8..76105ea45d03b 100644 --- a/crates/cast/src/opts.rs +++ b/crates/cast/src/opts.rs @@ -4,7 +4,7 @@ use crate::cmd::{ creation_code::CreationCodeArgs, da_estimate::DAEstimateArgs, erc20::Erc20Subcommand, estimate::EstimateArgs, find_block::FindBlockArgs, interface::InterfaceArgs, logs::LogsArgs, mktx::MakeTxArgs, rpc::RpcArgs, run::RunArgs, send::SendTxArgs, storage::StorageArgs, - txpool::TxPoolSubcommands, wallet::WalletSubcommands, + txpool::TxPoolSubcommands, validate_auth::ValidateAuthArgs, wallet::WalletSubcommands, }; use alloy_ens::NameOrAddress; use alloy_primitives::{Address, B256, Selector, U256}; @@ -1119,6 +1119,10 @@ pub enum CastSubcommand { #[command(visible_aliases = &["decode-auth"])] RecoverAuthority { auth: String }, + /// Validate EIP-7702 authorizations in a transaction and print validity status. + #[command(name = "validate-auth", visible_aliases = &["va", "validate-auths"])] + ValidateAuth(ValidateAuthArgs), + /// Extracts function selectors and arguments from bytecode #[command(visible_alias = "sel")] Selectors { diff --git a/crates/cast/tests/cli/main.rs b/crates/cast/tests/cli/main.rs index d76b6f51592f9..8dc282897b604 100644 --- a/crates/cast/tests/cli/main.rs +++ b/crates/cast/tests/cli/main.rs @@ -1,12 +1,15 @@ //! Contains various tests for checking cast commands use alloy_chains::NamedChain; +use alloy_eips::eip7702::Authorization; use alloy_hardforks::EthereumHardfork; -use alloy_network::{TransactionBuilder, TransactionResponse}; +use alloy_network::{ + EthereumWallet, TransactionBuilder, TransactionBuilder7702, TransactionResponse, +}; use alloy_primitives::{B256, Bytes, U256, address, b256, hex}; use alloy_provider::{Provider, ProviderBuilder}; -use alloy_rpc_types::{Authorization, BlockNumberOrTag, Index, TransactionRequest}; -use alloy_signer::Signer; +use alloy_rpc_types::{BlockNumberOrTag, Index, TransactionRequest}; +use alloy_signer::{Signer, SignerSync}; use alloy_signer_local::PrivateKeySigner; use anvil::NodeConfig; use foundry_test_utils::{ @@ -2661,6 +2664,335 @@ Error: Multiple address-based authorizations provided. Only one address can be s "#]]); }); +casttest!(validate_auth, async |_prj, cmd| { + let (_api, handle) = + anvil::spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into()))).await; + let endpoint = handle.http_endpoint(); + + // Send EIP-7702 transaction and capture the tx hash + let output = cmd + .args([ + "send", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &endpoint, + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Extract tx hash from output (format: "transactionHash 0x...") + let tx_hash = output + .lines() + .find(|l| l.contains("transactionHash")) + .and_then(|l| l.split_whitespace().last()) + .expect("should have tx hash"); + + // Validate the auth - check exact output format + cmd.cast_fuse() + .args(["validate-auth", tx_hash, "--rpc-url", &endpoint]) + .assert_success() + .stdout_eq(str![[r#" +Transaction: [..] +Block: 1 (tx index: 0) + +Authorization #0 + Decoded: + Chain ID: 31337 + [ADDRESS] + Nonce: 1 + r: [..] + s: [..] + v: [..] + Recovered Authority: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 + Validation Status: + Chain: VALID + Nonce: VALID + Code Status: ACTIVE (delegated to 0x70997970C51812dc3A010C7d01b50e0d17dc79C8) + + +"#]]); +}); + +casttest!(validate_auth_invalid_nonce, async |_prj, cmd| { + let (_api, handle) = + anvil::spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into()))).await; + let endpoint = handle.http_endpoint(); + + // First, send a regular tx from the authority account to increment its nonce + cmd.args([ + "send", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "--value", + "1", + "--private-key", + // Authority's private key (account 1) + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + "--rpc-url", + &endpoint, + ]) + .assert_success(); + + // Sign an authorization with nonce 0 (which is now stale since we sent a tx) + let auth_output = cmd + .cast_fuse() + .args([ + "wallet", + "sign-auth", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", // delegate to account 2 + "--private-key", + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", // authority (account 1) + "--nonce", + "0", // Stale nonce - authority's nonce is now 1 + "--chain", + "31337", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let auth_hex = auth_output.trim(); + + // Send EIP-7702 tx with the stale authorization + let output = cmd + .cast_fuse() + .args([ + "send", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--auth", + auth_hex, + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &endpoint, + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let tx_hash = output + .lines() + .find(|l| l.contains("transactionHash")) + .and_then(|l| l.split_whitespace().last()) + .expect("should have tx hash"); + + // Validate - should show invalid nonce + cmd.cast_fuse() + .args(["validate-auth", tx_hash, "--rpc-url", &endpoint]) + .assert_success() + .stdout_eq(str![[r#" +Transaction: [..] +Block: 2 (tx index: 0) + +Authorization #0 + Decoded: + Chain ID: 31337 + [ADDRESS] + Nonce: 0 + r: [..] + s: [..] + v: [..] + Recovered Authority: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 + Validation Status: + Chain: VALID + Nonce: INVALID (expected: 1, got: 0) + Code Status: No delegation (account has no code) + + +"#]]); +}); + +casttest!(validate_auth_duplicate_nonce, async |_prj, cmd| { + let (_api, handle) = + anvil::spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into()))).await; + let endpoint = handle.http_endpoint(); + let sender_key: PrivateKeySigner = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80".parse().unwrap(); + let sender_wallet = EthereumWallet::from(sender_key); + let provider = ProviderBuilder::new().wallet(sender_wallet).connect(&endpoint).await.unwrap(); + + // Authority key (account 1) + let authority_key: PrivateKeySigner = + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d".parse().unwrap(); + + // Sign two authorizations from the same authority with the same nonce + let auth1 = Authorization { + chain_id: U256::from(31337), + address: address!("0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"), + nonce: 0, + }; + let sig1 = authority_key.sign_hash_sync(&auth1.signature_hash()).unwrap(); + let signed_auth1 = auth1.into_signed(sig1); + + let auth2 = Authorization { + chain_id: U256::from(31337), + address: address!("0x90F79bf6EB2c4f870365E785982E1f101E93b906"), + nonce: 0, // Same nonce - will be invalid after first auth executes + }; + let sig2 = authority_key.sign_hash_sync(&auth2.signature_hash()).unwrap(); + let signed_auth2 = auth2.into_signed(sig2); + + // Build and send tx with both authorizations + let tx = TransactionRequest::default() + .with_to(address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266")) + .with_authorization_list(vec![signed_auth1, signed_auth2]); + + let pending = provider.send_transaction(tx).await.unwrap(); + let tx_hash = format!("{:?}", pending.tx_hash()); + + // Wait for tx to be mined + let _ = pending.get_receipt().await.unwrap(); + + // Validate - first auth should be valid, second should have invalid nonce + cmd.args(["validate-auth", &tx_hash, "--rpc-url", &endpoint]).assert_success().stdout_eq(str![ + [r#" +Transaction: [..] +Block: 1 (tx index: 0) + +Authorization #0 + Decoded: + Chain ID: 31337 + [ADDRESS] + Nonce: 0 + r: [..] + s: [..] + v: [..] + Recovered Authority: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 + Validation Status: + Chain: VALID + Nonce: VALID + Code Status: ACTIVE (delegated to 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC) + +Authorization #1 + Decoded: + Chain ID: 31337 + [ADDRESS] + Nonce: 0 + r: [..] + s: [..] + v: [..] + Recovered Authority: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 + Validation Status: + Chain: VALID + Nonce: INVALID (expected: 1, got: 0) + Code Status: SUPERSEDED (currently delegated to 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC) + + +"#] + ]); +}); + +casttest!(validate_auth_invalid_chain, async |_prj, cmd| { + let (_api, handle) = + anvil::spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into()))).await; + let endpoint = handle.http_endpoint(); + + // Sign an authorization with wrong chain ID (1 instead of 31337) + let auth_output = cmd + .args([ + "wallet", + "sign-auth", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "--private-key", + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + "--nonce", + "0", + "--chain", + "1", // Wrong chain ID (mainnet instead of anvil's 31337) + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let auth_hex = auth_output.trim(); + + // Send EIP-7702 tx with wrong chain authorization + let output = cmd + .cast_fuse() + .args([ + "send", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--auth", + auth_hex, + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &endpoint, + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let tx_hash = output + .lines() + .find(|l| l.contains("transactionHash")) + .and_then(|l| l.split_whitespace().last()) + .expect("should have tx hash"); + + // Validate - should show invalid chain + cmd.cast_fuse() + .args(["validate-auth", tx_hash, "--rpc-url", &endpoint]) + .assert_success() + .stdout_eq(str![[r#" +Transaction: [..] +Block: 1 (tx index: 0) + +Authorization #0 + Decoded: + Chain ID: 1 + [ADDRESS] + Nonce: 0 + r: [..] + s: [..] + v: [..] + Recovered Authority: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 + Validation Status: + Chain: INVALID (expected: 0 or 31337) + Nonce: VALID + Code Status: No delegation (account has no code) + + +"#]]); +}); + +casttest!(validate_auth_non_eip7702_tx, async |_prj, cmd| { + let (_api, handle) = anvil::spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + + // Send a regular transaction + let output = cmd + .args([ + "send", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--value", + "1", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &endpoint, + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let tx_hash = output + .lines() + .find(|l| l.contains("transactionHash")) + .and_then(|l| l.split_whitespace().last()) + .expect("should have tx hash"); + + // Should fail for non EIP-7702 tx + cmd.cast_fuse() + .args(["validate-auth", tx_hash, "--rpc-url", &endpoint]) + .assert_failure() + .stderr_eq(str![[r#" +Error: transaction is not an EIP-7702 transaction + +"#]]); +}); + casttest!(send_sync, async |_prj, cmd| { let (_api, handle) = anvil::spawn(NodeConfig::test()).await; let endpoint = handle.http_endpoint();