From af71badf6c5be5328338de7aba6f96b601412afd Mon Sep 17 00:00:00 2001 From: Gregory Sobol Date: Thu, 28 May 2026 16:49:43 +0200 Subject: [PATCH 1/4] intitial --- .../consensus/src/validator/batch/manager.rs | 58 ++- ethexe/consensus/src/validator/batch/tests.rs | 139 +++++- ethexe/consensus/src/validator/coordinator.rs | 23 +- ethexe/consensus/src/validator/core.rs | 4 +- ethexe/consensus/src/validator/idle.rs | 13 +- ethexe/consensus/src/validator/mod.rs | 117 ++++- ethexe/consensus/src/validator/participant.rs | 16 +- ethexe/consensus/src/validator/watcher.rs | 468 ++++++++++++++++++ ethexe/service/src/lib.rs | 17 +- ethexe/service/src/tests/mod.rs | 189 +++++++ ethexe/service/src/tests/utils/env.rs | 89 ++-- 11 files changed, 1031 insertions(+), 102 deletions(-) create mode 100644 ethexe/consensus/src/validator/watcher.rs diff --git a/ethexe/consensus/src/validator/batch/manager.rs b/ethexe/consensus/src/validator/batch/manager.rs index c4eb2495c6e..70247eafc49 100644 --- a/ethexe/consensus/src/validator/batch/manager.rs +++ b/ethexe/consensus/src/validator/batch/manager.rs @@ -132,17 +132,35 @@ impl BatchCommitmentManager { self.limits.commitment_delay_limit, ); - if let Ok(Some(BatchCommitment { - chain_commitment: Some(chain_commitment), - .. - })) = batch_commitment.as_ref() - { - self.store_outgoing_actions_for_chain_commitment(chain_commitment); + if let Ok(Some(batch)) = batch_commitment.as_ref() { + self.persist_outgoing_actions(batch); } batch_commitment } + /// Persist per-`new_state_hash` value-claim mappings to the local DB. + fn persist_outgoing_actions(&self, batch: &BatchCommitment) { + let Some(ChainCommitment { transitions, .. }) = batch.chain_commitment.as_ref() else { + return; + }; + for StateTransition { + new_state_hash, + value_claims, + .. + } in transitions + { + let outgoing_actions: OutgoingActions = value_claims + .iter() + .cloned() + .map(OutgoingAction::ValueClaim) + .collect::>() + .into(); + self.db + .set_outgoing_actions(*new_state_hash, outgoing_actions); + } + } + /// Participant: re-derive the coordinator's batch and return whether digests agree. /// Drops the signature (Rejected) on chain mismatch instead of erroring. pub async fn validate_batch_commitment( @@ -324,7 +342,6 @@ impl BatchCommitmentManager { std::mem::take(&mut chain_commitment.transitions), ); super::utils::sort_transitions_by_value_to_receive(&mut chain_commitment.transitions); - self.store_outgoing_actions_for_chain_commitment(&chain_commitment); batch_parts.chain_commitment = Some(chain_commitment); } @@ -356,7 +373,8 @@ impl BatchCommitmentManager { }); } - let batch_encoded_size = Gear::BatchCommitment::from(batch).abi_encoded_size() as u64; + let batch_encoded_size = + Gear::BatchCommitment::from(batch.clone()).abi_encoded_size() as u64; if batch_encoded_size > self.limits.batch_size_limit { return Ok(ValidationStatus::Rejected { request, @@ -364,26 +382,12 @@ impl BatchCommitmentManager { }); } - Ok(ValidationStatus::Accepted(digest)) - } - - fn store_outgoing_actions_for_chain_commitment(&self, commitment: &ChainCommitment) { - for StateTransition { - new_state_hash, - value_claims, - .. - } in &commitment.transitions - { - let mut outgoing_actions = vec![]; + // Cache the per-state-hash value-claim mapping locally. Both validating + // validators and non-validator watchers persist here so any node can + // serve `mirror.outgoing_actions(state_hash)` for merkle-proof clients. + self.persist_outgoing_actions(&batch); - for value_claim in value_claims { - outgoing_actions.push(OutgoingAction::ValueClaim(value_claim.clone())); - } - - let outgoing_actions: OutgoingActions = outgoing_actions.into(); - self.db - .set_outgoing_actions(*new_state_hash, outgoing_actions); - } + Ok(ValidationStatus::Accepted(digest)) } pub async fn aggregate_validators_commitment( diff --git a/ethexe/consensus/src/validator/batch/tests.rs b/ethexe/consensus/src/validator/batch/tests.rs index 4d73800980a..75f50bd51e1 100644 --- a/ethexe/consensus/src/validator/batch/tests.rs +++ b/ethexe/consensus/src/validator/batch/tests.rs @@ -12,17 +12,21 @@ use super::{BatchCommitmentManager, BatchLimits, ValidationStatus, types::ValidationRejectReason}; use crate::validator::core::MiddlewareWrapper; use ethexe_common::{ - Address, Digest, ProgramStates, Schedule, SimpleBlockData, ToDigest, ValidatorsVec, + Address, Digest, OutgoingAction, ProgramStates, Schedule, SimpleBlockData, ToDigest, + ValidatorsVec, consensus::BatchCommitmentValidationRequest, - db::{BlockMetaStorageRW, CompactMb, GlobalsStorageRW, MbStorageRW, SetConfig}, - gear::StateTransition, + db::{ + BlockMetaStorageRW, CompactMb, GlobalsStorageRW, MbStorageRW, OutgoingActionStorageRO, + SetConfig, + }, + gear::{StateTransition, ValueClaim}, malachite::{ProcessQueuesLimits, Transaction, Transactions}, mock::*, }; use ethexe_db::Database; use ethexe_ethereum::middleware::{ElectionProvider, MockElectionProvider}; use gear_core::ids::prelude::CodeIdExt; -use gprimitives::{ActorId, CodeId, H256, U256}; +use gprimitives::{ActorId, CodeId, H256, MessageId, U256}; use std::num::{NonZero, NonZeroU64}; const BLOCK_GAS_LIMIT: u64 = ethexe_common::DEFAULT_BLOCK_GAS_LIMIT; @@ -116,6 +120,23 @@ fn nonempty_transition(seed: u8) -> StateTransition { } } +/// Same shape as [`nonempty_transition`] but carries `n` distinct value +/// claims so tests can assert per-`new_state_hash` persistence into the +/// `outgoing_actions` table. +fn transition_with_value_claims(seed: u8, n: u32) -> StateTransition { + let value_claims = (0..n) + .map(|i| ValueClaim { + message_id: MessageId::from([(seed.wrapping_add(i as u8)); 32]), + destination: ActorId::from([0xCC; 32]), + value: ((seed as u128) << 8) | (i as u128), + }) + .collect(); + StateTransition { + value_claims, + ..nonempty_transition(seed) + } +} + /// Build a batch from a small canonical setup so multiple tests can /// share the scaffolding. Returns the chain head block plus the /// resulting batch. @@ -770,3 +791,113 @@ async fn test_aggregate_validators_commitment() { .await .unwrap_err(); } + +/// Both the producer (`create_batch_commitment`) and the verifier +/// (`validate_batch_commitment`, used by Participant and Watcher) must +/// write the `(post-squash state_hash → outgoing_actions)` mapping to the +/// local DB. Without parity here, RPC clients hitting a non-producer node +/// (and that's most nodes most of the time) would get empty results from +/// `mirror.outgoing_actions`, breaking merkle-proof construction for +/// value claims. +#[tokio::test] +async fn validator_path_persists_outgoing_actions() { + // 1. Build the canonical batch on `producer_db`. The producer's own + // persist step runs as a side effect of create_batch_commitment. + let producer_db = Database::memory(); + let chain = test_block_chain(3).setup(&producer_db); + let block = chain.blocks[3].to_simple(); + + let claims_seed = 7u8; + let claims_count = 2u32; + setup_mb_chain( + &producer_db, + vec![ + vec![transition_with_value_claims(claims_seed, claims_count)], + vec![nonempty_transition(2)], + ], + ); + + let batch = mock_batch_manager(producer_db.clone()) + .create_batch_commitment(block) + .await + .expect("create_batch_commitment must not error") + .expect("expected non-empty batch"); + + // Pluck the (state_hash, value_claims) pairs the batch ended up with — + // these are post-squash hashes, exactly what RPC will query by. + let expected_mappings: Vec<(H256, Vec)> = batch + .chain_commitment + .as_ref() + .expect("chain commitment present") + .transitions + .iter() + .filter(|t| !t.value_claims.is_empty()) + .map(|t| (t.new_state_hash, t.value_claims.clone())) + .collect(); + assert!( + !expected_mappings.is_empty(), + "test fixture must produce at least one transition with value claims", + ); + + // Sanity check: producer-side persist actually happened. + for (state_hash, value_claims) in &expected_mappings { + let stored = producer_db + .outgoing_actions(*state_hash) + .expect("producer must persist outgoing_actions") + .into_inner(); + let expected: Vec = value_claims + .iter() + .cloned() + .map(OutgoingAction::ValueClaim) + .collect(); + assert_eq!(stored, expected, "producer persist mismatch"); + } + + // 2. Replay the same chain into a fresh DB and verify the batch via + // `validate_batch_commitment`. The watcher and participant paths + // flow through here too. We never call `create_batch_commitment` + // on this DB, so the only writer of `outgoing_actions` is the + // validator path under test. + let verifier_db = Database::memory(); + test_block_chain(3).setup(&verifier_db); + setup_mb_chain( + &verifier_db, + vec![ + vec![transition_with_value_claims(claims_seed, claims_count)], + vec![nonempty_transition(2)], + ], + ); + + // Confirm the verifier DB has no mappings before validation runs. + for (state_hash, _) in &expected_mappings { + assert!( + verifier_db.outgoing_actions(*state_hash).is_none(), + "fresh verifier DB unexpectedly already had outgoing_actions" + ); + } + + let request = BatchCommitmentValidationRequest::new(&batch); + let status = mock_batch_manager(verifier_db.clone()) + .validate_batch_commitment(block, request) + .await + .unwrap(); + assert!( + matches!(status, ValidationStatus::Accepted(_)), + "expected acceptance, got {status:?}" + ); + + // 3. After validation, the verifier DB must hold the same mappings + // that the producer wrote. + for (state_hash, value_claims) in &expected_mappings { + let stored = verifier_db + .outgoing_actions(*state_hash) + .expect("validator must persist outgoing_actions after Accepted") + .into_inner(); + let expected: Vec = value_claims + .iter() + .cloned() + .map(OutgoingAction::ValueClaim) + .collect(); + assert_eq!(stored, expected, "validator persist mismatch"); + } +} diff --git a/ethexe/consensus/src/validator/coordinator.rs b/ethexe/consensus/src/validator/coordinator.rs index 5267661985e..181d2224dc2 100644 --- a/ethexe/consensus/src/validator/coordinator.rs +++ b/ethexe/consensus/src/validator/coordinator.rs @@ -18,7 +18,7 @@ use anyhow::{Context as _, Result, anyhow, ensure}; use derive_more::Display; use ethexe_common::{ Address, SimpleBlockData, ToDigest, ValidatorsVec, consensus::BatchCommitmentValidationRequest, - gear::BatchCommitment, network::ValidatorMessage, + ecdsa::PublicKey, gear::BatchCommitment, network::ValidatorMessage, }; use futures::{FutureExt, future::BoxFuture}; use gsigner::secp256k1::Secp256k1SignerExt; @@ -42,6 +42,7 @@ pub struct CoordinatorBoot { ctx: ValidatorContext, block: SimpleBlockData, validators: ValidatorsVec, + pub_key: PublicKey, /// `Some` while we're either sleeping or awaiting the batch builder. pending: Option>>>, } @@ -59,6 +60,7 @@ impl CoordinatorBoot { ctx: ValidatorContext, block: SimpleBlockData, validators: ValidatorsVec, + pub_key: PublicKey, ) -> Result { let delay = ctx.core.coordinator_aggregation_delay; let batch_manager = ctx.core.batch_manager.clone(); @@ -75,6 +77,7 @@ impl CoordinatorBoot { ctx, block, validators, + pub_key, pending: Some(pending), } .into()) @@ -113,7 +116,13 @@ impl StateHandler for CoordinatorBoot { Ok((Poll::Ready(()), next)) } Poll::Ready(Ok(Some(batch))) => { - let next = Coordinator::create(self.ctx, self.validators, batch, self.block)?; + let next = Coordinator::create( + self.ctx, + self.validators, + batch, + self.block, + self.pub_key, + )?; Ok((Poll::Ready(()), next)) } } @@ -128,6 +137,7 @@ impl StateHandler for CoordinatorBoot { pub struct Coordinator { ctx: ValidatorContext, validators: BTreeSet
, + pub_key: PublicKey, multisigned_batch: MultisignedBatchCommitment, } @@ -174,6 +184,7 @@ impl Coordinator { validators: ValidatorsVec, batch: BatchCommitment, block: SimpleBlockData, + pub_key: PublicKey, ) -> Result { debug_assert_eq!(batch.block_hash, block.hash, "Block hash mismatch"); ensure!( @@ -190,7 +201,7 @@ impl Coordinator { batch, &ctx.core.signer, ctx.core.router_address, - ctx.core.pub_key, + pub_key, )?; ctx.core @@ -210,16 +221,14 @@ impl Coordinator { let payload = BatchCommitmentValidationRequest::new(multisigned_batch.batch()); let message = ValidatorMessage { era_index, payload }; - let validation_request = ctx - .core - .signer - .signed_data(ctx.core.pub_key, message, None)?; + let validation_request = ctx.core.signer.signed_data(pub_key, message, None)?; ctx.output(ConsensusEvent::PublishMessage(validation_request.into())); Ok(Self { ctx, validators: validators.into_iter().collect(), + pub_key, multisigned_batch, } .into()) diff --git a/ethexe/consensus/src/validator/core.rs b/ethexe/consensus/src/validator/core.rs index 2b8db990bc6..a72b4f5ca23 100644 --- a/ethexe/consensus/src/validator/core.rs +++ b/ethexe/consensus/src/validator/core.rs @@ -23,7 +23,9 @@ use tokio::sync::RwLock; pub struct ValidatorCore { pub signatures_threshold: u64, pub router_address: Address, - pub pub_key: PublicKey, + /// `Some` only for nodes that hold a validator key. + /// `None` means watcher. + pub pub_key: Option, pub timelines: ProtocolTimelines, #[debug(skip)] diff --git a/ethexe/consensus/src/validator/idle.rs b/ethexe/consensus/src/validator/idle.rs index b87f49dd09a..b18e93eab5b 100644 --- a/ethexe/consensus/src/validator/idle.rs +++ b/ethexe/consensus/src/validator/idle.rs @@ -18,6 +18,7 @@ use super::{ Participant, StateHandler, ValidatorContext, ValidatorState, coordinator::CoordinatorBoot, + watcher::Watcher, }; use anyhow::{Context as _, Result, anyhow}; use derive_more::{Debug, Display}; @@ -164,10 +165,14 @@ impl Idle { .block_coordinator_at(&validators, block.header.timestamp) .ok_or_else(|| anyhow!("cannot determine coordinator for block {}", block.hash))?; - if coordinator_addr == self.ctx.core.pub_key.to_address() { - CoordinatorBoot::start(self.ctx, block, validators) - } else { - Participant::create(self.ctx, block, coordinator_addr) + match self.ctx.core.pub_key { + Some(pub_key) if pub_key.to_address() == coordinator_addr => { + CoordinatorBoot::start(self.ctx, block, validators, pub_key) + } + Some(pub_key) if validators.iter().any(|v| *v == pub_key.to_address()) => { + Participant::create(self.ctx, block, coordinator_addr, pub_key) + } + _ => Watcher::create(self.ctx, block, coordinator_addr), } } } diff --git a/ethexe/consensus/src/validator/mod.rs b/ethexe/consensus/src/validator/mod.rs index dc17d079a6d..625cc631b9c 100644 --- a/ethexe/consensus/src/validator/mod.rs +++ b/ethexe/consensus/src/validator/mod.rs @@ -27,6 +27,7 @@ use crate::{ core::{MiddlewareWrapper, ValidatorCore}, idle::Idle, participant::Participant, + watcher::Watcher, }, }; use anyhow::Result; @@ -58,6 +59,7 @@ mod coordinator; mod core; mod idle; mod participant; +mod watcher; /// The main validator service that implements the `ConsensusService` trait. /// This service manages the validation workflow. @@ -67,8 +69,8 @@ pub struct ValidatorService { /// Configuration parameters for the validator service. pub struct ValidatorConfig { - /// ECDSA public key of this validator - pub pub_key: PublicKey, + /// ECDSA public key of this validator, or `None` for watcher-only nodes. + pub pub_key: Option, /// ECDSA multi-signature threshold // TODO #4637: threshold should be a ratio (and maybe also a block dependent value) pub signatures_threshold: u64, @@ -163,7 +165,10 @@ impl ValidatorService { impl ConsensusService for ValidatorService { fn role(&self) -> String { - format!("Validator ({:?})", self.context().core.pub_key.to_address()) + match self.context().core.pub_key { + Some(key) => format!("Validator ({:?})", key.to_address()), + None => "Watcher".to_string(), + } } fn receive_new_chain_head(&mut self, block: SimpleBlockData) -> Result<()> { @@ -292,6 +297,7 @@ enum ValidatorState { CoordinatorBoot(CoordinatorBoot), Coordinator(Coordinator), Participant(Participant), + Watcher(Watcher), } macro_rules! delegate_call { @@ -301,6 +307,7 @@ macro_rules! delegate_call { ValidatorState::CoordinatorBoot(s) => s.$func($( $arg ),*), ValidatorState::Coordinator(s) => s.$func($( $arg ),*), ValidatorState::Participant(s) => s.$func($( $arg ),*), + ValidatorState::Watcher(s) => s.$func($( $arg ),*), } }; } @@ -427,3 +434,107 @@ struct ValidatorMetrics { /// The last block number validator signed batch commitment for. pub last_signed_commitment_block_number: metrics::Gauge, } + +#[cfg(test)] +#[allow(private_interfaces)] // ValidatorContext is intentionally crate-private; test helpers reach into it. +pub(super) mod test_support { + //! Shared scaffolding for state-machine tests under `validator/`. + //! + //! Builds a minimal but real [`ValidatorContext`] — real DB, real signer, + //! real `BatchCommitmentManager`, no-op committer — so individual state + //! tests (Watcher, Participant, etc.) can exercise their poll behavior + //! without bringing up an end-to-end service. + + use super::{ + BatchLimits, MiddlewareWrapper, ValidatorContext, ValidatorCore, ValidatorMetrics, + batch::BatchCommitmentManager, core::BatchCommitter, + }; + use crate::ConsensusEvent; + use anyhow::Result; + use async_trait::async_trait; + use ethexe_common::{ + Address, + db::ConfigStorageRO, + ecdsa::{ContractSignature, PublicKey}, + gear::BatchCommitment, + }; + use ethexe_db::Database; + use ethexe_ethereum::middleware::{ElectionProvider, MockElectionProvider}; + use futures::stream::FuturesUnordered; + use gprimitives::H256; + use gsigner::secp256k1::Signer; + use std::{collections::VecDeque, num::NonZero, time::Duration}; + + /// No-op [`BatchCommitter`] for tests — never actually submits anything. + #[derive(Clone)] + pub struct NoopCommitter; + + #[async_trait] + impl BatchCommitter for NoopCommitter { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + + async fn commit( + self: Box, + _batch: BatchCommitment, + _signatures: Vec, + ) -> Result { + Ok(H256::zero()) + } + } + + /// Build a [`ValidatorContext`] backed by `db`. `pub_key = None` selects + /// the watcher path through [`super::idle::Idle::maybe_advance_to_role`]. + pub fn test_context(db: Database, pub_key: Option) -> ValidatorContext { + let timelines = db.config().timelines; + + let election = MockElectionProvider::new(); + let middleware = + MiddlewareWrapper::from_inner(Box::new(election) as Box); + let batch_manager = + BatchCommitmentManager::new(BatchLimits::default(), db.clone(), middleware); + + ValidatorContext { + core: ValidatorCore { + signatures_threshold: 1, + router_address: Address::default(), + pub_key, + timelines, + signer: Signer::memory(), + db, + committer: Box::new(NoopCommitter), + batch_manager, + metrics: ValidatorMetrics::default(), + commitment_delay_limit: NonZero::new(1).expect("1 != 0"), + coordinator_aggregation_delay: Duration::ZERO, + }, + pending_events: VecDeque::new(), + output: VecDeque::new(), + tasks: FuturesUnordered::new(), + } + } + + /// Drain all `Warning` events currently buffered in `ctx.output`, + /// returning their formatted strings in queue order. + pub fn drain_warnings(ctx: &mut ValidatorContext) -> Vec { + let mut warnings = Vec::new(); + ctx.output.retain(|event| match event { + ConsensusEvent::Warning(s) => { + warnings.push(s.clone()); + false + } + _ => true, + }); + warnings + } + + /// Drain any `PublishMessage` events — used to assert that signing-only + /// paths (Coordinator, Participant) did NOT fire from a non-signing state. + pub fn count_publish_messages(ctx: &ValidatorContext) -> usize { + ctx.output + .iter() + .filter(|e| matches!(e, ConsensusEvent::PublishMessage(_))) + .count() + } +} diff --git a/ethexe/consensus/src/validator/participant.rs b/ethexe/consensus/src/validator/participant.rs index fc565020094..2d46a92cf61 100644 --- a/ethexe/consensus/src/validator/participant.rs +++ b/ethexe/consensus/src/validator/participant.rs @@ -15,6 +15,7 @@ use derive_more::{Debug, Display}; use ethexe_common::{ Address, SimpleBlockData, consensus::{BatchCommitmentValidationRequest, VerifiedValidationRequest}, + ecdsa::PublicKey, network::ValidatorMessage, }; use futures::{FutureExt, future::BoxFuture}; @@ -27,6 +28,7 @@ pub struct Participant { ctx: ValidatorContext, block: SimpleBlockData, coordinator: Address, + pub_key: PublicKey, state: State, } @@ -74,7 +76,7 @@ impl StateHandler for Participant { Ok(ValidationStatus::Accepted(digest)) => { let signature = self.ctx.core.signer.sign_for_contract_digest( self.ctx.core.router_address, - self.ctx.core.pub_key, + self.pub_key, digest, None, )?; @@ -97,11 +99,11 @@ impl StateHandler for Participant { payload: reply, }; - let reply = - self.ctx - .core - .signer - .signed_data(self.ctx.core.pub_key, reply, None)?; + let reply = self + .ctx + .core + .signer + .signed_data(self.pub_key, reply, None)?; self.ctx .output(ConsensusEvent::PublishMessage(reply.into())); @@ -127,6 +129,7 @@ impl Participant { mut ctx: ValidatorContext, block: SimpleBlockData, coordinator: Address, + pub_key: PublicKey, ) -> Result { let mut earlier_validation_request = None; ctx.pending_events.retain(|event| match event { @@ -147,6 +150,7 @@ impl Participant { ctx, block, coordinator, + pub_key, state: State::WaitingForValidationRequest, }; diff --git a/ethexe/consensus/src/validator/watcher.rs b/ethexe/consensus/src/validator/watcher.rs new file mode 100644 index 00000000000..4cf076cfa7f --- /dev/null +++ b/ethexe/consensus/src/validator/watcher.rs @@ -0,0 +1,468 @@ +// Copyright (C) Gear Technologies Inc. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +//! [`Watcher`] is the non-signing twin of [`Participant`](super::Participant). +//! +//! Entered when the node has no validator key, or its key is not in the +//! current era's validator set. The watcher subscribes to the coordinator's +//! validation request just like a participant, re-derives the same batch via +//! `validate_batch_commitment` (which transitively populates the local +//! `outgoing_actions` cache — see +//! [`BatchCommitmentManager::persist_outgoing_actions`](super::batch::BatchCommitmentManager)), +//! and then returns to [`Idle`] without signing. +//! +//! The persistence side-effect is the whole point: RPC clients querying +//! `mirror.outgoing_actions(state_hash)` for merkle-proof building need the +//! `state_hash → value_claims` mapping in the local DB regardless of whether +//! the node was elected this round. + +use super::{ + DefaultProcessing, PendingEvent, StateHandler, ValidatorContext, ValidatorState, idle::Idle, +}; +use crate::validator::batch::ValidationStatus; +use anyhow::Result; +use derive_more::{Debug, Display}; +use ethexe_common::{ + Address, SimpleBlockData, + consensus::{BatchCommitmentValidationRequest, VerifiedValidationRequest}, +}; +use futures::{FutureExt, future::BoxFuture}; +use std::task::Poll; + +#[derive(Debug, Display)] +#[display("WATCHER in state {state:?}")] +pub struct Watcher { + ctx: ValidatorContext, + block: SimpleBlockData, + coordinator: Address, + state: State, +} + +#[derive(Debug)] +enum State { + WaitingForValidationRequest, + ProcessingValidationRequest { + #[debug(skip)] + future: BoxFuture<'static, Result>, + }, +} + +impl StateHandler for Watcher { + fn context(&self) -> &ValidatorContext { + &self.ctx + } + + fn context_mut(&mut self) -> &mut ValidatorContext { + &mut self.ctx + } + + fn into_context(self) -> ValidatorContext { + self.ctx + } + + fn process_validation_request( + self, + request: VerifiedValidationRequest, + ) -> Result { + if request.address() == self.coordinator { + self.process_coordinator_request(request.into_parts().0) + } else { + DefaultProcessing::validation_request(self, request) + } + } + + fn poll_next_state( + mut self, + cx: &mut std::task::Context<'_>, + ) -> Result<(Poll<()>, ValidatorState)> { + if let State::ProcessingValidationRequest { future } = &mut self.state + && let Poll::Ready(res) = future.poll_unpin(cx) + { + match res { + Ok(ValidationStatus::Accepted(digest)) => { + // Re-derivation matched the coordinator's digest; the + // outgoing-actions cache was populated as a side effect of + // `validate_batch_commitment`. Nothing else to do here. + tracing::debug!( + block = %self.block.hash, + ?digest, + "watcher: batch accepted, outgoing actions persisted", + ); + } + Ok(ValidationStatus::Rejected { request, reason }) => { + // Mismatch with the coordinator. Surface as a warning event + // so the operator notices a divergent local view, but do + // not propagate further — the watcher has no role to play. + self.warning(format!( + "rejected coordinator's batch {request:?}: {reason}" + )); + } + Err(err) => return Err(err), + } + + Idle::create(self.ctx).map(|s| (Poll::Ready(()), s)) + } else { + Ok((Poll::Pending, self.into())) + } + } +} + +impl Watcher { + pub fn create( + mut ctx: ValidatorContext, + block: SimpleBlockData, + coordinator: Address, + ) -> Result { + // Mirror Participant: drain at most one validation request from the + // pending stash that already matches our coordinator. + let mut earlier_validation_request = None; + ctx.pending_events.retain(|event| match event { + PendingEvent::ValidationRequest(signed_data) + if earlier_validation_request.is_none() && signed_data.address() == coordinator => + { + earlier_validation_request = Some(signed_data.data().clone()); + + false + } + _ => true, + }); + + let watcher = Self { + ctx, + block, + coordinator, + state: State::WaitingForValidationRequest, + }; + + let Some(validation_request) = earlier_validation_request else { + return Ok(watcher.into()); + }; + + watcher.process_coordinator_request(validation_request) + } + + fn process_coordinator_request( + mut self, + request: BatchCommitmentValidationRequest, + ) -> Result { + let State::WaitingForValidationRequest = self.state else { + self.warning("unexpected validation request".to_string()); + return Ok(self.into()); + }; + + self.state = State::ProcessingValidationRequest { + future: self + .ctx + .core + .batch_manager + .clone() + .validate_batch_commitment(self.block, request) + .boxed(), + }; + + Ok(self.into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::validator::{ + ValidatorState, + batch::BatchCommitmentManager, + test_support::{count_publish_messages, drain_warnings, test_context}, + }; + use ethexe_common::{ + OutgoingAction, ProgramStates, Schedule, + consensus::BatchCommitmentValidationRequest, + db::{CompactMb, GlobalsStorageRW, MbStorageRW}, + ecdsa::{PrivateKey, SignedData}, + gear::{StateTransition, ValueClaim}, + malachite::{ProcessQueuesLimits, Transaction, Transactions}, + mock::{BlockChain, Mock}, + }; + use ethexe_db::Database; + use ethexe_ethereum::middleware::{ElectionProvider, MockElectionProvider}; + use gprimitives::{ActorId, H256, MessageId}; + use std::task::{Context as PollContext, Waker}; + + /// Mirror the helpers from `batch/tests.rs` so this test module is + /// self-contained — keeps the cross-module surface small. + fn append_mb(db: &Database, parent: H256, height: u64, outcome: Vec) -> H256 { + let txs = Transactions::new(vec![ + Transaction::AdvanceTillEthereumBlock { + block_hash: H256::from_low_u64_be(0xEB00 + height), + }, + Transaction::ProcessQueues { + limits: ProcessQueuesLimits::default(), + }, + ]); + let transactions_hash = db.set_transactions(txs); + let mb_hash = H256::from_low_u64_be(0x1000 + height); + db.set_mb_compact_block( + mb_hash, + CompactMb { + parent, + height, + transactions_hash, + }, + ); + db.set_mb_outcome(mb_hash, outcome); + db.set_mb_schedule(mb_hash, Schedule::default()); + db.set_mb_program_states(mb_hash, ProgramStates::default()); + db.mutate_mb_meta(mb_hash, |meta| { + meta.computed = true; + meta.last_advanced_eb = H256::zero(); + }); + mb_hash + } + + fn setup_mb_chain(db: &Database, outcomes: Vec>) -> Vec { + let mut parent = H256::zero(); + let mut hashes = Vec::with_capacity(outcomes.len()); + for (i, outcome) in outcomes.into_iter().enumerate() { + let h = append_mb(db, parent, (i + 1) as u64, outcome); + hashes.push(h); + parent = h; + } + db.globals_mutate(|g| g.latest_finalized_mb_hash = parent); + hashes + } + + fn transition_with_value_claims(seed: u8) -> StateTransition { + StateTransition { + actor_id: ActorId::from([seed; 32]), + new_state_hash: H256::from([seed; 32]), + exited: false, + inheritor: ActorId::zero(), + value_to_receive: seed as u128, + value_to_receive_negative_sign: false, + value_claims: vec![ValueClaim { + message_id: MessageId::from([seed; 32]), + destination: ActorId::from([0xCC; 32]), + value: seed as u128, + }], + messages: vec![], + } + } + + /// Sign a validation request as if it came from `coordinator_pk`. + fn signed_request( + coordinator_pk: &PrivateKey, + request: BatchCommitmentValidationRequest, + ) -> VerifiedValidationRequest { + SignedData::create(coordinator_pk, request) + .expect("signing must succeed") + .into_verified() + } + + /// Produce a canonical batch via a producer-side run on `producer_db`. + /// The same chain has to be replayed onto the verifier DB so the + /// watcher's `validate_batch_commitment` re-derive lines up. + async fn build_canonical_batch(producer_db: &Database) -> ethexe_common::gear::BatchCommitment { + let chain = BlockChain::mock(3).setup(producer_db); + let block = chain.blocks[3].to_simple(); + + setup_mb_chain(producer_db, vec![vec![transition_with_value_claims(7)]]); + + let middleware = crate::validator::core::MiddlewareWrapper::from_inner(Box::new( + MockElectionProvider::new(), + ) + as Box); + BatchCommitmentManager::new( + crate::validator::batch::BatchLimits::default(), + producer_db.clone(), + middleware, + ) + .create_batch_commitment(block) + .await + .expect("create must succeed") + .expect("non-empty batch") + } + + /// Set up a verifier DB with the same chain state (so `validate_batch_commitment` + /// re-derives the same batch) but with no `outgoing_actions` pre-populated. + fn setup_verifier_db() -> (Database, SimpleBlockData) { + let db = Database::memory(); + let chain = BlockChain::mock(3).setup(&db); + let block = chain.blocks[3].to_simple(); + setup_mb_chain(&db, vec![vec![transition_with_value_claims(7)]]); + (db, block) + } + + /// Pump `poll_next_state` on a state until it's pending or transitions + /// away from Watcher. Returns the final state. + fn pump_to_completion(mut state: ValidatorState) -> ValidatorState { + let waker = Waker::noop(); + let mut cx = PollContext::from_waker(waker); + // FuturesUnordered as a host for the poll, to let the boxed future + // make progress. We have to manually advance the future via the + // state's `poll_next_state` since that's the contract. + for _ in 0..1024 { + let (poll, next) = match state { + ValidatorState::Watcher(w) => w.poll_next_state(&mut cx).unwrap(), + other => return other, + }; + state = next; + if poll.is_pending() { + // Spin briefly to let the boxed validation future make + // progress. The future is purely synchronous re-derivation, + // so a small number of polls suffices. + std::thread::yield_now(); + continue; + } + return state; + } + panic!("pump_to_completion: state did not settle within budget"); + } + + #[tokio::test] + async fn accept_path_persists_outgoing_actions_without_signing() { + // Build the canonical batch on a separate DB so we have a valid + // request payload to sign. + let producer_db = Database::memory(); + let batch = build_canonical_batch(&producer_db).await; + + // Collect the (state_hash, claims) mappings we expect the watcher + // to persist when it accepts the batch. Empty value_claims are + // skipped — producer/persist helper skips them too. + let expected_mappings: Vec<(H256, Vec)> = batch + .chain_commitment + .as_ref() + .expect("chain commitment present") + .transitions + .iter() + .filter(|t| !t.value_claims.is_empty()) + .map(|t| (t.new_state_hash, t.value_claims.clone())) + .collect(); + assert!(!expected_mappings.is_empty(), "fixture must have claims"); + + // Stand up a verifier DB and watcher; sign the request with a + // fresh coordinator key. + let (verifier_db, block) = setup_verifier_db(); + let coordinator_pk = PrivateKey::random(); + let coordinator_addr = coordinator_pk.public_key().to_address(); + + let ctx = test_context(verifier_db.clone(), None); + let initial = Watcher::create(ctx, block, coordinator_addr).unwrap(); + + // Forward the signed request; watcher should enter the + // ProcessingValidationRequest sub-state. + let request = BatchCommitmentValidationRequest::new(&batch); + let verified = signed_request(&coordinator_pk, request); + let processing = initial.process_validation_request(verified).unwrap(); + assert!(matches!(&processing, ValidatorState::Watcher(_))); + + // Pump the state machine; the validation future completes + // synchronously on a memory DB. + let settled = pump_to_completion(processing); + assert!( + matches!(&settled, ValidatorState::Idle(_)), + "watcher must return to Idle, got {settled}", + ); + + // The outgoing-actions side effect is the whole point of running + // the watcher. + use ethexe_common::db::OutgoingActionStorageRO; + for (state_hash, value_claims) in &expected_mappings { + let stored = verifier_db + .outgoing_actions(*state_hash) + .expect("watcher must persist outgoing_actions") + .into_inner(); + let expected: Vec = value_claims + .iter() + .cloned() + .map(OutgoingAction::ValueClaim) + .collect(); + assert_eq!(stored, expected); + } + + // And watcher must never sign. Inspect the post-settlement context. + let final_ctx = settled.into_context(); + assert_eq!( + count_publish_messages(&final_ctx), + 0, + "watcher must never emit PublishMessage", + ); + } + + #[tokio::test] + async fn reject_path_emits_warning_without_signing() { + // Build a real batch, then corrupt the request so re-derive + // produces a different digest → ValidationStatus::Rejected. + let producer_db = Database::memory(); + let batch = build_canonical_batch(&producer_db).await; + + let (verifier_db, block) = setup_verifier_db(); + let coordinator_pk = PrivateKey::random(); + let coordinator_addr = coordinator_pk.public_key().to_address(); + + let ctx = test_context(verifier_db.clone(), None); + let initial = Watcher::create(ctx, block, coordinator_addr).unwrap(); + + // Force a digest mismatch by appending a bogus code id; the + // watcher's re-derive will reject with CodeNotWaitingForCommitment. + let mut request = BatchCommitmentValidationRequest::new(&batch); + request.codes.push(gprimitives::CodeId::from([0xFA; 32])); + let verified = signed_request(&coordinator_pk, request); + let processing = initial.process_validation_request(verified).unwrap(); + + let settled = pump_to_completion(processing); + assert!( + matches!(&settled, ValidatorState::Idle(_)), + "watcher must return to Idle after rejecting, got {settled}", + ); + + let mut final_ctx = settled.into_context(); + let warnings = drain_warnings(&mut final_ctx); + assert!( + warnings.iter().any(|w| w.contains("rejected coordinator")), + "rejected path must emit a watcher-tagged warning, got {warnings:?}", + ); + assert_eq!( + count_publish_messages(&final_ctx), + 0, + "watcher must never emit PublishMessage even on rejection", + ); + } + + #[tokio::test] + async fn non_coordinator_request_is_stashed_as_pending() { + let (verifier_db, block) = setup_verifier_db(); + let coordinator_pk = PrivateKey::random(); + let coordinator_addr = coordinator_pk.public_key().to_address(); + + let ctx = test_context(verifier_db, None); + let watcher = Watcher::create(ctx, block, coordinator_addr).unwrap(); + + // Sign with a key *different* from the coordinator; the watcher + // must NOT treat this as the coordinator's request — it falls + // through to DefaultProcessing, which stashes it in `pending_events` + // for later. The default handler also emits a warning. + let other_pk = PrivateKey::random(); + let dummy_request = BatchCommitmentValidationRequest { + digest: ethexe_common::Digest::zero(), + head: None, + codes: vec![], + validators: false, + rewards: false, + }; + let verified = signed_request(&other_pk, dummy_request); + let next = watcher.process_validation_request(verified).unwrap(); + + // State must remain Watcher, not transition to Processing. + let ValidatorState::Watcher(w) = &next else { + panic!("expected Watcher state, got {next}"); + }; + assert!( + matches!(w.state, State::WaitingForValidationRequest), + "no-match must keep us in WaitingForValidationRequest, got {:?}", + w.state, + ); + assert_eq!( + w.ctx.pending_events.len(), + 1, + "non-coordinator request must be stashed for later", + ); + } +} diff --git a/ethexe/service/src/lib.rs b/ethexe/service/src/lib.rs index 36ce15973c0..89973929862 100644 --- a/ethexe/service/src/lib.rs +++ b/ethexe/service/src/lib.rs @@ -408,14 +408,19 @@ impl Service { Self::get_config_public_key(config.node.validator_session, &signer) .with_context(|| "failed to get validator session private key")?; - let consensus: Option>> = if let Some(pub_key) = - validator_pub_key - { + let consensus: Option>> = { + let sender_address = match validator_address { + Some(addr) => addr, + None => signer + .generate() + .with_context(|| "failed to generate ephemeral sender key for watcher")? + .to_address(), + }; let ethereum = EthereumBuilder::default() .rpc_url(&config.ethereum.rpc) .router_address(config.ethereum.router_address) .signer(signer.clone()) - .sender_address(pub_key.to_address()) + .sender_address(sender_address) .eip1559_fee_increase_percentage(config.ethereum.eip1559_fee_increase_percentage) .eip1559_max_fee_per_gas_in_gwei(config.ethereum.eip1559_max_fee_per_gas_in_gwei) .blob_gas_multiplier(config.ethereum.blob_gas_multiplier) @@ -427,7 +432,7 @@ impl Service { ethereum.router(), db.clone(), ValidatorConfig { - pub_key, + pub_key: validator_pub_key, signatures_threshold: threshold, // Coordinator-local: not a protocol constant; configured per node. commitment_delay_limit: config.node.commitment_delay_limit, @@ -437,8 +442,6 @@ impl Service { uncommitted_chain_len_threshold: config.node.uncommitted_chain_len_threshold, }, )?)) - } else { - None }; let network = if let Some(net_config) = &config.network { diff --git a/ethexe/service/src/tests/mod.rs b/ethexe/service/src/tests/mod.rs index 878f4707690..760c45e75bf 100644 --- a/ethexe/service/src/tests/mod.rs +++ b/ethexe/service/src/tests/mod.rs @@ -3300,6 +3300,195 @@ async fn reply_callback() { stop_nodes([node]).await; } +/// Verifies that nodes which never produce or sign — but receive +/// `BatchCommitmentValidationRequest` gossip — still populate their local +/// `outgoing_actions` cache by re-deriving each batch through the watcher +/// state. Without this, an RPC client hitting a non-producer node would +/// get an empty response from `mirror.outgoing_actions(state_hash)` and +/// couldn't build a merkle proof for any value claim. +/// +/// Setup: one validator node (the producer/coordinator for every round in +/// this 1-validator env) plus one watcher node (no validator key at all). +/// We trigger a value claim and assert both DBs end up with the same +/// `(state_hash → ValueClaim)` mapping. +/// +/// The "key-present-but-not-in-current-validator-set" case takes the same +/// Idle routing branch into `Watcher` as the no-key case — see +/// [`ethexe_consensus::validator::idle`]. Both produce identical observable +/// behavior, so we don't replicate it here. +#[tokio::test] +#[ntest::timeout(120_000)] +async fn outgoing_actions_persisted_on_watcher_nodes() { + init_logger(); + + // Network must be enabled so gossip delivers + // `BatchCommitmentValidationRequest` from validators to the watcher. + // Three validators give malachite a comfortable >2/3 quorum when the + // watcher (a malachite full-node, no voting power) joins the mesh. + let config = TestEnvConfig { + validators: ValidatorsConfig::PreDefined(3), + network: EnvNetworkConfig::Enabled, + ..Default::default() + }; + let mut env = TestEnv::new(config).await.unwrap(); + + let mut validators = vec![]; + for (i, v) in env.validators.clone().into_iter().enumerate() { + let mut validator = env + .new_node(NodeConfig::named(format!("validator-{i}")).validator(v)) + .await; + validator.start_service().await; + validators.push(validator); + } + + // Pure watcher — `NodeConfig::default()` leaves `validator_config = None`, + // which selects `pub_key = None` and routes Idle into the `Watcher` state + // on every block. + let mut watcher = env.new_node(NodeConfig::named("watcher")).await; + watcher.start_service().await; + + // Drive a value claim through the full pipeline using the same fixture + // as `value_send_program_to_user_and_claimed`: piggy-bank top-up, claim, + // follow-up no-op message to flush the executor. + let code_id = env + .upload_code(demo_piggy_bank::WASM_BINARY) + .await + .unwrap() + .wait_for() + .await + .unwrap() + .code_id; + + let piggy_bank_id = env + .create_program(code_id, 500_000_000_000_000) + .await + .unwrap() + .wait_for() + .await + .unwrap() + .program_id; + + let _ = env + .send_message(piggy_bank_id, b"") + .await + .unwrap() + .wait_for() + .await + .unwrap(); + + const VALUE_SENT: u128 = 1_000 * ETHER; + let piggy_bank = env.ethereum.mirror(piggy_bank_id.to_address_lossy().into()); + piggy_bank.owned_balance_top_up(VALUE_SENT).await.unwrap(); + + // Force the deposit into a finalized MB. + let res = env + .send_message(piggy_bank_id, b"") + .await + .unwrap() + .wait_for() + .await + .unwrap(); + assert_eq!(res.code, ReplyCode::Success(SuccessReplyReason::Auto)); + + // Smash → enqueue the user mailbox message with the claim. + let res = env + .send_message(piggy_bank_id, b"smash") + .await + .unwrap() + .wait_for() + .await + .unwrap(); + assert_eq!(res.code, ReplyCode::Success(SuccessReplyReason::Auto)); + + // Read the mailbox on validator-0 to discover the message id that will + // become a claim. + let state_hash = piggy_bank.query().state_hash().await.unwrap(); + let program_state = validators[0].db.program_state(state_hash).unwrap(); + let mailbox = validators[0] + .db + .mailbox(program_state.mailbox_hash.to_inner().unwrap()) + .unwrap(); + let sender_address = env.ethereum.provider().default_signer_address(); + let user_mailbox = mailbox.into_values(&validators[0].db)[&sender_address.into()].clone(); + let mailboxed_msg_id = user_mailbox.into_keys().next().unwrap(); + + let receiver = env.new_observer_events(); + piggy_bank.claim_value(mailboxed_msg_id).await.unwrap(); + + // Force-process the claim by flushing the executor with one more + // no-op message. + let _ = env + .send_message(piggy_bank_id, b"") + .await + .unwrap() + .wait_for() + .await + .unwrap(); + + // `find_value_claim_proof` walks `BlockEvent::Mirror::StateChanged` for + // `piggy_bank_id` on validator-0's DB, finds the state_hash whose + // `outgoing_actions` contains a `ValueClaim` with our message id, and + // returns it. This proves the producer-side persist happened. + let (claim_state_hash, _total_leaves, _leaf_index, _proof) = + find_value_claim_proof(receiver, &validators[0].db, piggy_bank_id, mailboxed_msg_id).await; + + // What validator-0 persisted under `claim_state_hash`. + let producer_actions = validators[0] + .db + .outgoing_actions(claim_state_hash) + .expect("validator-0: outgoing_actions must be persisted on producer path") + .into_inner(); + assert!( + !producer_actions.is_empty(), + "validator-0: expected at least one outgoing action under {claim_state_hash}", + ); + + // Other validators (Participant path) and the watcher (Watcher path) + // both re-derive the same batch via `validate_batch_commitment`, which + // calls `persist_outgoing_actions` on Accepted. They should converge + // on the same mapping. Poll briefly: gossip propagation can lag the + // producer DB write by a couple of blocks. + let deadline = std::time::Instant::now() + Duration::from_secs(45); + + async fn wait_for_persist( + db: ðexe_db::Database, + state_hash: H256, + deadline: std::time::Instant, + label: &str, + ) -> Vec { + loop { + if let Some(actions) = db.outgoing_actions(state_hash) { + return actions.into_inner(); + } + if std::time::Instant::now() > deadline { + panic!( + "{label}: never persisted outgoing_actions for {state_hash} — \ + watcher path is not wired or gossip didn't deliver the request" + ); + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + } + + // Non-producer validator: goes through Participant. + let participant_actions = + wait_for_persist(&validators[1].db, claim_state_hash, deadline, "validator-1").await; + assert_eq!( + producer_actions, participant_actions, + "participant's re-derived mapping must match the producer's" + ); + + // Pure watcher: no validator key, routed through `Watcher` state. + let watcher_actions = + wait_for_persist(&watcher.db, claim_state_hash, deadline, "watcher").await; + assert_eq!( + producer_actions, watcher_actions, + "watcher's re-derived mapping must match the producer's" + ); + + stop_nodes(validators.into_iter().chain([watcher])).await; +} + #[tokio::test] #[ignore = "TODO: #5487 port to MB-driven test harness"] async fn fast_sync() {} diff --git a/ethexe/service/src/tests/utils/env.rs b/ethexe/service/src/tests/utils/env.rs index 5886c91e7cf..13ee4f99182 100644 --- a/ethexe/service/src/tests/utils/env.rs +++ b/ethexe/service/src/tests/utils/env.rs @@ -1075,50 +1075,53 @@ impl Node { .unwrap(); let consensus: Option>> = { - if let Some(config) = self.validator_config.as_ref() { - let committer = if let Some(custom_committer) = self.custom_committer.take() { - custom_committer - } else { - EthereumBuilder::default() - .rpc_url(&self.eth_cfg.rpc) - .router_address(self.eth_cfg.router_address) - .signer(self.signer.clone()) - .sender_address(config.public_key.to_address()) - .eip1559_fee_increase_percentage( - self.eth_cfg.eip1559_fee_increase_percentage, - ) - .blob_gas_multiplier(self.eth_cfg.blob_gas_multiplier) - .build() - .await - .unwrap() - .router() - .into() - }; - - Some(Box::pin( - ValidatorService::new( - self.signer.clone(), - self.election_provider.clone(), - committer, - self.db.clone(), - ethexe_consensus::ValidatorConfig { - pub_key: config.public_key, - signatures_threshold: self.threshold, - commitment_delay_limit: self.commitment_delay_limit, - router_address: self.eth_cfg.router_address, - batch_size_limit: DEFAULT_BATCH_SIZE_LIMIT, - coordinator_aggregation_delay: std::time::Duration::ZERO, - // High enough that the checkpoint path never fires across the - // short Eth-block budget service tests run for. - uncommitted_chain_len_threshold: std::num::NonZero::new(u32::MAX) - .unwrap(), - }, - ) - .unwrap(), - ) as Pin>) + let validator_pub_key = self.validator_config.as_ref().map(|c| c.public_key); + let sender_address = match validator_pub_key { + Some(k) => k.to_address(), + None => self + .signer + .generate() + .expect("test signer must generate ephemeral key") + .to_address(), + }; + + let committer = if let Some(custom_committer) = self.custom_committer.take() { + custom_committer } else { - None - } + EthereumBuilder::default() + .rpc_url(&self.eth_cfg.rpc) + .router_address(self.eth_cfg.router_address) + .signer(self.signer.clone()) + .sender_address(sender_address) + .eip1559_fee_increase_percentage(self.eth_cfg.eip1559_fee_increase_percentage) + .blob_gas_multiplier(self.eth_cfg.blob_gas_multiplier) + .build() + .await + .unwrap() + .router() + .into() + }; + + Some(Box::pin( + ValidatorService::new( + self.signer.clone(), + self.election_provider.clone(), + committer, + self.db.clone(), + ethexe_consensus::ValidatorConfig { + pub_key: validator_pub_key, + signatures_threshold: self.threshold, + commitment_delay_limit: self.commitment_delay_limit, + router_address: self.eth_cfg.router_address, + batch_size_limit: DEFAULT_BATCH_SIZE_LIMIT, + coordinator_aggregation_delay: std::time::Duration::ZERO, + // High enough that the checkpoint path never fires across the + // short Eth-block budget service tests run for. + uncommitted_chain_len_threshold: std::num::NonZero::new(u32::MAX).unwrap(), + }, + ) + .unwrap(), + ) as Pin>) }; let validator_pub_key = self.validator_config.as_ref().map(|c| c.public_key); From a96590fe2992bb414c9ae945697d70060be342cb Mon Sep 17 00:00:00 2001 From: Grisha Sobol Date: Thu, 28 May 2026 23:57:45 +0200 Subject: [PATCH 2/4] fix(ci): sync ensure_types_unchanged hash and Clones.sol generator - Bump EXPECTED_TYPE_INFO_HASH to match db types as drifted via merged master. - Update CLONES_CONTRACT_START to include Gear copyright header and pragma ^0.8.35 so the generator output matches the committed Clones.sol. Co-Authored-By: Claude Opus 4.7 (1M context) --- ethexe/common/src/db.rs | 2 +- ethexe/scripts/update-clones-sol.rs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ethexe/common/src/db.rs b/ethexe/common/src/db.rs index 0de1fdc2d24..9afb51b252c 100644 --- a/ethexe/common/src/db.rs +++ b/ethexe/common/src/db.rs @@ -277,7 +277,7 @@ mod tests { #[test] fn ensure_types_unchanged() { const EXPECTED_TYPE_INFO_HASH: &str = - "fabd74202a18149f60d6396119fb880e09475ca3c0245430d3fab7dff243f8c8"; + "740f626e5ecdc186db996b42ec13136210eab4ba45f8fa75b4335c1005a4a22f"; let types = [ meta_type::(), diff --git a/ethexe/scripts/update-clones-sol.rs b/ethexe/scripts/update-clones-sol.rs index c580701454c..257cedbc783 100755 --- a/ethexe/scripts/update-clones-sol.rs +++ b/ethexe/scripts/update-clones-sol.rs @@ -47,8 +47,9 @@ struct SolidityBuildArtifact { } const CLONES_CONTRACT_START: &[u8] = br#" +// Copyright (C) Gear Technologies Inc. // SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 -pragma solidity ^0.8.33; +pragma solidity ^0.8.35; import {Memory} from "frost-secp256k1-evm/utils/Memory.sol"; From e3537ae80bf2b3350dbf2348916cd1e6800a3594 Mon Sep 17 00:00:00 2001 From: Grisha Sobol Date: Fri, 29 May 2026 00:59:32 +0200 Subject: [PATCH 3/4] fix(wasm-optimizer): pass --import-undefined to lld for wasm builds Rust stable 1.96 (rust-lld) no longer treats undefined wasm symbols as imports by default for the wasm32v1-none target, causing the test-program (and any gear program built via wasm-builder) to fail linking with "undefined symbol: gr_debug" etc. Pass --import-undefined through the existing rustc link-arg list so syscall symbols and the gear-dlmalloc allocator hooks remain resolvable as wasm imports. Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk/wasm-optimizer/src/cargo_command.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/wasm-optimizer/src/cargo_command.rs b/sdk/wasm-optimizer/src/cargo_command.rs index 908a9900d71..72bebccc32d 100644 --- a/sdk/wasm-optimizer/src/cargo_command.rs +++ b/sdk/wasm-optimizer/src/cargo_command.rs @@ -26,7 +26,7 @@ impl CargoCommand { let rustc_version = rustc_version::version().expect("Failed to get rustc version"); let linker_plugin_lto = rustc_version.major == 1 && rustc_version.minor >= 91; - let mut rustc_flags = vec!["-Clink-arg=--import-memory"]; + let mut rustc_flags = vec!["-Clink-arg=--import-memory", "-Clink-arg=--import-undefined"]; if linker_plugin_lto { rustc_flags.extend_from_slice(&[ From 5844e889cd5c2a6f7fafd9b23f59c5448992dc92 Mon Sep 17 00:00:00 2001 From: Grisha Sobol Date: Fri, 29 May 2026 01:01:10 +0200 Subject: [PATCH 4/4] chore: rustfmt rustc_flags vec Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk/wasm-optimizer/src/cargo_command.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sdk/wasm-optimizer/src/cargo_command.rs b/sdk/wasm-optimizer/src/cargo_command.rs index 72bebccc32d..4fe44f5183f 100644 --- a/sdk/wasm-optimizer/src/cargo_command.rs +++ b/sdk/wasm-optimizer/src/cargo_command.rs @@ -26,7 +26,10 @@ impl CargoCommand { let rustc_version = rustc_version::version().expect("Failed to get rustc version"); let linker_plugin_lto = rustc_version.major == 1 && rustc_version.minor >= 91; - let mut rustc_flags = vec!["-Clink-arg=--import-memory", "-Clink-arg=--import-undefined"]; + let mut rustc_flags = vec![ + "-Clink-arg=--import-memory", + "-Clink-arg=--import-undefined", + ]; if linker_plugin_lto { rustc_flags.extend_from_slice(&[