diff --git a/Cargo.lock b/Cargo.lock index 16861330..3b5714b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/integration_tests/tests/cross_zone_bridge.rs b/integration_tests/tests/cross_zone_bridge.rs index 63b14c95..f0eedd69 100644 --- a/integration_tests/tests/cross_zone_bridge.rs +++ b/integration_tests/tests/cross_zone_bridge.rs @@ -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); diff --git a/integration_tests/tests/cross_zone_ingress_guard.rs b/integration_tests/tests/cross_zone_ingress_guard.rs index 5f401869..8a2e0f1b 100644 --- a/integration_tests/tests/cross_zone_ingress_guard.rs +++ b/integration_tests/tests/cross_zone_ingress_guard.rs @@ -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(); diff --git a/integration_tests/tests/cross_zone_ping.rs b/integration_tests/tests/cross_zone_ping.rs index 19223b5f..92628c5c 100644 --- a/integration_tests/tests/cross_zone_ping.rs +++ b/integration_tests/tests/cross_zone_ping.rs @@ -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); diff --git a/integration_tests/tests/cross_zone_state_machine.rs b/integration_tests/tests/cross_zone_state_machine.rs index 1b67914a..25583b9b 100644 --- a/integration_tests/tests/cross_zone_state_machine.rs +++ b/integration_tests/tests/cross_zone_state_machine.rs @@ -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, diff --git a/integration_tests/tests/cross_zone_verified.rs b/integration_tests/tests/cross_zone_verified.rs index e2f016c0..7bcd0998 100644 --- a/integration_tests/tests/cross_zone_verified.rs +++ b/integration_tests/tests/cross_zone_verified.rs @@ -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); diff --git a/integration_tests/tests/indexer_ffi_helpers/mod.rs b/integration_tests/tests/indexer_ffi_helpers/mod.rs index 09e0a927..c1065968 100644 --- a/integration_tests/tests/indexer_ffi_helpers/mod.rs +++ b/integration_tests/tests/indexer_ffi_helpers/mod.rs @@ -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")?; diff --git a/integration_tests/tests/two_zone.rs b/integration_tests/tests/two_zone.rs index c895acd1..f1bdd8c7 100644 --- a/integration_tests/tests/two_zone.rs +++ b/integration_tests/tests/two_zone.rs @@ -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")?; diff --git a/lee/state_machine/src/lib.rs b/lee/state_machine/src/lib.rs index f8cc034a..238ac5a7 100644 --- a/lee/state_machine/src/lib.rs +++ b/lee/state_machine/src/lib.rs @@ -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, }; diff --git a/lee/state_machine/src/state/mod.rs b/lee/state_machine/src/state/mod.rs index 97a77978..f21530e0 100644 --- a/lee/state_machine/src/state/mod.rs +++ b/lee/state_machine/src/state/mod.rs @@ -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 = 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], diff --git a/lez/cross_zone/Cargo.toml b/lez/cross_zone/Cargo.toml index 465436f1..25341bda 100644 --- a/lez/cross_zone/Cargo.toml +++ b/lez/cross_zone/Cargo.toml @@ -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 diff --git a/lez/cross_zone/src/lib.rs b/lez/cross_zone/src/lib.rs index 544d4286..983622bd 100644 --- a/lez/cross_zone/src/lib.rs +++ b/lez/cross_zone/src/lib.rs @@ -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, } +/// 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 { + 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), + ] +} diff --git a/lez/indexer/core/src/block_store.rs b/lez/indexer/core/src/block_store.rs index 8c974399..4748a219 100644 --- a/lez/indexer/core/src/block_store.rs +++ b/lez/indexer/core/src/block_store.rs @@ -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 { + pub fn open_db( + location: &Path, + genesis_programs: Vec, + genesis_accounts: Vec<(AccountId, Account)>, + ) -> Result { #[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; diff --git a/lez/indexer/core/src/chain_consistency.rs b/lez/indexer/core/src/chain_consistency.rs index df4b5ba6..8503bdd4 100644 --- a/lez/indexer/core/src/chain_consistency.rs +++ b/lez/indexer/core/src/chain_consistency.rs @@ -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") } diff --git a/lez/indexer/core/src/config.rs b/lez/indexer/core/src/config.rs index 159da23c..6f9da750 100644 --- a/lez/indexer/core/src/config.rs +++ b/lez/indexer/core/src/config.rs @@ -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, } /// A genesis-funded bridge-lock holder balance, configured identically on the diff --git a/lez/indexer/core/src/cross_zone_verifier.rs b/lez/indexer/core/src/cross_zone_verifier.rs index 6e2a818e..f7a76d00 100644 --- a/lez/indexer/core/src/cross_zone_verifier.rs +++ b/lez/indexer/core/src/cross_zone_verifier.rs @@ -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 { 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, peers: PeerBlocks, seen: Arc>>, @@ -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> { + 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) { + 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 { 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}" ); } diff --git a/lez/indexer/core/src/lib.rs b/lez/indexer/core/src/lib.rs index 7bee6387..b230568a 100644 --- a/lez/indexer/core/src/lib.rs +++ b/lez/indexer/core/src/lib.rs @@ -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()); diff --git a/lez/sequencer/core/src/config.rs b/lez/sequencer/core/src/config.rs index a11a9988..8966a874 100644 --- a/lez/sequencer/core/src/config.rs +++ b/lez/sequencer/core/src/config.rs @@ -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, }, } diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 3a96e6d5..fccce81e 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -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) { #[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 { 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 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 { + 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 diff --git a/lez/testnet_initial_state/Cargo.toml b/lez/testnet_initial_state/Cargo.toml index c0899ea9..f06d5f51 100644 --- a/lez/testnet_initial_state/Cargo.toml +++ b/lez/testnet_initial_state/Cargo.toml @@ -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 diff --git a/lez/testnet_initial_state/src/lib.rs b/lez/testnet_initial_state/src/lib.rs index c92371cd..3296b953 100644 --- a/lez/testnet_initial_state/src/lib.rs +++ b/lez/testnet_initial_state/src/lib.rs @@ -258,30 +258,9 @@ fn initial_public_accounts() -> HashMap { .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 { vec![ programs::authenticated_transfer(), @@ -292,12 +271,6 @@ fn initial_programs() -> Vec { 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(), ] } diff --git a/programs/bridge_lock/core/Cargo.toml b/programs/bridge_lock/core/Cargo.toml deleted file mode 100644 index 2c9e2f58..00000000 --- a/programs/bridge_lock/core/Cargo.toml +++ /dev/null @@ -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"] diff --git a/programs/bridge_lock/core/src/lib.rs b/programs/bridge_lock/core/src/lib.rs deleted file mode 100644 index eae1d681..00000000 --- a/programs/bridge_lock/core/src/lib.rs +++ /dev/null @@ -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, - 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)); - } -} diff --git a/programs/cross_zone_inbox/core/Cargo.toml b/programs/cross_zone_inbox/core/Cargo.toml deleted file mode 100644 index 374a287d..00000000 --- a/programs/cross_zone_inbox/core/Cargo.toml +++ /dev/null @@ -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"] diff --git a/programs/cross_zone_inbox/core/src/lib.rs b/programs/cross_zone_inbox/core/src/lib.rs deleted file mode 100644 index edb90424..00000000 --- a/programs/cross_zone_inbox/core/src/lib.rs +++ /dev/null @@ -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, - /// 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, -} - -/// 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, - /// Reserved for a future source-state proof; MUST be `None` in v1. - pub l1_inclusion_witness: Option>, -} - -/// 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, - pub allowed_targets: BTreeMap>, -} - -impl InboxConfig { - /// Borsh-encoded form stored in the inbox config account. - #[must_use] - pub fn to_bytes(&self) -> Vec { - borsh::to_vec(self).expect("InboxConfig serializes") - } - - /// Decodes an [`InboxConfig`] from account data. - pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result { - 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); - -impl SeenShard { - /// Decodes a shard from account data; empty data is an empty shard. - pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result { - if bytes.is_empty() { - return Ok(Self::default()); - } - borsh::from_slice(bytes) - } - - #[must_use] - pub fn to_bytes(&self) -> Vec { - 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, -) -> 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, -} - -/// 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 { - 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, -) -> 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); - } -} diff --git a/programs/cross_zone_outbox/core/Cargo.toml b/programs/cross_zone_outbox/core/Cargo.toml deleted file mode 100644 index c7876286..00000000 --- a/programs/cross_zone_outbox/core/Cargo.toml +++ /dev/null @@ -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 diff --git a/programs/cross_zone_outbox/core/src/lib.rs b/programs/cross_zone_outbox/core/src/lib.rs deleted file mode 100644 index b3449598..00000000 --- a/programs/cross_zone_outbox/core/src/lib.rs +++ /dev/null @@ -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, - 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, -} - -impl OutboxRecord { - /// Borsh-encoded form stored in the outbox PDA's account data. - #[must_use] - pub fn to_bytes(&self) -> Vec { - borsh::to_vec(self).expect("OutboxRecord serializes") - } - - /// Decodes an [`OutboxRecord`] from account data. - pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result { - 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)); - } -} diff --git a/programs/ping/core/Cargo.toml b/programs/ping/core/Cargo.toml deleted file mode 100644 index 29870630..00000000 --- a/programs/ping/core/Cargo.toml +++ /dev/null @@ -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"] } diff --git a/programs/ping/core/src/lib.rs b/programs/ping/core/src/lib.rs deleted file mode 100644 index 268e5b98..00000000 --- a/programs/ping/core/src/lib.rs +++ /dev/null @@ -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 }, -} - -/// 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, - 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) -} diff --git a/programs/wrapped_token/core/Cargo.toml b/programs/wrapped_token/core/Cargo.toml deleted file mode 100644 index ef0aabbc..00000000 --- a/programs/wrapped_token/core/Cargo.toml +++ /dev/null @@ -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 diff --git a/programs/wrapped_token/core/src/lib.rs b/programs/wrapped_token/core/src/lib.rs deleted file mode 100644 index ea4ffd97..00000000 --- a/programs/wrapped_token/core/src/lib.rs +++ /dev/null @@ -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 { - 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]) - ); - } -} diff --git a/test_fixtures/fixtures/prebuilt_sequencer_db.dump b/test_fixtures/fixtures/prebuilt_sequencer_db.dump index 2aa0c51f..38aeb5ad 100644 Binary files a/test_fixtures/fixtures/prebuilt_sequencer_db.dump and b/test_fixtures/fixtures/prebuilt_sequencer_db.dump differ diff --git a/test_fixtures/src/config.rs b/test_fixtures/src/config.rs index 960f349b..d6b61448 100644 --- a/test_fixtures/src/config.rs +++ b/test_fixtures/src/config.rs @@ -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, + deploy_programs: Vec, ) -> Result { 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 { + 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::ALL.to_vec() +} + pub fn addr_to_url(protocol: UrlProtocol, addr: SocketAddr) -> Result { // 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: @@ -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 = 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" + ); + } +} diff --git a/test_fixtures/src/lib.rs b/test_fixtures/src/lib.rs index 687da077..f2d06b69 100644 --- a/test_fixtures/src/lib.rs +++ b/test_fixtures/src/lib.rs @@ -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()) diff --git a/test_fixtures/src/setup.rs b/test_fixtures/src/setup.rs index 325e1628..afe56712 100644 --- a/test_fixtures/src/setup.rs +++ b/test_fixtures/src/setup.rs @@ -116,6 +116,7 @@ pub async fn setup_indexer( bedrock_addr: SocketAddr, channel_id: ChannelId, cross_zone: Option, + deploy_programs: Vec, ) -> 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, diff --git a/tools/cross_zone_chat/src/main.rs b/tools/cross_zone_chat/src/main.rs index 5a066e9e..0d29e56f 100644 --- a/tools/cross_zone_chat/src/main.rs +++ b/tools/cross_zone_chat/src/main.rs @@ -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 {