Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## [Unreleased]
### Added
- fix: Initialize metrics at startup. ([#2954](https://github.com/chainwayxyz/citrea/pull/2954))
- feat: implement `eth_sendRawTransactionSync` RPC as per eip-7966. ([#3095](https://github.com/chainwayxyz/citrea/pull/3095))

### Changed
- fix: `bitcoin::network::Testnet` vs `bitcoin::network::Testnet4` confusion in mempool.space fee retrieval ([#3087](https://github.com/chainwayxyz/citrea/pull/3087))
Expand Down
284 changes: 172 additions & 112 deletions bin/citrea/tests/mock/evm/ethers_js/test.js

Large diffs are not rendered by default.

85 changes: 78 additions & 7 deletions bin/citrea/tests/mock/evm/web3_py/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def setUp(self):

def test_connection(self):
self.assertEqual(self.web3.is_connected(), True)

def test_max_priority_fee(self):
max_priority_fee = self.web3.eth.max_priority_fee
self.assertGreater(max_priority_fee, 0)
Expand Down Expand Up @@ -59,7 +59,7 @@ def test_get_code(self):
return

self.fail("Code for address 0x32000000000000000000000000000000000000001 not found in genesis data.")


def test_get_block(self):
block = self.web3.eth.get_block('latest')
Expand All @@ -86,7 +86,7 @@ def test_get_transaction(self):
self.assertEqual(tx['hash'], self.first_tx_hash)
self.assertEqual(tx['from'], "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266")
self.assertEqual(tx['to'], "0x0000000000000000000000000000000000000000")

def test_get_transaction_by_block(self):
tx = self.web3.eth.get_transaction(self.first_tx_hash)
block = self.web3.eth.get_block(tx['blockHash'])
Expand All @@ -112,11 +112,11 @@ def test_call(self):
return_val = self.web3.eth.call({'value': 0, 'to': '0x3100000000000000000000000000000000000001', 'data': selector})
self.assertEqual(return_val, HexBytes('0x000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeaddead'))

def test_create_access_list(self):
def test_create_access_list(self):
tx = {
'from': "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
'to': "0x3100000000000000000000000000000000000002",
'value': self.web3.to_wei(10, 'ether'),
'value': self.web3.to_wei(10, 'ether'),
'gas': 200000,
'gasPrice': self.web3.to_wei(1, 'gwei'),
'data': "0x8786dba712340000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000", # withdraw(bytes32, bytes4), param is 0x1234, 0x01
Expand Down Expand Up @@ -195,7 +195,7 @@ def test_send_raw_transaction_reverts_correctly(self):
tx = {
'from': "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
'to': "0x3100000000000000000000000000000000000002",
'value': self.web3.to_wei(0.9, 'ether'),
'value': self.web3.to_wei(0.9, 'ether'),
'gas': 200000,
'gasPrice': self.web3.to_wei(1, 'gwei'),
'data': "0x8786dba712340000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000", # withdraw(bytes32, bytes4), param is 0x1234, 0x01
Expand All @@ -211,7 +211,7 @@ def test_call_errors_correctly_on_withdraw(self):
tx = {
'from': "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
'to': "0x3100000000000000000000000000000000000002",
'value': self.web3.to_wei(0.9, 'ether'),
'value': self.web3.to_wei(0.9, 'ether'),
'gas': 200000,
'gasPrice': self.web3.to_wei(1, 'gwei'),
'data': "0x8786dba712340000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000", # withdraw(bytes32, bytes4), param is 0x1234, 0x01
Expand Down Expand Up @@ -250,5 +250,76 @@ def test_get_logs_false_hash(self):
logs = self.web3.eth.get_logs({'fromBlock': 1, 'toBlock': 1, 'topics': ["0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"]})
self.assertEqual(logs, [])

def test_send_raw_transaction_sync(self):
transaction = {
'from': "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
'to': "0x0000000000000000000000000000000000000000",
'value': 1000000000,
'nonce': self.web3.eth.get_transaction_count("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"),
'gas': 200000,
'gasPrice': self.web3.eth.gas_price,
}
signed_tx = self.web3.eth.account.sign_transaction(transaction, "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80")

receipt = self.web3.provider.make_request(
'eth_sendRawTransactionSync',
[signed_tx.raw_transaction.hex(), 5000]
)['result']

self.assertIsNotNone(receipt['transactionHash'])
self.assertEqual(receipt['from'], "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266")
self.assertEqual(receipt['to'], "0x0000000000000000000000000000000000000000")
self.assertEqual(int(receipt['status'], 16), 1)
self.assertEqual(int(receipt['transactionIndex'], 16), 0)
self.assertGreater(int(receipt['l1DiffSize'], 16), 0)
self.assertGreater(int(receipt['l1FeeRate'], 16), 0)

def test_send_raw_transaction_sync_default_timeout(self):
"""Test eth_sendRawTransactionSync without timeout parameter (uses default 2s)"""
transaction = {
'from': "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
'to': "0x0000000000000000000000000000000000000000",
'value': 1000000000,
'nonce': self.web3.eth.get_transaction_count("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"),
'gas': 200000,
'gasPrice': self.web3.eth.gas_price,
}
signed_tx = self.web3.eth.account.sign_transaction(transaction, "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80")

# Call without timeout parameter - should use default timeout of 2s
receipt = self.web3.provider.make_request(
'eth_sendRawTransactionSync',
[signed_tx.raw_transaction.hex()]
)['result']

self.assertIsNotNone(receipt['transactionHash'])
self.assertEqual(receipt['from'], "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266")
self.assertEqual(receipt['to'], "0x0000000000000000000000000000000000000000")
self.assertEqual(int(receipt['status'], 16), 1)

def test_send_raw_transaction_sync_timeout_error(self):
"""Test eth_sendRawTransactionSync returns error code 4 on timeout (EIP-7966)"""
transaction = {
'from': "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
'to': "0x0000000000000000000000000000000000000000",
'value': 1000000000,
'nonce': self.web3.eth.get_transaction_count("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"),
'gas': 200000,
'gasPrice': self.web3.eth.gas_price,
}
signed_tx = self.web3.eth.account.sign_transaction(transaction, "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80")

response = self.web3.provider.make_request(
'eth_sendRawTransactionSync',
[signed_tx.raw_transaction.hex(), 1]
)

# Should have error response with EIP-7966 error code 4
self.assertIn('error', response)
self.assertEqual(response['error']['code'], 4)
self.assertIn("wasn't processed", response['error']['message'])
# Error data should contain the transaction hash
self.assertIn('data', response['error'])

if __name__ == '__main__':
unittest.main()
121 changes: 121 additions & 0 deletions crates/common/src/rpc/eip_7966.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//! EIP-7966: eth_sendRawTransactionSync method
//! Description: A JSON-RPC method to reduce transaction submission latency
//! by allowing synchronous receipt of transaction hash and block inclusion.
//!
//! This module provides utilities for implementing EIP-7966, which submits
//! a signed raw transaction and waits synchronously for the transaction receipt
//! or a configurable timeout before returning.
//!
//! See: https://eips.ethereum.org/EIPS/eip-7966
use alloy_primitives::B256;
use jsonrpsee::types::ErrorObjectOwned;

/// EIP-7966 error code 4: Transaction was added to mempool but not processed within timeout.
pub const TIMEOUT_ERROR_CODE: i32 = 4;

/// EIP-7966 error code 5: Node is not ready to process the transaction or the transaction is erroneous.
pub const UNREADY_ERROR_CODE: i32 = 5;

/// Default timeout in milliseconds. (2secs)
pub const DEFAULT_TIMEOUT_MS: u64 = 2_000;

/// Maximum allowed timeout in milliseconds. (1min)
pub const MAX_TIMEOUT_MS: u64 = 60_000;
Copy link
Member

Choose a reason for hiding this comment

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

can we make this configurable?

Copy link
Member

Choose a reason for hiding this comment

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

should be passable from RpcConfig but default value is used when env var is not found


/// Creates an EIP-7966 timeout error (code 4).
///
/// Returned when the transaction was added to the mempool but wasn't
/// processed within the specified timeout period.
///
/// # Arguments
/// * `hash` - The transaction hash that was submitted
/// * `timeout_ms` - The timeout that was used (in milliseconds)
pub fn timeout_error(hash: B256, timeout_ms: u64) -> ErrorObjectOwned {
let timeout_secs = timeout_ms as f64 / 1000.0;
ErrorObjectOwned::owned(
TIMEOUT_ERROR_CODE,
format!(
"The transaction was added to the mempool but wasn't processed in {timeout_secs}s."
),
Some(hash),
)
}

/// Creates an EIP-7966 unreadiness error (code 5).
///
/// Returned when the processing node is not ready to accept a new transaction or the transaction is erroneous.
///
/// # Arguments
/// * `reason` - Unreadiness error string
/// * `hash` - Optional transaction hash if the transaction was successfully submitted
pub fn unready_error(reason: &str, hash: Option<B256>) -> ErrorObjectOwned {
ErrorObjectOwned::owned(UNREADY_ERROR_CODE, reason.to_string(), hash)
}

/// Calculates the effective timeout
///
/// - If `None`, returns `DEFAULT_TIMEOUT_MS`
/// - If `Some(0)` returns `DEFAULT_TIMEOUT_MS`
/// - Otherwise, returns the minimum of the requested value and `MAX_TIMEOUT_MS`
///
/// # Arguments
/// * `requested_ms` - Optional timeout duration in milliseconds
pub fn calculate_timeout_ms(requested_ms: Option<u64>) -> u64 {
match requested_ms {
Some(ms) if ms > 0 => std::cmp::min(ms, MAX_TIMEOUT_MS),
_ => DEFAULT_TIMEOUT_MS,
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_timeout_error() {
let hash = B256::ZERO;
let err = timeout_error(hash, 5000);

assert_eq!(err.code(), TIMEOUT_ERROR_CODE);
assert!(err.message().contains("5s"));
assert!(err.message().contains("wasn't processed"));
}

#[test]
fn test_unready_error_with_hash() {
let hash = B256::ZERO;
let err = unready_error("Test reason", Some(hash));

assert_eq!(err.code(), UNREADY_ERROR_CODE);
assert_eq!(err.message(), "Test reason");
}

#[test]
fn test_unready_error_without_hash() {
let err = unready_error("No hash provided", None);

assert_eq!(err.code(), UNREADY_ERROR_CODE);
assert_eq!(err.message(), "No hash provided");
}

#[test]
fn test_calculate_timeout_default() {
assert_eq!(calculate_timeout_ms(None), DEFAULT_TIMEOUT_MS);
}

#[test]
fn test_calculate_timeout_zero() {
assert_eq!(calculate_timeout_ms(Some(0)), DEFAULT_TIMEOUT_MS);
}

#[test]
fn test_calculate_timeout_normal() {
assert_eq!(calculate_timeout_ms(Some(5000)), 5000);
}

#[test]
fn test_calculate_timeout_exceeds_max() {
assert_eq!(calculate_timeout_ms(Some(100_000)), MAX_TIMEOUT_MS);
}
}
3 changes: 2 additions & 1 deletion crates/common/src/rpc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use sov_rollup_interface::services::da::DaService;
use tower_http::cors::{Any, CorsLayer};

mod auth;
pub mod eip_7966;
mod metrics;
pub(crate) use metrics::RpcMetrics;
pub mod server;
Expand Down Expand Up @@ -152,7 +153,7 @@ where
tracing::trace!(id = ?req_id, method = ?req_method, result = ?resp.as_result(), "rpc_success");
} else {
match req_method.as_str() {
"eth_sendRawTransaction" => tracing::debug!(id = ?req_id, method = ?req_method, result = ?resp.as_result(), "rpc_error"),
"eth_sendRawTransaction" | "eth_sendRawTransactionSync"=> tracing::debug!(id = ?req_id, method = ?req_method, result = ?resp.as_result(), "rpc_error"),
_ => tracing::warn!(id = ?req_id, method = ?req_method, result = ?resp.as_result(), "rpc_error")
}

Expand Down
7 changes: 4 additions & 3 deletions crates/ethereum-rpc/src/ethereum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ impl<C: sov_modules_api::Context, Da: DaService> Ethereum<C, Da> {
storage: C::Storage,
ledger_db: LedgerDB,
sequencer_client: Option<HttpClient>,
l2_block_rx: Option<broadcast::Receiver<u64>>,
l2_block_rx: &Option<broadcast::Receiver<u64>>,
task_executor: reth_tasks::TaskExecutor,
) -> Self {
let evm = Evm::<C>::default();
Expand All @@ -68,8 +68,9 @@ impl<C: sov_modules_api::Context, Da: DaService> Ethereum<C, Da> {

let trace_cache = Mutex::new(LruMap::new(ByLength::new(MAX_TRACE_BLOCK)));

let subscription_manager = l2_block_rx
.map(|rx| SubscriptionManager::new::<C>(storage.clone(), ledger_db.clone(), rx));
let subscription_manager = l2_block_rx.as_ref().map(|rx| {
SubscriptionManager::new::<C>(storage.clone(), ledger_db.clone(), rx.resubscribe())
});

let citrea_filter = Arc::new(CitreaFilter::new(
task_executor,
Expand Down
Loading
Loading