refactor(cross-zone)!: register cross-zone programs as base builtins

BREAKING CHANGE: GenesisAction::DeployProgram and IndexerConfig.deploy_programs are removed. The cross-zone programs (inbox, outbox, ping_sender, ping_receiver, bridge_lock, wrapped_token) are now base builtins in testnet_initial_state, because program bytecode exceeds the genesis block inscription limit, so they cannot be shipped through genesis transactions.
This commit is contained in:
moudyellaz 2026-07-21 10:50:41 +02:00
parent 9380198eb9
commit a806ebb94a
19 changed files with 62 additions and 258 deletions

View File

@ -65,31 +65,20 @@ 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 mut genesis_a = vec![GenesisAction::SupplyBridgeLockHolding {
let 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,
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),
config::all_cross_zone_programs(),
)
.await
.context("Failed to set up zone B indexer")?;
let (_seq_b, _seq_b_home) =
setup_sequencer(partial, bedrock_addr, vec![], channel_b, Some(cross_zone.clone()))
.await
.context("Failed to set up zone B sequencer")?;
let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, Some(cross_zone))
.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,15 +34,9 @@ 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,
config::cross_zone_deploy_actions(),
channel,
None,
)
.await
.context("Failed to set up sequencer")?;
let (seq, _seq_home) = setup_sequencer(partial, bedrock_addr, vec![], 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,24 +54,13 @@ async fn ping_crosses_from_zone_a_to_zone_b() -> Result<()> {
}],
};
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")?;
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")?;
// Submit the ping on zone A, addressed to ping_receiver on zone B.
let ping = build_ping_tx(zone_b, receiver_id);

View File

@ -53,40 +53,24 @@ 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,
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_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_b, _seq_b_home) = setup_sequencer(
partial,
bedrock_addr,
config::cross_zone_deploy_actions(),
vec![],
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),
config::all_cross_zone_programs(),
)
.await
.context("Failed to set up zone B indexer")?;
let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, Some(cross_zone))
.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,7 +54,6 @@ 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, Vec::new())
let (idx_a, _idx_a_home) = setup_indexer(bedrock_addr, channel_a, None)
.await
.context("Failed to set up zone A indexer")?;
let (seq_b, _seq_b_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_b, None)
.await
.context("Failed to set up zone B sequencer")?;
let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, None, Vec::new())
let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, None)
.await
.context("Failed to set up zone B indexer")?;

View File

