mirror of
https://github.com/logos-blockchain/lssa.git
synced 2026-07-24 00:23:13 +00:00
Merge branch 'dev' into Pravdyvy/request-distribution-for-multiple-sequencers
This commit is contained in:
commit
17aaef0b03
26
Cargo.lock
generated
26
Cargo.lock
generated
@ -1368,6 +1368,26 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chain_state"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"borsh",
|
||||
"common",
|
||||
"futures",
|
||||
"lee",
|
||||
"lee_core",
|
||||
"log",
|
||||
"logos-blockchain-core",
|
||||
"logos-blockchain-zone-sdk",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"testnet_initial_state",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chkstk_stub"
|
||||
version = "0.1.0"
|
||||
@ -3939,6 +3959,7 @@ dependencies = [
|
||||
"arc-swap",
|
||||
"async-stream",
|
||||
"borsh",
|
||||
"chain_state",
|
||||
"common",
|
||||
"cross_zone",
|
||||
"cross_zone_inbox_core",
|
||||
@ -3958,7 +3979,6 @@ dependencies = [
|
||||
"storage",
|
||||
"tempfile",
|
||||
"testnet_initial_state",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
@ -4138,6 +4158,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"test_fixtures",
|
||||
"test_programs",
|
||||
"testnet_initial_state",
|
||||
"token_core",
|
||||
"tokio",
|
||||
"vault_core",
|
||||
@ -9023,6 +9044,7 @@ dependencies = [
|
||||
"borsh",
|
||||
"bridge_core",
|
||||
"bytesize",
|
||||
"chain_state",
|
||||
"chrono",
|
||||
"common",
|
||||
"cross_zone",
|
||||
@ -9053,6 +9075,7 @@ dependencies = [
|
||||
"testnet_initial_state",
|
||||
"token_core",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"url",
|
||||
"vault_core",
|
||||
]
|
||||
@ -9068,6 +9091,7 @@ dependencies = [
|
||||
"common",
|
||||
"env_logger",
|
||||
"futures",
|
||||
"hex",
|
||||
"jsonrpsee",
|
||||
"lee",
|
||||
"log",
|
||||
|
||||
@ -16,6 +16,7 @@ members = [
|
||||
|
||||
"lez",
|
||||
"lez/system_accounts",
|
||||
"lez/chain_state",
|
||||
"lez/sequencer/core",
|
||||
"lez/sequencer/service",
|
||||
"lez/sequencer/service/protocol",
|
||||
@ -72,6 +73,7 @@ members = [
|
||||
lee = { path = "lee/state_machine" }
|
||||
lee_core = { path = "lee/state_machine/core" }
|
||||
common = { path = "lez/common" }
|
||||
chain_state = { path = "lez/chain_state" }
|
||||
mempool = { path = "lez/mempool" }
|
||||
storage = { path = "lez/storage" }
|
||||
key_protocol = { path = "lee/key_protocol" }
|
||||
|
||||
18
Justfile
18
Justfile
@ -11,13 +11,19 @@ ARTIFACTS := "artifacts"
|
||||
# Linux/CI, which is unaffected.
|
||||
DEMO_ENV := if os() == "macos" { "DYLD_FALLBACK_FRAMEWORK_PATH=/Library/Developer/CommandLineTools/Library/Frameworks" } else { "" }
|
||||
|
||||
# Build risc0 program artifacts.
|
||||
# Build risc0 program artifacts and test fixture.
|
||||
build-artifacts:
|
||||
@echo "🔨 Building artifacts"
|
||||
@rm -rf {{ARTIFACTS}}
|
||||
@just build-artifact lee/privacy_preserving_circuit
|
||||
@just build-artifact lez/programs programs
|
||||
|
||||
@if [ "${GITHUB_ACTIONS:-}" = "true" ]; then \
|
||||
echo "Skipping test fixture regeneration because CI doesn't need it"; \
|
||||
else \
|
||||
just regenerate-test-fixture; \
|
||||
fi
|
||||
|
||||
build-artifact methods_path features="":
|
||||
@echo "Building artifacts for {{methods_path}}"
|
||||
@rm -rf target/{{methods_path}}/riscv32im-risc0-zkvm-elf/docker/*.bin
|
||||
@ -42,7 +48,7 @@ test:
|
||||
|
||||
# Regenerate the prebuilt sequencer db dump for fast TestContext::new() (needs Docker; commit the dump).
|
||||
regenerate-test-fixture:
|
||||
@echo "🧪 Regenerating test fixtures"
|
||||
@echo "🧪 Regenerating test fixture"
|
||||
RISC0_DEV_MODE=1 cargo run -p test_fixtures --bin regenerate_test_fixture
|
||||
|
||||
# Run criterion benches: fast crypto primitives, then the slow PPE verify (real proving setup).
|
||||
@ -58,15 +64,17 @@ run-bedrock:
|
||||
docker compose up
|
||||
|
||||
# Run Sequencer. Run with RISC0_DEV_MODE=1 to disable proof verification for faster iteration.
|
||||
# Optional home/port let a second instance run off the same config, e.g.
|
||||
# `just run-sequencer "" "$TMPDIR/lez-sequencer2" 3041` for the multi-sequencer demo.
|
||||
[working-directory: 'lez/sequencer/service']
|
||||
run-sequencer standalone="":
|
||||
run-sequencer standalone="" home="" port="3040":
|
||||
@echo "🧠 Running sequencer"
|
||||
@if [ "{{standalone}}" = "standalone" ]; then \
|
||||
echo "🧪 Running in standalone mode"; \
|
||||
RUST_LOG=info cargo run --features standalone --release -p sequencer_service configs/debug/sequencer_config.json; \
|
||||
RUST_LOG=info cargo run --features standalone --release -p sequencer_service -- configs/debug/sequencer_config.json --port {{port}} {{ if home != "" { "--home " + quote(home) } else { "" } }}; \
|
||||
else \
|
||||
echo "🚀 Running in normal mode"; \
|
||||
RUST_LOG=info cargo run --release -p sequencer_service configs/debug/sequencer_config.json; \
|
||||
RUST_LOG=info cargo run --release -p sequencer_service -- configs/debug/sequencer_config.json --port {{port}} {{ if home != "" { "--home " + quote(home) } else { "" } }}; \
|
||||
fi
|
||||
|
||||
# Run Indexer. Run with RISC0_DEV_MODE=1 to disable proof verification for faster iteration.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -36,6 +36,7 @@ indexer_service_protocol.workspace = true
|
||||
system_accounts.workspace = true
|
||||
programs.workspace = true
|
||||
test_programs.workspace = true
|
||||
testnet_initial_state.workspace = true
|
||||
|
||||
logos-blockchain-http-api-common.workspace = true
|
||||
logos-blockchain-core.workspace = true
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
//! wrapped token is minted to the recipient. Reuses the M3/M4 spine unchanged;
|
||||
//! only the source caller (`bridge_lock`) and target (`wrapped_token`) are new.
|
||||
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use common::transaction::LeeTransaction;
|
||||
@ -17,14 +17,14 @@ use cross_zone_outbox_core::outbox_pda;
|
||||
use integration_tests::{
|
||||
config::{self, SequencerPartialConfig},
|
||||
indexer_client::IndexerClient,
|
||||
setup::{setup_bedrock_node, setup_indexer, setup_sequencer},
|
||||
setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer},
|
||||
};
|
||||
use lee::{
|
||||
AccountId, PrivateKey, PublicKey, PublicTransaction,
|
||||
public_transaction::{Message, WitnessSet},
|
||||
};
|
||||
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, GenesisAction};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
|
||||
use sequencer_service_rpc::RpcClient as _;
|
||||
use tokio::test;
|
||||
|
||||
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
@ -62,18 +62,19 @@ async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> {
|
||||
holder: holder_id,
|
||||
amount: INITIAL_BALANCE,
|
||||
}];
|
||||
let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, genesis_a, channel_a, None)
|
||||
let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr)
|
||||
.with_channel_id(channel_a)
|
||||
.with_genesis(genesis_a)
|
||||
.setup()
|
||||
.await
|
||||
.context("Failed to set up zone A sequencer")?;
|
||||
let (_seq_b, _seq_b_home) = setup_sequencer(
|
||||
partial,
|
||||
bedrock_addr,
|
||||
vec![],
|
||||
channel_b,
|
||||
Some(cross_zone.clone()),
|
||||
)
|
||||
.await
|
||||
.context("Failed to set up zone B sequencer")?;
|
||||
let (_seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr)
|
||||
.with_channel_id(channel_b)
|
||||
.with_genesis(vec![])
|
||||
.with_cross_zone(cross_zone.clone())
|
||||
.setup()
|
||||
.await
|
||||
.context("Failed to set up zone B sequencer")?;
|
||||
let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, Some(cross_zone))
|
||||
.await
|
||||
.context("Failed to set up zone B indexer")?;
|
||||
@ -87,9 +88,7 @@ async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> {
|
||||
|
||||
// Wait until zone B's indexer reflects the verified mint.
|
||||
let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT);
|
||||
let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, idx_b.addr())
|
||||
.context("Failed to build indexer URL")?;
|
||||
let indexer = IndexerClient::new(&indexer_url)
|
||||
let indexer = indexer_client(idx_b.addr())
|
||||
.await
|
||||
.context("Failed to build indexer client")?;
|
||||
|
||||
@ -167,14 +166,6 @@ fn build_lock_tx(
|
||||
LeeTransaction::Public(PublicTransaction::new(message, witness))
|
||||
}
|
||||
|
||||
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
|
||||
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
|
||||
.context("Failed to build sequencer URL")?;
|
||||
SequencerClientBuilder::default()
|
||||
.build(url)
|
||||
.context("Failed to build sequencer client")
|
||||
}
|
||||
|
||||
/// Polls zone B's indexer until the recipient's wrapped holding is non-zero.
|
||||
async fn wait_for_mint(indexer: &IndexerClient, holding_id: AccountId) -> Result<u128> {
|
||||
let account_id = indexer_service_protocol::AccountId {
|
||||
|
||||
@ -9,8 +9,6 @@
|
||||
//! inbox guest's caller-is-none assertion passes for a top-level user tx, so the
|
||||
//! sequencer ingress guard is the only thing that stops this.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use common::transaction::LeeTransaction;
|
||||
use cross_zone_inbox_core::{
|
||||
@ -18,13 +16,13 @@ use cross_zone_inbox_core::{
|
||||
};
|
||||
use integration_tests::{
|
||||
config::{self, SequencerPartialConfig},
|
||||
setup::{setup_bedrock_node, setup_sequencer},
|
||||
setup::{SequencerSetup, sequencer_client, setup_bedrock_node},
|
||||
};
|
||||
use lee::{
|
||||
PublicTransaction,
|
||||
public_transaction::{Message, WitnessSet},
|
||||
};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
|
||||
use sequencer_service_rpc::RpcClient as _;
|
||||
use tokio::test;
|
||||
|
||||
#[test]
|
||||
@ -34,7 +32,10 @@ async fn user_origin_inbox_call_rejected() -> Result<()> {
|
||||
.context("Failed to set up Bedrock node")?;
|
||||
let partial = SequencerPartialConfig::default();
|
||||
let channel = config::bedrock_channel_id();
|
||||
let (seq, _seq_home) = setup_sequencer(partial, bedrock_addr, vec![], channel, None)
|
||||
let (seq, _seq_home) = SequencerSetup::new(partial, bedrock_addr)
|
||||
.with_channel_id(channel)
|
||||
.with_genesis(vec![])
|
||||
.setup()
|
||||
.await
|
||||
.context("Failed to set up sequencer")?;
|
||||
|
||||
@ -70,11 +71,3 @@ async fn user_origin_inbox_call_rejected() -> Result<()> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
|
||||
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
|
||||
.context("Failed to build sequencer URL")?;
|
||||
SequencerClientBuilder::default()
|
||||
.build(url)
|
||||
.context("Failed to build sequencer client")
|
||||
}
|
||||
|
||||
@ -11,20 +11,20 @@
|
||||
//! inbox dispatch, and zone B's sequencer delivers it. This is the M3 milestone,
|
||||
//! sequencer-trusted, with no indexer re-derivation (that is M4).
|
||||
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use common::transaction::LeeTransaction;
|
||||
use cross_zone_outbox_core::outbox_pda;
|
||||
use integration_tests::{
|
||||
config::{self, SequencerPartialConfig},
|
||||
setup::{setup_bedrock_node, setup_sequencer},
|
||||
setup::{SequencerSetup, sequencer_client, setup_bedrock_node},
|
||||
};
|
||||
use lee::{AccountId, PublicTransaction, public_transaction::Message};
|
||||
use lee_core::program::ProgramId;
|
||||
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
|
||||
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
|
||||
use tokio::test;
|
||||
|
||||
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(480);
|
||||
@ -54,13 +54,19 @@ async fn ping_crosses_from_zone_a_to_zone_b() -> Result<()> {
|
||||
}],
|
||||
};
|
||||
|
||||
let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_a, None)
|
||||
let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr)
|
||||
.with_channel_id(channel_a)
|
||||
.with_genesis(vec![])
|
||||
.setup()
|
||||
.await
|
||||
.context("Failed to set up zone A sequencer")?;
|
||||
let (seq_b, _seq_b_home) =
|
||||
setup_sequencer(partial, bedrock_addr, vec![], channel_b, Some(cross_zone))
|
||||
.await
|
||||
.context("Failed to set up zone B sequencer")?;
|
||||
let (seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr)
|
||||
.with_channel_id(channel_b)
|
||||
.with_genesis(vec![])
|
||||
.with_cross_zone(cross_zone)
|
||||
.setup()
|
||||
.await
|
||||
.context("Failed to set up zone B sequencer")?;
|
||||
|
||||
// Submit the ping on zone A, addressed to ping_receiver on zone B.
|
||||
let ping = build_ping_tx(zone_b, receiver_id);
|
||||
@ -117,14 +123,6 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio
|
||||
))
|
||||
}
|
||||
|
||||
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
|
||||
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
|
||||
.context("Failed to build sequencer URL")?;
|
||||
SequencerClientBuilder::default()
|
||||
.build(url)
|
||||
.context("Failed to build sequencer client")
|
||||
}
|
||||
|
||||
/// Polls zone B's sequencer until the ping record PDA holds a payload.
|
||||
async fn wait_for_delivery(client: SequencerClient, record_id: AccountId) -> Result<Vec<u8>> {
|
||||
let wait = async {
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
//! payload landing in the indexer's state proves verification passed; a forgery
|
||||
//! would have halted the indexer instead.
|
||||
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use common::transaction::LeeTransaction;
|
||||
@ -17,13 +17,13 @@ use cross_zone_outbox_core::outbox_pda;
|
||||
use integration_tests::{
|
||||
config::{self, SequencerPartialConfig},
|
||||
indexer_client::IndexerClient,
|
||||
setup::{setup_bedrock_node, setup_indexer, setup_sequencer},
|
||||
setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer},
|
||||
};
|
||||
use lee::{AccountId, PublicTransaction, public_transaction::Message};
|
||||
use lee_core::program::ProgramId;
|
||||
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
|
||||
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
|
||||
use sequencer_service_rpc::RpcClient as _;
|
||||
use tokio::test;
|
||||
|
||||
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
@ -53,21 +53,22 @@ async fn indexer_verifies_and_delivers_cross_zone_ping() -> Result<()> {
|
||||
|
||||
// Zone A: source. Zone B: destination, with the watcher on its sequencer and
|
||||
// the verifier on its indexer.
|
||||
let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_a, None)
|
||||
let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr)
|
||||
.with_channel_id(channel_a)
|
||||
.with_genesis(vec![])
|
||||
.setup()
|
||||
.await
|
||||
.context("Failed to set up zone A sequencer")?;
|
||||
let (_idx_a, _idx_a_home) = setup_indexer(bedrock_addr, channel_a, None)
|
||||
.await
|
||||
.context("Failed to set up zone A indexer")?;
|
||||
let (_seq_b, _seq_b_home) = setup_sequencer(
|
||||
partial,
|
||||
bedrock_addr,
|
||||
vec![],
|
||||
channel_b,
|
||||
Some(cross_zone.clone()),
|
||||
)
|
||||
.await
|
||||
.context("Failed to set up zone B sequencer")?;
|
||||
let (_seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr)
|
||||
.with_channel_id(channel_b)
|
||||
.with_genesis(vec![])
|
||||
.with_cross_zone(cross_zone.clone())
|
||||
.setup()
|
||||
.await
|
||||
.context("Failed to set up zone B sequencer")?;
|
||||
let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, Some(cross_zone))
|
||||
.await
|
||||
.context("Failed to set up zone B indexer")?;
|
||||
@ -82,9 +83,7 @@ async fn indexer_verifies_and_delivers_cross_zone_ping() -> Result<()> {
|
||||
// Wait until zone B's indexer records the delivered payload. The indexer only
|
||||
// applies the dispatch after re-deriving and verifying it.
|
||||
let record_id = ping_record_pda(receiver_id);
|
||||
let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, idx_b.addr())
|
||||
.context("Failed to build indexer URL")?;
|
||||
let indexer = IndexerClient::new(&indexer_url)
|
||||
let indexer = indexer_client(idx_b.addr())
|
||||
.await
|
||||
.context("Failed to build indexer client")?;
|
||||
|
||||
@ -129,14 +128,6 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio
|
||||
))
|
||||
}
|
||||
|
||||
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
|
||||
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
|
||||
.context("Failed to build sequencer URL")?;
|
||||
SequencerClientBuilder::default()
|
||||
.build(url)
|
||||
.context("Failed to build sequencer client")
|
||||
}
|
||||
|
||||
/// Polls zone B's indexer until the ping record PDA holds a payload.
|
||||
async fn wait_for_indexer_delivery(
|
||||
indexer: &IndexerClient,
|
||||
|
||||
236
integration_tests/tests/multi_sequencer.rs
Normal file
236
integration_tests/tests/multi_sequencer.rs
Normal file
@ -0,0 +1,236 @@
|
||||
#![expect(
|
||||
clippy::tests_outside_test_module,
|
||||
reason = "top-level test functions are conventional for integration tests"
|
||||
)]
|
||||
|
||||
//! Two sequencers share one channel: A starts solo as channel admin, live-
|
||||
//! accredits `[A, B]` with round-robin rotation, B joins and syncs, both
|
||||
//! produce on their turns, and A, B and an indexer converge on the same chain.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, Result, ensure};
|
||||
use indexer_service_rpc::RpcClient as _;
|
||||
use integration_tests::{
|
||||
config::{self, SequencerPartialConfig},
|
||||
indexer_client::IndexerClient,
|
||||
setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer},
|
||||
};
|
||||
use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key};
|
||||
use sequencer_core::{block_publisher::post_channel_config, config::BedrockConfig};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
|
||||
use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts};
|
||||
use tokio::test;
|
||||
|
||||
/// 1 s bedrock slots: rotate the turn every ~20 s of tenure; steal a stalled
|
||||
/// turn after ~30 s (bounds the stall while B is accredited but not started).
|
||||
const POSTING_TIMEFRAME_SLOTS: u32 = 20;
|
||||
const POSTING_TIMEOUT_SLOTS: u32 = 30;
|
||||
const PHASE_TIMEOUT: Duration = Duration::from_secs(360);
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||
const TRANSFER_AMOUNT: u128 = 10;
|
||||
/// ≈4 turn windows past B's join (5 s blocks, ~20 s turns → ~4 blocks/window).
|
||||
const ROTATION_BLOCKS: u64 = 8;
|
||||
|
||||
#[test]
|
||||
async fn multi_sequencer_committee_converges() -> Result<()> {
|
||||
let (_bedrock, bedrock_addr) = setup_bedrock_node()
|
||||
.await
|
||||
.context("Failed to set up Bedrock node")?;
|
||||
|
||||
// Fixed seeds so A can accredit B's public key before B exists.
|
||||
let key_a = [0xA1_u8; ED25519_SECRET_KEY_SIZE];
|
||||
let key_b = [0xB2_u8; ED25519_SECRET_KEY_SIZE];
|
||||
let pub_a = Ed25519Key::from_bytes(&key_a).public_key();
|
||||
let pub_b = Ed25519Key::from_bytes(&key_b).public_key();
|
||||
|
||||
let partial = SequencerPartialConfig {
|
||||
block_create_timeout: Duration::from_secs(5),
|
||||
..SequencerPartialConfig::default()
|
||||
};
|
||||
|
||||
// Phase 1: A solo (its first inscription creates the channel), plus an indexer.
|
||||
let (seq_a, _a_home) = SequencerSetup::new(partial, bedrock_addr)
|
||||
.with_genesis(vec![])
|
||||
.with_bedrock_signing_key(key_a)
|
||||
.setup()
|
||||
.await
|
||||
.context("Failed to set up sequencer A")?;
|
||||
let a = sequencer_client(seq_a.addr())?;
|
||||
let (idx, _idx_home) = setup_indexer(bedrock_addr, config::bedrock_channel_id(), None)
|
||||
.await
|
||||
.context("Failed to set up indexer")?;
|
||||
let indexer = indexer_client(idx.addr()).await?;
|
||||
|
||||
wait_for_height(&a, 2, "sequencer A to produce past genesis").await?;
|
||||
|
||||
// Phase 2: live roster change to [A, B] with rotation enabled, posted
|
||||
// straight to bedrock with A's admin key (the operator one-shot path).
|
||||
post_channel_config(
|
||||
&BedrockConfig {
|
||||
channel_id: config::bedrock_channel_id(),
|
||||
node_url: config::addr_to_url(config::UrlProtocol::Http, bedrock_addr)?,
|
||||
auth: None,
|
||||
},
|
||||
&Ed25519Key::from_bytes(&key_a),
|
||||
vec![pub_a, pub_b],
|
||||
POSTING_TIMEFRAME_SLOTS,
|
||||
POSTING_TIMEOUT_SLOTS,
|
||||
1,
|
||||
1,
|
||||
)
|
||||
.await
|
||||
.context("Failed to configure the channel committee")?;
|
||||
|
||||
let height_at_config = a.get_last_block_id().await?;
|
||||
wait_for_height(
|
||||
&a,
|
||||
height_at_config + 1,
|
||||
"A to produce after the roster change",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Phase 3: B joins live and syncs the existing chain.
|
||||
let (seq_b, _b_home) = SequencerSetup::new(partial, bedrock_addr)
|
||||
.with_genesis(vec![])
|
||||
.with_bedrock_signing_key(key_b)
|
||||
.setup()
|
||||
.await
|
||||
.context("Failed to set up sequencer B")?;
|
||||
let b = sequencer_client(seq_b.addr())?;
|
||||
|
||||
let join_height = a.get_last_block_id().await?;
|
||||
wait_for_height(&b, join_height, "B to sync to A's height at join").await?;
|
||||
|
||||
// Phase 4: rotation + convergence over ≈4 turn windows.
|
||||
let rotation_target = join_height + ROTATION_BLOCKS;
|
||||
wait_for_height(
|
||||
&a,
|
||||
rotation_target,
|
||||
"the chain to advance across turn windows",
|
||||
)
|
||||
.await?;
|
||||
wait_for_height(&b, rotation_target, "B to follow across turn windows").await?;
|
||||
assert_same_chain(&a, &b).await?;
|
||||
|
||||
// Phase 5: a tx submitted only to B is included by B and visible on A.
|
||||
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();
|
||||
|
||||
let to_balance_before = a.get_account_balance(to).await?;
|
||||
let nonce = b.get_accounts_nonces(vec![from]).await?[0];
|
||||
let tx = common::test_utils::create_transaction_native_token_transfer(
|
||||
from,
|
||||
nonce.0,
|
||||
to,
|
||||
TRANSFER_AMOUNT,
|
||||
&sign_key,
|
||||
);
|
||||
b.send_transaction(tx)
|
||||
.await
|
||||
.context("Failed to submit the transfer to B")?;
|
||||
|
||||
wait_for_balance(&a, to, to_balance_before + TRANSFER_AMOUNT).await?;
|
||||
|
||||
// Phase 6: the indexer finalizes the same chain, with no stall.
|
||||
wait_for_finalized(&indexer, join_height).await?;
|
||||
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}"))?;
|
||||
let block_a = a
|
||||
.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(())
|
||||
}
|
||||
|
||||
/// Polls the sequencer until its chain height reaches `target`.
|
||||
async fn wait_for_height(client: &SequencerClient, target: u64, what: &str) -> Result<()> {
|
||||
let wait = async {
|
||||
loop {
|
||||
if client.get_last_block_id().await? >= target {
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
};
|
||||
tokio::time::timeout(PHASE_TIMEOUT, wait)
|
||||
.await
|
||||
.with_context(|| format!("Timed out waiting for {what} (target height {target})"))?
|
||||
}
|
||||
|
||||
/// Polls the sequencer until `account`'s balance reaches `expected`.
|
||||
async fn wait_for_balance(
|
||||
client: &SequencerClient,
|
||||
account: lee::AccountId,
|
||||
expected: u128,
|
||||
) -> Result<()> {
|
||||
let wait = async {
|
||||
loop {
|
||||
if client.get_account_balance(account).await? == expected {
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
};
|
||||
tokio::time::timeout(PHASE_TIMEOUT, wait)
|
||||
.await
|
||||
.context("Timed out waiting for the cross-sequencer transfer to reach A")?
|
||||
}
|
||||
|
||||
/// Polls the indexer until its finalized height reaches `target`.
|
||||
async fn wait_for_finalized(indexer: &IndexerClient, target: u64) -> Result<()> {
|
||||
let wait = async {
|
||||
loop {
|
||||
if indexer.get_last_finalized_block_id().await?.unwrap_or(0) >= target {
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
};
|
||||
tokio::time::timeout(PHASE_TIMEOUT, wait)
|
||||
.await
|
||||
.context("Timed out waiting for the indexer to finalize")?
|
||||
}
|
||||
|
||||
/// Asserts A and B hold byte-identical block hashes over their common prefix.
|
||||
async fn assert_same_chain(a: &SequencerClient, b: &SequencerClient) -> Result<()> {
|
||||
let common = a
|
||||
.get_last_block_id()
|
||||
.await?
|
||||
.min(b.get_last_block_id().await?);
|
||||
for id in 1..=common {
|
||||
let block_a = a
|
||||
.get_block(id)
|
||||
.await?
|
||||
.with_context(|| format!("A is missing block {id}"))?;
|
||||
let block_b = b
|
||||
.get_block(id)
|
||||
.await?
|
||||
.with_context(|| format!("B is missing block {id}"))?;
|
||||
ensure!(
|
||||
block_a.header.hash == block_b.header.hash,
|
||||
"Chain divergence at block {id}: A {:?} vs B {:?}",
|
||||
block_a.header.hash,
|
||||
block_b.header.hash
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
538
integration_tests/tests/sequencer_bootstrap.rs
Normal file
538
integration_tests/tests/sequencer_bootstrap.rs
Normal file
@ -0,0 +1,538 @@
|
||||
//! 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,
|
||||
reason = "Integration tests live at crate root and don't care about these lints"
|
||||
)]
|
||||
|
||||
use std::{path::Path, time::Duration};
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use indexer_service_rpc::RpcClient as _;
|
||||
use integration_tests::L2_TO_L1_TIMEOUT;
|
||||
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};
|
||||
use test_fixtures::{
|
||||
config::{SequencerPartialConfig, UrlProtocol, addr_to_url},
|
||||
indexer_client::IndexerClient,
|
||||
setup::{SequencerSetup, sequencer_client, setup_bedrock_node, setup_indexer},
|
||||
};
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 = SequencerSetup::new(fast_blocks(), bedrock_addr)
|
||||
.with_genesis(genesis)
|
||||
.setup_at(home.path())
|
||||
.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;
|
||||
|
||||
let (_bedrock, bedrock_addr) = setup_bedrock_node()
|
||||
.await
|
||||
.context("Failed to setup Bedrock")?;
|
||||
let (indexer_handle, _indexer_dir) = setup_indexer(
|
||||
bedrock_addr,
|
||||
test_fixtures::config::bedrock_channel_id(),
|
||||
None,
|
||||
)
|
||||
.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 = SequencerSetup::new(fast_blocks(), bedrock_addr)
|
||||
.with_genesis(genesis.clone())
|
||||
.setup_at(home_a.path())
|
||||
.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, L2_TO_L1_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 = SequencerSetup::new(slow_blocks(), bedrock_addr)
|
||||
.with_genesis(genesis)
|
||||
.setup_at(home_a.path())
|
||||
.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 = SequencerSetup::new(fast_blocks(), bedrock_addr)
|
||||
.with_genesis(genesis.clone())
|
||||
.setup_at(home_a.path())
|
||||
.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),
|
||||
SequencerSetup::new(slow_blocks(), bedrock_addr)
|
||||
.with_channel_id(empty_channel)
|
||||
.with_genesis(genesis)
|
||||
.setup_at(&home_a_path),
|
||||
)
|
||||
.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;
|
||||
|
||||
let (_bedrock, bedrock_addr) = setup_bedrock_node()
|
||||
.await
|
||||
.context("Failed to setup Bedrock")?;
|
||||
let (indexer_handle, _indexer_dir) = setup_indexer(
|
||||
bedrock_addr,
|
||||
test_fixtures::config::bedrock_channel_id(),
|
||||
None,
|
||||
)
|
||||
.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 = SequencerSetup::new(fast_blocks(), bedrock_addr)
|
||||
.with_genesis(genesis.clone())
|
||||
.setup_at(home.path())
|
||||
.await
|
||||
.context("Failed to start sequencer A")?;
|
||||
let client_a = sequencer_client(handle_a.addr())?;
|
||||
let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, L2_TO_L1_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 = SequencerSetup::new(slow_blocks(), bedrock_addr)
|
||||
.with_genesis(genesis)
|
||||
.setup_at(home.path())
|
||||
.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;
|
||||
|
||||
let (_bedrock, bedrock_addr) = setup_bedrock_node()
|
||||
.await
|
||||
.context("Failed to setup Bedrock")?;
|
||||
let (indexer_handle, _indexer_dir) = setup_indexer(
|
||||
bedrock_addr,
|
||||
test_fixtures::config::bedrock_channel_id(),
|
||||
None,
|
||||
)
|
||||
.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 = SequencerSetup::new(slow_blocks(), bedrock_addr)
|
||||
.with_genesis(genesis.clone())
|
||||
.setup_at(home.path())
|
||||
.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 finalized = {
|
||||
let handle = SequencerSetup::new(fast_blocks(), bedrock_addr)
|
||||
.with_genesis(genesis.clone())
|
||||
.setup_at(home.path())
|
||||
.await
|
||||
.context("Failed to resume sequencer")?;
|
||||
let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, L2_TO_L1_TIMEOUT).await?;
|
||||
drop(handle);
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
finalized
|
||||
};
|
||||
|
||||
// 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 = SequencerSetup::new(slow_blocks(), bedrock_addr)
|
||||
.with_genesis(genesis)
|
||||
.setup_at(home.path())
|
||||
.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(())
|
||||
}
|
||||
@ -294,6 +294,7 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction {
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: sender_vpk,
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: sender_nsk,
|
||||
membership_proof: proof,
|
||||
identifier: 0,
|
||||
|
||||
@ -13,7 +13,7 @@ use indexer_service_rpc::RpcClient as _;
|
||||
use integration_tests::{
|
||||
config::{self, SequencerPartialConfig},
|
||||
indexer_client::IndexerClient,
|
||||
setup::{setup_bedrock_node, setup_indexer, setup_sequencer},
|
||||
setup::{SequencerSetup, setup_bedrock_node, setup_indexer},
|
||||
};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClientBuilder};
|
||||
use tokio::test;
|
||||
@ -35,13 +35,19 @@ async fn two_zones_share_one_bedrock_and_both_advance() -> Result<()> {
|
||||
let channel_b = config::bedrock_channel_id_b();
|
||||
|
||||
// Empty genesis is enough: the clock transaction drives block production.
|
||||
let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_a, None)
|
||||
let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr)
|
||||
.with_channel_id(channel_a)
|
||||
.with_genesis(vec![])
|
||||
.setup()
|
||||
.await
|
||||
.context("Failed to set up zone A sequencer")?;
|
||||
let (idx_a, _idx_a_home) = setup_indexer(bedrock_addr, channel_a, None)
|
||||
.await
|
||||
.context("Failed to set up zone A indexer")?;
|
||||
let (seq_b, _seq_b_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_b, None)
|
||||
let (seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr)
|
||||
.with_channel_id(channel_b)
|
||||
.with_genesis(vec![])
|
||||
.setup()
|
||||
.await
|
||||
.context("Failed to set up zone B sequencer")?;
|
||||
let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, None)
|
||||
|
||||
@ -4,7 +4,7 @@ use lee_core::{
|
||||
PrivacyPreservingCircuitOutput, PrivateAccountKind, SharedSecretKey,
|
||||
account::{Account, AccountId, Nonce},
|
||||
compute_digest_for_path,
|
||||
encryption::ViewingPublicKey,
|
||||
encryption::{ViewTag, ViewingPublicKey},
|
||||
};
|
||||
|
||||
use crate::execution_state::ExecutionState;
|
||||
@ -66,6 +66,7 @@ pub fn compute_circuit_output(
|
||||
*commitment_root,
|
||||
);
|
||||
let new_nonce = Nonce::private_account_nonce_init(&account_id);
|
||||
let view_tag = EncryptedAccountData::compute_view_tag(&npk, vpk);
|
||||
|
||||
emit_private_output(
|
||||
&mut output,
|
||||
@ -73,7 +74,7 @@ pub fn compute_circuit_output(
|
||||
post_state,
|
||||
&account_id,
|
||||
&PrivateAccountKind::Regular(*identifier),
|
||||
&npk,
|
||||
view_tag,
|
||||
vpk,
|
||||
random_seed,
|
||||
new_nullifier,
|
||||
@ -83,6 +84,7 @@ pub fn compute_circuit_output(
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk,
|
||||
random_seed,
|
||||
view_tag,
|
||||
nsk,
|
||||
membership_proof,
|
||||
identifier,
|
||||
@ -110,7 +112,7 @@ pub fn compute_circuit_output(
|
||||
post_state,
|
||||
&account_id,
|
||||
&PrivateAccountKind::Regular(*identifier),
|
||||
&npk,
|
||||
*view_tag,
|
||||
vpk,
|
||||
random_seed,
|
||||
new_nullifier,
|
||||
@ -142,6 +144,7 @@ pub fn compute_circuit_output(
|
||||
*commitment_root,
|
||||
);
|
||||
let new_nonce = Nonce::private_account_nonce_init(&account_id);
|
||||
let view_tag = EncryptedAccountData::compute_view_tag(npk, vpk);
|
||||
|
||||
emit_private_output(
|
||||
&mut output,
|
||||
@ -149,7 +152,7 @@ pub fn compute_circuit_output(
|
||||
post_state,
|
||||
&account_id,
|
||||
&PrivateAccountKind::Regular(*identifier),
|
||||
npk,
|
||||
view_tag,
|
||||
vpk,
|
||||
random_seed,
|
||||
new_nullifier,
|
||||
@ -190,6 +193,7 @@ pub fn compute_circuit_output(
|
||||
let (authority_program_id, seed) = pda_seed_by_position
|
||||
.get(&pos)
|
||||
.expect("PrivatePdaInit position must be in pda_seed_by_position");
|
||||
let view_tag = EncryptedAccountData::compute_view_tag(npk, vpk);
|
||||
emit_private_output(
|
||||
&mut output,
|
||||
&mut output_index,
|
||||
@ -200,7 +204,7 @@ pub fn compute_circuit_output(
|
||||
seed: *seed,
|
||||
identifier: *identifier,
|
||||
},
|
||||
npk,
|
||||
view_tag,
|
||||
vpk,
|
||||
random_seed,
|
||||
new_nullifier,
|
||||
@ -210,6 +214,7 @@ pub fn compute_circuit_output(
|
||||
InputAccountIdentity::PrivatePdaUpdate {
|
||||
vpk,
|
||||
random_seed,
|
||||
view_tag,
|
||||
nsk,
|
||||
membership_proof,
|
||||
identifier,
|
||||
@ -234,7 +239,6 @@ pub fn compute_circuit_output(
|
||||
let new_nonce = pre_state.account.nonce.private_account_nonce_increment(nsk);
|
||||
|
||||
let account_id = pre_state.account_id;
|
||||
let npk = NullifierPublicKey::from(nsk);
|
||||
let (authority_program_id, seed) = pda_seed_by_position
|
||||
.get(&pos)
|
||||
.expect("PrivatePdaUpdate position must be in pda_seed_by_position");
|
||||
@ -248,7 +252,7 @@ pub fn compute_circuit_output(
|
||||
seed: *seed,
|
||||
identifier: *identifier,
|
||||
},
|
||||
&npk,
|
||||
*view_tag,
|
||||
vpk,
|
||||
random_seed,
|
||||
new_nullifier,
|
||||
@ -271,7 +275,7 @@ fn emit_private_output(
|
||||
post_state: Account,
|
||||
account_id: &AccountId,
|
||||
kind: &PrivateAccountKind,
|
||||
npk: &NullifierPublicKey,
|
||||
view_tag: ViewTag,
|
||||
vpk: &ViewingPublicKey,
|
||||
random_seed: &[u8; 32],
|
||||
new_nullifier: (Nullifier, CommitmentSetDigest),
|
||||
@ -287,15 +291,6 @@ fn emit_private_output(
|
||||
let esk = EphemeralSecretKey::new(account_id, random_seed, &new_nonce);
|
||||
let (shared_secret, epk) = SharedSecretKey::encapsulate_deterministic(vpk, &esk);
|
||||
|
||||
// Currently the view tag is properlty generated for all accounts.
|
||||
// To increase privacy, this will be changed in the later version
|
||||
// to only be generated explicitly for initialized accounts and
|
||||
// fed by the prover directly for updated accounts.
|
||||
//
|
||||
// See issue 573:
|
||||
// https://github.com/logos-blockchain/logos-execution-zone/issues/573
|
||||
let view_tag = EncryptedAccountData::compute_view_tag(npk, vpk);
|
||||
|
||||
let encrypted_account = EncryptionScheme::encrypt(
|
||||
&post_with_updated_nonce,
|
||||
kind,
|
||||
|
||||
@ -4,7 +4,7 @@ use crate::{
|
||||
Commitment, CommitmentSetDigest, Identifier, MembershipProof, Nullifier, NullifierPublicKey,
|
||||
NullifierSecretKey,
|
||||
account::{Account, AccountWithMetadata},
|
||||
encryption::{EncryptedAccountData, ViewingPublicKey},
|
||||
encryption::{EncryptedAccountData, ViewTag, ViewingPublicKey},
|
||||
program::{BlockValidityWindow, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow},
|
||||
};
|
||||
|
||||
@ -42,6 +42,7 @@ pub enum InputAccountIdentity {
|
||||
PrivateAuthorizedUpdate {
|
||||
vpk: ViewingPublicKey,
|
||||
random_seed: [u8; 32],
|
||||
view_tag: ViewTag,
|
||||
nsk: NullifierSecretKey,
|
||||
membership_proof: MembershipProof,
|
||||
identifier: Identifier,
|
||||
@ -79,6 +80,7 @@ pub enum InputAccountIdentity {
|
||||
PrivatePdaUpdate {
|
||||
vpk: ViewingPublicKey,
|
||||
random_seed: [u8; 32],
|
||||
view_tag: ViewTag,
|
||||
nsk: NullifierSecretKey,
|
||||
membership_proof: MembershipProof,
|
||||
identifier: Identifier,
|
||||
|
||||
@ -8,8 +8,8 @@ mod default_values;
|
||||
type Value = [u8; 32];
|
||||
type Node = [u8; 32];
|
||||
|
||||
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
|
||||
#[derive(Clone, BorshSerialize, BorshDeserialize)]
|
||||
#[cfg_attr(test, derive(Debug))]
|
||||
#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
|
||||
pub struct MerkleTree {
|
||||
nodes: Vec<Node>,
|
||||
capacity: usize,
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
#![expect(clippy::shadow_unrelated, reason = "We don't care about it in tests")]
|
||||
|
||||
use lee_core::{
|
||||
Commitment, DUMMY_COMMITMENT_HASH, EncryptionScheme, EphemeralSecretKey, Nullifier,
|
||||
PrivacyPreservingCircuitOutput, SharedSecretKey,
|
||||
Commitment, DUMMY_COMMITMENT_HASH, EncryptedAccountData, EncryptionScheme, EphemeralSecretKey,
|
||||
Nullifier, PrivacyPreservingCircuitOutput, SharedSecretKey,
|
||||
account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data},
|
||||
program::{PdaSeed, PrivateAccountKind},
|
||||
};
|
||||
@ -198,6 +198,7 @@ fn prove_privacy_preserving_execution_circuit_fully_private() {
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: sender_keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: sender_keys.nsk,
|
||||
membership_proof: commitment_set
|
||||
.get_proof_for(&commitment_sender)
|
||||
@ -242,6 +243,76 @@ fn prove_privacy_preserving_execution_circuit_fully_private() {
|
||||
assert_eq!(recipient_post, expected_private_account_2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_note_view_tag_is_derived_from_account_keys() {
|
||||
let program = crate::test_methods::noop();
|
||||
let keys = test_private_account_keys_1();
|
||||
let identifier: u128 = 0;
|
||||
let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), identifier);
|
||||
let account = AccountWithMetadata::new(Account::default(), false, account_id);
|
||||
|
||||
let (output, proof) = execute_and_prove(
|
||||
vec![account],
|
||||
Program::serialize_instruction(()).unwrap(),
|
||||
vec![InputAccountIdentity::PrivateUnauthorized {
|
||||
vpk: keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
npk: keys.npk(),
|
||||
identifier,
|
||||
commitment_root: DUMMY_COMMITMENT_HASH,
|
||||
}],
|
||||
&program.into(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(proof.is_valid_for(&output));
|
||||
assert_eq!(output.encrypted_private_post_states.len(), 1);
|
||||
assert_eq!(
|
||||
output.encrypted_private_post_states[0].view_tag,
|
||||
EncryptedAccountData::compute_view_tag(&keys.npk(), &keys.vpk()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_note_view_tag_is_the_supplied_value() {
|
||||
let program = crate::test_methods::noop();
|
||||
let keys = test_private_account_keys_1();
|
||||
let identifier: u128 = 99;
|
||||
let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), identifier);
|
||||
let account = Account {
|
||||
program_owner: program.id(),
|
||||
balance: 1,
|
||||
..Account::default()
|
||||
};
|
||||
let commitment = Commitment::new(&account_id, &account);
|
||||
let mut commitment_set = CommitmentSet::with_capacity(1);
|
||||
commitment_set.extend(std::slice::from_ref(&commitment));
|
||||
let sender = AccountWithMetadata::new(account, true, account_id);
|
||||
|
||||
// A tag deliberately different from the address-derived one, so a passthrough is
|
||||
// distinguishable from re-derivation.
|
||||
let fed_tag = EncryptedAccountData::compute_view_tag(&keys.npk(), &keys.vpk()).wrapping_add(1);
|
||||
|
||||
let (output, proof) = execute_and_prove(
|
||||
vec![sender],
|
||||
Program::serialize_instruction(()).unwrap(),
|
||||
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: fed_tag,
|
||||
nsk: keys.nsk,
|
||||
membership_proof: commitment_set.get_proof_for(&commitment).unwrap(),
|
||||
identifier,
|
||||
}],
|
||||
&program.into(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(proof.is_valid_for(&output));
|
||||
assert_eq!(output.encrypted_private_post_states.len(), 1);
|
||||
assert_eq!(output.encrypted_private_post_states[0].view_tag, fed_tag);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circuit_fails_when_chained_validity_windows_have_empty_intersection() {
|
||||
let account_keys = test_private_account_keys_1();
|
||||
@ -574,6 +645,7 @@ fn private_authorized_update_encrypts_regular_kind_with_identifier() {
|
||||
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: keys.nsk,
|
||||
membership_proof: commitment_set.get_proof_for(&commitment).unwrap(),
|
||||
identifier,
|
||||
@ -630,6 +702,7 @@ fn private_pda_update_encrypts_pda_kind_with_identifier() {
|
||||
InputAccountIdentity::PrivatePdaUpdate {
|
||||
vpk: keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: keys.nsk,
|
||||
membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(),
|
||||
identifier,
|
||||
@ -708,6 +781,7 @@ fn private_pda_update_identifier_mismatch_fails() {
|
||||
InputAccountIdentity::PrivatePdaUpdate {
|
||||
vpk: keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: keys.nsk,
|
||||
membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(),
|
||||
identifier: 99,
|
||||
|
||||
@ -20,8 +20,8 @@ use crate::{
|
||||
|
||||
pub const MAX_NUMBER_CHAINED_CALLS: usize = 10;
|
||||
|
||||
#[derive(Clone, BorshSerialize, BorshDeserialize)]
|
||||
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
|
||||
#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
|
||||
#[cfg_attr(test, derive(Debug))]
|
||||
pub struct CommitmentSet {
|
||||
merkle_tree: MerkleTree,
|
||||
commitments: HashMap<Commitment, usize>,
|
||||
@ -67,8 +67,8 @@ impl CommitmentSet {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
|
||||
#[derive(Clone)]
|
||||
#[cfg_attr(test, derive(Debug))]
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
struct NullifierSet(BTreeSet<Nullifier>);
|
||||
|
||||
impl NullifierSet {
|
||||
@ -109,8 +109,8 @@ impl BorshDeserialize for NullifierSet {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, BorshSerialize, BorshDeserialize)]
|
||||
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
|
||||
#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
|
||||
#[cfg_attr(test, derive(Debug))]
|
||||
pub struct V03State {
|
||||
public_state: HashMap<AccountId, Account>,
|
||||
private_state: (CommitmentSet, NullifierSet),
|
||||
|
||||
@ -74,6 +74,7 @@ fn private_changer_claimer_no_data_change_no_claim_succeeds() {
|
||||
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: sender_keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: sender_keys.nsk,
|
||||
membership_proof: (0, vec![]),
|
||||
identifier: 0,
|
||||
@ -104,6 +105,7 @@ fn private_changer_claimer_data_change_no_claim_fails() {
|
||||
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: sender_keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: sender_keys.nsk,
|
||||
membership_proof: (0, vec![]),
|
||||
identifier: 0,
|
||||
|
||||
@ -64,6 +64,7 @@ fn circuit_fails_if_invalid_auth_keys_are_provided() {
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: sender_keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: recipient_keys.nsk,
|
||||
membership_proof: (0, vec![]),
|
||||
identifier: 0,
|
||||
@ -113,6 +114,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_balance_is_provid
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: sender_keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: sender_keys.nsk,
|
||||
membership_proof: (0, vec![]),
|
||||
identifier: 0,
|
||||
@ -162,6 +164,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_program_owner_is_
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: sender_keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: sender_keys.nsk,
|
||||
membership_proof: (0, vec![]),
|
||||
identifier: 0,
|
||||
@ -211,6 +214,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_data_is_provided(
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: sender_keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: sender_keys.nsk,
|
||||
membership_proof: (0, vec![]),
|
||||
identifier: 0,
|
||||
@ -260,6 +264,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_nonce_is_provided
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: sender_keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: sender_keys.nsk,
|
||||
membership_proof: (0, vec![]),
|
||||
identifier: 0,
|
||||
@ -307,6 +312,7 @@ fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_bu
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: sender_keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: sender_keys.nsk,
|
||||
membership_proof: (0, vec![]),
|
||||
identifier: 0,
|
||||
@ -694,6 +700,7 @@ fn circuit_should_fail_if_there_are_repeated_ids() {
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: sender_keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: sender_keys.nsk,
|
||||
membership_proof: (1, vec![]),
|
||||
identifier: 0,
|
||||
@ -701,6 +708,7 @@ fn circuit_should_fail_if_there_are_repeated_ids() {
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: sender_keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: sender_keys.nsk,
|
||||
membership_proof: (1, vec![]),
|
||||
identifier: 0,
|
||||
@ -1020,6 +1028,7 @@ fn two_private_pda_family_members_receive_and_spend() {
|
||||
InputAccountIdentity::PrivatePdaUpdate {
|
||||
vpk: alice_keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: alice_keys.nsk,
|
||||
membership_proof: state
|
||||
.get_proof_for_commitment(&commitment_pda_0)
|
||||
@ -1057,6 +1066,7 @@ fn two_private_pda_family_members_receive_and_spend() {
|
||||
InputAccountIdentity::PrivatePdaUpdate {
|
||||
vpk: alice_keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: alice_keys.nsk,
|
||||
membership_proof: state
|
||||
.get_proof_for_commitment(&commitment_pda_1)
|
||||
@ -1108,6 +1118,7 @@ fn two_private_pda_family_members_receive_and_spend() {
|
||||
InputAccountIdentity::PrivatePdaUpdate {
|
||||
vpk: alice_keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: alice_keys.nsk,
|
||||
membership_proof: state
|
||||
.get_proof_for_commitment(&commitment_pda_1_after_spend)
|
||||
|
||||
@ -328,6 +328,7 @@ fn authorized_public_account_claiming_succeeds_when_executed_privately() {
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: sender_keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: sender_keys.nsk,
|
||||
membership_proof: state
|
||||
.get_proof_for_commitment(&sender_commitment)
|
||||
@ -443,6 +444,7 @@ fn private_chained_call(number_of_calls: u32) {
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: from_keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: from_keys.nsk,
|
||||
membership_proof: state
|
||||
.get_proof_for_commitment(&from_commitment)
|
||||
@ -452,6 +454,7 @@ fn private_chained_call(number_of_calls: u32) {
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: to_keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: to_keys.nsk,
|
||||
membership_proof: state
|
||||
.get_proof_for_commitment(&to_commitment)
|
||||
|
||||
@ -330,6 +330,7 @@ fn private_balance_transfer_for_tests(
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: sender_keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: sender_keys.nsk,
|
||||
membership_proof: state
|
||||
.get_proof_for_commitment(&sender_commitment)
|
||||
@ -384,6 +385,7 @@ fn deshielded_balance_transfer_for_tests(
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: sender_keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: sender_keys.nsk,
|
||||
membership_proof: state
|
||||
.get_proof_for_commitment(&sender_commitment)
|
||||
|
||||
@ -524,6 +524,7 @@ fn malicious_authorization_changer_should_fail_in_privacy_preserving_circuit() {
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: recipient_keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: recipient_keys.nsk,
|
||||
membership_proof: state
|
||||
.get_proof_for_commitment(&recipient_commitment)
|
||||
|
||||
@ -167,6 +167,7 @@ fn privacy_malicious_programs_cannot_drain_public_victim() {
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: attacker_keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: attacker_keys.nsk,
|
||||
membership_proof,
|
||||
identifier: 0,
|
||||
@ -327,6 +328,7 @@ fn privacy_malicious_programs_cannot_drain_private_victim() {
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: attacker_keys.vpk(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: attacker_keys.nsk,
|
||||
membership_proof,
|
||||
identifier: 0,
|
||||
|
||||
27
lez/chain_state/Cargo.toml
Normal file
27
lez/chain_state/Cargo.toml
Normal file
@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "chain_state"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
common.workspace = true
|
||||
lee.workspace = true
|
||||
lee_core.workspace = true
|
||||
logos-blockchain-core.workspace = true
|
||||
logos-blockchain-zone-sdk.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
borsh.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
testnet_initial_state.workspace = true
|
||||
serde_json.workspace = true
|
||||
308
lez/chain_state/src/apply.rs
Normal file
308
lez/chain_state/src/apply.rs
Normal file
@ -0,0 +1,308 @@
|
||||
//! The single validate-then-apply entry point shared by the sequencer and the
|
||||
//! indexer. Pure and storage-free: callers apply on a scratch clone of state and
|
||||
//! commit only on `Ok`.
|
||||
|
||||
use common::{
|
||||
HashType,
|
||||
block::{Block, BlockMeta},
|
||||
transaction::{LeeTransaction, clock_invocation},
|
||||
};
|
||||
use lee::{GENESIS_BLOCK_ID, V03State};
|
||||
|
||||
use crate::ingest_error::BlockIngestError;
|
||||
|
||||
/// The parent the next block must chain on.
|
||||
// `l1_slot` will be added here when the `ChainState` anchor layer lands.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Tip {
|
||||
pub block_id: u64,
|
||||
pub hash: HashType,
|
||||
}
|
||||
|
||||
impl From<&Block> for Tip {
|
||||
fn from(block: &Block) -> Self {
|
||||
Self {
|
||||
block_id: block.header.block_id,
|
||||
hash: block.header.hash,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BlockMeta> for Tip {
|
||||
fn from(meta: BlockMeta) -> Self {
|
||||
Self {
|
||||
block_id: meta.id,
|
||||
hash: meta.hash,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Tip> for BlockMeta {
|
||||
fn from(tip: &Tip) -> Self {
|
||||
Self {
|
||||
id: tip.block_id,
|
||||
hash: tip.hash,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of feeding a parsed L2 block to a validated tip.
|
||||
pub enum AcceptOutcome {
|
||||
/// Chained and applied; the tip advances.
|
||||
Applied,
|
||||
/// A duplicate re-delivery of an already-applied block. No state change.
|
||||
AlreadyApplied,
|
||||
/// Did not chain or failed to apply; the tip stays frozen.
|
||||
Parked(BlockIngestError),
|
||||
/// Chained but failed to apply, possibly transiently
|
||||
/// ([`BlockIngestError::is_retryable`]); nothing recorded, tip and state
|
||||
/// untouched. The caller retries and parks once it gives up.
|
||||
///
|
||||
/// TODO: Only the indexer's `accept_block` emits this today; the sequencer's
|
||||
/// `ChainState` parks on all failures without retrying (see `on_follow`).
|
||||
RetryableFailure(BlockIngestError),
|
||||
}
|
||||
|
||||
/// Validates `block` against `tip`, then applies it to `state`.
|
||||
///
|
||||
/// Mutates `state` in place, so callers pass a scratch clone and commit on `Ok`.
|
||||
pub fn apply_block(
|
||||
tip: Option<&Tip>,
|
||||
block: &Block,
|
||||
state: &mut V03State,
|
||||
) -> Result<(), BlockIngestError> {
|
||||
validate_against_tip(tip, block)?;
|
||||
apply_block_to_state(block, state)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks that `block` is the valid continuation of `tip`: hash integrity,
|
||||
/// then block-id continuity, then `prev_block_hash` linkage. A `None` tip
|
||||
/// (cold state) expects the genesis block.
|
||||
pub fn validate_against_tip(tip: Option<&Tip>, block: &Block) -> Result<(), BlockIngestError> {
|
||||
let computed = block.recompute_hash();
|
||||
if computed != block.header.hash {
|
||||
return Err(BlockIngestError::HashMismatch {
|
||||
computed,
|
||||
header: block.header.hash,
|
||||
});
|
||||
}
|
||||
|
||||
match tip {
|
||||
None => {
|
||||
if block.header.block_id != GENESIS_BLOCK_ID {
|
||||
return Err(BlockIngestError::UnexpectedBlockId {
|
||||
expected: GENESIS_BLOCK_ID,
|
||||
got: block.header.block_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(tip) => {
|
||||
let expected = tip
|
||||
.block_id
|
||||
.checked_add(1)
|
||||
.expect("block id should not overflow");
|
||||
if block.header.block_id != expected {
|
||||
return Err(BlockIngestError::UnexpectedBlockId {
|
||||
expected,
|
||||
got: block.header.block_id,
|
||||
});
|
||||
}
|
||||
if block.header.prev_block_hash != tip.hash {
|
||||
return Err(BlockIngestError::BrokenChainLink {
|
||||
expected_prev: tip.hash,
|
||||
got_prev: block.header.prev_block_hash,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies a block's transactions to `state`, mapping every failure to a
|
||||
/// [`BlockIngestError`] so the caller can park rather than crash. Operates in
|
||||
/// place; the caller commits only on `Ok`.
|
||||
pub fn apply_block_to_state(block: &Block, state: &mut V03State) -> Result<(), BlockIngestError> {
|
||||
let (clock_tx, user_txs) = block
|
||||
.body
|
||||
.transactions
|
||||
.split_last()
|
||||
.ok_or(BlockIngestError::EmptyBlock)?;
|
||||
|
||||
let LeeTransaction::Public(clock_tx) = clock_tx else {
|
||||
return Err(BlockIngestError::InvalidClockTransaction);
|
||||
};
|
||||
if *clock_tx != clock_invocation(block.header.timestamp) {
|
||||
return Err(BlockIngestError::InvalidClockTransaction);
|
||||
}
|
||||
|
||||
let is_genesis = block.header.block_id == GENESIS_BLOCK_ID;
|
||||
for (tx_index, transaction) in user_txs.iter().enumerate() {
|
||||
let state_transition = |err: anyhow::Error| BlockIngestError::StateTransition {
|
||||
tx_index: tx_index.try_into().expect("tx index fits in u64"),
|
||||
reason: format!("{err:#}"),
|
||||
};
|
||||
if is_genesis {
|
||||
let LeeTransaction::Public(public_tx) = transaction else {
|
||||
return Err(BlockIngestError::NonPublicGenesisTransaction);
|
||||
};
|
||||
state
|
||||
.transition_from_public_transaction(
|
||||
public_tx,
|
||||
block.header.block_id,
|
||||
block.header.timestamp,
|
||||
)
|
||||
.map_err(|err| state_transition(err.into()))?;
|
||||
} else {
|
||||
transaction
|
||||
.clone()
|
||||
.execute_on_state(state, block.header.block_id, block.header.timestamp)
|
||||
.map_err(|err| state_transition(err.into()))?;
|
||||
}
|
||||
}
|
||||
|
||||
state
|
||||
.transition_from_public_transaction(clock_tx, block.header.block_id, block.header.timestamp)
|
||||
.map_err(|err| BlockIngestError::StateTransition {
|
||||
tx_index: user_txs.len().try_into().expect("tx index fits in u64"),
|
||||
reason: format!("{:#}", anyhow::Error::from(err)),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use common::{
|
||||
block::HashableBlockData,
|
||||
test_utils::{
|
||||
create_transaction_native_token_transfer, produce_dummy_block,
|
||||
produce_dummy_empty_transaction, sequencer_sign_key_for_testing,
|
||||
},
|
||||
};
|
||||
use testnet_initial_state::{initial_pub_accounts_private_keys, initial_state};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn tip_of(block: &Block) -> Tip {
|
||||
Tip::from(block)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genesis_applies_on_empty_tip() {
|
||||
let mut state = initial_state();
|
||||
let genesis = produce_dummy_block(1, None, vec![]);
|
||||
apply_block(None, &genesis, &mut state).expect("genesis applies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_genesis_first_block_is_unexpected_id() {
|
||||
let mut state = initial_state();
|
||||
let block = produce_dummy_block(2, None, vec![]);
|
||||
let err = apply_block(None, &block, &mut state).expect_err("should reject");
|
||||
assert!(matches!(
|
||||
err,
|
||||
BlockIngestError::UnexpectedBlockId {
|
||||
expected: 1,
|
||||
got: 2
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_ahead_block_is_unexpected_id() {
|
||||
let mut state = initial_state();
|
||||
let genesis = produce_dummy_block(1, None, vec![]);
|
||||
apply_block(None, &genesis, &mut state).expect("genesis applies");
|
||||
|
||||
// Tip is at 1; a block with id 3 skips ahead.
|
||||
let bad = produce_dummy_block(3, Some(genesis.header.hash), vec![]);
|
||||
let err =
|
||||
apply_block(Some(&tip_of(&genesis)), &bad, &mut state).expect_err("should reject");
|
||||
assert!(matches!(
|
||||
err,
|
||||
BlockIngestError::UnexpectedBlockId {
|
||||
expected: 2,
|
||||
got: 3
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_chain_link_detected() {
|
||||
let mut state = initial_state();
|
||||
let genesis = produce_dummy_block(1, None, vec![]);
|
||||
apply_block(None, &genesis, &mut state).expect("genesis applies");
|
||||
|
||||
// Correct id (2), wrong parent hash.
|
||||
let block2 = produce_dummy_block(2, Some(HashType([9_u8; 32])), vec![]);
|
||||
let err =
|
||||
apply_block(Some(&tip_of(&genesis)), &block2, &mut state).expect_err("should reject");
|
||||
assert!(matches!(err, BlockIngestError::BrokenChainLink { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_mismatch_detected() {
|
||||
let mut state = initial_state();
|
||||
let mut genesis = produce_dummy_block(1, None, vec![]);
|
||||
// Tampering with the header invalidates the stored hash.
|
||||
genesis.header.timestamp = 999;
|
||||
let err = apply_block(None, &genesis, &mut state).expect_err("should reject");
|
||||
assert!(matches!(err, BlockIngestError::HashMismatch { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_block_rejected() {
|
||||
let mut state = initial_state();
|
||||
// A block with no transactions at all (not even the mandatory clock tx).
|
||||
let block = HashableBlockData {
|
||||
block_id: 1,
|
||||
prev_block_hash: HashType([0_u8; 32]),
|
||||
timestamp: 0,
|
||||
transactions: vec![],
|
||||
}
|
||||
.into_pending_block(&sequencer_sign_key_for_testing());
|
||||
let err = apply_block(None, &block, &mut state).expect_err("should reject");
|
||||
assert!(matches!(err, BlockIngestError::EmptyBlock));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_clock_tail_is_invalid_clock() {
|
||||
let mut state = initial_state();
|
||||
// Last tx is not the expected clock invocation for the timestamp.
|
||||
let block = HashableBlockData {
|
||||
block_id: 1,
|
||||
prev_block_hash: HashType([0_u8; 32]),
|
||||
timestamp: 50,
|
||||
transactions: vec![produce_dummy_empty_transaction()],
|
||||
}
|
||||
.into_pending_block(&sequencer_sign_key_for_testing());
|
||||
let err = apply_block(None, &block, &mut state).expect_err("should reject");
|
||||
assert!(matches!(err, BlockIngestError::InvalidClockTransaction));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_transfers_and_advances_state() {
|
||||
let mut state = initial_state();
|
||||
let accounts = initial_pub_accounts_private_keys();
|
||||
let from = accounts[0].account_id;
|
||||
let to = accounts[1].account_id;
|
||||
let sign_key = accounts[0].pub_sign_key.clone();
|
||||
|
||||
// Genesis (block 1): clock-only.
|
||||
let genesis = produce_dummy_block(1, None, vec![]);
|
||||
apply_block(None, &genesis, &mut state).expect("genesis applies");
|
||||
let mut tip = tip_of(&genesis);
|
||||
|
||||
// Blocks 2..=11: one native transfer of 10 each (nonces 0..=9).
|
||||
for i in 0..10_u64 {
|
||||
let tx = create_transaction_native_token_transfer(from, i.into(), to, 10, &sign_key);
|
||||
let block = produce_dummy_block(i + 2, Some(tip.hash), vec![tx]);
|
||||
apply_block(Some(&tip), &block, &mut state).expect("transfer applies");
|
||||
tip = tip_of(&block);
|
||||
}
|
||||
|
||||
assert_eq!(state.get_account_by_id(from).balance, 9900);
|
||||
assert_eq!(state.get_account_by_id(to).balance, 20100);
|
||||
}
|
||||
}
|
||||
1034
lez/chain_state/src/chain.rs
Normal file
1034
lez/chain_state/src/chain.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,18 +1,18 @@
|
||||
//! Startup check that the local store still belongs to the chain the
|
||||
//! connected channel serves.
|
||||
//! Startup check that a local store still belongs to the chain the connected
|
||||
//! channel serves.
|
||||
|
||||
use anyhow::Result;
|
||||
use common::{HashType, block::Block};
|
||||
use futures::StreamExt as _;
|
||||
use lee_core::BlockId;
|
||||
use log::warn;
|
||||
use logos_blockchain_zone_sdk::{Slot, ZoneMessage, adapter::Node as _};
|
||||
|
||||
use crate::IndexerCore;
|
||||
use logos_blockchain_core::mantle::ops::channel::ChannelId;
|
||||
use logos_blockchain_zone_sdk::{Slot, ZoneMessage, adapter, indexer::ZoneIndexer};
|
||||
|
||||
/// Upper bound on the channel reads of the startup consistency check.
|
||||
const CHANNEL_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// Result of comparing the indexer's stored chain against the channel.
|
||||
/// Result of comparing a caller's stored chain against the channel.
|
||||
pub enum ChainConsistency {
|
||||
/// Channel still serves our anchor block (the stored tip position, or the
|
||||
/// parked block while stalled).
|
||||
@ -24,7 +24,7 @@ pub enum ChainConsistency {
|
||||
/// - or the channel read was inconclusive (timeout / error / empty stream)
|
||||
///
|
||||
/// NOTE: None of these prove a reset, so the caller proceeds.
|
||||
/// A genuine divergence is still caught later when the ingest loop tries to apply and parks.
|
||||
/// A genuine divergence is still caught later when applying the channel history.
|
||||
Inconclusive,
|
||||
/// Positive evidence that the channel is a different chain than the store.
|
||||
///
|
||||
@ -36,13 +36,13 @@ pub enum ChainConsistency {
|
||||
pub enum ChainMismatch {
|
||||
/// The channel serves a different block at the anchor's id.
|
||||
Block {
|
||||
ours: (u64, HashType),
|
||||
channel: (u64, HashType),
|
||||
ours: (BlockId, HashType),
|
||||
channel: (BlockId, HashType),
|
||||
},
|
||||
/// The channel serves a block at/below the anchor's id past the anchor
|
||||
/// slot; on the same chain those ids live at earlier slots.
|
||||
ReinscribedBlock {
|
||||
channel: (u64, HashType),
|
||||
channel: (BlockId, HashType),
|
||||
slot: Slot,
|
||||
anchor_slot: Slot,
|
||||
},
|
||||
@ -97,18 +97,26 @@ impl std::fmt::Display for ChainMismatch {
|
||||
/// A block that must still be inscribed at `slot` if the channel is the chain
|
||||
/// the store was built from: the tip at the read cursor, or the recorded
|
||||
/// parked block while stalled.
|
||||
struct Anchor {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Anchor {
|
||||
slot: Slot,
|
||||
/// The anchor block's `(id, hash)`.
|
||||
///
|
||||
/// `None` when parked on an undeserializable inscription (no header was recorded).
|
||||
block: Option<(u64, HashType)>,
|
||||
/// `None` when anchored on an undeserializable inscription (no header was recorded).
|
||||
block: Option<(BlockId, HashType)>,
|
||||
}
|
||||
|
||||
impl Anchor {
|
||||
/// Builds an anchor at `slot` on the block `(id, hash)`, or a headerless
|
||||
/// anchor (`None`) when only the slot is known.
|
||||
#[must_use]
|
||||
pub const fn new(slot: Slot, block: Option<(BlockId, HashType)>) -> Self {
|
||||
Self { slot, block }
|
||||
}
|
||||
|
||||
/// Probes a channel message read at/after the anchor slot.
|
||||
/// See [`IndexerCore::verify_chain_at_anchor`].
|
||||
pub fn probe_anchor_slot(&self, msg: &ZoneMessage, slot: Slot) -> AnchorProbe {
|
||||
/// See [`verify_chain_consistency`].
|
||||
fn probe_anchor_slot(&self, msg: &ZoneMessage, slot: Slot) -> AnchorProbe {
|
||||
if slot < self.slot {
|
||||
return AnchorProbe::KeepLooking;
|
||||
}
|
||||
@ -157,6 +165,66 @@ impl Anchor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifies a stored chain against the channel one message at a time.
|
||||
///
|
||||
/// The shared driver behind both consistency consumers:
|
||||
/// [`verify_chain_consistency`] runs it over a channel it reads itself, while a
|
||||
/// caller that already streams the channel history (e.g. a reconstructing
|
||||
/// sequencer) feeds it the same messages it replays. Run [`Self::check_frontier`]
|
||||
/// once the channel tip is known, then feed messages in slot order via
|
||||
/// [`Self::observe`] until a verdict is reached.
|
||||
pub struct AnchorConsistencyCheck {
|
||||
anchor: Anchor,
|
||||
verdict: Option<ChainConsistency>,
|
||||
}
|
||||
|
||||
impl AnchorConsistencyCheck {
|
||||
/// New checker for `anchor` with an undetermined verdict.
|
||||
#[must_use]
|
||||
pub const fn new(anchor: Anchor) -> Self {
|
||||
Self {
|
||||
anchor,
|
||||
verdict: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the frontier check against a known channel tip. Skip it when the
|
||||
/// tip could not be read, leaving the verdict to the message scan.
|
||||
pub fn check_frontier(&mut self, channel_tip_slot: Option<Slot>) {
|
||||
if self.verdict.is_none() {
|
||||
self.verdict = frontier_verdict(self.anchor.slot, channel_tip_slot)
|
||||
.map(ChainConsistency::Inconsistent);
|
||||
}
|
||||
}
|
||||
|
||||
/// Feeds the next channel message in slot order. Returns the verdict once it
|
||||
/// is known so the caller can stop, or `None` while still undetermined.
|
||||
pub fn observe(&mut self, msg: &ZoneMessage, slot: Slot) -> Option<&ChainConsistency> {
|
||||
if self.verdict.is_none() {
|
||||
self.verdict = match self.anchor.probe_anchor_slot(msg, slot) {
|
||||
AnchorProbe::SameChain => Some(ChainConsistency::Consistent),
|
||||
AnchorProbe::Mismatch(mismatch) => Some(ChainConsistency::Inconsistent(mismatch)),
|
||||
AnchorProbe::Bail => Some(ChainConsistency::Inconclusive),
|
||||
AnchorProbe::KeepLooking => None,
|
||||
};
|
||||
}
|
||||
self.verdict.as_ref()
|
||||
}
|
||||
|
||||
/// The verdict reached so far, or `None` while undetermined.
|
||||
#[must_use]
|
||||
pub const fn verdict(&self) -> Option<&ChainConsistency> {
|
||||
self.verdict.as_ref()
|
||||
}
|
||||
|
||||
/// Consumes the checker, defaulting an undetermined verdict (the anchor slot
|
||||
/// was never observed) to [`ChainConsistency::Inconclusive`].
|
||||
#[must_use]
|
||||
pub fn finish(self) -> ChainConsistency {
|
||||
self.verdict.unwrap_or(ChainConsistency::Inconclusive)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a single channel message tells the anchored consistency check.
|
||||
enum AnchorProbe {
|
||||
/// The anchor is still in place: same chain.
|
||||
@ -168,114 +236,65 @@ enum AnchorProbe {
|
||||
KeepLooking,
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::multiple_inherent_impl,
|
||||
reason = "split for clarity & isolation of relevant code"
|
||||
)]
|
||||
impl IndexerCore {
|
||||
/// Verifies whether the channel still serves the same chain the store was built from.
|
||||
/// This may change frequently during development where we reset the chain from time to
|
||||
/// time in devnet/testnet, but we do not expect [`ChainConsistency::Inconsistent`] in
|
||||
/// production.
|
||||
///
|
||||
/// To compare the chains, we use an [`Anchor`] block that is either the parked L2 block
|
||||
/// while stalled, or the tip L2 block at its own inscription L1 slot.
|
||||
pub(crate) async fn verify_chain_consistency(&self) -> Result<ChainConsistency> {
|
||||
let Some(anchor) = self.get_startup_anchor()? else {
|
||||
// empty or cold store: nothing to compare
|
||||
return Ok(ChainConsistency::Inconclusive);
|
||||
};
|
||||
|
||||
self.verify_chain_at_anchor(&anchor).await
|
||||
/// Detects when a local store belongs to a different chain than the connected
|
||||
/// L1 (e.g. a wiped/restarted Bedrock) so startup can react instead of silently
|
||||
/// diverging.
|
||||
///
|
||||
/// Verifies the channel still carries the anchor block at its slot. The anchor
|
||||
/// was finalized at `anchor.slot`, so the same chain must still serve it there,
|
||||
/// while a reset chain re-inscribes its content only at later wall-clock slots.
|
||||
/// Only positive evidence of a different chain yields
|
||||
/// [`ChainConsistency::Inconsistent`]; absence of data stays
|
||||
/// [`ChainConsistency::Inconclusive`].
|
||||
///
|
||||
/// `node` need only implement the zone-sdk [`adapter::Node`] trait; a throwaway
|
||||
/// [`ZoneIndexer`] is built internally for the channel read.
|
||||
pub async fn verify_chain_consistency<N>(
|
||||
node: &N,
|
||||
channel_id: ChannelId,
|
||||
anchor: &Anchor,
|
||||
) -> Result<ChainConsistency>
|
||||
where
|
||||
N: adapter::Node + Clone + Sync,
|
||||
{
|
||||
let mut check = AnchorConsistencyCheck::new(anchor.clone());
|
||||
match node.channel_state(channel_id).await {
|
||||
Ok(state) => check.check_frontier(state.map(|s| s.tip_slot)),
|
||||
Err(err) => warn!("Failed to read channel state for the consistency check: {err:#}"),
|
||||
}
|
||||
if check.verdict().is_some() {
|
||||
return Ok(check.finish());
|
||||
}
|
||||
|
||||
/// Builds the anchor for the startup check.
|
||||
///
|
||||
/// - If stalled, returns the recorded _parked_ block
|
||||
/// - If not stalled, returns the validated tip at its _own_ inscription slot.
|
||||
/// - If the store is empty, returns `None`.
|
||||
fn get_startup_anchor(&self) -> Result<Option<Anchor>> {
|
||||
if let Some(stall) = self.store.get_stall_reason()? {
|
||||
return Ok(Some(Anchor {
|
||||
slot: stall.l1_slot,
|
||||
block: stall.block_id.zip(stall.block_hash),
|
||||
}));
|
||||
}
|
||||
// `next_messages` is exclusive, so `slot - 1` includes the anchor slot.
|
||||
let Some(from_slot) = anchor.slot.into_inner().checked_sub(1) else {
|
||||
return Ok(ChainConsistency::Inconclusive);
|
||||
};
|
||||
|
||||
// not stalled, so anchor on the tip at its own inscription slot
|
||||
let Some(slot) = self.store.get_tip_slot()?.or(self.store.get_zone_cursor()?) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(tip_id) = self.store.get_last_block_id()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(tip) = self.store.get_block_at_id(tip_id)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(Anchor {
|
||||
slot,
|
||||
block: Some((tip_id, tip.header.hash)),
|
||||
}))
|
||||
}
|
||||
let zone_indexer = ZoneIndexer::new(channel_id, node.clone());
|
||||
let scan = async {
|
||||
let stream = zone_indexer
|
||||
.next_messages(Some(Slot::from(from_slot)))
|
||||
.await?;
|
||||
let mut stream = std::pin::pin!(stream);
|
||||
|
||||
/// Verifies the channel still carries the anchor block at its slot.
|
||||
///
|
||||
/// The anchor was finalized at `anchor.slot`, so the same chain must still
|
||||
/// serve it there, while a reset chain re-inscribes its content only at
|
||||
/// later wall-clock slots.
|
||||
///
|
||||
/// Only positive evidence of a different chain yields `Inconsistent`.
|
||||
/// Absence of data stays `Inconclusive`.
|
||||
async fn verify_chain_at_anchor(&self, anchor: &Anchor) -> Result<ChainConsistency> {
|
||||
match self.node.channel_state(self.config.channel_id).await {
|
||||
Ok(state) => {
|
||||
if let Some(mismatch) = frontier_verdict(anchor.slot, state.map(|s| s.tip_slot)) {
|
||||
return Ok(ChainConsistency::Inconsistent(mismatch));
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Failed to read channel state for the consistency check: {err:#}");
|
||||
while let Some((msg, slot)) = stream.next().await {
|
||||
if check.observe(&msg, slot).is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok::<_, anyhow::Error>(())
|
||||
};
|
||||
|
||||
// `next_messages` is exclusive, so `slot - 1` includes the anchor slot.
|
||||
let Some(from_slot) = anchor.slot.into_inner().checked_sub(1) else {
|
||||
return Ok(ChainConsistency::Inconclusive);
|
||||
};
|
||||
|
||||
let scan = async {
|
||||
let stream = self
|
||||
.zone_indexer
|
||||
.next_messages(Some(Slot::from(from_slot)))
|
||||
.await?;
|
||||
let mut stream = std::pin::pin!(stream);
|
||||
|
||||
while let Some((msg, slot)) = stream.next().await {
|
||||
match anchor.probe_anchor_slot(&msg, slot) {
|
||||
AnchorProbe::SameChain => return Ok(Some(ChainConsistency::Consistent)),
|
||||
AnchorProbe::Mismatch(mismatch) => {
|
||||
return Ok(Some(ChainConsistency::Inconsistent(mismatch)));
|
||||
}
|
||||
AnchorProbe::Bail => return Ok(Some(ChainConsistency::Inconclusive)),
|
||||
AnchorProbe::KeepLooking => { /* dont do anything */ }
|
||||
}
|
||||
}
|
||||
Ok::<_, anyhow::Error>(None)
|
||||
};
|
||||
|
||||
match tokio::time::timeout(CHANNEL_READ_TIMEOUT, scan).await {
|
||||
Ok(Ok(Some(outcome))) => Ok(outcome),
|
||||
Ok(Ok(None)) => Ok(ChainConsistency::Inconclusive),
|
||||
Ok(Err(err)) => {
|
||||
warn!(
|
||||
"Failed to read the anchor slot for the consistency check; proceeding: {err:#}"
|
||||
);
|
||||
Ok(ChainConsistency::Inconclusive)
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
warn!("Timed out reading the anchor slot for the consistency check; proceeding");
|
||||
Ok(ChainConsistency::Inconclusive)
|
||||
}
|
||||
match tokio::time::timeout(CHANNEL_READ_TIMEOUT, scan).await {
|
||||
Ok(Ok(())) => Ok(check.finish()),
|
||||
Ok(Err(err)) => {
|
||||
warn!("Failed to read the anchor slot for the consistency check; proceeding: {err:#}");
|
||||
Ok(ChainConsistency::Inconclusive)
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
warn!("Timed out reading the anchor slot for the consistency check; proceeding");
|
||||
Ok(ChainConsistency::Inconclusive)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -297,35 +316,13 @@ fn frontier_verdict(anchor_slot: Slot, channel_tip_slot: Option<Slot>) -> Option
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use common::block::HashableBlockData;
|
||||
use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription};
|
||||
use logos_blockchain_zone_sdk::ZoneBlock;
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
BlockIngestError,
|
||||
block_store::AcceptOutcome,
|
||||
config::{ChannelId, ClientConfig, IndexerConfig},
|
||||
};
|
||||
|
||||
fn unreachable_core(dir: &std::path::Path) -> IndexerCore {
|
||||
let config = IndexerConfig {
|
||||
consensus_info_polling_interval: Duration::from_secs(1),
|
||||
bedrock_config: ClientConfig {
|
||||
addr: "http://localhost:1".parse().expect("url"),
|
||||
auth: None,
|
||||
},
|
||||
channel_id: ChannelId::from([1; 32]),
|
||||
allow_chain_reset: false,
|
||||
cross_zone: None,
|
||||
bridge_lock_holdings: Vec::new(),
|
||||
};
|
||||
IndexerCore::open(config, dir).expect("open core")
|
||||
}
|
||||
|
||||
fn test_block(block_id: u64, timestamp: u64) -> Block {
|
||||
fn test_block(block_id: BlockId, timestamp: u64) -> Block {
|
||||
HashableBlockData {
|
||||
block_id,
|
||||
prev_block_hash: HashType([0; 32]),
|
||||
@ -344,98 +341,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn anchor_for(block: &Block, slot: Slot) -> Anchor {
|
||||
Anchor {
|
||||
slot,
|
||||
block: Some((block.header.block_id, block.header.hash)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cold_store_is_inconclusive() {
|
||||
// An empty store has no cursor, so there is nothing to compare: the check
|
||||
// must be Inconclusive (not Consistent), and it returns before any L1 read.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let core = unreachable_core(dir.path());
|
||||
assert!(matches!(
|
||||
core.verify_chain_consistency().await.expect("verify"),
|
||||
ChainConsistency::Inconclusive
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parked_store_with_unreachable_node_is_inconclusive() {
|
||||
// Network failure is not evidence of a reset: a parked store must stay
|
||||
// parked (Inconclusive), not error out or trip the wipe path.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let core = unreachable_core(dir.path());
|
||||
let parked = test_block(5, 42);
|
||||
core.store
|
||||
.record_stall(
|
||||
Some(&parked.header),
|
||||
Slot::from(1_000),
|
||||
BlockIngestError::EmptyBlock,
|
||||
)
|
||||
.expect("record stall");
|
||||
assert!(matches!(
|
||||
core.verify_chain_consistency().await.expect("verify"),
|
||||
ChainConsistency::Inconclusive
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn caught_up_store_with_unreachable_node_is_inconclusive() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let core = unreachable_core(dir.path());
|
||||
let genesis = common::test_utils::produce_dummy_block(1, None, vec![]);
|
||||
assert!(matches!(
|
||||
core.store
|
||||
.accept_block(&genesis, Slot::from(1_000))
|
||||
.await
|
||||
.expect("accept"),
|
||||
AcceptOutcome::Applied
|
||||
));
|
||||
core.store
|
||||
.set_zone_cursor(&Slot::from(1_000))
|
||||
.expect("set cursor");
|
||||
assert!(matches!(
|
||||
core.verify_chain_consistency().await.expect("verify"),
|
||||
ChainConsistency::Inconclusive
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_anchor_prefers_tip_slot_over_lagging_cursor() {
|
||||
// Cursor persist failures are warn-only, so the read cursor can lag the
|
||||
// tip by several blocks. The anchor must pair the tip with its own
|
||||
// inscription slot; pairing it with the stale cursor would make the scan
|
||||
// misread the chain's intermediate blocks as re-inscriptions.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let core = unreachable_core(dir.path());
|
||||
|
||||
let genesis = common::test_utils::produce_dummy_block(1, None, vec![]);
|
||||
core.store
|
||||
.accept_block(&genesis, Slot::from(1_000))
|
||||
.await
|
||||
.expect("accept");
|
||||
let block2 = common::test_utils::produce_dummy_block(2, Some(genesis.header.hash), vec![]);
|
||||
core.store
|
||||
.accept_block(&block2, Slot::from(1_005))
|
||||
.await
|
||||
.expect("accept");
|
||||
let block3 = common::test_utils::produce_dummy_block(3, Some(block2.header.hash), vec![]);
|
||||
core.store
|
||||
.accept_block(&block3, Slot::from(1_010))
|
||||
.await
|
||||
.expect("accept");
|
||||
|
||||
// Cursor last persisted at the genesis slot: two blocks behind the tip.
|
||||
core.store
|
||||
.set_zone_cursor(&Slot::from(1_000))
|
||||
.expect("set cursor");
|
||||
|
||||
let anchor = core.get_startup_anchor().expect("anchor").expect("present");
|
||||
assert_eq!(anchor.slot, Slot::from(1_010));
|
||||
assert_eq!(anchor.block, Some((3, block3.header.hash)));
|
||||
Anchor::new(slot, Some((block.header.block_id, block.header.hash)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -516,10 +422,7 @@ mod tests {
|
||||
fn probe_accepts_any_message_for_a_headerless_anchor() {
|
||||
// A deserialize park records no header: any message still present at
|
||||
// the anchor slot means the history is intact.
|
||||
let anchor = Anchor {
|
||||
slot: Slot::from(1_000),
|
||||
block: None,
|
||||
};
|
||||
let anchor = Anchor::new(Slot::from(1_000), None);
|
||||
let garbage = ZoneMessage::Block(ZoneBlock {
|
||||
id: MsgId::from([0_u8; 32]),
|
||||
data: Inscription::try_from(&[1_u8, 2, 3][..]).expect("inscription"),
|
||||
@ -1,10 +1,10 @@
|
||||
use common::HashType;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Why the indexer could not apply an L2 block from the channel.
|
||||
/// Why an L2 block from the channel could not be applied.
|
||||
///
|
||||
/// Persisted in `RocksDB`, so every variant must have the following
|
||||
/// traits: `Clone + Serialize + Deserialize`.
|
||||
/// Persisted in `RocksDB` (as part of [`crate::StallReason`]), so every variant
|
||||
/// must be `Clone + Serialize + Deserialize`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
|
||||
pub enum BlockIngestError {
|
||||
#[error("Failed to deserialize L2 block: {0}")]
|
||||
17
lez/chain_state/src/lib.rs
Normal file
17
lez/chain_state/src/lib.rs
Normal file
@ -0,0 +1,17 @@
|
||||
//! Storage-free chain-state core shared by the LEZ sequencer and indexer:
|
||||
//! the [`apply_block`] entry point plus [`BlockIngestError`], [`StallReason`],
|
||||
//! [`Tip`], and [`AcceptOutcome`]. See [`ChainState`] for the two-tier model.
|
||||
|
||||
pub use apply::{AcceptOutcome, Tip, apply_block, apply_block_to_state, validate_against_tip};
|
||||
pub use chain::{ChainState, HeadEntry};
|
||||
pub use consistency::{
|
||||
Anchor, AnchorConsistencyCheck, ChainConsistency, ChainMismatch, verify_chain_consistency,
|
||||
};
|
||||
pub use ingest_error::BlockIngestError;
|
||||
pub use stall_reason::StallReason;
|
||||
|
||||
pub mod apply;
|
||||
pub mod chain;
|
||||
pub mod consistency;
|
||||
pub mod ingest_error;
|
||||
pub mod stall_reason;
|
||||
@ -1,4 +1,4 @@
|
||||
use common::HashType;
|
||||
use common::{HashType, block::BlockHeader};
|
||||
use logos_blockchain_zone_sdk::Slot;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@ -23,3 +23,28 @@ pub struct StallReason {
|
||||
/// just count them.
|
||||
pub orphans_since: u64,
|
||||
}
|
||||
|
||||
impl StallReason {
|
||||
/// First stall for a break, built from the breaking block's header
|
||||
/// (`None` for a deserialize break).
|
||||
#[must_use]
|
||||
pub fn new(header: Option<&BlockHeader>, l1_slot: Slot, error: BlockIngestError) -> Self {
|
||||
Self {
|
||||
block_id: header.map(|header| header.block_id),
|
||||
block_hash: header.map(|header| header.hash),
|
||||
prev_block_hash: header.map(|header| header.prev_block_hash),
|
||||
first_seen: header.map(|header| header.timestamp),
|
||||
l1_slot,
|
||||
error,
|
||||
orphans_since: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A later stall on the same break: bumps `orphans_since`, preserving the
|
||||
/// original cause.
|
||||
#[must_use]
|
||||
pub const fn escalate(mut self) -> Self {
|
||||
self.orphans_since = self.orphans_since.saturating_add(1);
|
||||
self
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,15 @@ pub struct BlockMeta {
|
||||
pub hash: BlockHash,
|
||||
}
|
||||
|
||||
impl From<&Block> for BlockMeta {
|
||||
fn from(block: &Block) -> Self {
|
||||
Self {
|
||||
id: block.header.block_id,
|
||||
hash: block.header.hash,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// Our own hasher.
|
||||
/// Currently it is SHA256 hasher wrapper. May change in a future.
|
||||
|
||||
@ -12,6 +12,7 @@ default = []
|
||||
testnet = []
|
||||
|
||||
[dependencies]
|
||||
chain_state.workspace = true
|
||||
common.workspace = true
|
||||
logos-blockchain-zone-sdk.workspace = true
|
||||
lee.workspace = true
|
||||
@ -32,7 +33,6 @@ futures.workspace = true
|
||||
url.workspace = true
|
||||
logos-blockchain-core.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
async-stream.workspace = true
|
||||
tokio.workspace = true
|
||||
risc0-zkvm.workspace = true
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use common::{
|
||||
HashType,
|
||||
block::{BedrockStatus, Block, BlockHeader},
|
||||
transaction::{LeeTransaction, clock_invocation},
|
||||
use chain_state::{
|
||||
AcceptOutcome, BlockIngestError, StallReason, Tip, apply_block_to_state, validate_against_tip,
|
||||
};
|
||||
use lee::{Account, AccountId, GENESIS_BLOCK_ID, V03State};
|
||||
use common::{
|
||||
block::{BedrockStatus, Block, BlockHeader},
|
||||
transaction::LeeTransaction,
|
||||
};
|
||||
use lee::{Account, AccountId, V03State};
|
||||
use lee_core::BlockId;
|
||||
use log::warn;
|
||||
use logos_blockchain_core::header::HeaderId;
|
||||
@ -14,28 +16,6 @@ use logos_blockchain_zone_sdk::Slot;
|
||||
use storage::indexer::RocksDBIO;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::{ingest_error::BlockIngestError, stall_reason::StallReason};
|
||||
|
||||
struct Tip {
|
||||
block_id: u64,
|
||||
hash: HashType,
|
||||
}
|
||||
|
||||
/// Outcome of feeding a parsed L2 block to the validated tip.
|
||||
pub enum AcceptOutcome {
|
||||
/// Chained and applied; tip and L1 read cursor both advance.
|
||||
Applied,
|
||||
/// A duplicate re-delivery of the current tip. Just L2 advances.
|
||||
AlreadyApplied,
|
||||
/// Did not chain or failed to apply; tip stays frozen, stall recorded.
|
||||
Parked(BlockIngestError),
|
||||
/// Chained but failed to apply, possibly transiently
|
||||
/// ([`BlockIngestError::is_retryable`]); nothing recorded, tip and state
|
||||
/// untouched. The caller retries and parks via
|
||||
/// [`IndexerStore::record_stall`] once it gives up.
|
||||
RetryableFailure(BlockIngestError),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct IndexerStore {
|
||||
dbio: Arc<RocksDBIO>,
|
||||
@ -207,10 +187,7 @@ impl IndexerStore {
|
||||
let Some(block) = self.dbio.get_block(block_id)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(Tip {
|
||||
block_id,
|
||||
hash: block.header.hash,
|
||||
}))
|
||||
Ok(Some(Tip::from(&block)))
|
||||
}
|
||||
|
||||
/// Record the stall reason.
|
||||
@ -223,22 +200,10 @@ impl IndexerStore {
|
||||
l1_slot: Slot,
|
||||
error: BlockIngestError,
|
||||
) -> Result<()> {
|
||||
let stall = match self.get_stall_reason()? {
|
||||
Some(mut existing) => {
|
||||
existing.orphans_since = existing.orphans_since.saturating_add(1);
|
||||
existing
|
||||
}
|
||||
None => StallReason {
|
||||
// need to map out of `header` because they are not ser/de
|
||||
block_id: header.map(|h| h.block_id),
|
||||
block_hash: header.map(|h| h.hash),
|
||||
prev_block_hash: header.map(|h| h.prev_block_hash),
|
||||
first_seen: header.map(|h| h.timestamp),
|
||||
l1_slot,
|
||||
error,
|
||||
orphans_since: 0,
|
||||
},
|
||||
};
|
||||
let stall = self.get_stall_reason()?.map_or_else(
|
||||
|| StallReason::new(header, l1_slot, error),
|
||||
StallReason::escalate,
|
||||
);
|
||||
self.set_stall_reason(&Some(stall))
|
||||
}
|
||||
|
||||
@ -258,6 +223,8 @@ impl IndexerStore {
|
||||
return Ok(AcceptOutcome::AlreadyApplied);
|
||||
}
|
||||
|
||||
// Validate before paying for the scratch clone; validation failures
|
||||
// are never retryable, so parking immediately is exact.
|
||||
if let Err(err) = validate_against_tip(tip.as_ref(), block) {
|
||||
self.record_stall(Some(&block.header), l1_slot, err.clone())?;
|
||||
return Ok(AcceptOutcome::Parked(err));
|
||||
@ -265,7 +232,7 @@ impl IndexerStore {
|
||||
|
||||
// TODO: we use scratch state to be atomic, but need to revisit how expensive a clone is
|
||||
let mut scratch = self.current_state.read().await.clone();
|
||||
if let Err(err) = apply_block_to_scratch(block, &mut scratch) {
|
||||
if let Err(err) = apply_block_to_state(block, &mut scratch) {
|
||||
if err.is_retryable() {
|
||||
return Ok(AcceptOutcome::RetryableFailure(err));
|
||||
}
|
||||
@ -290,112 +257,11 @@ impl IndexerStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks that `block` is the valid continuation of `tip`: hash integrity,
|
||||
/// then block-id continuity, then `prev_block_hash` linkage. A `None` tip
|
||||
/// (cold store) expects the genesis block.
|
||||
fn validate_against_tip(tip: Option<&Tip>, block: &Block) -> Result<(), BlockIngestError> {
|
||||
let computed = block.recompute_hash();
|
||||
if computed != block.header.hash {
|
||||
return Err(BlockIngestError::HashMismatch {
|
||||
computed,
|
||||
header: block.header.hash,
|
||||
});
|
||||
}
|
||||
|
||||
match tip {
|
||||
None => {
|
||||
if block.header.block_id != GENESIS_BLOCK_ID {
|
||||
return Err(BlockIngestError::UnexpectedBlockId {
|
||||
expected: GENESIS_BLOCK_ID,
|
||||
got: block.header.block_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(tip) => {
|
||||
let expected = tip
|
||||
.block_id
|
||||
.checked_add(1)
|
||||
.expect("block id should not overflow");
|
||||
if block.header.block_id != expected {
|
||||
return Err(BlockIngestError::UnexpectedBlockId {
|
||||
expected,
|
||||
got: block.header.block_id,
|
||||
});
|
||||
}
|
||||
if block.header.prev_block_hash != tip.hash {
|
||||
return Err(BlockIngestError::BrokenChainLink {
|
||||
expected_prev: tip.hash,
|
||||
got_prev: block.header.prev_block_hash,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies a block's transactions to `state`, mapping every failure to a
|
||||
/// [`BlockIngestError`] so the caller can park rather than crash. Operates on a
|
||||
/// scratch state; the caller commits only on `Ok`.
|
||||
fn apply_block_to_scratch(block: &Block, state: &mut V03State) -> Result<(), BlockIngestError> {
|
||||
let (clock_tx, user_txs) = block
|
||||
.body
|
||||
.transactions
|
||||
.split_last()
|
||||
.ok_or(BlockIngestError::EmptyBlock)?;
|
||||
|
||||
let expected_clock = LeeTransaction::Public(clock_invocation(block.header.timestamp));
|
||||
if *clock_tx != expected_clock {
|
||||
return Err(BlockIngestError::InvalidClockTransaction);
|
||||
}
|
||||
|
||||
let is_genesis = block.header.block_id == GENESIS_BLOCK_ID;
|
||||
for (tx_index, transaction) in user_txs.iter().enumerate() {
|
||||
let state_transition = |err: anyhow::Error| BlockIngestError::StateTransition {
|
||||
tx_index: tx_index.try_into().expect("tx index fits in u64"),
|
||||
reason: format!("{err:#}"),
|
||||
};
|
||||
if is_genesis {
|
||||
let LeeTransaction::Public(public_tx) = transaction else {
|
||||
return Err(BlockIngestError::NonPublicGenesisTransaction);
|
||||
};
|
||||
state
|
||||
.transition_from_public_transaction(
|
||||
public_tx,
|
||||
block.header.block_id,
|
||||
block.header.timestamp,
|
||||
)
|
||||
.map_err(|err| state_transition(err.into()))?;
|
||||
} else {
|
||||
transaction
|
||||
.clone()
|
||||
.execute_on_state(state, block.header.block_id, block.header.timestamp)
|
||||
.map_err(|err| state_transition(err.into()))?;
|
||||
}
|
||||
}
|
||||
|
||||
let LeeTransaction::Public(clock_public_tx) = clock_tx else {
|
||||
return Err(BlockIngestError::InvalidClockTransaction);
|
||||
};
|
||||
state
|
||||
.transition_from_public_transaction(
|
||||
clock_public_tx,
|
||||
block.header.block_id,
|
||||
block.header.timestamp,
|
||||
)
|
||||
.map_err(|err| BlockIngestError::StateTransition {
|
||||
tx_index: user_txs.len().try_into().expect("tx index fits in u64"),
|
||||
reason: format!("{:#}", anyhow::Error::from(err)),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stall_reason_tests {
|
||||
use common::HashType;
|
||||
|
||||
use super::*;
|
||||
use crate::{ingest_error::BlockIngestError, stall_reason::StallReason};
|
||||
|
||||
#[tokio::test]
|
||||
async fn stall_reason_roundtrips_and_clears() {
|
||||
@ -544,7 +410,6 @@ mod accept_tests {
|
||||
use common::{HashType, block::HashableBlockData, test_utils::produce_dummy_block};
|
||||
|
||||
use super::*;
|
||||
use crate::ingest_error::BlockIngestError;
|
||||
|
||||
fn signing_key() -> lee::PrivateKey {
|
||||
lee::PrivateKey::try_new([7_u8; 32]).expect("valid key")
|
||||
|
||||
@ -2,31 +2,28 @@ use std::{path::Path, sync::Arc};
|
||||
|
||||
use anyhow::Result;
|
||||
use arc_swap::ArcSwap;
|
||||
pub use chain_state::{AcceptOutcome, BlockIngestError, StallReason};
|
||||
use chain_state::{Anchor, ChainConsistency};
|
||||
use common::block::Block;
|
||||
// TODO: Remove after testnet
|
||||
use futures::StreamExt as _;
|
||||
pub use ingest_error::BlockIngestError;
|
||||
use log::{error, info, warn};
|
||||
use logos_blockchain_zone_sdk::{
|
||||
CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
|
||||
};
|
||||
use retry::ApplyRetryGate;
|
||||
pub use stall_reason::StallReason;
|
||||
|
||||
use crate::{
|
||||
block_store::{AcceptOutcome, IndexerStore},
|
||||
chain_consistency::ChainConsistency,
|
||||
block_store::IndexerStore,
|
||||
config::IndexerConfig,
|
||||
cross_zone_verifier::CrossZoneVerifier,
|
||||
status::{IndexerStatus, IndexerSyncStatus},
|
||||
};
|
||||
|
||||
pub mod block_store;
|
||||
pub mod chain_consistency;
|
||||
pub mod config;
|
||||
pub mod cross_zone_verifier;
|
||||
pub mod ingest_error;
|
||||
mod retry;
|
||||
pub mod stall_reason;
|
||||
pub mod status;
|
||||
|
||||
/// Consecutive failed apply attempts of the same block before parking.
|
||||
@ -125,6 +122,52 @@ impl IndexerCore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Verifies whether the channel still serves the same chain the store was built from.
|
||||
/// This may change frequently during development where we reset the chain from time to
|
||||
/// time in devnet/testnet, but we do not expect [`ChainConsistency::Inconsistent`] in
|
||||
/// production.
|
||||
///
|
||||
/// To compare the chains, we use an [`Anchor`] block that is either the parked L2 block
|
||||
/// while stalled, or the tip L2 block at its own inscription L1 slot.
|
||||
pub(crate) async fn verify_chain_consistency(&self) -> Result<ChainConsistency> {
|
||||
let Some(anchor) = self.get_startup_anchor()? else {
|
||||
// empty or cold store: nothing to compare
|
||||
return Ok(ChainConsistency::Inconclusive);
|
||||
};
|
||||
|
||||
chain_state::verify_chain_consistency(&self.node, self.config.channel_id, &anchor).await
|
||||
}
|
||||
|
||||
/// Builds the anchor for the startup check.
|
||||
///
|
||||
/// - If stalled, returns the recorded _parked_ block
|
||||
/// - If not stalled, returns the validated tip at its _own_ inscription slot.
|
||||
/// - If the store is empty, returns `None`.
|
||||
fn get_startup_anchor(&self) -> Result<Option<Anchor>> {
|
||||
if let Some(stall) = self.store.get_stall_reason()? {
|
||||
return Ok(Some(Anchor::new(
|
||||
stall.l1_slot,
|
||||
stall.block_id.zip(stall.block_hash),
|
||||
)));
|
||||
}
|
||||
|
||||
// not stalled, so anchor on the tip at its own inscription slot
|
||||
let Some(slot) = self
|
||||
.store
|
||||
.get_tip_slot()?
|
||||
.map_or_else(|| self.store.get_zone_cursor(), |slot| Ok(Some(slot)))?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(tip_id) = self.store.get_last_block_id()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(tip) = self.store.get_block_at_id(tip_id)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(Anchor::new(slot, Some((tip_id, tip.header.hash)))))
|
||||
}
|
||||
|
||||
/// Snapshot of the current ingestion status (sync state + indexed tip).
|
||||
///
|
||||
/// Combines the ingest loop's live status with the L2 tip read fresh from the
|
||||
@ -207,8 +250,8 @@ impl IndexerCore {
|
||||
let mut cursor = initial_cursor;
|
||||
let mut retry_gate = ApplyRetryGate::new();
|
||||
|
||||
if cursor.is_some() {
|
||||
info!("Resuming indexer from cursor {cursor:?}");
|
||||
if let Some(slot) = &cursor {
|
||||
info!("Resuming indexer from cursor {slot:?}");
|
||||
} else {
|
||||
info!("Starting indexer from beginning of channel");
|
||||
}
|
||||
@ -370,3 +413,127 @@ impl IndexerCore {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{HashType, block::HashableBlockData};
|
||||
use logos_blockchain_zone_sdk::Slot;
|
||||
|
||||
use super::*;
|
||||
use crate::config::{ChannelId, ClientConfig, IndexerConfig};
|
||||
|
||||
fn unreachable_core(dir: &std::path::Path) -> IndexerCore {
|
||||
let config = IndexerConfig {
|
||||
consensus_info_polling_interval: Duration::from_secs(1),
|
||||
bedrock_config: ClientConfig {
|
||||
addr: "http://localhost:1".parse().expect("url"),
|
||||
auth: None,
|
||||
},
|
||||
channel_id: ChannelId::from([1; 32]),
|
||||
allow_chain_reset: false,
|
||||
cross_zone: None,
|
||||
bridge_lock_holdings: Vec::new(),
|
||||
};
|
||||
IndexerCore::open(config, dir).expect("open core")
|
||||
}
|
||||
|
||||
fn test_block(block_id: u64, timestamp: u64) -> Block {
|
||||
HashableBlockData {
|
||||
block_id,
|
||||
prev_block_hash: HashType([0; 32]),
|
||||
timestamp,
|
||||
transactions: vec![],
|
||||
}
|
||||
.into_pending_block(&lee::PrivateKey::try_new([7; 32]).expect("valid key"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cold_store_is_inconclusive() {
|
||||
// An empty store has no cursor, so there is nothing to compare: the check
|
||||
// must be Inconclusive (not Consistent), and it returns before any L1 read.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let core = unreachable_core(dir.path());
|
||||
assert!(matches!(
|
||||
core.verify_chain_consistency().await.expect("verify"),
|
||||
ChainConsistency::Inconclusive
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parked_store_with_unreachable_node_is_inconclusive() {
|
||||
// Network failure is not evidence of a reset: a parked store must stay
|
||||
// parked (Inconclusive), not error out or trip the wipe path.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let core = unreachable_core(dir.path());
|
||||
let parked = test_block(5, 42);
|
||||
core.store
|
||||
.record_stall(
|
||||
Some(&parked.header),
|
||||
Slot::from(1_000),
|
||||
BlockIngestError::EmptyBlock,
|
||||
)
|
||||
.expect("record stall");
|
||||
assert!(matches!(
|
||||
core.verify_chain_consistency().await.expect("verify"),
|
||||
ChainConsistency::Inconclusive
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn caught_up_store_with_unreachable_node_is_inconclusive() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let core = unreachable_core(dir.path());
|
||||
let genesis = common::test_utils::produce_dummy_block(1, None, vec![]);
|
||||
assert!(matches!(
|
||||
core.store
|
||||
.accept_block(&genesis, Slot::from(1_000))
|
||||
.await
|
||||
.expect("accept"),
|
||||
AcceptOutcome::Applied
|
||||
));
|
||||
core.store
|
||||
.set_zone_cursor(&Slot::from(1_000))
|
||||
.expect("set cursor");
|
||||
assert!(matches!(
|
||||
core.verify_chain_consistency().await.expect("verify"),
|
||||
ChainConsistency::Inconclusive
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_anchor_prefers_tip_slot_over_lagging_cursor() {
|
||||
// Cursor persist failures are warn-only, so the read cursor can lag the
|
||||
// tip by several blocks. The anchor must pair the tip with its own
|
||||
// inscription slot; pairing it with the stale cursor would make the scan
|
||||
// misread the chain's intermediate blocks as re-inscriptions.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let core = unreachable_core(dir.path());
|
||||
|
||||
let genesis = common::test_utils::produce_dummy_block(1, None, vec![]);
|
||||
core.store
|
||||
.accept_block(&genesis, Slot::from(1_000))
|
||||
.await
|
||||
.expect("accept");
|
||||
let block2 = common::test_utils::produce_dummy_block(2, Some(genesis.header.hash), vec![]);
|
||||
core.store
|
||||
.accept_block(&block2, Slot::from(1_005))
|
||||
.await
|
||||
.expect("accept");
|
||||
let block3 = common::test_utils::produce_dummy_block(3, Some(block2.header.hash), vec![]);
|
||||
core.store
|
||||
.accept_block(&block3, Slot::from(1_010))
|
||||
.await
|
||||
.expect("accept");
|
||||
|
||||
// Cursor last persisted at the genesis slot: two blocks behind the tip.
|
||||
core.store
|
||||
.set_zone_cursor(&Slot::from(1_000))
|
||||
.expect("set cursor");
|
||||
|
||||
let anchor = core.get_startup_anchor().expect("anchor").expect("present");
|
||||
let expected = Anchor::new(Slot::from(1_010), Some((3, block3.header.hash)));
|
||||
assert_eq!(anchor, expected);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
use chain_state::StallReason;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::stall_reason::StallReason;
|
||||
|
||||
/// Coarse lifecycle state of the indexer's ingestion loop, so a client can tell
|
||||
/// "still catching up" apart from "something went wrong".
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
@ -117,10 +116,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn stalled_status_serializes_with_stall_reason() {
|
||||
use chain_state::{BlockIngestError, StallReason};
|
||||
use logos_blockchain_zone_sdk::Slot;
|
||||
|
||||
use crate::{ingest_error::BlockIngestError, stall_reason::StallReason};
|
||||
|
||||
let status = IndexerStatus {
|
||||
sync: IndexerSyncStatus::stalled("broken chain link".to_owned()),
|
||||
indexed_block_id: Some(41),
|
||||
|
||||
@ -65,6 +65,11 @@ impl<T> MemPoolHandle<T> {
|
||||
pub async fn push(&self, item: T) -> Result<(), tokio::sync::mpsc::error::SendError<T>> {
|
||||
self.sender.send(item).await
|
||||
}
|
||||
|
||||
/// Send an item to the mempool, failing _immediately_ if it is full.
|
||||
pub fn try_push(&self, item: T) -> Result<(), tokio::sync::mpsc::error::TrySendError<T>> {
|
||||
self.sender.try_send(item)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -123,6 +128,19 @@ mod tests {
|
||||
assert_eq!(pool.pop(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn try_push_fails_when_full_without_blocking() {
|
||||
let (mut pool, handle) = MemPool::new(1);
|
||||
|
||||
handle.try_push(1).unwrap();
|
||||
assert!(handle.try_push(2).is_err(), "full mempool must not accept");
|
||||
|
||||
// Popping frees capacity again.
|
||||
assert_eq!(pool.pop(), Some(1));
|
||||
handle.try_push(2).unwrap();
|
||||
assert_eq!(pool.pop(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn push_front() {
|
||||
let (mut pool, handle) = MemPool::new(10);
|
||||
|
||||
@ -10,6 +10,7 @@ workspace = true
|
||||
[dependencies]
|
||||
lee.workspace = true
|
||||
lee_core.workspace = true
|
||||
chain_state.workspace = true
|
||||
common.workspace = true
|
||||
storage.workspace = true
|
||||
mempool.workspace = true
|
||||
@ -33,6 +34,7 @@ tempfile.workspace = true
|
||||
chrono.workspace = true
|
||||
log.workspace = true
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
tokio-util.workspace = true
|
||||
rand.workspace = true
|
||||
borsh.workspace = true
|
||||
bytesize.workspace = true
|
||||
|
||||
@ -1,21 +1,44 @@
|
||||
use std::{pin::Pin, sync::Arc, time::Duration};
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use anyhow::{Context as _, Result, anyhow, ensure};
|
||||
use common::block::Block;
|
||||
use futures::Stream;
|
||||
use log::{info, warn};
|
||||
pub use logos_blockchain_core::mantle::ops::channel::MsgId;
|
||||
use logos_blockchain_core::mantle::ops::channel::{ChannelId, inscribe::Inscription};
|
||||
pub use logos_blockchain_key_management_system_service::keys::{Ed25519Key, ZkKey};
|
||||
pub use logos_blockchain_core::mantle::ops::channel::{Ed25519PublicKey, MsgId};
|
||||
use logos_blockchain_core::{
|
||||
mantle::{
|
||||
MantleTx, SignedMantleTx, Transaction as _,
|
||||
channel::{SlotTimeframe, SlotTimeout},
|
||||
ops::{
|
||||
Op, OpProof,
|
||||
channel::{
|
||||
ChannelId,
|
||||
config::{ChannelConfigOp, Keys},
|
||||
inscribe::Inscription,
|
||||
},
|
||||
},
|
||||
},
|
||||
proofs::channel_multi_sig_proof::{ChannelMultiSigProof, IndexedSignature},
|
||||
};
|
||||
pub use logos_blockchain_key_management_system_service::keys::{
|
||||
ED25519_SECRET_KEY_SIZE, Ed25519Key, ZkKey,
|
||||
};
|
||||
pub use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint;
|
||||
use logos_blockchain_zone_sdk::{
|
||||
CommonHttpClient,
|
||||
adapter::NodeHttpClient,
|
||||
CommonHttpClient, Slot, ZoneMessage,
|
||||
adapter::{Node as _, NodeHttpClient},
|
||||
indexer::ZoneIndexer,
|
||||
sequencer::{
|
||||
DepositInfo, Event, FinalizedOp, InscriptionInfo,
|
||||
SequencerConfig as ZoneSdkSequencerConfig, WithdrawArg, WithdrawInfo, ZoneSequencer,
|
||||
DepositInfo, Event, FinalizedOp, InscriptionInfo, OrphanedTx,
|
||||
SequencerConfig as ZoneSdkSequencerConfig, TurnNotification, WithdrawArg, WithdrawInfo,
|
||||
ZoneSequencer,
|
||||
},
|
||||
};
|
||||
use tokio::{sync::mpsc, task::JoinHandle};
|
||||
use tokio::{
|
||||
sync::{mpsc, oneshot, watch},
|
||||
task::JoinHandle,
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::config::BedrockConfig;
|
||||
|
||||
@ -41,8 +64,38 @@ pub type OnDepositEventSink =
|
||||
pub type OnWithdrawEventSink =
|
||||
Box<dyn Fn(WithdrawInfo) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
|
||||
|
||||
/// The channel delta the follow path consumes from one `Event::BlocksProcessed`,
|
||||
/// with inscription payloads decoded into `(MsgId, Block)` pairs.
|
||||
pub struct FollowUpdate {
|
||||
/// Inscriptions newly on the followed L1 branch, in channel order: they
|
||||
/// extend (or, after a reorg, replace part of) the `head` tier.
|
||||
pub adopted: Vec<(MsgId, Block)>,
|
||||
/// Inscriptions dropped from the branch by an L1 reorg: their blocks are
|
||||
/// reverted from the `head` and their user txs resubmitted to the mempool.
|
||||
pub orphaned: Vec<(MsgId, Block)>,
|
||||
/// Inscriptions whose containing L1 block reached finality: their blocks
|
||||
/// move into the irreversible `final` tier.
|
||||
pub finalized: Vec<(MsgId, Block)>,
|
||||
}
|
||||
|
||||
/// Sink for the follow path: apply adopted/finalized blocks to chain state and
|
||||
/// revert orphaned ones.
|
||||
pub type OnFollowSink = Box<dyn Fn(FollowUpdate) + Send + 'static>;
|
||||
|
||||
/// Commands the drive task executes with `&mut sequencer`.
|
||||
enum Command {
|
||||
/// Publish an inscription (+ atomic withdrawals); responds with the assigned `MsgId`.
|
||||
Publish {
|
||||
inscription: Inscription,
|
||||
withdrawals: Vec<WithdrawArg>,
|
||||
resp: oneshot::Sender<Result<MsgId>>,
|
||||
},
|
||||
}
|
||||
|
||||
type CommandSender = mpsc::Sender<Command>;
|
||||
|
||||
#[expect(async_fn_in_trait, reason = "We don't care about Send/Sync here")]
|
||||
pub trait BlockPublisherTrait: Clone {
|
||||
pub trait BlockPublisherTrait: Sized {
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "Looks better than bundling all those callbacks into a struct"
|
||||
@ -56,22 +109,49 @@ pub trait BlockPublisherTrait: Clone {
|
||||
on_finalized_block: FinalizedBlockSink,
|
||||
on_deposit_event: OnDepositEventSink,
|
||||
on_withdraw_event: OnWithdrawEventSink,
|
||||
on_follow: OnFollowSink,
|
||||
) -> Result<Self>;
|
||||
|
||||
/// Fire-and-forget publish. Zone-sdk drives the actual submission and
|
||||
/// retries internally; this just hands the payload off.
|
||||
async fn publish_block(&self, block: &Block, withdrawals: Vec<WithdrawArg>) -> Result<()>;
|
||||
/// Publish a block and return the `MsgId` zone-sdk assigned its inscription.
|
||||
/// Zone-sdk drives the actual submission and retries internally.
|
||||
async fn publish_block(&self, block: &Block, withdrawals: Vec<WithdrawArg>) -> Result<MsgId>;
|
||||
|
||||
fn channel_id(&self) -> ChannelId;
|
||||
|
||||
/// Whether this sequencer is currently authorized to write to the channel.
|
||||
fn is_our_turn(&self) -> bool;
|
||||
|
||||
/// A [`CancellationToken`] cancelled when the publisher's background driver
|
||||
/// terminates (a panicked sink, an ended event stream). No channel events
|
||||
/// are processed past that point, so the node must halt.
|
||||
fn driver_cancellation(&self) -> CancellationToken;
|
||||
|
||||
/// Current channel frontier slot on the connected chain, or `None` if the
|
||||
/// channel does not exist there. Drives the startup frontier check.
|
||||
async fn channel_tip_slot(&self) -> Result<Option<Slot>>;
|
||||
|
||||
/// Finalized channel messages from `after_slot` (exclusive) up to LIB, used
|
||||
/// for the startup consistency check and reconstruction. Pass `None` to read
|
||||
/// from the channel's genesis.
|
||||
async fn read_channel_after(
|
||||
&self,
|
||||
after_slot: Option<Slot>,
|
||||
) -> Result<impl Stream<Item = (ZoneMessage, Slot)> + '_>;
|
||||
}
|
||||
|
||||
/// Real block publisher backed by zone-sdk's `ZoneSequencer`.
|
||||
#[derive(Clone)]
|
||||
pub struct ZoneSdkPublisher {
|
||||
channel_id: ChannelId,
|
||||
publish_tx: mpsc::Sender<(Inscription, Vec<WithdrawArg>)>,
|
||||
/// Direct node handle retained for channel reads (startup consistency check
|
||||
/// and reconstruction); the sequencer itself lives in the drive task.
|
||||
node: NodeHttpClient,
|
||||
command_tx: CommandSender,
|
||||
turn_rx: watch::Receiver<TurnNotification>,
|
||||
// Cancelled when the drive task ends for any reason, including a panic.
|
||||
driver_cancellation: CancellationToken,
|
||||
// Aborts the drive task when the last clone is dropped.
|
||||
_drive_task: Arc<DriveTaskGuard>,
|
||||
indexer: ZoneIndexer<NodeHttpClient>,
|
||||
}
|
||||
|
||||
struct DriveTaskGuard(JoinHandle<()>);
|
||||
@ -92,6 +172,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
||||
on_finalized_block: FinalizedBlockSink,
|
||||
on_deposit_event: OnDepositEventSink,
|
||||
on_withdraw_event: OnWithdrawEventSink,
|
||||
on_follow: OnFollowSink,
|
||||
) -> Result<Self> {
|
||||
let basic_auth = config.auth.clone().map(Into::into);
|
||||
let node = NodeHttpClient::new(CommonHttpClient::new(basic_auth), config.node_url.clone());
|
||||
@ -104,7 +185,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
||||
let mut sequencer = ZoneSequencer::init_with_config(
|
||||
config.channel_id,
|
||||
bedrock_signing_key,
|
||||
node,
|
||||
node.clone(),
|
||||
zone_sdk_config,
|
||||
initial_checkpoint,
|
||||
);
|
||||
@ -112,11 +193,18 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
||||
// Grab readiness receiver before moving the sequencer into the drive
|
||||
// task so we can await cold-start completion below.
|
||||
let mut ready_rx = sequencer.subscribe_ready();
|
||||
// Grab the turn watch before the move; the sdk actor keeps it current.
|
||||
let turn_rx = sequencer.subscribe_turn_to_write();
|
||||
|
||||
let (publish_tx, mut publish_rx) =
|
||||
mpsc::channel::<(Inscription, Vec<WithdrawArg>)>(PUBLISH_INBOX_CAPACITY);
|
||||
let (command_tx, mut command_rx): (CommandSender, _) =
|
||||
mpsc::channel(PUBLISH_INBOX_CAPACITY);
|
||||
|
||||
let driver_cancellation = CancellationToken::new();
|
||||
let driver_guard = driver_cancellation.clone().drop_guard();
|
||||
let drive_task = tokio::spawn(async move {
|
||||
// Dropped when this task ends (including panics in the sinks),
|
||||
// cancelling every `driver_cancellation`.
|
||||
let _driver_guard = driver_guard;
|
||||
loop {
|
||||
#[expect(
|
||||
clippy::integer_division_remainder_used,
|
||||
@ -124,32 +212,38 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
||||
)]
|
||||
{
|
||||
tokio::select! {
|
||||
// Drain external publish requests by calling the
|
||||
// borrowing handle — `&mut sequencer` is only
|
||||
// available here.
|
||||
Some((data_bounded, withdrawals)) = publish_rx.recv() => {
|
||||
let data_byte_size = data_bounded.len();
|
||||
if withdrawals.is_empty() {
|
||||
if let Err(e) = sequencer.handle()
|
||||
.publish(data_bounded)
|
||||
.context("Failed to publish block") {
|
||||
warn!("zone-sdk publish failed: {e:?}");
|
||||
}
|
||||
|
||||
info!("Published block with the size of {data_byte_size} bytes");
|
||||
} else {
|
||||
// Drain external commands by calling the borrowing
|
||||
// handle — `&mut sequencer` is only available here.
|
||||
Some(command) = command_rx.recv() => match command {
|
||||
Command::Publish { inscription: data_bounded, withdrawals, resp: resp_tx } => {
|
||||
let data_byte_size = data_bounded.len();
|
||||
let withdraw_count = withdrawals.len();
|
||||
if let Err(e) = sequencer.handle()
|
||||
.publish_atomic_withdraw(data_bounded, withdrawals)
|
||||
.context("Failed to publish block with withdrawals") {
|
||||
warn!("zone-sdk publish failed: {e:?}");
|
||||
}
|
||||
let published = if withdrawals.is_empty() {
|
||||
sequencer.handle()
|
||||
.publish(data_bounded)
|
||||
.context("Failed to publish block")
|
||||
} else {
|
||||
sequencer.handle()
|
||||
.publish_atomic_withdraw(data_bounded, withdrawals)
|
||||
.context("Failed to publish block with withdrawals")
|
||||
};
|
||||
|
||||
info!(
|
||||
"Published block with the size of {data_byte_size} bytes and {withdraw_count} bridge withdrawals",
|
||||
);
|
||||
let msg_result = published
|
||||
.map(|(result, _checkpoint)| result.tx.inscription().this_msg);
|
||||
match &msg_result {
|
||||
Ok(_) if withdraw_count == 0 => {
|
||||
info!("Published block with the size of {data_byte_size} bytes");
|
||||
}
|
||||
Ok(_) => {
|
||||
info!(
|
||||
"Published block with the size of {data_byte_size} bytes and {withdraw_count} bridge withdrawals",
|
||||
);
|
||||
}
|
||||
Err(e) => warn!("zone-sdk publish failed: {e:?}"),
|
||||
}
|
||||
let _dontcare = resp_tx.send(msg_result);
|
||||
}
|
||||
}
|
||||
},
|
||||
event = sequencer.next_event() => {
|
||||
let Some(event) = event else {
|
||||
continue;
|
||||
@ -157,17 +251,32 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
||||
match event {
|
||||
Event::BlocksProcessed {
|
||||
checkpoint,
|
||||
channel_update: _,
|
||||
channel_update,
|
||||
finalized,
|
||||
} => {
|
||||
on_checkpoint(checkpoint);
|
||||
|
||||
let adopted = channel_update
|
||||
.adopted
|
||||
.iter()
|
||||
.filter_map(block_from_inscription)
|
||||
.collect();
|
||||
let orphaned = channel_update
|
||||
.orphaned
|
||||
.iter()
|
||||
.map(orphan_inscription)
|
||||
.filter_map(block_from_inscription)
|
||||
.collect();
|
||||
|
||||
let mut finalized_blocks = Vec::new();
|
||||
for op in finalized.into_iter().flat_map(|item| item.ops) {
|
||||
match op {
|
||||
FinalizedOp::Inscription(inscription) => {
|
||||
if let Some(block_id) =
|
||||
block_id_from_inscription(&inscription)
|
||||
if let Some((msg, block)) =
|
||||
block_from_inscription(&inscription)
|
||||
{
|
||||
on_finalized_block(block_id);
|
||||
on_finalized_block(block.header.block_id);
|
||||
finalized_blocks.push((msg, block));
|
||||
}
|
||||
}
|
||||
FinalizedOp::Deposit(deposit) => {
|
||||
@ -178,8 +287,22 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
on_follow(FollowUpdate {
|
||||
adopted,
|
||||
orphaned,
|
||||
finalized: finalized_blocks,
|
||||
});
|
||||
}
|
||||
Event::Ready => {}
|
||||
Event::TurnNotification { notification } => {
|
||||
info!(
|
||||
"Turn update: our_turn={}, starting_slot={:?}, ends_at_slot={:?}",
|
||||
notification.our_turn_to_write,
|
||||
notification.starting_slot,
|
||||
notification.ends_at_slot
|
||||
);
|
||||
}
|
||||
Event::Ready | Event::TurnNotification { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -196,37 +319,151 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
||||
|
||||
Ok(Self {
|
||||
channel_id: config.channel_id,
|
||||
publish_tx,
|
||||
indexer: ZoneIndexer::new(config.channel_id, node.clone()),
|
||||
node,
|
||||
command_tx,
|
||||
turn_rx,
|
||||
driver_cancellation,
|
||||
_drive_task: Arc::new(DriveTaskGuard(drive_task)),
|
||||
})
|
||||
}
|
||||
|
||||
async fn publish_block(&self, block: &Block, withdrawals: Vec<WithdrawArg>) -> Result<()> {
|
||||
async fn publish_block(&self, block: &Block, withdrawals: Vec<WithdrawArg>) -> Result<MsgId> {
|
||||
let data = borsh::to_vec(block).context("Failed to serialize block")?;
|
||||
let data_bounded: Inscription = data
|
||||
.try_into()
|
||||
.context("Block data exceeds maximum allowed size")?;
|
||||
|
||||
self.publish_tx
|
||||
.send((data_bounded, withdrawals))
|
||||
let (resp_tx, resp_rx) = oneshot::channel();
|
||||
self.command_tx
|
||||
.send(Command::Publish {
|
||||
inscription: data_bounded,
|
||||
withdrawals,
|
||||
resp: resp_tx,
|
||||
})
|
||||
.await
|
||||
.map_err(|_closed| anyhow!("Drive task is no longer running"))?;
|
||||
|
||||
Ok(())
|
||||
resp_rx
|
||||
.await
|
||||
.map_err(|_closed| anyhow!("Drive task dropped the publish response"))?
|
||||
}
|
||||
|
||||
fn channel_id(&self) -> ChannelId {
|
||||
self.channel_id
|
||||
}
|
||||
|
||||
fn is_our_turn(&self) -> bool {
|
||||
self.turn_rx.borrow().our_turn_to_write
|
||||
}
|
||||
|
||||
fn driver_cancellation(&self) -> CancellationToken {
|
||||
self.driver_cancellation.clone()
|
||||
}
|
||||
|
||||
async fn channel_tip_slot(&self) -> Result<Option<Slot>> {
|
||||
Ok(self
|
||||
.node
|
||||
.channel_state(self.channel_id)
|
||||
.await
|
||||
.context("Failed to read channel state")?
|
||||
.map(|state| state.tip_slot))
|
||||
}
|
||||
|
||||
async fn read_channel_after(
|
||||
&self,
|
||||
after_slot: Option<Slot>,
|
||||
) -> Result<impl Stream<Item = (ZoneMessage, Slot)> + '_> {
|
||||
let stream = self
|
||||
.indexer
|
||||
.next_messages(after_slot)
|
||||
.await
|
||||
.context("Failed to start channel read stream")?;
|
||||
Ok(stream)
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize inscription payload as a `Block` and return it's`block_id`.
|
||||
/// Bad payloads are logged and skipped.
|
||||
fn block_id_from_inscription(inscription: &InscriptionInfo) -> Option<u64> {
|
||||
/// Deserialize an inscription payload into `(this_msg, Block)`. Bad payloads are
|
||||
/// logged and skipped.
|
||||
fn block_from_inscription(inscription: &InscriptionInfo) -> Option<(MsgId, Block)> {
|
||||
borsh::from_slice::<Block>(&inscription.payload)
|
||||
.inspect_err(|err| {
|
||||
warn!("Failed to deserialize block from inscription: {err:?}");
|
||||
})
|
||||
.ok()
|
||||
.map(|block| block.header.block_id)
|
||||
.map(|block| (inscription.this_msg, block))
|
||||
}
|
||||
|
||||
/// The inscription carried by an orphaned tx (plain or atomic-withdraw bundle).
|
||||
const fn orphan_inscription(orphan: &OrphanedTx) -> &InscriptionInfo {
|
||||
match orphan {
|
||||
OrphanedTx::Inscription(info) => info,
|
||||
OrphanedTx::AtomicWithdraw(bundle) => &bundle.inscription,
|
||||
}
|
||||
}
|
||||
|
||||
/// Signs a `ChannelConfig` op (accredited keys + rotation params) with
|
||||
/// `signing_key` and posts it straight to the bedrock node.
|
||||
///
|
||||
/// A standalone one-shot — no running sequencer involved, so authorization is
|
||||
/// holding the admin key: the L1 rejects non-admin signers. `Ok(())` means the
|
||||
/// node accepted the transaction; channel acceptance is asynchronous and a
|
||||
/// rejection only shows up in node logs and on-chain behavior.
|
||||
pub async fn post_channel_config(
|
||||
config: &BedrockConfig,
|
||||
signing_key: &Ed25519Key,
|
||||
keys: Vec<Ed25519PublicKey>,
|
||||
posting_timeframe: u32,
|
||||
posting_timeout: u32,
|
||||
configuration_threshold: u16,
|
||||
withdraw_threshold: u16,
|
||||
) -> Result<()> {
|
||||
ensure!(!keys.is_empty(), "Channel key list must not be empty");
|
||||
for (name, threshold) in [
|
||||
("configuration_threshold", configuration_threshold),
|
||||
("withdraw_threshold", withdraw_threshold),
|
||||
] {
|
||||
ensure!(
|
||||
threshold >= 1 && usize::from(threshold) <= keys.len(),
|
||||
"{name} must be between 1 and the key count ({}), got {threshold}",
|
||||
keys.len()
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
posting_timeframe > 0 && posting_timeout >= posting_timeframe,
|
||||
"posting_timeframe must be nonzero and posting_timeout at least as long, \
|
||||
got {posting_timeframe} and {posting_timeout}"
|
||||
);
|
||||
|
||||
let keys = Keys::try_from(keys).map_err(|err| anyhow!("Invalid channel key list: {err}"))?;
|
||||
let config_op = ChannelConfigOp {
|
||||
channel: config.channel_id,
|
||||
keys,
|
||||
posting_timeframe: SlotTimeframe::from(posting_timeframe),
|
||||
posting_timeout: SlotTimeout::from(posting_timeout),
|
||||
configuration_threshold,
|
||||
withdraw_threshold,
|
||||
};
|
||||
|
||||
let mantle_tx = MantleTx([Op::ChannelConfig(config_op)].into());
|
||||
let tx_hash = mantle_tx.hash();
|
||||
// The admin key is `keys[0]`, hence signature index 0.
|
||||
let signature = IndexedSignature::new(
|
||||
0,
|
||||
signing_key.sign_payload(tx_hash.as_signing_bytes().as_ref()),
|
||||
);
|
||||
let proof = ChannelMultiSigProof::new(vec![signature])
|
||||
.map_err(|err| anyhow!("Failed to assemble channel multi-sig proof: {err:?}"))?;
|
||||
let signed_tx = SignedMantleTx {
|
||||
ops_proofs: vec![OpProof::ChannelMultiSigProof(proof)],
|
||||
mantle_tx,
|
||||
};
|
||||
|
||||
let node = NodeHttpClient::new(
|
||||
CommonHttpClient::new(config.auth.clone().map(Into::into)),
|
||||
config.node_url.clone(),
|
||||
);
|
||||
node.post_transaction(signed_tx)
|
||||
.await
|
||||
.context("Failed to post channel config transaction")
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ use log::info;
|
||||
use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint;
|
||||
use storage::sequencer::{
|
||||
RocksDBIO,
|
||||
sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey},
|
||||
sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey, ZoneAnchorRecord},
|
||||
};
|
||||
pub use storage::{DbResult, sequencer::DbDump};
|
||||
|
||||
@ -28,29 +28,17 @@ impl SequencerStore {
|
||||
/// Open existing database at the given location. Fails if no database is found.
|
||||
pub fn open_db(location: &Path, signing_key: lee::PrivateKey) -> DbResult<Self> {
|
||||
let dbio = Arc::new(RocksDBIO::open(location)?);
|
||||
let genesis_id = dbio.get_meta_first_block_in_db()?;
|
||||
let last_id = dbio.latest_block_meta()?.id;
|
||||
Self::from_dbio_and_signing_key(dbio, signing_key)
|
||||
}
|
||||
|
||||
info!("Preparing block cache");
|
||||
let mut tx_hash_to_block_map = HashMap::new();
|
||||
for i in genesis_id..=last_id {
|
||||
let block = dbio
|
||||
.get_block(i)?
|
||||
.expect("Block should be present in the database");
|
||||
|
||||
tx_hash_to_block_map.extend(block_to_transactions_map(&block));
|
||||
}
|
||||
info!(
|
||||
"Block cache prepared. Total blocks in cache: {}",
|
||||
tx_hash_to_block_map.len()
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
dbio,
|
||||
tx_hash_to_block_map,
|
||||
genesis_id,
|
||||
signing_key,
|
||||
})
|
||||
/// Create a fresh rocksdb at `location` from `dump`.
|
||||
pub fn restore_db_from_dump(
|
||||
location: &Path,
|
||||
dump: &DbDump,
|
||||
signing_key: lee::PrivateKey,
|
||||
) -> DbResult<Self> {
|
||||
let dbio = Arc::new(RocksDBIO::restore_from_dump(location, dump)?);
|
||||
Self::from_dbio_and_signing_key(dbio, signing_key)
|
||||
}
|
||||
|
||||
/// Starting database at the start of new chain.
|
||||
@ -75,6 +63,38 @@ impl SequencerStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn from_dbio_and_signing_key(
|
||||
dbio: Arc<RocksDBIO>,
|
||||
signing_key: lee::PrivateKey,
|
||||
) -> DbResult<Self> {
|
||||
let genesis_id = dbio.get_meta_first_block_in_db()?;
|
||||
let last_id = dbio.latest_block_meta()?.map(|meta| meta.id);
|
||||
|
||||
let mut tx_hash_to_block_map = HashMap::new();
|
||||
|
||||
if let Some(last_id) = last_id {
|
||||
info!("Preparing block cache");
|
||||
for i in genesis_id..=last_id {
|
||||
let block = dbio
|
||||
.get_block(i)?
|
||||
.expect("Block should be present in the database");
|
||||
|
||||
tx_hash_to_block_map.extend(block_to_transactions_map(&block));
|
||||
}
|
||||
info!(
|
||||
"Block cache prepared. Total blocks in cache: {}",
|
||||
tx_hash_to_block_map.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
dbio,
|
||||
tx_hash_to_block_map,
|
||||
genesis_id,
|
||||
signing_key,
|
||||
})
|
||||
}
|
||||
|
||||
/// Shared handle to the underlying rocksdb. Used to persist the zone-sdk
|
||||
/// checkpoint from the sequencer's drive task without needing &mut to the
|
||||
/// store.
|
||||
@ -114,7 +134,7 @@ impl SequencerStore {
|
||||
);
|
||||
}
|
||||
|
||||
pub fn latest_block_meta(&self) -> DbResult<BlockMeta> {
|
||||
pub fn latest_block_meta(&self) -> DbResult<Option<BlockMeta>> {
|
||||
self.dbio.latest_block_meta()
|
||||
}
|
||||
|
||||
@ -165,13 +185,6 @@ impl SequencerStore {
|
||||
self.dbio.dump_all()
|
||||
}
|
||||
|
||||
/// Create a fresh rocksdb at `location` from `dump`, closing it before returning so a sequencer
|
||||
/// can open it normally afterwards.
|
||||
pub fn restore_db_from_dump(location: &Path, dump: &DbDump) -> DbResult<()> {
|
||||
RocksDBIO::restore_from_dump(location, dump)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_zone_checkpoint(&self) -> Result<Option<SequencerCheckpoint>> {
|
||||
let Some(bytes) = self.dbio.get_zone_sdk_checkpoint_bytes()? else {
|
||||
return Ok(None);
|
||||
@ -188,9 +201,33 @@ impl SequencerStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The last channel block read back and verified from Bedrock (L1 slot +
|
||||
/// `id`/`hash`), or `None` before any block has been read from the channel.
|
||||
pub fn get_zone_anchor(&self) -> DbResult<Option<ZoneAnchorRecord>> {
|
||||
self.dbio.get_zone_anchor()
|
||||
}
|
||||
|
||||
pub fn set_zone_anchor(&self, anchor: &ZoneAnchorRecord) -> DbResult<()> {
|
||||
self.dbio.put_zone_anchor(anchor)
|
||||
}
|
||||
|
||||
pub fn get_unfulfilled_deposit_events(&self) -> DbResult<Vec<PendingDepositEventRecord>> {
|
||||
self.dbio.get_pending_deposit_events()
|
||||
}
|
||||
|
||||
pub fn is_deposit_event_submitted(&self, deposit_op_id: HashType) -> DbResult<bool> {
|
||||
self.dbio.is_deposit_event_submitted(deposit_op_id)
|
||||
}
|
||||
|
||||
/// Marks the given deposit events submitted in `block_id`, in one write.
|
||||
pub fn mark_deposit_events_submitted(
|
||||
&self,
|
||||
deposit_op_ids: &[HashType],
|
||||
submitted_block_id: u64,
|
||||
) -> DbResult<()> {
|
||||
self.dbio
|
||||
.mark_deposit_events_submitted(deposit_op_ids, submitted_block_id)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn block_to_transactions_map(block: &Block) -> HashMap<HashType, u64> {
|
||||
@ -275,7 +312,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
// Verify that initially the latest block hash equals genesis hash
|
||||
let latest_meta = node_store.latest_block_meta().unwrap();
|
||||
let latest_meta = node_store.latest_block_meta().unwrap().unwrap();
|
||||
assert_eq!(latest_meta.hash, genesis_hash);
|
||||
}
|
||||
|
||||
@ -313,7 +350,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
// Verify that the latest block meta now equals the new block's hash
|
||||
let latest_meta = node_store.latest_block_meta().unwrap();
|
||||
let latest_meta = node_store.latest_block_meta().unwrap().unwrap();
|
||||
assert_eq!(latest_meta.hash, block_hash);
|
||||
}
|
||||
|
||||
|
||||
@ -1,19 +1,30 @@
|
||||
use std::{path::Path, sync::Arc, time::Instant};
|
||||
use std::{
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use borsh::BorshDeserialize;
|
||||
use chain_state::{
|
||||
AcceptOutcome, Anchor, AnchorConsistencyCheck, ChainConsistency, ChainMismatch, ChainState, Tip,
|
||||
};
|
||||
use common::{
|
||||
HashType,
|
||||
block::{BedrockStatus, Block, HashableBlockData},
|
||||
block::{BedrockStatus, Block, BlockMeta, HashableBlockData},
|
||||
transaction::{LeeTransaction, clock_invocation},
|
||||
};
|
||||
use config::{GenesisAction, SequencerConfig};
|
||||
use futures::StreamExt as _;
|
||||
use itertools::Itertools as _;
|
||||
use lee::{AccountId, PublicTransaction, public_transaction::Message};
|
||||
use lee_core::GENESIS_BLOCK_ID;
|
||||
use log::{error, info, warn};
|
||||
use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key};
|
||||
use logos_blockchain_zone_sdk::sequencer::{DepositInfo, WithdrawArg};
|
||||
use logos_blockchain_zone_sdk::{
|
||||
Slot, ZoneMessage,
|
||||
sequencer::{DepositInfo, WithdrawArg},
|
||||
};
|
||||
use mempool::{MemPool, MemPoolHandle};
|
||||
#[cfg(feature = "mock")]
|
||||
pub use mock::SequencerCoreWithMockClients;
|
||||
@ -21,11 +32,11 @@ use num_bigint::BigUint;
|
||||
pub use storage::error::DbError;
|
||||
use storage::sequencer::{
|
||||
RocksDBIO,
|
||||
sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey},
|
||||
sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey, ZoneAnchorRecord},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
block_publisher::{BlockPublisherTrait, ZoneSdkPublisher},
|
||||
block_publisher::{BlockPublisherTrait, MsgId, ZoneSdkPublisher},
|
||||
block_store::SequencerStore,
|
||||
};
|
||||
|
||||
@ -51,18 +62,13 @@ struct DepositMetadata {
|
||||
recipient_id: lee::AccountId,
|
||||
}
|
||||
|
||||
impl DepositMetadata {
|
||||
fn decode(bytes: &[u8]) -> Result<Self, std::io::Error> {
|
||||
Self::try_from_slice(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SequencerCore<BP: BlockPublisherTrait = ZoneSdkPublisher> {
|
||||
state: lee::V03State,
|
||||
/// Two-tier chain state: production builds on its head; the publisher's
|
||||
/// `on_follow` sink feeds adopted/orphaned/finalized peer blocks into it.
|
||||
chain: Arc<Mutex<ChainState>>,
|
||||
store: SequencerStore,
|
||||
mempool: MemPool<(TransactionOrigin, LeeTransaction)>,
|
||||
sequencer_config: SequencerConfig,
|
||||
chain_height: u64,
|
||||
block_publisher: BP,
|
||||
}
|
||||
|
||||
@ -72,7 +78,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
/// assumed to represent the correct latest state consistent with Bedrock-finalized data.
|
||||
/// If no database is found, the sequencer performs a fresh start from genesis,
|
||||
/// initializing its state with the accounts defined in the configuration file.
|
||||
fn open_or_create_store(config: &SequencerConfig) -> (SequencerStore, lee::V03State, Block) {
|
||||
fn open_or_create_store(config: &SequencerConfig) -> (SequencerStore, lee::V03State) {
|
||||
let signing_key = lee::PrivateKey::try_new(config.signing_key).unwrap();
|
||||
let db_path = config.home.join("rocksdb");
|
||||
|
||||
@ -86,11 +92,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
let state = store
|
||||
.get_lee_state()
|
||||
.expect("Failed to read state from store");
|
||||
let genesis_block = store
|
||||
.get_block_at_id(store.genesis_id())
|
||||
.expect("Failed to read genesis block from store")
|
||||
.expect("Genesis block not found in store");
|
||||
(store, state, genesis_block)
|
||||
(store, state)
|
||||
} else {
|
||||
warn!(
|
||||
"Database not found at {}, starting from genesis",
|
||||
@ -115,22 +117,70 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
)
|
||||
.expect("Failed to create database with genesis block");
|
||||
|
||||
(store, genesis_state, genesis_block)
|
||||
(store, genesis_state)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuilds the two-tier [`ChainState`]: the final tier from the persisted
|
||||
/// final snapshot (pre-genesis state when absent), the head tier by replaying
|
||||
/// every stored block above it, so a post-restart orphan can still revert.
|
||||
fn restore_chain_state(
|
||||
config: &SequencerConfig,
|
||||
store: &SequencerStore,
|
||||
stored_head_state: &lee::V03State,
|
||||
) -> ChainState {
|
||||
let final_snapshot = store
|
||||
.dbio()
|
||||
.get_final_snapshot()
|
||||
.expect("Failed to read final snapshot from store");
|
||||
let (final_state, final_tip) = match final_snapshot {
|
||||
Some((state, meta)) => (state, Some(Tip::from(meta))),
|
||||
// Nothing finalized yet: replay the whole stored chain.
|
||||
None => (build_initial_state(config), None),
|
||||
};
|
||||
let boundary = final_tip.as_ref().map_or(0, |tip| tip.block_id);
|
||||
|
||||
let mut head_blocks = store
|
||||
.get_all_blocks()
|
||||
.filter_ok(|block| block.header.block_id > boundary)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.expect("Failed to read blocks from store while restoring chain state");
|
||||
head_blocks.sort_unstable_by_key(|block| block.header.block_id);
|
||||
|
||||
let mut chain = ChainState::from_final(final_state, final_tip);
|
||||
for block in head_blocks {
|
||||
let block_id = block.header.block_id;
|
||||
chain.restore_head_block(block).unwrap_or_else(|err| {
|
||||
panic!("Stored block {block_id} does not replay while restoring chain state: {err}")
|
||||
});
|
||||
}
|
||||
|
||||
// The replayed head must reproduce the persisted state, else store
|
||||
// and config disagree (e.g. edited genesis actions).
|
||||
assert!(
|
||||
chain.head_state() == stored_head_state,
|
||||
"Persisted state does not match the replayed chain; reset the store or restore the original config"
|
||||
);
|
||||
|
||||
chain
|
||||
}
|
||||
|
||||
pub async fn start_from_config(
|
||||
config: SequencerConfig,
|
||||
) -> (Self, MemPoolHandle<(TransactionOrigin, LeeTransaction)>) {
|
||||
let bedrock_signing_key =
|
||||
load_or_create_signing_key(&config.home.join("bedrock_signing_key"))
|
||||
.expect("Failed to load or create bedrock signing key");
|
||||
info!(
|
||||
"Bedrock signing public key: {}",
|
||||
hex::encode(bedrock_signing_key.public_key().to_bytes())
|
||||
);
|
||||
|
||||
let (store, state, _genesis_block) = Self::open_or_create_store(&config);
|
||||
let (store, state) = Self::open_or_create_store(&config);
|
||||
|
||||
let latest_block_meta = store
|
||||
.latest_block_meta()
|
||||
.expect("Failed to read latest block meta from store");
|
||||
let chain = Arc::new(Mutex::new(Self::restore_chain_state(
|
||||
&config, &store, &state,
|
||||
)));
|
||||
|
||||
let initial_checkpoint = store
|
||||
.get_zone_checkpoint()
|
||||
@ -149,12 +199,33 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
Self::on_finalized_block(store.dbio()),
|
||||
Self::on_deposit_event(store.dbio(), mempool_handle.clone()),
|
||||
Self::on_withdraw_event(store.dbio()),
|
||||
Self::on_follow(store.dbio(), Arc::clone(&chain), mempool_handle.clone()),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to initialize Block Publisher");
|
||||
|
||||
// Fresh start (no checkpoint): republish all pending blocks
|
||||
if is_fresh_start {
|
||||
// Cross-zone messaging: start a watcher per configured peer. The inbox
|
||||
// config account is seeded into genesis state in `build_genesis_state`.
|
||||
if let Some(cross_zone) = &config.cross_zone {
|
||||
cross_zone_watcher::spawn_watchers(
|
||||
&config.bedrock_config,
|
||||
cross_zone,
|
||||
config.block_create_timeout,
|
||||
&mempool_handle,
|
||||
);
|
||||
}
|
||||
// Before producing, verify our local state still belongs to the chain
|
||||
// the channel serves and replay any channel blocks we are missing
|
||||
// (e.g. from other sequencers).
|
||||
let channel_was_empty =
|
||||
Self::verify_and_reconstruct(&block_publisher, &store, &chain, is_fresh_start)
|
||||
.await
|
||||
.expect("Failed to verify/reconstruct sequencer state from Bedrock");
|
||||
|
||||
// Publish our blocks only when bootstrapping an empty channel. If the
|
||||
// channel already has blocks (another sequencer bootstrapped it), we
|
||||
// adopted them during reconstruction instead.
|
||||
if is_fresh_start && channel_was_empty {
|
||||
let mut pending_blocks = store
|
||||
.get_all_blocks()
|
||||
.filter_ok(|block| matches!(block.bedrock_status, BedrockStatus::Pending))
|
||||
@ -182,22 +253,10 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-zone messaging: start a watcher per configured peer. The inbox
|
||||
// config account is seeded into genesis state in `build_genesis_state`.
|
||||
if let Some(cross_zone) = &config.cross_zone {
|
||||
cross_zone_watcher::spawn_watchers(
|
||||
&config.bedrock_config,
|
||||
cross_zone,
|
||||
config.block_create_timeout,
|
||||
&mempool_handle,
|
||||
);
|
||||
}
|
||||
|
||||
let sequencer_core = Self {
|
||||
state,
|
||||
chain,
|
||||
store,
|
||||
mempool,
|
||||
chain_height: latest_block_meta.id,
|
||||
sequencer_config: config,
|
||||
block_publisher,
|
||||
};
|
||||
@ -205,6 +264,220 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
(sequencer_core, mempool_handle)
|
||||
}
|
||||
|
||||
/// Verifies the local store still belongs to the chain the connected channel
|
||||
/// serves and replays any finalized channel blocks missing locally into
|
||||
/// `state`/`store`, recording each block's L1 inscription slot as the new
|
||||
/// anchor. Fails (never parks) on any divergence.
|
||||
///
|
||||
/// Returns whatever channel was empty or not.
|
||||
async fn verify_and_reconstruct(
|
||||
publisher: &BP,
|
||||
store: &SequencerStore,
|
||||
chain: &Mutex<ChainState>,
|
||||
is_fresh_start: bool,
|
||||
) -> Result<bool> {
|
||||
let anchor_record = store
|
||||
.get_zone_anchor()
|
||||
.context("Failed to read zone anchor")?;
|
||||
|
||||
let after_slot = anchor_record
|
||||
.and_then(|record| record.slot.checked_sub(1))
|
||||
.map(Slot::from);
|
||||
let channel_tip_slot = publisher
|
||||
.channel_tip_slot()
|
||||
.await
|
||||
.context("Failed to read channel tip slot")?;
|
||||
|
||||
// If this sequencer has already committed blocks to the channel, that
|
||||
// channel must still exist. A missing channel then means a wiped/rewound
|
||||
// Bedrock or a node pointing at a different chain, so refuse to resume
|
||||
// onto a foreign channel.
|
||||
//
|
||||
// "Committed" requires *both* a non-genesis tip and a checkpoint that was
|
||||
// persisted before this startup: the tip alone is set the moment we produce
|
||||
// (before the channel confirms it), while a checkpoint alone is written by
|
||||
// zone-sdk's cold-start backfill even on a brand-new empty channel before we
|
||||
// publish genesis. We must read the checkpoint presence from before `BP::new`
|
||||
// ran (`!is_fresh_start`), because its cold-start backfill re-persists a
|
||||
// checkpoint by the time we reach here — reading the store now would always
|
||||
// see one. Together they mean we produced blocks and zone-sdk processed
|
||||
// channel activity in a prior run.
|
||||
let local_tip = store
|
||||
.latest_block_meta()
|
||||
.context("Failed to read latest block meta")?
|
||||
.map(|meta| meta.id);
|
||||
let had_checkpoint_before_start = !is_fresh_start;
|
||||
if let Some(local_tip) = local_tip
|
||||
&& had_checkpoint_before_start
|
||||
&& channel_tip_slot.is_none()
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Sequencer holds committed blocks (tip {local_tip}) but the Bedrock channel \
|
||||
no longer exists on the connected chain — the channel was wiped or the node \
|
||||
points at a different chain. Refusing to resume onto a foreign channel."
|
||||
));
|
||||
}
|
||||
|
||||
let divergence_error = |mismatch: &ChainMismatch| {
|
||||
anyhow!(
|
||||
"Sequencer store diverges from the Bedrock channel ({mismatch}). \
|
||||
Delete the sequencer storage directory or point at the correct channel."
|
||||
)
|
||||
};
|
||||
|
||||
// With a recorded anchor, probe the channel for positive evidence of a
|
||||
// different chain: the frontier upfront (a missing/behind channel serves
|
||||
// no messages to scan), then the anchor block as messages stream in.
|
||||
let mut consistency_check = anchor_record.map(|record| {
|
||||
let anchor = Anchor::new(
|
||||
Slot::from(record.slot),
|
||||
Some((record.block_id, record.hash)),
|
||||
);
|
||||
let mut check = AnchorConsistencyCheck::new(anchor);
|
||||
check.check_frontier(channel_tip_slot);
|
||||
check
|
||||
});
|
||||
if let Some(ChainConsistency::Inconsistent(mismatch)) = consistency_check
|
||||
.as_ref()
|
||||
.and_then(AnchorConsistencyCheck::verdict)
|
||||
{
|
||||
return Err(divergence_error(mismatch));
|
||||
}
|
||||
|
||||
// Verify each message against the anchor and replay the
|
||||
// blocks (applying the ones we miss, checking the ones we hold).
|
||||
let messages = publisher
|
||||
.read_channel_after(after_slot)
|
||||
.await
|
||||
.context("Failed to read channel history for reconstruction")?;
|
||||
let mut messages = std::pin::pin!(messages);
|
||||
let mut channel_is_empty = true;
|
||||
while let Some((message, slot)) = messages.next().await {
|
||||
if let Some(check) = &mut consistency_check
|
||||
&& let Some(ChainConsistency::Inconsistent(mismatch)) =
|
||||
check.observe(&message, slot)
|
||||
{
|
||||
return Err(divergence_error(mismatch));
|
||||
}
|
||||
|
||||
let ZoneMessage::Block(zone_block) = message else {
|
||||
continue;
|
||||
};
|
||||
let block: Block = borsh::from_slice(&zone_block.data).map_err(|err| {
|
||||
anyhow!(
|
||||
"Failed to deserialize channel block at slot {}: {err}",
|
||||
slot.into_inner()
|
||||
)
|
||||
})?;
|
||||
channel_is_empty = false;
|
||||
// Locked per message (not across the stream `await`): concurrent
|
||||
// follow events interleave safely — both paths apply idempotently
|
||||
// and persist under this same lock.
|
||||
let mut chain = chain.lock().expect("chain state mutex poisoned");
|
||||
Self::apply_reconstructed_block(store, &mut chain, &block, slot)?;
|
||||
}
|
||||
|
||||
Ok(channel_is_empty)
|
||||
}
|
||||
|
||||
/// Applies a single channel block during reconstruction: idempotent for
|
||||
/// blocks we already hold (verifying their hash), a validated continuation
|
||||
/// for new ones. Advances the persisted anchor to the block's slot.
|
||||
fn apply_reconstructed_block(
|
||||
store: &SequencerStore,
|
||||
chain: &mut ChainState,
|
||||
block: &Block,
|
||||
slot: Slot,
|
||||
) -> Result<()> {
|
||||
let tip = store
|
||||
.latest_block_meta()
|
||||
.context("Failed to read latest block meta")?;
|
||||
let block_id = block.header.block_id;
|
||||
let block_hash = block.header.hash;
|
||||
|
||||
let record = ZoneAnchorRecord {
|
||||
slot: slot.into_inner(),
|
||||
block_id,
|
||||
hash: block_hash,
|
||||
};
|
||||
|
||||
// A block at/below the tip must match what we already stored, otherwise
|
||||
// the channel is a different chain.
|
||||
if let Some(tip) = &tip
|
||||
&& block_id <= tip.id
|
||||
{
|
||||
match store
|
||||
.get_block_at_id(block_id)
|
||||
.context("Failed to read stored block")?
|
||||
{
|
||||
Some(stored) if stored.header.hash == block_hash => {
|
||||
store
|
||||
.set_zone_anchor(&record)
|
||||
.context("Failed to persist zone anchor")?;
|
||||
return Ok(());
|
||||
}
|
||||
Some(stored) => {
|
||||
return Err(anyhow!(
|
||||
"Channel block {block_id} hash {block_hash} does not match stored hash {}",
|
||||
stored.header.hash
|
||||
));
|
||||
}
|
||||
None => {
|
||||
return Err(anyhow!(
|
||||
"Channel block {block_id} is at/below local tip {} but is missing locally",
|
||||
tip.id
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New continuation: channel history is finalized, so it goes through
|
||||
// the final tier — validation happens inside `apply_finalized`.
|
||||
match chain.apply_finalized(MsgId::from(block.header.hash.0), block, slot) {
|
||||
AcceptOutcome::Applied | AcceptOutcome::AlreadyApplied => {}
|
||||
AcceptOutcome::Parked(err) | AcceptOutcome::RetryableFailure(err) => {
|
||||
return Err(anyhow!(
|
||||
"Channel block {block_id} does not extend local tip {:?}: {err}",
|
||||
tip.map(|tip| tip.id)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Persist like the follow path: the tip meta stays pinned to the head
|
||||
// tip even when the reconstructed block lands below it.
|
||||
let head_tip = chain.head_tip().map(|head| BlockMeta::from(&head));
|
||||
let final_meta = chain.final_tip().map(|meta| BlockMeta::from(&meta));
|
||||
store
|
||||
.dbio()
|
||||
.store_followed_blocks(
|
||||
&[(block, true)],
|
||||
head_tip.as_ref(),
|
||||
chain.head_state(),
|
||||
final_meta.as_ref().map(|meta| (chain.final_state(), meta)),
|
||||
)
|
||||
.context("Failed to persist reconstructed block")?;
|
||||
|
||||
// Mark the deposits' pending records submitted so the production-time
|
||||
// guard drops the mints cold-start backfill re-queued for them. Withdraw
|
||||
// intents are deliberately not counted: backfill already re-delivered and
|
||||
// dropped their finalized L1 events, so an increment here would never be
|
||||
// consumed and would leave a phantom count.
|
||||
let deposit_event_ids: Vec<_> = block
|
||||
.body
|
||||
.transactions
|
||||
.iter()
|
||||
.filter_map(extract_bridge_deposit_id)
|
||||
.collect();
|
||||
store
|
||||
.mark_deposit_events_submitted(&deposit_event_ids, block_id)
|
||||
.context("Failed to mark reconstructed deposits submitted")?;
|
||||
|
||||
store
|
||||
.set_zone_anchor(&record)
|
||||
.context("Failed to persist zone anchor")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_checkpoint(dbio: Arc<RocksDBIO>) -> block_publisher::CheckpointSink {
|
||||
Box::new(move |cp| {
|
||||
let bytes = match serde_json::to_vec(&cp) {
|
||||
@ -335,6 +608,17 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Publisher sink adapter over [`apply_follow_update`].
|
||||
fn on_follow(
|
||||
dbio: Arc<RocksDBIO>,
|
||||
chain: Arc<Mutex<ChainState>>,
|
||||
mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
|
||||
) -> block_publisher::OnFollowSink {
|
||||
Box::new(move |update: block_publisher::FollowUpdate| {
|
||||
apply_follow_update(&dbio, &chain, &mempool_handle, update);
|
||||
})
|
||||
}
|
||||
|
||||
/// Produces a new block from mempool transactions and publishes it via zone-sdk.
|
||||
pub async fn produce_new_block(&mut self) -> Result<u64> {
|
||||
let BlockWithMeta {
|
||||
@ -351,26 +635,77 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
.collect::<Result<_>>()
|
||||
.context("Failed to build reconciliation keys for block withdrawals")?;
|
||||
|
||||
self.block_publisher
|
||||
let this_msg = self
|
||||
.block_publisher
|
||||
.publish_block(&block, withdrawals)
|
||||
.await
|
||||
.context("Failed to publish block to Bedrock")?;
|
||||
|
||||
self.store.update(
|
||||
self.record_produced_block(
|
||||
this_msg,
|
||||
&block,
|
||||
&deposit_event_ids,
|
||||
withdrawal_reconciliation_keys,
|
||||
&self.state,
|
||||
)?;
|
||||
|
||||
Ok(self.chain_height)
|
||||
Ok(block.header.block_id)
|
||||
}
|
||||
|
||||
/// Applies our own freshly-published block to the head with the [`MsgId`] the
|
||||
/// publish assigned it, so the head advances and the later adopted
|
||||
/// redelivery dedups, then persists it.
|
||||
///
|
||||
/// Persistence is gated on the block actually becoming the head: if a peer
|
||||
/// block won this height while we were publishing (`AlreadyApplied`, or
|
||||
/// `Parked` when the head reorged to a different parent), the canonical
|
||||
/// block is persisted by the follow path instead, and our invalidated
|
||||
/// inscription comes back via `orphaned`.
|
||||
fn record_produced_block(
|
||||
&mut self,
|
||||
this_msg: MsgId,
|
||||
block: &Block,
|
||||
deposit_event_ids: &[HashType],
|
||||
withdrawal_reconciliation_keys: Vec<WithdrawalReconciliationKey>,
|
||||
) -> Result<()> {
|
||||
let mut chain = self.chain.lock().expect("chain state mutex poisoned");
|
||||
match chain.apply_produced(this_msg, block) {
|
||||
AcceptOutcome::Applied => {
|
||||
// Persisted under the lock so disk writes land in apply order
|
||||
// with the follow path.
|
||||
self.store.update(
|
||||
block,
|
||||
deposit_event_ids,
|
||||
withdrawal_reconciliation_keys,
|
||||
chain.head_state(),
|
||||
)?;
|
||||
}
|
||||
AcceptOutcome::AlreadyApplied => {
|
||||
warn!(
|
||||
"Produced block {} lost a competing-write race, skipping persistence",
|
||||
block.header.block_id
|
||||
);
|
||||
}
|
||||
AcceptOutcome::Parked(err) | AcceptOutcome::RetryableFailure(err) => {
|
||||
warn!(
|
||||
"Produced block {} no longer chains on the head, skipping persistence: {err}",
|
||||
block.header.block_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validates and applies a single mempool transaction to the current state.
|
||||
/// Returns `Ok(true)` if the transaction was valid and applied, `Ok(false)` if
|
||||
/// it was skipped due to validation failure.
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "splitting the produce-path accumulators into a struct buys nothing"
|
||||
)]
|
||||
fn apply_mempool_transaction(
|
||||
&mut self,
|
||||
store: &SequencerStore,
|
||||
state: &mut lee::V03State,
|
||||
origin: TransactionOrigin,
|
||||
tx: &LeeTransaction,
|
||||
block_height: u64,
|
||||
@ -381,11 +716,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
let tx_hash = tx.hash();
|
||||
match origin {
|
||||
TransactionOrigin::User => {
|
||||
let validated_diff = match tx.validate_on_state(
|
||||
&self.state,
|
||||
block_height,
|
||||
timestamp,
|
||||
) {
|
||||
let validated_diff = match tx.validate_on_state(state, block_height, timestamp) {
|
||||
Ok(diff) => diff,
|
||||
Err(err) => {
|
||||
error!(
|
||||
@ -399,7 +730,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
withdrawals.push(withdraw_data);
|
||||
}
|
||||
|
||||
self.state.apply_state_diff(validated_diff);
|
||||
state.apply_state_diff(validated_diff);
|
||||
}
|
||||
TransactionOrigin::Sequencer => {
|
||||
let LeeTransaction::Public(public_tx) = tx else {
|
||||
@ -407,10 +738,17 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
};
|
||||
|
||||
if let Some(deposit_op_id) = extract_bridge_deposit_id(tx) {
|
||||
if store
|
||||
.is_deposit_event_submitted(deposit_op_id)
|
||||
.context("Failed to check whether deposit was already submitted")?
|
||||
{
|
||||
info!("Skipping already-submitted bridge deposit {deposit_op_id}");
|
||||
return Ok(false);
|
||||
}
|
||||
deposit_event_ids.push(deposit_op_id);
|
||||
}
|
||||
|
||||
self.state
|
||||
state
|
||||
.transition_from_public_transaction(public_tx, block_height, timestamp)
|
||||
.context("Failed to execute sequencer-generated transaction")?;
|
||||
}
|
||||
@ -423,7 +761,18 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
fn build_block_from_mempool(&mut self) -> Result<BlockWithMeta> {
|
||||
let now = Instant::now();
|
||||
|
||||
let new_block_height = self.next_block_id();
|
||||
// Build on the head: its tip is the parent, its state the validation base.
|
||||
let (prev_block_hash, new_block_height, mut working_state) = {
|
||||
let chain = self.chain.lock().expect("chain state mutex poisoned");
|
||||
let tip = chain.head_tip();
|
||||
let height = tip.as_ref().map_or(GENESIS_BLOCK_ID, |head| {
|
||||
head.block_id
|
||||
.checked_add(1)
|
||||
.expect("block id should not overflow")
|
||||
});
|
||||
let prev = tip.map_or(HashType([0; 32]), |head| head.hash);
|
||||
(prev, height, chain.head_state().clone())
|
||||
};
|
||||
|
||||
let mut valid_transactions = Vec::new();
|
||||
let mut deposit_event_ids = Vec::new();
|
||||
@ -432,11 +781,6 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
let max_block_size = usize::try_from(self.sequencer_config.max_block_size.as_u64())
|
||||
.expect("`max_block_size` should fit into usize");
|
||||
|
||||
let latest_block_meta = self
|
||||
.store
|
||||
.latest_block_meta()
|
||||
.context("Failed to get latest block meta from store")?;
|
||||
|
||||
let new_block_timestamp = u64::try_from(chrono::Utc::now().timestamp_millis())
|
||||
.expect("Timestamp must be positive");
|
||||
|
||||
@ -455,7 +799,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
let temp_hashable_data = HashableBlockData {
|
||||
block_id: new_block_height,
|
||||
transactions: temp_valid_transactions,
|
||||
prev_block_hash: latest_block_meta.hash,
|
||||
prev_block_hash,
|
||||
timestamp: new_block_timestamp,
|
||||
};
|
||||
|
||||
@ -472,7 +816,9 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
break;
|
||||
}
|
||||
|
||||
if self.apply_mempool_transaction(
|
||||
if Self::apply_mempool_transaction(
|
||||
&self.store,
|
||||
&mut working_state,
|
||||
origin,
|
||||
&tx,
|
||||
new_block_height,
|
||||
@ -488,7 +834,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
}
|
||||
}
|
||||
|
||||
self.state
|
||||
working_state
|
||||
.transition_from_public_transaction(&clock_tx, new_block_height, new_block_timestamp)
|
||||
.context("Clock transaction failed. Aborting block production.")?;
|
||||
valid_transactions.push(clock_lee_tx);
|
||||
@ -496,7 +842,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
let hashable_data = HashableBlockData {
|
||||
block_id: new_block_height,
|
||||
transactions: valid_transactions,
|
||||
prev_block_hash: latest_block_meta.hash,
|
||||
prev_block_hash,
|
||||
timestamp: new_block_timestamp,
|
||||
};
|
||||
|
||||
@ -504,8 +850,6 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
.clone()
|
||||
.into_pending_block(self.store.signing_key());
|
||||
|
||||
self.chain_height = new_block_height;
|
||||
|
||||
log::info!(
|
||||
"Created block with {} transactions in {} seconds",
|
||||
hashable_data.transactions.len(),
|
||||
@ -519,16 +863,27 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
})
|
||||
}
|
||||
|
||||
pub const fn state(&self) -> &lee::V03State {
|
||||
&self.state
|
||||
/// Reads the current head state under the lock without cloning it, so callers
|
||||
/// reuse `V03State`'s own API (accounts, nonces, proofs) with no whole-state copy.
|
||||
pub fn with_state<R>(&self, f: impl FnOnce(&lee::V03State) -> R) -> R {
|
||||
f(self
|
||||
.chain
|
||||
.lock()
|
||||
.expect("chain state mutex poisoned")
|
||||
.head_state())
|
||||
}
|
||||
|
||||
pub const fn block_store(&self) -> &SequencerStore {
|
||||
&self.store
|
||||
}
|
||||
|
||||
pub const fn chain_height(&self) -> u64 {
|
||||
self.chain_height
|
||||
#[must_use]
|
||||
pub fn chain_height(&self) -> u64 {
|
||||
self.chain
|
||||
.lock()
|
||||
.expect("chain state mutex poisoned")
|
||||
.head_tip()
|
||||
.map_or(0, |tip| tip.block_id)
|
||||
}
|
||||
|
||||
pub const fn sequencer_config(&self) -> &SequencerConfig {
|
||||
@ -561,14 +916,21 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn block_publisher(&self) -> BP {
|
||||
self.block_publisher.clone()
|
||||
pub const fn block_publisher(&self) -> &BP {
|
||||
&self.block_publisher
|
||||
}
|
||||
|
||||
fn next_block_id(&self) -> u64 {
|
||||
self.chain_height
|
||||
.checked_add(1)
|
||||
.unwrap_or_else(|| panic!("Max block height reached: {}", self.chain_height))
|
||||
/// Whether this sequencer is currently authorized to write to the channel.
|
||||
#[must_use]
|
||||
pub fn is_our_turn(&self) -> bool {
|
||||
self.block_publisher.is_our_turn()
|
||||
}
|
||||
|
||||
/// Shared handle to the two-tier follow state, for tests to drive the
|
||||
/// follow path directly.
|
||||
#[cfg(all(test, feature = "mock"))]
|
||||
fn chain(&self) -> Arc<Mutex<ChainState>> {
|
||||
Arc::clone(&self.chain)
|
||||
}
|
||||
}
|
||||
|
||||
@ -578,6 +940,105 @@ struct BlockWithMeta {
|
||||
withdrawals: Vec<WithdrawArg>,
|
||||
}
|
||||
|
||||
/// Feed one channel delta into the follow state and mirror it to the store:
|
||||
/// revert orphaned, then apply and persist adopted and finalized blocks.
|
||||
/// Production builds on this same head. Wired to the publisher via
|
||||
/// [`SequencerCore::on_follow`]; a free function so tests can drive it directly.
|
||||
///
|
||||
/// TODO: unlike the indexer's ingest loop, this path does not retry
|
||||
/// `is_retryable` (transient) apply failures — a failed block just parks and
|
||||
/// relies on a valid successor or a restart. `ChainState` never emits
|
||||
/// `AcceptOutcome::RetryableFailure` yet; adding retry parity here is a
|
||||
/// follow-up.
|
||||
fn apply_follow_update(
|
||||
dbio: &RocksDBIO,
|
||||
chain: &Mutex<ChainState>,
|
||||
mempool_handle: &MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
|
||||
update: block_publisher::FollowUpdate,
|
||||
) {
|
||||
let block_publisher::FollowUpdate {
|
||||
adopted,
|
||||
orphaned,
|
||||
finalized,
|
||||
} = update;
|
||||
|
||||
// The lock is held across the persist below so disk writes land in apply
|
||||
// order — the produce path persists under this same lock.
|
||||
let resubmit_txs = {
|
||||
let mut chain = chain.lock().expect("chain state mutex poisoned");
|
||||
|
||||
// User txs of orphaned blocks, returned to the mempool below.
|
||||
let resubmit_txs: Vec<LeeTransaction> = orphaned
|
||||
.iter()
|
||||
.flat_map(|(_, block)| resubmittable_txs(block))
|
||||
.collect();
|
||||
|
||||
// Outcomes align with `adopted`.
|
||||
let outcomes = chain.apply_channel_update(&orphaned, &adopted);
|
||||
let mut to_persist: Vec<(&Block, bool)> = adopted
|
||||
.iter()
|
||||
.zip(&outcomes)
|
||||
.filter(|(_, outcome)| matches!(outcome, AcceptOutcome::Applied))
|
||||
.map(|((_, block), _)| (block, false))
|
||||
.collect();
|
||||
|
||||
let mut final_advanced = false;
|
||||
for (this_msg, block) in &finalized {
|
||||
// FIXME: thread the finalized inscription's L1 slot once the
|
||||
// sdk surfaces it; only used for the invalid-finalized stall.
|
||||
if matches!(
|
||||
chain.apply_finalized(*this_msg, block, Slot::from(0)),
|
||||
AcceptOutcome::Applied
|
||||
) {
|
||||
to_persist.push((block, true));
|
||||
final_advanced = true;
|
||||
}
|
||||
}
|
||||
// Snapshot the advanced final tier so a restart re-anchors on it.
|
||||
let final_meta = final_advanced.then(|| {
|
||||
let tip = chain.final_tip().expect("advanced final tier has a tip");
|
||||
BlockMeta::from(&tip)
|
||||
});
|
||||
let head_tip = chain.head_tip().map(|tip| BlockMeta::from(&tip));
|
||||
|
||||
// One atomic write for the whole update: blocks, tip meta and the
|
||||
// state after the last block land together, so a crash can never
|
||||
// leave the stored state ahead of the stored blocks. A persist
|
||||
// failure is fatal: the in-memory chain has already advanced, and
|
||||
// continuing would leave a permanent gap in the store.
|
||||
//
|
||||
// The `panic!` ends the drive task, whose cancellation halts the node.
|
||||
//
|
||||
// TODO: the zone-sdk checkpoint is persisted by `on_checkpoint`
|
||||
// before this write; a crash in between resumes past these blocks
|
||||
// without them ever landing in the store. Full `BlocksProcessed`
|
||||
// atomicity (checkpoint + blocks + state in one batch, per the sdk's
|
||||
// event contract) is a follow-up.
|
||||
dbio.store_followed_blocks(
|
||||
&to_persist,
|
||||
head_tip.as_ref(),
|
||||
chain.head_state(),
|
||||
final_meta.as_ref().map(|meta| (chain.final_state(), meta)),
|
||||
)
|
||||
.unwrap_or_else(|err| panic!("Failed to persist followed blocks: {err:#}"));
|
||||
|
||||
resubmit_txs
|
||||
};
|
||||
|
||||
// Rebuild orphaned work: return its user txs to the mempool so the
|
||||
// next on-turn production re-includes them on the new head.
|
||||
//
|
||||
// We use [`try_push`] here because this is called from the
|
||||
// publisher's drive task, and only the block production drains the mempool.
|
||||
// A blocking push on a full mempool would deadlock here.
|
||||
for tx in resubmit_txs {
|
||||
let tx_hash = tx.hash();
|
||||
if let Err(err) = mempool_handle.try_push((TransactionOrigin::User, tx)) {
|
||||
warn!("Dropping orphaned transaction {tx_hash} on resubmit: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks the database for any pending deposit events that have not yet been marked as submitted in
|
||||
/// a block, and re-queues them in the mempool in a separate async task for inclusion in the next
|
||||
/// block.
|
||||
@ -627,41 +1088,15 @@ fn replay_unfulfilled_deposit_events(
|
||||
});
|
||||
}
|
||||
|
||||
/// Builds the initial genesis state from `testnet_initial_state` plus configured genesis
|
||||
/// transactions. Returns the final state and the list of [`LeeTransaction`]s that should be
|
||||
/// committed to the genesis block so external observers can replay them.
|
||||
fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTransaction>) {
|
||||
/// The pre-genesis state: `testnet_initial_state` plus accounts seeded outside
|
||||
/// any transaction (bridge-lock holdings, the cross-zone inbox config).
|
||||
fn build_initial_state(config: &SequencerConfig) -> lee::V03State {
|
||||
#[cfg(not(feature = "testnet"))]
|
||||
let mut state = testnet_initial_state::initial_state();
|
||||
|
||||
#[cfg(feature = "testnet")]
|
||||
let mut state = testnet_initial_state::initial_state_testnet();
|
||||
|
||||
let genesis_txs = config
|
||||
.genesis
|
||||
.iter()
|
||||
.filter_map(|genesis_tx| match genesis_tx {
|
||||
GenesisAction::SupplyAccount {
|
||||
account_id,
|
||||
balance,
|
||||
} => Some(build_supply_account_genesis_transaction(
|
||||
account_id, *balance,
|
||||
)),
|
||||
GenesisAction::SupplyBridgeAccount { balance } => {
|
||||
Some(build_supply_bridge_account_genesis_transaction(*balance))
|
||||
}
|
||||
// Force-inserted below: bridge_lock has no mint transaction.
|
||||
GenesisAction::SupplyBridgeLockHolding { .. } => None,
|
||||
})
|
||||
.chain(std::iter::once(clock_invocation(0)))
|
||||
.inspect(|tx| {
|
||||
state
|
||||
.transition_from_public_transaction(tx, GENESIS_BLOCK_ID, 0)
|
||||
.expect("Failed to execute genesis transaction");
|
||||
})
|
||||
.map(LeeTransaction::Public)
|
||||
.collect();
|
||||
|
||||
// Seed bridge-lock holder balances directly: they are not produced by any tx.
|
||||
for action in &config.genesis {
|
||||
if let GenesisAction::SupplyBridgeLockHolding { holder, amount } = action {
|
||||
@ -679,6 +1114,41 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
|
||||
state.insert_genesis_account(config_id, config_account);
|
||||
}
|
||||
|
||||
state
|
||||
}
|
||||
|
||||
/// Builds the initial genesis state from [`build_initial_state`] plus configured
|
||||
/// genesis transactions. Returns the final state and the list of
|
||||
/// [`LeeTransaction`]s that should be committed to the genesis block so external
|
||||
/// observers can replay them.
|
||||
fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTransaction>) {
|
||||
let mut state = build_initial_state(config);
|
||||
|
||||
let genesis_txs = config
|
||||
.genesis
|
||||
.iter()
|
||||
.filter_map(|genesis_tx| match genesis_tx {
|
||||
GenesisAction::SupplyAccount {
|
||||
account_id,
|
||||
balance,
|
||||
} => Some(build_supply_account_genesis_transaction(
|
||||
account_id, *balance,
|
||||
)),
|
||||
GenesisAction::SupplyBridgeAccount { balance } => {
|
||||
Some(build_supply_bridge_account_genesis_transaction(*balance))
|
||||
}
|
||||
// Force-inserted in `build_initial_state`: bridge_lock has no mint transaction.
|
||||
GenesisAction::SupplyBridgeLockHolding { .. } => None,
|
||||
})
|
||||
.chain(std::iter::once(clock_invocation(0)))
|
||||
.inspect(|tx| {
|
||||
state
|
||||
.transition_from_public_transaction(tx, GENESIS_BLOCK_ID, 0)
|
||||
.expect("Failed to execute genesis transaction");
|
||||
})
|
||||
.map(LeeTransaction::Public)
|
||||
.collect();
|
||||
|
||||
(state, genesis_txs)
|
||||
}
|
||||
|
||||
@ -743,7 +1213,7 @@ fn pending_deposit_event_record(deposit: &DepositInfo) -> PendingDepositEventRec
|
||||
}
|
||||
|
||||
fn build_bridge_deposit_tx_from_event(event: &PendingDepositEventRecord) -> Result<LeeTransaction> {
|
||||
let metadata = DepositMetadata::decode(&event.metadata)
|
||||
let metadata = DepositMetadata::try_from_slice(&event.metadata)
|
||||
.context("Failed to decode finalized Bedrock deposit metadata")?;
|
||||
|
||||
let bridge_program_id = programs::bridge().id();
|
||||
@ -771,6 +1241,26 @@ fn build_bridge_deposit_tx_from_event(event: &PendingDepositEventRecord) -> Resu
|
||||
)))
|
||||
}
|
||||
|
||||
/// User transactions of an orphaned block to return to the mempool: everything
|
||||
/// except the trailing clock tx, sequencer-generated bridge deposits (replayed
|
||||
/// from their own bedrock events) and sequencer-only cross-zone txs (replayed
|
||||
/// by the watcher; the ingress guard rejects them as `User`).
|
||||
fn resubmittable_txs(block: &Block) -> Vec<LeeTransaction> {
|
||||
let Some((_clock, rest)) = block.body.transactions.split_last() else {
|
||||
return Vec::new();
|
||||
};
|
||||
rest.iter()
|
||||
.filter(|tx| extract_bridge_deposit_id(tx).is_none() && !is_sequencer_only_tx(tx))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn is_sequencer_only_tx(tx: &LeeTransaction) -> bool {
|
||||
matches!(tx, LeeTransaction::Public(tx)
|
||||
if is_sequencer_only_program(tx.message().program_id))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn extract_bridge_deposit_id(tx: &LeeTransaction) -> Option<HashType> {
|
||||
let LeeTransaction::Public(tx) = tx else {
|
||||
@ -860,7 +1350,7 @@ fn withdraw_event_reconciliation_key(
|
||||
}
|
||||
|
||||
/// Load signing key from file or generate a new one if it doesn't exist.
|
||||
fn load_or_create_signing_key(path: &Path) -> Result<Ed25519Key> {
|
||||
pub fn load_or_create_signing_key(path: &Path) -> Result<Ed25519Key> {
|
||||
if path.exists() {
|
||||
let key_bytes = std::fs::read(path)?;
|
||||
|
||||
|
||||
@ -2,13 +2,15 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use common::block::Block;
|
||||
use logos_blockchain_core::mantle::ops::channel::ChannelId;
|
||||
use futures::Stream;
|
||||
use logos_blockchain_core::mantle::ops::channel::{ChannelId, MsgId};
|
||||
use logos_blockchain_key_management_system_service::keys::Ed25519Key;
|
||||
use logos_blockchain_zone_sdk::sequencer::WithdrawArg;
|
||||
use logos_blockchain_zone_sdk::{Slot, ZoneMessage, sequencer::WithdrawArg};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::{
|
||||
block_publisher::{
|
||||
BlockPublisherTrait, CheckpointSink, FinalizedBlockSink, OnDepositEventSink,
|
||||
BlockPublisherTrait, CheckpointSink, FinalizedBlockSink, OnDepositEventSink, OnFollowSink,
|
||||
OnWithdrawEventSink, SequencerCheckpoint,
|
||||
},
|
||||
config::BedrockConfig,
|
||||
@ -19,6 +21,31 @@ pub type SequencerCoreWithMockClients = crate::SequencerCore<MockBlockPublisher>
|
||||
#[derive(Clone)]
|
||||
pub struct MockBlockPublisher {
|
||||
channel_id: ChannelId,
|
||||
// Never cancelled: the mock driver never dies.
|
||||
driver_cancellation: CancellationToken,
|
||||
/// Canned channel frontier returned by [`Self::channel_tip_slot`].
|
||||
tip_slot: Option<Slot>,
|
||||
/// Canned finalized channel history returned by [`Self::read_channel_after`].
|
||||
messages: Vec<(ZoneMessage, Slot)>,
|
||||
}
|
||||
|
||||
impl MockBlockPublisher {
|
||||
/// Builds a mock publisher backed by a canned channel, for reconstruction
|
||||
/// and consistency tests. The default (via [`BlockPublisherTrait::new`])
|
||||
/// serves an empty channel.
|
||||
#[must_use]
|
||||
pub fn with_canned_channel(
|
||||
channel_id: ChannelId,
|
||||
tip_slot: Option<Slot>,
|
||||
messages: Vec<(ZoneMessage, Slot)>,
|
||||
) -> Self {
|
||||
Self {
|
||||
channel_id,
|
||||
driver_cancellation: CancellationToken::new(),
|
||||
tip_slot,
|
||||
messages,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockPublisherTrait for MockBlockPublisher {
|
||||
@ -31,21 +58,53 @@ impl BlockPublisherTrait for MockBlockPublisher {
|
||||
_on_finalized_block: FinalizedBlockSink,
|
||||
_on_deposit_event: OnDepositEventSink,
|
||||
_on_withdraw_event: OnWithdrawEventSink,
|
||||
_on_follow: OnFollowSink,
|
||||
) -> Result<Self> {
|
||||
Ok(Self {
|
||||
channel_id: config.channel_id,
|
||||
driver_cancellation: CancellationToken::new(),
|
||||
tip_slot: None,
|
||||
messages: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn publish_block(
|
||||
&self,
|
||||
_block: &Block,
|
||||
block: &Block,
|
||||
_bridge_withdrawals: Vec<WithdrawArg>,
|
||||
) -> Result<()> {
|
||||
Ok(())
|
||||
) -> Result<MsgId> {
|
||||
// Deterministic per-block id so head dedup behaves in tests.
|
||||
//
|
||||
// TODO: should we allow more "mockability" here?
|
||||
Ok(MsgId::from(block.header.hash.0))
|
||||
}
|
||||
|
||||
fn channel_id(&self) -> ChannelId {
|
||||
self.channel_id
|
||||
}
|
||||
|
||||
fn is_our_turn(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn driver_cancellation(&self) -> CancellationToken {
|
||||
self.driver_cancellation.clone()
|
||||
}
|
||||
|
||||
async fn channel_tip_slot(&self) -> Result<Option<Slot>> {
|
||||
Ok(self.tip_slot)
|
||||
}
|
||||
|
||||
async fn read_channel_after(
|
||||
&self,
|
||||
after_slot: Option<Slot>,
|
||||
) -> Result<impl Stream<Item = (ZoneMessage, Slot)> + '_> {
|
||||
// Mirror `next_messages`: `after_slot` is exclusive.
|
||||
let messages = self
|
||||
.messages
|
||||
.iter()
|
||||
.filter(move |(_, slot)| after_slot.is_none_or(|after| *slot > after))
|
||||
.cloned();
|
||||
Ok(futures::stream::iter(messages))
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ use std::{pin::pin, time::Duration};
|
||||
|
||||
use common::{
|
||||
HashType,
|
||||
block::HashableBlockData,
|
||||
block::{BedrockStatus, HashableBlockData},
|
||||
test_utils::sequencer_sign_key_for_testing,
|
||||
transaction::{LeeTransaction, clock_invocation},
|
||||
};
|
||||
@ -22,21 +22,25 @@ use lee_core::{
|
||||
account::{AccountWithMetadata, Nonce},
|
||||
program::PdaSeed,
|
||||
};
|
||||
use logos_blockchain_core::mantle::ops::channel::ChannelId;
|
||||
use logos_blockchain_core::mantle::ops::channel::{ChannelId, MsgId};
|
||||
use mempool::MemPoolHandle;
|
||||
use storage::sequencer::sequencer_cells::PendingDepositEventRecord;
|
||||
use tempfile::tempdir;
|
||||
use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts};
|
||||
|
||||
use crate::{
|
||||
TransactionOrigin,
|
||||
TransactionOrigin, apply_follow_update,
|
||||
block_publisher::FollowUpdate,
|
||||
block_store::SequencerStore,
|
||||
build_genesis_state,
|
||||
build_bridge_deposit_tx_from_event, build_genesis_state,
|
||||
config::{BedrockConfig, SequencerConfig},
|
||||
is_sequencer_only_program,
|
||||
mock::SequencerCoreWithMockClients,
|
||||
resubmittable_txs,
|
||||
};
|
||||
|
||||
mod reconstruction;
|
||||
|
||||
#[derive(borsh::BorshSerialize)]
|
||||
struct DepositMetadataForEncoding {
|
||||
recipient_id: lee::AccountId,
|
||||
@ -146,14 +150,14 @@ async fn start_from_config() {
|
||||
let (sequencer, _mempool_handle) =
|
||||
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
|
||||
|
||||
assert_eq!(sequencer.chain_height, 1);
|
||||
assert_eq!(sequencer.chain_height(), 1);
|
||||
assert_eq!(sequencer.sequencer_config.max_num_tx_in_block, 10);
|
||||
|
||||
let acc1_account_id = initial_public_user_accounts()[0].account_id;
|
||||
let acc2_account_id = initial_public_user_accounts()[1].account_id;
|
||||
|
||||
let balance_acc_1 = sequencer.state.get_account_by_id(acc1_account_id).balance;
|
||||
let balance_acc_2 = sequencer.state.get_account_by_id(acc2_account_id).balance;
|
||||
let balance_acc_1 = sequencer.with_state(|s| s.get_account_by_id(acc1_account_id).balance);
|
||||
let balance_acc_2 = sequencer.with_state(|s| s.get_account_by_id(acc2_account_id).balance);
|
||||
|
||||
assert_eq!(10000, balance_acc_1);
|
||||
assert_eq!(20000, balance_acc_2);
|
||||
@ -186,7 +190,7 @@ async fn start_from_config_opens_existing_db_if_it_exists() {
|
||||
|
||||
let (sequencer, _mempool_handle) =
|
||||
SequencerCoreWithMockClients::start_from_config(config).await;
|
||||
assert_eq!(sequencer.chain_height, 1);
|
||||
assert_eq!(sequencer.chain_height(), 1);
|
||||
assert!(sequencer.store.latest_block_meta().is_ok());
|
||||
}
|
||||
|
||||
@ -295,7 +299,7 @@ async fn transaction_pre_check_native_transfer_valid() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn transaction_pre_check_native_transfer_other_signature() {
|
||||
let (mut sequencer, _mempool_handle) = common_setup().await;
|
||||
let (sequencer, _mempool_handle) = common_setup().await;
|
||||
|
||||
let acc1 = initial_public_user_accounts()[0].account_id;
|
||||
let acc2 = initial_public_user_accounts()[1].account_id;
|
||||
@ -309,7 +313,15 @@ async fn transaction_pre_check_native_transfer_other_signature() {
|
||||
let tx = tx.transaction_stateless_check().unwrap();
|
||||
|
||||
// Signature is not from sender. Execution fails
|
||||
let result = tx.execute_check_on_state(&mut sequencer.state, 0, 0);
|
||||
let result = tx.execute_check_on_state(
|
||||
sequencer
|
||||
.chain()
|
||||
.lock()
|
||||
.expect("chain mutex poisoned")
|
||||
.head_state_mut(),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
@ -319,7 +331,7 @@ async fn transaction_pre_check_native_transfer_other_signature() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn transaction_pre_check_native_transfer_sent_too_much() {
|
||||
let (mut sequencer, _mempool_handle) = common_setup().await;
|
||||
let (sequencer, _mempool_handle) = common_setup().await;
|
||||
|
||||
let acc1 = initial_public_user_accounts()[0].account_id;
|
||||
let acc2 = initial_public_user_accounts()[1].account_id;
|
||||
@ -335,9 +347,15 @@ async fn transaction_pre_check_native_transfer_sent_too_much() {
|
||||
// Passed pre-check
|
||||
assert!(result.is_ok());
|
||||
|
||||
let result = result
|
||||
.unwrap()
|
||||
.execute_check_on_state(&mut sequencer.state, 0, 0);
|
||||
let result = result.unwrap().execute_check_on_state(
|
||||
sequencer
|
||||
.chain()
|
||||
.lock()
|
||||
.expect("chain mutex poisoned")
|
||||
.head_state_mut(),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
let is_failed_at_balance_mismatch = matches!(
|
||||
result.err().unwrap(),
|
||||
lee::error::LeeError::ProgramExecutionFailed(_)
|
||||
@ -348,7 +366,7 @@ async fn transaction_pre_check_native_transfer_sent_too_much() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn transaction_execute_native_transfer() {
|
||||
let (mut sequencer, _mempool_handle) = common_setup().await;
|
||||
let (sequencer, _mempool_handle) = common_setup().await;
|
||||
|
||||
let acc1 = initial_public_user_accounts()[0].account_id;
|
||||
let acc2 = initial_public_user_accounts()[1].account_id;
|
||||
@ -359,11 +377,19 @@ async fn transaction_execute_native_transfer() {
|
||||
acc1, 0, acc2, 100, &sign_key1,
|
||||
);
|
||||
|
||||
tx.execute_check_on_state(&mut sequencer.state, 0, 0)
|
||||
.unwrap();
|
||||
tx.execute_check_on_state(
|
||||
sequencer
|
||||
.chain()
|
||||
.lock()
|
||||
.expect("chain mutex poisoned")
|
||||
.head_state_mut(),
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let bal_from = sequencer.state.get_account_by_id(acc1).balance;
|
||||
let bal_to = sequencer.state.get_account_by_id(acc2).balance;
|
||||
let bal_from = sequencer.with_state(|s| s.get_account_by_id(acc1).balance);
|
||||
let bal_to = sequencer.with_state(|s| s.get_account_by_id(acc2).balance);
|
||||
|
||||
assert_eq!(bal_from, 9900);
|
||||
assert_eq!(bal_to, 20100);
|
||||
@ -400,7 +426,7 @@ async fn push_tx_into_mempool_blocks_until_mempool_is_full() {
|
||||
#[tokio::test]
|
||||
async fn build_block_from_mempool() {
|
||||
let (mut sequencer, mempool_handle) = common_setup().await;
|
||||
let genesis_height = sequencer.chain_height;
|
||||
let genesis_height = sequencer.chain_height();
|
||||
|
||||
let tx = common::test_utils::produce_dummy_empty_transaction();
|
||||
mempool_handle
|
||||
@ -410,7 +436,8 @@ async fn build_block_from_mempool() {
|
||||
|
||||
let result = sequencer.build_block_from_mempool();
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(sequencer.chain_height, genesis_height + 1);
|
||||
// Building itself does not advance the head; only apply-after-publish does.
|
||||
assert_eq!(sequencer.chain_height(), genesis_height);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -442,7 +469,7 @@ async fn replay_transactions_are_rejected_in_the_same_block() {
|
||||
sequencer.produce_new_block().await.unwrap();
|
||||
let block = sequencer
|
||||
.store
|
||||
.get_block_at_id(sequencer.chain_height)
|
||||
.get_block_at_id(sequencer.chain_height())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
@ -477,7 +504,7 @@ async fn replay_transactions_are_rejected_in_different_blocks() {
|
||||
sequencer.produce_new_block().await.unwrap();
|
||||
let block = sequencer
|
||||
.store
|
||||
.get_block_at_id(sequencer.chain_height)
|
||||
.get_block_at_id(sequencer.chain_height())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@ -496,7 +523,7 @@ async fn replay_transactions_are_rejected_in_different_blocks() {
|
||||
sequencer.produce_new_block().await.unwrap();
|
||||
let block = sequencer
|
||||
.store
|
||||
.get_block_at_id(sequencer.chain_height)
|
||||
.get_block_at_id(sequencer.chain_height())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
// The replay is rejected, so only the clock tx is in the block.
|
||||
@ -538,7 +565,7 @@ async fn restart_from_storage() {
|
||||
sequencer.produce_new_block().await.unwrap();
|
||||
let block = sequencer
|
||||
.store
|
||||
.get_block_at_id(sequencer.chain_height)
|
||||
.get_block_at_id(sequencer.chain_height())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@ -554,8 +581,8 @@ async fn restart_from_storage() {
|
||||
// with the above transaction and update the state to reflect that.
|
||||
let (sequencer, _mempool_handle) =
|
||||
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
|
||||
let balance_acc_1 = sequencer.state.get_account_by_id(acc1_account_id).balance;
|
||||
let balance_acc_2 = sequencer.state.get_account_by_id(acc2_account_id).balance;
|
||||
let balance_acc_1 = sequencer.with_state(|s| s.get_account_by_id(acc1_account_id).balance);
|
||||
let balance_acc_2 = sequencer.with_state(|s| s.get_account_by_id(acc2_account_id).balance);
|
||||
|
||||
// Balances should be consistent with the stored block
|
||||
assert_eq!(
|
||||
@ -625,7 +652,7 @@ async fn produce_block_with_correct_prev_meta_after_restart() {
|
||||
sequencer.produce_new_block().await.unwrap();
|
||||
|
||||
// Get the metadata of the last block produced
|
||||
sequencer.store.latest_block_meta().unwrap()
|
||||
sequencer.store.latest_block_meta().unwrap().unwrap()
|
||||
};
|
||||
|
||||
// Step 2: Restart sequencer from the same storage
|
||||
@ -653,7 +680,7 @@ async fn produce_block_with_correct_prev_meta_after_restart() {
|
||||
// Step 5: Verify the new block has correct previous block metadata
|
||||
let new_block = sequencer
|
||||
.store
|
||||
.get_block_at_id(sequencer.chain_height)
|
||||
.get_block_at_id(sequencer.chain_height())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
@ -705,7 +732,7 @@ async fn transactions_touching_clock_account_are_dropped_from_block() {
|
||||
|
||||
let block = sequencer
|
||||
.store
|
||||
.get_block_at_id(sequencer.chain_height)
|
||||
.get_block_at_id(sequencer.chain_height())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
@ -760,7 +787,7 @@ async fn user_tx_that_chain_calls_clock_is_dropped() {
|
||||
|
||||
let block = sequencer
|
||||
.store
|
||||
.get_block_at_id(sequencer.chain_height)
|
||||
.get_block_at_id(sequencer.chain_height())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
@ -779,10 +806,13 @@ async fn block_production_aborts_when_clock_account_data_is_corrupted() {
|
||||
|
||||
// Corrupt the clock 01 account data so the clock program panics on deserialization.
|
||||
let clock_account_id = system_accounts::clock_account_ids()[0];
|
||||
let mut corrupted = sequencer.state.get_account_by_id(clock_account_id);
|
||||
let mut corrupted = sequencer.with_state(|s| s.get_account_by_id(clock_account_id));
|
||||
corrupted.data = vec![0xff; 3].try_into().unwrap();
|
||||
sequencer
|
||||
.state
|
||||
.chain()
|
||||
.lock()
|
||||
.expect("chain mutex poisoned")
|
||||
.head_state_mut()
|
||||
.force_insert_account(clock_account_id, corrupted);
|
||||
|
||||
// Push a dummy transaction so the mempool is non-empty.
|
||||
@ -862,6 +892,7 @@ fn private_bridge_withdraw_invocation_is_dropped() {
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: sender_keys.viewing_public_key.clone(),
|
||||
random_seed: [0; 32],
|
||||
view_tag: 0,
|
||||
nsk: sender_keys.private_key_holder.nullifier_secret_key,
|
||||
membership_proof: state
|
||||
.get_proof_for_commitment(&sender_commitment)
|
||||
@ -1245,3 +1276,509 @@ fn pda_mechanism_with_pinata_token_program() {
|
||||
expected_winner_token_holding_post
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resubmittable_txs_drops_clock_and_bridge_deposits() {
|
||||
let user_tx = common::test_utils::produce_dummy_empty_transaction();
|
||||
let deposit_tx = build_bridge_deposit_tx_from_event(&PendingDepositEventRecord {
|
||||
deposit_op_id: HashType([13; 32]),
|
||||
source_tx_hash: HashType([7; 32]),
|
||||
amount: 1,
|
||||
metadata: borsh::to_vec(&DepositMetadataForEncoding {
|
||||
recipient_id: initial_public_user_accounts()[0].account_id,
|
||||
})
|
||||
.unwrap(),
|
||||
submitted_in_block_id: None,
|
||||
})
|
||||
.unwrap();
|
||||
let withdraw_tx = {
|
||||
let message = lee::public_transaction::Message::try_new(
|
||||
programs::bridge().id(),
|
||||
vec![system_accounts::bridge_account_id()],
|
||||
vec![],
|
||||
bridge_core::Instruction::Withdraw {
|
||||
amount: 1,
|
||||
bedrock_account_pk: [0; 32],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
LeeTransaction::Public(PublicTransaction::new(
|
||||
message,
|
||||
lee::public_transaction::WitnessSet::from_raw_parts(vec![]),
|
||||
))
|
||||
};
|
||||
|
||||
let block = common::test_utils::produce_dummy_block(
|
||||
2,
|
||||
Some(HashType([1; 32])),
|
||||
vec![user_tx.clone(), deposit_tx, withdraw_tx.clone()],
|
||||
);
|
||||
|
||||
// The trailing clock tx and the sequencer-generated deposit are dropped;
|
||||
// user txs (withdrawals included) are returned.
|
||||
assert_eq!(resubmittable_txs(&block), vec![user_tx, withdraw_tx]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resubmittable_txs_of_blocks_without_user_txs_is_empty() {
|
||||
// No transactions at all (not even the mandatory clock tx).
|
||||
let empty = HashableBlockData {
|
||||
block_id: 1,
|
||||
prev_block_hash: HashType([0; 32]),
|
||||
timestamp: 0,
|
||||
transactions: vec![],
|
||||
}
|
||||
.into_pending_block(&sequencer_sign_key_for_testing());
|
||||
assert!(resubmittable_txs(&empty).is_empty());
|
||||
|
||||
let clock_only = common::test_utils::produce_dummy_block(1, None, vec![]);
|
||||
assert!(resubmittable_txs(&clock_only).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_adopted_peer_block_applies_and_persists() {
|
||||
let config = setup_sequencer_config();
|
||||
let (sequencer, mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await;
|
||||
let genesis_meta = sequencer
|
||||
.store
|
||||
.latest_block_meta()
|
||||
.unwrap()
|
||||
.expect("genesis meta is set");
|
||||
|
||||
let acc1 = initial_public_user_accounts()[0].account_id;
|
||||
let acc2 = initial_public_user_accounts()[1].account_id;
|
||||
let tx = common::test_utils::create_transaction_native_token_transfer(
|
||||
acc1,
|
||||
0,
|
||||
acc2,
|
||||
10,
|
||||
&create_signing_key_for_account1(),
|
||||
);
|
||||
let peer_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![tx]);
|
||||
|
||||
apply_follow_update(
|
||||
&sequencer.store.dbio(),
|
||||
&sequencer.chain(),
|
||||
&mempool_handle,
|
||||
FollowUpdate {
|
||||
adopted: vec![(MsgId::from([1; 32]), peer_block.clone())],
|
||||
orphaned: vec![],
|
||||
finalized: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(sequencer.chain_height(), 2);
|
||||
let stored = sequencer
|
||||
.store
|
||||
.get_block_at_id(2)
|
||||
.unwrap()
|
||||
.expect("adopted peer block should be persisted");
|
||||
assert_eq!(stored.header.hash, peer_block.header.hash);
|
||||
assert_eq!(
|
||||
sequencer.with_state(|s| s.get_account_by_id(acc2).balance),
|
||||
20010
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_redelivery_of_own_block_is_deduped() {
|
||||
let config = setup_sequencer_config();
|
||||
let (mut sequencer, mempool_handle) =
|
||||
SequencerCoreWithMockClients::start_from_config(config).await;
|
||||
|
||||
let acc1 = initial_public_user_accounts()[0].account_id;
|
||||
let acc2 = initial_public_user_accounts()[1].account_id;
|
||||
let tx = common::test_utils::create_transaction_native_token_transfer(
|
||||
acc1,
|
||||
0,
|
||||
acc2,
|
||||
10,
|
||||
&create_signing_key_for_account1(),
|
||||
);
|
||||
mempool_handle
|
||||
.push((TransactionOrigin::User, tx))
|
||||
.await
|
||||
.unwrap();
|
||||
sequencer.produce_new_block().await.unwrap();
|
||||
let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap();
|
||||
|
||||
// The channel redelivers our own block under the MsgId the mock publisher
|
||||
// assigned at publish time.
|
||||
apply_follow_update(
|
||||
&sequencer.store.dbio(),
|
||||
&sequencer.chain(),
|
||||
&mempool_handle,
|
||||
FollowUpdate {
|
||||
adopted: vec![(MsgId::from(block2.header.hash.0), block2)],
|
||||
orphaned: vec![],
|
||||
finalized: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(sequencer.chain_height(), 2);
|
||||
assert_eq!(
|
||||
sequencer.with_state(|s| s.get_account_by_id(acc2).balance),
|
||||
20010,
|
||||
"the transfer must not be double-applied"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_orphan_reverts_head_and_requeues_user_txs() {
|
||||
let config = setup_sequencer_config();
|
||||
let (mut sequencer, mempool_handle) =
|
||||
SequencerCoreWithMockClients::start_from_config(config).await;
|
||||
|
||||
let acc1 = initial_public_user_accounts()[0].account_id;
|
||||
let acc2 = initial_public_user_accounts()[1].account_id;
|
||||
let tx = common::test_utils::create_transaction_native_token_transfer(
|
||||
acc1,
|
||||
0,
|
||||
acc2,
|
||||
10,
|
||||
&create_signing_key_for_account1(),
|
||||
);
|
||||
mempool_handle
|
||||
.push((TransactionOrigin::User, tx.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
sequencer.produce_new_block().await.unwrap();
|
||||
let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap();
|
||||
|
||||
apply_follow_update(
|
||||
&sequencer.store.dbio(),
|
||||
&sequencer.chain(),
|
||||
&mempool_handle,
|
||||
FollowUpdate {
|
||||
adopted: vec![],
|
||||
orphaned: vec![(MsgId::from(block2.header.hash.0), block2)],
|
||||
finalized: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(sequencer.chain_height(), 1);
|
||||
assert_eq!(
|
||||
sequencer.with_state(|s| s.get_account_by_id(acc1).balance),
|
||||
10000,
|
||||
"the orphaned transfer must be reverted from the head"
|
||||
);
|
||||
let (origin, requeued) = sequencer
|
||||
.mempool
|
||||
.pop()
|
||||
.expect("orphaned user tx should be requeued");
|
||||
assert!(matches!(origin, TransactionOrigin::User));
|
||||
assert_eq!(requeued, tx);
|
||||
assert!(
|
||||
sequencer.mempool.pop().is_none(),
|
||||
"the clock tx must not be requeued"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_finalized_own_block_moves_final_tier_and_marks_store() {
|
||||
let config = setup_sequencer_config();
|
||||
let (mut sequencer, mempool_handle) =
|
||||
SequencerCoreWithMockClients::start_from_config(config).await;
|
||||
|
||||
let tx = common::test_utils::produce_dummy_empty_transaction();
|
||||
mempool_handle
|
||||
.push((TransactionOrigin::User, tx))
|
||||
.await
|
||||
.unwrap();
|
||||
sequencer.produce_new_block().await.unwrap();
|
||||
let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap();
|
||||
|
||||
apply_follow_update(
|
||||
&sequencer.store.dbio(),
|
||||
&sequencer.chain(),
|
||||
&mempool_handle,
|
||||
FollowUpdate {
|
||||
adopted: vec![],
|
||||
orphaned: vec![],
|
||||
finalized: vec![(MsgId::from(block2.header.hash.0), block2)],
|
||||
},
|
||||
);
|
||||
|
||||
let final_tip = sequencer
|
||||
.chain()
|
||||
.lock()
|
||||
.expect("chain mutex poisoned")
|
||||
.final_tip()
|
||||
.expect("final tip set");
|
||||
assert_eq!(final_tip.block_id, 2);
|
||||
assert_eq!(sequencer.chain_height(), 2, "head is unchanged");
|
||||
let stored = sequencer.store.get_block_at_id(2).unwrap().unwrap();
|
||||
assert!(matches!(stored.bedrock_status, BedrockStatus::Finalized));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_finalized_backfill_block_is_applied_and_marked_finalized() {
|
||||
let config = setup_sequencer_config();
|
||||
let (sequencer, mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await;
|
||||
let genesis_meta = sequencer
|
||||
.store
|
||||
.latest_block_meta()
|
||||
.unwrap()
|
||||
.expect("genesis meta is set");
|
||||
|
||||
// A peer block we never saw as adopted arrives straight from the
|
||||
// finalized (backfill) stream.
|
||||
let peer_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![]);
|
||||
|
||||
apply_follow_update(
|
||||
&sequencer.store.dbio(),
|
||||
&sequencer.chain(),
|
||||
&mempool_handle,
|
||||
FollowUpdate {
|
||||
adopted: vec![],
|
||||
orphaned: vec![],
|
||||
finalized: vec![(MsgId::from([2; 32]), peer_block.clone())],
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
sequencer.chain_height(),
|
||||
2,
|
||||
"head mirrors final on backfill"
|
||||
);
|
||||
let stored = sequencer
|
||||
.store
|
||||
.get_block_at_id(2)
|
||||
.unwrap()
|
||||
.expect("backfilled block should be persisted");
|
||||
assert_eq!(stored.header.hash, peer_block.header.hash);
|
||||
assert!(matches!(stored.bedrock_status, BedrockStatus::Finalized));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restart_restores_head_tier_and_recovers_from_orphan() {
|
||||
let config = setup_sequencer_config();
|
||||
let acc1 = initial_public_user_accounts()[0].account_id;
|
||||
let acc2 = initial_public_user_accounts()[1].account_id;
|
||||
|
||||
// Produce block 2 (a user transfer), then "crash" before it finalizes.
|
||||
let (tx, block2) = {
|
||||
let (mut sequencer, mempool_handle) =
|
||||
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
|
||||
let tx = common::test_utils::create_transaction_native_token_transfer(
|
||||
acc1,
|
||||
0,
|
||||
acc2,
|
||||
10,
|
||||
&create_signing_key_for_account1(),
|
||||
);
|
||||
mempool_handle
|
||||
.push((TransactionOrigin::User, tx.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
sequencer.produce_new_block().await.unwrap();
|
||||
(tx, sequencer.store.get_block_at_id(2).unwrap().unwrap())
|
||||
};
|
||||
|
||||
// Restart: nothing is finalized, so block 2 must come back as *head*, not
|
||||
// final — the L1 can still orphan it.
|
||||
let (mut sequencer, mempool_handle) =
|
||||
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
|
||||
assert_eq!(sequencer.chain_height(), 2);
|
||||
|
||||
// The L1 orphans block 2 under its real MsgId (which we never persisted)
|
||||
// and adopts a competing empty block 2'.
|
||||
let genesis = sequencer.store.get_block_at_id(1).unwrap().unwrap();
|
||||
let block2_prime =
|
||||
common::test_utils::produce_dummy_block(2, Some(genesis.header.hash), vec![]);
|
||||
apply_follow_update(
|
||||
&sequencer.store.dbio(),
|
||||
&sequencer.chain(),
|
||||
&mempool_handle,
|
||||
FollowUpdate {
|
||||
adopted: vec![(MsgId::from([21; 32]), block2_prime.clone())],
|
||||
orphaned: vec![(MsgId::from([20; 32]), block2)],
|
||||
finalized: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
// The head reorged onto 2': transfer reverted, store overwritten, and the
|
||||
// orphaned user tx returned to the mempool.
|
||||
assert_eq!(sequencer.chain_height(), 2);
|
||||
let head_tip = sequencer
|
||||
.chain()
|
||||
.lock()
|
||||
.expect("chain mutex poisoned")
|
||||
.head_tip()
|
||||
.expect("head tip set");
|
||||
assert_eq!(head_tip.hash, block2_prime.header.hash);
|
||||
assert_eq!(
|
||||
sequencer.with_state(|s| s.get_account_by_id(acc1).balance),
|
||||
10000,
|
||||
"the orphaned transfer must be reverted"
|
||||
);
|
||||
let stored = sequencer.store.get_block_at_id(2).unwrap().unwrap();
|
||||
assert_eq!(stored.header.hash, block2_prime.header.hash);
|
||||
let (origin, requeued) = sequencer
|
||||
.mempool
|
||||
.pop()
|
||||
.expect("orphaned user tx should be requeued");
|
||||
assert!(matches!(origin, TransactionOrigin::User));
|
||||
assert_eq!(requeued, tx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restart_reanchors_on_the_persisted_final_snapshot() {
|
||||
let config = setup_sequencer_config();
|
||||
|
||||
// Produce block 2 and follow its finalization, which persists the final
|
||||
// snapshot; then "crash".
|
||||
{
|
||||
let (mut sequencer, mempool_handle) =
|
||||
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
|
||||
let tx = common::test_utils::produce_dummy_empty_transaction();
|
||||
mempool_handle
|
||||
.push((TransactionOrigin::User, tx))
|
||||
.await
|
||||
.unwrap();
|
||||
sequencer.produce_new_block().await.unwrap();
|
||||
let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap();
|
||||
apply_follow_update(
|
||||
&sequencer.store.dbio(),
|
||||
&sequencer.chain(),
|
||||
&mempool_handle,
|
||||
FollowUpdate {
|
||||
adopted: vec![],
|
||||
orphaned: vec![],
|
||||
finalized: vec![(MsgId::from(block2.header.hash.0), block2)],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Restart: the final tier re-anchors on the snapshot instead of treating
|
||||
// the whole stored chain as final.
|
||||
let (sequencer, _mempool_handle) =
|
||||
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
|
||||
let chain = sequencer.chain();
|
||||
let chain = chain.lock().expect("chain mutex poisoned");
|
||||
assert_eq!(chain.final_tip().expect("final tip set").block_id, 2);
|
||||
assert_eq!(chain.head_tip().expect("head tip set").block_id, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_produced_block_skips_persistence_on_lost_race() {
|
||||
let config = setup_sequencer_config();
|
||||
let (mut sequencer, _mempool_handle) =
|
||||
SequencerCoreWithMockClients::start_from_config(config).await;
|
||||
let genesis_meta = sequencer
|
||||
.store
|
||||
.latest_block_meta()
|
||||
.unwrap()
|
||||
.expect("genesis meta is set");
|
||||
|
||||
// A peer block wins height 2 while "our" block is in flight.
|
||||
let peer_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![]);
|
||||
sequencer
|
||||
.chain()
|
||||
.lock()
|
||||
.expect("chain mutex poisoned")
|
||||
.apply_adopted(MsgId::from([9; 32]), &peer_block);
|
||||
|
||||
// Our competing block at the same height: same parent, different content.
|
||||
let acc1 = initial_public_user_accounts()[0].account_id;
|
||||
let acc2 = initial_public_user_accounts()[1].account_id;
|
||||
let tx = common::test_utils::create_transaction_native_token_transfer(
|
||||
acc1,
|
||||
0,
|
||||
acc2,
|
||||
10,
|
||||
&create_signing_key_for_account1(),
|
||||
);
|
||||
let our_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![tx]);
|
||||
sequencer
|
||||
.record_produced_block(
|
||||
MsgId::from(our_block.header.hash.0),
|
||||
&our_block,
|
||||
&[],
|
||||
vec![],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// The lost-race block must not reach the store; the head keeps the peer block.
|
||||
assert!(sequencer.store.get_block_at_id(2).unwrap().is_none());
|
||||
let head_tip = sequencer
|
||||
.chain()
|
||||
.lock()
|
||||
.expect("chain mutex poisoned")
|
||||
.head_tip()
|
||||
.expect("head tip");
|
||||
assert_eq!(head_tip.hash, peer_block.header.hash);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_produced_block_skips_persistence_when_block_no_longer_chains() {
|
||||
let config = setup_sequencer_config();
|
||||
let (mut sequencer, _mempool_handle) =
|
||||
SequencerCoreWithMockClients::start_from_config(config).await;
|
||||
|
||||
// The head reorged under us: our block's parent is no longer the tip.
|
||||
let stale = common::test_utils::produce_dummy_block(2, Some(HashType([9; 32])), vec![]);
|
||||
sequencer
|
||||
.record_produced_block(MsgId::from(stale.header.hash.0), &stale, &[], vec![])
|
||||
.unwrap();
|
||||
|
||||
assert!(sequencer.store.get_block_at_id(2).unwrap().is_none());
|
||||
assert_eq!(sequencer.chain_height(), 1, "head is unchanged");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_update_persists_blocks_meta_and_state_atomically() {
|
||||
let config = setup_sequencer_config();
|
||||
let (sequencer, mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await;
|
||||
let genesis_meta = sequencer
|
||||
.store
|
||||
.latest_block_meta()
|
||||
.unwrap()
|
||||
.expect("genesis meta is set");
|
||||
|
||||
let acc1 = initial_public_user_accounts()[0].account_id;
|
||||
let acc2 = initial_public_user_accounts()[1].account_id;
|
||||
let tx = common::test_utils::create_transaction_native_token_transfer(
|
||||
acc1,
|
||||
0,
|
||||
acc2,
|
||||
10,
|
||||
&create_signing_key_for_account1(),
|
||||
);
|
||||
let block2 = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![tx]);
|
||||
let block3 = common::test_utils::produce_dummy_block(3, Some(block2.header.hash), vec![]);
|
||||
|
||||
// One update carrying several blocks: both adopted, block 2 also finalized.
|
||||
apply_follow_update(
|
||||
&sequencer.store.dbio(),
|
||||
&sequencer.chain(),
|
||||
&mempool_handle,
|
||||
FollowUpdate {
|
||||
adopted: vec![
|
||||
(MsgId::from([2; 32]), block2.clone()),
|
||||
(MsgId::from([3; 32]), block3.clone()),
|
||||
],
|
||||
orphaned: vec![],
|
||||
finalized: vec![(MsgId::from([2; 32]), block2)],
|
||||
},
|
||||
);
|
||||
|
||||
// Blocks, tip meta and state all reflect the end of the batch: a late
|
||||
// finalized entry for an earlier block must not drag the tip meta back.
|
||||
let meta = sequencer
|
||||
.store
|
||||
.latest_block_meta()
|
||||
.unwrap()
|
||||
.expect("meta is set");
|
||||
assert_eq!(meta.id, 3);
|
||||
assert_eq!(meta.hash, block3.header.hash);
|
||||
let stored2 = sequencer.store.get_block_at_id(2).unwrap().unwrap();
|
||||
assert!(matches!(stored2.bedrock_status, BedrockStatus::Finalized));
|
||||
let stored_balance = sequencer
|
||||
.store
|
||||
.get_lee_state()
|
||||
.unwrap()
|
||||
.get_account_by_id(acc2)
|
||||
.balance;
|
||||
assert_eq!(stored_balance, 20010);
|
||||
}
|
||||
|
||||
610
lez/sequencer/core/src/tests/reconstruction.rs
Normal file
610
lez/sequencer/core/src/tests/reconstruction.rs
Normal file
@ -0,0 +1,610 @@
|
||||
#![expect(
|
||||
clippy::arithmetic_side_effects,
|
||||
clippy::as_conversions,
|
||||
reason = "We don't care about it in tests"
|
||||
)]
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use chain_state::ChainState;
|
||||
use common::block::Block;
|
||||
use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription};
|
||||
use logos_blockchain_zone_sdk::{Slot, ZoneBlock, ZoneMessage};
|
||||
use storage::sequencer::sequencer_cells::ZoneAnchorRecord;
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
SequencerCore, block_store::SequencerStore, config::GenesisAction, mock::MockBlockPublisher,
|
||||
};
|
||||
|
||||
/// Fresh `(store, chain)` pair for a reconstruction target, as
|
||||
/// `start_from_config` would build them before the publisher starts.
|
||||
fn fresh_store_and_chain(config: &SequencerConfig) -> (SequencerStore, Mutex<ChainState>) {
|
||||
let (store, state) = SequencerCore::<MockBlockPublisher>::open_or_create_store(config);
|
||||
let chain = Mutex::new(SequencerCore::<MockBlockPublisher>::restore_chain_state(
|
||||
config, &store, &state,
|
||||
));
|
||||
(store, chain)
|
||||
}
|
||||
|
||||
fn block_to_channel_message(block: &Block, slot: u64) -> (ZoneMessage, Slot) {
|
||||
let bytes = borsh::to_vec(block).expect("serialize block");
|
||||
let message = ZoneMessage::Block(ZoneBlock {
|
||||
id: MsgId::from([0_u8; 32]),
|
||||
data: Inscription::try_from(bytes.as_slice()).expect("inscription"),
|
||||
});
|
||||
(message, Slot::from(slot))
|
||||
}
|
||||
|
||||
/// Collects a sequencer's whole chain (genesis..=tip) into a canned channel,
|
||||
/// one block per slot at `slot_step` spacing.
|
||||
fn channel_from_store(store: &SequencerStore, slot_step: u64) -> Vec<(ZoneMessage, Slot)> {
|
||||
let genesis_id = store.genesis_id();
|
||||
let tip_id = store.latest_block_meta().expect("tip").expect("present").id;
|
||||
(genesis_id..=tip_id)
|
||||
.enumerate()
|
||||
.map(|(index, id)| {
|
||||
let block = store.get_block_at_id(id).expect("read").expect("present");
|
||||
block_to_channel_message(&block, (index as u64 + 1) * slot_step)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconstructs_missing_channel_blocks_into_fresh_store() {
|
||||
// Sequencer A produces a few blocks; treat its chain as the channel.
|
||||
let config_a = setup_sequencer_config();
|
||||
let (mut seq_a, _handle_a) =
|
||||
SequencerCoreWithMockClients::start_from_config(config_a.clone()).await;
|
||||
seq_a.produce_new_block().await.unwrap();
|
||||
seq_a.produce_new_block().await.unwrap();
|
||||
let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap();
|
||||
|
||||
let messages = channel_from_store(seq_a.block_store(), 10);
|
||||
let tip_slot = messages.last().unwrap().1;
|
||||
let channel_id = config_a.bedrock_config.channel_id;
|
||||
|
||||
// Sequencer B starts from a fresh store and reconstructs A's chain.
|
||||
let config_b = setup_sequencer_config();
|
||||
let (store_b, chain_b) = fresh_store_and_chain(&config_b);
|
||||
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
|
||||
|
||||
let channel_was_empty = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
||||
&mock_b, &store_b, &chain_b, true,
|
||||
)
|
||||
.await
|
||||
.expect("reconstruct");
|
||||
assert!(!channel_was_empty);
|
||||
|
||||
let tip_b = store_b.latest_block_meta().unwrap().unwrap();
|
||||
assert_eq!(tip_b.id, tip_a.id);
|
||||
assert_eq!(tip_b.hash, tip_a.hash);
|
||||
|
||||
// State matches: initial account balances agree with sequencer A.
|
||||
let state_b = chain_b.lock().unwrap().head_state().clone();
|
||||
let state_a = seq_a.chain().lock().unwrap().head_state().clone();
|
||||
for account in initial_public_user_accounts() {
|
||||
assert_eq!(
|
||||
state_b.get_account_by_id(account.account_id).balance,
|
||||
state_a.get_account_by_id(account.account_id).balance,
|
||||
);
|
||||
}
|
||||
|
||||
let anchor = store_b.get_zone_anchor().unwrap().expect("anchor recorded");
|
||||
assert_eq!(anchor.block_id, tip_a.id);
|
||||
assert_eq!(anchor.slot, tip_slot.into_inner());
|
||||
|
||||
// Re-running is idempotent: everything is already applied, no error.
|
||||
let channel_was_empty = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
||||
&mock_b, &store_b, &chain_b, true,
|
||||
)
|
||||
.await
|
||||
.expect("reconstruct idempotent");
|
||||
assert!(!channel_was_empty);
|
||||
assert_eq!(store_b.latest_block_meta().unwrap().unwrap().id, tip_a.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_channel_serves_a_divergent_block() {
|
||||
let config = setup_sequencer_config();
|
||||
let (store, chain) = fresh_store_and_chain(&config);
|
||||
|
||||
// Anchor on the local genesis at some slot.
|
||||
let genesis_id = store.genesis_id();
|
||||
let genesis = store.get_block_at_id(genesis_id).unwrap().unwrap();
|
||||
let anchor_slot = 100_u64;
|
||||
store
|
||||
.set_zone_anchor(&ZoneAnchorRecord {
|
||||
slot: anchor_slot,
|
||||
block_id: genesis_id,
|
||||
hash: genesis.header.hash,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// The channel serves a different block at the anchor id/slot.
|
||||
let mut tampered = genesis.clone();
|
||||
tampered.header.hash = HashType([9_u8; 32]);
|
||||
let messages = vec![block_to_channel_message(&tampered, anchor_slot)];
|
||||
let mock = MockBlockPublisher::with_canned_channel(
|
||||
config.bedrock_config.channel_id,
|
||||
Some(Slot::from(anchor_slot)),
|
||||
messages,
|
||||
);
|
||||
|
||||
let result =
|
||||
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, true)
|
||||
.await;
|
||||
assert!(result.is_err(), "divergent channel must abort startup");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_channel_is_missing() {
|
||||
let config = setup_sequencer_config();
|
||||
let (store, chain) = fresh_store_and_chain(&config);
|
||||
let genesis_id = store.genesis_id();
|
||||
let genesis = store.get_block_at_id(genesis_id).unwrap().unwrap();
|
||||
store
|
||||
.set_zone_anchor(&ZoneAnchorRecord {
|
||||
slot: 100,
|
||||
block_id: genesis_id,
|
||||
hash: genesis.header.hash,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Anchor present, but the channel does not exist on the connected chain.
|
||||
let mock =
|
||||
MockBlockPublisher::with_canned_channel(config.bedrock_config.channel_id, None, vec![]);
|
||||
let result =
|
||||
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, true)
|
||||
.await;
|
||||
assert!(result.is_err(), "missing channel must abort startup");
|
||||
}
|
||||
|
||||
// The following cases exercise the divergence branches of
|
||||
// `apply_reconstructed_block` reached with no recorded anchor, so the block's own
|
||||
// validation fires rather than the up-front `AnchorConsistencyCheck`.
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_channel_reinscribes_genesis_with_a_different_hash() {
|
||||
let config = setup_sequencer_config();
|
||||
let (store, chain) = fresh_store_and_chain(&config);
|
||||
|
||||
// Fresh store, no anchor. The channel serves a genesis at the same id but a
|
||||
// different hash — a foreign chain reinscribing genesis.
|
||||
let mut reinscribed = store.get_block_at_id(store.genesis_id()).unwrap().unwrap();
|
||||
reinscribed.header.hash = HashType([0xAB_u8; 32]);
|
||||
|
||||
let messages = vec![block_to_channel_message(&reinscribed, 10)];
|
||||
let mock = MockBlockPublisher::with_canned_channel(
|
||||
config.bedrock_config.channel_id,
|
||||
Some(Slot::from(10)),
|
||||
messages,
|
||||
);
|
||||
let result =
|
||||
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, true)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"a reinscribed genesis with a different hash must abort startup"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_a_stored_block_hash_diverges_from_the_channel() {
|
||||
// A sequencer that committed blocks past genesis but never recorded an anchor.
|
||||
let config = setup_sequencer_config();
|
||||
let (mut seq, _handle) = SequencerCoreWithMockClients::start_from_config(config.clone()).await;
|
||||
seq.produce_new_block().await.unwrap();
|
||||
seq.produce_new_block().await.unwrap();
|
||||
|
||||
// A below-tip block re-served with a corrupted hash: we already hold this id
|
||||
// with a different hash, so the channel is a different chain.
|
||||
let below_tip_id = seq.block_store().genesis_id() + 1;
|
||||
let mut block = seq
|
||||
.block_store()
|
||||
.get_block_at_id(below_tip_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
block.header.hash = HashType([0xCD_u8; 32]);
|
||||
|
||||
let messages = vec![block_to_channel_message(&block, 10)];
|
||||
let mock = MockBlockPublisher::with_canned_channel(
|
||||
config.bedrock_config.channel_id,
|
||||
Some(Slot::from(10)),
|
||||
messages,
|
||||
);
|
||||
let result = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
||||
&mock, &seq.store, &seq.chain, true,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"a diverging below-tip block hash must abort startup"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_a_channel_block_is_missing_locally() {
|
||||
let config = setup_sequencer_config();
|
||||
let (store, chain) = fresh_store_and_chain(&config);
|
||||
|
||||
// A block numbered below our genesis is at/below the local tip yet absent from
|
||||
// the store — a foreign chain with a lower numbering.
|
||||
let mut foreign = store.get_block_at_id(store.genesis_id()).unwrap().unwrap();
|
||||
foreign.header.block_id = store.genesis_id() - 1;
|
||||
|
||||
let messages = vec![block_to_channel_message(&foreign, 10)];
|
||||
let mock = MockBlockPublisher::with_canned_channel(
|
||||
config.bedrock_config.channel_id,
|
||||
Some(Slot::from(10)),
|
||||
messages,
|
||||
);
|
||||
let result =
|
||||
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, true)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"a channel block below the local range must abort startup"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_a_channel_block_does_not_extend_the_tip() {
|
||||
let config = setup_sequencer_config();
|
||||
let (store, chain) = fresh_store_and_chain(&config);
|
||||
|
||||
// A block claiming an id far past genesis does not chain onto the local tip.
|
||||
let mut orphan = store.get_block_at_id(store.genesis_id()).unwrap().unwrap();
|
||||
orphan.header.block_id = store.genesis_id() + 5;
|
||||
|
||||
let messages = vec![block_to_channel_message(&orphan, 10)];
|
||||
let mock = MockBlockPublisher::with_canned_channel(
|
||||
config.bedrock_config.channel_id,
|
||||
Some(Slot::from(10)),
|
||||
messages,
|
||||
);
|
||||
let result =
|
||||
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, true)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"a non-contiguous channel block must abort startup"
|
||||
);
|
||||
}
|
||||
|
||||
/// A sequencer config whose genesis funds the bridge account, so replayed bridge
|
||||
/// deposit transactions have a source balance to mint from.
|
||||
fn bridge_funded_config() -> SequencerConfig {
|
||||
let mut config = setup_sequencer_config();
|
||||
config.genesis = vec![GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }];
|
||||
config
|
||||
}
|
||||
|
||||
/// Builds an unfulfilled pending deposit event for `recipient`, matching the
|
||||
/// encoding `build_bridge_deposit_tx_from_event` expects.
|
||||
fn deposit_event_record(
|
||||
op_id: [u8; 32],
|
||||
amount: u64,
|
||||
recipient: lee::AccountId,
|
||||
) -> PendingDepositEventRecord {
|
||||
PendingDepositEventRecord {
|
||||
deposit_op_id: HashType(op_id),
|
||||
source_tx_hash: HashType([0_u8; 32]),
|
||||
amount,
|
||||
metadata: borsh::to_vec(&DepositMetadataForEncoding {
|
||||
recipient_id: recipient,
|
||||
})
|
||||
.unwrap(),
|
||||
submitted_in_block_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a signed public bridge `Withdraw` transaction (the normal user path).
|
||||
fn build_public_withdraw_tx(
|
||||
sender: lee::AccountId,
|
||||
nonce: u128,
|
||||
amount: u64,
|
||||
bedrock_account_pk: [u8; 32],
|
||||
signing_key: &lee::PrivateKey,
|
||||
) -> LeeTransaction {
|
||||
let message = lee::public_transaction::Message::try_new(
|
||||
programs::bridge().id(),
|
||||
vec![sender, system_accounts::bridge_account_id()],
|
||||
vec![nonce.into()],
|
||||
bridge_core::Instruction::Withdraw {
|
||||
amount,
|
||||
bedrock_account_pk,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &[signing_key]);
|
||||
LeeTransaction::Public(lee::PublicTransaction::new(message, witness_set))
|
||||
}
|
||||
|
||||
/// The cold-start backfill re-delivers an already-finalized deposit into the
|
||||
/// mempool before reconstruction applies the same deposit block, and the queued
|
||||
/// mint cannot be pulled back out. Since the bridge program does not dedup on
|
||||
/// `l1_deposit_op_id`, block production must skip the already-submitted deposit
|
||||
/// so the vault is minted exactly once.
|
||||
#[tokio::test]
|
||||
async fn reconstructed_deposit_is_not_reminted_after_backfill_redelivery() {
|
||||
let recipient = initial_public_user_accounts()[0].account_id;
|
||||
let deposit_amount = 500_u64;
|
||||
let withdraw_amount = 100_u64;
|
||||
let bedrock_account_pk = [0x22_u8; 32];
|
||||
let deposit_op_id = [0x0d_u8; 32];
|
||||
|
||||
// Sequencer A produces a deposit block then a withdraw block.
|
||||
let config_a = bridge_funded_config();
|
||||
let (mut seq_a, mempool_a) =
|
||||
SequencerCoreWithMockClients::start_from_config(config_a.clone()).await;
|
||||
|
||||
let deposit_record = deposit_event_record(deposit_op_id, deposit_amount, recipient);
|
||||
let deposit_tx =
|
||||
crate::build_bridge_deposit_tx_from_event(&deposit_record).expect("build deposit tx");
|
||||
mempool_a
|
||||
.push((TransactionOrigin::Sequencer, deposit_tx.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
seq_a.produce_new_block().await.unwrap();
|
||||
|
||||
let withdraw_tx = build_public_withdraw_tx(
|
||||
recipient,
|
||||
0,
|
||||
withdraw_amount,
|
||||
bedrock_account_pk,
|
||||
&create_signing_key_for_account1(),
|
||||
);
|
||||
mempool_a
|
||||
.push((TransactionOrigin::User, withdraw_tx.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
seq_a.produce_new_block().await.unwrap();
|
||||
|
||||
let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap();
|
||||
let messages = channel_from_store(seq_a.block_store(), 10);
|
||||
let tip_slot = messages.last().unwrap().1;
|
||||
let channel_id = config_a.bedrock_config.channel_id;
|
||||
|
||||
let config_b = bridge_funded_config();
|
||||
let (mut seq_b, mempool_b) = SequencerCoreWithMockClients::start_from_config(config_b).await;
|
||||
|
||||
// Backfill re-delivery: persist the pending record and queue the mint, as
|
||||
// `on_deposit_event` does, before reconstruction runs.
|
||||
assert!(
|
||||
seq_b
|
||||
.block_store()
|
||||
.dbio()
|
||||
.add_pending_deposit_event(deposit_record.clone())
|
||||
.unwrap()
|
||||
);
|
||||
mempool_b
|
||||
.push((TransactionOrigin::Sequencer, deposit_tx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
|
||||
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
||||
&mock_b,
|
||||
&seq_b.store,
|
||||
&seq_b.chain,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect("reconstruct");
|
||||
|
||||
let tip_b = seq_b.block_store().latest_block_meta().unwrap().unwrap();
|
||||
assert_eq!(tip_b.id, tip_a.id);
|
||||
assert_eq!(tip_b.hash, tip_a.hash);
|
||||
|
||||
seq_b.produce_new_block().await.unwrap();
|
||||
|
||||
let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient);
|
||||
let bridge_id = system_accounts::bridge_account_id();
|
||||
let state_b = seq_b.chain().lock().unwrap().head_state().clone();
|
||||
let state_a = seq_a.chain().lock().unwrap().head_state().clone();
|
||||
for account in [vault_id, bridge_id, recipient] {
|
||||
assert_eq!(
|
||||
state_b.get_account_by_id(account).balance,
|
||||
state_a.get_account_by_id(account).balance,
|
||||
"reconstructed balance mismatch for {account:?}",
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
state_b.get_account_by_id(vault_id).balance,
|
||||
u128::from(deposit_amount),
|
||||
"deposit must mint into the recipient vault exactly once, not twice"
|
||||
);
|
||||
|
||||
let produced = seq_b
|
||||
.block_store()
|
||||
.get_block_at_id(tip_b.id + 1)
|
||||
.unwrap()
|
||||
.expect("produced block present");
|
||||
assert!(
|
||||
!produced
|
||||
.body
|
||||
.transactions
|
||||
.iter()
|
||||
.any(|tx| crate::extract_bridge_deposit_id(tx) == Some(HashType(deposit_op_id))),
|
||||
"the re-delivered mint must be skipped, not re-included in a block"
|
||||
);
|
||||
|
||||
// A reconstructed withdraw's finalized L1 event was already re-delivered (and
|
||||
// dropped) by cold-start backfill, so it will never be consumed again.
|
||||
// Reconstruction must not count it, or the count stays phantom-inflated forever.
|
||||
let withdraw_arg = crate::extract_bridge_withdraw_data(&withdraw_tx).expect("withdraw data");
|
||||
let key = crate::withdraw_event_reconciliation_key(&withdraw_arg.outputs).expect("recon key");
|
||||
assert!(
|
||||
!seq_b
|
||||
.block_store()
|
||||
.dbio()
|
||||
.consume_unseen_withdraw_count(key)
|
||||
.unwrap(),
|
||||
"reconstruction must not leave a phantom unseen-withdraw count"
|
||||
);
|
||||
}
|
||||
|
||||
/// A reconstructed withdraw block must not touch the unseen-withdraw counter.
|
||||
/// Its finalized L1 Withdraw event was already re-delivered (and dropped as a
|
||||
/// no-op) by cold-start backfill, so counting it during reconstruction would
|
||||
/// leave a permanent phantom that nothing ever consumes.
|
||||
#[tokio::test]
|
||||
async fn reconstructed_withdraw_leaves_no_phantom_unseen_count() {
|
||||
let recipient = initial_public_user_accounts()[0].account_id;
|
||||
let withdraw_amount = 100_u64;
|
||||
let bedrock_account_pk = [0x33_u8; 32];
|
||||
|
||||
// Sequencer A produces a single withdraw block; treat its chain as the channel.
|
||||
let config_a = bridge_funded_config();
|
||||
let (mut seq_a, mempool_a) =
|
||||
SequencerCoreWithMockClients::start_from_config(config_a.clone()).await;
|
||||
let withdraw_tx = build_public_withdraw_tx(
|
||||
recipient,
|
||||
0,
|
||||
withdraw_amount,
|
||||
bedrock_account_pk,
|
||||
&create_signing_key_for_account1(),
|
||||
);
|
||||
mempool_a
|
||||
.push((TransactionOrigin::User, withdraw_tx.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
seq_a.produce_new_block().await.unwrap();
|
||||
|
||||
let withdraw_arg = crate::extract_bridge_withdraw_data(&withdraw_tx).expect("withdraw data");
|
||||
let key = crate::withdraw_event_reconciliation_key(&withdraw_arg.outputs).expect("recon key");
|
||||
// Producing the withdraw counts it as unseen, awaiting its L1 event.
|
||||
assert!(
|
||||
seq_a
|
||||
.block_store()
|
||||
.dbio()
|
||||
.consume_unseen_withdraw_count(key)
|
||||
.unwrap(),
|
||||
"producing a withdraw must count it as unseen"
|
||||
);
|
||||
|
||||
let messages = channel_from_store(seq_a.block_store(), 10);
|
||||
let tip_slot = messages.last().unwrap().1;
|
||||
let channel_id = config_a.bedrock_config.channel_id;
|
||||
|
||||
// Sequencer B reconstructs A's chain from a fresh store.
|
||||
let config_b = bridge_funded_config();
|
||||
let (store_b, chain_b) = fresh_store_and_chain(&config_b);
|
||||
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
|
||||
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock_b, &store_b, &chain_b, true)
|
||||
.await
|
||||
.expect("reconstruct");
|
||||
|
||||
assert!(
|
||||
!store_b.dbio().consume_unseen_withdraw_count(key).unwrap(),
|
||||
"reconstruction must not leave a phantom unseen-withdraw count"
|
||||
);
|
||||
}
|
||||
|
||||
/// A deposit whose L1 event was observed (an unfulfilled pending record
|
||||
/// exists) and whose L2 mint is already contained in a finalized channel block.
|
||||
/// Reconstruction must reconcile the pending record against that block — marking
|
||||
/// it submitted so the startup replay does not re-inject it — and apply the mint
|
||||
/// exactly once.
|
||||
#[tokio::test]
|
||||
async fn reconstruction_reconciles_already_finished_deposit() {
|
||||
let recipient = initial_public_user_accounts()[0].account_id;
|
||||
let deposit_amount = 400_u64;
|
||||
let deposit_op_id = [0x1a_u8; 32];
|
||||
|
||||
// Sequencer A: a single block that fully processes the bridge deposit.
|
||||
let config_a = bridge_funded_config();
|
||||
let (mut seq_a, mempool_a) =
|
||||
SequencerCoreWithMockClients::start_from_config(config_a.clone()).await;
|
||||
let deposit_record = deposit_event_record(deposit_op_id, deposit_amount, recipient);
|
||||
let deposit_tx =
|
||||
crate::build_bridge_deposit_tx_from_event(&deposit_record).expect("build deposit tx");
|
||||
mempool_a
|
||||
.push((TransactionOrigin::Sequencer, deposit_tx))
|
||||
.await
|
||||
.unwrap();
|
||||
seq_a.produce_new_block().await.unwrap();
|
||||
let deposit_block_id = seq_a.block_store().latest_block_meta().unwrap().unwrap().id;
|
||||
|
||||
let messages = channel_from_store(seq_a.block_store(), 10);
|
||||
let tip_slot = messages.last().unwrap().1;
|
||||
let channel_id = config_a.bedrock_config.channel_id;
|
||||
|
||||
// Sequencer B: fresh store, but with the *unfulfilled* pending deposit event
|
||||
// pre-seeded, as the cold-start backfill would when it re-observes this
|
||||
// already-finalized deposit.
|
||||
let config_b = bridge_funded_config();
|
||||
let (store_b, chain_b) = fresh_store_and_chain(&config_b);
|
||||
assert!(
|
||||
store_b
|
||||
.dbio()
|
||||
.add_pending_deposit_event(deposit_record.clone())
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
|
||||
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock_b, &store_b, &chain_b, true)
|
||||
.await
|
||||
.expect("reconstruct");
|
||||
|
||||
// The mint was applied exactly once.
|
||||
let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient);
|
||||
assert_eq!(
|
||||
chain_b
|
||||
.lock()
|
||||
.unwrap()
|
||||
.head_state()
|
||||
.get_account_by_id(vault_id)
|
||||
.balance,
|
||||
u128::from(deposit_amount),
|
||||
"already-finished deposit must be applied exactly once"
|
||||
);
|
||||
|
||||
// The pending event is now marked submitted in the reconstructed block, so the
|
||||
// startup replay would not re-queue it — no double mint on restart.
|
||||
let record = store_b
|
||||
.get_unfulfilled_deposit_events()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|event| event.deposit_op_id == HashType(deposit_op_id))
|
||||
.expect("pending deposit event should still be recorded");
|
||||
assert_eq!(
|
||||
record.submitted_in_block_id,
|
||||
Some(deposit_block_id),
|
||||
"reconstruction must reconcile the already-finished deposit against its channel block"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn committed_local_against_missing_channel_fails_without_anchor() {
|
||||
// A sequencer that has committed blocks — a non-genesis tip plus a persisted
|
||||
// checkpoint — but only ever produced (so it never recorded a per-block
|
||||
// anchor). Restarting it against a wiped/missing channel must still fail,
|
||||
// driven by the committed-blocks invariant rather than an anchor probe.
|
||||
let config = setup_sequencer_config();
|
||||
{
|
||||
let (mut seq, _handle) =
|
||||
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
|
||||
seq.produce_new_block().await.unwrap();
|
||||
seq.produce_new_block().await.unwrap();
|
||||
assert!(seq.block_store().latest_block_meta().unwrap().unwrap().id > 1);
|
||||
} // drop releases the store so we can reopen it
|
||||
|
||||
// Reopen: blocks beyond genesis, no anchor. `is_fresh_start = false` stands in
|
||||
// for a checkpoint persisted by a prior sync (the mock never emits one).
|
||||
let (store, chain) = fresh_store_and_chain(&config);
|
||||
assert!(store.get_zone_anchor().unwrap().is_none());
|
||||
assert!(store.latest_block_meta().unwrap().unwrap().id > 1);
|
||||
|
||||
// The channel is gone: no tip, no messages.
|
||||
let mock =
|
||||
MockBlockPublisher::with_canned_channel(config.bedrock_config.channel_id, None, vec![]);
|
||||
let result =
|
||||
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, false)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"committed blocks against a missing channel must abort startup"
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
[package]
|
||||
name = "sequencer_service"
|
||||
version = "0.1.0"
|
||||
default-run = "sequencer_service"
|
||||
edition = "2024"
|
||||
license = { workspace = true }
|
||||
|
||||
@ -19,6 +20,7 @@ programs.workspace = true
|
||||
clap = { workspace = true, features = ["derive", "env"] }
|
||||
anyhow.workspace = true
|
||||
env_logger.workspace = true
|
||||
hex.workspace = true
|
||||
log.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-util.workspace = true
|
||||
|
||||
@ -90,6 +90,4 @@ pub trait Rpc {
|
||||
|
||||
#[method(name = "getChannelId")]
|
||||
async fn get_channel_id(&self) -> Result<ChannelId, ErrorObjectOwned>;
|
||||
|
||||
// =============================================================================================
|
||||
}
|
||||
|
||||
35
lez/sequencer/service/src/bin/bedrock_pubkey.rs
Normal file
35
lez/sequencer/service/src/bin/bedrock_pubkey.rs
Normal file
@ -0,0 +1,35 @@
|
||||
//! Prints the sequencer's bedrock signing public key (hex) without booting it.
|
||||
//!
|
||||
//! Loads `<home>/bedrock_signing_key` from the given sequencer config, creating
|
||||
//! the key if it doesn't exist yet, so that a node can be accredited into the
|
||||
//! channel committee before its first boot.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[clap(version)]
|
||||
struct Args {
|
||||
#[clap(name = "config")]
|
||||
config_path: PathBuf,
|
||||
/// Override the config's home directory, matching the sequencer's --home.
|
||||
#[clap(long)]
|
||||
home: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::print_stdout,
|
||||
reason = "the hex pubkey on stdout is this binary's output"
|
||||
)]
|
||||
fn main() -> Result<()> {
|
||||
let Args { config_path, home } = Args::parse();
|
||||
|
||||
let config = sequencer_service::SequencerConfig::from_path(&config_path)?;
|
||||
let home = home.unwrap_or(config.home);
|
||||
let key = sequencer_core::load_or_create_signing_key(&home.join("bedrock_signing_key"))?;
|
||||
println!("{}", hex::encode(key.public_key().to_bytes()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
71
lez/sequencer/service/src/bin/configure_channel.rs
Normal file
71
lez/sequencer/service/src/bin/configure_channel.rs
Normal file
@ -0,0 +1,71 @@
|
||||
//! Posts a `ChannelConfig` op (accredited keys + rotation params) to bedrock,
|
||||
//! signed with `<home>/bedrock_signing_key`, without booting the sequencer.
|
||||
//!
|
||||
//! Authorization is holding the admin key file — the L1 rejects non-admin
|
||||
//! signers. Acceptance is asynchronous: a rejection only shows up in node
|
||||
//! logs and on-chain behavior.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use clap::Parser;
|
||||
use sequencer_core::block_publisher::{Ed25519PublicKey, post_channel_config};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[clap(version)]
|
||||
struct Args {
|
||||
#[clap(name = "config")]
|
||||
config_path: PathBuf,
|
||||
/// Override the config's home directory, matching the sequencer's --home.
|
||||
#[clap(long)]
|
||||
home: Option<PathBuf>,
|
||||
/// Accredited ed25519 public keys (hex), admin (this node's key) first.
|
||||
#[clap(long, required = true, value_delimiter = ',')]
|
||||
keys: Vec<String>,
|
||||
/// Slots a sequencer's posting turn lasts.
|
||||
#[clap(long)]
|
||||
posting_timeframe: u32,
|
||||
/// Slots after which a stalled turn can be taken over.
|
||||
#[clap(long)]
|
||||
posting_timeout: u32,
|
||||
/// Signatures required for future config changes.
|
||||
#[clap(long, default_value_t = 1)]
|
||||
configuration_threshold: u16,
|
||||
/// Signatures required for channel withdrawals.
|
||||
#[clap(long, default_value_t = 1)]
|
||||
withdraw_threshold: u16,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
env_logger::init();
|
||||
let args = Args::parse();
|
||||
|
||||
let config = sequencer_service::SequencerConfig::from_path(&args.config_path)?;
|
||||
let home = args.home.unwrap_or(config.home);
|
||||
let signing_key =
|
||||
sequencer_core::load_or_create_signing_key(&home.join("bedrock_signing_key"))?;
|
||||
let keys = args
|
||||
.keys
|
||||
.iter()
|
||||
.map(|key| parse_key(key))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
||||
post_channel_config(
|
||||
&config.bedrock_config,
|
||||
&signing_key,
|
||||
keys,
|
||||
args.posting_timeframe,
|
||||
args.posting_timeout,
|
||||
args.configuration_threshold,
|
||||
args.withdraw_threshold,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn parse_key(hex_key: &str) -> Result<Ed25519PublicKey> {
|
||||
let mut bytes = [0_u8; 32];
|
||||
hex::decode_to_slice(hex_key, &mut bytes)
|
||||
.with_context(|| format!("Invalid hex-encoded key {hex_key}"))?;
|
||||
Ed25519PublicKey::from_bytes(&bytes).map_err(|err| anyhow!("Invalid Ed25519 public key: {err}"))
|
||||
}
|
||||
@ -11,10 +11,11 @@ use mempool::MemPoolHandle;
|
||||
use sequencer_core::SequencerCore;
|
||||
#[cfg(feature = "standalone")]
|
||||
use sequencer_core::SequencerCoreWithMockClients as SequencerCore;
|
||||
use sequencer_core::TransactionOrigin;
|
||||
pub use sequencer_core::config::*;
|
||||
use sequencer_core::{TransactionOrigin, block_publisher::BlockPublisherTrait as _};
|
||||
use sequencer_service_rpc::RpcServer as _;
|
||||
use tokio::{sync::Mutex, task::JoinHandle};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub mod service;
|
||||
|
||||
@ -28,6 +29,9 @@ pub struct SequencerHandle {
|
||||
/// Option because of `Drop` which forbids to simply move out of `self` in `stopped()`.
|
||||
server_handle: Option<ServerHandle>,
|
||||
main_loop_handle: JoinHandle<Result<Never>>,
|
||||
/// Cancelled when the publisher's drive task terminates (e.g. a panicked
|
||||
/// persist sink); no channel events are processed past that point.
|
||||
driver_cancellation: CancellationToken,
|
||||
}
|
||||
|
||||
impl SequencerHandle {
|
||||
@ -35,11 +39,13 @@ impl SequencerHandle {
|
||||
addr: SocketAddr,
|
||||
server_handle: ServerHandle,
|
||||
main_loop_handle: JoinHandle<Result<Never>>,
|
||||
driver_cancellation: CancellationToken,
|
||||
) -> Self {
|
||||
Self {
|
||||
addr,
|
||||
server_handle: Some(server_handle),
|
||||
main_loop_handle,
|
||||
driver_cancellation,
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,6 +59,7 @@ impl SequencerHandle {
|
||||
addr: _,
|
||||
server_handle,
|
||||
main_loop_handle,
|
||||
driver_cancellation,
|
||||
} = &mut self;
|
||||
|
||||
let server_handle = server_handle.take().expect("Server handle is set");
|
||||
@ -65,6 +72,9 @@ impl SequencerHandle {
|
||||
.context("Main loop task panicked")?
|
||||
.context("Main loop exited unexpectedly")
|
||||
}
|
||||
() = driver_cancellation.cancelled() => {
|
||||
Err(anyhow!("Publisher drive task terminated"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -78,10 +88,12 @@ impl SequencerHandle {
|
||||
addr: _,
|
||||
server_handle,
|
||||
main_loop_handle,
|
||||
driver_cancellation,
|
||||
} = self;
|
||||
|
||||
let stopped = server_handle.as_ref().is_none_or(ServerHandle::is_stopped)
|
||||
|| main_loop_handle.is_finished();
|
||||
|| main_loop_handle.is_finished()
|
||||
|| driver_cancellation.is_cancelled();
|
||||
!stopped
|
||||
}
|
||||
|
||||
@ -97,6 +109,7 @@ impl Drop for SequencerHandle {
|
||||
addr: _,
|
||||
server_handle,
|
||||
main_loop_handle,
|
||||
driver_cancellation: _,
|
||||
} = self;
|
||||
|
||||
main_loop_handle.abort();
|
||||
@ -111,21 +124,23 @@ impl Drop for SequencerHandle {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(config: SequencerConfig, port: u16) -> Result<SequencerHandle> {
|
||||
pub async fn run(config: SequencerConfig, listen_addr: SocketAddr) -> Result<SequencerHandle> {
|
||||
let block_timeout = config.block_create_timeout;
|
||||
let max_block_size = config.max_block_size;
|
||||
|
||||
let (sequencer_core, mempool_handle) = SequencerCore::start_from_config(config).await;
|
||||
let (sequencer_core, mempool_handle): (SequencerCore, _) =
|
||||
SequencerCore::start_from_config(config).await;
|
||||
|
||||
info!("Sequencer core set up");
|
||||
|
||||
let driver_cancellation = sequencer_core.block_publisher().driver_cancellation();
|
||||
let seq_core_wrapped = Arc::new(Mutex::new(sequencer_core));
|
||||
let mempool_handle_for_server = mempool_handle.clone();
|
||||
|
||||
let (server_handle, addr) = run_server(
|
||||
Arc::clone(&seq_core_wrapped),
|
||||
mempool_handle_for_server,
|
||||
port,
|
||||
listen_addr,
|
||||
max_block_size.as_u64(),
|
||||
)
|
||||
.await?;
|
||||
@ -136,13 +151,18 @@ pub async fn run(config: SequencerConfig, port: u16) -> Result<SequencerHandle>
|
||||
|
||||
let _ = mempool_handle;
|
||||
|
||||
Ok(SequencerHandle::new(addr, server_handle, main_loop_handle))
|
||||
Ok(SequencerHandle::new(
|
||||
addr,
|
||||
server_handle,
|
||||
main_loop_handle,
|
||||
driver_cancellation,
|
||||
))
|
||||
}
|
||||
|
||||
async fn run_server(
|
||||
sequencer: Arc<Mutex<SequencerCore>>,
|
||||
mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
|
||||
port: u16,
|
||||
listen_addr: SocketAddr,
|
||||
max_block_size: u64,
|
||||
) -> Result<(ServerHandle, SocketAddr)> {
|
||||
let server = jsonrpsee::server::ServerBuilder::with_config(
|
||||
@ -153,7 +173,7 @@ async fn run_server(
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build(SocketAddr::from(([0, 0, 0, 0], port)))
|
||||
.build(listen_addr)
|
||||
.await
|
||||
.context("Failed to build RPC server")?;
|
||||
|
||||
@ -172,16 +192,15 @@ async fn main_loop(seq_core: Arc<Mutex<SequencerCore>>, block_timeout: Duration)
|
||||
loop {
|
||||
tokio::time::sleep(block_timeout).await;
|
||||
|
||||
info!("Collecting transactions from mempool, block creation");
|
||||
let mut state = seq_core.lock().await;
|
||||
|
||||
let id = {
|
||||
let mut state = seq_core.lock().await;
|
||||
|
||||
state.produce_new_block().await?
|
||||
};
|
||||
// Only produce on our turn.
|
||||
if !state.is_our_turn() {
|
||||
continue;
|
||||
}
|
||||
|
||||
info!("Our turn: collecting transactions from mempool, creating block");
|
||||
let id = state.produce_new_block().await?;
|
||||
info!("Block with id {id} created");
|
||||
|
||||
info!("Waiting for new transactions");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
use std::path::PathBuf;
|
||||
use std::{
|
||||
net::{IpAddr, SocketAddr},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
@ -12,6 +15,14 @@ struct Args {
|
||||
config_path: PathBuf,
|
||||
#[clap(short, long, default_value = "3040")]
|
||||
port: u16,
|
||||
/// Interface the RPC server binds to. The RPC has no caller auth —
|
||||
/// bind loopback unless the port is firewalled.
|
||||
#[clap(long, default_value = "0.0.0.0")]
|
||||
listen_address: IpAddr,
|
||||
/// Override the config's home directory (`RocksDB` + bedrock signing key),
|
||||
/// so multiple instances can share one config file.
|
||||
#[clap(long)]
|
||||
home: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@ -22,14 +33,23 @@ struct Args {
|
||||
async fn main() -> Result<()> {
|
||||
env_logger::init();
|
||||
|
||||
let Args { config_path, port } = Args::parse();
|
||||
let Args {
|
||||
config_path,
|
||||
port,
|
||||
listen_address,
|
||||
home,
|
||||
} = Args::parse();
|
||||
|
||||
// TODO: handle this cancellation token more gracefully within Sequencer service
|
||||
// similar to how we do in Indexer
|
||||
let cancellation_token = listen_for_shutdown_signal();
|
||||
|
||||
let config = sequencer_service::SequencerConfig::from_path(&config_path)?;
|
||||
let sequencer_handle = sequencer_service::run(config, port).await?;
|
||||
let mut config = sequencer_service::SequencerConfig::from_path(&config_path)?;
|
||||
if let Some(home) = home {
|
||||
config.home = home;
|
||||
}
|
||||
let sequencer_handle =
|
||||
sequencer_service::run(config, SocketAddr::new(listen_address, port)).await?;
|
||||
|
||||
tokio::select! {
|
||||
() = cancellation_token.cancelled() => {
|
||||
|
||||
@ -40,7 +40,7 @@ impl<BC: BlockPublisherTrait> SequencerService<BC> {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<BC: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
|
||||
impl<BC: BlockPublisherTrait + Send + Sync + 'static> sequencer_service_rpc::RpcServer
|
||||
for SequencerService<BC>
|
||||
{
|
||||
async fn send_transaction(&self, tx: LeeTransaction) -> Result<HashType, ErrorObjectOwned> {
|
||||
@ -138,8 +138,8 @@ impl<BC: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
|
||||
|
||||
async fn get_account_balance(&self, account_id: AccountId) -> Result<u128, ErrorObjectOwned> {
|
||||
let sequencer = self.sequencer.lock().await;
|
||||
let account = sequencer.state().get_account_by_id(account_id);
|
||||
Ok(account.balance)
|
||||
let balance = sequencer.with_state(|state| state.get_account_by_id(account_id).balance);
|
||||
Ok(balance)
|
||||
}
|
||||
|
||||
async fn get_transaction(
|
||||
@ -155,10 +155,12 @@ impl<BC: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
|
||||
account_ids: Vec<AccountId>,
|
||||
) -> Result<Vec<Nonce>, ErrorObjectOwned> {
|
||||
let sequencer = self.sequencer.lock().await;
|
||||
let nonces = account_ids
|
||||
.into_iter()
|
||||
.map(|account_id| sequencer.state().get_account_by_id(account_id).nonce)
|
||||
.collect();
|
||||
let nonces = sequencer.with_state(|state| {
|
||||
account_ids
|
||||
.into_iter()
|
||||
.map(|account_id| state.get_account_by_id(account_id).nonce)
|
||||
.collect()
|
||||
});
|
||||
Ok(nonces)
|
||||
}
|
||||
|
||||
@ -167,17 +169,18 @@ impl<BC: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
|
||||
commitments: Vec<Commitment>,
|
||||
) -> Result<(Vec<Option<MembershipProof>>, CommitmentSetDigest), ErrorObjectOwned> {
|
||||
let sequencer = self.sequencer.lock().await;
|
||||
let state = sequencer.state();
|
||||
let proofs = commitments
|
||||
.iter()
|
||||
.map(|commitment| state.get_proof_for_commitment(commitment))
|
||||
.collect();
|
||||
Ok((proofs, state.commitment_root()))
|
||||
Ok(sequencer.with_state(|state| {
|
||||
let proofs = commitments
|
||||
.iter()
|
||||
.map(|commitment| state.get_proof_for_commitment(commitment))
|
||||
.collect();
|
||||
(proofs, state.commitment_root())
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_account(&self, account_id: AccountId) -> Result<Account, ErrorObjectOwned> {
|
||||
let sequencer = self.sequencer.lock().await;
|
||||
Ok(sequencer.state().get_account_by_id(account_id))
|
||||
Ok(sequencer.with_state(|state| state.get_account_by_id(account_id)))
|
||||
}
|
||||
|
||||
async fn get_program_ids(&self) -> Result<BTreeMap<String, ProgramId>, ErrorObjectOwned> {
|
||||
|
||||
@ -19,10 +19,12 @@ use crate::{
|
||||
},
|
||||
error::DbError,
|
||||
sequencer::sequencer_cells::{
|
||||
LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell, LatestBlockMetaCellOwned,
|
||||
LatestBlockMetaCellRef, PendingDepositEventRecord, PendingDepositEventsCellOwned,
|
||||
PendingDepositEventsCellRef, UnseenWithdrawCountCell, WithdrawalReconciliationKey,
|
||||
ZoneSdkCheckpointCellOwned, ZoneSdkCheckpointCellRef,
|
||||
FinalBlockMetaCellOwned, FinalBlockMetaCellRef, FinalLeeStateCellOwned,
|
||||
FinalLeeStateCellRef, LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell,
|
||||
LatestBlockMetaCellOwned, LatestBlockMetaCellRef, PendingDepositEventRecord,
|
||||
PendingDepositEventsCellOwned, PendingDepositEventsCellRef, UnseenWithdrawCountCell,
|
||||
WithdrawalReconciliationKey, ZoneAnchorCell, ZoneAnchorRecord, ZoneSdkCheckpointCellOwned,
|
||||
ZoneSdkCheckpointCellRef,
|
||||
},
|
||||
};
|
||||
|
||||
@ -34,6 +36,10 @@ pub const DB_META_LAST_FINALIZED_BLOCK_ID: &str = "last_finalized_block_id";
|
||||
pub const DB_META_LATEST_BLOCK_META_KEY: &str = "latest_block_meta";
|
||||
/// Key base for storing the zone-sdk sequencer checkpoint (opaque bytes).
|
||||
pub const DB_META_ZONE_SDK_CHECKPOINT_KEY: &str = "zone_sdk_checkpoint";
|
||||
/// Key base for storing the last channel block read back and verified from
|
||||
/// Bedrock (its L1 slot + `id`/`hash`) — the anchor for the startup
|
||||
/// consistency check and the resume point for reconstruction.
|
||||
pub const DB_META_ZONE_CURSOR_KEY: &str = "zone_cursor";
|
||||
/// Key base for storing queued deposit events that were not yet
|
||||
/// fulfilled on L2.
|
||||
pub const DB_META_PENDING_DEPOSIT_EVENTS_KEY: &str = "pending_deposit_events";
|
||||
@ -42,6 +48,10 @@ pub const DB_META_UNSEEN_WITHDRAW_COUNT_KEY: &str = "unseen_withdraw_count";
|
||||
|
||||
/// Key base for storing the LEE state.
|
||||
pub const DB_LEE_STATE_KEY: &str = "lee_state";
|
||||
/// Key base for storing the LEE state at the last L1-finalized block.
|
||||
pub const DB_FINAL_LEE_STATE_KEY: &str = "final_lee_state";
|
||||
/// Key base for storing `(id, hash)` of the last L1-finalized block.
|
||||
pub const DB_FINAL_BLOCK_META_KEY: &str = "final_block_meta";
|
||||
|
||||
/// Name of state column family.
|
||||
pub const CF_LEE_STATE_NAME: &str = "cf_lee_state";
|
||||
@ -340,8 +350,9 @@ impl RocksDBIO {
|
||||
self.put_batch(&LatestBlockMetaCellRef(block_meta), (), batch)
|
||||
}
|
||||
|
||||
pub fn latest_block_meta(&self) -> DbResult<BlockMeta> {
|
||||
self.get::<LatestBlockMetaCellOwned>(()).map(|val| val.0)
|
||||
pub fn latest_block_meta(&self) -> DbResult<Option<BlockMeta>> {
|
||||
self.get_opt::<LatestBlockMetaCellOwned>(())
|
||||
.map(|val| val.map(|cell| cell.0))
|
||||
}
|
||||
|
||||
pub fn get_zone_sdk_checkpoint_bytes(&self) -> DbResult<Option<Vec<u8>>> {
|
||||
@ -359,6 +370,14 @@ impl RocksDBIO {
|
||||
self.del::<ZoneSdkCheckpointCellOwned>(())
|
||||
}
|
||||
|
||||
pub fn get_zone_anchor(&self) -> DbResult<Option<ZoneAnchorRecord>> {
|
||||
Ok(self.get_opt::<ZoneAnchorCell>(())?.map(|cell| cell.0))
|
||||
}
|
||||
|
||||
pub fn put_zone_anchor(&self, anchor: &ZoneAnchorRecord) -> DbResult<()> {
|
||||
self.put(&ZoneAnchorCell(*anchor), ())
|
||||
}
|
||||
|
||||
pub fn get_pending_deposit_events(&self) -> DbResult<Vec<PendingDepositEventRecord>> {
|
||||
Ok(self
|
||||
.get_opt::<PendingDepositEventsCellOwned>(())?
|
||||
@ -390,6 +409,25 @@ impl RocksDBIO {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Marks the given deposit events submitted in `block_id`, in one write.
|
||||
pub fn mark_deposit_events_submitted(
|
||||
&self,
|
||||
deposit_op_ids: &[HashType],
|
||||
submitted_block_id: u64,
|
||||
) -> DbResult<()> {
|
||||
if deposit_op_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut batch = WriteBatch::default();
|
||||
self.mark_pending_deposit_events_submitted(deposit_op_ids, submitted_block_id, &mut batch)?;
|
||||
self.db.write(batch).map_err(|rerr| {
|
||||
DbError::rocksdb_cast_message(
|
||||
rerr,
|
||||
Some("Failed to mark deposit events submitted".to_owned()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn mark_pending_deposit_events_submitted(
|
||||
&self,
|
||||
deposit_op_ids: &[HashType],
|
||||
@ -434,6 +472,14 @@ impl RocksDBIO {
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Whether a bridge deposit for `deposit_op_id` is already recorded as
|
||||
/// included in a block (its pending record is marked submitted).
|
||||
pub fn is_deposit_event_submitted(&self, deposit_op_id: HashType) -> DbResult<bool> {
|
||||
Ok(self.get_pending_deposit_events()?.iter().any(|record| {
|
||||
record.deposit_op_id == deposit_op_id && record.submitted_in_block_id.is_some()
|
||||
}))
|
||||
}
|
||||
|
||||
fn increment_unseen_withdraw_count(
|
||||
&self,
|
||||
withdrawal: WithdrawalReconciliationKey,
|
||||
@ -482,23 +528,36 @@ impl RocksDBIO {
|
||||
}
|
||||
|
||||
pub fn put_block(&self, block: &Block, first: bool, batch: &mut WriteBatch) -> DbResult<()> {
|
||||
let cf_block = self.block_column();
|
||||
|
||||
if !first {
|
||||
// A produced block is the new head tip by construction: pin the
|
||||
// tip meta and drop any stale higher blocks a preceding reorg left
|
||||
// behind (mirrors `store_followed_blocks`).
|
||||
let last_curr_block = self.get_meta_last_block_in_db()?;
|
||||
|
||||
if block.header.block_id > last_curr_block {
|
||||
self.put_meta_last_block_in_db_batch(block.header.block_id, batch)?;
|
||||
self.put_meta_latest_block_meta_batch(
|
||||
&BlockMeta {
|
||||
id: block.header.block_id,
|
||||
hash: block.header.hash,
|
||||
},
|
||||
batch,
|
||||
)?;
|
||||
for stale_id in block.header.block_id.saturating_add(1)..=last_curr_block {
|
||||
self.delete_block_payload(stale_id, batch)?;
|
||||
}
|
||||
self.put_meta_last_block_in_db_batch(block.header.block_id, batch)?;
|
||||
self.put_meta_latest_block_meta_batch(&BlockMeta::from(block), batch)?;
|
||||
}
|
||||
|
||||
self.put_block_payload(block, batch)
|
||||
}
|
||||
|
||||
/// Stages deletion of a block payload into `batch`.
|
||||
fn delete_block_payload(&self, block_id: u64, batch: &mut WriteBatch) -> DbResult<()> {
|
||||
let cf_block = self.block_column();
|
||||
batch.delete_cf(
|
||||
&cf_block,
|
||||
borsh::to_vec(&block_id).map_err(|err| {
|
||||
DbError::borsh_cast_message(err, Some("Failed to serialize block id".to_owned()))
|
||||
})?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stages just the block payload into `batch`, without touching the tip meta.
|
||||
fn put_block_payload(&self, block: &Block, batch: &mut WriteBatch) -> DbResult<()> {
|
||||
let cf_block = self.block_column();
|
||||
batch.put_cf(
|
||||
&cf_block,
|
||||
borsh::to_vec(&block.header.block_id).map_err(|err| {
|
||||
@ -516,6 +575,26 @@ impl RocksDBIO {
|
||||
.map(|opt| opt.map(|val| val.0))
|
||||
}
|
||||
|
||||
/// `(state, meta)` at the last L1-finalized block; `None` until the first
|
||||
/// finalization is observed.
|
||||
pub fn get_final_snapshot(&self) -> DbResult<Option<(V03State, BlockMeta)>> {
|
||||
let Some(meta) = self.get_opt::<FinalBlockMetaCellOwned>(())? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let state = self.get::<FinalLeeStateCellOwned>(())?;
|
||||
Ok(Some((state.0, meta.0)))
|
||||
}
|
||||
|
||||
fn put_final_snapshot_batch(
|
||||
&self,
|
||||
state: &V03State,
|
||||
meta: &BlockMeta,
|
||||
batch: &mut WriteBatch,
|
||||
) -> DbResult<()> {
|
||||
self.put_batch(&FinalLeeStateCellRef(state), (), batch)?;
|
||||
self.put_batch(&FinalBlockMetaCellRef(meta), (), batch)
|
||||
}
|
||||
|
||||
pub fn get_lee_state(&self) -> DbResult<V03State> {
|
||||
self.get::<LEEStateCellOwned>(()).map(|val| val.0)
|
||||
}
|
||||
@ -612,6 +691,97 @@ impl RocksDBIO {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One-block form of [`Self::store_followed_blocks`], with the block as the
|
||||
/// head tip and no final snapshot. Production always uses the batch form.
|
||||
#[cfg(test)]
|
||||
fn store_followed_block(
|
||||
&self,
|
||||
block: &Block,
|
||||
state: &V03State,
|
||||
finalized: bool,
|
||||
) -> DbResult<()> {
|
||||
self.store_followed_blocks(
|
||||
&[(block, finalized)],
|
||||
Some(&BlockMeta::from(block)),
|
||||
state,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Persists a batch of followed blocks, the caller's head-tip `state`, and
|
||||
/// the optional final-tier snapshot in one atomic write.
|
||||
///
|
||||
/// The tip meta is pinned to `head_tip`, and blocks stored above it (left
|
||||
/// behind by a net-shortening reorg) are deleted in the same write, so
|
||||
/// restart replay never walks past the tip.
|
||||
///
|
||||
/// Per block: skips the payload write when the store already holds it (by
|
||||
/// id and hash), unless `finalized` is set, which rewrites it with the
|
||||
/// finalized status. A no-op update (nothing to write, tip unchanged)
|
||||
/// writes nothing.
|
||||
///
|
||||
/// TODO: the zone-sdk checkpoint is persisted by `on_checkpoint` *before*
|
||||
/// this write. Full `BlocksProcessed` atomicity (checkpoint, blocks, state
|
||||
/// and orphan reverts in one batch) is a follow-up.
|
||||
pub fn store_followed_blocks(
|
||||
&self,
|
||||
blocks: &[(&Block, bool)],
|
||||
head_tip: Option<&BlockMeta>,
|
||||
state: &V03State,
|
||||
final_snapshot: Option<(&V03State, &BlockMeta)>,
|
||||
) -> DbResult<()> {
|
||||
let last_block_in_db = self.get_meta_last_block_in_db()?;
|
||||
let mut batch = WriteBatch::default();
|
||||
|
||||
for (block, finalized) in blocks {
|
||||
let already_stored = self
|
||||
.get_block(block.header.block_id)?
|
||||
.filter(|stored| stored.header.hash == block.header.hash);
|
||||
|
||||
let mut to_write = match already_stored {
|
||||
Some(_) if !finalized => continue,
|
||||
Some(stored) => stored,
|
||||
None => (*block).clone(),
|
||||
};
|
||||
if *finalized {
|
||||
to_write.bedrock_status = BedrockStatus::Finalized;
|
||||
}
|
||||
self.put_block_payload(&to_write, &mut batch)?;
|
||||
}
|
||||
|
||||
// `head_tip` is `None` only for a chain holding no blocks at all, which
|
||||
// implies nothing was applied — and the store, created with genesis,
|
||||
// cannot represent it. No tip to pin, nothing to persist.
|
||||
let Some(tip) = head_tip else {
|
||||
debug_assert!(batch.is_empty() && final_snapshot.is_none());
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// A shrink-only update (orphans without adopted replacements) has no
|
||||
// payloads to write but must still rewind the tip meta, or the stored
|
||||
// state tears against the stale disk head on the next produce.
|
||||
if batch.is_empty() && final_snapshot.is_none() && tip.id == last_block_in_db {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for stale_id in tip.id.saturating_add(1)..=last_block_in_db {
|
||||
self.delete_block_payload(stale_id, &mut batch)?;
|
||||
}
|
||||
self.put_meta_last_block_in_db_batch(tip.id, &mut batch)?;
|
||||
self.put_meta_latest_block_meta_batch(tip, &mut batch)?;
|
||||
self.put_lee_state_in_db_batch(state, &mut batch)?;
|
||||
if let Some((final_state, final_meta)) = final_snapshot {
|
||||
self.put_final_snapshot_batch(final_state, final_meta, &mut batch)?;
|
||||
}
|
||||
|
||||
self.db.write(batch).map_err(|rerr| {
|
||||
DbError::rocksdb_cast_message(
|
||||
rerr,
|
||||
Some("Failed to write followed blocks batch".to_owned()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_all_blocks(&self) -> impl Iterator<Item = DbResult<Block>> {
|
||||
let cf_block = self.block_column();
|
||||
self.db
|
||||
@ -661,3 +831,6 @@ impl RocksDBIO {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@ -7,9 +7,10 @@ use crate::{
|
||||
cells::{SimpleReadableCell, SimpleStorableCell, SimpleWritableCell},
|
||||
error::DbError,
|
||||
sequencer::{
|
||||
CF_LEE_STATE_NAME, DB_LEE_STATE_KEY, DB_META_LAST_FINALIZED_BLOCK_ID,
|
||||
DB_META_LATEST_BLOCK_META_KEY, DB_META_PENDING_DEPOSIT_EVENTS_KEY,
|
||||
DB_META_UNSEEN_WITHDRAW_COUNT_KEY, DB_META_ZONE_SDK_CHECKPOINT_KEY,
|
||||
CF_LEE_STATE_NAME, DB_FINAL_BLOCK_META_KEY, DB_FINAL_LEE_STATE_KEY, DB_LEE_STATE_KEY,
|
||||
DB_META_LAST_FINALIZED_BLOCK_ID, DB_META_LATEST_BLOCK_META_KEY,
|
||||
DB_META_PENDING_DEPOSIT_EVENTS_KEY, DB_META_UNSEEN_WITHDRAW_COUNT_KEY,
|
||||
DB_META_ZONE_CURSOR_KEY, DB_META_ZONE_SDK_CHECKPOINT_KEY,
|
||||
},
|
||||
};
|
||||
|
||||
@ -43,6 +44,72 @@ impl SimpleWritableCell for LEEStateCellRef<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// State at the last L1-finalized block, written atomically with
|
||||
/// [`FinalBlockMetaCellRef`].
|
||||
#[derive(BorshDeserialize)]
|
||||
pub struct FinalLeeStateCellOwned(pub V03State);
|
||||
|
||||
impl SimpleStorableCell for FinalLeeStateCellOwned {
|
||||
type KeyParams = ();
|
||||
|
||||
const CELL_NAME: &'static str = DB_FINAL_LEE_STATE_KEY;
|
||||
const CF_NAME: &'static str = CF_LEE_STATE_NAME;
|
||||
}
|
||||
|
||||
impl SimpleReadableCell for FinalLeeStateCellOwned {}
|
||||
|
||||
#[derive(BorshSerialize)]
|
||||
pub struct FinalLeeStateCellRef<'state>(pub &'state V03State);
|
||||
|
||||
impl SimpleStorableCell for FinalLeeStateCellRef<'_> {
|
||||
type KeyParams = ();
|
||||
|
||||
const CELL_NAME: &'static str = DB_FINAL_LEE_STATE_KEY;
|
||||
const CF_NAME: &'static str = CF_LEE_STATE_NAME;
|
||||
}
|
||||
|
||||
impl SimpleWritableCell for FinalLeeStateCellRef<'_> {
|
||||
fn value_constructor(&self) -> DbResult<Vec<u8>> {
|
||||
borsh::to_vec(&self).map_err(|err| {
|
||||
DbError::borsh_cast_message(err, Some("Failed to serialize final state".to_owned()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `(id, hash)` of the last L1-finalized block, paired with [`FinalLeeStateCellRef`].
|
||||
#[derive(BorshDeserialize)]
|
||||
pub struct FinalBlockMetaCellOwned(pub BlockMeta);
|
||||
|
||||
impl SimpleStorableCell for FinalBlockMetaCellOwned {
|
||||
type KeyParams = ();
|
||||
|
||||
const CELL_NAME: &'static str = DB_FINAL_BLOCK_META_KEY;
|
||||
const CF_NAME: &'static str = CF_META_NAME;
|
||||
}
|
||||
|
||||
impl SimpleReadableCell for FinalBlockMetaCellOwned {}
|
||||
|
||||
#[derive(BorshSerialize)]
|
||||
pub struct FinalBlockMetaCellRef<'blockmeta>(pub &'blockmeta BlockMeta);
|
||||
|
||||
impl SimpleStorableCell for FinalBlockMetaCellRef<'_> {
|
||||
type KeyParams = ();
|
||||
|
||||
const CELL_NAME: &'static str = DB_FINAL_BLOCK_META_KEY;
|
||||
const CF_NAME: &'static str = CF_META_NAME;
|
||||
}
|
||||
|
||||
impl SimpleWritableCell for FinalBlockMetaCellRef<'_> {
|
||||
fn value_constructor(&self) -> DbResult<Vec<u8>> {
|
||||
borsh::to_vec(&self).map_err(|err| {
|
||||
DbError::borsh_cast_message(
|
||||
err,
|
||||
Some("Failed to serialize final block meta".to_owned()),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, BorshSerialize, BorshDeserialize)]
|
||||
pub struct LastFinalizedBlockIdCell(pub Option<u64>);
|
||||
|
||||
@ -132,6 +199,39 @@ impl SimpleWritableCell for ZoneSdkCheckpointCellRef<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The last channel block read back and verified from Bedrock.
|
||||
///
|
||||
/// Holds its L1 inscription `slot` plus the block's `id`/`hash`, and serves as
|
||||
/// both the anchor for the startup consistency check and the resume point for
|
||||
/// reconstruction. `slot` is stored as a raw `u64` because the zone-sdk `Slot`
|
||||
/// does not derive borsh; the caller converts to/from `Slot`.
|
||||
#[derive(Debug, Clone, Copy, BorshSerialize, BorshDeserialize)]
|
||||
pub struct ZoneAnchorRecord {
|
||||
pub slot: u64,
|
||||
pub block_id: u64,
|
||||
pub hash: HashType,
|
||||
}
|
||||
|
||||
#[derive(Debug, BorshSerialize, BorshDeserialize)]
|
||||
pub struct ZoneAnchorCell(pub ZoneAnchorRecord);
|
||||
|
||||
impl SimpleStorableCell for ZoneAnchorCell {
|
||||
type KeyParams = ();
|
||||
|
||||
const CELL_NAME: &'static str = DB_META_ZONE_CURSOR_KEY;
|
||||
const CF_NAME: &'static str = CF_META_NAME;
|
||||
}
|
||||
|
||||
impl SimpleReadableCell for ZoneAnchorCell {}
|
||||
|
||||
impl SimpleWritableCell for ZoneAnchorCell {
|
||||
fn value_constructor(&self) -> DbResult<Vec<u8>> {
|
||||
borsh::to_vec(&self).map_err(|err| {
|
||||
DbError::borsh_cast_message(err, Some("Failed to serialize zone cursor".to_owned()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
|
||||
pub struct PendingDepositEventRecord {
|
||||
pub deposit_op_id: HashType,
|
||||
|
||||
283
lez/storage/src/sequencer/tests.rs
Normal file
283
lez/storage/src/sequencer/tests.rs
Normal file
@ -0,0 +1,283 @@
|
||||
use common::test_utils::produce_dummy_block;
|
||||
use lee::{Account, AccountId};
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn marker_id() -> AccountId {
|
||||
AccountId::new([1; 32])
|
||||
}
|
||||
|
||||
/// A state distinguishable by the marker account's balance, so tests can tell
|
||||
/// which snapshot a write persisted.
|
||||
///
|
||||
/// TODO: is this a bit too much of a hot-fix for test snapshot?
|
||||
fn state_with_balance(balance: u128) -> V03State {
|
||||
V03State::new().with_public_accounts([(
|
||||
marker_id(),
|
||||
Account {
|
||||
balance,
|
||||
..Account::default()
|
||||
},
|
||||
)])
|
||||
}
|
||||
|
||||
fn dbio_with_genesis(path: &Path) -> (RocksDBIO, Block) {
|
||||
let genesis = produce_dummy_block(1, None, vec![]);
|
||||
let dbio = RocksDBIO::create(path, &genesis, &state_with_balance(100)).unwrap();
|
||||
(dbio, genesis)
|
||||
}
|
||||
|
||||
fn stored_balance(dbio: &RocksDBIO) -> u128 {
|
||||
dbio.get_lee_state()
|
||||
.unwrap()
|
||||
.get_account_by_id(marker_id())
|
||||
.balance
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_followed_block_persists_new_block_and_state() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
|
||||
|
||||
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
|
||||
dbio.store_followed_block(&block2, &state_with_balance(200), false)
|
||||
.unwrap();
|
||||
|
||||
let stored = dbio.get_block(2).unwrap().expect("block 2 is stored");
|
||||
assert_eq!(stored.header.hash, block2.header.hash);
|
||||
assert!(matches!(stored.bedrock_status, BedrockStatus::Pending));
|
||||
assert_eq!(
|
||||
dbio.latest_block_meta().unwrap().expect("meta is set").id,
|
||||
2
|
||||
);
|
||||
assert_eq!(stored_balance(&dbio), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_followed_block_finalized_marks_block() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
|
||||
|
||||
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
|
||||
dbio.store_followed_block(&block2, &state_with_balance(200), true)
|
||||
.unwrap();
|
||||
|
||||
let stored = dbio.get_block(2).unwrap().expect("block 2 is stored");
|
||||
assert!(matches!(stored.bedrock_status, BedrockStatus::Finalized));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_followed_block_redelivery_is_a_noop_and_keeps_finalized() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
|
||||
|
||||
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
|
||||
dbio.store_followed_block(&block2, &state_with_balance(200), true)
|
||||
.unwrap();
|
||||
dbio.store_followed_block(&block2, &state_with_balance(300), false)
|
||||
.unwrap();
|
||||
|
||||
let stored = dbio.get_block(2).unwrap().expect("block 2 is stored");
|
||||
assert!(
|
||||
matches!(stored.bedrock_status, BedrockStatus::Finalized),
|
||||
"re-delivery must not demote a finalized block"
|
||||
);
|
||||
assert_eq!(
|
||||
stored_balance(&dbio),
|
||||
200,
|
||||
"re-delivery must not overwrite the persisted state"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_followed_blocks_batch_lands_meta_and_state_on_last_block() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
|
||||
|
||||
// Block 2 is already stored (own production); one update then finalizes it
|
||||
// and adopts block 3.
|
||||
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
|
||||
dbio.store_followed_block(&block2, &state_with_balance(200), false)
|
||||
.unwrap();
|
||||
|
||||
let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]);
|
||||
let head_tip = BlockMeta {
|
||||
id: 3,
|
||||
hash: block3.header.hash,
|
||||
};
|
||||
dbio.store_followed_blocks(
|
||||
&[(&block2, true), (&block3, false)],
|
||||
Some(&head_tip),
|
||||
&state_with_balance(300),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored");
|
||||
assert!(matches!(stored2.bedrock_status, BedrockStatus::Finalized));
|
||||
let stored3 = dbio.get_block(3).unwrap().expect("block 3 is stored");
|
||||
assert!(matches!(stored3.bedrock_status, BedrockStatus::Pending));
|
||||
|
||||
// Meta and state land together on the last block of the batch.
|
||||
let meta = dbio.latest_block_meta().unwrap().expect("meta is set");
|
||||
assert_eq!(meta.id, 3);
|
||||
assert_eq!(meta.hash, block3.header.hash);
|
||||
assert_eq!(stored_balance(&dbio), 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_snapshot_round_trips_and_is_absent_on_fresh_store() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
|
||||
|
||||
// Fresh store: no finalization observed yet.
|
||||
assert!(dbio.get_final_snapshot().unwrap().is_none());
|
||||
|
||||
// A follow update that finalizes block 2 lands the snapshot in the same batch.
|
||||
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
|
||||
let final_meta = BlockMeta {
|
||||
id: 2,
|
||||
hash: block2.header.hash,
|
||||
};
|
||||
dbio.store_followed_blocks(
|
||||
&[(&block2, true)],
|
||||
Some(&final_meta),
|
||||
&state_with_balance(300),
|
||||
Some((&state_with_balance(200), &final_meta)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (final_state, meta) = dbio
|
||||
.get_final_snapshot()
|
||||
.unwrap()
|
||||
.expect("final snapshot is stored");
|
||||
assert_eq!(meta.id, 2);
|
||||
assert_eq!(meta.hash, block2.header.hash);
|
||||
assert_eq!(final_state.get_account_by_id(marker_id()).balance, 200);
|
||||
// The head state is stored independently of the final snapshot.
|
||||
assert_eq!(stored_balance(&dbio), 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_followed_block_overwrites_competing_block_at_same_id() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
|
||||
|
||||
let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
|
||||
dbio.store_followed_block(&block2a, &state_with_balance(200), false)
|
||||
.unwrap();
|
||||
|
||||
// A reorg replaces block 2: the competing block wins the slot.
|
||||
let block2b = produce_dummy_block(2, Some(HashType([9; 32])), vec![]);
|
||||
dbio.store_followed_block(&block2b, &state_with_balance(300), false)
|
||||
.unwrap();
|
||||
|
||||
let stored = dbio.get_block(2).unwrap().expect("block 2 is stored");
|
||||
assert_eq!(stored.header.hash, block2b.header.hash);
|
||||
assert!(matches!(stored.bedrock_status, BedrockStatus::Pending));
|
||||
assert_eq!(stored_balance(&dbio), 300);
|
||||
|
||||
// The tip meta must follow the reorg winner, or a restart seeds the chain
|
||||
// from the orphaned block's hash.
|
||||
let meta = dbio.latest_block_meta().unwrap().expect("meta is set");
|
||||
assert_eq!(meta.id, 2);
|
||||
assert_eq!(meta.hash, block2b.header.hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn net_shortening_reorg_drops_stale_blocks() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
|
||||
|
||||
let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
|
||||
dbio.store_followed_block(&block2a, &state_with_balance(200), false)
|
||||
.unwrap();
|
||||
let block3 = produce_dummy_block(3, Some(block2a.header.hash), vec![]);
|
||||
dbio.store_followed_block(&block3, &state_with_balance(300), false)
|
||||
.unwrap();
|
||||
|
||||
// A shorter competing chain wins: block 2 is replaced, block 3 gets no
|
||||
// replacement.
|
||||
let block2b = produce_dummy_block(2, Some(HashType([9; 32])), vec![]);
|
||||
let head_tip = BlockMeta {
|
||||
id: 2,
|
||||
hash: block2b.header.hash,
|
||||
};
|
||||
dbio.store_followed_blocks(
|
||||
&[(&block2b, false)],
|
||||
Some(&head_tip),
|
||||
&state_with_balance(400),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored");
|
||||
assert_eq!(stored2.header.hash, block2b.header.hash);
|
||||
assert!(
|
||||
dbio.get_block(3).unwrap().is_none(),
|
||||
"stale block above the new head must be deleted, or restart replay panics on its broken link"
|
||||
);
|
||||
let meta = dbio.latest_block_meta().unwrap().expect("meta is set");
|
||||
assert_eq!(meta.id, 2);
|
||||
assert_eq!(meta.hash, block2b.header.hash);
|
||||
assert_eq!(stored_balance(&dbio), 400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrink_only_reorg_rewinds_tip_meta() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
|
||||
|
||||
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
|
||||
dbio.store_followed_block(&block2, &state_with_balance(200), false)
|
||||
.unwrap();
|
||||
let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]);
|
||||
dbio.store_followed_block(&block3, &state_with_balance(300), false)
|
||||
.unwrap();
|
||||
|
||||
// Orphan-only update: block 3 falls off the branch with no replacement.
|
||||
let head_tip = BlockMeta {
|
||||
id: 2,
|
||||
hash: block2.header.hash,
|
||||
};
|
||||
dbio.store_followed_blocks(&[], Some(&head_tip), &state_with_balance(200), None)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
dbio.get_block(3).unwrap().is_none(),
|
||||
"the orphaned block must not survive the tip rewind"
|
||||
);
|
||||
let meta = dbio.latest_block_meta().unwrap().expect("meta is set");
|
||||
assert_eq!(meta.id, 2);
|
||||
assert_eq!(meta.hash, block2.header.hash);
|
||||
assert_eq!(stored_balance(&dbio), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn produced_block_below_disk_head_pins_meta_and_prunes() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
|
||||
|
||||
let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
|
||||
dbio.store_followed_block(&block2a, &state_with_balance(200), false)
|
||||
.unwrap();
|
||||
let block3 = produce_dummy_block(3, Some(block2a.header.hash), vec![]);
|
||||
dbio.store_followed_block(&block3, &state_with_balance(300), false)
|
||||
.unwrap();
|
||||
|
||||
// Producing at height 2 while the disk head is still 3: the produce path
|
||||
// pins the tip meta to the produced block and drops the stale suffix in
|
||||
// the same write, mirroring the follow path.
|
||||
let block2b = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
|
||||
dbio.atomic_update(&block2b, &[], vec![], &state_with_balance(400))
|
||||
.unwrap();
|
||||
|
||||
let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored");
|
||||
assert_eq!(stored2.header.hash, block2b.header.hash);
|
||||
assert!(dbio.get_block(3).unwrap().is_none());
|
||||
let meta = dbio.latest_block_meta().unwrap().expect("meta is set");
|
||||
assert_eq!(meta.id, 2);
|
||||
assert_eq!(meta.hash, block2b.header.hash);
|
||||
assert_eq!(stored_balance(&dbio), 400);
|
||||
}
|
||||
@ -8,7 +8,7 @@ use lee_core::{
|
||||
NullifierPublicKey, NullifierSecretKey, SharedSecretKey,
|
||||
account::{AccountWithMetadata, Nonce},
|
||||
compute_digest_for_path,
|
||||
encryption::ViewingPublicKey,
|
||||
encryption::{ViewTag, ViewingPublicKey},
|
||||
};
|
||||
use rand::{RngCore as _, rngs::OsRng};
|
||||
|
||||
@ -411,6 +411,7 @@ impl AccountManager {
|
||||
(Some(nsk), Some(membership_proof)) => InputAccountIdentity::PrivatePdaUpdate {
|
||||
vpk: pre.vpk.clone(),
|
||||
random_seed: pre.random_seed,
|
||||
view_tag: random_view_tag(),
|
||||
nsk,
|
||||
membership_proof,
|
||||
identifier: pre.identifier,
|
||||
@ -430,6 +431,7 @@ impl AccountManager {
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
vpk: pre.vpk.clone(),
|
||||
random_seed: pre.random_seed,
|
||||
view_tag: random_view_tag(),
|
||||
nsk,
|
||||
membership_proof,
|
||||
identifier: pre.identifier,
|
||||
@ -650,6 +652,13 @@ fn validate_proofs_against_root(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate random byte using OS randomness.
|
||||
fn random_view_tag() -> ViewTag {
|
||||
let mut byte: [u8; 1] = [0; 1];
|
||||
OsRng.fill_bytes(&mut byte);
|
||||
byte[0]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@ -111,8 +111,9 @@ pub enum NewSubcommand {
|
||||
#[arg(long, requires = "pda")]
|
||||
/// Program ID as hex string.
|
||||
program_id: Option<String>,
|
||||
#[arg(long, requires = "pda")]
|
||||
/// Identifier that diversifies this PDA within the (`program_id`, seed, npk) family.
|
||||
#[arg(long)]
|
||||
/// Identifier selecting the shared account.
|
||||
/// Co-owners must supply the same value to derive the same account.
|
||||
/// Defaults to a random value if not specified.
|
||||
identifier: Option<u128>,
|
||||
},
|
||||
@ -197,7 +198,7 @@ impl NewSubcommand {
|
||||
Ok(SubcommandReturnValue::RegisterAccount { account_id })
|
||||
}
|
||||
|
||||
fn handle_private_gms(
|
||||
async fn handle_private_gms(
|
||||
group: &Label,
|
||||
label: Option<Label>,
|
||||
pda: bool,
|
||||
@ -229,14 +230,22 @@ impl NewSubcommand {
|
||||
pid[i] = u32::from_le_bytes(chunk.try_into().unwrap());
|
||||
}
|
||||
|
||||
wallet_core.create_shared_pda_account(
|
||||
group.clone(),
|
||||
pda_seed,
|
||||
pid,
|
||||
identifier.unwrap_or_else(rand::random),
|
||||
)?
|
||||
wallet_core
|
||||
.create_shared_pda_account(
|
||||
group.clone(),
|
||||
pda_seed,
|
||||
pid,
|
||||
identifier.unwrap_or_else(rand::random),
|
||||
)
|
||||
.await?
|
||||
} else if let Some(id) = identifier {
|
||||
wallet_core
|
||||
.create_shared_regular_account_with_identifier(group.clone(), id)
|
||||
.await?
|
||||
} else {
|
||||
wallet_core.create_shared_regular_account(group.clone())?
|
||||
wallet_core
|
||||
.create_shared_regular_account(group.clone())
|
||||
.await?
|
||||
};
|
||||
|
||||
if let Some(label) = label {
|
||||
@ -295,15 +304,18 @@ impl WalletSubcommand for NewSubcommand {
|
||||
seed,
|
||||
program_id,
|
||||
identifier,
|
||||
} => Self::handle_private_gms(
|
||||
&group,
|
||||
label,
|
||||
pda,
|
||||
seed,
|
||||
program_id,
|
||||
identifier,
|
||||
wallet_core,
|
||||
),
|
||||
} => {
|
||||
Self::handle_private_gms(
|
||||
&group,
|
||||
label,
|
||||
pda,
|
||||
seed,
|
||||
program_id,
|
||||
identifier,
|
||||
wallet_core,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Self::PrivateAccountsKey { cci } => Self::handle_private_accounts_key(cci, wallet_core),
|
||||
}
|
||||
}
|
||||
|
||||
@ -426,8 +426,60 @@ impl WalletCore {
|
||||
);
|
||||
}
|
||||
|
||||
/// Re-derive a shared account's state by scanning its keypair from genesis to the current
|
||||
/// synced block. The init note's nullifier is deterministic on ID, so we await it and let
|
||||
/// the nullifier pass decode the init and every subsequent update.
|
||||
///
|
||||
/// If no initialization is found, will return `Ok` and default to usual hot-sync.
|
||||
async fn catch_up_shared_account(&mut self, account_id: AccountId) -> Result<()> {
|
||||
use futures::TryStreamExt as _;
|
||||
|
||||
let cursor = self.storage.last_synced_block();
|
||||
if cursor == 0
|
||||
|| self
|
||||
.storage
|
||||
.key_chain()
|
||||
.shared_private_account(account_id)
|
||||
.is_none()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("Scanning shared account {account_id:#?} from genesis to block {cursor}");
|
||||
|
||||
let mut index = NullifierIndex::default();
|
||||
index.track_initialization(account_id);
|
||||
|
||||
let poller = self.optimal_poller();
|
||||
let mut blocks = std::pin::pin!(poller.poll_block_range(1..=cursor));
|
||||
while let Some(block) = blocks.try_next().await? {
|
||||
for tx in block.body.transactions {
|
||||
let LeeTransaction::PrivacyPreserving(pp_tx) = &tx else {
|
||||
continue;
|
||||
};
|
||||
pp_tx.message.validate_note_lengths()?;
|
||||
// Sync updates while watching only the init nullifier.
|
||||
self.storage
|
||||
.key_chain_mut()
|
||||
.sync_updates_via_nullifiers(&pp_tx.message, &mut index);
|
||||
}
|
||||
}
|
||||
|
||||
let now = self.storage.last_synced_block();
|
||||
// This is a defence-in-depth. Currently during the async update the cursor
|
||||
// cannot advance. However, de-sync can be possible later. This hard error
|
||||
// will signal this.
|
||||
if now != cursor {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Shared-account catched-up to {cursor} with a cursor de-sync advancing to {now}"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a shared PDA account from a group's GMS. Returns the `AccountId` and derived keys.
|
||||
pub fn create_shared_pda_account(
|
||||
pub async fn create_shared_pda_account(
|
||||
&mut self,
|
||||
group_name: Label,
|
||||
pda_seed: lee_core::program::PdaSeed,
|
||||
@ -452,6 +504,7 @@ impl WalletCore {
|
||||
Some(pda_seed),
|
||||
Some(program_id),
|
||||
);
|
||||
self.catch_up_shared_account(account_id).await?;
|
||||
|
||||
Ok(SharedAccountInfo {
|
||||
account_id,
|
||||
@ -460,14 +513,21 @@ impl WalletCore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a shared regular private account from a group's GMS. Returns the `AccountId` and
|
||||
/// derived keys. The derivation seed is computed deterministically from a random identifier.
|
||||
pub fn create_shared_regular_account(
|
||||
/// Create a shared regular private account from a group's GMS with a random identifier.
|
||||
pub async fn create_shared_regular_account(
|
||||
&mut self,
|
||||
group_name: Label,
|
||||
) -> Result<SharedAccountInfo> {
|
||||
let identifier: lee_core::Identifier = rand::random();
|
||||
self.create_shared_regular_account_with_identifier(group_name, rand::random())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create a shared regular private account from a group's GMS under the given `identifier`.
|
||||
pub async fn create_shared_regular_account_with_identifier(
|
||||
&mut self,
|
||||
group_name: Label,
|
||||
identifier: lee_core::Identifier,
|
||||
) -> Result<SharedAccountInfo> {
|
||||
let holder = self
|
||||
.storage
|
||||
.key_chain()
|
||||
@ -480,6 +540,7 @@ impl WalletCore {
|
||||
let account_id = AccountId::from((&npk, &vpk, identifier));
|
||||
|
||||
self.register_shared_account(account_id, group_name, identifier, None, None);
|
||||
self.catch_up_shared_account(account_id).await?;
|
||||
|
||||
Ok(SharedAccountInfo {
|
||||
account_id,
|
||||
|
||||
@ -90,6 +90,14 @@ impl NullifierIndex {
|
||||
);
|
||||
}
|
||||
|
||||
/// Indexes `account_id` by the nullifier its initialization publishes.
|
||||
pub fn track_initialization(&mut self, account_id: AccountId) {
|
||||
self.0.insert(
|
||||
Nullifier::for_account_initialization(&account_id),
|
||||
account_id,
|
||||
);
|
||||
}
|
||||
|
||||
/// Replaces a spent nullifier with the account's `next` one.
|
||||
pub fn update(&mut self, spent: &Nullifier, next: Nullifier, account_id: AccountId) {
|
||||
self.0.remove(spent);
|
||||
@ -987,6 +995,93 @@ mod tests {
|
||||
assert!(index.account_for(&old_nullifier).is_none());
|
||||
}
|
||||
|
||||
// The genesis catch-up seeds only the init nullifier and lets the nullifier pass decode the
|
||||
// init note and every subsequent (randomly-tagged) update. Verify a shared account rolls from
|
||||
// default through its init to a later update purely by nullifier — the path the catch-up runs.
|
||||
#[test]
|
||||
fn nullifier_sync_catches_up_shared_account_from_init() {
|
||||
let mut kc = UserKeyChain::default();
|
||||
|
||||
let label = Label::new("group");
|
||||
let holder = GroupKeyHolder::new();
|
||||
let identifier = 0;
|
||||
let keys = holder.derive_regular_shared_account_keys_from_identifier(identifier);
|
||||
let npk = keys.generate_nullifier_public_key();
|
||||
let vpk = keys.generate_viewing_public_key();
|
||||
let nsk = keys.nullifier_secret_key;
|
||||
let account_id = AccountId::from((&npk, &vpk, identifier));
|
||||
|
||||
kc.insert_group_key_holder(label.clone(), holder);
|
||||
kc.insert_shared_private_account(
|
||||
account_id,
|
||||
SharedAccountEntry {
|
||||
group_label: label,
|
||||
identifier,
|
||||
pda_seed: None,
|
||||
authority_program_id: None,
|
||||
account: Account::default(),
|
||||
},
|
||||
);
|
||||
|
||||
let mut index = NullifierIndex::default();
|
||||
index.track_initialization(account_id);
|
||||
|
||||
// A note publishing `spent` and carrying the state `next`.
|
||||
let make_message = |spent: Nullifier, next: &Account| {
|
||||
let commitment = Commitment::new(&account_id, next);
|
||||
let (sender_ss, epk) = SharedSecretKey::encapsulate(&vpk);
|
||||
let ciphertext = EncryptionScheme::encrypt(
|
||||
next,
|
||||
&PrivateAccountKind::Regular(identifier),
|
||||
&sender_ss,
|
||||
&commitment,
|
||||
0,
|
||||
);
|
||||
let note = EncryptedAccountData::new(ciphertext, &npk, &vpk, epk);
|
||||
Message {
|
||||
encrypted_private_post_states: vec![note],
|
||||
new_commitments: vec![commitment],
|
||||
new_nullifiers: vec![(spent, [0; 32])],
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
|
||||
// Init: default -> initialized, discovered via the seeded init nullifier.
|
||||
let initialized = Account {
|
||||
balance: 250,
|
||||
..Account::default()
|
||||
};
|
||||
let init_msg = make_message(
|
||||
Nullifier::for_account_initialization(&account_id),
|
||||
&initialized,
|
||||
);
|
||||
assert_eq!(
|
||||
kc.sync_updates_via_nullifiers(&init_msg, &mut index),
|
||||
HashSet::from([0])
|
||||
);
|
||||
assert_eq!(
|
||||
kc.shared_private_account(account_id).unwrap().account,
|
||||
initialized
|
||||
);
|
||||
|
||||
// Update: initialized -> updated, discovered via the now-tracked update nullifier.
|
||||
let updated = Account {
|
||||
balance: 500,
|
||||
..Account::default()
|
||||
};
|
||||
let update_spent =
|
||||
Nullifier::for_account_update(&Commitment::new(&account_id, &initialized), &nsk);
|
||||
let update_msg = make_message(update_spent, &updated);
|
||||
assert_eq!(
|
||||
kc.sync_updates_via_nullifiers(&update_msg, &mut index),
|
||||
HashSet::from([0])
|
||||
);
|
||||
assert_eq!(
|
||||
kc.shared_private_account(account_id).unwrap().account,
|
||||
updated
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nullifier_sync_ignores_unindexed_nullifier() {
|
||||
let mut kc = UserKeyChain::default();
|
||||
|
||||
Binary file not shown.
@ -11,9 +11,9 @@ use sequencer_core::block_store::SequencerStore;
|
||||
use test_fixtures::{
|
||||
config,
|
||||
setup::{
|
||||
prebuilt_sequencer_db_dump_path, setup_bedrock_node,
|
||||
SequencerSetup, prebuilt_sequencer_db_dump_path, setup_bedrock_node,
|
||||
setup_private_accounts_with_initial_supply, setup_public_accounts_with_initial_supply,
|
||||
setup_sequencer, setup_wallet,
|
||||
setup_wallet,
|
||||
},
|
||||
};
|
||||
use wallet::config::WalletConfigOverrides;
|
||||
@ -49,15 +49,12 @@ async fn generate_prebuilt_fixture(dest: &Path) -> Result<()> {
|
||||
let genesis =
|
||||
config::genesis_from_accounts(&initial_public_accounts, &initial_private_accounts);
|
||||
|
||||
let (sequencer_handle, temp_sequencer_dir) = setup_sequencer(
|
||||
config::SequencerPartialConfig::default(),
|
||||
bedrock_addr,
|
||||
genesis,
|
||||
config::bedrock_channel_id(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.context("Failed to setup Sequencer for fixture generation")?;
|
||||
let (sequencer_handle, temp_sequencer_dir) =
|
||||
SequencerSetup::new(config::SequencerPartialConfig::default(), bedrock_addr)
|
||||
.with_genesis(genesis)
|
||||
.setup()
|
||||
.await
|
||||
.context("Failed to setup Sequencer for fixture generation")?;
|
||||
|
||||
let (mut wallet, _temp_wallet_dir, _wallet_password) = setup_wallet(
|
||||
sequencer_handle.addr(),
|
||||
|
||||
@ -80,7 +80,26 @@ pub fn sequencer_config(
|
||||
home: PathBuf,
|
||||
bedrock_addr: SocketAddr,
|
||||
genesis_transactions: Vec<GenesisAction>,
|
||||
cross_zone: Option<CrossZoneConfig>,
|
||||
) -> Result<SequencerConfig> {
|
||||
sequencer_config_with_channel(
|
||||
partial,
|
||||
home,
|
||||
bedrock_addr,
|
||||
bedrock_channel_id(),
|
||||
genesis_transactions,
|
||||
cross_zone,
|
||||
)
|
||||
}
|
||||
|
||||
/// Like [`sequencer_config`] but with an explicit Bedrock `channel_id`, so tests
|
||||
/// can point a sequencer at a fresh/empty channel (e.g. to model a wiped Bedrock).
|
||||
pub fn sequencer_config_with_channel(
|
||||
partial: SequencerPartialConfig,
|
||||
home: PathBuf,
|
||||
bedrock_addr: SocketAddr,
|
||||
channel_id: ChannelId,
|
||||
genesis_transactions: Vec<GenesisAction>,
|
||||
cross_zone: Option<CrossZoneConfig>,
|
||||
) -> Result<SequencerConfig> {
|
||||
let SequencerPartialConfig {
|
||||
|
||||
@ -12,7 +12,7 @@ use lee_core::Commitment;
|
||||
use log::{debug, error};
|
||||
use sequencer_core::config::GenesisAction;
|
||||
use sequencer_service::SequencerHandle;
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
|
||||
use serde::Serialize;
|
||||
use tempfile::TempDir;
|
||||
use testcontainers::compose::DockerCompose;
|
||||
@ -24,8 +24,8 @@ use wallet::{
|
||||
use crate::{
|
||||
indexer_client::IndexerClient,
|
||||
setup::{
|
||||
setup_bedrock_node, setup_indexer, setup_private_accounts_with_initial_supply,
|
||||
setup_public_accounts_with_initial_supply, setup_sequencer, setup_sequencer_from_prebuilt,
|
||||
SequencerSetup, setup_bedrock_node, setup_indexer,
|
||||
setup_private_accounts_with_initial_supply, setup_public_accounts_with_initial_supply,
|
||||
setup_wallet, sync_wallet_from_prebuilt,
|
||||
},
|
||||
};
|
||||
@ -340,9 +340,7 @@ impl TestContextBuilder {
|
||||
setup_indexer(bedrock_addr, config::bedrock_channel_id(), None)
|
||||
.await
|
||||
.context("Failed to setup Indexer")?;
|
||||
let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, indexer_handle.addr())
|
||||
.context("Failed to convert indexer addr to URL")?;
|
||||
let indexer_client = IndexerClient::new(&indexer_url)
|
||||
let indexer_client = setup::indexer_client(indexer_handle.addr())
|
||||
.await
|
||||
.context("Failed to create indexer client")?;
|
||||
Some(IndexerComponents {
|
||||
@ -359,11 +357,8 @@ impl TestContextBuilder {
|
||||
|
||||
let partial_config = sequencer_partial_config.unwrap_or_default();
|
||||
|
||||
let (sequencer_handle, temp_sequencer_dir) = if use_prebuilt {
|
||||
setup_sequencer_from_prebuilt(partial_config, bedrock_addr)
|
||||
.await
|
||||
.context("Failed to setup Sequencer from prebuilt database")?
|
||||
} else {
|
||||
let mut sequencer_setup = SequencerSetup::new(partial_config, bedrock_addr);
|
||||
if !use_prebuilt {
|
||||
// Wallet genesis must always be present so that
|
||||
// setup_public/private_accounts_with_initial_supply can claim from the vault PDAs.
|
||||
// When a test supplies custom genesis, merge rather than replace.
|
||||
@ -376,16 +371,12 @@ impl TestContextBuilder {
|
||||
}
|
||||
None => wallet_genesis,
|
||||
};
|
||||
setup_sequencer(
|
||||
partial_config,
|
||||
bedrock_addr,
|
||||
genesis,
|
||||
config::bedrock_channel_id(),
|
||||
None,
|
||||
)
|
||||
sequencer_setup = sequencer_setup.with_genesis(genesis);
|
||||
}
|
||||
let (sequencer_handle, temp_sequencer_dir) = sequencer_setup
|
||||
.setup()
|
||||
.await
|
||||
.context("Failed to setup Sequencer")?
|
||||
};
|
||||
.context("Failed to setup Sequencer")?;
|
||||
|
||||
let (mut wallet, temp_wallet_dir, wallet_password) = setup_wallet(
|
||||
sequencer_handle.addr(),
|
||||
@ -411,10 +402,7 @@ impl TestContextBuilder {
|
||||
.context("Failed to initialize private accounts in wallet")?;
|
||||
}
|
||||
|
||||
let sequencer_url = config::addr_to_url(config::UrlProtocol::Http, sequencer_handle.addr())
|
||||
.context("Failed to convert sequencer addr to URL")?;
|
||||
let sequencer_client = SequencerClientBuilder::default()
|
||||
.build(sequencer_url)
|
||||
let sequencer_client = setup::sequencer_client(sequencer_handle.addr())
|
||||
.context("Failed to create sequencer client")?;
|
||||
|
||||
Ok(TestContext {
|
||||
|
||||
@ -1,11 +1,18 @@
|
||||
use std::{net::SocketAddr, path::PathBuf};
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use indexer_service::{ChannelId, IndexerHandle};
|
||||
use lee::{AccountId, PrivateKey, PublicKey};
|
||||
use log::{debug, warn};
|
||||
use sequencer_core::block_store::{DbDump, SequencerStore};
|
||||
use sequencer_core::{
|
||||
block_publisher::ED25519_SECRET_KEY_SIZE,
|
||||
block_store::{DbDump, SequencerStore},
|
||||
};
|
||||
use sequencer_service::{GenesisAction, SequencerHandle};
|
||||
use sequencer_service_rpc::{SequencerClient, SequencerClientBuilder};
|
||||
use tempfile::TempDir;
|
||||
use testcontainers::compose::DockerCompose;
|
||||
use wallet::{
|
||||
@ -17,15 +24,127 @@ use wallet::{
|
||||
use crate::{
|
||||
BEDROCK_SERVICE_PORT, BEDROCK_SERVICE_WITH_OPEN_PORT,
|
||||
config::{self, InitialPrivateAccountForWallet},
|
||||
indexer_client::IndexerClient,
|
||||
private_mention, public_mention,
|
||||
};
|
||||
|
||||
/// How to initialize the sequencer's database.
|
||||
pub enum SequencerInit<'dump> {
|
||||
/// Apply these genesis actions from scratch.
|
||||
Genesis(Vec<GenesisAction>),
|
||||
/// Restore an existing chain from a prebuilt dump (genesis actions are then irrelevant).
|
||||
Prebuilt(&'dump DbDump),
|
||||
pub struct SequencerSetup {
|
||||
partial: config::SequencerPartialConfig,
|
||||
bedrock_addr: SocketAddr,
|
||||
channel_id: ChannelId,
|
||||
genesis_transactions: Option<Vec<GenesisAction>>,
|
||||
cross_zone: Option<sequencer_core::config::CrossZoneConfig>,
|
||||
bedrock_signing_key: Option<[u8; ED25519_SECRET_KEY_SIZE]>,
|
||||
}
|
||||
|
||||
impl SequencerSetup {
|
||||
#[must_use]
|
||||
pub fn new(partial: config::SequencerPartialConfig, bedrock_addr: SocketAddr) -> Self {
|
||||
Self {
|
||||
partial,
|
||||
bedrock_addr,
|
||||
channel_id: config::bedrock_channel_id(),
|
||||
genesis_transactions: None,
|
||||
cross_zone: None,
|
||||
bedrock_signing_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the Bedrock channel ID to use for the sequencer.
|
||||
/// If not set, the default channel ID from the Bedrock config will be used.
|
||||
#[must_use]
|
||||
pub const fn with_channel_id(mut self, channel_id: ChannelId) -> Self {
|
||||
self.channel_id = channel_id;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the cross-zone configuration to use for the sequencer.
|
||||
/// If not set, the sequencer will be configured to run in single-zone mode.
|
||||
#[must_use]
|
||||
pub fn with_cross_zone(mut self, cross_zone: sequencer_core::config::CrossZoneConfig) -> Self {
|
||||
self.cross_zone = Some(cross_zone);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the genesis transactions to apply when initializing the sequencer.
|
||||
/// If not set, the sequencer will be initialized from a prebuilt database dump.
|
||||
#[must_use]
|
||||
pub fn with_genesis(mut self, genesis_transactions: Vec<GenesisAction>) -> Self {
|
||||
self.genesis_transactions = Some(genesis_transactions);
|
||||
self
|
||||
}
|
||||
|
||||
/// Pre-write a bedrock (Ed25519, 32-byte seed) signing key into the home
|
||||
/// before boot, so tests know the sequencer's public key in advance (e.g.
|
||||
/// to accredit a committee member that has not started yet).
|
||||
#[must_use]
|
||||
pub const fn with_bedrock_signing_key(mut self, key: [u8; ED25519_SECRET_KEY_SIZE]) -> Self {
|
||||
self.bedrock_signing_key = Some(key);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set up the sequencer in a fresh temporary home directory, returning the
|
||||
/// owning [`TempDir`] alongside the handle.
|
||||
pub async fn setup(self) -> Result<(SequencerHandle, TempDir)> {
|
||||
let temp_sequencer_dir =
|
||||
tempfile::tempdir().context("Failed to create temp dir for sequencer home")?;
|
||||
|
||||
let sequencer_handle = self.setup_at(temp_sequencer_dir.path()).await?;
|
||||
|
||||
Ok((sequencer_handle, temp_sequencer_dir))
|
||||
}
|
||||
|
||||
/// Set up the sequencer in an explicit `home` directory owned by the caller.
|
||||
///
|
||||
/// Useful for tests that restart the sequencer against the same on-disk store.
|
||||
pub async fn setup_at(self, home: &Path) -> Result<SequencerHandle> {
|
||||
let Self {
|
||||
partial,
|
||||
bedrock_addr,
|
||||
channel_id,
|
||||
genesis_transactions,
|
||||
cross_zone,
|
||||
bedrock_signing_key,
|
||||
} = self;
|
||||
|
||||
debug!("Using sequencer home at {}", home.display());
|
||||
|
||||
if let Some(key_bytes) = bedrock_signing_key {
|
||||
std::fs::write(home.join("bedrock_signing_key"), key_bytes)
|
||||
.context("Failed to write pre-generated bedrock signing key")?;
|
||||
}
|
||||
|
||||
let genesis_transactions = if let Some(genesis) = genesis_transactions {
|
||||
genesis
|
||||
} else {
|
||||
let dump = load_prebuilt_dump()?;
|
||||
// `SequencerCore::open_or_create_store` looks for `<home>/rocksdb`.
|
||||
let dst = home.join("rocksdb");
|
||||
let _store = SequencerStore::restore_db_from_dump(
|
||||
&dst,
|
||||
&dump,
|
||||
lee::PrivateKey::try_new(config::SEQUENCER_SIGNING_KEY)?,
|
||||
)
|
||||
.context("Failed to restore prebuilt sequencer database from dump")?;
|
||||
// TODO: Technically not correct, we should reconstruct the genesis transactions
|
||||
// from the dump, but this crutch doesn't affect anything for now
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let config = config::sequencer_config_with_channel(
|
||||
partial,
|
||||
home.to_owned(),
|
||||
bedrock_addr,
|
||||
channel_id,
|
||||
genesis_transactions,
|
||||
cross_zone,
|
||||
)
|
||||
.context("Failed to create Sequencer config")?;
|
||||
|
||||
sequencer_service::run(config, SocketAddr::from(([127, 0, 0, 1], 0)))
|
||||
.await
|
||||
.context("Failed to run Sequencer Service")
|
||||
}
|
||||
}
|
||||
|
||||
/// Committed single-file dump of the prebuilt sequencer database (`just regenerate-test-fixture`).
|
||||
@ -42,6 +161,24 @@ fn load_prebuilt_dump() -> Result<DbDump> {
|
||||
DbDump::from_bytes(&bytes).context("Failed to deserialize prebuilt db dump")
|
||||
}
|
||||
|
||||
/// Builds an HTTP RPC client for the sequencer at `addr`.
|
||||
pub fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
|
||||
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
|
||||
.context("Failed to build sequencer URL")?;
|
||||
SequencerClientBuilder::default()
|
||||
.build(url)
|
||||
.context("Failed to build sequencer client")
|
||||
}
|
||||
|
||||
/// Builds a WebSocket RPC client for the indexer at `addr`.
|
||||
pub async fn indexer_client(addr: SocketAddr) -> Result<IndexerClient> {
|
||||
let url = config::addr_to_url(config::UrlProtocol::Ws, addr)
|
||||
.context("Failed to build indexer URL")?;
|
||||
IndexerClient::new(&url)
|
||||
.await
|
||||
.context("Failed to build indexer client")
|
||||
}
|
||||
|
||||
pub async fn setup_bedrock_node() -> Result<(DockerCompose, SocketAddr)> {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let bedrock_compose_path = PathBuf::from(manifest_dir).join("../bedrock/docker-compose.yml");
|
||||
@ -139,81 +276,6 @@ pub async fn setup_indexer(
|
||||
.map(|handle| (handle, temp_indexer_dir))
|
||||
}
|
||||
|
||||
pub async fn setup_sequencer(
|
||||
partial: config::SequencerPartialConfig,
|
||||
bedrock_addr: SocketAddr,
|
||||
genesis_transactions: Vec<GenesisAction>,
|
||||
channel_id: ChannelId,
|
||||
cross_zone: Option<sequencer_core::config::CrossZoneConfig>,
|
||||
) -> Result<(SequencerHandle, TempDir)> {
|
||||
setup_sequencer_inner(
|
||||
partial,
|
||||
bedrock_addr,
|
||||
SequencerInit::Genesis(genesis_transactions),
|
||||
channel_id,
|
||||
cross_zone,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Like [`setup_sequencer`], but rebuilds the sequencer database from the committed prebuilt dump
|
||||
/// so it starts from an existing chain instead of applying genesis from scratch.
|
||||
pub async fn setup_sequencer_from_prebuilt(
|
||||
partial: config::SequencerPartialConfig,
|
||||
bedrock_addr: SocketAddr,
|
||||
) -> Result<(SequencerHandle, TempDir)> {
|
||||
let dump = load_prebuilt_dump()?;
|
||||
setup_sequencer_inner(
|
||||
partial,
|
||||
bedrock_addr,
|
||||
SequencerInit::Prebuilt(&dump),
|
||||
config::bedrock_channel_id(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn setup_sequencer_inner(
|
||||
partial: config::SequencerPartialConfig,
|
||||
bedrock_addr: SocketAddr,
|
||||
init: SequencerInit<'_>,
|
||||
channel_id: ChannelId,
|
||||
cross_zone: Option<sequencer_core::config::CrossZoneConfig>,
|
||||
) -> Result<(SequencerHandle, TempDir)> {
|
||||
let temp_sequencer_dir =
|
||||
tempfile::tempdir().context("Failed to create temp dir for sequencer home")?;
|
||||
|
||||
debug!(
|
||||
"Using temp sequencer home at {}",
|
||||
temp_sequencer_dir.path().display()
|
||||
);
|
||||
|
||||
let genesis_transactions = match init {
|
||||
SequencerInit::Genesis(genesis) => genesis,
|
||||
SequencerInit::Prebuilt(dump) => {
|
||||
// `SequencerCore::open_or_create_store` looks for `<home>/rocksdb`.
|
||||
let dst = temp_sequencer_dir.path().join("rocksdb");
|
||||
SequencerStore::restore_db_from_dump(&dst, dump)
|
||||
.context("Failed to restore prebuilt sequencer database from dump")?;
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
let config = config::sequencer_config(
|
||||
partial,
|
||||
temp_sequencer_dir.path().to_owned(),
|
||||
bedrock_addr,
|
||||
genesis_transactions,
|
||||
channel_id,
|
||||
cross_zone,
|
||||
)
|
||||
.context("Failed to create Sequencer config")?;
|
||||
|
||||
let sequencer_handle = sequencer_service::run(config, 0).await?;
|
||||
|
||||
Ok((sequencer_handle, temp_sequencer_dir))
|
||||
}
|
||||
|
||||
pub async fn setup_wallet(
|
||||
sequencer_addr: SocketAddr,
|
||||
initial_public_accounts: &[(PrivateKey, u128)],
|
||||
|
||||
@ -66,7 +66,7 @@ use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuil
|
||||
use serde::{Deserialize, Serialize};
|
||||
use test_fixtures::{
|
||||
config::{self, SequencerPartialConfig, UrlProtocol, bedrock_channel_id, bedrock_channel_id_b},
|
||||
setup::{setup_bedrock_node, setup_sequencer},
|
||||
setup::{SequencerSetup, setup_bedrock_node},
|
||||
};
|
||||
|
||||
const HTTP_PORT: u16 = 8088;
|
||||
@ -289,14 +289,20 @@ async fn main() -> Result<()> {
|
||||
let cross_zone_b = watch_peer(zone_a, receiver_id);
|
||||
|
||||
let partial = SequencerPartialConfig::default();
|
||||
let (seq_a, _home_a) =
|
||||
setup_sequencer(partial, bedrock_addr, vec![], channel_a, Some(cross_zone_a))
|
||||
.await
|
||||
.context("Failed to set up zone A sequencer")?;
|
||||
let (seq_b, _home_b) =
|
||||
setup_sequencer(partial, bedrock_addr, vec![], channel_b, Some(cross_zone_b))
|
||||
.await
|
||||
.context("Failed to set up zone B sequencer")?;
|
||||
let (seq_a, _home_a) = SequencerSetup::new(partial, bedrock_addr)
|
||||
.with_genesis(vec![])
|
||||
.with_channel_id(channel_a)
|
||||
.with_cross_zone(cross_zone_a)
|
||||
.setup()
|
||||
.await
|
||||
.context("Failed to set up zone A sequencer")?;
|
||||
let (seq_b, _home_b) = SequencerSetup::new(partial, bedrock_addr)
|
||||
.with_genesis(vec![])
|
||||
.with_channel_id(channel_b)
|
||||
.with_cross_zone(cross_zone_b)
|
||||
.setup()
|
||||
.await
|
||||
.context("Failed to set up zone B sequencer")?;
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
zone_a: ZoneRuntime {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user