mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-07-25 07:03:13 +00:00
Merge pull request #615 from logos-blockchain/moudy/cross-zone-cleanup
refactor!(cross-zone): deploy programs at genesis instead of builtins
This commit is contained in:
commit
fb98e2d564
3
Cargo.lock
generated
3
Cargo.lock
generated
@ -1952,6 +1952,8 @@ dependencies = [
|
||||
"ping_core",
|
||||
"programs",
|
||||
"risc0-zkvm",
|
||||
"serde",
|
||||
"wrapped_token_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -10282,7 +10284,6 @@ dependencies = [
|
||||
"programs",
|
||||
"serde",
|
||||
"system_accounts",
|
||||
"wrapped_token_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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::time::Duration;
|
||||
|
||||
@ -103,16 +110,12 @@ async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> {
|
||||
// escrow now.
|
||||
let seq_a_client = sequencer_client(seq_a.addr())?;
|
||||
let escrow_id = bridge_lock_core::escrow_account_id(programs::bridge_lock().id());
|
||||
let escrowed = bridge_lock_core::read_balance(
|
||||
&seq_a_client.get_account(escrow_id).await?.data.into_inner(),
|
||||
);
|
||||
let escrowed = seq_a_client.get_account(escrow_id).await?.balance;
|
||||
assert_eq!(
|
||||
escrowed, LOCK_AMOUNT,
|
||||
"zone A escrow must hold the locked amount"
|
||||
);
|
||||
let remaining = bridge_lock_core::read_balance(
|
||||
&seq_a_client.get_account(holder_id).await?.data.into_inner(),
|
||||
);
|
||||
let remaining = seq_a_client.get_account(holder_id).await?.balance;
|
||||
assert_eq!(
|
||||
remaining,
|
||||
INITIAL_BALANCE - LOCK_AMOUNT,
|
||||
|
||||
@ -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,18 +170,14 @@ 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,
|
||||
balance: 0,
|
||||
data: bridge_lock_core::balance_bytes(INITIAL_BALANCE)
|
||||
.to_vec()
|
||||
.try_into()
|
||||
.expect("balance fits in account data"),
|
||||
nonce: 0_u128.into(),
|
||||
balance: INITIAL_BALANCE,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
)]);
|
||||
|
||||
let payload = mint_payload();
|
||||
let target_accounts = vec![
|
||||
@ -214,16 +210,14 @@ fn lock_escrows_balance_and_emits_to_outbox() {
|
||||
.expect("lock must validate and execute");
|
||||
let public_diff = diff.public_diff();
|
||||
|
||||
let holder_after =
|
||||
bridge_lock_core::read_balance(&public_diff[&holder_id].data.clone().into_inner());
|
||||
let holder_after = public_diff[&holder_id].balance;
|
||||
assert_eq!(
|
||||
holder_after,
|
||||
INITIAL_BALANCE - LOCK_AMOUNT,
|
||||
"holder debited"
|
||||
);
|
||||
|
||||
let escrow_after =
|
||||
bridge_lock_core::read_balance(&public_diff[&escrow_id].data.clone().into_inner());
|
||||
let escrow_after = public_diff[&escrow_id].balance;
|
||||
assert_eq!(escrow_after, LOCK_AMOUNT, "escrow credited");
|
||||
|
||||
let record =
|
||||
@ -314,7 +308,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 +319,7 @@ fn mint_replay_rejected() {
|
||||
.expect("shard fits in account data"),
|
||||
nonce: 0_u128.into(),
|
||||
},
|
||||
);
|
||||
)]);
|
||||
|
||||
let msg = CrossZoneMessage {
|
||||
src_zone,
|
||||
|
||||
@ -12,7 +12,6 @@ use std::{path::Path, time::Duration};
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use indexer_service_rpc::RpcClient as _;
|
||||
use integration_tests::L2_TO_L1_TIMEOUT;
|
||||
use lee::{AccountId, PrivateKey, PublicKey};
|
||||
use logos_blockchain_core::mantle::ops::channel::ChannelId;
|
||||
use sequencer_core::config::GenesisAction;
|
||||
@ -24,6 +23,10 @@ use test_fixtures::{
|
||||
};
|
||||
use tokio::test;
|
||||
|
||||
/// Finalization can lag several minutes under CI load; give the bootstrap and
|
||||
/// reconstruction waits generous headroom so runner-speed variance does not flake.
|
||||
const FINALIZE_TIMEOUT: Duration = Duration::from_mins(12);
|
||||
|
||||
/// Block cadence for the tests: short so we don't wait long for local production.
|
||||
fn fast_blocks() -> SequencerPartialConfig {
|
||||
SequencerPartialConfig {
|
||||
@ -227,7 +230,7 @@ async fn empty_local_reconstructs_from_populated_bedrock() -> Result<()> {
|
||||
|
||||
// Wait until those blocks are finalized on Bedrock — reconstruction only
|
||||
// reads finalized history. A stays alive so its publish task keeps flushing.
|
||||
let finalized = wait_for_finalized(&indexer, PRODUCED_TARGET, L2_TO_L1_TIMEOUT).await?;
|
||||
let finalized = wait_for_finalized(&indexer, PRODUCED_TARGET, FINALIZE_TIMEOUT).await?;
|
||||
|
||||
// Stop A, then wipe just its L2 store (keeping the bedrock signing key) so it
|
||||
// restarts from an empty store on the same channel/identity — a sequencer that
|
||||
@ -398,7 +401,7 @@ async fn local_ahead_of_channel_resumes() -> Result<()> {
|
||||
.await
|
||||
.context("Failed to start sequencer A")?;
|
||||
let client_a = sequencer_client(handle_a.addr())?;
|
||||
let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, L2_TO_L1_TIMEOUT).await?;
|
||||
let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, FINALIZE_TIMEOUT).await?;
|
||||
let tip_before = client_a.get_last_block_id().await?;
|
||||
assert!(
|
||||
tip_before > finalized,
|
||||
@ -501,7 +504,7 @@ async fn local_behind_channel_reconstructs_forward() -> Result<()> {
|
||||
.setup_at(home.path())
|
||||
.await
|
||||
.context("Failed to resume sequencer")?;
|
||||
let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, L2_TO_L1_TIMEOUT).await?;
|
||||
let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, FINALIZE_TIMEOUT).await?;
|
||||
drop(handle);
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
finalized
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,58 @@ 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 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};
|
||||
|
||||
// Destructure so adding a `V03State` field forces a decision here about
|
||||
// whether it belongs in the genesis fingerprint.
|
||||
let Self {
|
||||
public_state,
|
||||
private_state,
|
||||
programs,
|
||||
} = self;
|
||||
|
||||
let mut accounts: Vec<(&AccountId, &Account)> = public_state.iter().collect();
|
||||
accounts.sort_by(|a, b| a.0.as_ref().cmp(b.0.as_ref()));
|
||||
|
||||
let mut program_ids: Vec<ProgramId> = 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(private_state.0.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],
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -11,9 +17,10 @@ use cross_zone_inbox_core::{
|
||||
inbox_seen_shard_account_id,
|
||||
};
|
||||
use lee_core::{
|
||||
account::{Account, AccountId},
|
||||
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.
|
||||
@ -136,54 +143,78 @@ 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.
|
||||
///
|
||||
/// Owned by `bridge_lock`, data is the LE balance. Not produced by any
|
||||
/// transaction, so the sequencer and indexer both seed it through this one
|
||||
/// builder.
|
||||
/// A real native balance owned by `bridge_lock`, which can debit it on a lock; it
|
||||
/// is conserved like any other balance. Not produced by any 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)
|
||||
.to_vec()
|
||||
.try_into()
|
||||
.expect("balance fits in account data"),
|
||||
balance: amount,
|
||||
..Default::default()
|
||||
};
|
||||
(holder, 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();
|
||||
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(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// 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![]),
|
||||
)
|
||||
}
|
||||
|
||||
@ -27,17 +27,16 @@ impl IndexerStore {
|
||||
/// Creates files if necessary.
|
||||
pub fn open_db(location: &Path, genesis_seed: Vec<(AccountId, Account)>) -> Result<Self> {
|
||||
#[cfg(not(feature = "testnet"))]
|
||||
let mut initial_state = testnet_initial_state::initial_state();
|
||||
let base = testnet_initial_state::initial_state();
|
||||
|
||||
#[cfg(feature = "testnet")]
|
||||
let mut initial_state = testnet_initial_state::initial_state_testnet();
|
||||
let base = testnet_initial_state::initial_state_testnet();
|
||||
|
||||
// Seed any zone-specific genesis accounts (the cross-zone inbox config and
|
||||
// bridge-lock holdings) so the indexer's replayed state matches the
|
||||
// sequencer's; none are produced by a transaction.
|
||||
for (account_id, account) in genesis_seed {
|
||||
initial_state.insert_genesis_account(account_id, account);
|
||||
}
|
||||
// Seed any zone-specific genesis accounts (the bridge-lock holdings) so the
|
||||
// indexer's replayed state matches the sequencer's; none are produced by a
|
||||
// transaction. Cross-zone programs are base builtins, and their config
|
||||
// accounts are reconstructed by replaying the genesis block's InitConfig txs.
|
||||
let initial_state = base.with_public_accounts(genesis_seed);
|
||||
let dbio = RocksDBIO::open_or_create(location, &initial_state)?;
|
||||
|
||||
let current_state = dbio.final_state()?;
|
||||
|
||||
@ -12,7 +12,7 @@ use cross_zone_inbox_core::{
|
||||
};
|
||||
use futures::StreamExt as _;
|
||||
use lee::PublicKey;
|
||||
use log::{error, info};
|
||||
use log::{debug, error, info};
|
||||
use logos_blockchain_core::mantle::ops::channel::ChannelId;
|
||||
use logos_blockchain_zone_sdk::{
|
||||
CommonHttpClient, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
|
||||
@ -51,8 +51,7 @@ impl PeerBlocks {
|
||||
}
|
||||
|
||||
/// The highest block id this reader has finalized for `zone`, or `None` if it
|
||||
/// has read nothing yet. Used to tell forgery (we have read past the
|
||||
/// referenced block and it is absent) from lag (we simply have not caught up).
|
||||
/// has read nothing yet.
|
||||
async fn highest_seen(&self, zone: ZoneId) -> Option<u64> {
|
||||
self.chains
|
||||
.read()
|
||||
@ -65,13 +64,17 @@ impl PeerBlocks {
|
||||
/// The indexer-side Option B verifier.
|
||||
///
|
||||
/// For every cross-zone dispatch in a block it re-derives the transaction from
|
||||
/// the peer's finalized block and rejects it if the bytes differ (a forgery) or
|
||||
/// the message was already delivered (a replay), so delivery no longer relies on
|
||||
/// trusting the sequencer.
|
||||
/// the peer's finalized block and rejects it if the bytes differ (a forgery), so
|
||||
/// delivery no longer relies on trusting the sequencer. A replay of an
|
||||
/// already-delivered message is accepted, since the inbox no-ops it on chain.
|
||||
#[derive(Clone)]
|
||||
pub struct CrossZoneVerifier {
|
||||
self_zone: ZoneId,
|
||||
/// Pinned block-signing key per peer zone, enforced during re-derivation.
|
||||
/// One key per peer is sufficient while a zone has a single sequencer; key
|
||||
/// sets with rotation come in with decentralized sequencing. The pin is
|
||||
/// largely redundant given Bedrock's turn-based write authorization, so it is
|
||||
/// optional: a peer with no configured key is not signature-checked.
|
||||
peer_pubkeys: HashMap<ZoneId, PublicKey>,
|
||||
peers: PeerBlocks,
|
||||
seen: Arc<RwLock<HashSet<MessageKey>>>,
|
||||
@ -112,9 +115,17 @@ impl CrossZoneVerifier {
|
||||
})
|
||||
}
|
||||
|
||||
/// Verifies every cross-zone dispatch in a block, returning `Err` on the
|
||||
/// first forged or replayed dispatch. The caller halts ingestion on error.
|
||||
pub async fn verify_block(&self, block: &Block) -> Result<()> {
|
||||
/// Verifies every cross-zone dispatch in a block, returning `Err` on the first
|
||||
/// forged dispatch (the caller halts ingestion) or the keys to mark seen.
|
||||
///
|
||||
/// The caller MUST record the returned keys via [`Self::record_seen`] only
|
||||
/// after the block applies, so the seen-set mirrors the inbox's on-chain
|
||||
/// seen-shard. Marking a key from a block that never applies would let a later
|
||||
/// forged dispatch reuse it to skip re-derivation while the inbox delivers the
|
||||
/// forgery. A key already seen is a replay the inbox no-ops, so it is accepted
|
||||
/// without re-derivation rather than halting on a legitimate re-delivery.
|
||||
pub async fn verify_block(&self, block: &Block) -> Result<Vec<MessageKey>> {
|
||||
let mut verified = Vec::new();
|
||||
for tx in &block.body.transactions {
|
||||
let Some(msg) = Self::decode_dispatch(tx) else {
|
||||
continue;
|
||||
@ -122,10 +133,13 @@ impl CrossZoneVerifier {
|
||||
|
||||
let key = message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index);
|
||||
if self.seen.read().await.contains(&key) {
|
||||
bail!(
|
||||
"cross-zone replay: message {} re-delivered",
|
||||
hex::encode(key)
|
||||
debug!(
|
||||
"Skipping already-seen cross-zone dispatch from zone {} block {} tx {} (replay no-op)",
|
||||
hex::encode(msg.src_zone),
|
||||
msg.src_block_id,
|
||||
msg.src_tx_index
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let expected = self.rederive(&msg).await?;
|
||||
@ -138,15 +152,25 @@ impl CrossZoneVerifier {
|
||||
);
|
||||
}
|
||||
|
||||
self.seen.write().await.insert(key);
|
||||
info!(
|
||||
"Verified cross-zone dispatch from zone {} block {} tx {}",
|
||||
hex::encode(msg.src_zone),
|
||||
msg.src_block_id,
|
||||
msg.src_tx_index
|
||||
);
|
||||
verified.push(key);
|
||||
}
|
||||
Ok(())
|
||||
Ok(verified)
|
||||
}
|
||||
|
||||
/// Marks the given dispatch keys seen, so a later replay of them is accepted
|
||||
/// without re-derivation. Call only after the block that carried them has been
|
||||
/// applied on chain (see [`Self::verify_block`]).
|
||||
pub async fn record_seen(&self, keys: Vec<MessageKey>) {
|
||||
if keys.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.seen.write().await.extend(keys);
|
||||
}
|
||||
|
||||
/// Decodes a transaction into the cross-zone message it dispatches, or `None`
|
||||
@ -162,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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -225,11 +251,6 @@ impl CrossZoneVerifier {
|
||||
/// If the block is cached, return it. If our peer reader has already
|
||||
/// finalized past `block_id` and we still do not have it, the reference is to
|
||||
/// a block that does not exist on the peer chain, a forgery, so reject now.
|
||||
/// Otherwise the reader simply has not caught up yet: keep waiting, since a
|
||||
/// legitimate dispatch is only injected after its peer block finalized and
|
||||
/// our reader of the same finalized chain will see it too. Rejecting on a
|
||||
/// timeout here would turn a lagging reader into a permanent halt of an
|
||||
/// honest message.
|
||||
async fn wait_for_peer_block(&self, zone: ZoneId, block_id: u64) -> Result<Block> {
|
||||
let mut waited = Duration::ZERO;
|
||||
loop {
|
||||
@ -474,7 +495,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_replayed_dispatch() {
|
||||
async fn accepts_replayed_dispatch_as_noop() {
|
||||
let verifier = verifier();
|
||||
verifier
|
||||
.peers
|
||||
@ -485,15 +506,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}"
|
||||
);
|
||||
}
|
||||
|
||||
@ -90,23 +90,15 @@ 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();
|
||||
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,
|
||||
));
|
||||
}
|
||||
// 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();
|
||||
|
||||
// Option B verifier: re-derives each cross-zone dispatch from the peer's
|
||||
// finalized blocks. `None` when cross-zone messaging is disabled.
|
||||
@ -114,7 +106,7 @@ impl IndexerCore {
|
||||
|
||||
Ok(Self {
|
||||
zone_indexer: Arc::new(zone_indexer),
|
||||
store: IndexerStore::open_db(&home, genesis_seed)?,
|
||||
store: IndexerStore::open_db(&home, genesis_accounts)?,
|
||||
node,
|
||||
config,
|
||||
status: Arc::new(ArcSwap::from_pointee(IndexerSyncStatus::starting())),
|
||||
@ -300,23 +292,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());
|
||||
|
||||
@ -39,30 +39,10 @@ pub const 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 const fn balance_bytes(amount: u128) -> [u8; 16] {
|
||||
amount.to_le_bytes()
|
||||
}
|
||||
|
||||
#[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];
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
use bridge_lock_core::{Instruction, balance_bytes, escrow_account_id, escrow_seed, read_balance};
|
||||
use bridge_lock_core::{Instruction, escrow_account_id, escrow_seed};
|
||||
use cross_zone_outbox_core::Instruction as OutboxInstruction;
|
||||
use lee_core::{
|
||||
account::AccountWithMetadata,
|
||||
@ -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"
|
||||
@ -47,6 +50,10 @@ fn main() {
|
||||
.expect("Lock requires holder, escrow, and outbox accounts");
|
||||
|
||||
assert!(holder.is_authorized, "holder must authorize the lock");
|
||||
// The holder holding is bridge_lock-owned, so bridge_lock may debit its native
|
||||
// balance directly (state-machine rule 5). This also pins the transfer to a
|
||||
// genuine holding: a caller cannot substitute an account owned by some other
|
||||
// program to emit the mint without an actual lock.
|
||||
assert_eq!(
|
||||
holder.account.program_owner, self_program_id,
|
||||
"holder account must be a bridge_lock holding"
|
||||
@ -57,25 +64,26 @@ fn main() {
|
||||
"second account must be the escrow PDA"
|
||||
);
|
||||
|
||||
let holder_new = read_balance(&holder.account.data.clone().into_inner())
|
||||
// Move the real native balance holder -> escrow. bridge_lock owns both accounts,
|
||||
// so it debits the holder and credits the escrow directly; conservation holds
|
||||
// because the same amount moves between them.
|
||||
let holder_new = holder
|
||||
.account
|
||||
.balance
|
||||
.checked_sub(amount)
|
||||
.expect("insufficient balance to lock");
|
||||
let escrow_new = read_balance(&escrow.account.data.clone().into_inner())
|
||||
let escrow_new = escrow
|
||||
.account
|
||||
.balance
|
||||
.checked_add(amount)
|
||||
.expect("escrow balance overflow");
|
||||
|
||||
let mut holder_account = holder.account.clone();
|
||||
holder_account.data = balance_bytes(holder_new)
|
||||
.to_vec()
|
||||
.try_into()
|
||||
.expect("balance fits in account data");
|
||||
holder_account.balance = holder_new;
|
||||
let holder_post = AccountPostState::new(holder_account);
|
||||
|
||||
let mut escrow_account = escrow.account.clone();
|
||||
escrow_account.data = balance_bytes(escrow_new)
|
||||
.to_vec()
|
||||
.try_into()
|
||||
.expect("balance fits in account data");
|
||||
escrow_account.balance = escrow_new;
|
||||
let escrow_post =
|
||||
AccountPostState::new_claimed_if_default(escrow_account, Claim::Pda(escrow_seed()));
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,56 @@ 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, idempotent under genesis replay: a `default` config is a first
|
||||
// init; an already-owned config must already hold exactly these allowlists (the
|
||||
// genesis block is replayed onto seeded state during multi-sequencer
|
||||
// reconstruction), otherwise reject a post-genesis attempt to change them.
|
||||
// `new_claimed_if_default` alone would not stop the owning program from
|
||||
// rewriting its own config data on a later call.
|
||||
if config_meta.account != Account::default() {
|
||||
assert_eq!(
|
||||
config_meta.account.program_owner, self_program_id,
|
||||
"inbox config PDA is owned by another program"
|
||||
);
|
||||
assert_eq!(
|
||||
config_meta.account.data.clone().into_inner(),
|
||||
config.to_bytes(),
|
||||
"inbox config already initialized with different allowlists"
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,60 @@ 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, idempotent under genesis replay: a `default` config is a first
|
||||
// init; an already-owned config must already hold exactly this minter (the
|
||||
// genesis block is replayed onto seeded state during multi-sequencer
|
||||
// reconstruction), otherwise reject a post-genesis attempt to set a different
|
||||
// minter. `new_claimed_if_default` alone would not stop the owning program from
|
||||
// rewriting its own config data on a later call.
|
||||
if config.account != Account::default() {
|
||||
assert_eq!(
|
||||
config.account.program_owner, self_program_id,
|
||||
"wrapped-token config PDA is owned by another program"
|
||||
);
|
||||
assert_eq!(
|
||||
config.account.data.clone().into_inner(),
|
||||
minter_bytes(minter).to_vec(),
|
||||
"wrapped-token config already initialized with a different minter"
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ use bytesize::ByteSize;
|
||||
use common::config::BasicAuth;
|
||||
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 +21,15 @@ 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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -217,15 +217,16 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
// Before producing, verify our local state still belongs to the chain
|
||||
// the channel serves and replay any channel blocks we are missing
|
||||
// (e.g. from other sequencers).
|
||||
let channel_was_empty =
|
||||
let channel_absent =
|
||||
Self::verify_and_reconstruct(&block_publisher, &store, &chain, is_fresh_start)
|
||||
.await
|
||||
.expect("Failed to verify/reconstruct sequencer state from Bedrock");
|
||||
|
||||
// Publish our blocks only when bootstrapping an empty channel. If the
|
||||
// channel already has blocks (another sequencer bootstrapped it), we
|
||||
// adopted them during reconstruction instead.
|
||||
if is_fresh_start && channel_was_empty {
|
||||
// Publish our blocks only when we are bootstrapping a channel that does
|
||||
// not exist yet (no channel tip). If the channel already exists (another
|
||||
// sequencer created it), we adopted its blocks during reconstruction
|
||||
// instead; republishing then would fork the channel with our own copies.
|
||||
if is_fresh_start && channel_absent {
|
||||
let mut pending_blocks = store
|
||||
.get_all_blocks()
|
||||
.filter_ok(|block| matches!(block.bedrock_status, BedrockStatus::Pending))
|
||||
@ -269,7 +270,8 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
/// `state`/`store`, recording each block's L1 inscription slot as the new
|
||||
/// anchor. Fails (never parks) on any divergence.
|
||||
///
|
||||
/// Returns whatever channel was empty or not.
|
||||
/// Returns whether the channel does not exist yet (has no tip), i.e. whether
|
||||
/// this sequencer is the one that must bootstrap-publish its own blocks.
|
||||
async fn verify_and_reconstruct(
|
||||
publisher: &BP,
|
||||
store: &SequencerStore,
|
||||
@ -351,7 +353,6 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
.await
|
||||
.context("Failed to read channel history for reconstruction")?;
|
||||
let mut messages = std::pin::pin!(messages);
|
||||
let mut channel_is_empty = true;
|
||||
while let Some((message, slot)) = messages.next().await {
|
||||
if let Some(check) = &mut consistency_check
|
||||
&& let Some(ChainConsistency::Inconsistent(mismatch)) =
|
||||
@ -369,7 +370,6 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
slot.into_inner()
|
||||
)
|
||||
})?;
|
||||
channel_is_empty = false;
|
||||
// Locked per message (not across the stream `await`): concurrent
|
||||
// follow events interleave safely — both paths apply idempotently
|
||||
// and persist under this same lock.
|
||||
@ -377,7 +377,12 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
Self::apply_reconstructed_block(store, &mut chain, &block, slot)?;
|
||||
}
|
||||
|
||||
Ok(channel_is_empty)
|
||||
// The channel exists once it has a tip; only when it has none is this
|
||||
// sequencer the one bootstrapping it. This is deliberately not the
|
||||
// reconstruction scan's view above, which reads only finalized history
|
||||
// (up to LIB) and so reports "empty" while finality lags even though the
|
||||
// channel already holds unfinalized blocks from another sequencer.
|
||||
Ok(channel_tip_slot.is_none())
|
||||
}
|
||||
|
||||
/// Applies a single channel block during reconstruction: idempotent for
|
||||
@ -1088,33 +1093,22 @@ fn replay_unfulfilled_deposit_events(
|
||||
});
|
||||
}
|
||||
|
||||
/// The pre-genesis state: `testnet_initial_state` plus accounts seeded outside
|
||||
/// any transaction (bridge-lock holdings, the cross-zone inbox config).
|
||||
/// The pre-genesis state: `testnet_initial_state` plus the bridge-lock holdings,
|
||||
/// the only accounts seeded outside any transaction. Cross-zone config is seeded
|
||||
/// by genesis `InitConfig` transactions and reconstructed by replaying them.
|
||||
fn build_initial_state(config: &SequencerConfig) -> lee::V03State {
|
||||
#[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();
|
||||
|
||||
// Seed bridge-lock holder balances directly: they are not produced by any tx.
|
||||
for action in &config.genesis {
|
||||
if let GenesisAction::SupplyBridgeLockHolding { holder, amount } = action {
|
||||
let (holder_id, account) = cross_zone::build_holding_account(*holder, *amount);
|
||||
state.insert_genesis_account(holder_id, account);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed this zone's cross-zone inbox config so the inbox guest can authorize
|
||||
// inbound peer messages (zone-specific config, not produced by any tx).
|
||||
if let Some(cross_zone) = &config.cross_zone {
|
||||
let self_zone = *config.bedrock_config.channel_id.as_ref();
|
||||
let (config_id, config_account) =
|
||||
cross_zone::build_inbox_config_account(self_zone, cross_zone);
|
||||
state.insert_genesis_account(config_id, config_account);
|
||||
}
|
||||
|
||||
state
|
||||
// Bridge-lock holder balances belong to the source side and are not produced by
|
||||
// any transaction, so seed them directly. Cross-zone config is seeded by genesis
|
||||
// InitConfig transactions in `build_genesis_state`, not here.
|
||||
let holdings = bridge_lock_holdings(&config.genesis)
|
||||
.map(|(holder, amount)| cross_zone::build_holding_account(holder, amount));
|
||||
base.with_public_accounts(holdings)
|
||||
}
|
||||
|
||||
/// Builds the initial genesis state from [`build_initial_state`] plus configured
|
||||
@ -1124,22 +1118,42 @@ fn build_initial_state(config: &SequencerConfig) -> lee::V03State {
|
||||
fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTransaction>) {
|
||||
let mut state = build_initial_state(config);
|
||||
|
||||
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))
|
||||
}
|
||||
// Force-inserted in `build_initial_state`: bridge_lock has no mint transaction.
|
||||
GenesisAction::SupplyBridgeLockHolding { .. } => None,
|
||||
})
|
||||
// Fingerprint the directly-seeded state, before genesis txs, so it matches the indexer's.
|
||||
info!(
|
||||
"Genesis fingerprint: {}",
|
||||
hex::encode(state.genesis_fingerprint())
|
||||
);
|
||||
|
||||
// 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 in `build_initial_state` (holdings via `build_holding_account`), not a
|
||||
// genesis tx.
|
||||
GenesisAction::SupplyBridgeLockHolding { .. } => None,
|
||||
});
|
||||
|
||||
let genesis_txs = wrapped_token_config_tx
|
||||
.chain(inbox_config_tx)
|
||||
.chain(supply_txs)
|
||||
.chain(std::iter::once(clock_invocation(0)))
|
||||
.inspect(|tx| {
|
||||
state
|
||||
@ -1152,6 +1166,16 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
|
||||
(state, genesis_txs)
|
||||
}
|
||||
|
||||
/// Bridge-lock holder balances configured for this zone's genesis.
|
||||
fn bridge_lock_holdings(
|
||||
genesis: &[GenesisAction],
|
||||
) -> impl Iterator<Item = (lee::AccountId, lee::Balance)> + '_ {
|
||||
genesis.iter().filter_map(|action| match action {
|
||||
GenesisAction::SupplyBridgeLockHolding { holder, amount } => Some((*holder, *amount)),
|
||||
GenesisAction::SupplyAccount { .. } | GenesisAction::SupplyBridgeAccount { .. } => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -258,30 +258,9 @@ fn initial_public_accounts() -> HashMap<AccountId, Account> {
|
||||
.into_iter()
|
||||
.map(|clock_id| (clock_id, system_accounts::clock_account())),
|
||||
)
|
||||
.chain(std::iter::once(wrapped_token_config_account()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The wrapped-token config account.
|
||||
///
|
||||
/// Seeded so the `wrapped_token` guest can pin its authorized minter (the
|
||||
/// cross-zone inbox) without importing the inbox id. Fixed for every zone, so it
|
||||
/// lives in the shared initial state.
|
||||
fn wrapped_token_config_account() -> (AccountId, Account) {
|
||||
let wrapped_token_id = programs::wrapped_token().id();
|
||||
(
|
||||
wrapped_token_core::config_account_id(wrapped_token_id),
|
||||
Account {
|
||||
program_owner: wrapped_token_id,
|
||||
data: wrapped_token_core::minter_bytes(programs::cross_zone_inbox().id())
|
||||
.to_vec()
|
||||
.try_into()
|
||||
.expect("minter id fits in account data"),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn initial_programs() -> Vec<Program> {
|
||||
vec![
|
||||
programs::authenticated_transfer(),
|
||||
@ -292,8 +271,12 @@ fn initial_programs() -> Vec<Program> {
|
||||
programs::vault(),
|
||||
programs::faucet(),
|
||||
programs::bridge(),
|
||||
programs::cross_zone_outbox(),
|
||||
// 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(),
|
||||
|
||||
@ -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"]
|
||||
@ -1,92 +0,0 @@
|
||||
//! Core types for the bridge-lock program, the source side of the cross-zone
|
||||
//! token bridge. A holder locks part of their balance into an escrow and emits a
|
||||
//! cross-zone message minting the wrapped token on the target zone.
|
||||
|
||||
use lee_core::{
|
||||
account::AccountId,
|
||||
program::{PdaSeed, ProgramId},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const ESCROW_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeLockEscrow/0000/";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Instruction {
|
||||
/// Lock `amount` of the holder's balance and emit a cross-zone message
|
||||
/// minting the wrapped token on `target_zone`. The emission fields mirror
|
||||
/// `cross_zone_outbox::Instruction::Emit` so the watcher reads them directly.
|
||||
///
|
||||
/// Required accounts (3): holder holding (authorized), escrow PDA, outbox PDA.
|
||||
Lock {
|
||||
amount: u128,
|
||||
target_zone: [u8; 32],
|
||||
target_program_id: ProgramId,
|
||||
target_accounts: Vec<[u8; 32]>,
|
||||
payload: Vec<u8>,
|
||||
outbox_program_id: ProgramId,
|
||||
ordinal: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// PDA accumulating all locked balance on this zone.
|
||||
#[must_use]
|
||||
pub fn escrow_account_id(bridge_lock_id: ProgramId) -> AccountId {
|
||||
AccountId::for_public_pda(&bridge_lock_id, &escrow_seed())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn escrow_seed() -> PdaSeed {
|
||||
PdaSeed::new(ESCROW_SEED_DOMAIN)
|
||||
}
|
||||
|
||||
/// Reads a bridgeable balance from account data; empty data is a zero balance.
|
||||
#[must_use]
|
||||
pub fn read_balance(data: &[u8]) -> u128 {
|
||||
if data.len() < 16 {
|
||||
return 0;
|
||||
}
|
||||
u128::from_le_bytes(data[..16].try_into().unwrap_or_else(|_| unreachable!()))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn balance_bytes(amount: u128) -> [u8; 16] {
|
||||
amount.to_le_bytes()
|
||||
}
|
||||
|
||||
/// Builds the genesis holding account funding a holder's bridgeable balance:
|
||||
/// owned by bridge_lock, data is the LE balance, at the holder's account id. It
|
||||
/// is not produced by any transaction, so the sequencer and the indexer both
|
||||
/// seed it through this one builder to keep their genesis states identical.
|
||||
#[cfg(feature = "host")]
|
||||
#[must_use]
|
||||
pub fn build_holding_account(
|
||||
holder: AccountId,
|
||||
amount: u128,
|
||||
) -> (AccountId, lee_core::account::Account) {
|
||||
let account = lee_core::account::Account {
|
||||
program_owner: lee::program::Program::bridge_lock().id(),
|
||||
data: balance_bytes(amount)
|
||||
.to_vec()
|
||||
.try_into()
|
||||
.expect("balance fits in account data"),
|
||||
..Default::default()
|
||||
};
|
||||
(holder, account)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn balance_round_trips() {
|
||||
assert_eq!(read_balance(&balance_bytes(7)), 7);
|
||||
assert_eq!(read_balance(&[]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escrow_is_stable() {
|
||||
let id: ProgramId = [4; 8];
|
||||
assert_eq!(escrow_account_id(id), escrow_account_id(id));
|
||||
}
|
||||
}
|
||||
@ -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"]
|
||||
@ -1,378 +0,0 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use borsh::{BorshDeserialize, BorshSerialize};
|
||||
use lee_core::{
|
||||
account::AccountId,
|
||||
program::{PdaSeed, ProgramId},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`.
|
||||
pub type ZoneId = [u8; 32];
|
||||
|
||||
/// Block-signing public key pinned per peer zone.
|
||||
pub type ExpectedPubkey = [u8; 32];
|
||||
|
||||
/// Content-addressed replay key for a delivered message.
|
||||
pub type MessageKey = [u8; 32];
|
||||
|
||||
/// Source blocks per seen-set shard, so no single seen account grows without bound.
|
||||
pub const EPOCH_BLOCKS: u64 = 10_000;
|
||||
|
||||
const MESSAGE_KEY_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneMsgKey/00000/";
|
||||
const INBOX_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxCfg/000/";
|
||||
const INBOX_SEEN_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxSeen/00/";
|
||||
|
||||
/// A peer zone whose outbox a zone watches for inbound cross-zone messages.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct CrossZonePeer {
|
||||
/// The peer's Bedrock channel; its 32 bytes double as the peer's zone id.
|
||||
pub channel_id: ZoneId,
|
||||
/// Programs on the local zone a message from this peer is allowed to target.
|
||||
pub allowed_targets: Vec<ProgramId>,
|
||||
/// The peer's block-signing public key, pinned to reject blocks inscribed by
|
||||
/// anyone other than that zone's sequencer. `None` skips the check (the
|
||||
/// channel signer is still authenticated by the zone-sdk).
|
||||
#[serde(default)]
|
||||
pub expected_block_signing_pubkey: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
/// Cross-zone configuration shared by a zone's sequencer (watcher) and indexer
|
||||
/// (verifier): the peers it reads from Bedrock and, per peer, the local programs
|
||||
/// they may deliver to.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct CrossZoneConfig {
|
||||
pub peers: Vec<CrossZonePeer>,
|
||||
}
|
||||
|
||||
/// A finalized outbound message observed on a peer zone, addressed to a program
|
||||
/// on this zone. The watcher fills it from the peer's block; it is never
|
||||
/// self-reported by a user.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CrossZoneMessage {
|
||||
pub src_zone: ZoneId,
|
||||
pub src_block_id: u64,
|
||||
pub src_tx_index: u32,
|
||||
pub src_program_id: ProgramId,
|
||||
pub target_program_id: ProgramId,
|
||||
pub payload: Vec<u8>,
|
||||
/// Reserved for a future source-state proof; MUST be `None` in v1.
|
||||
pub l1_inclusion_witness: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Peer and per-peer target allowlists, plus this inbox's own zone id.
|
||||
#[derive(
|
||||
Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize,
|
||||
)]
|
||||
pub struct InboxConfig {
|
||||
pub self_zone: ZoneId,
|
||||
pub allowed_peers: BTreeMap<ZoneId, ExpectedPubkey>,
|
||||
pub allowed_targets: BTreeMap<ZoneId, Vec<ProgramId>>,
|
||||
}
|
||||
|
||||
impl InboxConfig {
|
||||
/// Borsh-encoded form stored in the inbox config account.
|
||||
#[must_use]
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
borsh::to_vec(self).expect("InboxConfig serializes")
|
||||
}
|
||||
|
||||
/// Decodes an [`InboxConfig`] from account data.
|
||||
pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result<Self> {
|
||||
borsh::from_slice(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// The replay keys seen for one `(src_zone, epoch)` shard.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
|
||||
pub struct SeenShard(pub BTreeSet<MessageKey>);
|
||||
|
||||
impl SeenShard {
|
||||
/// Decodes a shard from account data; empty data is an empty shard.
|
||||
pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result<Self> {
|
||||
if bytes.is_empty() {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
borsh::from_slice(bytes)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
borsh::to_vec(self).expect("SeenShard serializes")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn contains(&self, key: &MessageKey) -> bool {
|
||||
self.0.contains(key)
|
||||
}
|
||||
|
||||
/// Inserts a key; returns true if it was newly inserted.
|
||||
pub fn insert(&mut self, key: MessageKey) -> bool {
|
||||
self.0.insert(key)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Instruction {
|
||||
/// Delivers a finalized peer message to its target program.
|
||||
Dispatch(CrossZoneMessage),
|
||||
}
|
||||
|
||||
/// Content-addressed replay key: `(src_zone, src_block_id, src_tx_index)` hashed
|
||||
/// under a domain separator. Watcher-independent and immune to proof
|
||||
/// malleability, since it keys on block id plus index rather than a tx hash.
|
||||
#[must_use]
|
||||
pub fn message_key(src_zone: &ZoneId, src_block_id: u64, src_tx_index: u32) -> MessageKey {
|
||||
use risc0_zkvm::sha::{Impl, Sha256 as _};
|
||||
|
||||
let mut bytes = Vec::with_capacity(MESSAGE_KEY_DOMAIN.len() + 32 + 8 + 4);
|
||||
bytes.extend_from_slice(&MESSAGE_KEY_DOMAIN);
|
||||
bytes.extend_from_slice(src_zone);
|
||||
bytes.extend_from_slice(&src_block_id.to_le_bytes());
|
||||
bytes.extend_from_slice(&src_tx_index.to_le_bytes());
|
||||
|
||||
Impl::hash_bytes(&bytes)
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.unwrap_or_else(|_| unreachable!())
|
||||
}
|
||||
|
||||
/// The config account holding the allowlists.
|
||||
#[must_use]
|
||||
pub fn inbox_config_account_id(inbox_id: ProgramId) -> AccountId {
|
||||
AccountId::for_public_pda(&inbox_id, &PdaSeed::new(INBOX_CONFIG_SEED))
|
||||
}
|
||||
|
||||
/// The seen-set shard for the `(src_zone, epoch)` the message falls in.
|
||||
#[must_use]
|
||||
pub fn inbox_seen_shard_account_id(
|
||||
inbox_id: ProgramId,
|
||||
src_zone: &ZoneId,
|
||||
src_block_id: u64,
|
||||
) -> AccountId {
|
||||
AccountId::for_public_pda(&inbox_id, &inbox_seen_shard_seed(src_zone, src_block_id))
|
||||
}
|
||||
|
||||
/// Seed of the seen-shard PDA, exposed so the guest can claim the account.
|
||||
#[must_use]
|
||||
pub fn inbox_seen_shard_seed(src_zone: &ZoneId, src_block_id: u64) -> PdaSeed {
|
||||
use risc0_zkvm::sha::{Impl, Sha256 as _};
|
||||
|
||||
let src_epoch = src_block_id / EPOCH_BLOCKS;
|
||||
let mut bytes = Vec::with_capacity(INBOX_SEEN_SEED_DOMAIN.len() + 32 + 8);
|
||||
bytes.extend_from_slice(&INBOX_SEEN_SEED_DOMAIN);
|
||||
bytes.extend_from_slice(src_zone);
|
||||
bytes.extend_from_slice(&src_epoch.to_le_bytes());
|
||||
|
||||
let seed: [u8; 32] = Impl::hash_bytes(&bytes)
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.unwrap_or_else(|_| unreachable!());
|
||||
PdaSeed::new(seed)
|
||||
}
|
||||
|
||||
/// Builds the sequencer-origin dispatch transaction. Pure, so the watcher's
|
||||
/// injected tx and the indexer's re-derived tx are byte-identical for the same
|
||||
/// inputs (the basis of the Option B check). `target_account_ids` are the
|
||||
/// inbox's chained-call targets; deriving them is target-specific.
|
||||
#[cfg(feature = "host")]
|
||||
#[must_use]
|
||||
pub fn build_inbox_dispatch_tx(
|
||||
inbox_id: ProgramId,
|
||||
msg: &CrossZoneMessage,
|
||||
target_account_ids: Vec<AccountId>,
|
||||
) -> lee::PublicTransaction {
|
||||
let mut account_ids = Vec::with_capacity(2 + target_account_ids.len());
|
||||
account_ids.push(inbox_config_account_id(inbox_id));
|
||||
account_ids.push(inbox_seen_shard_account_id(
|
||||
inbox_id,
|
||||
&msg.src_zone,
|
||||
msg.src_block_id,
|
||||
));
|
||||
account_ids.extend(target_account_ids);
|
||||
|
||||
let message = lee::public_transaction::Message::try_new(
|
||||
inbox_id,
|
||||
account_ids,
|
||||
vec![],
|
||||
Instruction::Dispatch(msg.clone()),
|
||||
)
|
||||
.expect("inbox dispatch instruction must serialize");
|
||||
|
||||
lee::PublicTransaction::new(
|
||||
message,
|
||||
lee::public_transaction::WitnessSet::from_raw_parts(vec![]),
|
||||
)
|
||||
}
|
||||
|
||||
/// The cross-zone emission fields a watcher or verifier reads off a source
|
||||
/// transaction, common to every emitter program.
|
||||
#[cfg(feature = "host")]
|
||||
pub struct Emission {
|
||||
pub target_zone: ZoneId,
|
||||
pub target_program_id: ProgramId,
|
||||
pub target_accounts: Vec<[u8; 32]>,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Extracts the cross-zone emission from a source transaction, recognizing the
|
||||
/// known emitter programs. Returns `None` for any other program. The watcher and
|
||||
/// verifier both use this so they agree on what a given source tx emits.
|
||||
///
|
||||
/// Option A: each emitter is decoded explicitly. The principled alternative is to
|
||||
/// read the outbox PDA write, which would need re-execution of the source tx.
|
||||
#[cfg(feature = "host")]
|
||||
#[must_use]
|
||||
pub fn extract_emission(
|
||||
program_id: ProgramId,
|
||||
instruction_data: &[u32],
|
||||
ping_sender_id: ProgramId,
|
||||
bridge_lock_id: ProgramId,
|
||||
) -> Option<Emission> {
|
||||
if program_id == ping_sender_id {
|
||||
let ping_core::SenderInstruction::Send {
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
..
|
||||
} = risc0_zkvm::serde::from_slice(instruction_data).ok()?;
|
||||
Some(Emission {
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
})
|
||||
} else if program_id == bridge_lock_id {
|
||||
let bridge_lock_core::Instruction::Lock {
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
..
|
||||
} = risc0_zkvm::serde::from_slice(instruction_data).ok()?;
|
||||
Some(Emission {
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the dispatch transaction for one peer emission. Both the sequencer's
|
||||
/// watcher and the indexer's verifier go through this so their transactions are
|
||||
/// byte-identical for the same emission (the basis of the Option B check).
|
||||
#[cfg(feature = "host")]
|
||||
#[must_use]
|
||||
pub fn build_dispatch_from_emission(
|
||||
inbox_id: ProgramId,
|
||||
src_zone: ZoneId,
|
||||
src_block_id: u64,
|
||||
src_tx_index: u32,
|
||||
src_program_id: ProgramId,
|
||||
target_program_id: ProgramId,
|
||||
target_accounts: &[[u8; 32]],
|
||||
payload: Vec<u8>,
|
||||
) -> lee::PublicTransaction {
|
||||
let msg = CrossZoneMessage {
|
||||
src_zone,
|
||||
src_block_id,
|
||||
src_tx_index,
|
||||
src_program_id,
|
||||
target_program_id,
|
||||
payload,
|
||||
l1_inclusion_witness: None,
|
||||
};
|
||||
let target_ids = target_accounts
|
||||
.iter()
|
||||
.copied()
|
||||
.map(AccountId::new)
|
||||
.collect();
|
||||
build_inbox_dispatch_tx(inbox_id, &msg, target_ids)
|
||||
}
|
||||
|
||||
/// Builds the inbox config account a zone seeds into genesis state so the inbox
|
||||
/// guest can authorize inbound peer messages. The sequencer and indexer seed the
|
||||
/// same account from the same config, keeping their replayed state consistent.
|
||||
#[cfg(feature = "host")]
|
||||
#[must_use]
|
||||
pub fn build_inbox_config_account(
|
||||
self_zone: ZoneId,
|
||||
cross_zone: &CrossZoneConfig,
|
||||
) -> (AccountId, lee_core::account::Account) {
|
||||
let inbox_id = lee::program::Program::cross_zone_inbox().id();
|
||||
|
||||
let mut allowed_targets = BTreeMap::new();
|
||||
for peer in &cross_zone.peers {
|
||||
allowed_targets.insert(peer.channel_id, peer.allowed_targets.clone());
|
||||
}
|
||||
let config = InboxConfig {
|
||||
self_zone,
|
||||
allowed_peers: BTreeMap::new(),
|
||||
allowed_targets,
|
||||
};
|
||||
|
||||
let account = lee_core::account::Account {
|
||||
program_owner: inbox_id,
|
||||
balance: 0,
|
||||
data: config
|
||||
.to_bytes()
|
||||
.try_into()
|
||||
.expect("inbox config fits in account data"),
|
||||
nonce: 0_u128.into(),
|
||||
};
|
||||
(inbox_config_account_id(inbox_id), account)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn zone(b: u8) -> ZoneId {
|
||||
[b; 32]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_key_is_stable_and_content_addressed() {
|
||||
assert_eq!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 3));
|
||||
assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(2), 7, 3));
|
||||
assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(1), 8, 3));
|
||||
assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seen_shards_split_on_epoch_boundary() {
|
||||
let id: ProgramId = [9; 8];
|
||||
assert_eq!(
|
||||
inbox_seen_shard_account_id(id, &zone(1), 0),
|
||||
inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1),
|
||||
);
|
||||
assert_ne!(
|
||||
inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1),
|
||||
inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "host")]
|
||||
#[test]
|
||||
fn build_inbox_dispatch_tx_is_deterministic() {
|
||||
let inbox: ProgramId = [5; 8];
|
||||
let msg = CrossZoneMessage {
|
||||
src_zone: zone(1),
|
||||
src_block_id: 42,
|
||||
src_tx_index: 2,
|
||||
src_program_id: [6; 8],
|
||||
target_program_id: [7; 8],
|
||||
payload: vec![1, 2, 3, 4],
|
||||
l1_inclusion_witness: None,
|
||||
};
|
||||
let targets = vec![AccountId::new([8; 32]), AccountId::new([9; 32])];
|
||||
|
||||
let tx1 = build_inbox_dispatch_tx(inbox, &msg, targets.clone());
|
||||
let tx2 = build_inbox_dispatch_tx(inbox, &msg, targets);
|
||||
assert_eq!(tx1, tx2);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -1,93 +0,0 @@
|
||||
use borsh::{BorshDeserialize, BorshSerialize};
|
||||
use lee_core::{
|
||||
account::AccountId,
|
||||
program::{PdaSeed, ProgramId},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`.
|
||||
pub type ZoneId = [u8; 32];
|
||||
|
||||
const OUTBOX_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneOutbox/00000/";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Instruction {
|
||||
/// Records an outbound cross-zone message as a write to a self-owned PDA.
|
||||
///
|
||||
/// Required accounts (1):
|
||||
/// - Outbox PDA account
|
||||
Emit {
|
||||
target_zone: ZoneId,
|
||||
target_program_id: ProgramId,
|
||||
/// Accounts the destination inbox must hand to the target program's
|
||||
/// chained call. The emitter specifies them; the watcher forwards them
|
||||
/// verbatim so the inbox stays target-agnostic.
|
||||
target_accounts: Vec<[u8; 32]>,
|
||||
payload: Vec<u8>,
|
||||
ordinal: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// The message as stored in an outbox PDA. The destination zone's watcher reads
|
||||
/// this from the inscribed block; the source coordinates are filled by the
|
||||
/// watcher, not stored here.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
|
||||
pub struct OutboxRecord {
|
||||
pub target_zone: ZoneId,
|
||||
pub target_program_id: ProgramId,
|
||||
pub target_accounts: Vec<[u8; 32]>,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
impl OutboxRecord {
|
||||
/// Borsh-encoded form stored in the outbox PDA's account data.
|
||||
#[must_use]
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
borsh::to_vec(self).expect("OutboxRecord serializes")
|
||||
}
|
||||
|
||||
/// Decodes an [`OutboxRecord`] from account data.
|
||||
pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result<Self> {
|
||||
borsh::from_slice(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// PDA holding one emitted message, keyed by destination zone and a per-zone
|
||||
/// ordinal.
|
||||
#[must_use]
|
||||
pub fn outbox_pda(outbox_id: ProgramId, target_zone: &ZoneId, ordinal: u32) -> AccountId {
|
||||
AccountId::for_public_pda(&outbox_id, &outbox_pda_seed(target_zone, ordinal))
|
||||
}
|
||||
|
||||
/// Seed of an outbox message PDA, exposed so the guest can claim the account.
|
||||
#[must_use]
|
||||
pub fn outbox_pda_seed(target_zone: &ZoneId, ordinal: u32) -> PdaSeed {
|
||||
use risc0_zkvm::sha::{Impl, Sha256 as _};
|
||||
|
||||
let mut bytes = Vec::with_capacity(OUTBOX_SEED_DOMAIN.len() + target_zone.len() + 4);
|
||||
bytes.extend_from_slice(&OUTBOX_SEED_DOMAIN);
|
||||
bytes.extend_from_slice(target_zone);
|
||||
bytes.extend_from_slice(&ordinal.to_le_bytes());
|
||||
|
||||
let seed: [u8; 32] = Impl::hash_bytes(&bytes)
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.unwrap_or_else(|_| unreachable!());
|
||||
PdaSeed::new(seed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn outbox_pda_is_unique_per_zone_and_ordinal() {
|
||||
let id: ProgramId = [3; 8];
|
||||
let zone_a = [1; 32];
|
||||
let zone_b = [2; 32];
|
||||
|
||||
assert_eq!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 0));
|
||||
assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 1));
|
||||
assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_b, 0));
|
||||
}
|
||||
}
|
||||
@ -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"] }
|
||||
@ -1,38 +0,0 @@
|
||||
use lee_core::{
|
||||
account::AccountId,
|
||||
program::{PdaSeed, ProgramId},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const PING_RECORD_SEED: [u8; 32] = *b"/LEZ/v0.3/PingRecord/0000000000/";
|
||||
|
||||
/// Instruction delivered to `ping_receiver` by the inbox: record the payload.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ReceiverInstruction {
|
||||
Record { payload: Vec<u8> },
|
||||
}
|
||||
|
||||
/// Instruction to `ping_sender`: forwarded verbatim into `cross_zone_outbox::Instruction::Emit`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SenderInstruction {
|
||||
Send {
|
||||
outbox_program_id: ProgramId,
|
||||
target_zone: [u8; 32],
|
||||
target_program_id: ProgramId,
|
||||
target_accounts: Vec<[u8; 32]>,
|
||||
payload: Vec<u8>,
|
||||
ordinal: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// The account a `ping_receiver` records the latest delivered payload into.
|
||||
#[must_use]
|
||||
pub fn ping_record_pda(receiver_id: ProgramId) -> AccountId {
|
||||
AccountId::for_public_pda(&receiver_id, &ping_record_seed())
|
||||
}
|
||||
|
||||
/// Seed of the record PDA, exposed so the guest can claim the account.
|
||||
#[must_use]
|
||||
pub fn ping_record_seed() -> PdaSeed {
|
||||
PdaSeed::new(PING_RECORD_SEED)
|
||||
}
|
||||
@ -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
|
||||
@ -1,121 +0,0 @@
|
||||
//! Core types for the wrapped-token program, the destination side of the
|
||||
//! cross-zone bridge. Only the cross-zone inbox may mint; the guest enforces
|
||||
//! this by reading the authorized minter from a genesis-seeded config account.
|
||||
|
||||
use lee_core::{
|
||||
account::AccountId,
|
||||
program::{PdaSeed, ProgramId},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenConfig/00/";
|
||||
const HOLDING_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenHold/00000";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Instruction {
|
||||
/// Credit `amount` wrapped tokens to `recipient`'s holding. Delivered only by
|
||||
/// the cross-zone inbox.
|
||||
///
|
||||
/// Required accounts (2): the wrapped-token config PDA, then the recipient's
|
||||
/// holding PDA.
|
||||
Mint { recipient: [u8; 32], amount: u128 },
|
||||
}
|
||||
|
||||
/// PDA holding the authorized minter program id (the cross-zone inbox), seeded at
|
||||
/// genesis so the guest can pin its caller without importing the inbox image id.
|
||||
#[must_use]
|
||||
pub fn config_account_id(wrapped_token_id: ProgramId) -> AccountId {
|
||||
AccountId::for_public_pda(&wrapped_token_id, &config_seed())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn config_seed() -> PdaSeed {
|
||||
PdaSeed::new(CONFIG_SEED_DOMAIN)
|
||||
}
|
||||
|
||||
/// PDA holding one recipient's wrapped-token balance.
|
||||
#[must_use]
|
||||
pub fn holding_account_id(wrapped_token_id: ProgramId, recipient: &[u8; 32]) -> AccountId {
|
||||
AccountId::for_public_pda(&wrapped_token_id, &holding_seed(recipient))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn holding_seed(recipient: &[u8; 32]) -> PdaSeed {
|
||||
use risc0_zkvm::sha::{Impl, Sha256 as _};
|
||||
|
||||
let mut bytes = Vec::with_capacity(HOLDING_SEED_DOMAIN.len() + recipient.len());
|
||||
bytes.extend_from_slice(&HOLDING_SEED_DOMAIN);
|
||||
bytes.extend_from_slice(recipient);
|
||||
let seed: [u8; 32] = Impl::hash_bytes(&bytes)
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.unwrap_or_else(|_| unreachable!());
|
||||
PdaSeed::new(seed)
|
||||
}
|
||||
|
||||
/// Encodes the authorized minter program id for the config account's data.
|
||||
#[must_use]
|
||||
pub fn minter_bytes(minter: ProgramId) -> [u8; 32] {
|
||||
let mut bytes = [0_u8; 32];
|
||||
for (word, chunk) in minter.iter().zip(bytes.chunks_exact_mut(4)) {
|
||||
chunk.copy_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Decodes the authorized minter program id from the config account's data.
|
||||
#[must_use]
|
||||
pub fn read_minter(data: &[u8]) -> Option<ProgramId> {
|
||||
if data.len() < 32 {
|
||||
return None;
|
||||
}
|
||||
let mut minter = [0_u32; 8];
|
||||
for (word, chunk) in minter.iter_mut().zip(data[..32].chunks_exact(4)) {
|
||||
*word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!()));
|
||||
}
|
||||
Some(minter)
|
||||
}
|
||||
|
||||
/// Reads a wrapped-token balance from account data; empty data is a zero balance.
|
||||
#[must_use]
|
||||
pub fn read_balance(data: &[u8]) -> u128 {
|
||||
if data.len() < 16 {
|
||||
return 0;
|
||||
}
|
||||
u128::from_le_bytes(data[..16].try_into().unwrap_or_else(|_| unreachable!()))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn balance_bytes(amount: u128) -> [u8; 16] {
|
||||
amount.to_le_bytes()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn minter_round_trips() {
|
||||
let minter: ProgramId = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
assert_eq!(read_minter(&minter_bytes(minter)), Some(minter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balance_round_trips() {
|
||||
assert_eq!(read_balance(&balance_bytes(42)), 42);
|
||||
assert_eq!(read_balance(&[]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holding_is_unique_per_recipient() {
|
||||
let id: ProgramId = [9; 8];
|
||||
assert_ne!(
|
||||
holding_account_id(id, &[1; 32]),
|
||||
holding_account_id(id, &[2; 32])
|
||||
);
|
||||
assert_eq!(
|
||||
holding_account_id(id, &[1; 32]),
|
||||
holding_account_id(id, &[1; 32])
|
||||
);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@ -442,7 +442,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)
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user