Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions core/src/rpc_clients/bundler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ pub enum TwGetTransactionHashStatus {
Success,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TwGetDelegationContractResponse {
pub delegation_contract: Address,
}

impl BundlerClient {
/// Create a new bundler client with the given transport
pub fn new(transport: impl IntoBoxTransport) -> Self {
Expand Down Expand Up @@ -170,4 +176,9 @@ impl BundlerClient {

Ok(response)
}

pub async fn tw_get_delegation_contract(&self) -> TransportResult<TwGetDelegationContractResponse> {
let response: TwGetDelegationContractResponse = self.inner.request("tw_getDelegationContract", ()).await?;
Ok(response)
}
Comment on lines +180 to +183
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Prefer sending [] over () for zero-arg JSON-RPC calls

Some servers reject params: null. Using an empty array is safer and matches other methods here.

Apply this diff:

-    pub async fn tw_get_delegation_contract(&self) -> TransportResult<TwGetDelegationContractResponse> {
-        let response: TwGetDelegationContractResponse = self.inner.request("tw_getDelegationContract", ()).await?;
-        Ok(response)
-    }
+    pub async fn tw_get_delegation_contract(
+        &self,
+    ) -> TransportResult<TwGetDelegationContractResponse> {
+        let params = serde_json::json!([]);
+        let response: TwGetDelegationContractResponse =
+            self.inner.request("tw_getDelegationContract", params).await?;
+        Ok(response)
+    }

To verify consistency repo-wide (and catch any other zero-arg calls using ()), run:


🏁 Script executed:

#!/bin/bash
# Find JSON-RPC calls passing unit params
rg -nP --type=rust 'request\(\s*"[^"]+",\s*\(\)\s*\)'

Length of output: 305


Use an empty JSON array for zero-arg RPC calls

Some JSON-RPC servers reject params: null. To ensure compatibility and match the rest of our clients, always send [] instead of () when there are no parameters.

Affected methods:

  • core/src/rpc_clients/bundler.rs: tw_get_delegation_contract
  • core/src/rpc_clients/paymaster.rs: thirdweb_getUserOperationGasPrice

Proposed diffs:

--- a/core/src/rpc_clients/bundler.rs
+++ b/core/src/rpc_clients/bundler.rs
@@ -180,7 +180,12 @@ impl BundlerClient {
-    pub async fn tw_get_delegation_contract(&self) -> TransportResult<TwGetDelegationContractResponse> {
-        let response: TwGetDelegationContractResponse = self.inner.request("tw_getDelegationContract", ()).await?;
-        Ok(response)
-    }
+    pub async fn tw_get_delegation_contract(
+        &self,
+    ) -> TransportResult<TwGetDelegationContractResponse> {
+        let params = serde_json::json!([]);
+        let response: TwGetDelegationContractResponse =
+            self.inner.request("tw_getDelegationContract", params).await?;
+        Ok(response)
+    }
--- a/core/src/rpc_clients/paymaster.rs
+++ b/core/src/rpc_clients/paymaster.rs
@@ -45,7 +45,11 @@ impl PaymasterClient {
-        let response: ThirdwebGetUserOperationGasPriceResponse = self
-            .inner
-            .request("thirdweb_getUserOperationGasPrice", ())
-            .await?;
+        let params = serde_json::json!([]);
+        let response: ThirdwebGetUserOperationGasPriceResponse = self
+            .inner
+            .request("thirdweb_getUserOperationGasPrice", params)
+            .await?;
         Ok(response)
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub async fn tw_get_delegation_contract(&self) -> TransportResult<TwGetDelegationContractResponse> {
let response: TwGetDelegationContractResponse = self.inner.request("tw_getDelegationContract", ()).await?;
Ok(response)
}
// core/src/rpc_clients/bundler.rs
pub async fn tw_get_delegation_contract(
&self,
) -> TransportResult<TwGetDelegationContractResponse> {
let params = serde_json::json!([]);
let response: TwGetDelegationContractResponse =
self.inner.request("tw_getDelegationContract", params).await?;
Ok(response)
}
Suggested change
pub async fn tw_get_delegation_contract(&self) -> TransportResult<TwGetDelegationContractResponse> {
let response: TwGetDelegationContractResponse = self.inner.request("tw_getDelegationContract", ()).await?;
Ok(response)
}
// core/src/rpc_clients/paymaster.rs
let params = serde_json::json!([]);
let response: ThirdwebGetUserOperationGasPriceResponse = self
.inner
.request("thirdweb_getUserOperationGasPrice", params)
.await?;
Ok(response)
🤖 Prompt for AI Agents
In core/src/rpc_clients/bundler.rs around lines 180 to 183, the RPC call uses ()
as the params which serializes to null; change the params to an empty JSON array
so the request sends [] instead of null — replace the request invocation to use
an empty-array params value to ensure compatibility with JSON-RPC servers that
reject null params (and apply the same pattern to other zero-arg RPC methods
such as the paymaster thirdweb_getUserOperationGasPrice).

}
7 changes: 4 additions & 3 deletions eip7702-core/src/constants.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use alloy::primitives::{Address, address};

/// The minimal account implementation address used for EIP-7702 delegation
pub const MINIMAL_ACCOUNT_IMPLEMENTATION_ADDRESS: Address =
address!("0xD6999651Fc0964B9c6B444307a0ab20534a66560");
// The minimal account implementation address used for EIP-7702 delegation
// pub const MINIMAL_ACCOUNT_IMPLEMENTATION_ADDRESS: Address =
// address!("0xD6999651Fc0964B9c6B444307a0ab20534a66560");
// NOTE!: do not hardcode. If needed later, use tw_getDelegationContract

/// EIP-7702 delegation prefix bytes
pub const EIP_7702_DELEGATION_PREFIX: [u8; 3] = [0xef, 0x01, 0x00];
Expand Down
37 changes: 20 additions & 17 deletions eip7702-core/src/delegated_account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,7 @@ use engine_core::{
};
use rand::Rng;

use crate::constants::{
EIP_7702_DELEGATION_CODE_LENGTH, EIP_7702_DELEGATION_PREFIX,
MINIMAL_ACCOUNT_IMPLEMENTATION_ADDRESS,
};
use crate::constants::{EIP_7702_DELEGATION_CODE_LENGTH, EIP_7702_DELEGATION_PREFIX};

/// Represents an EOA address that can have EIP-7702 delegation, associated with a specific chain
#[derive(Clone, Debug)]
Expand Down Expand Up @@ -62,21 +59,26 @@ impl<C: Chain> DelegatedAccount<C> {

// 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;
// NOTE!: skip the actual delegated target address check for now
// extremely unlikely that an EOA being used with engine is delegated to a non-minimal account
// Potential source for fringe edge cases, please verify delegated target address if debugging 7702 execution issues

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"
);
// 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)
Ok(true)
}

/// Get the EOA address
Expand All @@ -103,6 +105,7 @@ impl<C: Chain> DelegatedAccount<C> {
&self,
eoa_signer: &S,
credentials: &SigningCredential,
delegation_contract: Address,
) -> Result<alloy::eips::eip7702::SignedAuthorization, EngineError> {
let nonce = self.get_nonce().await?;

Expand All @@ -115,7 +118,7 @@ impl<C: Chain> DelegatedAccount<C> {
.sign_authorization(
signing_options,
self.chain.chain_id(),
MINIMAL_ACCOUNT_IMPLEMENTATION_ADDRESS,
delegation_contract,
nonce,
credentials,
)
Expand Down
3 changes: 2 additions & 1 deletion eip7702-core/src/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,12 +193,13 @@ impl<C: Chain> MinimalAccountTransaction<C> {
mut self,
signer: &S,
credentials: &SigningCredential,
delegation_contract: Address,
) -> Result<Self, EngineError> {
if self.account.is_minimal_account().await? {
return Ok(self);
}

let authorization = self.account.sign_authorization(signer, credentials).await?;
let authorization = self.account.sign_authorization(signer, credentials, delegation_contract).await?;
self.authorization = Some(authorization);
Ok(self)
}
Expand Down
35 changes: 27 additions & 8 deletions eip7702-core/tests/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ use engine_core::{
transaction::InnerTransaction,
};
use engine_eip7702_core::{
constants::MINIMAL_ACCOUNT_IMPLEMENTATION_ADDRESS,
delegated_account::DelegatedAccount,
transaction::{CallSpec, LimitType, SessionSpec, WrappedCalls},
};
Expand Down Expand Up @@ -290,6 +289,7 @@ struct TestSetup {
user_address: Address,

signer: MockEoaSigner,
delegation_contract: Option<Address>,
}

const ANVIL_PORT: u16 = 8545;
Expand Down Expand Up @@ -358,6 +358,7 @@ impl TestSetup {
signer,
mock_erc20_contract: contract,
anvil_provider: provider,
delegation_contract: None,
})
}

Expand All @@ -376,22 +377,32 @@ impl TestSetup {
Ok(())
}

async fn fetch_and_set_bytecode(&self) -> Result<(), Box<dyn std::error::Error>> {
async fn fetch_and_set_bytecode(&mut self) -> Result<(), Box<dyn std::error::Error>> {
// Fetch bytecode from Base Sepolia
let base_sepolia_url = "https://84532.rpc.thirdweb.com".parse()?;
let base_sepolia_provider = ProviderBuilder::new().connect_http(base_sepolia_url);

let delegation_contract_response = self
.chain
.bundler_client()
.tw_get_delegation_contract()
.await?;

// Store the delegation contract address for later use
self.delegation_contract = Some(delegation_contract_response.delegation_contract);

let bytecode = base_sepolia_provider
.get_code_at(MINIMAL_ACCOUNT_IMPLEMENTATION_ADDRESS)
.get_code_at(delegation_contract_response.delegation_contract)
.await?;
// Set bytecode on our Anvil chain
let _: () = self
.anvil_provider
.anvil_set_code(MINIMAL_ACCOUNT_IMPLEMENTATION_ADDRESS, bytecode)
.anvil_set_code(delegation_contract_response.delegation_contract, bytecode)
.await?;

println!(
"Set bytecode for minimal account implementation at {MINIMAL_ACCOUNT_IMPLEMENTATION_ADDRESS}"
"Set bytecode for minimal account implementation at {:?}",
delegation_contract_response.delegation_contract
);

Ok(())
Expand Down Expand Up @@ -645,7 +656,7 @@ impl TestSetup {
#[tokio::test]
async fn test_eip7702_integration() -> Result<(), Box<dyn std::error::Error>> {
// Set up test environment
let setup = TestSetup::new().await?;
let mut setup = TestSetup::new().await?;

// Step 1: Fetch and set bytecode from Base Sepolia
setup.fetch_and_set_bytecode().await?;
Expand Down Expand Up @@ -683,7 +694,11 @@ async fn test_eip7702_integration() -> Result<(), Box<dyn std::error::Error>> {
let developer_tx = developer_account
.clone()
.owner_transaction(&[mint_transaction])
.add_authorization_if_needed(&setup.signer, &setup.developer_credentials)
.add_authorization_if_needed(
&setup.signer,
&setup.developer_credentials,
setup.delegation_contract.expect("Delegation contract should be set")
)
.await?;

let (wrapped_calls_json, signature) = developer_tx
Expand Down Expand Up @@ -723,7 +738,11 @@ async fn test_eip7702_integration() -> Result<(), Box<dyn std::error::Error>> {
// Step 8: Delegate user account (session key granter)
// User signs authorization but executor broadcasts it (user has no funds)
let user_authorization = user_account
.sign_authorization(&setup.signer, &setup.user_credentials)
.sign_authorization(
&setup.signer,
&setup.user_credentials,
setup.delegation_contract.expect("Delegation contract should be set")
)
.await?;

// Executor broadcasts the user's delegation transaction
Expand Down
36 changes: 26 additions & 10 deletions executors/src/eip7702_executor/confirm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ use twmq::{

use crate::eip7702_executor::send::Eip7702Sender;
use crate::{
metrics::{record_transaction_queued_to_confirmed, current_timestamp_ms, calculate_duration_seconds_from_twmq},
metrics::{
calculate_duration_seconds_from_twmq, current_timestamp_ms,
record_transaction_queued_to_confirmed,
},
transaction_registry::TransactionRegistry,
webhook::{
WebhookJobHandler,
Expand Down Expand Up @@ -194,10 +197,18 @@ where
.bundler_client()
.tw_get_transaction_hash(&job_data.bundler_transaction_id)
.await
.map_err(|e| Eip7702ConfirmationError::TransactionHashError {
message: e.to_string(),
.map_err(|e| {
tracing::error!(
bundler_transaction_id = job_data.bundler_transaction_id,
sender_details = ?job_data.sender_details,
error = ?e,
"Failed to get transaction hash from bundler"
);
Eip7702ConfirmationError::TransactionHashError {
message: e.to_string(),
}
})
.map_err_fail()?;
.map_err_nack(Some(Duration::from_secs(10)), RequeuePosition::Last)?;

let transaction_hash = match transaction_hash_res {
TwGetTransactionHashResponse::Success { transaction_hash } => {
Expand Down Expand Up @@ -266,12 +277,17 @@ where
"Transaction confirmed successfully"
);

// Record metrics if original timestamp is available
if let Some(original_timestamp) = job_data.original_queued_timestamp {
let confirmed_timestamp = current_timestamp_ms();
let queued_to_confirmed_duration = calculate_duration_seconds_from_twmq(original_timestamp, confirmed_timestamp);
record_transaction_queued_to_confirmed("eip7702", job_data.chain_id, queued_to_confirmed_duration);
}
// Record metrics if original timestamp is available
if let Some(original_timestamp) = job_data.original_queued_timestamp {
let confirmed_timestamp = current_timestamp_ms();
let queued_to_confirmed_duration =
calculate_duration_seconds_from_twmq(original_timestamp, confirmed_timestamp);
record_transaction_queued_to_confirmed(
"eip7702",
job_data.chain_id,
queued_to_confirmed_duration,
);
}

Ok(Eip7702ConfirmationResult {
transaction_id: job_data.transaction_id.clone(),
Expand Down
68 changes: 68 additions & 0 deletions executors/src/eip7702_executor/delegation_cache.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
use std::{ops::Deref, sync::Arc};

use alloy::primitives::Address;
use engine_core::{
chain::Chain,
error::{AlloyRpcErrorToEngineError, EngineError},
rpc_clients::TwGetDelegationContractResponse,
};
use moka::future::Cache;

/// Cache key for delegation contract - uses chain_id as the key since each chain has one delegation contract
#[derive(Hash, Eq, PartialEq, Clone, Debug)]
pub struct DelegationContractCacheKey {
chain_id: u64,
}

/// Cache for delegation contract addresses to avoid repeated RPC calls
#[derive(Clone)]
pub struct DelegationContractCache {
pub inner: moka::future::Cache<DelegationContractCacheKey, Address>,
}

impl DelegationContractCache {
/// Create a new delegation contract cache with the provided moka cache
pub fn new(cache: Cache<DelegationContractCacheKey, Address>) -> Self {
Self { inner: cache }
}

/// Get the delegation contract address for a chain, fetching it if not cached
pub async fn get_delegation_contract<C: Chain>(
&self,
chain: &C,
) -> Result<Address, EngineError> {
let cache_key = DelegationContractCacheKey {
chain_id: chain.chain_id(),
};

// Use try_get_with for SWR behavior - this will fetch if not cached or expired
let result = self
.inner
.try_get_with(cache_key, async {
tracing::debug!(
chain_id = chain.chain_id(),
"Fetching delegation contract from bundler"
);

let TwGetDelegationContractResponse {
delegation_contract,
} = chain
.bundler_client()
.tw_get_delegation_contract()
.await
.map_err(|e| e.to_engine_bundler_error(chain))?;

tracing::debug!(
chain_id = chain.chain_id(),
delegation_contract = ?delegation_contract,
"Successfully fetched and cached delegation contract"
);

Ok(delegation_contract)
})
.await
.map_err(|e: Arc<EngineError>| e.deref().clone())?;

Ok(result)
}
}
3 changes: 2 additions & 1 deletion executors/src/eip7702_executor/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pub mod send;
pub mod confirm;
pub mod confirm;
pub mod delegation_cache;
Loading
Loading