diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..29187eb2 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "contracts/lib/forge-std"] + path = contracts/lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "contracts/lib/solmate"] + path = contracts/lib/solmate + url = https://github.com/transmissions11/solmate diff --git a/Cargo.lock b/Cargo.lock index f0dc3a14..fdaac579 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,9 +101,9 @@ dependencies = [ [[package]] name = "alloy-consensus" -version = "1.0.41" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9b151e38e42f1586a01369ec52a6934702731d07e8509a7307331b09f6c46dc" +checksum = "90d103d3e440ad6f703dd71a5b58a6abd24834563bde8a5fabe706e00242f810" dependencies = [ "alloy-eips", "alloy-primitives", @@ -128,9 +128,9 @@ dependencies = [ [[package]] name = "alloy-consensus-any" -version = "1.0.41" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2d5e8668ef6215efdb7dcca6f22277b4e483a5650e05f5de22b2350971f4b8" +checksum = "48ead76c8c84ab3a50c31c56bc2c748c2d64357ad2131c32f9b10ab790a25e1a" dependencies = [ "alloy-consensus", "alloy-eips", @@ -204,9 +204,9 @@ dependencies = [ [[package]] name = "alloy-eips" -version = "1.0.41" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5434834adaf64fa20a6fb90877bc1d33214c41b055cc49f82189c98614368cc" +checksum = "7bdbec74583d0067798d77afa43d58f00d93035335d7ceaa5d3f93857d461bb9" dependencies = [ "alloy-eip2124", "alloy-eip2930", @@ -292,9 +292,9 @@ dependencies = [ [[package]] name = "alloy-json-rpc" -version = "1.0.41" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c69f6c9c68a1287c9d5ff903d0010726934de0dac10989be37b75a29190d55" +checksum = "31b67c5a702121e618217f7a86f314918acb2622276d0273490e2d4534490bc0" dependencies = [ "alloy-primitives", "alloy-sol-types", @@ -307,9 +307,9 @@ dependencies = [ [[package]] name = "alloy-network" -version = "1.0.41" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf2ae05219e73e0979cb2cf55612aafbab191d130f203079805eaf881cca58" +checksum = "612296e6b723470bb1101420a73c63dfd535aa9bf738ce09951aedbd4ab7292e" dependencies = [ "alloy-consensus", "alloy-consensus-any", @@ -333,9 +333,9 @@ dependencies = [ [[package]] name = "alloy-network-primitives" -version = "1.0.41" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e58f4f345cef483eab7374f2b6056973c7419ffe8ad35e994b7a7f5d8e0c7ba4" +checksum = "a0e7918396eecd69d9c907046ec8a93fb09b89e2f325d5e7ea9c4e3929aa0dd2" dependencies = [ "alloy-consensus", "alloy-eips", @@ -554,9 +554,9 @@ dependencies = [ [[package]] name = "alloy-rpc-types-any" -version = "1.0.41" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbde0801a32d21c5f111f037bee7e22874836fba7add34ed4a6919932dd7cf23" +checksum = "cdbf6d1766ca41e90ac21c4bc5cbc5e9e965978a25873c3f90b3992d905db4cb" dependencies = [ "alloy-consensus-any", "alloy-rpc-types-eth", @@ -617,9 +617,9 @@ dependencies = [ [[package]] name = "alloy-rpc-types-eth" -version = "1.0.41" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "361cd87ead4ba7659bda8127902eda92d17fa7ceb18aba1676f7be10f7222487" +checksum = "a15e4831b71eea9d20126a411c1c09facf1d01d5cac84fd51d532d3c429cfc26" dependencies = [ "alloy-consensus", "alloy-consensus-any", @@ -680,9 +680,9 @@ dependencies = [ [[package]] name = "alloy-serde" -version = "1.0.41" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64600fc6c312b7e0ba76f73a381059af044f4f21f43e07f51f1fa76c868fe302" +checksum = "751d1887f7d202514a82c5b3caf28ee8bd4a2ad9549e4f498b6f0bff99b52add" dependencies = [ "alloy-primitives", "arbitrary", @@ -692,9 +692,9 @@ dependencies = [ [[package]] name = "alloy-signer" -version = "1.0.41" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5772858492b26f780468ae693405f895d6a27dea6e3eab2c36b6217de47c2647" +checksum = "9cf0b42ffbf558badfecf1dde0c3c5ed91f29bb7e97876d0bed008c3d5d67171" dependencies = [ "alloy-primitives", "async-trait", @@ -707,9 +707,9 @@ dependencies = [ [[package]] name = "alloy-signer-local" -version = "1.0.41" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4195b803d0a992d8dbaab2ca1986fc86533d4bc80967c0cce7668b26ad99ef9" +checksum = "3e7d555ee5f27be29af4ae312be014b57c6cff9acb23fe2cf008500be6ca7e33" dependencies = [ "alloy-consensus", "alloy-network", @@ -893,11 +893,10 @@ dependencies = [ [[package]] name = "alloy-tx-macros" -version = "1.0.41" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8e52276fdb553d3c11563afad2898f4085165e4093604afe3d78b69afbf408f" +checksum = "cd7ce8ed34106acd6e21942022b6a15be6454c2c3ead4d76811d3bdcd63cf771" dependencies = [ - "alloy-primitives", "darling 0.21.3", "proc-macro2", "quote", @@ -1510,6 +1509,7 @@ dependencies = [ "alloy-rpc-types-engine", "alloy-rpc-types-eth", "arc-swap", + "base-reth-test-utils", "brotli", "eyre", "futures-util", @@ -1517,6 +1517,7 @@ dependencies = [ "jsonrpsee-types 0.26.0", "metrics", "metrics-derive", + "once_cell", "op-alloy-consensus 0.20.0", "op-alloy-network", "op-alloy-rpc-types", @@ -1646,6 +1647,60 @@ dependencies = [ "uuid", ] +[[package]] +name = "base-reth-test-utils" +version = "0.1.15" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-genesis", + "alloy-primitives", + "alloy-provider", + "alloy-rpc-client", + "alloy-rpc-types", + "alloy-rpc-types-engine", + "alloy-rpc-types-eth", + "alloy-serde", + "alloy-signer", + "alloy-signer-local", + "chrono", + "eyre", + "futures", + "futures-util", + "jsonrpsee 0.26.0", + "once_cell", + "op-alloy-consensus 0.20.0", + "op-alloy-network", + "op-alloy-rpc-types", + "op-alloy-rpc-types-engine", + "reth", + "reth-db", + "reth-db-common", + "reth-e2e-test-utils", + "reth-exex", + "reth-ipc", + "reth-optimism-chainspec 1.8.2", + "reth-optimism-cli", + "reth-optimism-node", + "reth-optimism-primitives 1.8.2", + "reth-optimism-rpc", + "reth-primitives", + "reth-primitives-traits 1.8.2", + "reth-provider", + "reth-rpc-layer", + "reth-testing-utils", + "reth-tracing", + "rollup-boost", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tokio-util", + "tower 0.5.2", + "tracing", + "url", +] + [[package]] name = "base-reth-transaction-tracing" version = "0.1.15" diff --git a/Cargo.toml b/Cargo.toml index 338a3fb9..5b0aad0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/flashblocks-rpc", "crates/metering", "crates/node", + "crates/test-utils", "crates/transaction-tracing", ] @@ -41,6 +42,7 @@ codegen-units = 1 base-reth-flashblocks-rpc = { path = "crates/flashblocks-rpc" } base-reth-metering = { path = "crates/metering" } base-reth-node = { path = "crates/node" } +base-reth-test-utils = { path = "crates/test-utils" } base-reth-transaction-tracing = { path = "crates/transaction-tracing" } # base/tips @@ -55,7 +57,10 @@ reth-primitives = { git = "https://github.com/paradigmxyz/reth", tag = "v1.8.2" reth-rpc-eth-api = { git = "https://github.com/paradigmxyz/reth", tag = "v1.8.2" } reth-optimism-primitives = { git = "https://github.com/paradigmxyz/reth", tag = "v1.8.2" } reth-rpc-convert = { git = "https://github.com/paradigmxyz/reth", tag = "v1.8.2" } -reth-optimism-rpc = { git = "https://github.com/paradigmxyz/reth", tag = "v1.8.2" } +reth-optimism-rpc = { git = "https://github.com/paradigmxyz/reth", tag = "v1.8.2", features = [ + "client", +] } +reth-rpc-layer = { git = "https://github.com/paradigmxyz/reth", tag = "v1.8.2" } reth-optimism-evm = { git = "https://github.com/paradigmxyz/reth", tag = "v1.8.2" } reth-optimism-chainspec = { git = "https://github.com/paradigmxyz/reth", tag = "v1.8.2" } reth-provider = { git = "https://github.com/paradigmxyz/reth", tag = "v1.8.2" } @@ -69,6 +74,7 @@ reth-exex = { git = "https://github.com/paradigmxyz/reth", tag = "v1.8.2" } reth-db = { git = "https://github.com/paradigmxyz/reth", tag = "v1.8.2" } reth-testing-utils = { git = "https://github.com/paradigmxyz/reth", tag = "v1.8.2" } reth-db-common = { git = "https://github.com/paradigmxyz/reth", tag = "v1.8.2" } +reth-ipc = { git = "https://github.com/paradigmxyz/reth", tag = "v1.8.2" } # revm revm = { version = "29.0.0", default-features = false } diff --git a/contracts/.gitignore b/contracts/.gitignore new file mode 100644 index 00000000..85198aaa --- /dev/null +++ b/contracts/.gitignore @@ -0,0 +1,14 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 00000000..8817d6ab --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,66 @@ +## Foundry + +**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.** + +Foundry consists of: + +- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). +- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. +- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network. +- **Chisel**: Fast, utilitarian, and verbose solidity REPL. + +## Documentation + +https://book.getfoundry.sh/ + +## Usage + +### Build + +```shell +$ forge build +``` + +### Test + +```shell +$ forge test +``` + +### Format + +```shell +$ forge fmt +``` + +### Gas Snapshots + +```shell +$ forge snapshot +``` + +### Anvil + +```shell +$ anvil +``` + +### Deploy + +```shell +$ forge script script/Counter.s.sol:CounterScript --rpc-url --private-key +``` + +### Cast + +```shell +$ cast +``` + +### Help + +```shell +$ forge --help +$ anvil --help +$ cast --help +``` diff --git a/contracts/foundry.lock b/contracts/foundry.lock new file mode 100644 index 00000000..fee8a957 --- /dev/null +++ b/contracts/foundry.lock @@ -0,0 +1,8 @@ +{ + "lib/forge-std": { + "tag": { + "name": "v1.11.0", + "rev": "8e40513d678f392f398620b3ef2b418648b33e89" + } + } +} \ No newline at end of file diff --git a/contracts/foundry.toml b/contracts/foundry.toml new file mode 100644 index 00000000..25b918f9 --- /dev/null +++ b/contracts/foundry.toml @@ -0,0 +1,6 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/contracts/lib/forge-std b/contracts/lib/forge-std new file mode 160000 index 00000000..8e40513d --- /dev/null +++ b/contracts/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 8e40513d678f392f398620b3ef2b418648b33e89 diff --git a/contracts/lib/solmate b/contracts/lib/solmate new file mode 160000 index 00000000..89365b88 --- /dev/null +++ b/contracts/lib/solmate @@ -0,0 +1 @@ +Subproject commit 89365b880c4f3c786bdd453d4b8e8fe410344a69 diff --git a/contracts/script/Counter.s.sol b/contracts/script/Counter.s.sol new file mode 100644 index 00000000..f01d69c3 --- /dev/null +++ b/contracts/script/Counter.s.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Script} from "forge-std/Script.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterScript is Script { + Counter public counter; + + function setUp() public {} + + function run() public { + vm.startBroadcast(); + + counter = new Counter(); + + vm.stopBroadcast(); + } +} diff --git a/contracts/src/Counter.sol b/contracts/src/Counter.sol new file mode 100644 index 00000000..aded7997 --- /dev/null +++ b/contracts/src/Counter.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} diff --git a/contracts/test/Counter.t.sol b/contracts/test/Counter.t.sol new file mode 100644 index 00000000..48319108 --- /dev/null +++ b/contracts/test/Counter.t.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + counter.setNumber(0); + } + + function test_Increment() public { + counter.increment(); + assertEq(counter.number(), 1); + } + + function testFuzz_SetNumber(uint256 x) public { + counter.setNumber(x); + assertEq(counter.number(), x); + } +} diff --git a/crates/flashblocks-rpc/Cargo.toml b/crates/flashblocks-rpc/Cargo.toml index 61467431..cb543821 100644 --- a/crates/flashblocks-rpc/Cargo.toml +++ b/crates/flashblocks-rpc/Cargo.toml @@ -74,8 +74,10 @@ brotli.workspace = true arc-swap.workspace = true [dev-dependencies] +base-reth-test-utils.workspace = true rand.workspace = true reth-db.workspace = true reth-testing-utils.workspace = true reth-db-common.workspace = true reth-e2e-test-utils.workspace = true +once_cell.workspace = true diff --git a/crates/flashblocks-rpc/src/tests/assets/genesis.json b/crates/flashblocks-rpc/src/tests/assets/genesis.json index 4d703497..b3099c33 100644 --- a/crates/flashblocks-rpc/src/tests/assets/genesis.json +++ b/crates/flashblocks-rpc/src/tests/assets/genesis.json @@ -1,6 +1,6 @@ { "config": { - "chainId": 8453, + "chainId": 84532, "homesteadBlock": 0, "eip150Block": 0, "eip155Block": 0, @@ -17,6 +17,12 @@ "mergeNetsplitBlock": 0, "bedrockBlock": 0, "regolithTime": 0, + "canyonTime": 0, + "ecotoneTime": 0, + "fjordTime": 0, + "graniteTime": 0, + "isthmusTime": 0, + "pragueTime": 0, "terminalTotalDifficulty": 0, "terminalTotalDifficultyPassed": true, "optimism": { diff --git a/crates/flashblocks-rpc/src/tests/framework_test.rs b/crates/flashblocks-rpc/src/tests/framework_test.rs new file mode 100644 index 00000000..14a4cc59 --- /dev/null +++ b/crates/flashblocks-rpc/src/tests/framework_test.rs @@ -0,0 +1,845 @@ +#[cfg(test)] +mod tests { + use crate::rpc::{EthApiExt, EthApiOverrideServer}; + use crate::state::FlashblocksState; + use crate::subscription::{Flashblock, FlashblocksReceiver, Metadata}; + use crate::tests::{BLOCK_INFO_TXN, BLOCK_INFO_TXN_HASH}; + use alloy_consensus::Receipt; + use alloy_eips::BlockNumberOrTag; + use alloy_primitives::map::HashMap; + use alloy_primitives::{address, b256, bytes, Address, Bytes, LogData, TxHash, B256, U256}; + use alloy_provider::Provider; + use alloy_rpc_client::RpcClient; + use alloy_rpc_types::simulate::{SimBlock, SimulatePayload}; + use alloy_rpc_types_engine::PayloadId; + use alloy_rpc_types_eth::error::EthRpcErrorCode; + use alloy_rpc_types_eth::TransactionInput; + use base_reth_test_utils::harness::TestHarness; + use eyre::Result; + use once_cell::sync::OnceCell; + use op_alloy_consensus::OpDepositReceipt; + use op_alloy_network::{Optimism, ReceiptResponse, TransactionResponse}; + use op_alloy_rpc_types::OpTransactionRequest; + use reth_exex::ExExEvent; + use reth_optimism_primitives::OpReceipt; + use reth_rpc_eth_api::RpcReceipt; + use rollup_boost::{ExecutionPayloadBaseV1, ExecutionPayloadFlashblockDeltaV1}; + use std::str::FromStr; + use std::sync::Arc; + use tokio::sync::{mpsc, oneshot}; + use tokio_stream::StreamExt; + + pub struct TestSetup { + sender: mpsc::Sender<(Flashblock, oneshot::Sender<()>)>, + harness: TestHarness, + } + + impl TestSetup { + pub async fn new() -> Result { + let (sender, mut receiver) = mpsc::channel::<(Flashblock, oneshot::Sender<()>)>(100); + let harness = TestHarness::new(|builder| { + let fb_cell: Arc>>> = Arc::new(OnceCell::new()); + + builder + .install_exex("flashblocks-canon", { + let fb_cell = fb_cell.clone(); + move |mut ctx| async move { + let fb = fb_cell + .get_or_init(|| { + Arc::new(FlashblocksState::new(ctx.provider().clone())) + }) + .clone(); + Ok(async move { + while let Some(note) = ctx.notifications.try_next().await? { + if let Some(committed) = note.committed_chain() { + for b in committed.blocks_iter() { + fb.on_canonical_block_received(b); + } + let _ = ctx.events.send(ExExEvent::FinishedHeight( + committed.tip().num_hash(), + )); + } + } + Ok(()) + }) + } + }) + .extend_rpc_modules(move |ctx| { + let fb = fb_cell + .get_or_init(|| Arc::new(FlashblocksState::new(ctx.provider().clone()))) + .clone(); + + fb.start(); + + let api_ext = EthApiExt::new( + ctx.registry.eth_api().clone(), + ctx.registry.eth_handlers().filter.clone(), + fb.clone(), + ); + + ctx.modules.replace_configured(api_ext.into_rpc())?; + + tokio::spawn(async move { + while let Some((payload, tx)) = receiver.recv().await { + fb.on_flashblock_received(payload); + tx.send(()).unwrap(); + } + }); + + Ok(()) + }) + .launch() + }) + .await?; + + Ok(Self { sender, harness }) + } + + pub async fn send_flashblock(&self, flashblock: Flashblock) -> Result<()> { + let (tx, rx) = oneshot::channel(); + self.sender.send((flashblock, tx)).await?; + rx.await?; + Ok(()) + } + + pub async fn send_test_payloads(&self) -> Result<()> { + let base_payload = create_first_payload(); + self.send_flashblock(base_payload).await?; + + let second_payload = create_second_payload(); + self.send_flashblock(second_payload).await?; + + Ok(()) + } + + pub async fn send_raw_transaction_sync( + &self, + tx: Bytes, + timeout_ms: Option, + ) -> Result> { + let url = self.harness.rpc_url(); + let client = RpcClient::new_http(url.parse()?); + + let receipt = client + .request::<_, RpcReceipt>("eth_sendRawTransactionSync", (tx, timeout_ms)) + .await?; + + Ok(receipt) + } + } + + // Test constants + const TEST_ADDRESS: Address = address!("0x1234567890123456789012345678901234567890"); + const PENDING_BALANCE: u64 = 4660; + + const DEPOSIT_SENDER: Address = address!("0xdeaddeaddeaddeaddeaddeaddeaddeaddead0001"); + const TX_SENDER: Address = address!("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"); + + const DEPOSIT_TX_HASH: TxHash = + b256!("0x2be2e6f8b01b03b87ae9f0ebca8bbd420f174bef0fbcc18c7802c5378b78f548"); + const TRANSFER_ETH_HASH: TxHash = + b256!("0xbb079fbde7d12fd01664483cd810e91014113e405247479e5615974ebca93e4a"); + + const DEPLOYMENT_HASH: TxHash = + b256!("0x2b14d58c13406f25a78cfb802fb711c0d2c27bf9eccaec2d1847dc4392918f63"); + + const INCREMENT_HASH: TxHash = + b256!("0x993ad6a332752f6748636ce899b3791e4a33f7eece82c0db4556c7339c1b2929"); + const INCREMENT2_HASH: TxHash = + b256!("0x617a3673399647d12bb82ec8eba2ca3fc468e99894bcf1c67eb50ef38ee615cb"); + + const COUNTER_ADDRESS: Address = address!("0xe7f1725e7734ce288f8367e1bb143e90bb3f0512"); + + // Test log topics - these represent common events + const TEST_LOG_TOPIC_0: B256 = + b256!("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"); // Transfer event + const TEST_LOG_TOPIC_1: B256 = + b256!("0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266"); // From address + const TEST_LOG_TOPIC_2: B256 = + b256!("0x0000000000000000000000001234567890123456789012345678901234567890"); // To address + + // Transaction bytes + const DEPOSIT_TX: Bytes = bytes!("0x7ef8f8a042a8ae5ec231af3d0f90f68543ec8bca1da4f7edd712d5b51b490688355a6db794deaddeaddeaddeaddeaddeaddeaddeaddead00019442000000000000000000000000000000000000158080830f424080b8a4440a5e200000044d000a118b00000000000000040000000067cb7cb0000000000077dbd4000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000014edd27304108914dd6503b19b9eeb9956982ef197febbeeed8a9eac3dbaaabdf000000000000000000000000fc56e7272eebbba5bc6c544e159483c4a38f8ba3"); + const TRANSFER_ETH_TX: Bytes = bytes!("0x02f87383014a3480808449504f80830186a094deaddeaddeaddeaddeaddeaddeaddeaddead00018ad3c21bcb3f6efc39800080c0019f5a6fe2065583f4f3730e82e5725f651cbbaf11dc1f82c8d29ba1f3f99e5383a061e0bf5dfff4a9bc521ad426eee593d3653c5c330ae8a65fad3175d30f291d31"); + const DEPLOYMENT_TX: Bytes = bytes!("0x02f9029483014a3401808449504f80830493e08080b9023c608060405260015f55600180553480156016575f80fd5b50610218806100245f395ff3fe608060405234801561000f575f80fd5b5060043610610060575f3560e01c80631d63e24d146100645780637477f70014610082578063a87d942c146100a0578063ab57b128146100be578063d09de08a146100c8578063d631c639146100d2575b5f80fd5b61006c6100f0565b6040516100799190610155565b60405180910390f35b61008a6100f6565b6040516100979190610155565b60405180910390f35b6100a86100fb565b6040516100b59190610155565b60405180910390f35b6100c6610103565b005b6100d061011c565b005b6100da610134565b6040516100e79190610155565b60405180910390f35b60015481565b5f5481565b5f8054905090565b60015f8154809291906101159061019b565b9190505550565b5f8081548092919061012d9061019b565b9190505550565b5f600154905090565b5f819050919050565b61014f8161013d565b82525050565b5f6020820190506101685f830184610146565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f6101a58261013d565b91507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82036101d7576101d661016e565b5b60018201905091905056fea264697066735822122025c7e02ddf460dece9c1e52a3f9ff042055b58005168e7825d7f6c426288c27164736f6c63430008190033c001a02f196658032e0b003bcd234349d63081f5d6c2785264c6fec6b25ad877ae326aa0290c9f96f4501439b07a7b5e8e938f15fc30a9c15db3fc5e654d44e1f522060c"); + const INCREMENT_TX: Bytes = bytes!("0x02f86d83014a3402808449504f8082abe094e7f1725e7734ce288f8367e1bb143e90bb3f05128084d09de08ac080a0a9c1a565668084d4052bbd9bc3abce8555a06aed6651c82c2756ac8a83a79fa2a03427f440ce4910a5227ea0cedb60b06cf0bea2dbbac93bd37efa91a474c29d89"); + const INCREMENT2_TX: Bytes = bytes!("0x02f86d83014a3403808449504f8082abe094e7f1725e7734ce288f8367e1bb143e90bb3f05128084ab57b128c001a03a155b8c81165fc8193aa739522c2a9e432e274adea7f0b90ef2b5078737f153a0288d7fad4a3b0d1e7eaf7fab63b298393a5020bf11d91ff8df13b235410799e2"); + + fn create_test_logs() -> Vec { + vec![ + alloy_primitives::Log { + address: COUNTER_ADDRESS, + data: LogData::new( + vec![TEST_LOG_TOPIC_0, TEST_LOG_TOPIC_1, TEST_LOG_TOPIC_2], + bytes!("0x0000000000000000000000000000000000000000000000000de0b6b3a7640000") + .into(), // 1 ETH in wei + ) + .unwrap(), + }, + alloy_primitives::Log { + address: TEST_ADDRESS, + data: LogData::new( + vec![TEST_LOG_TOPIC_0], + bytes!("0x0000000000000000000000000000000000000000000000000000000000000001") + .into(), // Value: 1 + ) + .unwrap(), + }, + ] + } + + fn create_first_payload() -> Flashblock { + Flashblock { + payload_id: PayloadId::new([0; 8]), + index: 0, + base: Some(ExecutionPayloadBaseV1 { + parent_beacon_block_root: B256::default(), + parent_hash: B256::default(), + fee_recipient: Address::ZERO, + prev_randao: B256::default(), + block_number: 1, + gas_limit: 30_000_000, + timestamp: 0, + extra_data: Bytes::new(), + base_fee_per_gas: U256::ZERO, + }), + diff: ExecutionPayloadFlashblockDeltaV1 { + transactions: vec![BLOCK_INFO_TXN], + ..Default::default() + }, + metadata: Metadata { + block_number: 1, + receipts: { + let mut receipts = HashMap::default(); + receipts.insert( + BLOCK_INFO_TXN_HASH, + OpReceipt::Deposit(OpDepositReceipt { + inner: Receipt { + status: true.into(), + cumulative_gas_used: 10000, + logs: vec![], + }, + deposit_nonce: Some(4012991u64), + deposit_receipt_version: None, + }), + ); + receipts + }, + new_account_balances: HashMap::default(), + }, + } + } + + fn create_second_payload() -> Flashblock { + Flashblock { + payload_id: PayloadId::new([0; 8]), + index: 1, + base: None, + diff: ExecutionPayloadFlashblockDeltaV1 { + state_root: B256::default(), + receipts_root: B256::default(), + gas_used: 0, + block_hash: B256::default(), + transactions: vec![ + DEPOSIT_TX, + TRANSFER_ETH_TX, + DEPLOYMENT_TX, + INCREMENT_TX, + INCREMENT2_TX, + ], + withdrawals: Vec::new(), + logs_bloom: Default::default(), + withdrawals_root: Default::default(), + }, + metadata: Metadata { + block_number: 1, + receipts: { + let mut receipts = HashMap::default(); + receipts.insert( + DEPOSIT_TX_HASH, + OpReceipt::Deposit(OpDepositReceipt { + inner: Receipt { + status: true.into(), + cumulative_gas_used: 31000, + logs: vec![], + }, + deposit_nonce: Some(4012992u64), + deposit_receipt_version: None, + }), + ); + receipts.insert( + TRANSFER_ETH_HASH, + OpReceipt::Legacy(Receipt { + status: true.into(), + cumulative_gas_used: 55000, + logs: vec![], + }), + ); + receipts.insert( + DEPLOYMENT_HASH, + OpReceipt::Legacy(Receipt { + status: true.into(), + cumulative_gas_used: 272279, + logs: vec![], + }), + ); + receipts.insert( + INCREMENT_HASH, + OpReceipt::Legacy(Receipt { + status: true.into(), + cumulative_gas_used: 272279 + 44000, + logs: create_test_logs(), + }), + ); + receipts.insert( + INCREMENT2_HASH, + OpReceipt::Legacy(Receipt { + status: true.into(), + cumulative_gas_used: 272279 + 44000 + 44000, + logs: vec![], + }), + ); + receipts + }, + new_account_balances: { + let mut map = HashMap::default(); + map.insert(TEST_ADDRESS, U256::from(PENDING_BALANCE)); + map.insert(COUNTER_ADDRESS, U256::from(0)); + map + }, + }, + } + } + + #[tokio::test] + async fn test_get_pending_block() -> Result<()> { + reth_tracing::init_test_tracing(); + let setup = TestSetup::new().await?; + let provider = setup.harness.provider(); + + let latest_block = provider + .get_block_by_number(BlockNumberOrTag::Latest) + .await? + .expect("latest block expected"); + assert_eq!(latest_block.number(), 0); + + // Querying pending block when it does not exist yet + let pending_block = provider + .get_block_by_number(BlockNumberOrTag::Pending) + .await?; + assert_eq!(pending_block.is_none(), true); + + let base_payload = create_first_payload(); + setup.send_flashblock(base_payload).await?; + + // Query pending block after sending the base payload with an empty delta + let pending_block = provider + .get_block_by_number(alloy_eips::BlockNumberOrTag::Pending) + .await? + .expect("pending block expected"); + + assert_eq!(pending_block.number(), 1); + assert_eq!(pending_block.transactions.hashes().len(), 1); // L1Info transaction + + Ok(()) + } + + #[tokio::test] + async fn test_get_balance_pending() -> Result<()> { + reth_tracing::init_test_tracing(); + let setup = TestSetup::new().await?; + let provider = setup.harness.provider(); + + setup.send_test_payloads().await?; + + let balance = provider.get_balance(TEST_ADDRESS).await?; + assert_eq!(balance, U256::ZERO); + + let pending_balance = provider.get_balance(TEST_ADDRESS).pending().await?; + assert_eq!(pending_balance, U256::from(PENDING_BALANCE)); + Ok(()) + } + + #[tokio::test] + async fn test_get_transaction_by_hash_pending() -> Result<()> { + reth_tracing::init_test_tracing(); + let setup = TestSetup::new().await?; + let provider = setup.harness.provider(); + + assert!(provider + .get_transaction_by_hash(DEPOSIT_TX_HASH) + .await? + .is_none()); + assert!(provider + .get_transaction_by_hash(TRANSFER_ETH_HASH) + .await? + .is_none()); + + setup.send_test_payloads().await?; + + let tx1 = provider + .get_transaction_by_hash(DEPOSIT_TX_HASH) + .await? + .expect("tx1 expected"); + assert_eq!(tx1.tx_hash(), DEPOSIT_TX_HASH); + assert_eq!(tx1.from(), DEPOSIT_SENDER); + + let tx2 = provider + .get_transaction_by_hash(TRANSFER_ETH_HASH) + .await? + .expect("tx2 expected"); + assert_eq!(tx2.tx_hash(), TRANSFER_ETH_HASH); + assert_eq!(tx2.from(), TX_SENDER); + + Ok(()) + } + + #[tokio::test] + async fn test_get_transaction_receipt_pending() -> Result<()> { + reth_tracing::init_test_tracing(); + let setup = TestSetup::new().await?; + let provider = setup.harness.provider(); + + let receipt = provider.get_transaction_receipt(DEPOSIT_TX_HASH).await?; + assert_eq!(receipt.is_none(), true); + + setup.send_test_payloads().await?; + + let receipt = provider + .get_transaction_receipt(DEPOSIT_TX_HASH) + .await? + .expect("receipt expected"); + assert_eq!(receipt.gas_used(), 21000); + + let receipt = provider + .get_transaction_receipt(TRANSFER_ETH_HASH) + .await? + .expect("receipt expected"); + assert_eq!(receipt.gas_used(), 24000); // 45000 - 21000 + + Ok(()) + } + + #[tokio::test] + async fn test_get_transaction_count() -> Result<()> { + reth_tracing::init_test_tracing(); + let setup = TestSetup::new().await?; + let provider = setup.harness.provider(); + + assert_eq!(provider.get_transaction_count(DEPOSIT_SENDER).await?, 0); + assert_eq!( + provider.get_transaction_count(TX_SENDER).pending().await?, + 0 + ); + + setup.send_test_payloads().await?; + + assert_eq!(provider.get_transaction_count(DEPOSIT_SENDER).await?, 0); + assert_eq!( + provider.get_transaction_count(TX_SENDER).pending().await?, + 4 + ); + + Ok(()) + } + + #[tokio::test] + async fn test_eth_call() -> Result<()> { + reth_tracing::init_test_tracing(); + let setup = TestSetup::new().await?; + let provider = setup.harness.provider(); + + // We ensure that eth_call will succeed because we are on plain state + let send_eth_call = OpTransactionRequest::default() + .from(TX_SENDER) + .transaction_type(0) + .gas_limit(200000) + .nonce(1) + .to(address!("0xf39635f2adf40608255779ff742afe13de31f577")) + .value(U256::from(9999999999849942300000u128)) + .input(TransactionInput::new(bytes!("0x"))); + + let res = provider + .call(send_eth_call.clone()) + .block(BlockNumberOrTag::Pending.into()) + .await; + + assert!(res.is_ok()); + + setup.send_test_payloads().await?; + + // We included a heavy spending transaction and now don't have enough funds for this request, so + // this eth_call with fail + let res = provider + .call(send_eth_call.nonce(4)) + .block(BlockNumberOrTag::Pending.into()) + .await; + + assert!(res.is_err()); + assert!(res + .unwrap_err() + .as_error_resp() + .unwrap() + .message + .contains("insufficient funds for gas")); + + // read count1 from counter contract + let eth_call_count1 = OpTransactionRequest::default() + .from(TX_SENDER) + .transaction_type(0) + .gas_limit(20000000) + .nonce(5) + .to(COUNTER_ADDRESS) + .value(U256::ZERO) + .input(TransactionInput::new(bytes!("0xa87d942c"))); + let res_count1 = provider.call(eth_call_count1).await; + assert!(res_count1.is_ok()); + assert_eq!( + U256::from_str(res_count1.unwrap().to_string().as_str()).unwrap(), + U256::from(2) + ); + + // read count2 from counter contract + let eth_call_count2 = OpTransactionRequest::default() + .from(TX_SENDER) + .transaction_type(0) + .gas_limit(20000000) + .nonce(6) + .to(COUNTER_ADDRESS) + .value(U256::ZERO) + .input(TransactionInput::new(bytes!("0xd631c639"))); + let res_count2 = provider.call(eth_call_count2).await; + assert!(res_count2.is_ok()); + assert_eq!( + U256::from_str(res_count2.unwrap().to_string().as_str()).unwrap(), + U256::from(2) + ); + + Ok(()) + } + + #[tokio::test] + async fn test_eth_estimate_gas() -> Result<()> { + reth_tracing::init_test_tracing(); + let setup = TestSetup::new().await?; + let provider = setup.harness.provider(); + + // We ensure that eth_estimate_gas will succeed because we are on plain state + let send_estimate_gas = OpTransactionRequest::default() + .from(TX_SENDER) + .transaction_type(0) + .gas_limit(200000) + .nonce(1) + .to(address!("0xf39635f2adf40608255779ff742afe13de31f577")) + .value(U256::from(9999999999849942300000u128)) + .input(TransactionInput::new(bytes!("0x"))); + + let res = provider + .estimate_gas(send_estimate_gas.clone()) + .block(BlockNumberOrTag::Pending.into()) + .await; + + assert!(res.is_ok()); + + setup.send_test_payloads().await?; + + // We included a heavy spending transaction and now don't have enough funds for this request, so + // this eth_estimate_gas with fail + let res = provider + .estimate_gas(send_estimate_gas.nonce(4)) + .block(BlockNumberOrTag::Pending.into()) + .await; + + assert!(res.is_err()); + assert!(res + .unwrap_err() + .as_error_resp() + .unwrap() + .message + .contains("insufficient funds for gas")); + + Ok(()) + } + + #[tokio::test] + async fn test_eth_simulate_v1() -> Result<()> { + reth_tracing::init_test_tracing(); + let setup = TestSetup::new().await?; + let provider = setup.harness.provider(); + setup.send_test_payloads().await?; + + let simulate_call = SimulatePayload { + block_state_calls: vec![SimBlock { + calls: vec![ + // read number from counter contract + OpTransactionRequest::default() + .from(address!("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266")) + .transaction_type(0) + .gas_limit(200000) + .to(address!("0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512")) + .value(U256::ZERO) + .input(TransactionInput::new(bytes!("0xa87d942c"))) + .into(), + // increment() value in contract + OpTransactionRequest::default() + .from(address!("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266")) + .transaction_type(0) + .gas_limit(200000) + .to(address!("0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512")) + .input(TransactionInput::new(bytes!("0xd09de08a"))) + .into(), + // read number from counter contract + OpTransactionRequest::default() + .from(address!("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266")) + .transaction_type(0) + .gas_limit(200000) + .to(address!("0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512")) + .value(U256::ZERO) + .input(TransactionInput::new(bytes!("0xa87d942c"))) + .into(), + ], + block_overrides: None, + state_overrides: None, + }], + trace_transfers: false, + validation: true, + return_full_transactions: true, + }; + let simulate_res = provider + .simulate(&simulate_call) + .block_id(BlockNumberOrTag::Pending.into()) + .await; + assert!(simulate_res.is_ok()); + let block = simulate_res.unwrap(); + assert_eq!(block.len(), 1); + assert_eq!(block[0].calls.len(), 3); + assert_eq!( + block[0].calls[0].return_data, + bytes!("0x0000000000000000000000000000000000000000000000000000000000000002") + ); + assert_eq!(block[0].calls[1].return_data, bytes!("0x")); + assert_eq!( + block[0].calls[2].return_data, + bytes!("0x0000000000000000000000000000000000000000000000000000000000000003") + ); + + Ok(()) + } + + #[tokio::test] + async fn test_send_raw_transaction_sync() -> Result<()> { + reth_tracing::init_test_tracing(); + let setup = TestSetup::new().await?; + + setup.send_flashblock(create_first_payload()).await?; + + // run the Tx sync and, in parallel, deliver the payload that contains the Tx + let (receipt_result, payload_result) = tokio::join!( + setup.send_raw_transaction_sync(TRANSFER_ETH_TX, None), + async { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + setup.send_flashblock(create_second_payload()).await + } + ); + + payload_result?; + let receipt = receipt_result?; + + assert_eq!(receipt.transaction_hash(), TRANSFER_ETH_HASH); + Ok(()) + } + + #[tokio::test] + async fn test_send_raw_transaction_sync_timeout() { + reth_tracing::init_test_tracing(); + let setup = TestSetup::new().await.unwrap(); + + // fail request immediately by passing a timeout of 0 ms + let receipt_result = setup + .send_raw_transaction_sync(TRANSFER_ETH_TX, Some(0)) + .await; + + let error_code = EthRpcErrorCode::TransactionConfirmationTimeout.code(); + assert!(receipt_result + .err() + .unwrap() + .to_string() + .contains(format!("{}", error_code).as_str())); + } + + #[tokio::test] + async fn test_get_logs_pending() -> Result<()> { + reth_tracing::init_test_tracing(); + let setup = TestSetup::new().await?; + let provider = setup.harness.provider(); + + // Test no logs when no flashblocks sent + let logs = provider + .get_logs( + &alloy_rpc_types_eth::Filter::default() + .select(alloy_eips::BlockNumberOrTag::Pending), + ) + .await?; + assert_eq!(logs.len(), 0); + + // Send payloads with transactions + setup.send_test_payloads().await?; + + // Test getting pending logs - must use both fromBlock and toBlock as "pending" + let logs = provider + .get_logs( + &alloy_rpc_types_eth::Filter::default() + .from_block(alloy_eips::BlockNumberOrTag::Pending) + .to_block(alloy_eips::BlockNumberOrTag::Pending), + ) + .await?; + + // We should now have 2 logs from the INCREMENT_TX transaction + assert_eq!(logs.len(), 2); + + // Verify the first log is from COUNTER_ADDRESS + assert_eq!(logs[0].address(), COUNTER_ADDRESS); + assert_eq!(logs[0].topics()[0], TEST_LOG_TOPIC_0); + assert_eq!(logs[0].transaction_hash, Some(INCREMENT_HASH)); + + // Verify the second log is from TEST_ADDRESS + assert_eq!(logs[1].address(), TEST_ADDRESS); + assert_eq!(logs[1].topics()[0], TEST_LOG_TOPIC_0); + assert_eq!(logs[1].transaction_hash, Some(INCREMENT_HASH)); + + Ok(()) + } + + #[tokio::test] + async fn test_get_logs_filter_by_address() -> Result<()> { + reth_tracing::init_test_tracing(); + let setup = TestSetup::new().await?; + let provider = setup.harness.provider(); + + setup.send_test_payloads().await?; + + // Test filtering by a specific address (COUNTER_ADDRESS) + let logs = provider + .get_logs( + &alloy_rpc_types_eth::Filter::default() + .address(COUNTER_ADDRESS) + .from_block(alloy_eips::BlockNumberOrTag::Pending) + .to_block(alloy_eips::BlockNumberOrTag::Pending), + ) + .await?; + + // Should get only 1 log from COUNTER_ADDRESS + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].address(), COUNTER_ADDRESS); + assert_eq!(logs[0].transaction_hash, Some(INCREMENT_HASH)); + + // Test filtering by TEST_ADDRESS + let logs = provider + .get_logs( + &alloy_rpc_types_eth::Filter::default() + .address(TEST_ADDRESS) + .from_block(alloy_eips::BlockNumberOrTag::Pending) + .to_block(alloy_eips::BlockNumberOrTag::Pending), + ) + .await?; + + // Should get only 1 log from TEST_ADDRESS + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].address(), TEST_ADDRESS); + assert_eq!(logs[0].transaction_hash, Some(INCREMENT_HASH)); + + Ok(()) + } + + #[tokio::test] + async fn test_get_logs_topic_filtering() -> Result<()> { + reth_tracing::init_test_tracing(); + let setup = TestSetup::new().await?; + let provider = setup.harness.provider(); + + setup.send_test_payloads().await?; + + // Test filtering by topic - should match both logs + let logs = provider + .get_logs( + &alloy_rpc_types_eth::Filter::default() + .event_signature(TEST_LOG_TOPIC_0) + .from_block(alloy_eips::BlockNumberOrTag::Pending) + .to_block(alloy_eips::BlockNumberOrTag::Pending), + ) + .await?; + + assert_eq!(logs.len(), 2); + assert!(logs.iter().all(|log| log.topics()[0] == TEST_LOG_TOPIC_0)); + + // Test filtering by specific topic combination - should match only the first log + let filter = alloy_rpc_types_eth::Filter::default() + .topic1(TEST_LOG_TOPIC_1) + .from_block(alloy_eips::BlockNumberOrTag::Pending) + .to_block(alloy_eips::BlockNumberOrTag::Pending); + + let logs = provider.get_logs(&filter).await?; + + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].address(), COUNTER_ADDRESS); + assert_eq!(logs[0].topics()[1], TEST_LOG_TOPIC_1); + + Ok(()) + } + + #[tokio::test] + async fn test_get_logs_mixed_block_ranges() -> Result<()> { + reth_tracing::init_test_tracing(); + let setup = TestSetup::new().await?; + let provider = setup.harness.provider(); + + setup.send_test_payloads().await?; + + // Test fromBlock: 0, toBlock: pending (should include both historical and pending) + let logs = provider + .get_logs( + &alloy_rpc_types_eth::Filter::default() + .from_block(0) + .to_block(alloy_eips::BlockNumberOrTag::Pending), + ) + .await?; + + // Should now include pending logs (2 logs from our test setup) + assert_eq!(logs.len(), 2); + assert!(logs + .iter() + .all(|log| log.transaction_hash == Some(INCREMENT_HASH))); + + // Test fromBlock: latest, toBlock: pending + let logs = provider + .get_logs( + &alloy_rpc_types_eth::Filter::default() + .from_block(alloy_eips::BlockNumberOrTag::Latest) + .to_block(alloy_eips::BlockNumberOrTag::Pending), + ) + .await?; + + // Should include pending logs (historical part is empty in our test setup) + assert_eq!(logs.len(), 2); + assert!(logs + .iter() + .all(|log| log.transaction_hash == Some(INCREMENT_HASH))); + + // Test fromBlock: earliest, toBlock: pending + let logs = provider + .get_logs( + &alloy_rpc_types_eth::Filter::default() + .from_block(alloy_eips::BlockNumberOrTag::Earliest) + .to_block(alloy_eips::BlockNumberOrTag::Pending), + ) + .await?; + + // Should include pending logs (historical part is empty in our test setup) + assert_eq!(logs.len(), 2); + assert!(logs + .iter() + .all(|log| log.transaction_hash == Some(INCREMENT_HASH))); + + Ok(()) + } +} diff --git a/crates/flashblocks-rpc/src/tests/mod.rs b/crates/flashblocks-rpc/src/tests/mod.rs index 820364fb..d3e9f6b1 100644 --- a/crates/flashblocks-rpc/src/tests/mod.rs +++ b/crates/flashblocks-rpc/src/tests/mod.rs @@ -1,5 +1,6 @@ use alloy_primitives::{b256, bytes, Bytes, B256}; +mod framework_test; mod rpc; mod state; mod utils; diff --git a/crates/flashblocks-rpc/src/tests/rpc.rs b/crates/flashblocks-rpc/src/tests/rpc.rs index 9c36d858..0652deb6 100644 --- a/crates/flashblocks-rpc/src/tests/rpc.rs +++ b/crates/flashblocks-rpc/src/tests/rpc.rs @@ -16,6 +16,7 @@ mod tests { use alloy_rpc_types_engine::PayloadId; use alloy_rpc_types_eth::error::EthRpcErrorCode; use alloy_rpc_types_eth::TransactionInput; + use once_cell::sync::OnceCell; use op_alloy_consensus::OpDepositReceipt; use op_alloy_network::{Optimism, ReceiptResponse, TransactionResponse}; use op_alloy_rpc_types::OpTransactionRequest; @@ -24,6 +25,7 @@ mod tests { use reth::chainspec::Chain; use reth::core::exit::NodeExitFuture; use reth::tasks::TaskManager; + use reth_exex::ExExEvent; use reth_optimism_chainspec::OpChainSpecBuilder; use reth_optimism_node::args::RollupArgs; use reth_optimism_node::OpNode; @@ -37,6 +39,7 @@ mod tests { use std::str::FromStr; use std::sync::Arc; use tokio::sync::{mpsc, oneshot}; + use tokio_stream::StreamExt; pub struct NodeContext { sender: mpsc::Sender<(Flashblock, oneshot::Sender<()>)>, @@ -117,9 +120,11 @@ mod tests { let node = OpNode::new(RollupArgs::default()); - // Start websocket server to simulate the builder and send payloads back to the node + // Start dummy websocket server to simulate the builder and send payloads back to the node let (sender, mut receiver) = mpsc::channel::<(Flashblock, oneshot::Sender<()>)>(100); + let fb_cell: Arc>>> = Arc::new(OnceCell::new()); + let NodeHandle { node, node_exit_future, @@ -128,23 +133,47 @@ mod tests { .with_types_and_provider::>() .with_components(node.components_builder()) .with_add_ons(node.add_ons()) + .install_exex("flashblocks-canon", { + let fb_cell = fb_cell.clone(); + move |mut ctx| async move { + let fb = fb_cell + .get_or_init(|| Arc::new(FlashblocksState::new(ctx.provider().clone()))) + .clone(); + Ok(async move { + while let Some(note) = ctx.notifications.try_next().await? { + if let Some(committed) = note.committed_chain() { + for b in committed.blocks_iter() { + fb.on_canonical_block_received(b); + } + let _ = ctx + .events + .send(ExExEvent::FinishedHeight(committed.tip().num_hash())); + } + } + Ok(()) + }) + } + }) .extend_rpc_modules(move |ctx| { - // We are not going to use the websocket connection to send payloads so we use - // a dummy url. - let flashblocks_state = Arc::new(FlashblocksState::new(ctx.provider().clone())); - flashblocks_state.start(); + // We are not going to use the websocket connection to send payloads so we don't + // initialize a flashblocks subscriber + let fb = fb_cell + .get_or_init(|| Arc::new(FlashblocksState::new(ctx.provider().clone()))) + .clone(); + + fb.start(); let api_ext = EthApiExt::new( ctx.registry.eth_api().clone(), ctx.registry.eth_handlers().filter.clone(), - flashblocks_state.clone(), + fb.clone(), ); ctx.modules.replace_configured(api_ext.into_rpc())?; tokio::spawn(async move { while let Some((payload, tx)) = receiver.recv().await { - flashblocks_state.on_flashblock_received(payload); + fb.on_flashblock_received(payload); tx.send(()).unwrap(); } }); diff --git a/crates/metering/src/tests/assets/genesis.json b/crates/metering/src/tests/assets/genesis.json index 4d703497..dbdbfe69 100644 --- a/crates/metering/src/tests/assets/genesis.json +++ b/crates/metering/src/tests/assets/genesis.json @@ -1,6 +1,6 @@ { "config": { - "chainId": 8453, + "chainId": 84532, "homesteadBlock": 0, "eip150Block": 0, "eip155Block": 0, diff --git a/crates/test-utils/Cargo.toml b/crates/test-utils/Cargo.toml new file mode 100644 index 00000000..56469561 --- /dev/null +++ b/crates/test-utils/Cargo.toml @@ -0,0 +1,81 @@ +[package] +name = "base-reth-test-utils" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +description = "Common integration test utilities for node-reth crates" + +[lints] +workspace = true + +[dependencies] +# reth +reth.workspace = true +reth-optimism-node.workspace = true +reth-optimism-chainspec.workspace = true +reth-optimism-cli.workspace = true +reth-optimism-primitives.workspace = true +reth-optimism-rpc.workspace = true +reth-provider.workspace = true +reth-primitives.workspace = true +reth-primitives-traits.workspace = true +reth-db.workspace = true +reth-db-common.workspace = true +reth-testing-utils.workspace = true +reth-e2e-test-utils.workspace = true +reth-exex.workspace = true +reth-tracing.workspace = true +reth-rpc-layer.workspace = true +reth-ipc.workspace = true + +# alloy +alloy-primitives.workspace = true +alloy-genesis.workspace = true +alloy-eips.workspace = true +alloy-rpc-types.workspace = true +alloy-rpc-types-engine.workspace = true +alloy-rpc-types-eth.workspace = true +alloy-consensus.workspace = true +alloy-provider.workspace = true +alloy-rpc-client.workspace = true +alloy-serde.workspace = true +alloy-signer = "1.0" +alloy-signer-local = "1.1.0" + +# op-alloy +op-alloy-rpc-types.workspace = true +op-alloy-rpc-types-engine.workspace = true +op-alloy-network.workspace = true +op-alloy-consensus.workspace = true + +# rollup-boost +rollup-boost.workspace = true + +# tokio +tokio.workspace = true +tokio-stream.workspace = true +tokio-util = { version = "0.7", features = ["compat"] } + +# async +futures.workspace = true +futures-util.workspace = true + +# rpc +jsonrpsee.workspace = true + +# misc +tracing.workspace = true +serde.workspace = true +serde_json.workspace = true +eyre.workspace = true +once_cell.workspace = true +url.workspace = true +chrono.workspace = true + +# tower for middleware +tower = "0.5" + +[dev-dependencies] diff --git a/crates/test-utils/README.md b/crates/test-utils/README.md new file mode 100644 index 00000000..630eb2b1 --- /dev/null +++ b/crates/test-utils/README.md @@ -0,0 +1,326 @@ +# Test Utils + +A comprehensive integration test framework for node-reth crates. + +## Overview + +This crate provides reusable testing utilities for integration tests across the node-reth workspace. It includes: + +- **LocalNode**: Isolated in-process node with Base Sepolia chainspec +- **TestHarness**: Unified orchestration layer combining node, Engine API, and flashblocks +- **EngineApi**: Type-safe Engine API client for CL operations +- **Test Accounts**: Pre-funded hardcoded accounts (Alice, Bob, Charlie, Deployer) +- **Flashblocks Support**: Testing pending state with flashblocks delivery + +## Quick Start + +```rust +use base_reth_test_utils::TestHarness; + +#[tokio::test] +async fn test_example() -> eyre::Result<()> { + let harness = TestHarness::new().await?; + + // Advance the chain + harness.advance_chain(5).await?; + + // Access accounts + let alice = &harness.accounts().alice; + + // Get balance via provider + let balance = harness.provider().get_balance(alice.address).await?; + + Ok(()) +} +``` + +## Architecture + +The framework follows a three-layer architecture: + +``` +┌─────────────────────────────────────┐ +│ TestHarness │ ← Orchestration layer (tests use this) +│ - Coordinates node + engine │ +│ - Builds blocks from transactions │ +│ - Manages test accounts │ +└─────────────────────────────────────┘ + │ │ + ┌──────┘ └──────┐ + ▼ ▼ +┌─────────┐ ┌──────────┐ +│LocalNode│ │EngineApi │ ← Raw API wrappers +│ (EL) │ │ (CL) │ +└─────────┘ └──────────┘ +``` + +### Component Responsibilities + +- **LocalNode** (EL wrapper): In-process Optimism node with HTTP RPC + Engine API IPC +- **EngineApi** (CL wrapper): Raw Engine API calls (forkchoice, payloads) +- **TestHarness**: Orchestrates block building by fetching latest block headers and calling Engine API + +## Components + +### 1. TestHarness + +The main entry point for integration tests. Combines node, engine, and accounts into a single interface. + +```rust +use base_reth_test_utils::TestHarness; +use alloy_primitives::Bytes; + +#[tokio::test] +async fn test_harness() -> eyre::Result<()> { + let harness = TestHarness::new().await?; + + // Access provider + let provider = harness.provider(); + let chain_id = provider.get_chain_id().await?; + + // Access accounts + let alice = &harness.accounts().alice; + let bob = &harness.accounts().bob; + + // Build empty blocks + harness.advance_chain(10).await?; + + // Build block with transactions + let txs: Vec = vec![/* signed transaction bytes */]; + harness.build_block_from_transactions(txs).await?; + + // Build block from flashblocks + harness.build_block_from_flashblocks(&flashblocks).await?; + + // Send flashblocks for pending state testing + harness.send_flashblock(flashblock).await?; + + Ok(()) +} +``` + +**Key Methods:** +- `new()` - Create new harness with node, engine, and accounts +- `provider()` - Get Alloy RootProvider for RPC calls +- `accounts()` - Access test accounts +- `advance_chain(n)` - Build N empty blocks +- `build_block_from_transactions(txs)` - Build block with specific transactions +- `build_block_from_flashblocks(&flashblocks)` - Extract txs from flashblocks and build block +- `send_flashblock(fb)` - Send flashblock to node for pending state + +**Block Building Process:** +1. Fetches latest block header from provider (no local state tracking) +2. Calculates next timestamp (parent + 2 seconds for Base) +3. Calls `engine.update_forkchoice()` with payload attributes +4. Waits for block construction +5. Calls `engine.get_payload()` to retrieve built payload +6. Calls `engine.new_payload()` to validate and submit +7. Calls `engine.update_forkchoice()` again to finalize + +### 2. LocalNode + +In-process Optimism node with Base Sepolia configuration. + +```rust +use base_reth_test_utils::LocalNode; + +#[tokio::test] +async fn test_node() -> eyre::Result<()> { + let node = LocalNode::new().await?; + + // Get provider + let provider = node.provider()?; + + // Get Engine API + let engine = node.engine_api()?; + + // Send flashblocks + node.send_flashblock(flashblock).await?; + + Ok(()) +} +``` + +**Features:** +- Base Sepolia chain configuration +- Disabled P2P discovery (isolated testing) +- Random unused ports (parallel test safety) +- HTTP RPC server at `node.http_api_addr` +- Engine API IPC at `node.engine_ipc_path` +- Flashblocks-canon ExEx integration + +**Note:** Most tests should use `TestHarness` instead of `LocalNode` directly. + +### 3. EngineApi + +Type-safe Engine API client wrapping raw CL operations. + +```rust +use base_reth_test_utils::EngineApi; +use alloy_primitives::B256; +use op_alloy_rpc_types_engine::OpPayloadAttributes; + +// Usually accessed via TestHarness, but can be used directly +let engine = node.engine_api()?; + +// Raw Engine API calls +let fcu = engine.update_forkchoice(current_head, new_head, Some(attrs)).await?; +let payload = engine.get_payload(payload_id).await?; +let status = engine.new_payload(payload, vec![], parent_root, requests).await?; +``` + +**Methods:** +- `get_payload(payload_id)` - Retrieve built payload by ID +- `new_payload(payload, hashes, root, requests)` - Submit new payload +- `update_forkchoice(current, new, attrs)` - Update forkchoice state + +**Note:** EngineApi is stateless. Block building logic lives in `TestHarness`. + +### 4. Test Accounts + +Hardcoded test accounts with deterministic addresses (Anvil-compatible). + +```rust +use base_reth_test_utils::TestAccounts; + +let accounts = TestAccounts::new(); + +let alice = &accounts.alice; +let bob = &accounts.bob; +let charlie = &accounts.charlie; +let deployer = &accounts.deployer; + +// Access via harness +let harness = TestHarness::new().await?; +let alice = &harness.accounts().alice; +``` + +**Account Details:** +- **Alice**: `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` - 10,000 ETH +- **Bob**: `0x70997970C51812dc3A010C7d01b50e0d17dc79C8` - 10,000 ETH +- **Charlie**: `0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC` - 10,000 ETH +- **Deployer**: `0x90F79bf6EB2c4f870365E785982E1f101E93b906` - 10,000 ETH + +Each account includes: +- `name` - Account identifier +- `address` - Ethereum address +- `private_key` - Private key (hex string) +- `initial_balance_eth` - Starting balance in ETH + +### 5. Flashblocks Support + +Test flashblocks delivery without WebSocket connections. + +```rust +use base_reth_test_utils::{FlashblocksContext, FlashblockBuilder}; + +#[tokio::test] +async fn test_flashblocks() -> eyre::Result<()> { + let (fb_ctx, receiver) = FlashblocksContext::new(); + + // Create base flashblock + let flashblock = FlashblockBuilder::new(1, 0) + .as_base(B256::ZERO, 1000) + .with_transaction(tx_bytes, tx_hash, 21000) + .with_balance(address, U256::from(1000)) + .build(); + + fb_ctx.send_flashblock(flashblock).await?; + + Ok(()) +} +``` + +**Via TestHarness:** +```rust +let harness = TestHarness::new().await?; +harness.send_flashblock(flashblock).await?; +``` + +## Configuration Constants + +Key constants defined in `harness.rs`: + +```rust +const BLOCK_TIME_SECONDS: u64 = 2; // Base L2 block time +const GAS_LIMIT: u64 = 200_000_000; // Default gas limit +const NODE_STARTUP_DELAY_MS: u64 = 500; // IPC endpoint initialization +const BLOCK_BUILD_DELAY_MS: u64 = 100; // Payload construction wait +``` + +## File Structure + +``` +test-utils/ +├── src/ +│ ├── lib.rs # Public API and re-exports +│ ├── accounts.rs # Test account definitions +│ ├── node.rs # LocalNode (EL wrapper) +│ ├── engine.rs # EngineApi (CL wrapper) +│ ├── harness.rs # TestHarness (orchestration) +│ └── flashblocks.rs # Flashblocks support +├── assets/ +│ └── genesis.json # Base Sepolia genesis +└── Cargo.toml +``` + +## Usage in Other Crates + +Add to `dev-dependencies`: + +```toml +[dev-dependencies] +base-reth-test-utils.workspace = true +``` + +Import in tests: + +```rust +use base_reth_test_utils::TestHarness; + +#[tokio::test] +async fn my_test() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let harness = TestHarness::new().await?; + // Your test logic + + Ok(()) +} +``` + +## Design Principles + +1. **Separation of Concerns**: LocalNode (EL), EngineApi (CL), TestHarness (orchestration) +2. **Stateless Components**: No local state tracking; always fetch from provider +3. **Type Safety**: Use reth's `OpEngineApiClient` trait instead of raw RPC strings +4. **Parallel Testing**: Random ports + isolated nodes enable concurrent tests +5. **Anvil Compatibility**: Same mnemonic as Anvil for tooling compatibility + +## Testing + +Run the test suite: + +```bash +cargo test -p base-reth-test-utils +``` + +Run specific test: + +```bash +cargo test -p base-reth-test-utils test_harness_setup +``` + +## Future Enhancements + +- Transaction builders for common operations +- Smart contract deployment helpers (Foundry integration planned) +- Snapshot/restore functionality +- Multi-node network simulation +- Performance benchmarking utilities + +## References + +Inspired by: +- [op-rbuilder test framework](https://github.com/flashbots/op-rbuilder/tree/main/crates/op-rbuilder/src/tests/framework) +- [reth e2e-test-utils](https://github.com/paradigmxyz/reth/tree/main/crates/e2e-test-utils) diff --git a/crates/test-utils/assets/genesis.json b/crates/test-utils/assets/genesis.json new file mode 100644 index 00000000..b3099c33 --- /dev/null +++ b/crates/test-utils/assets/genesis.json @@ -0,0 +1,106 @@ +{ + "config": { + "chainId": 84532, + "homesteadBlock": 0, + "eip150Block": 0, + "eip155Block": 0, + "eip158Block": 0, + "byzantiumBlock": 0, + "constantinopleBlock": 0, + "petersburgBlock": 0, + "istanbulBlock": 0, + "muirGlacierBlock": 0, + "berlinBlock": 0, + "londonBlock": 0, + "arrowGlacierBlock": 0, + "grayGlacierBlock": 0, + "mergeNetsplitBlock": 0, + "bedrockBlock": 0, + "regolithTime": 0, + "canyonTime": 0, + "ecotoneTime": 0, + "fjordTime": 0, + "graniteTime": 0, + "isthmusTime": 0, + "pragueTime": 0, + "terminalTotalDifficulty": 0, + "terminalTotalDifficultyPassed": true, + "optimism": { + "eip1559Elasticity": 6, + "eip1559Denominator": 50 + } + }, + "nonce": "0x0", + "timestamp": "0x0", + "extraData": "0x00", + "gasLimit": "0x1c9c380", + "difficulty": "0x0", + "mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "coinbase": "0x0000000000000000000000000000000000000000", + "alloc": { + "0x14dc79964da2c08b23698b3d3cc7ca32193d9955": { + "balance": "0xd3c21bcecceda1000000" + }, + "0x15d34aaf54267db7d7c367839aaf71a00a2c6a65": { + "balance": "0xd3c21bcecceda1000000" + }, + "0x1cbd3b2770909d4e10f157cabc84c7264073c9ec": { + "balance": "0xd3c21bcecceda1000000" + }, + "0x23618e81e3f5cdf7f54c3d65f7fbc0abf5b21e8f": { + "balance": "0xd3c21bcecceda1000000" + }, + "0x2546bcd3c84621e976d8185a91a922ae77ecec30": { + "balance": "0xd3c21bcecceda1000000" + }, + "0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc": { + "balance": "0xd3c21bcecceda1000000" + }, + "0x70997970c51812dc3a010c7d01b50e0d17dc79c8": { + "balance": "0xd3c21bcecceda1000000" + }, + "0x71be63f3384f5fb98995898a86b02fb2426c5788": { + "balance": "0xd3c21bcecceda1000000" + }, + "0x8626f6940e2eb28930efb4cef49b2d1f2c9c1199": { + "balance": "0xd3c21bcecceda1000000" + }, + "0x90f79bf6eb2c4f870365e785982e1f101e93b906": { + "balance": "0xd3c21bcecceda1000000" + }, + "0x976ea74026e726554db657fa54763abd0c3a0aa9": { + "balance": "0xd3c21bcecceda1000000" + }, + "0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc": { + "balance": "0xd3c21bcecceda1000000" + }, + "0x9c41de96b2088cdc640c6182dfcf5491dc574a57": { + "balance": "0xd3c21bcecceda1000000" + }, + "0xa0ee7a142d267c1f36714e4a8f75612f20a79720": { + "balance": "0xd3c21bcecceda1000000" + }, + "0xbcd4042de499d14e55001ccbb24a551f3b954096": { + "balance": "0xd3c21bcecceda1000000" + }, + "0xbda5747bfd65f08deb54cb465eb87d40e51b197e": { + "balance": "0xd3c21bcecceda1000000" + }, + "0xcd3b766ccdd6ae721141f452c550ca635964ce71": { + "balance": "0xd3c21bcecceda1000000" + }, + "0xdd2fd4581271e230360230f9337d5c0430bf44c0": { + "balance": "0xd3c21bcecceda1000000" + }, + "0xdf3e18d64bc6a983f673ab319ccae4f1a57c7097": { + "balance": "0xd3c21bcecceda1000000" + }, + "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266": { + "balance": "0xd3c21bcecceda1000000" + }, + "0xfabb0ac9d68b0b445fb7357272ff202c5651694a": { + "balance": "0xd3c21bcecceda1000000" + } + }, + "number": "0x0" +} \ No newline at end of file diff --git a/crates/test-utils/src/accounts.rs b/crates/test-utils/src/accounts.rs new file mode 100644 index 00000000..65ac2ccc --- /dev/null +++ b/crates/test-utils/src/accounts.rs @@ -0,0 +1,125 @@ +//! Test accounts with pre-funded balances for integration testing + +use alloy_consensus::{SignableTransaction, TxLegacy}; +use alloy_eips::eip2718::Encodable2718; +use alloy_primitives::{address, hex, Address, Bytes, FixedBytes, U256}; +use alloy_signer::SignerSync; +use alloy_signer_local::PrivateKeySigner; +use eyre::Result; + +/// Hardcoded test account with a fixed private key +#[derive(Debug, Clone)] +pub struct Account { + /// Account name for easy identification + pub name: &'static str, + /// Ethereum address + pub address: Address, + /// Private key (hex string without 0x prefix) + pub private_key: &'static str, +} + +impl Account { + /// Sign a simple ETH transfer transaction and return the signed bytes + pub fn sign_transaction_bytes( + &self, + to: Address, + value: U256, + nonce: u64, + chain_id: u64, + ) -> Result { + let key_bytes = hex::decode(self.private_key)?; + let key_fixed: FixedBytes<32> = FixedBytes::from_slice(&key_bytes); + let signer = PrivateKeySigner::from_bytes(&key_fixed)?; + + let tx = TxLegacy { + chain_id: Some(chain_id), + nonce, + gas_price: 200, + gas_limit: 21_000, + to: alloy_primitives::TxKind::Call(to), + value, + input: Bytes::new(), + }; + + let signature = signer.sign_hash_sync(&tx.signature_hash())?; + let signed_tx = tx.into_signed(signature); + + Ok(signed_tx.encoded_2718().into()) + } +} + +pub type TestAccount = Account; + +/// Collection of all test accounts +#[derive(Debug, Clone)] +pub struct TestAccounts { + pub alice: TestAccount, + pub bob: TestAccount, + pub charlie: TestAccount, + pub deployer: TestAccount, +} + +impl TestAccounts { + /// Create a new instance with all test accounts + pub fn new() -> Self { + Self { + alice: ALICE, + bob: BOB, + charlie: CHARLIE, + deployer: DEPLOYER, + } + } + + /// Get all accounts as a vector + pub fn all(&self) -> Vec<&TestAccount> { + vec![&self.alice, &self.bob, &self.charlie, &self.deployer] + } + + /// Get account by name + pub fn get(&self, name: &str) -> Option<&TestAccount> { + match name { + "alice" => Some(&self.alice), + "bob" => Some(&self.bob), + "charlie" => Some(&self.charlie), + "deployer" => Some(&self.deployer), + _ => None, + } + } +} + +impl Default for TestAccounts { + fn default() -> Self { + Self::new() + } +} + +// Hardcoded test accounts using Anvil's deterministic keys +// These are derived from the test mnemonic: "test test test test test test test test test test test junk" + +/// Alice - First test account (Anvil account #0) +pub const ALICE: TestAccount = TestAccount { + name: "Alice", + address: address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"), + private_key: "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", +}; + +/// Bob - Second test account (Anvil account #1) +pub const BOB: TestAccount = TestAccount { + name: "Bob", + address: address!("70997970C51812dc3A010C7d01b50e0d17dc79C8"), + private_key: "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", +}; + +/// Charlie - Third test account (Anvil account #2) +pub const CHARLIE: TestAccount = TestAccount { + name: "Charlie", + address: address!("3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"), + private_key: "5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", +}; + +/// Deployer - Account for deploying smart contracts (Anvil account #3) +pub const DEPLOYER: TestAccount = TestAccount { + name: "Deployer", + address: address!("90F79bf6EB2c4f870365E785982E1f101E93b906"), + private_key: "7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6", +}; diff --git a/crates/test-utils/src/engine.rs b/crates/test-utils/src/engine.rs new file mode 100644 index 00000000..4c1c19c1 --- /dev/null +++ b/crates/test-utils/src/engine.rs @@ -0,0 +1,183 @@ +//! Engine API integration for canonical block production +//! +//! This module provides a typed, type-safe Engine API client based on +//! reth's OpEngineApiClient trait instead of raw string-based RPC calls. + +use alloy_eips::eip7685::Requests; +use alloy_primitives::B256; +use alloy_rpc_types_engine::{ForkchoiceUpdated, PayloadId, PayloadStatus}; +use eyre::Result; +use jsonrpsee::core::client::SubscriptionClientT; +use op_alloy_rpc_types_engine::OpExecutionPayloadV4; +use reth::api::{EngineTypes, PayloadTypes}; +use reth::rpc::types::engine::ForkchoiceState; +use reth_optimism_node::OpEngineTypes; +use reth_optimism_rpc::engine::OpEngineApiClient; +use reth_rpc_layer::{AuthClientLayer, JwtSecret}; +use reth_tracing::tracing::debug; +use std::marker::PhantomData; +use std::time::Duration; +use url::Url; + +/// Default JWT secret for testing +const DEFAULT_JWT_SECRET: &str = + "0x0000000000000000000000000000000000000000000000000000000000000000"; + +#[derive(Clone, Debug)] +pub enum EngineAddress { + Http(Url), + Ipc(String), +} + +pub trait EngineProtocol: Send + Sync { + fn client( + jwt: JwtSecret, + address: EngineAddress, + ) -> impl std::future::Future< + Output = impl jsonrpsee::core::client::SubscriptionClientT + Send + Sync + Unpin + 'static, + > + Send; +} + +pub struct HttpEngine; + +impl EngineProtocol for HttpEngine { + async fn client( + jwt: JwtSecret, + address: EngineAddress, + ) -> impl SubscriptionClientT + Send + Sync + Unpin + 'static { + let EngineAddress::Http(url) = address else { + unreachable!(); + }; + + let secret_layer = AuthClientLayer::new(jwt); + let middleware = tower::ServiceBuilder::default().layer(secret_layer); + + jsonrpsee::http_client::HttpClientBuilder::default() + .request_timeout(Duration::from_secs(10)) + .set_http_middleware(middleware) + .build(url) + .expect("Failed to create http client") + } +} + +pub struct IpcEngine; + +impl EngineProtocol for IpcEngine { + async fn client( + _: JwtSecret, // ipc does not use JWT + address: EngineAddress, + ) -> impl SubscriptionClientT + Send + Sync + Unpin + 'static { + let EngineAddress::Ipc(path) = address else { + unreachable!(); + }; + reth_ipc::client::IpcClientBuilder::default() + .build(&path) + .await + .expect("Failed to create ipc client") + } +} + +pub struct EngineApi { + address: EngineAddress, + jwt_secret: JwtSecret, + _phantom: PhantomData

