feat(cross-zone): recognize multiple emitters via a shared extractor

This commit is contained in:
moudyellaz 2026-06-24 10:55:06 +02:00
parent 5d77359fd8
commit 2c387899c4
7 changed files with 133 additions and 62 deletions

View File

@ -8,7 +8,7 @@ use anyhow::{Context as _, Result, bail};
use common::{block::Block, transaction::LeeTransaction};
use cross_zone_inbox_core::{
CrossZoneMessage, Instruction as InboxInstruction, MessageKey, ZoneId,
build_dispatch_from_emission, message_key,
build_dispatch_from_emission, extract_emission, message_key,
};
use futures::StreamExt as _;
use lee::program::Program;
@ -18,7 +18,6 @@ use logos_blockchain_core::mantle::ops::channel::ChannelId;
use logos_blockchain_zone_sdk::{
CommonHttpClient, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
};
use ping_core::SenderInstruction;
use tokio::sync::RwLock;
use crate::config::IndexerConfig;
@ -61,7 +60,8 @@ impl PeerBlocks {
pub struct CrossZoneVerifier {
self_zone: ZoneId,
inbox_id: ProgramId,
emitter_id: ProgramId,
ping_sender_id: ProgramId,
bridge_lock_id: ProgramId,
peers: PeerBlocks,
seen: Arc<RwLock<HashSet<MessageKey>>>,
}
@ -90,7 +90,8 @@ impl CrossZoneVerifier {
Some(Self {
self_zone,
inbox_id: Program::cross_zone_inbox().id(),
emitter_id: Program::ping_sender().id(),
ping_sender_id: Program::ping_sender().id(),
bridge_lock_id: Program::bridge_lock().id(),
peers,
seen: Arc::new(RwLock::new(HashSet::new())),
})
@ -161,7 +162,7 @@ impl CrossZoneVerifier {
)
})?;
let emission = peer_block
let emission_tx = peer_block
.body
.transactions
.get(msg.src_tx_index as usize)
@ -169,23 +170,21 @@ impl CrossZoneVerifier {
anyhow::anyhow!("src_tx_index {} out of range in peer block", msg.src_tx_index)
})?;
let LeeTransaction::Public(emission) = emission else {
let LeeTransaction::Public(emission_tx) = emission_tx else {
bail!("peer emission transaction is not public");
};
if emission.message().program_id != self.emitter_id {
bail!("peer transaction at src_tx_index is not an emitter transaction");
}
let message = emission_tx.message();
let emission = extract_emission(
message.program_id,
&message.instruction_data,
self.ping_sender_id,
self.bridge_lock_id,
)
.ok_or_else(|| {
anyhow::anyhow!("peer transaction at src_tx_index is not a recognized emitter")
})?;
let SenderInstruction::Send {
target_zone,
target_program_id,
target_accounts,
payload,
..
} = risc0_zkvm::serde::from_slice(&emission.message().instruction_data)
.context("decode peer emission instruction")?;
if target_zone != self.self_zone {
if emission.target_zone != self.self_zone {
bail!("peer emission targets a different zone");
}
@ -194,10 +193,10 @@ impl CrossZoneVerifier {
msg.src_zone,
msg.src_block_id,
msg.src_tx_index,
self.emitter_id,
target_program_id,
&target_accounts,
payload,
message.program_id,
emission.target_program_id,
&emission.target_accounts,
emission.payload,
))
}
@ -269,7 +268,7 @@ mod tests {
program::Program,
public_transaction::{Message, WitnessSet},
};
use ping_core::ping_record_pda;
use ping_core::{SenderInstruction, ping_record_pda};
use super::*;
@ -281,7 +280,8 @@ mod tests {
CrossZoneVerifier {
self_zone: SELF_ZONE,
inbox_id: Program::cross_zone_inbox().id(),
emitter_id: Program::ping_sender().id(),
ping_sender_id: Program::ping_sender().id(),
bridge_lock_id: Program::bridge_lock().id(),
peers: PeerBlocks::default(),
seen: Arc::new(RwLock::new(HashSet::new())),
}

View File

@ -20,6 +20,7 @@ bridge_core.workspace = true
vault_core.workspace = true
cross_zone_inbox_core = { workspace = true, features = ["host"] }
ping_core.workspace = true
bridge_lock_core = { workspace = true, features = ["host"] }
logos-blockchain-key-management-system-service.workspace = true
logos-blockchain-core.workspace = true

View File

@ -25,6 +25,11 @@ pub enum GenesisAction {
SupplyBridgeAccount {
balance: u128,
},
/// Seeds a bridge-lock holder's initial bridgeable balance into genesis state.
SupplyBridgeLockHolding {
holder: AccountId,
amount: u128,
},
}
pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer};

