2026-07-22 19:33:13 +03:00
|
|
|
#![expect(
|
|
|
|
|
clippy::tests_outside_test_module,
|
|
|
|
|
reason = "top-level test functions are conventional for integration tests"
|
|
|
|
|
)]
|
|
|
|
|
|
2026-08-12 15:39:40 -03:00
|
|
|
//! Two sequencers share one channel: both are staked at genesis, so the channel
|
|
|
|
|
//! is created already accrediting `[A, B]`, B syncs the chain A began, both
|
|
|
|
|
//! produce on their turns, and A, B and an indexer converge on the same chain.
|
2026-07-22 19:33:13 +03:00
|
|
|
|
|
|
|
|
use std::time::Duration;
|
|
|
|
|
|
|
|
|
|
use anyhow::{Context as _, Result, ensure};
|
|
|
|
|
use indexer_service_rpc::RpcClient as _;
|
|
|
|
|
use integration_tests::{
|
2026-08-14 00:36:15 -03:00
|
|
|
assert_same_chain, committee,
|
2026-07-22 19:33:13 +03:00
|
|
|
config::{self, SequencerPartialConfig},
|
2026-08-14 00:36:15 -03:00
|
|
|
init_logger, wait_until,
|
2026-08-11 22:04:59 -03:00
|
|
|
};
|
2026-08-12 15:39:40 -03:00
|
|
|
use logos_blockchain_key_management_system_service::keys::Ed25519Key;
|
2026-08-14 00:36:15 -03:00
|
|
|
use sequencer_core::config::BedrockConfig;
|
2026-07-22 19:33:13 +03:00
|
|
|
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
|
2026-08-12 15:39:40 -03:00
|
|
|
use test_fixtures::{
|
|
|
|
|
MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig,
|
|
|
|
|
};
|
2026-07-22 19:33:13 +03:00
|
|
|
use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts};
|
|
|
|
|
use tokio::test;
|
|
|
|
|
|
|
|
|
|
const TRANSFER_AMOUNT: u128 = 10;
|
2026-08-12 15:39:40 -03:00
|
|
|
/// ≈4 turn windows, at the `system_accounts` posting timeframe and 5 s blocks.
|
2026-07-22 19:33:13 +03:00
|
|
|
const ROTATION_BLOCKS: u64 = 8;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
async fn multi_sequencer_committee_converges() -> Result<()> {
|
2026-08-11 22:04:59 -03:00
|
|
|
init_logger();
|
|
|
|
|
|
2026-08-12 15:39:40 -03:00
|
|
|
let channel = config::bedrock_channel_id();
|
2026-07-31 15:03:33 +03:00
|
|
|
let partial = SequencerPartialConfig {
|
|
|
|
|
block_create_timeout: Duration::from_secs(5),
|
|
|
|
|
..SequencerPartialConfig::default()
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-12 15:39:40 -03:00
|
|
|
let ctx = MultiZoneTestContextBuilder::default()
|
|
|
|
|
.with_zone(
|
|
|
|
|
ZoneTestContextBuilder::new(MultiNodeTestContextConfig {
|
|
|
|
|
num_nodes: 2,
|
|
|
|
|
bedrock_channel: channel,
|
|
|
|
|
})
|
|
|
|
|
.disable_wallet()
|
|
|
|
|
.with_sequencer_partial_config(partial),
|
|
|
|
|
)
|
|
|
|
|
.build()
|
2026-08-11 22:04:59 -03:00
|
|
|
.await
|
2026-08-12 15:39:40 -03:00
|
|
|
.context("Failed to build the two-sequencer test context")?;
|
2026-07-31 15:03:33 +03:00
|
|
|
|
2026-08-12 15:39:40 -03:00
|
|
|
let a = ctx
|
|
|
|
|
.sequencer_client_by_node_ids(channel, 0)
|
|
|
|
|
.context("Missing sequencer A")?;
|
|
|
|
|
let b = ctx
|
|
|
|
|
.sequencer_client_by_node_ids(channel, 1)
|
|
|
|
|
.context("Missing sequencer B")?;
|
|
|
|
|
let indexer = ctx.indexer_client();
|
2026-07-31 15:03:33 +03:00
|
|
|
|
2026-08-12 15:39:40 -03:00
|
|
|
let pub_a = Ed25519Key::from_bytes(&config::SEQUENCER_SIGNING_KEY).public_key();
|
|
|
|
|
let pub_b = Ed25519Key::from_bytes(&config::sequencer_signing_key_from_seed(1)).public_key();
|
|
|
|
|
|
|
|
|
|
let bedrock_config = BedrockConfig {
|
|
|
|
|
channel_id: channel,
|
|
|
|
|
node_url: config::addr_to_url(config::UrlProtocol::Http, ctx.bedrock_addr())?,
|
|
|
|
|
funding_key: config::bedrock_funding_key(),
|
|
|
|
|
auth: None,
|
|
|
|
|
priority_fee: sequencer_core::config::default_priority_fee(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Phase 1: both keys accredited from channel creation.
|
2026-08-11 22:04:59 -03:00
|
|
|
let mut want = vec![pub_a.to_bytes(), pub_b.to_bytes()];
|
|
|
|
|
want.sort_unstable();
|
|
|
|
|
wait_until("Bedrock to accredit both staked keys", || async {
|
|
|
|
|
Ok(committee(&bedrock_config).await?.0 == want)
|
|
|
|
|
})
|
2026-07-31 15:03:33 +03:00
|
|
|
.await?;
|
|
|
|
|
|
2026-08-12 15:39:40 -03:00
|
|
|
// Phase 2: B follows the chain A began.
|
|
|
|
|
let join_height = a.get_last_block_id().await?.max(1);
|
|
|
|
|
wait_for_height(b, join_height, "B to sync to A's height").await?;
|
2026-08-06 14:28:59 +03:00
|
|
|
|
2026-08-12 15:39:40 -03:00
|
|
|
// Phase 3: rotation + convergence; without the turn check, a chain A
|
|
|
|
|
// produces alone satisfies everything below.
|
2026-07-31 15:03:33 +03:00
|
|
|
let rotation_target = join_height + ROTATION_BLOCKS;
|
|
|
|
|
wait_for_height(
|
2026-08-12 15:39:40 -03:00
|
|
|
a,
|
2026-07-31 15:03:33 +03:00
|
|
|
rotation_target,
|
|
|
|
|
"the chain to advance across turn windows",
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
2026-08-12 15:39:40 -03:00
|
|
|
wait_for_height(b, rotation_target, "B to follow across turn windows").await?;
|
2026-08-11 22:04:59 -03:00
|
|
|
wait_until("the round-robin turn to reach B", || async {
|
|
|
|
|
Ok(committee(&bedrock_config).await?.1 == Some(pub_b))
|
|
|
|
|
})
|
2026-08-03 12:47:33 +03:00
|
|
|
.await?;
|
2026-08-12 15:39:40 -03:00
|
|
|
assert_same_chain(a, b).await?;
|
2026-08-06 14:28:59 +03:00
|
|
|
|
2026-08-12 15:39:40 -03:00
|
|
|
// Phase 4: a tx submitted only to B is included by B and visible on A.
|
2026-07-31 15:03:33 +03:00
|
|
|
let accounts = initial_public_user_accounts();
|
|
|
|
|
let from = accounts[0].account_id;
|
|
|
|
|
let to = accounts[1].account_id;
|
|
|
|
|
let sign_key = initial_pub_accounts_private_keys()[0].pub_sign_key.clone();
|
|
|
|
|
|
2026-08-11 22:04:59 -03:00
|
|
|
let to_balance_before = a.get_account_balance(to).await?;
|
|
|
|
|
let nonce = b.get_accounts_nonces(vec![from]).await?[0];
|
2026-07-31 15:03:33 +03:00
|
|
|
let tx = common::test_utils::create_transaction_native_token_transfer(
|
|
|
|
|
from,
|
|
|
|
|
nonce.0,
|
|
|
|
|
to,
|
|
|
|
|
TRANSFER_AMOUNT,
|
|
|
|
|
&sign_key,
|
|
|
|
|
);
|
2026-08-11 22:04:59 -03:00
|
|
|
b.send_transaction(tx)
|
2026-07-31 15:03:33 +03:00
|
|
|
.await
|
|
|
|
|
.context("Failed to submit the transfer to B")?;
|
|
|
|
|
|
2026-08-11 22:04:59 -03:00
|
|
|
let expected = to_balance_before + TRANSFER_AMOUNT;
|
|
|
|
|
wait_until("the cross-sequencer transfer to reach A", || async {
|
|
|
|
|
Ok(a.get_account_balance(to).await? == expected)
|
|
|
|
|
})
|
|
|
|
|
.await?;
|
2026-08-06 14:28:59 +03:00
|
|
|
|
2026-08-12 15:39:40 -03:00
|
|
|
// Phase 5: the indexer finalizes the same chain, with no stall.
|
2026-08-11 22:04:59 -03:00
|
|
|
wait_until("the indexer to finalize", || async {
|
|
|
|
|
Ok(indexer.get_last_finalized_block_id().await?.unwrap_or(0) >= join_height)
|
|
|
|
|
})
|
|
|
|
|
.await?;
|
2026-07-31 15:03:33 +03:00
|
|
|
let finalized = indexer.get_last_finalized_block_id().await?.unwrap_or(0);
|
|
|
|
|
for id in 1..=finalized {
|
|
|
|
|
let block_i = indexer
|
|
|
|
|
.get_block_by_id(id)
|
|
|
|
|
.await?
|
|
|
|
|
.with_context(|| format!("Indexer is missing finalized block {id}"))?;
|
2026-08-11 22:04:59 -03:00
|
|
|
let block_a = a
|
2026-07-31 15:03:33 +03:00
|
|
|
.get_block(id)
|
|
|
|
|
.await?
|
|
|
|
|
.with_context(|| format!("A is missing block {id}"))?;
|
|
|
|
|
ensure!(
|
|
|
|
|
block_i.header.hash == indexer_service_protocol::HashType::from(block_a.header.hash),
|
|
|
|
|
"Indexer diverges from A at block {id}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
let status = indexer.get_status().await?;
|
|
|
|
|
ensure!(
|
|
|
|
|
status.stall_reason.is_none(),
|
|
|
|
|
"Indexer is stalled: {:?}",
|
|
|
|
|
status.stall_reason
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 19:33:13 +03:00
|
|
|
/// Polls the sequencer until its chain height reaches `target`.
|
|
|
|
|
async fn wait_for_height(client: &SequencerClient, target: u64, what: &str) -> Result<()> {
|
2026-08-11 22:04:59 -03:00
|
|
|
wait_until(&format!("{what} (target height {target})"), || async {
|
|
|
|
|
Ok(client.get_last_block_id().await? >= target)
|
|
|
|
|
})
|
|
|
|
|
.await
|
2026-07-22 19:33:13 +03:00
|
|
|
}
|