218 lines
6.9 KiB
Rust
Raw Normal View History

use std::{
collections::HashSet,
num::{NonZeroU64, NonZeroUsize},
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
2025-12-18 09:00:14 +01:00
time::Duration,
};
use async_trait::async_trait;
chore: merge dev into master (#29) * Add node config overrides (#14) * chore: merge master into dev and update configs after merge (#17) * Sdp config structs from logos blockchain (#15) * Update configs after main repo merge --------- Co-authored-by: gusto <bacv@users.noreply.github.com> * Local deployer allows to stop and restart nodes (#16) * Unify local node control and restart support * Add local stop-node support * Use node names for restart/stop control * merge --------- Co-authored-by: hansieodendaal <hansie.odendaal@gmail.com> * Add orphan manual cluster test utilities * Update node rev and align consensus/wallet config * Update node rev and align wallet/KMS configs * Update main repo ref (#23) * Fix genesis utxos and scale leader stake * Document leader stake constants * feat: add custom persistent dir option for working files (#26) * chore: config and naming updates (#27) * Update config and crate naming - Updated configs to the lates main repo configs. - Updated all main repo crate namings to be same as the main repo. - Added `create_dir_all` to `pub(crate) fn create_tempdir(custom_work_dir: Option<PathBuf>) -> std::io::Result<TempDir> {`. - Wired in optional `persist_dir` when using the local deployer. - Update `time` vulnerability **Note:** Unsure about the `service_params` mapping in `pub(crate) fn cryptarchia_deployment(config: &GeneralConfig) -> CryptarchiaDeploymentSettings {` * fix ntp server config --------- Co-authored-by: Andrus Salumets <andrus@status.im> Co-authored-by: gusto <bacv@users.noreply.github.com> Co-authored-by: andrussal <salumets.andrus@gmail.com>
2026-02-09 14:12:26 +02:00
use lb_core::{header::HeaderId, mantle::AuthenticatedMantleTx as _};
use lb_key_management_system_service::keys::ZkPublicKey;
use testing_framework_core::scenario::{DynError, Expectation, RunContext};
use thiserror::Error;
2025-12-18 09:00:14 +01:00
use tokio::{sync::broadcast, time::sleep};
use super::workload::{SubmissionPlan, limited_user_count, submission_plan};
const MIN_INCLUSION_RATIO: f64 = 0.5;
2025-12-18 09:00:14 +01:00
const CATCHUP_POLL_INTERVAL: Duration = Duration::from_secs(1);
const MAX_CATCHUP_WAIT: Duration = Duration::from_secs(60);
#[derive(Clone)]
pub struct TxInclusionExpectation {
txs_per_block: NonZeroU64,
user_limit: Option<NonZeroUsize>,
capture_state: Option<CaptureState>,
}
#[derive(Clone)]
struct CaptureState {
observed: Arc<AtomicU64>,
expected: u64,
}
#[derive(Debug, Error)]
enum TxExpectationError {
#[error("transaction workload requires seeded accounts")]
MissingAccounts,
#[error("transaction workload planned zero transactions")]
NoPlannedTransactions,
#[error("transaction inclusion expectation not captured")]
NotCaptured,
#[error("transaction inclusion observed {observed} below required {required}")]
InsufficientInclusions { observed: u64, required: u64 },
}
impl TxInclusionExpectation {
/// Expectation that checks a minimum fraction of planned transactions were
/// included.
pub const NAME: &'static str = "tx_inclusion_expectation";
/// Constructs an inclusion expectation using the same parameters as the
/// workload.
#[must_use]
pub const fn new(txs_per_block: NonZeroU64, user_limit: Option<NonZeroUsize>) -> Self {
Self {
txs_per_block,
user_limit,
capture_state: None,
}
}
}
#[async_trait]
impl Expectation for TxInclusionExpectation {
fn name(&self) -> &'static str {
Self::NAME
}
async fn start_capture(&mut self, ctx: &RunContext) -> Result<(), DynError> {
if self.capture_state.is_some() {
return Ok(());
}
let (plan, tracked_accounts) = build_capture_plan(self, ctx)?;
if plan.transaction_count == 0 {
return Err(TxExpectationError::NoPlannedTransactions.into());
}
tracing::info!(
planned_txs = plan.transaction_count,
txs_per_block = self.txs_per_block.get(),
user_limit = self.user_limit.map(|u| u.get()),
"tx inclusion expectation starting capture"
);
let observed = Arc::new(AtomicU64::new(0));
spawn_tx_inclusion_capture(
ctx.block_feed().subscribe(),
Arc::new(tracked_accounts),
Arc::clone(&observed),
);
self.capture_state = Some(CaptureState {
observed,
expected: plan.transaction_count as u64,
});
Ok(())
}
2025-12-18 09:00:14 +01:00
async fn evaluate(&mut self, ctx: &RunContext) -> Result<(), DynError> {
let state = self
.capture_state
.as_ref()
.ok_or(TxExpectationError::NotCaptured)?;
let required = ((state.expected as f64) * MIN_INCLUSION_RATIO).ceil() as u64;
2025-12-18 09:00:14 +01:00
let mut observed = state.observed.load(Ordering::Relaxed);
if observed < required {
let security_param = ctx.descriptors().config().consensus_params.security_param;
let hinted_wait = ctx
.run_metrics()
.block_interval_hint()
.map(|interval| interval.mul_f64(security_param.get() as f64));
let mut remaining = hinted_wait
.unwrap_or(MAX_CATCHUP_WAIT)
.min(MAX_CATCHUP_WAIT);
while observed < required && remaining > Duration::ZERO {
sleep(CATCHUP_POLL_INTERVAL).await;
remaining = remaining.saturating_sub(CATCHUP_POLL_INTERVAL);
observed = state.observed.load(Ordering::Relaxed);
}
}
if observed >= required {
tracing::info!(
observed,
required,
expected = state.expected,
"tx inclusion expectation satisfied"
);
Ok(())
} else {
tracing::warn!(
observed,
required,
expected = state.expected,
"tx inclusion expectation failed"
);
Err(TxExpectationError::InsufficientInclusions { observed, required }.into())
}
}
}
fn build_capture_plan(
expectation: &TxInclusionExpectation,
ctx: &RunContext,
) -> Result<(SubmissionPlan, HashSet<ZkPublicKey>), DynError> {
let wallet_accounts = ctx.descriptors().config().wallet().accounts.clone();
if wallet_accounts.is_empty() {
return Err(TxExpectationError::MissingAccounts.into());
}
let available = limited_user_count(expectation.user_limit, wallet_accounts.len());
let plan = submission_plan(expectation.txs_per_block, ctx, available)?;
let wallet_pks = wallet_accounts
.into_iter()
.take(plan.transaction_count)
.map(|account| account.secret_key.to_public_key())
.collect::<HashSet<ZkPublicKey>>();
Ok((plan, wallet_pks))
}
fn spawn_tx_inclusion_capture(
mut receiver: broadcast::Receiver<Arc<testing_framework_core::scenario::BlockRecord>>,
tracked_accounts: Arc<HashSet<ZkPublicKey>>,
observed: Arc<AtomicU64>,
) {
tokio::spawn(async move {
let genesis_parent = HeaderId::from([0; 32]);
tracing::debug!("tx inclusion capture task started");
loop {
match receiver.recv().await {
Ok(record) => {
if record.block.header().parent_block() == genesis_parent {
continue;
}
capture_tx_outputs(record.as_ref(), &tracked_accounts, &observed);
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
tracing::debug!(skipped, "tx inclusion capture lagged");
}
Err(broadcast::error::RecvError::Closed) => {
tracing::debug!("tx inclusion capture feed closed");
break;
}
}
}
tracing::debug!("tx inclusion capture task exiting");
});
}
fn capture_tx_outputs(
record: &testing_framework_core::scenario::BlockRecord,
tracked_accounts: &HashSet<ZkPublicKey>,
observed: &AtomicU64,
) {
for tx in record.block.transactions() {
for note in &tx.mantle_tx().ledger_tx.outputs {
if tracked_accounts.contains(&note.pk) {
observed.fetch_add(1, Ordering::Relaxed);
tracing::debug!(pk = ?note.pk, "tx inclusion observed account output");
break;
}
}
}
}