Merge 9380198eb9aafd117c8cb7351ad579f0286c5b17 into 390f4b48d982511883eec8628a9742976f8de8d3

This commit is contained in:
Moudy 2026-07-16 12:48:27 +00:00 committed by GitHub
commit 835a061f5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 554 additions and 990 deletions

3
Cargo.lock generated
View File

@ -1863,6 +1863,8 @@ dependencies = [
"ping_core",
"programs",
"risc0-zkvm",
"serde",
"wrapped_token_core",
]
[[package]]
@ -9976,7 +9978,6 @@ dependencies = [
"programs",
"serde",
"system_accounts",
"wrapped_token_core",
]
[[package]]

View File

@ -8,6 +8,13 @@
//! zone B, where the indexer re-derives and verifies it (Option B) before the
//! 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.
//!
//! Not production-safe. The inbox allowlist gates the target program, not the
//! source emitter, and `extract_emission` recognizes any known emitter, so in a
//! zone that allows `wrapped_token` as a target a permissionless `ping_sender`
//! send can carry a `wrapped_token::Mint` and mint with no lock. Making this safe
//! needs source verification, where a value-bearing target checks the message
//! originated from `bridge_lock`; that is out of scope for the demo.
use std::{net::SocketAddr, time::Duration};
@ -58,25 +65,31 @@ async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> {
// Zone A seeds the holder's bridgeable balance. Zone B runs the watcher on its
// sequencer and the verifier on its indexer.
let genesis_a = vec![GenesisAction::SupplyBridgeLockHolding {
let mut genesis_a = vec![GenesisAction::SupplyBridgeLockHolding {
holder: holder_id,
amount: INITIAL_BALANCE,
}];
genesis_a.extend(config::cross_zone_deploy_actions());
let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, genesis_a, channel_a, None)
.await
.context("Failed to set up zone A sequencer")?;
let (_seq_b, _seq_b_home) = setup_sequencer(
partial,
bedrock_addr,
vec![],
config::cross_zone_deploy_actions(),
channel_b,
Some(cross_zone.clone()),
)
.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")?;
let (idx_b, _idx_b_home) = setup_indexer(
bedrock_addr,
channel_b,
Some(cross_zone),
config::all_cross_zone_programs(),
)
.await
.context("Failed to set up zone B indexer")?;
// Lock LOCK_AMOUNT on zone A, addressed to the recipient on zone B.
let lock = build_lock_tx(&holder_key, holder_id, zone_b);

View File

@ -34,9 +34,15 @@ 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)
.await
.context("Failed to set up sequencer")?;
let (seq, _seq_home) = setup_sequencer(
partial,
bedrock_addr,
config::cross_zone_deploy_actions(),
channel,
None,
)
.await
.context("Failed to set up sequencer")?;
// A user hand-builds a top-level inbox Dispatch and submits it via RPC.
let inbox_id = programs::cross_zone_inbox().id();

View File

@ -54,13 +54,24 @@ 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)
.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_a, _seq_a_home) = setup_sequencer(
partial,
bedrock_addr,
config::cross_zone_deploy_actions(),
channel_a,
None,
)
.await
.context("Failed to set up zone A sequencer")?;
let (seq_b, _seq_b_home) = setup_sequencer(
partial,
bedrock_addr,
config::cross_zone_deploy_actions(),
channel_b,
Some(cross_zone),
)
.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);

View File

@ -54,7 +54,7 @@ fn seed_inbox_config(
allowed_peers: BTreeMap::new(),
allowed_targets,
};
state.insert_genesis_account(
*state = std::mem::replace(state, V03State::new()).with_public_accounts([(
inbox_config_account_id(inbox_id),
Account {
program_owner: inbox_id,
@ -65,14 +65,14 @@ fn seed_inbox_config(
.expect("config fits in account data"),
nonce: 0_u128.into(),
},
);
)]);
}
/// Seeds the wrapped-token config account pinning the inbox as authorized minter,
/// matching what genesis seeds for a real zone.
fn seed_wrapped_config(state: &mut V03State) {
let wrapped_token_id = programs::wrapped_token().id();
state.insert_genesis_account(
*state = std::mem::replace(state, V03State::new()).with_public_accounts([(
wrapped_token_core::config_account_id(wrapped_token_id),
Account {
program_owner: wrapped_token_id,
@ -82,7 +82,7 @@ fn seed_wrapped_config(state: &mut V03State) {
.expect("minter id fits in account data"),
..Default::default()
},
);
)]);
}
/// The wrapped-token `Mint` the bridge forwards, serialized as the cross-zone
@ -170,7 +170,7 @@ fn lock_escrows_balance_and_emits_to_outbox() {
let holder_key = PrivateKey::try_new([7; 32]).expect("valid key");
let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key));
state.insert_genesis_account(
state = state.with_public_accounts([(
holder_id,
Account {
program_owner: bridge_lock_id,
@ -181,7 +181,7 @@ fn lock_escrows_balance_and_emits_to_outbox() {
.expect("balance fits in account data"),
nonce: 0_u128.into(),
},
);
)]);
let payload = mint_payload();
let target_accounts = vec![
@ -314,7 +314,7 @@ fn mint_replay_rejected() {
let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id);
let mut shard = SeenShard::default();
shard.insert(message_key(&src_zone, src_block_id, src_tx_index));
state.insert_genesis_account(
state = state.with_public_accounts([(
seen_id,
Account {
program_owner: inbox_id,
@ -325,7 +325,7 @@ fn mint_replay_rejected() {
.expect("shard fits in account data"),
nonce: 0_u128.into(),
},
);
)]);
let msg = CrossZoneMessage {
src_zone,

View File

@ -53,24 +53,40 @@ 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)
.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_a, _seq_a_home) = setup_sequencer(
partial,
bedrock_addr,
config::cross_zone_deploy_actions(),
channel_a,
None,
)
.await
.context("Failed to set up zone A sequencer")?;
let (_idx_a, _idx_a_home) = setup_indexer(
bedrock_addr,
channel_a,
None,
config::all_cross_zone_programs(),
)
.await
.context("Failed to set up zone A indexer")?;
let (_seq_b, _seq_b_home) = setup_sequencer(
partial,
bedrock_addr,
vec![],
config::cross_zone_deploy_actions(),
channel_b,
Some(cross_zone.clone()),
)
.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")?;
let (idx_b, _idx_b_home) = setup_indexer(
bedrock_addr,
channel_b,
Some(cross_zone),
config::all_cross_zone_programs(),
)
.await
.context("Failed to set up zone B indexer")?;
// Submit the ping on zone A, addressed to ping_receiver on zone B.
let ping = build_ping_tx(zone_b, receiver_id);

View File

@ -54,6 +54,7 @@ pub fn setup_indexer_ffi(bedrock_addr: SocketAddr) -> Result<(IndexerServiceFFI,
bedrock_addr,
integration_tests::config::bedrock_channel_id(),
None,
Vec::new(),
)
.context("Failed to create Indexer config")?;

View File

@ -38,13 +38,13 @@ async fn two_zones_share_one_bedrock_and_both_advance() -> Result<()> {
let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_a, None)
.await
.context("Failed to set up zone A sequencer")?;
let (idx_a, _idx_a_home) = setup_indexer(bedrock_addr, channel_a, None)
let (idx_a, _idx_a_home) = setup_indexer(bedrock_addr, channel_a, None, Vec::new())
.await
.context("Failed to set up zone A indexer")?;
let (seq_b, _seq_b_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_b, None)
.await
.context("Failed to set up zone B sequencer")?;
let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, None)
let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, None, Vec::new())
.await
.context("Failed to set up zone B indexer")?;

View File

@ -5,7 +5,7 @@
pub use lee_core::{
GENESIS_BLOCK_ID, SharedSecretKey,
account::{Account, AccountId, Data},
account::{Account, AccountId, Balance, Data},
encryption::EphemeralPublicKey,
program::ProgramId,
};

View File

