diff --git a/Cargo.lock b/Cargo.lock index b8162db..ca0a438 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1312,6 +1312,7 @@ dependencies = [ "alloy-primitives", "alloy-rpc-types-engine", "alloy-rpc-types-eth", + "alloy-rpc-types-trace", "alloy-sol-types", "arc-evm-node", "arc-execution-config", @@ -1335,7 +1336,9 @@ dependencies = [ "reth-tasks", "reth-tracing", "reth-transaction-pool", + "revm-bytecode", "rstest", + "serde_json", "tokio", "tracing", ] @@ -1876,6 +1879,7 @@ dependencies = [ "revm-inspectors", "revm-primitives", "serde_json", + "tikv-jemalloc-ctl", "tikv-jemallocator", "tokio", "tonic-build", diff --git a/Cargo.toml b/Cargo.toml index 49a8777..c7c154a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -205,6 +205,7 @@ reth-transaction-pool = { git = "https://github.com/paradigmxyz/reth", tag = "v1 # revm revm = { version = "34.0.0", default-features = false } +revm-bytecode = { version = "8.0.0", default-features = false } revm-context-interface = { version = "14.0.0", default-features = false } revm-database = { version = "10.0.0", default-features = false } revm-inspector = { version = "15.0.0", default-features = false } diff --git a/contracts/scripts/ProtocolConfigManagement.s.sol b/contracts/scripts/ProtocolConfigManagement.s.sol index 8fa4bcd..1fe9e93 100644 --- a/contracts/scripts/ProtocolConfigManagement.s.sol +++ b/contracts/scripts/ProtocolConfigManagement.s.sol @@ -384,8 +384,6 @@ contract ProtocolConfigManagement is Script { * @dev Requires CONTROLLER_KEY env var and controller role on ProtocolConfig */ function updateRewardBeneficiary(address newBeneficiary) public { - require(newBeneficiary != address(0), "beneficiary cannot be zero address"); - uint256 controllerKey = _getControllerKey(); vm.startBroadcast(controllerKey); @@ -394,4 +392,37 @@ contract ProtocolConfigManagement is Script { } } - +/// @title ProtocolConfigState +/// @notice Preserved-state hash helper used by upgrade/rollback scripts under +/// `contracts/deployments/-protocol-config-*/scripts/`. +/// +/// Aggregates every field an upgrade/rollback must preserve byte-for-byte and returns a +/// single hash. Pre-boundary and post-boundary calls should produce equal hashes; any +/// divergence indicates a storage-slot collision, accidental overwrite, or layout drift +/// between old and new implementations. +/// +/// Validity condition: valid only when the struct definitions returned by the getters +/// (`FeeParams`, `ConsensusParams`) are unchanged between old and new impl. A struct +/// layout change would make `abi.encode` produce different bytes for the same logical +/// state — when that happens, replace this helper with field-by-field comparison on +/// the surviving fields. +/// +/// `pauser` / `paused` are intentionally excluded. Their ERC-7201 slot may move across +/// a given upgrade, and during the upgrade window the value can be read from different +/// slots pre-vs-post boundary. Those fields belong in explicit specific-value +/// assertions at the call sites, not in this hash. +library ProtocolConfigState { + function hash(address proxy) internal view returns (bytes32) { + IProtocolConfig.FeeParams memory fee = ProtocolConfig(proxy).feeParams(); + IProtocolConfig.ConsensusParams memory cons = ProtocolConfig(proxy).consensusParams(); + return keccak256( + abi.encode( + fee, + cons, + ProtocolConfig(proxy).rewardBeneficiary(), + ProtocolConfig(proxy).owner(), + ProtocolConfig(proxy).controller() + ) + ); + } +} diff --git a/contracts/test/scripts/ProtocolConfigManagement.t.sol b/contracts/test/scripts/ProtocolConfigManagement.t.sol index ffdb44c..cdea16f 100644 --- a/contracts/test/scripts/ProtocolConfigManagement.t.sol +++ b/contracts/test/scripts/ProtocolConfigManagement.t.sol @@ -124,4 +124,25 @@ contract ProtocolConfigManagementTest is Test { assertEq(_feeParams.alpha, 0); assertEq(_feeParams.kRate, 0); } + + function test_UpdateRewardBeneficiary_Succeeds() public { + vm.setEnv("CONTROLLER_KEY", vm.toString(controllerPk)); + + address newBeneficiary = makeAddr("newBeneficiary"); + script.updateRewardBeneficiary(newBeneficiary); + + assertEq(protocolConfig.rewardBeneficiary(), newBeneficiary); + } + + function test_UpdateRewardBeneficiary_AcceptsZeroAddress() public { + vm.setEnv("CONTROLLER_KEY", vm.toString(controllerPk)); + + // Seed a non-zero beneficiary first so we can observe the clear. + address seedBeneficiary = makeAddr("seedBeneficiary"); + script.updateRewardBeneficiary(seedBeneficiary); + assertEq(protocolConfig.rewardBeneficiary(), seedBeneficiary); + + script.updateRewardBeneficiary(address(0)); + assertEq(protocolConfig.rewardBeneficiary(), address(0)); + } } diff --git a/crates/consensus-db/src/repositories/undecided_blocks.rs b/crates/consensus-db/src/repositories/undecided_blocks.rs index ff9683a..1b4105e 100644 --- a/crates/consensus-db/src/repositories/undecided_blocks.rs +++ b/crates/consensus-db/src/repositories/undecided_blocks.rs @@ -23,35 +23,33 @@ use arc_consensus_types::block::ConsensusBlock; pub trait UndecidedBlocksRepository { type Error: std::error::Error + Send + Sync + 'static; - /// Get the undecided block for the given height, round, and block hash. - /// - /// # Arguments - /// - `height`: The height to get the undecided block for. - /// - `round`: The round to get the undecided block for. - async fn get( + /// Gets the undecided block with the given height, round, and block hash. + /// Returns `None` if no such block exists. + async fn get_by_round_and_hash( &self, height: Height, round: Round, block_hash: BlockHash, ) -> Result, Self::Error>; - /// Get the undecided block for the given height and block hash (ignoring round). - /// Returns the first undecided block found that matches the height and block hash. - /// - /// # Arguments - /// - `height`: The height to get the undecided block for. - /// - `block_hash`: The block hash to get the undecided block for. - async fn get_first( + /// Gets all undecided blocks for the given height and round. + async fn get_by_round( + &self, + height: Height, + round: Round, + ) -> Result, Self::Error>; + + /// Gets the undecided block with the given height and block hash. + /// Scans across all rounds, returning the first match found. + /// Returns `None` if no such block exists. + async fn get_by_hash( &self, height: Height, block_hash: BlockHash, ) -> Result, Self::Error>; - /// Store the undecided block. - /// - /// # Arguments - /// - `block`: The block to store. - async fn store(&self, block: ConsensusBlock) -> Result<(), Self::Error>; + /// Stores the undecided block. + async fn store_undecided_block(&self, block: ConsensusBlock) -> Result<(), Self::Error>; } impl UndecidedBlocksRepository for &'_ T @@ -60,32 +58,42 @@ where { type Error = T::Error; - async fn get( + async fn get_by_round_and_hash( &self, height: Height, round: Round, block_hash: BlockHash, ) -> Result, Self::Error> { - (*self).get(height, round, block_hash).await + (*self) + .get_by_round_and_hash(height, round, block_hash) + .await } - async fn get_first( + async fn get_by_round( + &self, + height: Height, + round: Round, + ) -> Result, Self::Error> { + (*self).get_by_round(height, round).await + } + + async fn get_by_hash( &self, height: Height, block_hash: BlockHash, ) -> Result, Self::Error> { - (*self).get_first(height, block_hash).await + (*self).get_by_hash(height, block_hash).await } - async fn store(&self, block: ConsensusBlock) -> Result<(), Self::Error> { - (*self).store(block).await + async fn store_undecided_block(&self, block: ConsensusBlock) -> Result<(), Self::Error> { + (*self).store_undecided_block(block).await } } impl UndecidedBlocksRepository for Store { type Error = StoreError; - async fn get( + async fn get_by_round_and_hash( &self, height: Height, round: Round, @@ -94,7 +102,15 @@ impl UndecidedBlocksRepository for Store { self.get_undecided_block(height, round, block_hash).await } - async fn get_first( + async fn get_by_round( + &self, + height: Height, + round: Round, + ) -> Result, Self::Error> { + self.get_undecided_blocks(height, round).await + } + + async fn get_by_hash( &self, height: Height, block_hash: BlockHash, @@ -103,7 +119,7 @@ impl UndecidedBlocksRepository for Store { .await } - async fn store(&self, block: ConsensusBlock) -> Result<(), Self::Error> { + async fn store_undecided_block(&self, block: ConsensusBlock) -> Result<(), Self::Error> { self.store_undecided_block(block).await } } diff --git a/crates/evm-node/src/rpc_middleware.rs b/crates/evm-node/src/rpc_middleware.rs index bdc44a1..aabdebc 100644 --- a/crates/evm-node/src/rpc_middleware.rs +++ b/crates/evm-node/src/rpc_middleware.rs @@ -108,6 +108,8 @@ where } fn batch<'a>(&self, req: Batch<'a>) -> impl Future + Send + 'a { + // Pending-block filtering is intentionally skipped for batch requests. + // --rpc.pending-block=none (binary default) + network topology make this unexploitable. let batch = req .into_iter() .map( @@ -448,7 +450,7 @@ mod tests { // -- batch requests -- // // Pending-tx subscription/filter are blocked in batch. - // Pending block interception is NOT applied in batch; falls back to + // Pending block interception is intentionally NOT applied in batch; falls back to // --rpc.pending-block=none. #[tokio::test] diff --git a/crates/execution-e2e/Cargo.toml b/crates/execution-e2e/Cargo.toml index 5a3a19d..18f7c03 100644 --- a/crates/execution-e2e/Cargo.toml +++ b/crates/execution-e2e/Cargo.toml @@ -19,6 +19,8 @@ alloy-network.workspace = true alloy-primitives.workspace = true alloy-rpc-types-engine.workspace = true alloy-rpc-types-eth.workspace = true +alloy-rpc-types-trace.workspace = true +alloy-sol-types.workspace = true arc-evm-node = { path = "../evm-node" } # Arc crates arc-execution-config = { path = "../execution-config", features = ["test-utils"] } @@ -47,15 +49,16 @@ reth-rpc-api.workspace = true reth-rpc-server-types.workspace = true reth-tasks.workspace = true reth-transaction-pool.workspace = true +serde_json.workspace = true # Async tokio = { workspace = true, features = ["full"] } tracing.workspace = true [dev-dependencies] -alloy-sol-types.workspace = true arc-precompiles = { path = "../precompiles", features = ["test-utils"] } reth-tracing.workspace = true +revm-bytecode.workspace = true rstest.workspace = true [lints] diff --git a/crates/execution-e2e/src/actions/assert_named.rs b/crates/execution-e2e/src/actions/assert_named.rs new file mode 100644 index 0000000..3ba3f47 --- /dev/null +++ b/crates/execution-e2e/src/actions/assert_named.rs @@ -0,0 +1,248 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Environment-aware assertion actions that resolve named addresses at execution time. +//! +//! These complement the static `AssertTxLogs` and `AssertBalance` actions for cases +//! where the target address is only known after a prior action (e.g. `StoreDeployedAddress`) +//! has run. + +use crate::{action::Action, ArcEnvironment}; +use alloy_primitives::{Address, U256}; +use alloy_rpc_types_eth::BlockNumberOrTag; +use futures_util::future::BoxFuture; +use reth_provider::ReceiptProvider; +use reth_rpc_api::EthApiClient; +use tracing::info; + +use super::assert_tx_logs::{validate_event_log, TRANSFER_EVENT_SIGNATURE}; + +/// An address reference that can be either a concrete address or a named address +/// resolved from the environment at execution time. +#[derive(Clone)] +pub enum AddressRef { + /// A concrete address known at builder time. + Literal(Address), + /// A named address resolved from the environment at execution time. + Named(String), +} + +impl AddressRef { + fn resolve(&self, env: &ArcEnvironment) -> eyre::Result
{ + match self { + Self::Literal(addr) => Ok(*addr), + Self::Named(name) => env + .get_address(name) + .copied() + .ok_or_else(|| eyre::eyre!("Named address '{}' not found in environment", name)), + } + } +} + +impl From
for AddressRef { + fn from(addr: Address) -> Self { + Self::Literal(addr) + } +} + +/// Asserts an EIP-7708 Transfer event at a specific log index in a named transaction's receipt. +/// +/// Resolves `from` and `to` from the environment at execution time, supporting +/// both literal addresses and named deployed-contract addresses. +pub struct AssertTransferEvent { + tx_name: String, + log_index: usize, + from: AddressRef, + to: AddressRef, + value: U256, +} + +impl AssertTransferEvent { + /// Creates a new transfer event assertion. + /// + /// `from` and `to` accept either `Address` or `AddressRef::Named("name")`. + pub fn new( + tx_name: impl Into, + log_index: usize, + from: impl Into, + to: impl Into, + value: U256, + ) -> Self { + Self { + tx_name: tx_name.into(), + log_index, + from: from.into(), + to: to.into(), + value, + } + } + + /// Helper: named address reference for use with `from` / `to` parameters. + pub fn named(name: impl Into) -> AddressRef { + AddressRef::Named(name.into()) + } +} + +impl Action for AssertTransferEvent { + fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, eyre::Result<()>> { + Box::pin(async move { + let from = self.from.resolve(env)?; + let to = self.to.resolve(env)?; + + let tx_hash = *env.get_tx_hash(&self.tx_name).ok_or_else(|| { + eyre::eyre!("Transaction '{}' not found in environment", self.tx_name) + })?; + + let receipt = env + .node() + .inner + .provider() + .receipt_by_hash(tx_hash)? + .ok_or_else(|| { + eyre::eyre!("Receipt not found for tx '{}' ({})", self.tx_name, tx_hash) + })?; + + let log = receipt.logs.get(self.log_index).ok_or_else(|| { + eyre::eyre!( + "Tx '{}': no log at index {} (total: {})", + self.tx_name, + self.log_index, + receipt.logs.len() + ) + })?; + + validate_event_log( + &self.tx_name, + self.log_index, + log, + TRANSFER_EVENT_SIGNATURE, + from, + to, + self.value, + )?; + + info!( + tx = %self.tx_name, + index = self.log_index, + from = %from, + to = %to, + value = %self.value, + "Transfer event assertion passed" + ); + Ok(()) + }) + } +} + +/// Asserts account balance for a named address from the environment. +pub struct AssertNamedBalance { + address_name: String, + expected: U256, +} + +impl AssertNamedBalance { + /// Assert the balance of a named address equals the expected value. + pub fn of(address_name: impl Into) -> AssertNamedBalanceBuilder { + AssertNamedBalanceBuilder { + address_name: address_name.into(), + } + } +} + +/// Builder for `AssertNamedBalance`. +pub struct AssertNamedBalanceBuilder { + address_name: String, +} + +impl AssertNamedBalanceBuilder { + /// Assert exact balance match. + pub fn equals(self, expected: U256) -> AssertNamedBalance { + AssertNamedBalance { + address_name: self.address_name, + expected, + } + } +} + +impl Action for AssertNamedBalance { + fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, eyre::Result<()>> { + Box::pin(async move { + let address = *env.get_address(&self.address_name).ok_or_else(|| { + eyre::eyre!( + "Named address '{}' not found in environment", + self.address_name + ) + })?; + + let block_number = env.block_number(); + + info!( + name = %self.address_name, + address = %address, + expected = %self.expected, + block_number, + "Asserting named account balance" + ); + + let client = env + .node() + .rpc_client() + .ok_or_else(|| eyre::eyre!("RPC client not available"))?; + + let balance: U256 = >::balance( + &client, + address, + Some(BlockNumberOrTag::Number(block_number).into()), + ) + .await + .map_err(|e| { + eyre::eyre!( + "eth_getBalance failed for '{}' ({}) at block {}: {}", + self.address_name, + address, + block_number, + e + ) + })?; + + if balance != self.expected { + return Err(eyre::eyre!( + "Balance mismatch for '{}' ({}): expected {}, got {} (block {})", + self.address_name, + address, + self.expected, + balance, + block_number + )); + } + + info!( + name = %self.address_name, + address = %address, + balance = %balance, + "Named balance assertion passed" + ); + Ok(()) + }) + } +} diff --git a/crates/execution-e2e/src/actions/assert_tx_logs.rs b/crates/execution-e2e/src/actions/assert_tx_logs.rs new file mode 100644 index 0000000..f1f3eb5 --- /dev/null +++ b/crates/execution-e2e/src/actions/assert_tx_logs.rs @@ -0,0 +1,337 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Receipt log assertion action for EIP-7708 e2e tests. + +use crate::{action::Action, ArcEnvironment}; +use alloy_primitives::{Address, Bytes, B256, U256}; +use alloy_sol_types::{sol, SolEvent}; +use futures_util::future::BoxFuture; +use reth_provider::ReceiptProvider; +use tracing::info; + +/// Expected log entry at a specific index. +enum ExpectedLog { + /// Exact match on address, topics, and data. + Exact { + address: Address, + topics: Vec, + data: Bytes, + }, + /// ERC-20 Transfer(address,address,uint256) decode helper. + TransferEvent { + from: Address, + to: Address, + value: U256, + }, + /// NativeCoinTransferred(address,address,uint256) decode helper (pre-Zero5). + NativeCoinTransferredEvent { + from: Address, + to: Address, + amount: U256, + }, + /// Verify only the emitter address at a given index. + EmitterOnly { address: Address }, +} + +sol! { + event Transfer(address indexed from, address indexed to, uint256 value); + event NativeCoinTransferred(address indexed from, address indexed to, uint256 amount); +} + +/// keccak256("Transfer(address,address,uint256)") — derived from sol! macro. +pub const TRANSFER_EVENT_SIGNATURE: B256 = Transfer::SIGNATURE_HASH; + +/// keccak256("NativeCoinTransferred(address,address,uint256)") — derived from sol! macro. +pub const NATIVE_COIN_TRANSFERRED_SIGNATURE: B256 = NativeCoinTransferred::SIGNATURE_HASH; + +/// Validates a log entry against expected 3-topic event fields (signature, from, to, data). +/// +/// Shared by `AssertTxLogs` and `AssertTransferEvent` to avoid duplicated validation logic. +pub(crate) fn validate_event_log( + tx_name: &str, + index: usize, + log: &reth_ethereum::primitives::Log, + signature: B256, + from: Address, + to: Address, + value: U256, +) -> eyre::Result<()> { + let topics = log.topics(); + if topics.len() != 3 { + return Err(eyre::eyre!( + "Tx '{}' log[{}]: expected 3 topics, got {}", + tx_name, + index, + topics.len() + )); + } + if topics[0] != signature { + return Err(eyre::eyre!( + "Tx '{}' log[{}]: topic[0] signature mismatch: expected {}, got {}", + tx_name, + index, + signature, + topics[0] + )); + } + let expected_from = B256::left_padding_from(from.as_slice()); + if topics[1] != expected_from { + return Err(eyre::eyre!( + "Tx '{}' log[{}]: topic[1] (from) mismatch: expected {}, got {}", + tx_name, + index, + expected_from, + topics[1] + )); + } + let expected_to = B256::left_padding_from(to.as_slice()); + if topics[2] != expected_to { + return Err(eyre::eyre!( + "Tx '{}' log[{}]: topic[2] (to) mismatch: expected {}, got {}", + tx_name, + index, + expected_to, + topics[2] + )); + } + let expected_data = value.to_be_bytes::<32>(); + if log.data.data.as_ref() != expected_data.as_slice() { + return Err(eyre::eyre!( + "Tx '{}' log[{}]: data mismatch: expected {}, got {}", + tx_name, + index, + value, + log.data.data + )); + } + Ok(()) +} + +/// Asserts on receipt logs for a named transaction. +/// +/// Retrieves receipt via the provider, then validates log count +/// and individual log entries against expectations. +pub struct AssertTxLogs { + tx_name: String, + expected_log_count: Option, + expected_logs: Vec<(usize, ExpectedLog)>, +} + +impl AssertTxLogs { + /// Creates a new log assertion for the named transaction. + pub fn new(tx_name: impl Into) -> Self { + Self { + tx_name: tx_name.into(), + expected_log_count: None, + expected_logs: Vec::new(), + } + } + + /// Assert exact total number of logs. + pub fn expect_log_count(mut self, count: usize) -> Self { + self.expected_log_count = Some(count); + self + } + + /// Shorthand for `expect_log_count(0)`. + pub fn expect_no_logs(self) -> Self { + self.expect_log_count(0) + } + + /// Exact match on a single log entry. + pub fn expect_log_at( + mut self, + index: usize, + address: Address, + topics: Vec, + data: Bytes, + ) -> Self { + self.expected_logs.push(( + index, + ExpectedLog::Exact { + address, + topics, + data, + }, + )); + self + } + + /// Decode helper for ERC-20 Transfer(address,address,uint256). + pub fn expect_transfer_event( + mut self, + index: usize, + from: Address, + to: Address, + value: U256, + ) -> Self { + self.expected_logs + .push((index, ExpectedLog::TransferEvent { from, to, value })); + self + } + + /// Decode helper for pre-Zero5 NativeCoinTransferred(address,address,uint256). + pub fn expect_native_coin_transferred_event( + mut self, + index: usize, + from: Address, + to: Address, + amount: U256, + ) -> Self { + self.expected_logs.push(( + index, + ExpectedLog::NativeCoinTransferredEvent { from, to, amount }, + )); + self + } + + /// Verify only the emitter address at a given index. + pub fn expect_emitter_at(mut self, index: usize, address: Address) -> Self { + self.expected_logs + .push((index, ExpectedLog::EmitterOnly { address })); + self + } +} + +impl Action for AssertTxLogs { + fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, eyre::Result<()>> { + Box::pin(async move { + let tx_hash = *env.get_tx_hash(&self.tx_name).ok_or_else(|| { + eyre::eyre!("Transaction '{}' not found in environment", self.tx_name) + })?; + + info!( + name = %self.tx_name, + tx_hash = %tx_hash, + "Asserting transaction receipt logs" + ); + + let receipt = env + .node() + .inner + .provider() + .receipt_by_hash(tx_hash)? + .ok_or_else(|| { + eyre::eyre!("Receipt not found for tx '{}' ({})", self.tx_name, tx_hash) + })?; + + let logs = &receipt.logs; + + // Assert log count + if let Some(expected_count) = self.expected_log_count { + if logs.len() != expected_count { + return Err(eyre::eyre!( + "Tx '{}': expected {} logs, got {}. Logs: {:?}", + self.tx_name, + expected_count, + logs.len(), + logs + )); + } + } + + // Assert individual logs + for (index, expected) in &self.expected_logs { + let log = logs.get(*index).ok_or_else(|| { + eyre::eyre!( + "Tx '{}': no log at index {} (total: {})", + self.tx_name, + index, + logs.len() + ) + })?; + + match expected { + ExpectedLog::Exact { + address, + topics, + data, + } => { + if log.address != *address { + return Err(eyre::eyre!( + "Tx '{}' log[{}]: emitter mismatch: expected {}, got {}", + self.tx_name, + index, + address, + log.address + )); + } + let log_topics: Vec = log.topics().to_vec(); + if log_topics != *topics { + return Err(eyre::eyre!( + "Tx '{}' log[{}]: topics mismatch: expected {:?}, got {:?}", + self.tx_name, + index, + topics, + log_topics + )); + } + if log.data.data != *data { + return Err(eyre::eyre!( + "Tx '{}' log[{}]: data mismatch: expected {}, got {}", + self.tx_name, + index, + data, + log.data.data + )); + } + } + ExpectedLog::TransferEvent { from, to, value } => { + validate_event_log( + &self.tx_name, + *index, + log, + TRANSFER_EVENT_SIGNATURE, + *from, + *to, + *value, + )?; + } + ExpectedLog::NativeCoinTransferredEvent { from, to, amount } => { + validate_event_log( + &self.tx_name, + *index, + log, + NATIVE_COIN_TRANSFERRED_SIGNATURE, + *from, + *to, + *amount, + )?; + } + ExpectedLog::EmitterOnly { address } => { + if log.address != *address { + return Err(eyre::eyre!( + "Tx '{}' log[{}]: emitter mismatch: expected {}, got {}", + self.tx_name, + index, + address, + log.address + )); + } + } + } + } + + info!( + name = %self.tx_name, + log_count = logs.len(), + "Receipt log assertions passed" + ); + Ok(()) + }) + } +} diff --git a/crates/execution-e2e/src/actions/assert_tx_trace.rs b/crates/execution-e2e/src/actions/assert_tx_trace.rs new file mode 100644 index 0000000..cf8f4ef --- /dev/null +++ b/crates/execution-e2e/src/actions/assert_tx_trace.rs @@ -0,0 +1,98 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Debug trace assertion action for EIP-7708 e2e tests. + +use crate::{action::Action, ArcEnvironment}; +use alloy_rpc_types_trace::geth::{ + GethDebugBuiltInTracerType, GethDebugTracerConfig, GethDebugTracerType, GethDebugTracingOptions, +}; +use futures_util::future::BoxFuture; +use reth_rpc_api::DebugApiClient; +use tracing::info; + +/// Calls `debug_traceTransaction` for a named tx and asserts the call succeeds. +/// +/// At minimum, every test instantiates this to verify the tracer does not panic +/// on EIP-7708 transactions. Content assertions (log count, topics, data) are +/// provided as builder methods but should be commented out until the tracing bug is fixed. +pub struct AssertTxTrace { + tx_name: String, +} + +impl AssertTxTrace { + /// Creates a new trace assertion for the named transaction. + /// + /// The trace call uses `callTracer` with `{ withLog: true, onlyTopCall: false }`. + pub fn new(tx_name: impl Into) -> Self { + Self { + tx_name: tx_name.into(), + } + } +} + +impl Action for AssertTxTrace { + fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, eyre::Result<()>> { + Box::pin(async move { + let tx_hash = *env.get_tx_hash(&self.tx_name).ok_or_else(|| { + eyre::eyre!("Transaction '{}' not found in environment", self.tx_name) + })?; + + info!( + name = %self.tx_name, + tx_hash = %tx_hash, + "Calling debug_traceTransaction with callTracer" + ); + + let client = env + .node() + .rpc_client() + .ok_or_else(|| eyre::eyre!("RPC client not available"))?; + + let opts = GethDebugTracingOptions { + tracer: Some(GethDebugTracerType::BuiltInTracer( + GethDebugBuiltInTracerType::CallTracer, + )), + tracer_config: GethDebugTracerConfig( + serde_json::json!({ "withLog": true, "onlyTopCall": false }), + ), + ..Default::default() + }; + + let trace = >::debug_trace_transaction(&client, tx_hash, Some(opts)) + .await + .map_err(|e| { + eyre::eyre!( + "debug_traceTransaction failed for tx '{}' ({}): {}", + self.tx_name, + tx_hash, + e + ) + })?; + + info!( + name = %self.tx_name, + tx_hash = %tx_hash, + trace_variant = ?std::mem::discriminant(&trace), + "debug_traceTransaction succeeded" + ); + + Ok(()) + }) + } +} diff --git a/crates/execution-e2e/src/actions/mod.rs b/crates/execution-e2e/src/actions/mod.rs index c5d718b..c85319a 100644 --- a/crates/execution-e2e/src/actions/mod.rs +++ b/crates/execution-e2e/src/actions/mod.rs @@ -18,13 +18,22 @@ //! //! Actions are composable building blocks for test scenarios. +mod assert_named; +mod assert_tx_logs; +mod assert_tx_trace; mod assertions; mod call_contract; mod payload_utils; mod produce_blocks; mod produce_invalid_block; mod send_transaction; +mod store_deployed_address; +pub use assert_named::{AssertNamedBalance, AssertTransferEvent}; +pub use assert_tx_logs::{ + AssertTxLogs, NATIVE_COIN_TRANSFERRED_SIGNATURE, TRANSFER_EVENT_SIGNATURE, +}; +pub use assert_tx_trace::AssertTxTrace; pub use assertions::{ AssertBalance, AssertBlockNumber, AssertEthereumHardfork, AssertHardfork, AssertTxIncluded, AssertTxNotIncluded, TxStatus, @@ -37,3 +46,4 @@ pub use payload_utils::{ pub use produce_blocks::ProduceBlocks; pub use produce_invalid_block::ProduceInvalidBlock; pub use send_transaction::SendTransaction; +pub use store_deployed_address::StoreDeployedAddress; diff --git a/crates/execution-e2e/src/actions/send_transaction.rs b/crates/execution-e2e/src/actions/send_transaction.rs index a4f6862..5e8ae39 100644 --- a/crates/execution-e2e/src/actions/send_transaction.rs +++ b/crates/execution-e2e/src/actions/send_transaction.rs @@ -30,24 +30,54 @@ use reth_primitives_traits::SignerRecoverable; use reth_transaction_pool::{TransactionOrigin, TransactionPool}; use tracing::{debug, info}; -/// Sends an EIP-1559 transfer transaction to the node's transaction pool. +/// Closure that resolves calldata from the environment at execution time. +type DataResolver = Box eyre::Result + Send + Sync>; + +/// Sends an EIP-1559 transaction to the node's transaction pool. /// /// This action: /// 1. Creates an EIP-1559 transaction from a wallet /// 2. Signs and submits it directly to the transaction pool /// 3. Stores the transaction hash under the given name for later assertions -#[derive(Debug)] +/// +/// Supports both CALL (with `to` address) and CREATE (without `to`, using `with_create()`) +/// transactions. For CREATE transactions, the deployed contract address is extracted +/// from the receipt and stored under `"{name}_address"` in the environment. pub struct SendTransaction { /// Name to reference this transaction in assertions. name: String, /// The value to transfer (in wei). value: U256, - /// The recipient address. If None, sends to a random address. + /// The recipient address. If None and not create, sends to a random address. to: Option
, + /// Named address to look up at execution time (overrides `to` if set). + to_named: Option, + /// If true, this is a CREATE transaction (no `to` address). + create: bool, /// Optional input data for the transaction. data: Option, + /// Deferred calldata resolver — called at execution time with access to the environment. + data_resolver: Option, /// Gas limit for the transaction. gas_limit: u64, + /// Wallet index to sign from (default: 0 = first wallet). + wallet_index: usize, +} + +impl std::fmt::Debug for SendTransaction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SendTransaction") + .field("name", &self.name) + .field("value", &self.value) + .field("to", &self.to) + .field("to_named", &self.to_named) + .field("create", &self.create) + .field("data", &self.data) + .field("data_resolver", &self.data_resolver.as_ref().map(|_| "..")) + .field("gas_limit", &self.gas_limit) + .field("wallet_index", &self.wallet_index) + .finish() + } } impl SendTransaction { @@ -62,8 +92,12 @@ impl SendTransaction { name: name.into(), value: U256::from(1), to: None, + to_named: None, + create: false, data: None, + data_resolver: None, gas_limit: 26000, + wallet_index: 0, } } @@ -85,51 +119,120 @@ impl SendTransaction { self } + /// Marks this as a CREATE transaction (no `to` address). + /// The `data` field should contain the deployment bytecode. + pub fn with_create(mut self) -> Self { + self.create = true; + self + } + + /// Sets the recipient to a named address stored in the environment. + /// + /// At execution time, looks up the address by name (e.g. `"deploy_address"` + /// for a contract deployed by a CREATE tx named `"deploy"`). + pub fn with_to_named(mut self, address_name: impl Into) -> Self { + self.to_named = Some(address_name.into()); + self + } + + /// Deferred calldata — the closure is called at execution time with access + /// to the environment, so it can resolve named addresses or other runtime state. + pub fn with_data_fn( + mut self, + f: impl Fn(&ArcEnvironment) -> eyre::Result + Send + Sync + 'static, + ) -> Self { + self.data_resolver = Some(Box::new(f)); + self + } + /// Sets the gas limit for the transaction. pub fn with_gas_limit(mut self, gas_limit: u64) -> Self { self.gas_limit = gas_limit; self } - /// Signs, submits to pool, and returns the transaction hash and recovered transaction. + /// Signs from the wallet at the given index (default: 0). + /// + /// Useful for tests that need to send from different roles, + /// e.g. index 7 is the operator in localdev genesis. + pub fn with_wallet_index(mut self, index: usize) -> Self { + self.wallet_index = index; + self + } + + /// Executes the action and returns the transaction hash. pub async fn execute_and_return( &self, env: &mut ArcEnvironment, ) -> eyre::Result<(TxHash, reth_primitives_traits::Recovered)> { - let wallet = env.wallet_mut()?; - let signer = wallet - .wallet_gen() - .first() - .ok_or_else(|| eyre::eyre!("No wallets generated"))? - .clone(); - - let chain_id = wallet.chain_id; - let nonce = wallet.inner_nonce; + let (signer, chain_id) = { + let wallet = env.wallet_mut()?; + let wallets = wallet.wallet_gen(); + let signer = wallets + .get(self.wallet_index) + .ok_or_else(|| { + eyre::eyre!( + "Wallet index {} not available (only {} wallets)", + self.wallet_index, + wallets.len() + ) + })? + .clone(); + (signer, wallet.chain_id) + }; - wallet.inner_nonce += 1; + let nonce = env.next_nonce_for_wallet(self.wallet_index)?; - let to_address = self.to.unwrap_or_else(Address::random); + let tx_kind = if self.create { + info!( + name = %self.name, + nonce, + value = %self.value, + "Sending CREATE transaction" + ); + TxKind::Create + } else { + // Resolve recipient: named address > explicit address > random + let to_address = if let Some(ref addr_name) = self.to_named { + *env.get_address(addr_name).ok_or_else(|| { + eyre::eyre!( + "Named address '{}' not found in environment for tx '{}'", + addr_name, + self.name + ) + })? + } else { + self.to.unwrap_or_else(Address::random) + }; + info!( + name = %self.name, + nonce, + value = %self.value, + to = %to_address, + "Sending transaction" + ); + TxKind::Call(to_address) + }; - info!( - name = %self.name, - nonce, - value = %self.value, - to = %to_address, - "Sending transaction" - ); + // Resolve calldata: deferred resolver > explicit data + let resolved_data = if let Some(ref resolver) = self.data_resolver { + Some(resolver(env)?) + } else { + self.data.clone() + }; // Build EIP-1559 transaction request let tx = TransactionRequest { nonce: Some(nonce), value: Some(self.value), - to: Some(TxKind::Call(to_address)), + to: Some(tx_kind), gas: Some(self.gas_limit), max_fee_per_gas: Some(1000e9 as u128), max_priority_fee_per_gas: Some(1e9 as u128), chain_id: Some(chain_id), input: TransactionInput { input: None, - data: self.data.clone(), + data: resolved_data, }, ..Default::default() }; diff --git a/crates/execution-e2e/src/actions/store_deployed_address.rs b/crates/execution-e2e/src/actions/store_deployed_address.rs new file mode 100644 index 0000000..bbc742a --- /dev/null +++ b/crates/execution-e2e/src/actions/store_deployed_address.rs @@ -0,0 +1,81 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Extracts deployed contract address from a CREATE transaction. + +use crate::{action::Action, ArcEnvironment}; +use futures_util::future::BoxFuture; +use reth_provider::TransactionsProvider; +use tracing::info; + +/// Extracts the deployed contract address from a CREATE transaction +/// and stores it in the environment under `"{tx_name}_address"`. +/// +/// Computes the address from the sender and nonce of the transaction. +pub struct StoreDeployedAddress { + tx_name: String, +} + +impl StoreDeployedAddress { + /// Creates a new action for the named CREATE transaction. + pub fn new(tx_name: impl Into) -> Self { + Self { + tx_name: tx_name.into(), + } + } +} + +impl Action for StoreDeployedAddress { + fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, eyre::Result<()>> { + Box::pin(async move { + let tx_hash = *env.get_tx_hash(&self.tx_name).ok_or_else(|| { + eyre::eyre!("Transaction '{}' not found in environment", self.tx_name) + })?; + + let (tx, _meta) = env + .node() + .inner + .provider() + .transaction_by_hash_with_meta(tx_hash)? + .ok_or_else(|| { + eyre::eyre!("Transaction not found for '{}' ({})", self.tx_name, tx_hash) + })?; + + use reth_primitives_traits::SignerRecoverable; + let sender = tx.recover_signer().map_err(|e| { + eyre::eyre!("Failed to recover signer for '{}': {:?}", self.tx_name, e) + })?; + + use alloy_consensus::Transaction; + let nonce = tx.nonce(); + + let contract_address = sender.create(nonce); + let address_name = format!("{}_address", self.tx_name); + + info!( + tx_name = %self.tx_name, + sender = %sender, + nonce, + contract_address = %contract_address, + stored_as = %address_name, + "Stored deployed contract address" + ); + + env.insert_address(address_name, contract_address)?; + Ok(()) + }) + } +} diff --git a/crates/execution-e2e/src/environment.rs b/crates/execution-e2e/src/environment.rs index 6aa2587..8844d35 100644 --- a/crates/execution-e2e/src/environment.rs +++ b/crates/execution-e2e/src/environment.rs @@ -16,7 +16,7 @@ //! Arc test environment for e2e tests. -use alloy_primitives::{BlockHash, TxHash}; +use alloy_primitives::{Address, BlockHash, TxHash}; use arc_evm_node::node::ArcNode; use reth_e2e_test_utils::{wallet::Wallet, NodeHelperType}; use reth_node_builder::NodeTypesWithDBAdapter; @@ -61,6 +61,11 @@ pub struct ArcEnvironment { wallet: Option, /// Named transaction hashes for test assertions. tx_hashes: HashMap, + /// Named addresses (e.g., deployed contract addresses) for test reference. + addresses: HashMap, + /// Per-wallet-index nonce counter. Index 0 is seeded from `wallet.inner_nonce` + /// during setup; other indices start at 0. + wallet_nonces: HashMap, } impl Default for ArcEnvironment { @@ -77,6 +82,8 @@ impl ArcEnvironment { current_block: None, wallet: None, tx_hashes: HashMap::new(), + addresses: HashMap::new(), + wallet_nonces: HashMap::new(), } } @@ -86,7 +93,10 @@ impl ArcEnvironment { } /// Sets the wallet. Called by `ArcSetup::apply()`. + /// + /// Seeds the nonce counter for index 0 from the wallet's starting nonce. pub(crate) fn set_wallet(&mut self, wallet: Wallet) { + self.wallet_nonces.insert(0, wallet.inner_nonce); self.wallet = Some(wallet); } @@ -142,4 +152,34 @@ impl ArcEnvironment { pub fn get_tx_hash(&self, name: &str) -> Option<&TxHash> { self.tx_hashes.get(name) } + + /// Stores a named address (e.g., deployed contract address). + pub fn insert_address(&mut self, name: String, address: Address) -> eyre::Result<()> { + if let Some(existing) = self.addresses.get(&name) { + return Err(eyre::eyre!( + "Address '{}' is already in use (existing address: {}). \ + Each address must have a unique name.", + name, + existing + )); + } + self.addresses.insert(name, address); + Ok(()) + } + + /// Gets a named address. + pub fn get_address(&self, name: &str) -> Option<&Address> { + self.addresses.get(name) + } + + /// Gets and increments the nonce for a wallet at the given index. + /// + /// All indices use the same `wallet_nonces` map. Index 0 is seeded from + /// `wallet.inner_nonce` during `set_wallet`; other indices default to 0. + pub fn next_nonce_for_wallet(&mut self, wallet_index: usize) -> eyre::Result { + let nonce = self.wallet_nonces.entry(wallet_index).or_insert(0); + let current = *nonce; + *nonce += 1; + Ok(current) + } } diff --git a/crates/execution-e2e/src/setup.rs b/crates/execution-e2e/src/setup.rs index 9a84ddd..31b72c0 100644 --- a/crates/execution-e2e/src/setup.rs +++ b/crates/execution-e2e/src/setup.rs @@ -172,8 +172,10 @@ impl ArcSetup { .ok_or_else(|| eyre::eyre!("Genesis header not found"))?; let genesis_block = BlockInfo::new(genesis, 0, genesis_header.timestamp); + // Generate 10 wallets to match localdev genesis allocations. + // Index 7 is the operator (minter role on NativeFiatToken). let wallet = - reth_e2e_test_utils::wallet::Wallet::default().with_chain_id(chain_spec.chain().id()); + reth_e2e_test_utils::wallet::Wallet::new(10).with_chain_id(chain_spec.chain().id()); Ok((node, wallet, genesis_block)) } diff --git a/crates/execution-e2e/tests/eip7708_denylist.rs b/crates/execution-e2e/tests/eip7708_denylist.rs new file mode 100644 index 0000000..40e8be3 --- /dev/null +++ b/crates/execution-e2e/tests/eip7708_denylist.rs @@ -0,0 +1,224 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! EIP-7708 denylist interaction e2e tests. +//! +//! Verifies that the addresses denylist correctly blocks transactions with value +//! transfers to/from denylisted addresses, and that exclusion lists allow +//! transfers with proper EIP-7708 log emission. + +mod helpers; + +use alloy_network::eip2718::{Decodable2718, Encodable2718}; +use alloy_primitives::{address, Address, TxKind, U256}; +use alloy_rpc_types_eth::{TransactionInput, TransactionRequest}; +use arc_execution_config::addresses_denylist::{ + AddressesDenylistConfig, DEFAULT_DENYLIST_ADDRESS, DEFAULT_DENYLIST_ERC7201_BASE_SLOT, +}; +use arc_execution_config::chainspec::ArcChainSpec; +use arc_execution_e2e::{ + actions::{ + AssertTxIncluded, AssertTxLogs, AssertTxTrace, ProduceBlocks, SendTransaction, TxStatus, + }, + chainspec::localdev_with_denylisted_addresses, + ArcEnvironment, ArcSetup, ArcTestBuilder, +}; +use arc_execution_txpool::ArcTransactionValidatorError; +use helpers::constants::{SYSTEM_ADDRESS, WALLET_FIRST_ADDRESS}; +use reth_chainspec::EthChainSpec; +use reth_e2e_test_utils::transaction::TransactionTestContext; +use reth_ethereum_primitives::TransactionSigned; +use reth_primitives_traits::SignerRecoverable; +use reth_transaction_pool::error::{PoolError, PoolErrorKind}; +use reth_transaction_pool::{TransactionOrigin, TransactionPool}; +use std::sync::Arc; + +fn denylist_config_enabled(exclusions: Vec
) -> eyre::Result { + Ok(AddressesDenylistConfig::try_new( + true, + Some(DEFAULT_DENYLIST_ADDRESS), + Some(DEFAULT_DENYLIST_ERC7201_BASE_SLOT), + exclusions, + )?) +} + +/// Builds a signed tx from a wallet to `to` with a given value. +/// +/// Uses `wallet.inner` directly instead of regenerating signers via `wallet_gen()`. +async fn build_signed_tx_raw( + wallet: &reth_e2e_test_utils::wallet::Wallet, + to: Address, + value: U256, +) -> alloy_primitives::Bytes { + let tx = TransactionRequest { + nonce: Some(0), + value: Some(value), + to: Some(TxKind::Call(to)), + gas: Some(26000), + max_fee_per_gas: Some(1_000_000_000_000), + max_priority_fee_per_gas: Some(1_000_000_000), + chain_id: Some(wallet.chain_id), + input: TransactionInput::default(), + ..Default::default() + }; + let signed_tx = TransactionTestContext::sign_tx(wallet.inner.clone(), tx).await; + signed_tx.encoded_2718().into() +} + +/// Launches a node with denylist config, signs and submits a value transfer tx. +async fn sign_and_submit_value_tx( + chain_spec: Arc, + addresses_denylist_config: AddressesDenylistConfig, + to: Address, + value: U256, +) -> Result<(), eyre::Report> { + let mut env = ArcEnvironment::new(); + ArcSetup::new() + .with_chain_spec(chain_spec.clone()) + .with_addresses_denylist_config(addresses_denylist_config) + .apply(&mut env) + .await?; + + let wallet = + reth_e2e_test_utils::wallet::Wallet::default().with_chain_id(chain_spec.chain().id()); + + let raw_tx = build_signed_tx_raw(&wallet, to, value).await; + let tx_signed = TransactionSigned::decode_2718(&mut raw_tx.as_ref()).expect("Decode tx"); + let recovered_tx = tx_signed.try_into_recovered().expect("Recover signer"); + env.node() + .inner + .pool + .add_consensus_transaction(recovered_tx, TransactionOrigin::Local) + .await + .map_err(Into::into) + .map(|_| ()) +} + +/// Test #27: Value transfer to a denylisted address is rejected by the txpool. +#[tokio::test] +async fn test_value_transfer_to_denylisted_rejected() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let denylisted_to = address!("0xdead000000000000000000000000000000000001"); + let chain_spec = localdev_with_denylisted_addresses(vec![denylisted_to]); + let addresses_denylist_config = denylist_config_enabled(Vec::new())?; + + let err = sign_and_submit_value_tx( + chain_spec, + addresses_denylist_config, + denylisted_to, + U256::from(1_000_000), + ) + .await + .expect_err("Expected pool to reject tx to denylisted address"); + + let pool_err = err.downcast_ref::().expect("Expected PoolError"); + let invalid = match &pool_err.kind { + PoolErrorKind::InvalidTransaction(e) => e, + other => panic!("Expected InvalidTransaction (denylist), got: {:?}", other), + }; + let arc_err = invalid + .downcast_other_ref::() + .expect("Expected ArcTransactionValidatorError"); + assert!( + matches!( + arc_err, + ArcTransactionValidatorError::DenylistedAddressError(_) + ), + "Expected DenylistedAddressError, got: {:?}", + arc_err + ); + + Ok(()) +} + +/// Test #28: Value transfer from a denylisted sender is rejected by the txpool. +#[tokio::test] +async fn test_value_transfer_from_denylisted_rejected() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let chain_spec = localdev_with_denylisted_addresses(vec![WALLET_FIRST_ADDRESS]); + let addresses_denylist_config = denylist_config_enabled(Vec::new())?; + + let err = sign_and_submit_value_tx( + chain_spec, + addresses_denylist_config, + Address::random(), + U256::from(1_000_000), + ) + .await + .expect_err("Expected pool to reject tx from denylisted address"); + + let pool_err = err.downcast_ref::().expect("Expected PoolError"); + let invalid = match &pool_err.kind { + PoolErrorKind::InvalidTransaction(e) => e, + other => panic!("Expected InvalidTransaction (denylist), got: {:?}", other), + }; + let arc_err = invalid + .downcast_other_ref::() + .expect("Expected ArcTransactionValidatorError"); + assert!( + matches!( + arc_err, + ArcTransactionValidatorError::DenylistedAddressError(_) + ), + "Expected DenylistedAddressError, got: {:?}", + arc_err + ); + + Ok(()) +} + +/// Test #29: Excluded address can send value transfer and EIP-7708 log is emitted. +/// +/// When denylist is enabled but the sender is in the exclusion list, +/// the transfer proceeds and the standard EIP-7708 Transfer log is emitted. +#[tokio::test] +async fn test_denylist_exclusion_allows_transfer_with_log() { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x000000000000000000000000000000000000CAFE"); + let value = U256::from(1_000_000); + + // The sender (WALLET_FIRST_ADDRESS) is denylisted but also in the exclusion list + let chain_spec = localdev_with_denylisted_addresses(vec![WALLET_FIRST_ADDRESS]); + let addresses_denylist_config = + denylist_config_enabled(vec![WALLET_FIRST_ADDRESS]).expect("denylist config"); + + ArcTestBuilder::new() + .with_setup( + ArcSetup::new() + .with_chain_spec(chain_spec) + .with_addresses_denylist_config(addresses_denylist_config), + ) + .with_action( + SendTransaction::new("transfer") + .with_to(recipient) + .with_value(value), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) + .with_action( + AssertTxLogs::new("transfer") + .expect_log_count(1) + .expect_emitter_at(0, SYSTEM_ADDRESS) + .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient, value), + ) + .with_action(AssertTxTrace::new("transfer")) + .run() + .await + .expect("test_denylist_exclusion_allows_transfer_with_log failed"); +} diff --git a/crates/execution-e2e/tests/eip7708_edge_cases.rs b/crates/execution-e2e/tests/eip7708_edge_cases.rs new file mode 100644 index 0000000..f0d6da5 --- /dev/null +++ b/crates/execution-e2e/tests/eip7708_edge_cases.rs @@ -0,0 +1,356 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! EIP-7708 edge case e2e tests. +//! +//! Tests revert rollback, multi-log composition, inner-call semantics, +//! and unusual transfer patterns. + +mod helpers; + +use alloy_primitives::{address, U256}; +use arc_execution_e2e::{ + actions::{ + AssertTransferEvent, AssertTxIncluded, AssertTxLogs, AssertTxTrace, ProduceBlocks, + SendTransaction, StoreDeployedAddress, TxStatus, + }, + ArcSetup, ArcTestBuilder, +}; +use helpers::{ + constants::{NATIVE_COIN_AUTHORITY_ADDRESS, SYSTEM_ADDRESS, WALLET_FIRST_ADDRESS}, + contracts::right_pad_address, +}; +use rstest::rstest; + +/// Test #48: Send value to a reverting contract — tx reverts, no EIP-7708 log. +/// +/// When the entire CALL frame reverts, the EIP-7708 log is rolled back. +/// Deploys an actual reverting contract rather than using an existing address. +#[tokio::test] +async fn test_reverted_call_no_log() { + reth_tracing::init_test_tracing(); + + let deploy_code = helpers::contracts::reverting_contract_deploy_code(); + let value = U256::from(1_000_000); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("deploy") + .with_create() + .with_data(deploy_code) + .with_value(U256::ZERO) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("deploy").expect(TxStatus::Success)) + .with_action(StoreDeployedAddress::new("deploy")) + // Send value to the reverting contract + .with_action( + SendTransaction::new("revert_call") + .with_to_named("deploy_address") + .with_value(value) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("revert_call").expect(TxStatus::Reverted)) + .with_action(AssertTxLogs::new("revert_call").expect_no_logs()) + .with_action(AssertTxTrace::new("revert_call")) + .run() + .await + .expect("test_reverted_call_no_log failed"); +} + +/// Tests #49/#50: Inner CALL reverts but outer succeeds — outer log emitted, inner log rolled back. +/// +/// Deploys a reverting contract and an outer contract that forwards value to it. +/// The outer contract accepts value (emitting sender→outer log), then makes an +/// inner CALL with value to the reverting contract. The inner frame reverts, so +/// the inner value transfer log (outer→reverting) is rolled back. Only the +/// outer log remains. Parameterized over transfer amount to verify consistency. +#[rstest] +#[case::standard_value(U256::from(1_000_000))] +#[case::smaller_value(U256::from(500_000))] +#[tokio::test] +async fn test_inner_call_reverts_outer_succeeds(#[case] value: U256) { + reth_tracing::init_test_tracing(); + + let reverting_deploy = helpers::contracts::reverting_contract_deploy_code(); + let outer_deploy = helpers::contracts::call_target_with_value_contract_deploy_code(); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + // Deploy the reverting contract (inner target) + .with_action( + SendTransaction::new("deploy_reverting") + .with_create() + .with_data(reverting_deploy) + .with_value(U256::ZERO) + .with_gas_limit(200_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("deploy_reverting").expect(TxStatus::Success)) + .with_action(StoreDeployedAddress::new("deploy_reverting")) + // Deploy the outer contract (calls target from calldata with value) + .with_action( + SendTransaction::new("deploy_outer") + .with_create() + .with_data(outer_deploy) + .with_value(U256::ZERO) + .with_gas_limit(200_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("deploy_outer").expect(TxStatus::Success)) + .with_action(StoreDeployedAddress::new("deploy_outer")) + // Call the outer contract with value, passing the reverting contract's address + // as calldata. Uses stored address instead of nonce-derived guess. + .with_action( + SendTransaction::new("call") + .with_to_named("deploy_outer_address") + .with_value(value) + .with_data_fn(|env| { + let addr = env.get_address("deploy_reverting_address").ok_or_else(|| { + eyre::eyre!("Named address 'deploy_reverting_address' not found") + })?; + Ok(right_pad_address(*addr)) + }) + .with_gas_limit(200_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("call").expect(TxStatus::Success)) + // Outer frame succeeds → sender→outer_contract log preserved. + // Inner call reverts → inner log rolled back. + .with_action(AssertTxLogs::new("call").expect_log_count(1)) + .with_action(AssertTransferEvent::new( + "call", + 0, + WALLET_FIRST_ADDRESS, + AssertTransferEvent::named("deploy_outer_address"), + value, + )) + .with_action(AssertTxTrace::new("call")) + .run() + .await + .expect("test_inner_call_reverts_outer_succeeds failed"); +} + +/// Test #51: Multiple sequential value transfers in separate blocks. +/// +/// Each block contains a value transfer, verifying logs are emitted consistently +/// across blocks and don't leak between transactions. +#[tokio::test] +async fn test_sequential_blocks_each_emit_log() { + reth_tracing::init_test_tracing(); + + let recipient_1 = address!("0x000000000000000000000000000000000000AAA1"); + let recipient_2 = address!("0x000000000000000000000000000000000000AAA2"); + let value_1 = U256::from(100_000); + let value_2 = U256::from(200_000); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + // Block 1 + .with_action( + SendTransaction::new("tx1") + .with_to(recipient_1) + .with_value(value_1), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("tx1").expect(TxStatus::Success)) + .with_action( + AssertTxLogs::new("tx1") + .expect_log_count(1) + .expect_emitter_at(0, SYSTEM_ADDRESS) + .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient_1, value_1), + ) + // Block 2 + .with_action( + SendTransaction::new("tx2") + .with_to(recipient_2) + .with_value(value_2), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("tx2").expect(TxStatus::Success)) + .with_action( + AssertTxLogs::new("tx2") + .expect_log_count(1) + .expect_emitter_at(0, SYSTEM_ADDRESS) + .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient_2, value_2), + ) + .with_action(AssertTxTrace::new("tx1")) + .with_action(AssertTxTrace::new("tx2")) + .run() + .await + .expect("test_sequential_blocks_each_emit_log failed"); +} + +/// Test #52: Contract calls NativeCoinAuthority precompile with value. +/// +/// Deploys a contract that forwards a CALL with value to the NativeCoinAuthority +/// precompile address. The precompile will revert (unauthorized caller), but +/// the outer frame succeeds. The outer value transfer log (sender→contract) +/// is preserved; the inner log (contract→precompile) is rolled back because +/// the precompile rejects the call. +#[tokio::test] +async fn test_contract_calls_precompile_with_value() { + reth_tracing::init_test_tracing(); + + let deploy_code = helpers::contracts::call_target_with_value_contract_deploy_code(); + let value = U256::from(500_000); + + // Encode NativeCoinAuthority address as calldata target. + // The bytecode reads target via CALLDATALOAD(0) + SHR(96), extracting + // the top 20 bytes. So address must be right-padded (address at left). + let calldata = helpers::contracts::right_pad_address(NATIVE_COIN_AUTHORITY_ADDRESS); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("deploy") + .with_create() + .with_data(deploy_code) + .with_value(U256::ZERO) + .with_gas_limit(200_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("deploy").expect(TxStatus::Success)) + .with_action(StoreDeployedAddress::new("deploy")) + // Call the contract with value + precompile target in calldata + .with_action( + SendTransaction::new("call") + .with_to_named("deploy_address") + .with_value(value) + .with_data(calldata) + .with_gas_limit(200_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("call").expect(TxStatus::Success)) + // Outer transfer succeeds (sender→contract), inner reverts (contract→precompile) + // Only the outer log with exact from/to/value + .with_action(AssertTxLogs::new("call").expect_log_count(1)) + .with_action(AssertTransferEvent::new( + "call", + 0, + WALLET_FIRST_ADDRESS, + AssertTransferEvent::named("deploy_address"), + value, + )) + .with_action(AssertTxTrace::new("call")) + .run() + .await + .expect("test_contract_calls_precompile_with_value failed"); +} + +/// Test #53: Value transfer after producing multiple empty blocks. +/// +/// Verifies that EIP-7708 log emission works correctly even when +/// there are empty blocks between genesis and the transfer. +#[tokio::test] +async fn test_log_after_empty_blocks() { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x0000000000000000000000000000000000CC0001"); + let value = U256::from(500_000); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + // Produce several empty blocks first + .with_action(ProduceBlocks::new(5)) + // Now send a value transfer + .with_action( + SendTransaction::new("transfer") + .with_to(recipient) + .with_value(value), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) + .with_action( + AssertTxLogs::new("transfer") + .expect_log_count(1) + .expect_emitter_at(0, SYSTEM_ADDRESS) + .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient, value), + ) + .with_action(AssertTxTrace::new("transfer")) + .run() + .await + .expect("test_log_after_empty_blocks failed"); +} + +/// Test: Transfer to a contract that exists but has no code (EOA-like). +#[tokio::test] +async fn test_transfer_to_codeless_address() { + reth_tracing::init_test_tracing(); + + let target = address!("0x000000000000000000000000000000000000DEAD"); + let value = U256::from(1_000); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("transfer") + .with_to(target) + .with_value(value), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) + .with_action( + AssertTxLogs::new("transfer") + .expect_log_count(1) + .expect_emitter_at(0, SYSTEM_ADDRESS) + .expect_transfer_event(0, WALLET_FIRST_ADDRESS, target, value), + ) + .with_action(AssertTxTrace::new("transfer")) + .run() + .await + .expect("test_transfer_to_codeless_address failed"); +} + +/// Test: Value transfer and zero-value transfer in same block. +/// +/// Only the value transfer should emit a log; the zero-value transfer should not. +#[tokio::test] +async fn test_mixed_value_and_zero_value_in_block() { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x000000000000000000000000000000000000F00D"); + let value = U256::from(1_000_000); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("with_value") + .with_to(recipient) + .with_value(value), + ) + .with_action( + SendTransaction::new("zero_value") + .with_to(recipient) + .with_value(U256::ZERO), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("with_value").expect(TxStatus::Success)) + .with_action(AssertTxIncluded::new("zero_value").expect(TxStatus::Success)) + .with_action( + AssertTxLogs::new("with_value") + .expect_log_count(1) + .expect_emitter_at(0, SYSTEM_ADDRESS) + .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient, value), + ) + .with_action(AssertTxLogs::new("zero_value").expect_no_logs()) + .run() + .await + .expect("test_mixed_value_and_zero_value_in_block failed"); +} diff --git a/crates/execution-e2e/tests/eip7708_gas.rs b/crates/execution-e2e/tests/eip7708_gas.rs new file mode 100644 index 0000000..1d2f419 --- /dev/null +++ b/crates/execution-e2e/tests/eip7708_gas.rs @@ -0,0 +1,250 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! EIP-7708 gas accounting e2e tests. +//! +//! Verifies that gasUsed in receipts correctly accounts for the EIP-7708 +//! log emission cost. Tests isolate transactions in separate blocks to get +//! clean per-transaction gas measurements (cumulative_gas_used == per-tx gas +//! when the tx is alone in its block). + +mod helpers; + +use alloy_primitives::{address, U256}; +use arc_execution_e2e::{ + actions::{AssertTxIncluded, AssertTxTrace, ProduceBlocks, SendTransaction, TxStatus}, + ArcSetup, ArcTestBuilder, +}; +use reth_provider::ReceiptProvider; + +/// Mirrors `arc_precompiles::helpers::PRECOMPILE_SLOAD_GAS_COST`. +/// Under Zero6, each blocklist check costs one SLOAD at this price. +const PRECOMPILE_SLOAD_GAS_COST: u64 = 2100; + +/// Test #44: Value transfer gasUsed > zero-value transfer gasUsed. +/// +/// Isolates each tx in its own block so cumulative_gas_used == per-tx gas. +/// The value transfer incurs the EIP-7708 log emission cost (375 base + 375*3 topics +/// + 8*32 data = 1,756 gas overhead), so it must use strictly more gas. +#[tokio::test] +async fn test_value_transfer_gas_includes_log_cost() { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x0000000000000000000000000000000000009999"); + let value = U256::from(1_000_000); + + let mut env = arc_execution_e2e::ArcEnvironment::new(); + arc_execution_e2e::ArcSetup::new() + .apply(&mut env) + .await + .expect("setup failed"); + + // Block 1: value transfer (isolated) + let mut tx_with_value = SendTransaction::new("with_value") + .with_to(recipient) + .with_value(value); + arc_execution_e2e::Action::execute(&mut tx_with_value, &mut env) + .await + .expect("send with value"); + + let mut produce = arc_execution_e2e::actions::ProduceBlocks::new(1); + arc_execution_e2e::Action::execute(&mut produce, &mut env) + .await + .expect("produce block 1"); + + // Block 2: zero-value transfer (isolated) + let mut tx_zero_value = SendTransaction::new("zero_value") + .with_to(recipient) + .with_value(U256::ZERO); + arc_execution_e2e::Action::execute(&mut tx_zero_value, &mut env) + .await + .expect("send zero value"); + + let mut produce2 = arc_execution_e2e::actions::ProduceBlocks::new(1); + arc_execution_e2e::Action::execute(&mut produce2, &mut env) + .await + .expect("produce block 2"); + + // Get receipts — each tx is alone in its block, so cumulative_gas_used == per-tx gas + let with_value_hash = *env.get_tx_hash("with_value").expect("with_value hash"); + let zero_value_hash = *env.get_tx_hash("zero_value").expect("zero_value hash"); + + let receipt_with = env + .node() + .inner + .provider() + .receipt_by_hash(with_value_hash) + .expect("receipt query") + .expect("receipt not found"); + + let receipt_zero = env + .node() + .inner + .provider() + .receipt_by_hash(zero_value_hash) + .expect("receipt query") + .expect("receipt not found"); + + let gas_with_value = receipt_with.cumulative_gas_used; + let gas_zero_value = receipt_zero.cumulative_gas_used; + + // Value transfer must use strictly more gas due to EIP-7708 log emission + assert!( + gas_with_value > gas_zero_value, + "Value transfer gas ({}) should be greater than zero-value transfer gas ({}). \ + The difference should be ~1756 gas for the EIP-7708 Transfer log.", + gas_with_value, + gas_zero_value + ); + + // The overhead should be approximately 1,756 gas (LOG3 cost for Transfer event) + let overhead = gas_with_value + .checked_sub(gas_zero_value) + .expect("gas_with_value < gas_zero_value"); + assert!( + overhead > 1000, + "Gas overhead ({}) is suspiciously low — expected ~1756 for LOG3", + overhead + ); +} + +/// Test #45: Value transfer succeeds within default gas limit. +/// +/// The default gas limit (26,000) should be sufficient for a simple value transfer +/// with EIP-7708 log emission overhead. +#[tokio::test] +async fn test_value_transfer_within_gas_limit() { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x0000000000000000000000000000000000008888"); + let value = U256::from(1_000_000); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("transfer") + .with_to(recipient) + .with_value(value), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) + .with_action(AssertTxTrace::new("transfer")) + .run() + .await + .expect("test_value_transfer_within_gas_limit failed"); +} + +/// Test #46: Value transfer with explicit low gas succeeds. +/// +/// The intrinsic gas for a value transfer is 21,000 + EIP-7708 overhead (~1,756). +/// A gas limit of 26,000 should be sufficient. +#[tokio::test] +async fn test_value_transfer_explicit_gas_succeeds() { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x0000000000000000000000000000000000007777"); + let value = U256::from(100); + + let mut env = arc_execution_e2e::ArcEnvironment::new(); + arc_execution_e2e::ArcSetup::new() + .apply(&mut env) + .await + .expect("setup failed"); + + let mut send = SendTransaction::new("transfer") + .with_to(recipient) + .with_value(value) + .with_gas_limit(26_000); + arc_execution_e2e::Action::execute(&mut send, &mut env) + .await + .expect("send tx"); + + let mut produce = arc_execution_e2e::actions::ProduceBlocks::new(1); + arc_execution_e2e::Action::execute(&mut produce, &mut env) + .await + .expect("produce"); + + let tx_hash = *env.get_tx_hash("transfer").expect("tx hash"); + let receipt = env + .node() + .inner + .provider() + .receipt_by_hash(tx_hash) + .expect("receipt query") + .expect("receipt not found"); + + assert!( + receipt.success, + "Value transfer with 26,000 gas should succeed; got reverted. Gas used: {}", + receipt.cumulative_gas_used, + ); + + // Verify the gas used is reasonable (21,000 intrinsic + log overhead + value transfer cost) + assert!( + receipt.cumulative_gas_used > 21_000, + "Gas used ({}) should exceed intrinsic gas (21,000)", + receipt.cumulative_gas_used, + ); +} + +/// Test #47: Zero value transfer uses baseline gas (no log emission overhead). +/// +/// Isolates a zero-value transfer in its own block and verifies it uses exactly +/// the baseline gas (21,000 intrinsic, no EIP-7708 log overhead). +#[tokio::test] +async fn test_zero_value_transfer_baseline_gas() { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x0000000000000000000000000000000000006666"); + + let mut env = arc_execution_e2e::ArcEnvironment::new(); + arc_execution_e2e::ArcSetup::new() + .apply(&mut env) + .await + .expect("setup failed"); + + let mut send = SendTransaction::new("transfer") + .with_to(recipient) + .with_value(U256::ZERO); + arc_execution_e2e::Action::execute(&mut send, &mut env) + .await + .expect("send tx"); + + let mut produce = arc_execution_e2e::actions::ProduceBlocks::new(1); + arc_execution_e2e::Action::execute(&mut produce, &mut env) + .await + .expect("produce"); + + let tx_hash = *env.get_tx_hash("transfer").expect("tx hash"); + let receipt = env + .node() + .inner + .provider() + .receipt_by_hash(tx_hash) + .expect("receipt query") + .expect("receipt not found"); + + assert!(receipt.success, "Zero-value transfer should succeed"); + + // Under Zero6 (active in localdev), a zero-value call pays intrinsic gas + 1 SLOAD + // for the caller blocklist check (recipient check is skipped when value is zero). + let expected_gas = 21_000 + PRECOMPILE_SLOAD_GAS_COST; + assert_eq!( + receipt.cumulative_gas_used, expected_gas, + "Zero-value transfer gas mismatch: expected {} (21k intrinsic + {} blocklist SLOAD), got {}", + expected_gas, PRECOMPILE_SLOAD_GAS_COST, receipt.cumulative_gas_used, + ); +} diff --git a/crates/execution-e2e/tests/eip7708_hardfork_transition.rs b/crates/execution-e2e/tests/eip7708_hardfork_transition.rs new file mode 100644 index 0000000..6456b6a --- /dev/null +++ b/crates/execution-e2e/tests/eip7708_hardfork_transition.rs @@ -0,0 +1,183 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! EIP-7708 hardfork transition e2e tests. +//! +//! Verifies that EIP-7708 Transfer logs activate correctly at the Zero5 boundary +//! and that pre-Zero5 blocks emit NativeCoinTransferred logs from +//! NATIVE_COIN_AUTHORITY_ADDRESS instead. + +mod helpers; + +use alloy_primitives::{address, U256}; +use arc_execution_config::hardforks::ArcHardfork; +use arc_execution_e2e::{ + actions::{ + AssertBlockNumber, AssertHardfork, AssertTxIncluded, AssertTxLogs, AssertTxTrace, + ProduceBlocks, SendTransaction, TxStatus, + }, + chainspec::localdev_with_hardforks, + ArcSetup, ArcTestBuilder, +}; +use helpers::constants::{NATIVE_COIN_AUTHORITY_ADDRESS, SYSTEM_ADDRESS, WALLET_FIRST_ADDRESS}; + +/// Test #20: Pre-Zero5 value transfer emits NativeCoinTransferred from NativeCoinAuthority. +/// +/// Verifies exact event format: topic[0] = NativeCoinTransferred signature, +/// topic[1] = from, topic[2] = to, data = amount. +#[tokio::test] +async fn test_pre_zero5_emits_native_coin_transferred() { + reth_tracing::init_test_tracing(); + + let chain_spec = localdev_with_hardforks(&[ + (ArcHardfork::Zero3, 0), + (ArcHardfork::Zero4, 0), + (ArcHardfork::Zero5, 100), // far in the future + (ArcHardfork::Zero6, 100), + ]); + + let recipient = address!("0x000000000000000000000000000000000000bEEF"); + let value = U256::from(1_000_000); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new().with_chain_spec(chain_spec)) + .with_action(AssertHardfork::is_not_active(ArcHardfork::Zero5)) + .with_action( + SendTransaction::new("transfer") + .with_to(recipient) + .with_value(value), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) + .with_action( + AssertTxLogs::new("transfer") + .expect_log_count(1) + .expect_emitter_at(0, NATIVE_COIN_AUTHORITY_ADDRESS) + // Verify exact NativeCoinTransferred event topics and data + .expect_native_coin_transferred_event(0, WALLET_FIRST_ADDRESS, recipient, value), + ) + .with_action(AssertTxTrace::new("transfer")) + .run() + .await + .expect("test_pre_zero5_emits_native_coin_transferred failed"); +} + +/// Test #21: Zero5 activation boundary — tx before activation uses old log format, +/// tx after activation uses EIP-7708 format. +/// +/// Pre-Zero5 tx emits NativeCoinTransferred with exact topics/data; +/// post-Zero5 tx emits ERC-20 Transfer with exact topics/data. +#[tokio::test] +async fn test_zero5_activation_boundary() { + reth_tracing::init_test_tracing(); + + // Zero5 activates at block 3 + let chain_spec = localdev_with_hardforks(&[ + (ArcHardfork::Zero3, 0), + (ArcHardfork::Zero4, 0), + (ArcHardfork::Zero5, 3), + (ArcHardfork::Zero6, 100), + ]); + + let recipient = address!("0x000000000000000000000000000000000000bEEF"); + let value = U256::from(1_000_000); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new().with_chain_spec(chain_spec)) + .with_action(AssertHardfork::is_not_active(ArcHardfork::Zero5)) + // Send tx before Zero5 (block 1) + .with_action( + SendTransaction::new("pre_zero5") + .with_to(recipient) + .with_value(value), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertBlockNumber::new(1)) + .with_action(AssertTxIncluded::new("pre_zero5").expect(TxStatus::Success)) + .with_action( + AssertTxLogs::new("pre_zero5") + .expect_log_count(1) + .expect_emitter_at(0, NATIVE_COIN_AUTHORITY_ADDRESS) + // Exact pre-Zero5 event format + .expect_native_coin_transferred_event(0, WALLET_FIRST_ADDRESS, recipient, value), + ) + // Produce blocks 2-3 to reach Zero5 activation + .with_action(ProduceBlocks::new(2)) + .with_action(AssertBlockNumber::new(3)) + .with_action(AssertHardfork::is_active(ArcHardfork::Zero5)) + // Send tx after Zero5 (block 4) + .with_action( + SendTransaction::new("post_zero5") + .with_to(recipient) + .with_value(value), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("post_zero5").expect(TxStatus::Success)) + .with_action( + AssertTxLogs::new("post_zero5") + .expect_log_count(1) + .expect_emitter_at(0, SYSTEM_ADDRESS) + .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient, value), + ) + .with_action(AssertTxTrace::new("pre_zero5")) + .with_action(AssertTxTrace::new("post_zero5")) + .run() + .await + .expect("test_zero5_activation_boundary failed"); +} + +/// Test #22: Post-Zero5 value transfer emits Transfer from SYSTEM_ADDRESS. +#[tokio::test] +async fn test_post_zero5_emits_eip7708_transfer() { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x000000000000000000000000000000000000bEEF"); + let value = U256::from(1_000_000); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action(AssertHardfork::is_active(ArcHardfork::Zero5)) + .with_action( + SendTransaction::new("transfer") + .with_to(recipient) + .with_value(value), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) + .with_action( + AssertTxLogs::new("transfer") + .expect_log_count(1) + .expect_emitter_at(0, SYSTEM_ADDRESS) + .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient, value), + ) + .with_action(AssertTxTrace::new("transfer")) + .run() + .await + .expect("test_post_zero5_emits_eip7708_transfer failed"); +} + +/// Test #23: Verify Zero5 hardfork is active at genesis on default localdev. +#[tokio::test] +async fn test_zero5_active_at_genesis() { + reth_tracing::init_test_tracing(); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action(AssertHardfork::is_active(ArcHardfork::Zero5)) + .run() + .await + .expect("test_zero5_active_at_genesis failed"); +} diff --git a/crates/execution-e2e/tests/eip7708_log_format.rs b/crates/execution-e2e/tests/eip7708_log_format.rs new file mode 100644 index 0000000..5bc08f5 --- /dev/null +++ b/crates/execution-e2e/tests/eip7708_log_format.rs @@ -0,0 +1,176 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! EIP-7708 log format compliance e2e tests. +//! +//! Verifies the byte-level ERC-20 Transfer log format: emitter address, +//! topic[0] (event signature), topic[1] (from), topic[2] (to), data (value). + +mod helpers; + +use alloy_primitives::{address, Bytes, B256, U256}; +use arc_execution_e2e::{ + actions::{ + AssertTxIncluded, AssertTxLogs, AssertTxTrace, ProduceBlocks, SendTransaction, TxStatus, + }, + ArcSetup, ArcTestBuilder, +}; +use helpers::constants::{SYSTEM_ADDRESS, TRANSFER_EVENT_SIGNATURE, WALLET_FIRST_ADDRESS}; + +/// Test #37: topic[0] matches ERC-20 Transfer(address,address,uint256) signature. +#[tokio::test] +async fn test_transfer_log_topic0_matches_erc20_signature() { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x0000000000000000000000000000000000001111"); + let value = U256::from(1_000_000); + + // Build expected topics and data + let expected_topics = vec![ + TRANSFER_EVENT_SIGNATURE, + B256::left_padding_from(WALLET_FIRST_ADDRESS.as_slice()), + B256::left_padding_from(recipient.as_slice()), + ]; + let expected_data = Bytes::from(value.to_be_bytes::<32>().to_vec()); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("transfer") + .with_to(recipient) + .with_value(value), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) + .with_action( + AssertTxLogs::new("transfer") + .expect_log_count(1) + .expect_log_at(0, SYSTEM_ADDRESS, expected_topics, expected_data), + ) + .with_action(AssertTxTrace::new("transfer")) + .run() + .await + .expect("test_transfer_log_topic0_matches_erc20_signature failed"); +} + +/// Test #38: topic[1] encodes sender address as left-padded bytes32. +#[tokio::test] +async fn test_transfer_log_topic1_encodes_sender() { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x0000000000000000000000000000000000002222"); + let value = U256::from(42); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("transfer") + .with_to(recipient) + .with_value(value), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) + .with_action( + AssertTxLogs::new("transfer") + .expect_log_count(1) + .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient, value), + ) + .run() + .await + .expect("test_transfer_log_topic1_encodes_sender failed"); +} + +/// Test #39: topic[2] encodes recipient address as left-padded bytes32. +#[tokio::test] +async fn test_transfer_log_topic2_encodes_recipient() { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x0000000000000000000000000000000000003333"); + let value = U256::from(999); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("transfer") + .with_to(recipient) + .with_value(value), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) + .with_action( + AssertTxLogs::new("transfer") + .expect_log_count(1) + .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient, value), + ) + .run() + .await + .expect("test_transfer_log_topic2_encodes_recipient failed"); +} + +/// Test #40: data encodes value as big-endian uint256. +#[tokio::test] +async fn test_transfer_log_data_encodes_value() { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x0000000000000000000000000000000000004444"); + // Use a distinctive value to verify encoding + let value = U256::from(0xDEADBEEFu64); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("transfer") + .with_to(recipient) + .with_value(value), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) + .with_action( + AssertTxLogs::new("transfer") + .expect_log_count(1) + .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient, value), + ) + .run() + .await + .expect("test_transfer_log_data_encodes_value failed"); +} + +/// Test #41: emitter address is SYSTEM_ADDRESS, not the sender or NativeCoinAuthority. +#[tokio::test] +async fn test_transfer_log_emitter_is_system_address() { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x0000000000000000000000000000000000005555"); + let value = U256::from(1); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("transfer") + .with_to(recipient) + .with_value(value), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) + .with_action( + AssertTxLogs::new("transfer") + .expect_log_count(1) + .expect_emitter_at(0, SYSTEM_ADDRESS), + ) + .run() + .await + .expect("test_transfer_log_emitter_is_system_address failed"); +} diff --git a/crates/execution-e2e/tests/eip7708_native_transfer.rs b/crates/execution-e2e/tests/eip7708_native_transfer.rs new file mode 100644 index 0000000..06c5e39 --- /dev/null +++ b/crates/execution-e2e/tests/eip7708_native_transfer.rs @@ -0,0 +1,743 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! EIP-7708 native transfer e2e tests. +//! +//! Tests that native value transfers emit ERC-20 Transfer logs from SYSTEM_ADDRESS +//! under the Zero5 hardfork via CALL to EOA, contract, and precompile recipients, +//! as well as CREATE, SELFDESTRUCT, and nested value transfer scenarios. + +mod helpers; + +use alloy_primitives::{address, U256}; +use arc_execution_e2e::{ + actions::{ + AssertBalance, AssertNamedBalance, AssertTransferEvent, AssertTxIncluded, AssertTxLogs, + AssertTxTrace, ProduceBlocks, SendTransaction, StoreDeployedAddress, TxStatus, + }, + ArcSetup, ArcTestBuilder, +}; +use helpers::{ + constants::{SYSTEM_ADDRESS, WALLET_FIRST_ADDRESS}, + contracts::right_pad_address, +}; + +// ===== CALL to EOA (#1-3) ===== + +/// Test #1: EOA sends nonzero USDC to another EOA — emits 1 EIP-7708 Transfer log. +#[tokio::test] +async fn test_call_eoa_with_value_emits_eip7708_log() { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x000000000000000000000000000000000000bEEF"); + let value = U256::from(1_000_000); // 1 USDC (6 decimals) + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("transfer") + .with_to(recipient) + .with_value(value), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) + .with_action( + AssertTxLogs::new("transfer") + .expect_log_count(1) + .expect_emitter_at(0, SYSTEM_ADDRESS) + .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient, value), + ) + .with_action(AssertTxTrace::new("transfer")) + .run() + .await + .expect("test_call_eoa_with_value_emits_eip7708_log failed"); +} + +/// Test #2: EOA sends 0 value — no EIP-7708 log. +#[tokio::test] +async fn test_call_eoa_zero_value_no_log() { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x000000000000000000000000000000000000bEEF"); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("transfer") + .with_to(recipient) + .with_value(U256::ZERO), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) + .with_action(AssertTxLogs::new("transfer").expect_no_logs()) + .with_action(AssertTxTrace::new("transfer")) + .run() + .await + .expect("test_call_eoa_zero_value_no_log failed"); +} + +/// Test #3: EOA sends value to self — no EIP-7708 log (self-transfer is suppressed). +#[tokio::test] +async fn test_call_eoa_self_transfer_no_log() { + reth_tracing::init_test_tracing(); + + let value = U256::from(1_000_000); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("transfer") + .with_to(WALLET_FIRST_ADDRESS) + .with_value(value), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) + .with_action(AssertTxLogs::new("transfer").expect_no_logs()) + .with_action(AssertTxTrace::new("transfer")) + .run() + .await + .expect("test_call_eoa_self_transfer_no_log failed"); +} + +// ===== CALL to Contract (#4-6) ===== + +/// Test #4: EOA sends value to a payable contract — emits exact EIP-7708 Transfer log. +/// +/// Deploys a payable contract via CREATE, then sends value to it. +/// Asserts exact from (sender), to (deployed contract), and value using stored addresses. +#[tokio::test] +async fn test_call_contract_with_value_emits_eip7708_log() { + reth_tracing::init_test_tracing(); + + let deploy_code = helpers::contracts::payable_contract_deploy_code(); + let transfer_value = U256::from(500_000); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + // Deploy payable contract + .with_action( + SendTransaction::new("deploy") + .with_create() + .with_data(deploy_code) + .with_value(U256::ZERO) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("deploy").expect(TxStatus::Success)) + .with_action(StoreDeployedAddress::new("deploy")) + // Send value to the deployed contract + .with_action( + SendTransaction::new("value_call") + .with_to_named("deploy_address") + .with_value(transfer_value) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("value_call").expect(TxStatus::Success)) + .with_action(AssertTxLogs::new("value_call").expect_log_count(1)) + .with_action(AssertTransferEvent::new( + "value_call", + 0, + WALLET_FIRST_ADDRESS, + AssertTransferEvent::named("deploy_address"), + transfer_value, + )) + .with_action(AssertNamedBalance::of("deploy_address").equals(transfer_value)) + .with_action(AssertTxTrace::new("value_call")) + .run() + .await + .expect("test_call_contract_with_value_emits_eip7708_log failed"); +} + +/// Test #5: EOA sends 0 value to a contract — no EIP-7708 log. +#[tokio::test] +async fn test_call_contract_zero_value_no_log() { + reth_tracing::init_test_tracing(); + + let deploy_code = helpers::contracts::payable_contract_deploy_code(); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("deploy") + .with_create() + .with_data(deploy_code) + .with_value(U256::ZERO) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("deploy").expect(TxStatus::Success)) + .with_action(StoreDeployedAddress::new("deploy")) + .with_action( + SendTransaction::new("zero_call") + .with_to_named("deploy_address") + .with_value(U256::ZERO) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("zero_call").expect(TxStatus::Success)) + .with_action(AssertTxLogs::new("zero_call").expect_no_logs()) + .with_action(AssertTxTrace::new("zero_call")) + .run() + .await + .expect("test_call_contract_zero_value_no_log failed"); +} + +/// Test #6: EOA sends value to a reverting contract — tx reverts, no EIP-7708 log. +#[tokio::test] +async fn test_call_reverting_contract_with_value_no_log() { + reth_tracing::init_test_tracing(); + + let deploy_code = helpers::contracts::reverting_contract_deploy_code(); + let value = U256::from(500_000); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("deploy") + .with_create() + .with_data(deploy_code) + .with_value(U256::ZERO) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("deploy").expect(TxStatus::Success)) + .with_action(StoreDeployedAddress::new("deploy")) + .with_action( + SendTransaction::new("revert_call") + .with_to_named("deploy_address") + .with_value(value) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("revert_call").expect(TxStatus::Reverted)) + .with_action(AssertTxLogs::new("revert_call").expect_no_logs()) + .with_action(AssertTxTrace::new("revert_call")) + .run() + .await + .expect("test_call_reverting_contract_with_value_no_log failed"); +} + +// ===== CALL to Precompile (#7-8) ===== + +/// Test #7: CALL to precompile with value — reverts (unauthorized), logs rolled back. +#[tokio::test] +async fn test_call_precompile_with_value() { + reth_tracing::init_test_tracing(); + + let precompile = address!("0x1800000000000000000000000000000000000000"); + let value = U256::from(1_000); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("call_precompile") + .with_to(precompile) + .with_value(value) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("call_precompile").expect(TxStatus::Reverted)) + .with_action(AssertTxLogs::new("call_precompile").expect_no_logs()) + .with_action(AssertTxTrace::new("call_precompile")) + .run() + .await + .expect("test_call_precompile_with_value failed"); +} + +/// Test #8: CALL to precompile with 0 value — no EIP-7708 log. +#[tokio::test] +async fn test_call_precompile_zero_value_no_log() { + reth_tracing::init_test_tracing(); + + let precompile = address!("0x1800000000000000000000000000000000000000"); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("call_precompile") + .with_to(precompile) + .with_value(U256::ZERO) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("call_precompile").expect(TxStatus::Reverted)) + .with_action(AssertTxLogs::new("call_precompile").expect_no_logs()) + .with_action(AssertTxTrace::new("call_precompile")) + .run() + .await + .expect("test_call_precompile_zero_value_no_log failed"); +} + +// ===== CREATE (#9-10) ===== + +/// Test #9: CREATE with nonzero value — emits exact EIP-7708 Transfer log. +/// +/// When deploying a contract with value (endowment), the value transfer from +/// the deployer to the new contract address emits an EIP-7708 Transfer log. +/// Asserts exact from (sender), to (deployed address from receipt), and value. +#[tokio::test] +async fn test_create_with_value_emits_eip7708_log() { + reth_tracing::init_test_tracing(); + + let deploy_code = helpers::contracts::payable_contract_deploy_code(); + let endowment = U256::from(1_000_000); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("create") + .with_create() + .with_data(deploy_code) + .with_value(endowment) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("create").expect(TxStatus::Success)) + .with_action(StoreDeployedAddress::new("create")) + .with_action(AssertTxLogs::new("create").expect_log_count(1)) + .with_action(AssertTransferEvent::new( + "create", + 0, + WALLET_FIRST_ADDRESS, + AssertTransferEvent::named("create_address"), + endowment, + )) + .with_action(AssertNamedBalance::of("create_address").equals(endowment)) + .with_action(AssertTxTrace::new("create")) + .run() + .await + .expect("test_create_with_value_emits_eip7708_log failed"); +} + +/// Test #10: CREATE with zero value — no EIP-7708 Transfer log. +#[tokio::test] +async fn test_create_zero_value_no_log() { + reth_tracing::init_test_tracing(); + + let deploy_code = helpers::contracts::payable_contract_deploy_code(); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("create") + .with_create() + .with_data(deploy_code) + .with_value(U256::ZERO) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("create").expect(TxStatus::Success)) + .with_action(AssertTxLogs::new("create").expect_no_logs()) + .with_action(AssertTxTrace::new("create")) + .run() + .await + .expect("test_create_zero_value_no_log failed"); +} + +/// Test: CREATE with nonzero value where constructor reverts — tx reverts, no log, no balance leak. +/// +/// Sends a CREATE tx with endowment but the constructor reverts. +/// The EIP-7708 Transfer log is rolled back with the frame. +/// The would-be contract address must not have any balance. +#[tokio::test] +async fn test_create_revert_with_endowment_no_log() { + reth_tracing::init_test_tracing(); + + let initcode = helpers::contracts::reverting_constructor_code(); + let endowment = U256::from(1_000_000); + + // Nonce-derived address is necessary here because the CREATE reverts — + // StoreDeployedAddress cannot recover the address from a failed CREATE. + // We compute it to verify no balance leaked to the would-be address. + let would_be_addr = WALLET_FIRST_ADDRESS.create(0); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("create") + .with_create() + .with_data(initcode) + .with_value(endowment) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + // Constructor reverts → tx reverts + .with_action(AssertTxIncluded::new("create").expect(TxStatus::Reverted)) + // Transfer log rolled back + .with_action(AssertTxLogs::new("create").expect_no_logs()) + // No balance leakage to the would-be contract address + .with_action(AssertBalance::new(would_be_addr, U256::ZERO)) + .with_action(AssertTxTrace::new("create")) + .run() + .await + .expect("test_create_revert_with_endowment_no_log failed"); +} + +// ===== SELFDESTRUCT (#11-18) ===== + +/// Test #11: SELFDESTRUCT sends balance to beneficiary — emits exact EIP-7708 Transfer log. +/// +/// Asserts: from = contract address (stored), to = beneficiary, value = endowment. +#[tokio::test] +async fn test_selfdestruct_with_balance_emits_log() { + reth_tracing::init_test_tracing(); + + let deploy_code = helpers::contracts::selfdestruct_contract_deploy_code(); + let endowment = U256::from(1_000_000); + let beneficiary = address!("0x000000000000000000000000000000000000BEEF"); + + let calldata = helpers::contracts::right_pad_address(beneficiary); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + // Deploy selfdestruct contract with endowment + .with_action( + SendTransaction::new("deploy") + .with_create() + .with_data(deploy_code) + .with_value(endowment) + .with_gas_limit(200_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("deploy").expect(TxStatus::Success)) + .with_action(StoreDeployedAddress::new("deploy")) + // Trigger selfdestruct — sends balance to beneficiary + .with_action( + SendTransaction::new("selfdestruct") + .with_to_named("deploy_address") + .with_value(U256::ZERO) + .with_data(calldata) + .with_gas_limit(200_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("selfdestruct").expect(TxStatus::Success)) + // Exact Transfer: from=stored contract addr, to=beneficiary, value=endowment + .with_action(AssertTxLogs::new("selfdestruct").expect_log_count(1)) + .with_action(AssertTransferEvent::new( + "selfdestruct", + 0, + AssertTransferEvent::named("deploy_address"), + beneficiary, + endowment, + )) + .with_action(AssertNamedBalance::of("deploy_address").equals(U256::ZERO)) + .with_action(AssertTxTrace::new("selfdestruct")) + .run() + .await + .expect("test_selfdestruct_with_balance_emits_log failed"); +} + +/// Test #12: SELFDESTRUCT with zero balance — no EIP-7708 Transfer log. +#[tokio::test] +async fn test_selfdestruct_zero_balance_no_log() { + reth_tracing::init_test_tracing(); + + let deploy_code = helpers::contracts::selfdestruct_contract_deploy_code(); + let beneficiary = address!("0x000000000000000000000000000000000000BEEF"); + + let calldata = helpers::contracts::right_pad_address(beneficiary); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + // Deploy with zero balance + .with_action( + SendTransaction::new("deploy") + .with_create() + .with_data(deploy_code) + .with_value(U256::ZERO) + .with_gas_limit(200_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("deploy").expect(TxStatus::Success)) + .with_action(StoreDeployedAddress::new("deploy")) + // Trigger selfdestruct — zero balance to transfer + .with_action( + SendTransaction::new("selfdestruct") + .with_to_named("deploy_address") + .with_value(U256::ZERO) + .with_data(calldata) + .with_gas_limit(200_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("selfdestruct").expect(TxStatus::Success)) + .with_action(AssertTxLogs::new("selfdestruct").expect_no_logs()) + .with_action(AssertTxTrace::new("selfdestruct")) + .run() + .await + .expect("test_selfdestruct_zero_balance_no_log failed"); +} + +/// Test #13: SELFDESTRUCT to self — beneficiary == contract address. +/// +/// The implementation explicitly rejects SELFDESTRUCT where source == target +/// with nonzero balance under Zero5 (see `check_selfdestruct_accounts` in opcode.rs). +/// The SELFDESTRUCT opcode halts with Revert, causing the tx to revert. +/// No log is emitted and the contract retains its balance. +#[tokio::test] +async fn test_selfdestruct_to_self_reverts() { + reth_tracing::init_test_tracing(); + + let deploy_code = helpers::contracts::selfdestruct_contract_deploy_code(); + let endowment = U256::from(1_000_000); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("deploy") + .with_create() + .with_data(deploy_code) + .with_value(endowment) + .with_gas_limit(200_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("deploy").expect(TxStatus::Success)) + .with_action(StoreDeployedAddress::new("deploy")) + // Trigger selfdestruct to self — implementation rejects this + .with_action( + SendTransaction::new("selfdestruct") + .with_to_named("deploy_address") + .with_value(U256::ZERO) + .with_data_fn(|env| { + let addr = env + .get_address("deploy_address") + .ok_or_else(|| eyre::eyre!("Named address 'deploy_address' not found"))?; + Ok(right_pad_address(*addr)) + }) + .with_gas_limit(200_000), + ) + .with_action(ProduceBlocks::new(1)) + // SELFDESTRUCT to self reverts the tx under Zero5 + .with_action(AssertTxIncluded::new("selfdestruct").expect(TxStatus::Reverted)) + .with_action(AssertTxLogs::new("selfdestruct").expect_no_logs()) + // Contract retains its balance — no state change + .with_action(AssertNamedBalance::of("deploy_address").equals(endowment)) + .with_action(AssertTxTrace::new("selfdestruct")) + .run() + .await + .expect("test_selfdestruct_to_self_reverts failed"); +} + +// ===== Nested/Forwarded Transfer (#19) ===== + +/// Test #19: Contract forwards received value to another address — both transfers emit exact logs. +/// +/// Deploys a forwarder contract, then sends value to it with a target address. +/// The forwarder CALLs the target with the received value (CALLVALUE). +/// Expected: 2 EIP-7708 Transfer logs in order: +/// log[0]: Transfer(sender, forwarder, value) +/// log[1]: Transfer(forwarder, final_recipient, value) +#[tokio::test] +async fn test_nested_value_transfer_emits_multiple_logs() { + reth_tracing::init_test_tracing(); + + let deploy_code = helpers::contracts::forwarder_contract_deploy_code(); + let final_recipient = address!("0x000000000000000000000000000000000000CAFE"); + let value = U256::from(500_000); + + let calldata = helpers::contracts::right_pad_address(final_recipient); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + // Deploy forwarder + .with_action( + SendTransaction::new("deploy") + .with_create() + .with_data(deploy_code) + .with_value(U256::ZERO) + .with_gas_limit(200_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("deploy").expect(TxStatus::Success)) + .with_action(StoreDeployedAddress::new("deploy")) + // Send value to forwarder with target in calldata + .with_action( + SendTransaction::new("forward") + .with_to_named("deploy_address") + .with_value(value) + .with_data(calldata) + .with_gas_limit(200_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("forward").expect(TxStatus::Success)) + // Exact 2 logs: sender→forwarder, forwarder→final_recipient + .with_action(AssertTxLogs::new("forward").expect_log_count(2)) + .with_action(AssertTransferEvent::new( + "forward", + 0, + WALLET_FIRST_ADDRESS, + AssertTransferEvent::named("deploy_address"), + value, + )) + .with_action(AssertTransferEvent::new( + "forward", + 1, + AssertTransferEvent::named("deploy_address"), + final_recipient, + value, + )) + .with_action(AssertNamedBalance::of("deploy_address").equals(U256::ZERO)) + .with_action(AssertTxTrace::new("forward")) + .run() + .await + .expect("test_nested_value_transfer_emits_multiple_logs failed"); +} + +// ===== Additional coverage ===== + +/// Test: large value transfer emits correct log. +#[tokio::test] +async fn test_large_value_transfer() { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x0000000000000000000000000000000000001234"); + let value = U256::from(10_000_000); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("transfer") + .with_to(recipient) + .with_value(value), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) + .with_action( + AssertTxLogs::new("transfer") + .expect_log_count(1) + .expect_emitter_at(0, SYSTEM_ADDRESS) + .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient, value), + ) + .with_action(AssertTxTrace::new("transfer")) + .run() + .await + .expect("test_large_value_transfer failed"); +} + +/// Test: minimum value (1 wei) transfer emits correct log. +#[tokio::test] +async fn test_min_value_transfer() { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x0000000000000000000000000000000000005678"); + let value = U256::from(1); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("transfer") + .with_to(recipient) + .with_value(value), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) + .with_action( + AssertTxLogs::new("transfer") + .expect_log_count(1) + .expect_emitter_at(0, SYSTEM_ADDRESS) + .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient, value), + ) + .with_action(AssertTxTrace::new("transfer")) + .run() + .await + .expect("test_min_value_transfer failed"); +} + +/// Test: multiple value transfers in one block each emit their own log. +#[tokio::test] +async fn test_multiple_transfers_in_block() { + reth_tracing::init_test_tracing(); + + let recipient_a = address!("0x000000000000000000000000000000000000aaaa"); + let recipient_b = address!("0x000000000000000000000000000000000000bbbb"); + let value_a = U256::from(100_000); + let value_b = U256::from(200_000); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("tx_a") + .with_to(recipient_a) + .with_value(value_a), + ) + .with_action( + SendTransaction::new("tx_b") + .with_to(recipient_b) + .with_value(value_b), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("tx_a").expect(TxStatus::Success)) + .with_action(AssertTxIncluded::new("tx_b").expect(TxStatus::Success)) + .with_action( + AssertTxLogs::new("tx_a") + .expect_log_count(1) + .expect_emitter_at(0, SYSTEM_ADDRESS) + .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient_a, value_a), + ) + .with_action( + AssertTxLogs::new("tx_b") + .expect_log_count(1) + .expect_emitter_at(0, SYSTEM_ADDRESS) + .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient_b, value_b), + ) + .with_action(AssertTxTrace::new("tx_a")) + .with_action(AssertTxTrace::new("tx_b")) + .run() + .await + .expect("test_multiple_transfers_in_block failed"); +} + +/// Test: reverted value transfer to reverting contract does not leak balance. +/// +/// Sends value to a reverting contract. The tx reverts, so no value is transferred. +/// Asserts the target contract's balance remains zero after the revert. +#[tokio::test] +async fn test_reverted_value_transfer_balance_unchanged() { + reth_tracing::init_test_tracing(); + + let deploy_code = helpers::contracts::reverting_contract_deploy_code(); + let value = U256::from(500_000); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("deploy") + .with_create() + .with_data(deploy_code) + .with_value(U256::ZERO) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("deploy").expect(TxStatus::Success)) + .with_action(StoreDeployedAddress::new("deploy")) + // Confirm contract starts with zero balance + .with_action(AssertNamedBalance::of("deploy_address").equals(U256::ZERO)) + // Attempt value transfer — will revert + .with_action( + SendTransaction::new("revert_call") + .with_to_named("deploy_address") + .with_value(value) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("revert_call").expect(TxStatus::Reverted)) + .with_action(AssertTxLogs::new("revert_call").expect_no_logs()) + // Contract balance still zero — no value leaked through revert + .with_action(AssertNamedBalance::of("deploy_address").equals(U256::ZERO)) + .run() + .await + .expect("test_reverted_value_transfer_balance_unchanged failed"); +} diff --git a/crates/execution-e2e/tests/eip7708_payload_validation.rs b/crates/execution-e2e/tests/eip7708_payload_validation.rs new file mode 100644 index 0000000..3e7014a --- /dev/null +++ b/crates/execution-e2e/tests/eip7708_payload_validation.rs @@ -0,0 +1,110 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! EIP-7708 payload validation e2e tests. +//! +//! Verifies that Engine API accepts payloads containing EIP-7708 Transfer logs +//! and rejects payloads with corrupted state roots. + +use alloy_primitives::U256; +use alloy_rpc_types_engine::PayloadStatusEnum; +use arc_execution_e2e::{ + actions::{ + assert_valid_or_syncing, build_payload_for_next_block, set_payload_override_and_rehash, + submit_payload, AssertTxIncluded, ProduceBlocks, SendTransaction, TxStatus, + }, + Action, ArcEnvironment, ArcSetup, +}; +use eyre::Result; + +/// Test #42: Payload with EIP-7708 Transfer log is accepted as VALID. +#[tokio::test] +async fn test_payload_with_eip7708_log_accepted() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut env = ArcEnvironment::new(); + ArcSetup::new().apply(&mut env).await?; + + // Produce block 1 with a value transfer (triggers EIP-7708 log) + let mut send = SendTransaction::new("transfer") + .with_to(alloy_primitives::address!( + "0x000000000000000000000000000000000000bEEF" + )) + .with_value(U256::from(1_000_000)); + send.execute(&mut env).await?; + + let mut produce = ProduceBlocks::new(1); + produce.execute(&mut env).await?; + + // Verify the tx was included successfully + let mut assert_included = AssertTxIncluded::new("transfer").expect(TxStatus::Success); + assert_included.execute(&mut env).await?; + + // Now build the next payload and submit via Engine API + let (payload, execution_requests, parent_beacon_block_root) = + build_payload_for_next_block(&env).await?; + + let status = + submit_payload(&env, payload, execution_requests, parent_beacon_block_root).await?; + + assert_valid_or_syncing(&status, "EIP-7708 payload")?; + + Ok(()) +} + +/// Test #43: Payload with corrupted stateRoot after EIP-7708 tx is rejected as INVALID. +#[tokio::test] +async fn test_payload_with_corrupted_state_root_rejected() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut env = ArcEnvironment::new(); + ArcSetup::new().apply(&mut env).await?; + + // Produce block 1 with a value transfer + let mut send = SendTransaction::new("transfer") + .with_to(alloy_primitives::address!( + "0x000000000000000000000000000000000000bEEF" + )) + .with_value(U256::from(1_000_000)); + send.execute(&mut env).await?; + + let mut produce = ProduceBlocks::new(1); + produce.execute(&mut env).await?; + + // Build next payload + let (mut payload, execution_requests, parent_beacon_block_root) = + build_payload_for_next_block(&env).await?; + + // Corrupt the state root + let mut payload_override = payload.payload_inner.payload_inner.clone(); + payload_override.state_root = alloy_primitives::B256::repeat_byte(0xDE); + set_payload_override_and_rehash( + &mut payload, + &execution_requests, + parent_beacon_block_root, + payload_override, + )?; + + let status = + submit_payload(&env, payload, execution_requests, parent_beacon_block_root).await?; + + assert!( + matches!(status, PayloadStatusEnum::Invalid { .. }), + "Expected INVALID status for corrupted state root, got {status:?}" + ); + + Ok(()) +} diff --git a/crates/execution-e2e/tests/eip7708_precompile.rs b/crates/execution-e2e/tests/eip7708_precompile.rs new file mode 100644 index 0000000..264de98 --- /dev/null +++ b/crates/execution-e2e/tests/eip7708_precompile.rs @@ -0,0 +1,471 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! EIP-7708 precompile interaction e2e tests. +//! +//! Tests cover both unauthorized (revert) and authorized (success) paths for +//! NativeCoinAuthority precompile operations (mint, burn, transfer). +//! +//! Under Zero5, the NativeCoinAuthority precompile only accepts calls from +//! `NATIVE_FIAT_TOKEN_ADDRESS` (0x3600..0000). Direct EOA calls are rejected. +//! Authorized calls go through the NativeFiatToken contract, which delegates +//! to the precompile. The operator wallet (index 7 in localdev genesis) has +//! the minter role. + +mod helpers; + +use alloy_primitives::{address, Address, Bytes, U256}; +use alloy_sol_types::{sol, SolCall}; +use arc_execution_e2e::{ + actions::{ + AssertBalance, AssertTxIncluded, AssertTxLogs, AssertTxTrace, ProduceBlocks, + SendTransaction, TxStatus, + }, + ArcSetup, ArcTestBuilder, +}; +use helpers::constants::NATIVE_COIN_AUTHORITY_ADDRESS; + +/// NativeFiatToken proxy contract address — the only caller authorized to invoke +/// NativeCoinAuthority under Zero5. +const NATIVE_FIAT_TOKEN_ADDRESS: Address = address!("0x3600000000000000000000000000000000000000"); + +/// NativeCoinControl precompile address. +const NATIVE_COIN_CONTROL_ADDRESS: Address = address!("0x1800000000000000000000000000000000000001"); + +/// Operator wallet index in localdev genesis (has minter role on NativeFiatToken). +const WALLET_OPERATOR_INDEX: usize = 7; + +/// NativeFiatToken uses 6 decimals; the precompile operates in 18-decimal native units. +/// NativeFiatToken converts by multiplying by 10^12 before calling the precompile. +/// So 1 USDC (1_000_000 in 6-dec) becomes 10^18 in the precompile's event and balance. +const USDC_TO_NATIVE: U256 = U256::from_limbs([1_000_000_000_000u64, 0, 0, 0]); // 10^12 + +sol! { + /// NativeFiatToken contract ABI (authorized path — operator calls these). + interface INativeFiatToken { + function mint(address to, uint256 amount) public; + function burn(uint256 amount) public; + function transfer(address to, uint256 amount) public returns (bool); + } + + /// NativeCoinAuthority precompile ABI (unauthorized path — direct calls). + interface INativeCoinAuthority { + function mint(address to, uint256 amount) external returns (bool); + function burn(address from, uint256 amount) external returns (bool); + function transfer(address from, address to, uint256 amount) external returns (bool); + function totalSupply() external view returns (uint256 supply); + } +} + +// ===== Unauthorized paths (#30-32): Direct EOA calls to precompile ===== + +/// Test #30: Direct unauthorized call to NativeCoinAuthority mint — reverts, no EIP-7708 log. +#[tokio::test] +async fn test_unauthorized_mint_call_reverts_no_log() { + reth_tracing::init_test_tracing(); + + let calldata = INativeCoinAuthority::mintCall { + to: address!("0x000000000000000000000000000000000000bEEF"), + amount: U256::from(1_000_000), + } + .abi_encode(); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("mint_call") + .with_to(NATIVE_COIN_AUTHORITY_ADDRESS) + .with_value(U256::ZERO) + .with_data(Bytes::from(calldata)) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("mint_call").expect(TxStatus::Reverted)) + .with_action(AssertTxLogs::new("mint_call").expect_no_logs()) + .with_action(AssertTxTrace::new("mint_call")) + .run() + .await + .expect("test_unauthorized_mint_call_reverts_no_log failed"); +} + +/// Test #31: Direct unauthorized call to NativeCoinAuthority burn — reverts, no EIP-7708 log. +#[tokio::test] +async fn test_unauthorized_burn_call_reverts_no_log() { + reth_tracing::init_test_tracing(); + + let calldata = INativeCoinAuthority::burnCall { + from: address!("0x000000000000000000000000000000000000bEEF"), + amount: U256::from(1_000), + } + .abi_encode(); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("burn_call") + .with_to(NATIVE_COIN_AUTHORITY_ADDRESS) + .with_value(U256::ZERO) + .with_data(Bytes::from(calldata)) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("burn_call").expect(TxStatus::Reverted)) + .with_action(AssertTxLogs::new("burn_call").expect_no_logs()) + .with_action(AssertTxTrace::new("burn_call")) + .run() + .await + .expect("test_unauthorized_burn_call_reverts_no_log failed"); +} + +/// Test #32: Direct unauthorized call to NativeCoinAuthority transfer — reverts, no EIP-7708 log. +#[tokio::test] +async fn test_unauthorized_transfer_call_reverts_no_log() { + reth_tracing::init_test_tracing(); + + let calldata = INativeCoinAuthority::transferCall { + from: address!("0x000000000000000000000000000000000000bEEF"), + to: address!("0x000000000000000000000000000000000000CAFE"), + amount: U256::from(1_000), + } + .abi_encode(); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("transfer_call") + .with_to(NATIVE_COIN_AUTHORITY_ADDRESS) + .with_value(U256::ZERO) + .with_data(Bytes::from(calldata)) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("transfer_call").expect(TxStatus::Reverted)) + .with_action(AssertTxLogs::new("transfer_call").expect_no_logs()) + .with_action(AssertTxTrace::new("transfer_call")) + .run() + .await + .expect("test_unauthorized_transfer_call_reverts_no_log failed"); +} + +// ===== Value to precompile addresses (#33-34) ===== + +/// Test #33: Value transfer to NativeCoinAuthority — reverts, no log. +#[tokio::test] +async fn test_value_to_native_coin_authority() { + reth_tracing::init_test_tracing(); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("value_call") + .with_to(NATIVE_COIN_AUTHORITY_ADDRESS) + .with_value(U256::from(1_000)) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("value_call").expect(TxStatus::Reverted)) + .with_action(AssertTxLogs::new("value_call").expect_no_logs()) + .with_action(AssertTxTrace::new("value_call")) + .run() + .await + .expect("test_value_to_native_coin_authority failed"); +} + +/// Test #34: Value transfer to NativeCoinControl — reverts, no log. +#[tokio::test] +async fn test_value_to_native_coin_control() { + reth_tracing::init_test_tracing(); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("value_call") + .with_to(NATIVE_COIN_CONTROL_ADDRESS) + .with_value(U256::from(1_000)) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("value_call").expect(TxStatus::Reverted)) + .with_action(AssertTxLogs::new("value_call").expect_no_logs()) + .with_action(AssertTxTrace::new("value_call")) + .run() + .await + .expect("test_value_to_native_coin_control failed"); +} + +/// Test #35: Zero-value call to NativeFiatToken — no EIP-7708 log. +#[tokio::test] +async fn test_zero_value_call_to_native_fiat_token() { + reth_tracing::init_test_tracing(); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("call") + .with_to(NATIVE_FIAT_TOKEN_ADDRESS) + .with_value(U256::ZERO) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("call").expect(TxStatus::Reverted)) + .with_action(AssertTxLogs::new("call").expect_no_logs()) + .with_action(AssertTxTrace::new("call")) + .run() + .await + .expect("test_zero_value_call_to_native_fiat_token failed"); +} + +/// Test #36: Direct totalSupply read — succeeds without log. +#[tokio::test] +async fn test_total_supply_read_no_log() { + reth_tracing::init_test_tracing(); + + let calldata = Bytes::from(INativeCoinAuthority::totalSupplyCall {}.abi_encode()); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("total_supply") + .with_to(NATIVE_COIN_AUTHORITY_ADDRESS) + .with_value(U256::ZERO) + .with_data(calldata) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("total_supply").expect(TxStatus::Success)) + .with_action(AssertTxLogs::new("total_supply").expect_no_logs()) + .with_action(AssertTxTrace::new("total_supply")) + .run() + .await + .expect("test_total_supply_read_no_log failed"); +} + +// ===== Authorized paths: NativeFiatToken mint/burn ===== + +/// Test: Authorized mint via NativeFiatToken — emits EIP-7708 Transfer log + Mint event. +/// +/// The operator (wallet index 7) calls NativeFiatToken.mint(to, amount). +/// NativeFiatToken delegates to NativeCoinAuthority precompile. +/// Under Zero5, the precompile emits an EIP-7708 Transfer log from SYSTEM_ADDRESS +/// for the minted amount, plus the Solidity-level Mint and Transfer events from +/// the NativeFiatToken contract. +#[tokio::test] +async fn test_authorized_mint_via_native_fiat_token() { + reth_tracing::init_test_tracing(); + + let mint_recipient = address!("0x000000000000000000000000000000000000CAFE"); + // NativeFiatToken uses 6 decimals. Mint 1 USDC = 1_000_000 (6 decimals). + // The precompile converts this to 18-decimal native units internally. + let mint_amount_usdc = U256::from(1_000_000u64); + + let calldata = INativeFiatToken::mintCall { + to: mint_recipient, + amount: mint_amount_usdc, + } + .abi_encode(); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("mint") + .with_to(NATIVE_FIAT_TOKEN_ADDRESS) + .with_value(U256::ZERO) + .with_data(Bytes::from(calldata)) + .with_gas_limit(500_000) + .with_wallet_index(WALLET_OPERATOR_INDEX), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("mint").expect(TxStatus::Success)) + // The precompile emits Transfer(0x0, to, amount) from SYSTEM_ADDRESS under Zero5. + // NativeFiatToken converts 6-dec USDC to 18-dec native before calling the precompile, + // so the event amount is in 18-decimal native units. + .with_action( + AssertTxLogs::new("mint").expect_transfer_event( + 0, + Address::ZERO, + mint_recipient, + mint_amount_usdc + .checked_mul(USDC_TO_NATIVE) + .expect("usdc to native overflow"), + ), + ) + .with_action(AssertTxTrace::new("mint")) + // Verify recipient balance in 18-decimal native units + .with_action(AssertBalance::new( + mint_recipient, + mint_amount_usdc + .checked_mul(USDC_TO_NATIVE) + .expect("usdc to native overflow"), + )) + .run() + .await + .expect("test_authorized_mint_via_native_fiat_token failed"); +} + +/// Test: Authorized burn via NativeFiatToken — emits EIP-7708 Transfer log + Burn event. +/// +/// Burns tokens from the operator's own balance. Requires the operator to have +/// balance, so we first mint to the operator, then burn. +#[tokio::test] +async fn test_authorized_burn_via_native_fiat_token() { + reth_tracing::init_test_tracing(); + + // Operator address (wallet index 7) + let operator = { + let wallet = reth_e2e_test_utils::wallet::Wallet::new(10).with_chain_id(1337); + wallet.wallet_gen()[WALLET_OPERATOR_INDEX].address() + }; + + let mint_amount = U256::from(2_000_000u64); // 2 USDC + let burn_amount = U256::from(1_000_000u64); // 1 USDC + + // Mint to operator first + let mint_calldata = INativeFiatToken::mintCall { + to: operator, + amount: mint_amount, + } + .abi_encode(); + + let burn_calldata = INativeFiatToken::burnCall { + amount: burn_amount, + } + .abi_encode(); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + // Step 1: Mint to operator + .with_action( + SendTransaction::new("mint") + .with_to(NATIVE_FIAT_TOKEN_ADDRESS) + .with_value(U256::ZERO) + .with_data(Bytes::from(mint_calldata)) + .with_gas_limit(500_000) + .with_wallet_index(WALLET_OPERATOR_INDEX), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("mint").expect(TxStatus::Success)) + // Step 2: Burn from operator + .with_action( + SendTransaction::new("burn") + .with_to(NATIVE_FIAT_TOKEN_ADDRESS) + .with_value(U256::ZERO) + .with_data(Bytes::from(burn_calldata)) + .with_gas_limit(500_000) + .with_wallet_index(WALLET_OPERATOR_INDEX), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("burn").expect(TxStatus::Success)) + // Burn emits Transfer(operator, 0x0, amount) from SYSTEM_ADDRESS under Zero5. + // Amount is in 18-decimal native units (NativeFiatToken converts before calling precompile). + .with_action( + AssertTxLogs::new("burn").expect_transfer_event( + 0, + operator, + Address::ZERO, + burn_amount + .checked_mul(USDC_TO_NATIVE) + .expect("usdc to native overflow"), + ), + ) + .with_action(AssertTxTrace::new("burn")) + // Note: operator balance assertion omitted because the operator is funded in genesis + // and pays gas in USDC, making the exact post-burn balance variable. + // The Transfer log assertion above verifies the burn semantics. + .run() + .await + .expect("test_authorized_burn_via_native_fiat_token failed"); +} + +/// Test: Authorized transfer via NativeFiatToken — emits exact EIP-7708 Transfer log. +/// +/// Mints to the operator, then the operator calls NativeFiatToken.transfer(to, amount). +/// NativeFiatToken delegates to NativeCoinAuthority.transfer(from, to, amount). +/// Under Zero5, the precompile emits Transfer(from, to, amount) from SYSTEM_ADDRESS. +/// Verifies exact log fields and balance side effects. +#[tokio::test] +async fn test_authorized_transfer_via_native_fiat_token() { + reth_tracing::init_test_tracing(); + + let operator = { + let wallet = reth_e2e_test_utils::wallet::Wallet::new(10).with_chain_id(1337); + wallet.wallet_gen()[WALLET_OPERATOR_INDEX].address() + }; + + let transfer_recipient = address!("0x000000000000000000000000000000000000D00D"); + let mint_amount = U256::from(2_000_000u64); // 2 USDC + let transfer_amount = U256::from(1_000_000u64); // 1 USDC + + let mint_calldata = INativeFiatToken::mintCall { + to: operator, + amount: mint_amount, + } + .abi_encode(); + + let transfer_calldata = INativeFiatToken::transferCall { + to: transfer_recipient, + amount: transfer_amount, + } + .abi_encode(); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + // Mint to operator first + .with_action( + SendTransaction::new("mint") + .with_to(NATIVE_FIAT_TOKEN_ADDRESS) + .with_value(U256::ZERO) + .with_data(Bytes::from(mint_calldata)) + .with_gas_limit(500_000) + .with_wallet_index(WALLET_OPERATOR_INDEX), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("mint").expect(TxStatus::Success)) + // Transfer from operator to recipient + .with_action( + SendTransaction::new("transfer") + .with_to(NATIVE_FIAT_TOKEN_ADDRESS) + .with_value(U256::ZERO) + .with_data(Bytes::from(transfer_calldata)) + .with_gas_limit(500_000) + .with_wallet_index(WALLET_OPERATOR_INDEX), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) + // Transfer emits Transfer(operator, recipient, amount) from SYSTEM_ADDRESS. + // Amount is in 18-decimal native units. + .with_action( + AssertTxLogs::new("transfer").expect_transfer_event( + 0, + operator, + transfer_recipient, + transfer_amount + .checked_mul(USDC_TO_NATIVE) + .expect("usdc to native overflow"), + ), + ) + .with_action(AssertTxTrace::new("transfer")) + // Operator balance omitted (genesis-funded + gas costs make exact value variable). + // Recipient starts at zero and doesn't pay gas, so exact balance is deterministic. + .with_action(AssertBalance::new( + transfer_recipient, + transfer_amount + .checked_mul(USDC_TO_NATIVE) + .expect("usdc to native overflow"), + )) + .run() + .await + .expect("test_authorized_transfer_via_native_fiat_token failed"); +} diff --git a/crates/execution-e2e/tests/eip7708_zero_address.rs b/crates/execution-e2e/tests/eip7708_zero_address.rs new file mode 100644 index 0000000..9bc16c3 --- /dev/null +++ b/crates/execution-e2e/tests/eip7708_zero_address.rs @@ -0,0 +1,108 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! EIP-7708 zero address e2e tests. +//! +//! Arc custom behavior: value transfers to Address::ZERO are rejected under Zero5. + +mod helpers; + +use alloy_primitives::{Address, U256}; +use arc_execution_config::hardforks::ArcHardfork; +use arc_execution_e2e::{ + actions::{ + AssertTxIncluded, AssertTxLogs, AssertTxTrace, ProduceBlocks, SendTransaction, TxStatus, + }, + chainspec::localdev_with_hardforks, + ArcSetup, ArcTestBuilder, +}; + +/// Test #24: Send value to Address::ZERO under Zero5 — tx reverts. +#[tokio::test] +async fn test_zero_address_value_transfer_reverts() { + reth_tracing::init_test_tracing(); + + let value = U256::from(1_000_000); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("zero_addr") + .with_to(Address::ZERO) + .with_value(value) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("zero_addr").expect(TxStatus::Reverted)) + .with_action(AssertTxLogs::new("zero_addr").expect_no_logs()) + .with_action(AssertTxTrace::new("zero_addr")) + .run() + .await + .expect("test_zero_address_value_transfer_reverts failed"); +} + +/// Test #25: Send zero value to Address::ZERO — should succeed (no transfer, no log). +#[tokio::test] +async fn test_zero_address_zero_value_succeeds() { + reth_tracing::init_test_tracing(); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action( + SendTransaction::new("zero_addr") + .with_to(Address::ZERO) + .with_value(U256::ZERO) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("zero_addr").expect(TxStatus::Success)) + .with_action(AssertTxLogs::new("zero_addr").expect_no_logs()) + .with_action(AssertTxTrace::new("zero_addr")) + .run() + .await + .expect("test_zero_address_zero_value_succeeds failed"); +} + +/// Test #26: Pre-Zero5 — value transfer to Address::ZERO is not rejected. +#[tokio::test] +async fn test_pre_zero5_zero_address_allowed() { + reth_tracing::init_test_tracing(); + + let chain_spec = localdev_with_hardforks(&[ + (ArcHardfork::Zero3, 0), + (ArcHardfork::Zero4, 0), + (ArcHardfork::Zero5, 100), + (ArcHardfork::Zero6, 100), + ]); + + let value = U256::from(1_000_000); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new().with_chain_spec(chain_spec)) + .with_action( + SendTransaction::new("zero_addr") + .with_to(Address::ZERO) + .with_value(value) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + // Pre-Zero5: zero-address value transfer should succeed + .with_action(AssertTxIncluded::new("zero_addr").expect(TxStatus::Success)) + .with_action(AssertTxTrace::new("zero_addr")) + .run() + .await + .expect("test_pre_zero5_zero_address_allowed failed"); +} diff --git a/crates/execution-e2e/tests/helpers/constants.rs b/crates/execution-e2e/tests/helpers/constants.rs new file mode 100644 index 0000000..c44d602 --- /dev/null +++ b/crates/execution-e2e/tests/helpers/constants.rs @@ -0,0 +1,37 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Shared constants for EIP-7708 e2e tests. +//! +//! Each test binary only uses a subset of these; unused items are expected. +#![allow(dead_code)] + +use alloy_primitives::{address, Address}; + +// Re-export event signatures from the library to avoid duplication. +// Not all test files use both signatures, but they are shared constants. +#[allow(unused_imports)] +pub use arc_execution_e2e::actions::{NATIVE_COIN_TRANSFERRED_SIGNATURE, TRANSFER_EVENT_SIGNATURE}; + +/// EIP-7708 system address — emitter of Transfer logs under Zero5. +pub const SYSTEM_ADDRESS: Address = address!("0xfffffffffffffffffffffffffffffffffffffffe"); + +/// NativeCoinAuthority precompile — emitter of NativeCoinTransferred logs before Zero5. +pub const NATIVE_COIN_AUTHORITY_ADDRESS: Address = + address!("0x1800000000000000000000000000000000000000"); + +/// First account from test mnemonic (0xf39Fd...), funded in localdev genesis. +pub const WALLET_FIRST_ADDRESS: Address = address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); diff --git a/crates/execution-e2e/tests/helpers/contracts.rs b/crates/execution-e2e/tests/helpers/contracts.rs new file mode 100644 index 0000000..970b31f --- /dev/null +++ b/crates/execution-e2e/tests/helpers/contracts.rs @@ -0,0 +1,219 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Inline bytecode for minimal test contracts. +//! +//! Each test binary only uses a subset of these; unused items are expected. +#![allow(dead_code)] + +use alloy_primitives::Bytes; +use revm_bytecode::opcode::*; + +/// Contract with `receive() external payable {}` — accepts value, does nothing. +/// +/// ```eas +/// stop ;; [] — halt execution, accept any value sent +/// ``` +pub fn payable_contract_deploy_code() -> Bytes { + let runtime = [STOP]; + deploy_code(&runtime) +} + +/// Contract that always reverts with zero-length revert data. +/// +/// ```eas +/// push1 0x00 ;; [0] — revert data length +/// push1 0x00 ;; [0, 0] — revert data offset +/// revert ;; [] — revert(0, 0) +/// ``` +pub fn reverting_contract_deploy_code() -> Bytes { + #[rustfmt::skip] + let runtime = [ + PUSH1, 0x00, // revert(0, + PUSH1, 0x00, // 0) + REVERT, + ]; + deploy_code(&runtime) +} + +/// Constructor that reverts during deployment (no contract is created). +/// This is NOT wrapped in deploy_code — it IS the initcode that reverts. +/// +/// ```eas +/// ;; Initcode — reverts immediately, so CREATE/CREATE2 returns address(0). +/// push1 0x00 ;; [0] — revert data length +/// push1 0x00 ;; [0, 0] — revert data offset +/// revert ;; [] — revert(0, 0) +/// ``` +pub fn reverting_constructor_code() -> Bytes { + #[rustfmt::skip] + let initcode = vec![ + PUSH1, 0x00, // revert(0, + PUSH1, 0x00, // 0) + REVERT, + ]; + Bytes::from(initcode) +} + +/// Contract that calls SELFDESTRUCT with beneficiary from calldata. +/// +/// ```eas +/// push1 0x00 ;; [0] — calldata offset +/// calldataload ;; [cd[0..32]] — load 32-byte word +/// push1 0x60 ;; [96, cd] — shift amount (256 - 160) +/// shr ;; [addr] — isolate 20-byte address +/// selfdestruct ;; [] — send balance to addr and destroy +/// ``` +pub fn selfdestruct_contract_deploy_code() -> Bytes { + #[rustfmt::skip] + let runtime = [ + PUSH1, 0x00, CALLDATALOAD, // calldataload(0) → 32-byte word + PUSH1, 0x60, SHR, // shr(96) → target address + SELFDESTRUCT, // selfdestruct(addr) + ]; + deploy_code(&runtime) +} + +/// Contract that forwards received value to a target address via CALL. +/// +/// Pseudocode: `call(gas(), calldata[0:20], callvalue(), 0, 0, 0, 0)` +/// +/// ```eas +/// ;; Extract target address from calldata[0:20]. +/// push1 0x00 ;; [0] — calldata offset +/// calldataload ;; [cd[0..32]] — load 32-byte word +/// push1 0x60 ;; [96, cd[0..32]] — shift amount +/// shr ;; [addr] — isolate 20-byte address +/// +/// ;; Build CALL args (reverse order — EVM is stack-based). +/// push1 0x00 ;; [0, addr] — retLength +/// push1 0x00 ;; [0, 0, addr] — retOffset +/// push1 0x00 ;; [0, 0, 0, addr] — argsLength +/// push1 0x00 ;; [0, 0, 0, 0, addr] — argsOffset +/// callvalue ;; [value, 0, 0, 0, 0, addr] — msg.value +/// dup6 ;; [addr, value, 0, 0, 0, 0, addr] — copy target +/// gas ;; [gas, addr, value, 0, 0, 0, 0, addr] — remaining gas +/// call ;; [success, addr] — call(gas, addr, value, 0, 0, 0, 0) +/// stop ;; [] — halt +/// ``` +pub fn forwarder_contract_deploy_code() -> Bytes { + #[rustfmt::skip] + let runtime = [ + PUSH1, 0x00, CALLDATALOAD, // calldataload(0) → 32-byte word + PUSH1, 0x60, SHR, // shr(96) → target address + + PUSH1, 0x00, // call(gas(), + PUSH1, 0x00, // addr, + PUSH1, 0x00, // callvalue(), + PUSH1, 0x00, // 0, 0, 0, 0) + CALLVALUE, // + DUP6, // ↑ addr from stack position 6 + GAS, // + CALL, // → success + STOP, + ]; + deploy_code(&runtime) +} + +/// Like [`forwarder_contract_deploy_code`] but always succeeds — inner CALL +/// result is discarded with POP so the outer frame never reverts. +/// +/// Pseudocode: `pop(call(gas(), calldata[0:20], callvalue(), 0, 0, 0, 0))` +/// +/// ```eas +/// ;; Extract target address from calldata[0:20]. +/// push1 0x00 ;; [0] — calldata offset +/// calldataload ;; [cd[0..32]] — load 32-byte word +/// push1 0x60 ;; [96, cd[0..32]] — shift amount +/// shr ;; [addr] — isolate 20-byte address +/// +/// ;; Build CALL args (reverse order — EVM is stack-based). +/// push1 0x00 ;; [0, addr] — retLength +/// push1 0x00 ;; [0, 0, addr] — retOffset +/// push1 0x00 ;; [0, 0, 0, addr] — argsLength +/// push1 0x00 ;; [0, 0, 0, 0, addr] — argsOffset +/// callvalue ;; [value, 0, 0, 0, 0, addr] — msg.value +/// dup6 ;; [addr, value, 0, 0, 0, 0, addr] — copy target +/// gas ;; [gas, addr, value, 0, 0, 0, 0, addr] — remaining gas +/// call ;; [success, addr] — call(gas, addr, value, 0, 0, 0, 0) +/// pop ;; [addr] — discard success flag +/// stop ;; [] — always succeed +/// ``` +pub fn call_target_with_value_contract_deploy_code() -> Bytes { + #[rustfmt::skip] + let runtime = [ + PUSH1, 0x00, CALLDATALOAD, // calldataload(0) → 32-byte word + PUSH1, 0x60, SHR, // shr(96) → target address + + PUSH1, 0x00, // call(gas(), + PUSH1, 0x00, // addr, + PUSH1, 0x00, // callvalue(), + PUSH1, 0x00, // 0, 0, 0, 0) + CALLVALUE, // + DUP6, // ↑ addr from stack position 6 + GAS, // + CALL, // → success + POP, // discard success — always succeed + STOP, + ]; + deploy_code(&runtime) +} + +/// Right-pads an address to 32 bytes for use as calldata. +/// +/// Contracts that read a target address via `CALLDATALOAD(0) + SHR(96)` expect +/// the address in the top 20 bytes of the 32-byte word (right-padded with zeros). +pub fn right_pad_address(addr: alloy_primitives::Address) -> Bytes { + let mut buf = [0u8; 32]; + buf[..20].copy_from_slice(addr.as_slice()); + Bytes::from(buf.to_vec()) +} + +/// Helper: wraps runtime bytecode in a minimal constructor that deploys it. +/// +/// ```eas +/// ;; Constructor (11 bytes) — copies runtime to memory and returns it. +/// push1 LL ;; [len] — runtime bytecode length +/// dup1 ;; [len, len] — duplicate for RETURN size +/// push1 0x0b ;; [11, len, len] — code offset (constructor is 11 bytes) +/// push1 0x00 ;; [0, 11, len, len] — memory destination offset +/// codecopy ;; [len] — mem[0..len] = code[11..11+len] +/// push1 0x00 ;; [0, len] — memory offset for RETURN +/// return ;; [] — return(0, len) → deployed runtime +/// ``` +fn deploy_code(runtime: &[u8]) -> Bytes { + let len = runtime.len(); + assert!(len < 256, "runtime bytecode too large for PUSH1"); + let constructor_len: u8 = 11; + let len_u8 = u8::try_from(len).expect("runtime len checked < 256"); + let mut code = Vec::with_capacity( + usize::from(constructor_len) + .checked_add(len) + .expect("total len overflow"), + ); + #[rustfmt::skip] + code.extend_from_slice(&[ + PUSH1, len_u8, // codecopy(0, + DUP1, // + PUSH1, constructor_len, // 11, + PUSH1, 0x00, // len) + CODECOPY, // + PUSH1, 0x00, // return(0, len) + RETURN, // + ]); + code.extend_from_slice(runtime); + Bytes::from(code) +} diff --git a/crates/execution-e2e/tests/helpers/mod.rs b/crates/execution-e2e/tests/helpers/mod.rs new file mode 100644 index 0000000..e7fce07 --- /dev/null +++ b/crates/execution-e2e/tests/helpers/mod.rs @@ -0,0 +1,18 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +pub mod constants; +pub mod contracts; diff --git a/crates/malachite-app/README.md b/crates/malachite-app/README.md index 691f2db..0f005f1 100644 --- a/crates/malachite-app/README.md +++ b/crates/malachite-app/README.md @@ -61,6 +61,7 @@ arc-node-consensus start \ --home=~/.arc/consensus \ --moniker=validator-1 \ --validator \ + --suggested-fee-recipient=0xYourAddressHere \ --eth-socket=/tmp/reth.ipc \ --execution-socket=/tmp/auth.ipc \ --minimal @@ -73,6 +74,7 @@ arc-node-consensus start \ --home=~/.arc/consensus \ --moniker=validator-1 \ --validator \ + --suggested-fee-recipient=0xYourAddressHere \ --p2p.addr=/ip4/172.19.0.5/tcp/27000 \ --p2p.persistent-peers=/ip4/172.19.0.6/tcp/27000,/ip4/172.19.0.7/tcp/27000 \ --metrics=172.19.0.5:29000 \ @@ -89,6 +91,7 @@ arc-node-consensus start \ --home=~/.arc/consensus \ --moniker=validator-1 \ --validator \ + --suggested-fee-recipient=0xYourAddressHere \ --p2p.addr=/ip4/172.19.0.5/tcp/27000 \ --p2p.persistent-peers=/ip4/172.19.0.6/tcp/27000,/ip4/172.19.0.7/tcp/27000 \ --metrics=0.0.0.0:29000 \ @@ -152,7 +155,7 @@ https://example.com,wss=ws.example.com:1212 - `--p2p.persistent-peers` - Comma-separated list of persistent peer multiaddrs - `--p2p.persistent-peers-only` - Only allow connections to/from persistent peers (default: false). Useful for sentry node setups where a validator should only communicate with known trusted peers. -- `--validator` - Run as a validator: load the consensus signing key, sign the validator proof (ADR-006), and advertise a validator identity. Without this flag the node runs as a full node (no signing, ephemeral consensus key). Mutually exclusive with `--no-consensus` and `--follow`. +- `--validator` - Run as a validator: load the consensus signing key, sign the validator proof (ADR-006), and advertise a validator identity. Without this flag the node runs as a full node (no signing, ephemeral consensus key). Mutually exclusive with `--no-consensus` and `--follow`. Requires `--suggested-fee-recipient`. - `--no-consensus` - Run as a sync-only node that does not subscribe to consensus gossip topics. Mutually exclusive with `--validator`. - `--discovery` - Enable peer discovery (default: false) - `--discovery.num-outbound-peers` - Number of outbound peers (default: 20) @@ -167,7 +170,7 @@ https://example.com,wss=ws.example.com:1212 - `--log-level` - Log level: "trace", "debug", "info", "warn", "error" (default: "debug") - `--log-format` - Log format: "plaintext" or "json" (default: "plaintext") - `--pprof.addr` - Profiling server bind address (default: "0.0.0.0:6060") -- `--suggested-fee-recipient` - Address to receive tips and rewards +- `--suggested-fee-recipient
` - 20-byte address to receive tips and rewards. Required when `--validator` is set. - `--follow` - Enable RPC sync mode. The node fetches blocks from trusted RPC endpoints instead of participating in consensus (requires `--follow.endpoint`) - `--follow.endpoint ` - RPC endpoint to fetch blocks from in sync mode. Can be repeated. Format: `http://host:port[,ws=port]` (requires `--follow`) - `--runtime.flavor` - Tokio runtime flavor: "single-threaded" or "multi-threaded" (default: "multi-threaded") @@ -186,6 +189,7 @@ arc-node-consensus start \ --home=~/.arc/consensus \ --moniker=validator-1 \ --validator \ + --suggested-fee-recipient=0xYourAddressHere \ --eth-socket=/tmp/reth.ipc \ --execution-socket=/tmp/auth.ipc \ --minimal \ diff --git a/crates/malachite-app/src/config.rs b/crates/malachite-app/src/config.rs index ada5eef..b59ab33 100644 --- a/crates/malachite-app/src/config.rs +++ b/crates/malachite-app/src/config.rs @@ -122,6 +122,8 @@ pub struct StartConfig { pub execution_jwt: Option, /// The bind address for the pprof server pub pprof_bind_address: Option, + /// Whether to activate jemalloc heap profiling + pub pprof_heap_prof: bool, /// The address to receive the fees and rewards from the execution layer pub suggested_fee_recipient: Option
, /// Skip database schema upgrade on startup @@ -301,6 +303,7 @@ mod tests { execution_ws_endpoint: None, execution_jwt: None, pprof_bind_address: None, + pprof_heap_prof: false, suggested_fee_recipient: None, skip_db_upgrade: false, validator: false, @@ -332,6 +335,7 @@ mod tests { execution_ws_endpoint: None, execution_jwt: None, pprof_bind_address: None, + pprof_heap_prof: false, suggested_fee_recipient: None, skip_db_upgrade: false, validator: false, @@ -359,6 +363,7 @@ mod tests { execution_ws_endpoint: None, execution_jwt: None, pprof_bind_address: None, + pprof_heap_prof: false, suggested_fee_recipient: None, skip_db_upgrade: false, validator: false, @@ -385,6 +390,7 @@ mod tests { execution_ws_endpoint: None, execution_jwt: None, pprof_bind_address: None, + pprof_heap_prof: false, suggested_fee_recipient: None, skip_db_upgrade: false, validator: false, diff --git a/crates/malachite-app/src/handlers/decided.rs b/crates/malachite-app/src/handlers/decided.rs index 0387ad2..73fbe15 100644 --- a/crates/malachite-app/src/handlers/decided.rs +++ b/crates/malachite-app/src/handlers/decided.rs @@ -143,7 +143,7 @@ async fn decide( // NOTE: here the node searches for the block with maching value_id from any round // It needs to read the complete undecided blocks table, but the expectation is it should be small. let block = match undecided_blocks - .get_first(height, value_id.block_hash()) + .get_by_hash(height, value_id.block_hash()) .await { Ok(Some(block)) => block, @@ -416,7 +416,7 @@ mod tests { let mut undecided_blocks = MockUndecidedBlocksRepository::new(); undecided_blocks - .expect_get_first() + .expect_get_by_hash() .with(eq(Height::new(height)), eq(block_hash)) .return_once(move |_, _| Ok(Some(consensus_block.clone()))); @@ -479,7 +479,7 @@ mod tests { let mut undecided_blocks = MockUndecidedBlocksRepository::new(); undecided_blocks - .expect_get_first() + .expect_get_by_hash() .with(eq(Height::new(height)), eq(block_hash)) .return_once(|_, _| Ok(None)); @@ -514,7 +514,7 @@ mod tests { let mut undecided_blocks = MockUndecidedBlocksRepository::new(); undecided_blocks - .expect_get_first() + .expect_get_by_hash() .with(eq(Height::new(height)), eq(block_hash)) .return_once(|_, _| Err(std::io::Error::other("Database error"))); @@ -551,7 +551,7 @@ mod tests { let mut undecided_blocks = MockUndecidedBlocksRepository::new(); undecided_blocks - .expect_get_first() + .expect_get_by_hash() .with(eq(Height::new(height)), eq(block_hash)) .return_once(move |_, _| Ok(Some(consensus_block.clone()))); diff --git a/crates/malachite-app/src/handlers/get_value.rs b/crates/malachite-app/src/handlers/get_value.rs index f6aba49..58d3874 100644 --- a/crates/malachite-app/src/handlers/get_value.rs +++ b/crates/malachite-app/src/handlers/get_value.rs @@ -39,6 +39,7 @@ use crate::payload::{ }; use crate::proposal_parts::{prepare_stream, stream_proposal}; use crate::state::State; +use crate::store::repositories::UndecidedBlocksRepository; use crate::store::Store; use crate::utils::pretty::PrettyPayload; @@ -298,12 +299,12 @@ pub async fn build_block( /// We assume this implementation is not byzantine and we are the proposer for the given height and round. /// Therefore there must be a single block for the rounds where we are the proposer, with the proposer address matching our own. async fn get_previously_built_block( - store: &Store, + undecided_blocks: impl UndecidedBlocksRepository, proposer: Address, height: Height, round: Round, ) -> eyre::Result> { - let blocks = store.get_undecided_blocks(height, round).await?; + let blocks = undecided_blocks.get_by_round(height, round).await?; let block = blocks.into_iter().find(|p| p.proposer == proposer); Ok(block) } diff --git a/crates/malachite-app/src/handlers/process_synced_value.rs b/crates/malachite-app/src/handlers/process_synced_value.rs index b8794e6..38fb206 100644 --- a/crates/malachite-app/src/handlers/process_synced_value.rs +++ b/crates/malachite-app/src/handlers/process_synced_value.rs @@ -171,7 +171,7 @@ async fn on_process_synced_value( let proposal = ProposedValue::from(&block); - undecided_blocks_repo.store(block).await.wrap_err_with(|| { + undecided_blocks_repo.store_undecided_block(block).await.wrap_err_with(|| { format!( "Failed to store undecided block {} synced from the network for height={}, round={}, proposer={}", block_hash, height, round, proposer, @@ -232,7 +232,7 @@ mod tests { let mut undecided = MockUndecidedBlocksRepository::new(); undecided - .expect_store() + .expect_store_undecided_block() .withf(move |block| { block.height == height && block.round == round && block.proposer == proposer }) @@ -290,7 +290,7 @@ mod tests { // Expectation: If bytes are invalid, we should NOT validate the payload // and definitely NOT store the block. engine.expect_validate_payload().times(0); - undecided.expect_store().times(0); + undecided.expect_store_undecided_block().times(0); invalid.expect_append().times(1).returning(|_| Ok(())); let height = Height::new(1); @@ -334,7 +334,7 @@ mod tests { .returning(|_| Err(io::Error::other("Simulated engine error").into())); let mut undecided = MockUndecidedBlocksRepository::new(); - undecided.expect_store().times(0); + undecided.expect_store_undecided_block().times(0); let mut invalid = MockInvalidPayloadsRepository::new(); invalid.expect_append().times(0); @@ -365,7 +365,7 @@ mod tests { engine.expect_validate_payload().times(0); let mut undecided = MockUndecidedBlocksRepository::new(); - undecided.expect_store().times(0); + undecided.expect_store_undecided_block().times(0); let mut invalid = MockInvalidPayloadsRepository::new(); invalid @@ -405,7 +405,7 @@ mod tests { let mut undecided = MockUndecidedBlocksRepository::new(); undecided - .expect_store() + .expect_store_undecided_block() .times(1) .returning(|_| Err(io::Error::other("Simulated store error"))); @@ -444,7 +444,10 @@ mod tests { .returning(|_| Ok(PayloadValidationResult::Valid)); let mut undecided = MockUndecidedBlocksRepository::new(); - undecided.expect_store().times(1).returning(|_| Ok(())); + undecided + .expect_store_undecided_block() + .times(1) + .returning(|_| Ok(())); let mut invalid = MockInvalidPayloadsRepository::new(); invalid.expect_append().times(0); @@ -491,7 +494,10 @@ mod tests { }); let mut undecided = MockUndecidedBlocksRepository::new(); - undecided.expect_store().times(1).returning(|_| Ok(())); + undecided + .expect_store_undecided_block() + .times(1) + .returning(|_| Ok(())); let mut invalid = MockInvalidPayloadsRepository::new(); invalid.expect_append().times(1).returning(|_| Ok(())); @@ -532,7 +538,10 @@ mod tests { .returning(|_| Ok(PayloadValidationResult::Valid)); let mut undecided = MockUndecidedBlocksRepository::new(); - undecided.expect_store().times(1).returning(|_| Ok(())); + undecided + .expect_store_undecided_block() + .times(1) + .returning(|_| Ok(())); let mut invalid = MockInvalidPayloadsRepository::new(); invalid.expect_append().times(0); diff --git a/crates/malachite-app/src/handlers/restream_proposal.rs b/crates/malachite-app/src/handlers/restream_proposal.rs index aa87dc2..339778b 100644 --- a/crates/malachite-app/src/handlers/restream_proposal.rs +++ b/crates/malachite-app/src/handlers/restream_proposal.rs @@ -108,7 +108,7 @@ async fn get_block_to_restream( block_hash: BlockHash, ) -> eyre::Result> { let block = undecided_blocks - .get_first(height, block_hash) + .get_by_hash(height, block_hash) .await .wrap_err_with(|| { format!( @@ -122,7 +122,7 @@ async fn get_block_to_restream( block.valid_round = valid_round; undecided_blocks - .store(block.clone()) + .store_undecided_block(block.clone()) .await .wrap_err_with(|| { format!( @@ -179,13 +179,13 @@ mod tests { let original_block = create_dummy_block(height, Round::new(0), Round::Nil); mock_repo - .expect_get_first() + .expect_get_by_hash() .with(eq(height), eq(block_hash)) .times(1) .returning(move |_, _| Ok(Some(original_block.clone()))); mock_repo - .expect_store() + .expect_store_undecided_block() .withf(move |b| b.round == round && b.valid_round == valid_round) .times(1) .returning(|_| Ok(())); @@ -208,7 +208,7 @@ mod tests { let block_hash = BlockHash::default(); mock_repo - .expect_get_first() + .expect_get_by_hash() .with(eq(height), eq(block_hash)) .times(1) .returning(|_, _| Ok(None)); @@ -227,7 +227,7 @@ mod tests { let valid_round = Round::Nil; mock_repo - .expect_get_first() + .expect_get_by_hash() .returning(|_, _| Err(std::io::Error::other("DB connection failed"))); let result = diff --git a/crates/malachite-app/src/handlers/started_round.rs b/crates/malachite-app/src/handlers/started_round.rs index ec9ae37..9039267 100644 --- a/crates/malachite-app/src/handlers/started_round.rs +++ b/crates/malachite-app/src/handlers/started_round.rs @@ -28,11 +28,12 @@ use arc_signer::ArcSigningProvider; use crate::block::ConsensusBlock; use crate::metrics::AppMetrics; -use crate::payload::{validate_consensus_block, EnginePayloadValidator}; +use crate::payload::{validate_consensus_block, EnginePayloadValidator, PayloadValidator}; use crate::proposal_parts::{ assemble_block_from_parts, resolve_expected_proposer, validate_proposal_parts, }; use crate::state::State; +use crate::store::repositories::{InvalidPayloadsRepository, UndecidedBlocksRepository}; use crate::store::Store; use arc_consensus_db::invalid_payloads::InvalidPayload; @@ -143,9 +144,15 @@ async fn fetch_and_process_pending_proposals( .await .wrap_err("Failed to validate pending proposal parts")?; - let blocks = validate_undecided_blocks(height, round, store, engine, metrics) - .await - .wrap_err("failed to validate undecided blocks")?; + let blocks = validate_undecided_blocks( + height, + round, + store, + &EnginePayloadValidator::new(engine, metrics), + store, + ) + .await + .wrap_err("failed to validate undecided blocks")?; Ok(blocks.iter().map(ProposedValue::from).collect()) } @@ -222,12 +229,12 @@ async fn process_pending_proposal_parts( async fn validate_undecided_blocks( height: Height, round: Round, - store: &Store, - engine: &Engine, - metrics: &AppMetrics, + undecided_blocks: &impl UndecidedBlocksRepository, + payload_validator: &impl PayloadValidator, + invalid_payloads: &impl InvalidPayloadsRepository, ) -> eyre::Result> { - let undecided_blocks = store - .get_undecided_blocks(height, round) + let blocks = undecided_blocks + .get_by_round(height, round) .await .wrap_err_with(|| { format!( @@ -237,21 +244,21 @@ async fn validate_undecided_blocks( })?; // Holds all blocks that were validated (either valid or invalid) - let mut validated_blocks = Vec::with_capacity(undecided_blocks.len()); - let validator = EnginePayloadValidator::new(engine, metrics); + let mut validated_blocks = Vec::with_capacity(blocks.len()); - for mut block in undecided_blocks { + for mut block in blocks { let block_hash = block.block_hash(); info!(%height, %round, %block_hash, "Validating undecided block"); - let validity = match validate_consensus_block(&validator, &block, store).await { - Ok(validity) => validity, - Err(e) => { - error!(%height, %round, %block_hash, "Failed to validate undecided block: {e}"); - continue; - } - }; + let validity = + match validate_consensus_block(payload_validator, &block, invalid_payloads).await { + Ok(validity) => validity, + Err(e) => { + error!(%height, %round, %block_hash, "Failed to validate undecided block: {e}"); + continue; + } + }; // Update the block validity block.validity = validity; @@ -289,3 +296,186 @@ async fn remove_pending_parts_and_store_undecided_block( ) }) } + +#[cfg(test)] +mod tests { + use super::*; + + use crate::payload::{MockPayloadValidator, PayloadValidationResult}; + use crate::store::repositories::mocks::{ + MockInvalidPayloadsRepository, MockUndecidedBlocksRepository, + }; + + use alloy_rpc_types_engine::ExecutionPayloadV3; + use arbitrary::{Arbitrary, Unstructured}; + use malachitebft_core_types::Validity; + + fn create_dummy_block(height: Height, round: Round, seed: u8) -> ConsensusBlock { + let bytes = [seed; 1024]; + let mut u = Unstructured::new(&bytes); + + ConsensusBlock { + height, + round, + valid_round: Round::Nil, + proposer: Address::arbitrary(&mut u).unwrap(), + validity: Validity::Valid, + execution_payload: ExecutionPayloadV3::arbitrary(&mut u).unwrap(), + signature: None, + } + } + + #[tokio::test] + async fn validate_undecided_blocks_all_valid() { + let height = Height::new(1); + let round = Round::new(0); + + let block1 = create_dummy_block(height, round, 0x11); + let block2 = create_dummy_block(height, round, 0x22); + let blocks = vec![block1, block2]; + + let mut undecided = MockUndecidedBlocksRepository::new(); + undecided + .expect_get_by_round() + .returning(move |_, _| Ok(blocks.clone())); + + let mut validator = MockPayloadValidator::new(); + validator + .expect_validate_payload() + .times(2) + .returning(|_| Ok(PayloadValidationResult::Valid)); + + let mut invalid = MockInvalidPayloadsRepository::new(); + invalid.expect_append().times(0); + + let result = validate_undecided_blocks(height, round, &undecided, &validator, &invalid) + .await + .expect("should succeed"); + + assert_eq!(result.len(), 2); + assert!(result.iter().all(|b| b.validity == Validity::Valid)); + } + + #[tokio::test] + async fn validate_undecided_blocks_mixed_validity() { + let height = Height::new(1); + let round = Round::new(0); + + let block1 = create_dummy_block(height, round, 0x11); + let block2 = create_dummy_block(height, round, 0x22); + let blocks = vec![block1, block2]; + + let mut undecided = MockUndecidedBlocksRepository::new(); + undecided + .expect_get_by_round() + .returning(move |_, _| Ok(blocks.clone())); + + let mut call_count = 0usize; + let mut validator = MockPayloadValidator::new(); + validator + .expect_validate_payload() + .times(2) + .returning(move |_| { + call_count += 1; + if call_count == 1 { + Ok(PayloadValidationResult::Valid) + } else { + Ok(PayloadValidationResult::Invalid { + reason: "bad block".into(), + }) + } + }); + + let mut invalid = MockInvalidPayloadsRepository::new(); + invalid.expect_append().times(1).returning(|_| Ok(())); + + let result = validate_undecided_blocks(height, round, &undecided, &validator, &invalid) + .await + .expect("should succeed"); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].validity, Validity::Valid); + assert_eq!(result[1].validity, Validity::Invalid); + } + + #[tokio::test] + async fn validate_undecided_blocks_empty() { + let height = Height::new(1); + let round = Round::new(0); + + let mut undecided = MockUndecidedBlocksRepository::new(); + undecided.expect_get_by_round().returning(|_, _| Ok(vec![])); + + let validator = MockPayloadValidator::new(); + let invalid = MockInvalidPayloadsRepository::new(); + + let result = validate_undecided_blocks(height, round, &undecided, &validator, &invalid) + .await + .expect("should succeed"); + + assert!(result.is_empty()); + } + + #[tokio::test] + async fn validate_undecided_blocks_repository_error() { + let height = Height::new(1); + let round = Round::new(0); + + let mut undecided = MockUndecidedBlocksRepository::new(); + undecided + .expect_get_by_round() + .returning(|_, _| Err(std::io::Error::other("DB connection failed"))); + + let validator = MockPayloadValidator::new(); + let invalid = MockInvalidPayloadsRepository::new(); + + let err = validate_undecided_blocks(height, round, &undecided, &validator, &invalid) + .await + .expect_err("should propagate repository error"); + + assert!( + err.to_string().contains("Failed to fetch undecided blocks"), + "error should describe the failure, got: {err}", + ); + } + + #[tokio::test] + async fn validate_undecided_blocks_validation_error_skips_block() { + let height = Height::new(1); + let round = Round::new(0); + + let block1 = create_dummy_block(height, round, 0x11); + let block2 = create_dummy_block(height, round, 0x22); + let blocks = vec![block1, block2]; + + let mut undecided = MockUndecidedBlocksRepository::new(); + undecided + .expect_get_by_round() + .returning(move |_, _| Ok(blocks.clone())); + + let mut call_count = 0usize; + let mut validator = MockPayloadValidator::new(); + validator + .expect_validate_payload() + .times(2) + .returning(move |_| { + call_count += 1; + if call_count == 1 { + Err(eyre::eyre!("engine down")) + } else { + Ok(PayloadValidationResult::Valid) + } + }); + + let mut invalid = MockInvalidPayloadsRepository::new(); + invalid.expect_append().times(0); + + let result = validate_undecided_blocks(height, round, &undecided, &validator, &invalid) + .await + .expect("should succeed despite one block erroring"); + + // First block errored and was skipped, only second block returned + assert_eq!(result.len(), 1); + assert_eq!(result[0].validity, Validity::Valid); + } +} diff --git a/crates/malachite-app/src/main.rs b/crates/malachite-app/src/main.rs index ae37336..cd1820c 100644 --- a/crates/malachite-app/src/main.rs +++ b/crates/malachite-app/src/main.rs @@ -34,8 +34,12 @@ use arc_node_consensus::store::migrations::MigrationCoordinator; use arc_node_consensus_cli::{ args::{Args, Commands}, cmd::{ - db::DbCommands, db::MigrateCmd, download::DownloadCmd, init::InitCmd, key::KeyCmd, - start::StartCmd, + db::DbCommands, + db::MigrateCmd, + download::DownloadCmd, + init::InitCmd, + key::KeyCmd, + start::{StartCmd, RUNTIME_MULTI_THREADED, RUNTIME_SINGLE_THREADED}, }, config, logging, runtime, }; @@ -48,7 +52,7 @@ static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; #[cfg(feature = "pprof")] #[allow(non_upper_case_globals)] #[unsafe(export_name = "malloc_conf")] -pub static malloc_conf: &[u8] = b"prof:true,prof_active:true,lg_prof_sample:19\0"; +pub static malloc_conf: &[u8] = b"prof:true,prof_active:false,lg_prof_sample:19\0"; /// Main entry point for the application /// @@ -64,9 +68,14 @@ fn main() -> Result<()> { // Load command-line arguments and possible configuration file. let args = Args::new(); + // StartCmd log_level/log_format take precedence over Args-level (for backwards compat). + let (start_cmd_log_level, start_cmd_log_format) = match &args.command { + Commands::Start(cmd) => (cmd.log_level, cmd.log_format), + _ => (None, None), + }; let logging = config::LoggingConfig { - log_level: args.log_level, - log_format: args.log_format, + log_level: start_cmd_log_level.unwrap_or(args.log_level), + log_format: start_cmd_log_format.unwrap_or(args.log_format), }; // This is a drop guard responsible for flushing any remaining logs when the program terminates. @@ -144,12 +153,14 @@ fn build_config_from_cli(cmd: &StartCmd, logging: config::LoggingConfig) -> Resu }; let runtime = match cmd.runtime_flavor.as_str() { - "single-threaded" => RuntimeConfig::single_threaded(), - "multi-threaded" => RuntimeConfig::multi_threaded(cmd.worker_threads.unwrap_or(0)), + RUNTIME_SINGLE_THREADED => RuntimeConfig::single_threaded(), + RUNTIME_MULTI_THREADED => RuntimeConfig::multi_threaded(cmd.worker_threads.unwrap_or(0)), _ => { return Err(eyre!( - "Invalid runtime flavor: {}. Must be 'single-threaded' or 'multi-threaded'", - cmd.runtime_flavor + "Invalid runtime flavor: {}. Must be '{}' or '{}'", + cmd.runtime_flavor, + RUNTIME_SINGLE_THREADED, + RUNTIME_MULTI_THREADED, )); } }; @@ -228,6 +239,7 @@ fn start(args: &Args, cmd: &StartCmd, logging: config::LoggingConfig) -> Result< execution_ws_endpoint: cmd.execution_ws_endpoint.clone(), execution_jwt: cmd.execution_jwt.clone(), pprof_bind_address: Some(cmd.pprof_addr.parse()?), + pprof_heap_prof: cmd.pprof_heap_prof, suggested_fee_recipient: cmd.suggested_fee_recipient, skip_db_upgrade: cmd.skip_db_upgrade, validator: cmd.validator, @@ -627,7 +639,7 @@ mod tests { #[test] fn build_config_from_cli_multi_threaded_runtime_with_threads() { let mut cmd = minimal_start_cmd(); - cmd.runtime_flavor = "multi-threaded".to_string(); + cmd.runtime_flavor = RUNTIME_MULTI_THREADED.to_string(); cmd.worker_threads = Some(8); let logging = test_logging_config(); @@ -640,7 +652,7 @@ mod tests { #[test] fn build_config_from_cli_single_threaded_runtime() { let mut cmd = minimal_start_cmd(); - cmd.runtime_flavor = "single-threaded".to_string(); + cmd.runtime_flavor = RUNTIME_SINGLE_THREADED.to_string(); let logging = test_logging_config(); let config = build_config_from_cli(&cmd, logging).unwrap(); diff --git a/crates/malachite-app/src/node.rs b/crates/malachite-app/src/node.rs index 5b8bf9f..e1bd982 100644 --- a/crates/malachite-app/src/node.rs +++ b/crates/malachite-app/src/node.rs @@ -725,7 +725,7 @@ impl App { // Start the pprof server if enabled if let Some(pprof_bind_address) = self.start_config.pprof_bind_address { - spawn_pprof_server(pprof_bind_address); + spawn_pprof_server(pprof_bind_address, self.start_config.pprof_heap_prof); } Ok(Handle { @@ -867,7 +867,16 @@ async fn wait_for_termination() { } #[cfg(feature = "pprof")] -fn spawn_pprof_server(bind_address: std::net::SocketAddr) { +fn spawn_pprof_server(bind_address: std::net::SocketAddr, heap_prof: bool) { + if heap_prof { + // SAFETY: writing a bool to a well-known jemalloc mallctl key. + if let Err(e) = unsafe { tikv_jemalloc_ctl::raw::write(b"prof.active\0", true) } { + tracing::error!(error = %e, "failed to activate jemalloc heap profiling; /debug/pprof/allocs will return empty profiles"); + } else { + tracing::info!("jemalloc heap profiling activated"); + } + } + tokio::spawn(async move { if let Err(e) = pprof_hyper_server::serve(bind_address, pprof_hyper_server::Config::default()).await @@ -878,7 +887,7 @@ fn spawn_pprof_server(bind_address: std::net::SocketAddr) { } #[cfg(not(feature = "pprof"))] -fn spawn_pprof_server(_bind_address: std::net::SocketAddr) {} +fn spawn_pprof_server(_bind_address: std::net::SocketAddr, _heap_prof: bool) {} #[cfg(test)] mod tests { diff --git a/crates/malachite-app/src/state.rs b/crates/malachite-app/src/state.rs index 7f14a06..087e81e 100644 --- a/crates/malachite-app/src/state.rs +++ b/crates/malachite-app/src/state.rs @@ -42,6 +42,7 @@ use crate::node::ConsensusIdentity; use crate::request::Status; use crate::spec::{ChainId, ConsensusSpec, NetworkId}; use crate::stats::Stats; +use crate::store::repositories::UndecidedBlocksRepository; use crate::store::Store; use crate::streaming::PartStreamsMap; use crate::utils::sync_state::SyncState; @@ -446,11 +447,12 @@ impl State { height: Height, round: Round, ) -> eyre::Result> { - self - .store - .get_undecided_blocks(height, round) + self.store + .get_by_round(height, round) .await - .wrap_err_with(||format!("Failed to get undecided blocks for height {height} and round {round} from the database")) + .wrap_err_with(|| { + format!("Failed to get undecided blocks for height {height} and round {round} from the database") + }) } /// Move to the next height, updating the previous block, validator set, and consensus params. diff --git a/crates/malachite-cli/src/cmd/start.rs b/crates/malachite-cli/src/cmd/start.rs index b4ca376..38e134b 100644 --- a/crates/malachite-cli/src/cmd/start.rs +++ b/crates/malachite-cli/src/cmd/start.rs @@ -20,6 +20,7 @@ use std::path::PathBuf; use arc_consensus_types::rpc_sync::SyncEndpointUrl; use clap::Parser; use color_eyre::eyre; +use serde::{Deserialize, Serialize}; use tracing::info; use url::Url; @@ -29,14 +30,28 @@ use malachitebft_app::consensus::Multiaddr; use crate::file::save_priv_validator_key; use crate::new::generate_private_keys; +/// Tokio single-threaded runtime flavor. +pub const RUNTIME_SINGLE_THREADED: &str = "single-threaded"; + +/// Tokio multi-threaded runtime flavor. +pub const RUNTIME_MULTI_THREADED: &str = "multi-threaded"; + /// Start command to run a node. -#[derive(Parser, Debug, Clone, PartialEq)] +/// +/// Derives `clap::Parser` for CLI parsing and `serde::Serialize` / +/// `serde::Deserialize` for TOML-based deserialization by external +/// tooling. Deployment-specific fields are marked `#[serde(skip)]`, so +/// TOML cannot set them. They inherit their values from +/// `StartCmd::default()` via the container-level `#[serde(default)]`. +#[derive(Parser, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] pub struct StartCmd { // ===== Node Identity ===== /// A custom human-readable name for this node. /// /// If not provided, a random moniker will be generated. #[clap(long, value_name = "NAME")] + #[serde(skip)] pub moniker: Option, // ===== P2P Networking ===== @@ -48,12 +63,14 @@ pub struct StartCmd { value_name = "MULTIADDR", default_value = "/ip4/0.0.0.0/tcp/27000" )] + #[serde(skip)] pub p2p_addr: Multiaddr, /// Comma-separated list of persistent peer multiaddrs to connect to /// /// Example: /ip4/172.19.0.21/tcp/27000,/ip4/172.19.0.22/tcp/27000 #[clap(long = "p2p.persistent-peers", value_delimiter = ',', num_args = 0..)] + #[serde(skip)] pub p2p_persistent_peers: Vec, /// Only allow connections to/from persistent peers. @@ -62,6 +79,7 @@ pub struct StartCmd { /// in the persistent peers list. Useful for sentry node setups where /// a validator should only communicate with known trusted peers. #[clap(long = "p2p.persistent-peers-only")] + #[serde(skip)] pub p2p_persistent_peers_only: bool, /// Enable gossipsub explicit peering for persistent peers. @@ -70,6 +88,7 @@ pub struct StartCmd { /// meaning a node always sends and forwards messages to its explicit peers, /// regardless of mesh membership. #[clap(long = "gossipsub.explicit-peering", help_heading = "GossipSub")] + #[serde(skip)] pub gossipsub_explicit_peering: bool, /// Enable gossipsub mesh peer scoring / prioritization. @@ -77,6 +96,7 @@ pub struct StartCmd { /// When enabled, peers are scored and prioritized based on their type /// during mesh formation. #[clap(long = "gossipsub.mesh-prioritization", help_heading = "GossipSub")] + #[serde(skip)] pub gossipsub_mesh_prioritization: bool, /// Gossipsub network load profile controlling mesh size and bandwidth. @@ -90,8 +110,18 @@ pub struct StartCmd { help_heading = "GossipSub", value_parser = ["low", "average", "high"] )] + #[serde(skip)] pub gossipsub_load: Option, + // ===== Logging ===== + /// Log level + #[clap(long, value_name = "LOG_LEVEL")] + pub log_level: Option, + + /// Log format + #[clap(long, value_name = "LOG_FORMAT")] + pub log_format: Option, + // ===== Discovery ===== /// Enable peer discovery #[clap(long)] @@ -126,16 +156,27 @@ pub struct StartCmd { /// /// When set, the node loads its consensus signing key, /// signs a validator proof (ADR-006), and advertises a - /// validator identity on the P2P network. + /// validator identity on the P2P network. Requires + /// `--suggested-fee-recipient` so tips and rewards go to + /// an explicit address rather than being silently burned. /// /// Without this flag the node runs as a full node: it /// participates in gossip but does not sign votes or proposals. - #[clap(long, conflicts_with_all = ["no_consensus", "follow"])] + #[clap( + long, + conflicts_with_all = ["no_consensus", "follow"], + requires = "suggested_fee_recipient", + )] pub validator: bool, // ===== Value Sync ===== /// Enable value sync - #[clap(long, default_value = "true")] + #[clap( + long, + default_value_t = true, + num_args = 0..=1, + default_missing_value = "true", + )] pub value_sync: bool, // ===== Execution Layer Connection ===== @@ -145,6 +186,7 @@ pub struct StartCmd { /// /// This is recommended option if the consensus and execution layers are colocated on the same machine. #[clap(long, value_name = "PATH")] + #[serde(skip)] pub eth_socket: Option, /// The path to the execution engine socket. To enable this in reth, you @@ -152,6 +194,7 @@ pub struct StartCmd { /// /// This is recommended option if the consensus and execution layers are colocated on the same machine. #[clap(long, value_name = "PATH")] + #[serde(skip)] pub execution_socket: Option, /// The URL of the Ethereum JSON-RPC API. If the Ethereum full node is @@ -161,6 +204,7 @@ pub struct StartCmd { /// /// Use this option if the consensus and executation layer are on different machines. #[clap(long, value_name = "URL")] + #[serde(skip)] pub eth_rpc_endpoint: Option, /// The URL of the execution engine API. If the execution engine is running @@ -169,6 +213,7 @@ pub struct StartCmd { /// /// Use this option if the consensus and executation layer are on different machines. #[clap(long, value_name = "URL")] + #[serde(skip)] pub execution_endpoint: Option, /// The WebSocket URL of the execution engine. Used for subscribing to @@ -179,6 +224,7 @@ pub struct StartCmd { /// /// Example: ws://localhost:8546 #[clap(long, value_name = "URL")] + #[serde(skip)] pub execution_ws_endpoint: Option, /// Enable persistence backpressure during startup replay. When enabled, @@ -208,6 +254,7 @@ pub struct StartCmd { /// /// Use this option if the consensus and executation layer are on different machines. #[clap(long, value_name = "PATH")] + #[serde(skip)] pub execution_jwt: Option, // ===== Metrics ===== @@ -218,6 +265,7 @@ pub struct StartCmd { /// /// Example: 0.0.0.0:29000 #[clap(long, value_name = "ADDR")] + #[serde(skip)] pub metrics: Option, // ===== RPC ===== @@ -228,6 +276,7 @@ pub struct StartCmd { /// /// Example: 0.0.0.0:31000 #[clap(long = "rpc.addr", value_name = "ADDR")] + #[serde(skip)] pub rpc_addr: Option, // ===== Runtime ===== @@ -235,8 +284,8 @@ pub struct StartCmd { #[clap( long = "runtime.flavor", value_name = "FLAVOR", - default_value = "multi-threaded", - value_parser = ["single-threaded", "multi-threaded"] + default_value = RUNTIME_MULTI_THREADED, + value_parser = [RUNTIME_SINGLE_THREADED, RUNTIME_MULTI_THREADED] )] pub runtime_flavor: String, @@ -258,6 +307,7 @@ pub struct StartCmd { conflicts_with_all = &["minimal", "prune_certificates_distance", "prune_certificates_before"], help_heading = "Arc pruning presets" )] + #[serde(skip)] pub full: bool, /// Minimal-storage pruning preset. Sets --prune.certificates.distance 237600. @@ -270,6 +320,7 @@ pub struct StartCmd { conflicts_with_all = &["full", "prune_certificates_distance", "prune_certificates_before"], help_heading = "Arc pruning presets" )] + #[serde(skip)] pub minimal: bool, // ===== Pruning ===== @@ -311,6 +362,7 @@ pub struct StartCmd { /// Default: {home_dir}/config/priv_validator_key.json /// where `home_dir` is the directory provided with the `--home` global option #[clap(long, value_name = "PATH")] + #[serde(skip)] pub private_key: Option, /// Profiling server bind address @@ -319,15 +371,26 @@ pub struct StartCmd { value_name = "ADDR", default_value = "0.0.0.0:6060" )] + #[serde(skip)] pub pprof_addr: String, + /// Activate jemalloc heap profiling at startup. + /// + /// When built with the `pprof` feature, heap profiling infrastructure is + /// always available but inactive by default. This flag activates it so + /// that the `/debug/pprof/allocs` endpoint returns meaningful data. + #[clap(long = "pprof.heap-prof", default_value_t = false)] + pub pprof_heap_prof: bool, + /// 20-byte ethereum-style address to receive tips (transactions' priority fee) /// and rewards. /// /// The execution layer deposits fees and rewards to this address whenever the - /// validator successfully proposes a new block. Not setting it to a valid - /// address will result in losing the tips/rewards. - #[clap(long, value_name = "ADDRESS")] + /// validator successfully proposes a new block. The zero address is rejected + /// because rewards credited to it bypass the native-coin blocklist and are + /// unrecoverable. + #[clap(long, value_name = "ADDRESS", value_parser = parse_non_zero_address)] + #[serde(skip)] pub suggested_fee_recipient: Option
, /// Skip database schema upgrade on startup. @@ -335,6 +398,7 @@ pub struct StartCmd { /// WARNING: This flag should only be used when a database upgrade failed. /// Not upgrading the database may lead to errors or data corruption. #[clap(long = "db.skip-upgrade")] + #[serde(skip)] pub skip_db_upgrade: bool, // ===== Signing ===== @@ -351,6 +415,7 @@ pub struct StartCmd { value_name = "ENDPOINT", requires = "validator" )] + #[serde(skip)] pub signing_remote: Option, /// Path to TLS certificate for remote signing @@ -362,6 +427,7 @@ pub struct StartCmd { value_name = "PATH", requires = "signing_remote" )] + #[serde(skip)] pub signing_tls_cert_path: Option, /// Enable RPC sync mode (follow with verification). @@ -373,6 +439,7 @@ pub struct StartCmd { /// When no --follow.endpoint is provided, a default endpoint is resolved /// from the chain ID at startup. #[clap(long = "follow")] + #[serde(skip)] pub follow: bool, /// RPC endpoint to fetch blocks from in RPC sync mode. @@ -398,9 +465,22 @@ pub struct StartCmd { /// https://example.com,wss=ws.example.com /// https://example.com,wss=ws.example.com:1212 #[clap(long = "follow.endpoint", value_name = "ENDPOINT", requires = "follow")] + #[serde(skip)] pub follow_endpoints: Vec, } +fn parse_non_zero_address(s: &str) -> Result { + let addr: Address = s.parse().map_err(|e| format!("invalid address: {e}"))?; + if addr.to_alloy_address().is_zero() { + return Err( + "must not be the zero address; rewards credited to 0x0 bypass the \ + native-coin blocklist and are unrecoverable" + .to_string(), + ); + } + Ok(addr) +} + impl Default for StartCmd { fn default() -> Self { Self { @@ -411,6 +491,8 @@ impl Default for StartCmd { gossipsub_explicit_peering: false, gossipsub_mesh_prioritization: false, gossipsub_load: None, + log_level: None, + log_format: None, discovery: false, discovery_num_outbound_peers: 20, discovery_num_inbound_peers: 20, @@ -427,7 +509,7 @@ impl Default for StartCmd { execution_jwt: None, metrics: None, rpc_addr: None, - runtime_flavor: "multi-threaded".to_string(), + runtime_flavor: RUNTIME_MULTI_THREADED.to_string(), worker_threads: None, full: false, minimal: false, @@ -435,6 +517,7 @@ impl Default for StartCmd { prune_certificates_before: 0, private_key: None, pprof_addr: "0.0.0.0:6060".to_string(), + pprof_heap_prof: false, suggested_fee_recipient: None, skip_db_upgrade: false, signing_remote: None, @@ -446,6 +529,118 @@ impl Default for StartCmd { } impl StartCmd { + /// Generate CLI flag strings for all non-default, manifest-configurable fields. + /// + /// Each field maps 1:1 to its `#[clap(long = "...")]` name. Only fields + /// whose values differ from `StartCmd::default()` are emitted. Deployment- + /// specific fields (marked `#[serde(skip)]`) are included here because + /// callers populate these before invoking. + pub fn to_cli_flags(&self) -> Vec { + let defaults = Self::default(); + let mut flags = Vec::new(); + + macro_rules! push_each { + ($flag:literal, $items:expr) => { + for item in $items { + flags.push(format!(concat!("--", $flag, "={}"), item)); + } + }; + } + macro_rules! push_if_some { + ($flag:literal, $opt:expr) => { + if let Some(ref v) = $opt { + flags.push(format!(concat!("--", $flag, "={}"), v)); + } + }; + } + macro_rules! push_if { + ($flag:literal, $cond:expr) => { + if $cond { + flags.push(concat!("--", $flag).to_string()); + } + }; + } + macro_rules! push_if_non_default { + ($flag:literal, $field:ident) => { + if self.$field != defaults.$field { + flags.push(format!(concat!("--", $flag, "={}"), self.$field)); + } + }; + } + + // --- Deployment-specific fields (set by quake at runtime) --- + + push_if_some!("moniker", self.moniker); + push_if_non_default!("p2p.addr", p2p_addr); + if !self.p2p_persistent_peers.is_empty() { + let peers: Vec = self + .p2p_persistent_peers + .iter() + .map(|p| p.to_string()) + .collect(); + flags.push(format!("--p2p.persistent-peers={}", peers.join(","))); + } + push_if!("p2p.persistent-peers-only", self.p2p_persistent_peers_only); + push_if!( + "gossipsub.explicit-peering", + self.gossipsub_explicit_peering + ); + push_if!( + "gossipsub.mesh-prioritization", + self.gossipsub_mesh_prioritization + ); + push_if_some!("gossipsub.load", self.gossipsub_load); + + // --- Manifest-configurable fields --- + + push_if_some!("log-level", self.log_level); + push_if_some!("log-format", self.log_format); + push_if!("discovery", self.discovery); + push_if_non_default!("discovery.num-outbound-peers", discovery_num_outbound_peers); + push_if_non_default!("discovery.num-inbound-peers", discovery_num_inbound_peers); + push_if!("no-consensus", self.no_consensus); + push_if!("validator", self.validator); + push_if_non_default!("value-sync", value_sync); + push_if!( + "execution-persistence-backpressure", + self.execution_persistence_backpressure + ); + push_if_non_default!( + "execution-persistence-backpressure-threshold", + execution_persistence_backpressure_threshold + ); + push_if_non_default!("runtime.flavor", runtime_flavor); + push_if_some!("runtime.worker-threads", self.worker_threads); + push_if_non_default!("prune.certificates.distance", prune_certificates_distance); + push_if_non_default!("prune.certificates.before", prune_certificates_before); + + // --- Deployment-specific fields (set by quake, continued) --- + + push_if_some!("eth-socket", self.eth_socket); + push_if_some!("execution-socket", self.execution_socket); + push_if_some!("eth-rpc-endpoint", self.eth_rpc_endpoint); + push_if_some!("execution-endpoint", self.execution_endpoint); + push_if_some!("execution-ws-endpoint", self.execution_ws_endpoint); + push_if_some!("execution-jwt", self.execution_jwt); + push_if_some!("metrics", self.metrics); + push_if_some!("rpc.addr", self.rpc_addr); + push_if!("full", self.full); + push_if!("minimal", self.minimal); + if let Some(ref path) = self.private_key { + flags.push(format!("--private-key={}", path.display())); + } + push_if_non_default!("pprof.addr", pprof_addr); + push_if!("pprof.heap-prof", self.pprof_heap_prof); + push_if_some!("suggested-fee-recipient", self.suggested_fee_recipient); + push_if!("db.skip-upgrade", self.skip_db_upgrade); + push_if_some!("signing.remote", self.signing_remote); + push_if_some!("signing.tls-cert-path", self.signing_tls_cert_path); + push_if!("follow", self.follow); + push_each!("follow.endpoint", &self.follow_endpoints); + + flags + } + /// Validates that conflicting options are not provided simultaneously. /// /// This method ensures that users don't specify both IPC and RPC options @@ -561,6 +756,8 @@ mod tests { use std::fs::File; use tempfile::tempdir; + const TEST_FEE_RECIPIENT: &str = "0xf97e180c050e5ab072211ad2c213eb5aee4df134"; + fn new_start_cmd() -> StartCmd { StartCmd { moniker: Some("test-node".to_string()), @@ -808,6 +1005,7 @@ mod tests { ]; let cmd = StartCmd::try_parse_from(args).unwrap(); assert_eq!(cmd.pprof_addr, "0.0.0.0:6060"); + assert!(!cmd.pprof_heap_prof); assert_eq!(cmd.prune_certificates_distance, 0); assert_eq!(cmd.prune_certificates_before, 0); assert_eq!(cmd.discovery_num_outbound_peers, 20); @@ -817,6 +1015,20 @@ mod tests { assert!(!cmd.validator); } + #[test] + fn pprof_heap_prof_when_set() { + let args = vec![ + "arc-node-consensus", + "--moniker", + "test", + "--p2p.addr", + "/ip4/127.0.0.1/tcp/27000", + "--pprof.heap-prof", + ]; + let cmd = StartCmd::try_parse_from(args).unwrap(); + assert!(cmd.pprof_heap_prof); + } + #[test] fn p2p_listen_addr_returns_multiaddr() { let mut cmd = new_start_cmd(); @@ -887,6 +1099,8 @@ mod tests { "--p2p.addr", "/ip4/127.0.0.1/tcp/27000", "--validator", + "--suggested-fee-recipient", + TEST_FEE_RECIPIENT, "--signing.remote", "http://signer:10340", ]; @@ -904,6 +1118,8 @@ mod tests { "--p2p.addr", "/ip4/127.0.0.1/tcp/27000", "--validator", + "--suggested-fee-recipient", + TEST_FEE_RECIPIENT, "--signing.remote", "http://signer:10340", "--signing.tls-cert-path", @@ -1326,6 +1542,72 @@ mod tests { assert_eq!(cmd.gossipsub_load.as_deref(), Some("high")); } + // to_cli_flags tests + #[test] + fn to_cli_flags_empty_for_defaults() { + assert!(StartCmd::default().to_cli_flags().is_empty()); + } + + #[test] + fn to_cli_flags_round_trip_preserves_all_fields() { + let original = StartCmd { + moniker: Some("validator-01".to_string()), + p2p_addr: "/ip4/10.0.0.1/tcp/27001".parse().unwrap(), + p2p_persistent_peers: vec![ + "/ip4/10.0.0.2/tcp/27000".parse().unwrap(), + "/ip4/10.0.0.3/tcp/27000".parse().unwrap(), + ], + p2p_persistent_peers_only: true, + gossipsub_explicit_peering: true, + gossipsub_mesh_prioritization: true, + gossipsub_load: Some("high".to_string()), + log_level: None, + log_format: None, + discovery: true, + discovery_num_outbound_peers: 30, + discovery_num_inbound_peers: 40, + no_consensus: true, + validator: false, + value_sync: false, + eth_socket: Some("/tmp/reth.ipc".to_string()), + execution_socket: Some("/tmp/reth-auth.ipc".to_string()), + eth_rpc_endpoint: None, + execution_endpoint: None, + execution_ws_endpoint: None, + execution_persistence_backpressure: true, + execution_persistence_backpressure_threshold: 32, + execution_jwt: None, + metrics: Some("127.0.0.1:9000".parse().unwrap()), + rpc_addr: Some("127.0.0.1:31000".parse().unwrap()), + runtime_flavor: RUNTIME_SINGLE_THREADED.to_string(), + worker_threads: Some(8), + full: false, + minimal: false, + prune_certificates_distance: 1000, + prune_certificates_before: 0, + private_key: Some(PathBuf::from("/etc/arc/key.json")), + pprof_addr: "127.0.0.1:7070".to_string(), + pprof_heap_prof: true, + suggested_fee_recipient: Some( + "0x0000000000000000000000000000000000000042" + .parse() + .unwrap(), + ), + skip_db_upgrade: true, + signing_remote: Some("http://signer:10340".to_string()), + signing_tls_cert_path: Some("/etc/arc/signer.pem".to_string()), + follow: true, + follow_endpoints: vec!["http://rpc-1:8545,ws=8546".parse().unwrap()], + }; + + let args = std::iter::once("arc-node-consensus".to_string()) + .chain(original.to_cli_flags()) + .collect::>(); + let parsed = StartCmd::try_parse_from(args).expect("emitted flags should parse"); + + assert_eq!(parsed, original); + } + // Validator flag tests #[test] fn validator_defaults_to_false() { @@ -1342,11 +1624,49 @@ mod tests { "--p2p.addr", "/ip4/127.0.0.1/tcp/27000", "--validator", + "--suggested-fee-recipient", + TEST_FEE_RECIPIENT, ]; let cmd = StartCmd::try_parse_from(args).unwrap(); assert!(cmd.validator); } + #[test] + fn validator_requires_suggested_fee_recipient() { + let args = vec![ + "arc-node-consensus", + "--moniker", + "test", + "--p2p.addr", + "/ip4/127.0.0.1/tcp/27000", + "--validator", + ]; + let err = StartCmd::try_parse_from(args).unwrap_err().to_string(); + assert!( + err.contains("--suggested-fee-recipient"), + "expected error to mention --suggested-fee-recipient, got: {err}" + ); + } + + #[test] + fn suggested_fee_recipient_rejects_zero_address() { + let args = vec![ + "arc-node-consensus", + "--moniker", + "test", + "--p2p.addr", + "/ip4/127.0.0.1/tcp/27000", + "--validator", + "--suggested-fee-recipient", + "0x0000000000000000000000000000000000000000", + ]; + let err = StartCmd::try_parse_from(args).unwrap_err().to_string(); + assert!( + err.contains("zero address"), + "expected zero-address rejection, got: {err}" + ); + } + #[test] fn validator_conflicts_with_no_consensus() { let args = vec![ @@ -1356,6 +1676,8 @@ mod tests { "--p2p.addr", "/ip4/127.0.0.1/tcp/27000", "--validator", + "--suggested-fee-recipient", + TEST_FEE_RECIPIENT, "--no-consensus", ]; assert!(StartCmd::try_parse_from(args).is_err()); @@ -1370,6 +1692,8 @@ mod tests { "--p2p.addr", "/ip4/127.0.0.1/tcp/27000", "--validator", + "--suggested-fee-recipient", + TEST_FEE_RECIPIENT, "--follow", "--follow.endpoint", "http://localhost:8545", diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index f95bbd6..39170c1 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -14,7 +14,7 @@ path = "src/main.rs" [features] default = [] js-tracer = ["reth-node-builder/js-tracer", "reth-ethereum/js-tracer"] -pprof = ["dep:pprof_hyper_server", "dep:reth-node-metrics"] +pprof = ["dep:pprof_hyper_server", "dep:reth-node-metrics", "dep:tikv-jemalloc-ctl"] arbitrary = [ "alloy-primitives/arbitrary", "arc-execution-config/arbitrary", @@ -62,6 +62,7 @@ revm.workspace = true revm-inspectors.workspace = true revm-primitives.workspace = true serde_json = { workspace = true, default-features = false, features = ["alloc"] } +tikv-jemalloc-ctl = { version = "0.6", optional = true } tokio = { workspace = true, features = ["signal"] } tracing.workspace = true diff --git a/crates/node/src/main.rs b/crates/node/src/main.rs index cb67b9d..44aae3f 100644 --- a/crates/node/src/main.rs +++ b/crates/node/src/main.rs @@ -27,7 +27,7 @@ static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; #[cfg(feature = "pprof")] #[allow(non_upper_case_globals)] #[unsafe(export_name = "malloc_conf")] -pub static malloc_conf: &[u8] = b"prof:true,prof_active:true,lg_prof_sample:19\0"; +pub static malloc_conf: &[u8] = b"prof:true,prof_active:false,lg_prof_sample:19\0"; use arc_evm_node::node::{ArcNode, ArcRpcConfig}; use arc_execution_config::addresses_denylist::{ @@ -260,6 +260,18 @@ struct ArcExtraCli { help_heading = "Profiling" )] pprof_addr: String, + + /// Activate jemalloc heap profiling at startup. + /// + /// When built with the `pprof` feature, heap profiling infrastructure is + /// always available but inactive by default. This flag activates it so + /// that the `/debug/pprof/allocs` endpoint returns meaningful data. + #[arg( + long = "pprof.heap-prof", + default_value_t = false, + help_heading = "Profiling" + )] + pprof_heap_prof: bool, } /// Build [`AddressesDenylistConfig`] from CLI flags. @@ -463,7 +475,7 @@ fn main() { .launch_with_debug_capabilities() .await?; - spawn_pprof_server(ext.pprof_addr.parse()?); + spawn_pprof_server(ext.pprof_addr.parse()?, ext.pprof_heap_prof); #[cfg(unix)] install_sigterm_handler(handle.node.add_ons_handle.engine_shutdown.clone()); @@ -541,7 +553,16 @@ fn install_sigterm_handler(engine_shutdown: reth_node_builder::rpc::EngineShutdo fn install_sigterm_handler(_engine_shutdown: reth_node_builder::rpc::EngineShutdown) {} #[cfg(feature = "pprof")] -fn spawn_pprof_server(bind_address: std::net::SocketAddr) { +fn spawn_pprof_server(bind_address: std::net::SocketAddr, heap_prof: bool) { + if heap_prof { + // SAFETY: writing a bool to a well-known jemalloc mallctl key. + if let Err(e) = unsafe { tikv_jemalloc_ctl::raw::write(b"prof.active\0", true) } { + tracing::error!(error = %e, "failed to activate jemalloc heap profiling; /debug/pprof/allocs will return empty profiles"); + } else { + tracing::info!("jemalloc heap profiling activated"); + } + } + tokio::spawn(async move { if let Err(e) = pprof_hyper_server::serve(bind_address, pprof_hyper_server::Config::default()).await @@ -555,7 +576,7 @@ fn spawn_pprof_server(bind_address: std::net::SocketAddr) { } #[cfg(not(feature = "pprof"))] -fn spawn_pprof_server(_bind_address: std::net::SocketAddr) {} +fn spawn_pprof_server(_bind_address: std::net::SocketAddr, _heap_prof: bool) {} #[cfg(test)] mod tests { @@ -950,6 +971,27 @@ mod tests { } } + #[test] + fn test_pprof_heap_prof_default_is_false() { + let cli = ArcCli::try_parse_from(["arc-node-execution", "node"]).unwrap(); + if let Commands::Node(node_cmd) = cli.inner.command { + assert!(!node_cmd.ext.pprof_heap_prof); + } else { + panic!("Expected Node command"); + } + } + + #[test] + fn test_pprof_heap_prof_when_set() { + let cli = + ArcCli::try_parse_from(["arc-node-execution", "node", "--pprof.heap-prof"]).unwrap(); + if let Commands::Node(node_cmd) = cli.inner.command { + assert!(node_cmd.ext.pprof_heap_prof); + } else { + panic!("Expected Node command"); + } + } + /// --full gets --prune.block-interval=5000 injected. #[test] fn test_full_preset_argv_translation() { diff --git a/crates/quake/README.md b/crates/quake/README.md index 202afc4..4cb41c5 100644 --- a/crates/quake/README.md +++ b/crates/quake/README.md @@ -133,16 +133,16 @@ modify the generated configuration files before starting the nodes. ./quake perturb pause val*_cl # Send 1000 transactions per second during 30 seconds to one validator node -./quake load -t 30 -r 1000 validator1 +./quake load -t 30 -r 1000 --targets validator1 # Mixed EIP-1559 and legacy transfer load (70/30 split) -./quake load -t 30 -r 1000 --mix transfer=70,legacy=30 validator1 +./quake load -t 30 -r 1000 --mix transfer=70,legacy=30 --targets validator1 # Mixed ERC-20 and native transfer load (70/30 split) -./quake load -t 30 -r 1000 --mix transfer=70,erc20=30 validator1 +./quake load -t 30 -r 1000 --mix transfer=70,erc20=30 --targets validator1 # ERC-20 with mixed functions: 60% transfer, 30% approve, 10% transferFrom -./quake load -t 30 -r 1000 --mix erc20=100 --erc20-fn-weights transfer=60,approve=30,transfer-from=10 validator1 +./quake load -t 30 -r 1000 --mix erc20=100 --erc20-fn-weights transfer=60,approve=30,transfer-from=10 --targets validator1 # Stop one node (both CL and EL containers) ./quake stop val*3 @@ -280,9 +280,11 @@ You can customize the timeout and number of retries for transient RPC failures: ``` > [!TIP] -> When a node name is required as parameter, we can use a wildcard '*'. For -> example, `val*_cl` will expand to the names of all consensus layer containers -> of validator nodes (`validator1_cl`, `validator2_cl`, etc.). +> When a command takes node or container names directly, we can use a +> wildcard `*`. For example, `val*_cl` expands to the names of all +> consensus-layer containers of validator nodes (`validator1_cl`, +> `validator2_cl`, etc.). This does not apply to `load` or `spam` +> `--targets`, which accept exact node names and manifest node groups. Apply a pause of 300ms to the consensus layer containers of all validators: ```bash @@ -293,17 +295,17 @@ of time. For more on perturbations, see below. Send 1000 transactions per second during 30 seconds to one validator node: ```bash -./quake load -t 30 -r 1000 validator1 +./quake load -t 30 -r 1000 --targets validator1 ``` Send a mixed workload (ERC-20 and native transfers) at 500 TPS: ```bash -./quake load -t 60 -r 500 --mix transfer=50,erc20=50 validator1 +./quake load -t 60 -r 500 --mix transfer=50,erc20=50 --targets validator1 ``` Send ERC-20 traffic with diverse function calls (approve, transferFrom alongside transfer): ```bash -./quake load -t 60 -r 500 --mix erc20=100 --erc20-fn-weights transfer=60,approve=30,transfer-from=10 validator1 +./quake load -t 60 -r 500 --mix erc20=100 --erc20-fn-weights transfer=60,approve=30,transfer-from=10 --targets validator1 ``` Stop the nodes @@ -357,22 +359,28 @@ while still using optimistic nonces; expect multiple `nonce too low` errors to b Both commands support blending transaction types with `--mix`: ```bash # 1000 TPS of native transfers for 30 seconds (backpressure) -./quake load -t 30 -r 1000 validator1 +./quake load -t 30 -r 1000 --targets validator1 # Same workload in fire-and-forget mode -./quake spam -t 30 -r 1000 validator1 +./quake spam -t 30 -r 1000 --targets validator1 # Mixed workload: 70% native transfers, 30% ERC-20 -./quake load -t 60 -r 500 --mix transfer=70,erc20=30 validator1 +./quake load -t 60 -r 500 --mix transfer=70,erc20=30 --targets validator1 # Gas-intensive workload with diverse guzzler functions ./quake load -t 60 -r 200 --mix guzzler=100 \ - --guzzler-fn-weights hash-loop=70@2000,storage-write=30@600 validator1 + --guzzler-fn-weights hash-loop=70@2000,storage-write=30@600 \ + --targets validator1 # Fire-and-forget at high throughput, targeting all nodes ./quake spam -t 120 -r 5000 ``` +`--targets` accepts a comma-separated list of explicit node names or manifest +node groups such as `ALL_VALIDATORS`, `ALL_NON_VALIDATORS`, `ALL_NODES`, or +custom groups defined under `[node_groups]`. If `--targets` is omitted, +transactions are sent to all manifest nodes. + Common flags (see `./quake load --help` for the full list): | Flag | Short | Default | Description | @@ -384,8 +392,6 @@ Common flags (see `./quake load --help` for the full list): | `--mix` | | `transfer=100` | Transaction type blend: `transfer`, `erc20`, `guzzler` | | `--tx-latency` | | `false` | Record submit-to-finalized latency to CSV | -If no target nodes are specified, transactions are sent to all nodes. - #### Latency tracking (`--tx-latency`) The `--tx-latency` flag measures end-to-end transaction latency: the wall-clock @@ -394,8 +400,8 @@ See the [spammer README](../spammer/README.md#transaction-latency-tracking) for full details on the tracking architecture, CSV output format, and analysis tools. ```bash -./quake load -t 30 -r 1000 --tx-latency validator1 -./quake load -t 30 -r 1000 --tx-latency --csv-dir .quake/results validator1 +./quake load -t 30 -r 1000 --tx-latency --targets validator1 +./quake load -t 30 -r 1000 --tx-latency --csv-dir .quake/results --targets validator1 ``` **Recording behavior by mode:** @@ -808,24 +814,46 @@ Nodes are defined as individual TOML sections with names starting with `validato ### Node Configuration -- The configuration of the Malachite application (Consensus Layer) is defined in -`crates/types/src/config.rs`. It can be set globally or for each node by -prefixing the config field with `cl.config.`. -- The default configuration of Reth (Execution Layer) is defined in `crates/quake/src/manifest.rs`. -It can be set globally or for each node by prefixing the config field with `el.config.`. - -For example: +Consensus Layer (CL) configuration is set under `cl.config.*` keys. The +schema depends on the CL image version (`image_cl`): + +- **Modern CL (>= v0.5.0)**: the schema matches the `StartCmd` struct in + [`crates/malachite-cli/src/cmd/start.rs`](../malachite-cli/src/cmd/start.rs). + Keys are flat and map 1:1 to the `arc-node-consensus start` CLI flags + (e.g. `cl.config.log_level = "debug"` → `--log-level=debug`). Quake + translates the merged config into CLI flags at setup time and the node is + launched with no `config.toml`. +- **Legacy CL (< v0.5.0)**: the schema matches the `Config` struct in + [`crates/types/src/config.rs`](../types/src/config.rs). Keys are nested + (e.g. `cl.config.logging.log_level = "debug"`) and the merged config is + written to `config.toml` at setup time. Legacy mode is scheduled for + deprecation. + +Quake detects which schema to use by parsing the `image_cl` tag; `latest`, +missing tags, and unparseable tags are treated as Modern. The two formats +are not interchangeable — `cl.config.log_level` on a Legacy image (and +`cl.config.logging.log_level` on a Modern image) will fail to parse. +Upgrading a running testnet across the legacy/modern boundary with +`perturb upgrade` is **not supported**: the upgraded binary would start +with no CLI flags. For upgrade scenarios, start the testnet on a Modern +version. + +The default configuration of Reth (Execution Layer) is defined in +[`crates/quake/src/manifest.rs`](src/manifest.rs). It can be set globally +or for each node by prefixing the config field with `el.config.`. + +For example (Modern CL): ```toml # Global settings that apply to all nodes engine_api_connection = "rpc" # or "ipc" (default) -cl.config.logging.log_level = "debug" +cl.config.log_level = "debug" el.config.disable-discovery = true [[nodes]] [validator1] # Node-specific settings -cl.config.consensus.p2p.rpc_max_size = "1Mb" +cl.config.discovery_num_outbound_peers = 30 [validator2] # Node-specific settings el.config.builder.deadline = 5 @@ -1026,6 +1054,9 @@ You can define custom groups of nodes and use them to configure peer connections - `ALL_VALIDATORS` - All validator nodes, that is, nodes with names starting with `val` (e.g., `validator1`, `val2`) - `ALL_NON_VALIDATORS` - All nodes that are not validators +These names are reserved built-ins and cannot be redefined under +`[node_groups]`. + **Custom node groups** are defined in the `[node_groups]` section. Groups can reference individual node names, pre-defined groups, or other groups previously declared: ```toml @@ -1054,6 +1085,15 @@ In this example: - `full1` will connect to all non-validators: `full2`, `sentry`, `other_node` - `sentry` will connect to all nodes except itself +The same group names can also be used as `quake load` and `quake spam` +targets. For example: + +```bash +./quake load -t 60 -r 500 --targets ALL_VALIDATORS +./quake spam -t 30 -r 1000 --targets TRUSTED +./quake remote load -- --targets FULL_NODES -r 1000 -t 60 +``` + To distinguish group references from individual nodes in peer lists, by convention we use lowercase for node names and uppercase for node group names. Note: A node is automatically excluded from its own persistent peers list. @@ -1568,9 +1608,12 @@ stop. Send transaction load: ```sh -./quake remote load -- -r 1000 -t 60 validator1 validator2 +./quake remote load -- --targets validator1,validator2 -r 1000 -t 60 ``` Under the hood, this commands calls Spammer from CC. All Spammer options are supported. +`--targets` accepts the same comma-separated selectors as `quake load` on a +local testnet, including manifest node groups such as `ALL_VALIDATORS` or +custom `[node_groups]`. Download diagnostic artifacts from the remote testnet: ```bash diff --git a/crates/quake/scenarios/db-upgrade-v0-v1.toml b/crates/quake/scenarios/db-upgrade-v0-v1.toml index 133eb04..4340650 100644 --- a/crates/quake/scenarios/db-upgrade-v0-v1.toml +++ b/crates/quake/scenarios/db-upgrade-v0-v1.toml @@ -4,17 +4,21 @@ description = "DB upgrade testing scenario with 3 validators. Tests upgrade of a # Use RPC connection for Engine API engine_api_connection = "rpc" -# Starting versions (what the testnet begins with) +# Starting versions (what the testnet begins with). +# Both starting and upgrade images must be Modern (>= v0.5.0) — the upgrade +# path does not currently regenerate CLI flags when crossing the legacy/modern +# boundary, so Legacy-to-Modern upgrades would start the new binary with no +# flags. # IMAGE_REGISTRY_URL should be defined as an environment variable or in the .env file. -image_cl="${IMAGE_REGISTRY_URL}/arc-consensus:0.3.1" -image_el="${IMAGE_REGISTRY_URL}/arc-execution:0.3.1" +image_cl="${IMAGE_REGISTRY_URL}/arc-consensus:0.5.0" +image_el="${IMAGE_REGISTRY_URL}/arc-execution:0.5.0" # Upgrade versions (what we upgrade to during the test) image_cl_upgrade="arc_consensus:latest" image_el_upgrade="arc_execution:latest" # Global config -cl.config.logging.log_level = "info" +cl.config.log_level = "info" [nodes.validator1] [nodes.validator2] diff --git a/crates/quake/scenarios/examples/10nodes.toml b/crates/quake/scenarios/examples/10nodes.toml index 5cc6501..04aa75f 100644 --- a/crates/quake/scenarios/examples/10nodes.toml +++ b/crates/quake/scenarios/examples/10nodes.toml @@ -1,5 +1,5 @@ # Global config -cl.config.logging.log_level = "info" +cl.config.log_level = "info" [nodes.validator1] [nodes.validator2] @@ -16,4 +16,3 @@ start_at = 20 [nodes.full3] start_at = 10 - diff --git a/crates/quake/scenarios/examples/arc-node.toml b/crates/quake/scenarios/examples/arc-node.toml index 33172a5..707ace1 100644 --- a/crates/quake/scenarios/examples/arc-node.toml +++ b/crates/quake/scenarios/examples/arc-node.toml @@ -27,7 +27,7 @@ el.config.prune.preset = "minimal" el.config.prune.bodies.distance = 100 el.config.prune.block-interval = 5 -cl.config.prune.certificates_distance = 100 +cl.config.prune_certificates_distance = 100 # Trusted perimeter: only propagate txs to trusted peers. # Validators, full-p2p, and snapshot gossip freely among themselves; @@ -51,7 +51,7 @@ el.config.arc.hide_pending_txs = true # libp2p sync-only full node — --full profile (snapshot source + follow target) [nodes.snapshot] start_at = 10 -cl.config.consensus.enabled = false +cl.config.no_consensus = true el.config.prune.preset = "full" # RPC follow node — quake starts it at block 11, script stops it immediately, diff --git a/crates/quake/scenarios/examples/rpc-sync.toml b/crates/quake/scenarios/examples/rpc-sync.toml index 37886a0..1e7fce0 100644 --- a/crates/quake/scenarios/examples/rpc-sync.toml +++ b/crates/quake/scenarios/examples/rpc-sync.toml @@ -14,7 +14,7 @@ # libp2p sync-only full node (no consensus/gossip, only sync protocol) [nodes.full_p2p] -cl.config.consensus.enabled = false +cl.config.no_consensus = true # Follow full nodes [nodes.full1] diff --git a/crates/quake/scenarios/localdev.toml b/crates/quake/scenarios/localdev.toml index 06777a5..b9f5f94 100644 --- a/crates/quake/scenarios/localdev.toml +++ b/crates/quake/scenarios/localdev.toml @@ -1,6 +1,6 @@ # Enable persistence backpressure in CI to exercise the feature until it is # enabled by default. -cl.config.execution.persistence_backpressure = true +cl.config.execution_persistence_backpressure = true # All 5 validators use 0x65E0a200006D4FF91bD59F9694220dafc49dbBC1 (LOCALDEV_FEE_RECIPIENT) as # cl_suggested_fee_recipient so tests can hard-code a single known beneficiary address. diff --git a/crates/quake/scenarios/nightly-chaos-testing.toml b/crates/quake/scenarios/nightly-chaos-testing.toml index c00f74b..de55903 100644 --- a/crates/quake/scenarios/nightly-chaos-testing.toml +++ b/crates/quake/scenarios/nightly-chaos-testing.toml @@ -1,7 +1,7 @@ name = "nightly-chaos-testing" # Global config -cl.config.logging.log_level = "debug" +cl.config.log_level = "debug" latency_emulation = true engine_api_connection = "rpc" diff --git a/crates/quake/scenarios/nightly-upgrade.toml b/crates/quake/scenarios/nightly-upgrade.toml index 89663e9..3d60aae 100644 --- a/crates/quake/scenarios/nightly-upgrade.toml +++ b/crates/quake/scenarios/nightly-upgrade.toml @@ -17,7 +17,7 @@ image_cl_upgrade="arc_consensus:latest" image_el_upgrade="arc_execution:latest" # Global config -cl.config.logging.log_level = "info" +cl.config.log_level = "info" # 5 validators ensures BFT consensus continues during single node upgrade # Byzantine fault tolerance: f = (n-1)/3, so with 5 nodes we can tolerate 1 failure diff --git a/crates/quake/scenarios/public-testnet.toml b/crates/quake/scenarios/public-testnet.toml index 416c610..9ade2c2 100644 --- a/crates/quake/scenarios/public-testnet.toml +++ b/crates/quake/scenarios/public-testnet.toml @@ -2,10 +2,10 @@ name = "testnet" description = "A reproduction of the testnet configuration with latency emulation enabled." # Global config -cl.config.logging.log_level = "debug" +cl.config.log_level = "debug" -cl.config.consensus.p2p.discovery.num_inbound_peers = 50 -cl.config.consensus.p2p.discovery.num_outbound_peers = 50 +cl.config.discovery_num_inbound_peers = 50 +cl.config.discovery_num_outbound_peers = 50 latency_emulation = true engine_api_connection = "rpc" diff --git a/crates/quake/src/main.rs b/crates/quake/src/main.rs index f53ad83..ba08d35 100644 --- a/crates/quake/src/main.rs +++ b/crates/quake/src/main.rs @@ -31,16 +31,16 @@ use std::time::Duration; use tracing::{debug, info, warn}; use tracing_subscriber::EnvFilter; -use clean::{clean_scope, CleanScope}; -use perturb::Perturbation; -use testnet::{Testnet, TestnetError}; - use crate::infra::export; use crate::infra::{BuildProfile, INFRA_DATA_FILENAME}; use crate::manifest::{generate_manifests, EngineApiConnection}; use crate::perturb::{PERTURB_MAX_TIME_OFF, PERTURB_MIN_TIME_OFF}; use crate::valset::ValidatorPowerUpdate; +use clean::{clean_scope, CleanScope}; +use perturb::Perturbation; +use testnet::{Testnet, TestnetError}; + mod build; mod clean; mod genesis; @@ -178,20 +178,32 @@ enum Commands { /// Send transaction load to the testnet (backpressure mode: waits for each /// response and only advances the nonce on success). /// Use --mix to blend transaction types (e.g., --mix transfer=70,erc20=30). - #[command(verbatim_doc_comment)] + /// + /// If `--targets` is omitted, all manifest nodes are used. Each target may + /// be an exact node name or a manifest node group such as `ALL_VALIDATORS`. + #[command( + verbatim_doc_comment, + after_long_help = "Examples:\n quake load --rate 200 --time 60\n quake load --targets validator1,ALL_VALIDATORS --rate 200 --time 60\n" + )] Load { - /// Names of the nodes to send transactions to (all nodes if not specified) - target_nodes: Vec, + #[arg(long, value_delimiter = ',')] + targets: Option>, #[command(flatten)] args: SpammerArgs, }, /// Send transaction load to the testnet (fire-and-forget mode: pushes /// transactions into a buffer and sends without waiting for responses). /// Use --mix to blend transaction types (e.g., --mix transfer=70,erc20=30). - #[command(verbatim_doc_comment)] + /// + /// If `--targets` is omitted, all manifest nodes are used. Each target may + /// be an exact node name or a manifest node group such as `ALL_VALIDATORS`. + #[command( + verbatim_doc_comment, + after_long_help = "Examples:\n quake spam --rate 200 --time 60\n quake spam --targets validator1,ALL_VALIDATORS --rate 200 --time 60\n" + )] Spam { - /// Names of the nodes to send transactions to (all nodes if not specified) - target_nodes: Vec, + #[arg(long, value_delimiter = ',')] + targets: Option>, #[command(flatten)] args: SpammerArgs, }, @@ -546,14 +558,16 @@ pub(crate) enum RemoteSubcommand { /// Command to run on the node or CC server; if not provided, will open an interactive shell command: Vec, }, - /// Send transaction load to the nodes by running `quake load` from the Control Center - /// (backpressure mode). + /// Send transaction load to the nodes by running `quake load` from the Control + /// Center (backpressure mode). /// /// It accepts the same arguments as the `load` command. /// /// Examples: - /// Local network: `./quake load -- validator1 validator2 -r 200 -t 60 --pools` - /// Remote network: `./quake remote load -- validator1 validator2 -r 200 -t 60 --pools` + /// Local network: + /// `./quake load --targets validator1,validator2 -r 200 -t 60` + /// Remote network: + /// `./quake remote load -- --targets validator1,validator2 -r 200 -t 60` #[command(verbatim_doc_comment)] Load { args: Vec }, /// Send transaction load to the nodes by running `quake spam` from the Control Center @@ -561,8 +575,10 @@ pub(crate) enum RemoteSubcommand { /// /// It accepts the same arguments as the `spam` command. /// Example: - /// Local network: `./quake spam -- validator1 validator2 -r 200 -t 60 --pools` - /// Remote network: `./quake remote spam -- validator1 validator2 -r 200 -t 60 --pools` + /// Local network: + /// `./quake spam --targets validator1,validator2 -r 200 -t 60` + /// Remote network: + /// `./quake remote spam -- --targets validator1,validator2 -r 200 -t 60` #[command(verbatim_doc_comment)] Spam { args: Vec }, /// Export a JSON file with everything needed for another user to access this remote testnet @@ -832,20 +848,22 @@ async fn main() -> Result<()> { Commands::Logs { names, follow } => testnet.logs(names, follow).await?, Commands::Info { command } => testnet.info(command).await?, Commands::Remote { command } => testnet.remote(command).await?, - Commands::Load { target_nodes, args } => { + Commands::Load { targets, args } => { if testnet.is_remote() { bail!("Remote infrastructure does not support the `load` command. Please run `remote load` instead."); } let config = args.to_config(cli.verbosity.is_silent(), false); config.validate()?; + let target_nodes = targets.unwrap_or_default(); testnet.load(target_nodes, &config).await?; } - Commands::Spam { target_nodes, args } => { + Commands::Spam { targets, args } => { if testnet.is_remote() { bail!("Remote infrastructure does not support the `spam` command. Please run `remote spam` instead."); } let config = args.to_config(cli.verbosity.is_silent(), true); config.validate()?; + let target_nodes = targets.unwrap_or_default(); testnet.load(target_nodes, &config).await?; } Commands::ValSet { updates } => testnet.valset_update(updates).await?, @@ -959,3 +977,44 @@ async fn pre_start( Ok(()) } + +#[cfg(test)] +mod cli_tests { + use super::*; + use clap::error::ErrorKind; + + #[test] + fn cli_parses_load_targets() { + let cli = Cli::try_parse_from([ + "quake", + "load", + "--rate", + "42", + "--targets", + "validator1,ALL_VALIDATORS", + ]) + .expect("parsing load with --targets"); + + match cli.command { + Commands::Load { targets, args } => { + assert_eq!( + targets, + Some(vec!["validator1".to_string(), "ALL_VALIDATORS".to_string(),]) + ); + assert_eq!(args.rate, 42); + } + _ => panic!("expected load command"), + } + } + + #[test] + fn cli_rejects_positional_load_targets() { + let err = match Cli::try_parse_from(["quake", "load", "validator1"]) { + Ok(_) => panic!("positional load targets must be rejected"), + Err(err) => err, + }; + + assert_eq!(err.kind(), ErrorKind::UnknownArgument); + assert!(err.to_string().contains("validator1")); + } +} diff --git a/crates/quake/src/manifest.rs b/crates/quake/src/manifest.rs index bad872e..570f024 100644 --- a/crates/quake/src/manifest.rs +++ b/crates/quake/src/manifest.rs @@ -13,24 +13,25 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. - use alloy_primitives::Address; +use std::collections::HashSet; +use std::path::Path; + use color_eyre::eyre::{bail, Context, Result}; -use indexmap::IndexMap; +use indexmap::{IndexMap, IndexSet}; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; -use std::collections::HashSet; -use std::path::Path; use tracing::warn; -use crate::infra; -use crate::latency; -use crate::testnet; use arc_consensus_types::Config as ClConfigOverride; +use arc_node_consensus_cli::cmd::start::StartCmd; +use crate::infra; +use crate::latency; use crate::manifest::raw::RawManifest; use crate::node::NodeName; use crate::node::SubnetName; +use crate::testnet; mod flags; mod generate; @@ -453,6 +454,9 @@ pub(crate) struct Manifest { pub images: DockerImages, /// Map of node name to node metadata pub nodes: IndexMap, + /// Custom node groups from the manifest, preserved in the order they are + /// defined in the manifest. + pub node_groups: IndexMap>, /// Execution layer initial hardfork name for the network (e.g. "zero3", "zero4", "zero5") pub el_init_hardfork: Option, } @@ -508,13 +512,29 @@ pub struct ClGossipSubConfig { pub load: Option, } -#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)] +/// CL configuration for a node, version-dependent. +/// +/// - `Modern`: for CL >= v0.5.0, maps directly to CLI flags via [`StartCmd`]. +/// - `Legacy`: for CL < v0.5.0, serializes to `config.toml` via [`ClConfigOverride`]. +#[derive(Debug, Clone, PartialEq)] +pub enum NodeClConfig { + Modern(StartCmd), + Legacy(ClConfigOverride), +} + +impl Default for NodeClConfig { + fn default() -> Self { + Self::Modern(StartCmd::default()) + } +} + +#[derive(Debug, Default, Clone, PartialEq)] pub struct Node { /// The type of the node pub node_type: NodeType, - /// Consensus layer configuration - pub cl_config: ClConfigOverride, + /// Consensus layer configuration (version-dependent) + pub cl_config: NodeClConfig, /// Execution layer (Reth) CLI flags for this node pub el_config: ElConfigOverride, @@ -526,24 +546,19 @@ pub struct Node { pub region: Option, /// Persistent peers for the node - #[serde(default)] pub cl_persistent_peers: Option>, /// Only allow connections to/from persistent peers on the consensus layer - #[serde(default)] pub cl_persistent_peers_only: bool, /// GossipSub configuration overrides - #[serde(default)] pub cl_gossipsub: ClGossipSubConfig, /// Execution layer (Reth) trusted peers: node names or group names, resolved to enodes for --trusted-peers. - #[serde(default)] pub el_trusted_peers: Option>, /// Use the remote signing service for this node /// using the predefined key with the corresponding index. - #[serde(default)] pub remote_signer: Option, /// Enable follow mode (fetch blocks via RPC instead of P2P consensus) @@ -558,11 +573,9 @@ pub struct Node { /// CL pruning preset — emitted as `--full` or `--minimal` on the CL binary. /// Mutually exclusive with explicit `cl.config.prune.*` values. - #[serde(default)] pub cl_prune_preset: Option, /// Address to receive transaction fees and block rewards (--suggested-fee-recipient). - #[serde(default)] pub cl_suggested_fee_recipient: Option
, /// Mark this node as external (operated by a third party). @@ -668,6 +681,50 @@ impl Manifest { .collect() } + /// Build the runtime node-group map, including predefined groups + /// (ALL_NODES, ALL_VALIDATORS, ALL_NON_VALIDATORS). + pub(crate) fn runtime_node_groups(&self) -> IndexMap> { + let node_names = self.nodes.keys().cloned().collect::>(); + raw::build_node_groups(&node_names, &self.node_groups) + } + + /// Resolve Quake load/spam target selectors to explicit node names. + /// + /// A selector is one `--targets` value supplied by the user to commands + /// such as `quake load` or `quake spam`. Each selector must be + /// either: + /// - an exact node name from the manifest, such as `validator1` + /// - an exact node-group name, such as `ALL_VALIDATORS` or `TRUSTED` + /// + /// The returned vector contains only concrete node names. Group selectors + /// are expanded, duplicate nodes are removed while preserving the first-seen + /// order, and wildcard selectors like `val*` are rejected. + pub(crate) fn resolve_node_selectors(&self, selectors: &[String]) -> Result> { + let node_groups = self.runtime_node_groups(); + let mut resolved = IndexSet::new(); + + for selector in selectors { + if selector.contains('*') { + // TODO: support wildcards. + bail!("Wildcard selectors are not supported for load/spam targets: '{selector}'"); + } + + if let Some(group) = node_groups.get(selector) { + resolved.extend(group.iter().cloned()); + continue; + } + + if self.nodes.contains_key(selector) { + resolved.insert(selector.clone()); + continue; + } + + bail!("Unknown node or node group '{selector}'"); + } + + Ok(resolved.into_iter().collect()) + } + /// Collects explicit voting powers from validators, or `None` if none are set. pub(crate) fn validator_voting_powers(&self) -> Option> { let powers: Vec = self @@ -1031,6 +1088,15 @@ mod tests { use malachitebft_config::LogLevel; use std::env; + /// Extract the inner `ClConfigOverride` from a `NodeClConfig::Legacy` variant. + /// Panics if the variant is `Modern`. + fn unwrap_legacy(cl_config: &NodeClConfig) -> &ClConfigOverride { + match cl_config { + NodeClConfig::Legacy(cfg) => cfg, + NodeClConfig::Modern(_) => panic!("expected NodeClConfig::Legacy, got Modern"), + } + } + // Check number of nodes, names, types, and order of declaration in the manifest fn validate_nodes( nodes: &IndexMap, @@ -1115,27 +1181,16 @@ mod tests { ]; validate_nodes(&manifest.nodes, expected_node_names, expected_types); - // Check nodes individual config - assert_eq!( - manifest.nodes["validator1"].cl_config.logging.log_level, - LogLevel::Info - ); - assert_eq!( - manifest.nodes["validator2"].cl_config.logging.log_level, - LogLevel::Warn - ); - assert_eq!( - manifest.nodes["validator2"] - .cl_config - .consensus - .p2p - .rpc_max_size, - bytesize::ByteSize::kb(123), - ); - assert_eq!( - manifest.nodes["validator3"].cl_config.logging.log_level, - LogLevel::Warn - ); + // Check nodes individual config (Legacy variant because image_cl is v0.4.0) + let v1 = unwrap_legacy(&manifest.nodes["validator1"].cl_config); + assert_eq!(v1.logging.log_level, LogLevel::Info); + + let v2 = unwrap_legacy(&manifest.nodes["validator2"].cl_config); + assert_eq!(v2.logging.log_level, LogLevel::Warn); + assert_eq!(v2.consensus.p2p.rpc_max_size, bytesize::ByteSize::kb(123)); + + let v3 = unwrap_legacy(&manifest.nodes["validator3"].cl_config); + assert_eq!(v3.logging.log_level, LogLevel::Warn); } #[test] @@ -1250,8 +1305,9 @@ mod tests { cl.config = {} # explicitly empty "#; let result = Manifest::from_string(str).unwrap(); - // Verify the node inherited global config - assert!(!result.nodes["validator-0"].cl_config.consensus.enabled); + // Verify the node inherited global config (Legacy variant because image_cl is v0.4.0) + let cfg = unwrap_legacy(&result.nodes["validator-0"].cl_config); + assert!(!cfg.consensus.enabled); } #[test] @@ -1988,6 +2044,135 @@ mod tests { ); } + #[test] + fn test_runtime_node_groups_include_predefined_and_custom() { + let str = r#" + [node_groups] + FULL_NODES = ["full1", "full2"] + TRUSTED = ["ALL_VALIDATORS", "FULL_NODES", "other_node"] + + [nodes.validator1] + [nodes.validator2] + [nodes.full1] + [nodes.full2] + [nodes.other_node] + "#; + let manifest = Manifest::from_string(str).unwrap(); + let runtime_groups = manifest.runtime_node_groups(); + + assert_eq!( + runtime_groups["ALL_NODES"], + vec![ + "validator1".to_string(), + "validator2".to_string(), + "full1".to_string(), + "full2".to_string(), + "other_node".to_string(), + ] + ); + assert_eq!( + runtime_groups["ALL_VALIDATORS"], + vec!["validator1".to_string(), "validator2".to_string(),] + ); + assert_eq!( + runtime_groups["ALL_NON_VALIDATORS"], + vec![ + "full1".to_string(), + "full2".to_string(), + "other_node".to_string(), + ] + ); + assert_eq!( + runtime_groups["FULL_NODES"], + vec!["full1".to_string(), "full2".to_string(),] + ); + assert_eq!( + runtime_groups["TRUSTED"], + vec![ + "validator1".to_string(), + "validator2".to_string(), + "full1".to_string(), + "full2".to_string(), + "other_node".to_string(), + ] + ); + } + + // Test deduplication across the final list of nodes after resolving groups. + #[test] + fn test_resolve_node_selectors_dedupes_after_expansion() { + let str = r#" + [node_groups] + FULL_NODES = ["full1", "full2"] + TRUSTED = ["ALL_VALIDATORS", "FULL_NODES", "other_node"] + + [nodes.validator1] + [nodes.validator2] + [nodes.full1] + [nodes.full2] + [nodes.sentry] + [nodes.other_node] + "#; + let manifest = Manifest::from_string(str).unwrap(); + let selectors = vec![ + "TRUSTED".to_string(), + "full1".to_string(), + "ALL_NON_VALIDATORS".to_string(), + ]; + + assert_eq!( + manifest.resolve_node_selectors(&selectors).unwrap(), + vec![ + "validator1".to_string(), + "validator2".to_string(), + "full1".to_string(), + "full2".to_string(), + "other_node".to_string(), + "sentry".to_string(), + ] + ); + } + + #[test] + fn test_node_group_forward_reference_rejected() { + let str = r#" + [node_groups] + TRUSTED = ["FULL_NODES", "validator1"] + FULL_NODES = ["full1", "full2"] + + [nodes.validator1] + [nodes.full1] + [nodes.full2] + "#; + + let err = Manifest::from_string(str).unwrap_err(); + assert!( + err.to_string().contains("invalid node name 'FULL_NODES'"), + "forward references to later-defined groups should be rejected: {err}" + ); + } + + #[test] + fn test_resolve_node_selectors_rejects_unknown_names_and_wildcards() { + let str = r#" + [nodes.validator1] + [nodes.validator2] + "#; + let manifest = Manifest::from_string(str).unwrap(); + + let unknown = manifest + .resolve_node_selectors(&["missing".to_string()]) + .unwrap_err(); + assert!(unknown.to_string().contains("Unknown node or node group")); + + let wildcard = manifest + .resolve_node_selectors(&["val*".to_string()]) + .unwrap_err(); + assert!(wildcard + .to_string() + .contains("Wildcard selectors are not supported")); + } + #[test] fn test_node_group_with_non_existing_node() { let str = r#" @@ -1998,6 +2183,7 @@ mod tests { assert!(result.is_err()); } + // Test deduplication within a group definition #[test] fn test_node_group_with_repeated_elements() { let str = r#" @@ -2018,25 +2204,62 @@ mod tests { } #[test] - fn test_node_with_predefined_group_name() { + fn test_node_with_group_name() { let str = r#" - [nodes.ALL_NODES] + [node_groups] + GROUP_A = ["a", "b"] + [nodes.GROUP_A] "#; let result = Manifest::from_string(str); assert!(result.is_err()); - } - #[test] - fn test_node_with_group_name() { + // predefined group names should also not be allowed as node name let str = r#" - [node_groups] - GROUP_A = ["a", "b"] - [nodes.GROUP_A] + [nodes.ALL_NODES] "#; let result = Manifest::from_string(str); assert!(result.is_err()); } + #[test] + fn test_reserved_node_group_names_are_rejected() { + struct Case { + group_name: &'static str, + } + + let cases = [ + Case { + group_name: raw::NODE_GROUP_ALL, + }, + Case { + group_name: raw::NODE_GROUP_VALIDATORS, + }, + Case { + group_name: raw::NODE_GROUP_NON_VALIDATORS, + }, + ]; + + for case in cases { + let toml = format!( + r#" + [node_groups] + {group_name} = ["full1"] + + [nodes.validator1] + [nodes.full1] + "#, + group_name = case.group_name, + ); + + let err = Manifest::from_string(&toml).unwrap_err(); + assert!( + err.to_string().contains("reserved built-in group name"), + "group '{}' should be rejected: {err}", + case.group_name, + ); + } + } + #[test] fn test_load_multiple_subnets() { let str = r#" diff --git a/crates/quake/src/manifest/generate.rs b/crates/quake/src/manifest/generate.rs index e6bb3e0..8947ec2 100644 --- a/crates/quake/src/manifest/generate.rs +++ b/crates/quake/src/manifest/generate.rs @@ -19,11 +19,9 @@ /// It generates a manifest file with random configuration for the testnet based on the seed. /// Manifest parameters are generated based on a predefined per-parameter distributions. /// This is the minimal implementation that serves as a baseline for future randomization. -use arc_consensus_types::{ - Config as ClConfigOverride, ConsensusConfig, Height, LogFormat, LogLevel, LoggingConfig, - PruningConfig, RpcConfig, RuntimeConfig, ValueSyncConfig, +use arc_node_consensus_cli::cmd::start::{ + StartCmd, RUNTIME_MULTI_THREADED, RUNTIME_SINGLE_THREADED, }; -use std::time::Duration; use crate::latency::{Region, AWS_LATENCY_MATRIX}; use crate::manifest::subnets::Subnets; @@ -238,7 +236,7 @@ impl Manifest { .context("Failed to apply region strategy")?; for (_name, node) in nodes.iter_mut() { - node.cl_config = Self::random_cl_node_config(&mut rng); + node.cl_config = Self::random_cl_node_config(&mut rng, &node.node_type); node.el_config = Self::random_el_node_config(&mut rng); } @@ -264,25 +262,23 @@ impl Manifest { subnets: Subnets::new(&node_subnets), images: DockerImages::default(), nodes, + node_groups: IndexMap::new(), el_init_hardfork, }) } /// Build random per-node CL (Consensus Layer) config. - fn random_cl_node_config(rng: &mut StdRng) -> ClConfigOverride { - let parallel_requests = if rng.gen_bool(0.7) { - 5 - } else { - rng.gen_range(1..=20) - }; + fn random_cl_node_config(rng: &mut StdRng, node_type: &NodeType) -> manifest::NodeClConfig { + use malachitebft_config::{LogFormat, LogLevel}; // Runtime: 30% single_threaded, 70% multi_threaded; worker_threads 1-16 when multi. - let runtime = if rng.gen_bool(0.7) { - RuntimeConfig::MultiThreaded { - worker_threads: rng.gen_range(1..=16), - } + let (runtime_flavor, worker_threads) = if rng.gen_bool(0.7) { + ( + RUNTIME_MULTI_THREADED.to_string(), + Some(rng.gen_range(1..=16)), + ) } else { - RuntimeConfig::SingleThreaded + (RUNTIME_SINGLE_THREADED.to_string(), None) }; let distance: u64 = match rng.gen_range(0..100) { @@ -290,41 +286,40 @@ impl Manifest { 30..=69 => rng.gen_range(100..=1000), _ => rng.gen_range(1000..=10000), }; - let min_height: u64 = if rng.gen_bool(0.8) { - 0 - } else { + + // prune.certificates.distance and prune.certificates.before are mutually + // exclusive on the CLI; only randomize `before` when `distance` is unset. + let before: u64 = if distance == 0 && rng.gen_bool(0.2) { rng.gen_range(1..=1000) + } else { + 0 }; - ClConfigOverride { - logging: LoggingConfig { - log_level: LogLevel::Debug, - log_format: LogFormat::Plaintext, - }, - consensus: ConsensusConfig { - enabled: true, - ..Default::default() - }, - value_sync: ValueSyncConfig { - enabled: true, - parallel_requests, - batch_size: rng.gen_range(1..=1000), - request_timeout: Duration::from_secs(rng.gen_range(5..=30)), - status_update_interval: Duration::from_secs(rng.gen_range(0..=10)), - ..Default::default() - }, - runtime, - prune: PruningConfig { - certificates_distance: distance, - certificates_before: Height::new(min_height), - }, - rpc: RpcConfig { - // RPC: always enabled so quake wait height and test can query block height - enabled: true, - ..Default::default() - }, - ..Default::default() - } + let log_level = [ + LogLevel::Trace, + LogLevel::Debug, + LogLevel::Info, + LogLevel::Warn, + LogLevel::Error, + ] + .choose(rng) + .copied(); + + let log_format = [LogFormat::Plaintext, LogFormat::Json].choose(rng).copied(); + + // Only non-validators can skip consensus; setting this on a validator would break liveness. + let no_consensus = matches!(node_type, NodeType::NonValidator) && rng.gen_bool(0.1); + + manifest::NodeClConfig::Modern(StartCmd { + runtime_flavor, + worker_threads, + prune_certificates_distance: distance, + prune_certificates_before: before, + log_level, + log_format, + no_consensus, + ..StartCmd::default() + }) } /// Build random per-node EL (Execution Layer) config override. @@ -623,22 +618,8 @@ mod tests { height_strategy, region_strategy, }; - let mut manifest = Manifest::generate_random(42, &config).unwrap(); - // Manifest→RawManifest serialization emits cl_config as cl.config.* TOML. - // Set a pre-v0.5.0 image so the safeguard allows cl.config.* on re-parse. - // TODO: refactor RawManifest::try_from(Manifest) to emit explicit fields - // instead of cl.config.*, then this workaround can be removed. - manifest.images.cl = Some("arc_consensus:v0.4.0".to_string()); - - // TODO: ByteSize v1.3 has an issue - the serializer produces lossy output. - // This is fixed in a newer version; the dependency should be upgraded upstream first. - for (_name, node) in manifest.nodes.iter_mut() { - node.cl_config.value_sync.max_request_size = bytesize::ByteSize::kib(1000); - node.cl_config.value_sync.max_response_size = bytesize::ByteSize::kib(1000); - node.cl_config.consensus.p2p.rpc_max_size = bytesize::ByteSize::kib(1000); - node.cl_config.consensus.p2p.pubsub_max_size = - bytesize::ByteSize::kib(1000); - } + let manifest = Manifest::generate_random(42, &config).unwrap(); + manifest .validate() .context("Failed to validate manifest") @@ -875,45 +856,63 @@ mod tests { let manifest = Manifest::generate_random(100, &config).unwrap(); for (node_id, node) in &manifest.nodes { - let g = &node.cl_config; + let manifest::NodeClConfig::Modern(cmd) = &node.cl_config else { + panic!("node {node_id}: expected Modern cl_config"); + }; + assert!( - g != &ClConfigOverride::default(), + cmd != &StartCmd::default(), "node {node_id} cl_config should not be default/empty" ); - // Logging: fixed debug, plaintext - assert_eq!(g.logging.log_level, LogLevel::Debug); - assert_eq!(g.logging.log_format, LogFormat::Plaintext); - - // Consensus: always enabled - assert!(g.consensus.enabled); - - // ValueSync: enabled, parallel_requests and batch_size in spec ranges - assert!(g.value_sync.enabled); - let pr = g.value_sync.parallel_requests as i64; - assert!((1..=20).contains(&pr), "parallel_requests = {pr}"); - let bs = g.value_sync.batch_size as i64; - assert!((1..=1000).contains(&bs), "batch_size = {bs}"); - - // Runtime: single_threaded or multi_threaded with 1-16 worker threads - match g.runtime { - RuntimeConfig::SingleThreaded => {} - RuntimeConfig::MultiThreaded { worker_threads } => { - assert!( - (1..=16).contains(&worker_threads), - "worker_threads = {worker_threads}" - ); + // ValueSync: enabled + assert!(cmd.value_sync, "node {node_id}: value_sync should be true"); + + // Runtime: single-threaded or multi-threaded with 1-16 worker threads + match cmd.runtime_flavor.as_str() { + RUNTIME_SINGLE_THREADED => {} + RUNTIME_MULTI_THREADED => { + if let Some(wt) = cmd.worker_threads { + assert!( + (1..=16).contains(&wt), + "node {node_id}: worker_threads = {wt}" + ); + } } + other => panic!("node {node_id}: unexpected runtime_flavor: {other}"), } - // Prune: certificates_distance and certificates_before in spec ranges - let bi = g.prune.certificates_distance; - assert!(bi <= 10000, "certificates_distance = {bi}"); - let mh = g.prune.certificates_before.as_u64(); - assert!(mh <= 1000, "certificates_before = {mh}"); + // Prune: certificates_distance in spec range + assert!( + cmd.prune_certificates_distance <= 10000, + "node {node_id}: certificates_distance = {}", + cmd.prune_certificates_distance + ); + + // Prune: certificates_before in spec range + assert!( + cmd.prune_certificates_before <= 1000, + "node {node_id}: certificates_before = {}", + cmd.prune_certificates_before + ); + + // Prune: distance and before are mutually exclusive on the CLI + assert!( + cmd.prune_certificates_distance == 0 || cmd.prune_certificates_before == 0, + "node {node_id}: prune distance ({}) and before ({}) cannot both be set", + cmd.prune_certificates_distance, + cmd.prune_certificates_before + ); - // RPC: always enabled - assert!(g.rpc.enabled); + // Logging: log_level and log_format randomized per node + assert!( + cmd.log_level.is_some(), + "node {node_id}: log_level should be set" + ); + assert!( + cmd.log_format.is_some(), + "node {node_id}: log_format should be set" + ); } } diff --git a/crates/quake/src/manifest/raw.rs b/crates/quake/src/manifest/raw.rs index 5d15ffb..7e2a013 100644 --- a/crates/quake/src/manifest/raw.rs +++ b/crates/quake/src/manifest/raw.rs @@ -24,7 +24,7 @@ use tracing::warn; use crate::manifest::subnets::Subnets; use crate::manifest::{ default_subnet_singleton, ClGossipSubConfig, ClPruningPreset, DockerImages, ElConfigOverride, - EngineApiConnection, Manifest, Node, NodeType, RemoteKeyId, + EngineApiConnection, Manifest, Node, NodeClConfig, NodeType, RemoteKeyId, }; use crate::node::SubnetName; use crate::setup::supports_cli_flags; @@ -34,9 +34,16 @@ use crate::util::merge_toml_values; const VALIDATOR_PREFIX: &str = "val"; /// Pre-defined node groups. -const NODE_GROUP_ALL: &str = "ALL_NODES"; -const NODE_GROUP_VALIDATORS: &str = "ALL_VALIDATORS"; -const NODE_GROUP_NON_VALIDATORS: &str = "ALL_NON_VALIDATORS"; +pub(crate) const NODE_GROUP_ALL: &str = "ALL_NODES"; +pub(crate) const NODE_GROUP_VALIDATORS: &str = "ALL_VALIDATORS"; +pub(crate) const NODE_GROUP_NON_VALIDATORS: &str = "ALL_NON_VALIDATORS"; + +fn is_reserved_node_group_name(name: &str) -> bool { + matches!( + name, + NODE_GROUP_ALL | NODE_GROUP_VALIDATORS | NODE_GROUP_NON_VALIDATORS + ) +} /// Wrapper for execution layer configuration in TOML. /// @@ -201,6 +208,18 @@ pub struct RawManifest { image_el_upgrade: Option, } +impl RawManifest { + /// Build the `DockerImages` referenced by this raw manifest. + pub fn images(&self) -> DockerImages { + DockerImages { + cl: self.image_cl.clone(), + el: self.image_el.clone(), + cl_upgrade: self.image_cl_upgrade.clone(), + el_upgrade: self.image_el_upgrade.clone(), + } + } +} + impl Default for RawManifest { fn default() -> Self { Self { @@ -239,62 +258,6 @@ fn collect_toml_keys(table: &toml::Table, prefix: &str, out: &mut Vec) { } } -/// cl.config.* TOML paths that Quake can translate to CL CLI flags. -/// For v0.5.0+ images, any cl.config path NOT in this list will be rejected -/// to prevent silent ignores (config.toml is not read by v0.5.0+). -/// -/// TODO: Derive this list dynamically. -const CL_CONFIG_TRANSLATABLE: &[&str] = &[ - "logging.log_level", - "consensus.enabled", - "consensus.p2p.discovery.enabled", - "consensus.p2p.discovery.num_inbound_peers", - "consensus.p2p.discovery.num_outbound_peers", - "prune.certificates_distance", - "prune.certificates_before", - "execution.persistence_backpressure", - "execution.persistence_backpressure_threshold", -]; - -/// For v0.5.0+ CL images, reject any cl.config.* paths that cannot be translated -/// to CLI flags. Pre-v0.5.0 images read config.toml directly so all paths are fine. -fn validate_cl_config(raw: &RawManifest) -> Result<()> { - if !supports_cli_flags(raw.image_cl.as_deref()) { - return Ok(()); - } - - reject_untranslatable_cl_config(&raw.cl.config, "global")?; - for (node_name, raw_node) in &raw.nodes { - reject_untranslatable_cl_config(&raw_node.cl.config, node_name)?; - } - Ok(()) -} - -/// Walk all leaf keys in a cl.config table and bail if any are not in `CL_CONFIG_TRANSLATABLE`. -fn reject_untranslatable_cl_config(table: &toml::Table, scope: &str) -> Result<()> { - let mut keys = Vec::new(); - collect_toml_keys(table, "", &mut keys); - - let untranslatable: Vec<&String> = keys - .iter() - .filter(|k| !CL_CONFIG_TRANSLATABLE.contains(&k.as_str())) - .collect(); - - if !untranslatable.is_empty() { - bail!( - "{scope}: cl.config.* settings have no CLI flag equivalent and will be \ - silently ignored by CL v0.5.0+: [{}]. \ - Remove these settings or request CLI flag support from the CL team.", - untranslatable - .iter() - .map(|k| format!("cl.config.{k}")) - .collect::>() - .join(", ") - ); - } - Ok(()) -} - /// Reject manifests where a node sets both `cl_prune_preset` and `cl.config.prune.*`. /// These are mutually exclusive: the preset is a named shortcut while explicit prune /// config overrides individual knobs. Allowing both would make precedence ambiguous. @@ -326,26 +289,19 @@ impl TryFrom for Manifest { warn!("arc_image_tag and arc_image_registry are deprecated; use image_cl/image_el with full image references instead"); } - // Validate CL config consistency before converting - validate_cl_config(&raw)?; validate_prune_exclusivity(&raw)?; - let node_names = raw.nodes.keys().cloned().collect::>(); + // Build Docker images (needed early to determine CL config format) + let images = raw.images(); - // Add pre-defined node groups - let mut node_groups = IndexMap::new(); - node_groups.insert(NODE_GROUP_ALL.to_string(), node_names.clone()); - let (validators, non_validators): (Vec<_>, Vec<_>) = node_names - .clone() - .into_iter() - .partition(|name| is_validator(name)); - node_groups.insert(NODE_GROUP_VALIDATORS.to_string(), validators); - node_groups.insert(NODE_GROUP_NON_VALIDATORS.to_string(), non_validators); - - // Build node groups map from raw node groups, while expanding already declared group names to node names - for (key, raw_node_group) in raw.node_groups { - node_groups.insert(key, expand_node_group_names(&raw_node_group, &node_groups)); + let node_names = raw.nodes.keys().cloned().collect::>(); + let custom_node_groups = raw.node_groups.clone(); + for group_name in custom_node_groups.keys() { + if is_reserved_node_group_name(group_name) { + bail!("Node group '{group_name}' uses a reserved built-in group name"); + } } + let node_groups = build_node_groups(&node_names, &custom_node_groups); // Check that node names are not used as node group names for node_group in node_groups.keys() { @@ -365,8 +321,14 @@ impl TryFrom for Manifest { // Merge default CL and EL configs with manifest's global config. // Precedence: defaults < manifest global < per-node - - let default_cl = toml::Value::try_from(ClConfigOverride::default())?; + // The CL default depends on the image version: Modern uses StartCmd, + // Legacy uses ClConfigOverride. + let is_modern = supports_cli_flags(images.cl.as_deref()); + let default_cl = if is_modern { + toml::Value::try_from(arc_node_consensus_cli::cmd::start::StartCmd::default())? + } else { + toml::Value::try_from(ClConfigOverride::default())? + }; let manifest_cl = toml::Value::Table(raw.cl.config.clone()); let global_cl_config = merge_toml_values(default_cl, manifest_cl)?; @@ -374,14 +336,6 @@ impl TryFrom for Manifest { let manifest_el = toml::Value::Table(raw.el.config.clone()); let global_el_config = merge_toml_values(default_el, manifest_el)?; - // Build Docker images - let images = DockerImages { - cl: raw.image_cl, - el: raw.image_el, - cl_upgrade: raw.image_cl_upgrade, - el_upgrade: raw.image_el_upgrade, - }; - // Build nodes map from raw nodes let mut nodes = IndexMap::new(); let mut node_subnets = IndexMap::new(); @@ -396,7 +350,7 @@ impl TryFrom for Manifest { // Expand node group names in persistent peers list and remove self from // the list let cl_persistent_peers = raw_node.cl_persistent_peers.map(|peers| { - expand_node_group_names(&peers, &node_groups) + expand_node_group(&peers, &node_groups) .into_iter() .filter(|n| *n != key) .collect() @@ -404,7 +358,14 @@ impl TryFrom for Manifest { // Merge node-specific CL config with global CL config let node_cl_config = toml::Value::Table(raw_node.cl.config); - let cl_config = merge_toml_values(global_cl_config.clone(), node_cl_config)?; + let cl_config_toml = merge_toml_values(global_cl_config.clone(), node_cl_config)?; + + // Version-branched deserialization + let cl_config = if is_modern { + NodeClConfig::Modern(cl_config_toml.try_into()?) + } else { + NodeClConfig::Legacy(cl_config_toml.try_into()?) + }; // Merge global el.config with node-specific el.config as TOML let node_el_config = toml::Value::Table(raw_node.el.config); @@ -417,7 +378,7 @@ impl TryFrom for Manifest { let el_trusted_peers = if !el_config.trusted_peers.is_empty() { let names = el_config.trusted_peers; el_config.trusted_peers = vec![]; - let peers: Vec = expand_node_group_names(&names, &node_groups) + let peers: Vec = expand_node_group(&names, &node_groups) .into_iter() .filter(|n| *n != key) .collect(); @@ -436,7 +397,7 @@ impl TryFrom for Manifest { key, Node { node_type, - cl_config: cl_config.try_into()?, + cl_config, el_config, start_at: raw_node.start_at, region: raw_node.region, @@ -469,6 +430,7 @@ impl TryFrom for Manifest { subnets: Subnets::new(&node_subnets), images, nodes, + node_groups: custom_node_groups, el_init_hardfork: raw.el_init_hardfork, }) } @@ -478,10 +440,7 @@ impl TryFrom for RawManifest { type Error = color_eyre::eyre::Error; fn try_from(manifest: Manifest) -> Result { - // The `Manifest` struct does not retain node group information after expansion. - // Attempting to reconstruct it can lead to conflicts and incorrect manifests. - // Serializing with an empty `node_groups` is the safe approach. - let node_groups = IndexMap::new(); + let node_groups = manifest.node_groups.clone(); Ok(Self { name: manifest.name, @@ -530,15 +489,27 @@ impl RawNode { ) -> Result { let mut el_config = node.el_config.clone(); el_config.trusted_peers = trusted_peers.unwrap_or_default(); - let node_cl_table = toml::Table::try_from(node.cl_config)?; let node_el_table = toml::Table::try_from(el_config)?; - - let default_cl_config: toml::Table = toml::Table::try_from(ClConfigOverride::default())?; let default_el_config: toml::Table = toml::Table::try_from(ElConfigOverride::default())?; + // Serialize cl_config to TOML based on variant + let cl_config_table = match &node.cl_config { + NodeClConfig::Modern(start_cmd) => { + let table = toml::Table::try_from(start_cmd)?; + let default_table = + toml::Table::try_from(arc_node_consensus_cli::cmd::start::StartCmd::default())?; + Self::config_diff(&table, &default_table) + } + NodeClConfig::Legacy(config) => { + let table = toml::Table::try_from(config)?; + let default_table = toml::Table::try_from(ClConfigOverride::default())?; + Self::config_diff(&table, &default_table) + } + }; + Ok(Self { cl: ClConfig { - config: Self::config_diff(&node_cl_table, &default_cl_config), + config: cl_config_table, }, el: ElConfig { config: Self::config_diff(&node_el_table, &default_el_config), @@ -592,16 +563,45 @@ impl RawNode { } } +/// Build the runtime node-group map from manifest node names and custom groups. +/// +/// The returned map always contains the predefined groups +/// `ALL_NODES`, `ALL_VALIDATORS`, and `ALL_NON_VALIDATORS`, followed by the +/// custom groups in declaration order. Custom groups from the manifest are expanded +/// against the groups already present in the map, so a later custom group may +/// reference an earlier one or a predefined group. +pub(crate) fn build_node_groups( + node_names: &[String], + custom_node_groups: &IndexMap>, +) -> IndexMap> { + let mut resolved_groups = IndexMap::new(); + resolved_groups.insert(NODE_GROUP_ALL.to_string(), node_names.to_vec()); + + let (validators, non_validators): (Vec<_>, Vec<_>) = node_names + .iter() + .cloned() + .partition(|name| is_validator(name)); + resolved_groups.insert(NODE_GROUP_VALIDATORS.to_string(), validators); + resolved_groups.insert(NODE_GROUP_NON_VALIDATORS.to_string(), non_validators); + + for (group_name, group_members) in custom_node_groups { + let expanded_group = expand_node_group(group_members, &resolved_groups); + resolved_groups.insert(group_name.clone(), expanded_group); + } + + resolved_groups +} + /// Expand the group names in the list using the existing node group definitions. -fn expand_node_group_names( +pub(crate) fn expand_node_group( names: &[String], existing_node_groups: &IndexMap>, ) -> Vec { // Use an IndexSet to avoid duplicates while preserving order let mut expanded_names = IndexSet::new(); for name in names { - if let Some(node_names) = existing_node_groups.get(name) { - expanded_names.extend(node_names.iter().cloned()); + if let Some(group_members) = existing_node_groups.get(name) { + expanded_names.extend(group_members.iter().cloned()); } else { expanded_names.insert(name.clone()); } @@ -616,7 +616,8 @@ pub(crate) fn is_validator(node_name: &str) -> bool { #[cfg(test)] mod tests { - use malachitebft_config::{LogLevel, LoggingConfig}; + use arc_node_consensus_cli::cmd::start::StartCmd; + use malachitebft_config::LogLevel; use crate::manifest::ElTxpoolConfig; @@ -699,116 +700,68 @@ mod tests { assert_eq!(manifest2.nodes["val2"].el_trusted_peers, None); } - /// el.config.trusted_peers must be an array; a scalar value should return an error. #[test] - fn test_el_trusted_peers_wrong_type_returns_error() { + fn test_custom_node_groups_roundtrip() { let toml = r#" - [nodes.val1.el.config] - trusted_peers = "val2" - [nodes.val2] + image_cl = "arc_consensus:v0.4.0" + [node_groups] + FULL_NODES = ["full1", "full2"] + TRUSTED = ["ALL_VALIDATORS", "FULL_NODES", "other_node"] + + [nodes.validator1] + [nodes.validator2] + [nodes.full1] + [nodes.full2] + [nodes.other_node] "#; - let result = Manifest::from_string(toml); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("Failed to merge toml values: array and string"), - "unexpected error: {msg}" - ); - } + let expected_custom_groups = IndexMap::from([ + ( + "FULL_NODES".to_string(), + vec!["full1".to_string(), "full2".to_string()], + ), + ( + "TRUSTED".to_string(), + vec![ + "ALL_VALIDATORS".to_string(), + "FULL_NODES".to_string(), + "other_node".to_string(), + ], + ), + ]); - #[test] - fn test_validate_cl_config_allows_translatable_key_with_new_image() { - let toml_str = r#" - image_cl = "ghcr.io/org/arc-consensus:latest" - cl.config.logging.log_level = "debug" - [nodes.val1] - "#; - let raw: RawManifest = toml::from_str(toml_str).unwrap(); - let result = Manifest::try_from(raw); - assert!( - result.is_ok(), - "translatable cl.config path should be allowed for v0.5.0+: {:?}", - result.err() - ); - } + let manifest1 = Manifest::from_string(toml).unwrap(); + assert_eq!(manifest1.node_groups, expected_custom_groups); - #[test] - fn test_validate_cl_config_rejects_untranslatable_key_with_new_image() { - let toml_str = r#" - image_cl = "ghcr.io/org/arc-consensus:v0.5.0" - cl.config.consensus.p2p.rpc_max_size = "42 Mib" - [nodes.val1] - "#; - let raw: RawManifest = toml::from_str(toml_str).unwrap(); - let result = Manifest::try_from(raw); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("no CLI flag equivalent"), - "should mention no CLI equivalent: {msg}" - ); - } + let raw1 = RawManifest::try_from(manifest1).unwrap(); + assert_eq!(raw1.node_groups, expected_custom_groups); - #[test] - fn test_validate_cl_config_allows_old_image_with_any_cl_config() { - let toml_str = r#" - image_cl = "ghcr.io/org/arc-consensus:v0.4.0" - cl.config.logging.log_level = "debug" - cl.config.consensus.p2p.rpc_max_size = "42 Mib" - [nodes.val1] - "#; - let raw: RawManifest = toml::from_str(toml_str).unwrap(); - let result = Manifest::try_from(raw); + let serialized_raw = toml::to_string(&raw1).unwrap(); assert!( - result.is_ok(), - "old image should allow all cl.config.*: {:?}", - result.err() + serialized_raw.contains("[node_groups]"), + "custom node_groups must be present in serialized TOML" ); - } - #[test] - fn test_validate_cl_config_rejects_untranslatable_when_no_image() { - let toml_str = r#" - cl.config.value_sync.max_request_size = "10 Mib" - [nodes.val1] - "#; - let raw: RawManifest = toml::from_str(toml_str).unwrap(); - let result = Manifest::try_from(raw); - assert!( - result.is_err(), - "no image_cl should assume v0.5.0+ and reject untranslatable cl.config" - ); + let raw2: RawManifest = toml::from_str(&serialized_raw).unwrap(); + assert_eq!(raw2.node_groups, expected_custom_groups); + + let manifest2 = Manifest::from_string(&serialized_raw).unwrap(); + assert_eq!(manifest2.node_groups, expected_custom_groups); } + /// el.config.trusted_peers must be an array; a scalar value should return an error. #[test] - fn test_validate_cl_config_rejects_per_node_untranslatable_key() { - let toml_str = r#" - image_cl = "ghcr.io/org/arc-consensus:latest" - [nodes.val1] - cl.config.consensus.p2p.rpc_max_size = "42 Mib" - [nodes.val2] + fn test_el_trusted_peers_wrong_type_returns_error() { + let toml = r#" + [nodes.val1.el.config] + trusted_peers = "val2" + [nodes.val2] "#; - let raw: RawManifest = toml::from_str(toml_str).unwrap(); - let result = Manifest::try_from(raw); + let result = Manifest::from_string(toml); assert!(result.is_err()); let msg = result.unwrap_err().to_string(); - assert!(msg.contains("val1"), "error should name the node: {msg}"); - } - - #[test] - fn test_validate_cl_config_allows_per_node_translatable_key() { - let toml_str = r#" - image_cl = "ghcr.io/org/arc-consensus:latest" - [nodes.val1] - cl.config.execution.persistence_backpressure = true - [nodes.val2] - "#; - let raw: RawManifest = toml::from_str(toml_str).unwrap(); - let result = Manifest::try_from(raw); assert!( - result.is_ok(), - "translatable per-node cl.config should succeed: {:?}", - result.err() + msg.contains("Failed to merge toml values: array and string"), + "unexpected error: {msg}" ); } @@ -897,13 +850,10 @@ rpc_max_size = "42 Mib" #[test] fn test_default_manifest_serialization() { let node = Node { - cl_config: ClConfigOverride { - logging: LoggingConfig { - log_level: LogLevel::Info, - ..LoggingConfig::default() - }, - ..ClConfigOverride::default() - }, + cl_config: NodeClConfig::Modern(StartCmd { + log_level: Some(LogLevel::Info), + ..StartCmd::default() + }), el_config: ElConfigOverride { txpool: crate::manifest::ElTxpoolConfig { pending_max_count: Some(2), @@ -931,7 +881,7 @@ rpc_max_size = "42 Mib" // serializes nodes as table sections [nodes.val0] rather than inline. assert_eq!( serialized, - "[nodes.val0.cl.config.logging]\nlog_level = \"info\"\n\n[nodes.val0.el.config.txpool]\npending_max_count = 2\n\n[nodes.val1]\n" + "[nodes.val0.cl.config]\nlog_level = \"info\"\n\n[nodes.val0.el.config.txpool]\npending_max_count = 2\n\n[nodes.val1]\n" ); } } diff --git a/crates/quake/src/nodes.rs b/crates/quake/src/nodes.rs index 7b78607..aa520db 100644 --- a/crates/quake/src/nodes.rs +++ b/crates/quake/src/nodes.rs @@ -100,7 +100,10 @@ impl NodesMetadata { .filter_map(|endpoint_name| node_to_el_url.get(endpoint_name).cloned()) .collect(); - let consensus_enabled = manifest_node.cl_config.consensus.enabled; + let consensus_enabled = match &manifest_node.cl_config { + crate::manifest::NodeClConfig::Modern(cmd) => !cmd.no_consensus, + crate::manifest::NodeClConfig::Legacy(cfg) => cfg.consensus.enabled, + }; let mut node = match infra_data.infra_type { InfraType::Local => { diff --git a/crates/quake/src/setup.rs b/crates/quake/src/setup.rs index 6103e80..ec3a06c 100644 --- a/crates/quake/src/setup.rs +++ b/crates/quake/src/setup.rs @@ -19,16 +19,17 @@ use std::os::unix::fs::PermissionsExt; use std::time::Duration; use std::{fs, path::Path}; -use alloy_primitives::Address; +use alloy_primitives::{address, Address}; use arc_consensus_types::{ Config, LoggingConfig, MetricsConfig, PruningConfig, RemoteSigningConfig, RpcConfig, RuntimeConfig, SigningConfig, }; use arc_node_consensus::hardcoded_config; use arc_node_consensus_cli::args::Args; -use arc_node_consensus_cli::cmd::start::StartCmd; +use arc_node_consensus_cli::cmd::start::{StartCmd, RUNTIME_SINGLE_THREADED}; use arc_node_consensus_cli::file::save_priv_validator_key; use arc_node_consensus_cli::new::generate_private_keys; +use clap::Parser; use color_eyre::eyre::{eyre, Context, Result}; use handlebars::Handlebars; use indexmap::{IndexMap, IndexSet}; @@ -51,6 +52,10 @@ const APP_METRICS_DEFAULT_PORT: usize = 29000; const APP_RPC_DEFAULT_PORT: usize = 31000; const REMOTE_SIGNER_PROXY_PORT: usize = 10340; +/// Placeholder recipient for validators that don't set `cl_suggested_fee_recipient`; +/// matches `LOCALDEV_FEE_RECIPIENT` in `tests/helpers/networks/localdev.ts`. +const QUAKE_DEFAULT_FEE_RECIPIENT: Address = address!("0x65E0a200006D4FF91bD59F9694220dafc49dbBC1"); + /// Compile system contracts and bindings pub(crate) fn generate_system_contracts(repo_root_dir: &Path, force: bool) -> Result<()> { let npm_dir = repo_root_dir.join("node_modules"); @@ -463,6 +468,13 @@ pub(crate) fn generate_app_config_files( continue; } + // Only generate config.toml for Legacy nodes (< v0.5.0). + // Modern nodes use CLI flags exclusively. + let legacy_config = match &node.cl_config { + manifest::NodeClConfig::Legacy(config) => config, + manifest::NodeClConfig::Modern(_) => continue, + }; + debug!(node=%name, dir=%node_home_dir.display(), "Generating node configuration..."); let peers_ips: Vec = if let Some(peers) = &node.cl_persistent_peers { @@ -491,10 +503,11 @@ pub(crate) fn generate_app_config_files( }; // Generate an initial config and merge it with the config customisations from the manifest by ser/deserializing to TOML values - let initial_config = generate_consensus_config(name, node, &peers_ips)?; + let initial_config = + generate_legacy_consensus_config(name, node, legacy_config, &peers_ips)?; let config = util::merge_toml_values( toml::Value::try_from(initial_config)?, - toml::Value::try_from(node.cl_config.clone())?, + toml::Value::try_from(legacy_config.clone())?, )? .try_into()?; @@ -510,10 +523,11 @@ pub(crate) fn generate_app_config_files( Ok(()) } -/// Generate a consensus configuration for a node. -fn generate_consensus_config( +/// Generate a consensus configuration for a legacy (< v0.5.0) node. +fn generate_legacy_consensus_config( name: &str, node: &manifest::Node, + cl_config: &Config, peers_ips: &[String], ) -> Result { let transport = TransportProtocol::default(); @@ -530,7 +544,7 @@ fn generate_consensus_config( let metrics_listen_addr = format!("{listen_ip}:{APP_METRICS_DEFAULT_PORT}") .parse() - .unwrap(); + .context("failed to parse metrics listen address")?; let persistent_peers_only = node.cl_persistent_peers_only; @@ -540,7 +554,7 @@ fn generate_consensus_config( load: hardcoded_config::GossipLoad::from_str_opt(node.cl_gossipsub.load.as_deref()), }; - let discovery = &node.cl_config.consensus.p2p.discovery; + let discovery = &cl_config.consensus.p2p.discovery; let discovery_enabled = discovery.enabled; let num_outbound_peers = if discovery.num_outbound_peers > 0 { discovery.num_outbound_peers @@ -582,7 +596,9 @@ fn generate_consensus_config( rpc: RpcConfig { enabled: true, // IPADDR_ANY because we need external access to it for testing. - listen_addr: format!("0.0.0.0:{APP_RPC_DEFAULT_PORT}").parse().unwrap(), + listen_addr: format!("0.0.0.0:{APP_RPC_DEFAULT_PORT}") + .parse() + .context("failed to parse RPC listen address")?, }, signing: if node.remote_signer.is_some() { SigningConfig::Remote(RemoteSigningConfig { @@ -740,163 +756,161 @@ pub(crate) fn supports_cli_flags(image_tag: Option<&str>) -> bool { /// Generate CLI flags for a node based on its configuration. /// -/// If `image_tag` is provided and the version is older than v0.5.0, returns an empty -/// Vec (older versions use config.toml instead of CLI flags). +/// For `NodeClConfig::Modern`: builds a `StartCmd` from the manifest config + +/// Node-level overrides + deployment-specific fields, then calls `to_cli_flags()`. +/// For `NodeClConfig::Legacy`: returns an empty Vec (uses config.toml instead). /// /// `follow_endpoint_urls` are pre-resolved container-accessible EL RPC URLs for follow /// mode (e.g. `http://validator-1_el:8545` for local, `http://10.0.0.5:8545` for remote). -pub(crate) fn generate_node_cli_flags( +pub(crate) fn generate_consensus_cli_flags( name: &str, node: Option<&manifest::Node>, listen_ip: &str, peers_ips: &[String], image_tag: Option<&str>, follow_endpoint_urls: &[String], - suggested_fee_recipient: Option
, -) -> Vec { - // For older versions (< v0.5.0), skip CLI flags as they use config.toml - if !supports_cli_flags(image_tag) { - debug!("Skipping CLI flags for node {name}: image version {image_tag:?} does not support CLI flags"); - return Vec::new(); - } - - let transport = TransportProtocol::default(); - - let mut flags = vec![ - format!("--moniker={name}"), - format!( - "--p2p.addr={}", - transport.multiaddr(listen_ip, APP_CONSENSUS_DEFAULT_PORT) - ), - ]; - - // Add persistent peers if any - if !peers_ips.is_empty() { - let cl_persistent_peers: Vec = peers_ips - .iter() - .map(|ip| { - transport - .multiaddr(ip, APP_CONSENSUS_DEFAULT_PORT) - .to_string() - }) - .collect(); - - flags.push(format!( - "--p2p.persistent-peers={}", - cl_persistent_peers.join(",") - )); - } - - // Add metrics - flags.push(format!("--metrics={listen_ip}:{APP_METRICS_DEFAULT_PORT}")); - - // Add RPC - flags.push(format!("--rpc.addr=0.0.0.0:{APP_RPC_DEFAULT_PORT}")); +) -> Result> { + let Some(node) = node else { + return generate_default_consensus_cli_flags(name, listen_ip, peers_ips, image_tag); + }; - // Add runtime - flags.push("--runtime.flavor=single-threaded".to_string()); + match &node.cl_config { + manifest::NodeClConfig::Legacy(_) => { + debug!("Skipping CLI flags for legacy CL node {name}"); + Ok(Vec::new()) + } + manifest::NodeClConfig::Modern(start_cmd) => { + let transport = TransportProtocol::default(); - // Enable value sync by default - flags.push("--value-sync".to_string()); + let mut cmd = start_cmd.clone(); - if let Some(addr) = suggested_fee_recipient { - flags.push(format!("--suggested-fee-recipient={addr}")); - } + cmd.moniker = Some(name.to_string()); + cmd.p2p_addr = transport.multiaddr(listen_ip, APP_CONSENSUS_DEFAULT_PORT); + cmd.metrics = Some( + format!("{listen_ip}:{APP_METRICS_DEFAULT_PORT}") + .parse() + .context("failed to parse metrics listen address")?, + ); + cmd.rpc_addr = Some( + format!("0.0.0.0:{APP_RPC_DEFAULT_PORT}") + .parse() + .context("failed to parse RPC listen address")?, + ); - if let Some(node) = node { - let cl = &node.cl_config; + if !peers_ips.is_empty() { + cmd.p2p_persistent_peers = peers_ips + .iter() + .map(|ip| transport.multiaddr(ip, APP_CONSENSUS_DEFAULT_PORT)) + .collect(); + } - // Always set log level and format to ensure consistent logs for testing, debugging, and log collection. - flags.push(format!("--log-level={}", cl.logging.log_level)); - flags.push(format!("--log-format={}", cl.logging.log_format)); + cmd.p2p_persistent_peers_only = node.cl_persistent_peers_only; + cmd.gossipsub_explicit_peering = node.cl_gossipsub.explicit_peering; + cmd.gossipsub_mesh_prioritization = node.cl_gossipsub.mesh_prioritization; + cmd.gossipsub_load = node.cl_gossipsub.load.clone(); - // Add persistent-peers-only if enabled - if node.cl_persistent_peers_only { - flags.push("--p2p.persistent-peers-only".to_string()); - } + if node.node_type == manifest::NodeType::Validator { + cmd.validator = true; + } - // Add gossipsub flags - if node.cl_gossipsub.explicit_peering { - flags.push("--gossipsub.explicit-peering".to_string()); - } - if node.cl_gossipsub.mesh_prioritization { - flags.push("--gossipsub.mesh-prioritization".to_string()); - } - if let Some(ref load) = node.cl_gossipsub.load { - flags.push(format!("--gossipsub.load={load}")); - } + // `--validator` requires a non-zero `--suggested-fee-recipient`. When + // validator scenarios omit `cl_suggested_fee_recipient`, fall back to + // the localdev placeholder so consensus can still start. + let effective_fee_recipient = node.cl_suggested_fee_recipient.or_else(|| { + (node.node_type == manifest::NodeType::Validator) + .then_some(QUAKE_DEFAULT_FEE_RECIPIENT) + }); + if let Some(addr) = effective_fee_recipient { + cmd.suggested_fee_recipient = Some(addr.into()); + } - // Translate cl.config.* typed fields to CLI flags. - // Defaults come from the CL CLI itself (StartCmd) so we only emit - // flags whose values differ from what the binary would use anyway. - let cli_defaults = StartCmd::default(); + if node.remote_signer.is_some() { + cmd.signing_remote = Some(format!( + "http://{name}-signer-proxy:{REMOTE_SIGNER_PROXY_PORT}" + )); + } - let discovery = &cl.consensus.p2p.discovery; - if discovery.enabled { - flags.push("--discovery".to_string()); - } - if discovery.num_outbound_peers != cli_defaults.discovery_num_outbound_peers { - flags.push(format!( - "--discovery.num-outbound-peers={}", - discovery.num_outbound_peers - )); - } - if discovery.num_inbound_peers != cli_defaults.discovery_num_inbound_peers { - flags.push(format!( - "--discovery.num-inbound-peers={}", - discovery.num_inbound_peers - )); - } + if cmd.prune_certificates_distance == 0 && cmd.prune_certificates_before == 0 { + if let Some(preset) = node.cl_prune_preset { + match preset { + manifest::ClPruningPreset::Full => cmd.full = true, + manifest::ClPruningPreset::Minimal => cmd.minimal = true, + } + } + } - if !cl.consensus.enabled { - flags.push("--no-consensus".to_string()); - } else if node.node_type == manifest::NodeType::Validator { - flags.push("--validator".to_string()); - } + if node.follow { + cmd.follow = true; + cmd.follow_endpoints = follow_endpoint_urls + .iter() + .map(|url| { + url.parse() + .context(format!("invalid follow endpoint URL: {url}")) + }) + .collect::>>()?; + } - if node.remote_signer.is_some() { - flags.push(format!( - "--signing.remote=http://{name}-signer-proxy:{REMOTE_SIGNER_PROXY_PORT}" - )); + let flags = cmd.to_cli_flags(); + validate_generated_cl_flags(&flags)?; + Ok(flags) } + } +} - if cl.execution.persistence_backpressure { - flags.push("--execution-persistence-backpressure".to_string()); - flags.push(format!( - "--execution-persistence-backpressure-threshold={}", - cl.execution.persistence_backpressure_threshold, - )); - } +/// Generate default CLI flags when no node config is provided. +/// Used for nodes without manifest entries that use the modern CL. +fn generate_default_consensus_cli_flags( + name: &str, + listen_ip: &str, + peers_ips: &[String], + image_tag: Option<&str>, +) -> Result> { + if !supports_cli_flags(image_tag) { + return Ok(Vec::new()); + } - // Pruning: explicit distance/before from cl.config wins over cl_prune_preset. - let prune = &cl.prune; - if prune.certificates_distance > 0 { - flags.push(format!( - "--prune.certificates.distance={}", - prune.certificates_distance - )); - } else if prune.certificates_before > arc_consensus_types::Height::new(0) { - flags.push(format!( - "--prune.certificates.before={}", - prune.certificates_before - )); - } else if let Some(preset) = node.cl_prune_preset { - flags.push(preset.to_string()); - } + let transport = TransportProtocol::default(); + let cmd = StartCmd { + moniker: Some(name.to_string()), + p2p_addr: transport.multiaddr(listen_ip, APP_CONSENSUS_DEFAULT_PORT), + metrics: Some( + format!("{listen_ip}:{APP_METRICS_DEFAULT_PORT}") + .parse() + .context("failed to parse metrics listen address")?, + ), + rpc_addr: Some( + format!("0.0.0.0:{APP_RPC_DEFAULT_PORT}") + .parse() + .context("failed to parse RPC listen address")?, + ), + // Use single-threaded runtime for lower resource usage when running local devnet. + runtime_flavor: RUNTIME_SINGLE_THREADED.to_string(), + p2p_persistent_peers: peers_ips + .iter() + .map(|ip| transport.multiaddr(ip, APP_CONSENSUS_DEFAULT_PORT)) + .collect(), + ..StartCmd::default() + }; - // Follow mode - if node.follow { - flags.push("--follow".to_string()); - for url in follow_endpoint_urls { - flags.push(format!("--follow.endpoint={url}")); - } - } - } else { - flags.push("--log-level=debug".to_string()); - flags.push("--log-format=plaintext".to_string()); - } + let flags = cmd.to_cli_flags(); + validate_generated_cl_flags(&flags)?; + Ok(flags) +} - flags +/// Validate generated CL CLI flags by trial-parsing them against the actual +/// Args/StartCmd parser. Any flag accepted by the CL binary is automatically valid. +fn validate_generated_cl_flags(flags: &[String]) -> Result<()> { + let trial_args = std::iter::once("arc-node-consensus") + .chain(std::iter::once("start")) + .chain(flags.iter().map(String::as_str)); + + Args::try_parse_from(trial_args).map_err(|e| { + eyre!( + "Generated CL flags are invalid — a flag may be missing from StartCmd \ + or have an incompatible value: {e}" + ) + })?; + Ok(()) } #[derive(Serialize)] @@ -1408,9 +1422,9 @@ mod tests { } #[test] - fn generate_node_cli_flags_includes_required_flags() { - let flags = - generate_node_cli_flags("validator-1", None, "172.19.0.5", &[], None, &[], None); + fn generate_consensus_cli_flags_includes_required_flags() { + let flags = generate_consensus_cli_flags("validator-1", None, "172.19.0.5", &[], None, &[]) + .unwrap(); let flags_str = flags.join(" "); assert!(flags_str.contains("--moniker")); @@ -1420,10 +1434,11 @@ mod tests { } #[test] - fn generate_node_cli_flags_with_cl_persistent_peers() { + fn generate_consensus_cli_flags_with_cl_persistent_peers() { let peers = vec!["172.19.0.6".to_string(), "172.19.0.7".to_string()]; let flags = - generate_node_cli_flags("validator-1", None, "172.19.0.5", &peers, None, &[], None); + generate_consensus_cli_flags("validator-1", None, "172.19.0.5", &peers, None, &[]) + .unwrap(); let flags_str = flags.join(" "); assert!(flags_str.contains("--p2p.persistent-peers")); @@ -1432,9 +1447,9 @@ mod tests { } #[test] - fn generate_node_cli_flags_without_persistent_peers() { - let flags = - generate_node_cli_flags("validator-1", None, "172.19.0.5", &[], None, &[], None); + fn generate_consensus_cli_flags_without_persistent_peers() { + let flags = generate_consensus_cli_flags("validator-1", None, "172.19.0.5", &[], None, &[]) + .unwrap(); let flags_str = flags.join(" "); // Should not contain persistent peers flag when empty @@ -1442,9 +1457,9 @@ mod tests { } #[test] - fn generate_node_cli_flags_includes_metrics() { - let flags = - generate_node_cli_flags("validator-1", None, "172.19.0.5", &[], None, &[], None); + fn generate_consensus_cli_flags_includes_metrics() { + let flags = generate_consensus_cli_flags("validator-1", None, "172.19.0.5", &[], None, &[]) + .unwrap(); let flags_str = flags.join(" "); assert!(flags_str.contains("--metrics")); @@ -1452,9 +1467,9 @@ mod tests { } #[test] - fn generate_node_cli_flags_includes_rpc() { - let flags = - generate_node_cli_flags("validator-1", None, "172.19.0.5", &[], None, &[], None); + fn generate_consensus_cli_flags_includes_rpc() { + let flags = generate_consensus_cli_flags("validator-1", None, "172.19.0.5", &[], None, &[]) + .unwrap(); let flags_str = flags.join(" "); assert!(flags_str.contains("--rpc.addr")); @@ -1462,89 +1477,87 @@ mod tests { } #[test] - fn generate_node_cli_flags_includes_value_sync() { - let flags = - generate_node_cli_flags("validator-1", None, "172.19.0.5", &[], None, &[], None); + fn generate_consensus_cli_flags_omits_default_value_sync() { + // value_sync defaults to true in StartCmd, so the flag is not emitted + // (the binary uses it by default). Only emitted when explicitly disabled. + let flags = generate_consensus_cli_flags("validator-1", None, "172.19.0.5", &[], None, &[]) + .unwrap(); let flags_str = flags.join(" "); - assert!(flags_str.contains("--value-sync")); + assert!(!flags_str.contains("--value-sync")); } #[test] - fn generate_node_cli_flags_logging_defaults_when_no_node() { + fn generate_consensus_cli_flags_emits_suggested_fee_recipient() { + use alloy_primitives::address; + let recipient = address!("0x98e503f35D0a019cB0a251aD243a4cCFCF371F46"); + let node = manifest::Node { + cl_suggested_fee_recipient: Some(recipient), + ..Default::default() + }; let flags = - generate_node_cli_flags("validator-1", None, "172.19.0.5", &[], None, &[], None); - - let flags_str = flags.join(" "); - assert!(flags_str.contains("--log-level=debug")); - assert!(flags_str.contains("--log-format=plaintext")); + generate_consensus_cli_flags("validator-1", Some(&node), "172.19.0.5", &[], None, &[]) + .unwrap(); + let flags_str = flags.join(" ").to_lowercase(); + assert!( + flags_str + .contains("--suggested-fee-recipient=0x98e503f35d0a019cb0a251ad243a4ccfcf371f46"), + "missing suggested-fee-recipient: {flags_str}" + ); } #[test] - fn generate_node_cli_flags_logging_from_node_config() { - use malachitebft_config::{LogFormat, LogLevel}; - - let mut node = manifest::Node::default(); - node.cl_config.logging.log_level = LogLevel::Info; - node.cl_config.logging.log_format = LogFormat::Json; - - let flags = generate_node_cli_flags( - "validator-1", - Some(&node), - "172.19.0.5", - &[], - None, - &[], - None, - ); - + fn generate_consensus_cli_flags_omits_suggested_fee_recipient_when_none() { + let flags = generate_consensus_cli_flags("validator-1", None, "172.19.0.5", &[], None, &[]) + .unwrap(); let flags_str = flags.join(" "); - assert!(flags_str.contains("--log-level=info")); - assert!(flags_str.contains("--log-format=json")); + assert!(!flags_str.contains("--suggested-fee-recipient")); } #[test] - fn generate_node_cli_flags_emits_suggested_fee_recipient() { - use alloy_primitives::address; - let recipient = address!("0x98e503f35D0a019cB0a251aD243a4cCFCF371F46"); - let flags = generate_node_cli_flags( - "validator-1", - None, - "172.19.0.5", - &[], - None, - &[], - Some(recipient), + fn generate_consensus_cli_flags_falls_back_to_default_for_validator_without_recipient() { + let node = manifest::Node { + node_type: manifest::NodeType::Validator, + ..Default::default() + }; + let flags = + generate_consensus_cli_flags("val-1", Some(&node), "172.19.0.5", &[], None, &[]) + .unwrap(); + let flags_str = flags.join(" ").to_lowercase(); + let expected = + format!("--suggested-fee-recipient={QUAKE_DEFAULT_FEE_RECIPIENT}").to_lowercase(); + assert!( + flags_str.contains(&expected), + "expected default fee recipient fallback for validator: {flags_str}" ); - let flags_str = flags.join(" "); - assert!(flags_str.contains(&format!("--suggested-fee-recipient={recipient}"))); } #[test] - fn generate_node_cli_flags_omits_suggested_fee_recipient_when_none() { - let flags = - generate_node_cli_flags("validator-1", None, "172.19.0.5", &[], None, &[], None); + fn generate_consensus_cli_flags_no_fallback_for_non_validator_without_recipient() { + let node = manifest::Node { + node_type: manifest::NodeType::NonValidator, + ..Default::default() + }; + let flags = generate_consensus_cli_flags("fn-1", Some(&node), "172.19.0.5", &[], None, &[]) + .unwrap(); let flags_str = flags.join(" "); - assert!(!flags_str.contains("--suggested-fee-recipient")); + assert!( + !flags_str.contains("--suggested-fee-recipient"), + "should not emit flag for non-validator without explicit recipient: {flags_str}" + ); } #[test] - fn generate_node_cli_flags_with_remote_signer() { + fn generate_consensus_cli_flags_with_remote_signer() { let node = manifest::Node { node_type: manifest::NodeType::Validator, remote_signer: Some(manifest::RemoteKeyId::new(1).unwrap()), ..Default::default() }; - let flags = generate_node_cli_flags( - "validator-1", - Some(&node), - "172.19.0.5", - &[], - None, - &[], - None, - ); + let flags = + generate_consensus_cli_flags("validator-1", Some(&node), "172.19.0.5", &[], None, &[]) + .unwrap(); let flags_str = flags.join(" "); assert!(flags_str.contains("--signing.remote")); @@ -1552,59 +1565,47 @@ mod tests { } #[test] - fn generate_node_cli_flags_without_remote_signer() { + fn generate_consensus_cli_flags_without_remote_signer() { let node = manifest::Node::default(); - let flags = generate_node_cli_flags( - "validator-1", - Some(&node), - "172.19.0.5", - &[], - None, - &[], - None, - ); + let flags = + generate_consensus_cli_flags("validator-1", Some(&node), "172.19.0.5", &[], None, &[]) + .unwrap(); let flags_str = flags.join(" "); assert!(!flags_str.contains("--signing.remote")); } #[test] - fn generate_node_cli_flags_with_persistent_peers_only() { + fn generate_consensus_cli_flags_with_persistent_peers_only() { let node = manifest::Node { cl_persistent_peers_only: true, ..Default::default() }; let peers = vec!["172.19.0.6".to_string()]; - let flags = generate_node_cli_flags( + let flags = generate_consensus_cli_flags( "validator-1", Some(&node), "172.19.0.5", &peers, None, &[], - None, - ); + ) + .unwrap(); let flags_str = flags.join(" "); assert!(flags_str.contains("--p2p.persistent-peers-only")); } #[test] - fn generate_node_cli_flags_without_persistent_peers_only() { + fn generate_consensus_cli_flags_without_persistent_peers_only() { let node = manifest::Node::default(); - let flags = generate_node_cli_flags( - "validator-1", - Some(&node), - "172.19.0.5", - &[], - None, - &[], - None, - ); + let flags = + generate_consensus_cli_flags("validator-1", Some(&node), "172.19.0.5", &[], None, &[]) + .unwrap(); let flags_str = flags.join(" "); assert!(!flags_str.contains("--p2p.persistent-peers-only")); } #[test] - fn generate_node_cli_flags_with_gossipsub_explicit_peering() { + fn generate_consensus_cli_flags_with_gossipsub_explicit_peering() { let node = manifest::Node { cl_gossipsub: manifest::ClGossipSubConfig { explicit_peering: true, @@ -1612,21 +1613,15 @@ mod tests { }, ..Default::default() }; - let flags = generate_node_cli_flags( - "validator-1", - Some(&node), - "172.19.0.5", - &[], - None, - &[], - None, - ); + let flags = + generate_consensus_cli_flags("validator-1", Some(&node), "172.19.0.5", &[], None, &[]) + .unwrap(); let flags_str = flags.join(" "); assert!(flags_str.contains("--gossipsub.explicit-peering")); } #[test] - fn generate_node_cli_flags_with_gossipsub_mesh_prioritization() { + fn generate_consensus_cli_flags_with_gossipsub_mesh_prioritization() { let node = manifest::Node { cl_gossipsub: manifest::ClGossipSubConfig { mesh_prioritization: true, @@ -1634,21 +1629,15 @@ mod tests { }, ..Default::default() }; - let flags = generate_node_cli_flags( - "validator-1", - Some(&node), - "172.19.0.5", - &[], - None, - &[], - None, - ); + let flags = + generate_consensus_cli_flags("validator-1", Some(&node), "172.19.0.5", &[], None, &[]) + .unwrap(); let flags_str = flags.join(" "); assert!(flags_str.contains("--gossipsub.mesh-prioritization")); } #[test] - fn generate_node_cli_flags_with_gossipsub_load() { + fn generate_consensus_cli_flags_with_gossipsub_load() { let node = manifest::Node { cl_gossipsub: manifest::ClGossipSubConfig { load: Some("high".to_string()), @@ -1656,31 +1645,19 @@ mod tests { }, ..Default::default() }; - let flags = generate_node_cli_flags( - "validator-1", - Some(&node), - "172.19.0.5", - &[], - None, - &[], - None, - ); + let flags = + generate_consensus_cli_flags("validator-1", Some(&node), "172.19.0.5", &[], None, &[]) + .unwrap(); let flags_str = flags.join(" "); assert!(flags_str.contains("--gossipsub.load=high")); } #[test] - fn generate_node_cli_flags_without_gossipsub_overrides() { + fn generate_consensus_cli_flags_without_gossipsub_overrides() { let node = manifest::Node::default(); - let flags = generate_node_cli_flags( - "validator-1", - Some(&node), - "172.19.0.5", - &[], - None, - &[], - None, - ); + let flags = + generate_consensus_cli_flags("validator-1", Some(&node), "172.19.0.5", &[], None, &[]) + .unwrap(); let flags_str = flags.join(" "); assert!(!flags_str.contains("--gossipsub.explicit-peering")); assert!(!flags_str.contains("--gossipsub.mesh-prioritization")); @@ -1792,18 +1769,17 @@ mod tests { } #[test] - fn generate_node_cli_flags_includes_pruning_distance() { - let mut node = manifest::Node::default(); - node.cl_config.prune.certificates_distance = 500; - let flags = generate_node_cli_flags( - "validator-1", - Some(&node), - "172.19.0.5", - &[], - None, - &[], - None, - ); + fn generate_consensus_cli_flags_includes_pruning_distance() { + let node = manifest::Node { + cl_config: manifest::NodeClConfig::Modern(StartCmd { + prune_certificates_distance: 500, + ..StartCmd::default() + }), + ..Default::default() + }; + let flags = + generate_consensus_cli_flags("validator-1", Some(&node), "172.19.0.5", &[], None, &[]) + .unwrap(); let flags_str = flags.join(" "); assert!( flags_str.contains("--prune.certificates.distance=500"), @@ -1816,18 +1792,17 @@ mod tests { } #[test] - fn generate_node_cli_flags_includes_pruning_before() { - let mut node = manifest::Node::default(); - node.cl_config.prune.certificates_before = arc_consensus_types::Height::new(100); - let flags = generate_node_cli_flags( - "validator-1", - Some(&node), - "172.19.0.5", - &[], - None, - &[], - None, - ); + fn generate_consensus_cli_flags_includes_pruning_before() { + let node = manifest::Node { + cl_config: manifest::NodeClConfig::Modern(StartCmd { + prune_certificates_before: 100, + ..StartCmd::default() + }), + ..Default::default() + }; + let flags = + generate_consensus_cli_flags("validator-1", Some(&node), "172.19.0.5", &[], None, &[]) + .unwrap(); let flags_str = flags.join(" "); assert!( flags_str.contains("--prune.certificates.before=100"), @@ -1840,7 +1815,7 @@ mod tests { } #[test] - fn generate_node_cli_flags_no_prune_when_none_set() { + fn generate_consensus_cli_flags_no_prune_when_none_set() { for node_type in [ manifest::NodeType::Validator, manifest::NodeType::NonValidator, @@ -1849,15 +1824,15 @@ mod tests { node_type, ..Default::default() }; - let flags = generate_node_cli_flags( + let flags = generate_consensus_cli_flags( "validator-1", Some(&node), "172.19.0.5", &[], None, &[], - None, - ); + ) + .unwrap(); let flags_str = flags.join(" "); assert!( !flags_str.contains("--prune.certificates.distance"), @@ -1879,18 +1854,17 @@ mod tests { } #[test] - fn generate_node_cli_flags_prune_distance_emitted() { - let mut node = manifest::Node::default(); - node.cl_config.prune.certificates_distance = 500; - let flags = generate_node_cli_flags( - "validator-1", - Some(&node), - "172.19.0.5", - &[], - None, - &[], - None, - ); + fn generate_consensus_cli_flags_prune_distance_emitted() { + let node = manifest::Node { + cl_config: manifest::NodeClConfig::Modern(StartCmd { + prune_certificates_distance: 500, + ..StartCmd::default() + }), + ..Default::default() + }; + let flags = + generate_consensus_cli_flags("validator-1", Some(&node), "172.19.0.5", &[], None, &[]) + .unwrap(); let flags_str = flags.join(" "); assert!( flags_str.contains("--prune.certificates.distance=500"), @@ -1899,39 +1873,40 @@ mod tests { } #[test] - fn generate_node_cli_flags_returns_empty_for_old_version() { - let flags = generate_node_cli_flags( + fn generate_consensus_cli_flags_returns_empty_for_old_version() { + let flags = generate_consensus_cli_flags( "validator-1", None, "172.19.0.5", &[], Some("arc_consensus:v0.4.0"), &[], - None, - ); + ) + .unwrap(); assert!(flags.is_empty()); } #[test] - fn generate_node_cli_flags_returns_flags_for_new_version() { - let flags = generate_node_cli_flags( + fn generate_consensus_cli_flags_returns_flags_for_new_version() { + let flags = generate_consensus_cli_flags( "validator-1", None, "172.19.0.5", &[], Some("arc_consensus:v0.5.0"), &[], - None, - ); + ) + .unwrap(); assert!(!flags.is_empty()); assert!(flags.contains(&"--moniker=validator-1".to_string())); } #[test] - fn generate_node_cli_flags_includes_follow_mode() { + fn generate_consensus_cli_flags_includes_follow_mode() { let node = manifest::Node { + node_type: manifest::NodeType::NonValidator, follow: true, ..Default::default() }; @@ -1939,15 +1914,9 @@ mod tests { "http://validator-1_el:8545".to_string(), "http://validator-2_el:8545".to_string(), ]; - let flags = generate_node_cli_flags( - "rpc-1", - Some(&node), - "172.19.0.5", - &[], - None, - &endpoints, - None, - ); + let flags = + generate_consensus_cli_flags("rpc-1", Some(&node), "172.19.0.5", &[], None, &endpoints) + .unwrap(); let flags_str = flags.join(" "); assert!( flags_str.contains("--follow"), @@ -1964,18 +1933,12 @@ mod tests { } #[test] - fn generate_node_cli_flags_no_follow_when_not_enabled() { + fn generate_consensus_cli_flags_no_follow_when_not_enabled() { let node = manifest::Node::default(); let endpoints = vec!["http://validator-1_el:8545".to_string()]; - let flags = generate_node_cli_flags( - "rpc-1", - Some(&node), - "172.19.0.5", - &[], - None, - &endpoints, - None, - ); + let flags = + generate_consensus_cli_flags("rpc-1", Some(&node), "172.19.0.5", &[], None, &endpoints) + .unwrap(); let flags_str = flags.join(" "); assert!( !flags_str.contains("--follow"), @@ -1984,11 +1947,18 @@ mod tests { } #[test] - fn generate_node_cli_flags_includes_no_consensus() { - let mut node = manifest::Node::default(); - node.cl_config.consensus.enabled = false; + fn generate_consensus_cli_flags_includes_no_consensus() { + let node = manifest::Node { + node_type: manifest::NodeType::NonValidator, + cl_config: manifest::NodeClConfig::Modern(StartCmd { + no_consensus: true, + ..StartCmd::default() + }), + ..Default::default() + }; let flags = - generate_node_cli_flags("rpc-1", Some(&node), "172.19.0.5", &[], None, &[], None); + generate_consensus_cli_flags("rpc-1", Some(&node), "172.19.0.5", &[], None, &[]) + .unwrap(); let flags_str = flags.join(" "); assert!( flags_str.contains("--no-consensus"), @@ -1997,13 +1967,14 @@ mod tests { } #[test] - fn generate_node_cli_flags_includes_validator_for_validator_nodes() { + fn generate_consensus_cli_flags_includes_validator_for_validator_nodes() { let node = manifest::Node { node_type: manifest::NodeType::Validator, ..Default::default() }; let flags = - generate_node_cli_flags("val-1", Some(&node), "172.19.0.5", &[], None, &[], None); + generate_consensus_cli_flags("val-1", Some(&node), "172.19.0.5", &[], None, &[]) + .unwrap(); let flags_str = flags.join(" "); assert!( flags_str.contains("--validator"), @@ -2012,13 +1983,13 @@ mod tests { } #[test] - fn generate_node_cli_flags_omits_validator_for_non_validator_nodes() { + fn generate_consensus_cli_flags_omits_validator_for_non_validator_nodes() { let node = manifest::Node { node_type: manifest::NodeType::NonValidator, ..Default::default() }; - let flags = - generate_node_cli_flags("fn-1", Some(&node), "172.19.0.5", &[], None, &[], None); + let flags = generate_consensus_cli_flags("fn-1", Some(&node), "172.19.0.5", &[], None, &[]) + .unwrap(); let flags_str = flags.join(" "); assert!( !flags_str.contains("--validator"), @@ -2027,13 +1998,14 @@ mod tests { } #[test] - fn generate_node_cli_flags_includes_cl_prune_preset() { + fn generate_consensus_cli_flags_includes_cl_prune_preset() { let node = manifest::Node { cl_prune_preset: Some(manifest::ClPruningPreset::Full), ..Default::default() }; let flags = - generate_node_cli_flags("val-1", Some(&node), "172.19.0.5", &[], None, &[], None); + generate_consensus_cli_flags("val-1", Some(&node), "172.19.0.5", &[], None, &[]) + .unwrap(); let flags_str = flags.join(" "); assert!( flags_str.contains("--full"), @@ -2042,21 +2014,18 @@ mod tests { } #[test] - fn generate_node_cli_flags_prune_distance_overrides_preset() { - let mut node = manifest::Node { + fn generate_consensus_cli_flags_prune_distance_overrides_preset() { + let node = manifest::Node { cl_prune_preset: Some(manifest::ClPruningPreset::Minimal), + cl_config: manifest::NodeClConfig::Modern(StartCmd { + prune_certificates_distance: 500, + ..StartCmd::default() + }), ..Default::default() }; - node.cl_config.prune.certificates_distance = 500; - let flags = generate_node_cli_flags( - "validator-1", - Some(&node), - "172.19.0.5", - &[], - None, - &[], - None, - ); + let flags = + generate_consensus_cli_flags("validator-1", Some(&node), "172.19.0.5", &[], None, &[]) + .unwrap(); let flags_str = flags.join(" "); assert!( flags_str.contains("--prune.certificates.distance=500"), @@ -2068,6 +2037,12 @@ mod tests { ); } + #[test] + fn validate_generated_cl_flags_rejects_unknown_flags() { + let result = validate_generated_cl_flags(&["--nonexistent-flag".to_string()]); + assert!(result.is_err()); + } + #[test] fn generate_jwt_secret_creates_file() { let dir = tempdir().unwrap(); diff --git a/crates/quake/src/testnet.rs b/crates/quake/src/testnet.rs index 0b1b6f1..928cb81 100644 --- a/crates/quake/src/testnet.rs +++ b/crates/quake/src/testnet.rs @@ -14,15 +14,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -use color_eyre::eyre::{self, bail, eyre, Context, Result}; -use indexmap::IndexMap; -use itertools::Itertools; -use rand::Rng; -use spammer::{self, Spammer}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use std::{env, fs}; + +use color_eyre::eyre::{self, bail, eyre, Context, Result}; +use indexmap::IndexMap; +use itertools::Itertools; +use rand::Rng; use tokio::task::JoinHandle; use tracing::{debug, info, warn}; @@ -40,6 +40,7 @@ use crate::valset::ValidatorPowerUpdate; use crate::wait::{check_ws_connectable, wait_for_nodes, wait_for_nodes_sync, wait_for_rounds}; use crate::{build, genesis, info as info_mod, latency, monitor, setup, shell}; use crate::{DownloadSubcommand, InfoSubcommand, RemoteSubcommand, SSMSubcommand}; +use spammer::{self, Spammer}; pub(crate) const QUAKE_DIR: &str = ".quake"; pub(crate) const LAST_MANIFEST_FILENAME: &str = ".last_manifest"; @@ -437,15 +438,14 @@ impl Testnet { .collect(); // Generate CL CLI flags including persistent peers - let cl_cli_flags = setup::generate_node_cli_flags( + let cl_cli_flags = setup::generate_consensus_cli_flags( node_name, Some(node), "0.0.0.0", // Remote nodes listen on all interfaces &peers_ips, Some(self.images.cl.as_str()), &follow_endpoint_urls, - None, - ); + )?; let compose_data = setup::ComposeTemplateDataRemote { compose_project_name: COMPOSE_PROJECT_NAME.to_string(), @@ -574,13 +574,6 @@ impl Testnet { } } - if let Ok(remote_infra) = self.remote_infra() { - match remote_infra.start_monitoring() { - Ok(output) => info!(%output, "✅ Monitoring started on CC"), - Err(err) => warn!("⚠️ Failed to start monitoring on CC: {err:#}"), - } - } - info!(dir=%self.dir.display(), "✅ Testnet started"); println!("📁 Testnet files: {}", self.dir.display()); if monitoring { @@ -1121,25 +1114,11 @@ impl Testnet { } } RemoteSubcommand::Load { args } => { - // Run `spammer` from the Control Center server (backpressure mode, the default) - let cmd = ["./spammer.sh", "nodes", "--nodes-path", "nodes.json"]; - let mut cmd: Vec = cmd.iter().map(|s| s.to_string()).collect(); - cmd.extend(args); - + let cmd = build_remote_spammer_cmd(&self.manifest, &args, false)?; infra.ssh_cc(&cmd.join(" "), false) } RemoteSubcommand::Spam { args } => { - // Run `spammer` from the Control Center server (fire-and-forget mode) - let cmd = [ - "./spammer.sh", - "nodes", - "--nodes-path", - "nodes.json", - "--fire-and-forget", - ]; - let mut cmd: Vec = cmd.iter().map(|s| s.to_string()).collect(); - cmd.extend(args); - + let cmd = build_remote_spammer_cmd(&self.manifest, &args, true)?; infra.ssh_cc(&cmd.join(" "), false) } RemoteSubcommand::Export { @@ -1201,15 +1180,7 @@ impl Testnet { /// Generate and send transaction load to a node pub async fn load(&self, target_nodes: Vec, config: &spammer::Config) -> Result<()> { - // Validate arguments - self.manifest.contain_nodes(&target_nodes)?; - - let mut target_nodes = self.nodes_metadata.expand_to_nodes_list(&target_nodes)?; - - // If no target nodes are provided, use all nodes - if target_nodes.is_empty() { - target_nodes = self.nodes_metadata.node_names(); - } + let target_nodes = resolve_load_target_nodes(&self.manifest, &target_nodes)?; // Build EL WebSocket URLs of target nodes let target_ws_urls = self.nodes_metadata.to_execution_ws_urls(&target_nodes); @@ -1337,30 +1308,26 @@ impl Testnet { }) .unwrap_or_default(); - let fee_recipient = node_config.and_then(|nc| nc.cl_suggested_fee_recipient); - // Generate CLI flags for the consensus layer - let cli_flags = setup::generate_node_cli_flags( + let cli_flags = setup::generate_consensus_cli_flags( name, node_config, &listen_ip, &peers_ips, Some(self.images.cl.as_str()), &follow_endpoint_urls, - fee_recipient, - ); + )?; node_metadata.consensus.set_cli_flags(cli_flags); // Generate CLI flags for the consensus layer after upgrade - let cli_flags = setup::generate_node_cli_flags( + let cli_flags = setup::generate_consensus_cli_flags( name, node_config, &listen_ip, &peers_ips, self.images.cl_upgrade.as_deref(), &follow_endpoint_urls, - fee_recipient, - ); + )?; node_metadata.consensus.set_cli_flags_upgraded(cli_flags); } @@ -1379,3 +1346,375 @@ impl Testnet { } } } + +/// Resolve local `quake load` and `quake spam` targets to concrete node names. +/// +/// This helper keeps load/spam selector semantics aligned with the manifest: +/// an empty selector list means "all nodes", while a non-empty list may +/// contain exact node names or manifest node-group names. +/// +/// Explicit selectors must resolve to at least one node. Load generation +/// against an empty target set is treated as an error. +fn resolve_load_target_nodes(manifest: &Manifest, selectors: &[NodeName]) -> Result> { + if selectors.is_empty() { + return Ok(manifest.nodes.keys().cloned().collect()); + } + + let target_nodes = manifest.resolve_node_selectors(selectors)?; + if target_nodes.is_empty() { + bail!("load/spam targets resolved to no nodes"); + } + + Ok(target_nodes) +} + +/// Split remote `quake load/spam...` args into spammer flags and targets. +/// +/// Quake only modifies the `--targets` segment. All other args are passed through +/// to the remote `spammer` process unchanged. +/// +/// Examples: +/// - `["--rate", "42", "--targets", "validator1,RPC_NODES"]` becomes: +/// - forwarded args: `["--rate", "42"]` +/// - target selectors: `["validator1", "RPC_NODES"]` +/// - `["--targets=validator1,RPC_NODES", "--time", "5"]` becomes: +/// - forwarded args: `["--time", "5"]` +/// - target selectors: `["validator1", "RPC_NODES"]` +/// - `["--targets", "validator1", "--time", "5"]` becomes: +/// - forwarded args: `["--time", "5"]` +/// - target selectors: `["validator1"]` +/// +/// The returned selectors are later expanded against the manifest, and the final +/// remote spammer command gets a normalized +/// `--targets ...` segment appended at the end. +fn split_remote_targets(args: &[String]) -> Result<(Vec, Vec)> { + let mut forwarded_args = Vec::new(); + let mut target_selectors = Vec::new(); + let mut index = 0; + + while index < args.len() { + let arg = &args[index]; + + if arg != "--targets" && !arg.starts_with("--targets=") { + forwarded_args.push(arg.clone()); + index += 1; + continue; + } + + let targets = if let Some(args_targets) = arg.strip_prefix("--targets=") { + if args_targets.is_empty() { + bail!("remote load/spam `--targets` requires a comma-separated target list"); + } + index += 1; + args_targets.to_string() + } else { + index += 1; + // covers both the case where `--targets` is the last arg, and the case + // where it's followed by another flag (e.g. `--time`) without a value + // (e.g. `--targets --time 5`) + if index >= args.len() || args[index].starts_with('-') { + bail!("remote load/spam `--targets` requires a comma-separated target list"); + } + let args_targets = args[index].clone(); + index += 1; + // covers the case where `--targets` is followed by more than one value + // (e.g. `--targets val1,val2 val3`), which is incorrect. + // Notice the space between `val2` and `val3`, but `val3` is not a flag, + // and should be attached to the `--targets` value with a comma instead. + // An example of valid syntax is `--targets val1,val2 --time 5`. + if index < args.len() && !args[index].starts_with('-') { + bail!("remote load/spam `--targets` must use one comma-separated target list"); + } + args_targets + }; + + for target in targets.split(',') { + if target.is_empty() { + bail!("remote load/spam `--targets` requires non-empty target values"); + } + target_selectors.push(target.to_string()); + } + } + + Ok((forwarded_args, target_selectors)) +} + +/// Build the `spammer nodes` command for remote `quake load/spam`. +/// +/// Strips only the `--targets` segment to expand manifest node groups +/// locally, and appends explicit node names as one comma-delimited +/// `--targets` value. +fn build_remote_spammer_cmd( + manifest: &Manifest, + args: &[String], + fire_and_forget: bool, +) -> Result> { + let (forwarded_args, target_selectors) = split_remote_targets(args)?; + let mut cmd = vec![ + "./spammer.sh".to_string(), + "nodes".to_string(), + "--nodes-path".to_string(), + "nodes.json".to_string(), + ]; + + if fire_and_forget { + cmd.push("--fire-and-forget".to_string()); + } + + cmd.extend(forwarded_args); + + if !target_selectors.is_empty() { + let target_nodes = resolve_load_target_nodes(manifest, &target_selectors)?; + cmd.push("--targets".to_string()); + cmd.push(target_nodes.join(",")); + } + + Ok(cmd) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::{Node, NodeType}; + use indexmap::IndexMap; + + fn remote_manifest() -> Manifest { + let mut nodes = IndexMap::new(); + nodes.insert("validator1".to_string(), Node::default()); + nodes.insert("validator2".to_string(), Node::default()); + nodes.insert( + "full1".to_string(), + Node { + node_type: NodeType::NonValidator, + ..Node::default() + }, + ); + + let mut node_groups = IndexMap::new(); + node_groups.insert( + "TRUSTED".to_string(), + vec!["ALL_VALIDATORS".to_string(), "full1".to_string()], + ); + + Manifest { + nodes, + node_groups, + ..Manifest::default() + } + } + + fn validators_only_manifest() -> Manifest { + let mut nodes = IndexMap::new(); + nodes.insert("validator1".to_string(), Node::default()); + nodes.insert("validator2".to_string(), Node::default()); + + Manifest { + nodes, + ..Manifest::default() + } + } + + #[test] + fn split_remote_targets_success_cases() { + struct Case { + name: &'static str, + args: Vec<&'static str>, + expected_forwarded: Vec<&'static str>, + expected_targets: Vec<&'static str>, + } + + let cases = vec![ + Case { + name: "no targets flag", + args: vec!["--rate", "42", "--time", "5"], + expected_forwarded: vec!["--rate", "42", "--time", "5"], + expected_targets: vec![], + }, + Case { + name: "targets in middle of argv", + args: vec![ + "--rate", + "42", + "--targets", + "validator1,ALL_VALIDATORS", + "--mix", + "transfer=70,erc20=30", + ], + expected_forwarded: vec!["--rate", "42", "--mix", "transfer=70,erc20=30"], + expected_targets: vec!["validator1", "ALL_VALIDATORS"], + }, + Case { + name: "targets at beginning of argv", + args: vec!["--targets", "validator1,TRUSTED", "--rate", "42"], + expected_forwarded: vec!["--rate", "42"], + expected_targets: vec!["validator1", "TRUSTED"], + }, + Case { + name: "inline targets in middle of argv", + args: vec![ + "--rate", + "42", + "--targets=validator1,ALL_VALIDATORS", + "--time", + "5", + ], + expected_forwarded: vec!["--rate", "42", "--time", "5"], + expected_targets: vec!["validator1", "ALL_VALIDATORS"], + }, + ]; + + for case in cases { + let args: Vec = case.args.iter().map(|s| s.to_string()).collect(); + let (forwarded, targets) = + split_remote_targets(&args).expect("split_remote_targets should succeed"); + assert_eq!( + forwarded, case.expected_forwarded, + "case '{}': forwarded args mismatch", + case.name, + ); + assert_eq!( + targets, case.expected_targets, + "case '{}': target selectors mismatch", + case.name, + ); + } + } + + #[test] + fn split_remote_targets_err_cases() { + struct Case { + name: &'static str, + args: Vec<&'static str>, + expected_message: &'static str, + } + + let cases = vec![ + Case { + name: "standalone targets flag last arg without value", + args: vec!["--rate", "42", "--targets"], + expected_message: "`--targets` requires a comma-separated target list", + }, + Case { + name: "inline targets flag without value", + args: vec!["--rate", "42", "--targets="], + expected_message: "`--targets` requires a comma-separated target list", + }, + Case { + name: "space-separated targets are rejected", + args: vec!["--rate", "42", "--targets", "val1,val2", "val3"], + expected_message: "`--targets` must use one comma-separated target list", + }, + ]; + + for case in cases { + let args: Vec = case.args.iter().map(|s| s.to_string()).collect(); + let err = split_remote_targets(&args).unwrap_err(); + assert!( + err.to_string().contains(case.expected_message), + "case '{}': unexpected error: {err}", + case.name, + ); + } + } + + #[test] + fn build_remote_spammer_cmd_expands_group_targets() { + struct Case<'a> { + name: &'a str, + args: &'a [&'a str], + fire_and_forget: bool, + expected_cmd: &'a [&'a str], + } + + let manifest = remote_manifest(); + let cases = vec![ + Case { + name: "no targets flag", + args: &["--rate", "42", "--time", "5"], + fire_and_forget: false, + expected_cmd: &[ + "./spammer.sh", + "nodes", + "--nodes-path", + "nodes.json", + "--rate", + "42", + "--time", + "5", + ], + }, + Case { + name: "standalone targets flag", + args: &["--rate", "42", "--targets", "TRUSTED", "--time", "5"], + fire_and_forget: true, + expected_cmd: &[ + "./spammer.sh", + "nodes", + "--nodes-path", + "nodes.json", + "--fire-and-forget", + "--rate", + "42", + "--time", + "5", + "--targets", + "validator1,validator2,full1", + ], + }, + Case { + name: "inline targets flag", + args: &["--rate", "42", "--targets=TRUSTED", "--time", "5"], + fire_and_forget: false, + expected_cmd: &[ + "./spammer.sh", + "nodes", + "--nodes-path", + "nodes.json", + "--rate", + "42", + "--time", + "5", + "--targets", + "validator1,validator2,full1", + ], + }, + ]; + + for case in cases { + let args: Vec = case.args.iter().map(|s| s.to_string()).collect(); + let cmd = build_remote_spammer_cmd(&manifest, &args, case.fire_and_forget) + .expect("build_remote_spammer_cmd should succeed"); + assert_eq!( + cmd, case.expected_cmd, + "case '{}': remote spammer command mismatch", + case.name, + ); + } + } + + #[test] + fn resolve_load_target_nodes_rejects_empty_expansion() { + let manifest = validators_only_manifest(); + let selectors = vec!["ALL_NON_VALIDATORS".to_string()]; + + let err = resolve_load_target_nodes(&manifest, &selectors).unwrap_err(); + assert!( + err.to_string() + .contains("load/spam targets resolved to no nodes"), + "unexpected error: {err}", + ); + } + + #[test] + fn build_remote_spammer_cmd_rejects_empty_expansion() { + let manifest = validators_only_manifest(); + let args = vec!["--targets".to_string(), "ALL_NON_VALIDATORS".to_string()]; + + let err = build_remote_spammer_cmd(&manifest, &args, false).unwrap_err(); + assert!( + err.to_string() + .contains("load/spam targets resolved to no nodes"), + "unexpected error: {err}", + ); + } +} diff --git a/crates/quake/src/tests/sanity.rs b/crates/quake/src/tests/sanity.rs index e0e86f2..1a36169 100644 --- a/crates/quake/src/tests/sanity.rs +++ b/crates/quake/src/tests/sanity.rs @@ -42,7 +42,7 @@ //! | `warmup_s` | `30` | Seconds to wait for network stabilization | //! | `duration_s` | `60` | Experiment window (load duration or sleep) | //! | `load_rate` | `50` | TPS sent during experiment (0 = no load) | -//! | `load_targets` | `""` (all nodes) | Comma-separated node names to send load to | +//! | `load_targets` | `""` (all nodes) | Load selectors: node names or manifest groups | //! | `load_mix` | `transfer=100` | Tx type mix (`--mix` format; `erc20`/`guzzler` need contracts in genesis) | //! | `strict_mesh` | `true` | Enforce mesh tier expectations | //! | `mesh_verbose` | `true` | Print full `quake info mesh`-style report before mesh checks | @@ -130,7 +130,11 @@ fn build_remote_load_args( mix: &str, targets: &[String], ) -> Vec { - let mut args: Vec = targets.to_vec(); + let mut args = Vec::new(); + if !targets.is_empty() { + args.push("--targets".into()); + args.push(targets.join(",")); + } args.extend(["-r".into(), rate.to_string()]); args.extend(["-t".into(), duration_s.to_string()]); args.extend(["--mix".into(), mix.into()]); diff --git a/crates/spammer/README.md b/crates/spammer/README.md index bfaa427..bfc2dbf 100644 --- a/crates/spammer/README.md +++ b/crates/spammer/README.md @@ -197,29 +197,31 @@ using subcommands. ### `spammer ws` -Target nodes by directly specifying their WebSocket endpoints (as `IP:PORT` or `ws://...`). -If no endpoints are provided, it defaults to `127.0.0.1:8546`. +Target nodes by directly specifying their WebSocket endpoints with +`--targets` as a comma-separated list (each target may be `IP:PORT` or +`ws://...`). If `--targets` is omitted, it defaults to `127.0.0.1:8546`. Examples: ```bash -spammer ws 127.0.0.1:8546 -spammer ws ws://127.0.0.1:8546 ws://127.0.0.1:9546 +spammer ws --targets 127.0.0.1:8546 +spammer ws --targets ws://127.0.0.1:8546,ws://127.0.0.1:9546 ``` ### `spammer nodes` -Target nodes by name using a nodes metadata file (for example a Quake-generated -`.quake//nodes.json`). +Target nodes by name using a nodes metadata file (for example a +Quake-generated `.quake//nodes.json`). `--targets` takes a +comma-separated list of node names. -- If no node names are provided, all nodes from the file are targeted. +- If `--targets` is omitted, all nodes from the file are targeted. - `--nodes-path` is required. Examples: ```bash spammer nodes --nodes-path ./.quake/5nodes/nodes.json -spammer nodes --nodes-path ./.quake/5nodes/nodes.json validator1 validator2 +spammer nodes --nodes-path ./.quake/5nodes/nodes.json --targets validator1,validator2 ``` ## Main parameters @@ -291,7 +293,7 @@ Examples: ```bash # WebSocket endpoints directly -spammer ws ws://127.0.0.1:8546 --guzzler-fn-weights hash-loop=100@2000,storage-write=0,storage-read=0,guzzle=0,guzzle2=0 -r 20 -t 30 +spammer ws --targets ws://127.0.0.1:8546 --guzzler-fn-weights hash-loop=100@2000,storage-write=0,storage-read=0,guzzle=0,guzzle2=0 -r 20 -t 30 # Nodes by name from a Quake-generated nodes.json spammer nodes --nodes-path ./.quake/5nodes/nodes.json --guzzler-fn-weights hash-loop=0,storage-write=100@500,storage-read=0,guzzle=0,guzzle2=0 -r 50 -t 60 @@ -330,16 +332,16 @@ Examples: ```bash # 100% ERC-20 transfers (default function) -spammer ws ws://127.0.0.1:8546 --mix erc20=100 -r 100 -t 30 +spammer ws --targets ws://127.0.0.1:8546 --mix erc20=100 -r 100 -t 30 # 70% EIP-1559 transfers, 30% legacy transfers -spammer ws ws://127.0.0.1:8546 --mix transfer=70,legacy=30 -r 100 -t 30 +spammer ws --targets ws://127.0.0.1:8546 --mix transfer=70,legacy=30 -r 100 -t 30 # 70% native transfers, 30% ERC-20 -spammer ws ws://127.0.0.1:8546 --mix transfer=70,erc20=30 -r 100 -t 30 +spammer ws --targets ws://127.0.0.1:8546 --mix transfer=70,erc20=30 -r 100 -t 30 # All three types: 50% transfer, 20% ERC-20, 30% GasGuzzler (hashLoop) -spammer ws ws://127.0.0.1:8546 --mix transfer=50,erc20=20,guzzler=30 --guzzler-fn-weights hash-loop=100@2000 -r 100 -t 30 +spammer ws --targets ws://127.0.0.1:8546 --mix transfer=50,erc20=20,guzzler=30 --guzzler-fn-weights hash-loop=100@2000 -r 100 -t 30 ``` ### ERC-20 Function Mix @@ -370,13 +372,13 @@ Examples: ```bash # ERC-20 with mixed functions: 60% transfer, 30% approve, 10% transferFrom -spammer ws ws://127.0.0.1:8546 --mix erc20=100 --erc20-fn-weights transfer=60,approve=30,transfer-from=10 -r 100 -t 30 +spammer ws --targets ws://127.0.0.1:8546 --mix erc20=100 --erc20-fn-weights transfer=60,approve=30,transfer-from=10 -r 100 -t 30 # Stress approve-heavy workload -spammer ws ws://127.0.0.1:8546 --mix erc20=100 --erc20-fn-weights approve=100 -r 100 -t 30 +spammer ws --targets ws://127.0.0.1:8546 --mix erc20=100 --erc20-fn-weights approve=100 -r 100 -t 30 # Mixed workload: native transfers plus diverse ERC-20 functions -spammer ws ws://127.0.0.1:8546 --mix transfer=50,erc20=50 --erc20-fn-weights transfer=70,approve=30 -r 100 -t 30 +spammer ws --targets ws://127.0.0.1:8546 --mix transfer=50,erc20=50 --erc20-fn-weights transfer=70,approve=30 -r 100 -t 30 ``` ## Transaction Latency Tracking @@ -402,13 +404,13 @@ Add the `--tx-latency` flag to any spammer command: ```bash # With WebSocket endpoints -spammer ws ws://127.0.0.1:8546 --tx-latency -r 100 -t 30 +spammer ws --targets ws://127.0.0.1:8546 --tx-latency -r 100 -t 30 # With nodes from a Quake-generated file spammer nodes --nodes-path ./.quake/5nodes/nodes.json --tx-latency -r 1000 -t 60 # Write the CSV to a specific directory -spammer ws ws://127.0.0.1:8546 --tx-latency --csv-dir /tmp/results -r 100 -t 30 +spammer ws --targets ws://127.0.0.1:8546 --tx-latency --csv-dir /tmp/results -r 100 -t 30 ``` ### How it works diff --git a/crates/spammer/src/main.rs b/crates/spammer/src/main.rs index 15bb1e8..802a624 100644 --- a/crates/spammer/src/main.rs +++ b/crates/spammer/src/main.rs @@ -20,16 +20,19 @@ clippy::unwrap_used )] +use std::{collections::HashMap, fs, io::IsTerminal}; + use clap::{Parser, Subcommand}; use clap_verbosity_flag::{InfoLevel, Verbosity}; use color_eyre::eyre::{self, Context, Result}; use serde::Deserialize; -use std::{collections::HashMap, fs, io::IsTerminal}; use tracing_subscriber::EnvFilter; use url::Url; use spammer::{Spammer, SpammerArgs}; +const DEFAULT_WS_TARGET: &str = "127.0.0.1:8546"; + #[derive(Parser)] #[command( name = "spammer", @@ -62,19 +65,18 @@ struct Cli { enum TargetCommand { /// Target nodes by directly specifying their WebSocket endpoints #[command( - after_long_help = "Examples:\n spammer ws 127.0.0.1:8546\n spammer ws ws://127.0.0.1:8546 ws://127.0.0.1:9546\n" + after_long_help = "Examples:\n spammer ws\n spammer ws --targets ws://127.0.0.1:8546,ws://127.0.0.1:9546\n" )] Ws { - /// List of nodes to send transactions to, as WebSocket endpoints (formatted - /// as IP:PORT or ws://URL) - #[arg(default_value = "127.0.0.1:8546")] - target_nodes: Vec, + /// Explicit targets for this command. + #[arg(long, value_delimiter = ',')] + targets: Option>, }, /// Target nodes by name, using a nodes metadata file. /// /// If no node names are provided, all nodes from the file are used. #[command( - after_long_help = "Examples:\n spammer nodes --nodes-path some/path/nodes.json\n spammer nodes --nodes-path some/path/nodes.json validator1 validator2\n" + after_long_help = "Examples:\n spammer nodes --nodes-path some/path/nodes.json\n spammer nodes --nodes-path some/path/nodes.json --targets validator1,validator2\n" )] Nodes { /// File with node metadata used to resolve node names to WebSocket @@ -84,8 +86,9 @@ enum TargetCommand { /// `quake setup` command. #[arg(short = 'f', long, required = true)] nodes_path: String, - /// List of node names to target (absent means all nodes from the file) - target_nodes: Option>, + /// Explicit targets for this command. + #[arg(long, value_delimiter = ',')] + targets: Option>, }, } @@ -122,11 +125,17 @@ async fn main() -> Result<()> { // Build the WebSocket URLs of the target nodes let target_ws_urls = match cli.command { - TargetCommand::Ws { target_nodes } => ws_urls_from_strings(target_nodes), + TargetCommand::Ws { targets } => { + let target_nodes = targets.unwrap_or_else(|| vec![DEFAULT_WS_TARGET.to_string()]); + ws_urls_from_strings(target_nodes) + } TargetCommand::Nodes { nodes_path, - target_nodes, - } => ws_urls_from_file(&nodes_path, target_nodes.unwrap_or_default())?, + targets, + } => { + let target_nodes = targets.unwrap_or_default(); + ws_urls_from_file(&nodes_path, target_nodes)? + } }; let spammer = Spammer::new(target_ws_urls, &config).await?; @@ -199,6 +208,7 @@ struct ExecutionContainer { #[cfg(test)] mod tests { use super::*; + use clap::error::ErrorKind; use std::path::PathBuf; fn write_temp_nodes_json(contents: &str) -> PathBuf { @@ -287,29 +297,23 @@ mod tests { } #[test] - fn cli_parses_ws_subcommand_with_default_target() { - let cli = Cli::try_parse_from(["spammer", "ws"]).expect("parsing ws subcommand"); - match cli.command { - TargetCommand::Ws { target_nodes } => { - assert_eq!(target_nodes, vec!["127.0.0.1:8546".to_string()]); - } - _ => panic!("expected ws subcommand"), - } - } - - #[test] - fn cli_parses_ws_subcommand_with_explicit_targets() { - let cli = Cli::try_parse_from(["spammer", "ws", "127.0.0.1:8546", "ws://127.0.0.1:9546"]) - .expect("parsing ws subcommand with explicit targets"); + fn cli_parses_ws_subcommand_with_targets_flag() { + let cli = Cli::try_parse_from([ + "spammer", + "ws", + "--targets", + "127.0.0.1:8546,ws://127.0.0.1:9546", + ]) + .expect("parsing ws subcommand with --targets"); match cli.command { - TargetCommand::Ws { target_nodes } => { + TargetCommand::Ws { targets } => { assert_eq!( - target_nodes, - vec![ + targets, + Some(vec![ "127.0.0.1:8546".to_string(), - "ws://127.0.0.1:9546".to_string() - ] + "ws://127.0.0.1:9546".to_string(), + ]) ); } _ => panic!("expected ws subcommand"), @@ -317,42 +321,36 @@ mod tests { } #[test] - fn cli_parses_nodes_subcommand_without_target_nodes() { - let cli = Cli::try_parse_from(["spammer", "nodes", "--nodes-path", "nodes.json"]) - .expect("parsing nodes subcommand without providing target nodes"); - - match cli.command { - TargetCommand::Nodes { - nodes_path, - target_nodes, - } => { - assert_eq!(nodes_path, "nodes.json"); - assert_eq!(target_nodes, None); - } - _ => panic!("expected nodes subcommand"), - } + fn cli_rejects_ws_positional_targets() { + let err = match Cli::try_parse_from(["spammer", "ws", "127.0.0.1:8546"]) { + Ok(_) => panic!("positional ws targets must be rejected"), + Err(err) => err, + }; + + assert_eq!(err.kind(), ErrorKind::UnknownArgument); + assert!(err.to_string().contains("127.0.0.1:8546")); } #[test] - fn cli_parses_nodes_subcommand_with_target_nodes() { + fn cli_parses_nodes_subcommand_with_targets_flag() { let cli = Cli::try_parse_from([ "spammer", "nodes", "--nodes-path", "nodes.json", - "validator1", - "validator2", + "--targets", + "validator1,validator2", ]) - .expect("parsing nodes subcommand with target nodes"); + .expect("parsing nodes subcommand with --targets"); match cli.command { TargetCommand::Nodes { nodes_path, - target_nodes, + targets, } => { assert_eq!(nodes_path, "nodes.json"); assert_eq!( - target_nodes, + targets, Some(vec!["validator1".to_string(), "validator2".to_string()]) ); } @@ -361,18 +359,31 @@ mod tests { } #[test] - fn cli_accepts_shared_flags_after_subcommand() { - let cli = Cli::try_parse_from(["spammer", "ws", "127.0.0.1:8546", "--rate", "42"]) - .expect("parsing shared flags after ws subcommand"); - assert_eq!(cli.args.rate, 42); + fn cli_rejects_nodes_positional_targets() { + let err = match Cli::try_parse_from([ + "spammer", + "nodes", + "--nodes-path", + "nodes.json", + "validator1", + ]) { + Ok(_) => panic!("positional node targets must be rejected"), + Err(err) => err, + }; + assert_eq!(err.kind(), ErrorKind::UnknownArgument); + assert!(err.to_string().contains("validator1")); + } + + #[test] + fn cli_accepts_shared_flags_after_nodes_subcommand() { let cli = Cli::try_parse_from([ "spammer", "nodes", "--nodes-path", "nodes.json", - "validator1", - "validator2", + "--targets", + "validator1,validator2", "--rate", "42", ]) @@ -395,7 +406,8 @@ mod tests { let cli = Cli::try_parse_from([ "spammer", "ws", - "127.0.0.1:8546", + "--targets", + "127.0.0.1:8546,ws://127.0.0.1:9546", "--fire-and-forget", "--rate", "500", diff --git a/deployments/docker-compose.yml b/deployments/docker-compose.yml new file mode 100644 index 0000000..19fbbd7 --- /dev/null +++ b/deployments/docker-compose.yml @@ -0,0 +1,114 @@ +# Docker Compose for running an Arc node in follow mode. +# +# Start: docker compose up -d (first run downloads ~84 GB of snapshots) +# Stop: docker compose down +# Logs: docker compose logs -f +# +# Required environment variables (see docs/running-an-arc-node-docker.md): +# ARC_EXECUTION_IMAGE — EL Docker image (e.g. docker.cloudsmith.io/circle/arc-network/arc-execution:0.6.0) +# ARC_CONSENSUS_IMAGE — CL Docker image (e.g. docker.cloudsmith.io/circle/arc-network/arc-consensus:0.6.0) +# ARC_HOME — data directory on the host (e.g. ~/.arc) + +services: + # --- Initialization (runs once, then exits) --- + + # Download EL and CL snapshots (the CLI skips if data already exists) + arc-snapshots: + image: ${ARC_EXECUTION_IMAGE:?ARC_EXECUTION_IMAGE must be set} + entrypoint: ["/usr/local/bin/arc-snapshots"] + command: + - download + - --chain=arc-testnet + - --execution-path=/data/execution + - --consensus-path=/data/consensus + volumes: + - ${ARC_HOME:?ARC_HOME must be set}:/data + + # Initialize consensus layer private key + arc-consensus-init: + image: ${ARC_CONSENSUS_IMAGE:?ARC_CONSENSUS_IMAGE must be set} + command: + - init + - --home=/data/consensus + depends_on: + arc-snapshots: + condition: service_completed_successfully + volumes: + - ${ARC_HOME}/consensus:/data/consensus + + # Set ownership on the shared sockets volume so the EL (UID 999) can write + arc-sockets-init: + image: debian:bookworm-slim + entrypoint: ["/bin/sh", "-c"] + command: ["rm -f /sockets/*.ipc && chown 999:999 /sockets"] + volumes: + - arc-sockets:/sockets + depends_on: + arc-consensus-init: + condition: service_completed_successfully + + # --- Long-running services --- + + # Execution Layer (EL) + arc-execution: + image: ${ARC_EXECUTION_IMAGE} + command: + - node + - --chain=arc-testnet + - --datadir=/data/execution + - --full + - --http + - --http.addr=0.0.0.0 + - --http.port=8545 + - --http.api=eth,net,web3,txpool,trace,debug + - --auth-ipc + - --auth-ipc.path=/sockets/auth.ipc + - --ipcpath=/sockets/reth.ipc + - --metrics=0.0.0.0:9001 + - --disable-discovery + - --enable-arc-rpc + - --rpc.forwarder=https://rpc.quicknode.testnet.arc.network/ + - --log.file.directory=/data/execution/logs + volumes: + - ${ARC_HOME}/execution:/data/execution + - arc-sockets:/sockets + ports: + - "127.0.0.1:8545:8545" + - "127.0.0.1:9001:9001" + depends_on: + arc-sockets-init: + condition: service_completed_successfully + restart: unless-stopped + + # Consensus Layer (CL) + arc-consensus: + image: ${ARC_CONSENSUS_IMAGE} + command: + - start + - --home=/data/consensus + - --eth-socket=/sockets/reth.ipc + - --execution-socket=/sockets/auth.ipc + - --rpc.addr=0.0.0.0:31000 + - --follow + - --follow.endpoint=https://rpc.drpc.testnet.arc.network,wss=rpc.drpc.testnet.arc.network + - --follow.endpoint=https://rpc.quicknode.testnet.arc.network,wss=rpc.quicknode.testnet.arc.network + - --follow.endpoint=https://rpc.blockdaemon.testnet.arc.network,wss=rpc.blockdaemon.testnet.arc.network/websocket + - --full + - --execution-persistence-backpressure + - --execution-persistence-backpressure-threshold=50 + - --metrics=0.0.0.0:29000 + volumes: + - ${ARC_HOME}/consensus:/data/consensus + - arc-sockets:/sockets + ports: + - "127.0.0.1:31000:31000" + - "127.0.0.1:29000:29000" + depends_on: + arc-consensus-init: + condition: service_completed_successfully + arc-execution: + condition: service_started + restart: unless-stopped + +volumes: + arc-sockets: diff --git a/docs/PROFILING.md b/docs/PROFILING.md index 349f65e..e1a062f 100644 --- a/docs/PROFILING.md +++ b/docs/PROFILING.md @@ -90,7 +90,8 @@ the Control Center to route pprof requests to individual nodes. ## Running When built with `--features pprof`, the pprof HTTP server starts -automatically on the default port. No extra flags are needed. +automatically on the default port. No extra flags are needed for CPU +profiling. | Binary | Default pprof port | | -------------------------- | ------------------ | @@ -99,6 +100,21 @@ automatically on the default port. No extra flags are needed. Override with `--pprof.addr=0.0.0.0:`. +### Heap profiling activation + +Jemalloc heap profiling infrastructure is compiled in but **inactive by +default** to avoid runtime overhead when profiling is not needed. To +activate it, pass `--pprof.heap-prof`: + +```bash +arc-node-execution node --pprof.heap-prof +arc-node-consensus start --pprof.heap-prof +``` + +Without this flag the `/debug/pprof/allocs` endpoint will return an +empty profile. CPU profiling (`/debug/pprof/profile`) is always +available regardless of this flag. + ## Collecting profiles ### Endpoints diff --git a/docs/running-an-arc-node-docker.md b/docs/running-an-arc-node-docker.md new file mode 100644 index 0000000..18e0599 --- /dev/null +++ b/docs/running-an-arc-node-docker.md @@ -0,0 +1,170 @@ +# Running an Arc Node with Docker + +As an alternative to [building from source](running-an-arc-node.md), you can +run an Arc node using Docker containers. The setup uses the same **follow mode** +as the binary guide, with IPC between the execution and consensus containers. + +## Prerequisites + +- [Docker Engine](https://docs.docker.com/engine/install/) 24+ with BuildKit +- [Docker Compose](https://docs.docker.com/compose/install/) v2 +- Meets the [system requirements](running-an-arc-node.md#system-requirements) + +## Docker images + +Running an Arc node requires two Docker images — one for each layer: + +| Image | Description | +|-------|-------------| +| `arc-execution` | Execution Layer (EL) — EVM, RPC, transaction pool | +| `arc-consensus` | Consensus Layer (CL) — BFT consensus, follow mode | + +You can either pull pre-built images from the public registry or build them +from source. Both approaches are described below. + +Throughout this guide, the compose file reads images from two environment +variables. Set the version once and export both before running any +`docker compose` command: + +```sh +export ARC_VERSION=0.6.0 +export ARC_HOME=~/.arc +``` + +### Public Docker images + +Pre-built multi-arch images (amd64 and arm64) are published to +[Cloudsmith](https://cloudsmith.io/~circle/repos/arc-network/packages/). + +Optionally, you can pull the images from the public repository. This step can +be skipped, as the images will be pulled automatically by `docker compose`. + +```sh +docker pull docker.cloudsmith.io/circle/arc-network/arc-execution:$ARC_VERSION +docker pull docker.cloudsmith.io/circle/arc-network/arc-consensus:$ARC_VERSION +``` + +Export the aliases `docker compose` is expecting for the Docker images. + +```sh +export ARC_EXECUTION_IMAGE=docker.cloudsmith.io/circle/arc-network/arc-execution:$ARC_VERSION +export ARC_CONSENSUS_IMAGE=docker.cloudsmith.io/circle/arc-network/arc-consensus:$ARC_VERSION +``` + +### Build images + +Alternatively, build images from a release tag or a commit hash: + +```sh +git clone https://github.com/circlefin/arc-node.git && cd arc-node +git checkout v$ARC_VERSION +docker buildx bake \ + --set "*.args.GIT_COMMIT_HASH=$(git rev-parse v$ARC_VERSION^{commit})" \ + --set "*.args.GIT_VERSION=v$ARC_VERSION" \ + --set "*.args.GIT_SHORT_HASH=$(git rev-parse --short v$ARC_VERSION^{commit})" \ + --set "arc-execution.tags=arc-execution:$ARC_VERSION" \ + --set "arc-consensus.tags=arc-consensus:$ARC_VERSION" +``` + +Then export the local image tags: + +```sh +export ARC_EXECUTION_IMAGE=arc-execution:$ARC_VERSION +export ARC_CONSENSUS_IMAGE=arc-consensus:$ARC_VERSION +``` + +## Prepare data directory + +Create the `$ARC_HOME` directory on the host before running Docker Compose. If it doesn't exist, Docker will create it as root and the `arc-snapshots` container will fail with permission errors: + +```sh +mkdir -p "${ARC_HOME:-$HOME/.arc}" +``` + +## Download the compose file + +Download `docker-compose.yml` into a working directory: + +```sh +curl -O https://raw.githubusercontent.com/circlefin/arc-node/v${ARC_VERSION}/deployments/docker-compose.yml +``` + +## Start + +If you have already exported `ARC_EXECUTION_IMAGE`, `ARC_CONSENSUS_IMAGE`, and +`ARC_HOME` as described above, run from the directory containing +`docker-compose.yml`: + +```sh +docker compose up -d +``` + +Or with all variables inline: + +```sh +export ARC_VERSION=0.6.0 ARC_HOME=~/.arc +export ARC_EXECUTION_IMAGE=docker.cloudsmith.io/circle/arc-network/arc-execution:$ARC_VERSION \ + ARC_CONSENSUS_IMAGE=docker.cloudsmith.io/circle/arc-network/arc-consensus:$ARC_VERSION +docker compose up -d +``` + +On the first run, init containers automatically: + +1. Download the latest testnet snapshots (~84 GB compressed — see + [download sizes](./running-an-arc-node.md#download-snapshots) for details) +2. Initialize the consensus layer private key +3. Prepare the shared IPC socket volume + +Subsequent runs detect that initialization is already complete and start +immediately. + +> The init container runs as root so it can set file ownership for the +> main services (UID 999). No manual `chown` is needed. + +## Verify + +On the first run, wait for the init containers to finish downloading snapshots +(`docker compose logs -f arc-snapshots`). Once the EL and CL containers start, +wait about 30 seconds, then check the latest block height: + +```sh +curl -s -X POST http://localhost:8545 \ + -H "Content-Type: application/json" \ + -d '{ "jsonrpc": "2.0", "method": "eth_blockNumber", "params": [], "id": 1}' +``` + +The `result` field should increase over time as the node catches up with the +network. Initial sync from a snapshot may take several hours depending on how +far behind the snapshot is. + +If the result remains `0x0`, check logs: + +```sh +docker compose logs -f +``` + +## Monitoring + +The containers expose Prometheus metrics on the host: + +| Endpoint | Description | +|----------|-------------| +| `localhost:9001/metrics` | Execution Layer metrics | +| `localhost:29000/metrics` | Consensus Layer metrics | + +## Stop + +```sh +docker compose down +``` + +Node data persists in `~/.arc/` (or the path set by `ARC_HOME`). To remove +all data and start fresh: + +```sh +docker compose down -v # also removes the named sockets volume +rm -rf ~/.arc +``` + +> **Warning:** This permanently deletes the consensus layer private key +> (network identity). It cannot be recovered. diff --git a/docs/running-an-arc-node.md b/docs/running-an-arc-node.md index b194d5e..0a20af6 100644 --- a/docs/running-an-arc-node.md +++ b/docs/running-an-arc-node.md @@ -18,8 +18,6 @@ An Arc node is composed of two processes: Refer to the [installation](installation.md) instructions to install `arc-node-execution` (EL) and `arc-node-consensus` (CL). -> **Docker:** Container images and Docker Compose instructions are coming soon. - ### Configure paths This guide adopts the following variables to define paths of Arc components: @@ -169,6 +167,8 @@ arc-node-consensus start \ --follow.endpoint https://rpc.drpc.testnet.arc.network,wss=rpc.drpc.testnet.arc.network \ --follow.endpoint https://rpc.quicknode.testnet.arc.network,wss=rpc.quicknode.testnet.arc.network \ --follow.endpoint https://rpc.blockdaemon.testnet.arc.network,wss=rpc.blockdaemon.testnet.arc.network/websocket \ + --execution-persistence-backpressure \ + --execution-persistence-backpressure-threshold=50 \ --metrics 127.0.0.1:29000 ``` @@ -203,6 +203,9 @@ If it remains `0x0`, check the logs of the consensus layer for errors. > If the address and port of the HTTP endpoint are configured differently than > the above example, adapt the command accordingly. +> **Docker:** For running with Docker Compose instead of binaries, see +> [Running an Arc Node with Docker](running-an-arc-node-docker.md). + ## Separated hosts The [Quick Start](#quick-start) section describes the setup of the execution @@ -373,6 +376,8 @@ ExecStart=/usr/local/bin/arc-node-consensus start \ --follow.endpoint https://rpc.drpc.testnet.arc.network,wss=rpc.drpc.testnet.arc.network \ --follow.endpoint https://rpc.quicknode.testnet.arc.network,wss=rpc.quicknode.testnet.arc.network \ --follow.endpoint https://rpc.blockdaemon.testnet.arc.network,wss=rpc.blockdaemon.testnet.arc.network/websocket \ + --execution-persistence-backpressure \ + --execution-persistence-backpressure-threshold=50 \ --metrics 127.0.0.1:29000 Restart=always