diff --git a/Cargo.lock b/Cargo.lock index 1faabf3..e09635c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4552,6 +4552,8 @@ version = "0.0.0" dependencies = [ "alloy-network", "alloy-primitives", + "alloy-provider", + "alloy-rpc-client", "alloy-signer-local", "clap", "eyre", @@ -4609,21 +4611,37 @@ dependencies = [ "tracing", ] +[[package]] +name = "odyssey-relay" +version = "0.0.0" +dependencies = [ + "alloy-primitives", + "alloy-provider", + "alloy-rpc-client", + "alloy-signer-local", + "clap", + "eyre", + "jsonrpsee", + "odyssey-wallet", + "reth-tracing", + "tokio", + "tracing", + "url", +] + [[package]] name = "odyssey-wallet" version = "0.0.0" dependencies = [ - "alloy-eips", "alloy-network", "alloy-primitives", + "alloy-provider", "alloy-rpc-types", + "alloy-transport", "jsonrpsee", "metrics 0.23.0", "metrics-derive", "reth-optimism-rpc", - "reth-rpc-eth-api", - "reth-storage-api", - "revm-primitives", "serde", "serde_json", "thiserror 1.0.69", diff --git a/Cargo.toml b/Cargo.toml index bec8d4a..10404fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,13 @@ [workspace] members = [ "bin/odyssey/", + "bin/relay/", "crates/node", "crates/e2e-tests", "crates/wallet", "crates/walltime", ] -default-members = ["bin/odyssey/"] +default-members = ["bin/odyssey/", "bin/relay/"] resolver = "2" [workspace.package] @@ -148,8 +149,11 @@ alloy-consensus = "0.6.4" alloy-eips = "0.6.4" alloy-network = "0.6.4" alloy-primitives = "0.8.11" +alloy-provider = "0.6.4" +alloy-rpc-client = "0.6.4" alloy-rpc-types = "0.6.4" alloy-signer-local = { version = "0.6.4", features = ["mnemonic"] } +alloy-transport = "0.6.4" # tokio tokio = { version = "1.21", default-features = false } @@ -159,7 +163,6 @@ reth-chainspec = { git = "https://github.com/paradigmxyz/reth.git", rev = "f211a reth-cli = { git = "https://github.com/paradigmxyz/reth.git", rev = "f211aac" } reth-cli-util = { git = "https://github.com/paradigmxyz/reth.git", rev = "f211aac" } reth-evm = { git = "https://github.com/paradigmxyz/reth.git", rev = "f211aac" } -reth-rpc-eth-api = { git = "https://github.com/paradigmxyz/reth.git", rev = "f211aac" } reth-node-api = { git = "https://github.com/paradigmxyz/reth.git", rev = "f211aac" } reth-node-builder = { git = "https://github.com/paradigmxyz/reth.git", rev = "f211aac" } reth-node-core = { git = "https://github.com/paradigmxyz/reth.git", rev = "f211aac", features = [ @@ -185,7 +188,6 @@ reth-provider = { git = "https://github.com/paradigmxyz/reth.git", rev = "f211aa "optimism", ] } reth-revm = { git = "https://github.com/paradigmxyz/reth.git", rev = "f211aac" } -reth-storage-api = { git = "https://github.com/paradigmxyz/reth.git", rev = "f211aac" } reth-tracing = { git = "https://github.com/paradigmxyz/reth.git", rev = "f211aac" } reth-transaction-pool = { git = "https://github.com/paradigmxyz/reth.git", rev = "f211aac" } reth-trie-db = { git = "https://github.com/paradigmxyz/reth.git", rev = "f211aac" } @@ -210,6 +212,7 @@ serde = "1" serde_json = "1" thiserror = "1" futures = "0.3" +url = "2.5" # misc-testing rstest = "0.18.2" diff --git a/bin/odyssey/Cargo.toml b/bin/odyssey/Cargo.toml index c8fb97c..fd3de54 100644 --- a/bin/odyssey/Cargo.toml +++ b/bin/odyssey/Cargo.toml @@ -15,6 +15,8 @@ workspace = true alloy-signer-local.workspace = true alloy-network.workspace = true alloy-primitives.workspace = true +alloy-provider.workspace = true +alloy-rpc-client.workspace = true odyssey-node.workspace = true odyssey-wallet.workspace = true odyssey-walltime.workspace = true diff --git a/bin/odyssey/src/main.rs b/bin/odyssey/src/main.rs index 6030d53..6b502a5 100644 --- a/bin/odyssey/src/main.rs +++ b/bin/odyssey/src/main.rs @@ -25,6 +25,8 @@ use alloy_network::EthereumWallet; use alloy_primitives::Address; +use alloy_provider::ProviderBuilder; +use alloy_rpc_client::RpcClient; use alloy_signer_local::PrivateKeySigner; use clap::Parser; use eyre::Context; @@ -70,11 +72,23 @@ fn main() { .collect::>() .wrap_err("No valid EXP0001 delegations specified")?; + // construct a boxed rpc client + let rpc_client = RpcClient::new_http( + format!( + "http://{}:{}", + ctx.config().rpc.http_addr, + ctx.config().rpc.http_port + ) + .parse() + .expect("invalid rpc url, this should not happen"), + ) + .boxed(); ctx.modules.merge_configured( OdysseyWallet::new( - ctx.provider().clone(), - wallet, - ctx.registry.eth_api().clone(), + ProviderBuilder::new() + .with_recommended_fillers() + .wallet(wallet) + .on_client(rpc_client), ctx.config().chain.chain().id(), valid_delegations, ) diff --git a/bin/relay/Cargo.toml b/bin/relay/Cargo.toml new file mode 100644 index 0000000..42bdbc9 --- /dev/null +++ b/bin/relay/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "odyssey-relay" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Odyssey Relay is an EIP-7702 native transaction batcher and sponsor." + +[lints] +workspace = true + +[dependencies] +alloy-signer-local.workspace = true +alloy-primitives.workspace = true +alloy-provider.workspace = true +alloy-rpc-client.workspace = true +odyssey-wallet.workspace = true +eyre.workspace = true +jsonrpsee = { workspace = true, features = ["server"] } +tracing.workspace = true +reth-tracing.workspace = true +clap = { workspace = true, features = ["derive", "env"] } +url.workspace = true +tokio = { workspace = true, features = ["rt", "macros"] } + +[features] +default = [] +min-error-logs = ["tracing/release_max_level_error"] +min-warn-logs = ["tracing/release_max_level_warn"] +min-info-logs = ["tracing/release_max_level_info"] +min-debug-logs = ["tracing/release_max_level_debug"] +min-trace-logs = ["tracing/release_max_level_trace"] + +[[bin]] +name = "relay" +path = "src/main.rs" diff --git a/bin/relay/src/main.rs b/bin/relay/src/main.rs new file mode 100644 index 0000000..6ac6707 --- /dev/null +++ b/bin/relay/src/main.rs @@ -0,0 +1,75 @@ +//! # Odyssey Relay +//! +//! TBD + +use alloy_provider::{network::EthereumWallet, Provider, ProviderBuilder}; +use alloy_rpc_client::RpcClient; +use alloy_signer_local::PrivateKeySigner; +use clap::Parser; +use eyre::Context; +use jsonrpsee::server::Server; +use odyssey_wallet::{OdysseyWallet, OdysseyWalletApiServer}; +use reth_tracing::Tracer; +use std::net::{IpAddr, Ipv4Addr}; +use tracing::info; +use url::Url; + +/// The Odyssey relayer service sponsors transactions for EIP-7702 accounts. +#[derive(Debug, Parser)] +#[command(author, about = "Relay", long_about = None)] +struct Args { + /// The address to serve the RPC on. + #[arg(long = "http.addr", value_name = "ADDR", default_value_t = IpAddr::V4(Ipv4Addr::LOCALHOST))] + address: IpAddr, + /// The port to serve the RPC on. + #[arg(long = "http.port", value_name = "PORT", default_value_t = 9119)] + port: u16, + /// The RPC endpoint of the chain to send transactions to. + #[arg(long, value_name = "RPC_ENDPOINT")] + upstream: Url, + /// The secret key to sponsor transactions with. + #[arg(long, value_name = "SECRET_KEY", env = "RELAY_SK")] + secret_key: String, +} + +/// Run the relayer service. +async fn run(args: Args) -> eyre::Result<()> { + let _guard = reth_tracing::RethTracer::new().init()?; + + // construct provider + let signer: PrivateKeySigner = args.secret_key.parse().wrap_err("Invalid signing key")?; + let wallet = EthereumWallet::from(signer); + let rpc_client = RpcClient::new_http(args.upstream).boxed(); + let provider = + ProviderBuilder::new().with_recommended_fillers().wallet(wallet).on_client(rpc_client); + + // get chain id + let chain_id = provider.get_chain_id().await?; + + // construct rpc module + let rpc = OdysseyWallet::new(provider, chain_id, vec![]).into_rpc(); + + // start server + let server = Server::builder().http_only().build((args.address, args.port)).await?; + info!(addr = ?server.local_addr().unwrap(), "Started relay service"); + + let handle = server.start(rpc); + handle.stopped().await; + + Ok(()) +} + +#[doc(hidden)] +#[tokio::main] +async fn main() { + // Enable backtraces unless a RUST_BACKTRACE value has already been explicitly provided. + if std::env::var_os("RUST_BACKTRACE").is_none() { + std::env::set_var("RUST_BACKTRACE", "1"); + } + + let args = Args::parse(); + if let Err(err) = run(args).await { + eprint!("Error: {err:?}"); + std::process::exit(1); + } +} diff --git a/crates/wallet/Cargo.toml b/crates/wallet/Cargo.toml index 221b51b..399a2b6 100644 --- a/crates/wallet/Cargo.toml +++ b/crates/wallet/Cargo.toml @@ -10,17 +10,14 @@ keywords.workspace = true categories.workspace = true [dependencies] -alloy-eips.workspace = true alloy-network.workspace = true alloy-primitives.workspace = true +alloy-provider.workspace = true alloy-rpc-types.workspace = true +alloy-transport.workspace = true -reth-storage-api.workspace = true -reth-rpc-eth-api.workspace = true reth-optimism-rpc.workspace = true -revm-primitives.workspace = true - jsonrpsee = { workspace = true, features = ["server", "macros"] } serde = { workspace = true, features = ["derive"] } thiserror.workspace = true diff --git a/crates/wallet/src/lib.rs b/crates/wallet/src/lib.rs index 67fa618..b54986c 100644 --- a/crates/wallet/src/lib.rs +++ b/crates/wallet/src/lib.rs @@ -4,14 +4,13 @@ //! //! - `wallet_getCapabilities` based on [EIP-5792][eip-5792], with the only capability being //! `delegation`. -//! - `odyssey_sendTransaction` that can perform sequencer-sponsored [EIP-7702][eip-7702] -//! delegations and send other sequencer-sponsored transactions on behalf of EOAs with delegated -//! code. +//! - `odyssey_sendTransaction` that can perform service-sponsored [EIP-7702][eip-7702] delegations +//! and send other service-sponsored transactions on behalf of EOAs with delegated code. //! //! # Restrictions //! //! `odyssey_sendTransaction` has additional verifications in place to prevent some -//! rudimentary abuse of the sequencer's funds. For example, transactions cannot contain any +//! rudimentary abuse of the service's funds. For example, transactions cannot contain any //! `value`. //! //! [eip-5792]: https://eips.ethereum.org/EIPS/eip-5792 @@ -19,10 +18,6 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] -use alloy_eips::BlockId; -use alloy_network::{ - eip2718::Encodable2718, Ethereum, EthereumWallet, NetworkWallet, TransactionBuilder, -}; use alloy_primitives::{map::HashMap, Address, ChainId, TxHash, TxKind, U256, U64}; use alloy_rpc_types::TransactionRequest; use jsonrpsee::{ @@ -31,9 +26,7 @@ use jsonrpsee::{ }; use metrics::Counter; use metrics_derive::Metrics; -use reth_rpc_eth_api::helpers::{EthCall, EthTransactions, FullEthApi, LoadFee, LoadState}; -use reth_storage_api::{StateProvider, StateProviderFactory}; -use revm_primitives::Bytecode; + use serde::{Deserialize, Serialize}; use std::sync::Arc; use tracing::{trace, warn}; @@ -41,9 +34,9 @@ use tracing::{trace, warn}; use reth_optimism_rpc as _; use tokio::sync::Mutex; -/// The capability to perform [EIP-7702][eip-7702] delegations, sponsored by the sequencer. +/// The capability to perform [EIP-7702][eip-7702] delegations, sponsored by the service. /// -/// The sequencer will only perform delegations, and act on behalf of delegated accounts, if the +/// The service will only perform delegations, and act on behalf of delegated accounts, if the /// account delegates to one of the addresses specified within this capability. /// /// [eip-7702]: https://eips.ethereum.org/EIPS/eip-7702 @@ -86,7 +79,7 @@ pub trait OdysseyWalletApi { #[method(name = "getCapabilities")] fn get_capabilities(&self) -> RpcResult; - /// Send a sequencer-sponsored transaction. + /// Send a sponsored transaction. /// /// The transaction will only be processed if: /// @@ -95,8 +88,8 @@ pub trait OdysseyWalletApi { /// delegated to one of the addresses above /// - The value in the transaction is exactly 0. /// - /// The sequencer will sign the transaction and inject it into the transaction pool, provided it - /// is valid. The nonce is managed by the sequencer. + /// The service will sign the transaction and inject it into the transaction pool, provided it + /// is valid. The nonce is managed by the service. /// /// [eip-7702]: https://eips.ethereum.org/EIPS/eip-7702 /// [eip-1559]: https://eips.ethereum.org/EIPS/eip-1559 @@ -109,18 +102,18 @@ pub trait OdysseyWalletApi { pub enum OdysseyWalletError { /// The transaction value is not 0. /// - /// The value should be 0 to prevent draining the sequencer. + /// The value should be 0 to prevent draining the service. #[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. + /// service. #[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. + /// Requests with the nonce field set are rejected, as this is managed by the service. #[error("tx nonce is set")] NonceSet, /// The to field of the transaction was invalid. @@ -133,12 +126,12 @@ pub enum OdysseyWalletError { IllegalDestination, /// The transaction request was invalid. /// - /// This is likely an internal error, as most of the request is built by the sequencer. + /// This is likely an internal error, as most of the request is built by the service. #[error("invalid tx request")] InvalidTransactionRequest, /// The request was estimated to consume too much gas. /// - /// The gas usage by each request is limited to counteract draining the sequencers funds. + /// The gas usage by each request is limited to counteract draining the services funds. #[error("request would use too much gas: estimated {estimate}")] GasEstimateTooHigh { /// The amount of gas the request was estimated to consume. @@ -161,23 +154,19 @@ impl From for jsonrpsee::types::error::ErrorObject<'static> /// Implementation of the Odyssey `wallet_` namespace. #[derive(Debug)] -pub struct OdysseyWallet { - inner: Arc>, +pub struct OdysseyWallet { + inner: Arc>, } -impl OdysseyWallet { +impl OdysseyWallet { /// Create a new Odyssey wallet module. - pub fn new( - provider: Provider, - wallet: EthereumWallet, - eth_api: Eth, - chain_id: ChainId, - valid_designations: Vec
, - ) -> Self { + /// + /// # Note + /// + /// The provider should do nonce management, gas estimation, and sign. + pub fn new(provider: Provider, chain_id: ChainId, valid_designations: Vec
) -> Self { let inner = OdysseyWalletInner { provider, - wallet, - eth_api, chain_id, capabilities: WalletCapabilities(HashMap::from_iter([( U64::from(chain_id), @@ -195,10 +184,10 @@ impl OdysseyWallet { } #[async_trait] -impl OdysseyWalletApiServer for OdysseyWallet +impl OdysseyWalletApiServer for OdysseyWallet where - Provider: StateProviderFactory + Send + Sync + 'static, - Eth: FullEthApi + Send + Sync + 'static, + Provider: + alloy_provider::Provider + 'static, { fn get_capabilities(&self) -> RpcResult { trace!(target: "rpc::wallet", "Serving wallet_getCapabilities"); @@ -219,24 +208,27 @@ where // 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.inner.provider.latest().map_err(|_| { - self.inner.metrics.invalid_send_transaction_calls.increment(1); - OdysseyWalletError::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 eip-7702 bytecode - if delegated_address == Address::ZERO { - self.inner.metrics.invalid_send_transaction_calls.increment(1); - return Err(OdysseyWalletError::IllegalDestination.into()); + let code = self + .inner + .provider + .get_code_at(addr) + .await + .map_err(|_| OdysseyWalletError::InternalError)?; + match code.as_ref() { + // A valid EIP-7702 delegation + [0xef, 0x01, 0x00, address @ ..] => { + let addr = Address::from_slice(address); + // the delegation was cleared + if addr.is_zero() { + self.inner.metrics.invalid_send_transaction_calls.increment(1); + return Err(OdysseyWalletError::IllegalDestination.into()); + } + } + // Not an EIP-7702 delegation, or an empty (cleared) delegation + _ => { + self.inner.metrics.invalid_send_transaction_calls.increment(1); + return Err(OdysseyWalletError::IllegalDestination.into()); + } } } // if it's an eip-7702 tx, let it through @@ -251,82 +243,59 @@ where // we acquire the permit here so that all following operations are performed exclusively let _permit = self.inner.permit.lock().await; - // set nonce - let next_nonce = LoadState::next_available_nonce( - &self.inner.eth_api, - NetworkWallet::::default_signer_address(&self.inner.wallet), - ) - .await - .map_err(|err| { - self.inner.metrics.invalid_send_transaction_calls.increment(1); - err.into() - })?; - request.nonce = Some(next_nonce); - // set chain id request.chain_id = Some(self.chain_id()); // set gas limit // note: we also set the `from` field here to correctly estimate for contracts that use e.g. // `tx.origin` - request.from = Some(NetworkWallet::::default_signer_address(&self.inner.wallet)); + + // todo: what to do here + // request.from = + // Some(NetworkWallet::::default_signer_address(&self.inner.wallet)); let (estimate, base_fee) = tokio::join!( - EthCall::estimate_gas_at(&self.inner.eth_api, request.clone(), BlockId::latest(), None), - LoadFee::eip1559_fees(&self.inner.eth_api, None, None) + self.inner.provider.estimate_gas(&request), + self.inner.provider.estimate_eip1559_fees(None) ); - let estimate = estimate.map_err(|err| { + let estimate = estimate.map_err(|_err| { self.inner.metrics.invalid_send_transaction_calls.increment(1); - err.into() + // err.into() + OdysseyWalletError::InvalidTransactionRequest })?; - - if estimate >= U256::from(350_000) { + if estimate >= 350_000 { self.inner.metrics.invalid_send_transaction_calls.increment(1); - return Err(OdysseyWalletError::GasEstimateTooHigh { estimate: estimate.to() }.into()); + return Err(OdysseyWalletError::GasEstimateTooHigh { estimate }.into()); } - request.gas = Some(estimate.to()); + request.gas = Some(estimate); // set gas price - let (base_fee, _) = base_fee.map_err(|_| { + let fee_estimate = base_fee.map_err(|_| { self.inner.metrics.invalid_send_transaction_calls.increment(1); OdysseyWalletError::InvalidTransactionRequest })?; - let max_priority_fee_per_gas = 1_000_000_000; // 1 gwei - request.max_fee_per_gas = Some(base_fee.to::() + max_priority_fee_per_gas); - request.max_priority_fee_per_gas = Some(max_priority_fee_per_gas); + request.max_fee_per_gas = Some(fee_estimate.max_fee_per_gas); + request.max_priority_fee_per_gas = Some(fee_estimate.max_priority_fee_per_gas); request.gas_price = None; - // build and sign - let envelope = - >::build::( - request, - &self.inner.wallet, - ) - .await - .map_err(|_| { - self.inner.metrics.invalid_send_transaction_calls.increment(1); - OdysseyWalletError::InvalidTransactionRequest - })?; - // all checks passed, increment the valid calls counter self.inner.metrics.valid_send_transaction_calls.increment(1); - // 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.inner.eth_api, envelope.encoded_2718().into()) + self.inner + .provider + .send_transaction(request) .await - .inspect_err(|err| warn!(target: "rpc::wallet", ?err, "Error adding sequencer-sponsored tx to pool")) - .map_err(Into::into) + .inspect_err( + |err| warn!(target: "rpc::wallet", ?err, "Error adding sponsored tx to pool"), + ) + .map_err(|_| OdysseyWalletError::InvalidTransactionRequest.into()) + .map(|pending| *pending.tx_hash()) } } /// Implementation of the Odyssey `wallet_` namespace. #[derive(Debug)] -struct OdysseyWalletInner { +struct OdysseyWalletInner { provider: Provider, - eth_api: Eth, - wallet: EthereumWallet, chain_id: ChainId, capabilities: WalletCapabilities, /// Used to guard tx signing @@ -336,17 +305,17 @@ struct OdysseyWalletInner { } fn validate_tx_request(request: &TransactionRequest) -> Result<(), OdysseyWalletError> { - // reject transactions that have a non-zero value to prevent draining the sequencer. + // reject transactions that have a non-zero value to prevent draining the service. if request.value.is_some_and(|val| val > U256::ZERO) { return Err(OdysseyWalletError::ValueNotZero); } - // reject transactions that have from set, as this will be the sequencer. + // reject transactions that have from set, as this will be the service. if request.from.is_some() { return Err(OdysseyWalletError::FromSet); } - // reject transaction requests that have nonce set, as this is managed by the sequencer. + // reject transaction requests that have nonce set, as this is managed by the service. if request.nonce.is_some() { return Err(OdysseyWalletError::NonceSet); }