mirror of
https://github.com/logos-co/nomos-node.git
synced 2026-08-31 11:31:09 +00:00
refactor(cucumber): organize steps by domain
This commit is contained in:
@@ -111,9 +111,7 @@ pub fn build_manual_cluster_deployment(
|
||||
|
||||
if let Some(genesis_block) = deployment.config.genesis_block.clone() {
|
||||
world.chain.genesis_block_utxos =
|
||||
crate::cucumber::steps::manual_nodes::utils::genesis_block_utxos(
|
||||
&genesis_block.genesis_tx(),
|
||||
);
|
||||
crate::cucumber::steps::nodes::genesis_block_utxos(&genesis_block.genesis_tx());
|
||||
world.chain.genesis_block_id = Some(genesis_block.header().id());
|
||||
}
|
||||
|
||||
@@ -373,7 +371,7 @@ fn compose_dial_addr(listen_addr: Option<&Multiaddr>, peer_id: PeerId) -> Option
|
||||
pub fn build_user_wallets(
|
||||
world: &CucumberWorld,
|
||||
node_name: &str,
|
||||
wallet_start_info: &[crate::cucumber::steps::manual_nodes::utils::WalletStartInfo],
|
||||
wallet_start_info: &[crate::cucumber::steps::nodes::WalletStartInfo],
|
||||
) -> Result<HashMap<String, WalletInfo>, StepError> {
|
||||
let mut wallet_info = HashMap::new();
|
||||
for wallet in wallet_start_info {
|
||||
@@ -7,12 +7,12 @@ use testing_framework_core::scenario::{PeerSelection, StartNodeOptions};
|
||||
use crate::cucumber::{
|
||||
error::{StepError, StepResult},
|
||||
steps::{
|
||||
manual_cluster::{
|
||||
cluster::{
|
||||
assert_manual_node_has_peers, build_manual_cluster_deployment, build_user_wallets,
|
||||
insert_started_node_info, start_manual_node, stop_active_manual_cluster,
|
||||
wait_manual_node_ready,
|
||||
},
|
||||
manual_nodes::utils::{
|
||||
nodes::{
|
||||
NodesToStartUnordered, parse_wallet_resources_table_row,
|
||||
start_nodes_order_respecting_dependencies, verify_node_wallet_resources_table_indexes,
|
||||
},
|
||||
@@ -1,7 +0,0 @@
|
||||
pub(crate) mod config_override;
|
||||
pub(crate) mod diagnostics;
|
||||
pub mod lib_assertions;
|
||||
pub mod parameters;
|
||||
pub(crate) mod snapshots;
|
||||
pub mod steps;
|
||||
pub mod utils;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,933 +0,0 @@
|
||||
use std::{collections::HashSet, time::Duration};
|
||||
|
||||
use cucumber::{gherkin::Step, given, then, when};
|
||||
use tokio::time::timeout;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{
|
||||
common::wallet::WalletUtxos,
|
||||
cucumber::{
|
||||
error::{StepError, StepResult},
|
||||
steps::{
|
||||
TARGET,
|
||||
manual_transactions::{
|
||||
command_file_parsing::ManualCommand,
|
||||
command_file_utils::{
|
||||
execute_coin_splits_all_user_wallets,
|
||||
execute_continuous_next_wallet_user_wallet,
|
||||
execute_continuous_round_robin_user_wallets, log_wallet_balances,
|
||||
perform_manual_step_control, verify_min_outputs_all_user_wallets,
|
||||
},
|
||||
drain_wallets::{drain_all_node_wallets, drain_node_wallet, drain_user_wallet},
|
||||
tracked_transactions::{
|
||||
submit_funded_transfer_transaction, submit_invalid_transfer_transaction,
|
||||
submit_stateless_invalid_transfer_transaction,
|
||||
transaction_is_not_included_in_seconds,
|
||||
transaction_is_rejected_during_preverification,
|
||||
},
|
||||
utils,
|
||||
utils::{
|
||||
WalletOutputState,
|
||||
assert_tracked_wallet_fees_equal_sponsored_fee_account_spend,
|
||||
create_and_submit_transaction, parse_wallet_output_state,
|
||||
wait_for_wallet_output_state, wait_for_wallet_submitted_transactions_inclusion,
|
||||
},
|
||||
},
|
||||
},
|
||||
wallet::{
|
||||
submissions::create_and_submit_transaction_hashes_with_utxo_cache,
|
||||
sync::{WalletSendReadiness, wait_wallet_send_ready},
|
||||
},
|
||||
world::{CucumberWorld, WalletInfo, WalletType},
|
||||
},
|
||||
non_zero,
|
||||
};
|
||||
|
||||
#[when(expr = "I do a coin split for {string} of {int} UTXOs valued at {int} LGO tokens each")]
|
||||
async fn step_do_coin_split(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
number_of_outputs: usize,
|
||||
output_value: u64,
|
||||
) -> StepResult {
|
||||
let wallet = world.resolve_wallet(&wallet_name).inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
|
||||
let mut available_utxos = WalletUtxos::new();
|
||||
let best_node_info = wait_wallet_send_ready(
|
||||
world,
|
||||
&step.value,
|
||||
&wallet_name,
|
||||
180,
|
||||
number_of_outputs as u64 * output_value,
|
||||
WalletSendReadiness::TotalValueOnly,
|
||||
&mut available_utxos,
|
||||
&HashSet::new(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let self_pk = wallet.public_key().inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
let receivers = vec![(self_pk, output_value); number_of_outputs];
|
||||
let tx_hash_hex = create_and_submit_transaction(
|
||||
world,
|
||||
&step.value,
|
||||
&wallet_name,
|
||||
&receivers,
|
||||
Some(&best_node_info),
|
||||
Some(&mut available_utxos),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Submitted coin split transaction for `{wallet_name}/{}`, outputs: {number_of_outputs}, \
|
||||
value: {output_value}, tx hash: {tx_hash_hex}",
|
||||
wallet.node_name
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tops up a node's funding wallet from a user wallet with `note_count`
|
||||
/// notes of `note_value` LGO each, in a single transaction.
|
||||
///
|
||||
/// Funding a transaction reserves one wallet note until the transaction is
|
||||
/// mined, so a funding wallet supports at most `note count` concurrent
|
||||
/// in-flight funded transactions — and its single `10_000` genesis note cannot
|
||||
/// pay the fees of scenarios that publish many messages at non-zero gas
|
||||
/// prices. This mirrors the workflow of a real node operator provisioning
|
||||
/// their sequencer's funding wallet.
|
||||
#[when(
|
||||
expr = "wallet {string} sends {int} notes of {int} LGO to node {string} funding wallet as {string}"
|
||||
)]
|
||||
async fn step_topup_node_funding_wallet(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
note_count: usize,
|
||||
note_value: u64,
|
||||
node_name: String,
|
||||
transaction_alias: String,
|
||||
) -> StepResult {
|
||||
let funding_wallet = world.funding_wallet(&node_name).inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
let funding_pk = funding_wallet.public_key().inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
|
||||
let mut available_utxos = WalletUtxos::new();
|
||||
let best_node_info = wait_wallet_send_ready(
|
||||
world,
|
||||
&step.value,
|
||||
&wallet_name,
|
||||
180,
|
||||
note_count as u64 * note_value,
|
||||
WalletSendReadiness::TotalValueOnly,
|
||||
&mut available_utxos,
|
||||
&HashSet::new(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let receivers = vec![(funding_pk, note_value); note_count];
|
||||
let tx_hashes = create_and_submit_transaction_hashes_with_utxo_cache(
|
||||
world,
|
||||
&step.value,
|
||||
&wallet_name,
|
||||
&receivers,
|
||||
Some(&best_node_info),
|
||||
Some(&mut available_utxos),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
let tx_hash = tx_hashes
|
||||
.first()
|
||||
.copied()
|
||||
.ok_or_else(|| StepError::LogicalError {
|
||||
message: "funding wallet top-up produced no transaction".to_owned(),
|
||||
})?;
|
||||
world.remember_submitted_transaction(transaction_alias.clone(), tx_hash);
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Submitted funding wallet top-up `{transaction_alias}`: {note_count} notes of \
|
||||
{note_value} LGO from `{wallet_name}` to `{}`",
|
||||
funding_wallet.wallet_name,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(expr = "wallet {string} has {int} or more outputs in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has {int} or more outputs in {int} seconds")]
|
||||
async fn step_wallet_has_at_least_coins(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
min_coin_count: usize,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_wallet_output_state(
|
||||
world,
|
||||
&step.value,
|
||||
wallet_name,
|
||||
Some(&min_coin_count),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
time_out_seconds,
|
||||
WalletOutputState::OnChain,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "wallet {string} has {int} or less outputs in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has {int} or less outputs in {int} seconds")]
|
||||
async fn step_wallet_has_at_most_coins(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
max_coin_count: usize,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_wallet_output_state(
|
||||
world,
|
||||
&step.value,
|
||||
wallet_name,
|
||||
None,
|
||||
Some(&max_coin_count),
|
||||
None,
|
||||
None,
|
||||
time_out_seconds,
|
||||
WalletOutputState::OnChain,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "wallet {string} has exactly {int} outputs in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has exactly {int} outputs in {int} seconds")]
|
||||
async fn step_wallet_has_exact_coins(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
coin_count: usize,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_wallet_output_state(
|
||||
world,
|
||||
&step.value,
|
||||
wallet_name,
|
||||
Some(&coin_count),
|
||||
Some(&coin_count),
|
||||
None,
|
||||
None,
|
||||
time_out_seconds,
|
||||
WalletOutputState::OnChain,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "wallet {string} has {int} or less encumbered outputs in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has {int} or less encumbered outputs in {int} seconds")]
|
||||
async fn step_wallet_has_at_most_encumbered_coins(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
max_coin_count: usize,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_wallet_output_state(
|
||||
world,
|
||||
&step.value,
|
||||
wallet_name,
|
||||
None,
|
||||
Some(&max_coin_count),
|
||||
None,
|
||||
None,
|
||||
time_out_seconds,
|
||||
WalletOutputState::Reserved,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "wallet {string} has {int} or more LGO in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has {int} or more LGO in {int} seconds")]
|
||||
async fn step_wallet_has_at_least_value(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
min_token_value: u64,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_wallet_output_state(
|
||||
world,
|
||||
&step.value,
|
||||
wallet_name,
|
||||
None,
|
||||
None,
|
||||
Some(&min_token_value),
|
||||
None,
|
||||
time_out_seconds,
|
||||
WalletOutputState::OnChain,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "wallet {string} has exactly {int} LGO in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has exactly {int} LGO in {int} seconds")]
|
||||
async fn step_wallet_has_exact_value(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
token_value: u64,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_wallet_output_state(
|
||||
world,
|
||||
&step.value,
|
||||
wallet_name,
|
||||
None,
|
||||
None,
|
||||
Some(&token_value),
|
||||
Some(&token_value),
|
||||
time_out_seconds,
|
||||
WalletOutputState::OnChain,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "wallet {string} has {int} or less LGO in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has {int} or less LGO in {int} seconds")]
|
||||
async fn step_wallet_has_at_most_value(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
max_token_value: u64,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_wallet_output_state(
|
||||
world,
|
||||
&step.value,
|
||||
wallet_name,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(&max_token_value),
|
||||
time_out_seconds,
|
||||
WalletOutputState::OnChain,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "wallet {string} has {int} or more outputs and {int} or more LGO in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has {int} or more outputs and {int} or more LGO in {int} seconds")]
|
||||
async fn step_wallet_has_at_least_coins_and_value(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
min_coin_count: usize,
|
||||
min_token_value: u64,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_wallet_output_state(
|
||||
world,
|
||||
&step.value,
|
||||
wallet_name,
|
||||
Some(&min_coin_count),
|
||||
None,
|
||||
Some(&min_token_value),
|
||||
None,
|
||||
time_out_seconds,
|
||||
WalletOutputState::OnChain,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "wallet {string} has {int} or less outputs and {int} or less LGO in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has {int} or less outputs and {int} or less LGO in {int} seconds")]
|
||||
async fn step_wallet_has_at_most_coins_and_value(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
max_coin_count: usize,
|
||||
max_token_value: u64,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_wallet_output_state(
|
||||
world,
|
||||
&step.value,
|
||||
wallet_name,
|
||||
None,
|
||||
Some(&max_coin_count),
|
||||
None,
|
||||
Some(&max_token_value),
|
||||
time_out_seconds,
|
||||
WalletOutputState::OnChain,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "wallet {string} has exactly {int} outputs and {int} LGO in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has exactly {int} outputs and {int} LGO in {int} seconds")]
|
||||
async fn step_wallet_has_exact_coins_and_value(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
coin_count: usize,
|
||||
token_value: u64,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_wallet_output_state(
|
||||
world,
|
||||
&step.value,
|
||||
wallet_name,
|
||||
Some(&coin_count),
|
||||
Some(&coin_count),
|
||||
Some(&token_value),
|
||||
Some(&token_value),
|
||||
time_out_seconds,
|
||||
WalletOutputState::OnChain,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "I log wallet balances for all wallets")]
|
||||
#[then(expr = "I log wallet balances for all wallets")]
|
||||
async fn step_wallet_balance_all_wallets(world: &mut CucumberWorld, step: &Step) -> StepResult {
|
||||
let mut wallets = world.all_user_wallets();
|
||||
wallets.extend(world.all_node_wallets());
|
||||
|
||||
log_wallet_balances(world, &step.value, wallets).await
|
||||
}
|
||||
|
||||
#[when(expr = "I drain wallet {string} into {string}")]
|
||||
#[then(expr = "I drain wallet {string} into {string}")]
|
||||
async fn step_drain_wallet(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sender_wallet_name: String,
|
||||
receiver_wallet_name: String,
|
||||
) -> StepResult {
|
||||
let sender = world.resolve_wallet(&sender_wallet_name)?;
|
||||
let receiver = world.resolve_recipient(&receiver_wallet_name)?;
|
||||
let sender_pk = sender.public_key()?;
|
||||
let receiver_pk = receiver.public_key;
|
||||
|
||||
if sender_pk == receiver_pk {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!("Cannot drain wallet `{sender_wallet_name}` into itself"),
|
||||
});
|
||||
}
|
||||
|
||||
match sender.wallet_type {
|
||||
WalletType::User { .. } => {
|
||||
drain_user_wallet(world, &step.value, &sender, receiver_pk).await
|
||||
}
|
||||
WalletType::Funding { .. } => drain_node_wallet(world, &sender, receiver_pk).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[when(expr = "I drain all node {string} wallets into {string}")]
|
||||
#[then(expr = "I drain all node {string} wallets into {string}")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require a mutable World reference"
|
||||
)]
|
||||
async fn step_drain_all_node_wallets(
|
||||
world: &mut CucumberWorld,
|
||||
node_name: String,
|
||||
receiver_wallet_name: String,
|
||||
) -> StepResult {
|
||||
drain_all_node_wallets(world, &node_name, &receiver_wallet_name).await
|
||||
}
|
||||
|
||||
#[when(expr = "wallet {string} has all submitted transactions settled in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has all submitted transactions settled in {int} seconds")]
|
||||
#[when(expr = "wallet {string} has all submitted transactions included in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has all submitted transactions included in {int} seconds")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require the world as the first `&mut` argument"
|
||||
)]
|
||||
async fn step_wallet_has_all_submitted_transactions_settled(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_wallet_submitted_transactions_inclusion(
|
||||
world,
|
||||
&wallet_name,
|
||||
Duration::from_secs(time_out_seconds),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})
|
||||
}
|
||||
|
||||
#[when(expr = "tracked wallet fees equal sponsored fee account spent fees")]
|
||||
#[then(expr = "tracked wallet fees equal sponsored fee account spent fees")]
|
||||
async fn step_tracked_wallet_fees_equal_sponsored_fee_account_spend(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
) -> StepResult {
|
||||
assert_tracked_wallet_fees_equal_sponsored_fee_account_spend(world, &step.value).await
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "I send {int} transactions of {int} LGO each from wallet {string} to wallet {string}"
|
||||
)]
|
||||
async fn step_send_multiple_transactions_to_single_wallet(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
number_of_transactions: usize,
|
||||
output_value: u64,
|
||||
sender_wallet_name: String,
|
||||
receiver_wallet_name: String,
|
||||
) -> StepResult {
|
||||
let sender_wallet = world.resolve_wallet(&sender_wallet_name).inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
let receiver = world.resolve_recipient(&receiver_wallet_name)?;
|
||||
let receiver_wallet_pk = receiver.public_key;
|
||||
|
||||
let mut available_utxos = WalletUtxos::new();
|
||||
let best_node_info = wait_wallet_send_ready(
|
||||
world,
|
||||
&step.value,
|
||||
&sender_wallet_name,
|
||||
180,
|
||||
number_of_transactions as u64 * output_value,
|
||||
WalletSendReadiness::TotalValueOnly,
|
||||
&mut available_utxos,
|
||||
&HashSet::new(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for _ in 0..number_of_transactions {
|
||||
let tx_hash_hex = create_and_submit_transaction(
|
||||
world,
|
||||
&step.value,
|
||||
&sender_wallet_name,
|
||||
&[(receiver_wallet_pk, output_value)],
|
||||
Some(&best_node_info),
|
||||
Some(&mut available_utxos),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Sent normal transaction from `{sender_wallet_name}/{}` to {}, \
|
||||
value: {output_value}, tx hash: {tx_hash_hex}",
|
||||
sender_wallet.node_name,
|
||||
receiver.label
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(expr = "I submit invalid transfer transaction {string} to node {string}")]
|
||||
async fn step_submit_invalid_transfer_transaction(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
transaction_alias: String,
|
||||
node_name: String,
|
||||
) -> StepResult {
|
||||
submit_invalid_transfer_transaction(world, &step.value, transaction_alias, node_name).await
|
||||
}
|
||||
|
||||
#[when(expr = "I submit a stateless-invalid transfer transaction {string} to node {string}")]
|
||||
async fn step_submit_stateless_invalid_transfer_transaction(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
transaction_alias: String,
|
||||
node_name: String,
|
||||
) -> StepResult {
|
||||
submit_stateless_invalid_transfer_transaction(world, &step.value, transaction_alias, node_name)
|
||||
.await
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_value,
|
||||
reason = "Cucumber step captures are always owned `String`s, even when the step only needs to borrow them"
|
||||
)]
|
||||
#[then(expr = "transaction {string} is rejected during preverification")]
|
||||
fn step_transaction_is_rejected_during_preverification(
|
||||
world: &mut CucumberWorld,
|
||||
transaction_alias: String,
|
||||
) -> StepResult {
|
||||
transaction_is_rejected_during_preverification(world, &transaction_alias)
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "I submit funded transfer transaction {string} of {int} LGO from wallet {string} to wallet {string}"
|
||||
)]
|
||||
async fn step_submit_funded_transfer_transaction(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
transaction_alias: String,
|
||||
amount: u64,
|
||||
sender_wallet_name: String,
|
||||
receiver_wallet_name: String,
|
||||
) -> StepResult {
|
||||
submit_funded_transfer_transaction(
|
||||
world,
|
||||
&step.value,
|
||||
transaction_alias,
|
||||
amount,
|
||||
sender_wallet_name,
|
||||
receiver_wallet_name,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "transaction {string} is not included in {int} seconds")]
|
||||
#[then(expr = "transaction {string} is not included in {int} seconds")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_transaction_is_not_included_in_seconds(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
transaction_alias: String,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
transaction_is_not_included_in_seconds(world, &step.value, transaction_alias, timeout_seconds)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "I send one transaction with {int} outputs of {int} LGO each from wallet {string} to wallet {string}"
|
||||
)]
|
||||
async fn step_send_single_transaction_multiple_outputs_to_single_wallet(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
number_of_outputs: usize,
|
||||
output_value: u64,
|
||||
sender_wallet_name: String,
|
||||
receiver_wallet_name: String,
|
||||
) -> StepResult {
|
||||
let sender_wallet = world.resolve_wallet(&sender_wallet_name).inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
let receiver = world.resolve_recipient(&receiver_wallet_name)?;
|
||||
let receiver_wallet_pk = receiver.public_key;
|
||||
|
||||
let receivers = vec![(receiver_wallet_pk, output_value); number_of_outputs];
|
||||
let tx_hash_hex = create_and_submit_transaction(
|
||||
world,
|
||||
&step.value,
|
||||
&sender_wallet_name,
|
||||
&receivers,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Sent normal transaction from `{sender_wallet_name}/{}` to {}, \
|
||||
number_of_outputs: {number_of_outputs}, value: {output_value}, tx hash: {tx_hash_hex}",
|
||||
sender_wallet.node_name,
|
||||
receiver.label
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(expr = "I perform manual control of transactions for all wallets for {int} seconds")]
|
||||
async fn step_manual_control_transactions(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
perform_manual_step_control(world, &step.value, timeout_seconds).await
|
||||
}
|
||||
|
||||
#[when(expr = "I perform manual control of transactions for all wallets no time-out")]
|
||||
async fn step_manual_control_transactions_no_time_out(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
) -> StepResult {
|
||||
perform_manual_step_control(world, &step.value, u64::MAX).await
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "I perform continuous transactions on user wallets with {int} coin split outputs of {int} LGO, {int} transactions of {int} LGO each for {int} cycles with {int} epochs headroom"
|
||||
)]
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "Cucumber step captures map directly to step arguments"
|
||||
)]
|
||||
async fn step_continuous_user_wallets(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
coin_split_outputs: usize,
|
||||
coin_split_value: u64,
|
||||
transactions: usize,
|
||||
value: u64,
|
||||
cycles: usize,
|
||||
epochs_headroom: u32,
|
||||
) -> StepResult {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Starting continuous user wallet transactions: coin_split_outputs={coin_split_outputs}, coin_split_value={coin_split_value}, transactions={transactions}, value={value}, cycles={cycles}"
|
||||
);
|
||||
|
||||
execute_continuous_round_robin_user_wallets(
|
||||
world,
|
||||
&step.value,
|
||||
coin_split_outputs,
|
||||
coin_split_value,
|
||||
transactions,
|
||||
value,
|
||||
cycles,
|
||||
epochs_headroom,
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
|
||||
info!(target: TARGET, "Completed continuous user wallet transactions step");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "I perform continuous transactions on user wallets with {int} coin split outputs of {int} LGO, {int} transactions of {int} LGO each for {int} cycles and timeout of {int} seconds with {int} epochs headroom"
|
||||
)]
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "Cucumber step captures map directly to step function arguments."
|
||||
)]
|
||||
async fn step_continuous_user_wallets_with_timeout(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
coin_split_outputs: usize,
|
||||
coin_split_value: u64,
|
||||
transactions: usize,
|
||||
value: u64,
|
||||
cycles: usize,
|
||||
timeout_seconds: u64,
|
||||
epochs_headroom: u32,
|
||||
) -> StepResult {
|
||||
timeout(
|
||||
Duration::from_secs(timeout_seconds),
|
||||
step_continuous_user_wallets(
|
||||
world,
|
||||
step,
|
||||
coin_split_outputs,
|
||||
coin_split_value,
|
||||
transactions,
|
||||
value,
|
||||
cycles,
|
||||
epochs_headroom,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| StepError::Timeout {
|
||||
message: format!(
|
||||
"continuous user wallet transactions did not finish within {timeout_seconds} seconds"
|
||||
),
|
||||
})?
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "I perform {int} coin split transactions for each user wallet with {int} outputs of {int} LGO each"
|
||||
)]
|
||||
async fn step_coin_split_transactions_for_each_user_wallet(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
splits_per_wallet: usize,
|
||||
outputs: usize,
|
||||
value: u64,
|
||||
) -> StepResult {
|
||||
execute_coin_splits_all_user_wallets(world, &step.value, splits_per_wallet, outputs, value)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(expr = "I verify each wallet has minimum {int} outputs {string} in {int} seconds")]
|
||||
async fn step_verify_each_wallet_minimum_outputs(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
min_outputs: usize,
|
||||
wallet_state_type: String,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
verify_min_outputs_all_user_wallets(
|
||||
world,
|
||||
&step.value,
|
||||
min_outputs,
|
||||
timeout_seconds,
|
||||
parse_wallet_output_state(&wallet_state_type)
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})
|
||||
.map_err(|e| StepError::InvalidArgument {
|
||||
message: e.to_string(),
|
||||
})?,
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "I perform {int} stress continuous cycles with {int} transactions of {int} LGO to the next user wallet with {int} epochs headroom"
|
||||
)]
|
||||
async fn step_perform_stress_continuous_cycles_next_user_wallet(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
cycles: usize,
|
||||
num_transactions: usize,
|
||||
value: u64,
|
||||
epochs_headroom: u32,
|
||||
) -> StepResult {
|
||||
execute_continuous_next_wallet_user_wallet(
|
||||
world,
|
||||
&step.value,
|
||||
&ManualCommand::ContinuousNextWalletUserWallets {
|
||||
cycles,
|
||||
num_transactions,
|
||||
value,
|
||||
epochs_headroom,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given(expr = "I have a faucet with URL {string}")]
|
||||
#[when(expr = "I have a faucet with URL {string}")]
|
||||
fn step_faucet_details(world: &mut CucumberWorld, base_url: String) {
|
||||
world.wallet_registry.faucet_base_url = Some(base_url);
|
||||
}
|
||||
|
||||
#[given(expr = "I request {int} rounds of faucet funds for wallet {string}")]
|
||||
#[when(expr = "I request {int} rounds of faucet funds for wallet {string}")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_value,
|
||||
reason = "Required by cucumber expression"
|
||||
)]
|
||||
fn step_request_faucet_funds_for_wallet(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
number_of_rounds: usize,
|
||||
wallet_name: String,
|
||||
) -> StepResult {
|
||||
let wallet = world.resolve_wallet(&wallet_name).inspect_err(|error| {
|
||||
warn!(target: TARGET, "Step `{}` error: {error}", step.value);
|
||||
})?;
|
||||
|
||||
let wallet_pk_hex = wallet.public_key_hex();
|
||||
|
||||
utils::request_faucet_funds(
|
||||
world,
|
||||
&step.value,
|
||||
non_zero!("number of rounds", number_of_rounds)?,
|
||||
&[wallet_pk_hex],
|
||||
)
|
||||
}
|
||||
|
||||
#[given(expr = "I request {int} rounds of faucet funds for all wallets")]
|
||||
#[when(expr = "I request {int} rounds of faucet funds for all wallets")]
|
||||
fn step_request_faucet_funds_for_all_wallets(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
number_of_rounds: usize,
|
||||
) -> StepResult {
|
||||
let all_wallets_pk_hex = world
|
||||
.wallet_registry
|
||||
.wallet_info
|
||||
.values()
|
||||
.map(WalletInfo::public_key_hex)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
utils::request_faucet_funds(
|
||||
world,
|
||||
&step.value,
|
||||
non_zero!("number of rounds", number_of_rounds)?,
|
||||
&all_wallets_pk_hex,
|
||||
)
|
||||
}
|
||||
|
||||
#[given(expr = "I request {int} rounds of faucet funds for all user wallets")]
|
||||
#[when(expr = "I request {int} rounds of faucet funds for all user wallets")]
|
||||
fn step_request_faucet_funds_for_all_user_wallets(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
number_of_rounds: usize,
|
||||
) -> StepResult {
|
||||
let all_wallets_pk_hex = world
|
||||
.wallet_registry
|
||||
.wallet_info
|
||||
.values()
|
||||
.filter(|w| w.is_user_wallet())
|
||||
.map(WalletInfo::public_key_hex)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
utils::request_faucet_funds(
|
||||
world,
|
||||
&step.value,
|
||||
non_zero!("number of rounds", number_of_rounds)?,
|
||||
&all_wallets_pk_hex,
|
||||
)
|
||||
}
|
||||
|
||||
#[given(expr = "I request {int} rounds of faucet funds for all funding wallets")]
|
||||
#[when(expr = "I request {int} rounds of faucet funds for all funding wallets")]
|
||||
fn step_request_faucet_funds_for_all_funding_wallets(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
number_of_rounds: usize,
|
||||
) -> StepResult {
|
||||
let all_wallets_pk_hex = world
|
||||
.wallet_registry
|
||||
.wallet_info
|
||||
.values()
|
||||
.filter(|wallet| wallet.is_node_funding_wallet())
|
||||
.map(WalletInfo::public_key_hex)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
utils::request_faucet_funds(
|
||||
world,
|
||||
&step.value,
|
||||
non_zero!("number of rounds", number_of_rounds)?,
|
||||
&all_wallets_pk_hex,
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
||||
mod actions;
|
||||
mod assertions;
|
||||
mod errors;
|
||||
pub mod runner;
|
||||
pub mod steps;
|
||||
mod support;
|
||||
mod tables;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1
-3
@@ -25,9 +25,7 @@ use crate::{
|
||||
common::wallet::{WalletTransactionError, WalletTransactionIntent},
|
||||
cucumber::{
|
||||
error::StepError,
|
||||
steps::{
|
||||
TARGET, manual_transactions::tracked_transactions::create_stateless_invalid_transaction,
|
||||
},
|
||||
steps::{TARGET, transactions::tracked_transactions::create_stateless_invalid_transaction},
|
||||
utils::{tx_hash_to_hex, user_config_from_node_yaml},
|
||||
wallet::submissions::{
|
||||
SignedUserWalletSubmission, prepare_user_wallet_transaction_submission,
|
||||
+1
-1
@@ -2,7 +2,7 @@ use cucumber::{gherkin::Step, then, when};
|
||||
|
||||
use crate::cucumber::{
|
||||
error::{StepError, StepResult},
|
||||
steps::manual_mempool::{
|
||||
steps::mempool::{
|
||||
actions::{
|
||||
prepare_transfer_transaction, submit_prepared_transaction_through_blend,
|
||||
submit_prepared_transaction_to_nodes, try_submit_invalid_transaction,
|
||||
@@ -2,16 +2,16 @@ pub mod run;
|
||||
pub mod scenario;
|
||||
pub mod workloads;
|
||||
|
||||
pub mod cluster;
|
||||
pub mod fees;
|
||||
pub mod manual_cluster;
|
||||
pub mod manual_k8s;
|
||||
pub mod manual_mempool;
|
||||
pub mod manual_nodes;
|
||||
pub mod manual_transactions;
|
||||
pub mod manual_zone;
|
||||
pub mod k8s;
|
||||
pub mod mempool;
|
||||
pub mod nodes;
|
||||
pub mod parse_steps;
|
||||
pub mod pow;
|
||||
pub mod tokio_console;
|
||||
pub mod transactions;
|
||||
pub mod wallet_fund;
|
||||
pub mod zone;
|
||||
|
||||
const TARGET: &str = "cucumber_steps";
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ use tracing::{info, warn};
|
||||
use crate::cucumber::{
|
||||
TARGET,
|
||||
error::{StepError, StepResult},
|
||||
steps::manual_nodes::config_override::set_deployment_config_override,
|
||||
steps::nodes::config_override::set_deployment_config_override,
|
||||
utils::{peer_id_from_node_yaml, user_config_from_node_yaml},
|
||||
world::{BlendDiagnosticPhase, CucumberWorld},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
pub(crate) mod config_override;
|
||||
pub(crate) mod diagnostics;
|
||||
pub mod lib_assertions;
|
||||
pub mod parameters;
|
||||
pub(crate) mod snapshots;
|
||||
pub mod steps;
|
||||
|
||||
mod operations;
|
||||
|
||||
pub(crate) use operations::{
|
||||
NodesToStartUnordered, WalletStartInfo, create_snapshot_all_nodes_with_wallet_state,
|
||||
create_snapshot_node_with_wallet_state, create_snapshots_all_nodes,
|
||||
ensure_all_nodes_agree_on_lib, ensure_fee_sponsorship_and_fork_groups_are_not_mixed,
|
||||
fetch_public_peer_consensus, genesis_block_utxos, get_cryptarchia_info_all_nodes,
|
||||
nodes_converged, parse_genesis_wallet_tokens_row, parse_mining_wallet_resources_table_row,
|
||||
parse_url, parse_wallet_resources_table_row, poll_all_nodes_and_update_consensus_cache,
|
||||
restart_node, start_node, start_nodes_order_respecting_dependencies, stop_node,
|
||||
verify_genesis_wallet_resources_table_indexes,
|
||||
verify_mining_node_wallet_resources_table_indexes, verify_node_wallet_resources_table_indexes,
|
||||
verify_reponsive_and_network_ready_with_timeout, wait_all_nodes_responive,
|
||||
wait_for_all_nodes_to_be_synced_to_chain,
|
||||
};
|
||||
@@ -0,0 +1,376 @@
|
||||
use super::*;
|
||||
|
||||
fn tips_aligned_at_min_difference(
|
||||
nodes_chain_info: &HashMap<String, ChainInfoMap>,
|
||||
all_nodes_min: u64,
|
||||
) -> (AlignmentStatus, Vec<MaybeSnapshot>) {
|
||||
// Always return per-node view at min_height for logging
|
||||
let mut anchor_hashes: Vec<MaybeSnapshot> = Vec::with_capacity(nodes_chain_info.len());
|
||||
|
||||
for node_name in nodes_chain_info.keys() {
|
||||
let peer_chain = nodes_chain_info
|
||||
.get(node_name)
|
||||
.expect("nodes_chain_info must be pre-initialized");
|
||||
anchor_hashes.push(MaybeSnapshot {
|
||||
height: all_nodes_min,
|
||||
header_hash: peer_chain.get(&all_nodes_min).cloned(),
|
||||
});
|
||||
}
|
||||
|
||||
let all_have = anchor_hashes.iter().all(|snap| snap.header_hash.is_some());
|
||||
if !all_have {
|
||||
return (AlignmentStatus::MissingChainInfo, anchor_hashes);
|
||||
}
|
||||
|
||||
let compare_hash = anchor_hashes[0].header_hash.as_ref().unwrap();
|
||||
let all_same = anchor_hashes
|
||||
.iter()
|
||||
.all(|snap| snap.header_hash.as_ref().unwrap() == compare_hash);
|
||||
|
||||
if all_same {
|
||||
(AlignmentStatus::Aligned, anchor_hashes)
|
||||
} else {
|
||||
(AlignmentStatus::Fork, anchor_hashes)
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_and_update_chain_info(
|
||||
step: &str,
|
||||
nodes_info: &mut HashMap<String, NodeInfo>,
|
||||
nodes_chain_info: &mut HashMap<String, ChainInfoMap>,
|
||||
) -> Result<(u64, u64, Vec<u64>), StepError> {
|
||||
poll_all_nodes_and_update_consensus_cache(step, nodes_info).await?;
|
||||
|
||||
let mut best_node_heights: Vec<u64> = Vec::with_capacity(nodes_info.len());
|
||||
|
||||
for node_info in nodes_info.values() {
|
||||
let max_height = node_info.best_height().unwrap_or_default();
|
||||
best_node_heights.push(max_height);
|
||||
|
||||
let started_node_name = node_info.started_node.name.clone();
|
||||
let chain =
|
||||
nodes_chain_info
|
||||
.get_mut(&started_node_name)
|
||||
.ok_or(StepError::LogicalError {
|
||||
message: format!(
|
||||
"Started node '{started_node_name}' not found in chain info map",
|
||||
),
|
||||
})?;
|
||||
let chain_info = node_info.chain_info();
|
||||
for (height, hash) in chain_info {
|
||||
chain.insert(*height, hash.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let all_nodes_min = *best_node_heights.iter().min().unwrap_or(&0);
|
||||
let all_nodes_max = *best_node_heights.iter().max().unwrap_or(&0);
|
||||
let diff = all_nodes_max - all_nodes_min;
|
||||
|
||||
Ok((all_nodes_min, diff, best_node_heights))
|
||||
}
|
||||
|
||||
fn log_waiting_status(
|
||||
status: &AlignmentStatus,
|
||||
min_height: Option<u64>,
|
||||
diff: u64,
|
||||
peer_heights: &[u64],
|
||||
peer_min: u64,
|
||||
anchor_hashes: &[MaybeSnapshot],
|
||||
start: Instant,
|
||||
) {
|
||||
match status {
|
||||
AlignmentStatus::Aligned => {
|
||||
let converge = min_height.map_or_else(
|
||||
|| "Waiting for all nodes to converge".to_owned(),
|
||||
|min_height| format!("Waiting for at least {min_height} blocks converged"),
|
||||
);
|
||||
info!(
|
||||
target: TARGET,
|
||||
"{converge} - elapsed: {:.2?}, diff: {diff}, heights: {peer_heights:?}",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
AlignmentStatus::MissingChainInfo => {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Waiting for all node's hashes at height {peer_min} - elapsed: {:.2?}, diff: \
|
||||
{diff}, heights: {peer_heights:?}, anchors: {:?}",
|
||||
start.elapsed(),
|
||||
anchor_hashes.iter().map(|snap| &snap.header_hash).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
AlignmentStatus::Fork => {
|
||||
let fork_hashes: HashSet<_> = anchor_hashes
|
||||
.iter()
|
||||
.filter_map(|snap| snap.header_hash.as_ref())
|
||||
.collect();
|
||||
info!(
|
||||
target: TARGET,
|
||||
"{} fork chains detected!!! Elapsed: {:.2?}, diff: {diff}, heights: {peer_heights:?}, \
|
||||
fork hashes at height {}: {:?}",
|
||||
fork_hashes.len(), start.elapsed(), anchor_hashes[0].height, fork_hashes
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn nodes_converged(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
min_height: Option<u64>,
|
||||
max_diff_height: u64,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
let nodes_info = &world.nodes_info.values().collect::<Vec<&NodeInfo>>();
|
||||
let start = Instant::now();
|
||||
let time_out = Duration::from_secs(time_out_seconds);
|
||||
|
||||
// node_name -> (height -> header_id) (overwrites on reorg)
|
||||
let mut nodes_chain_info: HashMap<String, ChainInfoMap> =
|
||||
HashMap::with_capacity(nodes_info.len());
|
||||
|
||||
// Pre-initialize so lookups are deterministic
|
||||
for node_info in nodes_info {
|
||||
nodes_chain_info
|
||||
.entry(node_info.started_node.name.clone())
|
||||
.or_default();
|
||||
}
|
||||
|
||||
let mut count = 0usize;
|
||||
loop {
|
||||
let (all_nodes_min, diff, peer_heights) =
|
||||
fetch_and_update_chain_info(step, &mut world.nodes_info, &mut nodes_chain_info).await?;
|
||||
let (status, anchor_hashes) =
|
||||
tips_aligned_at_min_difference(&nodes_chain_info, all_nodes_min);
|
||||
|
||||
if diff <= max_diff_height
|
||||
&& matches!(status, AlignmentStatus::Aligned)
|
||||
&& all_nodes_min >= min_height.unwrap_or_default()
|
||||
{
|
||||
if let Some(min_height) = min_height {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"All nodes have at least {min_height} blocks, converged in {:.2?} - max diff: \
|
||||
{diff}, heights: {peer_heights:?}",
|
||||
start.elapsed()
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"All nodes converged in {:.2?} - max diff: {diff}, heights: {peer_heights:?}",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if count.is_multiple_of(50) {
|
||||
log_waiting_status(
|
||||
&status,
|
||||
min_height,
|
||||
diff,
|
||||
&peer_heights,
|
||||
all_nodes_min,
|
||||
&anchor_hashes,
|
||||
start,
|
||||
);
|
||||
}
|
||||
|
||||
if start.elapsed() >= time_out {
|
||||
let err = min_height.map_or_else(|| StepError::StepFail {
|
||||
message: format!(
|
||||
"Step `{step}` error: Nodes did not converge to {max_diff_height} blocks at in \
|
||||
{time_out_seconds} s"
|
||||
),
|
||||
}, |min_height| StepError::StepFail {
|
||||
message: format!(
|
||||
"Step `{step}` error: Nodes did not converge to {max_diff_height} blocks at minimum height \
|
||||
{min_height} in {time_out_seconds} s"
|
||||
),
|
||||
});
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ensure_all_nodes_agree_on_lib(
|
||||
world: &CucumberWorld,
|
||||
step: &str,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
let start = Instant::now();
|
||||
let time_out = Duration::from_secs(time_out_seconds);
|
||||
let mut count = 0usize;
|
||||
|
||||
loop {
|
||||
let snapshots = try_join_all(world.nodes_info.values().map(async |node| {
|
||||
let consensus = node.started_node.client.consensus_info().await?;
|
||||
Ok::<_, StepError>((
|
||||
node.name.clone(),
|
||||
consensus.cryptarchia_info.height,
|
||||
consensus.cryptarchia_info.lib.encode_hex::<String>(),
|
||||
))
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let libs = snapshots
|
||||
.iter()
|
||||
.map(|(_, _, lib)| lib.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
if libs.len() == 1 {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"All nodes agree on LIB in {:.2?}",
|
||||
start.elapsed()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if count.is_multiple_of(50) {
|
||||
let status = format_lib_agreement_status(&snapshots);
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Waiting for all nodes to agree on LIB - elapsed {:.2?}, {status}",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
if start.elapsed() >= time_out {
|
||||
let status = format_lib_agreement_status(&snapshots);
|
||||
|
||||
return Err(StepError::StepFail {
|
||||
message: format!(
|
||||
"Step `{step}` error: Nodes did not agree on LIB in {time_out_seconds} s ({status})"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn format_lib_agreement_status(snapshots: &[(String, u64, String)]) -> String {
|
||||
snapshots
|
||||
.iter()
|
||||
.map(|(node_name, height, lib)| format!("{node_name}: {height}/{}", truncate_hash(lib, 16)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
pub async fn poll_all_nodes_and_update_consensus_cache<S: ::std::hash::BuildHasher>(
|
||||
step: &str,
|
||||
nodes_info: &mut HashMap<String, NodeInfo, S>,
|
||||
) -> Result<(), StepError> {
|
||||
use futures_util::future::join_all;
|
||||
|
||||
let nodes = nodes_info.values().collect::<Vec<&NodeInfo>>();
|
||||
|
||||
// Query every node, but do not fail-fast on the first error.
|
||||
let info_futures = nodes.iter().map(async |node| {
|
||||
let node_name = node.name.clone();
|
||||
let result = node.started_node.client.consensus_info().await;
|
||||
(node_name, result)
|
||||
});
|
||||
|
||||
let results = join_all(info_futures).await;
|
||||
|
||||
let mut snapshots = Vec::<ConsensusSnapshot>::new();
|
||||
let mut failed_nodes = Vec::<String>::new();
|
||||
|
||||
for (node_name, result) in results {
|
||||
match result {
|
||||
Ok(info) => snapshots.push(ConsensusSnapshot {
|
||||
node_name,
|
||||
height: info.cryptarchia_info.height,
|
||||
header_hash: info.cryptarchia_info.tip.encode_hex(),
|
||||
}),
|
||||
Err(e) => {
|
||||
// If both `consensus_info` and `network_info` fail, assume the node is no
|
||||
// longer responsive.
|
||||
if let Err(e2) = poll_network_info(
|
||||
nodes_info.get_mut(&node_name).expect("Failed to get node"),
|
||||
&node_name,
|
||||
5,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(StepError::StepFail {
|
||||
message: format!(
|
||||
"Step `{step}` error: {node_name} is not responsive anymore: {e} / {e2}"
|
||||
),
|
||||
});
|
||||
}
|
||||
warn!(
|
||||
target: TARGET,
|
||||
"Step `{step}` error: node `{node_name}` did not respond with consensus_info: {e}",
|
||||
);
|
||||
failed_nodes.push(node_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If all nodes failed in this poll, surface a hard error.
|
||||
// If at least one succeeded, update cache for those and let caller keep
|
||||
// polling.
|
||||
if snapshots.is_empty() {
|
||||
let failed = if failed_nodes.is_empty() {
|
||||
"none".to_owned()
|
||||
} else {
|
||||
failed_nodes.join(", ")
|
||||
};
|
||||
return Err(StepError::StepFail {
|
||||
message: format!(
|
||||
"Step `{step}` error: all nodes failed to respond with consensus_info in this poll \
|
||||
(failed: [{failed}])"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
for snap in &snapshots {
|
||||
let node = nodes_info
|
||||
.get_mut(&snap.node_name)
|
||||
.ok_or(StepError::LogicalError {
|
||||
message: format!(
|
||||
"Step `{step}` error: Runtime node '{}' not found in world.nodes_info",
|
||||
snap.node_name
|
||||
),
|
||||
})?;
|
||||
node.upsert_tip(snap.height, snap.header_hash.clone());
|
||||
}
|
||||
|
||||
if !failed_nodes.is_empty() {
|
||||
warn!(
|
||||
target: TARGET,
|
||||
"Step `{step}` warning: partial consensus poll failure; updated {}/{} node(s), failed: [{}]",
|
||||
snapshots.len(),
|
||||
snapshots.len() + failed_nodes.len(),
|
||||
failed_nodes.join(", "),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn poll_network_info(
|
||||
node_info: &NodeInfo,
|
||||
node_name: &str,
|
||||
time_out_seconds: u64,
|
||||
) -> Result<(), String> {
|
||||
let start = TokioInstant::now();
|
||||
let time_out = Duration::from_secs(time_out_seconds);
|
||||
while start.elapsed() <= time_out {
|
||||
if node_info.started_node.client.network_info().await.is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
Err(format!(
|
||||
"Node `{node_name}` did not respond to network_info after {time_out_seconds:.2?}"
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,995 @@
|
||||
use super::*;
|
||||
|
||||
// Sort nodes_to_start with empty peers first to ensure standalone nodes start
|
||||
// before connected nodes, then by dependency order to ensure all peers of a
|
||||
// node are started before the node itself is started. If there is a circular
|
||||
// dependency, return an error.
|
||||
pub fn start_nodes_order_respecting_dependencies(
|
||||
nodes_to_start: NodesToStartUnordered,
|
||||
already_started: HashSet<String>,
|
||||
) -> Result<NodesToStartOrdered, StepError> {
|
||||
let mut remaining = nodes_to_start;
|
||||
// Peers that are already running (started by earlier steps) count as
|
||||
// satisfied dependencies, so a node in this batch may connect to them.
|
||||
let mut started = already_started;
|
||||
let mut ordered = Vec::new();
|
||||
|
||||
// Step 1: Find all nodes whose peer dependencies are already satisfied
|
||||
// (no in-batch peers, or all peers already running).
|
||||
let nodes_without_peers: Vec<String> = remaining
|
||||
.iter()
|
||||
.filter(|&(_, (_, peers))| peers.iter().all(|peer| started.contains(peer)))
|
||||
.map(|(node_name, (_, _))| node_name.clone())
|
||||
.collect();
|
||||
|
||||
if nodes_without_peers.is_empty() && !remaining.is_empty() {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: "No nodes without peer dependencies found. Possible circular dependency."
|
||||
.to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
// Update start list with all nodes without peers
|
||||
for node_name in nodes_without_peers {
|
||||
if let Some((wallet_infos, initial_peers)) = remaining.remove(&node_name) {
|
||||
ordered.push((node_name.clone(), wallet_infos, initial_peers));
|
||||
started.insert(node_name);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Iteratively find nodes whose peer dependencies are already included
|
||||
// in the start list
|
||||
while !remaining.is_empty() {
|
||||
let mut made_progress = false;
|
||||
|
||||
let ready_nodes: Vec<String> = remaining
|
||||
.iter()
|
||||
.filter_map(|(node_name, (_, peers))| {
|
||||
let all_peers_started = peers.iter().all(|peer| started.contains(peer));
|
||||
all_peers_started.then(|| node_name.clone())
|
||||
})
|
||||
.collect();
|
||||
|
||||
for node_name in ready_nodes {
|
||||
if let Some((wallet_infos, mut peers)) = remaining.remove(&node_name) {
|
||||
peers.sort();
|
||||
peers.dedup();
|
||||
ordered.push((node_name.clone(), wallet_infos, peers));
|
||||
started.insert(node_name);
|
||||
made_progress = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !made_progress {
|
||||
let remaining_nodes: Vec<String> = remaining.keys().cloned().collect();
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!("Circular dependency detected among nodes: {remaining_nodes:?}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ordered)
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_lines,
|
||||
reason = "Covers startup, optional snapshot seeding, wallet wiring, and readiness in one path"
|
||||
)]
|
||||
#[expect(
|
||||
clippy::cognitive_complexity,
|
||||
reason = "Singular fn with multiple branches to handle different events and futures."
|
||||
)]
|
||||
pub async fn start_node(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
node_name: &str,
|
||||
wallet_start_info: &[WalletStartInfo],
|
||||
initial_peers: &[String],
|
||||
immediate_start: bool,
|
||||
extra_user_overrides: &[ConfigOverride],
|
||||
) -> StepResult {
|
||||
if world.cluster.local_cluster.is_none() {
|
||||
return Err(StepError::LogicalError {
|
||||
message: "No local cluster available".into(),
|
||||
});
|
||||
}
|
||||
let mut startup_settings =
|
||||
get_startup_settings(world, initial_peers, node_name).inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{step}` error: {e}");
|
||||
})?;
|
||||
// Merge per-node user config overrides (e.g. a mining node's derived
|
||||
// `pow.claim_address`) on top of the scenario-wide ones, upserting by path.
|
||||
for extra in extra_user_overrides {
|
||||
if let Some(existing) = startup_settings
|
||||
.user_config_overrides
|
||||
.iter_mut()
|
||||
.find(|item| item.path == extra.path)
|
||||
{
|
||||
existing.value = extra.value.clone();
|
||||
} else {
|
||||
startup_settings.user_config_overrides.push(extra.clone());
|
||||
}
|
||||
}
|
||||
let is_bootstrap_node = startup_settings.is_bootstrap_node;
|
||||
let join_external_network = startup_settings.join_external_network;
|
||||
let persist_dir = world.lifecycle.scenario_base_dir.join(node_name);
|
||||
let runtime_dir_prefix = format!("{node_name}_");
|
||||
let final_dir_ignore_list = matching_child_dirs(&persist_dir, &runtime_dir_prefix);
|
||||
let tokio_console_node = startup_settings.tokio_console_node.clone();
|
||||
let scenario_wallet_key_ids = world
|
||||
.wallet_registry
|
||||
.wallet_accounts
|
||||
.values()
|
||||
.map(wallet_account_key_id)
|
||||
.chain(
|
||||
world
|
||||
.wallet_registry
|
||||
.fee_state
|
||||
.wallet_account
|
||||
.iter()
|
||||
.map(wallet_account_key_id),
|
||||
)
|
||||
.collect();
|
||||
let start_options = StartNodeOptions::default()
|
||||
.with_peers(startup_settings.peer_selection)
|
||||
.with_persist_dir(persist_dir)
|
||||
.create_patch(move |mut config: RunConfig| {
|
||||
prepare_config_patch(
|
||||
&mut config,
|
||||
startup_settings.join_external_network,
|
||||
startup_settings.deployment_settings_override.as_ref(),
|
||||
&startup_settings.manual_node_config_overrides,
|
||||
startup_settings.initial_peers_override.as_ref(),
|
||||
&startup_settings.ibd_peers,
|
||||
&startup_settings.user_config_overrides,
|
||||
&startup_settings.deployment_config_overrides,
|
||||
startup_settings.tokio_console_node.as_ref(),
|
||||
&scenario_wallet_key_ids,
|
||||
)?;
|
||||
Ok(config)
|
||||
});
|
||||
|
||||
let started_node = {
|
||||
let cluster = world
|
||||
.cluster
|
||||
.local_cluster
|
||||
.as_ref()
|
||||
.expect("local cluster checked");
|
||||
Box::pin(cluster.start_node_with(node_name, start_options))
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{step}` error: {e}");
|
||||
})?
|
||||
};
|
||||
|
||||
let node_final_dir = extract_child_dir_name(
|
||||
&world.lifecycle.scenario_base_dir,
|
||||
&runtime_dir_prefix,
|
||||
&final_dir_ignore_list,
|
||||
)
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{step}` error: {e}");
|
||||
})?;
|
||||
let node_runtime_dir = world
|
||||
.lifecycle
|
||||
.scenario_base_dir
|
||||
.join(node_final_dir.clone());
|
||||
populate_slots_per_epoch_from_deployment(world, &node_runtime_dir)?;
|
||||
let started_node_name = started_node.name.clone();
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Starting node `{node_name}` with runtime_dir='{}'",
|
||||
display_last_path_components(&node_runtime_dir, 4)
|
||||
);
|
||||
|
||||
// `StartNodeOptions::with_persist_dir` currently creates a fresh runtime
|
||||
// directory for each launch. Seed that runtime directory and restart once
|
||||
// to effectively initialize from a named snapshot.
|
||||
let restored_node_snapshot = if let Some(node_snapshot) =
|
||||
world.snapshots.node_snapshot_on_startup.clone()
|
||||
{
|
||||
let stop_result = {
|
||||
let cluster = world
|
||||
.cluster
|
||||
.local_cluster
|
||||
.as_ref()
|
||||
.expect("local cluster checked");
|
||||
cluster
|
||||
.stop_node(&started_node_name)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{step}` error: {e}");
|
||||
})
|
||||
};
|
||||
stop_result?;
|
||||
|
||||
restore_node_state_from_snapshot(&node_snapshot, &node_runtime_dir).inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{step}` error: {e}");
|
||||
})?;
|
||||
populate_slots_per_epoch_from_deployment(world, &node_runtime_dir)?;
|
||||
|
||||
let restart_result = {
|
||||
let cluster = world
|
||||
.cluster
|
||||
.local_cluster
|
||||
.as_ref()
|
||||
.expect("local cluster checked");
|
||||
cluster
|
||||
.restart_node(&started_node_name)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{step}` error: {e}");
|
||||
})
|
||||
};
|
||||
restart_result?;
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Node {node_name} started from snapshot {}/{}",
|
||||
node_snapshot.name, node_snapshot.node
|
||||
);
|
||||
Some(node_snapshot)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Scrape the final node directory name to get the correct path to the node's
|
||||
// YAML file for extracting the peer ID, since the actual directory name has
|
||||
// a random suffix added by the deployer.
|
||||
world.cluster.node_peer_ids.insert(
|
||||
node_name.to_owned(),
|
||||
peer_id_from_node_yaml(&node_runtime_dir.join(USER_CONFIG_FILE))?,
|
||||
);
|
||||
|
||||
let wallet_info = add_wallets(
|
||||
world,
|
||||
step,
|
||||
node_name,
|
||||
wallet_start_info,
|
||||
&started_node,
|
||||
&node_runtime_dir,
|
||||
join_external_network,
|
||||
)
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{step}` error: {e}");
|
||||
})?;
|
||||
|
||||
world
|
||||
.wallet_registry
|
||||
.wallet_info
|
||||
.extend(wallet_info.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
|
||||
let client = started_node.client.clone();
|
||||
// Move `started_node` into the world's NodeInfo (no clone required)
|
||||
world.nodes_info.insert(
|
||||
node_name.to_owned(),
|
||||
NodeInfo {
|
||||
name: node_name.to_owned(),
|
||||
started_node,
|
||||
run_config: None,
|
||||
chain_info: HashMap::default(),
|
||||
wallet_info,
|
||||
runtime_dir: node_runtime_dir,
|
||||
immediate_start,
|
||||
},
|
||||
);
|
||||
|
||||
if let Some(node_snapshot) = restored_node_snapshot
|
||||
&& let Some(snapshot_name) = world.snapshots.restore.extensions.clone()
|
||||
{
|
||||
restore_wallet_snapshot_if_present(
|
||||
&snapshot_name,
|
||||
&node_snapshot.node,
|
||||
node_name,
|
||||
&client,
|
||||
world,
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{step}` error: {e}");
|
||||
})?;
|
||||
}
|
||||
|
||||
// All nodes are required to be network ready responsive, and bootstrap nodes
|
||||
// must be `Mode::OnLine` for IBD of other peers to succeed
|
||||
if !immediate_start {
|
||||
let cluster = world
|
||||
.cluster
|
||||
.local_cluster
|
||||
.as_ref()
|
||||
.expect("local cluster checked");
|
||||
ensure_node_ready(
|
||||
cluster,
|
||||
&client,
|
||||
node_name,
|
||||
&started_node_name,
|
||||
is_bootstrap_node,
|
||||
world.startup.require_all_peers_mode_online_at_startup,
|
||||
startup_settings.join_external_network,
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{step}` error: {e}");
|
||||
})?;
|
||||
}
|
||||
|
||||
if world.snapshots.node_snapshot_on_startup.is_some() {
|
||||
match client.consensus_info().await {
|
||||
Ok(info) => {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Node `{node_name}` snapshot state - height: {}/{}, tip: {}, lib: {}",
|
||||
info.cryptarchia_info.height,
|
||||
info.cryptarchia_info.slot.into_inner(),
|
||||
truncate_hash(&info.cryptarchia_info.tip.encode_hex::<String>(), 16),
|
||||
truncate_hash(&info.cryptarchia_info.lib.encode_hex::<String>(), 16)
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target: TARGET,
|
||||
"Node `{node_name}` failed to fetch post-start consensus after snapshot init: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tokio_console) = tokio_console_node {
|
||||
check_tokio_console_port(node_name, tokio_console.port);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_tokio_console_port(node_name: &str, port: u16) {
|
||||
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
|
||||
|
||||
match TcpStream::connect_timeout(&addr, Duration::from_secs(2)) {
|
||||
Ok(_) => info!(
|
||||
target: TARGET,
|
||||
"Tokio console endpoint for `{node_name}` is listening at port `{port}`, connect with \
|
||||
`tokio-console http://127.0.0.1:{port}`"
|
||||
),
|
||||
Err(error) => warn!(
|
||||
target: TARGET,
|
||||
"Tokio console endpoint for `{node_name}` is not reachable at \
|
||||
`http://127.0.0.1:{port}`: {error}. Refer to the repo root `README.md -> Tokio task \
|
||||
profiling` for general instructions."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop a node and leave it down.
|
||||
///
|
||||
/// Unlike [`restart_node`], which brings it back up and waits for readiness,
|
||||
/// this leaves the node down, useful to exercise reconnect behavior while the
|
||||
/// node is down.
|
||||
pub async fn stop_node(world: &CucumberWorld, step: &str, node_name: &str) -> StepResult {
|
||||
let cluster = world
|
||||
.cluster
|
||||
.local_cluster
|
||||
.as_ref()
|
||||
.ok_or(StepError::LogicalError {
|
||||
message: "No local cluster available".into(),
|
||||
})?;
|
||||
let started_node_name = world
|
||||
.resolve_node_runtime_name(node_name)
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{step}` error: {e}");
|
||||
})?;
|
||||
|
||||
log_node_lifecycle_marker(world, "node_stop", node_name, "before").await;
|
||||
|
||||
cluster
|
||||
.stop_node(&started_node_name)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{step}` error: {e}");
|
||||
})?;
|
||||
|
||||
log_node_lifecycle_marker(world, "node_stop", node_name, "after").await;
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Stopped node `{node_name}` (runtime name `{started_node_name}`)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn restart_node(world: &CucumberWorld, step: &str, node_name: &str) -> StepResult {
|
||||
let cluster = world
|
||||
.cluster
|
||||
.local_cluster
|
||||
.as_ref()
|
||||
.ok_or(StepError::LogicalError {
|
||||
message: "No local cluster available".into(),
|
||||
})?;
|
||||
let started_node_name = world
|
||||
.resolve_node_runtime_name(node_name)
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{step}` error: {e}");
|
||||
})?;
|
||||
|
||||
log_node_lifecycle_marker(world, "node_restart", node_name, "before").await;
|
||||
|
||||
cluster
|
||||
.restart_node(&started_node_name)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{step}` error: {e}");
|
||||
})?;
|
||||
|
||||
log_node_lifecycle_marker(world, "node_restart", node_name, "after").await;
|
||||
let client = world.resolve_node_http_client(node_name).inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{step}` error: {e}");
|
||||
})?;
|
||||
ensure_node_ready(
|
||||
cluster,
|
||||
&client,
|
||||
node_name,
|
||||
&started_node_name,
|
||||
// TODO: Add `is_bootstrap_node` to world
|
||||
false,
|
||||
None,
|
||||
world.startup.join_external_network.unwrap_or_default(),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{step}` error: {e}");
|
||||
})?;
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Restarted node `{node_name}` (runtime name `{started_node_name}`)"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_wallets(
|
||||
world: &CucumberWorld,
|
||||
step: &str,
|
||||
node_name: &str,
|
||||
wallet_start_info: &[WalletStartInfo],
|
||||
started_node: &StartedNode<LbcEnv>,
|
||||
node_runtime_dir: &Path,
|
||||
join_external_network: bool,
|
||||
) -> Result<WalletInfoMap, StepError> {
|
||||
let wallet_info = compile_wallet_in_map(
|
||||
wallet_start_info,
|
||||
node_name,
|
||||
world,
|
||||
step,
|
||||
node_runtime_dir,
|
||||
join_external_network,
|
||||
)?;
|
||||
for (wallet_name, info) in &wallet_info {
|
||||
let wallet_type = match info.wallet_type.clone() {
|
||||
WalletType::User { .. } => "User",
|
||||
WalletType::Funding { .. } => "Funding",
|
||||
};
|
||||
info!(target: TARGET, "{wallet_type} wallet `{}/{node_name}` created: {}",
|
||||
wallet_name,
|
||||
format!("{}wallet/{}/balance", started_node.client.base_url(), info.public_key_hex())
|
||||
);
|
||||
}
|
||||
|
||||
Ok(wallet_info)
|
||||
}
|
||||
|
||||
struct StartupSettings {
|
||||
peer_selection: PeerSelection,
|
||||
ibd_peers: HashSet<PeerId>,
|
||||
is_bootstrap_node: bool,
|
||||
initial_peers_override: Option<Vec<Multiaddr>>,
|
||||
join_external_network: bool,
|
||||
user_config_overrides: Vec<ConfigOverride>,
|
||||
deployment_config_overrides: Vec<ConfigOverride>,
|
||||
deployment_settings_override: Option<DeploymentSettings>,
|
||||
manual_node_config_overrides: ManualNodeConfigOverrides,
|
||||
tokio_console_node: Option<TokioConsoleProfileNode>,
|
||||
}
|
||||
|
||||
fn get_startup_settings(
|
||||
world: &CucumberWorld,
|
||||
initial_peers: &[String],
|
||||
node_name: &str,
|
||||
) -> Result<StartupSettings, StepError> {
|
||||
let peer_selection = if initial_peers.is_empty() {
|
||||
PeerSelection::None
|
||||
} else {
|
||||
let named = initial_peers
|
||||
.iter()
|
||||
.map(|peer| world.resolve_node_runtime_name(peer))
|
||||
.collect::<Result<Vec<String>, StepError>>()?;
|
||||
PeerSelection::Named(named)
|
||||
};
|
||||
let mut ibd_peers = world.startup.ibd_peers_override.clone().unwrap_or_default();
|
||||
let populate_ibd_peers_from_initial_peers = world
|
||||
.startup
|
||||
.populate_ibd_peers_from_initial_peers
|
||||
.unwrap_or_default();
|
||||
if populate_ibd_peers_from_initial_peers {
|
||||
for peer in initial_peers {
|
||||
if let Some(peer_id) = world.cluster.node_peer_ids.get(peer) {
|
||||
ibd_peers.insert(*peer_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let is_bootstrap_node = initial_peers.is_empty();
|
||||
let initial_peers_override = world.startup.initial_peers_override.clone();
|
||||
let join_external_network = world.startup.join_external_network.unwrap_or_default();
|
||||
let deployment_settings_override = world
|
||||
.startup
|
||||
.deployment_config_override_path
|
||||
.clone()
|
||||
.map(|path| load_run_config(&path))
|
||||
.transpose()?;
|
||||
let user_config_overrides = world.startup.user_config_overrides.clone();
|
||||
let deployment_config_overrides = world.startup.deployment_config_overrides.clone();
|
||||
let tokio_console_node = world.tokio_console_profile.node(node_name).cloned();
|
||||
|
||||
Ok(StartupSettings {
|
||||
peer_selection,
|
||||
ibd_peers,
|
||||
is_bootstrap_node,
|
||||
initial_peers_override,
|
||||
join_external_network,
|
||||
deployment_settings_override,
|
||||
manual_node_config_overrides: world.startup.manual_node_config_overrides.clone(),
|
||||
user_config_overrides,
|
||||
deployment_config_overrides,
|
||||
tokio_console_node,
|
||||
})
|
||||
}
|
||||
|
||||
#[expect(clippy::too_many_arguments, reason = "all needed")]
|
||||
fn prepare_config_patch(
|
||||
config: &mut RunConfig,
|
||||
join_external_network: bool,
|
||||
deployment_override: Option<&DeploymentSettings>,
|
||||
config_overrides: &ManualNodeConfigOverrides,
|
||||
initial_peers_override: Option<&Vec<Multiaddr>>,
|
||||
ibd_peers: &HashSet<PeerId>,
|
||||
user_config_overrides: &[ConfigOverride],
|
||||
deployment_config_overrides: &[ConfigOverride],
|
||||
tokio_console_node: Option<&TokioConsoleProfileNode>,
|
||||
scenario_wallet_key_ids: &HashSet<KeyId>,
|
||||
) -> Result<(), StepError> {
|
||||
if join_external_network {
|
||||
config.deployment = deployment_override
|
||||
.cloned()
|
||||
.unwrap_or_else(DeploymentSettings::default);
|
||||
} else if let Some(deployment_override) = deployment_override {
|
||||
config.deployment = deployment_override.clone();
|
||||
}
|
||||
|
||||
config_overrides.apply_to(config);
|
||||
|
||||
if let Some(initial_peers) = &initial_peers_override {
|
||||
config
|
||||
.user
|
||||
.network
|
||||
.backend
|
||||
.initial_peers
|
||||
.clone_from(initial_peers);
|
||||
}
|
||||
config
|
||||
.user
|
||||
.cryptarchia
|
||||
.network
|
||||
.bootstrap
|
||||
.ibd
|
||||
.peers
|
||||
.clone_from(ibd_peers);
|
||||
if let Some(node) = &tokio_console_node {
|
||||
config.user.tracing.console = ConsoleLayer::Console(TokioConfig {
|
||||
bind_address: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
port: node.port,
|
||||
recording_path: node
|
||||
.record_raw
|
||||
.then(|| PathBuf::from("tokio-console-raw.jsonl")),
|
||||
});
|
||||
}
|
||||
|
||||
apply_user_config_overrides(config, user_config_overrides)?;
|
||||
apply_deployment_config_overrides(config, deployment_config_overrides)?;
|
||||
if join_external_network {
|
||||
remove_external_scenario_wallet_keys(config, scenario_wallet_key_ids);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn wallet_account_key_id(account: &WalletAccount) -> KeyId {
|
||||
let key: Key = account.secret_key.clone().into();
|
||||
key_id_for_preload_backend(&key)
|
||||
}
|
||||
|
||||
fn remove_external_scenario_wallet_keys(
|
||||
config: &mut RunConfig,
|
||||
scenario_wallet_key_ids: &HashSet<KeyId>,
|
||||
) {
|
||||
remove_external_scenario_wallet_keys_from_maps(
|
||||
&mut config.user.wallet.known_keys,
|
||||
&mut config.user.kms.backend.keys,
|
||||
scenario_wallet_key_ids,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn remove_external_scenario_wallet_keys_from_maps(
|
||||
known_keys: &mut HashMap<KeyId, lb_key_management_system_service::keys::ZkPublicKey>,
|
||||
kms_keys: &mut HashMap<KeyId, Key>,
|
||||
scenario_wallet_key_ids: &HashSet<KeyId>,
|
||||
) {
|
||||
for key_id in scenario_wallet_key_ids {
|
||||
known_keys.remove(key_id);
|
||||
kms_keys.remove(key_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_run_config(path: &Path) -> Result<DeploymentSettings, StepError> {
|
||||
let text = fs::read_to_string(path).map_err(|e| StepError::LogicalError {
|
||||
message: format!("Failed to read '{}': {e}", path.display()),
|
||||
})?;
|
||||
serde_yaml::from_str::<DeploymentSettings>(&text).map_err(|e| StepError::LogicalError {
|
||||
message: format!("Failed to parse '{}': {e}", path.display()),
|
||||
})
|
||||
}
|
||||
|
||||
fn populate_slots_per_epoch_from_deployment(
|
||||
world: &mut CucumberWorld,
|
||||
node_runtime_dir: &Path,
|
||||
) -> Result<(), StepError> {
|
||||
let path = node_runtime_dir.join("deployment.yaml");
|
||||
let text = fs::read_to_string(&path).map_err(|source| StepError::LogicalError {
|
||||
message: format!(
|
||||
"failed to read effective deployment config '{}': {source}",
|
||||
path.display()
|
||||
),
|
||||
})?;
|
||||
let deployment = serde_yaml::from_str::<DeploymentSettings>(&text).map_err(|source| {
|
||||
StepError::LogicalError {
|
||||
message: format!(
|
||||
"failed to parse effective deployment config '{}': {source}",
|
||||
path.display()
|
||||
),
|
||||
}
|
||||
})?;
|
||||
let slots_per_epoch = deployment.cryptarchia.slots_per_epoch();
|
||||
let slots_per_epoch = NonZero::new(slots_per_epoch).ok_or_else(|| StepError::LogicalError {
|
||||
message: format!(
|
||||
"effective deployment config '{}' has zero slots per epoch",
|
||||
path.display()
|
||||
),
|
||||
})?;
|
||||
world.chain.slots_per_epoch = slots_per_epoch;
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Loaded effective epoch configuration from '{}': slots_per_epoch={slots_per_epoch}",
|
||||
path.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Ensure this node is ready, and achieved `Mode::OnLine` if it is a bootstrap
|
||||
// node.
|
||||
async fn ensure_node_ready(
|
||||
cluster: &LbcManualCluster,
|
||||
client: &NodeHttpClient,
|
||||
node_name: &str,
|
||||
started_node_name: &str,
|
||||
is_bootstrap_node: bool,
|
||||
require_all_peers_mode_online_at_startup: Option<Duration>,
|
||||
join_external_network: bool,
|
||||
) -> StepResult {
|
||||
// General readiness check to ensure the node is responsive.
|
||||
let operation = format!("node '{started_node_name}' readiness");
|
||||
track_progress(&operation, Duration::from_secs(5), async {
|
||||
cluster
|
||||
.wait_node_ready(started_node_name)
|
||||
.await
|
||||
.map_err(|source| StepError::StepFail {
|
||||
message: format!(
|
||||
"node '{started_node_name}' did not become ready after start: {source}"
|
||||
),
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
verify_reponsive_and_network_ready(client, node_name, started_node_name).await?;
|
||||
|
||||
if !is_bootstrap_node && require_all_peers_mode_online_at_startup.is_none()
|
||||
|| join_external_network
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
verify_online(
|
||||
client,
|
||||
node_name,
|
||||
started_node_name,
|
||||
require_all_peers_mode_online_at_startup,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn verify_online(
|
||||
client: &NodeHttpClient,
|
||||
node_name: &str,
|
||||
started_node_name: &str,
|
||||
time_out: Option<Duration>,
|
||||
) -> StepResult {
|
||||
let time_out = time_out.unwrap_or_else(|| Duration::from_mins(1));
|
||||
let start = Instant::now();
|
||||
let mut count = 0usize;
|
||||
loop {
|
||||
let mut mode_online = false;
|
||||
match client.consensus_info().await {
|
||||
Ok(val) => {
|
||||
if matches!(val.phase, PhaseTag::Following) {
|
||||
mode_online = true;
|
||||
}
|
||||
}
|
||||
Err(e) if start.elapsed() < time_out => {
|
||||
if count.is_multiple_of(20) {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Waiting for node `{node_name}/{started_node_name}` to be `Mode::OnLine` - \
|
||||
elapsed: {:.2?} ({e})",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(StepError::StepFail {
|
||||
message: format!(
|
||||
"Node `{node_name}/{started_node_name}` failed `Mode::OnLine` - elapsed \
|
||||
{:.2?}: {e}",
|
||||
start.elapsed()
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
if mode_online {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Node `{node_name}/{started_node_name}` achieved `Mode::OnLine` and listen \
|
||||
addresses in {:.2?}",
|
||||
start.elapsed()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for all nodes to become responsive
|
||||
pub async fn wait_all_nodes_responive(
|
||||
cluster: &LbcManualCluster,
|
||||
time_out: Duration,
|
||||
) -> StepResult {
|
||||
timeout(time_out, cluster.wait_network_ready())
|
||||
.await
|
||||
.map_err(|_| StepError::StepFail {
|
||||
message: format!("Not all nodes became responsive after {time_out:?}"),
|
||||
})?
|
||||
.map_err(|e| StepError::StepFail {
|
||||
message: format!("Failed to check all nodes ready: {e}"),
|
||||
})
|
||||
}
|
||||
|
||||
async fn verify_reponsive_and_network_ready(
|
||||
client: &NodeHttpClient,
|
||||
node_name: &str,
|
||||
started_node_name: &str,
|
||||
) -> StepResult {
|
||||
verify_reponsive_and_network_ready_with_timeout(
|
||||
client,
|
||||
node_name,
|
||||
started_node_name,
|
||||
Duration::from_mins(1),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Wait for the node to be responsive and network ready, with a timeout.
|
||||
#[expect(
|
||||
clippy::cognitive_complexity,
|
||||
reason = "Singular fn with multiple branches to handle different events and futures."
|
||||
)]
|
||||
pub async fn verify_reponsive_and_network_ready_with_timeout(
|
||||
client: &NodeHttpClient,
|
||||
node_name: &str,
|
||||
started_node_name: &str,
|
||||
time_out: Duration,
|
||||
) -> StepResult {
|
||||
let start = Instant::now();
|
||||
let mut count = 0usize;
|
||||
let mut can_provide_consensus_info;
|
||||
let mut is_network_ready;
|
||||
|
||||
loop {
|
||||
can_provide_consensus_info = false;
|
||||
match client.consensus_info().await {
|
||||
Ok(_) => {
|
||||
can_provide_consensus_info = true;
|
||||
}
|
||||
Err(e) if start.elapsed() < time_out => {
|
||||
if count.is_multiple_of(20) {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Waiting for node `{node_name}/{started_node_name}` to be responsive - \
|
||||
elapsed: {:.2?} ({e})",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(StepError::StepFail {
|
||||
message: format!(
|
||||
"Node `{node_name}/{started_node_name}` failed to be responsive - elapsed \
|
||||
{:.2?}: {e}",
|
||||
start.elapsed()
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
is_network_ready = false;
|
||||
match client.network_info().await {
|
||||
Ok(val) => {
|
||||
is_network_ready = !val.listen_addresses.is_empty();
|
||||
}
|
||||
Err(e) if start.elapsed() < time_out => {
|
||||
if count.is_multiple_of(20) {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Waiting for node `{node_name}/{started_node_name}` to be network ready - \
|
||||
elapsed: {:.2?} ({e})",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(StepError::StepFail {
|
||||
message: format!(
|
||||
"Node `{node_name}/{started_node_name}` failed to be network ready - elapsed \
|
||||
{:.2?}: {e}",
|
||||
start.elapsed()
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
if can_provide_consensus_info && is_network_ready {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Node `{node_name}/{started_node_name}` is responsive and network ready in {:.2?}",
|
||||
start.elapsed()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn compile_wallet_in_map(
|
||||
wallet_start_info: &[WalletStartInfo],
|
||||
node_name: &str,
|
||||
world: &CucumberWorld,
|
||||
step: &str,
|
||||
node_runtime_dir: &Path,
|
||||
join_external_network: bool,
|
||||
) -> Result<WalletInfoMap, StepError> {
|
||||
let mut wallet_info: WalletInfoMap = HashMap::new();
|
||||
for wallet in wallet_start_info {
|
||||
let wallet_account = match world
|
||||
.wallet_registry
|
||||
.wallet_accounts
|
||||
.get(&wallet.account_index)
|
||||
{
|
||||
Some(wallet_account) => wallet_account.clone(),
|
||||
None => {
|
||||
if join_external_network {
|
||||
WalletAccount::random()
|
||||
.map_err(|source| StepError::LogicalError {
|
||||
message: format!(
|
||||
"Step `{step}` error: Failed to derive random wallet account for index {}: {source}",
|
||||
wallet.account_index
|
||||
),
|
||||
})?
|
||||
} else {
|
||||
WalletAccount::deterministic(
|
||||
wallet.account_index as u64,
|
||||
0,
|
||||
true,
|
||||
)
|
||||
.map_err(|source| StepError::LogicalError {
|
||||
message: format!(
|
||||
"Step `{step}` error: Failed to derive deterministic wallet account for index {}: {source}",
|
||||
wallet.account_index
|
||||
),
|
||||
})?
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
wallet_info.insert(
|
||||
wallet.wallet_name.clone(),
|
||||
WalletInfo {
|
||||
wallet_name: wallet.wallet_name.clone(),
|
||||
node_name: node_name.to_owned(),
|
||||
wallet_type: WalletType::User { wallet_account },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let node_wallet_keys =
|
||||
node_wallet_keys_from_node_yaml(&node_runtime_dir.join(USER_CONFIG_FILE))?;
|
||||
let user_wallets_by_pk = world
|
||||
.wallet_registry
|
||||
.wallet_accounts
|
||||
.values()
|
||||
.map(|account| (account.public_key_hex(), account.label.clone()))
|
||||
.chain(
|
||||
world
|
||||
.wallet_registry
|
||||
.fee_state
|
||||
.wallet_account
|
||||
.iter()
|
||||
.map(|account| (account.public_key_hex(), account.label.clone())),
|
||||
)
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut generic_key_index = 0usize;
|
||||
|
||||
for node_wallet_key in node_wallet_keys {
|
||||
if let Some(user_wallet_name) = user_wallets_by_pk.get(&node_wallet_key.wallet_pk) {
|
||||
if node_wallet_key.role != NodeWalletKeyRole::General {
|
||||
return Err(StepError::LogicalError {
|
||||
message: format!(
|
||||
"Scenario wallet `{user_wallet_name}` public key conflicts with the \
|
||||
{role:?} key owned by `{node_name}`",
|
||||
role = node_wallet_key.role,
|
||||
),
|
||||
});
|
||||
}
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Scenario wallet `{user_wallet_name}` is registered with `{node_name}`; \
|
||||
excluding its public key from node-wallet aliases"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let wallet_name = node_wallet_name(node_name, &node_wallet_key, &mut generic_key_index);
|
||||
wallet_info.insert(
|
||||
wallet_name.clone(),
|
||||
WalletInfo {
|
||||
wallet_name,
|
||||
node_name: node_name.to_owned(),
|
||||
wallet_type: WalletType::Funding {
|
||||
key: node_wallet_key,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(wallet_info)
|
||||
}
|
||||
|
||||
pub(super) fn node_wallet_name(
|
||||
node_name: &str,
|
||||
key: &NodeWalletKey,
|
||||
generic_key_index: &mut usize,
|
||||
) -> String {
|
||||
let role = match key.role {
|
||||
NodeWalletKeyRole::Funding => "FUNDING".to_owned(),
|
||||
NodeWalletKeyRole::VoucherMaster => "VOUCHER_MASTER".to_owned(),
|
||||
NodeWalletKeyRole::BlendZk => "BLEND_ZK".to_owned(),
|
||||
NodeWalletKeyRole::General => {
|
||||
*generic_key_index += 1;
|
||||
format!("GENERAL_{}", *generic_key_index)
|
||||
}
|
||||
};
|
||||
format!("{node_name}_WALLET_{role}")
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream},
|
||||
num::NonZero,
|
||||
path::{Path, PathBuf},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use cucumber::gherkin::Table;
|
||||
use futures::future::try_join_all;
|
||||
use hex::ToHex as _;
|
||||
use lb_chain_service::{ChainServiceInfo, CryptarchiaInfo, PhaseTag};
|
||||
use lb_config::kms::key_id_for_preload_backend;
|
||||
use lb_core::mantle::{Utxo, ops::OpId as _, traits::GenesisTx as _};
|
||||
use lb_http_api_common::paths::CRYPTARCHIA_INFO;
|
||||
use lb_key_management_system_service::{backend::preload::KeyId, keys::Key};
|
||||
use lb_libp2p::PeerId;
|
||||
use lb_node::config::{
|
||||
DeploymentSettings, RunConfig,
|
||||
tracing::serde::console::{Layer as ConsoleLayer, TokioConfig},
|
||||
};
|
||||
use lb_testing_framework::{
|
||||
LbcEnv, LbcManualCluster, NodeHttpClient, USER_CONFIG_FILE, configs::wallet::WalletAccount,
|
||||
};
|
||||
use libp2p::Multiaddr;
|
||||
use reqwest::{Client, Url};
|
||||
use testing_framework_core::scenario::{PeerSelection, StartNodeOptions, StartedNode};
|
||||
use tokio::time::{Instant as TokioInstant, sleep, timeout};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::cucumber::{
|
||||
error::{StepError, StepResult},
|
||||
steps::{
|
||||
TARGET,
|
||||
nodes::{
|
||||
config_override::{apply_deployment_config_overrides, apply_user_config_overrides},
|
||||
diagnostics::log_node_lifecycle_marker,
|
||||
snapshots::{
|
||||
reset_named_snapshot, restore_node_state_from_snapshot,
|
||||
save_named_node_state_snapshot, validate_snapshot_path_component,
|
||||
},
|
||||
},
|
||||
tokio_console::profile::TokioConsoleProfileNode,
|
||||
},
|
||||
utils::{
|
||||
display_last_path_components, extract_child_dir_name, matching_child_dirs,
|
||||
node_wallet_keys_from_node_yaml, peer_id_from_node_yaml, track_progress, truncate_hash,
|
||||
},
|
||||
wallet::snapshot::{create_and_save_all_wallets_snapshot, restore_wallet_snapshot_if_present},
|
||||
world::{
|
||||
ChainInfoMap, ConfigOverride, CucumberWorld, ManualNodeConfigOverrides, NodeInfo,
|
||||
NodeWalletKey, NodeWalletKeyRole, PublicCryptarchiaEndpointPeer, WalletInfo, WalletInfoMap,
|
||||
WalletType,
|
||||
},
|
||||
};
|
||||
|
||||
pub type NodesToStartUnordered = HashMap<String, (Vec<WalletStartInfo>, Vec<String>)>;
|
||||
type NodesToStartOrdered = Vec<(String, Vec<WalletStartInfo>, Vec<String>)>;
|
||||
|
||||
const CHAIN_SYNC_POLL_INTERVAL: Duration = Duration::from_secs(5);
|
||||
const CHAIN_SYNC_STATUS_LOG_INTERVAL: Duration = Duration::from_mins(2);
|
||||
|
||||
// Returns the root directory for a named snapshot.
|
||||
|
||||
enum AlignmentStatus {
|
||||
MissingChainInfo,
|
||||
Fork,
|
||||
Aligned,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ConsensusSnapshot {
|
||||
node_name: String,
|
||||
height: u64,
|
||||
header_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct MaybeSnapshot {
|
||||
height: u64,
|
||||
header_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct SyncTargetStats {
|
||||
lib: String,
|
||||
tip: String,
|
||||
slot: u64,
|
||||
height: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PublicPeerConsensusSnapshot {
|
||||
peer_url: String,
|
||||
stats: SyncTargetStats,
|
||||
}
|
||||
|
||||
mod consensus;
|
||||
mod lifecycle;
|
||||
mod resources;
|
||||
mod snapshots;
|
||||
mod synchronization;
|
||||
|
||||
pub use consensus::{
|
||||
ensure_all_nodes_agree_on_lib, nodes_converged, poll_all_nodes_and_update_consensus_cache,
|
||||
};
|
||||
pub use lifecycle::{
|
||||
restart_node, start_node, start_nodes_order_respecting_dependencies, stop_node,
|
||||
verify_reponsive_and_network_ready_with_timeout, wait_all_nodes_responive,
|
||||
};
|
||||
pub use resources::{
|
||||
ensure_fee_sponsorship_and_fork_groups_are_not_mixed, genesis_block_utxos,
|
||||
parse_genesis_wallet_tokens_row, parse_mining_wallet_resources_table_row,
|
||||
parse_wallet_resources_table_row, verify_genesis_wallet_resources_table_indexes,
|
||||
verify_mining_node_wallet_resources_table_indexes, verify_node_wallet_resources_table_indexes,
|
||||
};
|
||||
pub use snapshots::{
|
||||
WalletStartInfo, create_snapshot_all_nodes_with_wallet_state,
|
||||
create_snapshot_node_with_wallet_state, create_snapshots_all_nodes,
|
||||
get_cryptarchia_info_all_nodes,
|
||||
};
|
||||
pub use synchronization::{
|
||||
fetch_public_peer_consensus, parse_url, wait_for_all_nodes_to_be_synced_to_chain,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct MajorityPublicSyncTarget {
|
||||
peer_urls: Vec<String>,
|
||||
stats: SyncTargetStats,
|
||||
}
|
||||
|
||||
impl SyncTargetStats {
|
||||
fn from_cryptarchia_info(info: &CryptarchiaInfo) -> Self {
|
||||
Self {
|
||||
lib: info.lib.encode_hex::<String>(),
|
||||
tip: info.tip.encode_hex::<String>(),
|
||||
slot: info.slot.into_inner(),
|
||||
height: info.height,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
use super::*;
|
||||
|
||||
#[must_use]
|
||||
pub fn genesis_block_utxos(genesis_tx: &lb_core::mantle::transactions::GenesisTx) -> Vec<Utxo> {
|
||||
let transfer_op = genesis_tx.genesis_transfer().clone();
|
||||
let transfer_id = transfer_op.op_id();
|
||||
|
||||
transfer_op
|
||||
.outputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, note)| Utxo::new(transfer_id, idx, *note))
|
||||
.collect()
|
||||
}
|
||||
|
||||
const ACCOUNT_INDEX: &str = "account_index";
|
||||
const ACCOUNT_INDEX_IDX_T1: usize = 0;
|
||||
const TOKEN_COUNT: &str = "token_count";
|
||||
const TOKEN_COUNT_IDX: usize = 1;
|
||||
const TOKEN_AMOUNT: &str = "token_amount";
|
||||
const TOKEN_AMOUNT_IDX: usize = 2;
|
||||
|
||||
pub fn verify_genesis_wallet_resources_table_indexes(
|
||||
table: &Table,
|
||||
step: &str,
|
||||
) -> Result<(), StepError> {
|
||||
if table.rows.is_empty()
|
||||
|| table.rows[0].len() != 3
|
||||
|| table.rows[0][ACCOUNT_INDEX_IDX_T1] != ACCOUNT_INDEX
|
||||
|| table.rows[0][TOKEN_COUNT_IDX] != TOKEN_COUNT
|
||||
|| table.rows[0][TOKEN_AMOUNT_IDX] != TOKEN_AMOUNT
|
||||
{
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{step}` error: Wallet resources table must have a header row with columns: \
|
||||
{ACCOUNT_INDEX}, {TOKEN_COUNT}, {TOKEN_AMOUNT}"
|
||||
),
|
||||
});
|
||||
}
|
||||
// All wallet account indexes must be unique
|
||||
let wallet_accounts: HashSet<_> = table
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| &row[ACCOUNT_INDEX_IDX_T1])
|
||||
.collect();
|
||||
if wallet_accounts.len() != table.rows.len() {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{step}` error: Duplicate {ACCOUNT_INDEX} indexes found in the table"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn parse_genesis_wallet_tokens_row(
|
||||
step: &str,
|
||||
row: &[String],
|
||||
) -> Result<(usize, usize, u64), StepError> {
|
||||
let account_index =
|
||||
row[ACCOUNT_INDEX_IDX_T1]
|
||||
.parse::<usize>()
|
||||
.map_err(|_| StepError::InvalidArgument {
|
||||
message: format!("Step `{step}` error: {ACCOUNT_INDEX} must be a valid number"),
|
||||
})?;
|
||||
let token_count =
|
||||
row[TOKEN_COUNT_IDX]
|
||||
.parse::<usize>()
|
||||
.map_err(|_| StepError::InvalidArgument {
|
||||
message: format!("Step `{step}` error: {TOKEN_COUNT} must be a valid number"),
|
||||
})?;
|
||||
let token_amount =
|
||||
row[TOKEN_AMOUNT_IDX]
|
||||
.parse::<u64>()
|
||||
.map_err(|_| StepError::InvalidArgument {
|
||||
message: format!("Step `{step}` error: {TOKEN_AMOUNT} must be a valid number"),
|
||||
})?;
|
||||
Ok((account_index, token_count, token_amount))
|
||||
}
|
||||
|
||||
const NODE_NAME: &str = "node_name";
|
||||
const NODE_NAME_IDX: usize = 0;
|
||||
const ACCOUNT_INDEX_IDX_T2: usize = 1;
|
||||
const WALLET_NAME: &str = "wallet_name";
|
||||
const WALLET_NAME_IDX: usize = 2;
|
||||
const CONNECTED_TO: &str = "connected_to";
|
||||
const CONNECTED_TO_IDX: usize = 3;
|
||||
|
||||
// Mining-node wallet-resources table adds an `is_mining_wallet` column between
|
||||
// `wallet_name` and `connected_to`.
|
||||
const IS_MINING_WALLET: &str = "is_mining_wallet";
|
||||
const IS_MINING_WALLET_IDX: usize = 3;
|
||||
const MINING_CONNECTED_TO_IDX: usize = 4;
|
||||
|
||||
pub fn verify_node_wallet_resources_table_indexes(
|
||||
table: &Table,
|
||||
step: &str,
|
||||
) -> Result<(), StepError> {
|
||||
if table.rows.is_empty()
|
||||
|| table.rows[0].len() != 4
|
||||
|| table.rows[0][NODE_NAME_IDX] != NODE_NAME
|
||||
|| table.rows[0][ACCOUNT_INDEX_IDX_T2] != ACCOUNT_INDEX
|
||||
|| table.rows[0][WALLET_NAME_IDX] != WALLET_NAME
|
||||
|| table.rows[0][CONNECTED_TO_IDX] != CONNECTED_TO
|
||||
{
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{step}` error: Wallet resources table must have a header row with columns: {NODE_NAME}, {ACCOUNT_INDEX}, {WALLET_NAME}, {CONNECTED_TO}"
|
||||
),
|
||||
});
|
||||
}
|
||||
// All wallet indexes must be unique
|
||||
let account_indexes: HashSet<_> = table
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| &row[ACCOUNT_INDEX_IDX_T2])
|
||||
.collect();
|
||||
if account_indexes.len() != table.rows.len() {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{step}` error: Duplicate {ACCOUNT_INDEX} indexes found in the table"
|
||||
),
|
||||
});
|
||||
}
|
||||
// All wallet names must be unique
|
||||
let wallet_names: HashSet<_> = table.rows.iter().map(|row| &row[WALLET_NAME_IDX]).collect();
|
||||
if wallet_names.len() != table.rows.len() {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{step}` error: Duplicate {WALLET_NAME} indexes found in the table"
|
||||
),
|
||||
});
|
||||
}
|
||||
// node_name and connected_to must be different
|
||||
for row in table.rows.iter().skip(1) {
|
||||
let node_name = row[NODE_NAME_IDX].trim();
|
||||
let connected_to = row
|
||||
.get(CONNECTED_TO_IDX)
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty());
|
||||
if let Some(peer) = connected_to
|
||||
&& peer == node_name
|
||||
{
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{step}` error: {NODE_NAME} and {CONNECTED_TO} cannot be the same"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn parse_wallet_resources_table_row(
|
||||
step: &str,
|
||||
row: &[String],
|
||||
) -> Result<(String, WalletStartInfo, Option<String>), StepError> {
|
||||
let node_name = row[NODE_NAME_IDX].trim().to_owned();
|
||||
if node_name.is_empty() {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!("Step `{step}` error: {NODE_NAME} cannot be empty"),
|
||||
});
|
||||
}
|
||||
let account_index = row[ACCOUNT_INDEX_IDX_T2]
|
||||
.trim()
|
||||
.parse::<usize>()
|
||||
.map_err(|_| StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{step}` error: {ACCOUNT_INDEX} '{}' must be a valid number",
|
||||
row[ACCOUNT_INDEX_IDX_T2]
|
||||
),
|
||||
})?;
|
||||
let wallet_name = row[WALLET_NAME_IDX].trim().to_owned();
|
||||
if wallet_name.is_empty() {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!("Step `{step}` error: {WALLET_NAME} cannot be empty"),
|
||||
});
|
||||
}
|
||||
let connected_to = row
|
||||
.get(CONNECTED_TO_IDX)
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned);
|
||||
|
||||
Ok((
|
||||
node_name,
|
||||
WalletStartInfo {
|
||||
wallet_name,
|
||||
account_index,
|
||||
},
|
||||
connected_to,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn verify_mining_node_wallet_resources_table_indexes(
|
||||
table: &Table,
|
||||
step: &str,
|
||||
) -> Result<(), StepError> {
|
||||
if table.rows.is_empty()
|
||||
|| table.rows[0].len() != 5
|
||||
|| table.rows[0][NODE_NAME_IDX] != NODE_NAME
|
||||
|| table.rows[0][ACCOUNT_INDEX_IDX_T2] != ACCOUNT_INDEX
|
||||
|| table.rows[0][WALLET_NAME_IDX] != WALLET_NAME
|
||||
|| table.rows[0][IS_MINING_WALLET_IDX] != IS_MINING_WALLET
|
||||
|| table.rows[0][MINING_CONNECTED_TO_IDX] != CONNECTED_TO
|
||||
{
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{step}` error: Mining wallet resources table must have a header row with columns: {NODE_NAME}, {ACCOUNT_INDEX}, {WALLET_NAME}, {IS_MINING_WALLET}, {CONNECTED_TO}"
|
||||
),
|
||||
});
|
||||
}
|
||||
// All wallet indexes must be unique.
|
||||
let account_indexes: HashSet<_> = table
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| &row[ACCOUNT_INDEX_IDX_T2])
|
||||
.collect();
|
||||
if account_indexes.len() != table.rows.len() {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{step}` error: Duplicate {ACCOUNT_INDEX} indexes found in the table"
|
||||
),
|
||||
});
|
||||
}
|
||||
// All wallet names must be unique.
|
||||
let wallet_names: HashSet<_> = table.rows.iter().map(|row| &row[WALLET_NAME_IDX]).collect();
|
||||
if wallet_names.len() != table.rows.len() {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{step}` error: Duplicate {WALLET_NAME} indexes found in the table"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn parse_mining_wallet_resources_table_row(
|
||||
step: &str,
|
||||
row: &[String],
|
||||
) -> Result<(String, WalletStartInfo, bool, Option<String>), StepError> {
|
||||
let node_name = row[NODE_NAME_IDX].trim().to_owned();
|
||||
if node_name.is_empty() {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!("Step `{step}` error: {NODE_NAME} cannot be empty"),
|
||||
});
|
||||
}
|
||||
let account_index = row[ACCOUNT_INDEX_IDX_T2]
|
||||
.trim()
|
||||
.parse::<usize>()
|
||||
.map_err(|_| StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{step}` error: {ACCOUNT_INDEX} '{}' must be a valid number",
|
||||
row[ACCOUNT_INDEX_IDX_T2]
|
||||
),
|
||||
})?;
|
||||
let wallet_name = row[WALLET_NAME_IDX].trim().to_owned();
|
||||
if wallet_name.is_empty() {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!("Step `{step}` error: {WALLET_NAME} cannot be empty"),
|
||||
});
|
||||
}
|
||||
let is_mining_wallet = match row[IS_MINING_WALLET_IDX].trim() {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
other => {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{step}` error: {IS_MINING_WALLET} '{other}' must be 'true' or 'false'"
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
let connected_to = row
|
||||
.get(MINING_CONNECTED_TO_IDX)
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned);
|
||||
|
||||
Ok((
|
||||
node_name,
|
||||
WalletStartInfo {
|
||||
wallet_name,
|
||||
account_index,
|
||||
},
|
||||
is_mining_wallet,
|
||||
connected_to,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn ensure_fee_sponsorship_and_fork_groups_are_not_mixed(
|
||||
world: &CucumberWorld,
|
||||
step_value: &str,
|
||||
) -> StepResult {
|
||||
if world
|
||||
.wallet_registry
|
||||
.fee_state
|
||||
.sponsored_genesis_account
|
||||
.is_some()
|
||||
&& !world.fork_groups.groups().is_empty()
|
||||
{
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{step_value}` error: sponsored fee accounts cannot be combined with distinct node groups in the same scenario"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
use super::*;
|
||||
|
||||
/// This struct represents the wallet resources to be associated with a node at
|
||||
/// startup.
|
||||
pub struct WalletStartInfo {
|
||||
// Logical name of the wallet resource, used for referencing in steps.
|
||||
pub wallet_name: String,
|
||||
// The account index in the genesis tokens that this resource corresponds to.
|
||||
pub account_index: usize,
|
||||
}
|
||||
|
||||
/// Saves the current node state of all nodes into a named snapshot location for
|
||||
/// later use.
|
||||
pub fn create_snapshots_all_nodes(
|
||||
world: &CucumberWorld,
|
||||
snapshot_name: &str,
|
||||
) -> Result<(), StepError> {
|
||||
validate_snapshot_path_component(snapshot_name, "Snapshot name")?;
|
||||
reset_named_snapshot(snapshot_name)?;
|
||||
|
||||
let runtime_dir_by_node_name: Vec<(String, PathBuf)> = world
|
||||
.nodes_info
|
||||
.iter()
|
||||
.map(|(node_name, info)| (node_name.clone(), info.runtime_dir.clone()))
|
||||
.collect();
|
||||
|
||||
for (node_name, node_runtime_dir) in &runtime_dir_by_node_name {
|
||||
save_named_node_state_snapshot(snapshot_name, node_name, node_runtime_dir)?;
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Saved snapshot `{snapshot_name}` for node `{node_name}`",
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_snapshot_all_nodes_with_wallet_state(
|
||||
world: &mut CucumberWorld,
|
||||
snapshot_name: &str,
|
||||
) -> StepResult {
|
||||
if world.nodes_info.is_empty() {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: "cannot create snapshot: no running nodes".to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
create_and_save_all_wallets_snapshot(snapshot_name, world).await?;
|
||||
create_snapshots_all_nodes(world, snapshot_name)
|
||||
}
|
||||
|
||||
pub async fn create_snapshot_node_with_wallet_state(
|
||||
world: &mut CucumberWorld,
|
||||
snapshot_name: &str,
|
||||
node_name: &str,
|
||||
) -> StepResult {
|
||||
if world.nodes_info.is_empty() {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: "cannot create snapshot: no running nodes".to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(runtime_dir) = world
|
||||
.nodes_info
|
||||
.get(node_name)
|
||||
.map(|info| info.runtime_dir.clone())
|
||||
{
|
||||
reset_named_snapshot(snapshot_name)?;
|
||||
create_and_save_all_wallets_snapshot(snapshot_name, world).await?;
|
||||
save_named_node_state_snapshot(snapshot_name, node_name, &runtime_dir)?;
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Saved snapshot `{snapshot_name}` for node {}",
|
||||
runtime_dir.display()
|
||||
);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(StepError::InvalidArgument {
|
||||
message: format!("Node {node_name} does not exist"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches and logs the consensus info of all nodes, for debugging purposes.
|
||||
/// Does not require the nodes to be aligned or have any specific state, and is
|
||||
/// resilient to some nodes being offline or unresponsive.
|
||||
#[expect(
|
||||
clippy::cognitive_complexity,
|
||||
reason = "Singular fn with multiple branches to handle different events and futures."
|
||||
)]
|
||||
pub async fn get_cryptarchia_info_all_nodes(world: &CucumberWorld, step: &str) {
|
||||
let mut node_names = world.nodes_info.keys().cloned().collect::<Vec<_>>();
|
||||
node_names.sort();
|
||||
|
||||
if node_names.is_empty() {
|
||||
warn!(
|
||||
target: TARGET,
|
||||
"Step `{step}` no nodes found for CRYPTARCHIA_INFO_ALL_NODES"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for node_name in node_names {
|
||||
let Some(node_info) = world.nodes_info.get(&node_name) else {
|
||||
continue;
|
||||
};
|
||||
match node_info.started_node.client.consensus_info().await {
|
||||
Ok(consensus) => {
|
||||
let mode = if matches!(consensus.phase, PhaseTag::Following) {
|
||||
"Online"
|
||||
} else {
|
||||
"Bootstrapping"
|
||||
};
|
||||
info!(
|
||||
target: TARGET,
|
||||
"cryptarchia/info - '{}', '{}', {}/{}, tip '{} ...', lib '{} ...'",
|
||||
node_name,
|
||||
mode,
|
||||
consensus.cryptarchia_info.height,
|
||||
consensus.cryptarchia_info.slot.into_inner(),
|
||||
truncate_hash(&consensus.cryptarchia_info.tip.encode_hex::<String>(), 16),
|
||||
truncate_hash(&consensus.cryptarchia_info.lib.encode_hex::<String>(), 16),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target: TARGET,
|
||||
"Step `{step}` CRYPTARCHIA_INFO failed for node `{node_name}`: {e}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
use super::{
|
||||
CHAIN_SYNC_POLL_INTERVAL, CHAIN_SYNC_STATUS_LOG_INTERVAL, CRYPTARCHIA_INFO, ChainServiceInfo,
|
||||
Client, CryptarchiaInfo, CucumberWorld, Duration, HashMap, Instant, MajorityPublicSyncTarget,
|
||||
PublicCryptarchiaEndpointPeer, PublicPeerConsensusSnapshot, StepError, StepResult,
|
||||
SyncTargetStats, TARGET, Url, get_cryptarchia_info_all_nodes, info, sleep, truncate_hash, warn,
|
||||
};
|
||||
|
||||
pub async fn wait_for_all_nodes_to_be_synced_to_chain(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
) -> StepResult {
|
||||
let public_cryptarchia_endpoint_peers = world
|
||||
.startup
|
||||
.public_cryptarchia_endpoint_peers
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
if public_cryptarchia_endpoint_peers.is_empty() {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{step}` error: no public cryptarchia endpoint peers configured"
|
||||
),
|
||||
});
|
||||
}
|
||||
if world.nodes_info.is_empty() {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!("Step `{step}` error: no local nodes are available to check sync"),
|
||||
});
|
||||
}
|
||||
|
||||
let client = Client::new();
|
||||
let start = Instant::now();
|
||||
let mut last_status_log_at = None;
|
||||
|
||||
loop {
|
||||
let public_snapshots =
|
||||
fetch_public_peer_consensus_snapshots(&client, &public_cryptarchia_endpoint_peers)
|
||||
.await;
|
||||
let majority_target = select_majority_public_sync_target(&public_snapshots);
|
||||
|
||||
if let Some(target) = majority_target.as_ref()
|
||||
&& all_local_nodes_match_sync_target(world, target).await
|
||||
{
|
||||
get_cryptarchia_info_all_nodes(world, step).await;
|
||||
info!(
|
||||
target: TARGET,
|
||||
"All nodes synced to the chain in {:.2?}",
|
||||
start.elapsed()
|
||||
);
|
||||
|
||||
catch_up_known_wallet_tracking_after_chain_sync(world, step).await?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if should_log_chain_sync_status(last_status_log_at) {
|
||||
log_chain_sync_progress(
|
||||
start.elapsed(),
|
||||
public_cryptarchia_endpoint_peers.len(),
|
||||
&public_snapshots,
|
||||
majority_target.as_ref(),
|
||||
);
|
||||
get_cryptarchia_info_all_nodes(world, step).await;
|
||||
last_status_log_at = Some(Instant::now());
|
||||
}
|
||||
|
||||
sleep(CHAIN_SYNC_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn catch_up_known_wallet_tracking_after_chain_sync(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
) -> StepResult {
|
||||
if world.wallet_registry.wallet_info.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let started_at = Instant::now();
|
||||
// The wallet scanner timeout is moderate here because the contract is that the
|
||||
// majority nodes have been synced prior just to this step.
|
||||
world
|
||||
.wait_for_wallet_scanner_catch_up(Duration::from_secs(30))
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Wallet scanner caught up after chain sync for step `{step}` in {:.2?}",
|
||||
started_at.elapsed()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn parse_url(raw: &str) -> Result<String, String> {
|
||||
let mut trimmed = raw.trim();
|
||||
trimmed = trimmed.trim_end_matches('/');
|
||||
if trimmed.is_empty() {
|
||||
return Err("url cannot be empty".to_owned());
|
||||
}
|
||||
|
||||
Url::parse(trimmed).map_err(|e| format!("invalid url '{trimmed}': {e}"))?;
|
||||
|
||||
Ok(trimmed.to_owned())
|
||||
}
|
||||
|
||||
async fn fetch_public_peer_consensus_snapshots(
|
||||
client: &Client,
|
||||
peers: &[PublicCryptarchiaEndpointPeer],
|
||||
) -> Vec<PublicPeerConsensusSnapshot> {
|
||||
let mut snapshots = Vec::new();
|
||||
|
||||
for peer in peers {
|
||||
match fetch_public_peer_consensus(client, peer).await {
|
||||
Ok(info) => snapshots.push(PublicPeerConsensusSnapshot {
|
||||
peer_url: peer.base_url.clone(),
|
||||
stats: SyncTargetStats::from_cryptarchia_info(&info),
|
||||
}),
|
||||
Err(e) => warn!(
|
||||
target: TARGET,
|
||||
"Failed to fetch public cryptarchia info from '{}': {e}",
|
||||
peer.base_url
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
snapshots
|
||||
}
|
||||
|
||||
/// Fetch the current consensus info from a public cryptarchia endpoint peer.
|
||||
/// Returns an error if the request fails or the response is invalid.
|
||||
pub async fn fetch_public_peer_consensus(
|
||||
client: &Client,
|
||||
peer: &PublicCryptarchiaEndpointPeer,
|
||||
) -> Result<CryptarchiaInfo, StepError> {
|
||||
let request_url = Url::parse(&format!(
|
||||
"{peer_url}/{path}",
|
||||
peer_url = peer.base_url.as_str(),
|
||||
path = CRYPTARCHIA_INFO.trim_start_matches('/')
|
||||
))
|
||||
.map_err(|e| StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Invalid public cryptarchia info URL for '{}': {e}",
|
||||
peer.base_url.as_str()
|
||||
),
|
||||
})?;
|
||||
|
||||
Ok(client
|
||||
.get(request_url)
|
||||
.basic_auth(&peer.username, Some(&peer.password))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<ChainServiceInfo>()
|
||||
.await
|
||||
.map_err(StepError::from)?
|
||||
.cryptarchia_info)
|
||||
}
|
||||
|
||||
fn select_majority_public_sync_target(
|
||||
snapshots: &[PublicPeerConsensusSnapshot],
|
||||
) -> Option<MajorityPublicSyncTarget> {
|
||||
let mut groups = HashMap::<SyncTargetStats, Vec<String>>::new();
|
||||
for snapshot in snapshots {
|
||||
groups
|
||||
.entry(snapshot.stats.clone())
|
||||
.or_default()
|
||||
.push(snapshot.peer_url.clone());
|
||||
}
|
||||
|
||||
let best = groups
|
||||
.into_iter()
|
||||
.max_by(|(left_stats, left_peers), (right_stats, right_peers)| {
|
||||
left_peers
|
||||
.len()
|
||||
.cmp(&right_peers.len())
|
||||
.then_with(|| left_stats.height.cmp(&right_stats.height))
|
||||
.then_with(|| left_stats.slot.cmp(&right_stats.slot))
|
||||
})
|
||||
.map(|(stats, peer_urls)| MajorityPublicSyncTarget { peer_urls, stats })?;
|
||||
|
||||
if best.peer_urls.len() * 2 <= snapshots.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(best)
|
||||
}
|
||||
|
||||
async fn all_local_nodes_match_sync_target(
|
||||
world: &CucumberWorld,
|
||||
target: &MajorityPublicSyncTarget,
|
||||
) -> bool {
|
||||
let mut node_names = world.nodes_info.keys().cloned().collect::<Vec<_>>();
|
||||
node_names.sort();
|
||||
|
||||
for node_name in node_names {
|
||||
let Some(node_info) = world.nodes_info.get(&node_name) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Ok(consensus) = node_info.started_node.client.consensus_info().await else {
|
||||
return false;
|
||||
};
|
||||
if SyncTargetStats::from_cryptarchia_info(&consensus.cryptarchia_info) != target.stats {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn should_log_chain_sync_status(last_status_log_at: Option<Instant>) -> bool {
|
||||
last_status_log_at.is_none_or(|last| last.elapsed() >= CHAIN_SYNC_STATUS_LOG_INTERVAL)
|
||||
}
|
||||
|
||||
fn log_chain_sync_progress(
|
||||
elapsed: Duration,
|
||||
total_public_peers: usize,
|
||||
public_snapshots: &[PublicPeerConsensusSnapshot],
|
||||
majority_target: Option<&MajorityPublicSyncTarget>,
|
||||
) {
|
||||
if let Some(target) = majority_target {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Waiting to be synced - elapsed {:.2?}, height {}/{}, public peers {}/{}, majority {}/{}, tip '{} ...', lib '{} ...'",
|
||||
elapsed,
|
||||
target.stats.height,
|
||||
target.stats.slot,
|
||||
public_snapshots.len(),
|
||||
total_public_peers,
|
||||
target.peer_urls.len(),
|
||||
public_snapshots.len(),
|
||||
truncate_hash(&target.stats.tip, 16),
|
||||
truncate_hash(&target.stats.lib, 16),
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Waiting to be synced - elapsed {:.2?}, no majority public peer consensus ({}/{} reachable)",
|
||||
elapsed,
|
||||
public_snapshots.len(),
|
||||
total_public_peers,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
#[cfg(test)]
|
||||
mod wallet_name_tests {
|
||||
use super::{NodeWalletKey, NodeWalletKeyRole, node_wallet_name};
|
||||
|
||||
fn node_wallet_key(role: NodeWalletKeyRole) -> NodeWalletKey {
|
||||
NodeWalletKey {
|
||||
wallet_pk: "00".repeat(32),
|
||||
role,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_wallet_names_use_semantic_roles_before_numbered_fallbacks() {
|
||||
let mut generic_key_index = 0;
|
||||
|
||||
assert_eq!(
|
||||
node_wallet_name(
|
||||
"NODE_1",
|
||||
&node_wallet_key(NodeWalletKeyRole::Funding),
|
||||
&mut generic_key_index
|
||||
),
|
||||
"NODE_1_WALLET_FUNDING"
|
||||
);
|
||||
assert_eq!(
|
||||
node_wallet_name(
|
||||
"NODE_1",
|
||||
&node_wallet_key(NodeWalletKeyRole::VoucherMaster),
|
||||
&mut generic_key_index
|
||||
),
|
||||
"NODE_1_WALLET_VOUCHER_MASTER"
|
||||
);
|
||||
assert_eq!(
|
||||
node_wallet_name(
|
||||
"NODE_1",
|
||||
&node_wallet_key(NodeWalletKeyRole::BlendZk),
|
||||
&mut generic_key_index
|
||||
),
|
||||
"NODE_1_WALLET_BLEND_ZK"
|
||||
);
|
||||
assert_eq!(
|
||||
node_wallet_name(
|
||||
"NODE_1",
|
||||
&node_wallet_key(NodeWalletKeyRole::General),
|
||||
&mut generic_key_index
|
||||
),
|
||||
"NODE_1_WALLET_GENERAL_1"
|
||||
);
|
||||
assert_eq!(
|
||||
node_wallet_name(
|
||||
"NODE_1",
|
||||
&node_wallet_key(NodeWalletKeyRole::General),
|
||||
&mut generic_key_index
|
||||
),
|
||||
"NODE_1_WALLET_GENERAL_2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_wallet_names_do_not_consume_general_indexes() {
|
||||
let mut generic_key_index = 0;
|
||||
|
||||
assert_eq!(
|
||||
node_wallet_name(
|
||||
"NODE_1",
|
||||
&node_wallet_key(NodeWalletKeyRole::Funding),
|
||||
&mut generic_key_index
|
||||
),
|
||||
"NODE_1_WALLET_FUNDING"
|
||||
);
|
||||
assert_eq!(generic_key_index, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod scenario_wallet_key_tests {
|
||||
use lb_key_management_system_service::keys::{Ed25519Key, secured_key::SecuredKey as _};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn removes_only_external_scenario_wallet_keys() {
|
||||
let mut kms_keys = HashMap::new();
|
||||
let mut known_keys = HashMap::new();
|
||||
let scenario_accounts = [
|
||||
WalletAccount::deterministic(100, 0, true).expect("account"),
|
||||
WalletAccount::deterministic(101, 0, true).expect("account"),
|
||||
];
|
||||
let sponsored_fee_account =
|
||||
WalletAccount::deterministic(103, 0, true).expect("fee account");
|
||||
let scenario_key_ids = scenario_accounts
|
||||
.iter()
|
||||
.map(wallet_account_key_id)
|
||||
.chain(std::iter::once(wallet_account_key_id(
|
||||
&sponsored_fee_account,
|
||||
)))
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
for account in scenario_accounts
|
||||
.iter()
|
||||
.chain(std::iter::once(&sponsored_fee_account))
|
||||
{
|
||||
let key: Key = account.secret_key.clone().into();
|
||||
let key_id = key_id_for_preload_backend(&key);
|
||||
kms_keys.insert(key_id.clone(), key);
|
||||
known_keys.insert(key_id, account.secret_key.as_public_key());
|
||||
}
|
||||
|
||||
let unrelated = WalletAccount::deterministic(102, 0, true).expect("account");
|
||||
let unrelated_key: Key = unrelated.secret_key.clone().into();
|
||||
let unrelated_key_id = key_id_for_preload_backend(&unrelated_key);
|
||||
kms_keys.insert(unrelated_key_id.clone(), unrelated_key);
|
||||
known_keys.insert(
|
||||
unrelated_key_id.clone(),
|
||||
unrelated.secret_key.as_public_key(),
|
||||
);
|
||||
|
||||
let ed25519_key: Key = Ed25519Key::from_bytes(&[9; 32]).into();
|
||||
let ed25519_key_id = key_id_for_preload_backend(&ed25519_key);
|
||||
kms_keys.insert(ed25519_key_id.clone(), ed25519_key);
|
||||
let voucher_master = WalletAccount::deterministic(104, 0, true).expect("account");
|
||||
let voucher_key: Key = voucher_master.secret_key.clone().into();
|
||||
let voucher_master_key_id = key_id_for_preload_backend(&voucher_key);
|
||||
kms_keys.insert(voucher_master_key_id.clone(), voucher_key);
|
||||
known_keys.insert(
|
||||
voucher_master_key_id.clone(),
|
||||
voucher_master.secret_key.as_public_key(),
|
||||
);
|
||||
|
||||
remove_external_scenario_wallet_keys_from_maps(
|
||||
&mut known_keys,
|
||||
&mut kms_keys,
|
||||
&scenario_key_ids,
|
||||
);
|
||||
remove_external_scenario_wallet_keys_from_maps(
|
||||
&mut known_keys,
|
||||
&mut kms_keys,
|
||||
&scenario_key_ids,
|
||||
);
|
||||
|
||||
for key_id in &scenario_key_ids {
|
||||
assert!(!kms_keys.contains_key(key_id));
|
||||
assert!(!known_keys.contains_key(key_id));
|
||||
}
|
||||
assert!(kms_keys.contains_key(&unrelated_key_id));
|
||||
assert!(known_keys.contains_key(&unrelated_key_id));
|
||||
assert!(kms_keys.contains_key(&ed25519_key_id));
|
||||
assert!(kms_keys.contains_key(&voucher_master_key_id));
|
||||
assert!(known_keys.contains_key(&voucher_master_key_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_scenario_wallet_set_changes_nothing() {
|
||||
let mut kms_keys = HashMap::new();
|
||||
let mut known_keys = HashMap::new();
|
||||
let account = WalletAccount::deterministic(105, 0, true).expect("account");
|
||||
let key: Key = account.secret_key.clone().into();
|
||||
let key_id = key_id_for_preload_backend(&key);
|
||||
kms_keys.insert(key_id.clone(), key);
|
||||
known_keys.insert(key_id, account.secret_key.as_public_key());
|
||||
let before_kms = kms_keys.clone();
|
||||
let before_known_keys = known_keys.clone();
|
||||
|
||||
remove_external_scenario_wallet_keys_from_maps(
|
||||
&mut known_keys,
|
||||
&mut kms_keys,
|
||||
&HashSet::new(),
|
||||
);
|
||||
|
||||
assert_eq!(kms_keys, before_kms);
|
||||
assert_eq!(known_keys, before_known_keys);
|
||||
}
|
||||
}
|
||||
use super::{
|
||||
lifecycle::{
|
||||
node_wallet_name, remove_external_scenario_wallet_keys_from_maps, wallet_account_key_id,
|
||||
},
|
||||
*,
|
||||
};
|
||||
@@ -0,0 +1,416 @@
|
||||
use super::*;
|
||||
|
||||
#[when(
|
||||
expr = "all nodes have at least {int} blocks and converged to within {int} blocks in {int} seconds"
|
||||
)]
|
||||
#[then(
|
||||
expr = "all nodes have at least {int} blocks and converged to within {int} blocks in {int} seconds"
|
||||
)]
|
||||
async fn step_all_nodes_reached_min_height_and_converged(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
min_height: u64,
|
||||
max_diff_height: u64,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
nodes_converged(
|
||||
world,
|
||||
&step.value,
|
||||
Some(min_height),
|
||||
max_diff_height,
|
||||
time_out_seconds,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "all nodes agree on LIB in {int} seconds")]
|
||||
#[then(expr = "all nodes agree on LIB in {int} seconds")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require the world as the first `&mut` argument"
|
||||
)]
|
||||
async fn step_all_nodes_agree_on_lib(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
ensure_all_nodes_agree_on_lib(world, &step.value, time_out_seconds).await
|
||||
}
|
||||
|
||||
#[when("I wait for all nodes to be synced to the chain")]
|
||||
#[then("I wait for all nodes to be synced to the chain")]
|
||||
async fn step_wait_for_all_nodes_to_be_synced_to_the_chain(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
) -> StepResult {
|
||||
wait_for_all_nodes_to_be_synced_to_chain(world, &step.value).await
|
||||
}
|
||||
|
||||
#[when("I query cryptarchia info for all nodes")]
|
||||
#[then("I query cryptarchia info for all nodes")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require the world as the first `&mut` argument"
|
||||
)]
|
||||
async fn step_query_cryptarchia_info_all_nodes(world: &mut CucumberWorld, step: &Step) {
|
||||
get_cryptarchia_info_all_nodes(world, &step.value).await;
|
||||
}
|
||||
|
||||
#[then(expr = "I stop all nodes")]
|
||||
async fn step_stop_all_nodes(world: &mut CucumberWorld) -> StepResult {
|
||||
let runtime_dir_by_node_name: Vec<(String, String)> = world
|
||||
.nodes_info
|
||||
.iter()
|
||||
.map(|(node_name, info)| (node_name.clone(), info.started_node.name.clone()))
|
||||
.collect();
|
||||
|
||||
if world.snapshots.save.extensions.is_some() {
|
||||
prepare_all_wallets_snapshot(world).await?;
|
||||
}
|
||||
|
||||
world.reset_wallet_scanner_after_current_iteration().await;
|
||||
world.zone.clear();
|
||||
stop_active_manual_cluster(world)?;
|
||||
|
||||
if let Some(snapshot_name) = world.snapshots.save.node_state.take() {
|
||||
create_snapshots_all_nodes(world, &snapshot_name)?;
|
||||
}
|
||||
|
||||
if let Some(snapshot_name) = world.snapshots.save.extensions.take() {
|
||||
save_prepared_all_wallets_snapshot(&snapshot_name, world)?;
|
||||
}
|
||||
|
||||
for (node_name, _) in &runtime_dir_by_node_name {
|
||||
info!(target: TARGET, "Stopping node '{node_name}'");
|
||||
}
|
||||
world.nodes_info.clear();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "I send {int} transactions of {int} LGO each from wallet {string} to blend core zk key of node {string}"
|
||||
)]
|
||||
async fn step_send_multiple_transactions_to_blend_core_zk_key(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
number_of_transactions: usize,
|
||||
output_value: u64,
|
||||
sender_wallet_name: String,
|
||||
receiver_node_name: String,
|
||||
) -> StepResult {
|
||||
let receiver_blend_zk_pk = blend_zk_pk_for_node(world, &receiver_node_name)?;
|
||||
let sender_node_name = world.resolve_wallet(&sender_wallet_name)?.node_name;
|
||||
let sender_node_client = world
|
||||
.nodes_info
|
||||
.get(&sender_node_name)
|
||||
.ok_or_else(|| StepError::LogicalError {
|
||||
message: format!("Node '{sender_node_name}' not found in world state"),
|
||||
})?
|
||||
.started_node
|
||||
.client
|
||||
.clone();
|
||||
|
||||
let mut available_utxos = WalletUtxos::new();
|
||||
let best_node_info = wait_wallet_send_ready(
|
||||
world,
|
||||
&step.value,
|
||||
&sender_wallet_name,
|
||||
180,
|
||||
number_of_transactions as u64 * output_value,
|
||||
WalletSendReadiness::TotalValueOnly,
|
||||
&mut available_utxos,
|
||||
&HashSet::new(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for _ in 0..number_of_transactions {
|
||||
let tx_hashes = create_and_submit_transaction_hashes_with_utxo_cache(
|
||||
world,
|
||||
&step.value,
|
||||
&sender_wallet_name,
|
||||
&[(receiver_blend_zk_pk, output_value)],
|
||||
Some(&best_node_info),
|
||||
Some(&mut available_utxos),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|error| {
|
||||
warn!(target: TARGET, "Step `{}` error: {error}", step.value);
|
||||
})?;
|
||||
|
||||
wait_for_transactions_inclusion(&sender_node_client, &tx_hashes, Duration::from_mins(2))
|
||||
.await
|
||||
.inspect_err(|error| {
|
||||
warn!(target: TARGET, "Step `{}` error: {error}", step.value);
|
||||
})?;
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Sent and included normal transaction from `{sender_wallet_name}` to blend zk key of {receiver_node_name}, value: {output_value}, tx count: {}",
|
||||
tx_hashes.len(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn blend_zk_pk_for_node(world: &CucumberWorld, node_name: &str) -> Result<ZkPublicKey, StepError> {
|
||||
let node_info = world
|
||||
.nodes_info
|
||||
.get(node_name)
|
||||
.ok_or_else(|| StepError::LogicalError {
|
||||
message: format!("Node '{node_name}' not found in world state"),
|
||||
})?;
|
||||
|
||||
let user_config_path = node_info.runtime_dir.join(USER_CONFIG_FILE);
|
||||
let blend_zk_pk_hex = blend_core_zk_pk_from_node_yaml(&user_config_path)?;
|
||||
let blend_zk_pk = ZkPublicKey::from_bytes(&hex::decode(blend_zk_pk_hex)?)?;
|
||||
|
||||
Ok(blend_zk_pk)
|
||||
}
|
||||
|
||||
/// Wait for the node-local wallet API to expose a funded note for a Blend key.
|
||||
///
|
||||
/// Blend ZK keys are read from node configuration and are not scenario wallets,
|
||||
/// so the wallet scanner does not currently track them. Keep this exception
|
||||
/// node-local because the returned note is immediately consumed by that node's
|
||||
/// SDP declaration endpoint.
|
||||
async fn wait_for_blend_funded_note(
|
||||
world: &CucumberWorld,
|
||||
node_name: &str,
|
||||
blend_zk_pk: ZkPublicKey,
|
||||
) -> Result<lb_core::mantle::NoteId, StepError> {
|
||||
let base_url = world
|
||||
.nodes_info
|
||||
.get(node_name)
|
||||
.ok_or_else(|| StepError::LogicalError {
|
||||
message: format!("Node '{node_name}' not found in world state"),
|
||||
})?
|
||||
.started_node
|
||||
.client
|
||||
.base_url()
|
||||
.clone();
|
||||
let timeout = Duration::from_secs(30);
|
||||
let started = Instant::now();
|
||||
let client = CommonHttpClient::new(None);
|
||||
|
||||
loop {
|
||||
let last_error = match client
|
||||
.get_wallet_balance(base_url.clone(), blend_zk_pk, None)
|
||||
.await
|
||||
{
|
||||
Ok(wallet_balance) => {
|
||||
if let Some(note_id) = wallet_balance.notes.keys().next().copied() {
|
||||
return Ok(note_id);
|
||||
}
|
||||
"wallet has no notes yet".to_owned()
|
||||
}
|
||||
Err(error) => error.to_string(),
|
||||
};
|
||||
|
||||
if started.elapsed() >= timeout {
|
||||
return Err(StepError::Timeout {
|
||||
message: format!(
|
||||
"Timed out waiting for a funded note on Blend ZK key of '{node_name}' via \
|
||||
'{base_url}' (last error: {last_error})"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Required to be mutable by cucumber step function signature"
|
||||
)]
|
||||
#[expect(unused_variables, reason = "Cucumber step function signature")]
|
||||
#[then(expr = "I declare node {string} as blend core node via the CLI binary")]
|
||||
async fn step_run_blend_sdp_declaration_cli(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
declarer_node_name: String,
|
||||
) -> StepResult {
|
||||
let user_config_path = node_user_config_path(world, &declarer_node_name)?;
|
||||
let locator = blend_core_locator_from_node_yaml(&user_config_path)?;
|
||||
let blend_zk_pk = blend_zk_pk_for_node(world, &declarer_node_name)?;
|
||||
let service_note_id =
|
||||
wait_for_blend_funded_note(world, &declarer_node_name, blend_zk_pk).await?;
|
||||
let service_note_id_json =
|
||||
serde_json::to_string(&service_note_id).map_err(|error| StepError::LogicalError {
|
||||
message: format!("Failed to serialize service note ID: {error}"),
|
||||
})?;
|
||||
let service_note_id_hex = service_note_id_json.trim_matches('"').to_owned();
|
||||
|
||||
let declarer_api_base_url = world
|
||||
.nodes_info
|
||||
.get(&declarer_node_name)
|
||||
.ok_or_else(|| StepError::LogicalError {
|
||||
message: format!("Node '{declarer_node_name}' not found in world state"),
|
||||
})?
|
||||
.started_node
|
||||
.client
|
||||
.base_url()
|
||||
.clone();
|
||||
|
||||
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let output = tokio::process::Command::new("cargo")
|
||||
.current_dir(workspace_root)
|
||||
.arg("run")
|
||||
.arg("-p")
|
||||
.arg("logos-blockchain-tools")
|
||||
.arg("--bin")
|
||||
.arg("logos-blockchain-tools-api")
|
||||
.arg("--")
|
||||
.arg("sdp")
|
||||
.arg("post-blend-declaration")
|
||||
.arg("--user-config-path")
|
||||
.arg(user_config_path)
|
||||
.arg("--blend-addr")
|
||||
.arg(format!("{locator}"))
|
||||
.arg("--service-note-id")
|
||||
.arg(service_note_id_hex)
|
||||
.arg("--node-address")
|
||||
.arg(declarer_api_base_url.to_string())
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(StepError::StepFail {
|
||||
message: format!(
|
||||
"Blend declaration CLI failed for node '{declarer_node_name}'\nstdout:\n{stdout}\nstderr:\n{stderr}"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Required to be mutable by cucumber step function signature"
|
||||
)]
|
||||
#[then(expr = "I declare node {string} as blend core node via the API")]
|
||||
async fn step_run_blend_sdp_declaration_api(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
declarer_node_name: String,
|
||||
) -> StepResult {
|
||||
let user_config_path = node_user_config_path(world, &declarer_node_name)?;
|
||||
let locator = blend_core_locator_from_node_yaml(&user_config_path)?;
|
||||
let blend_zk_pk = blend_zk_pk_for_node(world, &declarer_node_name)?;
|
||||
let service_note_id =
|
||||
wait_for_blend_funded_note(world, &declarer_node_name, blend_zk_pk).await?;
|
||||
|
||||
let declarer_node_client = world
|
||||
.nodes_info
|
||||
.get(&declarer_node_name)
|
||||
.ok_or_else(|| StepError::LogicalError {
|
||||
message: format!("Node '{declarer_node_name}' not found in world state"),
|
||||
})?
|
||||
.started_node
|
||||
.client
|
||||
.clone();
|
||||
|
||||
let declaration_id = declarer_node_client
|
||||
.join_blend_network(locator, service_note_id)
|
||||
.await
|
||||
.inspect_err(|error| {
|
||||
warn!(target: TARGET, "Step `{}` error: {error}", step.value);
|
||||
})?;
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Node '{declarer_node_name}' joined blend core via API, declaration id: {declaration_id}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn node_user_config_path(world: &CucumberWorld, node_name: &str) -> Result<PathBuf, StepError> {
|
||||
let node_info = world
|
||||
.nodes_info
|
||||
.get(node_name)
|
||||
.ok_or_else(|| StepError::LogicalError {
|
||||
message: format!("Node '{node_name}' not found in world state"),
|
||||
})?;
|
||||
|
||||
Ok(node_info.runtime_dir.join(USER_CONFIG_FILE))
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::cognitive_complexity,
|
||||
reason = "TODO: Address this at some point."
|
||||
)]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Required to be mutable by cucumber step function signature"
|
||||
)]
|
||||
#[then(expr = "blend core SDP declaration for node {string} is included on node {string}")]
|
||||
async fn step_verify_blend_sdp_declaration_included(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
declarer_node_name: String,
|
||||
api_node_name: String,
|
||||
) -> StepResult {
|
||||
let blend_zk_pk = blend_zk_pk_for_node(world, &declarer_node_name)?;
|
||||
let service_note_id =
|
||||
wait_for_blend_funded_note(world, &declarer_node_name, blend_zk_pk).await?;
|
||||
|
||||
let step_timeout = Duration::from_secs(30);
|
||||
let start_time = Instant::now();
|
||||
loop {
|
||||
let declarations_result = world
|
||||
.nodes_info
|
||||
.get(&api_node_name)
|
||||
.ok_or_else(|| StepError::LogicalError {
|
||||
message: format!("Node '{api_node_name}' not found in world state"),
|
||||
})?
|
||||
.started_node
|
||||
.client
|
||||
.get_sdp_declarations()
|
||||
.await;
|
||||
|
||||
let declarations = match declarations_result {
|
||||
Ok(declarations) => declarations,
|
||||
Err(error) => {
|
||||
let error_message = error.to_string();
|
||||
if error_message.contains("404 Not Found") {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Skipping declaration visibility assertion on '{api_node_name}' because testing SDP endpoint is unavailable: {error_message}",
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
warn!(target: TARGET, "Step `{}` error: {error}", step.value);
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
|
||||
if declarations.values().any(|declaration| {
|
||||
declaration.service_note_id == service_note_id && declaration.zk_id == blend_zk_pk
|
||||
}) {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Blend declaration observed for node '{declarer_node_name}'"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if start_time.elapsed() >= step_timeout {
|
||||
return Err(StepError::Timeout {
|
||||
message: format!(
|
||||
"Timed out waiting for declaration submitted by '{declarer_node_name}' to appear on node '{api_node_name}'"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
use super::{
|
||||
CucumberWorld, SdpFundingConfig, Step, StepError, StepResult,
|
||||
ensure_fee_sponsorship_and_fork_groups_are_not_mixed, given,
|
||||
rebuild_pending_local_manual_cluster, set_blend_diagnostic_parameter_set,
|
||||
set_deployment_config_override, set_user_config_override, when,
|
||||
};
|
||||
|
||||
#[given(expr = "the cluster uses Blend diagnostic parameter set {string}")]
|
||||
#[when(expr = "the cluster uses Blend diagnostic parameter set {string}")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_value,
|
||||
reason = "Cucumber step arguments must use owned types"
|
||||
)]
|
||||
fn step_set_blend_diagnostic_parameter_set(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
parameter_set_name: String,
|
||||
) -> StepResult {
|
||||
set_blend_diagnostic_parameter_set(world, &step.value, ¶meter_set_name)
|
||||
}
|
||||
|
||||
#[given(expr = "we use IBD peers")]
|
||||
#[when(expr = "we use IBD peers")]
|
||||
const fn step_we_use_ibd_peers(world: &mut CucumberWorld) {
|
||||
world.startup.populate_ibd_peers_from_initial_peers = Some(true);
|
||||
}
|
||||
|
||||
#[given(expr = "we join an external network")]
|
||||
#[when(expr = "we join an external network")]
|
||||
const fn step_we_join_external_network(world: &mut CucumberWorld) {
|
||||
world.startup.join_external_network = Some(true);
|
||||
}
|
||||
|
||||
#[given(expr = "we will have distinct node groups to query wallet balances:")]
|
||||
#[when(expr = "we will have distinct node groups to query wallet balances:")]
|
||||
fn step_define_node_groups(world: &mut CucumberWorld, step: &Step) -> Result<(), StepError> {
|
||||
ensure_fee_sponsorship_and_fork_groups_are_not_mixed(world, &step.value)?;
|
||||
|
||||
let table = step.table.as_ref().ok_or(StepError::LogicalError {
|
||||
message: "Expected a data table".to_owned(),
|
||||
})?;
|
||||
|
||||
if table.rows.is_empty() || table.rows[0].len() != 2 {
|
||||
return Err(StepError::LogicalError {
|
||||
message: "Expected table columns: | group_name | node_name |".to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
if table.rows[0][0].trim() != "group_name" || table.rows[0][1].trim() != "node_name" {
|
||||
return Err(StepError::LogicalError {
|
||||
message: "Expected table columns: | group_name | node_name |".to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let assignments = table
|
||||
.rows
|
||||
.iter()
|
||||
.skip(1)
|
||||
.map(|row| {
|
||||
if row.len() != 2 {
|
||||
return Err(StepError::LogicalError {
|
||||
message: "Each node-group row must have exactly two columns".to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok((row[0].trim().to_owned(), row[1].trim().to_owned()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
world.fork_groups.replace_all(assignments)
|
||||
}
|
||||
|
||||
#[given(expr = "I have user config override {string} as {string}")]
|
||||
#[when(expr = "I have user config override {string} as {string}")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_value,
|
||||
reason = "Required by cucumber expression"
|
||||
)]
|
||||
fn step_set_user_config_setting(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
setting_path: String,
|
||||
setting_value: String,
|
||||
) -> StepResult {
|
||||
set_user_config_override(world, &step.value, &setting_path, &setting_value)
|
||||
}
|
||||
|
||||
#[given(expr = "I have deployment config override {string} as {string}")]
|
||||
#[when(expr = "I have deployment config override {string} as {string}")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_value,
|
||||
reason = "Required by cucumber expression"
|
||||
)]
|
||||
fn step_set_deployment_config_setting(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
setting_path: String,
|
||||
setting_value: String,
|
||||
) -> StepResult {
|
||||
set_deployment_config_override(world, &step.value, &setting_path, &setting_value)
|
||||
}
|
||||
|
||||
#[given(expr = "the first {int} nodes are declared as blend providers")]
|
||||
#[when(expr = "the first {int} nodes are declared as blend providers")]
|
||||
fn step_blend_provider_count(world: &mut CucumberWorld, provider_count: usize) -> StepResult {
|
||||
world.cluster.blend_core_nodes = Some(provider_count);
|
||||
rebuild_pending_local_manual_cluster(world)
|
||||
}
|
||||
|
||||
#[given(expr = "the cluster uses SDP funding of {int} per provider split across {int} notes")]
|
||||
#[when(expr = "the cluster uses SDP funding of {int} per provider split across {int} notes")]
|
||||
fn step_set_sdp_funding(
|
||||
world: &mut CucumberWorld,
|
||||
total_value_per_node: u64,
|
||||
target_notes_per_node: usize,
|
||||
) -> StepResult {
|
||||
if target_notes_per_node == 0 {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: "SDP funding note count must be greater than zero".to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
world.set_sdp_funding_config(SdpFundingConfig::new(
|
||||
total_value_per_node,
|
||||
target_notes_per_node,
|
||||
));
|
||||
rebuild_pending_local_manual_cluster(world)
|
||||
}
|
||||
|
||||
#[given(expr = "no nodes are declared as blend providers")]
|
||||
#[when(expr = "no nodes are declared as blend providers")]
|
||||
fn step_no_blend_providers(world: &mut CucumberWorld) -> StepResult {
|
||||
world.cluster.blend_core_nodes = Some(0);
|
||||
rebuild_pending_local_manual_cluster(world)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use super::{
|
||||
CucumberWorld, Duration, GenesisTime, Instant, NodeBinaryProfile, OffsetDateTime, Step,
|
||||
StepError, StepResult, TARGET, TimeDuration, ensure_node_binary_built, given, info,
|
||||
rebuild_pending_local_manual_cluster, sleep, then, when,
|
||||
};
|
||||
|
||||
pub(super) fn resolve_step_genesis_time(
|
||||
step_value: &str,
|
||||
now: OffsetDateTime,
|
||||
seconds: i64,
|
||||
) -> Result<GenesisTime, StepError> {
|
||||
if seconds < 0 {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!("step `{step_value}` requires a non-negative offset"),
|
||||
});
|
||||
}
|
||||
|
||||
let genesis_datetime = now
|
||||
.checked_add(TimeDuration::seconds(seconds))
|
||||
.ok_or_else(|| StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"step `{step_value}` has an invalid genesis time: offset is out of range"
|
||||
),
|
||||
})?;
|
||||
|
||||
GenesisTime::try_from(genesis_datetime).map_err(|error| StepError::InvalidArgument {
|
||||
message: format!("step `{step_value}` has an invalid genesis time: {error}"),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn validate_genesis_time_change(
|
||||
existing_genesis_time: Option<GenesisTime>,
|
||||
nodes_started: bool,
|
||||
requested_genesis_time: GenesisTime,
|
||||
) -> StepResult {
|
||||
if nodes_started && existing_genesis_time != Some(requested_genesis_time) {
|
||||
return Err(StepError::LogicalError {
|
||||
message: "cannot change genesis time after nodes have started".to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given(expr = "the chain starts {int} seconds from now")]
|
||||
#[when(expr = "the chain starts {int} seconds from now")]
|
||||
async fn step_chain_starts_from_now(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
seconds: i64,
|
||||
) -> StepResult {
|
||||
let node_binary_profile = if world.tokio_console_profile_enabled() {
|
||||
NodeBinaryProfile::TokioConsole
|
||||
} else {
|
||||
NodeBinaryProfile::default()
|
||||
};
|
||||
ensure_node_binary_built(&node_binary_profile)
|
||||
.await
|
||||
.map_err(|error| StepError::Preflight {
|
||||
message: format!("failed to resolve/build node binary: {error}"),
|
||||
})?;
|
||||
|
||||
let genesis_time = resolve_step_genesis_time(&step.value, OffsetDateTime::now_utc(), seconds)?;
|
||||
validate_genesis_time_change(
|
||||
world.lifecycle.genesis_time,
|
||||
!world.nodes_info.is_empty(),
|
||||
genesis_time,
|
||||
)?;
|
||||
|
||||
world.set_genesis_time(genesis_time);
|
||||
if world.nodes_info.is_empty() && world.cluster.manual_cluster_spec.is_some() {
|
||||
rebuild_pending_local_manual_cluster(world)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[then(expr = "the configured genesis time has not passed for {int} seconds")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require the world as the first `&mut` argument"
|
||||
)]
|
||||
async fn step_genesis_time_has_not_passed(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
seconds: u64,
|
||||
) -> StepResult {
|
||||
let genesis_time = world
|
||||
.lifecycle
|
||||
.genesis_time
|
||||
.ok_or_else(|| StepError::LogicalError {
|
||||
message: "the scenario has no configured genesis time".to_owned(),
|
||||
})?;
|
||||
let genesis_datetime = OffsetDateTime::from(genesis_time);
|
||||
let timeout = Duration::from_secs(seconds);
|
||||
let started_waiting = Instant::now();
|
||||
|
||||
loop {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
if now >= genesis_datetime {
|
||||
return Err(StepError::StepFail {
|
||||
message: format!(
|
||||
"Step `{}` failed: configured genesis time {genesis_datetime} had passed at {now}",
|
||||
step.value
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if started_waiting.elapsed() >= timeout {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Configured genesis time {genesis_datetime} remained in the future for {seconds} seconds"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
use super::{
|
||||
ConfigOverride, CucumberWorld, Duration, GenesisTokens, HashMap, Instant, ManualClusterKind,
|
||||
ManualClusterSpec, NodesToStartUnordered, Step, StepError, StepResult, TARGET, WalletAccount,
|
||||
assert_manual_node_has_peers, connect_manual_node_to_node,
|
||||
ensure_fee_sponsorship_and_fork_groups_are_not_mixed, given, install_local_manual_cluster,
|
||||
non_zero, parse_genesis_wallet_tokens_row, parse_mining_wallet_resources_table_row,
|
||||
parse_wallet_resources_table_row, restart_node, start_node,
|
||||
start_nodes_order_respecting_dependencies, stop_node, then,
|
||||
verify_genesis_wallet_resources_table_indexes,
|
||||
verify_mining_node_wallet_resources_table_indexes, verify_node_wallet_resources_table_indexes,
|
||||
warn, when,
|
||||
};
|
||||
|
||||
#[given(expr = "I have a cluster with capacity of {int} nodes")]
|
||||
#[when(expr = "I have a cluster with capacity of {int} nodes")]
|
||||
fn step_manual_cluster(world: &mut CucumberWorld, step: &Step, nodes_count: usize) -> StepResult {
|
||||
install_local_manual_cluster(
|
||||
world,
|
||||
ManualClusterSpec {
|
||||
kind: ManualClusterKind::Generated,
|
||||
capacity: nodes_count,
|
||||
},
|
||||
)
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step '{step}' error: {e}");
|
||||
})
|
||||
}
|
||||
|
||||
#[given(expr = "I have a devnet cluster with capacity of {int} nodes")]
|
||||
#[when(expr = "I have a devnet cluster with capacity of {int} nodes")]
|
||||
fn step_manual_devnet_cluster(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
nodes_count: usize,
|
||||
) -> StepResult {
|
||||
install_local_manual_cluster(
|
||||
world,
|
||||
ManualClusterSpec {
|
||||
kind: ManualClusterKind::Devnet,
|
||||
capacity: nodes_count,
|
||||
},
|
||||
)
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step '{step}' error: {e}");
|
||||
})
|
||||
}
|
||||
|
||||
#[given("the genesis block has the following wallet resources:")]
|
||||
#[when("the genesis block has the following wallet resources:")]
|
||||
fn step_cluster_has_wallet_resources(world: &mut CucumberWorld, step: &Step) -> StepResult {
|
||||
let table = step
|
||||
.table
|
||||
.as_ref()
|
||||
.ok_or(StepError::MissingTable)
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
|
||||
verify_genesis_wallet_resources_table_indexes(table, &step.value)?;
|
||||
world.chain.genesis_tokens.clear();
|
||||
for row in table.rows.iter().skip(1) {
|
||||
let (account_index, token_count, token_amount) =
|
||||
parse_genesis_wallet_tokens_row(&step.value, row)?;
|
||||
|
||||
world.chain.genesis_tokens.push(GenesisTokens {
|
||||
account_index,
|
||||
token_count,
|
||||
token_amount,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given(expr = "we have a sponsored genesis fee account with {int} tokens of {int} value each")]
|
||||
#[when(expr = "we have a sponsored genesis fee account with {int} tokens of {int} value each")]
|
||||
fn step_sponsored_genesis_fee_account(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
token_count: usize,
|
||||
token_value: u64,
|
||||
) -> StepResult {
|
||||
ensure_fee_sponsorship_and_fork_groups_are_not_mixed(world, step.value.as_str())?;
|
||||
|
||||
let token_count = non_zero!("genesis fee token count", token_count)?;
|
||||
let token_value = non_zero!("genesis fee token value", token_value)?;
|
||||
|
||||
world
|
||||
.wallet_registry
|
||||
.fee_state
|
||||
.set_sponsored_genesis_account(token_count, token_value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given("I start nodes with wallet resources:")]
|
||||
#[when("I start nodes with wallet resources:")]
|
||||
async fn step_start_nodes_with_wallet_resources(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
) -> StepResult {
|
||||
let table = step
|
||||
.table
|
||||
.as_ref()
|
||||
.ok_or(StepError::MissingTable)
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
|
||||
// Map wallet start info and connected peers to node name
|
||||
verify_node_wallet_resources_table_indexes(table, &step.value)?;
|
||||
let mut nodes_to_start: NodesToStartUnordered = HashMap::new();
|
||||
for row in table.rows.iter().skip(1) {
|
||||
let (node_name, wallet_start_info, connected_to) =
|
||||
parse_wallet_resources_table_row(&step.value, row)?;
|
||||
let entry = nodes_to_start
|
||||
.entry(node_name)
|
||||
.or_insert_with(|| (Vec::new(), Vec::new()));
|
||||
entry.0.push(wallet_start_info);
|
||||
if let Some(peer) = connected_to {
|
||||
entry.1.push(peer);
|
||||
}
|
||||
}
|
||||
|
||||
let nodes_to_start_ordered = start_nodes_order_respecting_dependencies(
|
||||
nodes_to_start,
|
||||
world.nodes_info.keys().cloned().collect(),
|
||||
)
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
for (node_name, wallet_start_info, mut initial_peers) in nodes_to_start_ordered {
|
||||
initial_peers.sort();
|
||||
initial_peers.dedup();
|
||||
start_node(
|
||||
world,
|
||||
&step.value,
|
||||
&node_name,
|
||||
&wallet_start_info,
|
||||
&initial_peers,
|
||||
false,
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
world.ensure_wallet_scanner_started().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Starts mining nodes, each carrying one or more wallet resources of which
|
||||
/// exactly one is flagged `is_mining_wallet`. That wallet's public key is
|
||||
/// derived and applied as the node's `pow.claim_address`, so mined rewards are
|
||||
/// paid to a wallet the test tracks. Multiple mining nodes are supported; each
|
||||
/// gets its own single mining wallet / claim address.
|
||||
#[given("I start mining nodes with wallet resources:")]
|
||||
#[when("I start mining nodes with wallet resources:")]
|
||||
async fn step_start_mining_nodes_with_wallet_resources(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
) -> StepResult {
|
||||
let table = step
|
||||
.table
|
||||
.as_ref()
|
||||
.ok_or(StepError::MissingTable)
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
|
||||
verify_mining_node_wallet_resources_table_indexes(table, &step.value)?;
|
||||
|
||||
let mut nodes_to_start: NodesToStartUnordered = HashMap::new();
|
||||
let mut node_claim_overrides: HashMap<String, ConfigOverride> = HashMap::new();
|
||||
let mut node_mining_wallet_count: HashMap<String, usize> = HashMap::new();
|
||||
for row in table.rows.iter().skip(1) {
|
||||
let (node_name, wallet_start_info, is_mining_wallet, connected_to) =
|
||||
parse_mining_wallet_resources_table_row(&step.value, row)?;
|
||||
|
||||
if is_mining_wallet {
|
||||
*node_mining_wallet_count
|
||||
.entry(node_name.clone())
|
||||
.or_insert(0) += 1;
|
||||
let account =
|
||||
WalletAccount::deterministic(wallet_start_info.account_index as u64, 0, true)
|
||||
.map_err(|source| StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{}` error: failed to derive mining wallet public key for \
|
||||
`{}`: {source}",
|
||||
step.value, wallet_start_info.wallet_name
|
||||
),
|
||||
})?;
|
||||
let value = serde_yaml::to_value(account.public_key()).map_err(|source| {
|
||||
StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{}` error: failed to serialize mining wallet public key for \
|
||||
`{}`: {source}",
|
||||
step.value, wallet_start_info.wallet_name
|
||||
),
|
||||
}
|
||||
})?;
|
||||
node_claim_overrides.insert(
|
||||
node_name.clone(),
|
||||
ConfigOverride {
|
||||
path: "pow.claim_address".to_owned(),
|
||||
value,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let entry = nodes_to_start
|
||||
.entry(node_name)
|
||||
.or_insert_with(|| (Vec::new(), Vec::new()));
|
||||
entry.0.push(wallet_start_info);
|
||||
if let Some(peer) = connected_to {
|
||||
entry.1.push(peer);
|
||||
}
|
||||
}
|
||||
|
||||
// Every mining node must configure exactly one mining wallet.
|
||||
for node_name in nodes_to_start.keys() {
|
||||
let count = node_mining_wallet_count
|
||||
.get(node_name)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
if count != 1 {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{}` error: mining node `{node_name}` must have exactly one \
|
||||
is_mining_wallet row, found {count}",
|
||||
step.value
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let nodes_to_start_ordered = start_nodes_order_respecting_dependencies(
|
||||
nodes_to_start,
|
||||
world.nodes_info.keys().cloned().collect(),
|
||||
)
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
for (node_name, wallet_start_info, mut initial_peers) in nodes_to_start_ordered {
|
||||
initial_peers.sort();
|
||||
initial_peers.dedup();
|
||||
let extra_user_overrides = node_claim_overrides
|
||||
.get(&node_name)
|
||||
.map_or(&[][..], std::slice::from_ref);
|
||||
start_node(
|
||||
world,
|
||||
&step.value,
|
||||
&node_name,
|
||||
&wallet_start_info,
|
||||
&initial_peers,
|
||||
false,
|
||||
extra_user_overrides,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
world.ensure_wallet_scanner_started().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given(expr = "I start node {string}")]
|
||||
#[when(expr = "I start node {string}")]
|
||||
async fn step_start_manual_stand_alone_node(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
node_name: String,
|
||||
) -> StepResult {
|
||||
start_node(
|
||||
world,
|
||||
&step.value,
|
||||
&node_name,
|
||||
&Vec::new(),
|
||||
&Vec::new(),
|
||||
false,
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[given(expr = "I immediate start node {string}")]
|
||||
#[when(expr = "I immediate start node {string}")]
|
||||
async fn step_start_manual_network_ready_only_stand_alone_node(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
node_name: String,
|
||||
) -> StepResult {
|
||||
start_node(
|
||||
world,
|
||||
&step.value,
|
||||
&node_name,
|
||||
&Vec::new(),
|
||||
&Vec::new(),
|
||||
true,
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "I start node {string} to be ready between {int} and {int} seconds")]
|
||||
async fn step_start_manual_stand_alone_node_not_ready_before(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
node_name: String,
|
||||
min_wait_seconds: u64,
|
||||
max_wait_seconds: u64,
|
||||
) -> StepResult {
|
||||
let start = Instant::now();
|
||||
start_node(
|
||||
world,
|
||||
&step.value,
|
||||
&node_name,
|
||||
&Vec::new(),
|
||||
&Vec::new(),
|
||||
false,
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
if elapsed < Duration::from_secs(min_wait_seconds) {
|
||||
return Err(StepError::StepFail {
|
||||
message: format!(
|
||||
"Step `{}` error: Node '{node_name}' became ready too early: elapsed {:.2?}, \
|
||||
expected at least {min_wait_seconds}s",
|
||||
step.value, elapsed,
|
||||
),
|
||||
});
|
||||
}
|
||||
if elapsed > Duration::from_secs(max_wait_seconds) {
|
||||
return Err(StepError::StepFail {
|
||||
message: format!(
|
||||
"Step `{}` error: Node '{node_name}' took too long to become ready: elapsed {:.2?}, \
|
||||
expected at most {max_wait_seconds}s",
|
||||
step.value, elapsed,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "I start peer node {string} connected to node {string} to be ready between {int} and {int} seconds"
|
||||
)]
|
||||
async fn step_start_manual_peer_node_not_ready_before(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
node_name: String,
|
||||
peer_name: String,
|
||||
min_wait_seconds: u64,
|
||||
max_wait_seconds: u64,
|
||||
) -> StepResult {
|
||||
let start = Instant::now();
|
||||
start_node(
|
||||
world,
|
||||
&step.value,
|
||||
&node_name,
|
||||
&Vec::new(),
|
||||
&[peer_name],
|
||||
false,
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
if elapsed < Duration::from_secs(min_wait_seconds) {
|
||||
return Err(StepError::StepFail {
|
||||
message: format!(
|
||||
"Step `{}` error: Node '{node_name}' became ready too early: elapsed {:.2?}, \
|
||||
expected at least {min_wait_seconds}s",
|
||||
step.value, elapsed,
|
||||
),
|
||||
});
|
||||
}
|
||||
if elapsed > Duration::from_secs(max_wait_seconds) {
|
||||
return Err(StepError::StepFail {
|
||||
message: format!(
|
||||
"Step `{}` error: Node '{node_name}' took too long to become ready: elapsed {:.2?}, \
|
||||
expected at most {max_wait_seconds}s",
|
||||
step.value, elapsed,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(expr = "I connect node {string} to node {string} at runtime")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step entrypoints must take `&mut World`"
|
||||
)]
|
||||
async fn step_connect_nodes_at_runtime(
|
||||
world: &mut CucumberWorld,
|
||||
source_node_name: String,
|
||||
target_node_name: String,
|
||||
) -> StepResult {
|
||||
connect_manual_node_to_node(world, &source_node_name, &target_node_name).await
|
||||
}
|
||||
|
||||
#[then(expr = "node {string} has at least {int} peers within {int} seconds")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step entrypoints must take `&mut World`"
|
||||
)]
|
||||
async fn step_node_has_peers(
|
||||
world: &mut CucumberWorld,
|
||||
node_name: String,
|
||||
min_peers: usize,
|
||||
timeout_secs: u64,
|
||||
) -> StepResult {
|
||||
assert_manual_node_has_peers(world, &node_name, min_peers, timeout_secs).await
|
||||
}
|
||||
|
||||
#[when(expr = "I restart node {string}")]
|
||||
async fn step_restart_node(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
node_name: String,
|
||||
) -> StepResult {
|
||||
restart_node(world, &step.value, &node_name).await?;
|
||||
if world.blend_diagnostics.observation_count > 0 {
|
||||
world.blend_diagnostics.stopped_nodes.remove(&node_name);
|
||||
world.blend_diagnostics.phase =
|
||||
Some(crate::cucumber::world::BlendDiagnosticPhase::Recovery);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(expr = "I stop node {string}")]
|
||||
async fn step_stop_node(world: &mut CucumberWorld, step: &Step, node_name: String) -> StepResult {
|
||||
if world.blend_diagnostics.observation_count > 0 {
|
||||
world.blend_diagnostics.phase = Some(crate::cucumber::world::BlendDiagnosticPhase::Outage);
|
||||
}
|
||||
stop_node(world, &step.value, &node_name).await?;
|
||||
if world.blend_diagnostics.observation_count > 0 {
|
||||
world.blend_diagnostics.stopped_nodes.insert(node_name);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::PathBuf,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use cucumber::{gherkin::Step, given, then, when};
|
||||
use lb_common_http_client::CommonHttpClient;
|
||||
use lb_core::{codec::DeserializeOp as _, mantle::GenesisTime};
|
||||
use lb_key_management_system_service::keys::ZkPublicKey;
|
||||
use lb_libp2p::{Multiaddr, PeerId};
|
||||
use lb_testing_framework::{
|
||||
USER_CONFIG_FILE,
|
||||
configs::{
|
||||
deployment::{NodeBinaryProfile, SdpFundingConfig},
|
||||
wallet::WalletAccount,
|
||||
},
|
||||
ensure_node_binary_built,
|
||||
};
|
||||
use time::{Duration as TimeDuration, OffsetDateTime};
|
||||
use tokio::time::{Instant, sleep};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{
|
||||
common::wallet::WalletUtxos,
|
||||
cucumber::{
|
||||
error::{StepError, StepResult},
|
||||
steps::{
|
||||
TARGET,
|
||||
cluster::{
|
||||
assert_manual_node_has_peers, connect_manual_node_to_node,
|
||||
install_local_manual_cluster, rebuild_pending_local_manual_cluster,
|
||||
stop_active_manual_cluster,
|
||||
},
|
||||
nodes::{
|
||||
NodesToStartUnordered,
|
||||
config_override::{set_deployment_config_override, set_user_config_override},
|
||||
create_snapshot_all_nodes_with_wallet_state,
|
||||
create_snapshot_node_with_wallet_state, create_snapshots_all_nodes,
|
||||
diagnostics::set_blend_diagnostic_parameter_set,
|
||||
ensure_all_nodes_agree_on_lib,
|
||||
ensure_fee_sponsorship_and_fork_groups_are_not_mixed,
|
||||
get_cryptarchia_info_all_nodes, nodes_converged, parse_genesis_wallet_tokens_row,
|
||||
parse_mining_wallet_resources_table_row, parse_url,
|
||||
parse_wallet_resources_table_row, poll_all_nodes_and_update_consensus_cache,
|
||||
restart_node,
|
||||
snapshots::validate_snapshot_path_component,
|
||||
start_node, start_nodes_order_respecting_dependencies, stop_node,
|
||||
verify_genesis_wallet_resources_table_indexes,
|
||||
verify_mining_node_wallet_resources_table_indexes,
|
||||
verify_node_wallet_resources_table_indexes,
|
||||
verify_reponsive_and_network_ready_with_timeout, wait_all_nodes_responive,
|
||||
wait_for_all_nodes_to_be_synced_to_chain,
|
||||
},
|
||||
transactions::utils::{
|
||||
create_and_submit_transaction_hashes_with_utxo_cache,
|
||||
wait_for_transactions_inclusion,
|
||||
},
|
||||
},
|
||||
utils::{
|
||||
blend_core_locator_from_node_yaml, blend_core_zk_pk_from_node_yaml,
|
||||
resolve_literal_or_env,
|
||||
},
|
||||
wallet::{
|
||||
snapshot::{
|
||||
prepare_all_wallets_snapshot, prepare_wallet_snapshot_restore_if_present,
|
||||
save_prepared_all_wallets_snapshot,
|
||||
},
|
||||
sync::{WalletSendReadiness, wait_wallet_send_ready},
|
||||
},
|
||||
world::{
|
||||
ConfigOverride, CucumberWorld, GenesisTokens, ManualClusterKind, ManualClusterSpec,
|
||||
NodeSnapshot, PublicCryptarchiaEndpointPeer,
|
||||
},
|
||||
},
|
||||
non_zero,
|
||||
};
|
||||
|
||||
const PUBLIC_CRYPTARCHIA_ENDPOINT: &str = "public_cryptarchia_endpoint";
|
||||
const PUBLIC_CRYPTARCHIA_ENDPOINT_USERNAME: &str = "username";
|
||||
const PUBLIC_CRYPTARCHIA_ENDPOINT_PASSWORD: &str = "password";
|
||||
|
||||
mod blend;
|
||||
mod configuration;
|
||||
mod genesis;
|
||||
mod lifecycle;
|
||||
mod network;
|
||||
mod snapshots;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,383 @@
|
||||
use super::{
|
||||
CucumberWorld, Duration, HashSet, Instant, Multiaddr, PUBLIC_CRYPTARCHIA_ENDPOINT,
|
||||
PUBLIC_CRYPTARCHIA_ENDPOINT_PASSWORD, PUBLIC_CRYPTARCHIA_ENDPOINT_USERNAME, PeerId,
|
||||
PublicCryptarchiaEndpointPeer, Step, StepError, StepResult, TARGET, given, info,
|
||||
nodes_converged, parse_url, poll_all_nodes_and_update_consensus_cache, resolve_literal_or_env,
|
||||
sleep, start_node, then, verify_reponsive_and_network_ready_with_timeout,
|
||||
wait_all_nodes_responive, when,
|
||||
};
|
||||
|
||||
#[given("I have public cryptarchia endpoint peers:")]
|
||||
#[when("I have public cryptarchia endpoint peers:")]
|
||||
fn step_set_public_cryptarchia_endpoint_peers(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
) -> StepResult {
|
||||
let table = step.table.as_ref().ok_or(StepError::MissingTable)?;
|
||||
|
||||
if table.rows.is_empty() {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{step}` error: public cryptarchia endpoint peers table cannot be empty"
|
||||
),
|
||||
});
|
||||
}
|
||||
if table.rows.iter().any(|row| row.len() != 3) {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{step}` error: public cryptarchia endpoint peers table must have exactly three columns"
|
||||
),
|
||||
});
|
||||
}
|
||||
if !matches!(table.rows[0][0].trim(), PUBLIC_CRYPTARCHIA_ENDPOINT)
|
||||
|| !matches!(
|
||||
table.rows[0][1].trim(),
|
||||
PUBLIC_CRYPTARCHIA_ENDPOINT_USERNAME
|
||||
)
|
||||
|| !matches!(
|
||||
table.rows[0][2].trim(),
|
||||
PUBLIC_CRYPTARCHIA_ENDPOINT_PASSWORD
|
||||
)
|
||||
{
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{step}` error: public cryptarchia endpoint peers table header row must be \
|
||||
'{PUBLIC_CRYPTARCHIA_ENDPOINT}', '{PUBLIC_CRYPTARCHIA_ENDPOINT_USERNAME}', \
|
||||
'{PUBLIC_CRYPTARCHIA_ENDPOINT_PASSWORD}'"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let mut endpoint_peers = Vec::with_capacity(table.rows.len().saturating_sub(1));
|
||||
for row in table.rows.iter().skip(1) {
|
||||
let url = parse_url(&row[0]).map_err(|e| StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{}` error: invalid public cryptarchia endpoint '{}': {e}",
|
||||
step.value, row[0]
|
||||
),
|
||||
})?;
|
||||
|
||||
let username =
|
||||
resolve_literal_or_env(row[1].trim(), "public cryptarchia endpoint username").map_err(
|
||||
|e| StepError::InvalidArgument {
|
||||
message: format!("Step `{}` error: {e}", step.value),
|
||||
},
|
||||
)?;
|
||||
if username.is_empty() {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{}` error: username cannot be empty for public cryptarchia endpoint '{}'",
|
||||
step.value, url
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let password =
|
||||
resolve_literal_or_env(row[2].trim(), "public cryptarchia endpoint password").map_err(
|
||||
|e| StepError::InvalidArgument {
|
||||
message: format!("Step `{}` error: {e}", step.value),
|
||||
},
|
||||
)?;
|
||||
if password.is_empty() {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{}` error: password cannot be empty for public cryptarchia endpoint '{}'",
|
||||
step.value, url
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
endpoint_peers.push(PublicCryptarchiaEndpointPeer {
|
||||
base_url: url,
|
||||
username,
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
if endpoint_peers.is_empty() {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{step}` error: at least one public cryptarchia endpoint peer is required"
|
||||
),
|
||||
});
|
||||
}
|
||||
world.startup.public_cryptarchia_endpoint_peers = Some(endpoint_peers);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given(expr = "all peers must be mode online after startup in {int} seconds")]
|
||||
#[when(expr = "all peers must be mode online after startup in {int} seconds")]
|
||||
const fn step_all_nodes_to_be_mode_online(world: &mut CucumberWorld, on_line_time_out: u64) {
|
||||
world.startup.require_all_peers_mode_online_at_startup =
|
||||
Some(Duration::from_secs(on_line_time_out));
|
||||
}
|
||||
|
||||
#[given("I have initial peers:")]
|
||||
#[when("I have initial peers:")]
|
||||
fn step_set_initial_peers(world: &mut CucumberWorld, step: &Step) -> StepResult {
|
||||
let table = step.table.as_ref().ok_or(StepError::MissingTable)?;
|
||||
if table.rows.is_empty() || table.rows[0].len() != 1 || table.rows[0][0] != "initial_peer" {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{}` error: initial peers table header must be `initial_peer`",
|
||||
step.value
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let mut peers = Vec::with_capacity(table.rows.len().saturating_sub(1));
|
||||
for row in table.rows.iter().skip(1) {
|
||||
let peer = row[0]
|
||||
.trim()
|
||||
.parse::<Multiaddr>()
|
||||
.map_err(|e| StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{}` error: invalid initial peer '{}': {e}",
|
||||
step.value, row[0]
|
||||
),
|
||||
})?;
|
||||
peers.push(peer);
|
||||
}
|
||||
|
||||
world.startup.initial_peers_override = Some(peers);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given("I have IBD peers:")]
|
||||
#[when("I have IBD peers:")]
|
||||
fn step_set_ibd_peers(world: &mut CucumberWorld, step: &Step) -> StepResult {
|
||||
let table = step.table.as_ref().ok_or(StepError::MissingTable)?;
|
||||
if table.rows.is_empty() || table.rows[0].len() != 1 || table.rows[0][0] != "ibd_peer" {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{}` error: IBD peers table header must be `ibd_peer`",
|
||||
step.value
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let mut peers = HashSet::with_capacity(table.rows.len().saturating_sub(1));
|
||||
for row in table.rows.iter().skip(1) {
|
||||
let peer = row[0]
|
||||
.trim()
|
||||
.parse::<PeerId>()
|
||||
.map_err(|e| StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{}` error: invalid IBD peer '{}': {e}",
|
||||
step.value, row[0]
|
||||
),
|
||||
})?;
|
||||
peers.insert(peer);
|
||||
}
|
||||
|
||||
world.startup.ibd_peers_override = Some(peers);
|
||||
world.startup.populate_ibd_peers_from_initial_peers = Some(true);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given(expr = "I start peer node {string} connected to node {string}")]
|
||||
#[when(expr = "I start peer node {string} connected to node {string}")]
|
||||
async fn step_start_manual_connected_node(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
node_name: String,
|
||||
peer_name: String,
|
||||
) -> StepResult {
|
||||
start_node(
|
||||
world,
|
||||
&step.value,
|
||||
&node_name,
|
||||
&Vec::new(),
|
||||
&[peer_name],
|
||||
false,
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[given(expr = "I immediate start peer node {string} connected to node {string}")]
|
||||
#[when(expr = "I immediate start peer node {string} connected to node {string}")]
|
||||
async fn step_immediate_start_manual_connected_node(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
node_name: String,
|
||||
peer_name: String,
|
||||
) -> StepResult {
|
||||
start_node(
|
||||
world,
|
||||
&step.value,
|
||||
&node_name,
|
||||
&Vec::new(),
|
||||
&[peer_name],
|
||||
true,
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[given(expr = "I start peer node {string} connected to node {string} and node {string}")]
|
||||
#[when(expr = "I start peer node {string} connected to node {string} and node {string}")]
|
||||
async fn step_start_manual_two_connected_nodes(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
node_name: String,
|
||||
peer_name1: String,
|
||||
peer_name2: String,
|
||||
) -> StepResult {
|
||||
start_node(
|
||||
world,
|
||||
&step.value,
|
||||
&node_name,
|
||||
&Vec::new(),
|
||||
&[peer_name1, peer_name2],
|
||||
false,
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[given(expr = "I immediate start peer node {string} connected to node {string} and node {string}")]
|
||||
#[when(expr = "I immediate start peer node {string} connected to node {string} and node {string}")]
|
||||
async fn step_immediate_start_manual_two_connected_nodes(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
node_name: String,
|
||||
peer_name1: String,
|
||||
peer_name2: String,
|
||||
) -> StepResult {
|
||||
start_node(
|
||||
world,
|
||||
&step.value,
|
||||
&node_name,
|
||||
&Vec::new(),
|
||||
&[peer_name1, peer_name2],
|
||||
true,
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "I wait for all nodes to be responsive in {int} seconds")]
|
||||
#[then(expr = "I wait for all nodes to be responsive in {int} seconds")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require the world as the first `&mut` argument"
|
||||
)]
|
||||
async fn step_wait_all_nodes_responsive(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
let cluster = world
|
||||
.cluster
|
||||
.local_cluster
|
||||
.as_ref()
|
||||
.ok_or(StepError::LogicalError {
|
||||
message: "No local cluster available".into(),
|
||||
})?;
|
||||
if let Err(e) = wait_all_nodes_responive(cluster, Duration::from_secs(time_out_seconds)).await {
|
||||
return Err(StepError::StepFail {
|
||||
message: format!("Step `{}` error: {e}", step.value),
|
||||
});
|
||||
}
|
||||
|
||||
let wait_tasks: Vec<_> = world
|
||||
.nodes_info
|
||||
.values()
|
||||
.map(|node| {
|
||||
let fut = verify_reponsive_and_network_ready_with_timeout(
|
||||
&node.started_node.client,
|
||||
&node.name,
|
||||
&node.started_node.name,
|
||||
Duration::from_secs(time_out_seconds),
|
||||
);
|
||||
let step_value = step.value.clone();
|
||||
async move {
|
||||
fut.await.map_err(|e| StepError::StepFail {
|
||||
message: format!("Step `{step_value}` error: {e}"),
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
futures::future::try_join_all(wait_tasks).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(expr = "node {string} is at height {int} in {int} seconds")]
|
||||
#[then(expr = "node {string} is at height {int} in {int} seconds")]
|
||||
async fn step_node_is_at_height(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
node_name: String,
|
||||
height: u64,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
let start = Instant::now();
|
||||
let time_out = Duration::from_secs(time_out_seconds);
|
||||
|
||||
let mut count = 0usize;
|
||||
loop {
|
||||
poll_all_nodes_and_update_consensus_cache(&step.value, &mut world.nodes_info).await?;
|
||||
let best_height = world.node_best_height(&node_name)?.unwrap_or_default();
|
||||
if best_height >= height {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Node '{node_name}' reached height {height} in {:.2?}",
|
||||
start.elapsed()
|
||||
);
|
||||
return Ok(());
|
||||
} else if count.is_multiple_of(50) {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Waiting for '{node_name}' to reach height {height} - elapsed: {:.2?}, current \
|
||||
height: {}", start.elapsed(), best_height
|
||||
);
|
||||
}
|
||||
|
||||
if start.elapsed() >= time_out {
|
||||
return Err(StepError::StepFail {
|
||||
message: format!(
|
||||
"Step `{}` error: Node '{node_name}' did not reach height {height} in {time_out_seconds} s",
|
||||
step.value
|
||||
),
|
||||
});
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[when(expr = "node {string} is exactly at height")]
|
||||
#[then(expr = "node {string} is exactly at height")]
|
||||
async fn step_node_is_exactly_at_height(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
node_name: String,
|
||||
height: u64,
|
||||
) -> StepResult {
|
||||
poll_all_nodes_and_update_consensus_cache(&step.value, &mut world.nodes_info).await?;
|
||||
let node_height = world.node_best_height(&node_name)?.unwrap_or_default();
|
||||
if node_height != height {
|
||||
return Err(StepError::StepFail {
|
||||
message: format!(
|
||||
"Step `{}` error: Node '{node_name}' is at height {node_height}, required {height}",
|
||||
step.value
|
||||
),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(expr = "all nodes converged to within {int} blocks in {int} seconds")]
|
||||
#[then(expr = "all nodes converged to within {int} blocks in {int} seconds")]
|
||||
async fn step_all_nodes_converged(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
max_diff_height: u64,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
nodes_converged(world, &step.value, None, max_diff_height, time_out_seconds).await
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use super::{
|
||||
CucumberWorld, NodeSnapshot, StepError, StepResult,
|
||||
create_snapshot_all_nodes_with_wallet_state, create_snapshot_node_with_wallet_state, given,
|
||||
prepare_wallet_snapshot_restore_if_present, then, validate_snapshot_path_component, when,
|
||||
};
|
||||
|
||||
#[given(expr = "I will create a snapshot {string} of all nodes when stopping")]
|
||||
#[when(expr = "I will create a snapshot {string} of all nodes when stopping")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_value,
|
||||
reason = "Required by cucumber expression"
|
||||
)]
|
||||
fn step_set_snapshot_all_nodes_on_stop(
|
||||
world: &mut CucumberWorld,
|
||||
snapshot_name: String,
|
||||
) -> StepResult {
|
||||
if snapshot_name.trim().is_empty() {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: "Snapshot name cannot be empty".to_owned(),
|
||||
});
|
||||
}
|
||||
validate_snapshot_path_component(&snapshot_name, "Snapshot name")?;
|
||||
let snapshot_name = snapshot_name.trim().to_owned();
|
||||
world.snapshots.save.node_state = Some(snapshot_name.clone());
|
||||
world.snapshots.save.extensions = Some(snapshot_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given(expr = "I will initialize started nodes from snapshot {string} source node {string}")]
|
||||
#[when(expr = "I will initialize started nodes from snapshot {string} source node {string}")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_value,
|
||||
reason = "Required by cucumber expression"
|
||||
)]
|
||||
fn step_set_node_snapshot_on_startup(
|
||||
world: &mut CucumberWorld,
|
||||
snapshot_name: String,
|
||||
node_name: String,
|
||||
) -> StepResult {
|
||||
validate_snapshot_path_component(&snapshot_name, "Snapshot name")?;
|
||||
validate_snapshot_path_component(&node_name, "Node name")?;
|
||||
|
||||
let snapshot_name = snapshot_name.trim().to_owned();
|
||||
world.snapshots.node_snapshot_on_startup = Some(NodeSnapshot {
|
||||
name: snapshot_name.clone(),
|
||||
node: node_name.trim().to_owned(),
|
||||
});
|
||||
world.snapshots.restore.extensions = Some(snapshot_name);
|
||||
if let Some(snapshot_name) = world.snapshots.restore.extensions.clone() {
|
||||
prepare_wallet_snapshot_restore_if_present(&snapshot_name, world)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given(expr = "I create a snapshot {string} of all nodes")]
|
||||
#[when(expr = "I create a snapshot {string} of all nodes")]
|
||||
async fn step_create_snapshot_all_nodes_now(
|
||||
world: &mut CucumberWorld,
|
||||
snapshot_name: String,
|
||||
) -> StepResult {
|
||||
create_snapshot_all_nodes_with_wallet_state(world, &snapshot_name).await
|
||||
}
|
||||
|
||||
#[given(expr = "I create a snapshot {string} of node {string}")]
|
||||
#[when(expr = "I create a snapshot {string} of node {string}")]
|
||||
#[then(expr = "I create a snapshot {string} of node {string}")]
|
||||
async fn step_create_snapshot_node_now(
|
||||
world: &mut CucumberWorld,
|
||||
snapshot_name: String,
|
||||
node_name: String,
|
||||
) -> StepResult {
|
||||
create_snapshot_node_with_wallet_state(world, &snapshot_name, &node_name).await
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use super::{
|
||||
GenesisTime, OffsetDateTime, StepError,
|
||||
genesis::{resolve_step_genesis_time, validate_genesis_time_change},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn step_relative_genesis_time_uses_now_plus_offset() {
|
||||
let now = OffsetDateTime::from_unix_timestamp(1_000).expect("valid timestamp");
|
||||
|
||||
let genesis_time = resolve_step_genesis_time("the chain starts 60 seconds from now", now, 60)
|
||||
.expect("offset should produce a valid genesis time");
|
||||
|
||||
assert_eq!(genesis_time, GenesisTime::new(1_060));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflowing_step_relative_genesis_time_is_invalid_argument() {
|
||||
let now = time::Date::MAX.midnight().assume_utc();
|
||||
|
||||
let error = resolve_step_genesis_time("the chain starts 1 seconds from now", now, 1)
|
||||
.expect_err("overflowing offset should fail");
|
||||
|
||||
assert!(matches!(error, StepError::InvalidArgument { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genesis_time_change_after_nodes_started_is_rejected() {
|
||||
let error =
|
||||
validate_genesis_time_change(Some(GenesisTime::new(1_000)), true, GenesisTime::new(1_001))
|
||||
.expect_err("genesis time should not change after nodes start");
|
||||
|
||||
assert!(
|
||||
matches!(error, StepError::LogicalError { message } if message == "cannot change genesis time after nodes have started")
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -9,7 +9,7 @@ use tracing::{info, warn};
|
||||
|
||||
use crate::cucumber::{
|
||||
error::StepError,
|
||||
steps::{TARGET, manual_nodes::utils::fetch_public_peer_consensus},
|
||||
steps::{TARGET, nodes::fetch_public_peer_consensus},
|
||||
utils::truncate_hash,
|
||||
world::PublicCryptarchiaEndpointPeer,
|
||||
};
|
||||
+1
-1
@@ -17,7 +17,7 @@ use crate::{
|
||||
error::{StepError, StepResult},
|
||||
steps::{
|
||||
TARGET,
|
||||
manual_transactions::utils::{
|
||||
transactions::utils::{
|
||||
prepare_user_wallet_transaction_submission, submit_prepared_user_wallet_transaction,
|
||||
},
|
||||
},
|
||||
@@ -0,0 +1,54 @@
|
||||
use super::{
|
||||
CucumberWorld, Duration, Instant, MANUAL_COMMAND_FILE_ENV, MANUAL_COMMAND_POLL_INTERVAL_ENV,
|
||||
Path, StepError, TARGET, env, execute_manual_command, info, sleep, take_next_command,
|
||||
};
|
||||
|
||||
#[expect(
|
||||
clippy::cognitive_complexity,
|
||||
reason = "Singular fn with multiple branches to handle different events and futures."
|
||||
)]
|
||||
pub async fn perform_manual_step_control(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
timeout_seconds: u64,
|
||||
) -> Result<(), StepError> {
|
||||
let command_file =
|
||||
env::var(MANUAL_COMMAND_FILE_ENV).map_err(|_| StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"Step `{step}` requires environment variable '{MANUAL_COMMAND_FILE_ENV}' to be set",
|
||||
),
|
||||
})?;
|
||||
let poll_interval_ms = env::var(MANUAL_COMMAND_POLL_INTERVAL_ENV)
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.unwrap_or(300);
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Manual control step started. Monitoring command file: `{command_file}`"
|
||||
);
|
||||
|
||||
let time_out = Duration::from_secs(timeout_seconds);
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < time_out {
|
||||
if let Some(command) = take_next_command(Path::new(&command_file))? {
|
||||
info!(target: TARGET, "====> manual command: {command:?}");
|
||||
if matches!(
|
||||
execute_manual_command(world, step, &command).await,
|
||||
Ok(true)
|
||||
) {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Manual command loop stopped by STOP command after {:.2?}",
|
||||
start.elapsed()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
sleep(Duration::from_millis(poll_interval_ms)).await;
|
||||
}
|
||||
}
|
||||
info!(target: TARGET, "Manual command loop stopped by tine-out after {:.2?}", start.elapsed());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
use super::{
|
||||
BTreeMap, BuildHasher, CucumberWorld, Duration, HashSet, ManualCommand, NoteId, StepError,
|
||||
TARGET, TxHash, WalletOutputState, WalletSendReadiness, WalletUtxos, all_user_wallets,
|
||||
build_cycle_fee_policy, clear_all_wallet_encumbrances, clear_wallet_encumbrances,
|
||||
create_snapshot_all_nodes_with_wallet_state, create_snapshot_node_with_wallet_state,
|
||||
current_available_utxos_for_user_wallets, drain_all_node_wallets, execute_coin_split,
|
||||
execute_coin_split_with_utxo_cache, execute_continuous_round_robin, execute_drain,
|
||||
execute_send, export_funds, extend_note_id_set, extend_tx_hash_set, handle_verify_command,
|
||||
info, log_wallet_balance, log_wallet_balances, nodes,
|
||||
prepare_ring_send_round_send_with_utxo_cache, request_faucet_funds_all_funding_wallets,
|
||||
request_faucet_funds_all_user_wallets, restart_node, sync, utils,
|
||||
validate_fee_horizon_after_wallet_batch, verify_no_duplicate_transactions,
|
||||
wait_for_all_nodes_to_be_synced_to_chain, wait_for_observed_transaction_hashes,
|
||||
};
|
||||
|
||||
pub async fn execute_manual_command(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
command: &ManualCommand,
|
||||
) -> Result<bool, StepError> {
|
||||
if matches!(command, ManualCommand::Stop) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
execute_non_stop_manual_command(world, step, command).await?;
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "Cucumber transaction shape and fee policy inputs"
|
||||
)]
|
||||
pub async fn execute_continuous_round_robin_user_wallets(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
coin_split_outputs: usize,
|
||||
coin_split_value: u64,
|
||||
num_transactions: usize,
|
||||
value: u64,
|
||||
cycles: usize,
|
||||
epochs_headroom: u32,
|
||||
) -> Result<(), StepError> {
|
||||
let command = ManualCommand::ContinuousRoundRobinUserWallets {
|
||||
coin_split_outputs,
|
||||
coin_split_value,
|
||||
num_transactions,
|
||||
value,
|
||||
cycles,
|
||||
epochs_headroom,
|
||||
};
|
||||
execute_non_stop_manual_command(world, step, &command).await
|
||||
}
|
||||
|
||||
pub async fn execute_coin_splits_all_user_wallets(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
splits_per_wallet: usize,
|
||||
outputs: usize,
|
||||
value: u64,
|
||||
) -> Result<(), StepError> {
|
||||
let mut wallet_names: Vec<_> = world
|
||||
.all_user_wallets()
|
||||
.iter()
|
||||
.map(|w| w.wallet_name.clone())
|
||||
.collect();
|
||||
if wallet_names.len() < 2 {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: "coin split for all user wallets requires at least two wallets".to_owned(),
|
||||
});
|
||||
}
|
||||
wallet_names.sort();
|
||||
let mut available_utxos = current_available_utxos_for_user_wallets(world, step).await?;
|
||||
|
||||
for wallet_name in &wallet_names {
|
||||
let best_node_info = sync::wait_wallet_send_ready(
|
||||
world,
|
||||
step,
|
||||
wallet_name,
|
||||
180,
|
||||
splits_per_wallet as u64 * outputs as u64 * value,
|
||||
WalletSendReadiness::TotalValueOnly,
|
||||
&mut available_utxos,
|
||||
&HashSet::new(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for _ in 0..splits_per_wallet {
|
||||
execute_coin_split_with_utxo_cache(
|
||||
world,
|
||||
step,
|
||||
wallet_name,
|
||||
outputs,
|
||||
value,
|
||||
Some(&best_node_info),
|
||||
&mut available_utxos,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn verify_min_outputs_all_user_wallets(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
min_outputs: usize,
|
||||
timeout_seconds: u64,
|
||||
wallet_state_type: WalletOutputState,
|
||||
) -> Result<(), StepError> {
|
||||
let mut wallet_names: Vec<_> = world
|
||||
.all_user_wallets()
|
||||
.iter()
|
||||
.map(|w| w.wallet_name.clone())
|
||||
.collect();
|
||||
wallet_names.sort();
|
||||
|
||||
for wallet_name in &wallet_names {
|
||||
utils::wait_for_wallet_output_state(
|
||||
world,
|
||||
step,
|
||||
wallet_name.clone(),
|
||||
Some(&min_outputs),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
timeout_seconds,
|
||||
wallet_state_type,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn destructure_next_wallet_command(
|
||||
command: &ManualCommand,
|
||||
) -> Result<(usize, usize, u64, u32), StepError> {
|
||||
let ManualCommand::ContinuousNextWalletUserWallets {
|
||||
cycles,
|
||||
num_transactions,
|
||||
value,
|
||||
epochs_headroom,
|
||||
} = command
|
||||
else {
|
||||
return Err(StepError::LogicalError {
|
||||
message: "expected ContinuousNextWalletUserWallets command".to_owned(),
|
||||
});
|
||||
};
|
||||
Ok((*cycles, *num_transactions, *value, *epochs_headroom))
|
||||
}
|
||||
|
||||
pub async fn execute_continuous_next_wallet_user_wallet(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
command: &ManualCommand,
|
||||
) -> Result<(), StepError> {
|
||||
execute_continuous_next_wallet_user_wallet_inner(world, step, command).await
|
||||
}
|
||||
|
||||
async fn execute_continuous_next_wallet_user_wallet_inner(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
command: &ManualCommand,
|
||||
) -> Result<(), StepError> {
|
||||
let (cycles, transactions_per_wallet, value, epochs_headroom) =
|
||||
destructure_next_wallet_command(command)?;
|
||||
let wallet_names = all_user_wallets(world)?;
|
||||
|
||||
let mut used_input_note_ids: HashSet<NoteId> = HashSet::new();
|
||||
let mut all_next_wallet_tx_hashes = HashSet::new();
|
||||
for cycle in 0..cycles {
|
||||
let mut available_utxos = current_available_utxos_for_user_wallets(world, step).await?;
|
||||
|
||||
let (cycle_tx_hashes, cycle_used_input_note_ids) = execute_ring_send_round_with_utxo_cache(
|
||||
world,
|
||||
step,
|
||||
&wallet_names,
|
||||
transactions_per_wallet,
|
||||
value,
|
||||
cycle,
|
||||
epochs_headroom,
|
||||
&mut available_utxos,
|
||||
&used_input_note_ids,
|
||||
)
|
||||
.await?;
|
||||
verify_no_duplicate_transactions(
|
||||
&cycle_tx_hashes,
|
||||
&all_next_wallet_tx_hashes,
|
||||
cycle,
|
||||
"CONTINUOUS NEXT WALLET",
|
||||
)?;
|
||||
extend_note_id_set(&mut used_input_note_ids, &cycle_used_input_note_ids);
|
||||
|
||||
verify_transactions_mined(
|
||||
world,
|
||||
step,
|
||||
&cycle_tx_hashes,
|
||||
wallet_names.len() * transactions_per_wallet,
|
||||
Some(cycle + 1),
|
||||
"CONTINUOUS NEXT WALLET",
|
||||
"D",
|
||||
)
|
||||
.await?;
|
||||
extend_tx_hash_set(&mut all_next_wallet_tx_hashes, &cycle_tx_hashes);
|
||||
}
|
||||
|
||||
let expected_total = cycles * wallet_names.len() * transactions_per_wallet;
|
||||
if all_next_wallet_tx_hashes.len() != expected_total {
|
||||
return Err(StepError::StepFail {
|
||||
message: format!(
|
||||
"CONTINUOUS NEXT WALLET submitted {} unique transaction hash(es), expected \
|
||||
{expected_total}",
|
||||
all_next_wallet_tx_hashes.len(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"CONTINUOUS NEXT WALLET scenario complete: {} unique submitted transaction(s) verified \
|
||||
across {} cycle(s)",
|
||||
all_next_wallet_tx_hashes.len(),
|
||||
cycles,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn verify_transactions_mined(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
tx_hashes: &HashSet<TxHash>,
|
||||
expected_tx_count: usize,
|
||||
cycle: Option<usize>,
|
||||
tag: &str,
|
||||
phase: &str,
|
||||
) -> Result<(), StepError> {
|
||||
if tx_hashes.len() != expected_tx_count {
|
||||
return Err(StepError::StepFail {
|
||||
message: format!(
|
||||
"{tag}{} submitted {} transaction hash(es), expected {expected_tx_count}",
|
||||
cycle.map_or_else(String::new, |cycle| format!(" cycle {cycle}")),
|
||||
tx_hashes.len(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"{tag}{} {phase}: Wait for {} submitted transaction hashes to be observed in chain blocks",
|
||||
cycle.map_or_else(String::new, |cycle| format!(" cycle {cycle}")),
|
||||
tx_hashes.len(),
|
||||
);
|
||||
|
||||
wait_for_observed_transaction_hashes(world, step, tx_hashes, Duration::from_mins(10)).await
|
||||
}
|
||||
|
||||
pub(super) fn log_phase_counts(
|
||||
tag: &str,
|
||||
cycle: usize,
|
||||
phase: &str,
|
||||
kind: &str,
|
||||
counts: &BTreeMap<String, usize>,
|
||||
) {
|
||||
let counts = counts
|
||||
.iter()
|
||||
.map(|(wallet, count)| format!("{wallet}={count}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
info!(
|
||||
target: TARGET,
|
||||
"{tag} cycle {} {phase}: {kind} tx counts by sender wallet: {counts}",
|
||||
cycle + 1,
|
||||
);
|
||||
}
|
||||
|
||||
#[expect(clippy::too_many_arguments, reason = "Need all args")]
|
||||
async fn execute_ring_send_round_with_utxo_cache<S: BuildHasher + Sync>(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
wallet_names: &[String],
|
||||
transactions_per_wallet: usize,
|
||||
value: u64,
|
||||
cycle: usize,
|
||||
epochs_headroom: u32,
|
||||
available_utxos: &mut WalletUtxos,
|
||||
used_input_note_ids: &HashSet<NoteId, S>,
|
||||
) -> Result<(HashSet<TxHash>, HashSet<NoteId>), StepError> {
|
||||
let policy = build_cycle_fee_policy(world, step, &wallet_names[0], epochs_headroom).await?;
|
||||
|
||||
let mut signed_submissions = Vec::with_capacity(wallet_names.len() * transactions_per_wallet);
|
||||
let mut prepared_counts = BTreeMap::new();
|
||||
|
||||
for i in 0..wallet_names.len() {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"CONTINUOUS NEXT WALLET cycle {} A: Await funds",
|
||||
cycle + 1
|
||||
);
|
||||
let from = &wallet_names[i];
|
||||
let to = &wallet_names[(i + 1) % wallet_names.len()];
|
||||
|
||||
let required_available = transactions_per_wallet as u64 * value;
|
||||
sync::wait_wallet_send_ready(
|
||||
world,
|
||||
step,
|
||||
from,
|
||||
180,
|
||||
required_available,
|
||||
WalletSendReadiness::EligibleUtxoBatch {
|
||||
min_required_outputs: transactions_per_wallet,
|
||||
min_value_per_transaction: value,
|
||||
},
|
||||
available_utxos,
|
||||
used_input_note_ids,
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"CONTINUOUS NEXT WALLET cycle {} B: Prepare transactions to next wallet concurrently",
|
||||
cycle + 1
|
||||
);
|
||||
let mut prepared = prepare_ring_send_round_send_with_utxo_cache(
|
||||
world,
|
||||
step,
|
||||
transactions_per_wallet,
|
||||
value,
|
||||
from,
|
||||
to,
|
||||
available_utxos,
|
||||
Some(policy.horizon.ceiling_prices.clone()),
|
||||
policy.priority_fee_percent,
|
||||
)
|
||||
.await?;
|
||||
prepared_counts.insert(from.clone(), prepared.len());
|
||||
validate_fee_horizon_after_wallet_batch(world, &policy, from, prepared.len()).await?;
|
||||
signed_submissions.append(&mut prepared);
|
||||
}
|
||||
|
||||
let mut cycle_used_input_note_ids: HashSet<NoteId> = HashSet::new();
|
||||
for submission in &signed_submissions {
|
||||
extend_note_id_set(
|
||||
&mut cycle_used_input_note_ids,
|
||||
&submission.reserved_inputs().input_note_ids_list(),
|
||||
);
|
||||
}
|
||||
|
||||
log_phase_counts(
|
||||
"CONTINUOUS NEXT WALLET",
|
||||
cycle,
|
||||
"C",
|
||||
"prepared",
|
||||
&prepared_counts,
|
||||
);
|
||||
|
||||
let submitted_hashes = utils::submit_signed_user_wallet_submissions_concurrently(
|
||||
world,
|
||||
signed_submissions,
|
||||
Some(&policy),
|
||||
)
|
||||
.await?;
|
||||
let mut submitted_counts = BTreeMap::new();
|
||||
for (sender, _) in &submitted_hashes {
|
||||
*submitted_counts.entry(sender.clone()).or_insert(0usize) += 1;
|
||||
}
|
||||
log_phase_counts(
|
||||
"CONTINUOUS NEXT WALLET",
|
||||
cycle,
|
||||
"D",
|
||||
"submitted",
|
||||
&submitted_counts,
|
||||
);
|
||||
let cycle_tx_hashes = submitted_hashes
|
||||
.into_iter()
|
||||
.map(|(_, tx_hash)| tx_hash)
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
Ok((cycle_tx_hashes, cycle_used_input_note_ids))
|
||||
}
|
||||
|
||||
#[expect(clippy::too_many_lines, reason = "Test function.")]
|
||||
async fn execute_non_stop_manual_command(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
command: &ManualCommand,
|
||||
) -> Result<(), StepError> {
|
||||
match command {
|
||||
ManualCommand::CreateSnapshotAllNodes { snapshot_name } => {
|
||||
create_snapshot_all_nodes_with_wallet_state(world, snapshot_name).await
|
||||
}
|
||||
ManualCommand::CreateSnapshotNode {
|
||||
snapshot_name,
|
||||
node_name,
|
||||
} => create_snapshot_node_with_wallet_state(world, snapshot_name, node_name).await,
|
||||
ManualCommand::CoinSplit {
|
||||
wallet,
|
||||
outputs,
|
||||
value,
|
||||
} => execute_coin_split(world, step, wallet, *outputs, *value)
|
||||
.await
|
||||
.map(|_| ()),
|
||||
ManualCommand::Verify { .. } => handle_verify_command(world, step, command).await,
|
||||
ManualCommand::WalletBalance { wallet_name } => {
|
||||
log_wallet_balance(world, step, wallet_name).await
|
||||
}
|
||||
ManualCommand::WalletBalanceAllUserWallets => {
|
||||
log_wallet_balances(world, step, world.all_user_wallets()).await
|
||||
}
|
||||
ManualCommand::WalletBalanceAllFundingWallets => {
|
||||
log_wallet_balances(world, step, world.all_node_wallets()).await
|
||||
}
|
||||
ManualCommand::WalletBalanceAllWallets => {
|
||||
let mut wallets = world.all_user_wallets();
|
||||
wallets.extend(world.all_node_wallets());
|
||||
|
||||
log_wallet_balances(world, step, wallets).await
|
||||
}
|
||||
ManualCommand::ExportFunds {
|
||||
wallet_name,
|
||||
value,
|
||||
output_path,
|
||||
include_secret,
|
||||
} => {
|
||||
export_funds(
|
||||
world,
|
||||
step,
|
||||
wallet_name,
|
||||
*value,
|
||||
output_path,
|
||||
*include_secret,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ManualCommand::ClearEncumbrances { wallet_name } => {
|
||||
clear_wallet_encumbrances(world, step, wallet_name)
|
||||
}
|
||||
ManualCommand::ClearEncumbrancesAllWallets => clear_all_wallet_encumbrances(world, step),
|
||||
ManualCommand::Send {
|
||||
num_transactions,
|
||||
value,
|
||||
from,
|
||||
to,
|
||||
} => execute_send(world, step, *num_transactions, *value, from, to).await,
|
||||
ManualCommand::Drain { from, to } => execute_drain(world, step, from, to).await,
|
||||
ManualCommand::DrainAllNodeWallets { node_name, to } => {
|
||||
drain_all_node_wallets(world, node_name, to).await
|
||||
}
|
||||
ManualCommand::ContinuousRoundRobinUserWallets { .. } => {
|
||||
execute_continuous_round_robin(world, step, command).await
|
||||
}
|
||||
ManualCommand::FaucetFundsAllUserWallets { rounds } => {
|
||||
request_faucet_funds_all_user_wallets(world, step, *rounds)
|
||||
}
|
||||
ManualCommand::FaucetFundsAllFundingWallets { rounds } => {
|
||||
request_faucet_funds_all_funding_wallets(world, step, *rounds)
|
||||
}
|
||||
ManualCommand::RestartNode { node_name } => restart_node(world, step, node_name).await,
|
||||
ManualCommand::CryptarchiaInfoAllNodes => {
|
||||
nodes::get_cryptarchia_info_all_nodes(world, step).await;
|
||||
Ok(())
|
||||
}
|
||||
ManualCommand::WaitAllNodesSyncedToChain => {
|
||||
wait_for_all_nodes_to_be_synced_to_chain(world, step).await
|
||||
}
|
||||
ManualCommand::CoinSplitAllUserWallets {
|
||||
splits_per_wallet,
|
||||
outputs,
|
||||
value,
|
||||
} => {
|
||||
execute_coin_splits_all_user_wallets(world, step, *splits_per_wallet, *outputs, *value)
|
||||
.await
|
||||
}
|
||||
ManualCommand::VerifyMinAvailableOutputsAllUserWallets {
|
||||
min_outputs,
|
||||
timeout_seconds,
|
||||
} => {
|
||||
verify_min_outputs_all_user_wallets(
|
||||
world,
|
||||
step,
|
||||
*min_outputs,
|
||||
*timeout_seconds,
|
||||
WalletOutputState::Available,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ManualCommand::ContinuousNextWalletUserWallets { .. } => {
|
||||
execute_continuous_next_wallet_user_wallet(world, step, command).await
|
||||
}
|
||||
ManualCommand::Stop => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use super::{
|
||||
CucumberWorld, GasPrices, MAX_TEST_EPOCH_HEADROOM, StepError, TARGET, TransactionFeePolicy,
|
||||
build_fee_horizon, get_best_node_info, info,
|
||||
};
|
||||
|
||||
pub async fn build_cycle_fee_policy(
|
||||
world: &CucumberWorld,
|
||||
step: &str,
|
||||
representative_wallet: &str,
|
||||
epochs_headroom: u32,
|
||||
) -> Result<TransactionFeePolicy, StepError> {
|
||||
if epochs_headroom > MAX_TEST_EPOCH_HEADROOM {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!("epochs headroom per cycle must be at most {MAX_TEST_EPOCH_HEADROOM}"),
|
||||
});
|
||||
}
|
||||
|
||||
let best = get_best_node_info(world, representative_wallet, None).await?;
|
||||
let best_node = best.best_node_for_wallet(world, representative_wallet)?;
|
||||
let client = world.resolve_node_http_client(&best_node)?;
|
||||
let consensus = client
|
||||
.consensus_info()
|
||||
.await
|
||||
.map_err(|source| StepError::StepFail {
|
||||
message: format!("Step `{step}` error: consensus query failed: {source}"),
|
||||
})?;
|
||||
let tip = consensus.cryptarchia_info.tip;
|
||||
let prices = client
|
||||
.gas_prices(Some(tip))
|
||||
.await
|
||||
.map_err(|source| StepError::StepFail {
|
||||
message: format!("Step `{step}` error: gas prices query failed: {source}"),
|
||||
})?;
|
||||
if prices.tip != tip {
|
||||
return Err(StepError::StepFail {
|
||||
message: format!(
|
||||
"Step `{step}` error: gas prices response referenced tip {:?}, requested {:?}",
|
||||
prices.tip, tip
|
||||
),
|
||||
});
|
||||
}
|
||||
let live_prices = GasPrices {
|
||||
execution_base_gas_price: prices.execution_base_gas_price,
|
||||
storage_gas_price: prices.storage_gas_price,
|
||||
};
|
||||
let horizon = build_fee_horizon(
|
||||
tip,
|
||||
u64::from(consensus.cryptarchia_info.slot),
|
||||
world.chain.slots_per_epoch,
|
||||
epochs_headroom,
|
||||
live_prices,
|
||||
)
|
||||
.map_err(|source| StepError::LogicalError {
|
||||
message: format!("failed to build transaction fee horizon: {source}"),
|
||||
})?;
|
||||
let policy = TransactionFeePolicy::new(horizon).map_err(|source| StepError::LogicalError {
|
||||
message: format!("failed to build transaction fee policy: {source}"),
|
||||
})?;
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Cycle fee horizon: prepared tip {:?}, slot {:?}, epoch {:?}, valid through {:?}, execution {}->{}, storage {}->{}, priority reserve {}%, representative wallet `{representative_wallet}`",
|
||||
policy.horizon.prepared_at_tip,
|
||||
consensus.cryptarchia_info.slot,
|
||||
policy.horizon.prepared_at_epoch,
|
||||
policy.horizon.valid_through_epoch,
|
||||
policy.horizon.live_prices.execution_base_gas_price.into_inner(),
|
||||
policy.horizon.ceiling_prices.execution_base_gas_price.into_inner(),
|
||||
policy.horizon.live_prices.storage_gas_price.into_inner(),
|
||||
policy.horizon.ceiling_prices.storage_gas_price.into_inner(),
|
||||
policy.priority_fee_percent,
|
||||
);
|
||||
Ok(policy)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! This module executes manual commands for Cucumber scenarios.
|
||||
//!
|
||||
//! External command controller:
|
||||
//! - Set `CUCUMBER_MANUAL_COMMAND_FILE=/tmp/cucumber-manual-commands.txt`.
|
||||
//! - Start the scenario.
|
||||
//! - Prepare the command file beforehand, or append commands while the test
|
||||
//! runs.
|
||||
//!
|
||||
//! Supported commands (one per line):
|
||||
//!
|
||||
//! ```text
|
||||
//! COIN_SPLIT, wallet '<wallet_name>', outputs <count>, value <amount>
|
||||
//! VERIFY, wallet '<wallet_name>', outputs <count>, time_out <duration_seconds>
|
||||
//! BALANCE, wallet '<wallet_name>'
|
||||
//! EXPORT_FUNDS, wallet '<wallet_name>', value <amount>, output '<path>', include_secret true|false
|
||||
//! BALANCE_ALL_WALLETS
|
||||
//! BALANCE_ALL_USER_WALLETS
|
||||
//! BALANCE_ALL_FUNDING_WALLETS
|
||||
//! CLEAR_ENCUMBRANCES, wallet '<wallet_name>'
|
||||
//! CLEAR_ENCUMBRANCES_ALL_WALLETS
|
||||
//! SEND, num_transactions <count>, value <amount>, from '<wallet_name>', to '<wallet_name>'
|
||||
//! DRAIN, from '<wallet_name>', to '<wallet_name>'
|
||||
//! DRAIN_ALL_NODE_WALLETS, node_name '<node_name>', to '<wallet_name>'
|
||||
//! VERIFY_MAX, wallet '<wallet_name>', wallet_state_type 'on-chain'/'encumbered'/'available', outputs <count>, value 14000, time_out <duration_seconds>
|
||||
//! VERIFY_MIN, wallet '<wallet_name>', wallet_state_type 'on-chain'/'encumbered'/'available', outputs <count>, value 14000, time_out <duration_seconds>
|
||||
//! CONTINUOUS_ROUND_ROBIN_USER_WALLETS, coin_split_outputs <count>, coin_split_value <amount>, num_transactions <count>, value <amount>, cycles <count>, epochs_headroom <count>
|
||||
//! COIN_SPLIT_ALL_USER_WALLETS, splits_per_wallet <count>, outputs <count>, value <amount>
|
||||
//! VERIFY_MIN_AVAILABLE_OUTPUTS_ALL_USER_WALLETS, min_outputs <count>, timeout_seconds <duration_seconds>
|
||||
//! CONTINUOUS_NEXT_WALLET_USER_WALLETS, cycles <count>, num_transactions <count>, value <amount>, epochs_headroom <count>
|
||||
//! FAUCET_ALL_USER_WALLETS, rounds <count>
|
||||
//! FAUCET_ALL_FUNDING_WALLETS, rounds <count>
|
||||
//! CREATE_SNAPSHOT_ALL_NODES, snapshot_name '<snapshot_name>'
|
||||
//! CREATE_SNAPSHOT_NODE, snapshot_name '<snapshot_name>', node_name '<node_name>'
|
||||
//! RESTART_NODE, node_name '<node_name>'
|
||||
//! CRYPTARCHIA_INFO_ALL_NODES
|
||||
//! WAIT_ALL_NODES_SYNCED_TO_CHAIN
|
||||
//! STOP
|
||||
//! ```
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, HashSet},
|
||||
env, fs,
|
||||
hash::BuildHasher,
|
||||
num::NonZero,
|
||||
path::Path,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use lb_core::mantle::{
|
||||
NoteId, Utxo,
|
||||
transactions::{GasPrices, hash::TxHash},
|
||||
};
|
||||
use lb_key_management_system_service::keys::ZkPublicKey;
|
||||
use lb_wallet::WalletError;
|
||||
use serde::Serialize;
|
||||
use tokio::time::{Instant, sleep};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{
|
||||
common::wallet::{TransactionFeePolicy, WalletStateView, WalletUtxos, build_fee_horizon},
|
||||
cucumber::{
|
||||
error::{StepError, StepResult},
|
||||
steps::{
|
||||
TARGET, nodes,
|
||||
nodes::{
|
||||
create_snapshot_all_nodes_with_wallet_state,
|
||||
create_snapshot_node_with_wallet_state, restart_node,
|
||||
wait_for_all_nodes_to_be_synced_to_chain,
|
||||
},
|
||||
transactions::{
|
||||
drain_wallets::{drain_all_node_wallets, drain_node_wallet, drain_user_wallet},
|
||||
manual_control::parsing::{ManualCommand, take_next_command},
|
||||
utils,
|
||||
utils::{BestNodeInfo, WalletOutputState, extend_note_id_set, extend_tx_hash_set},
|
||||
},
|
||||
},
|
||||
wallet::{
|
||||
best_node::get_best_node_info,
|
||||
checks::wait_for_observed_transaction_hashes,
|
||||
submissions::{SignedUserWalletSubmission, validate_fee_horizon_after_wallet_batch},
|
||||
sync,
|
||||
sync::{WalletSendReadiness, current_available_utxos_for_user_wallets},
|
||||
},
|
||||
world::{CucumberWorld, WalletInfo, WalletType},
|
||||
},
|
||||
};
|
||||
|
||||
const MANUAL_COMMAND_FILE_ENV: &str = "CUCUMBER_MANUAL_COMMAND_FILE";
|
||||
const MANUAL_COMMAND_POLL_INTERVAL_ENV: &str = "CUCUMBER_MANUAL_COMMAND_POLL_INTERVAL_MS";
|
||||
const MAX_TEST_EPOCH_HEADROOM: u32 = 16;
|
||||
|
||||
mod control;
|
||||
mod dispatch;
|
||||
mod fee_policy;
|
||||
mod round_robin;
|
||||
mod transactions;
|
||||
mod wallet_state;
|
||||
|
||||
pub use control::perform_manual_step_control;
|
||||
pub use dispatch::{
|
||||
execute_coin_splits_all_user_wallets, execute_continuous_next_wallet_user_wallet,
|
||||
execute_continuous_round_robin_user_wallets, execute_manual_command,
|
||||
verify_min_outputs_all_user_wallets,
|
||||
};
|
||||
use dispatch::{log_phase_counts, verify_transactions_mined};
|
||||
pub use fee_policy::build_cycle_fee_policy;
|
||||
use round_robin::{
|
||||
all_user_wallets, execute_continuous_round_robin, verify_no_duplicate_transactions,
|
||||
};
|
||||
use transactions::{
|
||||
execute_coin_split, execute_coin_split_with_utxo_cache, execute_send, handle_verify_command,
|
||||
prepare_coin_splits_all_wallets_with_utxo_cache, prepare_ring_send_round_send_with_utxo_cache,
|
||||
request_faucet_funds_all_funding_wallets, request_faucet_funds_all_user_wallets,
|
||||
};
|
||||
pub use wallet_state::log_wallet_balances;
|
||||
use wallet_state::{
|
||||
clear_all_wallet_encumbrances, clear_wallet_encumbrances, execute_drain, export_funds,
|
||||
log_wallet_balance,
|
||||
};
|
||||
@@ -0,0 +1,537 @@
|
||||
use super::{
|
||||
BTreeMap, BuildHasher, CucumberWorld, Duration, GasPrices, HashSet, Instant, ManualCommand,
|
||||
NoteId, SignedUserWalletSubmission, StepError, TARGET, TransactionFeePolicy, TxHash,
|
||||
WalletSendReadiness, WalletUtxos, build_cycle_fee_policy, extend_note_id_set,
|
||||
extend_tx_hash_set, get_best_node_info, info, log_phase_counts,
|
||||
prepare_coin_splits_all_wallets_with_utxo_cache, sleep, sync, utils,
|
||||
validate_fee_horizon_after_wallet_batch, verify_transactions_mined,
|
||||
wait_for_observed_transaction_hashes, warn,
|
||||
};
|
||||
|
||||
fn destructure_round_robin_command(
|
||||
command: &ManualCommand,
|
||||
) -> Result<(usize, u64, usize, u64, usize, u32), StepError> {
|
||||
let ManualCommand::ContinuousRoundRobinUserWallets {
|
||||
coin_split_outputs,
|
||||
coin_split_value,
|
||||
num_transactions,
|
||||
value,
|
||||
cycles,
|
||||
epochs_headroom,
|
||||
} = command
|
||||
else {
|
||||
return Err(StepError::LogicalError {
|
||||
message: "expected ContinuousRoundRobinUserWallets command".to_owned(),
|
||||
});
|
||||
};
|
||||
Ok((
|
||||
*coin_split_outputs,
|
||||
*coin_split_value,
|
||||
*num_transactions,
|
||||
*value,
|
||||
*cycles,
|
||||
*epochs_headroom,
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn all_user_wallets(world: &CucumberWorld) -> Result<Vec<String>, StepError> {
|
||||
let mut wallet_names = world
|
||||
.all_user_wallets()
|
||||
.iter()
|
||||
.map(|w| w.wallet_name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
if wallet_names.len() < 2 {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: "This command requires at least two user wallets".to_owned(),
|
||||
});
|
||||
}
|
||||
wallet_names.sort();
|
||||
Ok(wallet_names)
|
||||
}
|
||||
|
||||
#[expect(clippy::too_many_arguments, reason = "Transaction preparation inputs")]
|
||||
async fn prepare_and_submit_round_robin_transactions(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
cycle: usize,
|
||||
wallet_names: &[String],
|
||||
num_transactions: usize,
|
||||
value: u64,
|
||||
available_utxos: &mut WalletUtxos,
|
||||
epochs_headroom: u32,
|
||||
) -> Result<(HashSet<TxHash>, HashSet<NoteId>), StepError> {
|
||||
let policy = build_cycle_fee_policy(world, step, &wallet_names[0], epochs_headroom).await?;
|
||||
|
||||
let mut signed_submissions = Vec::with_capacity(wallet_names.len() * num_transactions);
|
||||
let mut prepared_counts = BTreeMap::new();
|
||||
for sender in wallet_names {
|
||||
let recipients = recipient_wallets(wallet_names, sender)?;
|
||||
let mut prepared = prepare_round_robin_with_utxo_cache(
|
||||
world,
|
||||
step,
|
||||
sender,
|
||||
&recipients,
|
||||
num_transactions,
|
||||
value,
|
||||
available_utxos,
|
||||
Some(policy.horizon.ceiling_prices.clone()),
|
||||
policy.priority_fee_percent,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| StepError::StepFail {
|
||||
message: format!(
|
||||
"CONTINUOUS ROUND ROBIN cycle {} failed to prepare transactions for sender \
|
||||
'{sender}': {e}",
|
||||
cycle + 1,
|
||||
),
|
||||
})?;
|
||||
|
||||
prepared_counts.insert(sender.clone(), prepared.len());
|
||||
validate_fee_horizon_after_wallet_batch(world, &policy, sender, prepared.len()).await?;
|
||||
signed_submissions.append(&mut prepared);
|
||||
}
|
||||
|
||||
let mut cycle_used_input_note_ids: HashSet<NoteId> = HashSet::new();
|
||||
for submission in &signed_submissions {
|
||||
extend_note_id_set(
|
||||
&mut cycle_used_input_note_ids,
|
||||
&submission.reserved_inputs().input_note_ids_list(),
|
||||
);
|
||||
}
|
||||
|
||||
log_phase_counts(
|
||||
"CONTINUOUS ROUND ROBIN",
|
||||
cycle,
|
||||
"D",
|
||||
"prepared",
|
||||
&prepared_counts,
|
||||
);
|
||||
|
||||
let submitted_hashes = utils::submit_signed_user_wallet_submissions_concurrently(
|
||||
world,
|
||||
signed_submissions,
|
||||
Some(&policy),
|
||||
)
|
||||
.await?;
|
||||
let mut submitted_counts = BTreeMap::new();
|
||||
for (sender, _) in &submitted_hashes {
|
||||
*submitted_counts.entry(sender.clone()).or_insert(0usize) += 1;
|
||||
}
|
||||
log_phase_counts(
|
||||
"CONTINUOUS ROUND ROBIN",
|
||||
cycle,
|
||||
"D",
|
||||
"submitted",
|
||||
&submitted_counts,
|
||||
);
|
||||
|
||||
let cycle_tx_hashes = submitted_hashes
|
||||
.into_iter()
|
||||
.map(|(_, tx_hash)| tx_hash)
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
Ok((cycle_tx_hashes, cycle_used_input_note_ids))
|
||||
}
|
||||
|
||||
/// Manages the coin split process for a round robin cycle, including performing
|
||||
/// the coin splits, waiting for them to be mined, and verifying that the
|
||||
/// transactions were successfully mined. Returns a set of used input note IDs
|
||||
/// from the coin split transactions. Note: This function needs a readiness
|
||||
/// prepared UTXO cache.
|
||||
#[expect(clippy::too_many_arguments, reason = "Round-robin split inputs")]
|
||||
async fn manage_round_robin_coin_splits_with_utxo_cache(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
cycle: usize,
|
||||
wallet_names: &[String],
|
||||
coin_split_outputs: usize,
|
||||
coin_split_value: u64,
|
||||
epochs_headroom: u32,
|
||||
available_utxos: &mut WalletUtxos,
|
||||
) -> Result<HashSet<NoteId>, StepError> {
|
||||
let policy = build_cycle_fee_policy(world, step, &wallet_names[0], epochs_headroom).await?;
|
||||
|
||||
let (split_tx_hashes, split_used_input_note_ids) =
|
||||
perform_coin_splits_for_round_robin_with_utxo_cache(
|
||||
world,
|
||||
step,
|
||||
wallet_names,
|
||||
coin_split_outputs,
|
||||
coin_split_value,
|
||||
cycle,
|
||||
available_utxos,
|
||||
&policy,
|
||||
)
|
||||
.await?;
|
||||
|
||||
wait_for_n_blocks_or_warn(world, step, wallet_names, Duration::from_mins(3), 2, cycle).await?;
|
||||
|
||||
verify_transactions_mined(
|
||||
world,
|
||||
step,
|
||||
&split_tx_hashes,
|
||||
wallet_names.len(),
|
||||
Some(cycle + 1),
|
||||
"CONTINUOUS ROUND ROBIN",
|
||||
"B",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(split_used_input_note_ids)
|
||||
}
|
||||
|
||||
async fn refresh_round_robin_sender_cache_entries<S: BuildHasher + Sync>(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
wallet_names: &[String],
|
||||
required_available: u64,
|
||||
readiness: WalletSendReadiness,
|
||||
available_utxos: &mut WalletUtxos,
|
||||
used_input_note_ids: &HashSet<NoteId, S>,
|
||||
) -> Result<(), StepError> {
|
||||
for sender in wallet_names {
|
||||
sync::wait_wallet_send_ready(
|
||||
world,
|
||||
step,
|
||||
sender,
|
||||
180,
|
||||
required_available,
|
||||
readiness,
|
||||
available_utxos,
|
||||
used_input_note_ids,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn verify_no_duplicate_transactions(
|
||||
cycle_tx_hashes: &HashSet<TxHash>,
|
||||
all_tx_hashes: &HashSet<TxHash>,
|
||||
cycle: usize,
|
||||
scenario_tag: &str,
|
||||
) -> Result<(), StepError> {
|
||||
let duplicate_hashes = cycle_tx_hashes
|
||||
.intersection(all_tx_hashes)
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
if duplicate_hashes.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(StepError::StepFail {
|
||||
message: format!(
|
||||
"{scenario_tag} cycle {} prepared/submitted {} duplicate transaction hash(es) from \
|
||||
previous cycles",
|
||||
cycle + 1,
|
||||
duplicate_hashes.len(),
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::too_many_lines, reason = "Test function.")]
|
||||
#[expect(
|
||||
clippy::cognitive_complexity,
|
||||
reason = "This function has multiple steps that are logically distinct."
|
||||
)]
|
||||
pub(super) async fn execute_continuous_round_robin(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
command: &ManualCommand,
|
||||
) -> Result<(), StepError> {
|
||||
let (coin_split_outputs, coin_split_value, num_transactions, value, cycles, epochs_headroom) =
|
||||
destructure_round_robin_command(command)?;
|
||||
let wallet_names = all_user_wallets(world)?;
|
||||
|
||||
let mut used_input_note_ids: HashSet<NoteId> = HashSet::new();
|
||||
let mut all_round_robin_tx_hashes = HashSet::new();
|
||||
let mut available_utxos = WalletUtxos::new();
|
||||
for cycle in 0..cycles {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"CONTINUOUS ROUND ROBIN cycle {} A: Wait for available coin-split funds all wallets",
|
||||
cycle + 1
|
||||
);
|
||||
|
||||
refresh_round_robin_sender_cache_entries(
|
||||
world,
|
||||
step,
|
||||
&wallet_names,
|
||||
coin_split_outputs as u64 * coin_split_value,
|
||||
WalletSendReadiness::TotalValueOnly,
|
||||
&mut available_utxos,
|
||||
&used_input_note_ids,
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"CONTINUOUS ROUND ROBIN cycle {} B: Perform coin splits all wallets and wait mined",
|
||||
cycle + 1
|
||||
);
|
||||
|
||||
let split_used_input_note_ids = manage_round_robin_coin_splits_with_utxo_cache(
|
||||
world,
|
||||
step,
|
||||
cycle,
|
||||
&wallet_names,
|
||||
coin_split_outputs,
|
||||
coin_split_value,
|
||||
epochs_headroom,
|
||||
&mut available_utxos,
|
||||
)
|
||||
.await?;
|
||||
extend_note_id_set(&mut used_input_note_ids, &split_used_input_note_ids);
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"CONTINUOUS ROUND ROBIN cycle {} C: Wait all wallets ready with coin-split outputs",
|
||||
cycle + 1
|
||||
);
|
||||
|
||||
refresh_round_robin_sender_cache_entries(
|
||||
world,
|
||||
step,
|
||||
&wallet_names,
|
||||
num_transactions as u64 * value,
|
||||
WalletSendReadiness::EligibleUtxoBatch {
|
||||
min_required_outputs: num_transactions,
|
||||
min_value_per_transaction: value,
|
||||
},
|
||||
&mut available_utxos,
|
||||
&used_input_note_ids,
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"CONTINUOUS ROUND ROBIN cycle {} D: Prepare and send all round robin transactions",
|
||||
cycle + 1
|
||||
);
|
||||
|
||||
let (cycle_tx_hashes, cycle_used_input_note_ids) =
|
||||
prepare_and_submit_round_robin_transactions(
|
||||
world,
|
||||
step,
|
||||
cycle,
|
||||
&wallet_names,
|
||||
num_transactions,
|
||||
value,
|
||||
&mut available_utxos,
|
||||
epochs_headroom,
|
||||
)
|
||||
.await?;
|
||||
verify_no_duplicate_transactions(
|
||||
&cycle_tx_hashes,
|
||||
&all_round_robin_tx_hashes,
|
||||
cycle,
|
||||
"CONTINUOUS ROUND ROBIN",
|
||||
)?;
|
||||
extend_note_id_set(&mut used_input_note_ids, &cycle_used_input_note_ids);
|
||||
|
||||
// Assert transaction count
|
||||
verify_transactions_mined(
|
||||
world,
|
||||
step,
|
||||
&cycle_tx_hashes,
|
||||
wallet_names.len() * num_transactions,
|
||||
Some(cycle + 1),
|
||||
"CONTINUOUS ROUND ROBIN",
|
||||
"E",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Collect hashes for final drain verification
|
||||
extend_tx_hash_set(&mut all_round_robin_tx_hashes, &cycle_tx_hashes);
|
||||
}
|
||||
|
||||
// Final drain: verify submitted D-phase transaction hashes are observed in
|
||||
// chain blocks.
|
||||
info!(
|
||||
target: TARGET,
|
||||
"CONTINUOUS ROUND ROBIN final: Verify {} submitted round-robin transaction(s) were observed \
|
||||
in chain blocks",
|
||||
all_round_robin_tx_hashes.len(),
|
||||
);
|
||||
|
||||
wait_for_observed_transaction_hashes(
|
||||
world,
|
||||
step,
|
||||
&all_round_robin_tx_hashes,
|
||||
Duration::from_mins(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"CONTINUOUS ROUND ROBIN scenario complete: {} transaction(s) verified from observed chain block transaction hashes across {} cycle(s)",
|
||||
all_round_robin_tx_hashes.len(),
|
||||
cycles
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_n_blocks_or_warn(
|
||||
world: &CucumberWorld,
|
||||
step: &str,
|
||||
wallet_names: &[String],
|
||||
time_out: Duration,
|
||||
blocks_to_wait: u64,
|
||||
cycle: usize,
|
||||
) -> Result<(), StepError> {
|
||||
if wallet_names.is_empty() {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: "No wallet names provided for wait_for_n_blocks".to_owned(),
|
||||
});
|
||||
}
|
||||
let mut last_msg = String::new();
|
||||
let best_node_info = get_best_node_info(world, &wallet_names[0], Some(&mut last_msg)).await?;
|
||||
let node = world
|
||||
.resolve_node_http_client(&best_node_info.best_node_for_wallet(world, &wallet_names[0])?)?;
|
||||
let start_height = node.consensus_info().await?.cryptarchia_info.height;
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
let best_node_info =
|
||||
get_best_node_info(world, &wallet_names[0], Some(&mut last_msg)).await?;
|
||||
let node = world.resolve_node_http_client(
|
||||
&best_node_info.best_node_for_wallet(world, &wallet_names[0])?,
|
||||
)?;
|
||||
let height = node.consensus_info().await?.cryptarchia_info.height;
|
||||
if height >= start_height + blocks_to_wait {
|
||||
return Ok(());
|
||||
}
|
||||
if start.elapsed() > time_out {
|
||||
warn!(
|
||||
target: TARGET,
|
||||
"Step `{step}` cycle {}: Chain could not grow by {blocks_to_wait} blocks in {time_out:.2?}",
|
||||
cycle + 1
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::too_many_arguments, reason = "Round-robin split inputs")]
|
||||
async fn perform_coin_splits_for_round_robin_with_utxo_cache(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
wallet_names: &[String],
|
||||
coin_split_outputs: usize,
|
||||
coin_split_value: u64,
|
||||
cycle: usize,
|
||||
available_utxos: &mut WalletUtxos,
|
||||
policy: &TransactionFeePolicy,
|
||||
) -> Result<(HashSet<TxHash>, HashSet<NoteId>), StepError> {
|
||||
info!(target: TARGET, "CONTINUOUS ROUND ROBIN cycle {} B: Perform coin splits all wallets", cycle + 1);
|
||||
|
||||
let (signed_submissions, prepared_split_counts) =
|
||||
prepare_coin_splits_all_wallets_with_utxo_cache(
|
||||
world,
|
||||
step,
|
||||
wallet_names,
|
||||
coin_split_outputs,
|
||||
coin_split_value,
|
||||
available_utxos,
|
||||
Some(policy.horizon.ceiling_prices.clone()),
|
||||
policy.priority_fee_percent,
|
||||
)
|
||||
.await?;
|
||||
log_phase_counts(
|
||||
"CONTINUOUS ROUND ROBIN",
|
||||
cycle,
|
||||
"B",
|
||||
"split prepared",
|
||||
&prepared_split_counts,
|
||||
);
|
||||
|
||||
let mut split_used_input_note_ids: HashSet<NoteId> = HashSet::new();
|
||||
for submission in &signed_submissions {
|
||||
extend_note_id_set(
|
||||
&mut split_used_input_note_ids,
|
||||
&submission.reserved_inputs().input_note_ids_list(),
|
||||
);
|
||||
}
|
||||
|
||||
let submitted_split_hashes = utils::submit_signed_user_wallet_submissions_concurrently(
|
||||
world,
|
||||
signed_submissions,
|
||||
Some(policy),
|
||||
)
|
||||
.await?;
|
||||
let mut submitted_split_counts = BTreeMap::new();
|
||||
for (sender, _) in &submitted_split_hashes {
|
||||
*submitted_split_counts
|
||||
.entry(sender.clone())
|
||||
.or_insert(0usize) += 1;
|
||||
}
|
||||
log_phase_counts(
|
||||
"CONTINUOUS ROUND ROBIN",
|
||||
cycle,
|
||||
"B",
|
||||
"split submitted",
|
||||
&submitted_split_counts,
|
||||
);
|
||||
|
||||
let split_tx_hashes = submitted_split_hashes
|
||||
.into_iter()
|
||||
.map(|(_, tx_hash)| tx_hash)
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
Ok((split_tx_hashes, split_used_input_note_ids))
|
||||
}
|
||||
|
||||
fn recipient_wallets(wallet_names: &[String], sender: &str) -> Result<Vec<String>, StepError> {
|
||||
let recipients: Vec<_> = wallet_names
|
||||
.iter()
|
||||
.filter(|wallet| wallet.as_str() != sender)
|
||||
.cloned()
|
||||
.collect();
|
||||
if recipients.is_empty() {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!("No recipient wallets available for sender '{sender}'"),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(recipients)
|
||||
}
|
||||
|
||||
#[expect(clippy::too_many_arguments, reason = "Transaction preparation inputs")]
|
||||
async fn prepare_round_robin_with_utxo_cache(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
sender: &str,
|
||||
recipients: &[String],
|
||||
transactions: usize,
|
||||
value: u64,
|
||||
available_utxos: &mut WalletUtxos,
|
||||
gas_prices: Option<GasPrices>,
|
||||
priority_fee_percent: u64,
|
||||
) -> Result<Vec<SignedUserWalletSubmission>, StepError> {
|
||||
let mut reserved_submissions = Vec::with_capacity(transactions);
|
||||
|
||||
for i in 0..transactions {
|
||||
let receiver_name = &recipients[i % recipients.len()];
|
||||
let receiver = world.resolve_recipient(receiver_name)?;
|
||||
let receiver_pk = receiver.public_key;
|
||||
|
||||
let receivers = vec![(receiver_pk, value)];
|
||||
let reserved_submission =
|
||||
utils::reserve_user_wallet_transaction_submission_with_utxo_cache(
|
||||
world,
|
||||
step,
|
||||
sender,
|
||||
&receivers,
|
||||
available_utxos,
|
||||
gas_prices.clone(),
|
||||
priority_fee_percent,
|
||||
)
|
||||
.await?;
|
||||
|
||||
reserved_submissions.push(reserved_submission);
|
||||
}
|
||||
|
||||
utils::finalize_reserved_user_wallet_submissions_concurrently(step, reserved_submissions).await
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
use super::{
|
||||
BTreeMap, BestNodeInfo, CucumberWorld, GasPrices, HashSet, ManualCommand, NonZero,
|
||||
SignedUserWalletSubmission, StepError, TxHash, WalletError, WalletInfo, WalletSendReadiness,
|
||||
WalletUtxos, ZkPublicKey, sync, utils,
|
||||
};
|
||||
|
||||
pub(super) async fn handle_verify_command(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
command: &ManualCommand,
|
||||
) -> Result<(), StepError> {
|
||||
let ManualCommand::Verify {
|
||||
wallet,
|
||||
outputs,
|
||||
value,
|
||||
time_out,
|
||||
wallet_state_type,
|
||||
verify_max,
|
||||
} = command
|
||||
else {
|
||||
unreachable!("handle_verify_command must be called with ManualCommand::Verify")
|
||||
};
|
||||
|
||||
let verify_min = !*verify_max;
|
||||
utils::wait_for_wallet_output_state(
|
||||
world,
|
||||
step,
|
||||
wallet.clone(),
|
||||
if verify_min { outputs.as_ref() } else { None },
|
||||
if *verify_max { outputs.as_ref() } else { None },
|
||||
if verify_min { value.as_ref() } else { None },
|
||||
if *verify_max { value.as_ref() } else { None },
|
||||
*time_out,
|
||||
*wallet_state_type,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) fn request_faucet_funds_all_user_wallets(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
rounds: usize,
|
||||
) -> Result<(), StepError> {
|
||||
let number_of_rounds = NonZero::new(rounds).ok_or_else(|| StepError::InvalidArgument {
|
||||
message: "Invalid value for 'rounds': '0'".to_owned(),
|
||||
})?;
|
||||
let all_wallets_pk_hex = world
|
||||
.wallet_registry
|
||||
.wallet_info
|
||||
.values()
|
||||
.filter(|w| w.is_user_wallet())
|
||||
.map(WalletInfo::public_key_hex)
|
||||
.collect::<Vec<_>>();
|
||||
utils::request_faucet_funds(world, step, number_of_rounds, &all_wallets_pk_hex)
|
||||
}
|
||||
|
||||
pub(super) fn request_faucet_funds_all_funding_wallets(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
rounds: usize,
|
||||
) -> Result<(), StepError> {
|
||||
let number_of_rounds = NonZero::new(rounds).ok_or_else(|| StepError::InvalidArgument {
|
||||
message: "Invalid value for 'rounds': '0'".to_owned(),
|
||||
})?;
|
||||
let all_wallets_pk_hex = world
|
||||
.wallet_registry
|
||||
.wallet_info
|
||||
.values()
|
||||
.filter(|wallet| wallet.is_node_funding_wallet())
|
||||
.map(WalletInfo::public_key_hex)
|
||||
.collect::<Vec<_>>();
|
||||
utils::request_faucet_funds(world, step, number_of_rounds, &all_wallets_pk_hex)
|
||||
}
|
||||
|
||||
pub(super) async fn execute_coin_split(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
wallet_name: &str,
|
||||
outputs: usize,
|
||||
value: u64,
|
||||
) -> Result<Vec<TxHash>, StepError> {
|
||||
let wallet = world.resolve_wallet(wallet_name)?;
|
||||
let self_pk = wallet.public_key()?;
|
||||
let receivers = vec![(self_pk, value); outputs];
|
||||
|
||||
let mut available_utxos = WalletUtxos::new();
|
||||
let best_node_info = sync::wait_wallet_send_ready(
|
||||
world,
|
||||
step,
|
||||
wallet_name,
|
||||
180,
|
||||
outputs as u64 * value,
|
||||
WalletSendReadiness::TotalValueOnly,
|
||||
&mut available_utxos,
|
||||
&HashSet::new(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
utils::create_and_submit_transaction_hashes_with_utxo_cache(
|
||||
world,
|
||||
step,
|
||||
wallet_name,
|
||||
&receivers,
|
||||
Some(&best_node_info),
|
||||
Some(&mut available_utxos),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn execute_coin_split_with_utxo_cache(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
wallet_name: &str,
|
||||
outputs: usize,
|
||||
value: u64,
|
||||
best_node_info: Option<&BestNodeInfo>,
|
||||
available_utxos: &mut WalletUtxos,
|
||||
) -> Result<Vec<TxHash>, StepError> {
|
||||
let wallet = world.resolve_wallet(wallet_name)?;
|
||||
let self_pk = wallet.public_key()?;
|
||||
let receivers = vec![(self_pk, value); outputs];
|
||||
utils::create_and_submit_transaction_hashes_with_utxo_cache(
|
||||
world,
|
||||
step,
|
||||
wallet_name,
|
||||
&receivers,
|
||||
best_node_info,
|
||||
Some(available_utxos),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn prepare_signed_submissions_with_utxo_cache(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
requests: Vec<(String, Vec<(ZkPublicKey, u64)>)>,
|
||||
available_utxos: &mut WalletUtxos,
|
||||
gas_prices: Option<GasPrices>,
|
||||
priority_fee_percent: u64,
|
||||
) -> Result<Vec<SignedUserWalletSubmission>, StepError> {
|
||||
let mut reserved_submissions = Vec::with_capacity(requests.len());
|
||||
|
||||
for (sender, receivers) in requests {
|
||||
let reserved_submission =
|
||||
utils::reserve_user_wallet_transaction_submission_with_utxo_cache(
|
||||
world,
|
||||
step,
|
||||
&sender,
|
||||
&receivers,
|
||||
available_utxos,
|
||||
gas_prices.clone(),
|
||||
priority_fee_percent,
|
||||
)
|
||||
.await?;
|
||||
reserved_submissions.push(reserved_submission);
|
||||
}
|
||||
|
||||
utils::finalize_reserved_user_wallet_submissions_concurrently(step, reserved_submissions).await
|
||||
}
|
||||
|
||||
#[expect(clippy::too_many_arguments, reason = "Coin-split preparation inputs")]
|
||||
pub(super) async fn prepare_coin_splits_all_wallets_with_utxo_cache(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
wallet_names: &[String],
|
||||
outputs: usize,
|
||||
value: u64,
|
||||
available_utxos: &mut WalletUtxos,
|
||||
gas_prices: Option<GasPrices>,
|
||||
priority_fee_percent: u64,
|
||||
) -> Result<(Vec<SignedUserWalletSubmission>, BTreeMap<String, usize>), StepError> {
|
||||
let mut requests = Vec::with_capacity(wallet_names.len());
|
||||
let mut prepared_counts = BTreeMap::new();
|
||||
|
||||
for wallet_name in wallet_names {
|
||||
let wallet = world.resolve_wallet(wallet_name)?;
|
||||
let self_pk = wallet.public_key()?;
|
||||
let receivers = vec![(self_pk, value); outputs];
|
||||
*prepared_counts.entry(wallet_name.clone()).or_insert(0usize) += 1;
|
||||
requests.push((wallet_name.clone(), receivers));
|
||||
}
|
||||
|
||||
let signed_submissions = prepare_signed_submissions_with_utxo_cache(
|
||||
world,
|
||||
step,
|
||||
requests,
|
||||
available_utxos,
|
||||
gas_prices,
|
||||
priority_fee_percent,
|
||||
)
|
||||
.await?;
|
||||
Ok((signed_submissions, prepared_counts))
|
||||
}
|
||||
|
||||
pub(super) async fn execute_send(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
number_of_transactions: usize,
|
||||
value: u64,
|
||||
from: &str,
|
||||
to: &str,
|
||||
) -> Result<(), StepError> {
|
||||
let receiver = world.resolve_recipient(to)?;
|
||||
let receiver_pk = receiver.public_key;
|
||||
|
||||
let mut available_utxos = WalletUtxos::new();
|
||||
let best_node_info = sync::wait_wallet_send_ready(
|
||||
world,
|
||||
step,
|
||||
from,
|
||||
180,
|
||||
number_of_transactions as u64 * value,
|
||||
WalletSendReadiness::EligibleUtxoBatch {
|
||||
min_required_outputs: number_of_transactions,
|
||||
min_value_per_transaction: value,
|
||||
},
|
||||
&mut available_utxos,
|
||||
&HashSet::new(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for i in 0..number_of_transactions {
|
||||
let result = utils::create_and_submit_transaction(
|
||||
world,
|
||||
step,
|
||||
from,
|
||||
&[(receiver_pk, value)],
|
||||
Some(&best_node_info),
|
||||
Some(&mut available_utxos),
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(StepError::WalletError(WalletError::InsufficientFunds { available })) = result {
|
||||
return Err(StepError::FundsDeficit {
|
||||
available,
|
||||
num_utxos_required: number_of_transactions - i,
|
||||
value_per_utxos_required: value,
|
||||
});
|
||||
}
|
||||
result?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[expect(clippy::too_many_arguments, reason = "Transaction preparation inputs")]
|
||||
pub(super) async fn prepare_ring_send_round_send_with_utxo_cache(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
transactions: usize,
|
||||
value: u64,
|
||||
from: &str,
|
||||
to: &str,
|
||||
available_utxos: &mut WalletUtxos,
|
||||
gas_prices: Option<GasPrices>,
|
||||
priority_fee_percent: u64,
|
||||
) -> Result<Vec<SignedUserWalletSubmission>, StepError> {
|
||||
let receiver = world.resolve_recipient(to)?;
|
||||
let receiver_pk = receiver.public_key;
|
||||
let mut reserved_submissions = Vec::with_capacity(transactions);
|
||||
|
||||
for i in 0..transactions {
|
||||
let sender_utxo_count_before = available_utxos.get(from).map_or(0usize, Vec::len);
|
||||
|
||||
let receivers = vec![(receiver_pk, value)];
|
||||
let reserved_submission =
|
||||
utils::reserve_user_wallet_transaction_submission_with_utxo_cache(
|
||||
world,
|
||||
step,
|
||||
from,
|
||||
&receivers,
|
||||
available_utxos,
|
||||
gas_prices.clone(),
|
||||
priority_fee_percent,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| match error {
|
||||
StepError::WalletError(WalletError::InsufficientFunds { available }) => {
|
||||
StepError::FundsDeficit {
|
||||
available,
|
||||
num_utxos_required: transactions - i,
|
||||
value_per_utxos_required: value,
|
||||
}
|
||||
}
|
||||
error => error,
|
||||
})?;
|
||||
let sender_utxo_count_after = available_utxos.get(from).map_or(0usize, Vec::len);
|
||||
|
||||
if transactions > 1 && sender_utxo_count_after >= sender_utxo_count_before {
|
||||
return Err(StepError::LogicalError {
|
||||
message: format!(
|
||||
"Batch cache accounting failed for '{from}': expected available input count to \
|
||||
decrease between submissions ({sender_utxo_count_before} -> {sender_utxo_count_after})"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
reserved_submissions.push(reserved_submission);
|
||||
}
|
||||
|
||||
utils::finalize_reserved_user_wallet_submissions_concurrently(step, reserved_submissions).await
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
use super::{
|
||||
BTreeMap, CucumberWorld, Path, Serialize, StepError, StepResult, TARGET, Utxo, WalletError,
|
||||
WalletInfo, WalletOutputState, WalletStateView, WalletType,
|
||||
current_available_utxos_for_user_wallets, drain_node_wallet, drain_user_wallet, fs, info,
|
||||
utils, warn,
|
||||
};
|
||||
|
||||
pub(super) async fn execute_drain(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
from: &str,
|
||||
to: &str,
|
||||
) -> StepResult {
|
||||
let sender = world.resolve_wallet(from)?;
|
||||
let receiver_pk = world.resolve_recipient(to)?.public_key;
|
||||
|
||||
if sender.public_key()? == receiver_pk {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!("Cannot drain wallet `{from}` into itself"),
|
||||
});
|
||||
}
|
||||
|
||||
match sender.wallet_type {
|
||||
WalletType::User { .. } => drain_user_wallet(world, step, &sender, receiver_pk).await,
|
||||
WalletType::Funding { .. } => drain_node_wallet(world, &sender, receiver_pk).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn log_wallet_balances(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
wallets: Vec<WalletInfo>,
|
||||
) -> StepResult {
|
||||
let tracked_wallets = wallets
|
||||
.iter()
|
||||
.filter(|wallet| wallet.is_user_wallet())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let states = if tracked_wallets.is_empty() {
|
||||
BTreeMap::new()
|
||||
} else {
|
||||
utils::current_wallet_states_for_wallets(world, step, &tracked_wallets).await?
|
||||
};
|
||||
|
||||
for wallet in &wallets {
|
||||
if wallet.is_node_wallet() {
|
||||
log_node_wallet_balance(world, wallet).await?;
|
||||
continue;
|
||||
}
|
||||
let state =
|
||||
states
|
||||
.get(wallet.wallet_name.as_str())
|
||||
.ok_or_else(|| StepError::LogicalError {
|
||||
message: format!(
|
||||
"Wallet `{}` balance state is not tracked",
|
||||
wallet.wallet_name
|
||||
),
|
||||
})?;
|
||||
log_wallet_state_balance(&wallet.wallet_name, &wallet.public_key_hex(), state);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn log_node_wallet_balance(world: &CucumberWorld, wallet: &WalletInfo) -> StepResult {
|
||||
let node = world
|
||||
.nodes_info
|
||||
.get(&wallet.node_name)
|
||||
.ok_or_else(|| StepError::LogicalError {
|
||||
message: format!(
|
||||
"Node '{}' for wallet '{}' not found",
|
||||
wallet.node_name, wallet.wallet_name
|
||||
),
|
||||
})?;
|
||||
|
||||
let balance_response = node
|
||||
.started_node
|
||||
.client
|
||||
.wallet_balance(wallet.public_key()?, None)
|
||||
.await;
|
||||
match balance_response {
|
||||
Ok(balance) => {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Wallet `{}` [On-chain] {}/{} LGO, {}",
|
||||
wallet.wallet_name,
|
||||
balance.notes.len(),
|
||||
balance.balance,
|
||||
wallet.public_key_hex(),
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Wallet `{}` [On-chain] no funds yet, {}",
|
||||
wallet.wallet_name,
|
||||
wallet.public_key_hex(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn log_wallet_balance(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
wallet_name: &str,
|
||||
) -> StepResult {
|
||||
let wallet = world.resolve_wallet(wallet_name)?;
|
||||
log_wallet_balances(world, step, vec![wallet]).await
|
||||
}
|
||||
|
||||
fn log_wallet_state_balance(wallet_name: &str, public_key_hex: &str, state: &WalletStateView) {
|
||||
let available = state.balance(WalletOutputState::Available);
|
||||
let reserved = state.balance(WalletOutputState::Reserved);
|
||||
let on_chain = state.balance(WalletOutputState::OnChain);
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Wallet `{wallet_name}` [Available] {}/{} LGO, [Encumbered] {}/{} LGO, \
|
||||
[On-chain] {}/{} LGO, {}",
|
||||
available.output_count,
|
||||
available.value,
|
||||
reserved.output_count,
|
||||
reserved.value,
|
||||
on_chain.output_count,
|
||||
on_chain.value,
|
||||
public_key_hex,
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct WalletFundsExport {
|
||||
wallet: String,
|
||||
node_url: String,
|
||||
public_key: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
secret_key: Option<String>,
|
||||
requested_value: u64,
|
||||
selected_value: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
height: Option<u64>,
|
||||
utxos: Vec<ExportedUtxo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ExportedUtxo {
|
||||
utxo_id: String,
|
||||
value: u64,
|
||||
encoded_utxo: String,
|
||||
}
|
||||
|
||||
pub(super) async fn export_funds(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
wallet_name: &str,
|
||||
value: u64,
|
||||
output_path: &str,
|
||||
include_secret: bool,
|
||||
) -> Result<(), StepError> {
|
||||
let wallet = world.resolve_wallet(wallet_name)?.clone();
|
||||
let available_utxos = current_available_utxos_for_user_wallets(world, step)
|
||||
.await?
|
||||
.get(wallet_name)
|
||||
.cloned()
|
||||
.ok_or(StepError::LogicalError {
|
||||
message: format!("Wallet '{wallet_name}' not found in updated balances"),
|
||||
})?;
|
||||
let selected = select_utxos_covering(available_utxos.clone(), value)?;
|
||||
let selected_value = selected.iter().map(|utxo| utxo.note.value).sum();
|
||||
let export = WalletFundsExport {
|
||||
wallet: wallet.wallet_name.clone(),
|
||||
node_url: format!(
|
||||
"{}",
|
||||
world
|
||||
.resolve_node_http_client(&wallet.node_name)?
|
||||
.base_url()
|
||||
),
|
||||
public_key: wallet.public_key_hex(),
|
||||
secret_key: exported_secret_key(&wallet, include_secret)?,
|
||||
requested_value: value,
|
||||
selected_value,
|
||||
height: best_known_wallet_node_height(world, &wallet).await,
|
||||
utxos: selected
|
||||
.iter()
|
||||
.map(exported_utxo)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
};
|
||||
|
||||
let path = Path::new(output_path);
|
||||
if let Some(parent) = path.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
fs::create_dir_all(parent).map_err(|error| StepError::StepFail {
|
||||
message: format!(
|
||||
"Failed to create EXPORT_FUNDS output directory '{}': {error}",
|
||||
parent.display()
|
||||
),
|
||||
})?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(&export).map_err(|error| StepError::StepFail {
|
||||
message: format!("Failed to serialize EXPORT_FUNDS JSON: {error}"),
|
||||
})?;
|
||||
fs::write(path, json).map_err(|error| StepError::StepFail {
|
||||
message: format!(
|
||||
"Failed to write EXPORT_FUNDS output '{}': {error}",
|
||||
path.display()
|
||||
),
|
||||
})?;
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"EXPORT_FUNDS wrote {} UTXO(s), selected value {}, requested value {}, output '{}'",
|
||||
export.utxos.len(),
|
||||
selected_value,
|
||||
value,
|
||||
path.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn select_utxos_covering(mut utxos: Vec<Utxo>, value: u64) -> Result<Vec<Utxo>, StepError> {
|
||||
utxos.sort_by_key(|utxo| std::cmp::Reverse(utxo.note.value));
|
||||
let available = utxos.iter().map(|utxo| utxo.note.value).sum();
|
||||
let mut selected = Vec::new();
|
||||
let mut selected_value = 0u64;
|
||||
|
||||
for utxo in utxos {
|
||||
selected_value = selected_value.saturating_add(utxo.note.value);
|
||||
selected.push(utxo);
|
||||
if selected_value >= value {
|
||||
return Ok(selected);
|
||||
}
|
||||
}
|
||||
|
||||
Err(StepError::WalletError(WalletError::InsufficientFunds {
|
||||
available,
|
||||
}))
|
||||
}
|
||||
|
||||
fn exported_secret_key(
|
||||
wallet: &WalletInfo,
|
||||
include_secret: bool,
|
||||
) -> Result<Option<String>, StepError> {
|
||||
if !include_secret {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let WalletType::User { wallet_account } = &wallet.wallet_type else {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!(
|
||||
"EXPORT_FUNDS include_secret true requires a user wallet; '{}' is a funding wallet",
|
||||
wallet.wallet_name
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
bincode::serialize(&wallet_account.secret_key)
|
||||
.map(hex::encode)
|
||||
.map(Some)
|
||||
.map_err(|error| StepError::StepFail {
|
||||
message: format!("Failed to encode wallet secret key for EXPORT_FUNDS: {error}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn exported_utxo(utxo: &Utxo) -> Result<ExportedUtxo, StepError> {
|
||||
Ok(ExportedUtxo {
|
||||
utxo_id: hex::encode(utxo.id().as_bytes()),
|
||||
value: utxo.note.value,
|
||||
encoded_utxo: bincode::serialize(utxo).map(hex::encode).map_err(|error| {
|
||||
StepError::StepFail {
|
||||
message: format!("Failed to encode UTXO for EXPORT_FUNDS: {error}"),
|
||||
}
|
||||
})?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn best_known_wallet_node_height(world: &CucumberWorld, wallet: &WalletInfo) -> Option<u64> {
|
||||
let node = world.nodes_info.get(&wallet.node_name)?;
|
||||
node.started_node
|
||||
.client
|
||||
.consensus_info()
|
||||
.await
|
||||
.ok()
|
||||
.map(|info| info.cryptarchia_info.height)
|
||||
}
|
||||
|
||||
pub(super) fn clear_wallet_encumbrances(
|
||||
world: &mut CucumberWorld,
|
||||
step: &str,
|
||||
wallet_name: &str,
|
||||
) -> StepResult {
|
||||
if world.resolve_wallet(wallet_name).is_err() {
|
||||
warn!(target: TARGET, "Step `{}` error: wallet '{wallet_name}' not found in world state", step);
|
||||
return Err(StepError::LogicalError {
|
||||
message: format!("wallet '{wallet_name}' not found in world state"),
|
||||
});
|
||||
}
|
||||
|
||||
world.with_wallets_mut(|wallets| wallets.clear_encumbrances(wallet_name))?;
|
||||
world
|
||||
.wallet_registry
|
||||
.fee_state
|
||||
.clear_wallet_reservations(wallet_name);
|
||||
info!(target: TARGET, "Cleared encumbrances for wallet '{wallet_name}'");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn clear_all_wallet_encumbrances(world: &mut CucumberWorld, step: &str) -> StepResult {
|
||||
let wallet_names: Vec<String> = world.wallet_registry.wallet_info.keys().cloned().collect();
|
||||
|
||||
for wallet_name in wallet_names {
|
||||
clear_wallet_encumbrances(world, step, &wallet_name)?;
|
||||
}
|
||||
info!(target: TARGET, "Cleared encumbrances for all wallets");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
mod execution;
|
||||
pub mod parsing;
|
||||
|
||||
pub(crate) use execution::*;
|
||||
+1
-1
@@ -2,7 +2,7 @@ use std::{fs, path::Path};
|
||||
|
||||
use crate::cucumber::{
|
||||
error::StepError,
|
||||
steps::manual_transactions::utils::{WalletOutputState, parse_wallet_output_state},
|
||||
steps::transactions::utils::{WalletOutputState, parse_wallet_output_state},
|
||||
};
|
||||
|
||||
#[cfg_attr(test, derive(strum_macros::EnumCount))]
|
||||
+1
-2
@@ -1,8 +1,7 @@
|
||||
pub mod command_file_parsing;
|
||||
pub mod command_file_utils;
|
||||
mod drain_wallets;
|
||||
mod faucet;
|
||||
pub mod inscriptions;
|
||||
pub mod manual_control;
|
||||
pub mod steps;
|
||||
pub(crate) mod tracked_transactions;
|
||||
pub mod utils;
|
||||
@@ -0,0 +1,81 @@
|
||||
use super::{
|
||||
CucumberWorld, Duration, Step, StepError, StepResult, TARGET, WalletType,
|
||||
assert_tracked_wallet_fees_equal_sponsored_fee_account_spend, drain_all_node_wallets,
|
||||
drain_node_wallet, drain_user_wallet, then, wait_for_wallet_submitted_transactions_inclusion,
|
||||
warn, when,
|
||||
};
|
||||
|
||||
#[when(expr = "I drain wallet {string} into {string}")]
|
||||
#[then(expr = "I drain wallet {string} into {string}")]
|
||||
async fn step_drain_wallet(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sender_wallet_name: String,
|
||||
receiver_wallet_name: String,
|
||||
) -> StepResult {
|
||||
let sender = world.resolve_wallet(&sender_wallet_name)?;
|
||||
let receiver = world.resolve_recipient(&receiver_wallet_name)?;
|
||||
let sender_pk = sender.public_key()?;
|
||||
let receiver_pk = receiver.public_key;
|
||||
|
||||
if sender_pk == receiver_pk {
|
||||
return Err(StepError::InvalidArgument {
|
||||
message: format!("Cannot drain wallet `{sender_wallet_name}` into itself"),
|
||||
});
|
||||
}
|
||||
|
||||
match sender.wallet_type {
|
||||
WalletType::User { .. } => {
|
||||
drain_user_wallet(world, &step.value, &sender, receiver_pk).await
|
||||
}
|
||||
WalletType::Funding { .. } => drain_node_wallet(world, &sender, receiver_pk).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[when(expr = "I drain all node {string} wallets into {string}")]
|
||||
#[then(expr = "I drain all node {string} wallets into {string}")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require a mutable World reference"
|
||||
)]
|
||||
async fn step_drain_all_node_wallets(
|
||||
world: &mut CucumberWorld,
|
||||
node_name: String,
|
||||
receiver_wallet_name: String,
|
||||
) -> StepResult {
|
||||
drain_all_node_wallets(world, &node_name, &receiver_wallet_name).await
|
||||
}
|
||||
|
||||
#[when(expr = "wallet {string} has all submitted transactions settled in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has all submitted transactions settled in {int} seconds")]
|
||||
#[when(expr = "wallet {string} has all submitted transactions included in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has all submitted transactions included in {int} seconds")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require the world as the first `&mut` argument"
|
||||
)]
|
||||
async fn step_wallet_has_all_submitted_transactions_settled(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_wallet_submitted_transactions_inclusion(
|
||||
world,
|
||||
&wallet_name,
|
||||
Duration::from_secs(time_out_seconds),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})
|
||||
}
|
||||
|
||||
#[when(expr = "tracked wallet fees equal sponsored fee account spent fees")]
|
||||
#[then(expr = "tracked wallet fees equal sponsored fee account spent fees")]
|
||||
async fn step_tracked_wallet_fees_equal_sponsored_fee_account_spend(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
) -> StepResult {
|
||||
assert_tracked_wallet_fees_equal_sponsored_fee_account_spend(world, &step.value).await
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
use super::{
|
||||
CucumberWorld, Step, StepError, StepResult, TARGET, WalletInfo, given, non_zero, utils, warn,
|
||||
when,
|
||||
};
|
||||
|
||||
#[given(expr = "I have a faucet with URL {string}")]
|
||||
#[when(expr = "I have a faucet with URL {string}")]
|
||||
fn step_faucet_details(world: &mut CucumberWorld, base_url: String) {
|
||||
world.wallet_registry.faucet_base_url = Some(base_url);
|
||||
}
|
||||
|
||||
#[given(expr = "I request {int} rounds of faucet funds for wallet {string}")]
|
||||
#[when(expr = "I request {int} rounds of faucet funds for wallet {string}")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_value,
|
||||
reason = "Required by cucumber expression"
|
||||
)]
|
||||
fn step_request_faucet_funds_for_wallet(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
number_of_rounds: usize,
|
||||
wallet_name: String,
|
||||
) -> StepResult {
|
||||
let wallet = world.resolve_wallet(&wallet_name).inspect_err(|error| {
|
||||
warn!(target: TARGET, "Step `{}` error: {error}", step.value);
|
||||
})?;
|
||||
|
||||
let wallet_pk_hex = wallet.public_key_hex();
|
||||
|
||||
utils::request_faucet_funds(
|
||||
world,
|
||||
&step.value,
|
||||
non_zero!("number of rounds", number_of_rounds)?,
|
||||
&[wallet_pk_hex],
|
||||
)
|
||||
}
|
||||
|
||||
#[given(expr = "I request {int} rounds of faucet funds for all wallets")]
|
||||
#[when(expr = "I request {int} rounds of faucet funds for all wallets")]
|
||||
fn step_request_faucet_funds_for_all_wallets(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
number_of_rounds: usize,
|
||||
) -> StepResult {
|
||||
let all_wallets_pk_hex = world
|
||||
.wallet_registry
|
||||
.wallet_info
|
||||
.values()
|
||||
.map(WalletInfo::public_key_hex)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
utils::request_faucet_funds(
|
||||
world,
|
||||
&step.value,
|
||||
non_zero!("number of rounds", number_of_rounds)?,
|
||||
&all_wallets_pk_hex,
|
||||
)
|
||||
}
|
||||
|
||||
#[given(expr = "I request {int} rounds of faucet funds for all user wallets")]
|
||||
#[when(expr = "I request {int} rounds of faucet funds for all user wallets")]
|
||||
fn step_request_faucet_funds_for_all_user_wallets(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
number_of_rounds: usize,
|
||||
) -> StepResult {
|
||||
let all_wallets_pk_hex = world
|
||||
.wallet_registry
|
||||
.wallet_info
|
||||
.values()
|
||||
.filter(|w| w.is_user_wallet())
|
||||
.map(WalletInfo::public_key_hex)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
utils::request_faucet_funds(
|
||||
world,
|
||||
&step.value,
|
||||
non_zero!("number of rounds", number_of_rounds)?,
|
||||
&all_wallets_pk_hex,
|
||||
)
|
||||
}
|
||||
|
||||
#[given(expr = "I request {int} rounds of faucet funds for all funding wallets")]
|
||||
#[when(expr = "I request {int} rounds of faucet funds for all funding wallets")]
|
||||
fn step_request_faucet_funds_for_all_funding_wallets(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
number_of_rounds: usize,
|
||||
) -> StepResult {
|
||||
let all_wallets_pk_hex = world
|
||||
.wallet_registry
|
||||
.wallet_info
|
||||
.values()
|
||||
.filter(|wallet| wallet.is_node_funding_wallet())
|
||||
.map(WalletInfo::public_key_hex)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
utils::request_faucet_funds(
|
||||
world,
|
||||
&step.value,
|
||||
non_zero!("number of rounds", number_of_rounds)?,
|
||||
&all_wallets_pk_hex,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use std::{collections::HashSet, time::Duration};
|
||||
|
||||
use cucumber::{gherkin::Step, given, then, when};
|
||||
use tokio::time::timeout;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{
|
||||
common::wallet::WalletUtxos,
|
||||
cucumber::{
|
||||
error::{StepError, StepResult},
|
||||
steps::{
|
||||
TARGET,
|
||||
transactions::{
|
||||
drain_wallets::{drain_all_node_wallets, drain_node_wallet, drain_user_wallet},
|
||||
manual_control::{
|
||||
execute_coin_splits_all_user_wallets,
|
||||
execute_continuous_next_wallet_user_wallet,
|
||||
execute_continuous_round_robin_user_wallets, log_wallet_balances,
|
||||
parsing::ManualCommand, perform_manual_step_control,
|
||||
verify_min_outputs_all_user_wallets,
|
||||
},
|
||||
tracked_transactions::{
|
||||
submit_funded_transfer_transaction, submit_invalid_transfer_transaction,
|
||||
submit_stateless_invalid_transfer_transaction,
|
||||
transaction_is_not_included_in_seconds,
|
||||
transaction_is_rejected_during_preverification,
|
||||
},
|
||||
utils,
|
||||
utils::{
|
||||
WalletOutputState,
|
||||
assert_tracked_wallet_fees_equal_sponsored_fee_account_spend,
|
||||
create_and_submit_transaction, parse_wallet_output_state,
|
||||
wait_for_wallet_output_state, wait_for_wallet_submitted_transactions_inclusion,
|
||||
},
|
||||
},
|
||||
},
|
||||
wallet::{
|
||||
submissions::create_and_submit_transaction_hashes_with_utxo_cache,
|
||||
sync::{WalletSendReadiness, wait_wallet_send_ready},
|
||||
},
|
||||
world::{CucumberWorld, WalletInfo, WalletType},
|
||||
},
|
||||
non_zero,
|
||||
};
|
||||
|
||||
mod draining;
|
||||
mod faucet;
|
||||
mod submissions;
|
||||
mod transfers;
|
||||
mod wallets;
|
||||
mod workloads;
|
||||
@@ -0,0 +1,180 @@
|
||||
use super::{
|
||||
CucumberWorld, HashSet, Step, StepResult, TARGET, WalletSendReadiness, WalletUtxos,
|
||||
create_and_submit_transaction, info, submit_funded_transfer_transaction,
|
||||
submit_invalid_transfer_transaction, submit_stateless_invalid_transfer_transaction, then,
|
||||
transaction_is_not_included_in_seconds, transaction_is_rejected_during_preverification,
|
||||
wait_wallet_send_ready, warn, when,
|
||||
};
|
||||
|
||||
#[when(
|
||||
expr = "I send {int} transactions of {int} LGO each from wallet {string} to wallet {string}"
|
||||
)]
|
||||
async fn step_send_multiple_transactions_to_single_wallet(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
number_of_transactions: usize,
|
||||
output_value: u64,
|
||||
sender_wallet_name: String,
|
||||
receiver_wallet_name: String,
|
||||
) -> StepResult {
|
||||
let sender_wallet = world.resolve_wallet(&sender_wallet_name).inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
let receiver = world.resolve_recipient(&receiver_wallet_name)?;
|
||||
let receiver_wallet_pk = receiver.public_key;
|
||||
|
||||
let mut available_utxos = WalletUtxos::new();
|
||||
let best_node_info = wait_wallet_send_ready(
|
||||
world,
|
||||
&step.value,
|
||||
&sender_wallet_name,
|
||||
180,
|
||||
number_of_transactions as u64 * output_value,
|
||||
WalletSendReadiness::TotalValueOnly,
|
||||
&mut available_utxos,
|
||||
&HashSet::new(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for _ in 0..number_of_transactions {
|
||||
let tx_hash_hex = create_and_submit_transaction(
|
||||
world,
|
||||
&step.value,
|
||||
&sender_wallet_name,
|
||||
&[(receiver_wallet_pk, output_value)],
|
||||
Some(&best_node_info),
|
||||
Some(&mut available_utxos),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Sent normal transaction from `{sender_wallet_name}/{}` to {}, \
|
||||
value: {output_value}, tx hash: {tx_hash_hex}",
|
||||
sender_wallet.node_name,
|
||||
receiver.label
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(expr = "I submit invalid transfer transaction {string} to node {string}")]
|
||||
async fn step_submit_invalid_transfer_transaction(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
transaction_alias: String,
|
||||
node_name: String,
|
||||
) -> StepResult {
|
||||
submit_invalid_transfer_transaction(world, &step.value, transaction_alias, node_name).await
|
||||
}
|
||||
|
||||
#[when(expr = "I submit a stateless-invalid transfer transaction {string} to node {string}")]
|
||||
async fn step_submit_stateless_invalid_transfer_transaction(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
transaction_alias: String,
|
||||
node_name: String,
|
||||
) -> StepResult {
|
||||
submit_stateless_invalid_transfer_transaction(world, &step.value, transaction_alias, node_name)
|
||||
.await
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_value,
|
||||
reason = "Cucumber step captures are always owned `String`s, even when the step only needs to borrow them"
|
||||
)]
|
||||
#[then(expr = "transaction {string} is rejected during preverification")]
|
||||
fn step_transaction_is_rejected_during_preverification(
|
||||
world: &mut CucumberWorld,
|
||||
transaction_alias: String,
|
||||
) -> StepResult {
|
||||
transaction_is_rejected_during_preverification(world, &transaction_alias)
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "I submit funded transfer transaction {string} of {int} LGO from wallet {string} to wallet {string}"
|
||||
)]
|
||||
async fn step_submit_funded_transfer_transaction(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
transaction_alias: String,
|
||||
amount: u64,
|
||||
sender_wallet_name: String,
|
||||
receiver_wallet_name: String,
|
||||
) -> StepResult {
|
||||
submit_funded_transfer_transaction(
|
||||
world,
|
||||
&step.value,
|
||||
transaction_alias,
|
||||
amount,
|
||||
sender_wallet_name,
|
||||
receiver_wallet_name,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "transaction {string} is not included in {int} seconds")]
|
||||
#[then(expr = "transaction {string} is not included in {int} seconds")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_transaction_is_not_included_in_seconds(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
transaction_alias: String,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
transaction_is_not_included_in_seconds(world, &step.value, transaction_alias, timeout_seconds)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "I send one transaction with {int} outputs of {int} LGO each from wallet {string} to wallet {string}"
|
||||
)]
|
||||
async fn step_send_single_transaction_multiple_outputs_to_single_wallet(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
number_of_outputs: usize,
|
||||
output_value: u64,
|
||||
sender_wallet_name: String,
|
||||
receiver_wallet_name: String,
|
||||
) -> StepResult {
|
||||
let sender_wallet = world.resolve_wallet(&sender_wallet_name).inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
let receiver = world.resolve_recipient(&receiver_wallet_name)?;
|
||||
let receiver_wallet_pk = receiver.public_key;
|
||||
|
||||
let receivers = vec![(receiver_wallet_pk, output_value); number_of_outputs];
|
||||
let tx_hash_hex = create_and_submit_transaction(
|
||||
world,
|
||||
&step.value,
|
||||
&sender_wallet_name,
|
||||
&receivers,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Sent normal transaction from `{sender_wallet_name}/{}` to {}, \
|
||||
number_of_outputs: {number_of_outputs}, value: {output_value}, tx hash: {tx_hash_hex}",
|
||||
sender_wallet.node_name,
|
||||
receiver.label
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
use super::{
|
||||
CucumberWorld, HashSet, Step, StepError, StepResult, TARGET, WalletSendReadiness, WalletUtxos,
|
||||
create_and_submit_transaction, create_and_submit_transaction_hashes_with_utxo_cache, info,
|
||||
wait_wallet_send_ready, warn, when,
|
||||
};
|
||||
|
||||
#[when(expr = "I do a coin split for {string} of {int} UTXOs valued at {int} LGO tokens each")]
|
||||
async fn step_do_coin_split(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
number_of_outputs: usize,
|
||||
output_value: u64,
|
||||
) -> StepResult {
|
||||
let wallet = world.resolve_wallet(&wallet_name).inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
|
||||
let mut available_utxos = WalletUtxos::new();
|
||||
let best_node_info = wait_wallet_send_ready(
|
||||
world,
|
||||
&step.value,
|
||||
&wallet_name,
|
||||
180,
|
||||
number_of_outputs as u64 * output_value,
|
||||
WalletSendReadiness::TotalValueOnly,
|
||||
&mut available_utxos,
|
||||
&HashSet::new(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let self_pk = wallet.public_key().inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
let receivers = vec![(self_pk, output_value); number_of_outputs];
|
||||
let tx_hash_hex = create_and_submit_transaction(
|
||||
world,
|
||||
&step.value,
|
||||
&wallet_name,
|
||||
&receivers,
|
||||
Some(&best_node_info),
|
||||
Some(&mut available_utxos),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Submitted coin split transaction for `{wallet_name}/{}`, outputs: {number_of_outputs}, \
|
||||
value: {output_value}, tx hash: {tx_hash_hex}",
|
||||
wallet.node_name
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tops up a node's funding wallet from a user wallet with `note_count`
|
||||
/// notes of `note_value` LGO each, in a single transaction.
|
||||
///
|
||||
/// Funding a transaction reserves one wallet note until the transaction is
|
||||
/// mined, so a funding wallet supports at most `note count` concurrent
|
||||
/// in-flight funded transactions — and its single `10_000` genesis note cannot
|
||||
/// pay the fees of scenarios that publish many messages at non-zero gas
|
||||
/// prices. This mirrors the workflow of a real node operator provisioning
|
||||
/// their sequencer's funding wallet.
|
||||
#[when(
|
||||
expr = "wallet {string} sends {int} notes of {int} LGO to node {string} funding wallet as {string}"
|
||||
)]
|
||||
async fn step_topup_node_funding_wallet(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
note_count: usize,
|
||||
note_value: u64,
|
||||
node_name: String,
|
||||
transaction_alias: String,
|
||||
) -> StepResult {
|
||||
let funding_wallet = world.funding_wallet(&node_name).inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
let funding_pk = funding_wallet.public_key().inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
|
||||
let mut available_utxos = WalletUtxos::new();
|
||||
let best_node_info = wait_wallet_send_ready(
|
||||
world,
|
||||
&step.value,
|
||||
&wallet_name,
|
||||
180,
|
||||
note_count as u64 * note_value,
|
||||
WalletSendReadiness::TotalValueOnly,
|
||||
&mut available_utxos,
|
||||
&HashSet::new(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let receivers = vec![(funding_pk, note_value); note_count];
|
||||
let tx_hashes = create_and_submit_transaction_hashes_with_utxo_cache(
|
||||
world,
|
||||
&step.value,
|
||||
&wallet_name,
|
||||
&receivers,
|
||||
Some(&best_node_info),
|
||||
Some(&mut available_utxos),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
let tx_hash = tx_hashes
|
||||
.first()
|
||||
.copied()
|
||||
.ok_or_else(|| StepError::LogicalError {
|
||||
message: "funding wallet top-up produced no transaction".to_owned(),
|
||||
})?;
|
||||
world.remember_submitted_transaction(transaction_alias.clone(), tx_hash);
|
||||
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Submitted funding wallet top-up `{transaction_alias}`: {note_count} notes of \
|
||||
{note_value} LGO from `{wallet_name}` to `{}`",
|
||||
funding_wallet.wallet_name,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
use super::{
|
||||
CucumberWorld, Step, StepResult, WalletOutputState, log_wallet_balances, then,
|
||||
wait_for_wallet_output_state, when,
|
||||
};
|
||||
|
||||
#[when(expr = "wallet {string} has {int} or more outputs in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has {int} or more outputs in {int} seconds")]
|
||||
async fn step_wallet_has_at_least_coins(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
min_coin_count: usize,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_wallet_output_state(
|
||||
world,
|
||||
&step.value,
|
||||
wallet_name,
|
||||
Some(&min_coin_count),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
time_out_seconds,
|
||||
WalletOutputState::OnChain,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "wallet {string} has {int} or less outputs in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has {int} or less outputs in {int} seconds")]
|
||||
async fn step_wallet_has_at_most_coins(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
max_coin_count: usize,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_wallet_output_state(
|
||||
world,
|
||||
&step.value,
|
||||
wallet_name,
|
||||
None,
|
||||
Some(&max_coin_count),
|
||||
None,
|
||||
None,
|
||||
time_out_seconds,
|
||||
WalletOutputState::OnChain,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "wallet {string} has exactly {int} outputs in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has exactly {int} outputs in {int} seconds")]
|
||||
async fn step_wallet_has_exact_coins(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
coin_count: usize,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_wallet_output_state(
|
||||
world,
|
||||
&step.value,
|
||||
wallet_name,
|
||||
Some(&coin_count),
|
||||
Some(&coin_count),
|
||||
None,
|
||||
None,
|
||||
time_out_seconds,
|
||||
WalletOutputState::OnChain,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "wallet {string} has {int} or less encumbered outputs in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has {int} or less encumbered outputs in {int} seconds")]
|
||||
async fn step_wallet_has_at_most_encumbered_coins(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
max_coin_count: usize,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_wallet_output_state(
|
||||
world,
|
||||
&step.value,
|
||||
wallet_name,
|
||||
None,
|
||||
Some(&max_coin_count),
|
||||
None,
|
||||
None,
|
||||
time_out_seconds,
|
||||
WalletOutputState::Reserved,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "wallet {string} has {int} or more LGO in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has {int} or more LGO in {int} seconds")]
|
||||
async fn step_wallet_has_at_least_value(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
min_token_value: u64,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_wallet_output_state(
|
||||
world,
|
||||
&step.value,
|
||||
wallet_name,
|
||||
None,
|
||||
None,
|
||||
Some(&min_token_value),
|
||||
None,
|
||||
time_out_seconds,
|
||||
WalletOutputState::OnChain,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "wallet {string} has exactly {int} LGO in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has exactly {int} LGO in {int} seconds")]
|
||||
async fn step_wallet_has_exact_value(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
token_value: u64,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_wallet_output_state(
|
||||
world,
|
||||
&step.value,
|
||||
wallet_name,
|
||||
None,
|
||||
None,
|
||||
Some(&token_value),
|
||||
Some(&token_value),
|
||||
time_out_seconds,
|
||||
WalletOutputState::OnChain,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "wallet {string} has {int} or less LGO in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has {int} or less LGO in {int} seconds")]
|
||||
async fn step_wallet_has_at_most_value(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
max_token_value: u64,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_wallet_output_state(
|
||||
world,
|
||||
&step.value,
|
||||
wallet_name,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(&max_token_value),
|
||||
time_out_seconds,
|
||||
WalletOutputState::OnChain,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "wallet {string} has {int} or more outputs and {int} or more LGO in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has {int} or more outputs and {int} or more LGO in {int} seconds")]
|
||||
async fn step_wallet_has_at_least_coins_and_value(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
min_coin_count: usize,
|
||||
min_token_value: u64,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_wallet_output_state(
|
||||
world,
|
||||
&step.value,
|
||||
wallet_name,
|
||||
Some(&min_coin_count),
|
||||
None,
|
||||
Some(&min_token_value),
|
||||
None,
|
||||
time_out_seconds,
|
||||
WalletOutputState::OnChain,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "wallet {string} has {int} or less outputs and {int} or less LGO in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has {int} or less outputs and {int} or less LGO in {int} seconds")]
|
||||
async fn step_wallet_has_at_most_coins_and_value(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
max_coin_count: usize,
|
||||
max_token_value: u64,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_wallet_output_state(
|
||||
world,
|
||||
&step.value,
|
||||
wallet_name,
|
||||
None,
|
||||
Some(&max_coin_count),
|
||||
None,
|
||||
Some(&max_token_value),
|
||||
time_out_seconds,
|
||||
WalletOutputState::OnChain,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "wallet {string} has exactly {int} outputs and {int} LGO in {int} seconds")]
|
||||
#[then(expr = "wallet {string} has exactly {int} outputs and {int} LGO in {int} seconds")]
|
||||
async fn step_wallet_has_exact_coins_and_value(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
wallet_name: String,
|
||||
coin_count: usize,
|
||||
token_value: u64,
|
||||
time_out_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_wallet_output_state(
|
||||
world,
|
||||
&step.value,
|
||||
wallet_name,
|
||||
Some(&coin_count),
|
||||
Some(&coin_count),
|
||||
Some(&token_value),
|
||||
Some(&token_value),
|
||||
time_out_seconds,
|
||||
WalletOutputState::OnChain,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "I log wallet balances for all wallets")]
|
||||
#[then(expr = "I log wallet balances for all wallets")]
|
||||
async fn step_wallet_balance_all_wallets(world: &mut CucumberWorld, step: &Step) -> StepResult {
|
||||
let mut wallets = world.all_user_wallets();
|
||||
wallets.extend(world.all_node_wallets());
|
||||
|
||||
log_wallet_balances(world, &step.value, wallets).await
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
use super::{
|
||||
CucumberWorld, Duration, ManualCommand, Step, StepError, StepResult, TARGET,
|
||||
execute_coin_splits_all_user_wallets, execute_continuous_next_wallet_user_wallet,
|
||||
execute_continuous_round_robin_user_wallets, info, parse_wallet_output_state,
|
||||
perform_manual_step_control, timeout, verify_min_outputs_all_user_wallets, warn, when,
|
||||
};
|
||||
|
||||
#[when(expr = "I perform manual control of transactions for all wallets for {int} seconds")]
|
||||
async fn step_manual_control_transactions(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
perform_manual_step_control(world, &step.value, timeout_seconds).await
|
||||
}
|
||||
|
||||
#[when(expr = "I perform manual control of transactions for all wallets no time-out")]
|
||||
async fn step_manual_control_transactions_no_time_out(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
) -> StepResult {
|
||||
perform_manual_step_control(world, &step.value, u64::MAX).await
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "I perform continuous transactions on user wallets with {int} coin split outputs of {int} LGO, {int} transactions of {int} LGO each for {int} cycles with {int} epochs headroom"
|
||||
)]
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "Cucumber step captures map directly to step arguments"
|
||||
)]
|
||||
async fn step_continuous_user_wallets(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
coin_split_outputs: usize,
|
||||
coin_split_value: u64,
|
||||
transactions: usize,
|
||||
value: u64,
|
||||
cycles: usize,
|
||||
epochs_headroom: u32,
|
||||
) -> StepResult {
|
||||
info!(
|
||||
target: TARGET,
|
||||
"Starting continuous user wallet transactions: coin_split_outputs={coin_split_outputs}, coin_split_value={coin_split_value}, transactions={transactions}, value={value}, cycles={cycles}"
|
||||
);
|
||||
|
||||
execute_continuous_round_robin_user_wallets(
|
||||
world,
|
||||
&step.value,
|
||||
coin_split_outputs,
|
||||
coin_split_value,
|
||||
transactions,
|
||||
value,
|
||||
cycles,
|
||||
epochs_headroom,
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
|
||||
info!(target: TARGET, "Completed continuous user wallet transactions step");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "I perform continuous transactions on user wallets with {int} coin split outputs of {int} LGO, {int} transactions of {int} LGO each for {int} cycles and timeout of {int} seconds with {int} epochs headroom"
|
||||
)]
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "Cucumber step captures map directly to step function arguments."
|
||||
)]
|
||||
async fn step_continuous_user_wallets_with_timeout(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
coin_split_outputs: usize,
|
||||
coin_split_value: u64,
|
||||
transactions: usize,
|
||||
value: u64,
|
||||
cycles: usize,
|
||||
timeout_seconds: u64,
|
||||
epochs_headroom: u32,
|
||||
) -> StepResult {
|
||||
timeout(
|
||||
Duration::from_secs(timeout_seconds),
|
||||
step_continuous_user_wallets(
|
||||
world,
|
||||
step,
|
||||
coin_split_outputs,
|
||||
coin_split_value,
|
||||
transactions,
|
||||
value,
|
||||
cycles,
|
||||
epochs_headroom,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| StepError::Timeout {
|
||||
message: format!(
|
||||
"continuous user wallet transactions did not finish within {timeout_seconds} seconds"
|
||||
),
|
||||
})?
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "I perform {int} coin split transactions for each user wallet with {int} outputs of {int} LGO each"
|
||||
)]
|
||||
async fn step_coin_split_transactions_for_each_user_wallet(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
splits_per_wallet: usize,
|
||||
outputs: usize,
|
||||
value: u64,
|
||||
) -> StepResult {
|
||||
execute_coin_splits_all_user_wallets(world, &step.value, splits_per_wallet, outputs, value)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(expr = "I verify each wallet has minimum {int} outputs {string} in {int} seconds")]
|
||||
async fn step_verify_each_wallet_minimum_outputs(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
min_outputs: usize,
|
||||
wallet_state_type: String,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
verify_min_outputs_all_user_wallets(
|
||||
world,
|
||||
&step.value,
|
||||
min_outputs,
|
||||
timeout_seconds,
|
||||
parse_wallet_output_state(&wallet_state_type)
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})
|
||||
.map_err(|e| StepError::InvalidArgument {
|
||||
message: e.to_string(),
|
||||
})?,
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "I perform {int} stress continuous cycles with {int} transactions of {int} LGO to the next user wallet with {int} epochs headroom"
|
||||
)]
|
||||
async fn step_perform_stress_continuous_cycles_next_user_wallet(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
cycles: usize,
|
||||
num_transactions: usize,
|
||||
value: u64,
|
||||
epochs_headroom: u32,
|
||||
) -> StepResult {
|
||||
execute_continuous_next_wallet_user_wallet(
|
||||
world,
|
||||
&step.value,
|
||||
&ManualCommand::ContinuousNextWalletUserWallets {
|
||||
cycles,
|
||||
num_transactions,
|
||||
value,
|
||||
epochs_headroom,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
warn!(target: TARGET, "Step `{}` error: {e}", step.value);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+1
-2
@@ -18,8 +18,7 @@ use crate::{
|
||||
cucumber::{
|
||||
error::StepError,
|
||||
steps::{
|
||||
TARGET,
|
||||
manual_transactions::utils::create_and_submit_transaction_hashes_with_utxo_cache,
|
||||
TARGET, transactions::utils::create_and_submit_transaction_hashes_with_utxo_cache,
|
||||
},
|
||||
wallet::sync::{WalletSendReadiness, wait_wallet_send_ready},
|
||||
world::{CucumberWorld, WalletType},
|
||||
+1
-1
@@ -30,7 +30,7 @@ pub(crate) use crate::cucumber::wallet::{
|
||||
};
|
||||
use crate::cucumber::{
|
||||
error::{StepError, StepResult},
|
||||
steps::{TARGET, manual_transactions::faucet::FaucetTask},
|
||||
steps::{TARGET, transactions::faucet::FaucetTask},
|
||||
world::CucumberWorld,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
use super::{
|
||||
AtomicZoneDepositRequest, CucumberWorld, Duration, Ed25519Key, Inscription, Keys, Metadata,
|
||||
PublishDeadline, PublishResult, SequencerCheckpoint, Step, StepError, StepResult, TxHash, Utxo,
|
||||
WalletInfo, WalletReservedInputs, ZONE_CHANNEL_DEPOSIT_THRESHOLD,
|
||||
ZONE_CHANNEL_WITHDRAW_THRESHOLD, ZoneDeposit, ZoneTestError, build_zone_deposit,
|
||||
build_zone_deposit_from_values, current_available_utxos_for_wallet, log_step_error,
|
||||
make_inscription, publish_atomic_zone_withdraw, submit_atomic_zone_deposit,
|
||||
submit_zone_channel_split, submit_zone_deposit, submit_zone_withdraw, timeout, zone_step_error,
|
||||
};
|
||||
|
||||
pub(in super::super) async fn submit_zone_channel_config(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: &str,
|
||||
transaction_alias: String,
|
||||
authorized_aliases: Vec<String>,
|
||||
posting_timeframe: u32,
|
||||
posting_timeout: u32,
|
||||
) -> StepResult {
|
||||
let handle = log_step_error(step, world.zone.sequencer_client(sequencer_alias))?;
|
||||
let mut ordered_aliases = vec![sequencer_alias.to_owned()];
|
||||
|
||||
for alias in authorized_aliases {
|
||||
if ordered_aliases.iter().any(|existing| existing == &alias) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ordered_aliases.push(alias);
|
||||
}
|
||||
|
||||
let authorized_keys = ordered_aliases
|
||||
.into_iter()
|
||||
.map(|alias| {
|
||||
world
|
||||
.zone
|
||||
.sequencer_signing_key(&alias)
|
||||
.map(Ed25519Key::public_key)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let mut checkpoint_rx = world
|
||||
.zone
|
||||
.checkpoint_receiver(sequencer_alias)
|
||||
.ok_or_else(|| StepError::LogicalError {
|
||||
message: format!("Zone sequencer '{sequencer_alias}' has no checkpoint watch"),
|
||||
})?;
|
||||
checkpoint_rx.mark_unchanged();
|
||||
|
||||
let ((result, post_call_checkpoint), _signed_tx) = handle
|
||||
.channel_config(
|
||||
Keys::new_unchecked(authorized_keys),
|
||||
posting_timeframe.into(),
|
||||
posting_timeout.into(),
|
||||
ZONE_CHANNEL_WITHDRAW_THRESHOLD,
|
||||
ZONE_CHANNEL_DEPOSIT_THRESHOLD,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| StepError::LogicalError {
|
||||
message: format!("Zone channel_config failed: {error}"),
|
||||
})?;
|
||||
|
||||
// Sanity-check the inline checkpoint already mentions our tx; the
|
||||
// event-stream watcher below also catches it once the drive task
|
||||
// re-publishes its checkpoint after the next block.
|
||||
let tx_hash = result.inscription_id();
|
||||
let checkpoint = if post_call_checkpoint
|
||||
.pending_txs
|
||||
.iter()
|
||||
.any(|(hash, _)| *hash == tx_hash)
|
||||
{
|
||||
post_call_checkpoint
|
||||
} else {
|
||||
timeout(
|
||||
Duration::from_secs(30),
|
||||
wait_for_checkpoint_with_tx(&mut checkpoint_rx, tx_hash),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| StepError::LogicalError {
|
||||
message: format!(
|
||||
"timed out waiting for sequencer '{sequencer_alias}' checkpoint to include {tx_hash:?}",
|
||||
),
|
||||
})?
|
||||
.map_err(|message| StepError::LogicalError { message })?
|
||||
};
|
||||
|
||||
world
|
||||
.zone
|
||||
.set_latest_checkpoint_for(sequencer_alias, checkpoint.clone());
|
||||
world
|
||||
.zone
|
||||
.remember_checkpoint(format!("{transaction_alias}_CHECKPOINT"), checkpoint);
|
||||
world.remember_submitted_transaction(transaction_alias, tx_hash);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in super::super) fn stop_zone_sequencer(
|
||||
world: &mut CucumberWorld,
|
||||
sequencer_alias: impl AsRef<str>,
|
||||
) -> StepResult {
|
||||
world.zone.stop_sequencer(sequencer_alias.as_ref())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in super::super) fn save_zone_checkpoint(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: impl AsRef<str>,
|
||||
checkpoint_alias: String,
|
||||
) -> StepResult {
|
||||
let sequencer_alias = sequencer_alias.as_ref();
|
||||
let checkpoint = log_step_error(step, world.zone.current_checkpoint_for(sequencer_alias))?;
|
||||
|
||||
world.zone.remember_checkpoint(checkpoint_alias, checkpoint);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in super::super) fn remember_published_zone_message(
|
||||
world: &mut CucumberWorld,
|
||||
sequencer_alias: &str,
|
||||
message_alias: String,
|
||||
payload: Inscription,
|
||||
result: &PublishResult,
|
||||
) {
|
||||
let checkpoint = world.zone.current_checkpoint_for(sequencer_alias).ok();
|
||||
world.zone.remember_zone_message(
|
||||
message_alias,
|
||||
payload,
|
||||
Some(result.inscription_id()),
|
||||
Some(sequencer_alias),
|
||||
checkpoint,
|
||||
);
|
||||
}
|
||||
|
||||
async fn wait_for_checkpoint_with_tx(
|
||||
rx: &mut tokio::sync::watch::Receiver<Option<SequencerCheckpoint>>,
|
||||
tx_hash: TxHash,
|
||||
) -> Result<SequencerCheckpoint, String> {
|
||||
loop {
|
||||
let snapshot = rx.borrow_and_update().clone();
|
||||
if let Some(checkpoint) = snapshot
|
||||
&& checkpoint
|
||||
.pending_txs
|
||||
.iter()
|
||||
.any(|(hash, _)| *hash == tx_hash)
|
||||
{
|
||||
return Ok(checkpoint);
|
||||
}
|
||||
|
||||
rx.changed()
|
||||
.await
|
||||
.map_err(|error| format!("checkpoint watch closed: {error}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_zone_wallet(
|
||||
world: &CucumberWorld,
|
||||
sequencer_alias: &str,
|
||||
) -> Result<WalletInfo, StepError> {
|
||||
let wallet_name = world.zone.sequencer_default_wallet_name(sequencer_alias)?;
|
||||
|
||||
world.resolve_wallet(wallet_name)
|
||||
}
|
||||
|
||||
fn record_zone_wallet_submission(
|
||||
world: &CucumberWorld,
|
||||
wallet_name: &str,
|
||||
tx_hash: TxHash,
|
||||
reserved_inputs: Vec<Utxo>,
|
||||
) -> StepResult {
|
||||
world.with_wallets_mut(|wallets| {
|
||||
wallets.record_wallet_reservation(
|
||||
wallet_name.to_owned(),
|
||||
tx_hash,
|
||||
WalletReservedInputs::new(reserved_inputs, Vec::new()),
|
||||
0,
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
pub(in super::super) async fn submit_zone_deposit_transaction(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
transaction_alias: String,
|
||||
channel_alias: String,
|
||||
amount: u64,
|
||||
metadata: Metadata,
|
||||
) -> StepResult {
|
||||
let node_url = log_step_error(step, world.zone_node_url_for_sequencer(&channel_alias))?;
|
||||
let wallet = log_step_error(step, resolve_zone_wallet(world, &channel_alias))?;
|
||||
let public_key = log_step_error(step, wallet.public_key())?;
|
||||
let available_utxos = log_step_error(
|
||||
step,
|
||||
current_available_utxos_for_wallet(world, &step.value, &wallet.wallet_name).await,
|
||||
)?;
|
||||
let ZoneDeposit {
|
||||
deposit,
|
||||
reserved_inputs,
|
||||
channel_notes,
|
||||
} = build_zone_deposit(
|
||||
available_utxos,
|
||||
world.zone.sequencer_channel_id(&channel_alias)?,
|
||||
amount,
|
||||
metadata,
|
||||
)
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
let response = submit_zone_deposit(&node_url, &deposit, public_key)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
world
|
||||
.zone
|
||||
.remember_deposit_channel_notes(transaction_alias.clone(), channel_notes);
|
||||
world
|
||||
.zone
|
||||
.remember_submitted_deposit(transaction_alias.clone(), deposit, amount);
|
||||
record_zone_wallet_submission(world, &wallet.wallet_name, response, reserved_inputs)?;
|
||||
world.remember_submitted_transaction(transaction_alias, response);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Submits a multi-input channel deposit that consumes one wallet note per
|
||||
/// listed value, exercising the channel wallet's per-note value tracking.
|
||||
pub(in super::super) async fn submit_zone_multi_deposit_transaction(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
transaction_alias: String,
|
||||
channel_alias: String,
|
||||
input_values: Vec<u64>,
|
||||
metadata: Metadata,
|
||||
) -> StepResult {
|
||||
let node_url = log_step_error(step, world.zone_node_url_for_sequencer(&channel_alias))?;
|
||||
let wallet = log_step_error(step, resolve_zone_wallet(world, &channel_alias))?;
|
||||
let public_key = log_step_error(step, wallet.public_key())?;
|
||||
let available_utxos = log_step_error(
|
||||
step,
|
||||
current_available_utxos_for_wallet(world, &step.value, &wallet.wallet_name).await,
|
||||
)?;
|
||||
let amount: u64 = input_values.iter().sum();
|
||||
let ZoneDeposit {
|
||||
deposit,
|
||||
reserved_inputs,
|
||||
channel_notes,
|
||||
} = build_zone_deposit_from_values(
|
||||
available_utxos,
|
||||
world.zone.sequencer_channel_id(&channel_alias)?,
|
||||
&input_values,
|
||||
metadata,
|
||||
)
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
let response = submit_zone_deposit(&node_url, &deposit, public_key)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
world
|
||||
.zone
|
||||
.remember_deposit_channel_notes(transaction_alias.clone(), channel_notes);
|
||||
world
|
||||
.zone
|
||||
.remember_submitted_deposit(transaction_alias.clone(), deposit, amount);
|
||||
record_zone_wallet_submission(world, &wallet.wallet_name, response, reserved_inputs)?;
|
||||
world.remember_submitted_transaction(transaction_alias, response);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in super::super) async fn submit_zone_channel_split_transaction(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: &str,
|
||||
deposit_alias: &str,
|
||||
dust_count: usize,
|
||||
transaction_alias: String,
|
||||
) -> StepResult {
|
||||
let node_url = log_step_error(step, world.zone_node_url_for_sequencer(sequencer_alias))?;
|
||||
let wallet = log_step_error(step, resolve_zone_wallet(world, sequencer_alias))?;
|
||||
let public_key = log_step_error(step, wallet.public_key())?;
|
||||
let channel_id = world.zone.sequencer_channel_id(sequencer_alias)?;
|
||||
let signing_key =
|
||||
log_step_error(step, world.zone.sequencer_signing_key(sequencer_alias))?.clone();
|
||||
let input_note = *log_step_error(
|
||||
step,
|
||||
world.zone.resolve_deposit_channel_notes(deposit_alias),
|
||||
)?
|
||||
.first()
|
||||
.ok_or_else(|| StepError::LogicalError {
|
||||
message: format!("Zone deposit '{deposit_alias}' created no channel notes to split"),
|
||||
})?;
|
||||
|
||||
let tx_hash = submit_zone_channel_split(
|
||||
&node_url,
|
||||
channel_id,
|
||||
&signing_key,
|
||||
public_key,
|
||||
input_note,
|
||||
dust_count,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
world.remember_submitted_transaction(transaction_alias, tx_hash);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in super::super) async fn submit_atomic_zone_deposit_transaction(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: &str,
|
||||
transaction_alias: String,
|
||||
message_alias: String,
|
||||
amount: u64,
|
||||
metadata: Metadata,
|
||||
) -> StepResult {
|
||||
let node_url = log_step_error(step, world.zone_node_url_for_sequencer(sequencer_alias))?;
|
||||
let wallet = log_step_error(step, resolve_zone_wallet(world, sequencer_alias))?;
|
||||
let public_key = log_step_error(step, wallet.public_key())?;
|
||||
let available_utxos = log_step_error(
|
||||
step,
|
||||
current_available_utxos_for_wallet(world, &step.value, &wallet.wallet_name).await,
|
||||
)?;
|
||||
let sequencer = log_step_error(step, world.zone.sequencer_client(sequencer_alias))?;
|
||||
let inscription_data = make_inscription(&format!("Mint {amount} to Alice"));
|
||||
|
||||
let submission = submit_atomic_zone_deposit(
|
||||
&node_url,
|
||||
sequencer,
|
||||
AtomicZoneDepositRequest {
|
||||
channel_id: world.zone.sequencer_channel_id(sequencer_alias)?,
|
||||
funding_public_key: public_key,
|
||||
available_utxos,
|
||||
amount,
|
||||
metadata,
|
||||
inscription_data: inscription_data.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
world
|
||||
.zone
|
||||
.remember_submitted_deposit(transaction_alias.clone(), submission.deposit, amount);
|
||||
remember_published_zone_message(
|
||||
world,
|
||||
sequencer_alias,
|
||||
message_alias,
|
||||
inscription_data,
|
||||
&submission.publish,
|
||||
);
|
||||
record_zone_wallet_submission(
|
||||
world,
|
||||
&wallet.wallet_name,
|
||||
submission.publish.inscription_id(),
|
||||
submission.reserved_inputs,
|
||||
)?;
|
||||
world.remember_submitted_transaction(transaction_alias, submission.publish.inscription_id());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in super::super) async fn submit_zone_withdraw_transaction(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: &str,
|
||||
transaction_alias: String,
|
||||
message_alias: String,
|
||||
amount: u64,
|
||||
) -> StepResult {
|
||||
let wallet = log_step_error(step, resolve_zone_wallet(world, sequencer_alias))?;
|
||||
let public_key = log_step_error(step, wallet.public_key())?;
|
||||
let sequencer = log_step_error(step, world.zone.sequencer_client(sequencer_alias))?;
|
||||
let inscription_data = make_inscription(&format!("Burn {amount}"));
|
||||
|
||||
let submission = submit_zone_withdraw(
|
||||
sequencer,
|
||||
world.zone.sequencer_channel_id(sequencer_alias)?,
|
||||
public_key,
|
||||
amount,
|
||||
inscription_data.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
world
|
||||
.zone
|
||||
.remember_submitted_withdraw(transaction_alias.clone(), submission.withdraw);
|
||||
remember_published_zone_message(
|
||||
world,
|
||||
sequencer_alias,
|
||||
message_alias,
|
||||
inscription_data,
|
||||
&submission.publish,
|
||||
);
|
||||
world.remember_submitted_transaction(transaction_alias, submission.publish.inscription_id());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Action wrapper for the new `publish_atomic_withdraw` SDK API. Mirrors
|
||||
/// [`submit_zone_withdraw_transaction`] but uses the high-level fire-and-forget
|
||||
/// flow: SDK fills the withdraw nonce, locates its own accredited-key index,
|
||||
/// builds the bundled `MantleTx`, signs locally, and submits.
|
||||
///
|
||||
/// `withdraw_rows` carries one `(alias, outputs)` per `WithdrawArg`; each
|
||||
/// withdraw is remembered under its own alias so multi-withdraw bundles can
|
||||
/// be asserted per-withdraw via the indexer step. `bundle_alias` is remembered
|
||||
/// as the bundle's tx hash for `zone transaction "..." is finalized`.
|
||||
pub(in super::super) async fn publish_atomic_zone_withdraw_transaction(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: &str,
|
||||
bundle_alias: String,
|
||||
message_alias: String,
|
||||
withdraw_rows: Vec<(String, Vec<u64>)>,
|
||||
) -> StepResult {
|
||||
let wallet = log_step_error(step, resolve_zone_wallet(world, sequencer_alias))?;
|
||||
let public_key = log_step_error(step, wallet.public_key())?;
|
||||
let total: u64 = withdraw_rows
|
||||
.iter()
|
||||
.flat_map(|(_, outputs)| outputs.iter())
|
||||
.sum();
|
||||
let inscription_data = make_inscription(&format!("Burn {total}"));
|
||||
let outputs_per_arg: Vec<Vec<u64>> = withdraw_rows
|
||||
.iter()
|
||||
.map(|(_, outputs)| outputs.clone())
|
||||
.collect();
|
||||
|
||||
let submission = {
|
||||
let sequencer = log_step_error(step, world.zone.sequencer_client(sequencer_alias))?.clone();
|
||||
|
||||
publish_atomic_zone_withdraw(
|
||||
&sequencer,
|
||||
public_key,
|
||||
outputs_per_arg,
|
||||
inscription_data.clone(),
|
||||
PublishDeadline::from_now(Duration::from_mins(3)),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?
|
||||
};
|
||||
|
||||
// A bundle carries a single `ChannelWithdrawOp` that releases every
|
||||
// recipient note the transfer created, regardless of how many withdraw args
|
||||
// were passed. Remember that one op under each row alias so per-withdraw
|
||||
// indexer assertions all resolve to the same finalized op.
|
||||
let [withdraw_op] = submission.withdraws.as_slice() else {
|
||||
return Err(zone_step_error(
|
||||
step,
|
||||
&ZoneTestError::SubmitWithdraw {
|
||||
message: format!(
|
||||
"atomic withdraw bundle produced {} withdraw ops, expected exactly 1",
|
||||
submission.withdraws.len(),
|
||||
),
|
||||
},
|
||||
));
|
||||
};
|
||||
for (alias, _) in &withdraw_rows {
|
||||
world
|
||||
.zone
|
||||
.remember_submitted_withdraw(alias.clone(), withdraw_op.clone());
|
||||
}
|
||||
remember_published_zone_message(
|
||||
world,
|
||||
sequencer_alias,
|
||||
message_alias,
|
||||
inscription_data,
|
||||
&submission.publish,
|
||||
);
|
||||
world.remember_submitted_transaction(bundle_alias, submission.publish.inscription_id());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use super::{
|
||||
CucumberWorld, Duration, HashMap, NodesToStartUnordered, NonZero, Step, StepResult, TARGET,
|
||||
WalletStartInfo, ZONE_SECURITY_PARAM, ZoneNodeResourcesRow, keygen,
|
||||
set_deployment_config_override, start_node, start_nodes_order_respecting_dependencies, warn,
|
||||
};
|
||||
|
||||
pub(in super::super) fn register_zone_sequencers_with_shared_key(
|
||||
world: &mut CucumberWorld,
|
||||
source_alias: &str,
|
||||
aliases: Vec<String>,
|
||||
) -> StepResult {
|
||||
let signing_key = world.zone.sequencer_signing_key(source_alias)?.clone();
|
||||
|
||||
for alias in aliases {
|
||||
world.zone.register_sequencer(alias, signing_key.clone());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in super::super) async fn start_nodes_with_zone_resources(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
rows: Vec<ZoneNodeResourcesRow>,
|
||||
) -> StepResult {
|
||||
apply_zone_timing_defaults(world, &step.value)?;
|
||||
|
||||
let nodes = collect_zone_nodes_to_start(&rows);
|
||||
let nodes = start_nodes_order_respecting_dependencies(
|
||||
nodes,
|
||||
world.nodes_info.keys().cloned().collect(),
|
||||
)
|
||||
.inspect_err(|error| {
|
||||
warn!(target: TARGET, "Step `{}` error: {error}", step.value);
|
||||
})?;
|
||||
|
||||
for (node_name, wallet_start_info, mut initial_peers) in nodes {
|
||||
initial_peers.sort();
|
||||
initial_peers.dedup();
|
||||
|
||||
start_node(
|
||||
world,
|
||||
&step.value,
|
||||
&node_name,
|
||||
&wallet_start_info,
|
||||
&initial_peers,
|
||||
false,
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
register_zone_resources(world, rows)
|
||||
}
|
||||
|
||||
fn apply_zone_timing_defaults(world: &mut CucumberWorld, step: &str) -> StepResult {
|
||||
world.set_cryptarchia_security_param(
|
||||
NonZero::new(ZONE_SECURITY_PARAM).expect("zone security parameter is non-zero"),
|
||||
);
|
||||
world.set_prolonged_bootstrap_period(Duration::ZERO);
|
||||
|
||||
set_deployment_config_override(world, step, "time.slot_duration", "seconds(1)")?;
|
||||
set_deployment_config_override(
|
||||
world,
|
||||
step,
|
||||
"cryptarchia.slot_activation_coeff.numerator",
|
||||
"1",
|
||||
)?;
|
||||
set_deployment_config_override(
|
||||
world,
|
||||
step,
|
||||
"cryptarchia.slot_activation_coeff.denominator",
|
||||
"2",
|
||||
)
|
||||
}
|
||||
|
||||
fn collect_zone_nodes_to_start(rows: &[ZoneNodeResourcesRow]) -> NodesToStartUnordered {
|
||||
let mut nodes = HashMap::new();
|
||||
|
||||
for row in rows {
|
||||
let entry = nodes
|
||||
.entry(row.node_name.clone())
|
||||
.or_insert_with(|| (Vec::new(), Vec::new()));
|
||||
|
||||
entry.0.push(WalletStartInfo {
|
||||
wallet_name: row.wallet_name.clone(),
|
||||
account_index: row.account_index,
|
||||
});
|
||||
|
||||
if let Some(peer) = &row.connected_to {
|
||||
entry.1.push(peer.clone());
|
||||
}
|
||||
}
|
||||
|
||||
nodes
|
||||
}
|
||||
|
||||
fn register_zone_resources(
|
||||
world: &mut CucumberWorld,
|
||||
rows: Vec<ZoneNodeResourcesRow>,
|
||||
) -> StepResult {
|
||||
for row in rows {
|
||||
for alias in row.sequencers {
|
||||
if !world.zone.has_sequencer(&alias) {
|
||||
world.zone.register_sequencer(alias.clone(), keygen());
|
||||
}
|
||||
|
||||
world.zone.attach_sequencer_resources(
|
||||
&alias,
|
||||
row.node_name.clone(),
|
||||
row.wallet_name.clone(),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
use std::{collections::HashMap, num::NonZero, time::Duration};
|
||||
|
||||
use cucumber::gherkin::Step;
|
||||
use futures::future::join_all;
|
||||
use lb_common_http_client::CommonHttpClient;
|
||||
use lb_core::mantle::{
|
||||
TxHash, Utxo,
|
||||
gas::GasCost,
|
||||
ops::channel::{config::Keys, deposit::Metadata, inscribe::Inscription},
|
||||
};
|
||||
use lb_key_management_system_service::keys::Ed25519Key;
|
||||
use lb_testing_framework::NodeHttpClient;
|
||||
use lb_zone_sdk::{
|
||||
adapter::NodeHttpClient as ZoneNodeHttpClient,
|
||||
sequencer::{FundingConfig, ZoneSequencer},
|
||||
};
|
||||
use tokio::{
|
||||
sync::broadcast,
|
||||
task::JoinHandle,
|
||||
time::{error::Elapsed, timeout},
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
use super::{
|
||||
AtomicZoneDepositRequest, CustomRepublishDeps, DiscardedPayloads, PolicyRuntime,
|
||||
PublishDeadline, ZoneAccountBalances, ZoneDeposit, ZoneTestError, build_zone_deposit,
|
||||
build_zone_deposit_from_values, ensure_zone_transactions_included,
|
||||
errors::{log_step_error, zone_step_error},
|
||||
keygen, publish_atomic_zone_withdraw, publish_message_with_retry,
|
||||
runner::{Event, PublishResult, SequencerCheckpoint, SequencerClient},
|
||||
sequencer_config, sequencer_config_with_pending_submit_depth, start_balance_aware_policy,
|
||||
start_custom_republish_policy, start_republish_lineage_policy, start_sequencer_event_loop,
|
||||
start_sorted_conflict_policy,
|
||||
steps::DEFAULT_ZONE_SEQUENCER,
|
||||
submit_atomic_zone_deposit, submit_zone_channel_split, submit_zone_deposit,
|
||||
submit_zone_withdraw,
|
||||
tables::{ConcurrentZoneMessageRow, ZoneNodeResourcesRow, group_zone_messages_by_sequencer},
|
||||
};
|
||||
use crate::{
|
||||
common::{
|
||||
mantle_inscription::make_inscription, manual_cluster::wait_for_height,
|
||||
wallet::WalletReservedInputs,
|
||||
},
|
||||
cucumber::{
|
||||
error::{StepError, StepResult},
|
||||
steps::{
|
||||
TARGET,
|
||||
nodes::{
|
||||
NodesToStartUnordered, WalletStartInfo,
|
||||
config_override::set_deployment_config_override, start_node,
|
||||
start_nodes_order_respecting_dependencies,
|
||||
},
|
||||
},
|
||||
wallet::sync::current_available_utxos_for_wallet,
|
||||
world::{CucumberWorld, WalletInfo, ZoneReaderConfig},
|
||||
},
|
||||
};
|
||||
|
||||
const ZONE_CHANNEL_WITHDRAW_THRESHOLD: u16 = 1;
|
||||
const ZONE_CHANNEL_DEPOSIT_THRESHOLD: u16 = 1;
|
||||
const SEQUENCER_READY_TIMEOUT: Duration = Duration::from_mins(2);
|
||||
const SEQUENCER_READY_POLL_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const SEQUENCER_READY_HEIGHT_ADVANCE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const ZONE_SECURITY_PARAM: u32 = 5;
|
||||
// These high-volume scenarios can move storage prices before all queued
|
||||
// publishes are included. Keep their test funding comfortably above the
|
||||
// public 12% default so they exercise sequencing rather than fee starvation.
|
||||
const ZONE_TEST_PRIORITY_FEE_PERCENT: u64 = 50;
|
||||
|
||||
pub(super) enum DriveMode {
|
||||
Passive {
|
||||
republish_orphans: bool,
|
||||
},
|
||||
RepublishLineage {
|
||||
planned: Vec<Inscription>,
|
||||
},
|
||||
Sorted {
|
||||
discarded: DiscardedPayloads,
|
||||
},
|
||||
BalanceAware {
|
||||
initial_balances: ZoneAccountBalances,
|
||||
planned_payloads: Vec<Inscription>,
|
||||
},
|
||||
CustomRepublish {
|
||||
deps: Box<CustomRepublishDeps>,
|
||||
},
|
||||
}
|
||||
|
||||
impl DriveMode {
|
||||
pub(super) const fn passive() -> Self {
|
||||
Self::Passive {
|
||||
republish_orphans: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) const fn passive_republish_orphans() -> Self {
|
||||
Self::Passive {
|
||||
republish_orphans: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PublishedZoneMessage {
|
||||
alias: String,
|
||||
payload: Inscription,
|
||||
result: PublishResult,
|
||||
}
|
||||
|
||||
struct StartedSequencerRuntime {
|
||||
task: JoinHandle<()>,
|
||||
client: SequencerClient,
|
||||
events: broadcast::Receiver<Event>,
|
||||
checkpoint_rx: tokio::sync::watch::Receiver<Option<SequencerCheckpoint>>,
|
||||
ready_rx: tokio::sync::watch::Receiver<bool>,
|
||||
channel_view_rx: tokio::sync::watch::Receiver<lb_zone_sdk::sequencer::SequencerChannelView>,
|
||||
turn_to_write_rx: tokio::sync::watch::Receiver<lb_zone_sdk::sequencer::TurnNotification>,
|
||||
tx_status_rx: broadcast::Receiver<lb_zone_sdk::sequencer::TxStatusUpdate>,
|
||||
discarded_payloads: Option<DiscardedPayloads>,
|
||||
}
|
||||
|
||||
mod channel;
|
||||
mod cluster;
|
||||
mod publishing;
|
||||
mod sequencer;
|
||||
|
||||
pub(super) use channel::{
|
||||
publish_atomic_zone_withdraw_transaction, remember_published_zone_message,
|
||||
save_zone_checkpoint, stop_zone_sequencer, submit_atomic_zone_deposit_transaction,
|
||||
submit_zone_channel_config, submit_zone_channel_split_transaction,
|
||||
submit_zone_deposit_transaction, submit_zone_multi_deposit_transaction,
|
||||
submit_zone_withdraw_transaction,
|
||||
};
|
||||
pub(super) use cluster::{
|
||||
register_zone_sequencers_with_shared_key, start_nodes_with_zone_resources,
|
||||
};
|
||||
pub(super) use publishing::{
|
||||
initialize_zone_indexer, publish_zone_messages, publish_zone_messages_concurrently,
|
||||
};
|
||||
pub(super) use sequencer::{
|
||||
start_named_sequencer, start_named_sequencer_with_pending_submit_depth,
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
use super::{
|
||||
ConcurrentZoneMessageRow, CucumberWorld, DEFAULT_ZONE_SEQUENCER, Duration, Inscription,
|
||||
PublishDeadline, PublishedZoneMessage, Step, StepError, StepResult, ZoneReaderConfig,
|
||||
ensure_zone_transactions_included, group_zone_messages_by_sequencer, join_all, log_step_error,
|
||||
publish_message_with_retry, remember_published_zone_message, zone_step_error,
|
||||
};
|
||||
|
||||
pub(in super::super) fn initialize_zone_indexer(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: impl AsRef<str>,
|
||||
) -> StepResult {
|
||||
let sequencer_alias = sequencer_alias.as_ref();
|
||||
let node_url = log_step_error(step, world.zone_node_url_for_sequencer(sequencer_alias))?;
|
||||
let indexer = ZoneReaderConfig {
|
||||
channel_id: world.zone.sequencer_channel_id(sequencer_alias)?,
|
||||
node_url,
|
||||
};
|
||||
|
||||
world.zone.set_indexer(indexer);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in super::super) async fn publish_zone_messages(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: impl AsRef<str>,
|
||||
rows: Vec<(String, Inscription)>,
|
||||
) -> StepResult {
|
||||
let sequencer_alias = sequencer_alias.as_ref().to_owned();
|
||||
let node = log_step_error(
|
||||
step,
|
||||
world.zone_node_http_client_for_sequencer(&sequencer_alias),
|
||||
)?;
|
||||
|
||||
let published = {
|
||||
let sequencer =
|
||||
log_step_error(step, world.zone.sequencer_client(&sequencer_alias))?.clone();
|
||||
|
||||
let publish_deadline = PublishDeadline::from_now(Duration::from_mins(3));
|
||||
let mut published = Vec::with_capacity(rows.len());
|
||||
|
||||
for (alias, payload) in &rows {
|
||||
let result = publish_message_with_retry(&sequencer, payload, publish_deadline)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
ensure_zone_transactions_included(
|
||||
&node,
|
||||
&[result.inscription_id()],
|
||||
Duration::from_mins(3),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
published.push(PublishedZoneMessage {
|
||||
alias: alias.clone(),
|
||||
payload: payload.clone(),
|
||||
result,
|
||||
});
|
||||
}
|
||||
|
||||
published
|
||||
};
|
||||
|
||||
for message in published {
|
||||
remember_published_zone_message(
|
||||
world,
|
||||
&sequencer_alias,
|
||||
message.alias,
|
||||
message.payload,
|
||||
&message.result,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in super::super) async fn publish_zone_messages_concurrently(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
rows: Vec<ConcurrentZoneMessageRow>,
|
||||
) -> StepResult {
|
||||
let grouped = group_zone_messages_by_sequencer(&rows);
|
||||
let handles = grouped
|
||||
.keys()
|
||||
.map(|sequencer_alias| {
|
||||
log_step_error(step, world.zone.sequencer_client(sequencer_alias))
|
||||
.map(|handle| (sequencer_alias.clone(), handle.clone()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
join_all(handles.into_iter().map(|(sequencer_alias, handle)| {
|
||||
let payloads = grouped[&sequencer_alias]
|
||||
.iter()
|
||||
.map(|message| message.payload.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
async move {
|
||||
for payload in payloads {
|
||||
handle.publish(payload).await.map_err(|error| {
|
||||
StepError::LogicalError {
|
||||
message: format!(
|
||||
"Zone concurrent publish failed for sequencer '{sequencer_alias}': {error}"
|
||||
),
|
||||
}
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok::<(), StepError>(())
|
||||
}
|
||||
}))
|
||||
.await
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
for row in rows {
|
||||
world
|
||||
.zone
|
||||
.remember_zone_message(row.message_alias, row.payload, None, None, None);
|
||||
}
|
||||
|
||||
if world.zone.indexer().is_err() {
|
||||
initialize_zone_indexer(world, step, DEFAULT_ZONE_SEQUENCER)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
use super::{
|
||||
CommonHttpClient, CucumberWorld, DiscardedPayloads, DriveMode, Elapsed, FundingConfig, GasCost,
|
||||
NodeHttpClient, PolicyRuntime, SEQUENCER_READY_HEIGHT_ADVANCE_TIMEOUT,
|
||||
SEQUENCER_READY_POLL_TIMEOUT, SEQUENCER_READY_TIMEOUT, SequencerCheckpoint,
|
||||
StartedSequencerRuntime, Step, StepError, StepResult, ZONE_TEST_PRIORITY_FEE_PERCENT,
|
||||
ZoneNodeHttpClient, ZoneSequencer, log_step_error, sequencer_config,
|
||||
sequencer_config_with_pending_submit_depth, start_balance_aware_policy,
|
||||
start_custom_republish_policy, start_republish_lineage_policy, start_sequencer_event_loop,
|
||||
start_sorted_conflict_policy, timeout, wait_for_height,
|
||||
};
|
||||
|
||||
pub(in super::super) async fn start_named_sequencer(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: impl AsRef<str>,
|
||||
checkpoint: Option<SequencerCheckpoint>,
|
||||
mode: DriveMode,
|
||||
) -> StepResult {
|
||||
let funding = log_step_error(step, sequencer_funding(world, sequencer_alias.as_ref()))?;
|
||||
start_named_sequencer_with_config(
|
||||
world,
|
||||
step,
|
||||
sequencer_alias,
|
||||
checkpoint,
|
||||
mode,
|
||||
sequencer_config(funding),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Fund sequencer transactions from the node's own funding wallet.
|
||||
fn sequencer_funding(
|
||||
world: &CucumberWorld,
|
||||
sequencer_alias: &str,
|
||||
) -> Result<FundingConfig, StepError> {
|
||||
let node_name = world.zone.sequencer_node_name(sequencer_alias)?;
|
||||
let funding_pk = world.funding_wallet(node_name)?.public_key()?;
|
||||
Ok(FundingConfig {
|
||||
funding_pk,
|
||||
change_pk: None,
|
||||
max_tx_fee: GasCost::new(u64::MAX),
|
||||
priority_fee_percent: ZONE_TEST_PRIORITY_FEE_PERCENT,
|
||||
})
|
||||
}
|
||||
|
||||
pub(in super::super) async fn start_named_sequencer_with_pending_submit_depth(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: impl AsRef<str>,
|
||||
checkpoint: Option<SequencerCheckpoint>,
|
||||
mode: DriveMode,
|
||||
max_pending_publish_depth: usize,
|
||||
) -> StepResult {
|
||||
let funding = log_step_error(step, sequencer_funding(world, sequencer_alias.as_ref()))?;
|
||||
let config = sequencer_config_with_pending_submit_depth(max_pending_publish_depth, funding);
|
||||
|
||||
start_named_sequencer_with_config(world, step, sequencer_alias, checkpoint, mode, config).await
|
||||
}
|
||||
|
||||
async fn start_named_sequencer_with_config(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: impl AsRef<str>,
|
||||
checkpoint: Option<SequencerCheckpoint>,
|
||||
mode: DriveMode,
|
||||
config: lb_zone_sdk::sequencer::SequencerConfig,
|
||||
) -> StepResult {
|
||||
let sequencer_alias = sequencer_alias.as_ref().to_owned();
|
||||
let signing_key =
|
||||
log_step_error(step, world.zone.sequencer_signing_key(&sequencer_alias))?.clone();
|
||||
let node_client = log_step_error(
|
||||
step,
|
||||
world.zone_node_http_client_for_sequencer(&sequencer_alias),
|
||||
)?;
|
||||
let node_url = log_step_error(step, world.zone_node_url_for_sequencer(&sequencer_alias))?;
|
||||
let sequencer = ZoneSequencer::init_with_config(
|
||||
world.zone.sequencer_channel_id(&sequencer_alias)?,
|
||||
signing_key,
|
||||
ZoneNodeHttpClient::new(CommonHttpClient::new(None), node_url),
|
||||
config,
|
||||
checkpoint,
|
||||
);
|
||||
|
||||
let runtime = start_sequencer_runtime(sequencer, mode);
|
||||
let mut ready_rx = runtime.ready_rx.clone();
|
||||
|
||||
if let Err(error) =
|
||||
wait_for_sequencer_ready(&sequencer_alias, &node_client, &mut ready_rx).await
|
||||
{
|
||||
runtime.task.abort();
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
world.zone.set_sequencer_runtime(
|
||||
sequencer_alias,
|
||||
runtime.client,
|
||||
runtime.task,
|
||||
runtime.events,
|
||||
runtime.checkpoint_rx,
|
||||
runtime.channel_view_rx,
|
||||
runtime.turn_to_write_rx,
|
||||
runtime.tx_status_rx,
|
||||
runtime.discarded_payloads,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_sequencer_ready(
|
||||
sequencer_alias: &str,
|
||||
node_client: &NodeHttpClient,
|
||||
ready_rx: &mut tokio::sync::watch::Receiver<bool>,
|
||||
) -> StepResult {
|
||||
timeout(SEQUENCER_READY_TIMEOUT, async {
|
||||
let mut last_height = node_client.consensus_info().await?.cryptarchia_info.height;
|
||||
|
||||
loop {
|
||||
let poll = timeout(SEQUENCER_READY_POLL_TIMEOUT, async {
|
||||
loop {
|
||||
if ready_rx.changed().await.is_err() {
|
||||
return Err(());
|
||||
}
|
||||
if *ready_rx.borrow() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
if matches!(poll, Ok(Ok(()))) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let _ = wait_for_height(
|
||||
node_client,
|
||||
last_height.saturating_add(1),
|
||||
SEQUENCER_READY_HEIGHT_ADVANCE_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
|
||||
last_height = node_client
|
||||
.consensus_info()
|
||||
.await?
|
||||
.cryptarchia_info
|
||||
.height
|
||||
.max(last_height);
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_: Elapsed| StepError::Timeout {
|
||||
message: format!(
|
||||
"Sequencer `{sequencer_alias}` did not become ready within {} seconds",
|
||||
SEQUENCER_READY_TIMEOUT.as_secs()
|
||||
),
|
||||
})?
|
||||
}
|
||||
|
||||
fn from_policy_runtime(
|
||||
rt: PolicyRuntime,
|
||||
discarded_payloads: Option<DiscardedPayloads>,
|
||||
) -> StartedSequencerRuntime {
|
||||
StartedSequencerRuntime {
|
||||
task: rt.task,
|
||||
client: rt.client,
|
||||
events: rt.events,
|
||||
checkpoint_rx: rt.checkpoint_rx,
|
||||
ready_rx: rt.ready_rx,
|
||||
channel_view_rx: rt.channel_view_rx,
|
||||
turn_to_write_rx: rt.turn_to_write_rx,
|
||||
tx_status_rx: rt.tx_status_rx,
|
||||
discarded_payloads,
|
||||
}
|
||||
}
|
||||
|
||||
fn start_sequencer_runtime(
|
||||
sequencer: ZoneSequencer<ZoneNodeHttpClient>,
|
||||
mode: DriveMode,
|
||||
) -> StartedSequencerRuntime {
|
||||
match mode {
|
||||
DriveMode::Passive { republish_orphans } => from_policy_runtime(
|
||||
start_sequencer_event_loop(sequencer, republish_orphans),
|
||||
None,
|
||||
),
|
||||
DriveMode::RepublishLineage { planned } => {
|
||||
from_policy_runtime(start_republish_lineage_policy(sequencer, planned), None)
|
||||
}
|
||||
DriveMode::Sorted { discarded } => from_policy_runtime(
|
||||
start_sorted_conflict_policy(sequencer, &discarded),
|
||||
Some(discarded),
|
||||
),
|
||||
DriveMode::BalanceAware {
|
||||
initial_balances,
|
||||
planned_payloads,
|
||||
} => from_policy_runtime(
|
||||
start_balance_aware_policy(sequencer, initial_balances, planned_payloads),
|
||||
None,
|
||||
),
|
||||
DriveMode::CustomRepublish { deps } => {
|
||||
from_policy_runtime(start_custom_republish_policy(sequencer, *deps), None)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@ use std::{
|
||||
|
||||
use lb_core::mantle::ops::channel::inscribe::Inscription;
|
||||
|
||||
use super::support::{
|
||||
use super::{
|
||||
DiscardedPayloads, ZoneTestError, replay_finalized_history, replayed_inscription_payloads,
|
||||
};
|
||||
use crate::cucumber::{
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
use cucumber::gherkin::Step;
|
||||
use tracing::warn;
|
||||
|
||||
use super::support::ZoneTestError;
|
||||
use super::ZoneTestError;
|
||||
use crate::cucumber::{error::StepError, steps::TARGET};
|
||||
|
||||
pub(super) fn zone_step_error(step: &Step, error: &ZoneTestError) -> StepError {
|
||||
@@ -0,0 +1,26 @@
|
||||
mod actions;
|
||||
mod assertions;
|
||||
mod errors;
|
||||
mod operations;
|
||||
pub mod runner;
|
||||
pub mod steps;
|
||||
mod tables;
|
||||
|
||||
use operations::{
|
||||
AtomicZoneDepositRequest, CustomRepublishDeps, DiscardedPayloads, PolicyRuntime,
|
||||
PublishDeadline, ZoneAccountBalances, ZoneDeposit, ZoneTestError, balance_update_payload,
|
||||
build_zone_deposit, build_zone_deposit_from_values, collect_indexed_messages,
|
||||
collect_indexed_messages_exactly_once, ensure_zone_transactions_included, keygen,
|
||||
parse_balance_payload, publish_atomic_zone_withdraw, publish_message_with_retry,
|
||||
replay_finalized_history, replayed_inscription_payloads, sequencer_config,
|
||||
sequencer_config_with_pending_submit_depth, start_balance_aware_policy,
|
||||
start_custom_republish_policy, start_republish_lineage_policy, start_sequencer_event_loop,
|
||||
start_sorted_conflict_policy, submit_atomic_zone_deposit, submit_zone_channel_split,
|
||||
submit_zone_deposit, submit_zone_withdraw, wait_for_channel_transfer_input_count,
|
||||
wait_for_channel_view, wait_for_channel_wallet_counts, wait_for_channel_wallet_note,
|
||||
wait_for_deposit, wait_for_exact_indexed_payload_count,
|
||||
wait_for_finalized_deposit_via_sequencer_and_collect_mempool_pending,
|
||||
wait_for_finalized_withdraw_via_sequencer_and_collect_mempool_pending, wait_for_lib_advance,
|
||||
wait_for_on_chain_statuses_and_collect_mempool_pending, wait_for_transactions_finalized,
|
||||
wait_for_turn_to_write, wait_for_tx_status_lifecycle, wait_for_withdraw,
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
use super::*;
|
||||
|
||||
/// Builds the funding transfer that creates the note consumed by an atomic
|
||||
/// zone deposit.
|
||||
/// Generous fee margin for the atomic `[Transfer, Deposit, Inscribe]`
|
||||
/// transaction. The mandatory fee (execution + size-based storage gas) is
|
||||
/// roughly 2k and varies with input count and change-note presence, so a
|
||||
/// tight margin intermittently underfunds the tx — which is permanently
|
||||
/// invalid and silently evicted at block assembly. Matches
|
||||
/// `MAX_ZONE_DEPOSIT_TX_FEE`; the excess above the mandatory fee is a tip.
|
||||
const ATOMIC_DEPOSIT_FEE_MARGIN: u64 = 10_000;
|
||||
|
||||
pub(super) fn build_atomic_deposit_transfer(
|
||||
available_utxos: Vec<Utxo>,
|
||||
funding_public_key: ZkPublicKey,
|
||||
amount: Value,
|
||||
) -> Result<(TransferOp, Vec<Utxo>), ZoneTestError> {
|
||||
let deposit_note = Note::new(amount, funding_public_key);
|
||||
let funded_transfer = build_wallet_funded_transfer(
|
||||
available_utxos,
|
||||
vec![deposit_note],
|
||||
funding_public_key,
|
||||
ATOMIC_DEPOSIT_FEE_MARGIN,
|
||||
)
|
||||
.map_err(|error| ZoneTestError::BuildAtomicDeposit {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
|
||||
Ok(funded_transfer.into_parts())
|
||||
}
|
||||
|
||||
/// Points the channel deposit at the note created by the atomic funding
|
||||
/// transfer, keeping both operations in the same transaction.
|
||||
pub(super) fn build_atomic_deposit_op(
|
||||
channel_id: ChannelId,
|
||||
metadata: Metadata,
|
||||
transfer: &TransferOp,
|
||||
) -> Result<DepositOp, ZoneTestError> {
|
||||
let deposit_note_id = transfer
|
||||
.outputs
|
||||
.utxo_by_index(0, transfer)
|
||||
.ok_or_else(|| ZoneTestError::BuildAtomicDeposit {
|
||||
message: "transfer did not produce the deposit note".to_owned(),
|
||||
})?
|
||||
.id();
|
||||
|
||||
Ok(DepositOp {
|
||||
channel_id,
|
||||
inputs: Inputs::new([deposit_note_id]),
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
|
||||
/// Submits a channel withdraw signed by the active zone sequencer and publishes
|
||||
/// the withdraw inscription as part of the same SDK flow.
|
||||
///
|
||||
/// The withdraw pays a single note of `amount` back to `funding_public_key`
|
||||
/// (self-withdraw). Inputs are selected automatically by the SDK
|
||||
/// (`WithdrawInputs::Auto`, best-fit largest-first, capped at 255 inputs).
|
||||
pub async fn submit_zone_withdraw(
|
||||
client: &SequencerClient,
|
||||
_channel_id: ChannelId,
|
||||
funding_public_key: ZkPublicKey,
|
||||
amount: Value,
|
||||
inscription_data: Inscription,
|
||||
) -> Result<ZoneWithdrawSubmission, ZoneTestError> {
|
||||
let (result, _cp) = client
|
||||
.publish_atomic_withdraw(
|
||||
inscription_data,
|
||||
vec![WithdrawArg {
|
||||
outputs: Outputs::new([Note::new(amount, funding_public_key)]),
|
||||
}],
|
||||
WithdrawInputs::Auto,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| ZoneTestError::SubmitWithdraw {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
|
||||
let PendingTx::AtomicWithdraw(info) = result.tx else {
|
||||
return Err(ZoneTestError::SubmitWithdraw {
|
||||
message: "publish_atomic_withdraw returned a non-AtomicWithdraw publish result"
|
||||
.to_owned(),
|
||||
});
|
||||
};
|
||||
let withdraw = info
|
||||
.withdraws
|
||||
.first()
|
||||
.ok_or_else(|| ZoneTestError::SubmitWithdraw {
|
||||
message: "atomic withdraw bundle had no withdraw ops".to_owned(),
|
||||
})?
|
||||
.op
|
||||
.clone();
|
||||
|
||||
Ok(ZoneWithdrawSubmission {
|
||||
withdraw,
|
||||
publish: PublishResult {
|
||||
tx: PendingTx::AtomicWithdraw(info),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Result of publishing an atomic inscription+withdraw bundle. Carries every
|
||||
/// withdraw op produced by the SDK (one per `WithdrawArg`, in submission
|
||||
/// order) so a multi-withdraw scenario can match each by its outputs.
|
||||
pub struct ZoneAtomicWithdrawSubmission {
|
||||
pub withdraws: Vec<ChannelWithdrawOp>,
|
||||
pub publish: PublishResult,
|
||||
}
|
||||
|
||||
/// Publishes an atomic inscription+withdraw bundle through the runner.
|
||||
/// Returns every withdraw op (with the nonce filled by the SDK) from the
|
||||
/// publish call's return value, so downstream cucumber assertions can
|
||||
/// match each withdraw by its outputs.
|
||||
///
|
||||
/// `outputs_per_arg` carries one entry per `WithdrawArg`; each inner `Vec`
|
||||
/// becomes that arg's `Outputs` (one `Note::new(amount, funding_pk)` per
|
||||
/// listed amount). Exercises the SDK API at full width: multiple args, with
|
||||
/// any arg able to carry multiple output notes.
|
||||
pub async fn publish_atomic_zone_withdraw(
|
||||
client: &SequencerClient,
|
||||
funding_public_key: ZkPublicKey,
|
||||
outputs_per_arg: Vec<Vec<Value>>,
|
||||
inscription_data: Inscription,
|
||||
_deadline: PublishDeadline,
|
||||
) -> Result<ZoneAtomicWithdrawSubmission, ZoneTestError> {
|
||||
if outputs_per_arg.is_empty() {
|
||||
return Err(ZoneTestError::SubmitWithdraw {
|
||||
message: "publish_atomic_zone_withdraw requires at least one withdraw arg".to_owned(),
|
||||
});
|
||||
}
|
||||
let withdraw_args: Vec<WithdrawArg> = outputs_per_arg
|
||||
.iter()
|
||||
.map(|amounts| {
|
||||
Ok::<WithdrawArg, ZoneTestError>(WithdrawArg {
|
||||
outputs: Outputs::try_new(
|
||||
amounts
|
||||
.iter()
|
||||
.map(|amount| Note::new(*amount, funding_public_key))
|
||||
.collect::<Vec<_>>(),
|
||||
)?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, ZoneTestError>>()?;
|
||||
|
||||
let (result, _cp) = client
|
||||
.publish_atomic_withdraw(inscription_data, withdraw_args, WithdrawInputs::Auto)
|
||||
.await
|
||||
.map_err(|error| ZoneTestError::SubmitWithdraw {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
|
||||
let PendingTx::AtomicWithdraw(info) = result.tx else {
|
||||
return Err(ZoneTestError::SubmitWithdraw {
|
||||
message: "publish_atomic_withdraw returned a non-AtomicWithdraw publish result"
|
||||
.to_owned(),
|
||||
});
|
||||
};
|
||||
if info.withdraws.is_empty() {
|
||||
return Err(ZoneTestError::SubmitWithdraw {
|
||||
message: "atomic withdraw bundle had no withdraw ops".to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(ZoneAtomicWithdrawSubmission {
|
||||
withdraws: info.withdraws.iter().map(|w| w.op.clone()).collect(),
|
||||
publish: PublishResult {
|
||||
tx: PendingTx::AtomicWithdraw(info),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Asks the node wallet service to sign a Mantle transaction for the requested
|
||||
/// ZK keys.
|
||||
pub(super) async fn sign_tx_zk(
|
||||
node_url: &Url,
|
||||
tx: &RawMantleTx,
|
||||
public_keys: Vec<ZkPublicKey>,
|
||||
) -> Result<ZkSignature, ZoneTestError> {
|
||||
let request_url =
|
||||
node_url
|
||||
.join("wallet/sign/zk")
|
||||
.map_err(|error| ZoneTestError::SignTransaction {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
let response: WalletSignTxZkResponseBody = CommonHttpClient::new(None)
|
||||
.post(
|
||||
request_url,
|
||||
&WalletSignTxZkRequestBody {
|
||||
tx_hash: tx.hash(),
|
||||
pks: ZkPublicKeys::try_from(public_keys)?,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| ZoneTestError::SignTransaction {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
|
||||
Ok(response.sig)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
use super::{
|
||||
ChannelId, ChannelUpdateTx, Ed25519Key, Event, HashSet, Inscription, MsgId, NodeHttpClient,
|
||||
PolicyRuntime, SequencerChannelView, VecDeque, ZkPublicKey, ZoneNodeHttpClient, ZoneSequencer,
|
||||
build_funded_custom_tx, channel_inscriptions, finalized_inscriptions, runner,
|
||||
to_policy_runtime, warn,
|
||||
};
|
||||
|
||||
pub struct CustomRepublishDeps {
|
||||
pub node_client: NodeHttpClient,
|
||||
pub channel_id: ChannelId,
|
||||
pub signing_key: Ed25519Key,
|
||||
pub funding_pk: ZkPublicKey,
|
||||
pub batches: VecDeque<Vec<Inscription>>,
|
||||
}
|
||||
|
||||
pub fn start_custom_republish_policy(
|
||||
sequencer: ZoneSequencer<ZoneNodeHttpClient>,
|
||||
deps: CustomRepublishDeps,
|
||||
) -> PolicyRuntime {
|
||||
let view_rx = sequencer.subscribe_channel_view();
|
||||
let policy = CustomRepublishPolicy {
|
||||
deps,
|
||||
view_rx,
|
||||
pending: HashSet::new(),
|
||||
finalized: HashSet::new(),
|
||||
chain_tip: None,
|
||||
ready: false,
|
||||
};
|
||||
to_policy_runtime(runner::spawn(sequencer, policy))
|
||||
}
|
||||
|
||||
/// [`OrphanRepublishPolicy`] for the custom-tx flow: orphans that are
|
||||
/// neither in `pending` nor finalized are rebuilt and re-submitted.
|
||||
struct CustomRepublishPolicy {
|
||||
deps: CustomRepublishDeps,
|
||||
view_rx: tokio::sync::watch::Receiver<SequencerChannelView>,
|
||||
pending: HashSet<Inscription>,
|
||||
finalized: HashSet<Inscription>,
|
||||
/// Where our own submitted chain ends; reset on orphans so rebuilds
|
||||
/// chain from the channel tip instead.
|
||||
chain_tip: Option<MsgId>,
|
||||
/// No submissions until ready — a fail-fast submit would leak its
|
||||
/// funding reservation.
|
||||
ready: bool,
|
||||
}
|
||||
|
||||
impl CustomRepublishPolicy {
|
||||
async fn submit<Node>(
|
||||
&mut self,
|
||||
sequencer: &mut ZoneSequencer<Node>,
|
||||
payloads: Vec<Inscription>,
|
||||
) -> bool
|
||||
where
|
||||
Node: lb_zone_sdk::adapter::Node + Clone + Send + Sync + 'static,
|
||||
{
|
||||
let parent = self
|
||||
.chain_tip
|
||||
.unwrap_or_else(|| self.view_rx.borrow().tip_message);
|
||||
let built = build_funded_custom_tx(
|
||||
&self.deps.node_client,
|
||||
self.deps.channel_id,
|
||||
&self.deps.signing_key,
|
||||
self.deps.funding_pk,
|
||||
&payloads,
|
||||
parent,
|
||||
)
|
||||
.await;
|
||||
let (signed_tx, msg_id) = match built {
|
||||
Ok(built) => built,
|
||||
Err(error) => {
|
||||
warn!(%error, "Failed to build custom zone tx");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
match sequencer.handle().submit_signed_tx(signed_tx, msg_id) {
|
||||
Ok((_result, _checkpoint)) => {
|
||||
self.pending.extend(payloads);
|
||||
self.chain_tip = Some(msg_id);
|
||||
true
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(%error, "Failed to submit custom zone tx");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_payloads(&self, entry: &ChannelUpdateTx) -> Vec<Inscription> {
|
||||
match entry {
|
||||
ChannelUpdateTx::Custom(tx) => channel_inscriptions(tx, self.deps.channel_id)
|
||||
.into_iter()
|
||||
.map(|info| info.payload)
|
||||
.collect(),
|
||||
typed => typed
|
||||
.inscription()
|
||||
.map(|info| info.payload.clone())
|
||||
.into_iter()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Node> runner::Policy<Node> for CustomRepublishPolicy
|
||||
where
|
||||
Node: lb_zone_sdk::adapter::Node + Clone + Send + Sync + 'static,
|
||||
{
|
||||
async fn on_event(&mut self, sequencer: &mut ZoneSequencer<Node>, event: &Event) {
|
||||
let (channel_update, finalized) = match event {
|
||||
Event::Ready => {
|
||||
self.ready = true;
|
||||
(None, None)
|
||||
}
|
||||
Event::BlocksProcessed {
|
||||
channel_update,
|
||||
finalized,
|
||||
..
|
||||
} => (Some(channel_update), Some(finalized)),
|
||||
_ => return,
|
||||
};
|
||||
|
||||
if let Some(finalized) = finalized {
|
||||
self.finalized
|
||||
.extend(finalized_inscriptions(finalized).map(|info| info.payload.clone()));
|
||||
}
|
||||
|
||||
if let Some(channel_update) = channel_update {
|
||||
let orphaned: HashSet<Inscription> = channel_update
|
||||
.orphaned
|
||||
.iter()
|
||||
.flat_map(|entry| self.entry_payloads(entry))
|
||||
.collect();
|
||||
let adopted: Vec<Inscription> = channel_update
|
||||
.adopted
|
||||
.iter()
|
||||
.flat_map(|entry| self.entry_payloads(entry))
|
||||
.collect();
|
||||
for payload in &orphaned {
|
||||
self.pending.remove(payload);
|
||||
}
|
||||
self.pending.extend(adopted);
|
||||
|
||||
let republish: Vec<Inscription> = orphaned
|
||||
.into_iter()
|
||||
.filter(|payload| {
|
||||
!self.pending.contains(payload) && !self.finalized.contains(payload)
|
||||
})
|
||||
.collect();
|
||||
if self.ready && !republish.is_empty() {
|
||||
self.chain_tip = None;
|
||||
if !self.submit(sequencer, republish.clone()).await {
|
||||
self.deps.batches.push_back(republish);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One attempt per batch per event: a failed submission stops the
|
||||
// drain and is retried on the next event.
|
||||
while self.ready {
|
||||
let Some(batch) = self.deps.batches.pop_front() else {
|
||||
break;
|
||||
};
|
||||
if !self.submit(sequencer, batch.clone()).await {
|
||||
self.deps.batches.push_front(batch);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Zone SDK test helpers shared by Cucumber steps.
|
||||
//!
|
||||
//! The helpers in this module keep the feature steps focused on scenario
|
||||
//! intent: start a zone-backed node, run sequencers, publish messages, observe
|
||||
//! the indexer, and submit the channel operations that the zone layer relies
|
||||
//! on.
|
||||
|
||||
use std::{
|
||||
collections::{BTreeSet, HashMap, HashSet, VecDeque},
|
||||
sync::{Arc, LazyLock},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use lb_common_http_client::{CommonHttpClient, Slot};
|
||||
use lb_core::{
|
||||
mantle::{
|
||||
Note, Op, OpProof, RawMantleTx, Utxo, Value,
|
||||
gas::GasCost,
|
||||
ledger::{Inputs, Outputs, OutputsError},
|
||||
ops::{
|
||||
OpId as _,
|
||||
channel::{
|
||||
ChannelId, MsgId,
|
||||
channel_transfer::ChannelTransferOp,
|
||||
deposit::{DepositOp, Metadata},
|
||||
inscribe::{Inscription, InscriptionOp},
|
||||
withdraw::ChannelWithdrawOp,
|
||||
},
|
||||
transfer::TransferOp,
|
||||
},
|
||||
traits::Hashable as _,
|
||||
transactions::{OpsProofs, builder::MantleTxBuilder, states::Unverified},
|
||||
},
|
||||
proofs::channel_multi_sig_proof::{ChannelMultiSigProof, IndexedSignature},
|
||||
};
|
||||
use lb_http_api_common::bodies::{
|
||||
channel::{ChannelDepositRequestBody, ChannelDepositResponseBody},
|
||||
wallet::{
|
||||
fund::WalletFundRequestBody,
|
||||
sign::{WalletSignTxZkRequestBody, WalletSignTxZkResponseBody},
|
||||
},
|
||||
};
|
||||
use lb_key_management_system_service::keys::{Ed25519Key, ZkPublicKey, ZkPublicKeys, ZkSignature};
|
||||
use lb_node::SignedMantleTx;
|
||||
use lb_testing_framework::NodeHttpClient;
|
||||
use lb_zone_sdk::{
|
||||
adapter::NodeHttpClient as ZoneNodeHttpClient,
|
||||
sequencer::{ZoneSequencer, channel_inscriptions},
|
||||
};
|
||||
use rand::{Rng as _, thread_rng};
|
||||
use reqwest::Url;
|
||||
use tokio::{
|
||||
task::JoinHandle,
|
||||
time::{sleep, timeout},
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
use super::runner::{
|
||||
self, ChannelUpdate, ChannelUpdateTx, Event, FinalizedOp, FinalizedTx, FundingConfig,
|
||||
InscriptionId, InscriptionInfo, PendingTx, PublishResult, SequencerChannelView,
|
||||
SequencerCheckpoint, SequencerClient, SequencerConfig, TurnNotification, TxStatus,
|
||||
TxStatusUpdate, WithdrawArg, WithdrawInputs,
|
||||
};
|
||||
|
||||
/// Inscriptions in the just-finalized txs — the permanent, settled part of the
|
||||
/// channel. Once a payload finalizes it's on chain for good, so a policy pins
|
||||
/// these and never re-homes a finalized payload when it later drops off a
|
||||
/// non-canonical branch.
|
||||
fn finalized_inscriptions(finalized: &[FinalizedTx]) -> impl Iterator<Item = &InscriptionInfo> {
|
||||
finalized
|
||||
.iter()
|
||||
.flat_map(|tx| tx.ops.iter())
|
||||
.filter_map(|op| match op {
|
||||
FinalizedOp::Inscription(info) => Some(info),
|
||||
FinalizedOp::Deposit(_)
|
||||
| FinalizedOp::Withdraw(_)
|
||||
| FinalizedOp::Config(_)
|
||||
| FinalizedOp::ChannelTransfer(_) => None,
|
||||
})
|
||||
}
|
||||
use crate::{
|
||||
common::{
|
||||
chain::wait_for_transactions_inclusion, mantle_inscription::make_inscription,
|
||||
wallet::build_wallet_funded_transfer,
|
||||
},
|
||||
cucumber::world::ZoneReaderConfig,
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ZoneTestError {
|
||||
#[error("timed out waiting for zone sequencer to accept a publish request")]
|
||||
PublishTimeout,
|
||||
#[error("zone indexer request failed: {message}")]
|
||||
Indexer { message: String },
|
||||
#[error("timed out waiting for zone indexer to return all messages")]
|
||||
IndexerTimeout,
|
||||
#[error("zone indexer returned {actual} copies of '{payload}', expected {expected}")]
|
||||
IndexedPayloadCountMismatch {
|
||||
payload: String,
|
||||
expected: usize,
|
||||
actual: usize,
|
||||
},
|
||||
#[error("timed out waiting for zone transactions to appear on the canonical chain")]
|
||||
InclusionTimeout,
|
||||
#[error("failed to fetch consensus info while checking finalized transactions: {message}")]
|
||||
Consensus { message: String },
|
||||
#[error("failed to fetch block while checking finalized transactions: {message}")]
|
||||
Block { message: String },
|
||||
#[error("timed out waiting for zone transactions to finalize")]
|
||||
FinalizationTimeout,
|
||||
#[error("channel wallet request failed: {message}")]
|
||||
ChannelWallet { message: String },
|
||||
#[error("timed out waiting for a channel wallet note")]
|
||||
ChannelWalletTimeout,
|
||||
#[error("timed out waiting for zone LIB to advance")]
|
||||
LibAdvanceTimeout,
|
||||
#[error("timed out waiting for zone sequencer channel view condition: {message}")]
|
||||
ChannelViewTimeout { message: String },
|
||||
#[error("failed to find a funding note with exact value {value}")]
|
||||
MissingExactFundingNote { value: Value },
|
||||
#[error("failed to submit zone deposit: {message}")]
|
||||
SubmitDeposit { message: String },
|
||||
#[error("failed to submit zone channel split transfer: {message}")]
|
||||
SplitTransfer { message: String },
|
||||
#[error("failed to sign zone transaction: {message}")]
|
||||
SignTransaction { message: String },
|
||||
#[error("failed to build atomic zone deposit transaction: {message}")]
|
||||
BuildAtomicDeposit { message: String },
|
||||
#[error("failed to submit atomic zone deposit transaction: {message}")]
|
||||
SubmitAtomicDeposit { message: String },
|
||||
#[error("failed to submit zone withdraw transaction: {message}")]
|
||||
SubmitWithdraw { message: String },
|
||||
#[error("timed out waiting for zone withdraw to appear in the indexer")]
|
||||
WithdrawTimeout,
|
||||
#[error("failed to build custom zone transaction: {message}")]
|
||||
BuildCustomTx { message: String },
|
||||
#[error("failed to submit custom zone transaction: {message}")]
|
||||
SubmitCustomTx { message: String },
|
||||
#[error("zone sequencer event stream stopped before observing the expected event")]
|
||||
SequencerStopped,
|
||||
#[error(transparent)]
|
||||
BoundedError(#[from] lb_utils::bounded::BoundedError),
|
||||
#[error(transparent)]
|
||||
OutputsError(#[from] OutputsError),
|
||||
}
|
||||
|
||||
/// Result of an atomic deposit scenario where a deposit and zone inscription
|
||||
/// are submitted as one Mantle transaction.
|
||||
pub struct AtomicZoneDepositSubmission {
|
||||
pub deposit: DepositOp,
|
||||
pub publish: PublishResult,
|
||||
pub reserved_inputs: Vec<Utxo>,
|
||||
}
|
||||
|
||||
pub struct AtomicZoneDepositRequest {
|
||||
pub channel_id: ChannelId,
|
||||
pub funding_public_key: ZkPublicKey,
|
||||
pub available_utxos: Vec<Utxo>,
|
||||
pub amount: Value,
|
||||
pub inscription_data: Inscription,
|
||||
pub metadata: Metadata,
|
||||
}
|
||||
|
||||
/// Result of a withdraw scenario where the zone sequencer signs the channel
|
||||
/// withdraw and publishes the accompanying inscription.
|
||||
pub struct ZoneWithdrawSubmission {
|
||||
pub withdraw: ChannelWithdrawOp,
|
||||
pub publish: PublishResult,
|
||||
}
|
||||
|
||||
pub struct ZoneDeposit {
|
||||
pub deposit: DepositOp,
|
||||
pub reserved_inputs: Vec<Utxo>,
|
||||
/// The channel notes the deposit re-creates (1:1 with its inputs). Computed
|
||||
/// deterministically from the deposit's `op_id` and its input notes so a
|
||||
/// later channel transfer can spend them without waiting on the indexer.
|
||||
pub channel_notes: Vec<Utxo>,
|
||||
}
|
||||
|
||||
/// The channel notes a deposit re-creates: one per input, same note (value +
|
||||
/// pk), re-homed under the deposit's `op_id`. Matches the ledger's deposit
|
||||
/// execution (`DepositOp::outputs` re-creates inputs 1:1) and the zone-sdk's
|
||||
/// channel-note derivation.
|
||||
fn recreated_channel_notes(deposit: &DepositOp, reserved_inputs: &[Utxo]) -> Vec<Utxo> {
|
||||
let op_id = deposit.op_id();
|
||||
reserved_inputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, utxo)| Utxo::new(op_id, index, utxo.note))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub type DiscardedPayloads = Arc<tokio::sync::Mutex<HashSet<Inscription>>>;
|
||||
pub type ZoneAccountBalances = HashMap<String, i64>;
|
||||
|
||||
/// Shared deadline for a publish attempt and the matching event wait so the
|
||||
/// whole operation has one timeout budget.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct PublishDeadline {
|
||||
started_at: Instant,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl PublishDeadline {
|
||||
#[must_use]
|
||||
pub fn from_now(timeout: Duration) -> Self {
|
||||
Self {
|
||||
started_at: Instant::now(),
|
||||
timeout,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_expired(self) -> bool {
|
||||
self.started_at.elapsed() > self.timeout
|
||||
}
|
||||
}
|
||||
|
||||
/// Bundle returned from policy starters so callers can wire the cucumber
|
||||
/// world. Wraps [`runner::Runtime`] — events and checkpoints are exposed
|
||||
/// uniformly across all policies because the policy runs inline on the
|
||||
/// drive task; the event mpsc is purely for test observation.
|
||||
pub struct PolicyRuntime {
|
||||
pub task: JoinHandle<()>,
|
||||
pub client: SequencerClient,
|
||||
pub events: tokio::sync::broadcast::Receiver<Event>,
|
||||
pub checkpoint_rx: tokio::sync::watch::Receiver<Option<SequencerCheckpoint>>,
|
||||
pub ready_rx: tokio::sync::watch::Receiver<bool>,
|
||||
pub channel_view_rx: tokio::sync::watch::Receiver<SequencerChannelView>,
|
||||
pub turn_to_write_rx: tokio::sync::watch::Receiver<TurnNotification>,
|
||||
pub tx_status_rx: tokio::sync::broadcast::Receiver<TxStatusUpdate>,
|
||||
}
|
||||
|
||||
fn to_policy_runtime(rt: runner::Runtime) -> PolicyRuntime {
|
||||
PolicyRuntime {
|
||||
task: rt.task,
|
||||
client: rt.client,
|
||||
events: rt.event_rx,
|
||||
checkpoint_rx: rt.checkpoint_rx,
|
||||
ready_rx: rt.ready_rx,
|
||||
channel_view_rx: rt.channel_view_rx,
|
||||
turn_to_write_rx: rt.turn_to_write_rx,
|
||||
tx_status_rx: rt.tx_status_rx,
|
||||
}
|
||||
}
|
||||
|
||||
mod atomic;
|
||||
mod custom_policy;
|
||||
mod observation;
|
||||
mod policies;
|
||||
mod transactions;
|
||||
|
||||
use atomic::{build_atomic_deposit_op, build_atomic_deposit_transfer, sign_tx_zk};
|
||||
pub(super) use atomic::{publish_atomic_zone_withdraw, submit_zone_withdraw};
|
||||
pub(super) use custom_policy::{CustomRepublishDeps, start_custom_republish_policy};
|
||||
pub(super) use observation::{
|
||||
balance_update_payload, collect_indexed_messages, collect_indexed_messages_exactly_once,
|
||||
ensure_zone_transactions_included, keygen, parse_balance_payload, publish_message_with_retry,
|
||||
replay_finalized_history, replayed_inscription_payloads, sequencer_config,
|
||||
sequencer_config_with_pending_submit_depth, wait_for_channel_transfer_input_count,
|
||||
wait_for_channel_view, wait_for_channel_wallet_counts, wait_for_channel_wallet_note,
|
||||
wait_for_deposit, wait_for_exact_indexed_payload_count,
|
||||
wait_for_finalized_deposit_via_sequencer_and_collect_mempool_pending,
|
||||
wait_for_finalized_withdraw_via_sequencer_and_collect_mempool_pending, wait_for_lib_advance,
|
||||
wait_for_on_chain_statuses_and_collect_mempool_pending, wait_for_transactions_finalized,
|
||||
wait_for_turn_to_write, wait_for_tx_status_lifecycle, wait_for_withdraw,
|
||||
};
|
||||
pub(super) use policies::{
|
||||
start_balance_aware_policy, start_republish_lineage_policy, start_sequencer_event_loop,
|
||||
start_sorted_conflict_policy,
|
||||
};
|
||||
use transactions::build_funded_custom_tx;
|
||||
pub(super) use transactions::{
|
||||
build_zone_deposit, build_zone_deposit_from_values, submit_atomic_zone_deposit,
|
||||
submit_zone_channel_split, submit_zone_deposit,
|
||||
};
|
||||
@@ -0,0 +1,685 @@
|
||||
use super::*;
|
||||
|
||||
/// Creates a scenario-local sequencer key.
|
||||
#[must_use]
|
||||
pub fn keygen() -> Ed25519Key {
|
||||
let mut key_bytes = [0u8; 32];
|
||||
thread_rng().fill(&mut key_bytes);
|
||||
Ed25519Key::from_bytes(&key_bytes)
|
||||
}
|
||||
|
||||
/// Encodes a balance-affecting zone payload used by balance-aware sequencer
|
||||
/// scenarios.
|
||||
#[must_use]
|
||||
pub fn balance_update_payload(uuid: &str, account: &str, delta: i64) -> Inscription {
|
||||
make_inscription(&format!("{uuid}:{account}:{delta}"))
|
||||
}
|
||||
|
||||
/// Parses a balance-affecting payload in the same format produced by
|
||||
/// [`balance_update_payload`].
|
||||
pub fn parse_balance_payload(payload: &Inscription) -> Option<(String, String, i64)> {
|
||||
let payload = std::str::from_utf8(payload.as_slice()).ok()?;
|
||||
let parts = payload.splitn(3, ':').collect::<Vec<_>>();
|
||||
let [uuid, account, delta] = parts.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
Some((
|
||||
(*uuid).to_owned(),
|
||||
(*account).to_owned(),
|
||||
delta.parse().ok()?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Uses a short resubmit interval so retry-sensitive zone scenarios settle
|
||||
/// quickly enough for CI.
|
||||
#[must_use]
|
||||
pub const fn sequencer_config(funding: FundingConfig) -> SequencerConfig {
|
||||
SequencerConfig {
|
||||
resubmit_interval: Duration::from_secs(3),
|
||||
min_slots_remaining_in_turn: 2,
|
||||
..SequencerConfig::new(funding)
|
||||
}
|
||||
}
|
||||
|
||||
/// Uses the same retry profile while overriding pending publish submit depth.
|
||||
#[must_use]
|
||||
pub const fn sequencer_config_with_pending_submit_depth(
|
||||
max_pending_publish_depth: usize,
|
||||
funding: FundingConfig,
|
||||
) -> SequencerConfig {
|
||||
SequencerConfig {
|
||||
max_pending_publish_depth,
|
||||
..sequencer_config(funding)
|
||||
}
|
||||
}
|
||||
|
||||
/// Publishes a zone payload through the runner and returns the SDK's
|
||||
/// [`PublishResult`] inline. Retries transient publish errors until the
|
||||
/// deadline elapses. No "wait for event" — the SDK accepts the publish
|
||||
/// inline (funding it via the node when configured) and the runner forwards
|
||||
/// the call through the drive task.
|
||||
pub async fn publish_message_with_retry(
|
||||
client: &SequencerClient,
|
||||
data: &Inscription,
|
||||
deadline: PublishDeadline,
|
||||
) -> Result<PublishResult, ZoneTestError> {
|
||||
loop {
|
||||
if deadline.is_expired() {
|
||||
return Err(ZoneTestError::PublishTimeout);
|
||||
}
|
||||
match client.publish(data.clone()).await {
|
||||
Ok((result, _cp)) => return Ok(result),
|
||||
Err(error) => {
|
||||
warn!(error = %error, "Zone sequencer publish failed, retrying");
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits until every tx in `tx_hashes` reports [`TxStatus::OnChain`] on the
|
||||
/// sequencer's status stream, collecting the tx hashes seen as
|
||||
/// [`TxStatus::PendingMempool`] along the way. Own publishes don't echo in
|
||||
/// [`ChannelUpdate::adopted`] on chain extension (the sequencer already
|
||||
/// tracks them), so the per-tx status stream is where "landed on chain, not
|
||||
/// yet finalized" is observable.
|
||||
pub async fn wait_for_on_chain_statuses_and_collect_mempool_pending(
|
||||
statuses: &mut tokio::sync::broadcast::Receiver<TxStatusUpdate>,
|
||||
tx_hashes: &[InscriptionId],
|
||||
duration: Duration,
|
||||
) -> Result<HashSet<InscriptionId>, ZoneTestError> {
|
||||
timeout(duration, async {
|
||||
let mut on_chain: HashSet<InscriptionId> = HashSet::new();
|
||||
let mut mempool_pending = HashSet::new();
|
||||
|
||||
while on_chain.len() < tx_hashes.len() {
|
||||
let update = match statuses.recv().await {
|
||||
Ok(update) => update,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("status subscriber lagged by {n}, recovering");
|
||||
continue;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
return Err(ZoneTestError::SequencerStopped);
|
||||
}
|
||||
};
|
||||
match update.status {
|
||||
TxStatus::PendingMempool => {
|
||||
mempool_pending.insert(update.tx_hash);
|
||||
}
|
||||
TxStatus::OnChain(_) if tx_hashes.contains(&update.tx_hash) => {
|
||||
on_chain.insert(update.tx_hash);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(mempool_pending)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ZoneTestError::PublishTimeout)?
|
||||
}
|
||||
|
||||
pub async fn wait_for_tx_status_lifecycle(
|
||||
tx_status_rx: &mut tokio::sync::broadcast::Receiver<TxStatusUpdate>,
|
||||
tx_hashes: &[InscriptionId],
|
||||
statuses: &[TxStatus],
|
||||
duration: Duration,
|
||||
) -> Result<(), ZoneTestError> {
|
||||
let mut remaining: HashSet<(InscriptionId, TxStatus)> = tx_hashes
|
||||
.iter()
|
||||
.flat_map(|tx_hash| statuses.iter().map(move |status| (*tx_hash, *status)))
|
||||
.collect();
|
||||
|
||||
timeout(duration, async {
|
||||
while !remaining.is_empty() {
|
||||
let update = match tx_status_rx.recv().await {
|
||||
Ok(update) => update,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("tx-status subscriber lagged by {n}, recovering");
|
||||
continue;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
return Err(ZoneTestError::SequencerStopped);
|
||||
}
|
||||
};
|
||||
remaining.remove(&(update.tx_hash, update.status));
|
||||
if remaining.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ZoneTestError::IndexerTimeout)?
|
||||
}
|
||||
|
||||
/// Waits until the subscribed channel view satisfies the supplied predicate.
|
||||
pub async fn wait_for_channel_view(
|
||||
view_rx: &mut tokio::sync::watch::Receiver<SequencerChannelView>,
|
||||
duration: Duration,
|
||||
predicate: impl Fn(&SequencerChannelView) -> bool + Send + Sync,
|
||||
) -> Result<SequencerChannelView, ZoneTestError> {
|
||||
timeout(duration, async {
|
||||
loop {
|
||||
let current = view_rx.borrow().clone();
|
||||
if predicate(¤t) {
|
||||
return Ok(current);
|
||||
}
|
||||
|
||||
view_rx
|
||||
.changed()
|
||||
.await
|
||||
.map_err(|error| ZoneTestError::Indexer {
|
||||
message: format!("channel view sender closed: {error}"),
|
||||
})?;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ZoneTestError::ChannelViewTimeout {
|
||||
message: format!(
|
||||
"condition not reached within {} seconds",
|
||||
duration.as_secs()
|
||||
),
|
||||
})?
|
||||
}
|
||||
|
||||
/// Waits until the sequencer emits a turn-to-write notification.
|
||||
pub async fn wait_for_turn_to_write(
|
||||
turn_rx: &mut tokio::sync::watch::Receiver<TurnNotification>,
|
||||
duration: Duration,
|
||||
) -> Result<TurnNotification, ZoneTestError> {
|
||||
timeout(duration, async {
|
||||
loop {
|
||||
let current = turn_rx.borrow().clone();
|
||||
if current.our_turn_to_write {
|
||||
return Ok(current);
|
||||
}
|
||||
|
||||
turn_rx
|
||||
.changed()
|
||||
.await
|
||||
.map_err(|error| ZoneTestError::Indexer {
|
||||
message: format!("turn-to-write sender closed: {error}"),
|
||||
})?;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ZoneTestError::ChannelViewTimeout {
|
||||
message: format!(
|
||||
"turn to write not reached within {} seconds",
|
||||
duration.as_secs()
|
||||
),
|
||||
})?
|
||||
}
|
||||
|
||||
/// Replays the channel's finalized history by cold-starting a fresh
|
||||
/// read-only sequencer: a random signing key that is not part of the channel
|
||||
/// rotation, so the instance can never publish or repost anything —
|
||||
/// inscription posting is turn-gated. Finalized txs are collected from the
|
||||
/// backfill events until the sequencer reports `Ready`, then the instance is
|
||||
/// dropped; each call observes a fresh snapshot up to the LIB at connect
|
||||
/// time.
|
||||
pub async fn replay_finalized_history(
|
||||
reader: &ZoneReaderConfig,
|
||||
) -> Result<Vec<FinalizedTx>, ZoneTestError> {
|
||||
let node = ZoneNodeHttpClient::new(CommonHttpClient::new(None), reader.node_url.clone());
|
||||
// Placeholder funding: the reader never publishes (random key, posting is
|
||||
// turn-gated), so the funding wallet is never exercised.
|
||||
let funding = FundingConfig {
|
||||
funding_pk: lb_groth16::Fr::from(1u64).into(),
|
||||
change_pk: None,
|
||||
max_tx_fee: GasCost::new(u64::MAX),
|
||||
priority_fee_percent: FundingConfig::DEFAULT_PRIORITY_FEE_PERCENT,
|
||||
};
|
||||
let mut sequencer = ZoneSequencer::init(reader.channel_id, keygen(), node, funding, None);
|
||||
|
||||
timeout(Duration::from_mins(3), async {
|
||||
let mut finalized = Vec::new();
|
||||
loop {
|
||||
match sequencer.next_event().await {
|
||||
Event::BlocksProcessed {
|
||||
finalized: batch, ..
|
||||
} => finalized.extend(batch),
|
||||
Event::Ready => return finalized,
|
||||
Event::MempoolPending(_) | Event::TurnNotification { .. } => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ZoneTestError::IndexerTimeout)
|
||||
}
|
||||
|
||||
/// Ordered inscription payloads within a finalized-history replay.
|
||||
pub fn replayed_inscription_payloads(history: &[FinalizedTx]) -> Vec<Inscription> {
|
||||
finalized_inscriptions(history)
|
||||
.map(|info| info.payload.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Collects indexed block payloads until all expected messages have appeared.
|
||||
///
|
||||
/// The returned order is the finalized on-chain order, which lets assertions
|
||||
/// decide whether ordering matters for the scenario.
|
||||
pub async fn collect_indexed_messages(
|
||||
reader: &ZoneReaderConfig,
|
||||
expected_messages: &[Inscription],
|
||||
duration: Duration,
|
||||
) -> Result<Vec<Inscription>, ZoneTestError> {
|
||||
let expected: HashSet<Inscription> = expected_messages.iter().cloned().collect();
|
||||
|
||||
timeout(duration, async {
|
||||
loop {
|
||||
let payloads = replayed_inscription_payloads(&replay_finalized_history(reader).await?);
|
||||
let mut seen: HashSet<Inscription> = HashSet::new();
|
||||
let mut ordered: Vec<Inscription> = Vec::new();
|
||||
for payload in payloads {
|
||||
if expected.contains(&payload) && seen.insert(payload.clone()) {
|
||||
ordered.push(payload);
|
||||
}
|
||||
}
|
||||
|
||||
if seen == expected {
|
||||
return Ok(ordered);
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ZoneTestError::IndexerTimeout)?
|
||||
}
|
||||
|
||||
/// Replays the finalized history until it exactly matches the expected
|
||||
/// message sequence without duplicates.
|
||||
pub async fn collect_indexed_messages_exactly_once(
|
||||
reader: &ZoneReaderConfig,
|
||||
expected_messages: &[Inscription],
|
||||
duration: Duration,
|
||||
) -> Result<Vec<Inscription>, ZoneTestError> {
|
||||
let expected: HashSet<Inscription> = expected_messages.iter().cloned().collect();
|
||||
|
||||
timeout(duration, async {
|
||||
loop {
|
||||
let ordered: Vec<Inscription> =
|
||||
replayed_inscription_payloads(&replay_finalized_history(reader).await?)
|
||||
.into_iter()
|
||||
.filter(|payload| expected.contains(payload))
|
||||
.collect();
|
||||
|
||||
if ordered == expected_messages {
|
||||
return Ok(ordered);
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ZoneTestError::IndexerTimeout)?
|
||||
}
|
||||
|
||||
/// Waits until the finalized history contains exactly `expected_count` copies
|
||||
/// of one payload after a short settle period.
|
||||
///
|
||||
/// This intentionally counts duplicate payload bytes, which is required for
|
||||
/// shared-payload zone tests where each inscription has the same data but a
|
||||
/// distinct transaction lineage.
|
||||
pub async fn wait_for_exact_indexed_payload_count(
|
||||
reader: &ZoneReaderConfig,
|
||||
expected_payload: Inscription,
|
||||
expected_count: usize,
|
||||
duration: Duration,
|
||||
) -> Result<(), ZoneTestError> {
|
||||
timeout(duration, async {
|
||||
loop {
|
||||
let count = count_indexed_payload(reader, &expected_payload).await?;
|
||||
|
||||
if count >= expected_count {
|
||||
sleep(Duration::from_secs(30)).await;
|
||||
|
||||
let final_count = count_indexed_payload(reader, &expected_payload).await?;
|
||||
if final_count == expected_count {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
return Err(ZoneTestError::IndexedPayloadCountMismatch {
|
||||
payload: String::from_utf8_lossy(expected_payload.as_slice()).to_string(),
|
||||
expected: expected_count,
|
||||
actual: final_count,
|
||||
});
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ZoneTestError::IndexerTimeout)?
|
||||
}
|
||||
|
||||
async fn count_indexed_payload(
|
||||
reader: &ZoneReaderConfig,
|
||||
expected_payload: &Inscription,
|
||||
) -> Result<usize, ZoneTestError> {
|
||||
Ok(
|
||||
replayed_inscription_payloads(&replay_finalized_history(reader).await?)
|
||||
.iter()
|
||||
.filter(|payload| *payload == expected_payload)
|
||||
.count(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Polls until the wallet holds exactly the given number of finalized and
|
||||
/// unfinalized notes — an exact-count check that catches double-counting.
|
||||
pub async fn wait_for_channel_wallet_counts(
|
||||
client: &SequencerClient,
|
||||
finalized: usize,
|
||||
unfinalized: usize,
|
||||
duration: Duration,
|
||||
) -> Result<(), ZoneTestError> {
|
||||
timeout(duration, async {
|
||||
loop {
|
||||
let view =
|
||||
client
|
||||
.channel_wallet()
|
||||
.await
|
||||
.map_err(|error| ZoneTestError::ChannelWallet {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
if view.finalized.len() == finalized && view.unfinalized.len() == unfinalized {
|
||||
return Ok(());
|
||||
}
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ZoneTestError::ChannelWalletTimeout)?
|
||||
}
|
||||
|
||||
/// Polls the sequencer's channel wallet until a note of `value` is present.
|
||||
/// With `finalized_only`, only the finalized layer counts.
|
||||
pub async fn wait_for_channel_wallet_note(
|
||||
client: &SequencerClient,
|
||||
value: Value,
|
||||
finalized_only: bool,
|
||||
duration: Duration,
|
||||
) -> Result<(), ZoneTestError> {
|
||||
timeout(duration, async {
|
||||
loop {
|
||||
let view =
|
||||
client
|
||||
.channel_wallet()
|
||||
.await
|
||||
.map_err(|error| ZoneTestError::ChannelWallet {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
let unfinalized = (!finalized_only)
|
||||
.then_some(view.unfinalized.iter())
|
||||
.into_iter()
|
||||
.flatten();
|
||||
if view
|
||||
.finalized
|
||||
.iter()
|
||||
.chain(unfinalized)
|
||||
.any(|note| note.value == value)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ZoneTestError::ChannelWalletTimeout)?
|
||||
}
|
||||
|
||||
/// Waits until the finalized channel history contains the expected channel
|
||||
/// deposit, including its amount.
|
||||
pub async fn wait_for_deposit(
|
||||
reader: &ZoneReaderConfig,
|
||||
expected: &DepositOp,
|
||||
expected_amount: Value,
|
||||
duration: Duration,
|
||||
) -> Result<(), ZoneTestError> {
|
||||
poll_replayed_history_until(reader, duration, ZoneTestError::IndexerTimeout, |op| {
|
||||
matches!(op, FinalizedOp::Deposit(deposit)
|
||||
if deposit.inputs == expected.inputs
|
||||
&& deposit.amount == expected_amount
|
||||
&& deposit.metadata == expected.metadata)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Waits until the finalized channel history contains the expected withdraw.
|
||||
pub async fn wait_for_withdraw(
|
||||
reader: &ZoneReaderConfig,
|
||||
expected: &ChannelWithdrawOp,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<(), ZoneTestError> {
|
||||
poll_replayed_history_until(
|
||||
reader,
|
||||
timeout_duration,
|
||||
ZoneTestError::WithdrawTimeout,
|
||||
|op| matches!(op, FinalizedOp::Withdraw(withdraw) if withdraw.op.inputs == expected.inputs),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Waits until the finalized channel history contains a channel transfer whose
|
||||
/// input set has exactly `expected_inputs` notes.
|
||||
///
|
||||
/// This is the on-chain record of the withdrawal's note selection: in the
|
||||
/// dust-flood scenario it proves best-fit largest-first consumed a single
|
||||
/// covering note rather than sweeping the >255-note dust flood into a transfer
|
||||
/// the ledger would reject.
|
||||
pub async fn wait_for_channel_transfer_input_count(
|
||||
reader: &ZoneReaderConfig,
|
||||
expected_inputs: usize,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<(), ZoneTestError> {
|
||||
poll_replayed_history_until(
|
||||
reader,
|
||||
timeout_duration,
|
||||
ZoneTestError::IndexerTimeout,
|
||||
move |op| {
|
||||
matches!(op, FinalizedOp::ChannelTransfer(transfer)
|
||||
if transfer.op.inputs.len() == expected_inputs)
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn poll_replayed_history_until(
|
||||
reader: &ZoneReaderConfig,
|
||||
duration: Duration,
|
||||
timeout_error: ZoneTestError,
|
||||
mut predicate: impl FnMut(&FinalizedOp) -> bool,
|
||||
) -> Result<(), ZoneTestError> {
|
||||
timeout(duration, async {
|
||||
loop {
|
||||
let history = replay_finalized_history(reader).await?;
|
||||
if history
|
||||
.iter()
|
||||
.flat_map(|tx| tx.ops.iter())
|
||||
.any(&mut predicate)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| timeout_error)?
|
||||
}
|
||||
|
||||
/// Waits until the sequencer's event stream surfaces the expected deposit
|
||||
/// in [`Event::BlocksProcessed::finalized`] (matched by `inputs`, `amount`,
|
||||
/// and `metadata`) while collecting any mempool-pending events. Drains the
|
||||
/// events channel as it goes — call this after any earlier event consumers in
|
||||
/// the scenario have moved past the relevant publish events.
|
||||
pub async fn wait_for_finalized_deposit_via_sequencer_and_collect_mempool_pending(
|
||||
events: &mut tokio::sync::broadcast::Receiver<Event>,
|
||||
expected: &DepositOp,
|
||||
expected_amount: Value,
|
||||
duration: Duration,
|
||||
) -> Result<HashSet<InscriptionId>, ZoneTestError> {
|
||||
poll_sequencer_finalized_until_and_collect_mempool_pending(
|
||||
events,
|
||||
duration,
|
||||
ZoneTestError::IndexerTimeout,
|
||||
|op| {
|
||||
matches!(op, FinalizedOp::Deposit(d)
|
||||
if d.inputs == expected.inputs
|
||||
&& d.amount == expected_amount
|
||||
&& d.metadata == expected.metadata)
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Waits until the sequencer's event stream surfaces the expected withdraw
|
||||
/// (matched by `outputs`) while collecting any mempool-pending events. Drains
|
||||
/// the events channel as it goes.
|
||||
pub async fn wait_for_finalized_withdraw_via_sequencer_and_collect_mempool_pending(
|
||||
events: &mut tokio::sync::broadcast::Receiver<Event>,
|
||||
expected: &ChannelWithdrawOp,
|
||||
duration: Duration,
|
||||
) -> Result<HashSet<InscriptionId>, ZoneTestError> {
|
||||
poll_sequencer_finalized_until_and_collect_mempool_pending(
|
||||
events,
|
||||
duration,
|
||||
ZoneTestError::WithdrawTimeout,
|
||||
|op| matches!(op, FinalizedOp::Withdraw(w) if w.op.inputs == expected.inputs),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn poll_sequencer_finalized_until_and_collect_mempool_pending(
|
||||
events: &mut tokio::sync::broadcast::Receiver<Event>,
|
||||
duration: Duration,
|
||||
timeout_error: ZoneTestError,
|
||||
mut predicate: impl FnMut(&FinalizedOp) -> bool,
|
||||
) -> Result<HashSet<InscriptionId>, ZoneTestError> {
|
||||
timeout(duration, async {
|
||||
let mut mempool_pending = HashSet::new();
|
||||
loop {
|
||||
let event = match events.recv().await {
|
||||
Ok(event) => event,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("event subscriber lagged by {n}, recovering");
|
||||
continue;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
return Err(ZoneTestError::SequencerStopped);
|
||||
}
|
||||
};
|
||||
if let Event::MempoolPending(tx_hash) = event {
|
||||
mempool_pending.insert(tx_hash);
|
||||
continue;
|
||||
}
|
||||
let Event::BlocksProcessed { finalized, .. } = event else {
|
||||
continue;
|
||||
};
|
||||
for tx in finalized {
|
||||
if tx.ops.iter().any(&mut predicate) {
|
||||
return Ok(mempool_pending);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| timeout_error)?
|
||||
}
|
||||
|
||||
/// Waits until node mempool/chain observation confirms the submitted zone
|
||||
/// transactions reached the canonical chain.
|
||||
pub async fn ensure_zone_transactions_included(
|
||||
client: &NodeHttpClient,
|
||||
tx_hashes: &[InscriptionId],
|
||||
duration: Duration,
|
||||
) -> Result<(), ZoneTestError> {
|
||||
let included = wait_for_transactions_inclusion(client, tx_hashes, duration).await;
|
||||
|
||||
if included {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(ZoneTestError::InclusionTimeout)
|
||||
}
|
||||
|
||||
/// Walks back from LIB until every expected zone transaction is found in the
|
||||
/// finalized chain.
|
||||
pub async fn wait_for_transactions_finalized(
|
||||
node_url: Url,
|
||||
tx_hashes: &[InscriptionId],
|
||||
duration: Duration,
|
||||
) -> Result<(), ZoneTestError> {
|
||||
let client = CommonHttpClient::new(None);
|
||||
let expected: HashSet<_> = tx_hashes.iter().copied().collect();
|
||||
|
||||
timeout(duration, async {
|
||||
loop {
|
||||
let info = client
|
||||
.consensus_info(node_url.clone())
|
||||
.await
|
||||
.map_err(|error| ZoneTestError::Consensus {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
|
||||
let mut found = HashSet::new();
|
||||
let mut current = info.cryptarchia_info.lib;
|
||||
|
||||
while let Some(block) = client
|
||||
.get_block_by_id(node_url.clone(), current)
|
||||
.await
|
||||
.map_err(|error| ZoneTestError::Block {
|
||||
message: error.to_string(),
|
||||
})?
|
||||
{
|
||||
for tx in &block.transactions {
|
||||
let hash = tx.mantle_tx().hash();
|
||||
if expected.contains(&hash) {
|
||||
found.insert(hash);
|
||||
}
|
||||
}
|
||||
|
||||
current = block.header.parent_block;
|
||||
}
|
||||
|
||||
if found == expected {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ZoneTestError::FinalizationTimeout)?
|
||||
}
|
||||
|
||||
/// Waits for LIB movement after a restart so stale-checkpoint scenarios can
|
||||
/// distinguish old local state from new canonical chain progress.
|
||||
pub async fn wait_for_lib_advance(
|
||||
client: &NodeHttpClient,
|
||||
initial_lib_slot: Slot,
|
||||
duration: Duration,
|
||||
) -> Result<(), ZoneTestError> {
|
||||
timeout(duration, async {
|
||||
loop {
|
||||
let info = client
|
||||
.consensus_info()
|
||||
.await
|
||||
.map_err(|error| ZoneTestError::Consensus {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
|
||||
if info.cryptarchia_info.lib_slot > initial_lib_slot {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ZoneTestError::LibAdvanceTimeout)?
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
use super::{
|
||||
Arc, BTreeSet, ChannelUpdate, ChannelUpdateTx, DiscardedPayloads, Event, FinalizedTx, HashMap,
|
||||
HashSet, Inscription, InscriptionInfo, LazyLock, MsgId, PolicyRuntime, SequencerChannelView,
|
||||
VecDeque, ZoneAccountBalances, ZoneNodeHttpClient, ZoneSequencer, finalized_inscriptions,
|
||||
parse_balance_payload, runner, to_policy_runtime, warn,
|
||||
};
|
||||
|
||||
/// Spawn a sequencer drive task with a no-op policy. Step bodies drive
|
||||
/// publishes via [`SequencerClient`]; events flow to `PolicyRuntime.events`.
|
||||
/// If `republish_orphans` is set, the [`OrphanRepublishPolicy`] runs inline
|
||||
/// inside the drive loop.
|
||||
pub fn start_sequencer_event_loop(
|
||||
sequencer: ZoneSequencer<ZoneNodeHttpClient>,
|
||||
republish_orphans: bool,
|
||||
) -> PolicyRuntime {
|
||||
if republish_orphans {
|
||||
to_policy_runtime(runner::spawn(sequencer, OrphanRepublishPolicy::default()))
|
||||
} else {
|
||||
to_policy_runtime(runner::spawn(sequencer, runner::PassivePolicy))
|
||||
}
|
||||
}
|
||||
|
||||
/// Drives a competing-sequencer policy that publishes `planned` once ready and
|
||||
/// re-publishes its own orphans (tracked by intent lineage) until they land —
|
||||
/// correct even when payloads repeat.
|
||||
pub fn start_republish_lineage_policy(
|
||||
sequencer: ZoneSequencer<ZoneNodeHttpClient>,
|
||||
planned: Vec<Inscription>,
|
||||
) -> PolicyRuntime {
|
||||
let policy = RepublishLineagePolicy {
|
||||
planned,
|
||||
published_initial: false,
|
||||
lineage: LineageTracker::default(),
|
||||
};
|
||||
to_policy_runtime(runner::spawn(sequencer, policy))
|
||||
}
|
||||
|
||||
/// Drives a policy that republishes orphaned balance updates only when the
|
||||
/// local canonical view can still apply the update without going negative,
|
||||
/// and lays planned balance updates whenever it's our turn to write.
|
||||
pub fn start_balance_aware_policy(
|
||||
sequencer: ZoneSequencer<ZoneNodeHttpClient>,
|
||||
initial_balances: ZoneAccountBalances,
|
||||
planned_payloads: Vec<Inscription>,
|
||||
) -> PolicyRuntime {
|
||||
let view_rx = sequencer.subscribe_channel_view();
|
||||
let policy = BalanceAwarePolicy {
|
||||
balances: BalanceAwareState::new(initial_balances),
|
||||
planned: VecDeque::from(planned_payloads),
|
||||
view_rx,
|
||||
};
|
||||
to_policy_runtime(runner::spawn(sequencer, policy))
|
||||
}
|
||||
|
||||
/// Drives a deterministic conflict policy used by tests that expect the final
|
||||
/// zone chain to converge to sorted payload order.
|
||||
pub fn start_sorted_conflict_policy(
|
||||
sequencer: ZoneSequencer<ZoneNodeHttpClient>,
|
||||
discarded: &DiscardedPayloads,
|
||||
) -> PolicyRuntime {
|
||||
let policy = SortedConflictPolicy {
|
||||
state: SortedConflictState::new(Arc::clone(discarded)),
|
||||
};
|
||||
to_policy_runtime(runner::spawn(sequencer, policy))
|
||||
}
|
||||
|
||||
/// Inline policy: republish orphaned inscriptions not already back on the
|
||||
/// canonical chain. Plain inscriptions only — bundles re-prepare themselves.
|
||||
/// Assumes unique payloads; for repeating payloads see
|
||||
/// [`RepublishLineagePolicy`].
|
||||
///
|
||||
/// Tracks canonical on-chain state keyed by id, decided by payload (see
|
||||
/// `on_event`): a dead twin's id leaves while a live twin keeps the payload
|
||||
/// covered, so a payload still on chain is never re-homed.
|
||||
#[derive(Default)]
|
||||
struct OrphanRepublishPolicy {
|
||||
/// Canonical on-chain inscriptions by id (adopted-unfinalized + finalized).
|
||||
/// Finalized entries are added and never removed — they can't be orphaned —
|
||||
/// so this one set is the whole on-chain view.
|
||||
canonical: HashMap<MsgId, Inscription>,
|
||||
}
|
||||
|
||||
impl<Node> runner::Policy<Node> for OrphanRepublishPolicy
|
||||
where
|
||||
Node: lb_zone_sdk::adapter::Node + Clone + Send + Sync + 'static,
|
||||
{
|
||||
async fn on_event(&mut self, sequencer: &mut ZoneSequencer<Node>, event: &Event) {
|
||||
let Event::BlocksProcessed {
|
||||
channel_update,
|
||||
finalized,
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// 1. Remove orphaned by id — a dead twin leaves, a live twin stays.
|
||||
for entry in &channel_update.orphaned {
|
||||
if let Some(info) = entry.inscription() {
|
||||
self.canonical.remove(&info.this_msg);
|
||||
}
|
||||
}
|
||||
// 2. Add finalized by id (permanent — finalized can't be orphaned).
|
||||
for info in finalized_inscriptions(finalized) {
|
||||
self.canonical.insert(info.this_msg, info.payload.clone());
|
||||
}
|
||||
// 3. Add adopted by id (this block's new canonical).
|
||||
for info in channel_update
|
||||
.adopted
|
||||
.iter()
|
||||
.filter_map(ChannelUpdateTx::inscription)
|
||||
{
|
||||
self.canonical.insert(info.this_msg, info.payload.clone());
|
||||
}
|
||||
// 4. Republish orphaned whose payload no canonical id still carries.
|
||||
for entry in &channel_update.orphaned {
|
||||
let ChannelUpdateTx::Inscription(info) = entry else {
|
||||
continue;
|
||||
};
|
||||
if self
|
||||
.canonical
|
||||
.values()
|
||||
.any(|payload| *payload == info.payload)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = sequencer.handle().publish(info.payload.clone()).await {
|
||||
warn!(%error, "Failed to re-publish orphaned zone payload");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks our published inscriptions by intent lineage, so republishing works
|
||||
/// even when payloads repeat (identical bytes published as distinct messages).
|
||||
///
|
||||
/// Each original publish is its own intent, rooted at its `this_msg`; every
|
||||
/// republish we issue for an orphaned member is recorded under the same root.
|
||||
/// An intent is "live" while any of its `this_msg`s is on the channel
|
||||
/// (`adopted`) or in flight as a publish/republish we issued. Identical
|
||||
/// payloads form distinct intents (distinct `this_msg`s), so each lands once,
|
||||
/// and other sequencers' inscriptions are never in our map, so we never
|
||||
/// republish theirs.
|
||||
#[derive(Default)]
|
||||
struct LineageTracker {
|
||||
/// Every `this_msg` we've published (originals + republishes) → intent
|
||||
/// root.
|
||||
intent_root: HashMap<MsgId, MsgId>,
|
||||
/// Per intent root, the `this_msg`s currently pending (in the
|
||||
/// non-finalized channel view).
|
||||
pending: HashMap<MsgId, HashSet<MsgId>>,
|
||||
/// Intent roots that have finalized — permanently landed, so the intent is
|
||||
/// considered live forever and never re-homed again.
|
||||
finalized_roots: HashSet<MsgId>,
|
||||
}
|
||||
|
||||
impl LineageTracker {
|
||||
/// Record an original publish as its own intent, in flight.
|
||||
fn record_publish(&mut self, this_msg: MsgId) {
|
||||
self.intent_root.insert(this_msg, this_msg);
|
||||
self.pending.entry(this_msg).or_default().insert(this_msg);
|
||||
}
|
||||
|
||||
/// Record a republish of `orphan` as a new live member of its intent.
|
||||
fn record_republish(&mut self, orphan: MsgId, republished: MsgId) {
|
||||
let root = self.intent_root.get(&orphan).copied().unwrap_or(orphan);
|
||||
self.intent_root.insert(republished, root);
|
||||
self.pending.entry(root).or_default().insert(republished);
|
||||
}
|
||||
|
||||
/// Fold a delta into per-intent liveness — only our `msg_id`s are relevant.
|
||||
/// Adopted members become live; orphaned members stop being live.
|
||||
fn observe(&mut self, channel_update: &ChannelUpdate) {
|
||||
for info in channel_update
|
||||
.adopted
|
||||
.iter()
|
||||
.filter_map(ChannelUpdateTx::inscription)
|
||||
{
|
||||
if let Some(&root) = self.intent_root.get(&info.this_msg) {
|
||||
self.pending.entry(root).or_default().insert(info.this_msg);
|
||||
}
|
||||
}
|
||||
for entry in &channel_update.orphaned {
|
||||
if let ChannelUpdateTx::Inscription(info) = entry
|
||||
&& let Some(&root) = self.intent_root.get(&info.this_msg)
|
||||
&& let Some(members) = self.pending.get_mut(&root)
|
||||
{
|
||||
members.remove(&info.this_msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pin the intents of any finalized `this_msg`s of ours as permanently
|
||||
/// live — once a member finalizes the payload is on chain for good.
|
||||
fn observe_finalized(&mut self, finalized: impl Iterator<Item = MsgId>) {
|
||||
for this_msg in finalized {
|
||||
if let Some(&root) = self.intent_root.get(&this_msg) {
|
||||
self.finalized_roots.insert(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True if `this_msg` is one of ours.
|
||||
fn is_ours(&self, this_msg: &MsgId) -> bool {
|
||||
self.intent_root.contains_key(this_msg)
|
||||
}
|
||||
|
||||
/// True if the intent of `this_msg` has finalized, or still has a live
|
||||
/// member.
|
||||
fn intent_live(&self, this_msg: &MsgId) -> bool {
|
||||
let root = self.intent_root.get(this_msg).copied().unwrap_or(*this_msg);
|
||||
self.finalized_roots.contains(&root)
|
||||
|| self
|
||||
.pending
|
||||
.get(&root)
|
||||
.is_some_and(|members| !members.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline republish policy for channels whose payloads can repeat. Publishes
|
||||
/// its own `planned` payloads once the sequencer is ready, then republishes any
|
||||
/// of *our* orphans whose intent has no live member, tracking msg-id lineage
|
||||
/// (the payload can't identify the message when it repeats). Owning the
|
||||
/// publishes is what gives the policy its outbox: every `this_msg` it sends is
|
||||
/// recorded.
|
||||
struct RepublishLineagePolicy {
|
||||
planned: Vec<Inscription>,
|
||||
published_initial: bool,
|
||||
lineage: LineageTracker,
|
||||
}
|
||||
|
||||
impl<Node> runner::Policy<Node> for RepublishLineagePolicy
|
||||
where
|
||||
Node: lb_zone_sdk::adapter::Node + Clone + Send + Sync + 'static,
|
||||
{
|
||||
async fn on_event(&mut self, sequencer: &mut ZoneSequencer<Node>, event: &Event) {
|
||||
match event {
|
||||
Event::Ready if !self.published_initial => {
|
||||
self.published_initial = true;
|
||||
for payload in self.planned.clone() {
|
||||
match sequencer.handle().publish(payload).await {
|
||||
Ok((result, _checkpoint)) => {
|
||||
self.lineage
|
||||
.record_publish(result.tx.inscription().this_msg);
|
||||
}
|
||||
Err(error) => warn!(%error, "Failed to publish planned zone payload"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::BlocksProcessed {
|
||||
channel_update,
|
||||
finalized,
|
||||
..
|
||||
} => {
|
||||
self.lineage
|
||||
.observe_finalized(finalized_inscriptions(finalized).map(|i| i.this_msg));
|
||||
self.lineage.observe(channel_update);
|
||||
for entry in &channel_update.orphaned {
|
||||
let ChannelUpdateTx::Inscription(info) = entry else {
|
||||
continue;
|
||||
};
|
||||
if !self.lineage.is_ours(&info.this_msg)
|
||||
|| self.lineage.intent_live(&info.this_msg)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match sequencer.handle().publish(info.payload.clone()).await {
|
||||
Ok((result, _checkpoint)) => {
|
||||
self.lineage
|
||||
.record_republish(info.this_msg, result.tx.inscription().this_msg);
|
||||
}
|
||||
Err(error) => warn!(%error, "Failed to re-publish orphaned zone payload"),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline policy: republish orphans only when the local balance view still
|
||||
/// allows it; publish planned payloads as soon as it's our turn to write.
|
||||
///
|
||||
/// The balance view is rebuilt from the full delta — every orphaned op is
|
||||
/// removed and every adopted op applied — so affordability reflects all
|
||||
/// inscriptions on the channel. Removing an orphan we never applied (never-
|
||||
/// landed pending) is a no-op, and an already-adopted op is skipped because its
|
||||
/// id is already in the applied set after `record_adopted_payloads`.
|
||||
struct BalanceAwarePolicy {
|
||||
balances: BalanceAwareState,
|
||||
planned: VecDeque<Inscription>,
|
||||
view_rx: tokio::sync::watch::Receiver<SequencerChannelView>,
|
||||
}
|
||||
|
||||
impl<Node> runner::Policy<Node> for BalanceAwarePolicy
|
||||
where
|
||||
Node: lb_zone_sdk::adapter::Node + Clone + Send + Sync + 'static,
|
||||
{
|
||||
async fn on_event(&mut self, sequencer: &mut ZoneSequencer<Node>, event: &Event) {
|
||||
if let Event::BlocksProcessed {
|
||||
channel_update,
|
||||
finalized,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
self.balances.record_finalized_payloads(finalized);
|
||||
let ChannelUpdate { orphaned, adopted } = channel_update;
|
||||
let orphaned_inscriptions: Vec<InscriptionInfo> = orphaned
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
ChannelUpdateTx::Inscription(i) => Some(i.clone()),
|
||||
ChannelUpdateTx::AtomicWithdraw(_)
|
||||
| ChannelUpdateTx::Custom(_)
|
||||
| ChannelUpdateTx::Config(_) => None,
|
||||
})
|
||||
.collect();
|
||||
self.balances
|
||||
.remove_orphaned_payloads(&orphaned_inscriptions);
|
||||
self.balances.record_adopted_payloads(adopted);
|
||||
for info in orphaned_inscriptions {
|
||||
if !self.balances.should_republish(&info.payload) {
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = sequencer.handle().publish(info.payload.clone()).await {
|
||||
warn!(%error, "Failed to re-publish balance-aware zone payload");
|
||||
continue;
|
||||
}
|
||||
self.balances.record_republished_payload(&info.payload);
|
||||
}
|
||||
}
|
||||
|
||||
if !self.view_rx.borrow().our_turn_to_write {
|
||||
return;
|
||||
}
|
||||
while let Some(payload) = self.planned.pop_front() {
|
||||
if !self.balances.should_republish(&payload) {
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = sequencer.handle().publish(payload.clone()).await {
|
||||
warn!(%error, "Failed to publish planned balance-aware zone payload");
|
||||
self.planned.push_front(payload);
|
||||
break;
|
||||
}
|
||||
self.balances.record_republished_payload(&payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline policy: republish orphans only when they preserve sorted-payload
|
||||
/// order; otherwise mark them as discarded.
|
||||
///
|
||||
/// The full delta lets us rebuild the on-chain payload set each update (drop
|
||||
/// orphaned, add adopted), so the order floor we gate republishing on falls
|
||||
/// back correctly when the highest payload is orphaned.
|
||||
struct SortedConflictPolicy {
|
||||
state: SortedConflictState,
|
||||
}
|
||||
|
||||
impl<Node> runner::Policy<Node> for SortedConflictPolicy
|
||||
where
|
||||
Node: lb_zone_sdk::adapter::Node + Clone + Send + Sync + 'static,
|
||||
{
|
||||
async fn on_event(&mut self, sequencer: &mut ZoneSequencer<Node>, event: &Event) {
|
||||
let Event::BlocksProcessed {
|
||||
channel_update,
|
||||
finalized,
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// Pin finalized payloads first.
|
||||
self.state.record_finalized(finalized);
|
||||
let ChannelUpdate { orphaned, adopted } = channel_update;
|
||||
let orphaned_inscriptions: Vec<&InscriptionInfo> = orphaned
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
ChannelUpdateTx::Inscription(i) => Some(i),
|
||||
ChannelUpdateTx::AtomicWithdraw(_)
|
||||
| ChannelUpdateTx::Custom(_)
|
||||
| ChannelUpdateTx::Config(_) => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Rebuild on-chain state from this delta before deciding anything.
|
||||
self.state.revert_orphaned(&orphaned_inscriptions);
|
||||
self.state.record_adoptions(adopted).await;
|
||||
|
||||
let readopted: HashSet<&Inscription> = adopted
|
||||
.iter()
|
||||
.filter_map(|tx| tx.inscription().map(|i| &i.payload))
|
||||
.collect();
|
||||
|
||||
// Consider this round's fresh orphans together with everything parked,
|
||||
// in sorted order (a `BTreeSet` iterates ascending). A payload parked
|
||||
// under a higher floor on another branch then slots in ahead of a higher
|
||||
// fresh orphan instead of being locked out, and the chain stays sorted.
|
||||
// Finalized payloads are excluded — they're already permanently landed.
|
||||
let mut candidates: BTreeSet<Inscription> = orphaned_inscriptions
|
||||
.iter()
|
||||
.map(|i| i.payload.clone())
|
||||
.filter(|payload| !readopted.contains(payload) && !self.state.is_finalized(payload))
|
||||
.collect();
|
||||
candidates.extend(self.state.discarded_snapshot().await);
|
||||
|
||||
for payload in candidates {
|
||||
if self.state.is_finalized(&payload) {
|
||||
continue;
|
||||
}
|
||||
if self.state.preserves_order(&payload) {
|
||||
if let Err(error) = sequencer.handle().publish(payload.clone()).await {
|
||||
warn!(%error, "Failed to re-publish sorted zone payload");
|
||||
continue;
|
||||
}
|
||||
self.state.record_published_payload(payload).await;
|
||||
} else {
|
||||
self.state.discard(payload).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BalanceAwareState {
|
||||
initial_balances: ZoneAccountBalances,
|
||||
applied: HashMap<String, HashMap<String, i64>>,
|
||||
finalized: HashSet<String>,
|
||||
}
|
||||
|
||||
impl BalanceAwareState {
|
||||
fn new(initial_balances: ZoneAccountBalances) -> Self {
|
||||
Self {
|
||||
initial_balances,
|
||||
applied: HashMap::new(),
|
||||
finalized: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pin finalized payloads.
|
||||
fn record_finalized_payloads(&mut self, finalized: &[FinalizedTx]) {
|
||||
for inscription in finalized_inscriptions(finalized) {
|
||||
if let Some((uuid, _, _)) = parse_balance_payload(&inscription.payload) {
|
||||
self.finalized.insert(uuid);
|
||||
}
|
||||
self.record_applied_payload(&inscription.payload);
|
||||
}
|
||||
}
|
||||
|
||||
fn record_applied_payload(&mut self, payload: &Inscription) {
|
||||
let Some((uuid, account, delta)) = parse_balance_payload(payload) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.applied.entry(account).or_default().insert(uuid, delta);
|
||||
}
|
||||
|
||||
fn remove_orphaned_payloads(&mut self, orphaned: &[InscriptionInfo]) {
|
||||
for inscription in orphaned {
|
||||
let Some((uuid, account, _)) = parse_balance_payload(&inscription.payload) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// A finalized delta is permanent — never drop it on an orphan.
|
||||
if self.finalized.contains(&uuid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(account_updates) = self.applied.get_mut(&account) {
|
||||
account_updates.remove(&uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn record_adopted_payloads(&mut self, adopted: &[ChannelUpdateTx]) {
|
||||
for info in adopted.iter().filter_map(ChannelUpdateTx::inscription) {
|
||||
self.record_applied_payload(&info.payload);
|
||||
}
|
||||
}
|
||||
|
||||
fn should_republish(&self, payload: &Inscription) -> bool {
|
||||
let Some((uuid, account, delta)) = parse_balance_payload(payload) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if self.finalized.contains(&uuid) || self.account_updates(&account).contains_key(&uuid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.available_balance(&account) + delta >= 0
|
||||
}
|
||||
|
||||
fn record_republished_payload(&mut self, payload: &Inscription) {
|
||||
self.record_applied_payload(payload);
|
||||
}
|
||||
|
||||
fn available_balance(&self, account: &str) -> i64 {
|
||||
self.initial_balances.get(account).copied().unwrap_or(0)
|
||||
+ self.account_updates(account).values().sum::<i64>()
|
||||
}
|
||||
|
||||
fn account_updates(&self, account: &str) -> &HashMap<String, i64> {
|
||||
self.applied.get(account).unwrap_or(&EMPTY_BALANCE_UPDATES)
|
||||
}
|
||||
}
|
||||
|
||||
static EMPTY_BALANCE_UPDATES: LazyLock<HashMap<String, i64>> = LazyLock::new(HashMap::new);
|
||||
|
||||
struct SortedConflictState {
|
||||
/// The local channel view: pending (non-finalized) payloads plus the
|
||||
/// pinned finalized base, kept as the ordering floor.
|
||||
channel_view: BTreeSet<Inscription>,
|
||||
discarded: DiscardedPayloads,
|
||||
finalized: HashSet<Inscription>,
|
||||
}
|
||||
|
||||
impl SortedConflictState {
|
||||
fn new(discarded: DiscardedPayloads) -> Self {
|
||||
Self {
|
||||
channel_view: BTreeSet::new(),
|
||||
discarded,
|
||||
finalized: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pin finalized payloads into the channel view permanently.
|
||||
fn record_finalized(&mut self, finalized: &[FinalizedTx]) {
|
||||
for inscription in finalized_inscriptions(finalized) {
|
||||
self.finalized.insert(inscription.payload.clone());
|
||||
self.channel_view.insert(inscription.payload.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn is_finalized(&self, payload: &Inscription) -> bool {
|
||||
self.finalized.contains(payload)
|
||||
}
|
||||
|
||||
/// Drop orphaned payloads from the channel view — the order floor falls
|
||||
/// back to the max of whatever remains. Finalized payloads stay put.
|
||||
fn revert_orphaned(&mut self, orphaned: &[&InscriptionInfo]) {
|
||||
for inscription in orphaned {
|
||||
if self.finalized.contains(&inscription.payload) {
|
||||
continue;
|
||||
}
|
||||
self.channel_view.remove(&inscription.payload);
|
||||
}
|
||||
}
|
||||
|
||||
async fn record_adoptions(&mut self, adopted: &[ChannelUpdateTx]) {
|
||||
for info in adopted.iter().filter_map(ChannelUpdateTx::inscription) {
|
||||
self.discarded.lock().await.remove(&info.payload);
|
||||
self.channel_view.insert(info.payload.clone());
|
||||
}
|
||||
}
|
||||
|
||||
async fn record_published_payload(&mut self, payload: Inscription) {
|
||||
self.discarded.lock().await.remove(&payload);
|
||||
self.channel_view.insert(payload);
|
||||
}
|
||||
|
||||
fn preserves_order(&self, payload: &Inscription) -> bool {
|
||||
self.channel_view.last().is_none_or(|max| payload >= max)
|
||||
}
|
||||
|
||||
async fn discard(&self, payload: Inscription) {
|
||||
self.discarded.lock().await.insert(payload);
|
||||
}
|
||||
|
||||
async fn discarded_snapshot(&self) -> Vec<Inscription> {
|
||||
self.discarded.lock().await.iter().cloned().collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
use super::*;
|
||||
|
||||
/// Builds a regular channel deposit for an existing funding note with the
|
||||
/// exact deposit value.
|
||||
pub fn build_zone_deposit(
|
||||
available_utxos: Vec<Utxo>,
|
||||
channel_id: ChannelId,
|
||||
amount: Value,
|
||||
metadata: Metadata,
|
||||
) -> Result<ZoneDeposit, ZoneTestError> {
|
||||
let note = available_utxos
|
||||
.into_iter()
|
||||
.find(|utxo| utxo.note.value == amount)
|
||||
.ok_or(ZoneTestError::MissingExactFundingNote { value: amount })?;
|
||||
|
||||
let deposit = DepositOp {
|
||||
channel_id,
|
||||
inputs: Inputs::new([note.id()]),
|
||||
metadata,
|
||||
};
|
||||
let reserved_inputs = vec![note];
|
||||
let channel_notes = recreated_channel_notes(&deposit, &reserved_inputs);
|
||||
Ok(ZoneDeposit {
|
||||
deposit,
|
||||
reserved_inputs,
|
||||
channel_notes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a deposit that consumes one wallet note per listed value, in order.
|
||||
/// A deposit re-creates its inputs 1:1 as channel notes, so this yields a
|
||||
/// multi-input deposit whose recreated notes carry those exact per-note values
|
||||
/// — used to cover the channel wallet's per-note tracking.
|
||||
pub fn build_zone_deposit_from_values(
|
||||
available_utxos: Vec<Utxo>,
|
||||
channel_id: ChannelId,
|
||||
input_values: &[Value],
|
||||
metadata: Metadata,
|
||||
) -> Result<ZoneDeposit, ZoneTestError> {
|
||||
let mut remaining = available_utxos;
|
||||
let mut reserved_inputs = Vec::new();
|
||||
for &value in input_values {
|
||||
let index = remaining
|
||||
.iter()
|
||||
.position(|utxo| utxo.note.value == value)
|
||||
.ok_or(ZoneTestError::MissingExactFundingNote { value })?;
|
||||
reserved_inputs.push(remaining.remove(index));
|
||||
}
|
||||
|
||||
let input_ids: Vec<_> = reserved_inputs.iter().map(Utxo::id).collect();
|
||||
let deposit = DepositOp {
|
||||
channel_id,
|
||||
inputs: Inputs::try_new(input_ids).map_err(|error| ZoneTestError::SubmitDeposit {
|
||||
message: format!("deposit input set exceeds bound: {error:?}"),
|
||||
})?,
|
||||
metadata,
|
||||
};
|
||||
let channel_notes = recreated_channel_notes(&deposit, &reserved_inputs);
|
||||
Ok(ZoneDeposit {
|
||||
deposit,
|
||||
reserved_inputs,
|
||||
channel_notes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Generous cap on channel transaction fees at genesis gas prices; actual
|
||||
/// fees are a few hundred gas units for these small transactions.
|
||||
const MAX_ZONE_DEPOSIT_TX_FEE: u64 = 10_000;
|
||||
|
||||
/// Submits a regular channel deposit through the node wallet API.
|
||||
pub async fn submit_zone_deposit(
|
||||
node_url: &Url,
|
||||
deposit: &DepositOp,
|
||||
funding_public_key: ZkPublicKey,
|
||||
) -> Result<InscriptionId, ZoneTestError> {
|
||||
let body = ChannelDepositRequestBody {
|
||||
tip: None,
|
||||
deposit: deposit.clone(),
|
||||
change_public_key: funding_public_key,
|
||||
funding_public_keys: vec![funding_public_key],
|
||||
max_tx_fee: MAX_ZONE_DEPOSIT_TX_FEE.into(),
|
||||
};
|
||||
|
||||
let request_url =
|
||||
node_url
|
||||
.join("/channel/deposit")
|
||||
.map_err(|error| ZoneTestError::SubmitDeposit {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
|
||||
let response: ChannelDepositResponseBody = CommonHttpClient::new(None)
|
||||
.post(request_url, &body)
|
||||
.await
|
||||
.map_err(|error| ZoneTestError::SubmitDeposit {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
|
||||
Ok(response.hash)
|
||||
}
|
||||
|
||||
/// Splits one channel note into `dust_count` value-1 dust notes via a raw,
|
||||
/// node-funded `ChannelTransfer`, submitted straight to the node mempool (no
|
||||
/// zone-sdk involvement — mirrors how deposits are submitted).
|
||||
///
|
||||
/// The transfer is value-preserving, so the input note's value must equal
|
||||
/// `dust_count` (every output is value 1). The channel authorizes the transfer
|
||||
/// with the sequencer's accredited key at index 0 (single-signer channel,
|
||||
/// `transfer_threshold == 1`); the node appends and proves the fee transfer.
|
||||
pub async fn submit_zone_channel_split(
|
||||
node_url: &Url,
|
||||
channel_id: ChannelId,
|
||||
signing_key: &Ed25519Key,
|
||||
funding_pk: ZkPublicKey,
|
||||
input_note: Utxo,
|
||||
dust_count: usize,
|
||||
) -> Result<InscriptionId, ZoneTestError> {
|
||||
let input_value = input_note.note.value;
|
||||
if input_value != dust_count as u64 {
|
||||
return Err(ZoneTestError::SplitTransfer {
|
||||
message: format!(
|
||||
"channel note value {input_value} must equal dust count {dust_count} \
|
||||
(each dust note is value 1)"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let outputs =
|
||||
Outputs::try_new(vec![Note::new(1, funding_pk); dust_count]).map_err(|error| {
|
||||
ZoneTestError::SplitTransfer {
|
||||
message: format!("dust outputs exceed bound: {error:?}"),
|
||||
}
|
||||
})?;
|
||||
let transfer = ChannelTransferOp {
|
||||
channel_id,
|
||||
inputs: Inputs::new([input_note.id()]),
|
||||
outputs,
|
||||
};
|
||||
|
||||
let tx_builder = MantleTxBuilder::new()
|
||||
.push_op(Op::ChannelTransfer(transfer))
|
||||
.map_err(|error| ZoneTestError::SplitTransfer {
|
||||
message: format!("too many ops: {error}"),
|
||||
})?;
|
||||
|
||||
let node = NodeHttpClient::from_url(node_url.clone());
|
||||
let response = node
|
||||
.fund_tx(WalletFundRequestBody {
|
||||
tip: None,
|
||||
priority_fee_percent: 0,
|
||||
tx_builder,
|
||||
change_public_key: funding_pk,
|
||||
funding_public_keys: vec![funding_pk],
|
||||
max_tx_fee: GasCost::new(u64::MAX),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| ZoneTestError::SplitTransfer {
|
||||
message: format!("funding failed: {error}"),
|
||||
})?;
|
||||
|
||||
// The channel multi-sig proves the transfer over the funded tx hash; the
|
||||
// funding appends its own fee transfer proof as the last op.
|
||||
let funded_tx = response.funded_tx;
|
||||
let tx_hash = funded_tx.hash();
|
||||
let signature = signing_key.sign_payload(tx_hash.as_signing_bytes().as_ref());
|
||||
let proof = ChannelMultiSigProof::try_new([IndexedSignature::new(0, signature)].into())
|
||||
.map_err(|error| ZoneTestError::SplitTransfer {
|
||||
message: format!("multi-sig proof assembly failed: {error:?}"),
|
||||
})?;
|
||||
let mut ops_proofs = OpsProofs::new_unchecked(vec![OpProof::ChannelMultiSigProof(proof)]);
|
||||
if let Some(transfer_proof) = response.transfer_proof {
|
||||
ops_proofs
|
||||
.try_push(transfer_proof)
|
||||
.map_err(|error| ZoneTestError::SplitTransfer {
|
||||
message: format!("too many operation proofs: {error:?}"),
|
||||
})?;
|
||||
}
|
||||
|
||||
let signed_tx = SignedMantleTx::new(funded_tx, ops_proofs);
|
||||
node.submit_transaction(&signed_tx)
|
||||
.await
|
||||
.map_err(|error| ZoneTestError::SplitTransfer {
|
||||
message: format!("submit failed: {error}"),
|
||||
})?;
|
||||
|
||||
Ok(tx_hash)
|
||||
}
|
||||
|
||||
/// Builds and submits a single transaction that both creates the deposit note
|
||||
/// and publishes the zone inscription that consumes it.
|
||||
pub async fn submit_atomic_zone_deposit(
|
||||
node_url: &Url,
|
||||
client: &SequencerClient,
|
||||
request: AtomicZoneDepositRequest,
|
||||
) -> Result<AtomicZoneDepositSubmission, ZoneTestError> {
|
||||
let AtomicZoneDepositRequest {
|
||||
channel_id,
|
||||
funding_public_key,
|
||||
available_utxos,
|
||||
amount,
|
||||
metadata,
|
||||
inscription_data,
|
||||
} = request;
|
||||
let (transfer, reserved_inputs) =
|
||||
build_atomic_deposit_transfer(available_utxos, funding_public_key, amount)?;
|
||||
let deposit = build_atomic_deposit_op(channel_id, metadata, &transfer)?;
|
||||
|
||||
let (tx, msg_id, sequencer_sig) = client
|
||||
.prepare_tx(
|
||||
[Op::Transfer(transfer), Op::ChannelDeposit(deposit.clone())].into(),
|
||||
inscription_data,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| ZoneTestError::BuildAtomicDeposit {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
|
||||
let user_sig = sign_tx_zk(node_url, &tx, vec![funding_public_key]).await?;
|
||||
let signed_tx = SignedMantleTx::new(
|
||||
tx,
|
||||
[
|
||||
OpProof::ZkSig(user_sig.clone()),
|
||||
OpProof::ZkSig(user_sig),
|
||||
OpProof::Ed25519Sig(sequencer_sig),
|
||||
]
|
||||
.into(),
|
||||
);
|
||||
|
||||
let (result, _cp) = client
|
||||
.submit_signed_tx(signed_tx, msg_id)
|
||||
.await
|
||||
.map_err(|error| ZoneTestError::SubmitAtomicDeposit {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
|
||||
Ok(AtomicZoneDepositSubmission {
|
||||
deposit,
|
||||
publish: result,
|
||||
reserved_inputs,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn build_funded_custom_tx(
|
||||
node_client: &NodeHttpClient,
|
||||
channel_id: ChannelId,
|
||||
signing_key: &Ed25519Key,
|
||||
funding_pk: ZkPublicKey,
|
||||
payloads: &[Inscription],
|
||||
mut parent: MsgId,
|
||||
) -> Result<(SignedMantleTx<Unverified>, MsgId), ZoneTestError> {
|
||||
let signer = signing_key.public_key();
|
||||
let mut tx_builder = MantleTxBuilder::new();
|
||||
for payload in payloads {
|
||||
let op = InscriptionOp {
|
||||
channel_id,
|
||||
inscription: payload.clone(),
|
||||
parent,
|
||||
signer,
|
||||
};
|
||||
parent = op.id();
|
||||
tx_builder = tx_builder
|
||||
.push_op(Op::ChannelInscribe(op))
|
||||
.map_err(|error| ZoneTestError::BuildCustomTx {
|
||||
message: format!("too many ops: {error}"),
|
||||
})?;
|
||||
}
|
||||
|
||||
let response = node_client
|
||||
.fund_tx(WalletFundRequestBody {
|
||||
tip: None,
|
||||
priority_fee_percent: 0,
|
||||
tx_builder,
|
||||
change_public_key: funding_pk,
|
||||
funding_public_keys: vec![funding_pk],
|
||||
max_tx_fee: GasCost::new(u64::MAX),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| ZoneTestError::SubmitCustomTx {
|
||||
message: format!("funding failed: {error}"),
|
||||
})?;
|
||||
|
||||
// Funding appends the fee transfer as the last op; every inscription is
|
||||
// proven by the sequencer key over the funded tx hash.
|
||||
let funded_tx = response.funded_tx;
|
||||
let signature = signing_key.sign_payload(funded_tx.hash().as_signing_bytes().as_ref());
|
||||
let mut ops_proofs =
|
||||
OpsProofs::new_unchecked(vec![OpProof::Ed25519Sig(signature); payloads.len()]);
|
||||
if let Some(proof) = response.transfer_proof {
|
||||
ops_proofs
|
||||
.try_push(proof)
|
||||
.map_err(|error| ZoneTestError::BuildCustomTx {
|
||||
message: format!("too many operation proofs: {error:?}"),
|
||||
})?;
|
||||
}
|
||||
let signed_tx = SignedMantleTx::new(funded_tx, ops_proofs);
|
||||
|
||||
Ok((signed_tx, parent))
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
use super::{
|
||||
CONCURRENT_DUPLICATE_SETTLE_SECS, CucumberWorld, DEFAULT_ZONE_SEQUENCER, Duration, HashMap,
|
||||
HashSet, Inscription, Step, StepError, StepResult, TxSource, TxStatus, assert_sorted_outcome,
|
||||
collect_indexed_messages, collect_indexed_messages_exactly_once,
|
||||
ensure_zone_transactions_included, log_step_error, make_inscription, parse_balance_payload,
|
||||
scan_indexer_for_payloads, single_column_table, wait_for_channel_transfer_input_count,
|
||||
wait_for_channel_wallet_counts, wait_for_channel_wallet_note, wait_for_deposit,
|
||||
wait_for_exact_indexed_payload_count,
|
||||
wait_for_finalized_deposit_via_sequencer_and_collect_mempool_pending,
|
||||
wait_for_finalized_withdraw_via_sequencer_and_collect_mempool_pending,
|
||||
wait_for_indexer_unordered, wait_for_transactions_finalized, wait_for_tx_status_lifecycle,
|
||||
wait_for_withdraw, wait_until_sorted_conflict_settles, zone_step_error,
|
||||
};
|
||||
|
||||
#[cucumber::then(expr = "all zone messages are safe in {int} seconds")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_all_zone_messages_are_safe(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
let inscription_ids = log_step_error(step, world.zone.ordered_inscription_ids())?;
|
||||
|
||||
if !world.zone.has_published_messages() {
|
||||
return Err(StepError::LogicalError {
|
||||
message: "No zone messages have been published".to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let node = log_step_error(step, world.zone_node_http_client())?;
|
||||
|
||||
ensure_zone_transactions_included(
|
||||
&node,
|
||||
&inscription_ids,
|
||||
Duration::from_secs(timeout_seconds),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))
|
||||
}
|
||||
|
||||
#[cucumber::then(expr = "all zone messages are finalized in {int} seconds")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_all_zone_messages_are_finalized(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
let inscription_ids = log_step_error(step, world.zone.ordered_inscription_ids())?;
|
||||
|
||||
if !world.zone.has_published_messages() {
|
||||
return Err(StepError::LogicalError {
|
||||
message: "No zone messages have been published".to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let node_url = log_step_error(step, world.zone_node_url())?;
|
||||
|
||||
wait_for_transactions_finalized(
|
||||
node_url,
|
||||
&inscription_ids,
|
||||
Duration::from_secs(timeout_seconds),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))
|
||||
}
|
||||
|
||||
#[cucumber::then(
|
||||
expr = "sequencer {string} emits the full transaction lifecycle for zone messages in {int} seconds:"
|
||||
)]
|
||||
#[cucumber::when(
|
||||
expr = "sequencer {string} emits the full transaction lifecycle for zone messages in {int} seconds:"
|
||||
)]
|
||||
async fn step_sequencer_emits_full_transaction_lifecycle(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
let aliases = single_column_table(step, "alias", "zone message aliases")?;
|
||||
let tx_hashes = log_step_error(step, world.zone.message_tx_hashes_for_aliases(&aliases))?;
|
||||
let mut tx_status_rx = log_step_error(
|
||||
step,
|
||||
world.zone.take_sequencer_tx_status_rx(&sequencer_alias),
|
||||
)?;
|
||||
|
||||
wait_for_tx_status_lifecycle(
|
||||
&mut tx_status_rx,
|
||||
&tx_hashes,
|
||||
&[
|
||||
TxStatus::AcceptedLocally,
|
||||
TxStatus::PendingMempool,
|
||||
TxStatus::OnChain(TxSource::Local),
|
||||
TxStatus::Finalized(TxSource::Local),
|
||||
],
|
||||
Duration::from_secs(timeout_seconds),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))
|
||||
}
|
||||
|
||||
#[cucumber::then("the zone indexer returns messages in this order:")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_zone_indexer_returns_messages_in_order(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
) -> StepResult {
|
||||
let aliases = single_column_table(step, "alias", "zone message aliases")?;
|
||||
let expected = log_step_error(step, world.zone.message_payloads_for_aliases(&aliases))?;
|
||||
let indexer = log_step_error(step, world.zone.indexer())?;
|
||||
|
||||
let actual = collect_indexed_messages(indexer, &expected, Duration::from_mins(3))
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
if actual == expected {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(StepError::LogicalError {
|
||||
message: format!(
|
||||
"Zone indexer returned messages in unexpected order: expected {} messages, got {}",
|
||||
expected.len(),
|
||||
actual.len()
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
#[cucumber::then(expr = "the zone indexer returns messages in any order in {int} seconds:")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_zone_indexer_returns_messages_in_any_order(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
let aliases = single_column_table(step, "alias", "zone message aliases")?;
|
||||
let expected = log_step_error(step, world.zone.message_payloads_for_aliases(&aliases))?;
|
||||
let expected = expected.into_iter().collect::<HashSet<_>>();
|
||||
let indexer = log_step_error(step, world.zone.indexer())?;
|
||||
|
||||
wait_for_indexer_unordered(indexer, &expected, Duration::from_secs(timeout_seconds))
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cucumber::then("the zone indexer returns each of these messages exactly once in this order:")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_zone_indexer_returns_messages_exactly_once_in_order(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
) -> StepResult {
|
||||
let aliases = single_column_table(step, "alias", "zone message aliases")?;
|
||||
let expected = log_step_error(step, world.zone.message_payloads_for_aliases(&aliases))?;
|
||||
let indexer = log_step_error(step, world.zone.indexer())?;
|
||||
|
||||
let actual = collect_indexed_messages_exactly_once(indexer, &expected, Duration::from_mins(3))
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
if actual == expected {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(StepError::LogicalError {
|
||||
message: format!(
|
||||
"Zone indexer returned duplicate or out-of-order messages: expected {} messages, got {}",
|
||||
expected.len(),
|
||||
actual.len()
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
#[cucumber::then(expr = "zone transaction {string} is included in {int} seconds")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_zone_transaction_is_included(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
transaction_alias: String,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
let tx_hash = world.resolve_submitted_transaction(&transaction_alias)?;
|
||||
let node = log_step_error(step, world.zone_node_http_client())?;
|
||||
|
||||
ensure_zone_transactions_included(&node, &[tx_hash], Duration::from_secs(timeout_seconds))
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))
|
||||
}
|
||||
|
||||
#[cucumber::then(expr = "zone transaction {string} is finalized in {int} seconds")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_zone_transaction_is_finalized(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
transaction_alias: String,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
let tx_hash = world.resolve_submitted_transaction(&transaction_alias)?;
|
||||
let node_url = log_step_error(step, world.zone_node_url())?;
|
||||
|
||||
wait_for_transactions_finalized(node_url, &[tx_hash], Duration::from_secs(timeout_seconds))
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))
|
||||
}
|
||||
|
||||
#[cucumber::then(expr = "the zone indexer returns finalized deposit {string} in {int} seconds")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_zone_indexer_returns_finalized_deposit(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
deposit_alias: String,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
let (deposit, amount) = world
|
||||
.zone
|
||||
.resolve_submitted_deposit(&deposit_alias)?
|
||||
.clone();
|
||||
let indexer = log_step_error(step, world.zone.indexer())?;
|
||||
|
||||
wait_for_deposit(
|
||||
indexer,
|
||||
&deposit,
|
||||
amount,
|
||||
Duration::from_secs(timeout_seconds),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))
|
||||
}
|
||||
|
||||
#[cucumber::then(expr = "the zone indexer returns finalized withdraw {string} in {int} seconds")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_zone_indexer_returns_finalized_withdraw(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
withdraw_alias: String,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
let withdraw = world
|
||||
.zone
|
||||
.resolve_submitted_withdraw(&withdraw_alias)?
|
||||
.clone();
|
||||
let indexer = log_step_error(step, world.zone.indexer())?;
|
||||
|
||||
wait_for_withdraw(indexer, &withdraw, Duration::from_secs(timeout_seconds))
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))
|
||||
}
|
||||
|
||||
#[cucumber::then(
|
||||
expr = "the zone indexer returns a finalized channel transfer consuming {int} inputs in {int} seconds"
|
||||
)]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_zone_indexer_returns_finalized_channel_transfer_input_count(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
expected_inputs: usize,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
let indexer = log_step_error(step, world.zone.indexer())?;
|
||||
|
||||
wait_for_channel_transfer_input_count(
|
||||
indexer,
|
||||
expected_inputs,
|
||||
Duration::from_secs(timeout_seconds),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))
|
||||
}
|
||||
|
||||
#[cucumber::then(
|
||||
expr = "the channel wallet of {string} contains a note of value {int} in {int} seconds"
|
||||
)]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_channel_wallet_contains_note(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
value: u64,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
let client = log_step_error(step, world.zone.sequencer_client(&sequencer_alias))?;
|
||||
wait_for_channel_wallet_note(client, value, false, Duration::from_secs(timeout_seconds))
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))
|
||||
}
|
||||
|
||||
#[cucumber::then(
|
||||
expr = "the channel wallet of {string} contains a finalized note of value {int} in {int} seconds"
|
||||
)]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_channel_wallet_contains_finalized_note(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
value: u64,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
let client = log_step_error(step, world.zone.sequencer_client(&sequencer_alias))?;
|
||||
wait_for_channel_wallet_note(client, value, true, Duration::from_secs(timeout_seconds))
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))
|
||||
}
|
||||
|
||||
#[cucumber::then(
|
||||
expr = "the channel wallet of {string} has exactly {int} finalized and {int} unfinalized notes in {int} seconds"
|
||||
)]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_channel_wallet_has_exact_counts(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
finalized: usize,
|
||||
unfinalized: usize,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
let client = log_step_error(step, world.zone.sequencer_client(&sequencer_alias))?;
|
||||
wait_for_channel_wallet_counts(
|
||||
client,
|
||||
finalized,
|
||||
unfinalized,
|
||||
Duration::from_secs(timeout_seconds),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))
|
||||
}
|
||||
|
||||
#[cucumber::then(expr = "sequencer {string} finalizes deposit {string} in {int} seconds")]
|
||||
async fn step_zone_sequencer_finalizes_deposit(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
deposit_alias: String,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
let (deposit, amount) = world
|
||||
.zone
|
||||
.resolve_submitted_deposit(&deposit_alias)?
|
||||
.clone();
|
||||
let events = log_step_error(step, world.zone.sequencer_events_mut(&sequencer_alias))?;
|
||||
|
||||
let mempool_pending = wait_for_finalized_deposit_via_sequencer_and_collect_mempool_pending(
|
||||
events,
|
||||
&deposit,
|
||||
amount,
|
||||
Duration::from_secs(timeout_seconds),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
world
|
||||
.zone
|
||||
.record_mempool_pending(sequencer_alias.clone(), mempool_pending);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cucumber::then(expr = "sequencer {string} finalizes withdraw {string} in {int} seconds")]
|
||||
async fn step_zone_sequencer_finalizes_withdraw(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
withdraw_alias: String,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
let withdraw = world
|
||||
.zone
|
||||
.resolve_submitted_withdraw(&withdraw_alias)?
|
||||
.clone();
|
||||
let events = log_step_error(step, world.zone.sequencer_events_mut(&sequencer_alias))?;
|
||||
|
||||
let mempool_pending = wait_for_finalized_withdraw_via_sequencer_and_collect_mempool_pending(
|
||||
events,
|
||||
&withdraw,
|
||||
Duration::from_secs(timeout_seconds),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
world
|
||||
.zone
|
||||
.record_mempool_pending(sequencer_alias.clone(), mempool_pending);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cucumber::then(
|
||||
expr = "the zone indexer returns all zone messages exactly once in any order in {int} seconds"
|
||||
)]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_zone_indexer_returns_all_messages_exactly_once_any_order(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
let expected_set = published_payload_set(world, step)?;
|
||||
let indexer = log_step_error(step, world.zone.indexer())?;
|
||||
|
||||
let seen =
|
||||
wait_for_indexer_unordered(indexer, &expected_set, Duration::from_secs(timeout_seconds))
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(CONCURRENT_DUPLICATE_SETTLE_SECS)).await;
|
||||
|
||||
let all_payloads = scan_indexer_for_payloads(indexer, &expected_set)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
ensure_indexed_payloads_match_once(&expected_set, &seen, &all_payloads)
|
||||
}
|
||||
|
||||
fn published_payload_set(
|
||||
world: &CucumberWorld,
|
||||
step: &Step,
|
||||
) -> Result<HashSet<Inscription>, StepError> {
|
||||
let expected_payloads = log_step_error(step, world.zone.published_message_payloads())?;
|
||||
|
||||
Ok(expected_payloads.into_iter().collect())
|
||||
}
|
||||
|
||||
fn ensure_indexed_payloads_match_once(
|
||||
expected: &HashSet<Inscription>,
|
||||
seen: &HashSet<Inscription>,
|
||||
all_payloads: &[Inscription],
|
||||
) -> StepResult {
|
||||
let unique: HashSet<&Inscription> = all_payloads.iter().collect();
|
||||
|
||||
if unique.len() != all_payloads.len() {
|
||||
return Err(StepError::LogicalError {
|
||||
message: format!(
|
||||
"Duplicate inscriptions detected on chain: expected {} unique, got {} total",
|
||||
unique.len(),
|
||||
all_payloads.len()
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if unique.len() != expected.len() || seen != expected {
|
||||
return Err(StepError::LogicalError {
|
||||
message: format!(
|
||||
"Zone indexer did not return the expected message set: expected {}, got {}",
|
||||
expected.len(),
|
||||
unique.len()
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cucumber::then(
|
||||
expr = "the zone indexer returns {int} copies of zone message {string} in {int} seconds"
|
||||
)]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_zone_indexer_returns_payload_count(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
expected_count: usize,
|
||||
payload: String,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
let indexer = log_step_error(step, world.zone.indexer())?;
|
||||
wait_for_exact_indexed_payload_count(
|
||||
indexer,
|
||||
make_inscription(&payload),
|
||||
expected_count,
|
||||
Duration::from_secs(timeout_seconds),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))
|
||||
}
|
||||
|
||||
#[cucumber::then(expr = "zone balance updates keep all accounts non-negative after {int} seconds")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_zone_balance_updates_keep_accounts_non_negative(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
settle_seconds: u64,
|
||||
) -> StepResult {
|
||||
tokio::time::sleep(Duration::from_secs(settle_seconds)).await;
|
||||
|
||||
let mut balances = world.zone.zone_account_balances()?;
|
||||
let expected_set = published_payload_set(world, step)?;
|
||||
let indexer = log_step_error(step, world.zone.indexer())?;
|
||||
let on_chain = scan_indexer_for_payloads(indexer, &expected_set)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
apply_indexed_balance_updates(&mut balances, &on_chain);
|
||||
|
||||
ensure_balances_non_negative(&balances)
|
||||
}
|
||||
|
||||
#[cucumber::then(
|
||||
expr = "the zone indexer preserves per-sequencer order and converges without duplicates in {int} seconds"
|
||||
)]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_zone_indexer_preserves_per_sequencer_order_without_duplicates(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
let expected_set = published_payload_set(world, step)?;
|
||||
let total = world.zone.sorted_total_payloads()?;
|
||||
let expected_by_sequencer = world.zone.sorted_expected_by_sequencer()?;
|
||||
let discarded = log_step_error(step, world.zone.discarded_payloads(DEFAULT_ZONE_SEQUENCER))?;
|
||||
let indexer = log_step_error(step, world.zone.indexer())?;
|
||||
|
||||
let on_chain = wait_until_sorted_conflict_settles(
|
||||
indexer,
|
||||
&expected_set,
|
||||
&discarded,
|
||||
total,
|
||||
Duration::from_secs(timeout_seconds),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
let discarded_snapshot = discarded.lock().await.clone();
|
||||
assert_sorted_outcome(
|
||||
&on_chain,
|
||||
&discarded_snapshot,
|
||||
total,
|
||||
&expected_by_sequencer,
|
||||
)
|
||||
}
|
||||
|
||||
fn apply_indexed_balance_updates(balances: &mut HashMap<String, i64>, payloads: &[Inscription]) {
|
||||
for payload in payloads {
|
||||
let Some((_, account, delta)) = parse_balance_payload(payload) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
*balances.entry(account).or_default() += delta;
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_balances_non_negative(balances: &HashMap<String, i64>) -> StepResult {
|
||||
let negative = balances
|
||||
.iter()
|
||||
.filter(|(_, balance)| **balance < 0)
|
||||
.map(|(account, balance)| format!("{account}={balance}"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if negative.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(StepError::LogicalError {
|
||||
message: format!(
|
||||
"Zone account balances went negative: {}",
|
||||
negative.join(", ")
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
use super::{
|
||||
CucumberWorld, Step, StepResult, publish_atomic_zone_withdraw_transaction,
|
||||
save_zone_checkpoint, single_column_table, start_named_sequencer_with_startup,
|
||||
submit_atomic_zone_deposit_transaction, submit_zone_channel_config,
|
||||
submit_zone_channel_split_transaction, submit_zone_deposit_transaction,
|
||||
submit_zone_multi_deposit_transaction, submit_zone_withdraw_transaction, when,
|
||||
zone_atomic_withdraw_rows, zone_config_row,
|
||||
};
|
||||
|
||||
#[when(expr = "I save current checkpoint of sequencer {string} as {string}")]
|
||||
fn step_save_zone_checkpoint(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
checkpoint_alias: String,
|
||||
) -> StepResult {
|
||||
save_zone_checkpoint(world, step, sequencer_alias, checkpoint_alias)
|
||||
}
|
||||
|
||||
#[when(expr = "I restart zone sequencer {string} from checkpoint {string}")]
|
||||
async fn step_restart_zone_sequencer_from_checkpoint(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
checkpoint_alias: String,
|
||||
) -> StepResult {
|
||||
let checkpoint = world.zone.resolve_checkpoint(checkpoint_alias)?;
|
||||
let startup = world.zone.sequencer_startup_for(&sequencer_alias);
|
||||
|
||||
start_named_sequencer_with_startup(world, step, &sequencer_alias, Some(checkpoint), startup)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "I restart zone sequencer {string} fresh")]
|
||||
async fn step_restart_zone_sequencer_fresh(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
) -> StepResult {
|
||||
let startup = world.zone.sequencer_startup_for(&sequencer_alias);
|
||||
start_named_sequencer_with_startup(world, step, &sequencer_alias, None, startup).await
|
||||
}
|
||||
|
||||
#[when(expr = "sequencer {string} submits zone config transaction {string} authorizing:")]
|
||||
async fn step_submit_zone_channel_config_transaction(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
transaction_alias: String,
|
||||
) -> StepResult {
|
||||
let authorized_aliases =
|
||||
single_column_table(step, "alias", "authorized zone sequencer aliases")?;
|
||||
|
||||
submit_zone_channel_config(
|
||||
world,
|
||||
step,
|
||||
&sequencer_alias,
|
||||
transaction_alias,
|
||||
authorized_aliases,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "sequencer {string} submits zone config transaction {string} with posting timeframe {int} and timeout {int} authorizing:"
|
||||
)]
|
||||
async fn step_submit_zone_channel_config_transaction_with_posting_window(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
transaction_alias: String,
|
||||
posting_timeframe: u32,
|
||||
posting_timeout: u32,
|
||||
) -> StepResult {
|
||||
let authorized_aliases =
|
||||
single_column_table(step, "alias", "authorized zone sequencer aliases")?;
|
||||
|
||||
submit_zone_channel_config(
|
||||
world,
|
||||
step,
|
||||
&sequencer_alias,
|
||||
transaction_alias,
|
||||
authorized_aliases,
|
||||
posting_timeframe,
|
||||
posting_timeout,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "sequencer {string} submits zone config transaction:")]
|
||||
async fn step_submit_zone_channel_config_transaction_from_table(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
) -> StepResult {
|
||||
let row = zone_config_row(step)?;
|
||||
|
||||
submit_zone_channel_config(
|
||||
world,
|
||||
step,
|
||||
&sequencer_alias,
|
||||
row.config_name,
|
||||
row.authorized_sequencers,
|
||||
row.posting_timeframe,
|
||||
row.posting_timeout,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "I submit zone deposit transaction {string} into channel of {string} of {int} with metadata {string}"
|
||||
)]
|
||||
async fn step_submit_zone_deposit_transaction(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
transaction_alias: String,
|
||||
channel_alias: String,
|
||||
amount: u64,
|
||||
metadata: String,
|
||||
) -> StepResult {
|
||||
submit_zone_deposit_transaction(
|
||||
world,
|
||||
step,
|
||||
transaction_alias,
|
||||
channel_alias,
|
||||
amount,
|
||||
metadata
|
||||
.into_bytes()
|
||||
.try_into()
|
||||
.expect("Metadata too large for deposit op."),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "I submit zone deposit transaction {string} into channel of {string} consuming notes valued {string} with metadata {string}"
|
||||
)]
|
||||
async fn step_submit_zone_multi_deposit_transaction(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
transaction_alias: String,
|
||||
channel_alias: String,
|
||||
values: String,
|
||||
metadata: String,
|
||||
) -> StepResult {
|
||||
let input_values = values
|
||||
.split(',')
|
||||
.map(|part| part.trim().parse::<u64>())
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.expect("deposit note values must be a comma-separated list of integers");
|
||||
submit_zone_multi_deposit_transaction(
|
||||
world,
|
||||
step,
|
||||
transaction_alias,
|
||||
channel_alias,
|
||||
input_values,
|
||||
metadata
|
||||
.into_bytes()
|
||||
.try_into()
|
||||
.expect("Metadata too large for deposit op."),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "I submit zone deposit transaction {string} into channel of {string} consuming {int} notes of value {int} with metadata {string}"
|
||||
)]
|
||||
async fn step_submit_zone_bulk_deposit_transaction(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
transaction_alias: String,
|
||||
channel_alias: String,
|
||||
count: usize,
|
||||
value: u64,
|
||||
metadata: String,
|
||||
) -> StepResult {
|
||||
let input_values = vec![value; count];
|
||||
submit_zone_multi_deposit_transaction(
|
||||
world,
|
||||
step,
|
||||
transaction_alias,
|
||||
channel_alias,
|
||||
input_values,
|
||||
metadata
|
||||
.into_bytes()
|
||||
.try_into()
|
||||
.expect("Metadata too large for deposit op."),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "sequencer {string} splits deposit {string} into {int} dust notes as {string}")]
|
||||
async fn step_submit_zone_channel_split_transaction(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
deposit_alias: String,
|
||||
dust_count: usize,
|
||||
transaction_alias: String,
|
||||
) -> StepResult {
|
||||
submit_zone_channel_split_transaction(
|
||||
world,
|
||||
step,
|
||||
&sequencer_alias,
|
||||
&deposit_alias,
|
||||
dust_count,
|
||||
transaction_alias,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "sequencer {string} submits atomic zone deposit transaction {string} with inscription {string} of {int} with metadata {string}"
|
||||
)]
|
||||
async fn step_submit_atomic_zone_deposit_transaction(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
transaction_alias: String,
|
||||
message_alias: String,
|
||||
amount: u64,
|
||||
metadata: String,
|
||||
) -> StepResult {
|
||||
submit_atomic_zone_deposit_transaction(
|
||||
world,
|
||||
step,
|
||||
&sequencer_alias,
|
||||
transaction_alias,
|
||||
message_alias,
|
||||
amount,
|
||||
metadata
|
||||
.into_bytes()
|
||||
.try_into()
|
||||
.expect("Metadata too large for deposit op."),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "sequencer {string} submits zone withdraw transaction {string} with inscription {string} of {int}"
|
||||
)]
|
||||
async fn step_submit_zone_withdraw_transaction(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
transaction_alias: String,
|
||||
message_alias: String,
|
||||
amount: u64,
|
||||
) -> StepResult {
|
||||
submit_zone_withdraw_transaction(
|
||||
world,
|
||||
step,
|
||||
&sequencer_alias,
|
||||
transaction_alias,
|
||||
message_alias,
|
||||
amount,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "sequencer {string} publishes atomic withdraw {string} with inscription {string}:")]
|
||||
async fn step_publish_atomic_zone_withdraw_transaction(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
bundle_alias: String,
|
||||
message_alias: String,
|
||||
) -> StepResult {
|
||||
let withdraw_rows = zone_atomic_withdraw_rows(step)?;
|
||||
publish_atomic_zone_withdraw_transaction(
|
||||
world,
|
||||
step,
|
||||
&sequencer_alias,
|
||||
bundle_alias,
|
||||
message_alias,
|
||||
withdraw_rows,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
use super::{
|
||||
CucumberWorld, DriveMode, Duration, Step, StepError, StepResult, ZoneSequencerStartup, given,
|
||||
initialize_zone_indexer, log_step_error, parse_optional_submit_depth,
|
||||
register_zone_sequencers_with_shared_key, single_column_table, start_named_sequencer,
|
||||
start_named_sequencer_with_startup, start_nodes_with_zone_resources, stop_zone_sequencer,
|
||||
wait_for_lib_advance, when, zone_account_balances, zone_node_resource_rows,
|
||||
zone_sequencer_start_rows, zone_step_error,
|
||||
};
|
||||
|
||||
#[given("I start nodes with wallet and sequencer resources:")]
|
||||
#[when("I start nodes with wallet and sequencer resources:")]
|
||||
async fn step_start_nodes_with_wallet_and_sequencer_resources(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
) -> StepResult {
|
||||
let rows = zone_node_resource_rows(step)?;
|
||||
|
||||
start_nodes_with_zone_resources(world, step, rows).await
|
||||
}
|
||||
|
||||
#[given(expr = "the following zone sequencers share the signing key of {string}:")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_value,
|
||||
reason = "Cucumber string captures are provided as owned `String`s"
|
||||
)]
|
||||
fn step_zone_sequencers_share_signing_key(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
source_alias: String,
|
||||
) -> StepResult {
|
||||
let aliases = single_column_table(step, "alias", "zone sequencer aliases")?;
|
||||
register_zone_sequencers_with_shared_key(world, &source_alias, aliases)
|
||||
}
|
||||
|
||||
#[given("the following zone account balances exist:")]
|
||||
fn step_zone_account_balances(world: &mut CucumberWorld, step: &Step) -> StepResult {
|
||||
let balances = zone_account_balances(step)?
|
||||
.into_iter()
|
||||
.map(|row| (row.account, row.balance))
|
||||
.collect();
|
||||
|
||||
world.zone.set_zone_account_balances(balances);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(expr = "I start zone sequencer {string}")]
|
||||
async fn step_start_zone_sequencer(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
) -> StepResult {
|
||||
start_named_sequencer(world, step, sequencer_alias, None, DriveMode::passive()).await
|
||||
}
|
||||
|
||||
#[when(expr = "I start zone sequencer {string} with indexer")]
|
||||
async fn step_start_zone_sequencer_with_indexer(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
) -> StepResult {
|
||||
start_sequencer_with_indexer(world, step, &sequencer_alias).await
|
||||
}
|
||||
|
||||
async fn start_sequencer_with_indexer(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: &str,
|
||||
) -> StepResult {
|
||||
start_named_sequencer(world, step, sequencer_alias, None, DriveMode::passive()).await?;
|
||||
initialize_zone_indexer(world, step, sequencer_alias)
|
||||
}
|
||||
|
||||
#[when("I start zone sequencers:")]
|
||||
async fn step_start_zone_sequencers(world: &mut CucumberWorld, step: &Step) -> StepResult {
|
||||
for row in zone_sequencer_start_rows(step)? {
|
||||
let alias = row.alias;
|
||||
let startup = ZoneSequencerStartup {
|
||||
pending_submit_depth: parse_optional_submit_depth(step, &row.pending_submit_depth)?,
|
||||
passive_republish_orphans: row.passive_republish_orphans,
|
||||
};
|
||||
world.zone.set_sequencer_startup(&alias, startup);
|
||||
|
||||
start_named_sequencer_with_startup(world, step, &alias, None, startup).await?;
|
||||
|
||||
if row.indexer {
|
||||
initialize_zone_indexer(world, step, &alias)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(expr = "I stop zone sequencer {string}")]
|
||||
fn step_stop_zone_sequencer(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
) -> StepResult {
|
||||
let _ = step;
|
||||
stop_zone_sequencer(world, sequencer_alias)
|
||||
}
|
||||
|
||||
#[cucumber::when(expr = "the zone LIB advances in {int} seconds")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_zone_lib_advances(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
let client = log_step_error(step, world.zone_node_http_client())?;
|
||||
let initial_lib_slot = client
|
||||
.consensus_info()
|
||||
.await
|
||||
.map_err(|error| StepError::LogicalError {
|
||||
message: format!("Failed to fetch zone consensus info: {error}"),
|
||||
})?
|
||||
.cryptarchia_info
|
||||
.lib_slot;
|
||||
|
||||
wait_for_lib_advance(
|
||||
&client,
|
||||
initial_lib_slot,
|
||||
Duration::from_secs(timeout_seconds),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet, VecDeque},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use cucumber::{gherkin::Step, given, when};
|
||||
use lb_core::mantle::ops::channel::inscribe::Inscription;
|
||||
|
||||
use super::{
|
||||
CustomRepublishDeps, PublishDeadline,
|
||||
actions::{
|
||||
DriveMode, initialize_zone_indexer, publish_atomic_zone_withdraw_transaction,
|
||||
publish_zone_messages, publish_zone_messages_concurrently,
|
||||
register_zone_sequencers_with_shared_key, remember_published_zone_message,
|
||||
save_zone_checkpoint, start_named_sequencer,
|
||||
start_named_sequencer_with_pending_submit_depth, start_nodes_with_zone_resources,
|
||||
stop_zone_sequencer, submit_atomic_zone_deposit_transaction, submit_zone_channel_config,
|
||||
submit_zone_channel_split_transaction, submit_zone_deposit_transaction,
|
||||
submit_zone_multi_deposit_transaction, submit_zone_withdraw_transaction,
|
||||
},
|
||||
assertions::{
|
||||
assert_sorted_outcome, scan_indexer_for_payloads, wait_for_indexer_unordered,
|
||||
wait_until_sorted_conflict_settles,
|
||||
},
|
||||
balance_update_payload, collect_indexed_messages, collect_indexed_messages_exactly_once,
|
||||
ensure_zone_transactions_included,
|
||||
errors::{log_step_error, zone_step_error},
|
||||
parse_balance_payload, publish_message_with_retry,
|
||||
runner::{TxSource, TxStatus},
|
||||
tables::{
|
||||
ConcurrentZoneMessageRow, GeneratedZoneMessageBatch, concurrent_zone_message_rows,
|
||||
custom_tx_rows, generated_zone_message_batches, generated_zone_message_sequencers,
|
||||
group_zone_messages_by_sequencer, zone_account_balances, zone_atomic_withdraw_rows,
|
||||
zone_balance_rows, zone_config_row, zone_message_rows, zone_node_resource_rows,
|
||||
zone_sequencer_start_rows, zone_sequencing_state_row,
|
||||
},
|
||||
wait_for_channel_transfer_input_count, wait_for_channel_view, wait_for_channel_wallet_counts,
|
||||
wait_for_channel_wallet_note, wait_for_deposit, wait_for_exact_indexed_payload_count,
|
||||
wait_for_finalized_deposit_via_sequencer_and_collect_mempool_pending,
|
||||
wait_for_finalized_withdraw_via_sequencer_and_collect_mempool_pending, wait_for_lib_advance,
|
||||
wait_for_on_chain_statuses_and_collect_mempool_pending, wait_for_transactions_finalized,
|
||||
wait_for_turn_to_write, wait_for_tx_status_lifecycle, wait_for_withdraw,
|
||||
};
|
||||
use crate::{
|
||||
common::mantle_inscription::make_inscription,
|
||||
cucumber::{
|
||||
error::{StepError, StepResult},
|
||||
steps::parse_steps::single_column_table,
|
||||
world::{CucumberWorld, ZoneSequencerStartup},
|
||||
},
|
||||
};
|
||||
|
||||
pub(super) const DEFAULT_ZONE_SEQUENCER: &str = "SEQ_A";
|
||||
|
||||
fn parse_submit_depth(step: &Step, value: &str) -> Result<usize, StepError> {
|
||||
let value = value.trim();
|
||||
if matches!(value.to_lowercase().as_str(), "unlimited" | "none") {
|
||||
return Ok(usize::MAX);
|
||||
}
|
||||
|
||||
let inner = value
|
||||
.strip_prefix("Some(")
|
||||
.and_then(|rest| rest.strip_suffix(')'))
|
||||
.unwrap_or(value);
|
||||
|
||||
let limit = inner
|
||||
.parse::<usize>()
|
||||
.map_err(|error| StepError::LogicalError {
|
||||
message: format!(
|
||||
"Invalid pending submit depth '{value}' in step '{}': {error}",
|
||||
step.value
|
||||
),
|
||||
})?;
|
||||
|
||||
Ok(limit)
|
||||
}
|
||||
|
||||
fn parse_optional_submit_depth(step: &Step, value: &str) -> Result<Option<usize>, StepError> {
|
||||
let value = value.trim();
|
||||
if value.eq_ignore_ascii_case("default") {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
parse_submit_depth(step, value).map(Some)
|
||||
}
|
||||
|
||||
const fn passive_mode_for_startup(startup: ZoneSequencerStartup) -> DriveMode {
|
||||
if startup.passive_republish_orphans {
|
||||
DriveMode::passive_republish_orphans()
|
||||
} else {
|
||||
DriveMode::passive()
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_named_sequencer_with_startup(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: &str,
|
||||
checkpoint: Option<lb_zone_sdk::sequencer::SequencerCheckpoint>,
|
||||
startup: ZoneSequencerStartup,
|
||||
) -> StepResult {
|
||||
let mode = passive_mode_for_startup(startup);
|
||||
if let Some(submit_depth) = startup.pending_submit_depth {
|
||||
start_named_sequencer_with_pending_submit_depth(
|
||||
world,
|
||||
step,
|
||||
sequencer_alias,
|
||||
checkpoint,
|
||||
mode,
|
||||
submit_depth,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
start_named_sequencer(world, step, sequencer_alias, checkpoint, mode).await
|
||||
}
|
||||
}
|
||||
|
||||
const CONCURRENT_DUPLICATE_SETTLE_SECS: u64 = 30;
|
||||
|
||||
mod assertions;
|
||||
mod channel;
|
||||
mod lifecycle;
|
||||
mod policies;
|
||||
mod publishing;
|
||||
mod sequencing;
|
||||
@@ -0,0 +1,259 @@
|
||||
use super::{
|
||||
Arc, ConcurrentZoneMessageRow, CucumberWorld, DEFAULT_ZONE_SEQUENCER, DriveMode,
|
||||
GeneratedZoneMessageBatch, HashMap, HashSet, Inscription, Step, StepResult,
|
||||
balance_update_payload, concurrent_zone_message_rows, generated_zone_message_batches,
|
||||
generated_zone_message_sequencers, group_zone_messages_by_sequencer, initialize_zone_indexer,
|
||||
make_inscription, publish_zone_messages_concurrently, start_named_sequencer,
|
||||
start_named_sequencer_with_pending_submit_depth, when, zone_balance_rows,
|
||||
};
|
||||
|
||||
#[when("the following zone messages are published concurrently with republish policy:")]
|
||||
async fn step_publish_zone_messages_concurrently_with_republish_policy(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
) -> StepResult {
|
||||
let rows = concurrent_zone_message_rows(step)?;
|
||||
publish_zone_messages_with_republish_policy(world, step, rows).await
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "each listed zone sequencer publishes {int} generated zone messages concurrently with republish policy:"
|
||||
)]
|
||||
async fn step_publish_generated_zone_messages_concurrently_with_republish_policy(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
messages_per_sequencer: usize,
|
||||
) -> StepResult {
|
||||
let rows = build_generated_zone_message_rows(
|
||||
generated_zone_message_batches(step)?,
|
||||
messages_per_sequencer,
|
||||
);
|
||||
|
||||
publish_zone_messages_with_republish_policy(world, step, rows).await
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "each listed zone sequencer publishes {int} copies of zone message {string} concurrently with republish policy:"
|
||||
)]
|
||||
async fn step_publish_repeated_zone_messages_concurrently_with_republish_policy(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
copies_per_sequencer: usize,
|
||||
payload: String,
|
||||
) -> StepResult {
|
||||
let rows = build_repeated_zone_message_rows(
|
||||
generated_zone_message_sequencers(step)?,
|
||||
copies_per_sequencer,
|
||||
&payload,
|
||||
);
|
||||
|
||||
publish_zone_messages_with_republish_policy(world, step, rows).await
|
||||
}
|
||||
|
||||
fn build_generated_zone_message_rows(
|
||||
batches: Vec<GeneratedZoneMessageBatch>,
|
||||
messages_per_sequencer: usize,
|
||||
) -> Vec<ConcurrentZoneMessageRow> {
|
||||
let mut builder = GeneratedZoneMessages::default();
|
||||
|
||||
for batch in batches {
|
||||
builder.append_numbered_payloads(batch, messages_per_sequencer);
|
||||
}
|
||||
|
||||
builder.finish()
|
||||
}
|
||||
|
||||
fn build_repeated_zone_message_rows(
|
||||
sequencer_aliases: Vec<String>,
|
||||
copies_per_sequencer: usize,
|
||||
payload: &str,
|
||||
) -> Vec<ConcurrentZoneMessageRow> {
|
||||
let mut builder = GeneratedZoneMessages::default();
|
||||
let payload = make_inscription(payload);
|
||||
|
||||
for sequencer_alias in sequencer_aliases {
|
||||
builder.append_repeated_payloads(sequencer_alias, copies_per_sequencer, &payload);
|
||||
}
|
||||
|
||||
builder.finish()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct GeneratedZoneMessages {
|
||||
next_message_number: usize,
|
||||
rows: Vec<ConcurrentZoneMessageRow>,
|
||||
}
|
||||
|
||||
impl GeneratedZoneMessages {
|
||||
fn append_numbered_payloads(&mut self, batch: GeneratedZoneMessageBatch, count: usize) {
|
||||
let GeneratedZoneMessageBatch {
|
||||
sequencer_alias,
|
||||
data_prefix,
|
||||
} = batch;
|
||||
|
||||
for payload_number in 1..=count {
|
||||
self.push(
|
||||
sequencer_alias.clone(),
|
||||
make_inscription(&format!("{data_prefix}{payload_number}")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn append_repeated_payloads(
|
||||
&mut self,
|
||||
sequencer_alias: String,
|
||||
count: usize,
|
||||
payload: &Inscription,
|
||||
) {
|
||||
for _ in 1..count {
|
||||
self.push(sequencer_alias.clone(), payload.clone());
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
self.push(sequencer_alias, payload.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self, sequencer_alias: String, payload: Inscription) {
|
||||
self.next_message_number += 1;
|
||||
|
||||
self.rows.push(ConcurrentZoneMessageRow {
|
||||
sequencer_alias,
|
||||
message_alias: format!("MSG_{}", self.next_message_number),
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
fn finish(self) -> Vec<ConcurrentZoneMessageRow> {
|
||||
self.rows
|
||||
}
|
||||
}
|
||||
|
||||
async fn publish_zone_messages_with_republish_policy(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
rows: Vec<ConcurrentZoneMessageRow>,
|
||||
) -> StepResult {
|
||||
let grouped = group_zone_messages_by_sequencer(&rows);
|
||||
|
||||
for (sequencer_alias, messages) in &grouped {
|
||||
let planned = messages
|
||||
.iter()
|
||||
.map(|message| message.payload.clone())
|
||||
.collect();
|
||||
start_named_sequencer_with_pending_submit_depth(
|
||||
world,
|
||||
step,
|
||||
sequencer_alias,
|
||||
None,
|
||||
DriveMode::RepublishLineage { planned },
|
||||
usize::MAX,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Each sequencer's lineage policy owns publishing its own copies; the step
|
||||
// only records the messages so the indexer assertions can find them.
|
||||
for row in rows {
|
||||
world
|
||||
.zone
|
||||
.remember_zone_message(row.message_alias, row.payload, None, None, None);
|
||||
}
|
||||
if world.zone.indexer().is_err() {
|
||||
initialize_zone_indexer(world, step, DEFAULT_ZONE_SEQUENCER)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when("the following zone messages are published concurrently with sorted conflict policy:")]
|
||||
async fn step_publish_zone_messages_concurrently_with_sorted_policy(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
) -> StepResult {
|
||||
let rows = concurrent_zone_message_rows(step)?;
|
||||
let grouped = group_zone_messages_by_sequencer(&rows);
|
||||
let discarded = Arc::new(tokio::sync::Mutex::new(HashSet::new()));
|
||||
|
||||
for sequencer_alias in grouped.keys() {
|
||||
start_named_sequencer(
|
||||
world,
|
||||
step,
|
||||
sequencer_alias,
|
||||
None,
|
||||
DriveMode::Sorted {
|
||||
discarded: Arc::clone(&discarded),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
world.zone.set_sorted_total_payloads(rows.len());
|
||||
world.zone.set_sorted_expected_by_sequencer(
|
||||
grouped
|
||||
.iter()
|
||||
.map(|(sequencer_alias, messages)| {
|
||||
(
|
||||
sequencer_alias.clone(),
|
||||
messages
|
||||
.iter()
|
||||
.map(|message| message.payload.clone())
|
||||
.collect(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||
publish_zone_messages_concurrently(world, step, rows).await
|
||||
}
|
||||
|
||||
#[when("the following zone balance updates are published concurrently with balance-aware policy:")]
|
||||
async fn step_publish_zone_balance_updates_with_balance_policy(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
) -> StepResult {
|
||||
let rows = zone_balance_rows(step)?;
|
||||
let initial_balances = world.zone.zone_account_balances()?;
|
||||
let grouped = rows.iter().fold(
|
||||
HashMap::<String, Vec<(String, Inscription)>>::new(),
|
||||
|mut grouped, row| {
|
||||
let payload = balance_update_payload(&row.message_alias, &row.account, row.delta);
|
||||
grouped
|
||||
.entry(row.sequencer_alias.clone())
|
||||
.or_default()
|
||||
.push((row.message_alias.clone(), payload));
|
||||
grouped
|
||||
},
|
||||
);
|
||||
|
||||
for (sequencer_alias, planned) in &grouped {
|
||||
start_named_sequencer(
|
||||
world,
|
||||
step,
|
||||
sequencer_alias,
|
||||
None,
|
||||
DriveMode::BalanceAware {
|
||||
initial_balances: initial_balances.clone(),
|
||||
planned_payloads: planned.iter().map(|(_, payload)| payload.clone()).collect(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
for messages in grouped.values() {
|
||||
for (message_alias, payload) in messages {
|
||||
world.zone.remember_zone_message(
|
||||
message_alias.clone(),
|
||||
payload.clone(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if world.zone.indexer().is_err() {
|
||||
initialize_zone_indexer(world, step, DEFAULT_ZONE_SEQUENCER)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
use super::{
|
||||
CucumberWorld, CustomRepublishDeps, DEFAULT_ZONE_SEQUENCER, DriveMode, Duration, HashSet,
|
||||
Inscription, PublishDeadline, Step, StepError, StepResult, VecDeque, custom_tx_rows,
|
||||
log_step_error, make_inscription, publish_message_with_retry, publish_zone_messages,
|
||||
remember_published_zone_message, start_named_sequencer, wait_for_channel_view,
|
||||
wait_for_indexer_unordered, when, zone_message_rows, zone_step_error,
|
||||
};
|
||||
|
||||
#[when("I publish the following zone messages:")]
|
||||
async fn step_publish_zone_messages(world: &mut CucumberWorld, step: &Step) -> StepResult {
|
||||
publish_zone_messages(
|
||||
world,
|
||||
step,
|
||||
DEFAULT_ZONE_SEQUENCER,
|
||||
zone_message_rows(step)?,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(expr = "sequencer {string} publishes the following zone messages:")]
|
||||
async fn step_publish_zone_messages_for_sequencer(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
) -> StepResult {
|
||||
publish_zone_messages(world, step, sequencer_alias, zone_message_rows(step)?).await
|
||||
}
|
||||
|
||||
/// Publishing while the sequencer's node is down must be rejected: with
|
||||
/// funding configured, building a transaction needs the node's wallet, so
|
||||
/// the sequencer fails fast with `Unavailable` once the stream drop is
|
||||
/// noticed (or surfaces the funding error in the brief window before). A
|
||||
/// fresh `Ready` event fires once the node is back and a live block
|
||||
/// confirms the reconnect.
|
||||
#[cucumber::then(
|
||||
expr = "publishing zone message with data {string} via sequencer {string} fails while the node is down"
|
||||
)]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require the world as the first `&mut` argument"
|
||||
)]
|
||||
async fn step_publish_fails_while_node_down(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
data: String,
|
||||
sequencer_alias: String,
|
||||
) -> StepResult {
|
||||
let _ = step;
|
||||
let payload = make_inscription(&data);
|
||||
let client = world.zone.sequencer_client(&sequencer_alias)?.clone();
|
||||
match client.publish(payload).await {
|
||||
Ok(_) => Err(StepError::LogicalError {
|
||||
message: format!(
|
||||
"Zone publish unexpectedly succeeded for sequencer '{sequencer_alias}' while its node is down"
|
||||
),
|
||||
}),
|
||||
Err(_expected) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "I submit zone message {string} to sequencer {string} with data {string} immediately"
|
||||
)]
|
||||
async fn step_publish_single_zone_message_for_sequencer(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
message_alias: String,
|
||||
sequencer_alias: String,
|
||||
data: String,
|
||||
) -> StepResult {
|
||||
let _ = step;
|
||||
let payload = make_inscription(&data);
|
||||
let handle = world.zone.sequencer_client(&sequencer_alias)?.clone();
|
||||
|
||||
let (published, _checkpoint) = handle
|
||||
.publish(payload.clone())
|
||||
.await
|
||||
.map_err(|error| StepError::LogicalError {
|
||||
message: format!(
|
||||
"Zone publish failed for sequencer '{sequencer_alias}' and message '{message_alias}': {error}"
|
||||
),
|
||||
})?;
|
||||
|
||||
remember_published_zone_message(world, &sequencer_alias, message_alias, payload, &published);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "sequencer {string} submits zone message {string} with data {string} to queue immediately"
|
||||
)]
|
||||
async fn step_publish_single_zone_message_to_queue_for_sequencer(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
message_alias: String,
|
||||
data: String,
|
||||
) -> StepResult {
|
||||
step_publish_single_zone_message_for_sequencer(
|
||||
world,
|
||||
step,
|
||||
message_alias,
|
||||
sequencer_alias,
|
||||
data,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[when(
|
||||
"the following custom transactions are published concurrently with custom republish policy:"
|
||||
)]
|
||||
async fn step_publish_custom_txs_concurrently_with_policy(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
) -> StepResult {
|
||||
let rows = custom_tx_rows(step)?;
|
||||
let mut expected_payloads = Vec::new();
|
||||
|
||||
for row in &rows {
|
||||
let batches: VecDeque<Vec<Inscription>> = (0..row.transactions)
|
||||
.map(|tx_index| {
|
||||
(0..row.inscriptions)
|
||||
.map(|entry_index| {
|
||||
make_inscription(&format!(
|
||||
"custom-{}-{tx_index}-{entry_index}",
|
||||
row.sequencer_alias
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
expected_payloads.extend(batches.iter().flatten().cloned());
|
||||
|
||||
let node_client = log_step_error(
|
||||
step,
|
||||
world.zone_node_http_client_for_sequencer(&row.sequencer_alias),
|
||||
)?;
|
||||
let node_name = world
|
||||
.zone
|
||||
.sequencer_node_name(&row.sequencer_alias)?
|
||||
.to_owned();
|
||||
let funding_pk = world.funding_wallet(&node_name)?.public_key()?;
|
||||
let deps = CustomRepublishDeps {
|
||||
node_client,
|
||||
channel_id: world.zone.sequencer_channel_id(&row.sequencer_alias)?,
|
||||
signing_key: world
|
||||
.zone
|
||||
.sequencer_signing_key(&row.sequencer_alias)?
|
||||
.clone(),
|
||||
funding_pk,
|
||||
batches,
|
||||
};
|
||||
|
||||
start_named_sequencer(
|
||||
world,
|
||||
step,
|
||||
&row.sequencer_alias,
|
||||
None,
|
||||
DriveMode::CustomRepublish {
|
||||
deps: Box::new(deps),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
world
|
||||
.zone
|
||||
.remember_expected_custom_payloads(expected_payloads);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cucumber::then(expr = "the zone indexer returns all custom payloads in {int} seconds")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_zone_indexer_returns_all_custom_payloads(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
let expected: HashSet<Inscription> = world
|
||||
.zone
|
||||
.expected_custom_payloads()
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
if expected.is_empty() {
|
||||
return Err(StepError::LogicalError {
|
||||
message: "no custom transactions were planned".to_owned(),
|
||||
});
|
||||
}
|
||||
let indexer = log_step_error(step, world.zone.indexer())?;
|
||||
|
||||
wait_for_indexer_unordered(indexer, &expected, Duration::from_secs(timeout_seconds))
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(
|
||||
expr = "I submit zone message {string} to sequencer {string} with data {string} on its turn"
|
||||
)]
|
||||
async fn step_publish_single_zone_message_for_sequencer_on_turn(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
message_alias: String,
|
||||
sequencer_alias: String,
|
||||
data: String,
|
||||
) -> StepResult {
|
||||
let payload = make_inscription(&data);
|
||||
let handle = world.zone.sequencer_client(&sequencer_alias)?.clone();
|
||||
let mut view_rx = world.zone.sequencer_channel_view_rx(&sequencer_alias)?;
|
||||
|
||||
wait_for_channel_view(&mut view_rx, Duration::from_mins(3), |view| {
|
||||
view.our_turn_to_write
|
||||
&& view.authorized_key_index.is_some()
|
||||
&& view.authorized_key_index == view.own_key_index
|
||||
})
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
let published = publish_message_with_retry(
|
||||
&handle,
|
||||
&payload,
|
||||
PublishDeadline::from_now(Duration::from_mins(3)),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
remember_published_zone_message(world, &sequencer_alias, message_alias, payload, &published);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(expr = "sequencer {string} submits the following zone messages to queue immediately:")]
|
||||
async fn step_publish_zone_messages_to_queue_for_sequencer(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
) -> StepResult {
|
||||
let rows = zone_message_rows(step)?;
|
||||
let handle = world.zone.sequencer_client(&sequencer_alias)?.clone();
|
||||
|
||||
for (message_alias, payload) in rows {
|
||||
let (published, _checkpoint) = handle
|
||||
.publish(payload.clone())
|
||||
.await
|
||||
.map_err(|error| StepError::LogicalError {
|
||||
message: format!(
|
||||
"Zone publish failed for sequencer '{sequencer_alias}' and message '{message_alias}': {error}"
|
||||
),
|
||||
})?;
|
||||
|
||||
remember_published_zone_message(
|
||||
world,
|
||||
&sequencer_alias,
|
||||
message_alias,
|
||||
payload,
|
||||
&published,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Publish via [`SequencerClient`] and record each inscription id, without
|
||||
/// waiting for on-chain inclusion.
|
||||
///
|
||||
/// Unlike `publishes the following zone messages` (which polls the node to
|
||||
/// confirm inclusion), this performs no node HTTP calls, so it can be issued
|
||||
/// while the node is down: each publish is accepted locally and posted on
|
||||
/// reconnect. Recording the ids (the publish returns them locally) lets later
|
||||
/// `... are finalized` / indexer assertions track the messages once the node is
|
||||
/// back.
|
||||
#[when(
|
||||
expr = "sequencer {string} submits the following zone messages without waiting for inclusion:"
|
||||
)]
|
||||
async fn step_publish_zone_messages_without_inclusion_for_sequencer(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
) -> StepResult {
|
||||
let rows = zone_message_rows(step)?;
|
||||
let handle = world.zone.sequencer_client(&sequencer_alias)?.clone();
|
||||
|
||||
for (message_alias, payload) in rows {
|
||||
let (published, _checkpoint) = handle
|
||||
.publish(payload.clone())
|
||||
.await
|
||||
.map_err(|error| StepError::LogicalError {
|
||||
message: format!(
|
||||
"Zone publish failed for sequencer '{sequencer_alias}' and message '{message_alias}': {error}"
|
||||
),
|
||||
})?;
|
||||
remember_published_zone_message(
|
||||
world,
|
||||
&sequencer_alias,
|
||||
message_alias,
|
||||
payload,
|
||||
&published,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
use super::{
|
||||
CucumberWorld, Duration, Step, StepError, StepResult, log_step_error, single_column_table,
|
||||
wait_for_channel_view, wait_for_on_chain_statuses_and_collect_mempool_pending,
|
||||
wait_for_turn_to_write, zone_sequencing_state_row, zone_step_error,
|
||||
};
|
||||
|
||||
#[cucumber::then(
|
||||
expr = "sequencer {string} reaches sequencing state OWN_KEY_INDEX {int} NOT_OUR_TURN with {int} pending publish txs in {int} seconds"
|
||||
)]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_sequencer_reaches_sequencing_state_not_our_turn(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
own_key_index: usize,
|
||||
pending_publish_txs: usize,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_sequencing_state(
|
||||
world,
|
||||
step,
|
||||
&sequencer_alias,
|
||||
own_key_index,
|
||||
false,
|
||||
pending_publish_txs,
|
||||
timeout_seconds,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cucumber::then(
|
||||
expr = "sequencer {string} reaches sequencing state OWN_KEY_INDEX {int} OUR_TURN with {int} pending publish txs in {int} seconds"
|
||||
)]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_sequencer_reaches_sequencing_state_our_turn(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
own_key_index: usize,
|
||||
pending_publish_txs: usize,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
wait_for_sequencing_state(
|
||||
world,
|
||||
step,
|
||||
&sequencer_alias,
|
||||
own_key_index,
|
||||
true,
|
||||
pending_publish_txs,
|
||||
timeout_seconds,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cucumber::then(expr = "sequencer {string} reaches sequencing state:")]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_sequencer_reaches_sequencing_state_from_table(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
) -> StepResult {
|
||||
let row = zone_sequencing_state_row(step)?;
|
||||
|
||||
wait_for_sequencing_state(
|
||||
world,
|
||||
step,
|
||||
&sequencer_alias,
|
||||
row.own_key_index,
|
||||
row.is_our_turn,
|
||||
row.pending_transactions,
|
||||
row.timeout_seconds,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn wait_for_sequencing_state(
|
||||
world: &CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: &str,
|
||||
own_key_index: usize,
|
||||
is_our_turn: bool,
|
||||
pending_publish_txs: usize,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
let _handle = log_step_error(step, world.zone.sequencer_client(sequencer_alias))?.clone();
|
||||
let mut view_rx = log_step_error(step, world.zone.sequencer_channel_view_rx(sequencer_alias))?;
|
||||
|
||||
wait_for_channel_view(
|
||||
&mut view_rx,
|
||||
Duration::from_secs(timeout_seconds),
|
||||
move |view| {
|
||||
view.own_key_index == Some(own_key_index as u16)
|
||||
&& view.authorized_key_index.is_some()
|
||||
&& view.our_turn_to_write == is_our_turn
|
||||
&& (is_our_turn || view.authorized_key_index != view.own_key_index)
|
||||
&& (!is_our_turn || view.authorized_key_index == Some(own_key_index as u16))
|
||||
&& view.pending_publish_txs == pending_publish_txs
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cucumber::then(
|
||||
expr = "sequencer {string} is notified it is their turn to write in {int} seconds"
|
||||
)]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_sequencer_notified_turn_to_write(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
let mut turn_rx = log_step_error(
|
||||
step,
|
||||
world.zone.sequencer_turn_to_write_rx(&sequencer_alias),
|
||||
)?;
|
||||
wait_for_turn_to_write(&mut turn_rx, Duration::from_secs(timeout_seconds))
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cucumber::then(
|
||||
expr = "sequencer {string} emits published events for queued zone messages on its turn in {int} seconds:"
|
||||
)]
|
||||
async fn step_sequencer_emits_published_events_for_queued_zone_messages_on_turn(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
let aliases = single_column_table(step, "alias", "zone message aliases")?;
|
||||
let tx_hashes = log_step_error(step, world.zone.message_tx_hashes_for_aliases(&aliases))?;
|
||||
let mut view_rx = log_step_error(step, world.zone.sequencer_channel_view_rx(&sequencer_alias))?;
|
||||
wait_for_channel_view(&mut view_rx, Duration::from_secs(timeout_seconds), |view| {
|
||||
view.our_turn_to_write
|
||||
})
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
// The messages were remembered when queued; wait until they're mined
|
||||
// (`OnChain`, not yet finalized) via the per-tx status stream.
|
||||
let mut statuses = log_step_error(
|
||||
step,
|
||||
world.zone.take_sequencer_tx_status_rx(&sequencer_alias),
|
||||
)?;
|
||||
let mempool_pending = wait_for_on_chain_statuses_and_collect_mempool_pending(
|
||||
&mut statuses,
|
||||
&tx_hashes,
|
||||
Duration::from_secs(timeout_seconds),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
world
|
||||
.zone
|
||||
.record_mempool_pending(sequencer_alias.clone(), mempool_pending);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cucumber::then(expr = "sequencer {string} observed mempool pending events for zone messages:")]
|
||||
#[expect(
|
||||
clippy::unused_async,
|
||||
reason = "Cucumber step functions are async even when assertion is synchronous"
|
||||
)]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
async fn step_sequencer_emitted_mempool_pending_events_for_zone_messages(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
) -> StepResult {
|
||||
let aliases = single_column_table(step, "alias", "zone message aliases")?;
|
||||
let tx_hashes = log_step_error(step, world.zone.message_tx_hashes_for_aliases(&aliases))?;
|
||||
|
||||
for (alias, tx_hash) in aliases.iter().zip(tx_hashes.iter()) {
|
||||
if !world
|
||||
.zone
|
||||
.has_observed_mempool_pending(&sequencer_alias, tx_hash)
|
||||
{
|
||||
return Err(StepError::LogicalError {
|
||||
message: format!(
|
||||
"Sequencer '{sequencer_alias}' did not emit mempool pending event for zone message '{alias}'"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "Cucumber step functions require `&mut World` as the first parameter"
|
||||
)]
|
||||
#[cucumber::then(expr = "sequencer {string} has {int} pending publish txs in {int} seconds")]
|
||||
async fn step_sequencer_has_pending_publish_txs(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
pending_publish_txs: usize,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
let mut view_rx = log_step_error(step, world.zone.sequencer_channel_view_rx(&sequencer_alias))?;
|
||||
|
||||
wait_for_channel_view(
|
||||
&mut view_rx,
|
||||
Duration::from_secs(timeout_seconds),
|
||||
move |view| view.pending_publish_txs == pending_publish_txs,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cucumber::then(
|
||||
expr = "sequencer {string} publishes {string} immediately while in turn in {int} seconds"
|
||||
)]
|
||||
async fn step_sequencer_publishes_immediately_while_in_turn(
|
||||
world: &mut CucumberWorld,
|
||||
step: &Step,
|
||||
sequencer_alias: String,
|
||||
message_alias: String,
|
||||
timeout_seconds: u64,
|
||||
) -> StepResult {
|
||||
// The message was remembered when submitted; wait until it's mined
|
||||
// (`OnChain`, not yet finalized) via the per-tx status stream.
|
||||
let tx_hashes = log_step_error(
|
||||
step,
|
||||
world
|
||||
.zone
|
||||
.message_tx_hashes_for_aliases(std::slice::from_ref(&message_alias)),
|
||||
)?;
|
||||
let mut statuses = log_step_error(
|
||||
step,
|
||||
world.zone.take_sequencer_tx_status_rx(&sequencer_alias),
|
||||
)?;
|
||||
let mempool_pending = wait_for_on_chain_statuses_and_collect_mempool_pending(
|
||||
&mut statuses,
|
||||
&tx_hashes,
|
||||
Duration::from_secs(timeout_seconds),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| zone_step_error(step, &error))?;
|
||||
|
||||
world
|
||||
.zone
|
||||
.record_mempool_pending(sequencer_alias.clone(), mempool_pending);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -61,10 +61,10 @@ use crate::{
|
||||
error::{StepError, StepResult},
|
||||
fee_reserve::{SCENARIO_FEE_ACCOUNT_NAME, ScenarioFeeState},
|
||||
steps::{
|
||||
manual_zone::runner::{
|
||||
tokio_console::profile::TokioConsoleProfile,
|
||||
zone::runner::{
|
||||
Event, InscriptionId, SequencerCheckpoint, SequencerClient, TxStatusUpdate,
|
||||
},
|
||||
tokio_console::profile::TokioConsoleProfile,
|
||||
},
|
||||
utils::{make_builder, shared_host_bin_path},
|
||||
wallet::snapshot::WalletSnapshot,
|
||||
|
||||
Reference in New Issue
Block a user