diff --git a/Cargo.lock b/Cargo.lock index 4ff7e30..4e2914f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -772,8 +772,13 @@ dependencies = [ name = "alphanet" version = "0.0.0" dependencies = [ + "alloy-network", + "alloy-primitives", + "alloy-signer-local", "alphanet-node", + "alphanet-wallet", "clap", + "eyre", "reth-cli-util", "reth-node-builder", "reth-optimism-cli", @@ -841,10 +846,17 @@ dependencies = [ name = "alphanet-wallet" version = "0.0.0" dependencies = [ + "alloy-network", "alloy-primitives", "alloy-rpc-types", "jsonrpsee", + "reth-primitives", + "reth-rpc-eth-api", + "reth-storage-api", "serde", + "serde_json", + "thiserror", + "tracing", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a23c915..73b6bd2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,6 +80,9 @@ reth-chainspec = { git = "https://github.com/paradigmxyz/reth.git", rev = "78426 ] } reth-cli = { git = "https://github.com/paradigmxyz/reth.git", rev = "7842673" } reth-cli-util = { git = "https://github.com/paradigmxyz/reth.git", rev = "7842673" } +reth-rpc-eth-api = { git = "https://github.com/paradigmxyz/reth.git", rev = "7842673", features = [ + "optimism", +] } reth-node-api = { git = "https://github.com/paradigmxyz/reth.git", rev = "7842673" } reth-node-builder = { git = "https://github.com/paradigmxyz/reth.git", rev = "7842673" } reth-node-core = { git = "https://github.com/paradigmxyz/reth.git", rev = "7842673", features = [ @@ -106,6 +109,7 @@ reth-provider = { git = "https://github.com/paradigmxyz/reth.git", rev = "784267 reth-revm = { git = "https://github.com/paradigmxyz/reth.git", rev = "7842673", features = [ "optimism", ] } +reth-storage-api = { git = "https://github.com/paradigmxyz/reth.git", rev = "7842673" } reth-tracing = { git = "https://github.com/paradigmxyz/reth.git", rev = "7842673" } reth-transaction-pool = { git = "https://github.com/paradigmxyz/reth.git", rev = "7842673" } @@ -119,6 +123,7 @@ tracing = "0.1.0" serde = "1" serde_json = "1" once_cell = "1.19" +thiserror = "1" # misc-testing rstest = "0.18.2" diff --git a/bin/alphanet/Cargo.toml b/bin/alphanet/Cargo.toml index a15ef6b..1545089 100644 --- a/bin/alphanet/Cargo.toml +++ b/bin/alphanet/Cargo.toml @@ -12,7 +12,12 @@ default-run = "alphanet" workspace = true [dependencies] +alloy-signer-local.workspace = true +alloy-network.workspace = true +alloy-primitives.workspace = true alphanet-node.workspace = true +alphanet-wallet.workspace = true +eyre.workspace = true tracing.workspace = true reth-cli-util.workspace = true reth-node-builder.workspace = true diff --git a/bin/alphanet/src/main.rs b/bin/alphanet/src/main.rs index d1963e4..fa4279e 100644 --- a/bin/alphanet/src/main.rs +++ b/bin/alphanet/src/main.rs @@ -23,13 +23,19 @@ //! - `min-debug-logs`: Disables all logs below `debug` level. //! - `min-trace-logs`: Disables all logs below `trace` level. +use alloy_network::EthereumWallet; +use alloy_primitives::Address; +use alloy_signer_local::PrivateKeySigner; use alphanet_node::{chainspec::AlphanetChainSpecParser, node::AlphaNetNode}; +use alphanet_wallet::{AlphaNetWallet, AlphaNetWalletApiServer}; use clap::Parser; +use eyre::Context; use reth_node_builder::{engine_tree_config::TreeConfig, EngineNodeLauncher}; use reth_optimism_cli::Cli; use reth_optimism_node::{args::RollupArgs, node::OptimismAddOns}; use reth_optimism_rpc::sequencer::SequencerClient; use reth_provider::providers::BlockchainProvider2; +use tracing::{info, warn}; // We use jemalloc for performance reasons. #[cfg(all(feature = "jemalloc", unix))] @@ -60,6 +66,36 @@ fn main() { .set_sequencer_client(SequencerClient::new(sequencer_http))?; } + // register alphanet wallet namespace + if let Ok(sk) = std::env::var("EXP1_SK") { + let signer: PrivateKeySigner = + sk.parse().wrap_err("Invalid EXP0001 secret key.")?; + let wallet = EthereumWallet::from(signer); + + let raw_delegations = std::env::var("EXP1_WHITELIST") + .wrap_err("No EXP0001 delegations specified")?; + let valid_delegations: Vec
= raw_delegations + .split(',') + .map(|addr| Address::parse_checksummed(addr, None)) + .collect::>() + .wrap_err("No valid EXP0001 delegations specified")?; + + ctx.modules.merge_configured( + AlphaNetWallet::new( + ctx.provider().clone(), + wallet, + ctx.registry.eth_api().clone(), + ctx.config().chain.chain().id(), + valid_delegations, + ) + .into_rpc(), + )?; + + info!(target: "reth::cli", "EXP0001 wallet configured"); + } else { + warn!(target: "reth::cli", "EXP0001 wallet not configured"); + } + Ok(()) }) .launch_with_fn(|builder| { diff --git a/clippy.toml b/clippy.toml index d5cd174..1d4b5c8 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1,2 +1,2 @@ -msrv = "1.80" +msrv = "1.81" allow-dbg-in-tests = true diff --git a/crates/wallet/Cargo.toml b/crates/wallet/Cargo.toml index 44bdf84..29747e0 100644 --- a/crates/wallet/Cargo.toml +++ b/crates/wallet/Cargo.toml @@ -10,10 +10,20 @@ keywords.workspace = true categories.workspace = true [dependencies] +alloy-network.workspace = true alloy-primitives.workspace = true alloy-rpc-types.workspace = true jsonrpsee = { workspace = true, features = ["server", "macros"] } +reth-primitives.workspace = true +reth-storage-api.workspace = true +reth-rpc-eth-api.workspace = true serde = { workspace = true, features = ["derive"] } +thiserror.workspace = true +tracing.workspace = true + +[dev-dependencies] +serde_json.workspace = true +jsonrpsee = { workspace = true, features = ["server", "client", "macros"] } [lints] workspace = true diff --git a/crates/wallet/src/lib.rs b/crates/wallet/src/lib.rs index 39411e5..95538be 100644 --- a/crates/wallet/src/lib.rs +++ b/crates/wallet/src/lib.rs @@ -17,10 +17,20 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] -use alloy_primitives::{map::HashMap, Address, ChainId, TxHash}; +use alloy_network::{ + eip2718::Encodable2718, Ethereum, EthereumWallet, NetworkWallet, TransactionBuilder, +}; +use alloy_primitives::{map::HashMap, Address, ChainId, TxHash, TxKind, U256}; use alloy_rpc_types::TransactionRequest; -use jsonrpsee::{core::RpcResult, proc_macros::rpc}; +use jsonrpsee::{ + core::{async_trait, RpcResult}, + proc_macros::rpc, +}; +use reth_primitives::{revm_primitives::Bytecode, BlockId}; +use reth_rpc_eth_api::helpers::{EthCall, EthState, EthTransactions, FullEthApi}; +use reth_storage_api::{StateProvider, StateProviderFactory}; use serde::{Deserialize, Serialize}; +use tracing::trace; /// The capability to perform [EIP-7702][eip-7702] delegations, sponsored by the sequencer. /// @@ -28,23 +38,30 @@ use serde::{Deserialize, Serialize}; /// account delegates to one of the addresses specified within this capability. /// /// [eip-7702]: https://eips.ethereum.org/EIPS/eip-7702 -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] pub struct DelegationCapability { /// A list of valid delegation contracts. pub addresses: Vec
, } /// Wallet capabilities for a specific chain. -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] pub struct Capabilities { /// The capability to delegate. pub delegation: DelegationCapability, } /// A map of wallet capabilities per chain ID. -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] pub struct WalletCapabilities(pub HashMap); +impl WalletCapabilities { + /// Get the capabilities of the wallet API for the specified chain ID. + pub fn get(&self, chain_id: ChainId) -> Option<&Capabilities> { + self.0.get(&chain_id) + } +} + /// AlphaNet `wallet_` RPC namespace. #[cfg_attr(not(test), rpc(server, namespace = "wallet"))] #[cfg_attr(test, rpc(server, client, namespace = "wallet"))] @@ -75,5 +92,251 @@ pub trait AlphaNetWalletApi { /// [eip-7702]: https://eips.ethereum.org/EIPS/eip-7702 /// [eip-1559]: https://eips.ethereum.org/EIPS/eip-1559 #[method(name = "sendTransaction")] - fn send_transaction(&self, request: TransactionRequest) -> RpcResult; + async fn send_transaction(&self, request: TransactionRequest) -> RpcResult; +} + +/// Errors returned by the wallet API. +#[derive(Debug, thiserror::Error)] +pub enum AlphaNetWalletError { + /// The transaction value is not 0. + /// + /// The value should be 0 to prevent draining the sequencer. + #[error("tx value not zero")] + ValueNotZero, + /// The from field is set on the transaction. + /// + /// Requests with the from field are rejected, since it is implied that it will always be the + /// sequencer. + #[error("tx from field is set")] + FromSet, + /// The nonce field is set on the transaction. + /// + /// Requests with the nonce field set are rejected, as this is managed by the sequencer. + #[error("tx nonce is set")] + NonceSet, + /// An authorization item was invalid. + /// + /// The item is invalid if it tries to delegate an account to a contract that is not + /// whitelisted. + #[error("invalid authorization address")] + InvalidAuthorization, + /// The to field of the transaction was invalid. + /// + /// The destination is invalid if: + /// + /// - There is no bytecode at the destination, or + /// - The bytecode is not an EIP-7702 delegation designator, or + /// - The delegation designator points to a contract that is not whitelisted + #[error("the destination of the transaction is not a delegated account")] + IllegalDestination, + /// The transaction request was invalid. + /// + /// This is likely an internal error, as most of the request is built by the sequencer. + #[error("invalid tx request")] + InvalidTransactionRequest, +} + +impl From for jsonrpsee::types::error::ErrorObject<'static> { + fn from(error: AlphaNetWalletError) -> Self { + jsonrpsee::types::error::ErrorObject::owned::<()>( + jsonrpsee::types::error::INVALID_PARAMS_CODE, + error.to_string(), + None, + ) + } +} + +/// Implementation of the AlphaNet `wallet_` namespace. +pub struct AlphaNetWallet { + provider: Provider, + wallet: EthereumWallet, + chain_id: ChainId, + capabilities: WalletCapabilities, + eth_api: Eth, +} + +impl AlphaNetWallet { + /// Create a new AlphaNet wallet module. + pub fn new( + provider: Provider, + wallet: EthereumWallet, + eth_api: Eth, + chain_id: ChainId, + valid_designations: Vec
, + ) -> Self { + let mut caps = HashMap::default(); + caps.insert( + chain_id, + Capabilities { delegation: DelegationCapability { addresses: valid_designations } }, + ); + + Self { provider, wallet, eth_api, chain_id, capabilities: WalletCapabilities(caps) } + } +} + +#[async_trait] +impl AlphaNetWalletApiServer for AlphaNetWallet +where + Provider: StateProviderFactory + Send + Sync + 'static, + Eth: FullEthApi + Send + Sync + 'static, +{ + fn get_capabilities(&self) -> RpcResult { + trace!(target: "rpc::wallet", "Serving wallet_getCapabilities"); + Ok(self.capabilities.clone()) + } + + async fn send_transaction(&self, mut request: TransactionRequest) -> RpcResult { + trace!(target: "rpc::wallet", ?request, "Serving wallet_sendTransaction"); + + // validate fields common to eip-7702 and eip-1559 + validate_tx_request(&request)?; + + let valid_delegations: &[Address] = self + .capabilities + .get(self.chain_id) + .map(|caps| caps.delegation.addresses.as_ref()) + .unwrap_or_default(); + if let Some(authorizations) = &request.authorization_list { + // check that all auth items delegate to a valid address + if authorizations.iter().any(|auth| !valid_delegations.contains(&auth.address)) { + return Err(AlphaNetWalletError::InvalidAuthorization.into()); + } + } + + // validate destination + match (request.authorization_list.is_some(), request.to) { + // if this is an eip-1559 tx, ensure that it is an account that delegates to a + // whitelisted address + (false, Some(TxKind::Call(addr))) => { + let state = self.provider.latest().unwrap(); + let delegated_address = state + .account_code(addr) + .ok() + .flatten() + .and_then(|code| match code.0 { + Bytecode::Eip7702(code) => Some(code.address()), + _ => None, + }) + .unwrap_or_default(); + + // not a whitelisted address, or not an eip-7702 bytecode + if delegated_address == Address::ZERO + || !valid_delegations.contains(&delegated_address) + { + return Err(AlphaNetWalletError::IllegalDestination.into()); + } + } + // if it's an eip-7702 tx, let it through + (true, _) => (), + // create tx's disallowed + _ => return Err(AlphaNetWalletError::IllegalDestination.into()), + } + + // set nonce + let tx_count = EthState::transaction_count( + &self.eth_api, + NetworkWallet::::default_signer_address(&self.wallet), + Some(BlockId::pending()), + ) + .await + .map_err(Into::into)?; + request.nonce = Some(tx_count.to()); + + // set chain id + request.chain_id = Some(self.chain_id); + + // set gas limit + let estimate = + EthCall::estimate_gas_at(&self.eth_api, request.clone(), BlockId::latest(), None) + .await + .map_err(Into::into)?; + request = request.gas_limit(estimate.to()); + + // build and sign + let envelope = + >::build::( + request, + &self.wallet, + ) + .await + .map_err(|_| AlphaNetWalletError::InvalidTransactionRequest)?; + + // this uses the internal `OpEthApi` to either forward the tx to the sequencer, or add it to + // the txpool + // + // see: https://github.com/paradigmxyz/reth/blob/b67f004fbe8e1b7c05f84f314c4c9f2ed9be1891/crates/optimism/rpc/src/eth/transaction.rs#L35-L57 + EthTransactions::send_raw_transaction(&self.eth_api, envelope.encoded_2718().into()) + .await + .map_err(Into::into) + } +} + +fn validate_tx_request(request: &TransactionRequest) -> Result<(), AlphaNetWalletError> { + // reject transactions that have a non-zero value to prevent draining the sequencer. + if request.value.is_some_and(|val| val > U256::ZERO) { + return Err(AlphaNetWalletError::ValueNotZero); + } + + // reject transactions that have from set, as this will be the sequencer. + if request.from.is_some() { + return Err(AlphaNetWalletError::FromSet); + } + + // reject transaction requests that have nonce set, as this is managed by the sequencer. + if request.nonce.is_some() { + return Err(AlphaNetWalletError::NonceSet); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + mod types { + use crate::{Capabilities, DelegationCapability, WalletCapabilities}; + use alloy_primitives::{address, map::HashMap}; + + #[test] + fn ser() { + let mut caps = HashMap::default(); + caps.insert( + 0x69420, + Capabilities { + delegation: DelegationCapability { + addresses: vec![address!("90f79bf6eb2c4f870365e785982e1f101e93b906")], + }, + }, + ); + + let caps = WalletCapabilities(caps); + assert_eq!(serde_json::to_string(&caps).unwrap(), "{\"431136\":{\"delegation\":{\"addresses\":[\"0x90F79bf6EB2c4f870365E785982E1f101E93b906\"]}}}"); + } + + #[test] + fn de() { + let mut caps = HashMap::default(); + caps.insert( + 0x69420, + Capabilities { + delegation: DelegationCapability { + addresses: vec![address!("90f79bf6eb2c4f870365e785982e1f101e93b906")], + }, + }, + ); + let expected_caps = WalletCapabilities(caps); + + let caps: WalletCapabilities = serde_json::from_str( + r#"{ + "431136": { + "delegation": { + "addresses": ["0x90f79bf6eb2c4f870365e785982e1f101e93b906"] + } + } + }"#, + ) + .expect("could not deser"); + + assert_eq!(caps, expected_caps); + } + } }