, +} + +impl EngineApi { + pub fn new(engine_url: String) -> Result { + let url: Url = engine_url.parse()?; + let jwt_secret: JwtSecret = DEFAULT_JWT_SECRET.parse()?; + + Ok(Self { + address: EngineAddress::Http(url), + jwt_secret, + _phantom: PhantomData, + }) + } +} + +impl EngineApi { + pub fn new(path: String) -> Result { + let jwt_secret: JwtSecret = DEFAULT_JWT_SECRET.parse()?; + + Ok(Self { + address: EngineAddress::Ipc(path), + jwt_secret, + _phantom: PhantomData, + }) + } +} + +impl EngineApi

{ + /// Get a client instance + async fn client(&self) -> impl SubscriptionClientT + Send + Sync + Unpin + 'static + use

{ + P::client(self.jwt_secret, self.address.clone()).await + } + + /// Get a payload by ID from the Engine API + pub async fn get_payload( + &self, + payload_id: PayloadId, + ) -> eyre::Result<::ExecutionPayloadEnvelopeV4> { + debug!( + "Fetching payload with id: {} at {}", + payload_id, + chrono::Utc::now() + ); + Ok( + OpEngineApiClient::::get_payload_v4(&self.client().await, payload_id) + .await?, + ) + } + + /// Submit a new payload to the Engine API + pub async fn new_payload( + &self, + payload: OpExecutionPayloadV4, + versioned_hashes: Vec, + parent_beacon_block_root: B256, + execution_requests: Requests, + ) -> eyre::Result { + debug!("Submitting new payload at {}...", chrono::Utc::now()); + Ok(OpEngineApiClient::::new_payload_v4( + &self.client().await, + payload, + versioned_hashes, + parent_beacon_block_root, + execution_requests, + ) + .await?) + } + + /// Update forkchoice on the Engine API + pub async fn update_forkchoice( + &self, + current_head: B256, + new_head: B256, + payload_attributes: Option<::PayloadAttributes>, + ) -> eyre::Result { + debug!( + "Updating forkchoice at {} (current: {}, new: {})", + chrono::Utc::now(), + current_head, + new_head + ); + let result = OpEngineApiClient::::fork_choice_updated_v3( + &self.client().await, + ForkchoiceState { + head_block_hash: new_head, + safe_block_hash: current_head, + finalized_block_hash: current_head, + }, + payload_attributes, + ) + .await; + + match &result { + Ok(fcu) => debug!("Forkchoice updated successfully: {:?}", fcu), + Err(e) => debug!("Forkchoice update failed: {:?}", e), + } + + Ok(result?) + } +} diff --git a/crates/test-utils/src/harness.rs b/crates/test-utils/src/harness.rs new file mode 100644 index 00000000..854e679a --- /dev/null +++ b/crates/test-utils/src/harness.rs @@ -0,0 +1,172 @@ +//! Unified test harness combining node, engine API, and flashblocks functionality + +use crate::accounts::TestAccounts; +use crate::engine::{EngineApi, IpcEngine}; +use crate::node::{LocalNode, OpAddOns, OpBuilder}; +use alloy_eips::eip7685::Requests; +use alloy_primitives::{Bytes, B256}; +use alloy_provider::{Provider, RootProvider}; +use alloy_rpc_types::BlockNumberOrTag; +use alloy_rpc_types_engine::PayloadAttributes; +use eyre::{eyre, Result}; +use futures_util::Future; +use op_alloy_network::Optimism; +use op_alloy_rpc_types_engine::OpPayloadAttributes; +use reth::builder::NodeHandle; +use reth_e2e_test_utils::Adapter; +use reth_optimism_node::OpNode; +use std::time::Duration; +use tokio::time::sleep; + +const BLOCK_TIME_SECONDS: u64 = 2; +const GAS_LIMIT: u64 = 200_000_000; +const NODE_STARTUP_DELAY_MS: u64 = 500; +const BLOCK_BUILD_DELAY_MS: u64 = 100; + +pub struct TestHarness { + node: LocalNode, + engine: EngineApi, + accounts: TestAccounts, +} + +impl TestHarness { + pub async fn new(launcher: L) -> Result + where + L: FnOnce(OpBuilder) -> LRet, + LRet: Future, OpAddOns>>>, + { + let node = LocalNode::new(launcher).await?; + let engine = node.engine_api()?; + let accounts = TestAccounts::new(); + + sleep(Duration::from_millis(NODE_STARTUP_DELAY_MS)).await; + + Ok(Self { + node, + engine, + accounts, + }) + } + + pub fn provider(&self) -> RootProvider { + self.node + .provider() + .expect("provider should always be available after node initialization") + } + + pub fn accounts(&self) -> &TestAccounts { + &self.accounts + } + + pub fn rpc_url(&self) -> String { + format!("http://{}", self.node.http_api_addr) + } + + pub async fn build_block_from_transactions(&self, transactions: Vec) -> Result<()> { + let latest_block = self + .provider() + .get_block_by_number(BlockNumberOrTag::Latest) + .await? + .ok_or_else(|| eyre!("No genesis block found"))?; + + let parent_hash = latest_block.header.hash; + let next_timestamp = latest_block.header.timestamp + BLOCK_TIME_SECONDS; + + let payload_attributes = OpPayloadAttributes { + payload_attributes: PayloadAttributes { + timestamp: next_timestamp, + parent_beacon_block_root: Some(B256::ZERO), + withdrawals: Some(vec![]), + ..Default::default() + }, + transactions: Some(transactions), + gas_limit: Some(GAS_LIMIT), + no_tx_pool: Some(true), + ..Default::default() + }; + + let forkchoice_result = self + .engine + .update_forkchoice(parent_hash, parent_hash, Some(payload_attributes)) + .await?; + + let payload_id = forkchoice_result + .payload_id + .ok_or_else(|| eyre!("Forkchoice update did not return payload ID"))?; + + sleep(Duration::from_millis(BLOCK_BUILD_DELAY_MS)).await; + + let payload_envelope = self.engine.get_payload(payload_id).await?; + + let execution_requests = if payload_envelope.execution_requests.is_empty() { + Requests::default() + } else { + Requests::new(payload_envelope.execution_requests) + }; + + let payload_status = self + .engine + .new_payload( + payload_envelope.execution_payload, + vec![], + payload_envelope.parent_beacon_block_root, + execution_requests, + ) + .await?; + + if payload_status.status.is_invalid() { + return Err(eyre!("Engine rejected payload: {:?}", payload_status)); + } + + let new_block_hash = payload_status + .latest_valid_hash + .ok_or_else(|| eyre!("Payload status missing latest_valid_hash"))?; + + self.engine + .update_forkchoice(parent_hash, new_block_hash, None) + .await?; + + Ok(()) + } + + pub async fn advance_chain(&self, n: u64) -> Result<()> { + for _ in 0..n { + self.build_block_from_transactions(vec![]).await?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::node::default_launcher; + + use super::*; + use alloy_primitives::U256; + use alloy_provider::Provider; + + #[tokio::test] + async fn test_harness_setup() -> Result<()> { + reth_tracing::init_test_tracing(); + let harness = TestHarness::new(default_launcher).await?; + + assert_eq!(harness.accounts().alice.name, "Alice"); + assert_eq!(harness.accounts().bob.name, "Bob"); + + let provider = harness.provider(); + let chain_id = provider.get_chain_id().await?; + assert_eq!(chain_id, crate::node::BASE_CHAIN_ID); + + let alice_balance = provider + .get_balance(harness.accounts().alice.address) + .await?; + assert!(alice_balance > U256::ZERO); + + let block_number = provider.get_block_number().await?; + harness.advance_chain(5).await?; + let new_block_number = provider.get_block_number().await?; + assert_eq!(new_block_number, block_number + 5); + + Ok(()) + } +} diff --git a/crates/test-utils/src/lib.rs b/crates/test-utils/src/lib.rs new file mode 100644 index 00000000..62913e26 --- /dev/null +++ b/crates/test-utils/src/lib.rs @@ -0,0 +1,4 @@ +pub mod accounts; +pub mod engine; +pub mod harness; +pub mod node; diff --git a/crates/test-utils/src/node.rs b/crates/test-utils/src/node.rs new file mode 100644 index 00000000..7c4f9d91 --- /dev/null +++ b/crates/test-utils/src/node.rs @@ -0,0 +1,184 @@ +//! Local node setup with Base Sepolia chainspec + +use crate::engine::EngineApi; +use alloy_genesis::Genesis; +use alloy_provider::RootProvider; +use alloy_rpc_client::RpcClient; +use eyre::Result; +use futures_util::Future; +use op_alloy_network::Optimism; +use reth::api::{FullNodeTypesAdapter, NodeTypesWithDBAdapter}; +use reth::args::{DiscoveryArgs, NetworkArgs, RpcServerArgs}; +use reth::builder::{ + Node, NodeBuilder, NodeBuilderWithComponents, NodeConfig, NodeHandle, WithLaunchContext, +}; +use reth::core::exit::NodeExitFuture; +use reth::tasks::TaskManager; +use reth_e2e_test_utils::{Adapter, TmpDB}; +use reth_optimism_chainspec::OpChainSpec; +use reth_optimism_node::args::RollupArgs; +use reth_optimism_node::OpNode; +use reth_provider::providers::BlockchainProvider; +use std::any::Any; +use std::net::SocketAddr; +use std::sync::Arc; + +pub const BASE_CHAIN_ID: u64 = 84532; + +pub struct LocalNode { + pub(crate) http_api_addr: SocketAddr, + engine_ipc_path: String, + // flashblock_sender: mpsc::Sender<(Flashblock, oneshot::Sender<()>)>, + _node_exit_future: NodeExitFuture, + _node: Box, + _task_manager: TaskManager, +} + +pub type OpTypes = + FullNodeTypesAdapter>>; +pub type OpComponentsBuilder = >::ComponentsBuilder; +pub type OpAddOns = >::AddOns; +pub type OpBuilder = + WithLaunchContext>; + +pub async fn default_launcher( + builder: OpBuilder, +) -> eyre::Result, OpAddOns>> { + let launcher = builder.engine_api_launcher(); + builder.launch_with(launcher).await +} + +impl LocalNode { + pub async fn new(launcher: L) -> Result + where + L: FnOnce(OpBuilder) -> LRet, + LRet: Future, OpAddOns>>>, + { + let tasks = TaskManager::current(); + let exec = tasks.executor(); + + let genesis: Genesis = serde_json::from_str(include_str!("../assets/genesis.json"))?; + let chain_spec = Arc::new(OpChainSpec::from_genesis(genesis)); + + let network_config = NetworkArgs { + discovery: DiscoveryArgs { + disable_discovery: true, + ..DiscoveryArgs::default() + }, + ..NetworkArgs::default() + }; + + // Generate unique IPC path for this test instance to avoid conflicts + // Use timestamp + thread ID + process ID for uniqueness + let unique_ipc_path = format!( + "/tmp/reth_engine_api_{}_{}_{:?}.ipc", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + std::process::id(), + std::thread::current().id() + ); + + let mut rpc_args = RpcServerArgs::default() + .with_unused_ports() + .with_http() + .with_auth_ipc(); + rpc_args.auth_ipc_path = unique_ipc_path; + + let node_config = NodeConfig::new(chain_spec.clone()) + .with_network(network_config) + .with_rpc(rpc_args) + .with_unused_ports(); + + let node = OpNode::new(RollupArgs::default()); + + // let (sender, mut receiver) = mpsc::channel::<(Flashblock, oneshot::Sender<()>)>(100); + // let fb_cell: Arc>>> = Arc::new(OnceCell::new()); + + let NodeHandle { + node: node_handle, + node_exit_future, + } = NodeBuilder::new(node_config.clone()) + .testing_node(exec.clone()) + .with_types_and_provider::>() + .with_components(node.components_builder()) + .with_add_ons(node.add_ons()) + // .install_exex("flashblocks-canon", { + // let fb_cell = fb_cell.clone(); + // move |mut ctx| async move { + // let fb = fb_cell + // .get_or_init(|| Arc::new(FlashblocksState::new(ctx.provider().clone()))) + // .clone(); + // Ok(async move { + // while let Some(note) = ctx.notifications.try_next().await? { + // if let Some(committed) = note.committed_chain() { + // for b in committed.blocks_iter() { + // fb.on_canonical_block_received(b); + // } + // let _ = ctx + // .events + // .send(ExExEvent::FinishedHeight(committed.tip().num_hash())); + // } + // } + // Ok(()) + // }) + // } + // }) + // .extend_rpc_modules(move |ctx| { + // let fb = fb_cell + // .get_or_init(|| Arc::new(FlashblocksState::new(ctx.provider().clone()))) + // .clone(); + // fb.start(); + // let api_ext = EthApiExt::new( + // ctx.registry.eth_api().clone(), + // ctx.registry.eth_handlers().filter.clone(), + // fb.clone(), + // ); + // ctx.modules.replace_configured(api_ext.into_rpc())?; + // // Spawn task to receive flashblocks from the test context + // tokio::spawn(async move { + // while let Some((payload, tx)) = receiver.recv().await { + // fb.on_flashblock_received(payload); + // tx.send(()).unwrap(); + // } + // }); + // Ok(()) + // }) + .launch_with_fn(launcher) + .await?; + + let http_api_addr = node_handle + .rpc_server_handle() + .http_local_addr() + .ok_or_else(|| eyre::eyre!("HTTP RPC server failed to bind to address"))?; + + let engine_ipc_path = node_config.rpc.auth_ipc_path; + + Ok(Self { + http_api_addr, + engine_ipc_path, + // flashblock_sender: sender, + _node_exit_future: node_exit_future, + _node: Box::new(node_handle), + _task_manager: tasks, + }) + } + + // pub async fn send_flashblock(&self, flashblock: Flashblock) -> Result<()> { + // let (tx, rx) = oneshot::channel(); + // self.flashblock_sender.send((flashblock, tx)).await?; + // rx.await?; + // Ok(()) + // } + + pub fn provider(&self) -> Result> { + let url = format!("http://{}", self.http_api_addr); + let client = RpcClient::builder().http(url.parse()?); + Ok(RootProvider::::new(client)) + } + + pub fn engine_api(&self) -> Result> { + EngineApi::::new(self.engine_ipc_path.clone()) + } +}