@ -287,12 +287,12 @@ impl V03State {
/// 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.
/// The sequencer and the indexer build the directly-seeded part of genesis
/// (base builtins plus any directly-seeded accounts) separately from their own
/// configs, so a divergence there would otherwise go unnoticed. 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};

View File

@ -16,12 +16,10 @@ 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, 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.
@ -32,47 +30,6 @@ 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
@ -237,13 +194,6 @@ pub fn build_holding_account(holder: AccountId, amount: Balance) -> (AccountId,
(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.

View File

@ -364,6 +364,9 @@ fn apply_block_to_scratch(block: &Block, state: &mut V03State) -> Result<(), Blo
reason: format!("{err:#}"),
};
if is_genesis {
// Every genesis transaction is public (program config, supplies, clock);
// a non-public one is unexpected and parks the indexer rather than
// silently diverging.
let LeeTransaction::Public(public_tx) = transaction else {
return Err(BlockIngestError::NonPublicGenesisTransaction);
};

View File

@ -321,7 +321,6 @@ 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,7 +2,6 @@ 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;
@ -38,12 +37,6 @@ 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

@ -93,8 +93,7 @@ impl IndexerCore {
);
let zone_indexer = ZoneIndexer::new(config.channel_id, node.clone());
// Cross-zone programs, mirroring the sequencer's DeployProgram set.
let genesis_programs = cross_zone::deployed_programs(&config.deploy_programs);
// Cross-zone programs are base builtins, so none are seeded here.
// Bridge-lock holdings (source side) always; inbox + wrapped-token config only when
// cross_zone is set.
let mut genesis_accounts: Vec<_> = config
@ -113,7 +112,7 @@ impl IndexerCore {
Ok(Self {
zone_indexer: Arc::new(zone_indexer),
store: IndexerStore::open_db(&home, genesis_programs, genesis_accounts)?,
store: IndexerStore::open_db(&home, Vec::new(), genesis_accounts)?,
node,
config,
status: Arc::new(ArcSwap::from_pointee(IndexerSyncStatus::starting())),

View File

@ -8,7 +8,6 @@ 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, Balance};
@ -32,13 +31,6 @@ pub enum GenesisAction {
holder: AccountId,
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,
},
}
// TODO: Provide default values

View File

@ -637,8 +637,8 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
#[cfg(feature = "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));
// Cross-zone programs are base builtins, so nothing extra is registered here.
let mut state = base;
// Bridge-lock holdings belong to the source side, seeded regardless of receiving config.
let holdings: Vec<_> = bridge_lock_holdings(&config.genesis)
.into_iter()
@ -670,11 +670,8 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
GenesisAction::SupplyBridgeAccount { balance } => {
Some(build_supply_bridge_account_genesis_transaction(*balance))
}
// Seeded/registered directly (accounts via `cross_zone::genesis_accounts`,
// programs via `with_programs`), not by a genesis tx.
GenesisAction::SupplyBridgeLockHolding { .. } | GenesisAction::DeployProgram { .. } => {
None
}
// Seeded directly (accounts via `cross_zone::genesis_accounts`), not by a genesis tx.
GenesisAction::SupplyBridgeLockHolding { .. } => None,
})
.chain(std::iter::once(clock_invocation(0)))
.inspect(|tx| {
@ -694,28 +691,11 @@ fn bridge_lock_holdings(genesis: &[GenesisAction]) -> Vec<(lee::AccountId, lee::
.iter()
.filter_map(|action| match action {
GenesisAction::SupplyBridgeLockHolding { holder, amount } => Some((*holder, *amount)),
GenesisAction::SupplyAccount { .. }
| GenesisAction::SupplyBridgeAccount { .. }
| GenesisAction::DeployProgram { .. } => None,
GenesisAction::SupplyAccount { .. } | GenesisAction::SupplyBridgeAccount { .. } => 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

@ -271,6 +271,16 @@ fn initial_programs() -> Vec<Program> {
programs::vault(),
programs::faucet(),
programs::bridge(),
// Cross-zone programs are builtins: their bytecode is baked into every node,
// so registering them in the base state (rather than shipping ELFs through
// the genesis block, which exceeds the inscription size limit) keeps the two
// nodes in lock-step with nothing to desync.
programs::cross_zone_inbox(),
programs::cross_zone_outbox(),
programs::ping_sender(),
programs::ping_receiver(),
programs::bridge_lock(),
programs::wrapped_token(),
]
}

View File

@ -6,9 +6,7 @@ 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, CrossZoneProgram, GenesisAction, SequencerConfig,
};
use sequencer_core::config::{BedrockConfig, CrossZoneConfig, GenesisAction, SequencerConfig};
use url::Url;
use wallet::config::WalletConfig;
@ -210,7 +208,6 @@ 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),
@ -223,26 +220,9 @@ 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>
@ -275,58 +255,3 @@ 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, Vec::new())
setup_indexer(bedrock_addr, config::bedrock_channel_id(), None)
.await
.context("Failed to setup Indexer")?;
let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, indexer_handle.addr())

View File

@ -116,7 +116,6 @@ 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")?;
@ -126,9 +125,8 @@ pub async fn setup_indexer(
temp_indexer_dir.path().display()
);
let indexer_config =
config::indexer_config(bedrock_addr, channel_id, cross_zone, deploy_programs)
.context("Failed to create Indexer config")?;
let indexer_config = config::indexer_config(bedrock_addr, channel_id, cross_zone)
.context("Failed to create Indexer config")?;
indexer_service::run_server(
indexer_config,

View File

@ -292,7 +292,7 @@ async fn main() -> Result<()> {
let (seq_a, _home_a) = setup_sequencer(
partial,
bedrock_addr,
config::cross_zone_deploy_actions(),
vec![],
channel_a,
Some(cross_zone_a),
)
@ -301,7 +301,7 @@ async fn main() -> Result<()> {
let (seq_b, _home_b) = setup_sequencer(
partial,
bedrock_addr,
config::cross_zone_deploy_actions(),
vec![],
channel_b,
Some(cross_zone_b),
)