@ -199,14 +199,6 @@ impl V03State {
self.programs.insert(program.id(), program);
}
/// Seeds a single genesis account that is not produced by any transaction
/// (e.g. the cross-zone inbox config or a bridge-lock holding). Lets the
/// sequencer and indexer seed identical zone-specific state after building
/// the shared initial state.
pub fn insert_genesis_account(&mut self, account_id: AccountId, account: Account) {
self.public_state.insert(account_id, account);
}
pub fn apply_state_diff(&mut self, diff: ValidatedStateDiff) {
let StateDiff {
signer_account_ids,
@ -292,6 +284,50 @@ impl V03State {
self.private_state.0.digest()
}
/// Order-independent fingerprint of the genesis-relevant state: the public
/// account set, the deployed program set, and the commitment-set digest.
///
/// The sequencer and the indexer build genesis separately from their own
/// configs (the cross-zone deploy set is seeded directly, not carried by a
/// genesis transaction), so a mismatched `DeployProgram` / `deploy_programs`
/// list would otherwise diverge silently. Both nodes log this at startup;
/// equal values mean the two genesis states agree. Entries are sorted by id
/// before hashing, so the value does not depend on `HashMap` iteration order.
#[must_use]
pub fn genesis_fingerprint(&self) -> [u8; 32] {
use sha2::{Digest as _, Sha256};
let mut accounts: Vec<(&AccountId, &Account)> = self.public_state.iter().collect();
accounts.sort_by(|a, b| a.0.as_ref().cmp(b.0.as_ref()));
let mut program_ids: Vec<ProgramId> = self.programs.keys().copied().collect();
program_ids.sort_unstable();
let account_count = u64::try_from(accounts.len()).expect("account count fits in u64");
let program_count = u64::try_from(program_ids.len()).expect("program count fits in u64");
let mut hasher = Sha256::new();
hasher.update(account_count.to_le_bytes());
for (id, account) in accounts {
hasher.update(id.as_ref());
let bytes = borsh::to_vec(account).expect("Account is BorshSerialize");
let len = u64::try_from(bytes.len()).expect("account encoding fits in u64");
hasher.update(len.to_le_bytes());
hasher.update(&bytes);
}
hasher.update(program_count.to_le_bytes());
for id in program_ids {
for word in id {
hasher.update(word.to_le_bytes());
}
}
hasher.update(self.commitment_set_digest());
let mut out = [0_u8; 32];
out.copy_from_slice(&hasher.finalize());
out
}
pub(crate) fn check_commitments_are_new(
&self,
new_commitments: &[Commitment],

View File

@ -14,4 +14,6 @@ programs.workspace = true
cross_zone_inbox_core.workspace = true
bridge_lock_core.workspace = true
ping_core.workspace = true
wrapped_token_core.workspace = true
serde.workspace = true
risc0-zkvm.workspace = true

View File

@ -2,6 +2,12 @@
//! machine (`lee`), kept out of the guest-pure cores. Mirrors `system_accounts`:
//! it resolves builtin program ids and bakes them into transactions and genesis
//! accounts for the watcher (sequencer) and verifier (indexer).
//!
//! This crate is the reference LEZ-to-LEZ adapter: it re-derives each delivery
//! byte-for-byte from a peer LEZ zone's finalized blocks, valid only because the
//! peer runs identical LEZ code. A non-LEZ peer needs a separate adapter with its
//! own block-reading, emission-extraction, delivery-building, and trust model; a
//! shared trait is best lifted from that first real adapter, not from this one.
use std::collections::BTreeMap;
@ -10,10 +16,12 @@ use cross_zone_inbox_core::{
CrossZoneMessage, InboxConfig, Instruction, ZoneId, inbox_config_account_id,
inbox_seen_shard_account_id,
};
use lee::program::Program;
use lee_core::{
account::{Account, AccountId},
account::{Account, AccountId, Balance},
program::ProgramId,
};
use serde::{Deserialize, Serialize};
/// The cross-zone emission fields a watcher or verifier reads off a source
/// transaction, common to every emitter program.
@ -24,6 +32,47 @@ pub struct Emission {
pub payload: Vec<u8>,
}
/// A cross-zone builtin a zone deploys at genesis via `DeployProgram`.
///
/// Keeps the cross-zone programs out of the production builtin set. A zone lists
/// the ones it uses (a sender needs the outbox and its emitter, a receiver the
/// inbox and its targets); [`CrossZoneProgram::ALL`] deploys the whole set.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CrossZoneProgram {
Outbox,
Inbox,
PingSender,
PingReceiver,
BridgeLock,
WrappedToken,
}
impl CrossZoneProgram {
/// Every cross-zone builtin, for a zone that participates in all flows.
pub const ALL: [Self; 6] = [
Self::Outbox,
Self::Inbox,
Self::PingSender,
Self::PingReceiver,
Self::BridgeLock,
Self::WrappedToken,
];
/// Resolves to the builtin program to register in genesis state.
#[must_use]
pub const fn program(self) -> Program {
match self {
Self::Outbox => programs::cross_zone_outbox(),
Self::Inbox => programs::cross_zone_inbox(),
Self::PingSender => programs::ping_sender(),
Self::PingReceiver => programs::ping_receiver(),
Self::BridgeLock => programs::bridge_lock(),
Self::WrappedToken => programs::wrapped_token(),
}
}
}
/// Whether a program may only be invoked by sequencer-origin transactions.
///
/// The cross-zone inbox is injected solely by the watcher; a user-submitted call
@ -176,7 +225,7 @@ pub fn build_inbox_config_account(
/// transaction, so the sequencer and indexer both seed it through this one
/// builder.
#[must_use]
pub fn build_holding_account(holder: AccountId, amount: u128) -> (AccountId, Account) {
pub fn build_holding_account(holder: AccountId, amount: Balance) -> (AccountId, Account) {
let account = Account {
program_owner: programs::bridge_lock().id(),
data: bridge_lock_core::balance_bytes(amount)
@ -187,3 +236,42 @@ pub fn build_holding_account(holder: AccountId, amount: u128) -> (AccountId, Acc
};
(holder, account)
}
/// Resolves a list of [`CrossZoneProgram`] selectors to the builtin programs to
/// register in genesis state.
#[must_use]
pub fn deployed_programs(programs: &[CrossZoneProgram]) -> Vec<Program> {
programs.iter().map(|p| p.program()).collect()
}
/// The wrapped-token config account, pinning the cross-zone inbox as the
/// authorized minter without importing the inbox id into the guest. Fixed for
/// every zone, part of the cross-zone genesis setup.
fn wrapped_token_config_account() -> (AccountId, Account) {
let wrapped_token_id = programs::wrapped_token().id();
(
wrapped_token_core::config_account_id(wrapped_token_id),
Account {
program_owner: wrapped_token_id,
data: wrapped_token_core::minter_bytes(programs::cross_zone_inbox().id())
.to_vec()
.try_into()
.expect("minter id fits in account data"),
..Default::default()
},
)
}
/// The receiving-side cross-zone genesis accounts a zone seeds.
///
/// The wrapped-token config and this zone's inbox config, for a zone that
/// receives (one with `cross_zone` config). Bridge-lock holdings are seeded
/// separately via [`build_holding_account`], since they belong to the locking
/// (source) side and must not depend on receiving config.
#[must_use]
pub fn genesis_accounts(self_zone: ZoneId, config: &CrossZoneConfig) -> Vec<(AccountId, Account)> {
vec![
wrapped_token_config_account(),
build_inbox_config_account(self_zone, config),
]
}

View File

@ -6,9 +6,9 @@ use common::{
block::{BedrockStatus, Block, BlockHeader},
transaction::{LeeTransaction, clock_invocation},
};
use lee::{Account, AccountId, GENESIS_BLOCK_ID, V03State};
use lee::{Account, AccountId, GENESIS_BLOCK_ID, V03State, program::Program};
use lee_core::BlockId;
use log::warn;
use log::{info, warn};
use logos_blockchain_core::header::HeaderId;
use logos_blockchain_zone_sdk::Slot;
use storage::indexer::RocksDBIO;
@ -45,19 +45,28 @@ pub struct IndexerStore {
impl IndexerStore {
/// Starting database at the start of new chain.
/// Creates files if necessary.
pub fn open_db(location: &Path, genesis_seed: Vec<(AccountId, Account)>) -> Result<Self> {
pub fn open_db(
location: &Path,
genesis_programs: Vec<Program>,
genesis_accounts: Vec<(AccountId, Account)>,
) -> Result<Self> {
#[cfg(not(feature = "testnet"))]
let mut initial_state = testnet_initial_state::initial_state();
let base = testnet_initial_state::initial_state();
#[cfg(feature = "testnet")]
let mut initial_state = testnet_initial_state::initial_state_testnet();
let base = testnet_initial_state::initial_state_testnet();
// Seed any zone-specific genesis accounts (the cross-zone inbox config and
// bridge-lock holdings) so the indexer's replayed state matches the
// sequencer's; none are produced by a transaction.
for (account_id, account) in genesis_seed {
initial_state.insert_genesis_account(account_id, account);
}
// Extend with this zone's cross-zone genesis (programs + accounts) through
// the state constructor, matching how the sequencer builds genesis so the
// replayed states stay byte-identical.
let initial_state = base
.with_programs(genesis_programs)
.with_public_accounts(genesis_accounts);
// Same fingerprint the sequencer logs; a mismatch means a divergent deploy set.
info!(
"Genesis fingerprint: {}",
hex::encode(initial_state.genesis_fingerprint())
);
let dbio = RocksDBIO::open_or_create(location, &initial_state)?;
let current_state = dbio.final_state()?;
@ -400,7 +409,7 @@ mod stall_reason_tests {
#[tokio::test]
async fn stall_reason_roundtrips_and_clears() {
let dir = tempfile::tempdir().expect("tempdir");
let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store");
let store = IndexerStore::open_db(dir.path(), Vec::new(), Vec::new()).expect("open store");
assert!(store.get_stall_reason().expect("get").is_none());
@ -447,7 +456,7 @@ mod tests {
fn correct_startup() {
let home = tempdir().unwrap();
let storage = IndexerStore::open_db(home.as_ref(), Vec::new()).unwrap();
let storage = IndexerStore::open_db(home.as_ref(), Vec::new(), Vec::new()).unwrap();
let final_id = storage.get_last_block_id().unwrap();
@ -457,7 +466,7 @@ mod tests {
#[tokio::test]
async fn accept_block_applies_transfers_and_advances_tip() {
let home = tempdir().unwrap();
let store = IndexerStore::open_db(home.as_ref(), Vec::new()).unwrap();
let store = IndexerStore::open_db(home.as_ref(), Vec::new(), Vec::new()).unwrap();
let initial_accounts = initial_pub_accounts_private_keys();
let from = initial_accounts[0].account_id;
@ -499,7 +508,7 @@ mod tests {
#[tokio::test]
async fn account_state_at_block_reflects_history() {
let home = tempdir().unwrap();
let store = IndexerStore::open_db(home.as_ref(), Vec::new()).unwrap();
let store = IndexerStore::open_db(home.as_ref(), Vec::new(), Vec::new()).unwrap();
let initial_accounts = initial_pub_accounts_private_keys();
let from = initial_accounts[0].account_id;
@ -565,7 +574,7 @@ mod accept_tests {
#[tokio::test]
async fn non_genesis_first_block_parks_with_unexpected_id() {
let dir = tempfile::tempdir().expect("tempdir");
let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store");
let store = IndexerStore::open_db(dir.path(), Vec::new(), Vec::new()).expect("open store");
let block = valid_hash_block(2, HashType([0_u8; 32]));
let outcome = store
@ -588,7 +597,7 @@ mod accept_tests {
#[tokio::test]
async fn hash_mismatch_parks() {
let dir = tempfile::tempdir().expect("tempdir");
let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store");
let store = IndexerStore::open_db(dir.path(), Vec::new(), Vec::new()).expect("open store");
let mut block = valid_hash_block(1, HashType([0_u8; 32]));
block.header.timestamp = 999; // invalidates the stored hash
@ -606,7 +615,7 @@ mod accept_tests {
#[tokio::test]
async fn second_break_bumps_orphan_count_and_keeps_first() {
let dir = tempfile::tempdir().expect("tempdir");
let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store");
let store = IndexerStore::open_db(dir.path(), Vec::new(), Vec::new()).expect("open store");
let first = valid_hash_block(2, HashType([0_u8; 32]));
store
@ -627,7 +636,7 @@ mod accept_tests {
#[tokio::test]
async fn deserialize_break_records_stall_without_header() {
let dir = tempfile::tempdir().expect("tempdir");
let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store");
let store = IndexerStore::open_db(dir.path(), Vec::new(), Vec::new()).expect("open store");
store
.record_stall(
@ -645,7 +654,7 @@ mod accept_tests {
#[tokio::test]
async fn parks_then_recovers_on_valid_continuation() {
let dir = tempfile::tempdir().expect("tempdir");
let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store");
let store = IndexerStore::open_db(dir.path(), Vec::new(), Vec::new()).expect("open store");
// Genesis (block 1, clock-only) applies and advances the tip.
let genesis = produce_dummy_block(1, None, vec![]);
@ -693,7 +702,7 @@ mod accept_tests {
#[tokio::test]
async fn accept_block_records_tip_inscription_slot() {
let dir = tempfile::tempdir().expect("tempdir");
let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store");
let store = IndexerStore::open_db(dir.path(), Vec::new(), Vec::new()).expect("open store");
assert_eq!(store.get_tip_slot().expect("get"), None);
@ -735,7 +744,7 @@ mod accept_tests {
use testnet_initial_state::initial_pub_accounts_private_keys;
let dir = tempfile::tempdir().expect("tempdir");
let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store");
let store = IndexerStore::open_db(dir.path(), Vec::new(), Vec::new()).expect("open store");
let accounts = initial_pub_accounts_private_keys();
let from = accounts[0].account_id;
@ -785,7 +794,7 @@ mod accept_tests {
use testnet_initial_state::initial_pub_accounts_private_keys;
let dir = tempfile::tempdir().expect("tempdir");
let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store");
let store = IndexerStore::open_db(dir.path(), Vec::new(), Vec::new()).expect("open store");
let accounts = initial_pub_accounts_private_keys();
let from = accounts[0].account_id;
@ -845,7 +854,7 @@ mod accept_tests {
use testnet_initial_state::initial_pub_accounts_private_keys;
let dir = tempfile::tempdir().expect("tempdir");
let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store");
let store = IndexerStore::open_db(dir.path(), Vec::new(), Vec::new()).expect("open store");
let accounts = initial_pub_accounts_private_keys();
let from = accounts[0].account_id;
@ -882,7 +891,7 @@ mod accept_tests {
// The #605 restart: reopening past the boundary must work.
drop(store);
let reopened = IndexerStore::open_db(dir.path(), Vec::new()).expect("reopen");
let reopened = IndexerStore::open_db(dir.path(), Vec::new(), Vec::new()).expect("reopen");
assert_eq!(reopened.last_block().unwrap(), Some(101));
}
@ -891,7 +900,7 @@ mod accept_tests {
use testnet_initial_state::initial_pub_accounts_private_keys;
let dir = tempfile::tempdir().expect("tempdir");
let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store");
let store = IndexerStore::open_db(dir.path(), Vec::new(), Vec::new()).expect("open store");
let accounts = initial_pub_accounts_private_keys();
let from = accounts[0].account_id;

View File

@ -321,6 +321,7 @@ mod tests {
allow_chain_reset: false,
cross_zone: None,
bridge_lock_holdings: Vec::new(),
deploy_programs: Vec::new(),
};
IndexerCore::open(config, dir).expect("open core")
}

View File

@ -2,6 +2,7 @@ use std::{fs::File, io::BufReader, path::Path, time::Duration};
use anyhow::{Context as _, Result};
use common::config::BasicAuth;
use cross_zone::CrossZoneProgram;
use cross_zone_inbox_core::CrossZoneConfig;
use humantime_serde;
use lee::AccountId;
@ -37,6 +38,12 @@ pub struct IndexerConfig {
/// Defaults to `false`: on mismatch the indexer refuses to start.
#[serde(default)]
pub allow_chain_reset: bool,
/// Cross-zone builtins to deploy at genesis, mirroring the sequencer's
/// `DeployProgram` actions so the replayed genesis is identical. This set
/// must match the sequencer's; compare the genesis fingerprint both nodes
/// log at startup to confirm.
#[serde(default)]
pub deploy_programs: Vec<CrossZoneProgram>,
}
/// A genesis-funded bridge-lock holder balance, configured identically on the

View File

@ -12,7 +12,7 @@ use cross_zone_inbox_core::{
};
use futures::StreamExt as _;
use lee::PublicKey;
use log::{error, info};
use log::{debug, error, info};
use logos_blockchain_core::mantle::ops::channel::ChannelId;
use logos_blockchain_zone_sdk::{
CommonHttpClient, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
@ -51,8 +51,7 @@ impl PeerBlocks {
}
/// The highest block id this reader has finalized for `zone`, or `None` if it
/// has read nothing yet. Used to tell forgery (we have read past the
/// referenced block and it is absent) from lag (we simply have not caught up).
/// has read nothing yet.
async fn highest_seen(&self, zone: ZoneId) -> Option<u64> {
self.chains
.read()
@ -65,13 +64,17 @@ impl PeerBlocks {
/// The indexer-side Option B verifier.
///
/// For every cross-zone dispatch in a block it re-derives the transaction from
/// the peer's finalized block and rejects it if the bytes differ (a forgery) or
/// the message was already delivered (a replay), so delivery no longer relies on
/// trusting the sequencer.
/// the peer's finalized block and rejects it if the bytes differ (a forgery), so
/// delivery no longer relies on trusting the sequencer. A replay of an
/// already-delivered message is accepted, since the inbox no-ops it on chain.
#[derive(Clone)]
pub struct CrossZoneVerifier {
self_zone: ZoneId,
/// Pinned block-signing key per peer zone, enforced during re-derivation.
/// One key per peer is sufficient while a zone has a single sequencer; key
/// sets with rotation come in with decentralized sequencing. The pin is
/// largely redundant given Bedrock's turn-based write authorization, so it is
/// optional: a peer with no configured key is not signature-checked.
peer_pubkeys: HashMap<ZoneId, PublicKey>,
peers: PeerBlocks,
seen: Arc<RwLock<HashSet<MessageKey>>>,
@ -112,9 +115,17 @@ impl CrossZoneVerifier {
})
}
/// Verifies every cross-zone dispatch in a block, returning `Err` on the
/// first forged or replayed dispatch. The caller halts ingestion on error.
pub async fn verify_block(&self, block: &Block) -> Result<()> {
/// Verifies every cross-zone dispatch in a block, returning `Err` on the first
/// forged dispatch (the caller halts ingestion) or the keys to mark seen.
///
/// The caller MUST record the returned keys via [`Self::record_seen`] only
/// after the block applies, so the seen-set mirrors the inbox's on-chain
/// seen-shard. Marking a key from a block that never applies would let a later
/// forged dispatch reuse it to skip re-derivation while the inbox delivers the
/// forgery. A key already seen is a replay the inbox no-ops, so it is accepted
/// without re-derivation rather than halting on a legitimate re-delivery.
pub async fn verify_block(&self, block: &Block) -> Result<Vec<MessageKey>> {
let mut verified = Vec::new();
for tx in &block.body.transactions {
let Some(msg) = Self::decode_dispatch(tx) else {
continue;
@ -122,10 +133,13 @@ impl CrossZoneVerifier {
let key = message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index);
if self.seen.read().await.contains(&key) {
bail!(
"cross-zone replay: message {} re-delivered",
hex::encode(key)
debug!(
"Skipping already-seen cross-zone dispatch from zone {} block {} tx {} (replay no-op)",
hex::encode(msg.src_zone),
msg.src_block_id,
msg.src_tx_index
);
continue;
}
let expected = self.rederive(&msg).await?;
@ -138,15 +152,25 @@ impl CrossZoneVerifier {
);
}
self.seen.write().await.insert(key);
info!(
"Verified cross-zone dispatch from zone {} block {} tx {}",
hex::encode(msg.src_zone),
msg.src_block_id,
msg.src_tx_index
);
verified.push(key);
}
Ok(())
Ok(verified)
}
/// Marks the given dispatch keys seen, so a later replay of them is accepted
/// without re-derivation. Call only after the block that carried them has been
/// applied on chain (see [`Self::verify_block`]).
pub async fn record_seen(&self, keys: Vec<MessageKey>) {
if keys.is_empty() {
return;
}
self.seen.write().await.extend(keys);
}
/// Decodes a transaction into the cross-zone message it dispatches, or `None`
@ -225,11 +249,6 @@ impl CrossZoneVerifier {
/// If the block is cached, return it. If our peer reader has already
/// finalized past `block_id` and we still do not have it, the reference is to
/// a block that does not exist on the peer chain, a forgery, so reject now.
/// Otherwise the reader simply has not caught up yet: keep waiting, since a
/// legitimate dispatch is only injected after its peer block finalized and
/// our reader of the same finalized chain will see it too. Rejecting on a
/// timeout here would turn a lagging reader into a permanent halt of an
/// honest message.
async fn wait_for_peer_block(&self, zone: ZoneId, block_id: u64) -> Result<Block> {
let mut waited = Duration::ZERO;
loop {
@ -474,7 +493,7 @@ mod tests {
}
#[tokio::test]
async fn rejects_replayed_dispatch() {
async fn accepts_replayed_dispatch_as_noop() {
let verifier = verifier();
verifier
.peers
@ -485,15 +504,62 @@ mod tests {
.await;
let first = produce_dummy_block(9, None, vec![dispatch(b"hi")]);
verifier
let keys = verifier
.verify_block(&first)
.await
.expect("first delivery verifies");
// Mark the delivery seen, as the ingest loop does once the block applies.
verifier.record_seen(keys).await;
// Replace the peer block with a different emission so re-deriving the
// replay would mismatch. The replay must still be accepted, proving it is
// the seen-key short-circuit (the inbox no-ops it on chain) and not a
// successful re-derivation.
verifier
.peers
.insert(
PEER_ZONE,
produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"different")]),
)
.await;
let replay = produce_dummy_block(10, None, vec![dispatch(b"hi")]);
let err = verifier.verify_block(&replay).await.unwrap_err();
verifier
.verify_block(&replay)
.await
.expect("a replay is accepted as an on-chain no-op");
}
#[tokio::test]
async fn unaccepted_dispatch_does_not_poison_seen() {
// A dispatch verified in a block that never applies (e.g. one that parks)
// must not be marked seen. Otherwise a later forged dispatch could reuse
// its key to skip re-derivation, while the inbox, never having recorded
// the key on chain, would deliver the forgery.
let verifier = verifier();
verifier
.peers
.insert(
PEER_ZONE,
produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]),
)
.await;
// The dispatch verifies, but the block is not applied, so record_seen is
// not called (the ingest loop records only after an Applied outcome).
let first = produce_dummy_block(9, None, vec![dispatch(b"hi")]);
verifier
.verify_block(&first)
.await
.expect("dispatch verifies");
// A forged dispatch reusing the same key (same src zone, block, tx index)
// with a different payload must still be re-derived and rejected, since
// its key was never recorded as seen.
let forged = produce_dummy_block(10, None, vec![dispatch(b"forged")]);
let err = verifier.verify_block(&forged).await.unwrap_err();
assert!(
err.to_string().contains("replay"),
err.to_string().contains("forged"),
"unexpected error: {err}"
);
}

View File

@ -93,22 +93,18 @@ impl IndexerCore {
);
let zone_indexer = ZoneIndexer::new(config.channel_id, node.clone());
// Genesis accounts the indexer must seed to match the sequencer's state,
// since none are produced by a transaction: the cross-zone inbox config
// and any bridge-lock holdings. Both go through the same builders the
// sequencer uses, so the states are byte-identical.
let mut genesis_seed = Vec::new();
// Cross-zone programs, mirroring the sequencer's DeployProgram set.
let genesis_programs = cross_zone::deployed_programs(&config.deploy_programs);
// Bridge-lock holdings (source side) always; inbox + wrapped-token config only when
// cross_zone is set.
let mut genesis_accounts: Vec<_> = config
.bridge_lock_holdings
.iter()
.map(|holding| cross_zone::build_holding_account(holding.holder, holding.amount))
.collect();
if let Some(cross_zone) = config.cross_zone.as_ref() {
let self_zone: [u8; 32] = *config.channel_id.as_ref();
genesis_seed.push(cross_zone::build_inbox_config_account(
self_zone, cross_zone,
));
}
for holding in &config.bridge_lock_holdings {
genesis_seed.push(cross_zone::build_holding_account(
holding.holder,
holding.amount,
));
genesis_accounts.extend(cross_zone::genesis_accounts(self_zone, cross_zone));
}
// Option B verifier: re-derives each cross-zone dispatch from the peer's
@ -117,7 +113,7 @@ impl IndexerCore {
Ok(Self {
zone_indexer: Arc::new(zone_indexer),
store: IndexerStore::open_db(&home, genesis_seed)?,
store: IndexerStore::open_db(&home, genesis_programs, genesis_accounts)?,
node,
config,
status: Arc::new(ArcSwap::from_pointee(IndexerSyncStatus::starting())),
@ -257,23 +253,33 @@ impl IndexerCore {
};
// Option B: re-derive and verify every cross-zone dispatch
// before applying the block. A forged or replayed dispatch
// halts ingestion rather than persisting an invalid state.
if let Some(verifier) = &self.verifier
&& let Err(err) = verifier.verify_block(&block).await
{
error!(
"Cross-zone verification failed for block {}: {err:#}. Halting indexer ingestion.",
block.header.block_id
);
self.set_status(IndexerSyncStatus::error(format!(
"cross-zone verification failed: {err:#}"
)));
return;
}
// before applying the block. A forged dispatch halts ingestion
// rather than persisting an invalid state; a replay is accepted
// since the inbox no-ops it on chain. The verified keys are
// marked seen only once the block applies (below), so a block
// that does not apply cannot poison the seen-set.
let verified_keys = match &self.verifier {
Some(verifier) => match verifier.verify_block(&block).await {
Ok(keys) => keys,
Err(err) => {
error!(
"Cross-zone verification failed for block {}: {err:#}. Halting indexer ingestion.",
block.header.block_id
);
self.set_status(IndexerSyncStatus::error(format!(
"cross-zone verification failed: {err:#}"
)));
return;
}
},
None => Vec::new(),
};
match self.store.accept_block(&block, slot).await {
Ok(AcceptOutcome::Applied) => {
if let Some(verifier) = &self.verifier {
verifier.record_seen(verified_keys).await;
}
retry_gate.reset();
info!("Indexed L2 block {}", block.header.block_id);
self.set_status(IndexerSyncStatus::syncing());

View File

@ -8,9 +8,10 @@ use std::{
use anyhow::Result;
use bytesize::ByteSize;
use common::config::BasicAuth;
pub use cross_zone::CrossZoneProgram;
pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer};
use humantime_serde;
use lee::AccountId;
use lee::{AccountId, Balance};
use logos_blockchain_core::mantle::ops::channel::ChannelId;
use serde::{Deserialize, Serialize};
use url::Url;
@ -21,15 +22,22 @@ use url::Url;
pub enum GenesisAction {
SupplyAccount {
account_id: AccountId,
balance: u128,
balance: Balance,
},
SupplyBridgeAccount {
balance: u128,
balance: Balance,
},
/// Seeds a bridge-lock holder's initial bridgeable balance into genesis state.
SupplyBridgeLockHolding {
holder: AccountId,
amount: u128,
amount: Balance,
},
/// Registers a cross-zone builtin at genesis, keeping the cross-zone programs
/// out of the production builtin set. A zone lists the ones it uses. This set
/// must match the indexer's `deploy_programs`, or the two genesis states
/// diverge; compare the genesis fingerprint both nodes log at startup.
DeployProgram {
program: CrossZoneProgram,
},
}

View File

@ -632,10 +632,30 @@ fn replay_unfulfilled_deposit_events(
/// committed to the genesis block so external observers can replay them.
fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTransaction>) {
#[cfg(not(feature = "testnet"))]
let mut state = testnet_initial_state::initial_state();
let base = testnet_initial_state::initial_state();
#[cfg(feature = "testnet")]
let mut state = testnet_initial_state::initial_state_testnet();
let base = testnet_initial_state::initial_state_testnet();
// Cross-zone programs this zone declares; the indexer reproduces the set from config.
let mut state = base.with_programs(deployed_programs(&config.genesis));
// Bridge-lock holdings belong to the source side, seeded regardless of receiving config.
let holdings: Vec<_> = bridge_lock_holdings(&config.genesis)
.into_iter()
.map(|(holder, amount)| cross_zone::build_holding_account(holder, amount))
.collect();
state = state.with_public_accounts(holdings);
// Receiving-side accounts (inbox + wrapped-token config), only when cross_zone is set.
if let Some(cross_zone) = &config.cross_zone {
let self_zone = *config.bedrock_config.channel_id.as_ref();
state = state.with_public_accounts(cross_zone::genesis_accounts(self_zone, cross_zone));
}
// Fingerprint the directly-seeded state, before genesis txs, so it matches the indexer's.
info!(
"Genesis fingerprint: {}",
hex::encode(state.genesis_fingerprint())
);
let genesis_txs = config
.genesis
@ -650,8 +670,11 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
GenesisAction::SupplyBridgeAccount { balance } => {
Some(build_supply_bridge_account_genesis_transaction(*balance))
}
// Force-inserted below: bridge_lock has no mint transaction.
GenesisAction::SupplyBridgeLockHolding { .. } => None,
// Seeded/registered directly (accounts via `cross_zone::genesis_accounts`,
// programs via `with_programs`), not by a genesis tx.
GenesisAction::SupplyBridgeLockHolding { .. } | GenesisAction::DeployProgram { .. } => {
None
}
})
.chain(std::iter::once(clock_invocation(0)))
.inspect(|tx| {
@ -662,26 +685,37 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
.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 {
let (holder_id, account) = cross_zone::build_holding_account(*holder, *amount);
state.insert_genesis_account(holder_id, account);
}
}
// Seed this zone's cross-zone inbox config so the inbox guest can authorize
// inbound peer messages (zone-specific config, not produced by any tx).
if let Some(cross_zone) = &config.cross_zone {
let self_zone = *config.bedrock_config.channel_id.as_ref();
let (config_id, config_account) =
cross_zone::build_inbox_config_account(self_zone, cross_zone);
state.insert_genesis_account(config_id, config_account);
}
(state, genesis_txs)
}
/// Bridge-lock holder balances configured for this zone's genesis.
fn bridge_lock_holdings(genesis: &[GenesisAction]) -> Vec<(lee::AccountId, lee::Balance)> {
genesis
.iter()
.filter_map(|action| match action {
GenesisAction::SupplyBridgeLockHolding { holder, amount } => Some((*holder, *amount)),
GenesisAction::SupplyAccount { .. }
| GenesisAction::SupplyBridgeAccount { .. }
| GenesisAction::DeployProgram { .. } => None,
})
.collect()
}
/// The cross-zone builtins this zone deploys at genesis, from its `DeployProgram`
/// actions.
fn deployed_programs(genesis: &[GenesisAction]) -> Vec<lee::program::Program> {
let selectors: Vec<_> = genesis
.iter()
.filter_map(|action| match action {
GenesisAction::DeployProgram { program } => Some(*program),
GenesisAction::SupplyAccount { .. }
| GenesisAction::SupplyBridgeAccount { .. }
| GenesisAction::SupplyBridgeLockHolding { .. } => None,
})
.collect();
cross_zone::deployed_programs(&selectors)
}
/// Whether a program may only be invoked by sequencer-origin transactions.
///
/// The cross-zone inbox is injected solely by the watcher; a user-submitted call

View File

@ -10,7 +10,6 @@ lee.workspace = true
lee_core.workspace = true
system_accounts.workspace = true
programs.workspace = true
wrapped_token_core.workspace = true
serde.workspace = true

View File

@ -258,30 +258,9 @@ fn initial_public_accounts() -> HashMap<AccountId, Account> {
.into_iter()
.map(|clock_id| (clock_id, system_accounts::clock_account())),
)
.chain(std::iter::once(wrapped_token_config_account()))
.collect()
}
/// The wrapped-token config account.
///
/// Seeded so the `wrapped_token` guest can pin its authorized minter (the
/// cross-zone inbox) without importing the inbox id. Fixed for every zone, so it
/// lives in the shared initial state.
fn wrapped_token_config_account() -> (AccountId, Account) {
let wrapped_token_id = programs::wrapped_token().id();
(
wrapped_token_core::config_account_id(wrapped_token_id),
Account {
program_owner: wrapped_token_id,
data: wrapped_token_core::minter_bytes(programs::cross_zone_inbox().id())
.to_vec()
.try_into()
.expect("minter id fits in account data"),
..Default::default()
},
)
}
fn initial_programs() -> Vec<Program> {
vec![
programs::authenticated_transfer(),
@ -292,12 +271,6 @@ fn initial_programs() -> Vec<Program> {
programs::vault(),
programs::faucet(),
programs::bridge(),
programs::cross_zone_outbox(),
programs::cross_zone_inbox(),
programs::ping_sender(),
programs::ping_receiver(),
programs::bridge_lock(),
programs::wrapped_token(),
]
}

View File

@ -1,17 +0,0 @@
[package]
name = "bridge_lock_core"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
lee_core.workspace = true
serde = { workspace = true, features = ["alloc"] }
lee = { workspace = true, optional = true }
[features]
# Host-only genesis helper; pulls `lee`, so the risc0 guest builds without it.
host = ["dep:lee"]

View File

@ -1,92 +0,0 @@
//! Core types for the bridge-lock program, the source side of the cross-zone
//! token bridge. A holder locks part of their balance into an escrow and emits a
//! cross-zone message minting the wrapped token on the target zone.
use lee_core::{
account::AccountId,
program::{PdaSeed, ProgramId},
};
use serde::{Deserialize, Serialize};
const ESCROW_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeLockEscrow/0000/";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Instruction {
/// Lock `amount` of the holder's balance and emit a cross-zone message
/// minting the wrapped token on `target_zone`. The emission fields mirror
/// `cross_zone_outbox::Instruction::Emit` so the watcher reads them directly.
///
/// Required accounts (3): holder holding (authorized), escrow PDA, outbox PDA.
Lock {
amount: u128,
target_zone: [u8; 32],
target_program_id: ProgramId,
target_accounts: Vec<[u8; 32]>,
payload: Vec<u8>,
outbox_program_id: ProgramId,
ordinal: u32,
},
}
/// PDA accumulating all locked balance on this zone.
#[must_use]
pub fn escrow_account_id(bridge_lock_id: ProgramId) -> AccountId {
AccountId::for_public_pda(&bridge_lock_id, &escrow_seed())
}
#[must_use]
pub fn escrow_seed() -> PdaSeed {
PdaSeed::new(ESCROW_SEED_DOMAIN)
}
/// Reads a bridgeable balance from account data; empty data is a zero balance.
#[must_use]
pub fn read_balance(data: &[u8]) -> u128 {
if data.len() < 16 {
return 0;
}
u128::from_le_bytes(data[..16].try_into().unwrap_or_else(|_| unreachable!()))
}
#[must_use]
pub fn balance_bytes(amount: u128) -> [u8; 16] {
amount.to_le_bytes()
}
/// Builds the genesis holding account funding a holder's bridgeable balance:
/// owned by bridge_lock, data is the LE balance, at the holder's account id. It
/// is not produced by any transaction, so the sequencer and the indexer both
/// seed it through this one builder to keep their genesis states identical.
#[cfg(feature = "host")]
#[must_use]
pub fn build_holding_account(
holder: AccountId,
amount: u128,
) -> (AccountId, lee_core::account::Account) {
let account = lee_core::account::Account {
program_owner: lee::program::Program::bridge_lock().id(),
data: balance_bytes(amount)
.to_vec()
.try_into()
.expect("balance fits in account data"),
..Default::default()
};
(holder, account)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn balance_round_trips() {
assert_eq!(read_balance(&balance_bytes(7)), 7);
assert_eq!(read_balance(&[]), 0);
}
#[test]
fn escrow_is_stable() {
let id: ProgramId = [4; 8];
assert_eq!(escrow_account_id(id), escrow_account_id(id));
}
}

View File

@ -1,22 +0,0 @@
[package]
name = "cross_zone_inbox_core"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
lee_core.workspace = true
serde = { workspace = true, features = ["alloc"] }
risc0-zkvm.workspace = true
borsh.workspace = true
lee = { workspace = true, optional = true }
ping_core = { workspace = true, optional = true }
bridge_lock_core = { workspace = true, optional = true }
[features]
# Host-only transaction builder and emission extractor; pull `lee` and the
# emitter cores, so the risc0 guest builds without them.
host = ["dep:lee", "dep:ping_core", "dep:bridge_lock_core"]

View File

@ -1,378 +0,0 @@
use std::collections::{BTreeMap, BTreeSet};
use borsh::{BorshDeserialize, BorshSerialize};
use lee_core::{
account::AccountId,
program::{PdaSeed, ProgramId},
};
use serde::{Deserialize, Serialize};
/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`.
pub type ZoneId = [u8; 32];
/// Block-signing public key pinned per peer zone.
pub type ExpectedPubkey = [u8; 32];
/// Content-addressed replay key for a delivered message.
pub type MessageKey = [u8; 32];
/// Source blocks per seen-set shard, so no single seen account grows without bound.
pub const EPOCH_BLOCKS: u64 = 10_000;
const MESSAGE_KEY_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneMsgKey/00000/";
const INBOX_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxCfg/000/";
const INBOX_SEEN_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxSeen/00/";
/// A peer zone whose outbox a zone watches for inbound cross-zone messages.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CrossZonePeer {
/// The peer's Bedrock channel; its 32 bytes double as the peer's zone id.
pub channel_id: ZoneId,
/// Programs on the local zone a message from this peer is allowed to target.
pub allowed_targets: Vec<ProgramId>,
/// The peer's block-signing public key, pinned to reject blocks inscribed by
/// anyone other than that zone's sequencer. `None` skips the check (the
/// channel signer is still authenticated by the zone-sdk).
#[serde(default)]
pub expected_block_signing_pubkey: Option<[u8; 32]>,
}
/// Cross-zone configuration shared by a zone's sequencer (watcher) and indexer
/// (verifier): the peers it reads from Bedrock and, per peer, the local programs
/// they may deliver to.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CrossZoneConfig {
pub peers: Vec<CrossZonePeer>,
}
/// A finalized outbound message observed on a peer zone, addressed to a program
/// on this zone. The watcher fills it from the peer's block; it is never
/// self-reported by a user.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CrossZoneMessage {
pub src_zone: ZoneId,
pub src_block_id: u64,
pub src_tx_index: u32,
pub src_program_id: ProgramId,
pub target_program_id: ProgramId,
pub payload: Vec<u8>,
/// Reserved for a future source-state proof; MUST be `None` in v1.
pub l1_inclusion_witness: Option<Vec<u8>>,
}
/// Peer and per-peer target allowlists, plus this inbox's own zone id.
#[derive(
Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize,
)]
pub struct InboxConfig {
pub self_zone: ZoneId,
pub allowed_peers: BTreeMap<ZoneId, ExpectedPubkey>,
pub allowed_targets: BTreeMap<ZoneId, Vec<ProgramId>>,
}
impl InboxConfig {
/// Borsh-encoded form stored in the inbox config account.
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
borsh::to_vec(self).expect("InboxConfig serializes")
}
/// Decodes an [`InboxConfig`] from account data.
pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result<Self> {
borsh::from_slice(bytes)
}
}
/// The replay keys seen for one `(src_zone, epoch)` shard.
#[derive(Clone, Debug, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct SeenShard(pub BTreeSet<MessageKey>);
impl SeenShard {
/// Decodes a shard from account data; empty data is an empty shard.
pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result<Self> {
if bytes.is_empty() {
return Ok(Self::default());
}
borsh::from_slice(bytes)
}
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
borsh::to_vec(self).expect("SeenShard serializes")
}
#[must_use]
pub fn contains(&self, key: &MessageKey) -> bool {
self.0.contains(key)
}
/// Inserts a key; returns true if it was newly inserted.
pub fn insert(&mut self, key: MessageKey) -> bool {
self.0.insert(key)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Instruction {
/// Delivers a finalized peer message to its target program.
Dispatch(CrossZoneMessage),
}
/// Content-addressed replay key: `(src_zone, src_block_id, src_tx_index)` hashed
/// under a domain separator. Watcher-independent and immune to proof
/// malleability, since it keys on block id plus index rather than a tx hash.
#[must_use]
pub fn message_key(src_zone: &ZoneId, src_block_id: u64, src_tx_index: u32) -> MessageKey {
use risc0_zkvm::sha::{Impl, Sha256 as _};
let mut bytes = Vec::with_capacity(MESSAGE_KEY_DOMAIN.len() + 32 + 8 + 4);
bytes.extend_from_slice(&MESSAGE_KEY_DOMAIN);
bytes.extend_from_slice(src_zone);
bytes.extend_from_slice(&src_block_id.to_le_bytes());
bytes.extend_from_slice(&src_tx_index.to_le_bytes());
Impl::hash_bytes(&bytes)
.as_bytes()
.try_into()
.unwrap_or_else(|_| unreachable!())
}
/// The config account holding the allowlists.
#[must_use]
pub fn inbox_config_account_id(inbox_id: ProgramId) -> AccountId {
AccountId::for_public_pda(&inbox_id, &PdaSeed::new(INBOX_CONFIG_SEED))
}
/// The seen-set shard for the `(src_zone, epoch)` the message falls in.
#[must_use]
pub fn inbox_seen_shard_account_id(
inbox_id: ProgramId,
src_zone: &ZoneId,
src_block_id: u64,
) -> AccountId {
AccountId::for_public_pda(&inbox_id, &inbox_seen_shard_seed(src_zone, src_block_id))
}
/// Seed of the seen-shard PDA, exposed so the guest can claim the account.
#[must_use]
pub fn inbox_seen_shard_seed(src_zone: &ZoneId, src_block_id: u64) -> PdaSeed {
use risc0_zkvm::sha::{Impl, Sha256 as _};
let src_epoch = src_block_id / EPOCH_BLOCKS;
let mut bytes = Vec::with_capacity(INBOX_SEEN_SEED_DOMAIN.len() + 32 + 8);
bytes.extend_from_slice(&INBOX_SEEN_SEED_DOMAIN);
bytes.extend_from_slice(src_zone);
bytes.extend_from_slice(&src_epoch.to_le_bytes());
let seed: [u8; 32] = Impl::hash_bytes(&bytes)
.as_bytes()
.try_into()
.unwrap_or_else(|_| unreachable!());
PdaSeed::new(seed)
}
/// Builds the sequencer-origin dispatch transaction. Pure, so the watcher's
/// injected tx and the indexer's re-derived tx are byte-identical for the same
/// inputs (the basis of the Option B check). `target_account_ids` are the
/// inbox's chained-call targets; deriving them is target-specific.
#[cfg(feature = "host")]
#[must_use]
pub fn build_inbox_dispatch_tx(
inbox_id: ProgramId,
msg: &CrossZoneMessage,
target_account_ids: Vec<AccountId>,
) -> lee::PublicTransaction {
let mut account_ids = Vec::with_capacity(2 + target_account_ids.len());
account_ids.push(inbox_config_account_id(inbox_id));
account_ids.push(inbox_seen_shard_account_id(
inbox_id,
&msg.src_zone,
msg.src_block_id,
));
account_ids.extend(target_account_ids);
let message = lee::public_transaction::Message::try_new(
inbox_id,
account_ids,
vec![],
Instruction::Dispatch(msg.clone()),
)
.expect("inbox dispatch instruction must serialize");
lee::PublicTransaction::new(
message,
lee::public_transaction::WitnessSet::from_raw_parts(vec![]),
)
}
/// The cross-zone emission fields a watcher or verifier reads off a source
/// transaction, common to every emitter program.
#[cfg(feature = "host")]
pub struct Emission {
pub target_zone: ZoneId,
pub target_program_id: ProgramId,
pub target_accounts: Vec<[u8; 32]>,
pub payload: Vec<u8>,
}
/// Extracts the cross-zone emission from a source transaction, recognizing the
/// known emitter programs. Returns `None` for any other program. The watcher and
/// verifier both use this so they agree on what a given source tx emits.
///
/// Option A: each emitter is decoded explicitly. The principled alternative is to
/// read the outbox PDA write, which would need re-execution of the source tx.
#[cfg(feature = "host")]
#[must_use]
pub fn extract_emission(
program_id: ProgramId,
instruction_data: &[u32],
ping_sender_id: ProgramId,
bridge_lock_id: ProgramId,
) -> Option<Emission> {
if program_id == ping_sender_id {
let ping_core::SenderInstruction::Send {
target_zone,
target_program_id,
target_accounts,
payload,
..
} = risc0_zkvm::serde::from_slice(instruction_data).ok()?;
Some(Emission {
target_zone,
target_program_id,
target_accounts,
payload,
})
} else if program_id == bridge_lock_id {
let bridge_lock_core::Instruction::Lock {
target_zone,
target_program_id,
target_accounts,
payload,
..
} = risc0_zkvm::serde::from_slice(instruction_data).ok()?;
Some(Emission {
target_zone,
target_program_id,
target_accounts,
payload,
})
} else {
None
}
}
/// Builds the dispatch transaction for one peer emission. Both the sequencer's
/// watcher and the indexer's verifier go through this so their transactions are
/// byte-identical for the same emission (the basis of the Option B check).
#[cfg(feature = "host")]
#[must_use]
pub fn build_dispatch_from_emission(
inbox_id: ProgramId,
src_zone: ZoneId,
src_block_id: u64,
src_tx_index: u32,
src_program_id: ProgramId,
target_program_id: ProgramId,
target_accounts: &[[u8; 32]],
payload: Vec<u8>,
) -> lee::PublicTransaction {
let msg = CrossZoneMessage {
src_zone,
src_block_id,
src_tx_index,
src_program_id,
target_program_id,
payload,
l1_inclusion_witness: None,
};
let target_ids = target_accounts
.iter()
.copied()
.map(AccountId::new)
.collect();
build_inbox_dispatch_tx(inbox_id, &msg, target_ids)
}
/// Builds the inbox config account a zone seeds into genesis state so the inbox
/// guest can authorize inbound peer messages. The sequencer and indexer seed the
/// same account from the same config, keeping their replayed state consistent.
#[cfg(feature = "host")]
#[must_use]
pub fn build_inbox_config_account(
self_zone: ZoneId,
cross_zone: &CrossZoneConfig,
) -> (AccountId, lee_core::account::Account) {
let inbox_id = lee::program::Program::cross_zone_inbox().id();
let mut allowed_targets = BTreeMap::new();
for peer in &cross_zone.peers {
allowed_targets.insert(peer.channel_id, peer.allowed_targets.clone());
}
let config = InboxConfig {
self_zone,
allowed_peers: BTreeMap::new(),
allowed_targets,
};
let account = lee_core::account::Account {
program_owner: inbox_id,
balance: 0,
data: config
.to_bytes()
.try_into()
.expect("inbox config fits in account data"),
nonce: 0_u128.into(),
};
(inbox_config_account_id(inbox_id), account)
}
#[cfg(test)]
mod tests {
use super::*;
fn zone(b: u8) -> ZoneId {
[b; 32]
}
#[test]
fn message_key_is_stable_and_content_addressed() {
assert_eq!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 3));
assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(2), 7, 3));
assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(1), 8, 3));
assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 4));
}
#[test]
fn seen_shards_split_on_epoch_boundary() {
let id: ProgramId = [9; 8];
assert_eq!(
inbox_seen_shard_account_id(id, &zone(1), 0),
inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1),
);
assert_ne!(
inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1),
inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS),
);
}
#[cfg(feature = "host")]
#[test]
fn build_inbox_dispatch_tx_is_deterministic() {
let inbox: ProgramId = [5; 8];
let msg = CrossZoneMessage {
src_zone: zone(1),
src_block_id: 42,
src_tx_index: 2,
src_program_id: [6; 8],
target_program_id: [7; 8],
payload: vec![1, 2, 3, 4],
l1_inclusion_witness: None,
};
let targets = vec![AccountId::new([8; 32]), AccountId::new([9; 32])];
let tx1 = build_inbox_dispatch_tx(inbox, &msg, targets.clone());
let tx2 = build_inbox_dispatch_tx(inbox, &msg, targets);
assert_eq!(tx1, tx2);
}
}

View File

@ -1,14 +0,0 @@
[package]
name = "cross_zone_outbox_core"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
lee_core.workspace = true
serde = { workspace = true, features = ["alloc"] }
risc0-zkvm.workspace = true
borsh.workspace = true

View File

@ -1,93 +0,0 @@
use borsh::{BorshDeserialize, BorshSerialize};
use lee_core::{
account::AccountId,
program::{PdaSeed, ProgramId},
};
use serde::{Deserialize, Serialize};
/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`.
pub type ZoneId = [u8; 32];
const OUTBOX_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneOutbox/00000/";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Instruction {
/// Records an outbound cross-zone message as a write to a self-owned PDA.
///
/// Required accounts (1):
/// - Outbox PDA account
Emit {
target_zone: ZoneId,
target_program_id: ProgramId,
/// Accounts the destination inbox must hand to the target program's
/// chained call. The emitter specifies them; the watcher forwards them
/// verbatim so the inbox stays target-agnostic.
target_accounts: Vec<[u8; 32]>,
payload: Vec<u8>,
ordinal: u32,
},
}
/// The message as stored in an outbox PDA. The destination zone's watcher reads
/// this from the inscribed block; the source coordinates are filled by the
/// watcher, not stored here.
#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct OutboxRecord {
pub target_zone: ZoneId,
pub target_program_id: ProgramId,
pub target_accounts: Vec<[u8; 32]>,
pub payload: Vec<u8>,
}
impl OutboxRecord {
/// Borsh-encoded form stored in the outbox PDA's account data.
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
borsh::to_vec(self).expect("OutboxRecord serializes")
}
/// Decodes an [`OutboxRecord`] from account data.
pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result<Self> {
borsh::from_slice(bytes)
}
}
/// PDA holding one emitted message, keyed by destination zone and a per-zone
/// ordinal.
#[must_use]
pub fn outbox_pda(outbox_id: ProgramId, target_zone: &ZoneId, ordinal: u32) -> AccountId {
AccountId::for_public_pda(&outbox_id, &outbox_pda_seed(target_zone, ordinal))
}
/// Seed of an outbox message PDA, exposed so the guest can claim the account.
#[must_use]
pub fn outbox_pda_seed(target_zone: &ZoneId, ordinal: u32) -> PdaSeed {
use risc0_zkvm::sha::{Impl, Sha256 as _};
let mut bytes = Vec::with_capacity(OUTBOX_SEED_DOMAIN.len() + target_zone.len() + 4);
bytes.extend_from_slice(&OUTBOX_SEED_DOMAIN);
bytes.extend_from_slice(target_zone);
bytes.extend_from_slice(&ordinal.to_le_bytes());
let seed: [u8; 32] = Impl::hash_bytes(&bytes)
.as_bytes()
.try_into()
.unwrap_or_else(|_| unreachable!());
PdaSeed::new(seed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn outbox_pda_is_unique_per_zone_and_ordinal() {
let id: ProgramId = [3; 8];
let zone_a = [1; 32];
let zone_b = [2; 32];
assert_eq!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 0));
assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 1));
assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_b, 0));
}
}

View File

@ -1,12 +0,0 @@
[package]
name = "ping_core"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
lee_core.workspace = true
serde = { workspace = true, features = ["alloc"] }

View File

@ -1,38 +0,0 @@
use lee_core::{
account::AccountId,
program::{PdaSeed, ProgramId},
};
use serde::{Deserialize, Serialize};
const PING_RECORD_SEED: [u8; 32] = *b"/LEZ/v0.3/PingRecord/0000000000/";
/// Instruction delivered to `ping_receiver` by the inbox: record the payload.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReceiverInstruction {
Record { payload: Vec<u8> },
}
/// Instruction to `ping_sender`: forwarded verbatim into `cross_zone_outbox::Instruction::Emit`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SenderInstruction {
Send {
outbox_program_id: ProgramId,
target_zone: [u8; 32],
target_program_id: ProgramId,
target_accounts: Vec<[u8; 32]>,
payload: Vec<u8>,
ordinal: u32,
},
}
/// The account a `ping_receiver` records the latest delivered payload into.
#[must_use]
pub fn ping_record_pda(receiver_id: ProgramId) -> AccountId {
AccountId::for_public_pda(&receiver_id, &ping_record_seed())
}
/// Seed of the record PDA, exposed so the guest can claim the account.
#[must_use]
pub fn ping_record_seed() -> PdaSeed {
PdaSeed::new(PING_RECORD_SEED)
}

View File

@ -1,13 +0,0 @@
[package]
name = "wrapped_token_core"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
lee_core.workspace = true
serde = { workspace = true, features = ["alloc"] }
risc0-zkvm.workspace = true

View File

@ -1,121 +0,0 @@
//! Core types for the wrapped-token program, the destination side of the
//! cross-zone bridge. Only the cross-zone inbox may mint; the guest enforces
//! this by reading the authorized minter from a genesis-seeded config account.
use lee_core::{
account::AccountId,
program::{PdaSeed, ProgramId},
};
use serde::{Deserialize, Serialize};
const CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenConfig/00/";
const HOLDING_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenHold/00000";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Instruction {
/// Credit `amount` wrapped tokens to `recipient`'s holding. Delivered only by
/// the cross-zone inbox.
///
/// Required accounts (2): the wrapped-token config PDA, then the recipient's
/// holding PDA.
Mint { recipient: [u8; 32], amount: u128 },
}
/// PDA holding the authorized minter program id (the cross-zone inbox), seeded at
/// genesis so the guest can pin its caller without importing the inbox image id.
#[must_use]
pub fn config_account_id(wrapped_token_id: ProgramId) -> AccountId {
AccountId::for_public_pda(&wrapped_token_id, &config_seed())
}
#[must_use]
pub fn config_seed() -> PdaSeed {
PdaSeed::new(CONFIG_SEED_DOMAIN)
}
/// PDA holding one recipient's wrapped-token balance.
#[must_use]
pub fn holding_account_id(wrapped_token_id: ProgramId, recipient: &[u8; 32]) -> AccountId {
AccountId::for_public_pda(&wrapped_token_id, &holding_seed(recipient))
}
#[must_use]
pub fn holding_seed(recipient: &[u8; 32]) -> PdaSeed {
use risc0_zkvm::sha::{Impl, Sha256 as _};
let mut bytes = Vec::with_capacity(HOLDING_SEED_DOMAIN.len() + recipient.len());
bytes.extend_from_slice(&HOLDING_SEED_DOMAIN);
bytes.extend_from_slice(recipient);
let seed: [u8; 32] = Impl::hash_bytes(&bytes)
.as_bytes()
.try_into()
.unwrap_or_else(|_| unreachable!());
PdaSeed::new(seed)
}
/// Encodes the authorized minter program id for the config account's data.
#[must_use]
pub fn minter_bytes(minter: ProgramId) -> [u8; 32] {
let mut bytes = [0_u8; 32];
for (word, chunk) in minter.iter().zip(bytes.chunks_exact_mut(4)) {
chunk.copy_from_slice(&word.to_le_bytes());
}
bytes
}
/// Decodes the authorized minter program id from the config account's data.
#[must_use]
pub fn read_minter(data: &[u8]) -> Option<ProgramId> {
if data.len() < 32 {
return None;
}
let mut minter = [0_u32; 8];
for (word, chunk) in minter.iter_mut().zip(data[..32].chunks_exact(4)) {
*word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!()));
}
Some(minter)
}
/// Reads a wrapped-token balance from account data; empty data is a zero balance.
#[must_use]
pub fn read_balance(data: &[u8]) -> u128 {
if data.len() < 16 {
return 0;
}
u128::from_le_bytes(data[..16].try_into().unwrap_or_else(|_| unreachable!()))
}
#[must_use]
pub fn balance_bytes(amount: u128) -> [u8; 16] {
amount.to_le_bytes()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn minter_round_trips() {
let minter: ProgramId = [1, 2, 3, 4, 5, 6, 7, 8];
assert_eq!(read_minter(&minter_bytes(minter)), Some(minter));
}
#[test]
fn balance_round_trips() {
assert_eq!(read_balance(&balance_bytes(42)), 42);
assert_eq!(read_balance(&[]), 0);
}
#[test]
fn holding_is_unique_per_recipient() {
let id: ProgramId = [9; 8];
assert_ne!(
holding_account_id(id, &[1; 32]),
holding_account_id(id, &[2; 32])
);
assert_eq!(
holding_account_id(id, &[1; 32]),
holding_account_id(id, &[1; 32])
);
}
}

View File

@ -6,7 +6,9 @@ use indexer_service::{ChannelId, ClientConfig, IndexerConfig};
use key_protocol::key_management::{KeyChain, secret_holders::SeedHolder};
use lee::{AccountId, PrivateKey, PublicKey};
use lee_core::Identifier;
use sequencer_core::config::{BedrockConfig, CrossZoneConfig, GenesisAction, SequencerConfig};
use sequencer_core::config::{
BedrockConfig, CrossZoneConfig, CrossZoneProgram, GenesisAction, SequencerConfig,
};
use url::Url;
use wallet::config::WalletConfig;
@ -208,6 +210,7 @@ pub fn indexer_config(
bedrock_addr: SocketAddr,
channel_id: ChannelId,
cross_zone: Option<CrossZoneConfig>,
deploy_programs: Vec<CrossZoneProgram>,
) -> Result<IndexerConfig> {
Ok(IndexerConfig {
consensus_info_polling_interval: Duration::from_secs(1),
@ -220,9 +223,26 @@ pub fn indexer_config(
cross_zone,
bridge_lock_holdings: Vec::new(),
allow_chain_reset: false,
deploy_programs,
})
}
/// All cross-zone builtins as `DeployProgram` genesis actions, for a demo zone's
/// sequencer config.
#[must_use]
pub fn cross_zone_deploy_actions() -> Vec<GenesisAction> {
CrossZoneProgram::ALL
.into_iter()
.map(|program| GenesisAction::DeployProgram { program })
.collect()
}
/// All cross-zone builtins, for a demo zone's indexer `deploy_programs`.
#[must_use]
pub fn all_cross_zone_programs() -> Vec<CrossZoneProgram> {
CrossZoneProgram::ALL.to_vec()
}
pub fn addr_to_url(protocol: UrlProtocol, addr: SocketAddr) -> Result<Url> {
// Convert 0.0.0.0 to 127.0.0.1 for client connections
// When binding to port 0, the server binds to 0.0.0.0:<random_port>
@ -255,3 +275,58 @@ pub fn bedrock_channel_id_b() -> ChannelId {
.unwrap_or_else(|_| unreachable!());
ChannelId::from(channel_id)
}
#[cfg(test)]
mod tests {
use super::*;
/// A demo zone declares its cross-zone deploy set twice: as the sequencer's
/// `DeployProgram` genesis actions and as the indexer's `deploy_programs`. Pin
/// the two helpers to the same genesis fingerprint, and check the fingerprint
/// actually detects a divergent set and ignores registration order, since that
/// is what makes it a usable drift guard.
#[test]
fn cross_zone_demo_genesis_matches_across_sequencer_and_indexer() {
let seq_selectors: Vec<CrossZoneProgram> = cross_zone_deploy_actions()
.into_iter()
.filter_map(|action| match action {
GenesisAction::DeployProgram { program } => Some(program),
GenesisAction::SupplyAccount { .. }
| GenesisAction::SupplyBridgeAccount { .. }
| GenesisAction::SupplyBridgeLockHolding { .. } => None,
})
.collect();
let idx_selectors = all_cross_zone_programs();
let fingerprint = |selectors: &[CrossZoneProgram]| {
lee::V03State::new()
.with_programs(selectors.iter().map(|p| p.program()))
.genesis_fingerprint()
};
// Matched configs agree.
assert_eq!(
fingerprint(&seq_selectors),
fingerprint(&idx_selectors),
"sequencer DeployProgram set and indexer deploy_programs must agree"
);
// A dropped program on one side changes the fingerprint.
let mut drifted = idx_selectors.clone();
drifted.pop().expect("deploy set is non-empty");
assert_ne!(
fingerprint(&seq_selectors),
fingerprint(&drifted),
"genesis fingerprint must detect a divergent deploy set"
);
// Registration order does not change the fingerprint.
let mut reversed = idx_selectors.clone();
reversed.reverse();
assert_eq!(
fingerprint(&idx_selectors),
fingerprint(&reversed),
"genesis fingerprint must be independent of program order"
);
}
}

View File

@ -337,7 +337,7 @@ impl TestContextBuilder {
let indexer_components = if enable_indexer {
let (indexer_handle, temp_indexer_dir) =
setup_indexer(bedrock_addr, config::bedrock_channel_id(), None)
setup_indexer(bedrock_addr, config::bedrock_channel_id(), None, Vec::new())
.await
.context("Failed to setup Indexer")?;
let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, indexer_handle.addr())

View File

@ -116,6 +116,7 @@ pub async fn setup_indexer(
bedrock_addr: SocketAddr,
channel_id: ChannelId,
cross_zone: Option<sequencer_core::config::CrossZoneConfig>,
deploy_programs: Vec<sequencer_core::config::CrossZoneProgram>,
) -> Result<(IndexerHandle, TempDir)> {
let temp_indexer_dir =
tempfile::tempdir().context("Failed to create temp dir for indexer home")?;
@ -125,8 +126,9 @@ pub async fn setup_indexer(
temp_indexer_dir.path().display()
);
let indexer_config = config::indexer_config(bedrock_addr, channel_id, cross_zone)
.context("Failed to create Indexer config")?;
let indexer_config =
config::indexer_config(bedrock_addr, channel_id, cross_zone, deploy_programs)
.context("Failed to create Indexer config")?;
indexer_service::run_server(
indexer_config,

View File

@ -289,14 +289,24 @@ 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) = setup_sequencer(
partial,
bedrock_addr,
config::cross_zone_deploy_actions(),
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,
config::cross_zone_deploy_actions(),
channel_b,
Some(cross_zone_b),
)
.await
.context("Failed to set up zone B sequencer")?;
let state = Arc::new(AppState {
zone_a: ZoneRuntime {