View File

@ -1,7 +1,7 @@
use std::time::Duration;
use common::{block::Block, transaction::LeeTransaction};
use cross_zone_inbox_core::build_dispatch_from_emission;
use cross_zone_inbox_core::{build_dispatch_from_emission, extract_emission};
use futures::StreamExt as _;
use lee::program::Program;
use lee_core::program::ProgramId;
@ -11,7 +11,6 @@ use logos_blockchain_zone_sdk::{
CommonHttpClient, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
};
use mempool::MemPoolHandle;
use ping_core::SenderInstruction;
use crate::{
TransactionOrigin,
@ -30,7 +29,8 @@ pub fn spawn_watchers(
) {
let self_zone: [u8; 32] = *bedrock_config.channel_id.as_ref();
let inbox_id = Program::cross_zone_inbox().id();
let emitter_id = Program::ping_sender().id();
let ping_sender_id = Program::ping_sender().id();
let bridge_lock_id = Program::bridge_lock().id();
for peer in cross_zone.peers.clone() {
let node = NodeHttpClient::new(
@ -43,7 +43,8 @@ pub fn spawn_watchers(
peer.allowed_targets,
self_zone,
inbox_id,
emitter_id,
ping_sender_id,
bridge_lock_id,
poll_interval,
mempool_handle.clone(),
));
@ -60,7 +61,8 @@ async fn watch_peer(
allowed_targets: Vec<ProgramId>,
self_zone: [u8; 32],
inbox_id: ProgramId,
emitter_id: ProgramId,
ping_sender_id: ProgramId,
bridge_lock_id: ProgramId,
poll_interval: Duration,
mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
) {
@ -93,7 +95,8 @@ async fn watch_peer(
peer_zone,
self_zone,
inbox_id,
emitter_id,
ping_sender_id,
bridge_lock_id,
&allowed_targets,
&mempool_handle,
)
@ -110,16 +113,17 @@ async fn watch_peer(
}
/// Scans one peer block for outbound messages and injects a dispatch per match.
///
/// Option A (M3): the watcher recognizes the demo emitter and reads the outbound
/// message straight off its instruction. M4 replaces this with re-derivation
/// from the outbox PDA write, which removes the emitter-specific decoding.
#[expect(
clippy::too_many_arguments,
reason = "Each parameter is an independent piece of per-block delivery state"
)]
async fn deliver_block(
block: &Block,
peer_zone: [u8; 32],
self_zone: [u8; 32],
inbox_id: ProgramId,
emitter_id: ProgramId,
ping_sender_id: ProgramId,
bridge_lock_id: ProgramId,
allowed_targets: &[ProgramId],
mempool_handle: &MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
) {
@ -128,28 +132,19 @@ async fn deliver_block(
continue;
};
let message = public_tx.message();
if message.program_id != emitter_id {
let Some(emission) = extract_emission(
message.program_id,
&message.instruction_data,
ping_sender_id,
bridge_lock_id,
) else {
continue;
}
let SenderInstruction::Send {
target_zone,
target_program_id,
target_accounts,
payload,
..
} = match risc0_zkvm::serde::from_slice(&message.instruction_data) {
Ok(send) => send,
Err(err) => {
warn!("Watcher could not decode emitter instruction: {err}");
continue;
}
};
if target_zone != self_zone {
if emission.target_zone != self_zone {
continue;
}
if !allowed_targets.contains(&target_program_id) {
if !allowed_targets.contains(&emission.target_program_id) {
warn!(
"Watcher dropping message to disallowed target from peer {}",
hex::encode(peer_zone)
@ -162,10 +157,10 @@ async fn deliver_block(
peer_zone,
block.header.block_id,
u32::try_from(index).unwrap_or(u32::MAX),
emitter_id,
target_program_id,
&target_accounts,
payload,
message.program_id,
emission.target_program_id,
&emission.target_accounts,
emission.payload,
);
match mempool_handle

View File

@ -603,14 +603,16 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
let genesis_txs = config
.genesis
.iter()
.map(|genesis_tx| match genesis_tx {
.filter_map(|genesis_tx| match genesis_tx {
GenesisAction::SupplyAccount {
account_id,
balance,
} => build_supply_account_genesis_transaction(account_id, *balance),
} => Some(build_supply_account_genesis_transaction(account_id, *balance)),
GenesisAction::SupplyBridgeAccount { balance } => {
build_supply_bridge_account_genesis_transaction(*balance)
Some(build_supply_bridge_account_genesis_transaction(*balance))
}
// Force-inserted below: bridge_lock has no mint transaction.
GenesisAction::SupplyBridgeLockHolding { .. } => None,
})
.chain(std::iter::once(clock_invocation(0)))
.inspect(|tx| {
@ -621,6 +623,14 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
.map(LeeTransaction::Public)
.collect();
// Seed bridge-lock holder balances directly: they are not produced by any tx.
for action in &config.genesis {
if let GenesisAction::SupplyBridgeLockHolding { holder, amount } = action {
let (holder_id, account) = bridge_lock_core::build_holding_account(*holder, *amount);
state.insert_genesis_account(holder_id, account);
}
}
// Seed this zone's cross-zone inbox config so the inbox guest can authorize
// inbound peer messages (zone-specific config, not produced by any tx).
if let Some(cross_zone) = &config.cross_zone {

View File

@ -13,7 +13,10 @@ 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; pulls `lee`, so the risc0 guest builds without it.
host = ["dep:lee"]
# Host-only transaction builder and emission extractor; pull `lee` and the
# emitter cores, so the risc0 guest builds without them.
host = ["dep:lee", "dep:ping_core", "dep:bridge_lock_core"]

View File

@ -200,6 +200,63 @@ pub fn build_inbox_dispatch_tx(
)
}
/// The cross-zone emission fields a watcher or verifier reads off a source
/// transaction, common to every emitter program.
#[cfg(feature = "host")]
pub struct Emission {
pub target_zone: ZoneId,
pub target_program_id: ProgramId,
pub target_accounts: Vec<[u8; 32]>,
pub payload: Vec<u8>,
}
/// Extracts the cross-zone emission from a source transaction, recognizing the
/// known emitter programs. Returns `None` for any other program. The watcher and
/// verifier both use this so they agree on what a given source tx emits.
///
/// Option A: each emitter is decoded explicitly. The principled alternative is to
/// read the outbox PDA write, which would need re-execution of the source tx.
#[cfg(feature = "host")]
#[must_use]
pub fn extract_emission(
program_id: ProgramId,
instruction_data: &[u32],
ping_sender_id: ProgramId,
bridge_lock_id: ProgramId,
) -> Option<Emission> {
if program_id == ping_sender_id {
let ping_core::SenderInstruction::Send {
target_zone,
target_program_id,
target_accounts,
payload,
..
} = risc0_zkvm::serde::from_slice(instruction_data).ok()?;
Some(Emission {
target_zone,
target_program_id,
target_accounts,
payload,
})
} else if program_id == bridge_lock_id {
let bridge_lock_core::Instruction::Lock {
target_zone,
target_program_id,
target_accounts,
payload,
..
} = risc0_zkvm::serde::from_slice(instruction_data).ok()?;
Some(Emission {
target_zone,
target_program_id,
target_accounts,
payload,
})
} else {
None
}
}
/// Builds the dispatch transaction for one peer emission. Both the sequencer's
/// watcher and the indexer's verifier go through this so their transactions are
/// byte-identical for the same emission (the basis of the Option B check).