517 lines
20 KiB
Rust
Raw Normal View History

//! End-to-end tests for the sequencer's startup bootstrap/reconstruction from
//! Bedrock (verify-and-reconstruct). Each test drives a real Bedrock node and one
//! or more sequencer instances sharing the same channel, exercising how a
//! sequencer reconciles its local store against what the channel serves.
#![expect(
clippy::tests_outside_test_module,
clippy::arithmetic_side_effects,
reason = "Integration tests live at crate root and don't care about these lints"
)]
use std::{net::SocketAddr, path::Path, time::Duration};
use anyhow::{Context as _, Result, bail};
use indexer_service_rpc::RpcClient as _;
use lee::{AccountId, PrivateKey, PublicKey};
use logos_blockchain_core::mantle::ops::channel::ChannelId;
use sequencer_core::config::GenesisAction;
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
use test_fixtures::{
config::{SequencerPartialConfig, UrlProtocol, addr_to_url},
indexer_client::IndexerClient,
setup::{setup_bedrock_node, setup_indexer, setup_sequencer_at, setup_sequencer_at_channel},
};
use tokio::test;
/// Block cadence for the tests: short so we don't wait long for local production.
fn fast_blocks() -> SequencerPartialConfig {
SequencerPartialConfig {
block_create_timeout: Duration::from_secs(2),
..SequencerPartialConfig::default()
}
}
/// Block cadence for a sequencer we don't want producing during the inspection
/// window, so its post-reconstruction tip stays put while we read it.
fn slow_blocks() -> SequencerPartialConfig {
SequencerPartialConfig {
block_create_timeout: Duration::from_secs(30),
..SequencerPartialConfig::default()
}
}
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
let url = addr_to_url(UrlProtocol::Http, addr).context("Failed to build sequencer URL")?;
SequencerClientBuilder::default()
.build(url)
.context("Failed to build sequencer client")
}
/// Polls the indexer's last finalized block id until it reaches `target`. The
/// indexer reads finalized channel history, so this is our oracle for "block
/// `target` is finalized on Bedrock" — exactly what a reconstructing sequencer
/// can read back.
async fn wait_for_finalized(
indexer: &IndexerClient,
target: u64,
timeout: Duration,
) -> Result<u64> {
let poll = async {
loop {
let finalized = indexer
.get_last_finalized_block_id()
.await
.context("Failed to read indexer last finalized block id")?
.unwrap_or(0);
if finalized >= target {
return Ok::<_, anyhow::Error>(finalized);
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
};
tokio::time::timeout(timeout, poll)
.await
.with_context(|| format!("Timed out waiting for indexer to finalize block {target}"))?
}
/// Polls the sequencer's last block id until it reaches `target` or `timeout` elapses.
async fn wait_for_block_id(
client: &SequencerClient,
target: u64,
timeout: Duration,
) -> Result<u64> {
let poll = async {
loop {
let id = client
.get_last_block_id()
.await
.context("Failed to read sequencer last block id")?;
if id >= target {
return Ok::<_, anyhow::Error>(id);
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
};
tokio::time::timeout(timeout, poll)
.await
.with_context(|| format!("Timed out waiting for block id {target}"))?
}
/// Best-effort extraction of a panic payload's message (panics carry a `String`
/// or `&str`), for asserting a startup aborted for the *expected* reason.
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<String>()
.cloned()
.or_else(|| payload.downcast_ref::<&str>().map(|s| (*s).to_owned()))
.unwrap_or_else(|| "<non-string panic payload>".to_owned())
}
/// A `SupplyAccount` genesis action for a fresh account, returning the vault
/// account id the funds land in (genesis supply goes into a claimable vault, not
/// the account directly), so tests can assert genesis state is present.
fn supplied_account(balance: u128) -> (AccountId, GenesisAction) {
let account_id = AccountId::from(&PublicKey::new_from_private_key(
&PrivateKey::new_os_random(),
));
let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), account_id);
(
vault_id,
GenesisAction::SupplyAccount {
account_id,
balance,
},
)
}
/// Recursively copies the contents of `src` into `dst` (used to snapshot/restore a
/// sequencer's rocksdb directory while it is stopped).
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
std::fs::create_dir_all(dst)
.with_context(|| format!("Failed to create dir {}", dst.display()))?;
for entry in
std::fs::read_dir(src).with_context(|| format!("Failed to read dir {}", src.display()))?
{
let entry = entry.context("Failed to read dir entry")?;
let target = dst.join(entry.file_name());
if entry
.file_type()
.context("Failed to read file type")?
.is_dir()
{
copy_dir_recursive(&entry.path(), &target)?;
} else {
std::fs::copy(entry.path(), &target)
.with_context(|| format!("Failed to copy into {}", target.display()))?;
}
}
Ok(())
}
/// Case 1: local store is empty and the Bedrock channel is empty.
///
/// The sequencer bootstraps genesis state, finds nothing to reconstruct, publishes
/// its own genesis to open the channel, and starts producing.
#[test]
async fn empty_local_and_empty_bedrock_bootstraps_from_genesis() -> Result<()> {
let (_bedrock, bedrock_addr) = setup_bedrock_node()
.await
.context("Failed to setup Bedrock")?;
let home = tempfile::tempdir().context("Failed to create sequencer home")?;
let (vault_id, supply) = supplied_account(12_345);
let genesis = vec![
supply,
GenesisAction::SupplyBridgeAccount { balance: 1_000_000 },
];
let handle = setup_sequencer_at(home.path(), fast_blocks(), bedrock_addr, genesis)
.await
.context("Failed to start sequencer")?;
let client = sequencer_client(handle.addr())?;
// Fresh store + empty channel: startup bootstrapped genesis state directly.
assert_eq!(
client.get_account_balance(vault_id).await?,
12_345,
"genesis-supplied vault balance must be present after bootstrap"
);
// The sequencer is live and producing on the freshly opened channel.
let last = wait_for_block_id(&client, 3, Duration::from_secs(60)).await?;
assert!(
last >= 3,
"sequencer should keep producing blocks, last={last}"
);
assert!(handle.is_healthy(), "sequencer must stay healthy");
Ok(())
}
/// Case 2: local store is empty, but the Bedrock channel already has blocks.
///
/// A first sequencer opens the channel and produces blocks; a second sequencer
/// starts from an empty store on the same channel (reusing the first's bedrock
/// signing key) and reconstructs the finalized history into its state.
#[test]
async fn empty_local_reconstructs_from_populated_bedrock() -> Result<()> {
const PRODUCED_TARGET: u64 = 3;
const FINALIZATION_TIMEOUT: Duration = Duration::from_secs(240);
let (_bedrock, bedrock_addr) = setup_bedrock_node()
.await
.context("Failed to setup Bedrock")?;
let (indexer_handle, _indexer_dir) = setup_indexer(bedrock_addr)
.await
.context("Failed to setup indexer")?;
let indexer_url = addr_to_url(UrlProtocol::Ws, indexer_handle.addr())
.context("Failed to build indexer URL")?;
let indexer = IndexerClient::new(&indexer_url)
.await
.context("Failed to build indexer client")?;
let (vault_id, supply) = supplied_account(7_777);
let genesis = vec![
supply,
GenesisAction::SupplyBridgeAccount { balance: 1_000_000 },
];
// Sequencer A opens the channel and produces a few blocks.
let home_a = tempfile::tempdir().context("Failed to create sequencer A home")?;
let handle_a = setup_sequencer_at(home_a.path(), fast_blocks(), bedrock_addr, genesis.clone())
.await
.context("Failed to start sequencer A")?;
let client_a = sequencer_client(handle_a.addr())?;
wait_for_block_id(&client_a, PRODUCED_TARGET, Duration::from_secs(60)).await?;
// Wait until those blocks are finalized on Bedrock — reconstruction only
// reads finalized history. A stays alive so its publish task keeps flushing.
let finalized = wait_for_finalized(&indexer, PRODUCED_TARGET, FINALIZATION_TIMEOUT).await?;
// Stop A, then wipe just its L2 store (keeping the bedrock signing key) so it
// restarts from an empty store on the same channel/identity — a sequencer that
// lost its local DB.
drop(handle_a);
tokio::time::sleep(Duration::from_secs(2)).await;
std::fs::remove_dir_all(home_a.path().join("rocksdb"))
.context("Failed to wipe sequencer L2 store")?;
// Sequencer B restarts on the same home from that empty store and reconstructs.
let handle_b = setup_sequencer_at(home_a.path(), slow_blocks(), bedrock_addr, genesis)
.await
.context("Failed to start sequencer B")?;
let client_b = sequencer_client(handle_b.addr())?;
// Reconstruction ran synchronously during B's startup: even though its local
// store was empty, its tip is past genesis, matching the finalized channel.
let tip_b = client_b.get_last_block_id().await?;
assert!(
tip_b >= finalized,
"B should reconstruct at least the finalized blocks; tip_b={tip_b}, finalized={finalized}"
);
assert!(
tip_b > 1,
"B should have reconstructed blocks beyond genesis; tip_b={tip_b}"
);
// Genesis state was rebuilt as part of the reconstruction.
assert_eq!(
client_b.get_account_balance(vault_id).await?,
7_777,
"reconstructed genesis vault balance must be present"
);
assert!(handle_b.is_healthy(), "sequencer B must stay healthy");
Ok(())
}
/// Case 3: local store is not empty, but the Bedrock channel is empty.
///
/// A sequencer produces blocks (committing to a channel), is stopped, and is
/// restarted against a fresh/empty channel — i.e. the channel it committed to
/// was wiped or the node points at a different chain. Startup must fail rather
/// than silently resume onto a foreign channel. Crucially this must hold even
/// though the sequencer only ever *produced* (so it never recorded a per-block
/// anchor): the committed-but-missing-channel invariant catches it.
#[test]
async fn nonempty_local_against_empty_channel_fails_startup() -> Result<()> {
const PRODUCED_TARGET: u64 = 3;
let (_bedrock, bedrock_addr) = setup_bedrock_node()
.await
.context("Failed to setup Bedrock")?;
let (_vault_id, supply) = supplied_account(1);
let genesis = vec![
supply,
GenesisAction::SupplyBridgeAccount { balance: 1_000_000 },
];
// A opens the channel and produces blocks. They land in its local store
// immediately, so no need to wait for finalization.
let home_a = tempfile::tempdir().context("Failed to create sequencer A home")?;
let handle_a = setup_sequencer_at(home_a.path(), fast_blocks(), bedrock_addr, genesis.clone())
.await
.context("Failed to start sequencer A")?;
wait_for_block_id(
&sequencer_client(handle_a.addr())?,
PRODUCED_TARGET,
Duration::from_secs(60),
)
.await?;
drop(handle_a);
tokio::time::sleep(Duration::from_secs(2)).await;
// Restart on the SAME home (A's committed store: blocks + checkpoint) but
// pointed at a fresh, never-used channel — the channel it committed to is gone.
let empty_channel = ChannelId::from([0x5a_u8; 32]);
// Startup aborts on the missing-channel invariant (a panic in
// `start_from_config`). Run it on a dedicated OS thread with its own runtime
// so the panic is isolated to `join()` instead of failing the test thread.
// The `timeout` future must be created *inside* `block_on` (it needs a running
// reactor), so build it in an `async` block rather than as an eager argument.
let home_a_path = home_a.path().to_owned();
let outcome = std::thread::spawn(move || {
let runtime = tokio::runtime::Runtime::new().expect("Failed to build runtime");
runtime.block_on(async {
tokio::time::timeout(
Duration::from_secs(90),
setup_sequencer_at_channel(
&home_a_path,
slow_blocks(),
bedrock_addr,
empty_channel,
genesis,
),
)
.await
})
})
.join();
match outcome {
// Expected: `start_from_config` panicked on the missing-channel invariant.
// Assert the *reason*, so an unrelated panic fails the test rather than
// masquerading as success.
Err(panic) => {
let message = panic_message(&*panic);
assert!(
message.contains("Refusing to resume onto a foreign channel"),
"startup panicked for an unexpected reason: {message}"
);
}
Ok(Err(_elapsed)) => {
bail!("Sequencer startup hung instead of failing against an empty channel")
}
Ok(Ok(Err(err))) => {
bail!("Sequencer expected to panic, but it failed with error: {err:#?}")
}
Ok(Ok(Ok(_handle))) => {
bail!("Sequencer startup unexpectedly succeeded against an empty channel")
}
}
Ok(())
}
/// Case 4: both non-empty, but the local store is *ahead* of the finalized channel.
///
/// A sequencer produces blocks faster than Bedrock finalizes them (the normal
/// steady state), so on restart its local tip leads the channel's finalized tip.
/// Startup must succeed: reconstruction re-verifies the finalized blocks it already
/// holds and leaves the extra, not-yet-finalized local blocks untouched.
#[test]
async fn local_ahead_of_channel_resumes() -> Result<()> {
const FINALIZED_TARGET: u64 = 2;
const FINALIZATION_TIMEOUT: Duration = Duration::from_secs(240);
let (_bedrock, bedrock_addr) = setup_bedrock_node()
.await
.context("Failed to setup Bedrock")?;
let (indexer_handle, _indexer_dir) = setup_indexer(bedrock_addr)
.await
.context("Failed to setup indexer")?;
let indexer_url = addr_to_url(UrlProtocol::Ws, indexer_handle.addr())
.context("Failed to build indexer URL")?;
let indexer = IndexerClient::new(&indexer_url)
.await
.context("Failed to build indexer client")?;
let (vault_id, supply) = supplied_account(4_242);
let genesis = vec![
supply,
GenesisAction::SupplyBridgeAccount { balance: 1_000_000 },
];
// A produces continuously (fast cadence) while Bedrock finalizes slowly, so
// its local tip runs well ahead of the channel's finalized tip.
let home = tempfile::tempdir().context("Failed to create sequencer home")?;
let handle_a = setup_sequencer_at(home.path(), fast_blocks(), bedrock_addr, genesis.clone())
.await
.context("Failed to start sequencer A")?;
let client_a = sequencer_client(handle_a.addr())?;
let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, FINALIZATION_TIMEOUT).await?;
let tip_before = client_a.get_last_block_id().await?;
assert!(
tip_before > finalized,
"local tip {tip_before} should lead the finalized tip {finalized}"
);
// Restart on the same home; slow cadence so its tip stays put while we inspect.
drop(handle_a);
tokio::time::sleep(Duration::from_secs(2)).await;
let handle_b = setup_sequencer_at(home.path(), slow_blocks(), bedrock_addr, genesis)
.await
.context("Failed to restart sequencer")?;
let client_b = sequencer_client(handle_b.addr())?;
// Reconstruction verified the finalized prefix and preserved the extra blocks.
let tip_b = client_b.get_last_block_id().await?;
assert!(
tip_b >= tip_before,
"restart must not lose locally-produced blocks; tip_b={tip_b}, before={tip_before}"
);
assert_eq!(
client_b.get_account_balance(vault_id).await?,
4_242,
"genesis state must survive the restart"
);
assert!(handle_b.is_healthy(), "sequencer must stay healthy");
Ok(())
}
/// Case 5: both non-empty, but the local store is *behind* the finalized channel.
///
/// We snapshot a sequencer's store at an early tip, let it keep extending and
/// finalizing the channel, then restore the early snapshot and restart. Startup
/// must reconstruct forward — replay the finalized blocks the local store is
/// missing — catching the local tip up to the channel.
///
/// The snapshot/restore is essential here (not a gratuitous copy): a live
/// sequencer's local tip always leads finalization, so the only way to obtain a
/// local store that *lags* the finalized channel is to preserve an earlier state
/// while the same channel advances past it.
#[test]
async fn local_behind_channel_reconstructs_forward() -> Result<()> {
const SNAPSHOT_TIP: u64 = 2;
const FINALIZED_TARGET: u64 = 4;
const FINALIZATION_TIMEOUT: Duration = Duration::from_secs(300);
let (_bedrock, bedrock_addr) = setup_bedrock_node()
.await
.context("Failed to setup Bedrock")?;
let (indexer_handle, _indexer_dir) = setup_indexer(bedrock_addr)
.await
.context("Failed to setup indexer")?;
let indexer_url = addr_to_url(UrlProtocol::Ws, indexer_handle.addr())
.context("Failed to build indexer URL")?;
let indexer = IndexerClient::new(&indexer_url)
.await
.context("Failed to build indexer client")?;
let (vault_id, supply) = supplied_account(5_005);
let genesis = vec![
supply,
GenesisAction::SupplyBridgeAccount { balance: 1_000_000 },
];
let home = tempfile::tempdir().context("Failed to create sequencer home")?;
let rocksdb = home.path().join("rocksdb");
// Bring the sequencer up to an early tip, then stop it so its store is at rest.
let handle = setup_sequencer_at(home.path(), slow_blocks(), bedrock_addr, genesis.clone())
.await
.context("Failed to start sequencer")?;
wait_for_block_id(
&sequencer_client(handle.addr())?,
SNAPSHOT_TIP,
Duration::from_secs(120),
)
.await?;
drop(handle);
tokio::time::sleep(Duration::from_secs(2)).await;
// Snapshot the early store (tip == SNAPSHOT_TIP), safe because it is at rest.
let snapshot = tempfile::tempdir().context("Failed to create snapshot dir")?;
copy_dir_recursive(&rocksdb, snapshot.path()).context("Failed to snapshot store")?;
// Resume the sequencer (fast cadence) so it extends and finalizes the channel
// beyond the snapshot.
let handle = setup_sequencer_at(home.path(), fast_blocks(), bedrock_addr, genesis.clone())
.await
.context("Failed to resume sequencer")?;
let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, FINALIZATION_TIMEOUT).await?;
drop(handle);
tokio::time::sleep(Duration::from_secs(2)).await;
// Restore the early snapshot: the local store now lags the finalized channel.
std::fs::remove_dir_all(&rocksdb).context("Failed to remove store before restore")?;
copy_dir_recursive(snapshot.path(), &rocksdb).context("Failed to restore snapshot")?;
// Restart: reconstruction must catch the lagging store up to the channel.
let handle = setup_sequencer_at(home.path(), slow_blocks(), bedrock_addr, genesis)
.await
.context("Failed to restart sequencer from a lagging store")?;
let client = sequencer_client(handle.addr())?;
let tip = client.get_last_block_id().await?;
assert!(
tip >= finalized,
"lagging store must reconstruct forward to the finalized tip; tip={tip}, finalized={finalized}"
);
assert!(
tip > SNAPSHOT_TIP,
"reconstruction must advance beyond the snapshot tip; tip={tip}"
);
assert_eq!(
client.get_account_balance(vault_id).await?,
5_005,
"genesis state must be intact after reconstruction"
);
assert!(handle.is_healthy(), "sequencer must stay healthy");
Ok(())
}