refactor(cross-zone): seed cross-zone config via genesis transactions

This commit is contained in:
moudyellaz 2026-07-21 15:19:16 +02:00
parent a806ebb94a
commit f4a5d85c65
10 changed files with 271 additions and 112 deletions

View File

@ -20,6 +20,7 @@ use lee_core::{
account::{Account, AccountId, Balance},
program::ProgramId,
};
use serde::Serialize;
/// The cross-zone emission fields a watcher or verifier reads off a source
/// transaction, common to every emitter program.
@ -142,38 +143,35 @@ pub fn build_dispatch_from_emission(
build_inbox_dispatch_tx(programs::cross_zone_inbox().id(), &msg, target_ids)
}
/// Builds the inbox config account a zone seeds into genesis state.
///
/// Lets the inbox guest authorize inbound peer messages. The sequencer and
/// indexer seed the same account from the same config, keeping their replayed
/// state consistent.
#[must_use]
pub fn build_inbox_config_account(
self_zone: ZoneId,
cross_zone: &CrossZoneConfig,
) -> (AccountId, Account) {
let inbox_id = programs::cross_zone_inbox().id();
/// The inbox config a zone derives from its cross-zone config: the per-peer target
/// allowlists plus its own zone id.
fn inbox_config(self_zone: ZoneId, cross_zone: &CrossZoneConfig) -> InboxConfig {
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 {
InboxConfig {
self_zone,
allowed_peers: BTreeMap::new(),
allowed_targets,
};
}
}
let 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)
/// The genesis transaction that initializes this zone's inbox config PDA.
///
/// Lets the inbox guest authorize inbound peer messages; replaying it seeds the
/// same account on every node, keeping their state consistent.
#[must_use]
pub fn build_inbox_init_config_tx(
self_zone: ZoneId,
cross_zone: &CrossZoneConfig,
) -> lee::PublicTransaction {
let inbox_id = programs::cross_zone_inbox().id();
genesis_public_tx(
inbox_id,
vec![inbox_config_account_id(inbox_id)],
Instruction::InitConfig(inbox_config(self_zone, cross_zone)),
)
}
/// Builds the genesis holding account funding a holder's bridgeable balance.
@ -194,34 +192,32 @@ pub fn build_holding_account(holder: AccountId, amount: Balance) -> (AccountId,
(holder, account)
}
/// 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) {
/// The genesis transaction that pins the cross-zone inbox as the wrapped-token
/// minter, without importing the inbox id into the guest.
#[must_use]
pub fn build_wrapped_token_init_config_tx() -> lee::PublicTransaction {
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()
genesis_public_tx(
wrapped_token_id,
vec![wrapped_token_core::config_account_id(wrapped_token_id)],
wrapped_token_core::Instruction::InitConfig {
minter: programs::cross_zone_inbox().id(),
},
)
}
/// 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),
]
/// Builds an unsigned, sequencer-origin genesis transaction invoking `instruction`
/// on `program_id` over `account_ids`.
fn genesis_public_tx<I: Serialize>(
program_id: ProgramId,
account_ids: Vec<AccountId>,
instruction: I,
) -> lee::PublicTransaction {
let message =
lee::public_transaction::Message::try_new(program_id, account_ids, vec![], instruction)
.expect("genesis instruction must serialize");
lee::PublicTransaction::new(
message,
lee::public_transaction::WitnessSet::from_raw_parts(vec![]),
)
}

View File

@ -186,7 +186,9 @@ impl CrossZoneVerifier {
&public_tx.message().instruction_data,
) {
Ok(InboxInstruction::Dispatch(msg)) => Some(msg),
Err(_) => None,
// Only a dispatch carries a cross-zone message to re-derive; a genesis
// `InitConfig` is not verifier-relevant.
Ok(InboxInstruction::InitConfig(_)) | Err(_) => None,
}
}

View File

