diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index b66bf99..e8ee9f2 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -7,7 +7,7 @@ on: merge_group: push: branches: [main] - + env: CARGO_TERM_COLOR: always @@ -30,29 +30,29 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Install Kurtosis - run: | - echo "deb [trusted=yes] https://apt.fury.io/kurtosis-tech/ /" | sudo tee /etc/apt/sources.list.d/kurtosis.list - sudo apt update - sudo apt install kurtosis-cli + run: | + echo "deb [trusted=yes] https://apt.fury.io/kurtosis-tech/ /" | sudo tee /etc/apt/sources.list.d/kurtosis.list + sudo apt update + sudo apt install kurtosis-cli - name: Build Odyssey run: | - cargo build --profile release --locked --bin odyssey && - mkdir dist/ && - cp ./target/release/odyssey dist/odyssey && - docker buildx build . --load -f .github/assets/Dockerfile -t ghcr.io/ithacaxyz/odyssey:latest + cargo build --profile release --locked --bin odyssey && + mkdir dist/ && + cp ./target/release/odyssey dist/odyssey && + docker buildx build . --load -f .github/assets/Dockerfile -t ghcr.io/ithacaxyz/odyssey:latest - name: Run enclave id: kurtosis run: | - kurtosis engine start - kurtosis run --enclave op-devnet github.com/ethpandaops/optimism-package --args-file ./etc/kurtosis.yaml - ENCLAVE_ID=$(curl http://127.0.0.1:9779/api/enclaves | jq --raw-output 'keys[0]') - SEQUENCER_EL_PORT=$(curl "http://127.0.0.1:9779/api/enclaves/$ENCLAVE_ID/services" | jq '."op-el-1-op-reth-op-node-op-kurtosis".public_ports.rpc.number') - REPLICA_EL_PORT=$(curl "http://127.0.0.1:9779/api/enclaves/$ENCLAVE_ID/services" | jq '."op-el-2-op-reth-op-node-op-kurtosis".public_ports.rpc.number') - echo "SEQUENCER_RPC=http://127.0.0.1:$SEQUENCER_EL_PORT" >> $GITHUB_ENV - echo "REPLICA_RPC=http://127.0.0.1:$REPLICA_EL_PORT" >> $GITHUB_ENV + kurtosis engine start + kurtosis run --enclave op-devnet github.com/ethpandaops/optimism-package --args-file ./etc/kurtosis.yaml + ENCLAVE_ID=$(curl http://127.0.0.1:9779/api/enclaves | jq --raw-output 'keys[0]') + SEQUENCER_EL_PORT=$(curl "http://127.0.0.1:9779/api/enclaves/$ENCLAVE_ID/services" | jq '."op-el-1-op-reth-op-node-op-kurtosis".public_ports.rpc.number') + REPLICA_EL_PORT=$(curl "http://127.0.0.1:9779/api/enclaves/$ENCLAVE_ID/services" | jq '."op-el-2-op-reth-op-node-op-kurtosis".public_ports.rpc.number') + echo "SEQUENCER_RPC=http://127.0.0.1:$SEQUENCER_EL_PORT" >> $GITHUB_ENV + echo "REPLICA_RPC=http://127.0.0.1:$REPLICA_EL_PORT" >> $GITHUB_ENV - name: Run E2E tests run: | - cargo nextest run \ - --locked \ - --workspace \ - -E "package(odyssey-e2e-tests)" + cargo nextest run \ + --locked \ + --workspace \ + -E "package(odyssey-e2e-tests)" diff --git a/Cargo.lock b/Cargo.lock index b8a93b4..58998d7 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", @@ -4629,21 +4631,40 @@ 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", + "eyre", "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 d763557..63109b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,14 @@ [workspace] members = [ "bin/odyssey/", + "bin/relay/", "crates/common", "crates/node", "crates/e2e-tests", "crates/wallet", "crates/walltime", ] -default-members = ["bin/odyssey/"] +default-members = ["bin/odyssey/", "bin/relay/"] resolver = "2" [workspace.package] @@ -150,22 +151,24 @@ 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 = { version = "0.6.4", default-features = false } alloy-rpc-types = "0.6.4" alloy-rpc-types-eth = "0.6.4" alloy-signer-local = { version = "0.6.4", features = ["mnemonic"] } +alloy-transport = "0.6.4" +alloy-transport-http = { version = "0.6.4", default-features = false, features = [ + "reqwest", + "reqwest-rustls-tls", +] } +reqwest = { version = "0.12.9", default-features = false, features = [ + "rustls-tls", +] } # tokio tokio = { version = "1.21", default-features = false } -# reth -reth-chainspec = { git = "https://github.com/paradigmxyz/reth.git", rev = "38cf6c9" } -reth-cli = { git = "https://github.com/paradigmxyz/reth.git", rev = "38cf6c9" } -reth-cli-util = { git = "https://github.com/paradigmxyz/reth.git", rev = "38cf6c9" } -reth-errors = { git = "https://github.com/paradigmxyz/reth.git", rev = "38cf6c9" } -reth-evm = { git = "https://github.com/paradigmxyz/reth.git", rev = "38cf6c9" } -reth-rpc-eth-api = { git = "https://github.com/paradigmxyz/reth.git", rev = "38cf6c9" } -reth-node-api = { git = "https://github.com/paradigmxyz/reth.git", rev = "38cf6c9" } -reth-node-builder = { git = "https://github.com/paradigmxyz/reth.git", rev = "38cf6c9" } + reth-node-core = { git = "https://github.com/paradigmxyz/reth.git", rev = "38cf6c9", features = [ "optimism", ] } @@ -218,6 +221,15 @@ serde = "1" serde_json = "1" thiserror = "1" futures = "0.3" +url = "2.5" # misc-testing rstest = "0.18.2" +reth-chainspec = { git = "https://github.com/paradigmxyz/reth.git", rev = "38cf6c9" } +reth-cli = { git = "https://github.com/paradigmxyz/reth.git", rev = "38cf6c9" } +reth-cli-util = { git = "https://github.com/paradigmxyz/reth.git", rev = "38cf6c9" } +reth-errors = { git = "https://github.com/paradigmxyz/reth.git", rev = "38cf6c9" } +reth-evm = { git = "https://github.com/paradigmxyz/reth.git", rev = "38cf6c9" } +reth-rpc-eth-api = { git = "https://github.com/paradigmxyz/reth.git", rev = "38cf6c9" } +reth-node-api = { git = "https://github.com/paradigmxyz/reth.git", rev = "38cf6c9" } +reth-node-builder = { git = "https://github.com/paradigmxyz/reth.git", rev = "38cf6c9" } 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 5b6656f..b2a9e38 100644 --- a/bin/odyssey/src/main.rs +++ b/bin/odyssey/src/main.rs @@ -33,7 +33,7 @@ use odyssey_node::{ node::OdysseyNode, rpc::{EthApiExt, EthApiOverrideServer}, }; -use odyssey_wallet::{OdysseyWallet, OdysseyWalletApiServer}; +use odyssey_wallet::{OdysseyWallet, OdysseyWalletApiServer, RethNode}; use odyssey_walltime::{OdysseyWallTime, OdysseyWallTimeRpcApiServer}; use reth_node_builder::{engine_tree_config::TreeConfig, EngineNodeLauncher, NodeComponents}; use reth_optimism_cli::Cli; @@ -92,9 +92,11 @@ fn main() { if let Some(wallet) = wallet { ctx.modules.merge_configured( OdysseyWallet::new( - ctx.provider().clone(), - wallet, - ctx.registry.eth_api().clone(), + RethNode::new( + ctx.provider().clone(), + ctx.registry.eth_api().clone(), + wallet, + ), ctx.config().chain.chain().id(), ) .into_rpc(), 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..4a754e2 --- /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::{AlloyNode, 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(AlloyNode::new(provider), chain_id).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..4be86f1 100644 --- a/crates/wallet/Cargo.toml +++ b/crates/wallet/Cargo.toml @@ -10,20 +10,20 @@ 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 +reth-rpc-eth-api.workspace = true +reth-storage-api.workspace = true jsonrpsee = { workspace = true, features = ["server", "macros"] } serde = { workspace = true, features = ["derive"] } thiserror.workspace = true +eyre.workspace = true tracing.workspace = true tokio = { workspace = true, features = ["sync"] } diff --git a/crates/wallet/src/lib.rs b/crates/wallet/src/lib.rs index ae8a413..d85b07f 100644 --- a/crates/wallet/src/lib.rs +++ b/crates/wallet/src/lib.rs @@ -2,14 +2,13 @@ //! //! Implementations of a custom `wallet_` namespace for Odyssey experiment 1. //! -//! - `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 @@ -17,31 +16,200 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] -use alloy_eips::BlockId; use alloy_network::{ - eip2718::Encodable2718, Ethereum, EthereumWallet, NetworkWallet, TransactionBuilder, + eip2718::Encodable2718, Ethereum, EthereumWallet, Network, NetworkWallet, TransactionBuilder, }; -use alloy_primitives::{Address, ChainId, TxHash, TxKind, U256}; -use alloy_rpc_types::TransactionRequest; +use alloy_primitives::{Address, Bytes, ChainId, TxHash, TxKind, U256}; +use alloy_provider::{utils::Eip1559Estimation, Provider, WalletProvider}; +use alloy_rpc_types::{BlockId, TransactionRequest}; +use alloy_transport::Transport; use jsonrpsee::{ core::{async_trait, RpcResult}, proc_macros::rpc, }; 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 reth_storage_api::StateProviderFactory; use serde::{Deserialize, Serialize}; -use std::sync::Arc; +use std::{marker::PhantomData, sync::Arc}; 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. +/// A trait for any type capable of estimating, signing, and propagating sponsored transactions. +#[async_trait] +pub trait Node +where + N: Network, +{ + /// Get the address of the account that sponsors transactions. + fn default_signer_address(&self) -> Address; + + /// Get the code at a specific address. + async fn get_code(&self, address: Address) -> Result; + + /// Estimate the transaction request's gas usage and fees. + async fn estimate( + &self, + tx: &N::TransactionRequest, + ) -> Result<(u64, Eip1559Estimation), OdysseyWalletError>; + + /// Sign the transaction request and send it to the node. + async fn sign_and_send(&self, tx: N::TransactionRequest) -> Result; +} + +/// A wrapper around an Alloy provider for signing and sending sponsored transactions. +#[derive(Debug)] +pub struct AlloyNode { + provider: P, + _transport: PhantomData, + _network: PhantomData, +} + +impl AlloyNode { + /// Create a new [`AlloyNode`] + pub const fn new(provider: P) -> Self { + Self { provider, _transport: PhantomData, _network: PhantomData } + } +} + +#[async_trait] +impl Node for AlloyNode +where + P: Provider + WalletProvider, + T: Transport + Clone, + N: Network, +{ + fn default_signer_address(&self) -> Address { + self.provider.default_signer_address() + } + + async fn get_code(&self, address: Address) -> Result { + self.provider + .get_code_at(address) + .await + .map_err(|err| OdysseyWalletError::InternalError(err.into())) + } + + async fn estimate( + &self, + tx: &N::TransactionRequest, + ) -> Result<(u64, Eip1559Estimation), OdysseyWalletError> { + let (estimate, fee_estimate) = + tokio::join!(self.provider.estimate_gas(tx), self.provider.estimate_eip1559_fees(None)); + + Ok(( + estimate.map_err(|err| OdysseyWalletError::InternalError(err.into()))?, + fee_estimate.map_err(|err| OdysseyWalletError::InternalError(err.into()))?, + )) + } + + async fn sign_and_send(&self, tx: N::TransactionRequest) -> Result { + self.provider + .send_transaction(tx) + .await + .map_err(|err| OdysseyWalletError::InternalError(err.into())) + .map(|pending| *pending.tx_hash()) + } +} + +/// A handle to a Reth node that signs transactions and injects them directly into the transaction +/// pool. +#[derive(Debug)] +pub struct RethNode { + provider: Provider, + eth_api: Eth, + wallet: EthereumWallet, +} + +impl RethNode { + /// Create a new [`RethNode`]. + pub const fn new(provider: Provider, eth_api: Eth, wallet: EthereumWallet) -> Self { + Self { provider, eth_api, wallet } + } +} + +#[async_trait] +impl Node for RethNode +where + Provider: StateProviderFactory + Send + Sync, + Eth: FullEthApi + Send + Sync, +{ + fn default_signer_address(&self) -> Address { + NetworkWallet::::default_signer_address(&self.wallet) + } + + async fn get_code(&self, address: Address) -> Result { + let state = + self.provider.latest().map_err(|err| OdysseyWalletError::InternalError(err.into()))?; + + Ok(state + .account_code(address) + .ok() + .flatten() + .map(|code| code.0.bytes()) + .unwrap_or_default()) + } + + async fn estimate( + &self, + tx: &TransactionRequest, + ) -> Result<(u64, Eip1559Estimation), OdysseyWalletError> { + let (estimate, fee_estimate) = tokio::join!( + EthCall::estimate_gas_at(&self.eth_api, tx.clone(), BlockId::latest(), None), + LoadFee::eip1559_fees(&self.eth_api, None, None) + ); + + Ok(( + estimate + .map(|estimate| estimate.to()) + .map_err(|err| OdysseyWalletError::InternalError(eyre::Report::new(err)))?, + fee_estimate + .map(|(base, prio)| Eip1559Estimation { + max_fee_per_gas: (base + prio).to(), + max_priority_fee_per_gas: prio.to(), + }) + .map_err(|err| OdysseyWalletError::InternalError(eyre::Report::new(err)))?, + )) + } + + async fn sign_and_send( + &self, + mut tx: TransactionRequest, + ) -> Result { + let next_nonce = LoadState::next_available_nonce( + &self.eth_api, + NetworkWallet::::default_signer_address(&self.wallet), + ) + .await + .map_err(|err| OdysseyWalletError::InternalError(eyre::Report::new(err)))?; + tx.nonce = Some(next_nonce); + + // build and sign + let envelope = + >::build::( + tx, + &self.wallet, + ) + .await + .map_err(|err| OdysseyWalletError::InternalError(err.into()))?; + + // 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(|err| OdysseyWalletError::InternalError(eyre::Report::new(err))) + } +} + +/// 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 @@ -55,7 +223,7 @@ pub struct DelegationCapability { #[cfg_attr(not(test), rpc(server, namespace = "wallet"))] #[cfg_attr(test, rpc(server, client, namespace = "wallet"))] pub trait OdysseyWalletApi { - /// Send a sequencer-sponsored transaction. + /// Send a sponsored transaction. /// /// The transaction will only be processed if: /// @@ -64,8 +232,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 @@ -74,22 +242,22 @@ pub trait OdysseyWalletApi { } /// Errors returned by the wallet API. -#[derive(Debug, Eq, PartialEq, thiserror::Error)] +#[derive(Debug, thiserror::Error)] 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. @@ -102,20 +270,20 @@ 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. estimate: u64, }, /// An internal error occurred. - #[error("internal error")] - InternalError, + #[error(transparent)] + InternalError(#[from] eyre::Error), } impl From for jsonrpsee::types::error::ErrorObject<'static> { @@ -130,22 +298,15 @@ 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, - ) -> Self { + pub fn new(node: Node, chain_id: ChainId) -> Self { let inner = OdysseyWalletInner { - provider, - wallet, - eth_api, + node, chain_id, permit: Default::default(), metrics: WalletMetrics::default(), @@ -159,10 +320,9 @@ impl OdysseyWallet { } #[async_trait] -impl OdysseyWalletApiServer for OdysseyWallet +impl OdysseyWalletApiServer for OdysseyWallet where - Provider: StateProviderFactory + Send + Sync + 'static, - Eth: FullEthApi + Send + Sync + 'static, + N: Node + Sync + Send + 'static, { async fn send_transaction(&self, mut request: TransactionRequest) -> RpcResult { trace!(target: "rpc::wallet", ?request, "Serving odyssey_sendTransaction"); @@ -178,24 +338,22 @@ 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.node.get_code(addr).await?; + 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 @@ -210,82 +368,43 @@ 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)); - 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) - ); - let estimate = estimate.map_err(|err| { - self.inner.metrics.invalid_send_transaction_calls.increment(1); - err.into() - })?; - - if estimate >= U256::from(350_000) { + request.from = Some(self.inner.node.default_signer_address()); + let (estimate, fee_estimate) = self + .inner + .node + .estimate(&request) + .await + .inspect_err(|_| self.inner.metrics.invalid_send_transaction_calls.increment(1))?; + 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(|_| { - 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()) - .await - .inspect_err(|err| warn!(target: "rpc::wallet", ?err, "Error adding sequencer-sponsored tx to pool")) - .map_err(Into::into) + Ok(self.inner.node.sign_and_send(request).await.inspect_err( + |err| warn!(target: "rpc::wallet", ?err, "Error adding sponsored tx to pool"), + )?) } } /// Implementation of the Odyssey `wallet_` namespace. #[derive(Debug)] -struct OdysseyWalletInner { - provider: Provider, - eth_api: Eth, - wallet: EthereumWallet, +struct OdysseyWalletInner { + node: Node, chain_id: ChainId, /// Used to guard tx signing permit: Mutex<()>, @@ -294,17 +413,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); } @@ -329,34 +448,31 @@ mod tests { use alloy_rpc_types::TransactionRequest; #[test] fn no_value_allowed() { - assert_eq!( + matches!( validate_tx_request(&TransactionRequest::default().value(U256::from(1))), Err(OdysseyWalletError::ValueNotZero) ); - assert_eq!( - validate_tx_request(&TransactionRequest::default().value(U256::from(0))), - Ok(()) - ); + matches!(validate_tx_request(&TransactionRequest::default().value(U256::from(0))), Ok(())); } #[test] fn no_from_allowed() { - assert_eq!( + matches!( validate_tx_request(&TransactionRequest::default().from(Address::ZERO)), Err(OdysseyWalletError::FromSet) ); - assert_eq!(validate_tx_request(&TransactionRequest::default()), Ok(())); + matches!(validate_tx_request(&TransactionRequest::default()), Ok(())); } #[test] fn no_nonce_allowed() { - assert_eq!( + matches!( validate_tx_request(&TransactionRequest::default().nonce(1)), Err(OdysseyWalletError::NonceSet) ); - assert_eq!(validate_tx_request(&TransactionRequest::default()), Ok(())); + matches!(validate_tx_request(&TransactionRequest::default()), Ok(())); } }