diff --git a/core/src/execution_options/eip7702.rs b/core/src/execution_options/eip7702.rs new file mode 100644 index 0000000..225b2c1 --- /dev/null +++ b/core/src/execution_options/eip7702.rs @@ -0,0 +1,14 @@ +use alloy::primitives::Address; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::defs::AddressDef; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Eip7702ExecutionOptions { + /// The EOA address that will sign the EIP-7702 transaction + #[schemars(with = "AddressDef")] + #[schema(value_type = AddressDef)] + pub from: Address, +} diff --git a/core/src/execution_options/mod.rs b/core/src/execution_options/mod.rs index 186063e..1f6ae5d 100644 --- a/core/src/execution_options/mod.rs +++ b/core/src/execution_options/mod.rs @@ -7,6 +7,7 @@ use std::collections::HashMap; use crate::transaction::InnerTransaction; pub mod aa; pub mod auto; +pub mod eip7702; // Base execution options for all transactions // All specific execution options share this @@ -35,6 +36,9 @@ pub enum SpecificExecutionOptions { #[schema(title = "ERC-4337 Execution Options")] ERC4337(aa::Erc4337ExecutionOptions), + + #[schema(title = "EIP-7702 Execution Options")] + EIP7702(eip7702::Eip7702ExecutionOptions), } fn deserialize_with_default_auto<'de, D>( @@ -118,6 +122,8 @@ pub struct QueuedTransactionsResponse { pub enum ExecutorType { #[serde(rename = "ERC4337")] Erc4337, + #[serde(rename = "EIP7702")] + Eip7702, } impl ExecutionOptions { @@ -125,6 +131,7 @@ impl ExecutionOptions { match &self.specific { SpecificExecutionOptions::ERC4337(_) => ExecutorType::Erc4337, SpecificExecutionOptions::Auto(_) => ExecutorType::Erc4337, + SpecificExecutionOptions::EIP7702(_) => ExecutorType::Eip7702, } } diff --git a/core/src/rpc_clients/bundler.rs b/core/src/rpc_clients/bundler.rs index ecb27d7..e99f386 100644 --- a/core/src/rpc_clients/bundler.rs +++ b/core/src/rpc_clients/bundler.rs @@ -1,9 +1,11 @@ use alloy::consensus::{Receipt, ReceiptWithBloom}; +use alloy::eips::eip7702::SignedAuthorization; use alloy::primitives::{Address, Bytes, U256}; use alloy::rpc::client::RpcClient; use alloy::rpc::types::{Log, TransactionReceipt}; use alloy::transports::{IntoBoxTransport, TransportResult}; use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::collections::HashMap; use crate::userop::VersionedUserOp; @@ -60,6 +62,22 @@ pub struct UserOperationReceipt { pub receipt: TransactionReceipt>>, } +/// Response from tw_execute bundler method +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TwExecuteResponse { + /// The queue ID returned by the bundler + pub queue_id: String, +} + +/// Response from tw_getTransactionHash bundler method +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TwGetTransactionHashResponse { + /// The transaction hash + pub transaction_hash: String, +} + impl BundlerClient { /// Create a new bundler client with the given transport pub fn new(transport: impl IntoBoxTransport) -> Self { @@ -114,4 +132,29 @@ impl BundlerClient { Ok(result) } + + /// Execute an EIP-7702 transaction via the bundler + pub async fn tw_execute( + &self, + eoa_address: Address, + wrapped_calls: &Value, + signature: &str, + authorization: Option<&SignedAuthorization>, + ) -> TransportResult { + let params = serde_json::json!([eoa_address, wrapped_calls, signature, authorization]); + + let response: TwExecuteResponse = self.inner.request("tw_execute", params).await?; + + Ok(response.queue_id) + } + + /// Get transaction hash from bundler using transaction ID + pub async fn tw_get_transaction_hash(&self, transaction_id: &str) -> TransportResult { + let params = serde_json::json!([transaction_id]); + + let response: TwGetTransactionHashResponse = + self.inner.request("tw_getTransactionHash", params).await?; + + Ok(response.transaction_hash) + } } diff --git a/core/src/signer.rs b/core/src/signer.rs index d978dee..92799f8 100644 --- a/core/src/signer.rs +++ b/core/src/signer.rs @@ -1,7 +1,9 @@ use alloy::{ dyn_abi::TypedData, + eips::eip7702::SignedAuthorization, hex::FromHex, - primitives::{Address, Bytes, ChainId}, + primitives::{Address, Bytes, ChainId, U256}, + rpc::types::Authorization, }; use serde::{Deserialize, Serialize}; use serde_with::{DisplayFromStr, PickFirst, serde_as}; @@ -176,6 +178,16 @@ pub trait AccountSigner { typed_data: &TypedData, credentials: SigningCredential, ) -> impl std::future::Future> + Send; + + /// Sign EIP-7702 authorization + fn sign_authorization( + &self, + options: Self::SigningOptions, + chain_id: u64, + address: Address, + nonce: alloy::primitives::U256, + credentials: SigningCredential, + ) -> impl std::future::Future> + Send; } /// EOA signer implementation @@ -188,7 +200,10 @@ pub struct EoaSigner { impl EoaSigner { /// Create a new EOA signer pub fn new(vault_client: VaultClient, iaw_client: IAWClient) -> Self { - Self { vault_client, iaw_client } + Self { + vault_client, + iaw_client, + } } } @@ -221,7 +236,10 @@ impl AccountSigner for EoaSigner { Ok(vault_result.signature) } - SigningCredential::Iaw { auth_token, thirdweb_auth } => { + SigningCredential::Iaw { + auth_token, + thirdweb_auth, + } => { // Convert MessageFormat to IAW MessageFormat let iaw_format = match format { MessageFormat::Text => thirdweb_core::iaw::MessageFormat::Text, @@ -268,7 +286,10 @@ impl AccountSigner for EoaSigner { Ok(vault_result.signature) } - SigningCredential::Iaw { auth_token, thirdweb_auth } => { + SigningCredential::Iaw { + auth_token, + thirdweb_auth, + } => { let iaw_result = self .iaw_client .sign_typed_data( @@ -287,6 +308,54 @@ impl AccountSigner for EoaSigner { } } } + + async fn sign_authorization( + &self, + options: EoaSigningOptions, + chain_id: u64, + address: Address, + nonce: U256, + credentials: SigningCredential, + ) -> Result { + // Create the Authorization struct that both clients expect + let authorization = Authorization { + chain_id: U256::from(chain_id), + address, + nonce: nonce.to::(), + }; + + match credentials { + SigningCredential::Vault(auth_method) => { + let vault_result = self + .vault_client + .sign_authorization(auth_method, options.from, authorization) + .await + .map_err(|e| { + tracing::error!("Error signing authorization with EOA (Vault): {:?}", e); + e + })?; + + // Return the signed authorization as Authorization + Ok(vault_result.signed_authorization) + } + SigningCredential::Iaw { + auth_token, + thirdweb_auth, + } => { + let iaw_result = self + .iaw_client + .sign_authorization(auth_token, thirdweb_auth, options.from, authorization) + .await + .map_err(|e| { + tracing::error!("Error signing authorization with EOA (IAW): {:?}", e); + EngineError::from(e) + })?; + + // Return the signed authorization as Authorization + Ok(iaw_result.signed_authorization) + } + } + } } /// Parameters for signing a message (used in routes) diff --git a/executors/src/eip7702_executor/confirm.rs b/executors/src/eip7702_executor/confirm.rs new file mode 100644 index 0000000..aee2f2c --- /dev/null +++ b/executors/src/eip7702_executor/confirm.rs @@ -0,0 +1,311 @@ +use alloy::primitives::{Address, TxHash}; +use alloy::providers::Provider; +use engine_core::{ + chain::{Chain, ChainService, RpcCredentials}, + execution_options::WebhookOptions, +}; +use serde::{Deserialize, Serialize}; +use std::{sync::Arc, time::Duration}; +use twmq::{ + FailHookData, NackHookData, Queue, SuccessHookData, UserCancellable, + error::TwmqError, + hooks::TransactionContext, + job::{BorrowedJob, JobResult, RequeuePosition, ToJobError, ToJobResult}, +}; + +use crate::{ + transaction_registry::TransactionRegistry, + webhook::{ + WebhookJobHandler, + envelope::{ExecutorStage, HasTransactionMetadata, HasWebhookOptions, WebhookCapable}, + }, +}; + +// --- Job Payload --- +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Eip7702ConfirmationJobData { + pub transaction_id: String, + pub chain_id: u64, + pub bundler_transaction_id: String, + pub eoa_address: Address, + pub rpc_credentials: RpcCredentials, + pub webhook_options: Option>, +} + +impl HasWebhookOptions for Eip7702ConfirmationJobData { + fn webhook_options(&self) -> Option> { + self.webhook_options.clone() + } +} + +impl HasTransactionMetadata for Eip7702ConfirmationJobData { + fn transaction_id(&self) -> String { + self.transaction_id.clone() + } +} + +// --- Success Result --- +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Eip7702ConfirmationResult { + pub transaction_id: String, + pub transaction_hash: TxHash, + pub eoa_address: Address, + pub block_number: Option, + pub gas_used: Option, + pub status: bool, +} + +// --- Error Types --- +#[derive(Serialize, Deserialize, Debug, Clone, thiserror::Error)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE", tag = "errorCode")] +pub enum Eip7702ConfirmationError { + #[error("Chain service error for chainId {chain_id}: {message}")] + ChainServiceError { chain_id: u64, message: String }, + + #[error("Failed to get transaction hash from bundler: {message}")] + TransactionHashError { message: String }, + + #[error("Failed to confirm transaction: {message}")] + ConfirmationError { message: String }, + + #[error("Transaction failed: {message}")] + TransactionFailed { message: String }, + + #[error("Invalid RPC Credentials: {message}")] + InvalidRpcCredentials { message: String }, + + #[error("Internal error: {message}")] + InternalError { message: String }, + + #[error("Transaction cancelled by user")] + UserCancelled, +} + +impl From for Eip7702ConfirmationError { + fn from(error: TwmqError) -> Self { + Eip7702ConfirmationError::InternalError { + message: format!("Deserialization error for job data: {}", error), + } + } +} + +impl UserCancellable for Eip7702ConfirmationError { + fn user_cancelled() -> Self { + Eip7702ConfirmationError::UserCancelled + } +} + +// --- Handler --- +pub struct Eip7702ConfirmationHandler +where + CS: ChainService + Send + Sync + 'static, +{ + pub chain_service: Arc, + pub webhook_queue: Arc>, + pub transaction_registry: Arc, +} + +impl ExecutorStage for Eip7702ConfirmationHandler +where + CS: ChainService + Send + Sync + 'static, +{ + fn executor_name() -> &'static str { + "eip7702" + } + + fn stage_name() -> &'static str { + "confirm" + } +} + +impl WebhookCapable for Eip7702ConfirmationHandler +where + CS: ChainService + Send + Sync + 'static, +{ + fn webhook_queue(&self) -> &Arc> { + &self.webhook_queue + } +} + +impl twmq::DurableExecution for Eip7702ConfirmationHandler +where + CS: ChainService + Send + Sync + 'static, +{ + type Output = Eip7702ConfirmationResult; + type ErrorData = Eip7702ConfirmationError; + type JobData = Eip7702ConfirmationJobData; + + #[tracing::instrument(skip(self, job), fields(transaction_id = job.job.id, stage = Self::stage_name(), executor = Self::executor_name()))] + async fn process( + &self, + job: &BorrowedJob, + ) -> JobResult { + let job_data = &job.job.data; + + // 1. Get Chain + let chain = self + .chain_service + .get_chain(job_data.chain_id) + .map_err(|e| Eip7702ConfirmationError::ChainServiceError { + chain_id: job_data.chain_id, + message: format!("Failed to get chain instance: {}", e), + }) + .map_err_fail()?; + + let chain_auth_headers = job_data + .rpc_credentials + .to_header_map() + .map_err(|e| Eip7702ConfirmationError::InvalidRpcCredentials { + message: e.to_string(), + }) + .map_err_fail()?; + + let chain = chain.with_new_default_headers(chain_auth_headers); + + // 2. Get transaction hash from bundler + let transaction_hash_str = chain + .bundler_client() + .tw_get_transaction_hash(&job_data.bundler_transaction_id) + .await + .map_err(|e| { + // Check if it's a "not found" or "pending" error + let error_msg = e.to_string(); + if error_msg.contains("not found") || error_msg.contains("pending") { + // Transaction not ready yet, nack and retry + Eip7702ConfirmationError::TransactionHashError { + message: format!("Transaction not ready: {}", error_msg), + } + .nack(Some(Duration::from_secs(5)), RequeuePosition::Last) + } else { + Eip7702ConfirmationError::TransactionHashError { message: error_msg }.fail() + } + })?; + + let transaction_hash = transaction_hash_str.parse::().map_err(|e| { + Eip7702ConfirmationError::TransactionHashError { + message: format!("Invalid transaction hash format: {}", e), + } + .fail() + })?; + + tracing::debug!( + transaction_hash = ?transaction_hash, + bundler_transaction_id = job_data.bundler_transaction_id, + "Got transaction hash from bundler" + ); + + // 3. Wait for transaction confirmation + let receipt = chain + .provider() + .get_transaction_receipt(transaction_hash) + .await + .map_err(|e| { + // If transaction not found, nack and retry + Eip7702ConfirmationError::ConfirmationError { + message: format!("Failed to get transaction receipt: {}", e), + } + .nack(Some(Duration::from_secs(10)), RequeuePosition::Last) + })?; + + let receipt = match receipt { + Some(receipt) => receipt, + None => { + // Transaction not mined yet, nack and retry + return Err(Eip7702ConfirmationError::ConfirmationError { + message: "Transaction not mined yet".to_string(), + }) + .map_err_nack(Some(Duration::from_secs(10)), RequeuePosition::Last); + } + }; + + // 4. Check transaction status + let success = receipt.status(); + if !success { + return Err(Eip7702ConfirmationError::TransactionFailed { + message: "Transaction reverted".to_string(), + }) + .map_err_fail(); + } + + tracing::debug!( + transaction_hash = ?transaction_hash, + block_number = receipt.block_number, + gas_used = ?receipt.gas_used, + "Transaction confirmed successfully" + ); + + Ok(Eip7702ConfirmationResult { + transaction_id: job_data.transaction_id.clone(), + transaction_hash, + eoa_address: job_data.eoa_address, + block_number: receipt.block_number, + gas_used: Some(receipt.gas_used), + status: success, + }) + } + + async fn on_success( + &self, + job: &BorrowedJob, + success_data: SuccessHookData<'_, Eip7702ConfirmationResult>, + tx: &mut TransactionContext<'_>, + ) { + // Remove transaction from registry since confirmation is complete + self.transaction_registry + .add_remove_command(tx.pipeline(), &job.job.data.transaction_id); + + // Send webhook + if let Err(e) = self.queue_success_webhook(job, success_data, tx) { + tracing::error!( + transaction_id = job.job.data.transaction_id, + error = ?e, + "Failed to queue success webhook" + ); + } + } + + async fn on_nack( + &self, + job: &BorrowedJob, + nack_data: NackHookData<'_, Eip7702ConfirmationError>, + tx: &mut TransactionContext<'_>, + ) { + // Don't modify transaction registry on NACK - job will be retried + if let Err(e) = self.queue_nack_webhook(job, nack_data, tx) { + tracing::error!( + transaction_id = job.job.data.transaction_id, + error = ?e, + "Failed to queue nack webhook" + ); + } + } + + async fn on_fail( + &self, + job: &BorrowedJob, + fail_data: FailHookData<'_, Eip7702ConfirmationError>, + tx: &mut TransactionContext<'_>, + ) { + // Remove transaction from registry since it failed permanently + self.transaction_registry + .add_remove_command(tx.pipeline(), &job.job.data.transaction_id); + + tracing::error!( + transaction_id = job.job.data.transaction_id, + error = ?fail_data.error, + "EIP-7702 confirmation job failed" + ); + + if let Err(e) = self.queue_fail_webhook(job, fail_data, tx) { + tracing::error!( + transaction_id = job.job.data.transaction_id, + error = ?e, + "Failed to queue fail webhook" + ); + } + } +} + +// --- Helper Functions --- diff --git a/executors/src/eip7702_executor/mod.rs b/executors/src/eip7702_executor/mod.rs new file mode 100644 index 0000000..be241c9 --- /dev/null +++ b/executors/src/eip7702_executor/mod.rs @@ -0,0 +1,2 @@ +pub mod send; +pub mod confirm; \ No newline at end of file diff --git a/executors/src/eip7702_executor/send.rs b/executors/src/eip7702_executor/send.rs new file mode 100644 index 0000000..5248552 --- /dev/null +++ b/executors/src/eip7702_executor/send.rs @@ -0,0 +1,514 @@ +use alloy::{ + dyn_abi::TypedData, + eips::eip7702::Authorization, + primitives::{Address, Bytes, ChainId, FixedBytes, U256}, + providers::Provider, + sol_types::eip712_domain, +}; +use engine_core::{ + chain::{Chain, ChainService, RpcCredentials}, + credentials::SigningCredential, + error::{EngineError, RpcErrorKind}, + execution_options::WebhookOptions, + signer::{AccountSigner, EoaSigner, EoaSigningOptions}, + transaction::InnerTransaction, +}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::sync::Arc; +use twmq::{ + FailHookData, NackHookData, Queue, SuccessHookData, UserCancellable, + error::TwmqError, + hooks::TransactionContext, + job::{BorrowedJob, JobResult, ToJobResult}, +}; + +use crate::{ + transaction_registry::TransactionRegistry, + webhook::{ + WebhookJobHandler, + envelope::{ExecutorStage, HasTransactionMetadata, HasWebhookOptions, WebhookCapable}, + }, +}; + +use super::confirm::{Eip7702ConfirmationHandler, Eip7702ConfirmationJobData}; + +const MINIMAL_ACCOUNT_IMPLEMENTATION_ADDRESS: &str = "0xD6999651Fc0964B9c6B444307a0ab20534a66560"; + +// --- Job Payload --- +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Eip7702SendJobData { + pub transaction_id: String, + pub chain_id: u64, + pub transactions: Vec, + pub eoa_address: Address, + pub signing_credential: SigningCredential, + pub webhook_options: Option>, + pub rpc_credentials: RpcCredentials, + pub nonce: Option, +} + +impl HasWebhookOptions for Eip7702SendJobData { + fn webhook_options(&self) -> Option> { + self.webhook_options.clone() + } +} + +impl HasTransactionMetadata for Eip7702SendJobData { + fn transaction_id(&self) -> String { + self.transaction_id.clone() + } +} + +// --- Success Result --- +#[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, +} + +// --- Error Types --- +#[derive(Serialize, Deserialize, Debug, Clone, thiserror::Error)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE", tag = "errorCode")] +pub enum Eip7702SendError { + #[error("Chain service error for chainId {chain_id}: {message}")] + ChainServiceError { chain_id: u64, message: String }, + + #[error("Failed to sign typed data: {message}")] + SigningError { message: String }, + + #[error("Failed to sign authorization: {message}")] + AuthorizationError { message: String }, + + #[error("Failed to check 7702 delegation: {message}")] + DelegationCheckError { message: String }, + + #[error("Failed to call bundler: {message}")] + BundlerCallError { message: String }, + + #[error("Invalid RPC Credentials: {message}")] + InvalidRpcCredentials { message: String }, + + #[error("Internal error: {message}")] + InternalError { message: String }, + + #[error("Transaction cancelled by user")] + UserCancelled, +} + +impl From for Eip7702SendError { + fn from(error: TwmqError) -> Self { + Eip7702SendError::InternalError { + message: format!("Deserialization error for job data: {}", error), + } + } +} + +impl UserCancellable for Eip7702SendError { + fn user_cancelled() -> Self { + Eip7702SendError::UserCancelled + } +} + +// --- 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 + CS: ChainService + Send + Sync + 'static, +{ + pub chain_service: Arc, + pub eoa_signer: Arc, + pub webhook_queue: Arc>, + pub confirm_queue: Arc>>, + pub transaction_registry: Arc, +} + +impl ExecutorStage for Eip7702SendHandler +where + CS: ChainService + Send + Sync + 'static, +{ + fn executor_name() -> &'static str { + "eip7702" + } + + fn stage_name() -> &'static str { + "prepare_and_send" + } +} + +impl WebhookCapable for Eip7702SendHandler +where + CS: ChainService + Send + Sync + 'static, +{ + fn webhook_queue(&self) -> &Arc> { + &self.webhook_queue + } +} + +impl twmq::DurableExecution for Eip7702SendHandler +where + CS: ChainService + Send + Sync + 'static, +{ + type Output = Eip7702SendResult; + type ErrorData = Eip7702SendError; + type JobData = Eip7702SendJobData; + + #[tracing::instrument(skip(self, job), fields(transaction_id = job.job.id, stage = Self::stage_name(), executor = Self::executor_name()))] + async fn process( + &self, + job: &BorrowedJob, + ) -> JobResult { + let job_data = &job.job.data; + + // 1. Get Chain + let chain = self + .chain_service + .get_chain(job_data.chain_id) + .map_err(|e| Eip7702SendError::ChainServiceError { + chain_id: job_data.chain_id, + message: format!("Failed to get chain instance: {}", e), + }) + .map_err_fail()?; + + let chain_auth_headers = job_data + .rpc_credentials + .to_header_map() + .map_err(|e| Eip7702SendError::InvalidRpcCredentials { + message: e.to_string(), + }) + .map_err_fail()?; + + 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) + }, + }; + + // 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 signing_options = EoaSigningOptions { + from: job_data.eoa_address, + chain_id: Some(ChainId::from(job_data.chain_id)), + }; + + let signature = self + .eoa_signer + .sign_typed_data( + signing_options.clone(), + &typed_data, + job_data.signing_credential.clone(), + ) + .await + .map_err(|e| Eip7702SendError::SigningError { + message: e.to_string(), + }) + .map_err_fail()?; + + // 4. Check if wallet has 7702 delegation set + let is_minimal_account = check_is_7702_minimal_account(&chain, job_data.eoa_address) + .await + .map_err(|e| Eip7702SendError::DelegationCheckError { + message: e.to_string(), + }) + .map_err_fail()?; + + // 5. Sign authorization if needed + let authorization = if !is_minimal_account { + let nonce = job_data.nonce.unwrap_or_default(); + let minimal_account_address: Address = MINIMAL_ACCOUNT_IMPLEMENTATION_ADDRESS + .parse() + .map_err(|e| Eip7702SendError::AuthorizationError { + message: format!("Invalid minimal account implementation address: {}", e), + }) + .map_err_fail()?; + + let auth = self + .eoa_signer + .sign_authorization( + signing_options.clone(), + job_data.chain_id, + minimal_account_address, + nonce, + job_data.signing_credential.clone(), + ) + .await + .map_err(|e| Eip7702SendError::AuthorizationError { + message: e.to_string(), + }) + .map_err_fail()?; + + Some(auth.clone()) + } else { + None + }; + + // 6. Call bundler + let transaction_id = 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()?, + &signature, + authorization.as_ref(), + ) + .await + .map_err(|e| Eip7702SendError::BundlerCallError { + message: e.to_string(), + }) + .map_err_fail()?; + + tracing::debug!(transaction_id = ?transaction_id, "EIP-7702 transaction sent to bundler"); + + Ok(Eip7702SendResult { + eoa_address: job_data.eoa_address, + transaction_id, + 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()), + }) + } + + async fn on_success( + &self, + job: &BorrowedJob, + success_data: SuccessHookData<'_, Eip7702SendResult>, + tx: &mut TransactionContext<'_>, + ) { + // Update transaction registry: move from send queue to confirm queue + self.transaction_registry.add_set_command( + tx.pipeline(), + &job.job.data.transaction_id, + "eip7702_confirm", + ); + + // Send confirmation job + let confirmation_job = self + .confirm_queue + .clone() + .job(Eip7702ConfirmationJobData { + 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, + rpc_credentials: job.job.data.rpc_credentials.clone(), + webhook_options: job.job.data.webhook_options.clone(), + }) + .with_id(job.job.transaction_id()); + + if let Err(e) = tx.queue_job(confirmation_job) { + tracing::error!( + transaction_id = job.job.data.transaction_id, + error = ?e, + "Failed to enqueue confirmation job" + ); + } + + // Send webhook + if let Err(e) = self.queue_success_webhook(job, success_data, tx) { + tracing::error!( + transaction_id = job.job.data.transaction_id, + error = ?e, + "Failed to queue success webhook" + ); + } + } + + async fn on_nack( + &self, + job: &BorrowedJob, + nack_data: NackHookData<'_, Eip7702SendError>, + tx: &mut TransactionContext<'_>, + ) { + // Don't modify transaction registry on NACK - job will be retried + if let Err(e) = self.queue_nack_webhook(job, nack_data, tx) { + tracing::error!( + transaction_id = job.job.data.transaction_id, + error = ?e, + "Failed to queue nack webhook" + ); + } + } + + async fn on_fail( + &self, + job: &BorrowedJob, + fail_data: FailHookData<'_, Eip7702SendError>, + tx: &mut TransactionContext<'_>, + ) { + // Remove transaction from registry since it failed permanently + self.transaction_registry + .add_remove_command(tx.pipeline(), &job.job.data.transaction_id); + + tracing::error!( + transaction_id = job.job.data.transaction_id, + error = ?fail_data.error, + "EIP-7702 send job failed" + ); + + if let Err(e) = self.queue_fail_webhook(job, fail_data, tx) { + tracing::error!( + transaction_id = job.job.data.transaction_id, + error = ?e, + "Failed to queue fail webhook" + ); + } + } +} + +// --- 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, + } +} + +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); + } + + // 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 + .parse() + .map_err(|e| EngineError::ValidationError { + message: format!("Invalid minimal account implementation address: {}", e), + })?; + + 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) +} diff --git a/executors/src/lib.rs b/executors/src/lib.rs index 0e9dd3e..c8de07f 100644 --- a/executors/src/lib.rs +++ b/executors/src/lib.rs @@ -1,3 +1,4 @@ pub mod external_bundler; +pub mod eip7702_executor; pub mod webhook; pub mod transaction_registry; diff --git a/server/src/execution_router/mod.rs b/server/src/execution_router/mod.rs index b4bb9e2..6272394 100644 --- a/server/src/execution_router/mod.rs +++ b/server/src/execution_router/mod.rs @@ -8,11 +8,15 @@ use engine_core::{ error::EngineError, execution_options::{ BaseExecutionOptions, QueuedTransaction, SendTransactionRequest, SpecificExecutionOptions, - WebhookOptions, aa::Erc4337ExecutionOptions, + WebhookOptions, aa::Erc4337ExecutionOptions, eip7702::Eip7702ExecutionOptions, }, transaction::InnerTransaction, }; use engine_executors::{ + eip7702_executor::{ + confirm::Eip7702ConfirmationHandler, + send::{Eip7702SendHandler, Eip7702SendJobData}, + }, external_bundler::{ confirm::UserOpConfirmationHandler, send::{ExternalBundlerSendHandler, ExternalBundlerSendJobData}, @@ -34,6 +38,8 @@ pub struct ExecutionRouter { pub webhook_queue: Arc>, pub external_bundler_send_queue: Arc>>, pub userop_confirm_queue: Arc>>, + pub eip7702_send_queue: Arc>>, + pub eip7702_confirm_queue: Arc>>, pub transaction_registry: Arc, pub vault_client: Arc, pub chains: Arc, @@ -219,6 +225,31 @@ impl ExecutionRouter { Ok(vec![queued_transaction]) } + SpecificExecutionOptions::EIP7702(ref eip7702_execution_options) => { + self.execute_eip7702( + &execution_request.execution_options.base, + eip7702_execution_options, + &execution_request.webhook_options, + &execution_request.params, + rpc_credentials, + signing_credential, + ) + .await?; + + let queued_transaction = QueuedTransaction { + id: execution_request + .execution_options + .base + .idempotency_key + .clone(), + batch_index: 0, + execution_params: execution_request.execution_options, + transaction_params: execution_request.params, + }; + + Ok(vec![queued_transaction]) + } + SpecificExecutionOptions::Auto(_auto_execution_options) => { todo!() } @@ -273,4 +304,49 @@ impl ExecutionRouter { Ok(()) } + + async fn execute_eip7702( + &self, + base_execution_options: &BaseExecutionOptions, + eip7702_execution_options: &Eip7702ExecutionOptions, + webhook_options: &Option>, + transactions: &[InnerTransaction], + rpc_credentials: RpcCredentials, + signing_credential: SigningCredential, + ) -> Result<(), TwmqError> { + let job_data = Eip7702SendJobData { + 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, + signing_credential, + webhook_options: webhook_options.clone(), + rpc_credentials, + nonce: None, // Let the executor handle nonce generation + }; + + // Register transaction in registry first + self.transaction_registry + .set_transaction_queue(&base_execution_options.idempotency_key, "eip7702_send") + .await + .map_err(|e| TwmqError::Runtime { + message: format!("Failed to register transaction: {}", e), + })?; + + // Create job with transaction ID as the job ID for idempotency + self.eip7702_send_queue + .clone() + .job(job_data) + .with_id(&base_execution_options.idempotency_key) + .push() + .await?; + + tracing::debug!( + transaction_id = %base_execution_options.idempotency_key, + queue = "eip7702_send", + "Job queued successfully" + ); + + Ok(()) + } } diff --git a/server/src/main.rs b/server/src/main.rs index 592c280..243db04 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -52,8 +52,14 @@ async fn main() -> anyhow::Result<()> { }); let eoa_signer = Arc::new(EoaSigner::new(vault_client.clone(), iaw_client)); - let queue_manager = - QueueManager::new(&config.redis, &config.queue, chains.clone(), signer.clone()).await?; + let queue_manager = QueueManager::new( + &config.redis, + &config.queue, + chains.clone(), + signer.clone(), + eoa_signer.clone(), + ) + .await?; tracing::info!("Queue manager initialized"); @@ -71,6 +77,8 @@ async fn main() -> anyhow::Result<()> { webhook_queue: queue_manager.webhook_queue.clone(), external_bundler_send_queue: queue_manager.external_bundler_send_queue.clone(), userop_confirm_queue: queue_manager.userop_confirm_queue.clone(), + eip7702_send_queue: queue_manager.eip7702_send_queue.clone(), + eip7702_confirm_queue: queue_manager.eip7702_confirm_queue.clone(), transaction_registry: queue_manager.transaction_registry.clone(), vault_client: Arc::new(vault_client.clone()), chains: chains.clone(), diff --git a/server/src/queue/manager.rs b/server/src/queue/manager.rs index ceabcf2..4e93d50 100644 --- a/server/src/queue/manager.rs +++ b/server/src/queue/manager.rs @@ -4,6 +4,7 @@ use std::{sync::Arc, time::Duration}; use alloy::transports::http::reqwest; use engine_core::error::EngineError; use engine_executors::{ + eip7702_executor::{confirm::Eip7702ConfirmationHandler, send::Eip7702SendHandler}, external_bundler::{ confirm::UserOpConfirmationHandler, deployment::{RedisDeploymentCache, RedisDeploymentLock}, @@ -23,6 +24,8 @@ pub struct QueueManager { pub webhook_queue: Arc>, pub external_bundler_send_queue: Arc>>, pub userop_confirm_queue: Arc>>, + pub eip7702_send_queue: Arc>>, + pub eip7702_confirm_queue: Arc>>, pub transaction_registry: Arc, } @@ -35,6 +38,8 @@ fn get_queue_name_for_namespace(namespace: &Option, name: &str) -> Strin const EXTERNAL_BUNDLER_SEND_QUEUE_NAME: &str = "external_bundler_send"; const USEROP_CONFIRM_QUEUE_NAME: &str = "userop_confirm"; +const EIP7702_SEND_QUEUE_NAME: &str = "eip7702_send"; +const EIP7702_CONFIRM_QUEUE_NAME: &str = "eip7702_confirm"; const WEBHOOK_QUEUE_NAME: &str = "webhook"; impl QueueManager { @@ -43,6 +48,7 @@ impl QueueManager { queue_config: &QueueConfig, chain_service: Arc, userop_signer: Arc, + eoa_signer: Arc, ) -> Result { // Create Redis clients let redis_client = twmq::redis::Client::open(redis_config.url.as_str())?; @@ -74,6 +80,12 @@ impl QueueManager { let mut userop_confirm_queue_opts = base_queue_opts.clone(); userop_confirm_queue_opts.local_concurrency = queue_config.userop_confirm_workers; + let mut eip7702_send_queue_opts = base_queue_opts.clone(); + eip7702_send_queue_opts.local_concurrency = queue_config.external_bundler_send_workers; // Reuse same config for now + + let mut eip7702_confirm_queue_opts = base_queue_opts.clone(); + eip7702_confirm_queue_opts.local_concurrency = queue_config.userop_confirm_workers; // Reuse same config for now + let mut webhook_queue_opts = base_queue_opts.clone(); webhook_queue_opts.local_concurrency = queue_config.webhook_workers; @@ -96,6 +108,16 @@ impl QueueManager { USEROP_CONFIRM_QUEUE_NAME, ); + let eip7702_send_queue_name = get_queue_name_for_namespace( + &queue_config.execution_namespace, + EIP7702_SEND_QUEUE_NAME, + ); + + let eip7702_confirm_queue_name = get_queue_name_for_namespace( + &queue_config.execution_namespace, + EIP7702_CONFIRM_QUEUE_NAME, + ); + let webhook_queue = Queue::builder() .name(webhook_queue_name) .options(webhook_queue_opts) @@ -105,7 +127,7 @@ impl QueueManager { .await? .arc(); - // Create confirmation queue first (needed by send queue) + // Create confirmation queues first (needed by send queues) let confirm_handler = UserOpConfirmationHandler::new( chain_service.clone(), deployment_lock.clone(), @@ -122,7 +144,23 @@ impl QueueManager { .await? .arc(); - // Create send queue + // Create EIP-7702 confirmation queue + let eip7702_confirm_handler = Eip7702ConfirmationHandler { + chain_service: chain_service.clone(), + webhook_queue: webhook_queue.clone(), + transaction_registry: transaction_registry.clone(), + }; + + let eip7702_confirm_queue = Queue::builder() + .name(eip7702_confirm_queue_name) + .options(eip7702_confirm_queue_opts) + .handler(eip7702_confirm_handler) + .redis_client(redis_client.clone()) + .build() + .await? + .arc(); + + // Create send queues let send_handler = ExternalBundlerSendHandler { chain_service: chain_service.clone(), userop_signer, @@ -142,10 +180,30 @@ impl QueueManager { .await? .arc(); + // Create EIP-7702 send queue + let eip7702_send_handler = Eip7702SendHandler { + chain_service: chain_service.clone(), + eoa_signer, + webhook_queue: webhook_queue.clone(), + confirm_queue: eip7702_confirm_queue.clone(), + transaction_registry: transaction_registry.clone(), + }; + + let eip7702_send_queue = Queue::builder() + .name(eip7702_send_queue_name) + .options(eip7702_send_queue_opts) + .handler(eip7702_send_handler) + .redis_client(redis_client.clone()) + .build() + .await? + .arc(); + Ok(Self { webhook_queue, external_bundler_send_queue, userop_confirm_queue, + eip7702_send_queue, + eip7702_confirm_queue, transaction_registry, }) } @@ -166,16 +224,28 @@ impl QueueManager { tracing::info!("Starting external bundler confirmation worker"); let userop_confirm_worker = self.userop_confirm_queue.work(); + // Start EIP-7702 send workers + tracing::info!("Starting EIP-7702 send worker"); + let eip7702_send_worker = self.eip7702_send_queue.work(); + + // Start EIP-7702 confirmation workers + tracing::info!("Starting EIP-7702 confirmation worker"); + let eip7702_confirm_worker = self.eip7702_confirm_queue.work(); + tracing::info!( - "Started {} webhook workers, {} send workers, {} confirm workers", + "Started {} webhook workers, {} send workers, {} confirm workers, {} EIP-7702 send workers, {} EIP-7702 confirm workers", queue_config.webhook_workers, queue_config.external_bundler_send_workers, - queue_config.userop_confirm_workers + queue_config.userop_confirm_workers, + queue_config.external_bundler_send_workers, // Reusing same config for now + queue_config.userop_confirm_workers // Reusing same config for now ); ShutdownHandle::with_worker(webhook_worker) .and_worker(external_bundler_send_worker) .and_worker(userop_confirm_worker) + .and_worker(eip7702_send_worker) + .and_worker(eip7702_confirm_worker) } /// Get queue statistics for monitoring @@ -221,10 +291,28 @@ impl QueueManager { failed: self.userop_confirm_queue.count(JobStatus::Failed).await?, }; + let eip7702_send_stats = QueueStatistics { + pending: self.eip7702_send_queue.count(JobStatus::Pending).await?, + active: self.eip7702_send_queue.count(JobStatus::Active).await?, + delayed: self.eip7702_send_queue.count(JobStatus::Delayed).await?, + success: self.eip7702_send_queue.count(JobStatus::Success).await?, + failed: self.eip7702_send_queue.count(JobStatus::Failed).await?, + }; + + let eip7702_confirm_stats = QueueStatistics { + pending: self.eip7702_confirm_queue.count(JobStatus::Pending).await?, + active: self.eip7702_confirm_queue.count(JobStatus::Active).await?, + delayed: self.eip7702_confirm_queue.count(JobStatus::Delayed).await?, + success: self.eip7702_confirm_queue.count(JobStatus::Success).await?, + failed: self.eip7702_confirm_queue.count(JobStatus::Failed).await?, + }; + Ok(QueueStats { webhook: webhook_stats, external_bundler_send: send_stats, userop_confirm: confirm_stats, + eip7702_send: eip7702_send_stats, + eip7702_confirm: eip7702_confirm_stats, }) } } @@ -234,6 +322,8 @@ pub struct QueueStats { pub webhook: QueueStatistics, pub external_bundler_send: QueueStatistics, pub userop_confirm: QueueStatistics, + pub eip7702_send: QueueStatistics, + pub eip7702_confirm: QueueStatistics, } #[derive(Debug, serde::Serialize)] diff --git a/thirdweb-core/src/iaw/mod.rs b/thirdweb-core/src/iaw/mod.rs index 29d51ed..9b07542 100644 --- a/thirdweb-core/src/iaw/mod.rs +++ b/thirdweb-core/src/iaw/mod.rs @@ -1,9 +1,10 @@ use alloy::{ consensus::{EthereumTypedTransaction, TxEip4844Variant}, dyn_abi::TypedData, - eips::eip7702::{Authorization, SignedAuthorization}, + eips::eip7702::SignedAuthorization, hex, - primitives::{Address, ChainId}, + primitives::{Address, ChainId, U256}, + rpc::types::Authorization, }; use serde::{Deserialize, Serialize}; use serde_json; @@ -108,6 +109,73 @@ pub struct SignUserOpData { pub signature: String, } +/// Response structure from the IAW sign-authorization API +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SignAuthorizationApiResponse { + pub address: Address, + pub chain_id: String, + pub nonce: String, + pub r: String, + pub s: String, + pub y_parity: String, +} + +impl SignAuthorizationApiResponse { + /// Convert the API response into a SignedAuthorization + fn into_signed_authorization(self) -> Result { + // Parse the numeric fields + let chain_id: u64 = self + .chain_id + .parse() + .map_err(|e| IAWError::SerializationError { + message: format!("Invalid chainId: {}", e), + })?; + + let nonce: u64 = self + .nonce + .parse() + .map_err(|e| IAWError::SerializationError { + message: format!("Invalid nonce: {}", e), + })?; + + let r: U256 = self.r.parse().map_err(|e| IAWError::SerializationError { + message: format!("Invalid r value: {}", e), + })?; + + let s: U256 = self.s.parse().map_err(|e| IAWError::SerializationError { + message: format!("Invalid s value: {}", e), + })?; + + let y_parity: bool = match self.y_parity.as_str() { + "0" => false, + "1" => true, + _ => { + return Err(IAWError::SerializationError { + message: format!("Invalid yParity value: {}", self.y_parity), + }); + } + }; + + // Create the Authorization (from alloy::rpc::types) + let authorization = Authorization { + chain_id: U256::from(chain_id), + address: self.address, + nonce, + }; + + // Create the SignedAuthorization using the correct constructor + let signed_authorization = SignedAuthorization::new_unchecked( + authorization, + if y_parity { 1u8 } else { 0u8 }, + r, + s, + ); + + Ok(signed_authorization) + } +} + /// Client for interacting with the IAW (In-App Wallet) service #[derive(Clone)] pub struct IAWClient { @@ -424,15 +492,14 @@ impl IAWClient { // Parse the response let signed_response: serde_json::Value = response.json().await?; - // Extract the signed authorization from the response - let signed_authorization: SignedAuthorization = serde_json::from_value( - signed_response - .get("signedAuthorization") - .ok_or_else(|| IAWError::ApiError { - message: "No signedAuthorization in response".to_string(), - })? - .clone(), - )?; + // Parse the API response into our custom type + let api_response: SignAuthorizationApiResponse = serde_json::from_value(signed_response) + .map_err(|e| IAWError::SerializationError { + message: format!("Failed to parse sign authorization response: {}", e), + })?; + + // Convert to SignedAuthorization + let signed_authorization = api_response.into_signed_authorization()?; Ok(SignAuthorizationData { signed_authorization,