@ -93,18 +93,15 @@ impl IndexerCore {
);
let zone_indexer = ZoneIndexer::new(config.channel_id, node.clone());
// 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
// Cross-zone programs are base builtins, and their config accounts are
// reconstructed by replaying the genesis block's InitConfig transactions;
// neither is seeded here. Only bridge-lock holdings (source side), not
// produced by any transaction, are still seeded directly.
let 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_accounts.extend(cross_zone::genesis_accounts(self_zone, cross_zone));
}
// Option B verifier: re-derives each cross-zone dispatch from the peer's
// finalized blocks. `None` when cross-zone messaging is disabled.

View File

@ -36,7 +36,10 @@ fn main() {
let WrappedInstruction::Mint {
amount: mint_amount,
..
} = decode_mint(&payload);
} = decode_mint(&payload)
else {
panic!("bridge_lock payload must be a wrapped-token mint");
};
assert_eq!(
mint_amount, amount,
"locked amount must equal the wrapped mint amount"

View File

@ -116,6 +116,10 @@ impl SeenShard {
pub enum Instruction {
/// Delivers a finalized peer message to its target program.
Dispatch(CrossZoneMessage),
/// Initializes the inbox config account at genesis. Written once, into a
/// default (unclaimed) config PDA; the guest refuses a non-default pre-state,
/// so it cannot be re-run to overwrite the allowlists.
InitConfig(InboxConfig),
}
/// Content-addressed replay key for a delivered message.
@ -142,7 +146,14 @@ pub fn message_key(src_zone: &ZoneId, src_block_id: u64, src_tx_index: u32) -> M
/// 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))
AccountId::for_public_pda(&inbox_id, &inbox_config_seed())
}
/// Seed of the config PDA, exposed so the guest can claim the account when it
/// initializes the config at genesis.
#[must_use]
pub const fn inbox_config_seed() -> PdaSeed {
PdaSeed::new(INBOX_CONFIG_SEED)
}
/// The seen-set shard for the `(src_zone, epoch)` the message falls in.

View File

@ -1,10 +1,13 @@
use cross_zone_inbox_core::{
InboxConfig, Instruction, SeenShard, inbox_config_account_id, inbox_seen_shard_account_id,
inbox_seen_shard_seed, message_key,
CrossZoneMessage, InboxConfig, Instruction, SeenShard, inbox_config_account_id,
inbox_config_seed, inbox_seen_shard_account_id, inbox_seen_shard_seed, message_key,
};
use lee_core::{
account::AccountWithMetadata,
program::{AccountPostState, ChainedCall, Claim, ProgramInput, ProgramOutput, read_lee_inputs},
account::{Account, AccountWithMetadata},
program::{
AccountPostState, ChainedCall, Claim, ProgramId, ProgramInput, ProgramOutput,
read_lee_inputs,
},
};
fn unchanged(pre: &AccountWithMetadata) -> AccountPostState {
@ -27,8 +30,32 @@ fn main() {
"Inbox is only invoked as a top-level sequencer-origin transaction"
);
let Instruction::Dispatch(msg) = instruction;
match instruction {
Instruction::Dispatch(msg) => dispatch(
self_program_id,
caller_program_id,
pre_states,
instruction_words,
&msg,
),
Instruction::InitConfig(config) => init_config(
self_program_id,
caller_program_id,
pre_states,
instruction_words,
&config,
),
}
}
/// Delivers a finalized peer message to its target program, no-op on replay.
fn dispatch(
self_program_id: ProgramId,
caller_program_id: Option<ProgramId>,
pre_states: Vec<AccountWithMetadata>,
instruction_words: Vec<u32>,
msg: &CrossZoneMessage,
) {
assert!(
msg.l1_inclusion_witness.is_none(),
"l1_inclusion_witness must be None in v1"
@ -123,3 +150,47 @@ fn main() {
.with_chained_calls(chained_calls)
.write();
}
/// Writes the inbox config (peer + target allowlists) into the config PDA exactly
/// once at genesis.
fn init_config(
self_program_id: ProgramId,
caller_program_id: Option<ProgramId>,
pre_states: Vec<AccountWithMetadata>,
instruction_words: Vec<u32>,
config: &InboxConfig,
) {
// pre_states: [config PDA].
let [config_meta] = <[AccountWithMetadata; 1]>::try_from(pre_states)
.expect("InitConfig requires the config account");
assert_eq!(
config_meta.account_id,
inbox_config_account_id(self_program_id),
"account must be the inbox config PDA"
);
// Init-once: a default pre-state cannot be re-run to overwrite the allowlists,
// and `new_claimed_if_default` alone would not stop the owning program from
// rewriting its own config data on a later call.
assert_eq!(
config_meta.account,
Account::default(),
"inbox config already initialized"
);
let mut config_account = config_meta.account.clone();
config_account.data = config
.to_bytes()
.try_into()
.expect("inbox config fits in account data");
let config_post =
AccountPostState::new_claimed_if_default(config_account, Claim::Pda(inbox_config_seed()));
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
vec![config_meta],
vec![config_post],
)
.write();
}

View File

@ -19,6 +19,12 @@ pub enum Instruction {
/// Required accounts (2): the wrapped-token config PDA, then the recipient's
/// holding PDA.
Mint { recipient: [u8; 32], amount: u128 },
/// Pins `minter` (the cross-zone inbox) as the authorized minter, written once
/// into a default config PDA at genesis. The guest refuses a non-default
/// pre-state, so it cannot be re-run to hijack the minter.
///
/// Required accounts (1): the wrapped-token config PDA.
InitConfig { minter: ProgramId },
}
/// PDA holding the authorized minter program id (the cross-zone inbox), seeded at

View File

@ -1,10 +1,10 @@
use lee_core::{
account::AccountWithMetadata,
account::{Account, AccountWithMetadata},
program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs},
};
use wrapped_token_core::{
Instruction, balance_bytes, config_account_id, holding_account_id, holding_seed, read_balance,
read_minter,
Instruction, balance_bytes, config_account_id, config_seed, holding_account_id, holding_seed,
minter_bytes, read_balance, read_minter,
};
fn main() {
@ -18,8 +18,33 @@ fn main() {
instruction_words,
) = read_lee_inputs::<Instruction>();
let Instruction::Mint { recipient, amount } = instruction;
match instruction {
Instruction::Mint { recipient, amount } => mint(
self_program_id,
caller_program_id,
pre_states,
instruction_words,
recipient,
amount,
),
Instruction::InitConfig { minter } => init_config(
self_program_id,
caller_program_id,
pre_states,
instruction_words,
minter,
),
}
}
fn mint(
self_program_id: lee_core::program::ProgramId,
caller_program_id: Option<lee_core::program::ProgramId>,
pre_states: Vec<AccountWithMetadata>,
instruction_words: Vec<u32>,
recipient: [u8; 32],
amount: u128,
) {
// pre_states: [config PDA, recipient holding PDA].
let [config, holding] = <[AccountWithMetadata; 2]>::try_from(pre_states)
.expect("Mint requires the config and recipient holding accounts");
@ -68,3 +93,51 @@ fn main() {
)
.write();
}
/// Writes the authorized minter into the config PDA exactly once at genesis.
fn init_config(
self_program_id: lee_core::program::ProgramId,
caller_program_id: Option<lee_core::program::ProgramId>,
pre_states: Vec<AccountWithMetadata>,
instruction_words: Vec<u32>,
minter: lee_core::program::ProgramId,
) {
assert!(
caller_program_id.is_none(),
"InitConfig is a top-level genesis transaction"
);
// pre_states: [config PDA].
let [config] = <[AccountWithMetadata; 1]>::try_from(pre_states)
.expect("InitConfig requires the config account");
assert_eq!(
config.account_id,
config_account_id(self_program_id),
"account must be the wrapped-token config PDA"
);
// Init-once: a default pre-state cannot be re-run to overwrite the minter, and
// `new_claimed_if_default` alone would not stop the owning program from
// rewriting its own config data on a later call.
assert_eq!(
config.account,
Account::default(),
"wrapped-token config already initialized"
);
let mut config_account = config.account.clone();
config_account.data = minter_bytes(minter)
.to_vec()
.try_into()
.expect("minter id fits in account data");
let config_post =
AccountPostState::new_claimed_if_default(config_account, Claim::Pda(config_seed()));
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
vec![config],
vec![config_post],
)
.write();
}

