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..bcdf0a6 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, LoadFee};
+use reth_storage_api::{StateProvider, StateProviderFactory};
use serde::{Deserialize, Serialize};
+use tracing::{trace, warn};
/// 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,263 @@ 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,
+ /// An internal error occurred.
+ #[error("internal error")]
+ InternalError,
+}
+
+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 {
+ Self {
+ provider,
+ wallet,
+ eth_api,
+ chain_id,
+ capabilities: WalletCapabilities(HashMap::from_iter([(
+ chain_id,
+ Capabilities { delegation: DelegationCapability { addresses: valid_designations } },
+ )])),
+ }
+ }
+}
+
+#[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().map_err(|_| AlphaNetWalletError::InternalError)?;
+ 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());
+
+ // set gas fees
+ let (max_fee_per_gas, max_priority_fee_per_gas) =
+ LoadFee::eip1559_fees(&self.eth_api, None, None)
+ .await
+ .map_err(|_| AlphaNetWalletError::InvalidTransactionRequest)?;
+ request.max_fee_per_gas = Some(max_fee_per_gas.to());
+ request.max_priority_fee_per_gas = Some(max_priority_fee_per_gas.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
+ .inspect_err(|err| warn!(target: "rpc::wallet", ?err, "Error adding sequencer-sponsored tx to pool"))
+ .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 caps = WalletCapabilities(HashMap::from_iter([(
+ 0x69420,
+ Capabilities {
+ delegation: DelegationCapability {
+ addresses: vec![address!("90f79bf6eb2c4f870365e785982e1f101e93b906")],
+ },
+ },
+ )]));
+ assert_eq!(serde_json::to_string(&caps).unwrap(), "{\"431136\":{\"delegation\":{\"addresses\":[\"0x90F79bf6EB2c4f870365E785982E1f101E93b906\"]}}}");
+ }
+
+ #[test]
+ fn de() {
+ let caps: WalletCapabilities = serde_json::from_str(
+ r#"{
+ "431136": {
+ "delegation": {
+ "addresses": ["0x90f79bf6eb2c4f870365e785982e1f101e93b906"]
+ }
+ }
+ }"#,
+ )
+ .expect("could not deser");
+
+ assert_eq!(
+ caps,
+ WalletCapabilities(HashMap::from_iter([(
+ 0x69420,
+ Capabilities {
+ delegation: DelegationCapability {
+ addresses: vec![address!("90f79bf6eb2c4f870365e785982e1f101e93b906")],
+ },
+ },
+ )]))
+ );
+ }
+ }
}