diff --git a/Cargo.lock b/Cargo.lock index a013e51a2..dc4e33912 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2177,6 +2177,27 @@ dependencies = [ "spin", ] +[[package]] +name = "base-common-consensus" +version = "0.0.0" +source = "git+https://github.com/base/base.git?rev=a70d193532171f8ad1299d1c80f46bc6625789aa#a70d193532171f8ad1299d1c80f46bc6625789aa" +dependencies = [ + "alloy-consensus", + "alloy-eips 2.0.5", + "alloy-evm", + "alloy-primitives", + "alloy-rlp", + "alloy-rpc-types-eth", + "alloy-serde 2.0.5", + "bytes", + "reth-codecs 0.4.2", + "reth-primitives-traits 0.4.2", + "reth-zstd-compressors 0.4.2", + "revm 40.0.3", + "serde", + "thiserror 2.0.18", +] + [[package]] name = "base-common-genesis" version = "1.1.0" @@ -2756,6 +2777,7 @@ dependencies = [ "alloy-sol-types", "alloy-transport", "anvil", + "base-common-consensus", "chrono", "clap", "clap_complete", @@ -8817,6 +8839,25 @@ dependencies = [ "serde", ] +[[package]] +name = "reth-codecs" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c37bb4d1bac98661bf9128e1141b969034640d68697b22f70377e492fd332c" +dependencies = [ + "alloy-consensus", + "alloy-eips 2.0.5", + "alloy-genesis", + "alloy-primitives", + "alloy-trie", + "bytes", + "modular-bitfield", + "parity-scale-codec", + "reth-codecs-derive 0.4.2", + "reth-zstd-compressors 0.4.2", + "serde", +] + [[package]] name = "reth-codecs-derive" version = "0.2.0" @@ -8839,6 +8880,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "reth-codecs-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ceeb8dab90194305d3f7e3f043a22d2db1fb54b6dcf5d8e75c5a4fa8540e1f57" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "reth-ethereum-primitives" version = "2.2.0" @@ -8904,6 +8956,32 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "reth-primitives-traits" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9102518f0bbf99bc8f0e656a56fb2a7513248630275b608d3706076a82fde42b" +dependencies = [ + "alloy-consensus", + "alloy-eips 2.0.5", + "alloy-genesis", + "alloy-primitives", + "alloy-rlp", + "alloy-rpc-types-eth", + "alloy-trie", + "bytes", + "dashmap", + "derive_more", + "once_cell", + "reth-codecs 0.4.2", + "revm-bytecode 11.0.1", + "revm-primitives 24.0.1", + "revm-state 12.0.1", + "secp256k1 0.30.0", + "serde", + "thiserror 2.0.18", +] + [[package]] name = "reth-rpc-traits" version = "0.2.0" @@ -8937,6 +9015,15 @@ dependencies = [ "zstd", ] +[[package]] +name = "reth-zstd-compressors" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0e86cf594718932d42cebce1fa48292fb7b92721f7c914631add1ca970e814" +dependencies = [ + "zstd", +] + [[package]] name = "revm" version = "38.0.0" diff --git a/Cargo.toml b/Cargo.toml index 986a1dd3e..b8101dfbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -441,6 +441,10 @@ ethereum_ssz = "0.10" # Tempo tempo-primitives = { git = "https://github.com/tempoxyz/tempo", tag = "tempo-primitives@1.7.2", default-features = false, features = ["serde"] } +# EIP-8130 (account abstraction) consensus types. Pinned to a base/base commit +# that contains the `eip8130` module. Used by `cast` to build/sign EIP-8130 transactions. +base-common-consensus = { git = "https://github.com/base/base.git", rev = "a70d193532171f8ad1299d1c80f46bc6625789aa", default-features = false, features = ["std", "serde", "k256"] } + ## Pinned dependencies. Enabled for the workspace in crates/test-utils. # testing diff --git a/README.md b/README.md index b27f25ce6..20d47dfc6 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ base-foundryup This adds `base-foundryup` and the namespaced `base-forge`/`base-cast`/`base-anvil`/`base-chisel` commands, which enable Base precompiles by default. - **Test your Base app with base-anvil:** [`docs/base.md`](./docs/base.md) +- **Send EIP-8130 (account abstraction) transactions with cast:** [`docs/eip8130.md`](./docs/eip8130.md) - **Release model and the `base/base` pin (maintainers):** [`RELEASES.md`](./RELEASES.md) Everything below is inherited from upstream Foundry and works unchanged; only the Base additions above are specific to this fork. diff --git a/crates/cast/Cargo.toml b/crates/cast/Cargo.toml index 434039f99..f2f97e50f 100644 --- a/crates/cast/Cargo.toml +++ b/crates/cast/Cargo.toml @@ -32,6 +32,9 @@ foundry-wallets.workspace = true forge-fmt.workspace = true foundry-primitives.workspace = true +# EIP-8130 (tx type 0x79) consensus types for `cast send/mktx --8130`. +base-common-consensus.workspace = true + alloy-chains.workspace = true alloy-consensus = { workspace = true, features = ["serde", "kzg"] } alloy-contract.workspace = true diff --git a/crates/cast/src/cmd/mktx.rs b/crates/cast/src/cmd/mktx.rs index e78a4430c..1b563f286 100644 --- a/crates/cast/src/cmd/mktx.rs +++ b/crates/cast/src/cmd/mktx.rs @@ -99,6 +99,32 @@ impl MakeTxArgs { let provider = get_provider(&config)?; + // EIP-8130 (type 0x79) transactions are built, signed, and encoded directly (alloy's + // `EthereumWallet` doesn't understand the type) + if tx.eip8130.eip8130 { + if raw_unsigned { + eyre::bail!("--raw-unsigned is not supported for EIP-8130 transactions yet."); + } + if ethsign { + eyre::bail!("--ethsign is not supported for EIP-8130 transactions."); + } + if code.is_some() { + eyre::bail!("EIP-8130 transactions can't be CREATE transactions."); + } + + let signer = eth.wallet.signer().await?; + let from = signer.address(); + tx::validate_from_address(eth.wallet.from, from)?; + + let raw_tx = crate::eip8130::build_raw_transaction( + &provider, &signer, &tx, &config, to, sig, args, + ) + .await?; + + sh_println!("0x{}", hex::encode(&raw_tx))?; + return Ok(()); + } + let tx_builder = CastTxBuilder::new(&provider, tx.clone(), &config) .await? .with_to(to) diff --git a/crates/cast/src/cmd/send.rs b/crates/cast/src/cmd/send.rs index 3e50ed27e..720bb65f9 100644 --- a/crates/cast/src/cmd/send.rs +++ b/crates/cast/src/cmd/send.rs @@ -126,6 +126,54 @@ impl SendTxArgs { provider.client().set_poll_interval(Duration::from_secs(interval)) } + let timeout = send_tx.timeout.unwrap_or(config.transaction_timeout); + + // EIP-8130 (type 0x79) transactions are built, signed, and encoded directly (alloy's + // `EthereumWallet` doesn't understand the type), then broadcast as raw transactions + if tx.eip8130.eip8130 { + if unlocked { + return Err(eyre!( + "EIP-8130 transactions require a local signer and can't be sent with --unlocked" + )); + } + if send_tx.eth.wallet.browser { + return Err(eyre!("EIP-8130 transactions are not supported with browser wallets.")); + } + if code.is_some() { + return Err(eyre!("EIP-8130 transactions can't be CREATE transactions.")); + } + + let signer = send_tx.eth.wallet.signer().await?; + let from = signer.address(); + tx::validate_from_address(send_tx.eth.wallet.from, from)?; + + let raw_tx = crate::eip8130::build_raw_transaction( + &provider, &signer, &tx, &config, to, sig, args, + ) + .await?; + + let cast = CastTxSender::new(&provider); + let pending_tx = cast.send_raw(&raw_tx).await?; + let tx_hash = pending_tx.inner().tx_hash(); + + if send_tx.cast_async { + sh_println!("{tx_hash:#x}")?; + } else { + let receipt = cast + .receipt( + format!("{tx_hash:#x}"), + None, + send_tx.confirmations, + Some(timeout), + false, + ) + .await?; + sh_println!("{receipt}")?; + } + + return Ok(()); + } + let builder = CastTxBuilder::new(&provider, tx, &config) .await? .with_to(to) @@ -134,8 +182,6 @@ impl SendTxArgs { .await? .with_blob_data(blob_data)?; - let timeout = send_tx.timeout.unwrap_or(config.transaction_timeout); - // Check if this is a Tempo transaction - requires special handling for local signing let is_tempo = builder.is_tempo(); diff --git a/crates/cast/src/eip8130.rs b/crates/cast/src/eip8130.rs new file mode 100644 index 000000000..63dd0fe85 --- /dev/null +++ b/crates/cast/src/eip8130.rs @@ -0,0 +1,422 @@ +//! EIP-8130 (account abstraction, transaction type `0x79`) support for `cast send` / `cast mktx`. +//! +//! EIP-8130 is not understood by alloy's `EthereumWallet`, so like Tempo (`0x76`), these +//! transactions are built, signed, and encoded here directly using the canonical +//! [`base_common_consensus`] types, then broadcast via `eth_sendRawTransaction` (or printed by +//! `mktx`). This keeps all EIP-8130 handling contained to `cast` with no changes to `anvil` or +//! the shared `FoundryTxEnvelope`. +//! +//! ## Value handling +//! +//! Protocol-level EIP-8130 calls carry no value (`msg.value == 0`); native ETH transfers are +//! performed by the account's wallet bytecode. A code-less EOA sender is auto-delegated to +//! `DEFAULT_ACCOUNT`, whose `executeBatch(Call[])` entrypoint forwards value per sub-call. So any +//! phase that contains a value-bearing call is wrapped into a single self-call to +//! `executeBatch`, letting the wallet forward the value. Zero-value phases are emitted as native +//! `{to, data}` calls. + +use alloy_eips::Encodable2718; +use alloy_ens::NameOrAddress; +use alloy_network::AnyNetwork; +use alloy_primitives::{Address, Bytes, U64, U256}; +use alloy_provider::Provider; +use alloy_signer::Signer; +use alloy_sol_types::{SolCall, sol}; +use base_common_consensus::{Call as ProtocolCall, EIP8130_TX_TYPE_ID, Eip8130Signed, TxEip8130}; +use eyre::{Result, eyre}; +use foundry_cli::{ + opts::TransactionOpts, + utils::{self, parse_ether_value, parse_function_args}, +}; +use foundry_config::{Chain, Config}; +use foundry_wallets::WalletSigner; +use serde::Deserialize; + +sol! { + /// Mirrors `base/eip-8130`'s `DefaultAccount` call struct and batch entrypoint. + #[allow(missing_docs)] + struct DefaultAccountCall { + address target; + uint256 value; + bytes data; + } + #[allow(missing_docs)] + function executeBatch(DefaultAccountCall[] calls); +} + +/// A single call in a `--calls` JSON phase. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct JsonCall { + /// Target address of the call. + to: Address, + /// ETH value to forward (wei or unit string, e.g. `0.1ether`). Defaults to zero. + #[serde(default)] + value: Option, + /// Function signature to encode with `args` (alternative to `data`). + #[serde(default)] + sig: Option, + /// Arguments for `sig`. + #[serde(default)] + args: Vec, + /// Raw hex calldata (alternative to `sig`/`args`). + #[serde(default)] + data: Option, +} + +/// A resolved logical call before value-wrapping: `(to, value, data)`. +struct LogicalCall { + to: Address, + value: U256, + data: Bytes, +} + +/// Builds and signs an EIP-8130 transaction, returning the EIP-2718 encoded raw bytes ready to be +/// broadcast via `eth_sendRawTransaction` or printed by `mktx`. +/// +/// `to`/`sig`/`args` describe the single-call form (used when `--calls` is not provided); when +/// `--calls` is set they are ignored in favor of the JSON phases. +pub async fn build_raw_transaction>( + provider: &P, + signer: &WalletSigner, + tx_opts: &TransactionOpts, + config: &Config, + to: Option, + sig: Option, + args: Vec, +) -> Result> { + let opts = &tx_opts.eip8130; + let from = signer.address(); + let chain = utils::get_chain(config.chain, provider).await?; + let chain_id = chain.id(); + let etherscan_api_key = config.get_etherscan_api_key(Some(chain)); + + // Resolve the logical call phases + let phases = if let Some(calls_json) = &opts.calls { + parse_calls_json(provider, chain, etherscan_api_key.as_deref(), calls_json).await? + } else { + // Single-call form from the positional args + let to = match to { + Some(to) => to.resolve(provider).await?, + None => eyre::bail!( + "EIP-8130 transactions require a recipient (positional TO or --calls); \ + they cannot be CREATE transactions" + ), + }; + let (data, _) = parse_function_args( + &sig.unwrap_or_default(), + args, + Some(to), + chain, + provider, + etherscan_api_key.as_deref(), + ) + .await?; + let value = tx_opts.value.unwrap_or(U256::ZERO); + vec![vec![LogicalCall { to, value, data: data.into() }]] + }; + + // Encode logical phases into protocol calls, wrapping value via the account wallet + let calls = encode_phases(from, phases)?; + + // Resolve nonce (key + sequence) + let nonce_key = opts.nonce_key.unwrap_or(U256::ZERO); + let nonce_free = nonce_key == U256::MAX; + let nonce_sequence = resolve_nonce_sequence(provider, from, nonce_key, opts.nonce_seq).await?; + + // Expiry + nonce-free structural validation + let expiry = opts.expiry.unwrap_or(0); + if nonce_free { + if expiry == 0 { + eyre::bail!( + "the expiring nonce channel (--nonce-key = 2^256-1) requires a non-zero --expiry" + ); + } + if nonce_sequence != 0 { + eyre::bail!( + "the expiring nonce channel (--nonce-key = 2^256-1) requires --nonce-seq to be 0" + ); + } + } + + // Fees + let (max_fee_per_gas, max_priority_fee_per_gas) = resolve_fees(provider, tx_opts).await?; + + let mut tx = TxEip8130 { + chain_id, + sender: None, + nonce_key, + nonce_sequence, + expiry, + max_priority_fee_per_gas, + max_fee_per_gas, + gas_limit: 0, + account_changes: Vec::new(), + calls, + metadata: opts.metadata.clone().unwrap_or_default(), + payer: None, + }; + + // Gas limit + tx.gas_limit = match tx_opts.gas_limit { + Some(gas_limit) => gas_limit.to::(), + None => estimate_gas(provider, &tx, from).await?, + }; + + // Sign (EOA path: 65-byte r||s||v over the sender signature hash) + let signature = signer.sign_hash(&tx.sender_signature_hash()).await?; + let sender_auth = Bytes::copy_from_slice(&signature.as_bytes()); + let signed = Eip8130Signed::new(tx, sender_auth, Bytes::new()); + + let mut raw = Vec::with_capacity(signed.encode_2718_len()); + signed.encode_2718(&mut raw); + Ok(raw) +} + +/// Encodes logical phases into protocol `calls`, wrapping any value-bearing phase into a single +/// `executeBatch` self-call (`to == sender`) so the account wallet forwards the value. +fn encode_phases(from: Address, phases: Vec>) -> Result>> { + if phases.is_empty() || phases.iter().all(|p| p.is_empty()) { + eyre::bail!("EIP-8130 transaction has no calls"); + } + + let mut out = Vec::with_capacity(phases.len()); + for phase in phases { + if phase.iter().any(|c| !c.value.is_zero()) { + // Wrap the whole phase in a DEFAULT_ACCOUNT.executeBatch self-call + let batch = executeBatchCall { + calls: phase + .into_iter() + .map(|c| DefaultAccountCall { target: c.to, value: c.value, data: c.data }) + .collect(), + }; + out.push(vec![ProtocolCall { to: from, data: batch.abi_encode().into() }]); + } else { + out.push(phase.into_iter().map(|c| ProtocolCall { to: c.to, data: c.data }).collect()); + } + } + Ok(out) +} + +/// Parses the `--calls` JSON into logical phases, encoding each call's `sig`/`args` or `data`. +async fn parse_calls_json>( + provider: &P, + chain: Chain, + etherscan_api_key: Option<&str>, + json: &str, +) -> Result>> { + let phases: Vec> = serde_json::from_str(json) + .map_err(|e| eyre!("failed to parse --calls JSON (expected an array of phases): {e}"))?; + + let mut out = Vec::with_capacity(phases.len()); + for phase in phases { + let mut resolved = Vec::with_capacity(phase.len()); + for call in phase { + let data = match (call.data, call.sig) { + (Some(_), Some(_)) => { + eyre::bail!("call specifies both `data` and `sig`; provide only one") + } + (Some(data), None) => data, + (None, Some(sig)) => { + let (data, _) = parse_function_args( + &sig, + call.args, + Some(call.to), + chain, + provider, + etherscan_api_key, + ) + .await?; + data.into() + } + (None, None) => Bytes::new(), + }; + let value = match call.value { + Some(v) => parse_ether_value(&v)?, + None => U256::ZERO, + }; + resolved.push(LogicalCall { to: call.to, value, data }); + } + out.push(resolved); + } + Ok(out) +} + +/// Resolves the nonce sequence: explicit `--nonce-seq`, else queried from the node +/// (`eth_getTransactionCount(from, "pending", nonceKey)`; `nonce_key == 0` is the standard count). +async fn resolve_nonce_sequence>( + provider: &P, + from: Address, + nonce_key: U256, + explicit: Option, +) -> Result { + if let Some(seq) = explicit { + return Ok(seq); + } + if nonce_key == U256::MAX { + // Expiring/nonce-free channel: no counter exists, sequence must be 0 + return Ok(0); + } + if nonce_key.is_zero() { + return Ok(provider.get_transaction_count(from).pending().await?); + } + // Keyed channel: query the extended eth_getTransactionCount with the nonce key + let count: U64 = provider + .raw_request("eth_getTransactionCount".into(), (from, "pending", nonce_key)) + .await + .map_err(|e| { + eyre!( + "failed to query keyed nonce for --nonce-key {nonce_key} (node must support \ + EIP-8130 eth_getTransactionCount; otherwise pass --nonce-seq): {e}" + ) + })?; + Ok(count.to::()) +} + +/// Resolves the EIP-1559-style fees, honoring `--gas-price`/`--priority-gas-price` and otherwise +/// estimating from the provider +async fn resolve_fees>( + provider: &P, + tx_opts: &TransactionOpts, +) -> Result<(u128, u128)> { + let mut max_fee = tx_opts.gas_price.map(|v| v.to::()); + let mut max_priority = tx_opts.priority_gas_price.map(|v| v.to::()); + + if max_fee.is_none() || max_priority.is_none() { + let estimate = provider.estimate_eip1559_fees().await?; + max_fee.get_or_insert(estimate.max_fee_per_gas); + max_priority.get_or_insert(estimate.max_priority_fee_per_gas); + } + + Ok((max_fee.unwrap(), max_priority.unwrap())) +} + +/// Best-effort gas estimation via the node's EIP-8130-aware `eth_estimateGas`. Falls back to a +/// clear error asking for `--gas-limit` if the node does not accept the request shape +async fn estimate_gas>( + provider: &P, + tx: &TxEip8130, + from: Address, +) -> Result { + let mut request = serde_json::to_value(tx)?; + if let Some(obj) = request.as_object_mut() { + obj.insert("from".into(), serde_json::json!(from)); + obj.insert("type".into(), serde_json::json!(format!("0x{EIP8130_TX_TYPE_ID:x}"))); + obj.remove("gasLimit"); + } + + let estimated: U64 = + provider.raw_request("eth_estimateGas".into(), (request,)).await.map_err(|e| { + eyre!( + "failed to estimate gas for the EIP-8130 transaction (pass --gas-limit to set it \ + explicitly): {e}" + ) + })?; + Ok(estimated.to::()) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{address, bytes}; + use alloy_signer::SignerSync; + use alloy_signer_local::PrivateKeySigner; + + // Well-known anvil test key (account 0). + const TEST_KEY: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + fn logical(to: Address, value: u64, data: &[u8]) -> LogicalCall { + LogicalCall { to, value: U256::from(value), data: Bytes::copy_from_slice(data) } + } + + #[test] + fn zero_value_phase_emits_native_calls() { + let from = address!("0x1111111111111111111111111111111111111111"); + let a = address!("0x00000000000000000000000000000000000000aa"); + let b = address!("0x00000000000000000000000000000000000000bb"); + let calls = encode_phases( + from, + vec![vec![logical(a, 0, &hex_lit("dead")), logical(b, 0, &hex_lit("beef"))]], + ) + .unwrap(); + + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].len(), 2, "zero-value calls stay native, one protocol call each"); + assert_eq!(calls[0][0].to, a); + assert_eq!(calls[0][0].data, bytes!("dead")); + assert_eq!(calls[0][1].to, b); + } + + #[test] + fn value_phase_wraps_in_execute_batch_self_call() { + let from = address!("0x1111111111111111111111111111111111111111"); + let target = address!("0x00000000000000000000000000000000000000aa"); + let calls = + encode_phases(from, vec![vec![logical(target, 100, &hex_lit("dead"))]]).unwrap(); + + // Value-bearing phase collapses to a single self-call (to == sender). + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].len(), 1); + assert_eq!(calls[0][0].to, from, "value phase is a self-call to the account wallet"); + + // The calldata must decode as DEFAULT_ACCOUNT.executeBatch([{target,value,data}]). + let decoded = executeBatchCall::abi_decode(&calls[0][0].data).unwrap(); + assert_eq!(decoded.calls.len(), 1); + assert_eq!(decoded.calls[0].target, target); + assert_eq!(decoded.calls[0].value, U256::from(100)); + assert_eq!(decoded.calls[0].data, bytes!("dead")); + } + + #[test] + fn empty_calls_is_rejected() { + let from = address!("0x1111111111111111111111111111111111111111"); + assert!(encode_phases(from, vec![]).is_err()); + assert!(encode_phases(from, vec![vec![]]).is_err()); + } + + #[test] + fn sign_encode_decode_roundtrip_recovers_sender() { + let signer: PrivateKeySigner = TEST_KEY.parse().unwrap(); + let from = signer.address(); + + let tx = TxEip8130 { + chain_id: 8453, + sender: None, + nonce_key: U256::ZERO, + nonce_sequence: 1, + expiry: 0, + max_priority_fee_per_gas: 1_000_000_000, + max_fee_per_gas: 2_000_000_000, + gas_limit: 21_000, + account_changes: Vec::new(), + calls: vec![vec![ProtocolCall { + to: address!("0x00000000000000000000000000000000000000aa"), + data: bytes!("deadbeef"), + }]], + metadata: Bytes::new(), + payer: None, + }; + + let signature = signer.sign_hash_sync(&tx.sender_signature_hash()).unwrap(); + let sender_auth = Bytes::copy_from_slice(&signature.as_bytes()); + let signed = Eip8130Signed::new(tx.clone(), sender_auth, Bytes::new()); + + let mut raw = Vec::with_capacity(signed.encode_2718_len()); + signed.encode_2718(&mut raw); + + // EIP-2718 typed envelope prefixed with the pinned AA_TX_TYPE. + assert_eq!(raw[0], EIP8130_TX_TYPE_ID, "EIP-8130 tx uses the pinned AA tx type byte"); + + // Round-trips through base-common-consensus's own decoder (proves wire compatibility) + let decoded = Eip8130Signed::rlp_decode_signed(&mut &raw[1..]).unwrap(); + assert_eq!(decoded.tx(), &tx); + + // The EOA signature recovers to the signer (proves the sender_auth format is accepted) + assert_eq!(decoded.recover_sender().unwrap(), from); + } + + // Small hex-literal helper for building call data in tests + fn hex_lit(s: &str) -> Vec { + alloy_primitives::hex::decode(s).unwrap() + } +} diff --git a/crates/cast/src/lib.rs b/crates/cast/src/lib.rs index eef0ea838..d81a33820 100644 --- a/crates/cast/src/lib.rs +++ b/crates/cast/src/lib.rs @@ -61,6 +61,7 @@ pub mod opts; pub mod base; pub(crate) mod debug; +pub mod eip8130; pub mod errors; mod rlp_converter; pub mod tx; diff --git a/crates/cli/src/opts/eip8130.rs b/crates/cli/src/opts/eip8130.rs new file mode 100644 index 000000000..1d075ca69 --- /dev/null +++ b/crates/cli/src/opts/eip8130.rs @@ -0,0 +1,67 @@ +use alloy_primitives::{Bytes, U256}; +use clap::Parser; + +/// CLI options for EIP-8130 (account abstraction, transaction type `0x79`) transactions. +/// +/// EIP-8130 replaces the single scalar nonce with a 2D `(nonce_key, nonce_sequence)` +/// nonce, adds an expiry, opaque metadata and native call batching. The protocol-level +/// calls carry no value (`msg.value == 0`); native ETH transfers are performed by the +/// account's wallet bytecode, so a value-bearing call is transparently wrapped as a +/// self-call to the account's `DEFAULT_ACCOUNT` `executeBatch` entrypoint (see +/// `cast`'s `eip8130` module). +#[derive(Clone, Debug, Default, Parser)] +#[command(next_help_heading = "EIP-8130 (account abstraction)")] +pub struct Eip8130Opts { + /// Send an EIP-8130 (account abstraction, type `0x79`) transaction. + /// + /// Selects the `0x79` transaction type. Incompatible with `--legacy`, `--blob`, + /// `--auth` and `--create`. + #[arg( + long = "8130", + conflicts_with_all = &["legacy", "blob", "auth"], + help_heading = "EIP-8130 (account abstraction)" + )] + pub eip8130: bool, + + /// EIP-8130 nonce key: the channel selector of the 2D nonce. + /// + /// `0` (default) is the standard sequential channel; `1..=NONCE_KEY_MAX-1` are + /// independent parallel channels; `2^256-1` selects the expiring/nonce-free channel + /// (which requires `--expiry` and forbids `--nonce-seq`). + #[arg(long = "nonce-key", requires = "eip8130", value_name = "UINT256")] + pub nonce_key: Option, + + /// EIP-8130 nonce sequence: the counter within `--nonce-key`. + /// + /// If omitted, it is resolved from the node via `eth_getTransactionCount(from, "pending", + /// nonceKey)`. + #[arg(long = "nonce-seq", requires = "eip8130", value_name = "U64")] + pub nonce_seq: Option, + + /// EIP-8130 expiry: unix-seconds timestamp after which the transaction is invalid. + /// + /// `0` (default) means no expiry. Required (non-zero) in the expiring/nonce-free channel. + #[arg(long = "expiry", requires = "eip8130", value_name = "UNIX_SECS")] + pub expiry: Option, + + /// EIP-8130 opaque metadata bytes (hex). Committed to by the signature but otherwise + /// uninterpreted by the protocol + #[arg(long = "metadata", requires = "eip8130", value_name = "HEX")] + pub metadata: Option, + + /// EIP-8130 calls, as JSON, for native multicall / multi-phase transactions. + /// + /// The value is an array of phases; each phase is an array of calls; each call is + /// `{ "to": address, "value"?: string, "sig"?: string, "args"?: [..], "data"?: hex }`. + /// Phases execute atomically and in order. A call may specify either `sig`(+`args`) or + /// raw `data`. Any call with a non-zero `value` causes its whole phase to be wrapped in + /// a `DEFAULT_ACCOUNT.executeBatch` self-call so the value is forwarded by the wallet. + /// + /// When omitted, the positional `TO SIG ARGS` (with `--value`) form a single call. + /// + /// Example: + /// --calls '[[{"to":"0xA","sig":"approve(address,uint256)","args":["0xB","100"]}, + /// {"to":"0xB","value":"0.1ether","sig":"deposit()"}]]' + #[arg(long = "calls", requires = "eip8130", value_name = "JSON")] + pub calls: Option, +} diff --git a/crates/cli/src/opts/mod.rs b/crates/cli/src/opts/mod.rs index c173ebe3f..c696a9d34 100644 --- a/crates/cli/src/opts/mod.rs +++ b/crates/cli/src/opts/mod.rs @@ -1,6 +1,7 @@ mod build; mod chain; mod dependency; +mod eip8130; mod evm; mod global; mod rpc; @@ -10,6 +11,7 @@ mod transaction; pub use build::*; pub use chain::*; pub use dependency::*; +pub use eip8130::*; pub use evm::*; pub use global::*; pub use rpc::*; diff --git a/crates/cli/src/opts/transaction.rs b/crates/cli/src/opts/transaction.rs index db75d2c39..aefd2eff9 100644 --- a/crates/cli/src/opts/transaction.rs +++ b/crates/cli/src/opts/transaction.rs @@ -1,6 +1,6 @@ use std::str::FromStr; -use super::TempoOpts; +use super::{Eip8130Opts, TempoOpts}; use crate::utils::{parse_ether_value, parse_json}; use alloy_eips::{eip2930::AccessList, eip7702::SignedAuthorization}; use alloy_primitives::{Address, U64, U256, hex}; @@ -108,6 +108,9 @@ pub struct TransactionOpts { #[command(flatten)] pub tempo: TempoOpts, + + #[command(flatten)] + pub eip8130: Eip8130Opts, } #[cfg(test)] diff --git a/docs/base.md b/docs/base.md index f1a8f1ca0..7c38cd5f0 100644 --- a/docs/base.md +++ b/docs/base.md @@ -142,5 +142,7 @@ chain: creates a token, mints supply, and verifies the balance using these commands. - **The B20 token standard:** [docs.base.org/base-chain/specs/upgrades/beryl/b20](https://docs.base.org/base-chain/specs/upgrades/beryl/b20). +- **Send EIP-8130 (account abstraction) transactions with cast:** + [`docs/eip8130.md`](./eip8130.md). - **Maintainers, the `base/base` pin and release lifecycle:** [`RELEASES.md`](../RELEASES.md). diff --git a/docs/eip8130.md b/docs/eip8130.md new file mode 100644 index 000000000..4d909aee3 --- /dev/null +++ b/docs/eip8130.md @@ -0,0 +1,165 @@ +# Sending EIP-8130 (account abstraction) transactions with cast + +[EIP-8130](https://eips.ethereum.org/EIPS/eip-8130) adds protocol-native account +abstraction to Base via a new transaction type, **`0x79`**. base-anvil's `cast` +can build, sign, and broadcast these transactions with `cast send --8130` and +`cast mktx --8130`. + +An `0x79` transaction differs from a normal EIP-1559 transaction in a few ways: + +- a **2D nonce** a `(nonce_key, nonce_sequence)` pair instead of a single scalar, + so an account can have independent, parallel nonce channels; +- native **call batching** into ordered, atomic **phases** (`calls`), instead of a + single `(to, value, data)`; +- an **expiry**, opaque **metadata**, and (not yet exposed here) sponsorship and + account-configuration changes. + +> **This is a cast-only feature.** base-anvil's own `anvil` node does **not** +> execute `0x79` transactions. It has no EIP-8130 support and will reject the +> type. To actually submit and execute one you need a node with the **Cobalt** +> hardfork active, such as a local `base/base` devnet (see +> [Testing locally](#testing-locally)) or a Cobalt-enabled network. + +## Quick start + +```bash +# Simple call as an 0x79 transaction (nonce key 0 = the default sequential channel): +cast send --8130 0xContract "transfer(address,uint256)" 0xTo 1000 \ + --rpc-url http://localhost:8545 --private-key $PK --gas-limit 200000 +``` + +`--8130` selects the transaction type; everything else follows the usual +`cast send` shape. The `--legacy`, `--blob`, and `--auth` flags are mutually +exclusive with `--8130`, and `0x79` transactions can't be `--create`. + +## Flags + +| Flag | Meaning | +| ----------------------- | --------------------------------------------------------------------------------------------------- | +| `--8130` | Send an EIP-8130 (`0x79`) transaction. | +| `--nonce-key ` | Nonce channel selector (the 2D-nonce "key"). Default `0`. | +| `--nonce-seq ` | Sequence within `--nonce-key`. Auto-resolved from the node if omitted. | +| `--expiry ` | Unix-seconds expiry; `0` (default) means no expiry. | +| `--metadata ` | Opaque attribution bytes, committed to by the signature. | +| `--calls ` | Multiple calls / phases (see [Multicall](#multicall-and-phases)). | +| `--value ` | ETH to send with the (single) call; forwarded via the account wallet (see [Value](#sending-value)). | + +The standard `--gas-limit`, `--gas-price` (max fee per gas), `--priority-gas-price`, +`--nonce` and wallet/RPC flags apply as usual. When `--gas-limit` is omitted, cast +asks the node to estimate it; if the node can't, pass `--gas-limit` explicitly. + +## The 2D nonce + +EIP-8130 replaces the single account nonce with `(nonce_key, nonce_sequence)`: + +- **`--nonce-key 0`** (default) is the standard sequential channel. It behaves like + a normal account nonce. +- **`--nonce-key `** for `1 ≤ n < 2²⁵⁶-1` is an independent parallel channel. Two + transactions on different keys have no ordering dependency on each other, so they + can be included in parallel. +- **`--nonce-key` = `2²⁵⁶-1`** selects the _expiring / nonce-free_ channel: there is + no sequence counter, so `--nonce-seq` must be `0` and `--expiry` must be non-zero + (it is the only replay bound). cast enforces both. + +If you omit `--nonce-seq`, cast resolves it from the node: +`eth_getTransactionCount(from, "pending", nonceKey)`. The `nonceKey`-aware form +requires a Cobalt node; on the default key (`0`) it is just the normal transaction +count. + +```bash +# Two independent transactions on parallel channels 1 and 2: +cast send --8130 --nonce-key 1 0xA "poke()" --rpc-url $RPC --private-key $PK --gas-limit 100000 +cast send --8130 --nonce-key 2 0xB "poke()" --rpc-url $RPC --private-key $PK --gas-limit 100000 +``` + +## Sending value + +Protocol-level EIP-8130 calls always run with `msg.value == 0`; native ETH +transfers are performed by the account's wallet bytecode. A code-less EOA sender is +auto-delegated by the protocol to the **default account**, whose +`executeBatch(Call[])` entrypoint forwards value per sub-call. + +cast handles this for you: when a call carries a non-zero value, cast wraps that +phase into a single self-call to the sender's `executeBatch`, so the wallet forwards +the ETH. You just use `--value` as normal: + +```bash +# Send 0.1 ETH to an address as an 0x79 transaction: +cast send --8130 0xRecipient --value 0.1ether \ + --rpc-url $RPC --private-key $PK --gas-limit 100000 +``` + +Under the hood this becomes one protocol call to your own account, carrying +`executeBatch([{ target: 0xRecipient, value: 0.1e18, data: 0x }])`. + +## Multicall and phases + +`--calls` takes a JSON array of **phases**; each phase is an array of calls. Phases +execute in order and each phase is atomic (if any call in a phase reverts, that +phase is rolled back; earlier committed phases persist). A call is either a +`sig`(+`args`) or raw `data`, with an optional `value`: + +```bash +cast send --8130 \ + --calls '[ + [ + {"to":"0xToken","sig":"approve(address,uint256)","args":["0xPool","100"]}, + {"to":"0xPool","sig":"deposit(uint256)","args":["100"]} + ], + [ + {"to":"0xRecipient","value":"0.05ether"} + ] + ]' \ + --rpc-url $RPC --private-key $PK --gas-limit 500000 +``` + +- The first phase does an approve + deposit as a native two-call batch (both zero + value). +- The second phase sends `0.05 ETH`; because it has a value, cast wraps it in an + `executeBatch` self-call so the wallet forwards it. + +Each call object accepts: `to` (required), `sig` + `args` **or** `data`, and an +optional `value` (wei or a unit string like `0.1ether`). When `--calls` is given, +the positional `TO SIG ARGS` are ignored. + +## Building without broadcasting + +`cast mktx --8130 …` builds and signs the transaction and prints the raw +EIP-2718-encoded (`0x79…`) bytes instead of broadcasting, so you can inspect it or +publish it later with `cast publish`: + +```bash +RAW=$(cast mktx --8130 0xTo "poke()" --nonce-seq 0 --gas-limit 100000 \ + --rpc-url $RPC --private-key $PK) +echo "$RAW" # 0x79... +cast publish "$RAW" --rpc-url $RPC +``` + +Like normal `cast mktx`, it still reads chain state (nonce, fees, gas, chain id) +from the RPC for any field you don't pass explicitly. It does **not** support +`--raw-unsigned` or `--ethsign` for `0x79` yet. + +## Testing locally + +base-anvil's `cast` builds the transaction, but a **Cobalt-enabled node** must +execute it. The simplest option is a local `base/base` devnet. + +In a checkout of [`base/base`](https://github.com/base/base): + +```bash +just devnet up # starts an L1 + L2 Base devnet +just devnet status # wait until the L2 is producing blocks +just devnet accounts # prints pre-funded dev accounts (address + private key) +``` + +## Current limitations + +- **EOA sender only.** Configured-actor / smart-account authenticators (P-256, + WebAuthn) aren't exposed yet. +- **No sponsorship.** The `payer` field (a separate gas payer that co-signs) isn't + exposed; the sender always pays. +- **No account-configuration changes.** The `account_changes` field (account + creation, config changes, delegation) isn't exposed. +- **Gas estimation is best-effort.** If the node can't estimate an `0x79` + transaction, pass `--gas-limit`. +- `mktx --raw-unsigned` / `--ethsign` are unsupported for `0x79`.