View File

@ -637,19 +637,12 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
#[cfg(feature = "testnet")]
let base = testnet_initial_state::initial_state_testnet();
// 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()
.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));
}
let mut state = base.with_public_accounts(holdings);
// Fingerprint the directly-seeded state, before genesis txs, so it matches the indexer's.
info!(
@ -657,22 +650,37 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
hex::encode(state.genesis_fingerprint())
);
let genesis_txs = config
.genesis
.iter()
.filter_map(|genesis_tx| match genesis_tx {
GenesisAction::SupplyAccount {
account_id,
balance,
} => Some(build_supply_account_genesis_transaction(
account_id, *balance,
)),
GenesisAction::SupplyBridgeAccount { balance } => {
Some(build_supply_bridge_account_genesis_transaction(*balance))
}
// Seeded directly (accounts via `cross_zone::genesis_accounts`), not by a genesis tx.
GenesisAction::SupplyBridgeLockHolding { .. } => None,
})
// Config txs seed the config accounts by transaction, so every node
// reconstructs them by replaying the genesis block. The wrapped-token minter is
// initialized on every zone (wrapped_token is a builtin), since its InitConfig
// is user-callable and a config PDA left default would be claimable by anyone as
// the first initializer (a minter hijack). The inbox allowlist is initialized
// only on receiving zones; the inbox is sequencer-only, so its default config
// PDA is not user-claimable, merely unused until the zone receives.
let wrapped_token_config_tx = std::iter::once(cross_zone::build_wrapped_token_init_config_tx());
let inbox_config_tx = config.cross_zone.as_ref().map(|cross_zone| {
let self_zone = *config.bedrock_config.channel_id.as_ref();
cross_zone::build_inbox_init_config_tx(self_zone, cross_zone)
});
let supply_txs = config.genesis.iter().filter_map(|action| match action {
GenesisAction::SupplyAccount {
account_id,
balance,
} => Some(build_supply_account_genesis_transaction(
account_id, *balance,
)),
GenesisAction::SupplyBridgeAccount { balance } => {
Some(build_supply_bridge_account_genesis_transaction(*balance))
}
// Seeded directly (holdings via `build_holding_account`), not by a genesis tx.
GenesisAction::SupplyBridgeLockHolding { .. } => None,
});
// Applied in order with the mandatory clock last, then committed to the genesis
// block so external observers can replay them. Every genesis tx is public.
let genesis_txs = wrapped_token_config_tx
.chain(inbox_config_tx)
.chain(supply_txs)
.chain(std::iter::once(clock_invocation(0)))
.inspect(|tx| {
state

View File

@ -289,24 +289,14 @@ 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, 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 state = Arc::new(AppState {
zone_a: ZoneRuntime {
@ -446,7 +436,9 @@ async fn poll_finality(state: Arc<AppState>) {
fn decode_inbox_text(instruction_data: &[u32]) -> Option<String> {
let instruction: Instruction =
risc0_zkvm::serde::from_slice::<Instruction, u32>(instruction_data).ok()?;
let Instruction::Dispatch(message) = instruction;
let Instruction::Dispatch(message) = instruction else {
return None;
};
decode_payload(&message.payload)
}