From 6448274c30c66163bd0053cce46d9321990d38a7 Mon Sep 17 00:00:00 2001 From: gventino-cw Date: Mon, 28 Sep 2026 19:12:54 -0300 Subject: [PATCH 01/10] feat(rpc): stratus-native response format for stratus_getBlockAndReceipts (#2709) Leader: - `stratus_getBlockAndReceipts` gains an opt-in stratus-native response format via the optional pagination parameter `format` ("alloy" default, "stratus" new): serializes `BlockRocksdb` directly from the stratus `Block` type (receipts embedded) instead of converting through the alloy types, matching the `stratus_get_block_with_changes` precedent. Follower: - New importer config `importer.response_format` (default "alloy"). With "stratus", the follower requests the native format on every importer request (including pagination chunks) and re-executes blocks through the new native path (`Executor::execute_imported_block`, `Miner::mine_imported`, `Block::apply_imported`), mirroring the alloy path behavior exactly (same signer re-derivation, receipt comparison, failed-transaction handling and state changes). - Clear startup/sync error when a stratus-format follower hits an old leader that keeps answering in the legacy alloy format. Old leaders ignore the unknown `format` field; old followers never send it, so mixed fleets keep syncing during rollout (upgrade leaders first, then flip followers via config). --- config/stratus-follower.toml | 3 + config/stratus.example.toml | 3 + crates/stratus_metrics/src/definitions.rs | 6 + .../follower/e2e-pagination-stratus.test.ts | 47 ++ e2e/test/follower/e2e-pagination.test.ts | 53 ++ justfile | 24 +- src/config/loader.rs | 1 + .../evm/types/output/transaction_execution.rs | 513 ++++++++++++++++++ src/eth/executor/mod.rs | 136 +++++ src/eth/executor/transaction_worker.rs | 36 ++ src/eth/follower/importer/config.rs | 8 + .../importer/fetchers/block_with_receipts.rs | 122 +++-- .../importer/importers/blockchain_client.rs | 84 ++- .../follower/importer/importers/execution.rs | 54 +- .../importer/importers/fake_leader.rs | 51 +- src/eth/follower/importer/mod.rs | 26 +- src/eth/follower/importer/runtime.rs | 3 + src/eth/follower/importer/supervisor.rs | 26 +- src/eth/miner/miner.rs | 33 ++ src/eth/rpc/mod.rs | 179 +++++- src/eth/rpc/pagination.rs | 59 +- src/eth/rpc/server.rs | 32 +- .../storage/permanent/rocks/types/block.rs | 60 ++ src/eth/types/block/block.rs | 13 + .../types/transaction/transaction_input.rs | 2 +- 25 files changed, 1463 insertions(+), 111 deletions(-) create mode 100644 e2e/test/follower/e2e-pagination-stratus.test.ts diff --git a/config/stratus-follower.toml b/config/stratus-follower.toml index c518c236e..88012bbd8 100644 --- a/config/stratus-follower.toml +++ b/config/stratus-follower.toml @@ -10,6 +10,9 @@ chain_id = 2008 [importer] external_rpc = "http://localhost:3000/" external_rpc_ws = "ws://localhost:3000/" +# Response format for stratus_getBlockAndReceipts importer requests: "alloy" (default) or "stratus". +# The stratus format serializes blocks with receipts directly from the stratus types. +# response_format = "stratus" [common.tracing] filter = "info,stratus::eth::rpc::rpc_subscriptions::rx=off,stratus::eth::consensus::rx=off,stratus::eth::consensus=off,jsonrpsee-server=debug" diff --git a/config/stratus.example.toml b/config/stratus.example.toml index d3f57525e..47baca76b 100644 --- a/config/stratus.example.toml +++ b/config/stratus.example.toml @@ -137,6 +137,9 @@ chain_id = 2008 # async_threads = 4 # Compute an access list for transactions before forwarding them to the leader. # forward_access_list = true +# Response format for stratus_getBlockAndReceipts importer requests: "alloy" (default) or "stratus". +# The stratus format serializes blocks with receipts directly from the stratus types. +# response_format = "alloy" # Block number to stop importing at, e.g. `0x2a`. When absent, imports indefinitely. # stop_at_block = "0x2a" diff --git a/crates/stratus_metrics/src/definitions.rs b/crates/stratus_metrics/src/definitions.rs index 9c04a010b..a1bf4ad48 100644 --- a/crates/stratus_metrics/src/definitions.rs +++ b/crates/stratus_metrics/src/definitions.rs @@ -73,6 +73,12 @@ metrics! { "Time executing an external transaction." histogram_duration executor_external_transaction{contract, function}, + "Time executing and persist an imported stratus block with all transactions." + histogram_duration executor_imported_block{}, + + "Time executing an imported stratus transaction." + histogram_duration executor_imported_transaction{contract, function}, + "Time executing a local transaction." histogram_duration executor_local_transaction{success, contract, function}, diff --git a/e2e/test/follower/e2e-pagination-stratus.test.ts b/e2e/test/follower/e2e-pagination-stratus.test.ts new file mode 100644 index 000000000..161f672a7 --- /dev/null +++ b/e2e/test/follower/e2e-pagination-stratus.test.ts @@ -0,0 +1,47 @@ +import { expect } from "chai"; + +import { ALICE } from "../helpers/account"; +import { CHAIN_ID_DEC, send } from "../helpers/rpc"; +import { FOLLOWER_URL, rpcCall, waitForFollowerBlock, waitForReceipt } from "./helpers"; + +// Requires the `just e2e-leader-follower-pagination-stratus` recipe: leader and follower both +// running with MAX_RESPONSE_SIZE_BYTES=8192, and the follower importing through the +// stratus-native response format (`--response-format stratus`). The rule under test is that +// the follower re-executes and commits imported blocks identically to the alloy format. + +const FAT_TX_DATA_BYTES = 50_000; + +describe("Pagination (stratus-native importer format)", () => { + it("syncs the follower through the stratus-native importer format", async () => { + // fat contract deployment: the code always fails, but the fat data makes the response oversized + const nonce = await send("eth_getTransactionCount", [ALICE.address]); + const signedTx = await ALICE.signer().signTransaction({ + data: "0x" + "ab".repeat(FAT_TX_DATA_BYTES), + chainId: CHAIN_ID_DEC, + gasPrice: 0, + gasLimit: 10_000_000, + nonce: nonce, + }); + const txHash = await send("eth_sendRawTransaction", [signedTx]); + + const receipt = await waitForReceipt(txHash); + const fatBlockNumber = parseInt(receipt.blockNumber, 16); + + // the follower imports the fat block through the paginated stratus-format importer + await waitForFollowerBlock(fatBlockNumber); + + // the follower re-executed and stored the block: it serves the same block content. + // the leader block is requested thin: the leader's small response limit rejects the + // full fat block over `eth_getBlockByNumber` (only importer methods paginate) + const leaderBlock = await send("eth_getBlockByNumber", [receipt.blockNumber, false]); + const followerBlock = await rpcCall(FOLLOWER_URL, "eth_getBlockByNumber", [receipt.blockNumber, true]); + expect(followerBlock.result.hash).to.equal(leaderBlock.hash); + expect(followerBlock.result.transactions).to.have.lengthOf(leaderBlock.transactions.length); + expect(followerBlock.result.transactions[0].hash).to.equal(txHash); + + // and the transaction receipt is available on the follower + const followerReceipt = await rpcCall(FOLLOWER_URL, "eth_getTransactionReceipt", [txHash]); + expect(followerReceipt.result).to.not.be.null; + expect(followerReceipt.result.blockNumber).to.equal(receipt.blockNumber); + }); +}); diff --git a/e2e/test/follower/e2e-pagination.test.ts b/e2e/test/follower/e2e-pagination.test.ts index 12f69c0f5..43646c7f1 100644 --- a/e2e/test/follower/e2e-pagination.test.ts +++ b/e2e/test/follower/e2e-pagination.test.ts @@ -11,6 +11,14 @@ import { FOLLOWER_URL, rpcCall, waitForFollowerBlock, waitForReceipt } from "./h const MAX_RESPONSE_BYTES = 8192; const FAT_TX_DATA_BYTES = 50_000; +// The stratus-native block DTO serializes hashes as byte arrays; convert them to hex for assertions. +const bytesToHex = (bytes: number[]) => "0x" + Buffer.from(bytes).toString("hex"); + +// The block DTO serializes block numbers as byte-order-swapped u32 (paired to_be/from_be serde in +// Rust, the same wire contract as `stratus_get_block_with_changes`); swap them back for assertions. +const swapU32 = (value: number) => + ((value & 0xff) << 24) | ((value & 0xff00) << 8) | ((value >>> 8) & 0xff00) | (value >>> 24); + describe("Pagination", () => { it("paginates oversized importer responses and keeps the follower syncing", async () => { // a fitting response is served normally, with no envelope, so old followers are unaffected @@ -20,6 +28,18 @@ describe("Pagination", () => { expect(small.stratus_paginated).to.be.undefined; expect(small.block.number).to.equal("0x1"); + // the stratus-native format serves the block DTO directly, still without envelope when it fits + const smallStratus = await send("stratus_getBlockAndReceipts", [ + earlyBlock.hash, + { offset: 0, format: "stratus" }, + ]); + expect(smallStratus.stratus_paginated).to.be.undefined; + expect(smallStratus.block).to.be.undefined; + expect(smallStratus.receipts).to.be.undefined; + expect(bytesToHex(smallStratus.header.hash)).to.equal(earlyBlock.hash); + expect(swapU32(smallStratus.header.number)).to.equal(1); + expect(smallStratus.transactions).to.be.an("array"); + // fat contract deployment: the code always fails, but the fat data makes the response oversized const nonce = await send("eth_getTransactionCount", [ALICE.address]); const signedTx = await ALICE.signer().signTransaction({ @@ -63,6 +83,39 @@ describe("Pagination", () => { expect(response.receipts).to.have.length(1); expect(response.receipts[0].transactionHash).to.equal(txHash); + // the same oversized block paginates identically in the stratus-native format, + // with the format field riding every chunk request + let stratusAssembled: Buffer = Buffer.alloc(0); + let stratusTotal = 0; + for ( + let offset = 0; + stratusTotal === 0 || stratusAssembled.length < stratusTotal; + offset = stratusAssembled.length + ) { + const envelope = await send("stratus_getBlockAndReceipts", [ + fatBlockHash, + { offset: offset, format: "stratus" }, + ]); + expect(envelope.stratus_paginated).to.not.be.undefined; + stratusTotal = envelope.stratus_paginated.total; + const chunk = Buffer.from(envelope.stratus_paginated.chunk, "base64"); + expect(chunk.length).to.be.greaterThan(0); + stratusAssembled = Buffer.concat([stratusAssembled, chunk]); + } + expect(stratusAssembled.length).to.equal(stratusTotal); + expect(stratusTotal).to.be.greaterThan(MAX_RESPONSE_BYTES, "the stratus response should be oversized"); + + // the reassembled stratus content has the block DTO shape, with receipts embedded + const stratusResponse = JSON.parse(stratusAssembled.toString("utf8")); + expect(stratusResponse.block).to.be.undefined; + expect(stratusResponse.receipts).to.be.undefined; + expect(bytesToHex(stratusResponse.header.hash)).to.equal(fatBlockHash); + expect(swapU32(stratusResponse.header.number)).to.equal(fatBlockNumber); + expect(stratusResponse.transactions).to.have.length(1); + expect(bytesToHex(stratusResponse.transactions[0].input.hash)).to.equal(txHash); + expect(stratusResponse.transactions[0].execution).to.not.be.undefined; + expect(stratusResponse.transactions[0].logs).to.not.be.undefined; + // the follower imports the fat block through the paginated importer await waitForFollowerBlock(fatBlockNumber); const followerReceipt = await rpcCall(FOLLOWER_URL, "eth_getTransactionReceipt", [txHash]); diff --git a/justfile b/justfile index d96db1865..e26db978a 100644 --- a/justfile +++ b/justfile @@ -314,7 +314,7 @@ e2e-leader *extra-args="": unset ENABLE_BLOCK_CHANGES_REPLICATION RUST_BACKTRACE=1 RUST_LOG=info just stratus-test --block-mode 1s --rocks-path-prefix=temp_3000 {{extra-args}} -e2e-follower test="brlc" use_block_changes_replication="false": +e2e-follower test="brlc" use_block_changes_replication="false" *extra-args="": #!/bin/bash # the binary reads the replication mode from the CLI flag; # `_e2e-leader-follower-up-impl` still exports ENABLE_BLOCK_CHANGES_REPLICATION for the mocha tests @@ -335,7 +335,7 @@ e2e-follower test="brlc" use_block_changes_replication="false": fi RUST_BACKTRACE=1 RUST_LOG=info just stratus-follower-test --rocks-path-prefix=temp_3001 $replication_flag --kafka-bootstrap-servers {{kafka_bootstrap_servers}} --kafka-topic stratus-events --kafka-client-id stratus-producer --kafka-security-protocol none else - RUST_BACKTRACE=1 RUST_LOG=info just stratus-follower-test --rocks-path-prefix=temp_3001 $replication_flag + RUST_BACKTRACE=1 RUST_LOG=info just stratus-follower-test --rocks-path-prefix=temp_3001 $replication_flag {{extra-args}} fi @@ -424,6 +424,26 @@ e2e-leader-follower-pagination: just e2e-leader-follower-down exit $exit_code +# E2E: Leader & Follower pagination, with the follower importing the stratus-native response format +# (stratus_getBlockAndReceipts with `format: "stratus"`) +e2e-leader-follower-pagination-stratus: + #!/bin/bash + + # leader with a small response limit, forcing oversized importer responses to be paginated + just e2e-leader --max-response-size-bytes 8192 + + # follower importing through the stratus-native response format + just e2e-follower test/follower/e2e-pagination-stratus.test.ts false --response-format stratus + + cd e2e + if [ ! -d node_modules ]; then npm install; fi + npx hardhat test test/follower/e2e-pagination-stratus.test.ts --network stratus --bail + exit_code=$? + cd .. + + just e2e-leader-follower-down + exit $exit_code + # E2E: Leader & Follower pagination in block changes replication mode (stratus_getBlockWithChanges) e2e-leader-follower-pagination-changes: #!/bin/bash diff --git a/src/config/loader.rs b/src/config/loader.rs index 0fbd8de3a..cb3981741 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -477,6 +477,7 @@ mod tests { async_threads = 4 forward_access_list = false stop_at_block = "0x2a" + response_format = "stratus" [kafka] bootstrap_servers = "localhost:29092" diff --git a/src/eth/executor/evm/types/output/transaction_execution.rs b/src/eth/executor/evm/types/output/transaction_execution.rs index af0e88363..4a555907c 100644 --- a/src/eth/executor/evm/types/output/transaction_execution.rs +++ b/src/eth/executor/evm/types/output/transaction_execution.rs @@ -1,4 +1,5 @@ use alloy_primitives::B256; +use alloy_primitives::U256; use derive_more::Deref; use derive_more::DerefMut; use display_json::DebugAsJson; @@ -17,6 +18,7 @@ use crate::eth::types::Address; use crate::eth::types::Bytes; use crate::eth::types::ExternalReceipt; use crate::eth::types::Gas; +use crate::eth::types::Hash; use crate::eth::types::Log; use crate::eth::types::StratusError; use crate::eth::types::Wei; @@ -256,6 +258,178 @@ impl TransactionExecutionOutput { } } + // ------------------------------------------------------------------------- + // Imported stratus transactions + // ------------------------------------------------------------------------- + + /// Creates an execution from an imported stratus transaction that failed. + /// + /// Mirrors [`Self::from_failed_external_transaction`]: failed transactions are not re-executed + /// (the local re-execution runs with max gas and zero gas price, which could make a + /// transaction that failed under the original conditions succeed), so the outcome is + /// recreated from the stored execution instead. + pub fn from_failed_imported_transaction(sender: Account, gas_price: Wei, stored: &TransactionExecutionResult) -> anyhow::Result { + if stored.result.is_success() { + return log_and_err!("cannot create failed execution for successful transaction"); + } + if not(stored.logs.is_empty()) { + return log_and_err!("failed transaction should not have produced logs"); + } + + // generate sender changes incrementing the nonce + let address = sender.address; + let mut sender_changes = AccountChanges::default(); + sender_changes.apply_original(sender); + let sender_next_nonce = sender_changes.nonce.next_nonce(); + + sender_changes.nonce.apply(sender_next_nonce); + let mut changes = State::default(); + changes.accounts.insert(address, sender_changes); + + // crete execution and apply costs + let mut execution = Self { + outcome: TransactionExecutionResult { + result: ExecutionResult::new_reverted("reverted externally".into()), // assume it reverted + output: Bytes::default(), // we cannot really know without performing an eth_call to the external system + logs: Vec::new(), + gas_used: stored.gas_used, + deployed_contract_address: None, + }, + state: changes, + }; + execution.apply_imported(stored, gas_price, address)?; + Ok(execution) + } + + /// Checks if current execution state matches the stored execution of an imported transaction. + /// + /// Mirrors [`Self::compare_with_receipt`], comparing against the stratus-native stored + /// execution instead of an alloy receipt. + pub fn compare_with_imported(&self, tx_hash: Hash, stored: &TransactionExecutionResult) -> anyhow::Result<()> { + // compare execution status + if self.is_success() != stored.result.is_success() { + return log_and_err!(format!( + "transaction status mismatch | hash={} execution={:?} imported={:?}", + tx_hash, self.result, stored.result + )); + } + + // compare logs length + if self.logs.len() != stored.logs.len() { + tracing::trace!(logs = ?self.logs, "execution logs"); + tracing::trace!(logs = ?stored.logs, "imported logs"); + return log_and_err!(format!( + "logs length mismatch | hash={} execution={} imported={}", + tx_hash, + self.logs.len(), + stored.logs.len() + )); + } + + // compare logs pairs + for (log_index, (execution_log, imported_log)) in self.logs.iter().zip(&stored.logs).enumerate() { + // compare log topics length + if execution_log.topics_non_empty().len() != imported_log.topics_non_empty().len() { + return log_and_err!(format!( + "log topics length mismatch | hash={} log_index={} execution={} imported={}", + tx_hash, + log_index, + execution_log.topics_non_empty().len(), + imported_log.topics_non_empty().len(), + )); + } + + // compare log topics content + for (topic_index, (execution_log_topic, imported_log_topic)) in + execution_log.topics_non_empty().iter().zip(imported_log.topics_non_empty().iter()).enumerate() + { + if execution_log_topic != imported_log_topic { + return log_and_err!(format!( + "log topic content mismatch | hash={} log_index={} topic_index={} execution={:#x} imported={:#x}", + tx_hash, log_index, topic_index, execution_log_topic.0, imported_log_topic.0, + )); + } + } + + // compare log data content + if execution_log.data.as_ref() != imported_log.data.as_ref() { + return log_and_err!(format!( + "log data content mismatch | hash={} log_index={} execution={} imported={}", + tx_hash, log_index, execution_log.data, imported_log.data, + )); + } + } + Ok(()) + } + + /// Imported transactions are re-executed locally with max gas and zero gas price. + /// + /// This causes some attributes to be different from the stored execution. + /// + /// This method updates the attributes that can diverge based on the stored execution, mirroring + /// [`Self::apply_receipt`]: `gas_price` and `sender` come from the imported transaction input. + pub fn apply_imported(&mut self, stored: &TransactionExecutionResult, gas_price: Wei, sender: Address) -> anyhow::Result<()> { + // fix gas + self.gas_used = stored.gas_used; + + // fix logs + self.fix_logs_gas_left_from_stored(&stored.logs); + + // fix sender balance + let execution_cost = Wei(gas_price.0 * U256::from(stored.gas_used.as_u64())); + + if execution_cost > Wei::ZERO { + // find sender changes + let Some(sender_changes) = self.state.accounts.get_mut(&sender) else { + return log_and_err!("sender changes not present in execution when applying execution costs"); + }; + + // subtract execution cost from sender balance + let sender_balance = *sender_changes.balance.value(); + + let sender_new_balance = if sender_balance > execution_cost { + sender_balance - execution_cost + } else { + Wei::ZERO + }; + sender_changes.balance.apply(sender_new_balance); + } + + Ok(()) + } + + /// Apply `gasLeft` values from the stored logs to the execution logs. + /// + /// Imported transactions are re-executed locally with a different amount of gas limit, so rely + /// on the stored logs to copy the `gasLeft` values, mirroring [`Self::fix_logs_gas_left`]. + fn fix_logs_gas_left_from_stored(&mut self, stored_logs: &[Log]) { + const ERC20_TRACE_EVENT_HASH: [u8; 32] = hex!("31738ac4a7c9a10ecbbfd3fed5037971ba81b8f6aa4f72a23f5364e9bc76d671"); + const BALANCE_TRACKER_TRACE_EVENT_HASH: [u8; 32] = hex!("63f1e32b72965e2be75e03024856287aff9e4cdbcec65869c51014fc2c1c95d9"); + + const EVENT_HASHES: [&[u8]; 2] = [&ERC20_TRACE_EVENT_HASH, &BALANCE_TRACKER_TRACE_EVENT_HASH]; + + for (execution_log, stored_log) in self.logs.iter_mut().zip(stored_logs) { + let execution_log_matches = || execution_log.topic0.is_some_and(|topic| EVENT_HASHES.contains(&topic.0.as_ref())); + let stored_log_matches = || stored_log.topic0.is_some_and(|topic| EVENT_HASHES.contains(&topic.0.as_ref())); + + // only try overwriting if both logs refer to the target event + let should_overwrite = execution_log_matches() && stored_log_matches(); + if !should_overwrite { + continue; + } + + let Some(source) = stored_log.data.as_ref().get(0..32) else { + continue; + }; + let mut data = execution_log.data.0.to_vec(); + let Some(destination) = data.get_mut(0..32) else { + continue; + }; + destination.copy_from_slice(source); + execution_log.data = Bytes::from(data); + } + } + fn parse_revm_result(result: RevmExecutionResult) -> (ExecutionResult, Bytes, Vec, Gas) { match result { RevmExecutionResult::Success { output, gas, logs, .. } => { @@ -659,4 +833,343 @@ mod tests { let modified_balance = *sender_changes.balance.value(); assert_eq!(modified_balance, Wei::from(900u64)); // 1000 - 100 } + + #[test] + fn test_from_failed_imported_transaction() { + // Create a mock sender account + let sender_address: Address = Faker.fake(); + let sender = Account { + address: sender_address, + nonce: Nonce::from(1u64), + balance: Wei::from(1000u64), + bytecode: None, + }; + + // Create a stored failed execution + let mut stored: TransactionExecutionResult = Faker.fake(); + stored.result = ExecutionResult::new_reverted("reverted externally".into()); + stored.logs = Vec::new(); + stored.gas_used = Gas::from(100u64); + + // Test the method + let gas_price = Wei::from(1u64); + let execution = TransactionExecutionOutput::from_failed_imported_transaction(sender.clone(), gas_price, &stored).unwrap(); + + // Verify execution state + assert!(execution.is_failure()); + assert_eq!(execution.output, Bytes::default()); + assert!(execution.logs.is_empty()); + assert_eq!(execution.gas_used, Gas::from(100u64)); + + // Verify sender changes + let sender_changes = execution.state.accounts.get(&sender_address).unwrap(); + + // Nonce should be incremented + let modified_nonce = *sender_changes.nonce.value(); + assert_eq!(modified_nonce, Nonce::from(2u64)); + + // Balance should be reduced by execution cost (gas price * gas used) + let modified_balance = *sender_changes.balance.value(); + assert_eq!(modified_balance, Wei::from(900u64)); // 1000 - 1 * 100 + + // Guard: successful stored executions are rejected + let mut success_stored = stored.clone(); + success_stored.result = ExecutionResult::Success; + assert!(TransactionExecutionOutput::from_failed_imported_transaction(sender, gas_price, &success_stored).is_err()); + } + + #[test] + fn test_compare_with_imported_matching_execution_ok() { + // Create a mock execution and stored execution with identical content + let mut log: Log = Faker.fake(); + log.topic0 = Some(B256::from([1u8; 32]).into()); + log.topic1 = None; + log.topic2 = None; + log.topic3 = None; + log.data = vec![1, 2, 3, 4].into(); + + let mut execution: TransactionExecutionOutput = Faker.fake(); + execution.result = ExecutionResult::Success; + execution.logs = vec![log.clone()]; + + let mut stored: TransactionExecutionResult = Faker.fake(); + stored.result = ExecutionResult::Success; + stored.logs = vec![log]; + + let tx_hash: Hash = Faker.fake(); + assert!(execution.compare_with_imported(tx_hash, &stored).is_ok()); + } + + #[test] + fn test_compare_with_imported_status_mismatch() { + // Create a mock execution (success) + let mut execution: TransactionExecutionOutput = Faker.fake(); + execution.result = ExecutionResult::Success; + + // Create a stored execution (failed) + let mut stored: TransactionExecutionResult = Faker.fake(); + stored.result = ExecutionResult::new_reverted("reverted externally".into()); + + // Verify comparison fails + let tx_hash: Hash = Faker.fake(); + assert!(execution.compare_with_imported(tx_hash, &stored).is_err()); + } + + #[test] + fn test_compare_with_imported_logs_length_mismatch() { + // Create a mock execution with logs + let mut execution: TransactionExecutionOutput = Faker.fake(); + execution.result = ExecutionResult::Success; + execution.logs = vec![Faker.fake(), Faker.fake()]; // Two logs + + // Create a stored execution with only one log + let mut stored: TransactionExecutionResult = Faker.fake(); + stored.result = ExecutionResult::Success; + stored.logs = vec![Faker.fake()]; // Only one log + + // Verify comparison fails + let tx_hash: Hash = Faker.fake(); + assert!(execution.compare_with_imported(tx_hash, &stored).is_err()); + } + + #[test] + fn test_compare_with_imported_log_topics_length_mismatch() { + // Create a mock log with two topics + let mut log1: Log = Faker.fake(); + log1.topic0 = Some(Faker.fake()); + log1.topic1 = Some(Faker.fake()); + log1.topic2 = None; + log1.topic3 = None; + + // Create a mock execution with that log + let mut execution: TransactionExecutionOutput = Faker.fake(); + execution.result = ExecutionResult::Success; + execution.logs = vec![log1]; + + // Create a stored log with only one topic + let mut stored_log: Log = Faker.fake(); + stored_log.topic0 = Some(Faker.fake()); + stored_log.topic1 = None; + stored_log.topic2 = None; + stored_log.topic3 = None; + + let mut stored: TransactionExecutionResult = Faker.fake(); + stored.result = ExecutionResult::Success; + stored.logs = vec![stored_log]; + + // Verify comparison fails + let tx_hash: Hash = Faker.fake(); + assert!(execution.compare_with_imported(tx_hash, &stored).is_err()); + } + + #[test] + fn test_compare_with_imported_topic_content_mismatch() { + // Create two genuinely different topics + let topic_value = B256::from([1u8; 32]); + let different_topic = B256::from([2u8; 32]); + + // Create a mock log with only topic0 set + let mut log1: Log = Faker.fake(); + log1.topic0 = Some(topic_value.into()); + log1.topic1 = None; + log1.topic2 = None; + log1.topic3 = None; + log1.data = vec![].into(); + + // Create execution with that log + let mut execution: TransactionExecutionOutput = Faker.fake(); + execution.result = ExecutionResult::Success; + execution.logs = vec![log1]; + + // Create a stored log with the same number of topics but different content + let mut stored_log: Log = Faker.fake(); + stored_log.topic0 = Some(different_topic.into()); + stored_log.topic1 = None; + stored_log.topic2 = None; + stored_log.topic3 = None; + stored_log.data = vec![].into(); + + let mut stored: TransactionExecutionResult = Faker.fake(); + stored.result = ExecutionResult::Success; + stored.logs = vec![stored_log]; + + // Verify comparison fails due to topic content mismatch + let tx_hash: Hash = Faker.fake(); + let err = execution.compare_with_imported(tx_hash, &stored).unwrap_err(); + assert!(err.to_string().contains("log topic content mismatch")); + } + + #[test] + fn test_compare_with_imported_data_content_mismatch() { + // Create a mock log with data + let mut log1: Log = Faker.fake(); + log1.topic0 = Some(Faker.fake()); + log1.data = vec![1, 2, 3, 4].into(); + + // Create execution with that log + let mut execution: TransactionExecutionOutput = Faker.fake(); + execution.result = ExecutionResult::Success; + execution.logs = vec![log1]; + + // Create a stored log with different data + let mut stored_log: Log = Faker.fake(); + stored_log.topic0 = Some(Faker.fake()); + stored_log.data = vec![5, 6, 7, 8].into(); + + let mut stored: TransactionExecutionResult = Faker.fake(); + stored.result = ExecutionResult::Success; + stored.logs = vec![stored_log]; + + // Verify comparison fails + let tx_hash: Hash = Faker.fake(); + assert!(execution.compare_with_imported(tx_hash, &stored).is_err()); + } + + #[test] + fn test_apply_imported() { + // Create a mock sender account with balance + let sender_address: Address = Faker.fake(); + let sender = Account { + address: sender_address, + nonce: Nonce::from(1u64), + balance: Wei::from(1000u64), + bytecode: None, + }; + + // Create a mock execution with the sender account in its state + let mut execution: TransactionExecutionOutput = Faker.fake(); + let mut sender_changes = AccountChanges::default(); + sender_changes.apply_original(sender); + let mut accounts = HashMap::with_hasher(foldhash::fast::RandomState::default()); + accounts.insert(sender_address, sender_changes); + execution.state = State { + accounts, + ..Default::default() + }; + execution.gas_used = Gas::from(100u64); + + // Create a stored execution with different gas + let mut stored: TransactionExecutionResult = Faker.fake(); + stored.gas_used = Gas::from(200u64); + stored.logs = Vec::new(); + + // Apply the stored execution + let gas_price = Wei::from(3u64); + execution.apply_imported(&stored, gas_price, sender_address).unwrap(); + + // Gas used should match the stored execution + assert_eq!(execution.gas_used, Gas::from(200u64)); + + // Sender balance should be reduced by gas price * stored gas (1000 - 3 * 200 = 400) + let sender_changes = execution.state.accounts.get(&sender_address).unwrap(); + let modified_balance = *sender_changes.balance.value(); + assert_eq!(modified_balance, Wei::from(400u64)); + } + + #[test] + fn test_apply_imported_balance_floors_at_zero() { + // Create a mock sender account with a balance smaller than the execution cost + let sender_address: Address = Faker.fake(); + let sender = Account { + address: sender_address, + nonce: Nonce::from(1u64), + balance: Wei::from(100u64), + bytecode: None, + }; + + // Create a mock execution with the sender account in its state + let mut execution: TransactionExecutionOutput = Faker.fake(); + let mut sender_changes = AccountChanges::default(); + sender_changes.apply_original(sender); + let mut accounts = HashMap::with_hasher(foldhash::fast::RandomState::default()); + accounts.insert(sender_address, sender_changes); + execution.state = State { + accounts, + ..Default::default() + }; + + // Stored execution with a cost far above the balance (3 * 200 = 600 > 100) + let mut stored: TransactionExecutionResult = Faker.fake(); + stored.gas_used = Gas::from(200u64); + stored.logs = Vec::new(); + + let gas_price = Wei::from(3u64); + execution.apply_imported(&stored, gas_price, sender_address).unwrap(); + + // Sender balance should floor at zero instead of underflowing + let sender_changes = execution.state.accounts.get(&sender_address).unwrap(); + let modified_balance = *sender_changes.balance.value(); + assert_eq!(modified_balance, Wei::ZERO); + } + + #[test] + fn test_fix_logs_gas_left_from_stored() { + // Set up test constants + const ERC20_TRACE_HASH: [u8; 32] = hex!("31738ac4a7c9a10ecbbfd3fed5037971ba81b8f6aa4f72a23f5364e9bc76d671"); + const BALANCE_TRACKER_TRACE_HASH: [u8; 32] = hex!("63f1e32b72965e2be75e03024856287aff9e4cdbcec65869c51014fc2c1c95d9"); + + // Create an execution with logs that have gasLeft values we want to override + let mut execution: TransactionExecutionOutput = Faker.fake(); + execution.result = ExecutionResult::Success; + + // Create an ERC20 Trace log with mock gasLeft value + let mut erc20_log: Log = Faker.fake(); + erc20_log.topic0 = Some(ERC20_TRACE_HASH.into()); + let execution_gas_left = vec![0u8; 32]; + let mut log_data = Vec::with_capacity(execution_gas_left.len() + 32); + log_data.extend_from_slice(&execution_gas_left); + log_data.extend_from_slice(&[99u8; 32]); + erc20_log.data = log_data.into(); + + // Create a Balance Tracker Trace log + let mut balance_log: Log = Faker.fake(); + balance_log.topic0 = Some(BALANCE_TRACKER_TRACE_HASH.into()); + let balance_gas_left = vec![0u8; 32]; + balance_log.data = balance_gas_left.into(); + + // Create a regular log (not one we're targeting) + let regular_log: Log = Faker.fake(); + + execution.logs = vec![erc20_log, balance_log, regular_log.clone()]; + + // Create stored logs with different gasLeft values + let receipt_erc20_gas_left = vec![42u8; 32]; + let mut stored_erc20_log: Log = Faker.fake(); + stored_erc20_log.topic0 = Some(ERC20_TRACE_HASH.into()); + let mut stored_erc20_data = Vec::with_capacity(receipt_erc20_gas_left.len() + 32); + stored_erc20_data.extend_from_slice(&receipt_erc20_gas_left); + stored_erc20_data.extend_from_slice(&[99u8; 32]); + stored_erc20_log.data = stored_erc20_data.into(); + + let receipt_balance_gas_left = vec![24u8; 32]; + let mut stored_balance_log: Log = Faker.fake(); + stored_balance_log.topic0 = Some(BALANCE_TRACKER_TRACE_HASH.into()); + stored_balance_log.data = receipt_balance_gas_left.clone().into(); + + // Regular stored log (topic0 will not match the target hashes) + let stored_regular_log: Log = Faker.fake(); + + let stored = TransactionExecutionResult { + result: ExecutionResult::Success, + output: Bytes::default(), + logs: vec![stored_erc20_log, stored_balance_log, stored_regular_log], + gas_used: Gas::default(), + deployed_contract_address: None, + }; + + // Apply the fix + execution.fix_logs_gas_left_from_stored(&stored.logs); + + // Verify the first 32 bytes of ERC20 log data was overwritten + let updated_erc20_data = execution.logs[0].data.as_ref(); + assert_eq!(&updated_erc20_data[0..32], &receipt_erc20_gas_left[..]); + // Rest of the data should remain unchanged + assert_eq!(&updated_erc20_data[32..], &[99u8; 32]); + + // Verify the first 32 bytes of Balance Tracker log data was overwritten + assert_eq!(execution.logs[1].data.as_ref()[..32].to_vec(), receipt_balance_gas_left); + + // Verify regular log data was not modified + assert_eq!(execution.logs[2].data, regular_log.data); + } } diff --git a/src/eth/executor/mod.rs b/src/eth/executor/mod.rs index 1aeae0268..72d206480 100644 --- a/src/eth/executor/mod.rs +++ b/src/eth/executor/mod.rs @@ -45,6 +45,7 @@ use crate::eth::storage::ExecutionKind; use crate::eth::storage::StorageError; use crate::eth::storage::StratusStorage; use crate::eth::types::Address; +use crate::eth::types::Block; use crate::eth::types::BlockNumber; use crate::eth::types::CallInput; use crate::eth::types::ExternalBlock; @@ -55,6 +56,8 @@ use crate::eth::types::Hash; use crate::eth::types::PointInTime; use crate::eth::types::StratusError; use crate::eth::types::TransactionInput; +use crate::eth::types::TransactionMined; +use crate::eth::types::Wei; #[cfg(feature = "metrics")] use crate::ext::OptionExt; use crate::ext::to_json_string; @@ -220,6 +223,139 @@ impl Executor { Ok(()) } + // ------------------------------------------------------------------------- + // Imported stratus transactions + // ------------------------------------------------------------------------- + + /// Reexecutes an imported stratus block locally and imports it to the temporary storage. + /// + /// Mirrors [`Executor::execute_external_block`], but the block is already in the stratus-native + /// format, so the receipt data is embedded in each [`TransactionMined`] execution result. + #[timed(executor_imported_block)] + pub fn execute_imported_block(&self, mut block: Block) -> anyhow::Result<()> { + #[cfg(feature = "tracing")] + let _span = info_span!("executor::imported_block", block_number = %block.number()).entered(); + tracing::info!(block_number = %block.number(), "reexecuting imported block"); + + // track pending block + self.storage.set_pending_header(block.number(), block.timestamp()); + let block_number = block.number(); + let block_transactions = mem::take(&mut block.transactions); + + // determine how to execute each transaction + for tx in block_transactions { + self.execute_imported_transaction(tx, block_number)?; + } + + Ok(()) + } + + /// Reexecutes an imported stratus transaction locally ensuring it produces the same output. + #[timed(executor_imported_transaction, labels( + contract = |tx| codegen::contract_name(&tx.execution.input.to), + function = |tx| codegen::function_sig(tx.execution.input.data.as_ref()) + ) + )] + fn execute_imported_transaction(&self, tx: TransactionMined, block_number: BlockNumber) -> anyhow::Result<()> { + #[cfg(feature = "tracing")] + let _span = info_span!("executor::imported_transaction", tx_hash = %tx.execution.info.hash).entered(); + tracing::info!(%block_number, tx_hash = %tx.execution.info.hash, "reexecuting imported transaction"); + + self.transaction_worker.execute_imported_transaction(tx, block_number) + } + + fn execute_imported_transaction_inner( + storage: &StratusStorage, + miner: &Miner, + evm: &mut Evm, + tx: TransactionMined, + block_number: BlockNumber, + ) -> anyhow::Result<()> { + // deconstruct the stored transaction into its parts + let TransactionExecution { + info, + signature, + input: stored_input, + output: stored_output, + } = tx.execution; + let tx_hash = info.hash; + + // rebuild the transaction input from the stored fields to recover the signer + let tx_input = TransactionInput { + transaction_info: info, + execution_info: stored_input.clone().into(), + signature, + }; + + // rederive the signer from the saved signature so the follower executes with the same + // address the leader derived from the same saved fields (mirrors the external path) + let recovered = tx_input.recover_signer_address()?; + + // reuse the stored execution input, overriding the sender with the recovered signer + let mut evm_input = stored_input; + evm_input.from = recovered; + let gas_price = Wei::from(evm_input.gas_price); + + // when the stored transaction failed, create fake transaction instead of reexecuting + let (tx_execution, state) = match stored_output.result.is_success() { + // successful imported transaction, re-execute locally + true => { + // re-execute transaction + let evm_execution = evm + .execute(evm_input.clone()) + .and_then(|(result, metrics)| Ok((TransactionExecutionOutput::try_from(result)?, metrics))); + + // handle re-execution result + let (mut evm_result, _evm_metrics) = match evm_execution { + Ok((evm_result, evm_metrics)) => (evm_result, evm_metrics), + Err(e) => { + let json_tx = to_json_string(&tx_input); + let json_stored = to_json_string(&stored_output); + tracing::error!(reason = ?e, %block_number, tx_hash = %tx_hash, %json_tx, %json_stored, "failed to reexecute imported transaction"); + return Err(e.into()); + } + }; + + // update execution with the stored execution + evm_result.apply_imported(&stored_output, gas_price, recovered)?; + + // ensure it matches the stored execution before saving + if let Err(e) = evm_result.compare_with_imported(tx_hash, &stored_output) { + let json_tx = to_json_string(&tx_input); + let json_stored = to_json_string(&stored_output); + let json_execution_logs = to_json_string(&evm_result.logs); + tracing::error!(reason = ?e, %block_number, tx_hash = %tx_hash, %json_tx, %json_stored, %json_execution_logs, "failed to reexecute imported transaction"); + return Err(e); + }; + + (TransactionExecution::new(info, signature, evm_input, evm_result.outcome), evm_result.state) + } + // + // failed imported transaction, re-create from the stored execution without re-executing + false => { + let (sender, _) = storage.read_account(recovered, ExecutionKind::Transaction)?; + if tx_input.execution_info.nonce != sender.nonce { + bail!( + "reverted imported transaction should have the correct nonce. address: {:?}, input: {:?}, sender: {:?}", + tx_input.signer(), + tx_input.execution_info.nonce, + sender.nonce + ); + } + let evm_result = TransactionExecutionOutput::from_failed_imported_transaction(sender, gas_price, &stored_output)?; + + evm_input.gas_limit = tx_input.execution_info.gas_limit; + evm_input.gas_price = tx_input.execution_info.gas_price; + + (TransactionExecution::new(info, signature, evm_input, evm_result.outcome), evm_result.state) + } + }; + + // persist state + miner.save_execution(tx_execution, state)?; + Ok(()) + } + // ------------------------------------------------------------------------- // Local transactions // ------------------------------------------------------------------------- diff --git a/src/eth/executor/transaction_worker.rs b/src/eth/executor/transaction_worker.rs index 81cae68df..045e30932 100644 --- a/src/eth/executor/transaction_worker.rs +++ b/src/eth/executor/transaction_worker.rs @@ -28,6 +28,7 @@ use crate::eth::types::ExternalReceipt; use crate::eth::types::ExternalTransaction; use crate::eth::types::StratusError; use crate::eth::types::TransactionInput; +use crate::eth::types::TransactionMined; use crate::eth::types::UnexpectedError; use crate::ext::spawn_thread; use crate::infra::tracing::warn_task_tx_closed; @@ -35,6 +36,7 @@ use crate::infra::tracing::warn_task_tx_closed; const TASK_NAME: &str = "evm-tx-1"; type ExternalTransactionResult = anyhow::Result<()>; +type ImportedTransactionResult = anyhow::Result<()>; type LocalTransactionResult = Result; type LocalTransactionResponse = (Duration, LocalTransactionResult); @@ -79,6 +81,18 @@ impl TransactionWorker { } } + /// Reexecutes and persists an imported stratus transaction. + pub fn execute_imported_transaction(&self, tx: TransactionMined, block_number: BlockNumber) -> ImportedTransactionResult { + let (response_tx, response_rx) = oneshot::channel(); + self.task_tx + .send(TransactionTask::imported(tx, block_number, response_tx)) + .map_err(StratusError::from)?; + match response_rx.recv() { + Ok(result) => result, + Err(_) => Err(StratusError::from(UnexpectedError::ChannelClosed { channel: "evm" }).into()), + } + } + /// Executes and persists a local transaction, retrying state conflicts. #[timed(executor_local_transaction, labels( success = result.is_ok(), @@ -172,6 +186,17 @@ impl TransactionTask { } } + fn imported(tx: TransactionMined, block_number: BlockNumber, response_tx: oneshot::Sender) -> Self { + Self { + span: Span::current(), + kind: TransactionTaskKind::Imported { + tx: Box::new(tx), + block_number, + response_tx, + }, + } + } + fn execute(self, storage: &StratusStorage, miner: &Miner, evm: &mut Evm) -> anyhow::Result<(), StratusError> { let Self { span, kind } = self; let _enter = span.enter(); @@ -196,6 +221,12 @@ impl TransactionTask { tracing::error!(reason = ?e, "failed to send local transaction execution result"); } } + TransactionTaskKind::Imported { tx, block_number, response_tx } => { + let result = Executor::execute_imported_transaction_inner(storage, miner, evm, *tx, block_number); + if let Err(e) = response_tx.send(result) { + tracing::error!(reason = ?e, "failed to send imported transaction execution result"); + } + } })) .map_err(|err| ExecutorError::Panic { err: anyhow!("{err:?}") }.into()) } @@ -212,4 +243,9 @@ enum TransactionTaskKind { tx_input: Box, response_tx: oneshot::Sender, }, + Imported { + tx: Box, + block_number: BlockNumber, + response_tx: oneshot::Sender, + }, } diff --git a/src/eth/follower/importer/config.rs b/src/eth/follower/importer/config.rs index 885f58857..6f48c0518 100644 --- a/src/eth/follower/importer/config.rs +++ b/src/eth/follower/importer/config.rs @@ -17,6 +17,7 @@ use crate::eth::follower::importer::ImporterRuntimeConfig; use crate::eth::follower::importer::supervisor::ImporterConsensus; use crate::eth::miner::Miner; use crate::eth::rpc::RpcContext; +use crate::eth::rpc::pagination::ResponseFormat; use crate::eth::storage::StratusStorage; use crate::eth::types::BlockNumber; use crate::eth::types::StateError; @@ -79,6 +80,12 @@ pub struct ImporterConfig { /// Specify the block to stop importing. (useful for validating a follower db against a fake leader) #[arg(id = "importer.stop_at_block", long = "stop-at-block")] pub stop_at_block: Option, + + /// Response format used by the block-and-receipts importer RPC (importer online). Use + /// "stratus" to request stratus-native serialization (issue #2709), or "alloy" for the legacy + /// alloy-based serialization. + #[arg(id = "importer.response_format", long = "response-format", default_value = "alloy", required = false)] + pub response_format: ResponseFormat, } impl ImporterConfig { @@ -139,6 +146,7 @@ impl ImporterConfig { let importer_runtime = ImporterRuntime::start(ImporterRuntimeConfig { async_threads: self.importer_async_threads, importer_mode, + response_format: self.response_format, external_rpc: external_rpc.to_string(), external_rpc_ws: self.external_rpc_ws.clone(), external_rpc_timeout: self.external_rpc_timeout, diff --git a/src/eth/follower/importer/fetchers/block_with_receipts.rs b/src/eth/follower/importer/fetchers/block_with_receipts.rs index e710a75bb..7e26c2480 100644 --- a/src/eth/follower/importer/fetchers/block_with_receipts.rs +++ b/src/eth/follower/importer/fetchers/block_with_receipts.rs @@ -7,68 +7,114 @@ use anyhow::bail; use crate::eth::follower::importer::BlockchainClient; use crate::eth::follower::importer::fetch_with_retry; use crate::eth::follower::importer::fetchers::DataFetcher; +use crate::eth::rpc::pagination::ResponseFormat; +use crate::eth::types::Block; use crate::eth::types::BlockNumber; use crate::eth::types::ExternalBlock; use crate::eth::types::ExternalReceipt; +/// Block with receipts fetched from the leader, in either supported response format. +/// +/// The variants hold large transient payloads that are consumed immediately, so the size +/// difference is harmless. +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Clone)] +pub enum FetchedBlockWithReceipts { + /// Legacy alloy format: block and receipts as separate alloy RPC types. + Alloy { block: ExternalBlock, receipts: Vec }, + + /// Stratus-native format: block with receipts embedded, deserialized from the storage DTO. + Stratus(Block), +} + +impl FetchedBlockWithReceipts { + /// Returns the block number, regardless of the format it was fetched in. + pub fn block_number(&self) -> BlockNumber { + match self { + Self::Alloy { block, .. } => block.number(), + Self::Stratus(block) => block.number(), + } + } + + /// Returns the number of transactions (and therefore of receipts), regardless of the format. + pub fn receipts_len(&self) -> usize { + match self { + Self::Alloy { receipts, .. } => receipts.len(), + Self::Stratus(block) => block.transactions.len(), + } + } +} + pub struct BlockWithReceiptsFetcher { pub chain: Arc, + pub response_format: ResponseFormat, } impl DataFetcher for BlockWithReceiptsFetcher { - type FetchedType = (ExternalBlock, Vec); - type PostProcessType = (ExternalBlock, Vec); + type FetchedType = FetchedBlockWithReceipts; + type PostProcessType = FetchedBlockWithReceipts; async fn fetch(&self, block_number: BlockNumber) -> Self::FetchedType { let fetch_fn = |bn| { let chain = Arc::clone(&self.chain); - async move { - chain - .fetch_block_and_receipts(bn) - .await - .map(|opt| opt.map(|response| (response.block, response.receipts))) - } + let response_format = self.response_format; + async move { chain.fetch_block_and_receipts(bn, response_format).await } }; fetch_with_retry(block_number, fetch_fn, "block and receipts").await } async fn post_process(&self, data: Self::FetchedType) -> anyhow::Result { - let (mut block, mut receipts) = data; - let block_number = block.number(); - let BlockTransactions::Full(transactions) = &mut block.transactions else { - bail!("expected full transactions, got hashes or uncle"); - }; + match data { + FetchedBlockWithReceipts::Alloy { mut block, mut receipts } => { + let block_number = block.number(); + let BlockTransactions::Full(transactions) = &mut block.transactions else { + bail!("expected full transactions, got hashes or uncle"); + }; - if transactions.len() != receipts.len() { - bail!( - "block {} has mismatched transaction and receipt length: {} transactions but {} receipts", - block_number, - transactions.len(), - receipts.len() - ); - } + if transactions.len() != receipts.len() { + bail!( + "block {} has mismatched transaction and receipt length: {} transactions but {} receipts", + block_number, + transactions.len(), + receipts.len() + ); + } - // Stably sort transactions and receipts by transaction_index - transactions.sort_by_key(|a| a.transaction_index); - receipts.sort_by_key(|a| a.transaction_index); + // Stably sort transactions and receipts by transaction_index + transactions.sort_by_key(|a| a.transaction_index); + receipts.sort_by_key(|a| a.transaction_index); - // perform additional checks on the transaction index - for window in transactions.windows(2) { - let tx_index = window[0].transaction_index.ok_or(anyhow!("missing transaction index"))? as u32; - let next_tx_index = window[1].transaction_index.ok_or(anyhow!("missing transaction index"))? as u32; - if tx_index + 1 != next_tx_index { - tracing::error!(tx_index, next_tx_index, "two consecutive transactions must have consecutive indices"); + // perform additional checks on the transaction index + for window in transactions.windows(2) { + let tx_index = window[0].transaction_index.ok_or(anyhow!("missing transaction index"))? as u32; + let next_tx_index = window[1].transaction_index.ok_or(anyhow!("missing transaction index"))? as u32; + if tx_index + 1 != next_tx_index { + tracing::error!(tx_index, next_tx_index, "two consecutive transactions must have consecutive indices"); + } + } + for window in receipts.windows(2) { + let tx_index = window[0].transaction_index.ok_or(anyhow!("missing transaction index"))? as u32; + let next_tx_index = window[1].transaction_index.ok_or(anyhow!("missing transaction index"))? as u32; + if tx_index + 1 != next_tx_index { + tracing::error!(tx_index, next_tx_index, "two consecutive receipts must have consecutive indices"); + } + } + + Ok(FetchedBlockWithReceipts::Alloy { block, receipts }) } - } - for window in receipts.windows(2) { - let tx_index = window[0].transaction_index.ok_or(anyhow!("missing transaction index"))? as u32; - let next_tx_index = window[1].transaction_index.ok_or(anyhow!("missing transaction index"))? as u32; - if tx_index + 1 != next_tx_index { - tracing::error!(tx_index, next_tx_index, "two consecutive receipts must have consecutive indices"); + FetchedBlockWithReceipts::Stratus(block) => { + // transactions are stored in order, so no sorting is needed; only check indices + for window in block.transactions.windows(2) { + let tx_index = window[0].mined_data.index.0; + let next_tx_index = window[1].mined_data.index.0; + if tx_index + 1 != next_tx_index { + tracing::error!(tx_index, next_tx_index, "two consecutive transactions must have consecutive indices"); + } + } + + Ok(FetchedBlockWithReceipts::Stratus(block)) } } - - Ok((block, receipts)) } } diff --git a/src/eth/follower/importer/importers/blockchain_client.rs b/src/eth/follower/importer/importers/blockchain_client.rs index 4a24c36d6..db84a9823 100644 --- a/src/eth/follower/importer/importers/blockchain_client.rs +++ b/src/eth/follower/importer/importers/blockchain_client.rs @@ -22,7 +22,9 @@ use crate::alias::AlloyTransaction; use crate::alias::JsonValue; use crate::eth::executor::AccessListOutput; use crate::eth::executor::ExecutorError; +use crate::eth::follower::importer::fetchers::block_with_receipts::FetchedBlockWithReceipts; use crate::eth::rpc::pagination; +use crate::eth::rpc::pagination::ResponseFormat; use crate::eth::storage::permanent::rocks::types::BlockChangesRocksdb; use crate::eth::storage::permanent::rocks::types::BlockRocksdb; use crate::eth::types::Address; @@ -192,22 +194,57 @@ impl BlockchainClient { /// Sends the pagination capability parameter so a pagination-aware leader can split /// responses that do not fit in a single message (see `eth::rpc::pagination`). Old leaders /// ignore the extra parameter and answer normally, which is handled transparently. - async fn request_importer_data(&self, method: &'static str, block_number: BlockNumber) -> anyhow::Result> { - let Some(full) = self.fetch_serialized_response(method, block_number).await? else { + /// + /// The `format` parameter, when set, rides the pagination object so the leader serializes + /// every chunk of the response in the requested format. + async fn request_importer_data( + &self, + method: &'static str, + block_number: BlockNumber, + format: Option, + ) -> anyhow::Result> { + let Some(full) = self.fetch_serialized_response(method, block_number, format).await? else { return Ok(None); // block not available yet }; - let value = serde_json::from_str(full.get()).with_context(|| format!("failed to deserialize importer data from {method}"))?; + let value = match serde_json::from_str::(full.get()) { + Ok(value) => value, + Err(e) if format == Some(ResponseFormat::Stratus) && Self::is_legacy_alloy_response(full.get()) => { + // A leader that does not know the format parameter ignores it and answers with + // the legacy alloy shape. Surface the cause instead of a cryptic missing-field + // deserialization error (the original error `e` is discarded on purpose: the + // shape check below pinpoints the cause). + let _ = e; + tracing::error!(method, "leader answered the stratus format request with the legacy alloy format"); + anyhow::bail!( + "leader answered the stratus format request with the legacy alloy format, \ + which means it likely runs an old version without stratus response format support; \ + set importer response_format back to alloy or upgrade the leader" + ); + } + Err(e) => return Err(e).with_context(|| format!("failed to deserialize importer data from {method}")), + }; Ok(Some(value)) } + /// Checks whether a response has the legacy alloy shape (top-level `block` and `receipts` + /// objects) instead of the stratus DTO shape (top-level `header` and `transactions` objects). + fn is_legacy_alloy_response(response: &str) -> bool { + serde_json::from_str::(response).is_ok_and(|value| value.get("block").is_some() && value.get("receipts").is_some()) + } + /// Fetches the full serialized response for an importer method, reassembling pagination chunks. - async fn fetch_serialized_response(&self, method: &'static str, block_number: BlockNumber) -> anyhow::Result>> { + async fn fetch_serialized_response( + &self, + method: &'static str, + block_number: BlockNumber, + format: Option, + ) -> anyhow::Result>> { tracing::debug!(%block_number, method, "fetching importer data"); let number = to_json_value(block_number); // first request from offset zero - let params = [number.clone(), pagination::request_params(0)]; + let params = [number.clone(), pagination::request_params(0, format)]; let result = self.http.request::>, _>(method, params).await; let raw = match result { Ok(Some(raw)) => raw, @@ -244,7 +281,7 @@ impl BlockchainClient { } // next chunk from the current offset - let params = [number.clone(), pagination::request_params(reassembler.next_offset())]; + let params = [number.clone(), pagination::request_params(reassembler.next_offset(), format)]; let result = self.http.request::, _>(method, params).await; let raw = match result { Ok(raw) => raw, @@ -263,10 +300,37 @@ impl BlockchainClient { } /// Fetches a block by number with receipts. - pub async fn fetch_block_and_receipts(&self, block_number: BlockNumber) -> anyhow::Result> { - tracing::debug!(%block_number, "fetching block"); + /// + /// The response is deserialized according to the requested `response_format`: the legacy + /// alloy format (block and receipts as alloy RPC types) or the stratus-native format + /// (block with embedded receipts from the storage DTO). The format also rides the request + /// parameters so the leader serializes the response accordingly. + pub async fn fetch_block_and_receipts( + &self, + block_number: BlockNumber, + response_format: ResponseFormat, + ) -> anyhow::Result> { + tracing::debug!(%block_number, %response_format, "fetching block"); + + const METHOD: &str = "stratus_getBlockAndReceipts"; + + let result = match response_format { + ResponseFormat::Alloy => self + .request_importer_data::(METHOD, block_number, Some(response_format)) + .await + .map(|block| { + block.map(|response| FetchedBlockWithReceipts::Alloy { + block: response.block, + receipts: response.receipts, + }) + }), + ResponseFormat::Stratus => self + .request_importer_data::(METHOD, block_number, Some(response_format)) + .await + .map(|block| block.map(|block| FetchedBlockWithReceipts::Stratus(block.into()))), + }; - match self.request_importer_data("stratus_getBlockAndReceipts", block_number).await { + match result { Ok(block) => Ok(block), Err(e) => log_and_err!(reason = e, "failed to fetch block with receipts"), } @@ -276,7 +340,7 @@ impl BlockchainClient { pub async fn fetch_block_with_changes(&self, block_number: BlockNumber) -> anyhow::Result> { tracing::debug!(%block_number, "fetching block with changes"); - match self.request_importer_data("stratus_getBlockWithChanges", block_number).await { + match self.request_importer_data("stratus_getBlockWithChanges", block_number, None).await { Ok(block) => Ok(block), Err(e) => log_and_err!(reason = e, "failed to fetch block with changes"), } diff --git a/src/eth/follower/importer/importers/execution.rs b/src/eth/follower/importer/importers/execution.rs index 2819c83d9..f12c25c8b 100644 --- a/src/eth/follower/importer/importers/execution.rs +++ b/src/eth/follower/importer/importers/execution.rs @@ -4,13 +4,12 @@ use stratus_metrics::timed; use crate::GlobalState; use crate::eth::executor::Executor; +use crate::eth::follower::importer::fetchers::block_with_receipts::FetchedBlockWithReceipts; use crate::eth::follower::importer::importers::ImportData; use crate::eth::follower::importer::importers::ImporterWorker; use crate::eth::follower::importer::send_block_to_kafka; use crate::eth::miner::Miner; use crate::eth::miner::miner::CommitItem; -use crate::eth::types::ExternalBlock; -use crate::eth::types::ExternalReceipt; use crate::eth::types::ExternalReceipts; use crate::infra::kafka::KafkaConnector; use crate::log_and_err; @@ -23,32 +22,53 @@ pub struct ReexecutionWorker { impl ImportData for ::DataType { fn block_number(&self) -> crate::eth::types::BlockNumber { - self.0.number() + self.block_number() } } impl ImporterWorker for ReexecutionWorker { - type DataType = (ExternalBlock, Vec); + type DataType = FetchedBlockWithReceipts; #[timed(import_online_mined_block)] - async fn import(&self, (block, receipts): Self::DataType) -> anyhow::Result { + async fn import(&self, block: Self::DataType) -> anyhow::Result { const TASK_NAME: &str = "block-executor"; - let receipts_len = receipts.len(); + let receipts_len = block.receipts_len(); - if let Err(e) = self.executor.execute_external_block(block.clone(), ExternalReceipts::from(receipts)) { - let message = GlobalState::shutdown_from(TASK_NAME, "failed to reexecute external block"); - return log_and_err!(reason = e, message); - }; + let (mined_block, changes) = match block { + FetchedBlockWithReceipts::Alloy { block, receipts } => { + if let Err(e) = self.executor.execute_external_block(block.clone(), ExternalReceipts::from(receipts)) { + let message = GlobalState::shutdown_from(TASK_NAME, "failed to reexecute external block"); + return log_and_err!(reason = e, message); + }; - let (mined_block, changes) = match self.miner.mine_external(block) { - Ok((mined_block, changes)) => { - tracing::info!(number = %mined_block.number(), "mined external block"); - (mined_block, changes) + match self.miner.mine_external(block) { + Ok((mined_block, changes)) => { + tracing::info!(number = %mined_block.number(), "mined external block"); + (mined_block, changes) + } + Err(e) => { + let message = GlobalState::shutdown_from(TASK_NAME, "failed to mine external block"); + return log_and_err!(reason = e, message); + } + } } - Err(e) => { - let message = GlobalState::shutdown_from(TASK_NAME, "failed to mine external block"); - return log_and_err!(reason = e, message); + FetchedBlockWithReceipts::Stratus(block) => { + if let Err(e) = self.executor.execute_imported_block(block.clone()) { + let message = GlobalState::shutdown_from(TASK_NAME, "failed to reexecute imported block"); + return log_and_err!(reason = e, message); + }; + + match self.miner.mine_imported(block) { + Ok((mined_block, changes)) => { + tracing::info!(number = %mined_block.number(), "mined imported block"); + (mined_block, changes) + } + Err(e) => { + let message = GlobalState::shutdown_from(TASK_NAME, "failed to mine imported block"); + return log_and_err!(reason = e, message); + } + } } }; diff --git a/src/eth/follower/importer/importers/fake_leader.rs b/src/eth/follower/importer/importers/fake_leader.rs index 412419f90..1c47048fe 100644 --- a/src/eth/follower/importer/importers/fake_leader.rs +++ b/src/eth/follower/importer/importers/fake_leader.rs @@ -6,7 +6,9 @@ use stratus_metrics::timed; use crate::GlobalState; use crate::eth::executor::Executor; use crate::eth::executor::ExecutorError; +use crate::eth::executor::types::TransactionExecution; use crate::eth::follower::importer::fetchers::DataFetcher; +use crate::eth::follower::importer::fetchers::block_with_receipts::FetchedBlockWithReceipts; use crate::eth::follower::importer::fetchers::fake_leader::FakeLeaderFetcher; use crate::eth::follower::importer::importers::ImportData; use crate::eth::follower::importer::importers::ImporterWorker; @@ -14,6 +16,7 @@ use crate::eth::miner::Miner; use crate::eth::miner::miner::interval_miner::commit_retry; use crate::eth::storage::StratusStorage; use crate::eth::types::StratusError; +use crate::eth::types::TransactionInput; pub struct FakeLeaderWorker { pub executor: Arc, @@ -31,12 +34,50 @@ impl ImporterWorker for FakeLeaderWorker { type DataType = ::PostProcessType; #[timed(import_online_mined_block)] - async fn import(&self, ((block, _), (expected_block, expected_changes)): Self::DataType) -> anyhow::Result { - let block_tx_len = block.transactions.len(); - self.storage.set_pending_from_external(&block); - for tx in block.0.transactions.into_transactions() { + async fn import(&self, (fetched, (expected_block, expected_changes)): Self::DataType) -> anyhow::Result { + // prepare the block for local reexecution depending on the fetched response format + let (block_tx_len, transactions) = match fetched { + FetchedBlockWithReceipts::Alloy { block, .. } => { + let block_tx_len = block.transactions.len(); + self.storage.set_pending_from_external(&block); + let transactions = block + .0 + .transactions + .into_transactions() + .map(|tx| tx.try_into()) + .collect::, _>>()?; + (block_tx_len, transactions) + } + FetchedBlockWithReceipts::Stratus(mut block) => { + let block_tx_len = block.transactions.len(); + self.storage.set_pending_header(block.number(), block.timestamp()); + let transactions = std::mem::take(&mut block.transactions) + .into_iter() + .map(|tx| -> anyhow::Result { + // rebuild the transaction input from the stored execution, recovering the + // signer the same way the reexecution importer does + let TransactionExecution { + info, + signature, + input: stored_input, + output: _, + } = tx.execution; + let tx_input = TransactionInput { + transaction_info: info, + execution_info: stored_input.into(), + signature, + }; + tx_input.recover_signer_address()?; + Ok(tx_input) + }) + .collect::>>()?; + (block_tx_len, transactions) + } + }; + + for tx in transactions { tracing::info!(?tx, "executing tx as fake miner"); - if let Err(e) = self.executor.execute_local_transaction(tx.try_into()?, None) { + if let Err(e) = self.executor.execute_local_transaction(tx, None) { match e { StratusError::Executor(ExecutorError::Nonce { transaction: _, account: _ }) => { tracing::warn!(reason = ?e, "transaction failed, was this node restarted?"); diff --git a/src/eth/follower/importer/mod.rs b/src/eth/follower/importer/mod.rs index cf0ffa68a..5f3fe489b 100644 --- a/src/eth/follower/importer/mod.rs +++ b/src/eth/follower/importer/mod.rs @@ -12,6 +12,7 @@ use std::time::Duration; use anyhow::bail; pub use config::ImporterConfig; +pub use fetchers::block_with_receipts::FetchedBlockWithReceipts; pub use importers::BlockchainClient; pub use runtime::ImporterRuntime; pub use runtime::ImporterRuntimeConfig; @@ -159,18 +160,21 @@ async fn start_number_fetcher(chain: Arc, sync_interval: Durat set_external_rpc_current_block(block.number()); continue; } - Ok(None) => + Ok(None) => { if !should_shutdown(TASK_NAME) { tracing::error!("{} newHeads subscription closed by the other side", TASK_NAME); - }, - Ok(Some(Err(e))) => + } + } + Ok(Some(Err(e))) => { if !should_shutdown(TASK_NAME) { tracing::error!(reason = ?e, "{} failed to read newHeads subscription event", TASK_NAME); - }, - Err(_) => + } + } + Err(_) => { if !should_shutdown(TASK_NAME) { tracing::error!("{} timed-out waiting for newHeads subscription event", TASK_NAME); - }, + } + } } if should_shutdown(TASK_NAME) { @@ -186,10 +190,11 @@ async fn start_number_fetcher(chain: Arc, sync_interval: Durat tracing::info!("{} resubscribed to newHeads event", TASK_NAME); sub_new_heads = Some(sub); } - Err(e) => + Err(e) => { if !should_shutdown(TASK_NAME) { tracing::error!(reason = ?e, "{} failed to resubscribe to newHeads event", TASK_NAME); - }, + } + } } } } @@ -210,10 +215,11 @@ async fn start_number_fetcher(chain: Arc, sync_interval: Durat set_external_rpc_current_block(block_number); traced_sleep(sync_interval, SleepReason::SyncData).await; } - Err(e) => + Err(e) => { if !should_shutdown(TASK_NAME) { tracing::error!(reason = ?e, "failed to retrieve block number. retrying now."); - }, + } + } } } } diff --git a/src/eth/follower/importer/runtime.rs b/src/eth/follower/importer/runtime.rs index e9592f917..5ee9e6329 100644 --- a/src/eth/follower/importer/runtime.rs +++ b/src/eth/follower/importer/runtime.rs @@ -10,6 +10,7 @@ use crate::eth::follower::importer::BlockchainClient; use crate::eth::follower::importer::ImporterMode; use crate::eth::follower::importer::supervisor::start_importer; use crate::eth::miner::Miner; +use crate::eth::rpc::pagination::ResponseFormat; use crate::eth::storage::StratusStorage; use crate::eth::types::BlockNumber; use crate::infra::kafka::KafkaConnector; @@ -23,6 +24,7 @@ pub struct ImporterRuntime { pub struct ImporterRuntimeConfig { pub async_threads: usize, pub importer_mode: ImporterMode, + pub response_format: ResponseFormat, pub external_rpc: String, pub external_rpc_ws: Option, pub external_rpc_timeout: Duration, @@ -111,6 +113,7 @@ fn run_importer_runtime(config: ImporterRuntimeConfig, shutdown: CancellationTok tokio::select! { result = start_importer( config.importer_mode, + config.response_format, config.storage, config.executor, config.miner, diff --git a/src/eth/follower/importer/supervisor.rs b/src/eth/follower/importer/supervisor.rs index cab6dc60e..87a03f7e0 100644 --- a/src/eth/follower/importer/supervisor.rs +++ b/src/eth/follower/importer/supervisor.rs @@ -26,6 +26,7 @@ use crate::eth::follower::importer::importers::fake_leader::FakeLeaderWorker; use crate::eth::follower::importer::importers::replication::ReplicationWorker; use crate::eth::follower::importer::start_number_fetcher; use crate::eth::miner::Miner; +use crate::eth::rpc::pagination::ResponseFormat; use crate::eth::storage::StratusStorage; use crate::eth::types::BlockNumber; use crate::ext::spawn; @@ -46,25 +47,37 @@ where } impl ReexecutionFollower { - fn new(executor: Arc, miner: Arc, chain: Arc, kafka_connector: Option) -> Self { + fn new( + executor: Arc, + miner: Arc, + chain: Arc, + kafka_connector: Option, + response_format: ResponseFormat, + ) -> Self { let importer = ReexecutionWorker { executor, miner, kafka_connector, }; - let fetcher = BlockWithReceiptsFetcher { chain: Arc::clone(&chain) }; + let fetcher = BlockWithReceiptsFetcher { + chain: Arc::clone(&chain), + response_format, + }; Self { fetcher, importer } } } impl FakeLeader { - fn new(executor: Arc, miner: Arc, storage: Arc, chain: Arc) -> Self { + fn new(executor: Arc, miner: Arc, storage: Arc, chain: Arc, response_format: ResponseFormat) -> Self { let importer = FakeLeaderWorker { executor, miner, storage }; let fetcher = FakeLeaderFetcher { - block_with_receipts_fetcher: BlockWithReceiptsFetcher { chain: Arc::clone(&chain) }, + block_with_receipts_fetcher: BlockWithReceiptsFetcher { + chain: Arc::clone(&chain), + response_format, + }, block_with_changes_fetcher: BlockWithChangesFetcher { chain }, }; @@ -120,6 +133,7 @@ where #[allow(clippy::too_many_arguments)] pub async fn start_importer( importer_mode: ImporterMode, + response_format: ResponseFormat, storage: Arc, executor: Arc, miner: Arc, @@ -137,12 +151,12 @@ pub async fn start_importer( .await?; } ImporterMode::ReexecutionFollower => { - ReexecutionFollower::new(executor, miner, Arc::clone(&chain), kafka_connector) + ReexecutionFollower::new(executor, miner, Arc::clone(&chain), kafka_connector, response_format) .run(resume_from, sync_interval, chain, stop_at_block) .await?; } ImporterMode::FakeLeader => { - FakeLeader::new(executor, miner, storage, Arc::clone(&chain)) + FakeLeader::new(executor, miner, storage, Arc::clone(&chain), response_format) .run(resume_from, sync_interval, chain, stop_at_block) .await?; } diff --git a/src/eth/miner/miner.rs b/src/eth/miner/miner.rs index 9f3575b76..b5616bb23 100644 --- a/src/eth/miner/miner.rs +++ b/src/eth/miner/miner.rs @@ -273,6 +273,39 @@ impl Miner { } } + /// Mines an imported stratus block and its reexecuted transactions. + /// + /// Mirrors [`Self::mine_external`], but the imported block is already in the stratus-native + /// format. Local transactions are not allowed to be part of the block. + pub fn mine_imported(&self, imported_block: Block) -> anyhow::Result<(Block, State)> { + // track + #[cfg(feature = "tracing")] + let _span = info_span!("miner::mine_imported", block_number = field::Empty).entered(); + + // lock + let _mine_lock = self.locks.mine.lock(); + + // mine block + let (pending_block, changes) = self.storage.finish_pending_block(); + let mut block: Block = pending_block.into(); + + Span::with(|s| s.rec_str("block_number", &block.header.number)); + block.apply_imported(&imported_block); + + match block.number() == imported_block.number() && block.header.timestamp == imported_block.header.timestamp && block.hash() == imported_block.hash() { + true => Ok((block, changes)), + false => Err(anyhow!( + "mismatching block info:\n\tlocal:\n\t\tnumber: {:?}\n\t\ttimestamp: {:?}\n\t\thash: {:?}\n\timported:\n\t\tnumber: {:?}\n\t\ttimestamp: {:?}\n\t\thash: {:?}", + block.number(), + block.header.timestamp, + block.hash(), + imported_block.number(), + imported_block.header.timestamp, + imported_block.hash() + )), + } + } + /// Same as [`Self::mine_local`], but automatically commits the block instead of returning it. /// mainly used when is_automine is enabled. pub fn mine_local_and_commit(&self) -> anyhow::Result<(), StorageError> { diff --git a/src/eth/rpc/mod.rs b/src/eth/rpc/mod.rs index 472fa9a26..3a7f9a5e8 100644 --- a/src/eth/rpc/mod.rs +++ b/src/eth/rpc/mod.rs @@ -46,14 +46,53 @@ mod tests { use super::pagination::PaginationEnvelope; use super::pagination::PaginationParams; use super::pagination::Reassembler; + use super::pagination::ResponseFormat; use super::pagination::is_envelope; use super::pagination::parse_envelope; use super::pagination::parse_request; + use super::pagination::request_params; use super::pagination::respond; use super::types::RpcError; use crate::eth::types::StratusError; use crate::ext::InfallibleExt; + #[test] + fn parse_request_without_format_decodes_like_before() { + // compatibility: the wire format without the format field must decode exactly as before + let params = jsonrpsee::types::Params::new(Some(r#"["0x1", {"offset": 5}]"#)); + let mut sequence = params.sequence(); + sequence.optional_next::().expect("parse first").expect("present"); + let pagination = parse_request(sequence).expect("parse request").expect("present"); + assert_eq!(pagination.offset, 5); + assert_eq!(pagination.format, None); + } + + #[test] + fn parse_request_parses_format_when_present() { + let params = jsonrpsee::types::Params::new(Some(r#"["0x1", {"offset": 5, "format": "stratus"}]"#)); + let mut sequence = params.sequence(); + sequence.optional_next::().expect("parse first").expect("present"); + let pagination = parse_request(sequence).expect("parse request").expect("present"); + assert_eq!(pagination.offset, 5); + assert_eq!(pagination.format, Some(ResponseFormat::Stratus)); + } + + #[test] + fn parse_request_rejects_invalid_format() { + let params = jsonrpsee::types::Params::new(Some(r#"["0x1", {"offset": 5, "format": "yaml"}]"#)); + let mut sequence = params.sequence(); + sequence.optional_next::().expect("parse first").expect("present"); + assert!(matches!(parse_request(sequence), Err(RpcError::ParameterDecodeError { .. }))); + } + + #[test] + fn request_params_wire_format() { + // without a format the wire parameter is unchanged, so old leaders see the same bytes + assert_eq!(request_params(5, None), json!({"offset": 5})); + // with a format the field rides the pagination parameter for endpoints that support it + assert_eq!(request_params(5, Some(ResponseFormat::Stratus)), json!({"offset": 5, "format": "stratus"})); + } + #[test] fn respond_without_pagination_is_byte_identical() { let value = json!({"block": "abc", "receipts": [1, 2, 3]}); @@ -64,14 +103,14 @@ mod tests { #[test] fn respond_with_fitting_response_returns_full() { let value = json!({"block": "abc"}); - let raw = respond(value.clone(), Some(PaginationParams { offset: 0 }), 1024).expect("respond"); + let raw = respond(value.clone(), Some(PaginationParams { offset: 0, format: None }), 1024).expect("respond"); assert_eq!(raw.get(), serde_json::to_string(&value).expect_infallible()); } #[test] fn respond_with_oversized_response_returns_envelope() { let value = json!({"block": "a somewhat long value that will not fit"}); - let raw = respond(value.clone(), Some(PaginationParams { offset: 0 }), MARGIN + 16).expect("respond"); + let raw = respond(value.clone(), Some(PaginationParams { offset: 0, format: None }), MARGIN + 16).expect("respond"); let full = serde_json::to_string(&value).expect_infallible(); assert!(is_envelope(raw.get())); @@ -90,7 +129,7 @@ mod tests { let mut reassembler = Reassembler::new(0); let mut offset = 0; while offset < full.len() as u64 { - let raw = respond(value.clone(), Some(PaginationParams { offset }), limit).expect("respond"); + let raw = respond(value.clone(), Some(PaginationParams { offset, format: None }), limit).expect("respond"); assert!(is_envelope(raw.get()), "expected envelope at offset {offset}"); let envelope = parse_envelope(raw.get()).expect("parse envelope"); assert!( @@ -115,7 +154,7 @@ mod tests { let value = json!({"block": "some content"}); let full = serde_json::to_string(&value).expect("serialize"); - let result = respond(value, Some(PaginationParams { offset: 0 }), full.len() as u32 + MARGIN).expect("should respond"); + let result = respond(value, Some(PaginationParams { offset: 0, format: None }), full.len() as u32 + MARGIN).expect("should respond"); assert_eq!(result.get(), full); assert!(!is_envelope(result.get())); } @@ -123,7 +162,7 @@ mod tests { #[test] fn respond_with_offset_beyond_response_fails() { let value = json!({"block": "abc"}); - let error = respond(value, Some(PaginationParams { offset: 100 }), MARGIN + 8).expect_err("should fail"); + let error = respond(value, Some(PaginationParams { offset: 100, format: None }), MARGIN + 8).expect_err("should fail"); assert!(matches!(error, StratusError::RPC(RpcError::ParameterInvalid))); } @@ -140,7 +179,15 @@ mod tests { .expect("multi-byte char"); assert!(!full.is_char_boundary(misaligned)); - let raw = respond(value, Some(PaginationParams { offset: misaligned as u64 }), MARGIN + 8).expect("should respond"); + let raw = respond( + value, + Some(PaginationParams { + offset: misaligned as u64, + format: None, + }), + MARGIN + 8, + ) + .expect("should respond"); assert!(is_envelope(raw.get())); let envelope = parse_envelope(raw.get()).expect("parse envelope"); @@ -278,16 +325,21 @@ mod wire_tests { use serde_json::json; use super::pagination::MAX_REASSEMBLY_TOTAL; + use super::pagination::ResponseFormat; use super::pagination::parse_request; use super::pagination::respond; use super::parser::next_rpc_param; use super::types::BlockFilter; use crate::alias::JsonValue; use crate::eth::follower::importer::BlockchainClient; + use crate::eth::follower::importer::FetchedBlockWithReceipts; + use crate::eth::storage::permanent::rocks::types::BlockRocksdb; + use crate::eth::types::Block; use crate::eth::types::BlockNumber; use crate::eth::types::ExternalBlockWithReceipts; use crate::eth::types::ExternalReceipt; use crate::eth::types::StratusError; + use crate::eth::types::TransactionMined; use crate::ext::to_json_value; use crate::utils::test_utils::fake_first; use crate::utils::test_utils::fake_list; @@ -342,8 +394,15 @@ mod wire_tests { let url = format!("http://{addr}"); let client = BlockchainClient::new_http(&url, Duration::from_secs(10)).await.expect("build client"); - let fetched = client.fetch_block_and_receipts(BlockNumber::from(1)).await.expect("fetch block"); - assert_eq!(fetched.expect("block present"), expected); + let fetched = client + .fetch_block_and_receipts(BlockNumber::from(1), ResponseFormat::Alloy) + .await + .expect("fetch block"); + let FetchedBlockWithReceipts::Alloy { block, receipts } = fetched.expect("block present") else { + panic!("expected alloy response"); + }; + assert_eq!(block, expected.block); + assert_eq!(receipts, expected.receipts); } #[tokio::test] @@ -371,10 +430,52 @@ mod wire_tests { let url = format!("http://{addr}"); let client = BlockchainClient::new_http(&url, Duration::from_secs(10)).await.expect("build client"); - let error = client.fetch_block_and_receipts(BlockNumber::from(1)).await.expect_err("fetch should fail"); + let error = client + .fetch_block_and_receipts(BlockNumber::from(1), ResponseFormat::Alloy) + .await + .expect_err("fetch should fail"); assert!(error.to_string().contains("failed to fetch block with receipts")); } + #[tokio::test] + async fn stratus_format_against_old_leader_answers_with_clear_error() { + // minimal legacy alloy-shape response, so the old leader delivers it in a single message + let alloy_response = serde_json::json!({ + "block": { "number": "0x1" }, + "receipts": [], + }); + let storage = Arc::new(RwLock::new(alloy_response)); + + // old leader: ignores the format parameter, answers with the legacy alloy shape + let server_config = jsonrpsee::server::ServerConfig::builder().max_response_body_size(MAX_RESPONSE_BYTES).build(); + let server = Server::builder().set_config(server_config).build("127.0.0.1:0").await.expect("build server"); + let addr = server.local_addr().expect("server addr"); + + let mut module = RpcModule::new(Arc::clone(&storage)); + module + .register_method("net_listening", |_, _, _| Ok::<_, StratusError>(true)) + .expect("register net_listening"); + module + .register_method("stratus_getBlockAndReceipts", |_, storage, _| { + let value = storage.read().expect("read storage").clone(); + Ok(value) as Result + }) + .expect("register stratus_getBlockAndReceipts"); + let _server_handle = server.start(module); + + let url = format!("http://{addr}"); + let client = BlockchainClient::new_http(&url, Duration::from_secs(10)).await.expect("build client"); + + // the follower requests the stratus format; the error must explain the real cause + let error = client + .fetch_block_and_receipts(BlockNumber::from(1), ResponseFormat::Stratus) + .await + .expect_err("fetch should fail"); + let message = format!("{error:#}"); + assert!(message.contains("legacy alloy format"), "unexpected error: {message}"); + assert!(message.contains("old version"), "unexpected error: {message}"); + } + #[tokio::test] async fn null_response_means_block_not_available() { let storage = Arc::new(RwLock::new(JsonValue::Null)); @@ -399,7 +500,10 @@ mod wire_tests { let url = format!("http://{addr}"); let client = BlockchainClient::new_http(&url, Duration::from_secs(10)).await.expect("build client"); - let fetched = client.fetch_block_and_receipts(BlockNumber::from(1)).await.expect("fetch block"); + let fetched = client + .fetch_block_and_receipts(BlockNumber::from(1), ResponseFormat::Alloy) + .await + .expect("fetch block"); assert!(fetched.is_none(), "null response must deserialize to Ok(None)"); } @@ -429,7 +533,10 @@ mod wire_tests { let url = format!("http://{addr}"); let client = BlockchainClient::new_http(&url, Duration::from_secs(10)).await.expect("build client"); - let error = client.fetch_block_and_receipts(BlockNumber::from(1)).await.expect_err("fetch should fail"); + let error = client + .fetch_block_and_receipts(BlockNumber::from(1), ResponseFormat::Alloy) + .await + .expect_err("fetch should fail"); assert!(format!("{error:?}").contains("exceeds the reassembly cap")); } @@ -465,7 +572,55 @@ mod wire_tests { let url = format!("http://{addr}"); let client = BlockchainClient::new_http(&url, Duration::from_secs(10)).await.expect("build client"); - let error = client.fetch_block_and_receipts(BlockNumber::from(1)).await.expect_err("fetch should fail"); + let error = client + .fetch_block_and_receipts(BlockNumber::from(1), ResponseFormat::Alloy) + .await + .expect_err("fetch should fail"); assert!(format!("{error:?}").contains("expected paginated chunk but got normal response")); } + + #[tokio::test] + async fn stratus_format_response_round_trips_and_format_rides_every_chunk() { + // a big block in the stratus-native format, above the response limits + let mut block = fake_first::(); + block.header.number = BlockNumber::from(1u32); + block.transactions = fake_list::(200); + let expected_rocks = BlockRocksdb::from(block); + let expected: Block = expected_rocks.clone().into(); + let storage = Arc::new(RwLock::new(to_json_value(expected_rocks))); + + // leader with a tiny response limit, using the same handler shape as the real stratus branch + let server_config = jsonrpsee::server::ServerConfig::builder().max_response_body_size(MAX_RESPONSE_BYTES).build(); + let server = Server::builder().set_config(server_config).build("127.0.0.1:0").await.expect("build server"); + let addr = server.local_addr().expect("server addr"); + + let mut module = RpcModule::new(Arc::clone(&storage)); + module + .register_method("net_listening", |_, _, _| Ok::<_, StratusError>(true)) + .expect("register net_listening"); + module + .register_method("stratus_getBlockAndReceipts", |params, storage, _| { + let (sequence, _filter) = next_rpc_param::(params.sequence())?; + let pagination = parse_request(sequence)?; + // the response format must ride every chunk request so the leader keeps it stable + assert_eq!(pagination.as_ref().and_then(|params| params.format), Some(ResponseFormat::Stratus)); + let value = storage.read().expect("read storage").clone(); + respond(value, pagination, MAX_RESPONSE_BYTES) + }) + .expect("register stratus_getBlockAndReceipts"); + let _server_handle = server.start(module); + + // follower with a tiny response limit, like the importer uses + let url = format!("http://{addr}"); + let client = BlockchainClient::new_http(&url, Duration::from_secs(10)).await.expect("build client"); + + let fetched = client + .fetch_block_and_receipts(BlockNumber::from(1), ResponseFormat::Stratus) + .await + .expect("fetch block"); + let FetchedBlockWithReceipts::Stratus(fetched_block) = fetched.expect("block present") else { + panic!("expected stratus response"); + }; + assert_eq!(fetched_block, expected); + } } diff --git a/src/eth/rpc/pagination.rs b/src/eth/rpc/pagination.rs index 1aaa3e0b5..f7c361064 100644 --- a/src/eth/rpc/pagination.rs +++ b/src/eth/rpc/pagination.rs @@ -75,6 +75,57 @@ pub const MAX_REASSEMBLY_TOTAL: u64 = 512 * 1024 * 1024; pub struct PaginationParams { /// Byte offset of the requested chunk within the serialized response. pub offset: u64, + + /// Response format requested from the leader for endpoints that support more than one + /// serialization. Only `stratus_getBlockAndReceipts` currently does; the field is ignored by + /// endpoints with a single format and by old leaders (serde ignores unknown fields). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option, +} + +/// Response format for importer endpoints that support more than one serialization. +/// +/// - [`ResponseFormat::Alloy`] is the legacy format: block and receipts converted to alloy +/// JSON-RPC types. It is the default, so followers that do not opt in keep today's behavior. +/// - [`ResponseFormat::Stratus`] is the native format: block with embedded receipts serialized +/// directly from the stratus storage types, with no conversion. Requires an upgraded leader; +/// old leaders ignore the parameter and answer in the alloy format. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ResponseFormat { + /// Alloy JSON-RPC types (legacy). + #[default] + Alloy, + /// Stratus-native storage types. + Stratus, +} + +impl std::fmt::Display for ResponseFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl std::str::FromStr for ResponseFormat { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "alloy" => Ok(Self::Alloy), + "stratus" => Ok(Self::Stratus), + _ => Err(format!("invalid response format {s:?}; expected \"alloy\" or \"stratus\"")), + } + } +} + +impl ResponseFormat { + /// Returns the wire name of the format. + pub fn as_str(&self) -> &'static str { + match self { + Self::Alloy => "alloy", + Self::Stratus => "stratus", + } + } } /// Extracts the optional [`PaginationParams`] from the remaining request params sequence. @@ -167,8 +218,12 @@ pub fn respond(value: JsonValue, pagination: Option, max_respo } /// Builds pagination request params for the follower side. -pub fn request_params(offset: u64) -> JsonValue { - to_json_value(PaginationParams { offset }) +/// +/// When `format` is `None`, the serialized params are byte-identical to the pre-format wire +/// format (`{"offset": N}`); when set, the format rides alongside the offset so the leader +/// serializes every chunk of the response in the requested format. +pub fn request_params(offset: u64, format: Option) -> JsonValue { + to_json_value(PaginationParams { offset, format }) } /// Progressive reassembly of a paginated response, with validation against a malicious peer. diff --git a/src/eth/rpc/server.rs b/src/eth/rpc/server.rs index 223ff4c51..401c6ed45 100644 --- a/src/eth/rpc/server.rs +++ b/src/eth/rpc/server.rs @@ -82,12 +82,14 @@ use crate::eth::rpc::middleware::decode_input_arguments; use crate::eth::rpc::next_rpc_param; use crate::eth::rpc::next_rpc_param_or_default; use crate::eth::rpc::pagination; +use crate::eth::rpc::pagination::ResponseFormat; use crate::eth::rpc::parser::RpcExtensionsExt; use crate::eth::rpc::parser::parse_rpc_rlp; use crate::eth::rpc::subscriptions::RpcSubscriptionsHandles; use crate::eth::storage::ExecutionKind; use crate::eth::storage::StorageError; use crate::eth::storage::StratusStorage; +use crate::eth::storage::permanent::rocks::types::BlockRocksdb; use crate::eth::types::Address; use crate::eth::types::Bytes; use crate::eth::types::CallInput; @@ -299,7 +301,7 @@ impl Server { async fn health(&self) -> bool { match GlobalState::get_node_mode() { NodeMode::Leader | NodeMode::FakeLeader => true, - NodeMode::Follower => + NodeMode::Follower => { if GlobalState::is_importer_shutdown() { tracing::warn!("stratus is unhealthy because importer is shutdown"); false @@ -311,7 +313,8 @@ impl Server { false } } - }, + } + } } } } @@ -696,6 +699,7 @@ async fn stratus_init_importer(params: Params<'_>, ctx: Arc, ext: Ex importer_async_threads: 4, forward_access_list: true, stop_at_block: None, + response_format: ResponseFormat::default(), }; importer_config.init_follower_importer(ctx).await @@ -978,8 +982,11 @@ fn stratus_get_block_and_receipts(params: Params<'_>, ctx: Arc, ext: let (sequence, filter) = next_rpc_param::(params.sequence())?; let pagination = pagination::parse_request(sequence)?; + // absent format parameter means the legacy alloy format + let response_format = pagination.as_ref().and_then(|params| params.format).unwrap_or_default(); + // track - tracing::info!(%filter, "reading block and receipts"); + tracing::info!(%filter, %response_format, "reading block and receipts"); let Some(block) = ctx.server.storage.read_block(filter)? else { tracing::info!(%filter, "block not found"); @@ -987,12 +994,21 @@ fn stratus_get_block_and_receipts(params: Params<'_>, ctx: Arc, ext: }; tracing::info!(%filter, "block with transactions found"); - let receipts = block.transactions.iter().cloned().map(AlloyReceipt::from).collect::>(); - let value = json!({ - "block": block.to_json_rpc_with_full_transactions(), - "receipts": receipts, - }); + // serialize in the requested format + let value = match response_format { + ResponseFormat::Stratus => { + // receipts are embedded in the stratus storage types + to_json_value(BlockRocksdb::from(block)) + } + ResponseFormat::Alloy => { + let receipts = block.transactions.iter().cloned().map(AlloyReceipt::from).collect::>(); + json!({ + "block": block.to_json_rpc_with_full_transactions(), + "receipts": receipts, + }) + } + }; pagination::respond(value, pagination, ctx.server.rpc_config.rpc_max_response_size_bytes) } diff --git a/src/eth/storage/permanent/rocks/types/block.rs b/src/eth/storage/permanent/rocks/types/block.rs index 17ea78691..51e297dee 100644 --- a/src/eth/storage/permanent/rocks/types/block.rs +++ b/src/eth/storage/permanent/rocks/types/block.rs @@ -80,3 +80,63 @@ impl From for Block { } impl SerializeDeserializeWithContext for BlockRocksdb {} + +#[cfg(test)] +mod tests { + use super::BlockRocksdb; + use crate::eth::types::Block; + use crate::eth::types::BlockNumber; + use crate::eth::types::Index; + use crate::eth::types::Log; + use crate::eth::types::TransactionMined; + use crate::ext::to_json_value; + use crate::utils::test_utils::fake_first; + use crate::utils::test_utils::fake_list; + + /// Builds a block with a small number and transactions that have logs and small log indexes, + /// like a leader mines. Small values are required because the storage DTO narrows the block + /// number to `u32` and log indexes are derived from the log list. + fn sample_block() -> Block { + let mut block = fake_first::(); + block.header.number = BlockNumber::from(1u32); + block.transactions = fake_list::(3); + // give every transaction exactly two logs at a small, stable first log index, like the + // leader does, so mined data round-trips without depending on random fixture values + for (transaction_index, transaction) in block.transactions.iter_mut().enumerate() { + let transaction_index = (transaction_index as u64) * 2; + let first_log_index = Index::from(transaction_index * 10); + transaction.mined_data.index = Index::from(transaction_index); + transaction.mined_data.first_log_index = first_log_index; + let log = fake_first::(); + transaction.execution.output.logs = vec![log.clone(), log]; + } + block + } + + /// The stratus response format serializes a block through the storage DTO and JSON, exactly + /// like the leader serializes it and the follower deserializes it in `stratus_getBlockAndReceipts`. + #[test] + fn block_rocksdb_json_round_trip_is_lossless() { + let original = sample_block(); + + // first hop: the same serialization the leader performs in the RPC handler + let json = to_json_value(BlockRocksdb::from(original.clone())); + let first: Block = serde_json::from_value::(json).expect("deserialize from json").into(); + + // second hop must be a fixed point: nothing is canonicalized further + let second: Block = BlockRocksdb::from(first.clone()).into(); + assert_eq!(first, second); + + // the header is copied directly, field by field + assert_eq!(first.header, original.header); + + // mined data is rebuilt from the DTO fields, preserving the leader invariants + for (original_transaction, rebuilt_transaction) in original.transactions.iter().zip(first.transactions.iter()) { + assert_eq!(rebuilt_transaction.mined_data.index, original_transaction.mined_data.index); + assert_eq!(rebuilt_transaction.mined_data.first_log_index, original_transaction.mined_data.first_log_index); + // the block hash is rebuilt from the header the block was read with, not the stale + // fixture value, mirroring what the leader guarantees in real blocks + assert_eq!(rebuilt_transaction.mined_data.block_hash, original.header.hash); + } + } +} diff --git a/src/eth/types/block/block.rs b/src/eth/types/block/block.rs index e3abb0010..f3caf17f2 100644 --- a/src/eth/types/block/block.rs +++ b/src/eth/types/block/block.rs @@ -100,6 +100,19 @@ impl Block { transaction.mined_data.block_hash = external_block.hash(); } } + + /// Patches this locally mined block with the header and mined data of an imported stratus block. + /// + /// Mirrors [`Self::apply_external`], but the imported block is already in the stratus-native + /// format, so the transaction hashes and header match the local reexecution by construction. + pub fn apply_imported(&mut self, imported_block: &Block) { + self.header.hash = imported_block.hash(); + assert!(*self.header.timestamp == *imported_block.header.timestamp); + for transaction in self.transactions.iter_mut() { + assert!(transaction.input.block_timestamp == self.header.timestamp); + transaction.mined_data.block_hash = imported_block.hash(); + } + } } impl From for Block { diff --git a/src/eth/types/transaction/transaction_input.rs b/src/eth/types/transaction/transaction_input.rs index 802734463..d47baaa1e 100644 --- a/src/eth/types/transaction/transaction_input.rs +++ b/src/eth/types/transaction/transaction_input.rs @@ -323,7 +323,7 @@ impl TransactionInput { } /// Recovers the signer address from the transaction fields already stored in this input. - fn recover_signer_address(&self) -> anyhow::Result
{ + pub(crate) fn recover_signer_address(&self) -> anyhow::Result
{ let prehash = self.signature_hash(); let signature: AlloySignature = self.signature.into(); let signer = signature From 7ee28e74abdb664d9ed84f612fb31fc9750ea8b3 Mon Sep 17 00:00:00 2001 From: gventino-cw Date: Mon, 28 Sep 2026 20:58:40 -0300 Subject: [PATCH 02/10] chore: remove section banners and explanatory comments --- .../follower/e2e-pagination-stratus.test.ts | 12 ++----- e2e/test/follower/e2e-pagination.test.ts | 32 +++++++++---------- .../evm/types/output/transaction_execution.rs | 15 +-------- src/eth/executor/mod.rs | 14 -------- src/eth/follower/importer/config.rs | 4 +-- .../importer/fetchers/block_with_receipts.rs | 4 --- .../importer/importers/blockchain_client.rs | 15 +-------- .../importer/importers/fake_leader.rs | 3 -- src/eth/miner/miner.rs | 3 +- src/eth/rpc/pagination.rs | 10 +----- src/eth/rpc/server.rs | 6 +--- src/eth/types/block/block.rs | 4 --- 12 files changed, 24 insertions(+), 98 deletions(-) diff --git a/e2e/test/follower/e2e-pagination-stratus.test.ts b/e2e/test/follower/e2e-pagination-stratus.test.ts index 161f672a7..574100d45 100644 --- a/e2e/test/follower/e2e-pagination-stratus.test.ts +++ b/e2e/test/follower/e2e-pagination-stratus.test.ts @@ -4,11 +4,6 @@ import { ALICE } from "../helpers/account"; import { CHAIN_ID_DEC, send } from "../helpers/rpc"; import { FOLLOWER_URL, rpcCall, waitForFollowerBlock, waitForReceipt } from "./helpers"; -// Requires the `just e2e-leader-follower-pagination-stratus` recipe: leader and follower both -// running with MAX_RESPONSE_SIZE_BYTES=8192, and the follower importing through the -// stratus-native response format (`--response-format stratus`). The rule under test is that -// the follower re-executes and commits imported blocks identically to the alloy format. - const FAT_TX_DATA_BYTES = 50_000; describe("Pagination (stratus-native importer format)", () => { @@ -30,9 +25,8 @@ describe("Pagination (stratus-native importer format)", () => { // the follower imports the fat block through the paginated stratus-format importer await waitForFollowerBlock(fatBlockNumber); - // the follower re-executed and stored the block: it serves the same block content. - // the leader block is requested thin: the leader's small response limit rejects the - // full fat block over `eth_getBlockByNumber` (only importer methods paginate) + // the follower serves the same block content; the leader block is requested thin because + // its response limit rejects the full fat block over `eth_getBlockByNumber` const leaderBlock = await send("eth_getBlockByNumber", [receipt.blockNumber, false]); const followerBlock = await rpcCall(FOLLOWER_URL, "eth_getBlockByNumber", [receipt.blockNumber, true]); expect(followerBlock.result.hash).to.equal(leaderBlock.hash); @@ -41,7 +35,7 @@ describe("Pagination (stratus-native importer format)", () => { // and the transaction receipt is available on the follower const followerReceipt = await rpcCall(FOLLOWER_URL, "eth_getTransactionReceipt", [txHash]); - expect(followerReceipt.result).to.not.be.null; + expect(followerReceipt.result).to.not.equal(null); expect(followerReceipt.result.blockNumber).to.equal(receipt.blockNumber); }); }); diff --git a/e2e/test/follower/e2e-pagination.test.ts b/e2e/test/follower/e2e-pagination.test.ts index 43646c7f1..5602b4f34 100644 --- a/e2e/test/follower/e2e-pagination.test.ts +++ b/e2e/test/follower/e2e-pagination.test.ts @@ -14,8 +14,7 @@ const FAT_TX_DATA_BYTES = 50_000; // The stratus-native block DTO serializes hashes as byte arrays; convert them to hex for assertions. const bytesToHex = (bytes: number[]) => "0x" + Buffer.from(bytes).toString("hex"); -// The block DTO serializes block numbers as byte-order-swapped u32 (paired to_be/from_be serde in -// Rust, the same wire contract as `stratus_get_block_with_changes`); swap them back for assertions. +// The block DTO serializes block numbers as byte-order-swapped u32; swap them back for assertions. const swapU32 = (value: number) => ((value & 0xff) << 24) | ((value & 0xff00) << 8) | ((value >>> 8) & 0xff00) | (value >>> 24); @@ -23,9 +22,9 @@ describe("Pagination", () => { it("paginates oversized importer responses and keeps the follower syncing", async () => { // a fitting response is served normally, with no envelope, so old followers are unaffected const earlyBlock = await send("eth_getBlockByNumber", ["0x1", false]); - expect(earlyBlock).to.not.be.null; + expect(earlyBlock).to.not.equal(null); const small = await send("stratus_getBlockAndReceipts", [earlyBlock.hash]); - expect(small.stratus_paginated).to.be.undefined; + expect(small.stratus_paginated).to.equal(undefined); expect(small.block.number).to.equal("0x1"); // the stratus-native format serves the block DTO directly, still without envelope when it fits @@ -33,9 +32,9 @@ describe("Pagination", () => { earlyBlock.hash, { offset: 0, format: "stratus" }, ]); - expect(smallStratus.stratus_paginated).to.be.undefined; - expect(smallStratus.block).to.be.undefined; - expect(smallStratus.receipts).to.be.undefined; + expect(smallStratus.stratus_paginated).to.equal(undefined); + expect(smallStratus.block).to.equal(undefined); + expect(smallStratus.receipts).to.equal(undefined); expect(bytesToHex(smallStratus.header.hash)).to.equal(earlyBlock.hash); expect(swapU32(smallStratus.header.number)).to.equal(1); expect(smallStratus.transactions).to.be.an("array"); @@ -58,7 +57,7 @@ describe("Pagination", () => { // the old single-parameter call fails with the oversized response error (-32008), // which is exactly what would stall an importer before pagination existed const legacy = await sendAndGetFullResponse("stratus_getBlockAndReceipts", [fatBlockHash]); - expect(legacy.data.error).to.not.be.undefined; + expect(legacy.data.error).to.not.equal(undefined); expect(legacy.data.error.code).to.equal(-32008); // paginated reassembly; the chunk size is decided by the leader's response size limit @@ -66,7 +65,7 @@ describe("Pagination", () => { let total = 0; for (let offset = 0; total === 0 || assembled.length < total; offset = assembled.length) { const envelope = await send("stratus_getBlockAndReceipts", [fatBlockHash, { offset: offset }]); - expect(envelope.stratus_paginated).to.not.be.undefined; + expect(envelope.stratus_paginated).to.not.equal(undefined); total = envelope.stratus_paginated.total; const chunk = Buffer.from(envelope.stratus_paginated.chunk, "base64"); expect(chunk.length).to.be.greaterThan(0); @@ -83,8 +82,7 @@ describe("Pagination", () => { expect(response.receipts).to.have.length(1); expect(response.receipts[0].transactionHash).to.equal(txHash); - // the same oversized block paginates identically in the stratus-native format, - // with the format field riding every chunk request + // the same oversized block paginates identically in the stratus-native format let stratusAssembled: Buffer = Buffer.alloc(0); let stratusTotal = 0; for ( @@ -96,7 +94,7 @@ describe("Pagination", () => { fatBlockHash, { offset: offset, format: "stratus" }, ]); - expect(envelope.stratus_paginated).to.not.be.undefined; + expect(envelope.stratus_paginated).to.not.equal(undefined); stratusTotal = envelope.stratus_paginated.total; const chunk = Buffer.from(envelope.stratus_paginated.chunk, "base64"); expect(chunk.length).to.be.greaterThan(0); @@ -107,19 +105,19 @@ describe("Pagination", () => { // the reassembled stratus content has the block DTO shape, with receipts embedded const stratusResponse = JSON.parse(stratusAssembled.toString("utf8")); - expect(stratusResponse.block).to.be.undefined; - expect(stratusResponse.receipts).to.be.undefined; + expect(stratusResponse.block).to.equal(undefined); + expect(stratusResponse.receipts).to.equal(undefined); expect(bytesToHex(stratusResponse.header.hash)).to.equal(fatBlockHash); expect(swapU32(stratusResponse.header.number)).to.equal(fatBlockNumber); expect(stratusResponse.transactions).to.have.length(1); expect(bytesToHex(stratusResponse.transactions[0].input.hash)).to.equal(txHash); - expect(stratusResponse.transactions[0].execution).to.not.be.undefined; - expect(stratusResponse.transactions[0].logs).to.not.be.undefined; + expect(stratusResponse.transactions[0].execution).to.not.equal(undefined); + expect(stratusResponse.transactions[0].logs).to.not.equal(undefined); // the follower imports the fat block through the paginated importer await waitForFollowerBlock(fatBlockNumber); const followerReceipt = await rpcCall(FOLLOWER_URL, "eth_getTransactionReceipt", [txHash]); - expect(followerReceipt.result).to.not.be.null; + expect(followerReceipt.result).to.not.equal(null); expect(followerReceipt.result.blockNumber).to.equal(receipt.blockNumber); }); }); diff --git a/src/eth/executor/evm/types/output/transaction_execution.rs b/src/eth/executor/evm/types/output/transaction_execution.rs index 4a555907c..88c0b4b27 100644 --- a/src/eth/executor/evm/types/output/transaction_execution.rs +++ b/src/eth/executor/evm/types/output/transaction_execution.rs @@ -258,16 +258,7 @@ impl TransactionExecutionOutput { } } - // ------------------------------------------------------------------------- - // Imported stratus transactions - // ------------------------------------------------------------------------- - /// Creates an execution from an imported stratus transaction that failed. - /// - /// Mirrors [`Self::from_failed_external_transaction`]: failed transactions are not re-executed - /// (the local re-execution runs with max gas and zero gas price, which could make a - /// transaction that failed under the original conditions succeed), so the outcome is - /// recreated from the stored execution instead. pub fn from_failed_imported_transaction(sender: Account, gas_price: Wei, stored: &TransactionExecutionResult) -> anyhow::Result { if stored.result.is_success() { return log_and_err!("cannot create failed execution for successful transaction"); @@ -302,9 +293,6 @@ impl TransactionExecutionOutput { } /// Checks if current execution state matches the stored execution of an imported transaction. - /// - /// Mirrors [`Self::compare_with_receipt`], comparing against the stratus-native stored - /// execution instead of an alloy receipt. pub fn compare_with_imported(&self, tx_hash: Hash, stored: &TransactionExecutionResult) -> anyhow::Result<()> { // compare execution status if self.is_success() != stored.result.is_success() { @@ -366,8 +354,7 @@ impl TransactionExecutionOutput { /// /// This causes some attributes to be different from the stored execution. /// - /// This method updates the attributes that can diverge based on the stored execution, mirroring - /// [`Self::apply_receipt`]: `gas_price` and `sender` come from the imported transaction input. + /// This method updates the attributes that can diverge based on the stored execution of the imported transaction. pub fn apply_imported(&mut self, stored: &TransactionExecutionResult, gas_price: Wei, sender: Address) -> anyhow::Result<()> { // fix gas self.gas_used = stored.gas_used; diff --git a/src/eth/executor/mod.rs b/src/eth/executor/mod.rs index 72d206480..5ab377b8b 100644 --- a/src/eth/executor/mod.rs +++ b/src/eth/executor/mod.rs @@ -223,14 +223,7 @@ impl Executor { Ok(()) } - // ------------------------------------------------------------------------- - // Imported stratus transactions - // ------------------------------------------------------------------------- - /// Reexecutes an imported stratus block locally and imports it to the temporary storage. - /// - /// Mirrors [`Executor::execute_external_block`], but the block is already in the stratus-native - /// format, so the receipt data is embedded in each [`TransactionMined`] execution result. #[timed(executor_imported_block)] pub fn execute_imported_block(&self, mut block: Block) -> anyhow::Result<()> { #[cfg(feature = "tracing")] @@ -271,7 +264,6 @@ impl Executor { tx: TransactionMined, block_number: BlockNumber, ) -> anyhow::Result<()> { - // deconstruct the stored transaction into its parts let TransactionExecution { info, signature, @@ -280,23 +272,18 @@ impl Executor { } = tx.execution; let tx_hash = info.hash; - // rebuild the transaction input from the stored fields to recover the signer let tx_input = TransactionInput { transaction_info: info, execution_info: stored_input.clone().into(), signature, }; - // rederive the signer from the saved signature so the follower executes with the same - // address the leader derived from the same saved fields (mirrors the external path) let recovered = tx_input.recover_signer_address()?; - // reuse the stored execution input, overriding the sender with the recovered signer let mut evm_input = stored_input; evm_input.from = recovered; let gas_price = Wei::from(evm_input.gas_price); - // when the stored transaction failed, create fake transaction instead of reexecuting let (tx_execution, state) = match stored_output.result.is_success() { // successful imported transaction, re-execute locally true => { @@ -330,7 +317,6 @@ impl Executor { (TransactionExecution::new(info, signature, evm_input, evm_result.outcome), evm_result.state) } - // // failed imported transaction, re-create from the stored execution without re-executing false => { let (sender, _) = storage.read_account(recovered, ExecutionKind::Transaction)?; diff --git a/src/eth/follower/importer/config.rs b/src/eth/follower/importer/config.rs index 6f48c0518..a4e35dedd 100644 --- a/src/eth/follower/importer/config.rs +++ b/src/eth/follower/importer/config.rs @@ -81,9 +81,7 @@ pub struct ImporterConfig { #[arg(id = "importer.stop_at_block", long = "stop-at-block")] pub stop_at_block: Option, - /// Response format used by the block-and-receipts importer RPC (importer online). Use - /// "stratus" to request stratus-native serialization (issue #2709), or "alloy" for the legacy - /// alloy-based serialization. + /// Response format used by the block-and-receipts importer RPC (importer online). #[arg(id = "importer.response_format", long = "response-format", default_value = "alloy", required = false)] pub response_format: ResponseFormat, } diff --git a/src/eth/follower/importer/fetchers/block_with_receipts.rs b/src/eth/follower/importer/fetchers/block_with_receipts.rs index 7e26c2480..27f058a52 100644 --- a/src/eth/follower/importer/fetchers/block_with_receipts.rs +++ b/src/eth/follower/importer/fetchers/block_with_receipts.rs @@ -14,9 +14,6 @@ use crate::eth::types::ExternalBlock; use crate::eth::types::ExternalReceipt; /// Block with receipts fetched from the leader, in either supported response format. -/// -/// The variants hold large transient payloads that are consumed immediately, so the size -/// difference is harmless. #[allow(clippy::large_enum_variant)] #[derive(Debug, Clone)] pub enum FetchedBlockWithReceipts { @@ -104,7 +101,6 @@ impl DataFetcher for BlockWithReceiptsFetcher { Ok(FetchedBlockWithReceipts::Alloy { block, receipts }) } FetchedBlockWithReceipts::Stratus(block) => { - // transactions are stored in order, so no sorting is needed; only check indices for window in block.transactions.windows(2) { let tx_index = window[0].mined_data.index.0; let next_tx_index = window[1].mined_data.index.0; diff --git a/src/eth/follower/importer/importers/blockchain_client.rs b/src/eth/follower/importer/importers/blockchain_client.rs index db84a9823..68583f1c3 100644 --- a/src/eth/follower/importer/importers/blockchain_client.rs +++ b/src/eth/follower/importer/importers/blockchain_client.rs @@ -194,9 +194,6 @@ impl BlockchainClient { /// Sends the pagination capability parameter so a pagination-aware leader can split /// responses that do not fit in a single message (see `eth::rpc::pagination`). Old leaders /// ignore the extra parameter and answer normally, which is handled transparently. - /// - /// The `format` parameter, when set, rides the pagination object so the leader serializes - /// every chunk of the response in the requested format. async fn request_importer_data( &self, method: &'static str, @@ -209,10 +206,6 @@ impl BlockchainClient { let value = match serde_json::from_str::(full.get()) { Ok(value) => value, Err(e) if format == Some(ResponseFormat::Stratus) && Self::is_legacy_alloy_response(full.get()) => { - // A leader that does not know the format parameter ignores it and answers with - // the legacy alloy shape. Surface the cause instead of a cryptic missing-field - // deserialization error (the original error `e` is discarded on purpose: the - // shape check below pinpoints the cause). let _ = e; tracing::error!(method, "leader answered the stratus format request with the legacy alloy format"); anyhow::bail!( @@ -226,8 +219,7 @@ impl BlockchainClient { Ok(Some(value)) } - /// Checks whether a response has the legacy alloy shape (top-level `block` and `receipts` - /// objects) instead of the stratus DTO shape (top-level `header` and `transactions` objects). + /// Checks whether a response has the legacy alloy shape (top-level `block` and `receipts` objects). fn is_legacy_alloy_response(response: &str) -> bool { serde_json::from_str::(response).is_ok_and(|value| value.get("block").is_some() && value.get("receipts").is_some()) } @@ -300,11 +292,6 @@ impl BlockchainClient { } /// Fetches a block by number with receipts. - /// - /// The response is deserialized according to the requested `response_format`: the legacy - /// alloy format (block and receipts as alloy RPC types) or the stratus-native format - /// (block with embedded receipts from the storage DTO). The format also rides the request - /// parameters so the leader serializes the response accordingly. pub async fn fetch_block_and_receipts( &self, block_number: BlockNumber, diff --git a/src/eth/follower/importer/importers/fake_leader.rs b/src/eth/follower/importer/importers/fake_leader.rs index 1c47048fe..d9cd458d9 100644 --- a/src/eth/follower/importer/importers/fake_leader.rs +++ b/src/eth/follower/importer/importers/fake_leader.rs @@ -35,7 +35,6 @@ impl ImporterWorker for FakeLeaderWorker { #[timed(import_online_mined_block)] async fn import(&self, (fetched, (expected_block, expected_changes)): Self::DataType) -> anyhow::Result { - // prepare the block for local reexecution depending on the fetched response format let (block_tx_len, transactions) = match fetched { FetchedBlockWithReceipts::Alloy { block, .. } => { let block_tx_len = block.transactions.len(); @@ -54,8 +53,6 @@ impl ImporterWorker for FakeLeaderWorker { let transactions = std::mem::take(&mut block.transactions) .into_iter() .map(|tx| -> anyhow::Result { - // rebuild the transaction input from the stored execution, recovering the - // signer the same way the reexecution importer does let TransactionExecution { info, signature, diff --git a/src/eth/miner/miner.rs b/src/eth/miner/miner.rs index b5616bb23..53bc395cf 100644 --- a/src/eth/miner/miner.rs +++ b/src/eth/miner/miner.rs @@ -275,8 +275,7 @@ impl Miner { /// Mines an imported stratus block and its reexecuted transactions. /// - /// Mirrors [`Self::mine_external`], but the imported block is already in the stratus-native - /// format. Local transactions are not allowed to be part of the block. + /// Local transactions are not allowed to be part of the block. pub fn mine_imported(&self, imported_block: Block) -> anyhow::Result<(Block, State)> { // track #[cfg(feature = "tracing")] diff --git a/src/eth/rpc/pagination.rs b/src/eth/rpc/pagination.rs index f7c361064..d30286525 100644 --- a/src/eth/rpc/pagination.rs +++ b/src/eth/rpc/pagination.rs @@ -76,20 +76,12 @@ pub struct PaginationParams { /// Byte offset of the requested chunk within the serialized response. pub offset: u64, - /// Response format requested from the leader for endpoints that support more than one - /// serialization. Only `stratus_getBlockAndReceipts` currently does; the field is ignored by - /// endpoints with a single format and by old leaders (serde ignores unknown fields). + /// Response format requested from the leader for endpoints that support more than one serialization. #[serde(default, skip_serializing_if = "Option::is_none")] pub format: Option, } /// Response format for importer endpoints that support more than one serialization. -/// -/// - [`ResponseFormat::Alloy`] is the legacy format: block and receipts converted to alloy -/// JSON-RPC types. It is the default, so followers that do not opt in keep today's behavior. -/// - [`ResponseFormat::Stratus`] is the native format: block with embedded receipts serialized -/// directly from the stratus storage types, with no conversion. Requires an upgraded leader; -/// old leaders ignore the parameter and answer in the alloy format. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ResponseFormat { diff --git a/src/eth/rpc/server.rs b/src/eth/rpc/server.rs index 401c6ed45..9904a730a 100644 --- a/src/eth/rpc/server.rs +++ b/src/eth/rpc/server.rs @@ -982,7 +982,6 @@ fn stratus_get_block_and_receipts(params: Params<'_>, ctx: Arc, ext: let (sequence, filter) = next_rpc_param::(params.sequence())?; let pagination = pagination::parse_request(sequence)?; - // absent format parameter means the legacy alloy format let response_format = pagination.as_ref().and_then(|params| params.format).unwrap_or_default(); // track @@ -997,10 +996,7 @@ fn stratus_get_block_and_receipts(params: Params<'_>, ctx: Arc, ext: // serialize in the requested format let value = match response_format { - ResponseFormat::Stratus => { - // receipts are embedded in the stratus storage types - to_json_value(BlockRocksdb::from(block)) - } + ResponseFormat::Stratus => to_json_value(BlockRocksdb::from(block)), ResponseFormat::Alloy => { let receipts = block.transactions.iter().cloned().map(AlloyReceipt::from).collect::>(); json!({ diff --git a/src/eth/types/block/block.rs b/src/eth/types/block/block.rs index f3caf17f2..d31f17f0d 100644 --- a/src/eth/types/block/block.rs +++ b/src/eth/types/block/block.rs @@ -101,10 +101,6 @@ impl Block { } } - /// Patches this locally mined block with the header and mined data of an imported stratus block. - /// - /// Mirrors [`Self::apply_external`], but the imported block is already in the stratus-native - /// format, so the transaction hashes and header match the local reexecution by construction. pub fn apply_imported(&mut self, imported_block: &Block) { self.header.hash = imported_block.hash(); assert!(*self.header.timestamp == *imported_block.header.timestamp); From 91e96aa546e225ea79c0fa321389c465e6ba3417 Mon Sep 17 00:00:00 2001 From: gventino-cw Date: Tue, 29 Sep 2026 10:45:54 -0300 Subject: [PATCH 03/10] refactor(executor): split execute_imported_transaction_inner into success and failure helpers Reduces cyclomatic complexity by extracting reexecute_imported_transaction and recreate_failed_imported_transaction. No behavior change. --- src/eth/executor/mod.rs | 126 +++++++++++++++++++++++++--------------- 1 file changed, 79 insertions(+), 47 deletions(-) diff --git a/src/eth/executor/mod.rs b/src/eth/executor/mod.rs index 5ab377b8b..6617657a3 100644 --- a/src/eth/executor/mod.rs +++ b/src/eth/executor/mod.rs @@ -40,6 +40,7 @@ use crate::eth::executor::evm::types::InspectorInput; use crate::eth::executor::evm_worker_pool::EvmWorkerPool; use crate::eth::executor::transaction_worker::TransactionWorker; use crate::eth::executor::types::EvmRoute; +use crate::eth::executor::types::state::Complete; use crate::eth::miner::Miner; use crate::eth::storage::ExecutionKind; use crate::eth::storage::StorageError; @@ -270,7 +271,6 @@ impl Executor { input: stored_input, output: stored_output, } = tx.execution; - let tx_hash = info.hash; let tx_input = TransactionInput { transaction_info: info, @@ -287,54 +287,10 @@ impl Executor { let (tx_execution, state) = match stored_output.result.is_success() { // successful imported transaction, re-execute locally true => { - // re-execute transaction - let evm_execution = evm - .execute(evm_input.clone()) - .and_then(|(result, metrics)| Ok((TransactionExecutionOutput::try_from(result)?, metrics))); - - // handle re-execution result - let (mut evm_result, _evm_metrics) = match evm_execution { - Ok((evm_result, evm_metrics)) => (evm_result, evm_metrics), - Err(e) => { - let json_tx = to_json_string(&tx_input); - let json_stored = to_json_string(&stored_output); - tracing::error!(reason = ?e, %block_number, tx_hash = %tx_hash, %json_tx, %json_stored, "failed to reexecute imported transaction"); - return Err(e.into()); - } - }; - - // update execution with the stored execution - evm_result.apply_imported(&stored_output, gas_price, recovered)?; - - // ensure it matches the stored execution before saving - if let Err(e) = evm_result.compare_with_imported(tx_hash, &stored_output) { - let json_tx = to_json_string(&tx_input); - let json_stored = to_json_string(&stored_output); - let json_execution_logs = to_json_string(&evm_result.logs); - tracing::error!(reason = ?e, %block_number, tx_hash = %tx_hash, %json_tx, %json_stored, %json_execution_logs, "failed to reexecute imported transaction"); - return Err(e); - }; - - (TransactionExecution::new(info, signature, evm_input, evm_result.outcome), evm_result.state) + Self::reexecute_imported_transaction(evm, &tx_input, evm_input, &stored_output, gas_price, recovered, block_number)? } // failed imported transaction, re-create from the stored execution without re-executing - false => { - let (sender, _) = storage.read_account(recovered, ExecutionKind::Transaction)?; - if tx_input.execution_info.nonce != sender.nonce { - bail!( - "reverted imported transaction should have the correct nonce. address: {:?}, input: {:?}, sender: {:?}", - tx_input.signer(), - tx_input.execution_info.nonce, - sender.nonce - ); - } - let evm_result = TransactionExecutionOutput::from_failed_imported_transaction(sender, gas_price, &stored_output)?; - - evm_input.gas_limit = tx_input.execution_info.gas_limit; - evm_input.gas_price = tx_input.execution_info.gas_price; - - (TransactionExecution::new(info, signature, evm_input, evm_result.outcome), evm_result.state) - } + false => Self::recreate_failed_imported_transaction(storage, &tx_input, evm_input, &stored_output, gas_price, recovered)?, }; // persist state @@ -342,6 +298,82 @@ impl Executor { Ok(()) } + /// Reexecutes a successful imported transaction and validates it against the stored execution. + fn reexecute_imported_transaction( + evm: &mut Evm, + tx_input: &TransactionInput, + evm_input: TransactionExecutionInput, + stored_output: &TransactionExecutionResult, + gas_price: Wei, + recovered: Address, + block_number: BlockNumber, + ) -> anyhow::Result<(TransactionExecution, State)> { + let tx_hash = tx_input.transaction_info.hash; + + // re-execute transaction + let evm_execution = evm + .execute(evm_input.clone()) + .and_then(|(result, metrics)| Ok((TransactionExecutionOutput::try_from(result)?, metrics))); + + // handle re-execution result + let (mut evm_result, _evm_metrics) = match evm_execution { + Ok((evm_result, evm_metrics)) => (evm_result, evm_metrics), + Err(e) => { + let json_tx = to_json_string(tx_input); + let json_stored = to_json_string(stored_output); + tracing::error!(reason = ?e, %block_number, tx_hash = %tx_hash, %json_tx, %json_stored, "failed to reexecute imported transaction"); + return Err(e.into()); + } + }; + + // update execution with the stored execution + evm_result.apply_imported(stored_output, gas_price, recovered)?; + + // ensure it matches the stored execution before saving + if let Err(e) = evm_result.compare_with_imported(tx_hash, stored_output) { + let json_tx = to_json_string(tx_input); + let json_stored = to_json_string(stored_output); + let json_execution_logs = to_json_string(&evm_result.logs); + tracing::error!(reason = ?e, %block_number, tx_hash = %tx_hash, %json_tx, %json_stored, %json_execution_logs, "failed to reexecute imported transaction"); + return Err(e); + }; + + Ok(( + TransactionExecution::new(tx_input.transaction_info, tx_input.signature, evm_input, evm_result.outcome), + evm_result.state, + )) + } + + /// Re-creates a failed imported transaction from the stored execution without re-executing. + fn recreate_failed_imported_transaction( + storage: &StratusStorage, + tx_input: &TransactionInput, + mut evm_input: TransactionExecutionInput, + stored_output: &TransactionExecutionResult, + gas_price: Wei, + recovered: Address, + ) -> anyhow::Result<(TransactionExecution, State)> { + let (sender, _) = storage.read_account(recovered, ExecutionKind::Transaction)?; + if tx_input.execution_info.nonce != sender.nonce { + bail!( + "reverted imported transaction should have the correct nonce. address: {:?}, input: {:?}, sender: {:?}", + tx_input.signer(), + tx_input.execution_info.nonce, + sender.nonce + ); + } + + let evm_result = TransactionExecutionOutput::from_failed_imported_transaction(sender, gas_price, stored_output)?; + + evm_input.gas_limit = tx_input.execution_info.gas_limit; + evm_input.gas_price = tx_input.execution_info.gas_price; + + Ok(( + TransactionExecution::new(tx_input.transaction_info, tx_input.signature, evm_input, evm_result.outcome), + evm_result.state, + )) + } + // ------------------------------------------------------------------------- // Local transactions // ------------------------------------------------------------------------- From 909f2cd420620b4d3da5f6df410f734e4aa95100 Mon Sep 17 00:00:00 2001 From: gventino-cw Date: Tue, 29 Sep 2026 16:17:24 -0300 Subject: [PATCH 04/10] refactor: dedupe gas-left event hashes and extract transaction_input helper - hoist duplicated gas-left event hashes to module-level GAS_LEFT_EVENT_HASHES - extract TransactionExecution::transaction_input() and use it in the executor and fake leader - surface the parse error as reason in the legacy-format tracing - drop redundant required = false from the importer response_format arg --- .../evm/types/output/transaction_execution.rs | 32 +++++++++++-------- src/eth/executor/mod.rs | 14 ++------ .../executor/types/transaction_execution.rs | 9 ++++++ src/eth/follower/importer/config.rs | 2 +- .../importer/importers/blockchain_client.rs | 3 +- .../importer/importers/fake_leader.rs | 13 +------- 6 files changed, 33 insertions(+), 40 deletions(-) diff --git a/src/eth/executor/evm/types/output/transaction_execution.rs b/src/eth/executor/evm/types/output/transaction_execution.rs index 88c0b4b27..6e73dd57b 100644 --- a/src/eth/executor/evm/types/output/transaction_execution.rs +++ b/src/eth/executor/evm/types/output/transaction_execution.rs @@ -25,6 +25,15 @@ use crate::eth::types::Wei; use crate::ext::not; use crate::log_and_err; +/// `ERC20Trace` event hash, whose first 32 data bytes carry the transaction `gasLeft` at emit time. +const ERC20_TRACE_EVENT_HASH: [u8; 32] = hex!("31738ac4a7c9a10ecbbfd3fed5037971ba81b8f6aa4f72a23f5364e9bc76d671"); + +/// `BalanceTrackerTrace` event hash, whose first 32 data bytes carry the transaction `gasLeft` at emit time. +const BALANCE_TRACKER_TRACE_EVENT_HASH: [u8; 32] = hex!("63f1e32b72965e2be75e03024856287aff9e4cdbcec65869c51014fc2c1c95d9"); + +/// Event hashes whose first 32 data bytes carry the transaction `gasLeft` at emit time. +const GAS_LEFT_EVENT_HASHES: [&[u8]; 2] = [&ERC20_TRACE_EVENT_HASH, &BALANCE_TRACKER_TRACE_EVENT_HASH]; + /// Output of a transaction executed in the EVM. #[derive(DebugAsJson, Clone, PartialEq, Eq, serde::Serialize, Default, Deref, DerefMut)] #[cfg_attr(test, derive(fake::Dummy))] @@ -229,16 +238,16 @@ impl TransactionExecutionOutput { /// /// The overwriting should be done by copying the first 32 bytes from the receipt to log in `self`. fn fix_logs_gas_left(&mut self, receipt: &ExternalReceipt) { - const ERC20_TRACE_EVENT_HASH: [u8; 32] = hex!("31738ac4a7c9a10ecbbfd3fed5037971ba81b8f6aa4f72a23f5364e9bc76d671"); - const BALANCE_TRACKER_TRACE_EVENT_HASH: [u8; 32] = hex!("63f1e32b72965e2be75e03024856287aff9e4cdbcec65869c51014fc2c1c95d9"); - - const EVENT_HASHES: [&[u8]; 2] = [&ERC20_TRACE_EVENT_HASH, &BALANCE_TRACKER_TRACE_EVENT_HASH]; - let receipt_logs = receipt.inner.logs(); for (execution_log, receipt_log) in self.logs.iter_mut().zip(receipt_logs) { - let execution_log_matches = || execution_log.topic0.is_some_and(|topic| EVENT_HASHES.contains(&topic.as_ref())); - let receipt_log_matches = || receipt_log.topics().first().is_some_and(|topic| EVENT_HASHES.contains(&topic.as_ref())); + let execution_log_matches = || execution_log.topic0.is_some_and(|topic| GAS_LEFT_EVENT_HASHES.contains(&topic.as_ref())); + let receipt_log_matches = || { + receipt_log + .topics() + .first() + .is_some_and(|topic| GAS_LEFT_EVENT_HASHES.contains(&topic.as_ref())) + }; // only try overwriting if both logs refer to the target event let should_overwrite = execution_log_matches() && receipt_log_matches(); @@ -390,14 +399,9 @@ impl TransactionExecutionOutput { /// Imported transactions are re-executed locally with a different amount of gas limit, so rely /// on the stored logs to copy the `gasLeft` values, mirroring [`Self::fix_logs_gas_left`]. fn fix_logs_gas_left_from_stored(&mut self, stored_logs: &[Log]) { - const ERC20_TRACE_EVENT_HASH: [u8; 32] = hex!("31738ac4a7c9a10ecbbfd3fed5037971ba81b8f6aa4f72a23f5364e9bc76d671"); - const BALANCE_TRACKER_TRACE_EVENT_HASH: [u8; 32] = hex!("63f1e32b72965e2be75e03024856287aff9e4cdbcec65869c51014fc2c1c95d9"); - - const EVENT_HASHES: [&[u8]; 2] = [&ERC20_TRACE_EVENT_HASH, &BALANCE_TRACKER_TRACE_EVENT_HASH]; - for (execution_log, stored_log) in self.logs.iter_mut().zip(stored_logs) { - let execution_log_matches = || execution_log.topic0.is_some_and(|topic| EVENT_HASHES.contains(&topic.0.as_ref())); - let stored_log_matches = || stored_log.topic0.is_some_and(|topic| EVENT_HASHES.contains(&topic.0.as_ref())); + let execution_log_matches = || execution_log.topic0.is_some_and(|topic| GAS_LEFT_EVENT_HASHES.contains(&topic.0.as_ref())); + let stored_log_matches = || stored_log.topic0.is_some_and(|topic| GAS_LEFT_EVENT_HASHES.contains(&topic.0.as_ref())); // only try overwriting if both logs refer to the target event let should_overwrite = execution_log_matches() && stored_log_matches(); diff --git a/src/eth/executor/mod.rs b/src/eth/executor/mod.rs index 6617657a3..3b26e64fe 100644 --- a/src/eth/executor/mod.rs +++ b/src/eth/executor/mod.rs @@ -265,19 +265,13 @@ impl Executor { tx: TransactionMined, block_number: BlockNumber, ) -> anyhow::Result<()> { + let tx_input = tx.execution.transaction_input(); let TransactionExecution { - info, - signature, input: stored_input, output: stored_output, + .. } = tx.execution; - let tx_input = TransactionInput { - transaction_info: info, - execution_info: stored_input.clone().into(), - signature, - }; - let recovered = tx_input.recover_signer_address()?; let mut evm_input = stored_input; @@ -286,9 +280,7 @@ impl Executor { let (tx_execution, state) = match stored_output.result.is_success() { // successful imported transaction, re-execute locally - true => { - Self::reexecute_imported_transaction(evm, &tx_input, evm_input, &stored_output, gas_price, recovered, block_number)? - } + true => Self::reexecute_imported_transaction(evm, &tx_input, evm_input, &stored_output, gas_price, recovered, block_number)?, // failed imported transaction, re-create from the stored execution without re-executing false => Self::recreate_failed_imported_transaction(storage, &tx_input, evm_input, &stored_output, gas_price, recovered)?, }; diff --git a/src/eth/executor/types/transaction_execution.rs b/src/eth/executor/types/transaction_execution.rs index ff3af332f..aa266f3f0 100644 --- a/src/eth/executor/types/transaction_execution.rs +++ b/src/eth/executor/types/transaction_execution.rs @@ -30,6 +30,15 @@ pub struct TransactionExecution { } impl TransactionExecution { + /// Rebuilds the transaction input that produced this execution. + pub fn transaction_input(&self) -> TransactionInput { + TransactionInput { + transaction_info: self.info, + execution_info: self.input.clone().into(), + signature: self.signature, + } + } + pub fn create_alloy_logs(&self) -> Vec { self.logs() .iter() diff --git a/src/eth/follower/importer/config.rs b/src/eth/follower/importer/config.rs index a4e35dedd..5308779b8 100644 --- a/src/eth/follower/importer/config.rs +++ b/src/eth/follower/importer/config.rs @@ -82,7 +82,7 @@ pub struct ImporterConfig { pub stop_at_block: Option, /// Response format used by the block-and-receipts importer RPC (importer online). - #[arg(id = "importer.response_format", long = "response-format", default_value = "alloy", required = false)] + #[arg(id = "importer.response_format", long = "response-format", default_value = "alloy")] pub response_format: ResponseFormat, } diff --git a/src/eth/follower/importer/importers/blockchain_client.rs b/src/eth/follower/importer/importers/blockchain_client.rs index 68583f1c3..48dd2a053 100644 --- a/src/eth/follower/importer/importers/blockchain_client.rs +++ b/src/eth/follower/importer/importers/blockchain_client.rs @@ -206,8 +206,7 @@ impl BlockchainClient { let value = match serde_json::from_str::(full.get()) { Ok(value) => value, Err(e) if format == Some(ResponseFormat::Stratus) && Self::is_legacy_alloy_response(full.get()) => { - let _ = e; - tracing::error!(method, "leader answered the stratus format request with the legacy alloy format"); + tracing::error!(reason = ?e, method, "leader answered the stratus format request with the legacy alloy format"); anyhow::bail!( "leader answered the stratus format request with the legacy alloy format, \ which means it likely runs an old version without stratus response format support; \ diff --git a/src/eth/follower/importer/importers/fake_leader.rs b/src/eth/follower/importer/importers/fake_leader.rs index d9cd458d9..4945bd532 100644 --- a/src/eth/follower/importer/importers/fake_leader.rs +++ b/src/eth/follower/importer/importers/fake_leader.rs @@ -6,7 +6,6 @@ use stratus_metrics::timed; use crate::GlobalState; use crate::eth::executor::Executor; use crate::eth::executor::ExecutorError; -use crate::eth::executor::types::TransactionExecution; use crate::eth::follower::importer::fetchers::DataFetcher; use crate::eth::follower::importer::fetchers::block_with_receipts::FetchedBlockWithReceipts; use crate::eth::follower::importer::fetchers::fake_leader::FakeLeaderFetcher; @@ -53,17 +52,7 @@ impl ImporterWorker for FakeLeaderWorker { let transactions = std::mem::take(&mut block.transactions) .into_iter() .map(|tx| -> anyhow::Result { - let TransactionExecution { - info, - signature, - input: stored_input, - output: _, - } = tx.execution; - let tx_input = TransactionInput { - transaction_info: info, - execution_info: stored_input.into(), - signature, - }; + let tx_input = tx.execution.transaction_input(); tx_input.recover_signer_address()?; Ok(tx_input) }) From 4880f102fc1fa2df98203eb9fc9b0536dec42373 Mon Sep 17 00:00:00 2001 From: gventino-cw Date: Tue, 29 Sep 2026 16:31:40 -0300 Subject: [PATCH 05/10] chore: restore nightly-2026-05-08 match-arm formatting --- src/eth/follower/importer/mod.rs | 25 ++++++++++--------------- src/eth/rpc/server.rs | 5 ++--- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/src/eth/follower/importer/mod.rs b/src/eth/follower/importer/mod.rs index 5f3fe489b..e083e9b23 100644 --- a/src/eth/follower/importer/mod.rs +++ b/src/eth/follower/importer/mod.rs @@ -160,21 +160,18 @@ async fn start_number_fetcher(chain: Arc, sync_interval: Durat set_external_rpc_current_block(block.number()); continue; } - Ok(None) => { + Ok(None) => if !should_shutdown(TASK_NAME) { tracing::error!("{} newHeads subscription closed by the other side", TASK_NAME); - } - } - Ok(Some(Err(e))) => { + }, + Ok(Some(Err(e))) => if !should_shutdown(TASK_NAME) { tracing::error!(reason = ?e, "{} failed to read newHeads subscription event", TASK_NAME); - } - } - Err(_) => { + }, + Err(_) => if !should_shutdown(TASK_NAME) { tracing::error!("{} timed-out waiting for newHeads subscription event", TASK_NAME); - } - } + }, } if should_shutdown(TASK_NAME) { @@ -190,11 +187,10 @@ async fn start_number_fetcher(chain: Arc, sync_interval: Durat tracing::info!("{} resubscribed to newHeads event", TASK_NAME); sub_new_heads = Some(sub); } - Err(e) => { + Err(e) => if !should_shutdown(TASK_NAME) { tracing::error!(reason = ?e, "{} failed to resubscribe to newHeads event", TASK_NAME); - } - } + }, } } } @@ -215,11 +211,10 @@ async fn start_number_fetcher(chain: Arc, sync_interval: Durat set_external_rpc_current_block(block_number); traced_sleep(sync_interval, SleepReason::SyncData).await; } - Err(e) => { + Err(e) => if !should_shutdown(TASK_NAME) { tracing::error!(reason = ?e, "failed to retrieve block number. retrying now."); - } - } + }, } } } diff --git a/src/eth/rpc/server.rs b/src/eth/rpc/server.rs index 9904a730a..9c9e84e30 100644 --- a/src/eth/rpc/server.rs +++ b/src/eth/rpc/server.rs @@ -301,7 +301,7 @@ impl Server { async fn health(&self) -> bool { match GlobalState::get_node_mode() { NodeMode::Leader | NodeMode::FakeLeader => true, - NodeMode::Follower => { + NodeMode::Follower => if GlobalState::is_importer_shutdown() { tracing::warn!("stratus is unhealthy because importer is shutdown"); false @@ -313,8 +313,7 @@ impl Server { false } } - } - } + }, } } } From bbfc41125332d1e73f0bc2e19f7c783cc201472f Mon Sep 17 00:00:00 2001 From: gventino-cw Date: Wed, 30 Sep 2026 19:19:11 -0300 Subject: [PATCH 06/10] refactor(importer): collapse block-and-receipts importer to stratus-native format --- config/stratus-follower.toml | 3 - config/stratus.example.toml | 3 - src/config/loader.rs | 1 - src/eth/follower/importer/config.rs | 6 - .../importer/fetchers/block_with_receipts.rs | 101 ++------------- .../importer/importers/blockchain_client.rs | 122 +++--------------- .../follower/importer/importers/execution.rs | 56 +++----- .../importer/importers/fake_leader.rs | 41 ++---- src/eth/follower/importer/mod.rs | 5 +- src/eth/follower/importer/runtime.rs | 3 - src/eth/follower/importer/supervisor.rs | 18 +-- src/eth/rpc/mod.rs | 80 +++++------- src/eth/rpc/pagination.rs | 8 +- src/eth/rpc/server.rs | 1 - 14 files changed, 95 insertions(+), 353 deletions(-) diff --git a/config/stratus-follower.toml b/config/stratus-follower.toml index 88012bbd8..c518c236e 100644 --- a/config/stratus-follower.toml +++ b/config/stratus-follower.toml @@ -10,9 +10,6 @@ chain_id = 2008 [importer] external_rpc = "http://localhost:3000/" external_rpc_ws = "ws://localhost:3000/" -# Response format for stratus_getBlockAndReceipts importer requests: "alloy" (default) or "stratus". -# The stratus format serializes blocks with receipts directly from the stratus types. -# response_format = "stratus" [common.tracing] filter = "info,stratus::eth::rpc::rpc_subscriptions::rx=off,stratus::eth::consensus::rx=off,stratus::eth::consensus=off,jsonrpsee-server=debug" diff --git a/config/stratus.example.toml b/config/stratus.example.toml index 47baca76b..d3f57525e 100644 --- a/config/stratus.example.toml +++ b/config/stratus.example.toml @@ -137,9 +137,6 @@ chain_id = 2008 # async_threads = 4 # Compute an access list for transactions before forwarding them to the leader. # forward_access_list = true -# Response format for stratus_getBlockAndReceipts importer requests: "alloy" (default) or "stratus". -# The stratus format serializes blocks with receipts directly from the stratus types. -# response_format = "alloy" # Block number to stop importing at, e.g. `0x2a`. When absent, imports indefinitely. # stop_at_block = "0x2a" diff --git a/src/config/loader.rs b/src/config/loader.rs index 8c5c83dc9..dacf4e79c 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -618,7 +618,6 @@ mod tests { async_threads = 4 forward_access_list = false stop_at_block = "0x2a" - response_format = "stratus" [kafka] bootstrap_servers = "localhost:29092" diff --git a/src/eth/follower/importer/config.rs b/src/eth/follower/importer/config.rs index e76419e16..5e59373a7 100644 --- a/src/eth/follower/importer/config.rs +++ b/src/eth/follower/importer/config.rs @@ -17,7 +17,6 @@ use crate::eth::follower::importer::ImporterRuntimeConfig; use crate::eth::follower::importer::supervisor::ImporterConsensus; use crate::eth::miner::Miner; use crate::eth::rpc::RpcContext; -use crate::eth::rpc::pagination::ResponseFormat; use crate::eth::storage::StratusStorage; use crate::eth::types::BlockNumber; use crate::eth::types::StateError; @@ -80,10 +79,6 @@ pub struct ImporterConfig { /// Specify the block to stop importing. (useful for validating a follower db against a fake leader) #[arg(id = "importer.stop_at_block", long = "stop-at-block")] pub stop_at_block: Option, - - /// Response format used by the block-and-receipts importer RPC (importer online). - #[arg(id = "importer.response_format", long = "response-format", default_value = "alloy")] - pub response_format: ResponseFormat, } impl ImporterConfig { @@ -144,7 +139,6 @@ impl ImporterConfig { let importer_runtime = ImporterRuntime::start(ImporterRuntimeConfig { async_threads: self.async_threads, importer_mode, - response_format: self.response_format, external_rpc: external_rpc.to_string(), external_rpc_ws: self.external_rpc_ws.clone(), external_rpc_timeout: self.external_rpc_timeout, diff --git a/src/eth/follower/importer/fetchers/block_with_receipts.rs b/src/eth/follower/importer/fetchers/block_with_receipts.rs index 27f058a52..d10b26556 100644 --- a/src/eth/follower/importer/fetchers/block_with_receipts.rs +++ b/src/eth/follower/importer/fetchers/block_with_receipts.rs @@ -1,116 +1,37 @@ use std::sync::Arc; -use alloy_rpc_types_eth::BlockTransactions; -use anyhow::anyhow; -use anyhow::bail; - use crate::eth::follower::importer::BlockchainClient; use crate::eth::follower::importer::fetch_with_retry; use crate::eth::follower::importer::fetchers::DataFetcher; -use crate::eth::rpc::pagination::ResponseFormat; use crate::eth::types::Block; use crate::eth::types::BlockNumber; -use crate::eth::types::ExternalBlock; -use crate::eth::types::ExternalReceipt; - -/// Block with receipts fetched from the leader, in either supported response format. -#[allow(clippy::large_enum_variant)] -#[derive(Debug, Clone)] -pub enum FetchedBlockWithReceipts { - /// Legacy alloy format: block and receipts as separate alloy RPC types. - Alloy { block: ExternalBlock, receipts: Vec }, - - /// Stratus-native format: block with receipts embedded, deserialized from the storage DTO. - Stratus(Block), -} - -impl FetchedBlockWithReceipts { - /// Returns the block number, regardless of the format it was fetched in. - pub fn block_number(&self) -> BlockNumber { - match self { - Self::Alloy { block, .. } => block.number(), - Self::Stratus(block) => block.number(), - } - } - - /// Returns the number of transactions (and therefore of receipts), regardless of the format. - pub fn receipts_len(&self) -> usize { - match self { - Self::Alloy { receipts, .. } => receipts.len(), - Self::Stratus(block) => block.transactions.len(), - } - } -} pub struct BlockWithReceiptsFetcher { pub chain: Arc, - pub response_format: ResponseFormat, } impl DataFetcher for BlockWithReceiptsFetcher { - type FetchedType = FetchedBlockWithReceipts; - type PostProcessType = FetchedBlockWithReceipts; + type FetchedType = Block; + type PostProcessType = Block; async fn fetch(&self, block_number: BlockNumber) -> Self::FetchedType { let fetch_fn = |bn| { let chain = Arc::clone(&self.chain); - let response_format = self.response_format; - async move { chain.fetch_block_and_receipts(bn, response_format).await } + async move { chain.fetch_block_and_receipts(bn).await } }; fetch_with_retry(block_number, fetch_fn, "block and receipts").await } - async fn post_process(&self, data: Self::FetchedType) -> anyhow::Result { - match data { - FetchedBlockWithReceipts::Alloy { mut block, mut receipts } => { - let block_number = block.number(); - let BlockTransactions::Full(transactions) = &mut block.transactions else { - bail!("expected full transactions, got hashes or uncle"); - }; - - if transactions.len() != receipts.len() { - bail!( - "block {} has mismatched transaction and receipt length: {} transactions but {} receipts", - block_number, - transactions.len(), - receipts.len() - ); - } - - // Stably sort transactions and receipts by transaction_index - transactions.sort_by_key(|a| a.transaction_index); - receipts.sort_by_key(|a| a.transaction_index); - - // perform additional checks on the transaction index - for window in transactions.windows(2) { - let tx_index = window[0].transaction_index.ok_or(anyhow!("missing transaction index"))? as u32; - let next_tx_index = window[1].transaction_index.ok_or(anyhow!("missing transaction index"))? as u32; - if tx_index + 1 != next_tx_index { - tracing::error!(tx_index, next_tx_index, "two consecutive transactions must have consecutive indices"); - } - } - for window in receipts.windows(2) { - let tx_index = window[0].transaction_index.ok_or(anyhow!("missing transaction index"))? as u32; - let next_tx_index = window[1].transaction_index.ok_or(anyhow!("missing transaction index"))? as u32; - if tx_index + 1 != next_tx_index { - tracing::error!(tx_index, next_tx_index, "two consecutive receipts must have consecutive indices"); - } - } - - Ok(FetchedBlockWithReceipts::Alloy { block, receipts }) - } - FetchedBlockWithReceipts::Stratus(block) => { - for window in block.transactions.windows(2) { - let tx_index = window[0].mined_data.index.0; - let next_tx_index = window[1].mined_data.index.0; - if tx_index + 1 != next_tx_index { - tracing::error!(tx_index, next_tx_index, "two consecutive transactions must have consecutive indices"); - } - } - - Ok(FetchedBlockWithReceipts::Stratus(block)) + async fn post_process(&self, block: Self::FetchedType) -> anyhow::Result { + for window in block.transactions.windows(2) { + let tx_index = window[0].mined_data.index.0; + let next_tx_index = window[1].mined_data.index.0; + if tx_index + 1 != next_tx_index { + tracing::error!(tx_index, next_tx_index, "two consecutive transactions must have consecutive indices"); } } + + Ok(block) } } diff --git a/src/eth/follower/importer/importers/blockchain_client.rs b/src/eth/follower/importer/importers/blockchain_client.rs index 48dd2a053..523034481 100644 --- a/src/eth/follower/importer/importers/blockchain_client.rs +++ b/src/eth/follower/importer/importers/blockchain_client.rs @@ -18,20 +18,16 @@ use tokio::sync::RwLockReadGuard; use crate::GlobalState; use crate::alias::AlloyBytes; -use crate::alias::AlloyTransaction; +use crate::alias::AlloyHeader; use crate::alias::JsonValue; use crate::eth::executor::AccessListOutput; use crate::eth::executor::ExecutorError; -use crate::eth::follower::importer::fetchers::block_with_receipts::FetchedBlockWithReceipts; use crate::eth::rpc::pagination; -use crate::eth::rpc::pagination::ResponseFormat; use crate::eth::storage::permanent::rocks::types::BlockChangesRocksdb; use crate::eth::storage::permanent::rocks::types::BlockRocksdb; use crate::eth::types::Address; +use crate::eth::types::Block; use crate::eth::types::BlockNumber; -use crate::eth::types::ExternalBlock; -use crate::eth::types::ExternalBlockWithReceipts; -use crate::eth::types::ExternalReceipt; use crate::eth::types::Hash; use crate::eth::types::StratusError; use crate::eth::types::Wei; @@ -194,48 +190,22 @@ impl BlockchainClient { /// Sends the pagination capability parameter so a pagination-aware leader can split /// responses that do not fit in a single message (see `eth::rpc::pagination`). Old leaders /// ignore the extra parameter and answer normally, which is handled transparently. - async fn request_importer_data( - &self, - method: &'static str, - block_number: BlockNumber, - format: Option, - ) -> anyhow::Result> { - let Some(full) = self.fetch_serialized_response(method, block_number, format).await? else { + async fn request_importer_data(&self, method: &'static str, block_number: BlockNumber) -> anyhow::Result> { + let Some(full) = self.fetch_serialized_response(method, block_number).await? else { return Ok(None); // block not available yet }; - let value = match serde_json::from_str::(full.get()) { - Ok(value) => value, - Err(e) if format == Some(ResponseFormat::Stratus) && Self::is_legacy_alloy_response(full.get()) => { - tracing::error!(reason = ?e, method, "leader answered the stratus format request with the legacy alloy format"); - anyhow::bail!( - "leader answered the stratus format request with the legacy alloy format, \ - which means it likely runs an old version without stratus response format support; \ - set importer response_format back to alloy or upgrade the leader" - ); - } - Err(e) => return Err(e).with_context(|| format!("failed to deserialize importer data from {method}")), - }; + let value = serde_json::from_str::(full.get()).with_context(|| format!("failed to deserialize importer data from {method}"))?; Ok(Some(value)) } - /// Checks whether a response has the legacy alloy shape (top-level `block` and `receipts` objects). - fn is_legacy_alloy_response(response: &str) -> bool { - serde_json::from_str::(response).is_ok_and(|value| value.get("block").is_some() && value.get("receipts").is_some()) - } - /// Fetches the full serialized response for an importer method, reassembling pagination chunks. - async fn fetch_serialized_response( - &self, - method: &'static str, - block_number: BlockNumber, - format: Option, - ) -> anyhow::Result>> { + async fn fetch_serialized_response(&self, method: &'static str, block_number: BlockNumber) -> anyhow::Result>> { tracing::debug!(%block_number, method, "fetching importer data"); let number = to_json_value(block_number); // first request from offset zero - let params = [number.clone(), pagination::request_params(0, format)]; + let params = [number.clone(), pagination::request_params(0)]; let result = self.http.request::>, _>(method, params).await; let raw = match result { Ok(Some(raw)) => raw, @@ -272,7 +242,7 @@ impl BlockchainClient { } // next chunk from the current offset - let params = [number.clone(), pagination::request_params(reassembler.next_offset(), format)]; + let params = [number.clone(), pagination::request_params(reassembler.next_offset())]; let result = self.http.request::, _>(method, params).await; let raw = match result { Ok(raw) => raw, @@ -291,33 +261,13 @@ impl BlockchainClient { } /// Fetches a block by number with receipts. - pub async fn fetch_block_and_receipts( - &self, - block_number: BlockNumber, - response_format: ResponseFormat, - ) -> anyhow::Result> { - tracing::debug!(%block_number, %response_format, "fetching block"); + pub async fn fetch_block_and_receipts(&self, block_number: BlockNumber) -> anyhow::Result> { + tracing::debug!(%block_number, "fetching block"); const METHOD: &str = "stratus_getBlockAndReceipts"; - let result = match response_format { - ResponseFormat::Alloy => self - .request_importer_data::(METHOD, block_number, Some(response_format)) - .await - .map(|block| { - block.map(|response| FetchedBlockWithReceipts::Alloy { - block: response.block, - receipts: response.receipts, - }) - }), - ResponseFormat::Stratus => self - .request_importer_data::(METHOD, block_number, Some(response_format)) - .await - .map(|block| block.map(|block| FetchedBlockWithReceipts::Stratus(block.into()))), - }; - - match result { - Ok(block) => Ok(block), + match self.request_importer_data::(METHOD, block_number).await { + Ok(block) => Ok(block.map(|block| block.into())), Err(e) => log_and_err!(reason = e, "failed to fetch block with receipts"), } } @@ -326,54 +276,12 @@ impl BlockchainClient { pub async fn fetch_block_with_changes(&self, block_number: BlockNumber) -> anyhow::Result> { tracing::debug!(%block_number, "fetching block with changes"); - match self.request_importer_data("stratus_getBlockWithChanges", block_number, None).await { + match self.request_importer_data("stratus_getBlockWithChanges", block_number).await { Ok(block) => Ok(block), Err(e) => log_and_err!(reason = e, "failed to fetch block with changes"), } } - /// Fetches a block by number. - pub async fn fetch_block(&self, block_number: BlockNumber) -> anyhow::Result> { - tracing::debug!(%block_number, "fetching block"); - - let number = to_json_value(block_number); - let result = self - .http - .request::, _>("eth_getBlockByNumber", [number, JsonValue::Bool(true)]) - .await; - - match result { - Ok(block) => Ok(block), - Err(e) => log_and_err!(reason = e, "failed to fetch block by number"), - } - } - - /// Fetches a transaction by hash. - pub async fn fetch_transaction(&self, tx_hash: Hash) -> anyhow::Result> { - tracing::debug!(%tx_hash, "fetching transaction"); - - let hash = to_json_value(tx_hash); - let result = self.http.request::, _>("eth_getTransactionByHash", [hash]).await; - - match result { - Ok(tx) => Ok(tx), - Err(e) => log_and_err!(reason = e, "failed to fetch transaction by hash"), - } - } - - /// Fetches a receipt by hash. - pub async fn fetch_receipt(&self, tx_hash: Hash) -> anyhow::Result> { - tracing::debug!(%tx_hash, "fetching transaction receipt"); - - let hash = to_json_value(tx_hash); - let result = self.http.request::, _>("eth_getTransactionReceipt", [hash]).await; - - match result { - Ok(receipt) => Ok(receipt), - Err(e) => log_and_err!(reason = e, "failed to fetch transaction receipt by hash"), - } - } - /// Fetches account balance by address and block number. pub async fn fetch_balance(&self, address: Address, block_number: Option) -> anyhow::Result { tracing::debug!(%address, block_number = %block_number.or_empty(), "fetching account balance"); @@ -415,7 +323,7 @@ impl BlockchainClient { // RPC subscriptions // ------------------------------------------------------------------------- - pub async fn subscribe_new_heads(&self) -> anyhow::Result> { + pub async fn subscribe_new_heads(&self) -> anyhow::Result> { const TASK_NAME: &str = "blockchain::subscribe_new_heads"; tracing::debug!("subscribing to newHeads event"); @@ -427,7 +335,7 @@ impl BlockchainClient { let ws_read = self.require_ws().await?; let result = ws_read - .subscribe::("eth_subscribe", [JsonValue::String("newHeads".to_owned())], "eth_unsubscribe") + .subscribe::("eth_subscribe", [JsonValue::String("newHeads".to_owned())], "eth_unsubscribe") .await; match result { diff --git a/src/eth/follower/importer/importers/execution.rs b/src/eth/follower/importer/importers/execution.rs index f12c25c8b..3861e518c 100644 --- a/src/eth/follower/importer/importers/execution.rs +++ b/src/eth/follower/importer/importers/execution.rs @@ -4,13 +4,12 @@ use stratus_metrics::timed; use crate::GlobalState; use crate::eth::executor::Executor; -use crate::eth::follower::importer::fetchers::block_with_receipts::FetchedBlockWithReceipts; use crate::eth::follower::importer::importers::ImportData; use crate::eth::follower::importer::importers::ImporterWorker; use crate::eth::follower::importer::send_block_to_kafka; use crate::eth::miner::Miner; use crate::eth::miner::miner::CommitItem; -use crate::eth::types::ExternalReceipts; +use crate::eth::types::Block; use crate::infra::kafka::KafkaConnector; use crate::log_and_err; @@ -22,53 +21,32 @@ pub struct ReexecutionWorker { impl ImportData for ::DataType { fn block_number(&self) -> crate::eth::types::BlockNumber { - self.block_number() + self.number() } } impl ImporterWorker for ReexecutionWorker { - type DataType = FetchedBlockWithReceipts; + type DataType = Block; #[timed(import_online_mined_block)] async fn import(&self, block: Self::DataType) -> anyhow::Result { const TASK_NAME: &str = "block-executor"; - let receipts_len = block.receipts_len(); + let receipts_len = block.transactions.len(); - let (mined_block, changes) = match block { - FetchedBlockWithReceipts::Alloy { block, receipts } => { - if let Err(e) = self.executor.execute_external_block(block.clone(), ExternalReceipts::from(receipts)) { - let message = GlobalState::shutdown_from(TASK_NAME, "failed to reexecute external block"); - return log_and_err!(reason = e, message); - }; + if let Err(e) = self.executor.execute_imported_block(block.clone()) { + let message = GlobalState::shutdown_from(TASK_NAME, "failed to reexecute imported block"); + return log_and_err!(reason = e, message); + }; - match self.miner.mine_external(block) { - Ok((mined_block, changes)) => { - tracing::info!(number = %mined_block.number(), "mined external block"); - (mined_block, changes) - } - Err(e) => { - let message = GlobalState::shutdown_from(TASK_NAME, "failed to mine external block"); - return log_and_err!(reason = e, message); - } - } + let (mined_block, changes) = match self.miner.mine_imported(block) { + Ok((mined_block, changes)) => { + tracing::info!(number = %mined_block.number(), "mined imported block"); + (mined_block, changes) } - FetchedBlockWithReceipts::Stratus(block) => { - if let Err(e) = self.executor.execute_imported_block(block.clone()) { - let message = GlobalState::shutdown_from(TASK_NAME, "failed to reexecute imported block"); - return log_and_err!(reason = e, message); - }; - - match self.miner.mine_imported(block) { - Ok((mined_block, changes)) => { - tracing::info!(number = %mined_block.number(), "mined imported block"); - (mined_block, changes) - } - Err(e) => { - let message = GlobalState::shutdown_from(TASK_NAME, "failed to mine imported block"); - return log_and_err!(reason = e, message); - } - } + Err(e) => { + let message = GlobalState::shutdown_from(TASK_NAME, "failed to mine imported block"); + return log_and_err!(reason = e, message); } }; @@ -76,10 +54,10 @@ impl ImporterWorker for ReexecutionWorker { match self.miner.commit(CommitItem::Block(mined_block), changes) { Ok(_) => { - tracing::info!("committed external block"); + tracing::info!("committed imported block"); } Err(e) => { - let message = GlobalState::shutdown_from(TASK_NAME, "failed to commit external block"); + let message = GlobalState::shutdown_from(TASK_NAME, "failed to commit imported block"); return log_and_err!(reason = e, message); } } diff --git a/src/eth/follower/importer/importers/fake_leader.rs b/src/eth/follower/importer/importers/fake_leader.rs index 4945bd532..123a838aa 100644 --- a/src/eth/follower/importer/importers/fake_leader.rs +++ b/src/eth/follower/importer/importers/fake_leader.rs @@ -7,7 +7,6 @@ use crate::GlobalState; use crate::eth::executor::Executor; use crate::eth::executor::ExecutorError; use crate::eth::follower::importer::fetchers::DataFetcher; -use crate::eth::follower::importer::fetchers::block_with_receipts::FetchedBlockWithReceipts; use crate::eth::follower::importer::fetchers::fake_leader::FakeLeaderFetcher; use crate::eth::follower::importer::importers::ImportData; use crate::eth::follower::importer::importers::ImporterWorker; @@ -25,7 +24,7 @@ pub struct FakeLeaderWorker { impl ImportData for ::DataType { fn block_number(&self) -> crate::eth::types::BlockNumber { - self.0.block_number() + self.0.number() } } @@ -33,33 +32,17 @@ impl ImporterWorker for FakeLeaderWorker { type DataType = ::PostProcessType; #[timed(import_online_mined_block)] - async fn import(&self, (fetched, (expected_block, expected_changes)): Self::DataType) -> anyhow::Result { - let (block_tx_len, transactions) = match fetched { - FetchedBlockWithReceipts::Alloy { block, .. } => { - let block_tx_len = block.transactions.len(); - self.storage.set_pending_from_external(&block); - let transactions = block - .0 - .transactions - .into_transactions() - .map(|tx| tx.try_into()) - .collect::, _>>()?; - (block_tx_len, transactions) - } - FetchedBlockWithReceipts::Stratus(mut block) => { - let block_tx_len = block.transactions.len(); - self.storage.set_pending_header(block.number(), block.timestamp()); - let transactions = std::mem::take(&mut block.transactions) - .into_iter() - .map(|tx| -> anyhow::Result { - let tx_input = tx.execution.transaction_input(); - tx_input.recover_signer_address()?; - Ok(tx_input) - }) - .collect::>>()?; - (block_tx_len, transactions) - } - }; + async fn import(&self, (mut block, (expected_block, expected_changes)): Self::DataType) -> anyhow::Result { + let block_tx_len = block.transactions.len(); + self.storage.set_pending_header(block.number(), block.timestamp()); + let transactions = std::mem::take(&mut block.transactions) + .into_iter() + .map(|tx| -> anyhow::Result { + let tx_input = tx.execution.transaction_input(); + tx_input.recover_signer_address()?; + Ok(tx_input) + }) + .collect::>>()?; for tx in transactions { tracing::info!(?tx, "executing tx as fake miner"); diff --git a/src/eth/follower/importer/mod.rs b/src/eth/follower/importer/mod.rs index e083e9b23..c07c5d9c8 100644 --- a/src/eth/follower/importer/mod.rs +++ b/src/eth/follower/importer/mod.rs @@ -12,7 +12,6 @@ use std::time::Duration; use anyhow::bail; pub use config::ImporterConfig; -pub use fetchers::block_with_receipts::FetchedBlockWithReceipts; pub use importers::BlockchainClient; pub use runtime::ImporterRuntime; pub use runtime::ImporterRuntimeConfig; @@ -156,8 +155,8 @@ async fn start_number_fetcher(chain: Arc, sync_interval: Durat tracing::info!("{} awaiting block number from newHeads subscription", TASK_NAME); match timeout(TIMEOUT_NEW_HEADS, sub.next()).await { Ok(Some(Ok(block))) => { - tracing::info!(block_number = %block.number(), "{} received newHeads event", TASK_NAME); - set_external_rpc_current_block(block.number()); + tracing::info!(block_number = %block.number, "{} received newHeads event", TASK_NAME); + set_external_rpc_current_block(BlockNumber::from(block.number)); continue; } Ok(None) => diff --git a/src/eth/follower/importer/runtime.rs b/src/eth/follower/importer/runtime.rs index 5ee9e6329..e9592f917 100644 --- a/src/eth/follower/importer/runtime.rs +++ b/src/eth/follower/importer/runtime.rs @@ -10,7 +10,6 @@ use crate::eth::follower::importer::BlockchainClient; use crate::eth::follower::importer::ImporterMode; use crate::eth::follower::importer::supervisor::start_importer; use crate::eth::miner::Miner; -use crate::eth::rpc::pagination::ResponseFormat; use crate::eth::storage::StratusStorage; use crate::eth::types::BlockNumber; use crate::infra::kafka::KafkaConnector; @@ -24,7 +23,6 @@ pub struct ImporterRuntime { pub struct ImporterRuntimeConfig { pub async_threads: usize, pub importer_mode: ImporterMode, - pub response_format: ResponseFormat, pub external_rpc: String, pub external_rpc_ws: Option, pub external_rpc_timeout: Duration, @@ -113,7 +111,6 @@ fn run_importer_runtime(config: ImporterRuntimeConfig, shutdown: CancellationTok tokio::select! { result = start_importer( config.importer_mode, - config.response_format, config.storage, config.executor, config.miner, diff --git a/src/eth/follower/importer/supervisor.rs b/src/eth/follower/importer/supervisor.rs index 87a03f7e0..07a4cfe37 100644 --- a/src/eth/follower/importer/supervisor.rs +++ b/src/eth/follower/importer/supervisor.rs @@ -26,7 +26,6 @@ use crate::eth::follower::importer::importers::fake_leader::FakeLeaderWorker; use crate::eth::follower::importer::importers::replication::ReplicationWorker; use crate::eth::follower::importer::start_number_fetcher; use crate::eth::miner::Miner; -use crate::eth::rpc::pagination::ResponseFormat; use crate::eth::storage::StratusStorage; use crate::eth::types::BlockNumber; use crate::ext::spawn; @@ -47,13 +46,7 @@ where } impl ReexecutionFollower { - fn new( - executor: Arc, - miner: Arc, - chain: Arc, - kafka_connector: Option, - response_format: ResponseFormat, - ) -> Self { + fn new(executor: Arc, miner: Arc, chain: Arc, kafka_connector: Option) -> Self { let importer = ReexecutionWorker { executor, miner, @@ -62,7 +55,6 @@ impl ReexecutionFollower { let fetcher = BlockWithReceiptsFetcher { chain: Arc::clone(&chain), - response_format, }; Self { fetcher, importer } @@ -70,13 +62,12 @@ impl ReexecutionFollower { } impl FakeLeader { - fn new(executor: Arc, miner: Arc, storage: Arc, chain: Arc, response_format: ResponseFormat) -> Self { + fn new(executor: Arc, miner: Arc, storage: Arc, chain: Arc) -> Self { let importer = FakeLeaderWorker { executor, miner, storage }; let fetcher = FakeLeaderFetcher { block_with_receipts_fetcher: BlockWithReceiptsFetcher { chain: Arc::clone(&chain), - response_format, }, block_with_changes_fetcher: BlockWithChangesFetcher { chain }, }; @@ -133,7 +124,6 @@ where #[allow(clippy::too_many_arguments)] pub async fn start_importer( importer_mode: ImporterMode, - response_format: ResponseFormat, storage: Arc, executor: Arc, miner: Arc, @@ -151,12 +141,12 @@ pub async fn start_importer( .await?; } ImporterMode::ReexecutionFollower => { - ReexecutionFollower::new(executor, miner, Arc::clone(&chain), kafka_connector, response_format) + ReexecutionFollower::new(executor, miner, Arc::clone(&chain), kafka_connector) .run(resume_from, sync_interval, chain, stop_at_block) .await?; } ImporterMode::FakeLeader => { - FakeLeader::new(executor, miner, storage, Arc::clone(&chain), response_format) + FakeLeader::new(executor, miner, storage, Arc::clone(&chain)) .run(resume_from, sync_interval, chain, stop_at_block) .await?; } diff --git a/src/eth/rpc/mod.rs b/src/eth/rpc/mod.rs index 3a7f9a5e8..9741da566 100644 --- a/src/eth/rpc/mod.rs +++ b/src/eth/rpc/mod.rs @@ -87,10 +87,8 @@ mod tests { #[test] fn request_params_wire_format() { - // without a format the wire parameter is unchanged, so old leaders see the same bytes - assert_eq!(request_params(5, None), json!({"offset": 5})); - // with a format the field rides the pagination parameter for endpoints that support it - assert_eq!(request_params(5, Some(ResponseFormat::Stratus)), json!({"offset": 5, "format": "stratus"})); + // the wire parameter is unchanged, so old leaders see the same bytes + assert_eq!(request_params(5), json!({"offset": 5})); } #[test] @@ -325,19 +323,15 @@ mod wire_tests { use serde_json::json; use super::pagination::MAX_REASSEMBLY_TOTAL; - use super::pagination::ResponseFormat; use super::pagination::parse_request; use super::pagination::respond; use super::parser::next_rpc_param; use super::types::BlockFilter; use crate::alias::JsonValue; use crate::eth::follower::importer::BlockchainClient; - use crate::eth::follower::importer::FetchedBlockWithReceipts; use crate::eth::storage::permanent::rocks::types::BlockRocksdb; use crate::eth::types::Block; use crate::eth::types::BlockNumber; - use crate::eth::types::ExternalBlockWithReceipts; - use crate::eth::types::ExternalReceipt; use crate::eth::types::StratusError; use crate::eth::types::TransactionMined; use crate::ext::to_json_value; @@ -347,17 +341,18 @@ mod wire_tests { /// Response limit for both leader and follower sides in the tests below. const MAX_RESPONSE_BYTES: u32 = 2048; - /// Builds an importer response well above the response limits. - fn big_block_with_receipts() -> ExternalBlockWithReceipts { - let mut block = fake_first::(); - block.receipts = fake_list::(200); + /// Builds an importer block well above the response limits, in the stratus-native format. + fn big_block() -> Block { + let mut block = fake_first::(); + block.header.number = BlockNumber::from(1u32); + block.transactions = fake_list::(200); block } /// Asserts the serialized form of the test value cannot fit in the response limits. #[test] fn test_value_is_oversized() { - let value = to_json_value(big_block_with_receipts()); + let value = to_json_value(BlockRocksdb::from(big_block())); let serialized = serde_json::to_string(&value).expect("serialize"); assert!( serialized.len() > MAX_RESPONSE_BYTES as usize, @@ -368,8 +363,9 @@ mod wire_tests { #[tokio::test] async fn oversized_importer_response_is_paginated_over_the_wire() { - let expected = big_block_with_receipts(); - let storage = Arc::new(RwLock::new(to_json_value(expected.clone()))); + let expected_rocks = BlockRocksdb::from(big_block()); + let expected: Block = expected_rocks.clone().into(); + let storage = Arc::new(RwLock::new(to_json_value(expected_rocks))); // leader with a tiny response limit, using the same handler shape as the real one let server_config = jsonrpsee::server::ServerConfig::builder().max_response_body_size(MAX_RESPONSE_BYTES).build(); @@ -394,20 +390,17 @@ mod wire_tests { let url = format!("http://{addr}"); let client = BlockchainClient::new_http(&url, Duration::from_secs(10)).await.expect("build client"); - let fetched = client - .fetch_block_and_receipts(BlockNumber::from(1), ResponseFormat::Alloy) + let fetched_block = client + .fetch_block_and_receipts(BlockNumber::from(1)) .await - .expect("fetch block"); - let FetchedBlockWithReceipts::Alloy { block, receipts } = fetched.expect("block present") else { - panic!("expected alloy response"); - }; - assert_eq!(block, expected.block); - assert_eq!(receipts, expected.receipts); + .expect("fetch block") + .expect("block present"); + assert_eq!(fetched_block, expected); } #[tokio::test] async fn old_leader_without_pagination_still_fails_as_before() { - let storage = Arc::new(RwLock::new(to_json_value(big_block_with_receipts()))); + let storage = Arc::new(RwLock::new(to_json_value(BlockRocksdb::from(big_block())))); // old leader: ignores the extra pagination parameter, returns the full response let server_config = jsonrpsee::server::ServerConfig::builder().max_response_body_size(MAX_RESPONSE_BYTES).build(); @@ -431,22 +424,21 @@ mod wire_tests { let client = BlockchainClient::new_http(&url, Duration::from_secs(10)).await.expect("build client"); let error = client - .fetch_block_and_receipts(BlockNumber::from(1), ResponseFormat::Alloy) + .fetch_block_and_receipts(BlockNumber::from(1)) .await .expect_err("fetch should fail"); assert!(error.to_string().contains("failed to fetch block with receipts")); } #[tokio::test] - async fn stratus_format_against_old_leader_answers_with_clear_error() { - // minimal legacy alloy-shape response, so the old leader delivers it in a single message + async fn legacy_alloy_shape_fails_to_deserialize() { + // a leader still answering the pre-cleanup alloy shape now fails fast at deserialization let alloy_response = serde_json::json!({ "block": { "number": "0x1" }, "receipts": [], }); let storage = Arc::new(RwLock::new(alloy_response)); - // old leader: ignores the format parameter, answers with the legacy alloy shape let server_config = jsonrpsee::server::ServerConfig::builder().max_response_body_size(MAX_RESPONSE_BYTES).build(); let server = Server::builder().set_config(server_config).build("127.0.0.1:0").await.expect("build server"); let addr = server.local_addr().expect("server addr"); @@ -466,14 +458,11 @@ mod wire_tests { let url = format!("http://{addr}"); let client = BlockchainClient::new_http(&url, Duration::from_secs(10)).await.expect("build client"); - // the follower requests the stratus format; the error must explain the real cause let error = client - .fetch_block_and_receipts(BlockNumber::from(1), ResponseFormat::Stratus) + .fetch_block_and_receipts(BlockNumber::from(1)) .await .expect_err("fetch should fail"); - let message = format!("{error:#}"); - assert!(message.contains("legacy alloy format"), "unexpected error: {message}"); - assert!(message.contains("old version"), "unexpected error: {message}"); + assert!(format!("{error:#}").contains("failed to deserialize importer data")); } #[tokio::test] @@ -501,7 +490,7 @@ mod wire_tests { let client = BlockchainClient::new_http(&url, Duration::from_secs(10)).await.expect("build client"); let fetched = client - .fetch_block_and_receipts(BlockNumber::from(1), ResponseFormat::Alloy) + .fetch_block_and_receipts(BlockNumber::from(1)) .await .expect("fetch block"); assert!(fetched.is_none(), "null response must deserialize to Ok(None)"); @@ -534,7 +523,7 @@ mod wire_tests { let client = BlockchainClient::new_http(&url, Duration::from_secs(10)).await.expect("build client"); let error = client - .fetch_block_and_receipts(BlockNumber::from(1), ResponseFormat::Alloy) + .fetch_block_and_receipts(BlockNumber::from(1)) .await .expect_err("fetch should fail"); assert!(format!("{error:?}").contains("exceeds the reassembly cap")); @@ -573,19 +562,16 @@ mod wire_tests { let client = BlockchainClient::new_http(&url, Duration::from_secs(10)).await.expect("build client"); let error = client - .fetch_block_and_receipts(BlockNumber::from(1), ResponseFormat::Alloy) + .fetch_block_and_receipts(BlockNumber::from(1)) .await .expect_err("fetch should fail"); assert!(format!("{error:?}").contains("expected paginated chunk but got normal response")); } #[tokio::test] - async fn stratus_format_response_round_trips_and_format_rides_every_chunk() { + async fn stratus_native_response_round_trips_through_paginated_fetch() { // a big block in the stratus-native format, above the response limits - let mut block = fake_first::(); - block.header.number = BlockNumber::from(1u32); - block.transactions = fake_list::(200); - let expected_rocks = BlockRocksdb::from(block); + let expected_rocks = BlockRocksdb::from(big_block()); let expected: Block = expected_rocks.clone().into(); let storage = Arc::new(RwLock::new(to_json_value(expected_rocks))); @@ -602,8 +588,6 @@ mod wire_tests { .register_method("stratus_getBlockAndReceipts", |params, storage, _| { let (sequence, _filter) = next_rpc_param::(params.sequence())?; let pagination = parse_request(sequence)?; - // the response format must ride every chunk request so the leader keeps it stable - assert_eq!(pagination.as_ref().and_then(|params| params.format), Some(ResponseFormat::Stratus)); let value = storage.read().expect("read storage").clone(); respond(value, pagination, MAX_RESPONSE_BYTES) }) @@ -614,13 +598,11 @@ mod wire_tests { let url = format!("http://{addr}"); let client = BlockchainClient::new_http(&url, Duration::from_secs(10)).await.expect("build client"); - let fetched = client - .fetch_block_and_receipts(BlockNumber::from(1), ResponseFormat::Stratus) + let fetched_block = client + .fetch_block_and_receipts(BlockNumber::from(1)) .await - .expect("fetch block"); - let FetchedBlockWithReceipts::Stratus(fetched_block) = fetched.expect("block present") else { - panic!("expected stratus response"); - }; + .expect("fetch block") + .expect("block present"); assert_eq!(fetched_block, expected); } } diff --git a/src/eth/rpc/pagination.rs b/src/eth/rpc/pagination.rs index d30286525..a44683544 100644 --- a/src/eth/rpc/pagination.rs +++ b/src/eth/rpc/pagination.rs @@ -211,11 +211,9 @@ pub fn respond(value: JsonValue, pagination: Option, max_respo /// Builds pagination request params for the follower side. /// -/// When `format` is `None`, the serialized params are byte-identical to the pre-format wire -/// format (`{"offset": N}`); when set, the format rides alongside the offset so the leader -/// serializes every chunk of the response in the requested format. -pub fn request_params(offset: u64, format: Option) -> JsonValue { - to_json_value(PaginationParams { offset, format }) +/// The serialized params are byte-identical to the pre-format wire format (`{"offset": N}`). +pub fn request_params(offset: u64) -> JsonValue { + to_json_value(PaginationParams { offset, format: None }) } /// Progressive reassembly of a paginated response, with validation against a malicious peer. diff --git a/src/eth/rpc/server.rs b/src/eth/rpc/server.rs index dd27fcaec..772af35e5 100644 --- a/src/eth/rpc/server.rs +++ b/src/eth/rpc/server.rs @@ -698,7 +698,6 @@ async fn stratus_init_importer(params: Params<'_>, ctx: Arc, ext: Ex async_threads: 4, forward_access_list: true, stop_at_block: None, - response_format: ResponseFormat::default(), }; importer_config.init_follower_importer(ctx).await From e5b86e74ec3f6fd5ac0e5652922e0936eae9dc81 Mon Sep 17 00:00:00 2001 From: gventino-cw Date: Wed, 30 Sep 2026 19:21:15 -0300 Subject: [PATCH 07/10] refactor(rpc): remove response format negotiation from stratus_getBlockAndReceipts --- src/eth/rpc/mod.rs | 34 ++++++----------------------- src/eth/rpc/pagination.rs | 45 +-------------------------------------- src/eth/rpc/server.rs | 18 ++-------------- 3 files changed, 9 insertions(+), 88 deletions(-) diff --git a/src/eth/rpc/mod.rs b/src/eth/rpc/mod.rs index 9741da566..edfc69b22 100644 --- a/src/eth/rpc/mod.rs +++ b/src/eth/rpc/mod.rs @@ -46,7 +46,6 @@ mod tests { use super::pagination::PaginationEnvelope; use super::pagination::PaginationParams; use super::pagination::Reassembler; - use super::pagination::ResponseFormat; use super::pagination::is_envelope; use super::pagination::parse_envelope; use super::pagination::parse_request; @@ -57,32 +56,12 @@ mod tests { use crate::ext::InfallibleExt; #[test] - fn parse_request_without_format_decodes_like_before() { - // compatibility: the wire format without the format field must decode exactly as before + fn parse_request_decodes_offset() { let params = jsonrpsee::types::Params::new(Some(r#"["0x1", {"offset": 5}]"#)); let mut sequence = params.sequence(); sequence.optional_next::().expect("parse first").expect("present"); let pagination = parse_request(sequence).expect("parse request").expect("present"); assert_eq!(pagination.offset, 5); - assert_eq!(pagination.format, None); - } - - #[test] - fn parse_request_parses_format_when_present() { - let params = jsonrpsee::types::Params::new(Some(r#"["0x1", {"offset": 5, "format": "stratus"}]"#)); - let mut sequence = params.sequence(); - sequence.optional_next::().expect("parse first").expect("present"); - let pagination = parse_request(sequence).expect("parse request").expect("present"); - assert_eq!(pagination.offset, 5); - assert_eq!(pagination.format, Some(ResponseFormat::Stratus)); - } - - #[test] - fn parse_request_rejects_invalid_format() { - let params = jsonrpsee::types::Params::new(Some(r#"["0x1", {"offset": 5, "format": "yaml"}]"#)); - let mut sequence = params.sequence(); - sequence.optional_next::().expect("parse first").expect("present"); - assert!(matches!(parse_request(sequence), Err(RpcError::ParameterDecodeError { .. }))); } #[test] @@ -101,14 +80,14 @@ mod tests { #[test] fn respond_with_fitting_response_returns_full() { let value = json!({"block": "abc"}); - let raw = respond(value.clone(), Some(PaginationParams { offset: 0, format: None }), 1024).expect("respond"); + let raw = respond(value.clone(), Some(PaginationParams { offset: 0 }), 1024).expect("respond"); assert_eq!(raw.get(), serde_json::to_string(&value).expect_infallible()); } #[test] fn respond_with_oversized_response_returns_envelope() { let value = json!({"block": "a somewhat long value that will not fit"}); - let raw = respond(value.clone(), Some(PaginationParams { offset: 0, format: None }), MARGIN + 16).expect("respond"); + let raw = respond(value.clone(), Some(PaginationParams { offset: 0 }), MARGIN + 16).expect("respond"); let full = serde_json::to_string(&value).expect_infallible(); assert!(is_envelope(raw.get())); @@ -127,7 +106,7 @@ mod tests { let mut reassembler = Reassembler::new(0); let mut offset = 0; while offset < full.len() as u64 { - let raw = respond(value.clone(), Some(PaginationParams { offset, format: None }), limit).expect("respond"); + let raw = respond(value.clone(), Some(PaginationParams { offset }), limit).expect("respond"); assert!(is_envelope(raw.get()), "expected envelope at offset {offset}"); let envelope = parse_envelope(raw.get()).expect("parse envelope"); assert!( @@ -152,7 +131,7 @@ mod tests { let value = json!({"block": "some content"}); let full = serde_json::to_string(&value).expect("serialize"); - let result = respond(value, Some(PaginationParams { offset: 0, format: None }), full.len() as u32 + MARGIN).expect("should respond"); + let result = respond(value, Some(PaginationParams { offset: 0 }), full.len() as u32 + MARGIN).expect("should respond"); assert_eq!(result.get(), full); assert!(!is_envelope(result.get())); } @@ -160,7 +139,7 @@ mod tests { #[test] fn respond_with_offset_beyond_response_fails() { let value = json!({"block": "abc"}); - let error = respond(value, Some(PaginationParams { offset: 100, format: None }), MARGIN + 8).expect_err("should fail"); + let error = respond(value, Some(PaginationParams { offset: 100 }), MARGIN + 8).expect_err("should fail"); assert!(matches!(error, StratusError::RPC(RpcError::ParameterInvalid))); } @@ -181,7 +160,6 @@ mod tests { value, Some(PaginationParams { offset: misaligned as u64, - format: None, }), MARGIN + 8, ) diff --git a/src/eth/rpc/pagination.rs b/src/eth/rpc/pagination.rs index a44683544..7ae1e3b4c 100644 --- a/src/eth/rpc/pagination.rs +++ b/src/eth/rpc/pagination.rs @@ -75,49 +75,6 @@ pub const MAX_REASSEMBLY_TOTAL: u64 = 512 * 1024 * 1024; pub struct PaginationParams { /// Byte offset of the requested chunk within the serialized response. pub offset: u64, - - /// Response format requested from the leader for endpoints that support more than one serialization. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub format: Option, -} - -/// Response format for importer endpoints that support more than one serialization. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ResponseFormat { - /// Alloy JSON-RPC types (legacy). - #[default] - Alloy, - /// Stratus-native storage types. - Stratus, -} - -impl std::fmt::Display for ResponseFormat { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -impl std::str::FromStr for ResponseFormat { - type Err = String; - - fn from_str(s: &str) -> Result { - match s { - "alloy" => Ok(Self::Alloy), - "stratus" => Ok(Self::Stratus), - _ => Err(format!("invalid response format {s:?}; expected \"alloy\" or \"stratus\"")), - } - } -} - -impl ResponseFormat { - /// Returns the wire name of the format. - pub fn as_str(&self) -> &'static str { - match self { - Self::Alloy => "alloy", - Self::Stratus => "stratus", - } - } } /// Extracts the optional [`PaginationParams`] from the remaining request params sequence. @@ -213,7 +170,7 @@ pub fn respond(value: JsonValue, pagination: Option, max_respo /// /// The serialized params are byte-identical to the pre-format wire format (`{"offset": N}`). pub fn request_params(offset: u64) -> JsonValue { - to_json_value(PaginationParams { offset, format: None }) + to_json_value(PaginationParams { offset }) } /// Progressive reassembly of a paginated response, with validation against a malicious peer. diff --git a/src/eth/rpc/server.rs b/src/eth/rpc/server.rs index 772af35e5..113ffc088 100644 --- a/src/eth/rpc/server.rs +++ b/src/eth/rpc/server.rs @@ -50,7 +50,6 @@ use tracing::info_span; use crate::GlobalState; use crate::NodeMode; -use crate::alias::AlloyReceipt; use crate::alias::JsonValue; use crate::config::StratusConfig; use crate::eth::codegen; @@ -82,7 +81,6 @@ use crate::eth::rpc::middleware::decode_input_arguments; use crate::eth::rpc::next_rpc_param; use crate::eth::rpc::next_rpc_param_or_default; use crate::eth::rpc::pagination; -use crate::eth::rpc::pagination::ResponseFormat; use crate::eth::rpc::parser::RpcExtensionsExt; use crate::eth::rpc::parser::parse_rpc_rlp; use crate::eth::rpc::subscriptions::RpcSubscriptionsHandles; @@ -980,10 +978,8 @@ fn stratus_get_block_and_receipts(params: Params<'_>, ctx: Arc, ext: let (sequence, filter) = next_rpc_param::(params.sequence())?; let pagination = pagination::parse_request(sequence)?; - let response_format = pagination.as_ref().and_then(|params| params.format).unwrap_or_default(); - // track - tracing::info!(%filter, %response_format, "reading block and receipts"); + tracing::info!(%filter, "reading block and receipts"); let Some(block) = ctx.server.storage.read_block(filter)? else { tracing::info!(%filter, "block not found"); @@ -992,17 +988,7 @@ fn stratus_get_block_and_receipts(params: Params<'_>, ctx: Arc, ext: tracing::info!(%filter, "block with transactions found"); - // serialize in the requested format - let value = match response_format { - ResponseFormat::Stratus => to_json_value(BlockRocksdb::from(block)), - ResponseFormat::Alloy => { - let receipts = block.transactions.iter().cloned().map(AlloyReceipt::from).collect::>(); - json!({ - "block": block.to_json_rpc_with_full_transactions(), - "receipts": receipts, - }) - } - }; + let value = to_json_value(BlockRocksdb::from(block)); pagination::respond(value, pagination, ctx.server.rpc_config.rpc_max_response_size_bytes) } From a75f80f52f9403772be4ac6265bd92a7500db95e Mon Sep 17 00:00:00 2001 From: gventino-cw Date: Wed, 30 Sep 2026 19:36:36 -0300 Subject: [PATCH 08/10] refactor(executor): remove external transaction reexecution machinery --- crates/stratus_metrics/macros/src/lib.rs | 2 +- crates/stratus_metrics/src/definitions.rs | 6 - src/alias.rs | 3 - .../evm/types/output/transaction_execution.rs | 496 +----------------- src/eth/executor/mod.rs | 131 ----- src/eth/executor/transaction_worker.rs | 44 -- src/eth/follower/importer/supervisor.rs | 8 +- src/eth/miner/miner.rs | 33 -- src/eth/rpc/mod.rs | 34 +- src/eth/storage/stratus_storage.rs | 5 - src/eth/types/block/block.rs | 10 - src/eth/types/block/block_header.rs | 31 -- src/eth/types/external/external_block.rs | 131 ----- .../external/external_block_with_receipts.rs | 47 -- src/eth/types/external/external_receipt.rs | 236 --------- src/eth/types/external/external_receipts.rs | 52 -- .../types/external/external_transaction.rs | 280 ---------- src/eth/types/external/mod.rs | 11 - src/eth/types/mod.rs | 19 +- .../types/transaction/transaction_input.rs | 59 --- 20 files changed, 12 insertions(+), 1626 deletions(-) delete mode 100644 src/eth/types/external/external_block.rs delete mode 100644 src/eth/types/external/external_block_with_receipts.rs delete mode 100644 src/eth/types/external/external_receipt.rs delete mode 100644 src/eth/types/external/external_receipts.rs delete mode 100644 src/eth/types/external/external_transaction.rs delete mode 100644 src/eth/types/external/mod.rs diff --git a/crates/stratus_metrics/macros/src/lib.rs b/crates/stratus_metrics/macros/src/lib.rs index 92cee7c3e..a7d4a29d9 100644 --- a/crates/stratus_metrics/macros/src/lib.rs +++ b/crates/stratus_metrics/macros/src/lib.rs @@ -77,7 +77,7 @@ pub fn metrics(input: TokenStream) -> TokenStream { /// /// ```ignore /// #[timed( -/// executor_external_transaction, +/// executor_imported_transaction, /// labels( /// contract = |input| contract_name(&input.execution_info.to), /// function = |input| function_sig(&input.execution_info.input), diff --git a/crates/stratus_metrics/src/definitions.rs b/crates/stratus_metrics/src/definitions.rs index a1bf4ad48..a59b922b0 100644 --- a/crates/stratus_metrics/src/definitions.rs +++ b/crates/stratus_metrics/src/definitions.rs @@ -67,12 +67,6 @@ metrics! { }, group: executor { - "Time executing and persist an external block with all transactions." - histogram_duration executor_external_block{}, - - "Time executing an external transaction." - histogram_duration executor_external_transaction{contract, function}, - "Time executing and persist an imported stratus block with all transactions." histogram_duration executor_imported_block{}, diff --git a/src/alias.rs b/src/alias.rs index 2944f0ced..76f728e28 100644 --- a/src/alias.rs +++ b/src/alias.rs @@ -2,8 +2,6 @@ use alloy_primitives::B256; -use crate::eth::types::ExternalTransaction; - // ----------------------------------------------------------------------------- // Serde // ----------------------------------------------------------------------------- @@ -14,7 +12,6 @@ pub type JsonValue = serde_json::Value; // ----------------------------------------------------------------------------- pub type AlloyBlockVoid = alloy_rpc_types_eth::Block<()>; pub type AlloyBlockAlloyTransaction = alloy_rpc_types_eth::Block; -pub type AlloyBlockExternalTransaction = alloy_rpc_types_eth::Block; pub type AlloyBlockB256 = alloy_rpc_types_eth::Block; pub type AlloyBytes = alloy_primitives::Bytes; pub type AlloyLog = alloy_rpc_types_eth::Log; diff --git a/src/eth/executor/evm/types/output/transaction_execution.rs b/src/eth/executor/evm/types/output/transaction_execution.rs index 6e73dd57b..7d8c1a5a5 100644 --- a/src/eth/executor/evm/types/output/transaction_execution.rs +++ b/src/eth/executor/evm/types/output/transaction_execution.rs @@ -1,3 +1,4 @@ +#[cfg(test)] use alloy_primitives::B256; use alloy_primitives::U256; use derive_more::Deref; @@ -16,7 +17,6 @@ use crate::eth::executor::types::state::Complete; use crate::eth::types::Account; use crate::eth::types::Address; use crate::eth::types::Bytes; -use crate::eth::types::ExternalReceipt; use crate::eth::types::Gas; use crate::eth::types::Hash; use crate::eth::types::Log; @@ -68,40 +68,6 @@ pub struct TransactionExecutionResult { } impl TransactionExecutionOutput { - /// Creates an execution from an external transaction that failed. - pub fn from_failed_external_transaction(sender: Account, receipt: &ExternalReceipt) -> anyhow::Result { - if receipt.is_success() { - return log_and_err!("cannot create failed execution for successful transaction"); - } - if not(receipt.inner.logs().is_empty()) { - return log_and_err!("failed receipt should not have produced logs"); - } - - // generate sender changes incrementing the nonce - let address = sender.address; - let mut sender_changes = AccountChanges::default(); - sender_changes.apply_original(sender); - let sender_next_nonce = sender_changes.nonce.next_nonce(); - - sender_changes.nonce.apply(sender_next_nonce); - let mut changes = State::default(); - changes.accounts.insert(address, sender_changes); - - // crete execution and apply costs - let mut execution = Self { - outcome: TransactionExecutionResult { - result: ExecutionResult::new_reverted("reverted externally".into()), // assume it reverted - output: Bytes::default(), // we cannot really know without performing an eth_call to the external system - logs: Vec::new(), - gas_used: Gas::from(receipt.gas_used), - deployed_contract_address: None, - }, - state: changes, - }; - execution.apply_receipt(receipt)?; - Ok(execution) - } - /// Checks if the current transaction was completed normally. pub fn is_success(&self) -> bool { self.result.is_success() @@ -121,152 +87,6 @@ impl TransactionExecutionOutput { None } - /// Checks if current execution state matches the information present in the external receipt. - pub fn compare_with_receipt(&self, receipt: &ExternalReceipt) -> anyhow::Result<()> { - // compare execution status - if self.is_success() != receipt.is_success() { - return log_and_err!(format!( - "transaction status mismatch | hash={} execution={:?} receipt={:?}", - receipt.hash(), - self.result, - receipt.status() - )); - } - - let receipt_logs = receipt.inner.logs(); - - // compare logs length - if self.logs.len() != receipt_logs.len() { - tracing::trace!(logs = ?self.logs, "execution logs"); - tracing::trace!(logs = ?receipt_logs, "receipt logs"); - return log_and_err!(format!( - "logs length mismatch | hash={} execution={} receipt={}", - receipt.hash(), - self.logs.len(), - receipt_logs.len() - )); - } - - // compare logs pairs - for (log_index, (execution_log, receipt_log)) in self.logs.iter().zip(receipt_logs).enumerate() { - // compare log topics length - if execution_log.topics_non_empty().len() != receipt_log.topics().len() { - return log_and_err!(format!( - "log topics length mismatch | hash={} log_index={} execution={} receipt={}", - receipt.hash(), - log_index, - execution_log.topics_non_empty().len(), - receipt_log.topics().len(), - )); - } - - // compare log topics content - for (topic_index, (execution_log_topic, receipt_log_topic)) in execution_log.topics_non_empty().iter().zip(receipt_log.topics().iter()).enumerate() - { - if B256::from(*execution_log_topic) != *receipt_log_topic { - return log_and_err!(format!( - "log topic content mismatch | hash={} log_index={} topic_index={} execution={} receipt={:#x}", - receipt.hash(), - log_index, - topic_index, - execution_log_topic, - receipt_log_topic, - )); - } - } - - // compare log data content - if execution_log.data.as_ref() != receipt_log.data().data.as_ref() { - return log_and_err!(format!( - "log data content mismatch | hash={} log_index={} execution={} receipt={:#x}", - receipt.hash(), - log_index, - execution_log.data, - receipt_log.data().data, - )); - } - } - Ok(()) - } - - /// External transactions are re-executed locally with max gas and zero gas price. - /// - /// This causes some attributes to be different from the original execution. - /// - /// This method updates the attributes that can diverge based on the receipt of the external transaction. - pub fn apply_receipt(&mut self, receipt: &ExternalReceipt) -> anyhow::Result<()> { - // fix gas - self.gas_used = Gas::from(receipt.gas_used); - - // fix logs - self.fix_logs_gas_left(receipt); - - // fix sender balance - let execution_cost = receipt.execution_cost(); - - if execution_cost > Wei::ZERO { - // find sender changes - let sender_address: Address = receipt.0.from.into(); - let Some(sender_changes) = self.state.accounts.get_mut(&sender_address) else { - return log_and_err!("sender changes not present in execution when applying execution costs"); - }; - - // subtract execution cost from sender balance - let sender_balance = *sender_changes.balance.value(); - - let sender_new_balance = if sender_balance > execution_cost { - sender_balance - execution_cost - } else { - Wei::ZERO - }; - sender_changes.balance.apply(sender_new_balance); - } - - Ok(()) - } - - /// Apply `gasLeft` values from receipt to execution logs. - /// - /// External transactions are re-executed locally with a different amount of gas limit, so, rely - /// on the given receipt to copy the `gasLeft` values found in Logs. - /// - /// This is necessary if the contract emits an event that puts `gasLeft` in a log, this function - /// covers the following events that do the described: - /// - /// - `ERC20Trace` (topic0: `0x31738ac4a7c9a10ecbbfd3fed5037971ba81b8f6aa4f72a23f5364e9bc76d671`) - /// - `BalanceTrackerTrace` (topic0: `0x63f1e32b72965e2be75e03024856287aff9e4cdbcec65869c51014fc2c1c95d9`) - /// - /// The overwriting should be done by copying the first 32 bytes from the receipt to log in `self`. - fn fix_logs_gas_left(&mut self, receipt: &ExternalReceipt) { - let receipt_logs = receipt.inner.logs(); - - for (execution_log, receipt_log) in self.logs.iter_mut().zip(receipt_logs) { - let execution_log_matches = || execution_log.topic0.is_some_and(|topic| GAS_LEFT_EVENT_HASHES.contains(&topic.as_ref())); - let receipt_log_matches = || { - receipt_log - .topics() - .first() - .is_some_and(|topic| GAS_LEFT_EVENT_HASHES.contains(&topic.as_ref())) - }; - - // only try overwriting if both logs refer to the target event - let should_overwrite = execution_log_matches() && receipt_log_matches(); - if !should_overwrite { - continue; - } - - let Some(source) = receipt_log.data().data.get(0..32) else { - continue; - }; - let mut data = execution_log.data.0.to_vec(); - let Some(destination) = data.get_mut(0..32) else { - continue; - }; - destination.copy_from_slice(source); - execution_log.data = Bytes::from(data); - } - } - /// Creates an execution from an imported stratus transaction that failed. pub fn from_failed_imported_transaction(sender: Account, gas_price: Wei, stored: &TransactionExecutionResult) -> anyhow::Result { if stored.result.is_success() { @@ -397,7 +217,7 @@ impl TransactionExecutionOutput { /// Apply `gasLeft` values from the stored logs to the execution logs. /// /// Imported transactions are re-executed locally with a different amount of gas limit, so rely - /// on the stored logs to copy the `gasLeft` values, mirroring [`Self::fix_logs_gas_left`]. + /// on the stored logs to copy the `gasLeft` values. fn fix_logs_gas_left_from_stored(&mut self, stored_logs: &[Log]) { for (execution_log, stored_log) in self.logs.iter_mut().zip(stored_logs) { let execution_log_matches = || execution_log.topic0.is_some_and(|topic| GAS_LEFT_EVENT_HASHES.contains(&topic.0.as_ref())); @@ -513,318 +333,6 @@ mod tests { use super::*; use crate::eth::types::Nonce; - #[test] - fn test_from_failed_external_transaction() { - // Create a mock sender account - let sender_address: Address = Faker.fake(); - let sender = Account { - address: sender_address, - nonce: Nonce::from(1u64), - balance: Wei::from(1000u64), - bytecode: None, - }; - - // Create a mock failed receipt - let mut receipt: ExternalReceipt = Faker.fake(); - let mut inner_receipt = receipt.0.clone(); - - // Clear logs for failed transaction - if let alloy_consensus::ReceiptEnvelope::Legacy(ref mut r) = inner_receipt.inner { - r.receipt.status = alloy_consensus::Eip658Value::Eip658(false); - r.receipt.logs.clear(); - } else { - panic!("expected be legacy!") - } - - // Update from address - inner_receipt.from = sender_address.into(); - receipt.0 = inner_receipt; - - // Test the method - let execution = TransactionExecutionOutput::from_failed_external_transaction(sender.clone(), &receipt).unwrap(); - - // Verify execution state - assert!(execution.is_failure()); - assert_eq!(execution.output, Bytes::default()); - assert!(execution.logs.is_empty()); - assert_eq!(execution.gas_used, Gas::from(receipt.gas_used)); - - // Verify sender changes - let sender_changes = execution.state.accounts.get(&sender_address).unwrap(); - - // Nonce should be incremented - let modified_nonce = *sender_changes.nonce.value(); - assert_eq!(modified_nonce, Nonce::from(2u64)); - - // Balance should be reduced by execution cost - if receipt.execution_cost() > Wei::ZERO { - let modified_balance = *sender_changes.balance.value(); - assert!(sender.balance >= modified_balance); - } - } - - #[test] - fn test_compare_with_receipt_success_status_mismatch() { - // Create a mock execution (success) - let mut execution: TransactionExecutionOutput = Faker.fake(); - execution.result = ExecutionResult::Success; - - // Create a mock receipt (failed) - let mut receipt: ExternalReceipt = Faker.fake(); - if let alloy_consensus::ReceiptEnvelope::Legacy(r) = &mut receipt.0.inner { - r.receipt.status = alloy_consensus::Eip658Value::Eip658(false); - } else { - panic!("expected be legacy!") - } - - // Verify comparison fails - assert!(execution.compare_with_receipt(&receipt).is_err()); - } - - #[test] - fn test_compare_with_receipt_logs_length_mismatch() { - // Create a mock execution with logs - let mut execution: TransactionExecutionOutput = Faker.fake(); - execution.result = ExecutionResult::Success; - execution.logs = vec![Faker.fake(), Faker.fake()]; // Two logs - - // Create a mock receipt with different number of logs - let mut receipt: ExternalReceipt = Faker.fake(); - if let alloy_consensus::ReceiptEnvelope::Legacy(r) = &mut receipt.0.inner { - r.receipt.status = alloy_consensus::Eip658Value::Eip658(true); - r.receipt.logs = vec![alloy_rpc_types_eth::Log::default()]; // Only one log - } else { - panic!("expected be legacy!") - } - - // Verify comparison fails - assert!(execution.compare_with_receipt(&receipt).is_err()); - } - - #[test] - fn test_compare_with_receipt_log_topics_length_mismatch() { - // Create a mock log with topics - let mut log1: Log = Faker.fake(); - log1.topic0 = Some(Faker.fake()); - log1.topic1 = Some(Faker.fake()); - log1.topic2 = None; - log1.topic3 = None; - - // Create a mock execution with that log - let mut execution: TransactionExecutionOutput = Faker.fake(); - execution.result = ExecutionResult::Success; - execution.logs = vec![log1]; - - // Create receipt log with different number of topics - let mut receipt_log = alloy_rpc_types_eth::Log::::default(); - let topics = vec![B256::default()]; - receipt_log.inner.data = alloy_primitives::LogData::new_unchecked(topics, alloy_primitives::Bytes::default()); - // Only one topic instead of two - - // Create a receipt with this log - let mut receipt: ExternalReceipt = Faker.fake(); - if let alloy_consensus::ReceiptEnvelope::Legacy(r) = &mut receipt.0.inner { - r.receipt.status = alloy_consensus::Eip658Value::Eip658(true); - r.receipt.logs = vec![receipt_log.clone()]; - } else { - panic!("expected be legacy!") - } - - // Verify comparison fails - assert!(execution.compare_with_receipt(&receipt).is_err()); - } - - #[test] - fn test_compare_with_receipt_topic_content_mismatch() { - // Create two genuinely different topics - let topic_value = B256::from([1u8; 32]); - let different_topic = B256::from([2u8; 32]); - - // Create a mock log with only topic0 set (clear others to avoid length mismatch) - let mut log1: Log = Faker.fake(); - log1.topic0 = Some(topic_value.into()); - log1.topic1 = None; - log1.topic2 = None; - log1.topic3 = None; - log1.data = vec![].into(); - - // Create execution with that log - let mut execution: TransactionExecutionOutput = Faker.fake(); - execution.result = ExecutionResult::Success; - execution.logs = vec![log1]; - - // Create receipt log with same number of topics but different content - let mut receipt_log = alloy_rpc_types_eth::Log::::default(); - let topics = vec![different_topic]; - receipt_log.inner.data = alloy_primitives::LogData::new_unchecked(topics, alloy_primitives::Bytes::default()); - - // Create receipt with this log - let mut receipt: ExternalReceipt = Faker.fake(); - if let alloy_consensus::ReceiptEnvelope::Legacy(r) = &mut receipt.0.inner { - r.receipt.status = alloy_consensus::Eip658Value::Eip658(true); - r.receipt.logs = vec![receipt_log.clone()]; - } else { - panic!("expected be legacy!") - } - - // Verify comparison fails due to topic content mismatch - let err = execution.compare_with_receipt(&receipt).unwrap_err(); - assert!(err.to_string().contains("log topic content mismatch")); - } - - #[test] - fn test_compare_with_receipt_data_content_mismatch() { - // Create a mock log with data - let mut log1: Log = Faker.fake(); - log1.topic0 = Some(Faker.fake()); - log1.data = vec![1, 2, 3, 4].into(); - - // Create execution with that log - let mut execution: TransactionExecutionOutput = Faker.fake(); - execution.result = ExecutionResult::Success; - execution.logs = vec![log1]; - - // Create receipt log with different data - let mut receipt_log = alloy_rpc_types_eth::Log::::default(); - let topics = vec![B256::default()]; - receipt_log.inner.data = alloy_primitives::LogData::new_unchecked(topics, alloy_primitives::Bytes::default()); - receipt_log.inner.data = alloy_primitives::LogData::new(vec![B256::default()], alloy_primitives::Bytes::from(vec![5, 6, 7, 8])).unwrap(); - - // Create receipt with this log - let mut receipt: ExternalReceipt = Faker.fake(); - if let alloy_consensus::ReceiptEnvelope::Legacy(r) = &mut receipt.0.inner { - r.receipt.status = alloy_consensus::Eip658Value::Eip658(true); - r.receipt.logs = vec![receipt_log.clone()]; - } else { - panic!("expected be legacy!") - } - - // Verify comparison fails - assert!(execution.compare_with_receipt(&receipt).is_err()); - } - - #[test] - fn test_fix_logs_gas_left() { - // Set up test constants - const ERC20_TRACE_HASH: [u8; 32] = hex!("31738ac4a7c9a10ecbbfd3fed5037971ba81b8f6aa4f72a23f5364e9bc76d671"); - const BALANCE_TRACKER_TRACE_HASH: [u8; 32] = hex!("63f1e32b72965e2be75e03024856287aff9e4cdbcec65869c51014fc2c1c95d9"); - - // Create a mock execution with logs that have gasLeft value we want to override - let mut execution: TransactionExecutionOutput = Faker.fake(); - execution.result = ExecutionResult::Success; - - // Create an ERC20 Trace log with mock gasLeft value - let mut erc20_log: Log = Faker.fake(); - erc20_log.topic0 = Some(ERC20_TRACE_HASH.into()); - let execution_gas_left = vec![0u8; 32]; // Initial value all zeros - let mut log_data = Vec::with_capacity(execution_gas_left.len() + 32); - log_data.extend_from_slice(&execution_gas_left); - log_data.extend_from_slice(&[99u8; 32]); // Add some additional data - erc20_log.data = log_data.into(); - - // Create a Balance Tracker Trace log - let mut balance_log: Log = Faker.fake(); - balance_log.topic0 = Some(BALANCE_TRACKER_TRACE_HASH.into()); - let balance_gas_left = vec![0u8; 32]; // Initial value all zeros - balance_log.data = balance_gas_left.into(); - - // Create a regular log (not one we're targeting) - let regular_log: Log = Faker.fake(); - - execution.logs = vec![erc20_log, balance_log, regular_log.clone()]; - - // Create receipt logs with different gasLeft values - let receipt_erc20_gas_left = vec![42u8; 32]; // Different value for comparison - let mut erc20_receipt_log = alloy_rpc_types_eth::Log::::default(); - let erc20_topics = vec![B256::from_slice(&ERC20_TRACE_HASH)]; - - let mut erc20_receipt_data = Vec::with_capacity(receipt_erc20_gas_left.len() + 32); - erc20_receipt_data.extend_from_slice(&receipt_erc20_gas_left); - erc20_receipt_data.extend_from_slice(&[99u8; 32]); // Match additional data - - erc20_receipt_log.inner.data = alloy_primitives::LogData::new_unchecked(erc20_topics, alloy_primitives::Bytes::from(erc20_receipt_data)); - - // Balance tracker receipt log - let receipt_balance_gas_left = vec![24u8; 32]; - let mut balance_receipt_log = alloy_rpc_types_eth::Log::::default(); - let balance_topics = vec![B256::from_slice(&BALANCE_TRACKER_TRACE_HASH)]; - balance_receipt_log.inner.data = - alloy_primitives::LogData::new_unchecked(balance_topics, alloy_primitives::Bytes::from(receipt_balance_gas_left.clone())); - - // Regular log for receipt - let mut regular_receipt_log = alloy_rpc_types_eth::Log::::default(); - let regular_topics = Vec::new(); - regular_receipt_log.inner.data = alloy_primitives::LogData::new_unchecked(regular_topics, alloy_primitives::Bytes::default()); - - // Create receipt with these logs - let mut receipt: ExternalReceipt = Faker.fake(); - if let alloy_consensus::ReceiptEnvelope::Legacy(r) = &mut receipt.0.inner { - r.receipt.status = alloy_consensus::Eip658Value::Eip658(true); - r.receipt.logs = vec![erc20_receipt_log.clone(), balance_receipt_log.clone(), regular_receipt_log.clone()]; - } else { - panic!("expected be legacy!") - } - - // Apply the fix - execution.fix_logs_gas_left(&receipt); - - // Verify the first 32 bytes of ERC20 log data was overwritten - let updated_erc20_data = execution.logs[0].data.as_ref(); - assert_eq!(&updated_erc20_data[0..32], &receipt_erc20_gas_left[..]); - // Rest of the data should remain unchanged - assert_eq!(&updated_erc20_data[32..], &[99u8; 32]); - - // Verify the first 32 bytes of Balance Tracker log data was overwritten - assert_eq!(execution.logs[1].data.as_ref(), &receipt_balance_gas_left[..]); - - // Verify regular log data wasn't modified - assert_eq!(execution.logs[2].data, regular_log.data); - } - - #[test] - fn test_apply_receipt() { - // Create a mock sender account with balance - let sender_address: Address = Faker.fake(); - let sender = Account { - address: sender_address, - nonce: Nonce::from(1u64), - balance: Wei::from(1000u64), - bytecode: None, - }; - - // Create a mock execution - let mut execution: TransactionExecutionOutput = Faker.fake(); - - // Set up execution with sender account - let mut sender_changes = AccountChanges::default(); - sender_changes.apply_original(sender); - let mut accounts = HashMap::with_hasher(foldhash::fast::RandomState::default()); - accounts.insert(sender_address, sender_changes); - let changes = State:: { - accounts, - ..Default::default() - }; - execution.state = changes; - execution.gas_used = Gas::from(100u64); - - // Create a receipt with higher gas used and execution cost - let mut receipt: ExternalReceipt = Faker.fake(); - receipt.0.from = sender_address.into(); - receipt.0.gas_used = 100u64; // Higher gas - - // Make sure transaction has a cost - let gas_price = Wei::from(1u64); - receipt.0.effective_gas_price = gas_price.try_into().expect("wei was created with u64 which fits u128 qed."); - - // Apply receipt - execution.apply_receipt(&receipt).unwrap(); - - // Verify sender balance was reduced by execution cost - let sender_changes = execution.state.accounts.get(&sender_address).unwrap(); - let modified_balance = *sender_changes.balance.value(); - assert_eq!(modified_balance, Wei::from(900u64)); // 1000 - 100 - } - #[test] fn test_from_failed_imported_transaction() { // Create a mock sender account diff --git a/src/eth/executor/mod.rs b/src/eth/executor/mod.rs index 3b26e64fe..7f54a663a 100644 --- a/src/eth/executor/mod.rs +++ b/src/eth/executor/mod.rs @@ -7,8 +7,6 @@ pub mod types; use std::mem; use std::sync::Arc; -#[cfg(feature = "metrics")] -use alloy_consensus::Transaction; use alloy_rpc_types_trace::geth::GethDebugTracerType; use alloy_rpc_types_trace::geth::GethDebugTracingOptions; use alloy_rpc_types_trace::geth::GethTrace; @@ -49,18 +47,12 @@ use crate::eth::types::Address; use crate::eth::types::Block; use crate::eth::types::BlockNumber; use crate::eth::types::CallInput; -use crate::eth::types::ExternalBlock; -use crate::eth::types::ExternalReceipt; -use crate::eth::types::ExternalReceipts; -use crate::eth::types::ExternalTransaction; use crate::eth::types::Hash; use crate::eth::types::PointInTime; use crate::eth::types::StratusError; use crate::eth::types::TransactionInput; use crate::eth::types::TransactionMined; use crate::eth::types::Wei; -#[cfg(feature = "metrics")] -use crate::ext::OptionExt; use crate::ext::to_json_string; use crate::infra::tracing::SpanExt; use crate::utils::Semaphore; @@ -101,129 +93,6 @@ impl Executor { } } - // ------------------------------------------------------------------------- - // External transactions - // ------------------------------------------------------------------------- - - /// Reexecutes an external block locally and imports it to the temporary storage. - /// - /// Returns the remaining receipts that were not consumed by the execution. - #[timed(executor_external_block)] - pub fn execute_external_block(&self, mut block: ExternalBlock, mut receipts: ExternalReceipts) -> anyhow::Result<()> { - #[cfg(feature = "tracing")] - let _span = info_span!("executor::external_block", block_number = %block.number()).entered(); - tracing::info!(block_number = %block.number(), "reexecuting external block"); - - self.storage.set_pending_from_external(&block); - - // track pending block - let block_number = block.number(); - let block_transactions = mem::take(&mut block.transactions); - - // determine how to execute each transaction - for tx in block_transactions.into_transactions() { - let receipt = receipts.try_remove(tx.hash())?; - self.execute_external_transaction(tx, receipt, block_number)?; - } - - Ok(()) - } - - /// Reexecutes an external transaction locally ensuring it produces the same output. - /// - /// This function wraps `reexecute_external_tx_inner` and returns back the payload - /// to facilitate re-execution of parallel transactions that failed - #[timed(executor_external_transaction, labels( - contract = |tx| codegen::contract_name(&tx.0.to().map_into()), - function = |tx| codegen::function_sig(tx.inner.input()) - ) - )] - fn execute_external_transaction(&self, tx: ExternalTransaction, receipt: ExternalReceipt, block_number: BlockNumber) -> anyhow::Result<()> { - #[cfg(feature = "tracing")] - let _span = info_span!("executor::external_transaction", tx_hash = %tx.hash()).entered(); - tracing::info!(%block_number, tx_hash = %tx.hash(), "reexecuting external transaction"); - - self.transaction_worker.execute_external_transaction(tx, receipt, block_number) - } - - fn execute_external_transaction_inner( - storage: &StratusStorage, - miner: &Miner, - evm: &mut Evm, - tx: ExternalTransaction, - receipt: ExternalReceipt, - block_number: BlockNumber, - ) -> anyhow::Result<()> { - let tx_input: TransactionInput = tx.try_into()?; - let pending_header = storage.read_pending_block_header(); - let mut evm_input = TransactionExecutionInput::create(&tx_input, pending_header); - - // when transaction externally failed, create fake transaction instead of reexecuting - let (tx_execution, state) = match receipt.is_success() { - // successful external transaction, re-execute locally - true => { - // re-execute transaction - let evm_execution = evm - .execute(evm_input.clone()) - .and_then(|(result, metrics)| Ok((TransactionExecutionOutput::try_from(result)?, metrics))); - - // handle re-execution result - let (mut evm_result, _evm_metrics) = match evm_execution { - Ok((evm_result, evm_metrics)) => (evm_result, evm_metrics), - Err(e) => { - let json_tx = to_json_string(&tx_input); - let json_receipt = to_json_string(&receipt); - tracing::error!(reason = ?e, %block_number, tx_hash = %tx_input.transaction_info.hash, %json_tx, %json_receipt, "failed to reexecute external transaction"); - return Err(e.into()); - } - }; - - // update execution with receipt - evm_result.apply_receipt(&receipt)?; - - // ensure it matches receipt before saving - if let Err(e) = evm_result.compare_with_receipt(&receipt) { - let json_tx = to_json_string(&tx_input); - let json_receipt = to_json_string(&receipt); - let json_execution_logs = to_json_string(&evm_result.logs); - tracing::error!(reason = ?e, %block_number, tx_hash = %tx_input.transaction_info.hash, %json_tx, %json_receipt, %json_execution_logs, "failed to reexecute external transaction"); - return Err(e); - }; - - ( - TransactionExecution::new(tx_input.transaction_info, tx_input.signature, evm_input, evm_result.outcome), - evm_result.state, - ) - } - // - // failed external transaction, re-create from receipt without re-executing - false => { - let (sender, _) = storage.read_account(receipt.from.into(), ExecutionKind::Transaction)?; - if tx_input.execution_info.nonce != sender.nonce { - bail!( - "reverted external transaction should have the correct nonce. address: {:?}, input: {:?}, sender: {:?}", - tx_input.signer(), - tx_input.execution_info.nonce, - sender.nonce - ); - } - let evm_result = TransactionExecutionOutput::from_failed_external_transaction(sender, &receipt)?; - - evm_input.gas_limit = tx_input.execution_info.gas_limit; - evm_input.gas_price = tx_input.execution_info.gas_price; - - ( - TransactionExecution::new(tx_input.transaction_info, tx_input.signature, evm_input, evm_result.outcome), - evm_result.state, - ) - } - }; - - // persist state - miner.save_execution(tx_execution, state)?; - Ok(()) - } - /// Reexecutes an imported stratus block locally and imports it to the temporary storage. #[timed(executor_imported_block)] pub fn execute_imported_block(&self, mut block: Block) -> anyhow::Result<()> { diff --git a/src/eth/executor/transaction_worker.rs b/src/eth/executor/transaction_worker.rs index 045e30932..1742389ab 100644 --- a/src/eth/executor/transaction_worker.rs +++ b/src/eth/executor/transaction_worker.rs @@ -24,8 +24,6 @@ use crate::eth::miner::Miner; use crate::eth::storage::StorageError; use crate::eth::storage::StratusStorage; use crate::eth::types::BlockNumber; -use crate::eth::types::ExternalReceipt; -use crate::eth::types::ExternalTransaction; use crate::eth::types::StratusError; use crate::eth::types::TransactionInput; use crate::eth::types::TransactionMined; @@ -35,7 +33,6 @@ use crate::infra::tracing::warn_task_tx_closed; const TASK_NAME: &str = "evm-tx-1"; -type ExternalTransactionResult = anyhow::Result<()>; type ImportedTransactionResult = anyhow::Result<()>; type LocalTransactionResult = Result; type LocalTransactionResponse = (Duration, LocalTransactionResult); @@ -69,18 +66,6 @@ impl TransactionWorker { Self { task_tx } } - /// Reexecutes and persists an external transaction. - pub fn execute_external_transaction(&self, tx: ExternalTransaction, receipt: ExternalReceipt, block_number: BlockNumber) -> ExternalTransactionResult { - let (response_tx, response_rx) = oneshot::channel(); - self.task_tx - .send(TransactionTask::external(tx, receipt, block_number, response_tx)) - .map_err(StratusError::from)?; - match response_rx.recv() { - Ok(result) => result, - Err(_) => Err(StratusError::from(UnexpectedError::ChannelClosed { channel: "evm" }).into()), - } - } - /// Reexecutes and persists an imported stratus transaction. pub fn execute_imported_transaction(&self, tx: TransactionMined, block_number: BlockNumber) -> ImportedTransactionResult { let (response_tx, response_rx) = oneshot::channel(); @@ -164,18 +149,6 @@ struct TransactionTask { } impl TransactionTask { - fn external(tx: ExternalTransaction, receipt: ExternalReceipt, block_number: BlockNumber, response_tx: oneshot::Sender) -> Self { - Self { - span: Span::current(), - kind: TransactionTaskKind::External { - tx: Box::new(tx), - receipt: Box::new(receipt), - block_number, - response_tx, - }, - } - } - fn local(tx_input: TransactionInput, response_tx: oneshot::Sender) -> Self { Self { span: Span::current(), @@ -202,17 +175,6 @@ impl TransactionTask { let _enter = span.enter(); catch_unwind(AssertUnwindSafe(|| match kind { - TransactionTaskKind::External { - tx, - receipt, - block_number, - response_tx, - } => { - let result = Executor::execute_external_transaction_inner(storage, miner, evm, *tx, *receipt, block_number); - if let Err(e) = response_tx.send(result) { - tracing::error!(reason = ?e, "failed to send external transaction execution result"); - } - } TransactionTaskKind::Local { tx_input, response_tx } => { let start = stratus_metrics::now(); let result = TransactionWorker::execute_local_transaction_attempts(storage, miner, evm, *tx_input, usize::MAX); @@ -233,12 +195,6 @@ impl TransactionTask { } enum TransactionTaskKind { - External { - tx: Box, - receipt: Box, - block_number: BlockNumber, - response_tx: oneshot::Sender, - }, Local { tx_input: Box, response_tx: oneshot::Sender, diff --git a/src/eth/follower/importer/supervisor.rs b/src/eth/follower/importer/supervisor.rs index 07a4cfe37..cab6dc60e 100644 --- a/src/eth/follower/importer/supervisor.rs +++ b/src/eth/follower/importer/supervisor.rs @@ -53,9 +53,7 @@ impl ReexecutionFollower { kafka_connector, }; - let fetcher = BlockWithReceiptsFetcher { - chain: Arc::clone(&chain), - }; + let fetcher = BlockWithReceiptsFetcher { chain: Arc::clone(&chain) }; Self { fetcher, importer } } @@ -66,9 +64,7 @@ impl FakeLeader { let importer = FakeLeaderWorker { executor, miner, storage }; let fetcher = FakeLeaderFetcher { - block_with_receipts_fetcher: BlockWithReceiptsFetcher { - chain: Arc::clone(&chain), - }, + block_with_receipts_fetcher: BlockWithReceiptsFetcher { chain: Arc::clone(&chain) }, block_with_changes_fetcher: BlockWithChangesFetcher { chain }, }; diff --git a/src/eth/miner/miner.rs b/src/eth/miner/miner.rs index 53bc395cf..978df6493 100644 --- a/src/eth/miner/miner.rs +++ b/src/eth/miner/miner.rs @@ -23,7 +23,6 @@ use crate::eth::storage::StorageError; use crate::eth::storage::StratusStorage; use crate::eth::types::Block; use crate::eth::types::BlockHeader; -use crate::eth::types::ExternalBlock; use crate::eth::types::Hash; use crate::eth::types::LogMessage; use crate::eth::types::StratusError; @@ -241,38 +240,6 @@ impl Miner { Ok(()) } - /// Mines external block and external transactions. - /// - /// Local transactions are not allowed to be part of the block. - pub fn mine_external(&self, external_block: ExternalBlock) -> anyhow::Result<(Block, State)> { - // track - #[cfg(feature = "tracing")] - let _span = info_span!("miner::mine_external", block_number = field::Empty).entered(); - - // lock - let _mine_lock = self.locks.mine.lock(); - - // mine block - let (pending_block, changes) = self.storage.finish_pending_block(); - let mut block: Block = pending_block.into(); - - Span::with(|s| s.rec_str("block_number", &block.header.number)); - block.apply_external(&external_block); - - match external_block == block { - true => Ok((block, changes)), - false => Err(anyhow!( - "mismatching block info:\n\tlocal:\n\t\tnumber: {:?}\n\t\ttimestamp: {:?}\n\t\thash: {:?}\n\texternal:\n\t\tnumber: {:?}\n\t\ttimestamp: {:?}\n\t\thash: {:?}", - block.number(), - block.header.timestamp, - block.hash(), - external_block.number(), - external_block.timestamp(), - external_block.hash() - )), - } - } - /// Mines an imported stratus block and its reexecuted transactions. /// /// Local transactions are not allowed to be part of the block. diff --git a/src/eth/rpc/mod.rs b/src/eth/rpc/mod.rs index edfc69b22..9fea457d4 100644 --- a/src/eth/rpc/mod.rs +++ b/src/eth/rpc/mod.rs @@ -156,14 +156,7 @@ mod tests { .expect("multi-byte char"); assert!(!full.is_char_boundary(misaligned)); - let raw = respond( - value, - Some(PaginationParams { - offset: misaligned as u64, - }), - MARGIN + 8, - ) - .expect("should respond"); + let raw = respond(value, Some(PaginationParams { offset: misaligned as u64 }), MARGIN + 8).expect("should respond"); assert!(is_envelope(raw.get())); let envelope = parse_envelope(raw.get()).expect("parse envelope"); @@ -401,10 +394,7 @@ mod wire_tests { let url = format!("http://{addr}"); let client = BlockchainClient::new_http(&url, Duration::from_secs(10)).await.expect("build client"); - let error = client - .fetch_block_and_receipts(BlockNumber::from(1)) - .await - .expect_err("fetch should fail"); + let error = client.fetch_block_and_receipts(BlockNumber::from(1)).await.expect_err("fetch should fail"); assert!(error.to_string().contains("failed to fetch block with receipts")); } @@ -436,10 +426,7 @@ mod wire_tests { let url = format!("http://{addr}"); let client = BlockchainClient::new_http(&url, Duration::from_secs(10)).await.expect("build client"); - let error = client - .fetch_block_and_receipts(BlockNumber::from(1)) - .await - .expect_err("fetch should fail"); + let error = client.fetch_block_and_receipts(BlockNumber::from(1)).await.expect_err("fetch should fail"); assert!(format!("{error:#}").contains("failed to deserialize importer data")); } @@ -467,10 +454,7 @@ mod wire_tests { let url = format!("http://{addr}"); let client = BlockchainClient::new_http(&url, Duration::from_secs(10)).await.expect("build client"); - let fetched = client - .fetch_block_and_receipts(BlockNumber::from(1)) - .await - .expect("fetch block"); + let fetched = client.fetch_block_and_receipts(BlockNumber::from(1)).await.expect("fetch block"); assert!(fetched.is_none(), "null response must deserialize to Ok(None)"); } @@ -500,10 +484,7 @@ mod wire_tests { let url = format!("http://{addr}"); let client = BlockchainClient::new_http(&url, Duration::from_secs(10)).await.expect("build client"); - let error = client - .fetch_block_and_receipts(BlockNumber::from(1)) - .await - .expect_err("fetch should fail"); + let error = client.fetch_block_and_receipts(BlockNumber::from(1)).await.expect_err("fetch should fail"); assert!(format!("{error:?}").contains("exceeds the reassembly cap")); } @@ -539,10 +520,7 @@ mod wire_tests { let url = format!("http://{addr}"); let client = BlockchainClient::new_http(&url, Duration::from_secs(10)).await.expect("build client"); - let error = client - .fetch_block_and_receipts(BlockNumber::from(1)) - .await - .expect_err("fetch should fail"); + let error = client.fetch_block_and_receipts(BlockNumber::from(1)).await.expect_err("fetch should fail"); assert!(format!("{error:?}").contains("expected paginated chunk but got normal response")); } diff --git a/src/eth/storage/stratus_storage.rs b/src/eth/storage/stratus_storage.rs index d088be13e..f9f001ec2 100644 --- a/src/eth/storage/stratus_storage.rs +++ b/src/eth/storage/stratus_storage.rs @@ -30,7 +30,6 @@ use crate::eth::types::BlockInfo; use crate::eth::types::BlockNumber; #[cfg(feature = "dev")] use crate::eth::types::Bytes; -use crate::eth::types::ExternalBlock; use crate::eth::types::Hash; use crate::eth::types::LogMessage; #[cfg(feature = "dev")] @@ -133,10 +132,6 @@ impl StratusStorage { self.perm.read_mined_block_number() } - pub fn set_pending_from_external(&self, block: &ExternalBlock) { - self.temp.set_pending_header(block.number(), block.timestamp()); - } - pub fn set_pending_header(&self, number: BlockNumber, timestamp: UnixTime) { self.temp.set_pending_header(number, timestamp); } diff --git a/src/eth/types/block/block.rs b/src/eth/types/block/block.rs index d31f17f0d..86bc0dfaf 100644 --- a/src/eth/types/block/block.rs +++ b/src/eth/types/block/block.rs @@ -11,7 +11,6 @@ use crate::alias::JsonValue; use crate::eth::executor::TransactionExecution; use crate::eth::types::BlockHeader; use crate::eth::types::BlockNumber; -use crate::eth::types::ExternalBlock; use crate::eth::types::Hash; use crate::eth::types::Index; use crate::eth::types::LogMessage; @@ -92,15 +91,6 @@ impl Block { } } - pub fn apply_external(&mut self, external_block: &ExternalBlock) { - self.header.hash = external_block.hash(); - assert!(*self.header.timestamp == external_block.header.timestamp); - for transaction in self.transactions.iter_mut() { - assert!(transaction.input.block_timestamp == self.header.timestamp); - transaction.mined_data.block_hash = external_block.hash(); - } - } - pub fn apply_imported(&mut self, imported_block: &Block) { self.header.hash = imported_block.hash(); assert!(*self.header.timestamp == *imported_block.header.timestamp); diff --git a/src/eth/types/block/block_header.rs b/src/eth/types/block/block_header.rs index 201cc04c5..e1c0de8f6 100644 --- a/src/eth/types/block/block_header.rs +++ b/src/eth/types/block/block_header.rs @@ -26,7 +26,6 @@ use crate::eth::types::Address; use crate::eth::types::BlockNumber; use crate::eth::types::Bytes; use crate::eth::types::Difficulty; -use crate::eth::types::ExternalBlock; use crate::eth::types::Gas; use crate::eth::types::Hash; use crate::eth::types::LogsBloom; @@ -177,36 +176,6 @@ impl From for AlloyBlock { } } -// ----------------------------------------------------------------------------- -// Conversions: Other -> Self -// ----------------------------------------------------------------------------- - -impl TryFrom<&ExternalBlock> for BlockHeader { - type Error = anyhow::Error; - fn try_from(value: &ExternalBlock) -> Result { - Ok(Self { - number: BlockNumber::from(value.0.header.inner.number), - hash: Hash::from(value.0.header.hash), - transactions_root: Hash::from(value.0.header.inner.transactions_root), - gas_used: Gas::from(value.0.header.inner.gas_used), - gas_limit: Gas::from(value.0.header.inner.gas_limit), - bloom: LogsBloom::from(value.0.header.inner.logs_bloom), - timestamp: UnixTime::from(value.0.header.inner.timestamp), - parent_hash: Hash::from(value.0.header.inner.parent_hash), - author: Address::from(value.0.header.inner.beneficiary), - extra_data: Bytes::from(value.0.header.inner.extra_data.clone()), - miner: Address::from(value.0.header.inner.beneficiary), - difficulty: Difficulty::from(value.0.header.inner.difficulty), - receipts_root: Hash::from(value.0.header.inner.receipts_root), - uncle_hash: Hash::from(value.0.header.inner.ommers_hash), - size: Size::try_from(value.0.header.size.unwrap_or_default())?, - state_root: Hash::from(value.0.header.inner.state_root), - total_difficulty: Difficulty::from(value.0.header.total_difficulty.unwrap_or_default()), - nonce: MinerNonce::from(value.0.header.inner.nonce.0), - }) - } -} - impl From for SubscriptionMessage { fn from(value: BlockHeader) -> Self { serde_json::value::RawValue::from_string(serde_json::to_string(&AlloyBlockVoid::from(value)).expect_infallible()) diff --git a/src/eth/types/external/external_block.rs b/src/eth/types/external/external_block.rs deleted file mode 100644 index eb4be6198..000000000 --- a/src/eth/types/external/external_block.rs +++ /dev/null @@ -1,131 +0,0 @@ -#[cfg(test)] -use alloy_eips::eip4895::Withdrawals; -#[cfg(test)] -use alloy_primitives::B64; -#[cfg(test)] -use alloy_primitives::B256; -#[cfg(test)] -use alloy_primitives::Bloom; -#[cfg(test)] -use alloy_primitives::Bytes; -#[cfg(test)] -use alloy_primitives::U256; -#[cfg(test)] -use fake::Dummy; -#[cfg(test)] -use fake::Fake; -#[cfg(test)] -use fake::Faker; -use serde::Deserialize; - -use crate::alias::AlloyBlockExternalTransaction; -use crate::alias::JsonValue; -use crate::eth::types::Address; -use crate::eth::types::Block; -use crate::eth::types::BlockNumber; -#[cfg(test)] -use crate::eth::types::ExternalTransaction; -use crate::eth::types::Hash; -use crate::eth::types::UnixTime; -use crate::log_and_err; - -#[derive(Debug, Clone, PartialEq, derive_more::Deref, derive_more::DerefMut, serde::Serialize, serde::Deserialize)] -#[serde(transparent)] -pub struct ExternalBlock(#[deref] pub AlloyBlockExternalTransaction); - -impl ExternalBlock { - /// Returns the block hash. - #[allow(clippy::expect_used)] - pub fn hash(&self) -> Hash { - Hash::from(self.0.header.hash) - } - - /// Returns the block number. - #[allow(clippy::expect_used)] - pub fn number(&self) -> BlockNumber { - BlockNumber::from(self.0.header.inner.number) - } - - /// Returns the block timestamp. - pub fn timestamp(&self) -> UnixTime { - self.0.header.inner.timestamp.into() - } - - /// Returns the block author. - pub fn author(&self) -> Address { - self.0.header.inner.beneficiary.into() - } -} - -impl PartialEq for ExternalBlock { - fn eq(&self, other: &Block) -> bool { - self.number() == other.number() && self.timestamp() == other.header.timestamp && self.hash() == other.header.hash - } -} - -#[cfg(test)] -impl Dummy for ExternalBlock { - fn dummy_with_rng(faker: &Faker, rng: &mut R) -> Self { - let mut addr_bytes = [0u8; 20]; - let mut hash_bytes = [0u8; 32]; - let mut nonce_bytes = [0u8; 8]; - rng.fill_bytes(&mut addr_bytes); - rng.fill_bytes(&mut hash_bytes); - rng.fill_bytes(&mut nonce_bytes); - - let transaction: ExternalTransaction = faker.fake_with_rng(rng); - - let block = alloy_rpc_types_eth::Block { - header: alloy_rpc_types_eth::Header { - hash: B256::from_slice(&hash_bytes), - inner: alloy_consensus::Header { - parent_hash: B256::from_slice(&hash_bytes), - ommers_hash: B256::from_slice(&hash_bytes), - beneficiary: alloy_primitives::Address::from_slice(&addr_bytes), - state_root: B256::from_slice(&hash_bytes), - transactions_root: B256::from_slice(&hash_bytes), - receipts_root: B256::from_slice(&hash_bytes), - withdrawals_root: Some(B256::from_slice(&hash_bytes)), - number: rng.next_u64(), - gas_used: rng.next_u64(), - gas_limit: rng.next_u64(), - extra_data: Bytes::default(), - logs_bloom: Bloom::default(), - timestamp: rng.next_u64(), - difficulty: U256::from(rng.next_u64()), - mix_hash: B256::from_slice(&hash_bytes), - nonce: B64::from_slice(&nonce_bytes), - base_fee_per_gas: Some(rng.next_u64()), - blob_gas_used: None, - excess_blob_gas: None, - parent_beacon_block_root: None, - requests_hash: None, - block_access_list_hash: None, - slot_number: None, - }, - total_difficulty: Some(U256::from(rng.next_u64())), - size: Some(U256::from(rng.next_u64())), - }, - uncles: vec![B256::from_slice(&hash_bytes)], - transactions: alloy_rpc_types_eth::BlockTransactions::Full(vec![transaction]), - withdrawals: Some(Withdrawals::default()), - }; - - ExternalBlock(block) - } -} - -// ----------------------------------------------------------------------------- -// Conversions: Other -> Self -// ----------------------------------------------------------------------------- - -impl TryFrom for ExternalBlock { - type Error = anyhow::Error; - - fn try_from(value: JsonValue) -> Result { - match ExternalBlock::deserialize(&value) { - Ok(v) => Ok(v), - Err(e) => log_and_err!(reason = e, payload = value, "failed to convert payload value to ExternalBlock"), - } - } -} diff --git a/src/eth/types/external/external_block_with_receipts.rs b/src/eth/types/external/external_block_with_receipts.rs deleted file mode 100644 index af80a36e1..000000000 --- a/src/eth/types/external/external_block_with_receipts.rs +++ /dev/null @@ -1,47 +0,0 @@ -#[cfg(test)] -use fake::Dummy; -#[cfg(test)] -use fake::Faker; -use serde::Deserialize; -use serde::Serialize; - -use crate::alias::JsonValue; -use crate::eth::types::ExternalBlock; -use crate::eth::types::ExternalReceipt; -use crate::log_and_err; - -#[derive(Debug, Clone, PartialEq, Serialize, serde::Deserialize)] -pub struct ExternalBlockWithReceipts { - pub block: ExternalBlock, - pub receipts: Vec, -} - -#[cfg(test)] -impl Dummy for ExternalBlockWithReceipts { - fn dummy_with_rng(faker: &Faker, rng: &mut R) -> Self { - let block = ExternalBlock::dummy_with_rng(faker, rng); - - let receipts = match &block.transactions { - alloy_rpc_types_eth::BlockTransactions::Full(txs) => txs.iter().map(|_| ExternalReceipt::dummy_with_rng(faker, rng)).collect(), - alloy_rpc_types_eth::BlockTransactions::Hashes(_) => Vec::new(), - alloy_rpc_types_eth::BlockTransactions::Uncle => Vec::new(), - }; - - Self { block, receipts } - } -} - -// ----------------------------------------------------------------------------- -// Conversions: Other -> Self -// ----------------------------------------------------------------------------- - -impl TryFrom for ExternalBlockWithReceipts { - type Error = anyhow::Error; - - fn try_from(value: JsonValue) -> Result { - match ExternalBlockWithReceipts::deserialize(&value) { - Ok(v) => Ok(v), - Err(e) => log_and_err!(reason = e, payload = value, "failed to convert payload value to ExternalBlockWithReceipts"), - } - } -} diff --git a/src/eth/types/external/external_receipt.rs b/src/eth/types/external/external_receipt.rs deleted file mode 100644 index 64cab5270..000000000 --- a/src/eth/types/external/external_receipt.rs +++ /dev/null @@ -1,236 +0,0 @@ -#[cfg(test)] -use alloy_consensus::ReceiptEnvelope; -#[cfg(test)] -use alloy_primitives::B256; -#[cfg(test)] -use alloy_primitives::Bloom; -#[cfg(test)] -use alloy_primitives::Bytes; -use alloy_primitives::U256; -#[cfg(test)] -use fake::Dummy; -#[cfg(test)] -use fake::Faker; -use serde::Deserialize; - -use crate::alias::AlloyReceipt; -use crate::alias::JsonValue; -use crate::eth::types::BlockNumber; -use crate::eth::types::Hash; -use crate::eth::types::Wei; -use crate::log_and_err; - -#[derive(Debug, Clone, PartialEq, derive_more::Deref, serde::Serialize)] -#[serde(transparent)] -pub struct ExternalReceipt(#[deref] pub AlloyReceipt); - -impl ExternalReceipt { - /// Returns the transaction hash. - pub fn hash(&self) -> Hash { - Hash::from(self.0.transaction_hash.0) - } - - /// Returns the block number. - #[allow(clippy::expect_used)] - pub fn block_number(&self) -> BlockNumber { - self.0.block_number.expect("external receipt must have block number").into() - } - - /// Returns the block hash. - #[allow(clippy::expect_used)] - pub fn block_hash(&self) -> Hash { - Hash::from(self.0.block_hash.expect("external receipt must have block hash").0) - } - - /// Retuns the effective price the sender had to pay to execute the transaction. - pub fn execution_cost(&self) -> Wei { - let gas_price = U256::from(self.0.effective_gas_price); - let gas_used = U256::from(self.0.gas_used); - (gas_price * gas_used).into() - } - - /// Checks if the transaction was completed with success. - pub fn is_success(&self) -> bool { - self.0.inner.status() - } -} - -#[cfg(test)] -impl Dummy for ExternalReceipt { - fn dummy_with_rng(_faker: &Faker, rng: &mut R) -> Self { - let mut addr_bytes = [0u8; 20]; - let mut hash_bytes = [0u8; 32]; - rng.fill_bytes(&mut addr_bytes); - rng.fill_bytes(&mut hash_bytes); - - let log = alloy_rpc_types_eth::Log { - inner: alloy_primitives::Log { - address: alloy_primitives::Address::from_slice(&addr_bytes), - data: alloy_primitives::LogData::new_unchecked(vec![B256::from_slice(&hash_bytes)], Bytes::default()), - }, - block_hash: Some(B256::from_slice(&hash_bytes)), - block_number: Some(rng.next_u64()), - transaction_hash: Some(B256::from_slice(&hash_bytes)), - transaction_index: Some(rng.next_u64()), - log_index: Some(rng.next_u64()), - removed: false, - block_timestamp: Some(rng.next_u64()), - }; - - let receipt = alloy_consensus::Receipt { - status: alloy_consensus::Eip658Value::Eip658(true), - cumulative_gas_used: rng.next_u64(), - logs: vec![log], - }; - - let receipt_envelope = ReceiptEnvelope::Legacy(alloy_consensus::ReceiptWithBloom { - receipt, - logs_bloom: Bloom::default(), - }); - - let receipt = alloy_rpc_types_eth::TransactionReceipt { - inner: receipt_envelope, - transaction_hash: B256::from_slice(&hash_bytes), - transaction_index: Some(rng.next_u64()), - block_hash: Some(B256::from_slice(&hash_bytes)), - block_number: Some(rng.next_u64()), - from: alloy_primitives::Address::from_slice(&addr_bytes), - to: Some(alloy_primitives::Address::from_slice(&addr_bytes)), - contract_address: None, - gas_used: rng.next_u64(), - effective_gas_price: rng.next_u64() as u128, - blob_gas_used: None, - blob_gas_price: None, - }; - - ExternalReceipt(receipt) - } -} - -// ----------------------------------------------------------------------------- -// Serialization / Deserialization -// ----------------------------------------------------------------------------- - -impl<'de> serde::Deserialize<'de> for ExternalReceipt { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - // During migration from ethers to alloy, we need to handle receipts from both libraries. - // Ethers receipts do not include `effectiveGasPrice` and `type` fields which are - // required by alloy. - let mut value = JsonValue::deserialize(deserializer)?; - - if let Some(obj) = value.as_object_mut() { - if !obj.contains_key("effectiveGasPrice") { - obj.insert("effectiveGasPrice".to_string(), serde_json::json!("0x0")); - } - if !obj.contains_key("type") { - obj.insert("type".to_string(), serde_json::json!("0x0")); - } - } else { - return Err(serde::de::Error::custom("ExternalReceipt must be a JSON object, received invalid type")); - } - - let receipt = serde_json::from_value(value).map_err(|e| serde::de::Error::custom(format!("Failed to deserialize ExternalReceipt: {e}")))?; - - Ok(ExternalReceipt(receipt)) - } -} - -// ----------------------------------------------------------------------------- -// Conversions: Other -> Self -// ----------------------------------------------------------------------------- - -impl TryFrom for ExternalReceipt { - type Error = anyhow::Error; - - fn try_from(value: JsonValue) -> Result { - match ExternalReceipt::deserialize(&value) { - Ok(v) => Ok(v), - Err(e) => log_and_err!(reason = e, payload = value, "failed to convert payload value to ExternalReceipt"), - } - } -} - -#[cfg(test)] -mod tests { - - use alloy_consensus::TxType; - - use super::*; - - #[test] - fn test_deserialize_ethers_receipt() { - let ethers_receipt = r#"{ - "blockHash": "0xc05ff25c9e4bcfb57a5bab271a38b46a8c8b2d5d9ef815ba449d6e211da42251", - "blockNumber": "0x20", - "contractAddress": null, - "cumulativeGasUsed": "0x0", - "from": "0x4fe666531f4a27d0cf5e3d2e73d9122a7f03777b", - "gasUsed": "0xe19c", - "logs": [{ - "address": "0xe7f1725e7734ce288f8367e1bb143e90bb3f0512", - "blockHash": "0xc05ff25c9e4bcfb57a5bab271a38b46a8c8b2d5d9ef815ba449d6e211da42251", - "blockNumber": "0x20", - "data": "0x000000000000000000000000000000000000000000000000000000000000000a", - "logIndex": "0x0", - "removed": false, - "topics": [ - "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", - "0x0000000000000000000000004fe666531f4a27d0cf5e3d2e73d9122a7f03777b", - "0x000000000000000000000000673dfa23201c98b7a3bfb48fc5cc4011d6759869" - ], - "transactionHash": "0x1c9b122e1321398ac869512b121f97c057e28e0e2fa96e9a8df1ecbfa9824faf", - "transactionIndex": "0x20" - }], - "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000004200000000000000000000000000000008000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000800000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000002000000000000000000001000000000000000000000000000080000000000000000000000000000000000000000000000000000800000000000000000", - "status": "0x1", - "to": "0xe7f1725e7734ce288f8367e1bb143e90bb3f0512", - "transactionHash": "0x1c9b122e1321398ac869512b121f97c057e28e0e2fa96e9a8df1ecbfa9824faf", - "transactionIndex": "0x20" - }"#; - - let receipt: ExternalReceipt = serde_json::from_str(ethers_receipt).unwrap(); - assert_eq!(receipt.0.effective_gas_price, 0); - assert_eq!(receipt.0.transaction_type(), TxType::Legacy); - } - - #[test] - fn test_deserialize_alloy_receipt() { - let alloy_receipt = r#"{ - "blockHash": "0x20dd72172e4bd9c99a919c217dd8c0154cbe0f9e305e67c5247f2ee8ae987c06", - "blockNumber": "0x16", - "contractAddress": null, - "cumulativeGasUsed": "0xe19c", - "effectiveGasPrice": "0x0", - "from": "0x08ea581a1da0e4c8a3e494501102c1cb16a89d1d", - "gasUsed": "0xe19c", - "logs": [{ - "address": "0xe7f1725e7734ce288f8367e1bb143e90bb3f0512", - "blockHash": "0x20dd72172e4bd9c99a919c217dd8c0154cbe0f9e305e67c5247f2ee8ae987c06", - "blockNumber": "0x16", - "data": "0x0000000000000000000000000000000000000000000000000000000000000002", - "logIndex": "0x0", - "removed": false, - "topics": [ - "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", - "0x00000000000000000000000008ea581a1da0e4c8a3e494501102c1cb16a89d1d", - "0x0000000000000000000000008259d2809ea92d5fad80c279ea11d2e371b8e33c" - ], - "transactionHash": "0x8eef471d6dad6584888af17b80f01f25f79875a0e0a1cbd17809c74093381bbc", - "transactionIndex": "0x26" - }], - "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000010000000000000000000000000010000000000000000000000000008000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000002000004000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000", - "status": "0x1", - "to": "0xe7f1725e7734ce288f8367e1bb143e90bb3f0512", - "transactionHash": "0x8eef471d6dad6584888af17b80f01f25f79875a0e0a1cbd17809c74093381bbc", - "transactionIndex": "0x26", - "type": "0x0" - }"#; - - let receipt: ExternalReceipt = serde_json::from_str(alloy_receipt).unwrap(); - assert_eq!(receipt.0.effective_gas_price, 0); - assert_eq!(receipt.0.transaction_type(), TxType::Legacy); - } -} diff --git a/src/eth/types/external/external_receipts.rs b/src/eth/types/external/external_receipts.rs deleted file mode 100644 index 238a5d766..000000000 --- a/src/eth/types/external/external_receipts.rs +++ /dev/null @@ -1,52 +0,0 @@ -use std::collections::HashMap; - -use anyhow::anyhow; -#[cfg(test)] -use fake::Dummy; -#[cfg(test)] -use fake::Faker; - -use crate::eth::types::ExternalReceipt; -use crate::eth::types::Hash; - -/// A collection of [`ExternalReceipt`]. -#[derive(Debug, Clone, PartialEq, serde::Serialize)] -#[cfg_attr(test, derive(serde::Deserialize))] -pub struct ExternalReceipts(HashMap); - -impl ExternalReceipts { - /// Tries to remove a receipt by its hash. - pub fn try_remove(&mut self, tx_hash: Hash) -> anyhow::Result { - match self.0.remove(&tx_hash) { - Some(receipt) => Ok(receipt), - None => { - tracing::error!(%tx_hash, "receipt is missing for hash"); - Err(anyhow!("receipt missing for hash {tx_hash}")) - } - } - } -} - -#[cfg(test)] -impl Dummy for ExternalReceipts { - fn dummy_with_rng(faker: &Faker, rng: &mut R) -> Self { - let count = (rng.next_u32() % 5 + 1) as usize; - let receipts = (0..count).map(|_| ExternalReceipt::dummy_with_rng(faker, rng)).collect::>(); - - Self::from(receipts) - } -} - -// ----------------------------------------------------------------------------- -// Conversions: Other -> Self -// ----------------------------------------------------------------------------- - -impl From> for ExternalReceipts { - fn from(receipts: Vec) -> Self { - let mut receipts_by_hash = HashMap::with_capacity_and_hasher(receipts.len(), hash_hasher::HashBuildHasher::default()); - for receipt in receipts { - receipts_by_hash.insert(receipt.hash(), receipt); - } - Self(receipts_by_hash) - } -} diff --git a/src/eth/types/external/external_transaction.rs b/src/eth/types/external/external_transaction.rs deleted file mode 100644 index 91ae1d7bc..000000000 --- a/src/eth/types/external/external_transaction.rs +++ /dev/null @@ -1,280 +0,0 @@ -#[cfg(test)] -use alloy_consensus::Signed; -#[cfg(test)] -use alloy_consensus::TxEnvelope; -#[cfg(test)] -use alloy_consensus::TxLegacy; -#[cfg(test)] -use alloy_consensus::transaction::Recovered; -#[cfg(test)] -use alloy_primitives::Bytes; -#[cfg(test)] -use alloy_primitives::Signature; -#[cfg(test)] -use alloy_primitives::TxKind; -#[cfg(test)] -use alloy_primitives::U256; -use anyhow::Context; -use anyhow::Result; -#[cfg(test)] -use fake::Dummy; -#[cfg(test)] -use fake::Fake; -#[cfg(test)] -use fake::Faker; - -use crate::alias::AlloyTransaction; -#[cfg(test)] -use crate::eth::types::Address; -use crate::eth::types::BlockNumber; -use crate::eth::types::Hash; -#[cfg(test)] -use crate::eth::types::UnixTime; -#[cfg(test)] -use crate::eth::types::Wei; - -#[derive(Debug, Clone, PartialEq, derive_more::Deref, serde::Serialize)] -#[serde(transparent)] -pub struct ExternalTransaction(#[deref] pub AlloyTransaction); - -impl<'de> serde::Deserialize<'de> for ExternalTransaction { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - use serde::de::Error; - use serde_json::Value; - - let mut value = Value::deserialize(deserializer)?; - - if let Value::Object(ref mut map) = value { - // If v is 0x0 or 0x1, this is a type 2 (EIP-1559) transaction - if let Some(Value::String(v_value)) = map.get("v") - && (v_value == "0x0" || v_value == "0x1") - && !map.contains_key("type") - { - map.insert("type".to_string(), Value::String("0x2".to_string())); - } - - // Check if this is a type 2 transaction - if let Some(Value::String(type_value)) = map.get("type") - && type_value == "0x2" - { - let gas_price = map.get("gasPrice").cloned().unwrap_or(Value::String("0x0".to_string())); - // For EIP-1559 transactions, ensure max_fee_per_gas and max_priority_fee_per_gas are present - if !map.contains_key("maxFeePerGas") { - map.insert("maxFeePerGas".to_string(), gas_price.clone()); - } - if !map.contains_key("maxPriorityFeePerGas") { - map.insert("maxPriorityFeePerGas".to_string(), gas_price); - } - if !map.contains_key("accessList") { - map.insert("accessList".to_string(), Value::Array(Vec::new())); - } - } - // Check if this is a type 1 transaction - if let Some(Value::String(type_value)) = map.get("type") - && type_value == "0x1" - { - // For EIP-2930 transactions, ensure accessList is present - if !map.contains_key("accessList") { - map.insert("accessList".to_string(), Value::Array(Vec::new())); - } - } - } - - // Use the inner type's deserialization - let transaction = AlloyTransaction::deserialize(value).map_err(D::Error::custom)?; - - Ok(ExternalTransaction(transaction)) - } -} - -impl ExternalTransaction { - /// Returns the block number where the transaction was mined. - pub fn block_number(&self) -> Result { - Ok(self.0.block_number.context("ExternalTransaction has no block_number")?.into()) - } - - /// Returns the transaction hash. - pub fn hash(&self) -> Hash { - Hash::from(*self.0.inner.tx_hash()) - } -} - -#[cfg(test)] -impl Dummy for ExternalTransaction { - fn dummy_with_rng(faker: &Faker, rng: &mut R) -> Self { - let from: Address = faker.fake_with_rng(rng); - let to: Address = faker.fake_with_rng(rng); - - let block_hash: Hash = faker.fake_with_rng(rng); - let block_timestamp: UnixTime = faker.fake_with_rng(rng); - - let gas_price: u128 = faker.fake_with_rng(rng); - let value: Wei = Wei::from(rng.next_u64()); - - let tx = TxLegacy { - chain_id: Some(1), - nonce: rng.next_u64(), - gas_price, - gas_limit: rng.next_u64(), - to: TxKind::Call(from.into()), - value: value.into(), - input: Bytes::default(), - }; - - let r = U256::from(rng.next_u64()); - let s = U256::from(rng.next_u64()); - let v = rng.next_u64().is_multiple_of(2); - let signature = Signature::new(r, s, v); - - let hash: Hash = faker.fake_with_rng(rng); - let inner_tx = TxEnvelope::Legacy(Signed::new_unchecked(tx, signature, hash.into())); - - let inner = alloy_rpc_types_eth::Transaction { - inner: Recovered::new_unchecked(inner_tx, to.into()), - block_hash: Some(block_hash.into()), - block_number: Some(rng.next_u64()), - block_timestamp: Some(*block_timestamp), - transaction_index: Some(rng.next_u64()), - effective_gas_price: Some(gas_price), - }; - - ExternalTransaction(inner) - } -} - -// ----------------------------------------------------------------------------- -// Conversions: Other -> Self -// ----------------------------------------------------------------------------- -impl From for ExternalTransaction { - fn from(value: AlloyTransaction) -> Self { - ExternalTransaction(value) - } -} - -// ----------------------------------------------------------------------------- -// Tests -// ----------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - #[test] - fn test_deserialize_type0_transaction() { - let json = json!({ - "hash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - "type": "0x0", - "from": "0x1234567890123456789012345678901234567890", - "to": "0x0987654321098765432109876543210987654321", - "gas": "0x76c0", - "gasPrice": "0x9184e72a000", - "nonce": "0x1", - "value": "0x9184e72a", - "input": "0x", - "chainId": "0x1", - "r": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - "s": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", - "v": "0x1b" - }); - - let tx: ExternalTransaction = serde_json::from_value(json).unwrap(); - - assert!(matches!(tx.0.inner.inner(), TxEnvelope::Legacy(_))); - } - - #[test] - fn test_deserialize_type1_transaction_with_missing_access_list() { - let json = json!({ - "hash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - "type": "0x1", - "from": "0x1234567890123456789012345678901234567890", - "to": "0x0987654321098765432109876543210987654321", - "gas": "0x76c0", - "gasPrice": "0x9184e72a000", - "nonce": "0x1", - "value": "0x9184e72a", - "input": "0x", - "chainId": "0x1", - "r": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - "s": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", - "v": "0x0" - // accessList is missing - }); - - let tx: ExternalTransaction = serde_json::from_value(json).unwrap(); - - assert!(matches!(tx.0.inner.inner(), TxEnvelope::Eip2930(_))); - } - - #[test] - fn test_deserialize_type2_transaction_with_missing_fields() { - let json = json!({ - "hash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - "type": "0x2", - "from": "0x1234567890123456789012345678901234567890", - "to": "0x0987654321098765432109876543210987654321", - "gas": "0x76c0", - "nonce": "0x1", - "value": "0x9184e72a", - "input": "0x", - "chainId": "0x1", - "r": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - "s": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", - "v": "0x1" - // maxFeePerGas, maxPriorityFeePerGas, and accessList are missing - }); - - let tx: ExternalTransaction = serde_json::from_value(json).unwrap(); - - assert!(matches!(tx.0.inner.inner(), TxEnvelope::Eip1559(_))); - } - - #[test] - fn test_deserialize_type2_inferred_from_v_value() { - let json = json!({ - "hash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - "from": "0x1234567890123456789012345678901234567890", - "to": "0x0987654321098765432109876543210987654321", - "gas": "0x76c0", - "gasPrice": "0x9184e72a000", - "nonce": "0x1", - "value": "0x9184e72a", - "input": "0x", - "chainId": "0x1", - "r": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - "s": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", - "v": "0x0" - // type field is missing, but v is 0x0 so it should be inferred as type 2 - }); - - let tx: ExternalTransaction = serde_json::from_value(json).unwrap(); - - assert!(matches!(tx.0.inner.inner(), TxEnvelope::Eip1559(_))); - - // Test with v = 0x1 as well - let json = json!({ - "hash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - "from": "0x1234567890123456789012345678901234567890", - "to": "0x0987654321098765432109876543210987654321", - "gas": "0x76c0", - "gasPrice": "0x9184e72a000", - "nonce": "0x1", - "value": "0x9184e72a", - "input": "0x", - "chainId": "0x1", - "r": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - "s": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", - "v": "0x1" - // type field is missing, but v is 0x1 so it should be inferred as type 2 - }); - - let tx: ExternalTransaction = serde_json::from_value(json).unwrap(); - - assert!(matches!(tx.0.inner.inner(), TxEnvelope::Eip1559(_))); - } -} diff --git a/src/eth/types/external/mod.rs b/src/eth/types/external/mod.rs deleted file mode 100644 index 982ae87da..000000000 --- a/src/eth/types/external/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -mod external_block; -mod external_block_with_receipts; -mod external_receipt; -mod external_receipts; -mod external_transaction; - -pub use external_block::ExternalBlock; -pub use external_block_with_receipts::ExternalBlockWithReceipts; -pub use external_receipt::ExternalReceipt; -pub use external_receipts::ExternalReceipts; -pub use external_transaction::ExternalTransaction; diff --git a/src/eth/types/mod.rs b/src/eth/types/mod.rs index 170618781..798311395 100644 --- a/src/eth/types/mod.rs +++ b/src/eth/types/mod.rs @@ -1,7 +1,6 @@ pub mod block; mod error; pub mod execution_kind; -pub mod external; pub mod primitives; pub mod transaction; pub use block::Block; @@ -14,11 +13,6 @@ pub use error::StateError; pub use error::StratusError; pub use error::UnexpectedError; pub use execution_kind::ExecutionKind; -pub use external::ExternalBlock; -pub use external::ExternalBlockWithReceipts; -pub use external::ExternalReceipt; -pub use external::ExternalReceipts; -pub use external::ExternalTransaction; pub use primitives::Account; pub use primitives::Address; pub use primitives::BlockNumber; @@ -69,24 +63,13 @@ mod tests { use crate::gen_test_json; use crate::gen_test_serde; - // TODO: Test external structs and internal structs that contain external strtucts that do no implement faker::Dummy + // TODO: Test internal structs that do no implement faker::Dummy // gen_test_serde!(ExecutionConflicts); // gen_test_serde!(ExecutionConflictsBuilder); - // gen_test_serde!(ExternalBlock); - // gen_test_serde!(ExternalReceipt); - // gen_test_serde!(ExternalReceipts); - // gen_test_serde!(ExternalTransaction); - // gen_test_serde!(ExternalTransactionExecution); // gen_test_serde!(PendingBlock); // gen_test_serde!(TransactionExecution); // gen_test_serde!(TransactionStage); - gen_test_json!(ExternalBlock); - gen_test_json!(ExternalBlockWithReceipts); - gen_test_json!(ExternalReceipt); - gen_test_json!(ExternalReceipts); - gen_test_json!(ExternalTransaction); - gen_test_serde!(Account); gen_test_serde!(Address); gen_test_serde!(BlockFilter); diff --git a/src/eth/types/transaction/transaction_input.rs b/src/eth/types/transaction/transaction_input.rs index 577e76ab8..880112591 100644 --- a/src/eth/types/transaction/transaction_input.rs +++ b/src/eth/types/transaction/transaction_input.rs @@ -1,5 +1,4 @@ use alloy_consensus::Signed; -use alloy_consensus::Transaction; use alloy_consensus::TxEip1559; use alloy_consensus::TxEip2930; use alloy_consensus::TxEip4844; @@ -32,7 +31,6 @@ use crate::eth::rpc::TransactionDecodeError; use crate::eth::types::Address; use crate::eth::types::Bytes; use crate::eth::types::ChainId; -use crate::eth::types::ExternalTransaction; use crate::eth::types::Gas; use crate::eth::types::Hash; use crate::eth::types::Nonce; @@ -649,63 +647,6 @@ impl RlpDecodable for TransactionInput { // ----------------------------------------------------------------------------- // Conversion: Other -> Self // ----------------------------------------------------------------------------- -impl TryFrom for TransactionInput { - type Error = anyhow::Error; - - fn try_from(value: ExternalTransaction) -> anyhow::Result { - let envelope = value.0.inner.inner(); - - // Reject fields that were used to sign the transaction but are not stored in `TransactionInput`. - ensure_supported(envelope.access_list().is_none_or(|list| list.is_empty()), "accessList")?; - ensure_supported( - envelope.max_priority_fee_per_gas().is_none_or(|fee| fee == envelope.max_fee_per_gas()), - "maxPriorityFeePerGas", - )?; - ensure_supported(envelope.max_fee_per_blob_gas().is_none_or(|fee| fee == 0), "maxFeePerBlobGas")?; - ensure_supported(envelope.blob_versioned_hashes().is_none_or(|hashes| hashes.is_empty()), "blobVersionedHashes")?; - ensure_supported(envelope.authorization_list().is_none_or(|list| list.is_empty()), "authorizationList")?; - - // Get signature components from the envelope - let signature = envelope.signature(); - let signature = Signature { - r: signature.r(), - s: signature.s(), - v: if signature.v() { U64::ONE } else { U64::ZERO }, - }; - - // Build the TransactionInput from the fields we currently support, leaving the - // signer unrecovered. We intentionally ignore any signer that may - // already be present in the source transaction so that the leader and the follower always derive the same address - // from the same set of saved fields. - let mut tx_input = TransactionInput { - transaction_info: TransactionInfo { - tx_type: Some(U64::from(envelope.tx_type() as u8)), - hash: Hash::from(*envelope.tx_hash()), - }, - execution_info: ExecutionInfo { - chain_id: envelope.chain_id().map(Into::into), - nonce: Nonce::from(envelope.nonce()), - signer: Signer::Unrecovered, - to: match envelope.kind() { - TxKind::Call(addr) => Some(Address::from(addr)), - TxKind::Create => None, - }, - value: Wei::from(envelope.value()), - input: Bytes::from(envelope.input().clone()), - gas_limit: Gas::from(envelope.gas_limit()), - gas_price: envelope.max_fee_per_gas(), - }, - signature, - }; - - // Recover the signer directly from the saved fields. - let recovered_signer = tx_input.recover_signer_address()?; - tx_input.execution_info.signer = Signer::Recovered(recovered_signer); - - Ok(tx_input) - } -} - impl From for ExecutionInfo { fn from(value: TransactionExecutionInput) -> Self { Self { From 17c0a310d05173ae47bca40b2b1c6fe87dfd54bd Mon Sep 17 00:00:00 2001 From: gventino-cw Date: Wed, 30 Sep 2026 19:38:19 -0300 Subject: [PATCH 09/10] refactor(e2e): merge pagination tests into stratus-native format --- e2e/test/external/e2e-json-rpc.test.ts | 31 ++++---- .../follower/e2e-pagination-stratus.test.ts | 41 ---------- e2e/test/follower/e2e-pagination.test.ts | 79 ++++++------------- justfile | 24 +----- 4 files changed, 44 insertions(+), 131 deletions(-) delete mode 100644 e2e/test/follower/e2e-pagination-stratus.test.ts diff --git a/e2e/test/external/e2e-json-rpc.test.ts b/e2e/test/external/e2e-json-rpc.test.ts index 9dbbe5e50..b69437220 100644 --- a/e2e/test/external/e2e-json-rpc.test.ts +++ b/e2e/test/external/e2e-json-rpc.test.ts @@ -35,6 +35,13 @@ import { toPaddedHex, } from "../helpers/rpc"; +// The block DTO serializes hashes as byte arrays; convert them to hex for assertions. +const bytesToHex = (bytes: number[]) => "0x" + Buffer.from(bytes).toString("hex"); + +// The block DTO serializes block numbers as byte-order-swapped u32; swap them back for assertions. +const swapU32 = (value: number) => + ((value & 0xff) << 24) | ((value & 0xff00) << 8) | ((value >>> 8) & 0xff00) | (value >>> 24); + describe("JSON-RPC", () => { before(() => { expect(currentBlockMode()).eq(BlockMode.External, "Wrong block mining mode is used"); @@ -165,24 +172,18 @@ describe("JSON-RPC", () => { const block = await send("eth_getBlockByNumber", [blockNumber, true]); const blockHash = block.hash; - // Get individual block and receipt - const individualBlock = await send("eth_getBlockByHash", [blockHash, true]); - const individualReceipt = await send("eth_getTransactionReceipt", [txHash, true]); - // Get block and receipts using stratus endpoint const response = await send("stratus_getBlockAndReceipts", [blockHash]); - // Validate block - expect(response.block).to.not.be.null; - expect(response.block).to.deep.equal(individualBlock); - - // Validate receipt - expect(response.receipts).to.have.length(1); - const combinedReceipt = response.receipts[0]; - const safeIndividualReceipt = individualReceipt!; - - // Compare receipt fields - expect(combinedReceipt).to.deep.equal(safeIndividualReceipt); + // The response is the stratus block DTO, with receipts embedded in each transaction + expect(response.block).to.equal(undefined); + expect(response.receipts).to.equal(undefined); + expect(bytesToHex(response.header.hash)).to.equal(blockHash); + expect(swapU32(response.header.number)).to.equal(parseInt(blockNumber, 16)); + expect(response.transactions).to.have.length(1); + expect(bytesToHex(response.transactions[0].input.hash)).to.equal(txHash); + expect(response.transactions[0].execution).to.not.equal(undefined); + expect(response.transactions[0].logs).to.be.an("array"); }); }); describe("stratus_getBlockByTimestamp", () => { diff --git a/e2e/test/follower/e2e-pagination-stratus.test.ts b/e2e/test/follower/e2e-pagination-stratus.test.ts deleted file mode 100644 index 574100d45..000000000 --- a/e2e/test/follower/e2e-pagination-stratus.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { expect } from "chai"; - -import { ALICE } from "../helpers/account"; -import { CHAIN_ID_DEC, send } from "../helpers/rpc"; -import { FOLLOWER_URL, rpcCall, waitForFollowerBlock, waitForReceipt } from "./helpers"; - -const FAT_TX_DATA_BYTES = 50_000; - -describe("Pagination (stratus-native importer format)", () => { - it("syncs the follower through the stratus-native importer format", async () => { - // fat contract deployment: the code always fails, but the fat data makes the response oversized - const nonce = await send("eth_getTransactionCount", [ALICE.address]); - const signedTx = await ALICE.signer().signTransaction({ - data: "0x" + "ab".repeat(FAT_TX_DATA_BYTES), - chainId: CHAIN_ID_DEC, - gasPrice: 0, - gasLimit: 10_000_000, - nonce: nonce, - }); - const txHash = await send("eth_sendRawTransaction", [signedTx]); - - const receipt = await waitForReceipt(txHash); - const fatBlockNumber = parseInt(receipt.blockNumber, 16); - - // the follower imports the fat block through the paginated stratus-format importer - await waitForFollowerBlock(fatBlockNumber); - - // the follower serves the same block content; the leader block is requested thin because - // its response limit rejects the full fat block over `eth_getBlockByNumber` - const leaderBlock = await send("eth_getBlockByNumber", [receipt.blockNumber, false]); - const followerBlock = await rpcCall(FOLLOWER_URL, "eth_getBlockByNumber", [receipt.blockNumber, true]); - expect(followerBlock.result.hash).to.equal(leaderBlock.hash); - expect(followerBlock.result.transactions).to.have.lengthOf(leaderBlock.transactions.length); - expect(followerBlock.result.transactions[0].hash).to.equal(txHash); - - // and the transaction receipt is available on the follower - const followerReceipt = await rpcCall(FOLLOWER_URL, "eth_getTransactionReceipt", [txHash]); - expect(followerReceipt.result).to.not.equal(null); - expect(followerReceipt.result.blockNumber).to.equal(receipt.blockNumber); - }); -}); diff --git a/e2e/test/follower/e2e-pagination.test.ts b/e2e/test/follower/e2e-pagination.test.ts index 5602b4f34..61ff62d67 100644 --- a/e2e/test/follower/e2e-pagination.test.ts +++ b/e2e/test/follower/e2e-pagination.test.ts @@ -11,7 +11,7 @@ import { FOLLOWER_URL, rpcCall, waitForFollowerBlock, waitForReceipt } from "./h const MAX_RESPONSE_BYTES = 8192; const FAT_TX_DATA_BYTES = 50_000; -// The stratus-native block DTO serializes hashes as byte arrays; convert them to hex for assertions. +// The block DTO serializes hashes as byte arrays; convert them to hex for assertions. const bytesToHex = (bytes: number[]) => "0x" + Buffer.from(bytes).toString("hex"); // The block DTO serializes block numbers as byte-order-swapped u32; swap them back for assertions. @@ -20,24 +20,16 @@ const swapU32 = (value: number) => describe("Pagination", () => { it("paginates oversized importer responses and keeps the follower syncing", async () => { - // a fitting response is served normally, with no envelope, so old followers are unaffected + // a fitting response is served normally, with no envelope const earlyBlock = await send("eth_getBlockByNumber", ["0x1", false]); expect(earlyBlock).to.not.equal(null); const small = await send("stratus_getBlockAndReceipts", [earlyBlock.hash]); expect(small.stratus_paginated).to.equal(undefined); - expect(small.block.number).to.equal("0x1"); - - // the stratus-native format serves the block DTO directly, still without envelope when it fits - const smallStratus = await send("stratus_getBlockAndReceipts", [ - earlyBlock.hash, - { offset: 0, format: "stratus" }, - ]); - expect(smallStratus.stratus_paginated).to.equal(undefined); - expect(smallStratus.block).to.equal(undefined); - expect(smallStratus.receipts).to.equal(undefined); - expect(bytesToHex(smallStratus.header.hash)).to.equal(earlyBlock.hash); - expect(swapU32(smallStratus.header.number)).to.equal(1); - expect(smallStratus.transactions).to.be.an("array"); + expect(small.block).to.equal(undefined); + expect(small.receipts).to.equal(undefined); + expect(bytesToHex(small.header.hash)).to.equal(earlyBlock.hash); + expect(swapU32(small.header.number)).to.equal(1); + expect(small.transactions).to.be.an("array"); // fat contract deployment: the code always fails, but the fat data makes the response oversized const nonce = await send("eth_getTransactionCount", [ALICE.address]); @@ -74,48 +66,29 @@ describe("Pagination", () => { expect(assembled.length).to.equal(total); expect(total).to.be.greaterThan(MAX_RESPONSE_BYTES, "the block response should be oversized"); - // the reassembled content matches the block + // the reassembled content is the block DTO, with receipts embedded const response = JSON.parse(assembled.toString("utf8")); - expect(response.block.hash).to.equal(fatBlockHash); - expect(parseInt(response.block.number, 16)).to.equal(fatBlockNumber); - expect(response.block.transactions).to.have.length(1); - expect(response.receipts).to.have.length(1); - expect(response.receipts[0].transactionHash).to.equal(txHash); - - // the same oversized block paginates identically in the stratus-native format - let stratusAssembled: Buffer = Buffer.alloc(0); - let stratusTotal = 0; - for ( - let offset = 0; - stratusTotal === 0 || stratusAssembled.length < stratusTotal; - offset = stratusAssembled.length - ) { - const envelope = await send("stratus_getBlockAndReceipts", [ - fatBlockHash, - { offset: offset, format: "stratus" }, - ]); - expect(envelope.stratus_paginated).to.not.equal(undefined); - stratusTotal = envelope.stratus_paginated.total; - const chunk = Buffer.from(envelope.stratus_paginated.chunk, "base64"); - expect(chunk.length).to.be.greaterThan(0); - stratusAssembled = Buffer.concat([stratusAssembled, chunk]); - } - expect(stratusAssembled.length).to.equal(stratusTotal); - expect(stratusTotal).to.be.greaterThan(MAX_RESPONSE_BYTES, "the stratus response should be oversized"); - - // the reassembled stratus content has the block DTO shape, with receipts embedded - const stratusResponse = JSON.parse(stratusAssembled.toString("utf8")); - expect(stratusResponse.block).to.equal(undefined); - expect(stratusResponse.receipts).to.equal(undefined); - expect(bytesToHex(stratusResponse.header.hash)).to.equal(fatBlockHash); - expect(swapU32(stratusResponse.header.number)).to.equal(fatBlockNumber); - expect(stratusResponse.transactions).to.have.length(1); - expect(bytesToHex(stratusResponse.transactions[0].input.hash)).to.equal(txHash); - expect(stratusResponse.transactions[0].execution).to.not.equal(undefined); - expect(stratusResponse.transactions[0].logs).to.not.equal(undefined); + expect(response.block).to.equal(undefined); + expect(response.receipts).to.equal(undefined); + expect(bytesToHex(response.header.hash)).to.equal(fatBlockHash); + expect(swapU32(response.header.number)).to.equal(fatBlockNumber); + expect(response.transactions).to.have.length(1); + expect(bytesToHex(response.transactions[0].input.hash)).to.equal(txHash); + expect(response.transactions[0].execution).to.not.equal(undefined); + expect(response.transactions[0].logs).to.not.equal(undefined); // the follower imports the fat block through the paginated importer await waitForFollowerBlock(fatBlockNumber); + + // the follower serves the same block content; the leader block is requested thin because + // its response limit rejects the full fat block over `eth_getBlockByNumber` + const leaderBlock = await send("eth_getBlockByNumber", [receipt.blockNumber, false]); + const followerBlock = await rpcCall(FOLLOWER_URL, "eth_getBlockByNumber", [receipt.blockNumber, true]); + expect(followerBlock.result.hash).to.equal(leaderBlock.hash); + expect(followerBlock.result.transactions).to.have.lengthOf(leaderBlock.transactions.length); + expect(followerBlock.result.transactions[0].hash).to.equal(txHash); + + // and the transaction receipt is available on the follower const followerReceipt = await rpcCall(FOLLOWER_URL, "eth_getTransactionReceipt", [txHash]); expect(followerReceipt.result).to.not.equal(null); expect(followerReceipt.result.blockNumber).to.equal(receipt.blockNumber); diff --git a/justfile b/justfile index e26db978a..d96db1865 100644 --- a/justfile +++ b/justfile @@ -314,7 +314,7 @@ e2e-leader *extra-args="": unset ENABLE_BLOCK_CHANGES_REPLICATION RUST_BACKTRACE=1 RUST_LOG=info just stratus-test --block-mode 1s --rocks-path-prefix=temp_3000 {{extra-args}} -e2e-follower test="brlc" use_block_changes_replication="false" *extra-args="": +e2e-follower test="brlc" use_block_changes_replication="false": #!/bin/bash # the binary reads the replication mode from the CLI flag; # `_e2e-leader-follower-up-impl` still exports ENABLE_BLOCK_CHANGES_REPLICATION for the mocha tests @@ -335,7 +335,7 @@ e2e-follower test="brlc" use_block_changes_replication="false" *extra-args="": fi RUST_BACKTRACE=1 RUST_LOG=info just stratus-follower-test --rocks-path-prefix=temp_3001 $replication_flag --kafka-bootstrap-servers {{kafka_bootstrap_servers}} --kafka-topic stratus-events --kafka-client-id stratus-producer --kafka-security-protocol none else - RUST_BACKTRACE=1 RUST_LOG=info just stratus-follower-test --rocks-path-prefix=temp_3001 $replication_flag {{extra-args}} + RUST_BACKTRACE=1 RUST_LOG=info just stratus-follower-test --rocks-path-prefix=temp_3001 $replication_flag fi @@ -424,26 +424,6 @@ e2e-leader-follower-pagination: just e2e-leader-follower-down exit $exit_code -# E2E: Leader & Follower pagination, with the follower importing the stratus-native response format -# (stratus_getBlockAndReceipts with `format: "stratus"`) -e2e-leader-follower-pagination-stratus: - #!/bin/bash - - # leader with a small response limit, forcing oversized importer responses to be paginated - just e2e-leader --max-response-size-bytes 8192 - - # follower importing through the stratus-native response format - just e2e-follower test/follower/e2e-pagination-stratus.test.ts false --response-format stratus - - cd e2e - if [ ! -d node_modules ]; then npm install; fi - npx hardhat test test/follower/e2e-pagination-stratus.test.ts --network stratus --bail - exit_code=$? - cd .. - - just e2e-leader-follower-down - exit $exit_code - # E2E: Leader & Follower pagination in block changes replication mode (stratus_getBlockWithChanges) e2e-leader-follower-pagination-changes: #!/bin/bash From cfdf96d5764c6a9b72db3b1342d107079cb283a8 Mon Sep 17 00:00:00 2001 From: gventino-cw Date: Thu, 1 Oct 2026 10:38:27 -0300 Subject: [PATCH 10/10] fix(lint): remove unused gen_test_json import --- src/eth/types/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/eth/types/mod.rs b/src/eth/types/mod.rs index 798311395..4bdd7f77e 100644 --- a/src/eth/types/mod.rs +++ b/src/eth/types/mod.rs @@ -60,7 +60,6 @@ mod tests { use crate::eth::rpc::LogFilter; use crate::eth::rpc::LogFilterInput; use crate::eth::rpc::LogFilterInputTopic; - use crate::gen_test_json; use crate::gen_test_serde; // TODO: Test internal structs that do no implement faker::Dummy