diff --git a/Cargo.lock b/Cargo.lock index 4a739a5..8a3f692 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2175,6 +2175,20 @@ dependencies = [ "vault-types", ] +[[package]] +name = "engine-eip7702-core" +version = "0.1.0" +dependencies = [ + "alloy", + "engine-core", + "rand 0.9.1", + "serde", + "serde_json", + "thiserror 2.0.12", + "tokio", + "tracing", +] + [[package]] name = "engine-executors" version = "0.1.0" @@ -2184,6 +2198,7 @@ dependencies = [ "engine-aa-core", "engine-aa-types", "engine-core", + "engine-eip7702-core", "futures", "hex", "hmac", diff --git a/Cargo.toml b/Cargo.toml index ef7b824..72ea61b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "aa-types", "aa-core", "core", + "eip7702-core", "executors", "server", "thirdweb-core", diff --git a/core/src/execution_options/eip7702.rs b/core/src/execution_options/eip7702.rs index ab4a1cd..0746f53 100644 --- a/core/src/execution_options/eip7702.rs +++ b/core/src/execution_options/eip7702.rs @@ -1,15 +1,36 @@ use alloy::primitives::Address; -use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::defs::AddressDef; -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, utoipa::ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[schema(title = "EIP-7702 Execution Options")] +#[serde(rename_all = "camelCase", untagged)] +pub enum Eip7702ExecutionOptions { + /// Execute the transaction as the owner of the account + Owner(Eip7702OwnerExecution), + /// Execute a transaction on a different delegated account (`account_address`), which has granted a session key to the `session_key_address` + /// `session_key_address` is the signer for this transaction + SessionKey(Eip7702SessionKeyExecution), +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[schema(title = "EIP-7702 Owner Execution")] #[serde(rename_all = "camelCase")] -pub struct Eip7702ExecutionOptions { - /// The EOA address that will sign the EIP-7702 transaction - #[schemars(with = "AddressDef")] +pub struct Eip7702OwnerExecution { #[schema(value_type = AddressDef)] + /// The delegated EOA address pub from: Address, } + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[schema(title = "EIP-7702 Session Key Execution")] +#[serde(rename_all = "camelCase")] +pub struct Eip7702SessionKeyExecution { + #[schema(value_type = AddressDef)] + /// The session key address is your server wallet, which has been granted a session key to the `account_address` + pub session_key_address: Address, + #[schema(value_type = AddressDef)] + /// The account address is the address of a delegated account you want to execute the transaction on. This account has granted a session key to the `session_key_address` + pub account_address: Address, +} diff --git a/core/src/execution_options/mod.rs b/core/src/execution_options/mod.rs index 808a78f..7aaeaac 100644 --- a/core/src/execution_options/mod.rs +++ b/core/src/execution_options/mod.rs @@ -27,7 +27,7 @@ fn default_idempotency_key() -> String { } /// All supported specific execution options are contained here -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, utoipa::ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(tag = "type")] #[schema(title = "Execution Option Variants")] pub enum SpecificExecutionOptions { diff --git a/core/src/signer.rs b/core/src/signer.rs index a15ba44..4698a19 100644 --- a/core/src/signer.rs +++ b/core/src/signer.rs @@ -338,7 +338,7 @@ impl AccountSigner for EoaSigner { } => { let iaw_result = self .iaw_client - .sign_transaction(auth_token, thirdweb_auth, &transaction) + .sign_transaction(auth_token, thirdweb_auth, transaction) .await .map_err(|e| { tracing::error!("Error signing transaction with EOA (IAW): {:?}", e); diff --git a/eip7702-core/Cargo.toml b/eip7702-core/Cargo.toml new file mode 100644 index 0000000..c838160 --- /dev/null +++ b/eip7702-core/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "engine-eip7702-core" +version = "0.1.0" +edition = "2024" + +[dependencies] +alloy = { workspace = true, features = ["serde"] } +tokio = "1.44.2" +engine-core = { path = "../core" } +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0" +tracing = "0.1.41" +rand = "0.9" +thiserror = "2.0" \ No newline at end of file diff --git a/eip7702-core/src/constants.rs b/eip7702-core/src/constants.rs new file mode 100644 index 0000000..24d1574 --- /dev/null +++ b/eip7702-core/src/constants.rs @@ -0,0 +1,11 @@ +use alloy::primitives::{Address, address}; + +/// The minimal account implementation address used for EIP-7702 delegation +pub const MINIMAL_ACCOUNT_IMPLEMENTATION_ADDRESS: Address = + address!("0xD6999651Fc0964B9c6B444307a0ab20534a66560"); + +/// EIP-7702 delegation prefix bytes +pub const EIP_7702_DELEGATION_PREFIX: [u8; 3] = [0xef, 0x01, 0x00]; + +/// EIP-7702 delegation code length (prefix + address) +pub const EIP_7702_DELEGATION_CODE_LENGTH: usize = 23; \ No newline at end of file diff --git a/eip7702-core/src/delegated_account.rs b/eip7702-core/src/delegated_account.rs new file mode 100644 index 0000000..097d9b3 --- /dev/null +++ b/eip7702-core/src/delegated_account.rs @@ -0,0 +1,132 @@ +use alloy::{ + primitives::{Address, FixedBytes}, + providers::Provider, +}; +use engine_core::{ + chain::Chain, + credentials::SigningCredential, + error::{AlloyRpcErrorToEngineError, EngineError}, + signer::{AccountSigner, EoaSigner, EoaSigningOptions}, +}; +use rand::Rng; + +use crate::constants::{ + EIP_7702_DELEGATION_CODE_LENGTH, EIP_7702_DELEGATION_PREFIX, + MINIMAL_ACCOUNT_IMPLEMENTATION_ADDRESS, +}; + +/// Represents an EOA address that can have EIP-7702 delegation, associated with a specific chain +#[derive(Clone, Debug)] +pub struct DelegatedAccount { + /// The EOA address that may have delegation + pub eoa_address: Address, + /// The chain this account operates on + pub chain: C, +} + +impl DelegatedAccount { + /// Create a new delegated account from an EOA address and chain + pub fn new(eoa_address: Address, chain: C) -> Self { + Self { eoa_address, chain } + } + + /// Check if the EOA has EIP-7702 delegation to the minimal account implementation + pub async fn is_minimal_account(&self) -> Result { + // Get the bytecode at the EOA address using eth_getCode + let code = self + .chain + .provider() + .get_code_at(self.eoa_address) + .await + .map_err(|e| e.to_engine_error(self.chain()))?; + + tracing::debug!( + eoa_address = ?self.eoa_address, + code_length = code.len(), + code_hex = ?alloy::hex::encode(&code), + "Checking EIP-7702 delegation" + ); + + // Check if code exists and starts with EIP-7702 delegation prefix "0xef0100" + if code.len() < EIP_7702_DELEGATION_CODE_LENGTH + || !code.starts_with(&EIP_7702_DELEGATION_PREFIX) + { + tracing::debug!( + eoa_address = ?self.eoa_address, + has_delegation = false, + reason = "Code too short or doesn't start with EIP-7702 prefix", + "EIP-7702 delegation check result" + ); + return Ok(false); + } + + // Extract the target address from bytes 3-23 (20 bytes for address) + // EIP-7702 format: 0xef0100 + 20 bytes address + let target_bytes = &code[3..23]; + let target_address = Address::from_slice(target_bytes); + + // Compare with the minimal account implementation address + let is_delegated = target_address == MINIMAL_ACCOUNT_IMPLEMENTATION_ADDRESS; + + tracing::debug!( + eoa_address = ?self.eoa_address, + target_address = ?target_address, + minimal_account_address = ?MINIMAL_ACCOUNT_IMPLEMENTATION_ADDRESS, + has_delegation = is_delegated, + "EIP-7702 delegation check result" + ); + + Ok(is_delegated) + } + + /// Get the EOA address + pub fn address(&self) -> Address { + self.eoa_address + } + + /// Get the current nonce for the EOA + pub async fn get_nonce(&self) -> Result { + self.chain + .provider() + .get_transaction_count(self.eoa_address) + .await + .map_err(|e| e.to_engine_error(self.chain())) + } + + /// Get a reference to the chain + pub fn chain(&self) -> &C { + &self.chain + } + + /// Sign authorization for EIP-7702 delegation (automatically fetches nonce) + pub async fn sign_authorization( + &self, + eoa_signer: &EoaSigner, + credentials: &SigningCredential, + ) -> Result { + let nonce = self.get_nonce().await?; + + let signing_options = EoaSigningOptions { + from: self.eoa_address, + chain_id: Some(self.chain.chain_id()), + }; + + eoa_signer + .sign_authorization( + signing_options, + self.chain.chain_id(), + MINIMAL_ACCOUNT_IMPLEMENTATION_ADDRESS, + nonce, + credentials, + ) + .await + } + + /// Generate a random UID for wrapped calls + pub fn generate_random_uid() -> FixedBytes<32> { + let mut rng = rand::rng(); + let mut bytes = [0u8; 32]; + rng.fill(&mut bytes); + FixedBytes::from(bytes) + } +} diff --git a/eip7702-core/src/lib.rs b/eip7702-core/src/lib.rs new file mode 100644 index 0000000..dff5410 --- /dev/null +++ b/eip7702-core/src/lib.rs @@ -0,0 +1,3 @@ +pub mod constants; +pub mod delegated_account; +pub mod transaction; \ No newline at end of file diff --git a/eip7702-core/src/transaction.rs b/eip7702-core/src/transaction.rs new file mode 100644 index 0000000..812fdfe --- /dev/null +++ b/eip7702-core/src/transaction.rs @@ -0,0 +1,232 @@ +use alloy::{ + dyn_abi::TypedData, + primitives::{Address, U256}, + sol, + sol_types::{SolCall, eip712_domain}, +}; +use engine_core::{ + chain::Chain, + credentials::SigningCredential, + error::{AlloyRpcErrorToEngineError, EngineError}, + signer::{AccountSigner, EoaSigner, EoaSigningOptions}, + transaction::InnerTransaction, +}; +use serde_json::Value; + +use crate::delegated_account::DelegatedAccount; + +sol!( + #[derive(serde::Serialize)] + struct Call { + address target; + uint256 value; + bytes data; + } + + #[derive(serde::Serialize)] + struct WrappedCalls { + Call[] calls; + bytes32 uid; + } + + function execute(Call[] calldata calls) external payable; +); + +/// A transaction for a minimal account that supports signing and execution via bundler +pub struct MinimalAccountTransaction { + /// The delegated account this transaction belongs to + account: DelegatedAccount, + /// The raw transactions to be wrapped + wrapped_calls: WrappedCalls, + /// Authorization if needed for delegation setup + authorization: Option, +} + +impl DelegatedAccount { + /// Create a transaction for a session key address to execute on a target account + /// The session key address is the signer for this transaction, ie, they will sign the wrapped calls + /// The thirdweb executor is only responsible for calling executeWithSig + /// The flow is: + /// thirdweb executor -> executeWithSig(wrapped_calls) on the session key address + /// session key address -> execute(wrapped_calls.calls) on the target account + pub fn session_key_transaction( + self, + target_account: Address, + transactions: &[InnerTransaction], + ) -> MinimalAccountTransaction { + // First take all the inner transactions, and convert them to calls + // These are all the calls that the session key address wants to make on the target account + let inner_calls = transactions + .iter() + .map(|tx| Call { + target: tx.to.unwrap_or_default(), + value: tx.value, + data: tx.data.clone(), + }) + .collect(); + + // then get the calldata for calling the execute function (on the target account) with these calls + let outer_call = executeCall { calls: inner_calls }; + + // the session key address wants to call the execute function on the target account with these calls + let session_key_call = Call { + target: target_account, + value: U256::ZERO, + data: outer_call.abi_encode().into(), + }; + + // but the session key address still wants the "executor" (thirdweb bundler) to sponsor the transaction + // so the session key call is wrapped in a WrappedCalls struct + // the session key address is the signer for this transaction, ie, they will sign the wrapped calls + // the thirdweb executor is only responsible for calling executeWithSig + // the flow is: + // thirdweb executor -> executeWithSig(wrapped_calls) on the session key address + // session key address -> execute(wrapped_calls.calls) on the target account + let wrapped_calls = WrappedCalls { + calls: vec![session_key_call], + uid: Self::generate_random_uid(), + }; + + MinimalAccountTransaction { + account: self, + wrapped_calls, + authorization: None, + } + } + + pub fn owner_transaction( + self, + transactions: &[InnerTransaction], + ) -> MinimalAccountTransaction { + let inner_calls = transactions + .iter() + .map(|tx| Call { + target: tx.to.unwrap_or_default(), + value: tx.value, + data: tx.data.clone(), + }) + .collect(); + + let wrapped_calls = WrappedCalls { + calls: inner_calls, + uid: Self::generate_random_uid(), + }; + + MinimalAccountTransaction { + account: self, + wrapped_calls, + authorization: None, + } + } +} + +impl MinimalAccountTransaction { + /// Set the authorization for delegation setup + pub fn set_authorization(&mut self, authorization: alloy::eips::eip7702::SignedAuthorization) { + self.authorization = Some(authorization); + } + + pub async fn add_authorization_if_needed( + mut self, + signer: &EoaSigner, + credentials: &SigningCredential, + ) -> Result { + if self.account.is_minimal_account().await? { + return Ok(self); + } + + let authorization = self.account.sign_authorization(signer, credentials).await?; + self.authorization = Some(authorization); + Ok(self) + } + + /// Build the transaction data as JSON for bundler execution with automatic signing + pub async fn build( + &self, + eoa_signer: &EoaSigner, + credentials: &SigningCredential, + ) -> Result<(Value, String), EngineError> { + let signature = self.sign_wrapped_calls(eoa_signer, credentials).await?; + + // Serialize wrapped calls to JSON + let wrapped_calls_json = serde_json::to_value(&self.wrapped_calls).map_err(|e| { + EngineError::ValidationError { + message: format!("Failed to serialize wrapped calls: {}", e), + } + })?; + + Ok((wrapped_calls_json, signature)) + } + + /// Execute the transaction directly via bundler client + /// This builds the transaction and calls tw_execute on the bundler + pub async fn execute( + &self, + eoa_signer: &EoaSigner, + credentials: &SigningCredential, + ) -> Result { + let (wrapped_calls_json, signature) = self.build(eoa_signer, credentials).await?; + + self.account + .chain() + .bundler_client() + .tw_execute( + self.account.address(), + &wrapped_calls_json, + &signature, + self.authorization.as_ref(), + ) + .await + .map_err(|e| e.to_engine_bundler_error(self.account.chain())) + } + + /// Get the account this transaction belongs to + pub fn account(&self) -> &DelegatedAccount { + &self.account + } + + /// Get the authorization if set + pub fn authorization(&self) -> Option<&alloy::eips::eip7702::SignedAuthorization> { + self.authorization.as_ref() + } + + pub async fn sign_wrapped_calls( + &self, + eoa_signer: &EoaSigner, + credentials: &SigningCredential, + ) -> Result { + let typed_data = self.create_wrapped_calls_typed_data(); + self.sign_typed_data(eoa_signer, credentials, &typed_data) + .await + } + + /// Sign typed data with EOA signer + async fn sign_typed_data( + &self, + eoa_signer: &EoaSigner, + credentials: &SigningCredential, + typed_data: &TypedData, + ) -> Result { + let signing_options = EoaSigningOptions { + from: self.account.address(), + chain_id: Some(self.account.chain().chain_id()), + }; + + eoa_signer + .sign_typed_data(signing_options, typed_data, credentials) + .await + } + + /// Create typed data for signing wrapped calls using Alloy's native types + fn create_wrapped_calls_typed_data(&self) -> TypedData { + let domain = eip712_domain! { + name: "MinimalAccount", + version: "1", + chain_id: self.account.chain().chain_id(), + verifying_contract: self.account.address(), + }; + + // Use Alloy's native TypedData creation from struct + TypedData::from_struct(&self.wrapped_calls, Some(domain)) + } +} diff --git a/executors/Cargo.toml b/executors/Cargo.toml index 283ef56..fc95d89 100644 --- a/executors/Cargo.toml +++ b/executors/Cargo.toml @@ -16,6 +16,7 @@ thiserror = "2.0.12" tracing = "0.1.41" twmq = { version = "0.1.0", path = "../twmq" } engine-aa-types = { version = "0.1.0", path = "../aa-types" } +engine-eip7702-core = { version = "0.1.0", path = "../eip7702-core" } engine-core = { version = "0.1.0", path = "../core" } engine-aa-core = { version = "0.1.0", path = "../aa-core" } rand = "0.9.1" diff --git a/executors/src/eip7702_executor/confirm.rs b/executors/src/eip7702_executor/confirm.rs index 27f2efc..9ca6f37 100644 --- a/executors/src/eip7702_executor/confirm.rs +++ b/executors/src/eip7702_executor/confirm.rs @@ -15,6 +15,7 @@ use twmq::{ job::{BorrowedJob, JobResult, RequeuePosition, ToJobError, ToJobResult}, }; +use crate::eip7702_executor::send::Eip7702Sender; use crate::{ transaction_registry::TransactionRegistry, webhook::{ @@ -30,7 +31,12 @@ pub struct Eip7702ConfirmationJobData { pub transaction_id: String, pub chain_id: u64, pub bundler_transaction_id: String, - pub eoa_address: Address, + /// ! Deprecated todo: remove this field after all jobs are processed + pub eoa_address: Option
, + + // TODO: make non-optional after all jobs are processed + pub sender_details: Option, + pub rpc_credentials: RpcCredentials, #[serde(default)] pub webhook_options: Vec, @@ -55,7 +61,9 @@ pub struct Eip7702ConfirmationResult { pub transaction_id: String, pub transaction_hash: TxHash, pub receipt: TransactionReceipt, - pub eoa_address: Address, + + #[serde(flatten)] + pub sender_details: Eip7702Sender, } // --- Error Types --- @@ -86,7 +94,7 @@ pub enum Eip7702ConfirmationError { #[error("Transaction failed: {message}")] TransactionFailed { message: String, - receipt: TransactionReceipt, + receipt: Box, }, #[error("Invalid RPC Credentials: {message}")] @@ -242,7 +250,7 @@ where if !success { return Err(Eip7702ConfirmationError::TransactionFailed { message: "Transaction reverted".to_string(), - receipt, + receipt: Box::new(receipt), }) .map_err_fail(); } @@ -254,11 +262,25 @@ where "Transaction confirmed successfully" ); + // todo: remove this after all jobs are processed + let sender_details = job_data + .sender_details + .clone() + .or_else(|| { + job_data + .eoa_address + .map(|eoa_address| Eip7702Sender::Owner { eoa_address }) + }) + .ok_or_else(|| Eip7702ConfirmationError::InternalError { + message: "No sender details found".to_string(), + }) + .map_err_fail()?; + Ok(Eip7702ConfirmationResult { transaction_id: job_data.transaction_id.clone(), transaction_hash, receipt, - eoa_address: job_data.eoa_address, + sender_details, }) } diff --git a/executors/src/eip7702_executor/send.rs b/executors/src/eip7702_executor/send.rs index 4fa9992..3588849 100644 --- a/executors/src/eip7702_executor/send.rs +++ b/executors/src/eip7702_executor/send.rs @@ -1,27 +1,24 @@ use alloy::{ - dyn_abi::TypedData, eips::eip7702::Authorization, - primitives::{Address, Bytes, ChainId, FixedBytes, U256, address}, - providers::Provider, - sol_types::eip712_domain, + primitives::{Address, U256}, }; use engine_core::{ chain::{Chain, ChainService, RpcCredentials}, credentials::SigningCredential, - error::{EngineError, RpcErrorKind}, - execution_options::WebhookOptions, - signer::{AccountSigner, EoaSigner, EoaSigningOptions}, + error::{AlloyRpcErrorToEngineError, EngineError, RpcErrorKind}, + execution_options::{WebhookOptions, eip7702::Eip7702ExecutionOptions}, + signer::EoaSigner, transaction::InnerTransaction, }; -use rand::Rng; +use engine_eip7702_core::delegated_account::DelegatedAccount; use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use std::sync::Arc; +use serde_json::Value; +use std::{sync::Arc, time::Duration}; use twmq::{ FailHookData, NackHookData, Queue, SuccessHookData, UserCancellable, error::TwmqError, hooks::TransactionContext, - job::{BorrowedJob, JobResult, ToJobResult}, + job::{BorrowedJob, JobResult, ToJobError, ToJobResult}, }; use crate::{ @@ -34,9 +31,6 @@ use crate::{ use super::confirm::{Eip7702ConfirmationHandler, Eip7702ConfirmationJobData}; -const MINIMAL_ACCOUNT_IMPLEMENTATION_ADDRESS: Address = - address!("0xD6999651Fc0964B9c6B444307a0ab20534a66560"); - // --- Job Payload --- #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] @@ -44,7 +38,19 @@ pub struct Eip7702SendJobData { pub transaction_id: String, pub chain_id: u64, pub transactions: Vec, - pub eoa_address: Address, + + // !IMPORTANT TODO + // To preserve backwards compatibility with pre-existing queued jobs, we continue keeping the eoa_address field until the next release + // However, we make it optional now, and rely on the Eip7702ExecutionOptions instead + pub eoa_address: Option
, + + // We must also keep the execution_options as optional to prevent deserialization errors + // when we remove the eoa_address field, we can make execution_options required + // at runtime we resolve from both, with preference to execution_options + // if both are none, we return an error + #[serde(skip_serializing_if = "Option::is_none")] + pub execution_options: Option, + pub signing_credential: SigningCredential, #[serde(default)] pub webhook_options: Vec, @@ -68,11 +74,25 @@ impl HasTransactionMetadata for Eip7702SendJobData { #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct Eip7702SendResult { - pub eoa_address: Address, pub transaction_id: String, pub wrapped_calls: Value, pub signature: String, pub authorization: Option, + + #[serde(flatten)] + pub sender_details: Eip7702Sender, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(untagged)] +pub enum Eip7702Sender { + #[serde(rename_all = "camelCase")] + Owner { eoa_address: Address }, + #[serde(rename_all = "camelCase")] + SessionKey { + session_key_address: Address, + account_address: Address, + }, } // --- Error Types --- @@ -80,31 +100,20 @@ pub struct Eip7702SendResult { #[serde(rename_all = "SCREAMING_SNAKE_CASE", tag = "errorCode")] pub enum Eip7702SendError { #[error("Chain service error for chainId {chain_id}: {message}")] + #[serde(rename_all = "camelCase")] ChainServiceError { chain_id: u64, message: String }, - #[error("Failed to sign typed data: {message}")] + #[error("Failed to sign authorization or wrapped calls: {inner_error}")] #[serde(rename_all = "camelCase")] - SigningError { - message: String, - inner_error: Option, - }, + SigningFailed { inner_error: EngineError }, - #[error("Failed to check 7702 delegation: {message}")] + #[error("Failed to check delegation or add authorization: {inner_error}")] #[serde(rename_all = "camelCase")] - DelegationCheckError { - message: String, - inner_error: Option, - }, + DelegationCheckFailed { inner_error: EngineError }, - #[error("Failed to fetch nonce: {message}")] + #[error("Failed to call bundler: {inner_error}")] #[serde(rename_all = "camelCase")] - NonceFetchError { - message: String, - inner_error: Option, - }, - - #[error("Failed to call bundler: {message}")] - BundlerCallError { message: String }, + BundlerCallFailed { inner_error: EngineError }, #[error("Invalid RPC Credentials: {message}")] InvalidRpcCredentials { message: String }, @@ -130,22 +139,6 @@ impl UserCancellable for Eip7702SendError { } } -// --- Wrapped Calls Structure --- -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct Call { - pub target: Address, - pub value: U256, - pub data: Bytes, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct WrappedCalls { - pub calls: Vec, - pub uid: FixedBytes<32>, -} - // --- Handler --- pub struct Eip7702SendHandler where @@ -215,122 +208,129 @@ where let chain = chain.with_new_default_headers(chain_auth_headers); - // 2. Create wrapped calls with random UID - let wrapped_calls = WrappedCalls { - calls: job_data - .transactions - .iter() - .map(|tx| Call { - target: tx.to.unwrap_or_default(), - value: tx.value, - data: tx.data.clone(), - }) - .collect(), - uid: { - let mut rng = rand::rng(); - let mut bytes = [0u8; 32]; - rng.fill(&mut bytes); - FixedBytes::from(bytes) - }, - }; + let owner_address = job_data + .eoa_address + .or(job_data.execution_options.as_ref().map(|e| match e { + Eip7702ExecutionOptions::Owner(o) => o.from, + Eip7702ExecutionOptions::SessionKey(s) => s.session_key_address, + })) + .ok_or(Eip7702SendError::InternalError { + message: "No owner address found".to_string(), + }) + .map_err_fail()?; - // 3. Sign typed data for wrapped calls - let typed_data = create_wrapped_calls_typed_data( - job_data.chain_id, - job_data.eoa_address, - &wrapped_calls, - ); + let account = DelegatedAccount::new(owner_address, chain); + + let session_key_target_address = + job_data.execution_options.as_ref().and_then(|e| match e { + Eip7702ExecutionOptions::Owner(_) => None, + Eip7702ExecutionOptions::SessionKey(s) => Some(s.account_address), + }); - let signing_options = EoaSigningOptions { - from: job_data.eoa_address, - chain_id: Some(ChainId::from(job_data.chain_id)), + let mut transactions = match session_key_target_address { + Some(target_address) => { + account.session_key_transaction(target_address, &job_data.transactions) + } + None => account.owner_transaction(&job_data.transactions), }; - let signature = self - .eoa_signer - .sign_typed_data( - signing_options.clone(), - &typed_data, - &job_data.signing_credential, - ) + let is_authorization_needed = !transactions + .account() + .is_minimal_account() .await - .map_err(|e| Eip7702SendError::SigningError { - message: format!("Failed to sign typed data: {e}"), - inner_error: Some(e), - }) - .map_err_fail()?; + .map_err(|e| Eip7702SendError::DelegationCheckFailed { inner_error: e }) + .map_err_with_max_retries( + Some(Duration::from_secs(2)), + twmq::job::RequeuePosition::Last, + 3, + job.attempts(), + )?; + + if is_authorization_needed { + let authorization = transactions + .account() + .sign_authorization(&self.eoa_signer, &job_data.signing_credential) + .await + .map_err(|e| { + let mapped_error = Eip7702SendError::SigningFailed { + inner_error: e.clone(), + }; + + if is_build_error_retryable(&e) { + mapped_error.nack_with_max_retries( + Some(Duration::from_secs(2)), + twmq::job::RequeuePosition::Last, + 3, + job.attempts(), + ) + } else { + mapped_error.fail() + } + })?; + + transactions.set_authorization(authorization); + } - // 4. Check if wallet has 7702 delegation set - let is_minimal_account = check_is_7702_minimal_account(&chain, job_data.eoa_address) + let (wrapped_calls, signature) = transactions + .build(&self.eoa_signer, &job_data.signing_credential) .await - .map_err(|e| Eip7702SendError::DelegationCheckError { - message: format!("Failed to check if wallet has 7702 delegation: {e}"), - inner_error: Some(e), - }) + .map_err(|e| Eip7702SendError::SigningFailed { inner_error: e }) .map_err_fail()?; - // 5. Sign authorization if needed - let authorization = if !is_minimal_account { - let nonce = get_eoa_nonce(&chain, job_data.eoa_address) - .await - .map_err(|e| Eip7702SendError::NonceFetchError { - message: format!("Failed to fetch nonce: {e}"), - inner_error: Some(e), - }) - .map_err_fail()?; - - let auth = self - .eoa_signer - .sign_authorization( - signing_options.clone(), - job_data.chain_id, - MINIMAL_ACCOUNT_IMPLEMENTATION_ADDRESS, - nonce, - &job_data.signing_credential, - ) - .await - .map_err(|e| Eip7702SendError::SigningError { - message: format!("Failed to sign authorization: {e}"), - inner_error: Some(e), - }) - .map_err_fail()?; - - Some(auth.clone()) - } else { - None - }; - - // 6. Call bundler - let transaction_id = chain + let transaction_id = transactions + .account() + .chain() .bundler_client() .tw_execute( - job_data.eoa_address, - &serde_json::to_value(&wrapped_calls) - .map_err(|e| Eip7702SendError::InternalError { - message: format!("Failed to serialize wrapped calls: {}", e), - }) - .map_err_fail()?, + owner_address, + &wrapped_calls, &signature, - authorization.as_ref(), + transactions.authorization(), ) .await - .map_err(|e| Eip7702SendError::BundlerCallError { - message: e.to_string(), - }) - .map_err_fail()?; + .map_err(|e| { + let engine_error = e.to_engine_bundler_error(transactions.account().chain()); + let wrapped_error = Eip7702SendError::BundlerCallFailed { + inner_error: engine_error.clone(), + }; + if let EngineError::BundlerError { kind, .. } = &engine_error { + if is_retryable_rpc_error(kind) { + wrapped_error.nack_with_max_retries( + Some(Duration::from_secs(2)), + twmq::job::RequeuePosition::Last, + 3, + job.attempts(), + ) + } else { + wrapped_error.fail() + } + } else { + wrapped_error.fail() + } + })?; tracing::debug!(transaction_id = ?transaction_id, "EIP-7702 transaction sent to bundler"); + let sender_details = match session_key_target_address { + Some(target_address) => Eip7702Sender::SessionKey { + session_key_address: owner_address, + account_address: target_address, + }, + None => Eip7702Sender::Owner { + eoa_address: owner_address, + }, + }; + Ok(Eip7702SendResult { - eoa_address: job_data.eoa_address, - transaction_id, + sender_details, + transaction_id: transaction_id.clone(), wrapped_calls: serde_json::to_value(&wrapped_calls) .map_err(|e| Eip7702SendError::InternalError { message: format!("Failed to serialize wrapped calls: {}", e), }) .map_err_fail()?, signature, - authorization: authorization.map(|f| f.inner().clone()), + authorization: transactions.authorization().map(|f| f.inner().clone()), }) } @@ -355,7 +355,8 @@ where transaction_id: job.job.data.transaction_id.clone(), chain_id: job.job.data.chain_id, bundler_transaction_id: success_data.result.transaction_id.clone(), - eoa_address: success_data.result.eoa_address, + eoa_address: None, + sender_details: Some(success_data.result.sender_details.clone()), rpc_credentials: job.job.data.rpc_credentials.clone(), webhook_options: job.job.data.webhook_options.clone(), }) @@ -421,121 +422,73 @@ where } } -// --- Helper Functions --- - -fn create_wrapped_calls_typed_data( - chain_id: u64, - verifying_contract: Address, - wrapped_calls: &WrappedCalls, -) -> TypedData { - let domain = eip712_domain! { - name: "MinimalAccount", - version: "1", - chain_id: chain_id, - verifying_contract: verifying_contract, - }; - - let types_json = json!({ - "Call": [ - {"name": "target", "type": "address"}, - {"name": "value", "type": "uint256"}, - {"name": "data", "type": "bytes"} - ], - "WrappedCalls": [ - {"name": "calls", "type": "Call[]"}, - {"name": "uid", "type": "bytes32"} - ] - }); - - let message = json!({ - "calls": wrapped_calls.calls, - "uid": wrapped_calls.uid - }); - - // Parse the JSON into Eip712Types and create resolver - let eip712_types: alloy::dyn_abi::eip712::Eip712Types = - serde_json::from_value(types_json).expect("Failed to parse EIP712 types"); - - TypedData { - domain, - resolver: eip712_types.into(), - primary_type: "WrappedCalls".to_string(), - message, +/// Determines if an error should be retried based on its type and content +fn is_build_error_retryable(e: &EngineError) -> bool { + match e { + // Standard RPC errors - don't retry client errors (4xx) + EngineError::RpcError { kind, .. } => !is_client_error(kind), + + // Paymaster and Bundler errors - more restrictive retry policy + EngineError::PaymasterError { kind, .. } | EngineError::BundlerError { kind, .. } => { + is_retryable_rpc_error(kind) + } + + // Vault errors are never retryable (auth/encryption issues) + EngineError::VaultError { .. } => false, + + // All other errors are not retryable by default + _ => false, + } +} + +/// Check if an RPC error represents a client error (4xx) that shouldn't be retried +fn is_client_error(kind: &RpcErrorKind) -> bool { + match kind { + RpcErrorKind::TransportHttpError { status, .. } if *status >= 400 && *status < 500 => true, + RpcErrorKind::UnsupportedFeature { .. } => true, + _ => false, } } -async fn check_is_7702_minimal_account( - chain: &impl Chain, - eoa_address: Address, -) -> Result { - // Get the bytecode at the EOA address using eth_getCode - let code = chain - .provider() - .get_code_at(eoa_address) - .await - .map_err(|e| EngineError::RpcError { - chain_id: chain.chain_id(), - rpc_url: chain.rpc_url().to_string(), - message: format!("Failed to get code at address {}: {}", eoa_address, e), - kind: RpcErrorKind::InternalError { - message: e.to_string(), - }, - })?; - - tracing::debug!( - eoa_address = ?eoa_address, - code_length = code.len(), - code_hex = ?alloy::hex::encode(&code), - "Checking EIP-7702 delegation" - ); - - // Check if code exists and starts with EIP-7702 delegation prefix "0xef0100" - if code.len() < 23 || !code.starts_with(&[0xef, 0x01, 0x00]) { - tracing::debug!( - eoa_address = ?eoa_address, - has_delegation = false, - reason = "Code too short or doesn't start with EIP-7702 prefix", - "EIP-7702 delegation check result" - ); - return Ok(false); +/// Determine if an RPC error from paymaster/bundler should be retried +fn is_retryable_rpc_error(kind: &RpcErrorKind) -> bool { + match kind { + // Don't retry client errors (4xx) + RpcErrorKind::TransportHttpError { status, .. } if *status >= 400 && *status < 500 => false, + + // Don't retry 500 errors that contain revert data (contract reverts) + RpcErrorKind::TransportHttpError { status: 500, body } => !contains_revert_data(body), + + // Don't retry specific JSON-RPC error codes + RpcErrorKind::ErrorResp(resp) if is_non_retryable_rpc_code(resp.code) => false, + + // Retry other errors (network issues, temporary server errors, etc.) + _ => true, } +} - // Extract the target address from bytes 3-23 (20 bytes for address) - // EIP-7702 format: 0xef0100 + 20 bytes address - // JS equivalent: code.slice(8, 48) extracts 40 hex chars = 20 bytes - // In hex string: "0xef0100" + address, so address starts at position 8 - // In byte array: [0xef, 0x01, 0x00, address_bytes...] - // The address starts at byte 3 and is 20 bytes long (bytes 3-22) - let target_bytes = &code[3..23]; - let target_address = Address::from_slice(target_bytes); - - // Compare with the minimal account implementation address - let minimal_account_address: Address = MINIMAL_ACCOUNT_IMPLEMENTATION_ADDRESS; - - let is_delegated = target_address == minimal_account_address; - - tracing::debug!( - eoa_address = ?eoa_address, - target_address = ?target_address, - minimal_account_address = ?minimal_account_address, - has_delegation = is_delegated, - "EIP-7702 delegation check result" - ); - - Ok(is_delegated) +/// Check if the error body contains revert data indicating a contract revert +fn contains_revert_data(body: &str) -> bool { + // Common revert selectors that indicate contract execution failures + const REVERT_SELECTORS: &[&str] = &[ + "0x08c379a0", // Error(string) + "0x4e487b71", // Panic(uint256) + ]; + + // Check if body contains any revert selectors + REVERT_SELECTORS.iter().any(|selector| body.contains(selector)) || + // Also check for common revert-related keywords + body.contains("reverted during simulation") || + body.contains("execution reverted") || + body.contains("UserOperation reverted") } -async fn get_eoa_nonce(chain: &impl Chain, eoa_address: Address) -> Result { - chain - .provider() - .get_transaction_count(eoa_address) - .await - .map_err(|e| EngineError::RpcError { - chain_id: chain.chain_id(), - rpc_url: chain.rpc_url().to_string(), - message: format!("Failed to get nonce for address {}: {}", eoa_address, e), - kind: RpcErrorKind::InternalError { - message: e.to_string(), - }, - }) +/// Check if an RPC error code should not be retried +fn is_non_retryable_rpc_code(code: i64) -> bool { + match code { + -32000 => true, // Invalid input / execution error + -32001 => true, // Chain does not exist / invalid chain + -32603 => true, // Internal error (often indicates invalid params) + _ => false, + } } diff --git a/executors/src/eoa/error_classifier.rs b/executors/src/eoa/error_classifier.rs index 8dd1c80..e20f76b 100644 --- a/executors/src/eoa/error_classifier.rs +++ b/executors/src/eoa/error_classifier.rs @@ -242,7 +242,7 @@ impl EoaExecutionError { success_factory: impl FnOnce() -> T, error_factory: impl FnOnce(String) -> E, ) -> twmq::job::JobResult { - use twmq::job::{ToJobError, ToJobResult}; + use twmq::job::ToJobError; if strategy.queue_confirmation { // Treat as success since we need to check confirmation diff --git a/server/src/execution_router/mod.rs b/server/src/execution_router/mod.rs index 8d35986..b7eb5dc 100644 --- a/server/src/execution_router/mod.rs +++ b/server/src/execution_router/mod.rs @@ -350,7 +350,8 @@ impl ExecutionRouter { transaction_id: base_execution_options.idempotency_key.clone(), chain_id: base_execution_options.chain_id, transactions: transactions.to_vec(), - eoa_address: eip7702_execution_options.from, + eoa_address: None, + execution_options: Some(eip7702_execution_options.clone()), signing_credential, webhook_options, rpc_credentials, diff --git a/thirdweb-core/src/iaw/mod.rs b/thirdweb-core/src/iaw/mod.rs index 84b3617..d12bddf 100644 --- a/thirdweb-core/src/iaw/mod.rs +++ b/thirdweb-core/src/iaw/mod.rs @@ -68,16 +68,13 @@ impl From for IAWError { /// Message format for signing operations #[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Default)] pub enum MessageFormat { + #[default] Text, Hex, } -impl Default for MessageFormat { - fn default() -> Self { - MessageFormat::Text - } -} /// Response data for message signing operations #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/twmq/src/job.rs b/twmq/src/job.rs index afc382b..42c5b66 100644 --- a/twmq/src/job.rs +++ b/twmq/src/job.rs @@ -54,6 +54,14 @@ pub enum JobError { pub trait ToJobResult { fn map_err_nack(self, delay: Option, position: RequeuePosition) -> JobResult; fn map_err_fail(self) -> JobResult; + + fn map_err_with_max_retries( + self, + delay: Option, + position: RequeuePosition, + max_retries: u32, + current_attempts: u32, + ) -> JobResult; } impl ToJobResult for Result @@ -71,11 +79,39 @@ where fn map_err_fail(self) -> JobResult { self.map_err(|e| JobError::Fail(e.into())) } + + fn map_err_with_max_retries( + self, + delay: Option, + position: RequeuePosition, + max_retries: u32, + current_attempts: u32, + ) -> JobResult { + self.map_err(|e| { + if current_attempts >= max_retries { + JobError::Fail(e.into()) + } else { + JobError::Nack { + error: e.into(), + delay, + position, + } + } + }) + } } pub trait ToJobError { fn nack(self, delay: Option, position: RequeuePosition) -> JobError; fn fail(self) -> JobError; + + fn nack_with_max_retries( + self, + delay: Option, + position: RequeuePosition, + max_retries: u32, + current_attempts: u32, + ) -> JobError; } impl ToJobError for E { @@ -90,6 +126,24 @@ impl ToJobError for E { fn fail(self) -> JobError { JobError::Fail(self) } + + fn nack_with_max_retries( + self, + delay: Option, + position: RequeuePosition, + max_retries: u32, + current_attempts: u32, + ) -> JobError { + if current_attempts >= max_retries { + JobError::Fail(self) + } else { + JobError::Nack { + error: self, + delay, + position, + } + } + } } #[derive(Debug, Clone, Serialize, Deserialize)]