feat(cross-zone)!: pin bridge_lock's outbox, mint target, and mint accounts

BREAKING CHANGE: Instruction::Lock drops outbox_program_id and takes the
bridge-lock config PDA as its first account, changing both the instruction
encoding and the account list. bridge_lock's image id moves, relocating its
escrow and config PDAs and requiring a fresh genesis. Sequencer, indexer and
every peer zone must upgrade together: a stale decoder re-derives a different
dispatch and reports Forged.
This commit is contained in:
moudyellaz
2026-08-09 19:02:28 +02:00
parent a04b3eeaa9
commit 421e76b946
8 changed files with 594 additions and 77 deletions
Generated
+1
View File
@@ -1225,6 +1225,7 @@ name = "bridge_lock_core"
version = "0.1.0"
dependencies = [
"lee_core",
"risc0-zkvm",
"serde",
]
+6 -13
View File
@@ -9,12 +9,10 @@
//! 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.
//! A `ping_sender` send carrying a `wrapped_token::Mint` is refused as long as no
//! operator writes a `(ping_sender, wrapped_token)` route: the allowlist is a
//! source-and-target pair. Nothing forbids writing that route, and the token
//! still trusts the table rather than checking its own sources, which is #673.
use std::time::Duration;
@@ -156,19 +154,14 @@ fn build_lock_tx(
target_program_id: wrapped_token_id,
target_accounts,
payload,
outbox_program_id: outbox_id,
ordinal,
};
let accounts = vec![
bridge_lock_core::config_account_id(bridge_lock_id),
holder_id,
bridge_lock_core::escrow_account_id(bridge_lock_id),
outbox_pda(
outbox_id,
programs::bridge_lock().id(),
&target_zone,
ordinal,
),
outbox_pda(outbox_id, bridge_lock_id, &target_zone, ordinal),
];
// One nonce per signature: the holder signs, at its genesis nonce 0.
let message = Message::try_new(bridge_lock_id, accounts, vec![0_u128.into()], lock)
@@ -113,6 +113,26 @@ fn seed_ping_sender_config(state: &mut V03State) {
)]);
}
/// Seeds the bridge-lock config account pinning the real outbox and the wrapped
/// token, matching what genesis seeds for a real zone.
fn seed_bridge_lock_config(state: &mut V03State) {
let bridge_lock_id = programs::bridge_lock().id();
*state = std::mem::replace(state, V03State::new()).with_public_accounts([(
bridge_lock_core::config_account_id(bridge_lock_id),
Account {
program_owner: bridge_lock_id,
data: bridge_lock_core::config_bytes(
programs::cross_zone_outbox().id(),
programs::wrapped_token().id(),
)
.to_vec()
.try_into()
.expect("pinned ids fit in account data"),
..Default::default()
},
)]);
}
/// A `ping_sender::Send` carrying `payload` to `target_zone`, over the accounts
/// given rather than the correct ones, so tests can vary them.
fn send_tx(accounts: Vec<AccountId>, target_zone: [u8; 32], ordinal: u32) -> PublicTransaction {
@@ -299,33 +319,12 @@ fn lock_escrows_balance_and_emits_to_outbox() {
..Default::default()
},
)]);
seed_bridge_lock_config(&mut state);
let payload = mint_payload();
let target_accounts = vec![
wrapped_token_core::config_account_id(wrapped_token_id).into_value(),
wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT).into_value(),
];
let lock = bridge_lock_core::Instruction::Lock {
amount: LOCK_AMOUNT,
target_zone: zone_b,
target_program_id: wrapped_token_id,
target_accounts,
payload: payload.clone(),
outbox_program_id: outbox_id,
ordinal,
};
let escrow_id = bridge_lock_core::escrow_account_id(bridge_lock_id);
let outbox_record_id = outbox_pda(outbox_id, bridge_lock_id, &zone_b, ordinal);
let message = Message::try_new(
bridge_lock_id,
vec![holder_id, escrow_id, outbox_record_id],
vec![0_u128.into()],
lock,
)
.expect("build lock message");
let witness = WitnessSet::for_message(&message, &[&holder_key]);
let tx = PublicTransaction::new(message, witness);
let tx = lock_tx(&holder_key, holder_id, zone_b, ordinal, 0);
let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0)
.expect("lock must validate and execute");
@@ -365,25 +364,53 @@ fn lock_tx(
ordinal: u32,
nonce: u128,
) -> PublicTransaction {
let bridge_lock_id = programs::bridge_lock().id();
let wrapped_token_id = programs::wrapped_token().id();
lock_tx_to(
holder_key,
holder_id,
zone_b,
ordinal,
nonce,
wrapped_token_id,
mint_target_accounts(wrapped_token_id),
)
}
/// The mint's own account list: the wrapped-token config, then the recipient's
/// holding. What `wrapped_token::Mint` requires on the destination zone.
fn mint_target_accounts(wrapped_token_id: lee_core::program::ProgramId) -> Vec<[u8; 32]> {
vec![
wrapped_token_core::config_account_id(wrapped_token_id).into_value(),
wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT).into_value(),
]
}
/// The same lock aimed at `target_program_id` over `target_accounts`, so a test
/// can vary what the destination would be asked to do.
fn lock_tx_to(
holder_key: &PrivateKey,
holder_id: AccountId,
zone_b: [u8; 32],
ordinal: u32,
nonce: u128,
target_program_id: lee_core::program::ProgramId,
target_accounts: Vec<[u8; 32]>,
) -> PublicTransaction {
let bridge_lock_id = programs::bridge_lock().id();
let outbox_id = programs::cross_zone_outbox().id();
let lock = bridge_lock_core::Instruction::Lock {
amount: LOCK_AMOUNT,
target_zone: zone_b,
target_program_id: wrapped_token_id,
target_accounts: vec![
wrapped_token_core::config_account_id(wrapped_token_id).into_value(),
wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT).into_value(),
],
target_program_id,
target_accounts,
payload: mint_payload(),
outbox_program_id: outbox_id,
ordinal,
};
let message = Message::try_new(
bridge_lock_id,
vec![
bridge_lock_core::config_account_id(bridge_lock_id),
holder_id,
bridge_lock_core::escrow_account_id(bridge_lock_id),
outbox_pda(outbox_id, bridge_lock_id, &zone_b, ordinal),
@@ -415,6 +442,7 @@ fn a_second_emit_at_the_same_slot_is_rejected() {
..Default::default()
},
)]);
seed_bridge_lock_config(&mut state);
let first = lock_tx(&holder_key, holder_id, zone_b, ordinal, 0);
let diff = ValidatedStateDiff::from_public_transaction(&first, &state, 1, 0)
@@ -463,6 +491,7 @@ fn two_emitters_share_an_ordinal_without_colliding() {
},
)]);
seed_ping_sender_config(&mut state);
seed_bridge_lock_config(&mut state);
let lock_slot = outbox_pda(outbox_id, bridge_lock_id, &zone_b, ordinal);
let send_slot = outbox_pda(outbox_id, sender_id, &zone_b, ordinal);
@@ -533,6 +562,265 @@ fn a_send_into_a_foreign_outbox_slot_is_rejected() {
);
}
/// Nothing releases an escrow, so a message the destination will refuse is a
/// burn: debited here, never minted there. The refusal has to come before the
/// debit.
#[test]
fn a_lock_naming_another_target_program_is_rejected() {
let bridge_lock_id = programs::bridge_lock().id();
let zone_b = [2_u8; 32];
let holder_key = PrivateKey::try_new([7; 32]).expect("valid key");
let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key));
let mut state = base_state().with_public_accounts([(
holder_id,
Account {
program_owner: bridge_lock_id,
balance: INITIAL_BALANCE,
..Default::default()
},
)]);
seed_bridge_lock_config(&mut state);
let elsewhere = programs::ping_receiver().id();
let lock = lock_tx_to(
&holder_key,
holder_id,
zone_b,
0,
0,
elsewhere,
mint_target_accounts(elsewhere),
);
let Err(err) = ValidatedStateDiff::from_public_transaction(&lock, &state, 1, 0) else {
panic!("a lock aimed at another program must not execute");
};
assert!(
format!("{err:?}").contains("only mints through the wrapped token it is pinned to"),
"rejected for the wrong reason: {err:?}"
);
assert_eq!(
state.get_account_by_id(holder_id).balance,
INITIAL_BALANCE,
"a refused lock leaves the holder's balance alone"
);
}
/// The same burn by a different route: the right target program, the wrong
/// accounts for it. `wrapped_token::Mint` fails its own address asserts on the
/// destination, so the escrow has to be refused here instead.
#[test]
fn a_lock_naming_other_mint_accounts_is_rejected() {
let bridge_lock_id = programs::bridge_lock().id();
let wrapped_token_id = programs::wrapped_token().id();
let zone_b = [2_u8; 32];
let holder_key = PrivateKey::try_new([7; 32]).expect("valid key");
let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key));
let mut state = base_state().with_public_accounts([(
holder_id,
Account {
program_owner: bridge_lock_id,
balance: INITIAL_BALANCE,
..Default::default()
},
)]);
seed_bridge_lock_config(&mut state);
// A holding under someone other than the payload's recipient: a mint the
// destination would credit to the wrong account if it credited it at all.
let other_holding =
wrapped_token_core::holding_account_id(wrapped_token_id, &[4; 32]).into_value();
let lock = lock_tx_to(
&holder_key,
holder_id,
zone_b,
0,
0,
wrapped_token_id,
vec![
wrapped_token_core::config_account_id(wrapped_token_id).into_value(),
other_holding,
],
);
let Err(err) = ValidatedStateDiff::from_public_transaction(&lock, &state, 1, 0) else {
panic!("a lock over the wrong mint accounts must not execute");
};
assert!(
format!("{err:?}").contains("target accounts must be the mint's config"),
"rejected for the wrong reason: {err:?}"
);
assert_eq!(
state.get_account_by_id(holder_id).balance,
INITIAL_BALANCE,
"a refused lock leaves the holder's balance alone"
);
}
/// The config is read by address, so substituting another account for it fails
/// rather than reading the pins out of whatever that account holds. Without the
/// address check, 64 bytes a caller controls would re-pin both for one lock.
#[test]
fn a_lock_with_a_substituted_config_account_is_rejected() {
let bridge_lock_id = programs::bridge_lock().id();
let wrapped_token_id = programs::wrapped_token().id();
let outbox_id = programs::cross_zone_outbox().id();
let zone_b = [2_u8; 32];
let ordinal = 0;
let holder_key = PrivateKey::try_new([7; 32]).expect("valid key");
let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key));
// A bridge-lock-owned account holding pins of the caller's choosing, so only
// the address check stands between it and being read as the config.
let decoy_key = PrivateKey::try_new([8; 32]).expect("valid key");
let decoy_id = AccountId::from(&PublicKey::new_from_private_key(&decoy_key));
let mut state = base_state().with_public_accounts([
(
holder_id,
Account {
program_owner: bridge_lock_id,
balance: INITIAL_BALANCE,
..Default::default()
},
),
(
decoy_id,
Account {
program_owner: bridge_lock_id,
data: bridge_lock_core::config_bytes([3; 8], [4; 8])
.to_vec()
.try_into()
.expect("pinned ids fit in account data"),
..Default::default()
},
),
]);
seed_bridge_lock_config(&mut state);
let lock = bridge_lock_core::Instruction::Lock {
amount: LOCK_AMOUNT,
target_zone: zone_b,
target_program_id: wrapped_token_id,
target_accounts: mint_target_accounts(wrapped_token_id),
payload: mint_payload(),
ordinal,
};
let message = Message::try_new(
bridge_lock_id,
vec![
decoy_id,
holder_id,
bridge_lock_core::escrow_account_id(bridge_lock_id),
outbox_pda(outbox_id, bridge_lock_id, &zone_b, ordinal),
],
vec![0_u128.into()],
lock,
)
.expect("build lock message");
let tx = PublicTransaction::new(
message.clone(),
WitnessSet::for_message(&message, &[&holder_key]),
);
let Err(err) = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) else {
panic!("a lock over a substituted config account must not execute");
};
assert!(
format!("{err:?}").contains("must be the bridge-lock config PDA"),
"rejected for the wrong reason: {err:?}"
);
}
/// A bridge with no pin cannot fall back to caller-named programs: it stops
/// locking. The state a zone reaches by skipping the genesis init.
#[test]
fn a_lock_before_the_pins_are_set_is_rejected() {
let bridge_lock_id = programs::bridge_lock().id();
let zone_b = [2_u8; 32];
let holder_key = PrivateKey::try_new([7; 32]).expect("valid key");
let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key));
let state = base_state().with_public_accounts([(
holder_id,
Account {
program_owner: bridge_lock_id,
balance: INITIAL_BALANCE,
..Default::default()
},
)]);
let lock = lock_tx(&holder_key, holder_id, zone_b, 0, 0);
let Err(err) = ValidatedStateDiff::from_public_transaction(&lock, &state, 1, 0) else {
panic!("a lock with nothing pinned must not execute");
};
assert!(
format!("{err:?}").contains("config account holds an outbox and a mint target"),
"rejected for the wrong reason: {err:?}"
);
}
/// Written once, on the same terms as the sender's: an identical re-init is the
/// genesis replay, a different one would redirect every lock on the zone.
#[test]
fn the_bridge_pins_are_written_once_and_replayable() {
let bridge_lock_id = programs::bridge_lock().id();
let config_id = bridge_lock_core::config_account_id(bridge_lock_id);
let outbox_id = programs::cross_zone_outbox().id();
let wrapped_token_id = programs::wrapped_token().id();
let init = |outbox: lee_core::program::ProgramId, target: lee_core::program::ProgramId| {
let message = Message::try_new(
bridge_lock_id,
vec![config_id],
vec![],
bridge_lock_core::Instruction::InitConfig {
outbox_program_id: outbox,
target_program_id: target,
},
)
.expect("build InitConfig message");
PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![]))
};
let mut state = base_state();
let diff = ValidatedStateDiff::from_public_transaction(
&init(outbox_id, wrapped_token_id),
&state,
1,
0,
)
.expect("the first init claims the config PDA");
state.apply_state_diff(diff);
assert_eq!(
bridge_lock_core::read_config(&state.get_account_by_id(config_id).data.into_inner()),
Some((outbox_id, wrapped_token_id)),
"the config pins both programs after genesis"
);
ValidatedStateDiff::from_public_transaction(&init(outbox_id, wrapped_token_id), &state, 2, 0)
.expect("replaying the identical init is a no-op, not a failure");
// Either half moving is a redirect: the outbox decides whether the emission is
// recorded, the target where the value lands.
for (outbox, target, what) in [
([3; 8], wrapped_token_id, "outbox"),
(outbox_id, [3; 8], "mint target"),
] {
let Err(err) =
ValidatedStateDiff::from_public_transaction(&init(outbox, target), &state, 3, 0)
else {
panic!("a re-init naming a different {what} must not execute");
};
assert!(
format!("{err:?}").contains("already pins a different outbox or mint target"),
"rejected for the wrong reason: {err:?}"
);
}
}
/// An emitter with no pin cannot fall back to a caller-named outbox: it stops
/// emitting. The state a zone reaches by skipping the genesis init.
#[test]
+20 -2
View File
@@ -83,13 +83,16 @@ pub fn extract_emission(program_id: ProgramId, instruction_data: &[u32]) -> Opti
payload,
})
} else if program_id == programs::bridge_lock().id() {
let bridge_lock_core::Instruction::Lock {
let Ok(bridge_lock_core::Instruction::Lock {
target_zone,
target_program_id,
target_accounts,
payload,
..
} = risc0_zkvm::serde::from_slice(instruction_data).ok()?;
}) = risc0_zkvm::serde::from_slice(instruction_data)
else {
return None;
};
Some(Emission {
target_zone,
target_program_id,
@@ -234,6 +237,21 @@ pub fn build_ping_sender_init_config_tx() -> lee::PublicTransaction {
)
}
/// The genesis transaction that pins the outbox `bridge_lock` chains into and the
/// wrapped token it mints, without importing either id into the guest.
#[must_use]
pub fn build_bridge_lock_init_config_tx() -> lee::PublicTransaction {
let bridge_lock_id = programs::bridge_lock().id();
genesis_public_tx(
bridge_lock_id,
vec![bridge_lock_core::config_account_id(bridge_lock_id)],
bridge_lock_core::Instruction::InitConfig {
outbox_program_id: programs::cross_zone_outbox().id(),
target_program_id: programs::wrapped_token().id(),
},
)
}
/// Builds an unsigned, sequencer-origin genesis transaction invoking `instruction`
/// on `program_id` over `account_ids`.
fn genesis_public_tx<I: Serialize>(
+3
View File
@@ -10,3 +10,6 @@ workspace = true
[dependencies]
lee_core.workspace = true
serde = { workspace = true, features = ["alloc"] }
[dev-dependencies]
risc0-zkvm.workspace = true
+92 -4
View File
@@ -9,23 +9,41 @@ use lee_core::{
use serde::{Deserialize, Serialize};
const ESCROW_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeLockEscrow/0000/";
const CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeLockCfg/0000000/";
/// Variants are append-only. risc0 serde encodes the variant as a bare leading
/// tag word, so inserting one ahead of `Lock` shifts every existing encoding.
#[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.
/// minting the wrapped token on `target_zone`.
///
/// Required accounts (3): holder holding (authorized), escrow PDA, outbox PDA.
/// `target_program_id` and `target_accounts` are supplied though the guest
/// accepts one value for each: `cross_zone::extract_emission` reads them off
/// the transaction, decoding every emitter through one shape.
///
/// `target_zone` is the caller's, so a lock to a zone that will not route it
/// escrows and never mints. TODO: bound it source-side.
///
/// Required accounts (4): config PDA, 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,
},
/// Pins the outbox program and the mint target, written once into a default
/// config PDA at genesis. A re-run naming different programs is refused; an
/// identical one is a no-op, which is what genesis replay does.
///
/// Required accounts (1): the config PDA.
InitConfig {
outbox_program_id: ProgramId,
target_program_id: ProgramId,
},
}
/// PDA accumulating all locked balance on this zone.
@@ -39,6 +57,49 @@ pub const fn escrow_seed() -> PdaSeed {
PdaSeed::new(ESCROW_SEED_DOMAIN)
}
/// PDA holding the outbox program id and the mint target, seeded at genesis so
/// the guest can pin both without importing their image ids.
#[must_use]
pub fn config_account_id(bridge_lock_id: ProgramId) -> AccountId {
AccountId::for_public_pda(&bridge_lock_id, &config_seed())
}
#[must_use]
pub const fn config_seed() -> PdaSeed {
PdaSeed::new(CONFIG_SEED_DOMAIN)
}
/// Encodes the pinned outbox and mint target for the config account's data.
#[must_use]
pub fn config_bytes(outbox_program_id: ProgramId, target_program_id: ProgramId) -> [u8; 64] {
let mut bytes = [0_u8; 64];
for (word, chunk) in outbox_program_id
.iter()
.chain(target_program_id.iter())
.zip(bytes.chunks_exact_mut(4))
{
chunk.copy_from_slice(&word.to_le_bytes());
}
bytes
}
/// Decodes the pinned outbox and mint target from the config account's data.
#[must_use]
pub fn read_config(data: &[u8]) -> Option<(ProgramId, ProgramId)> {
if data.len() < 64 {
return None;
}
let mut ids = [0_u32; 16];
for (word, chunk) in ids.iter_mut().zip(data[..64].chunks_exact(4)) {
*word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!()));
}
let (outbox, target) = ids.split_at(8);
Some((
outbox.try_into().unwrap_or_else(|_| unreachable!()),
target.try_into().unwrap_or_else(|_| unreachable!()),
))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -48,4 +109,31 @@ mod tests {
let id: ProgramId = [4; 8];
assert_eq!(escrow_account_id(id), escrow_account_id(id));
}
#[test]
fn config_ids_round_trip() {
let outbox: ProgramId = [3; 8];
let target: ProgramId = [5; 8];
assert_eq!(
read_config(&config_bytes(outbox, target)),
Some((outbox, target))
);
}
/// `extract_emission` decodes `Lock` off peer transactions, so its tag word is
/// wire format: a variant inserted ahead of it would silently shift every
/// existing encoding.
#[test]
fn lock_is_the_first_variant() {
let lock = Instruction::Lock {
amount: 1,
target_zone: [7; 32],
target_program_id: [1; 8],
target_accounts: vec![],
payload: vec![],
ordinal: 0,
};
let words = risc0_zkvm::serde::to_vec(&lock).expect("Lock serializes");
assert_eq!(words[0], 0);
}
}
+146 -22
View File
@@ -1,8 +1,14 @@
use bridge_lock_core::{Instruction, escrow_account_id, escrow_seed};
use bridge_lock_core::{
Instruction, config_account_id, config_bytes, config_seed, escrow_account_id, escrow_seed,
read_config,
};
use cross_zone_outbox_core::Instruction as OutboxInstruction;
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,
},
};
use wrapped_token_core::{Instruction as WrappedInstruction, MAX_MINT_AMOUNT};
@@ -22,20 +28,74 @@ fn main() {
"bridge_lock is only invoked as a top-level user transaction"
);
let Instruction::Lock {
amount,
target_zone,
target_program_id,
target_accounts,
payload,
outbox_program_id,
ordinal,
} = instruction;
match instruction {
Instruction::Lock {
amount,
target_zone,
target_program_id,
target_accounts,
payload,
ordinal,
} => lock(
self_program_id,
caller_program_id,
pre_states,
instruction_words,
amount,
target_zone,
target_program_id,
target_accounts,
payload,
ordinal,
),
Instruction::InitConfig {
outbox_program_id,
target_program_id,
} => init_config(
self_program_id,
caller_program_id,
pre_states,
instruction_words,
outbox_program_id,
target_program_id,
),
}
}
#[expect(
clippy::too_many_arguments,
reason = "the emission fields are passed through verbatim"
)]
fn lock(
self_program_id: ProgramId,
caller_program_id: Option<ProgramId>,
pre_states: Vec<AccountWithMetadata>,
instruction_words: Vec<u32>,
amount: u128,
target_zone: [u8; 32],
target_program_id: ProgramId,
target_accounts: Vec<[u8; 32]>,
payload: Vec<u8>,
ordinal: u32,
) {
// pre_states: [config PDA, holder holding (authorized), escrow PDA, outbox PDA].
let [config, holder, escrow, outbox] = <[AccountWithMetadata; 4]>::try_from(pre_states)
.expect("Lock requires config, holder, escrow, and outbox accounts");
// Pinned rather than caller-named: chaining elsewhere would debit the escrow
// and leave no record of what it was for.
assert_eq!(
config.account_id,
config_account_id(self_program_id),
"first account must be the bridge-lock config PDA"
);
let (outbox_program_id, pinned_target) = read_config(&config.account.data.clone().into_inner())
.expect("config account holds an outbox and a mint target");
// Value conservation: the forwarded payload must mint exactly what is locked.
let WrappedInstruction::Mint {
recipient,
amount: mint_amount,
..
} = decode_mint(&payload)
else {
panic!("bridge_lock payload must be a wrapped-token mint");
@@ -44,18 +104,27 @@ fn main() {
mint_amount, amount,
"locked amount must equal the wrapped mint amount"
);
// Before the debit, not on the destination: nothing releases an escrow, so
// an amount the destination will not mint has to fail in the submitter's own
// transaction.
// All before the debit: nothing releases an escrow, so a message the
// destination refuses is a burn. `target_zone` is not checkable here, so a
// lock aimed at a zone that will not route it still burns.
assert_eq!(
target_program_id, pinned_target,
"bridge_lock only mints through the wrapped token it is pinned to"
);
assert_eq!(
target_accounts,
vec![
wrapped_token_core::config_account_id(pinned_target).into_value(),
wrapped_token_core::holding_account_id(pinned_target, &recipient).into_value(),
],
"target accounts must be the mint's config and the recipient's holding"
);
assert!(
amount <= MAX_MINT_AMOUNT,
"locked amount exceeds what the wrapped token will mint"
);
// pre_states: [holder holding (authorized), escrow PDA, outbox PDA].
let [holder, escrow, outbox] = <[AccountWithMetadata; 3]>::try_from(pre_states)
.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
@@ -68,7 +137,7 @@ fn main() {
assert_eq!(
escrow.account_id,
escrow_account_id(self_program_id),
"second account must be the escrow PDA"
"third account must be the escrow PDA"
);
// Move the real native balance holder -> escrow. bridge_lock owns both accounts,
@@ -106,12 +175,15 @@ fn main() {
},
);
let config_post = AccountPostState::new(config.account.clone());
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
vec![holder, escrow, outbox.clone()],
vec![config, holder, escrow, outbox.clone()],
vec![
config_post,
holder_post,
escrow_post,
AccountPostState::new(outbox.account),
@@ -121,6 +193,58 @@ fn main() {
.write();
}
/// Writes the outbox program and the mint target 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>,
outbox_program_id: ProgramId,
target_program_id: ProgramId,
) {
// 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 bridge-lock config PDA"
);
// Init-once, idempotent under genesis replay: a `default` config is a first
// init; an already-owned one must already pin exactly these programs, since
// genesis is replayed onto seeded state during multi-sequencer reconstruction.
// `new_claimed_if_default` alone would not stop a later self-owned rewrite.
if config.account != Account::default() {
assert_eq!(
config.account.program_owner, self_program_id,
"bridge-lock config PDA is owned by another program"
);
assert_eq!(
config.account.data.clone().into_inner(),
config_bytes(outbox_program_id, target_program_id).to_vec(),
"bridge-lock config already pins a different outbox or mint target"
);
}
let mut config_account = config.account.clone();
config_account.data = config_bytes(outbox_program_id, target_program_id)
.to_vec()
.try_into()
.expect("pinned ids fit 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();
}
/// Decodes the cross-zone payload (risc0 words, little-endian bytes) into the
/// wrapped-token instruction it carries.
fn decode_mint(payload: &[u8]) -> WrappedInstruction {
+8 -6
View File
@@ -1523,14 +1523,15 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
// Config txs seed the config accounts by transaction, so every node
// reconstructs them by replaying the genesis block. The wrapped-token minter and
// the ping-sender outbox pin are initialized on every zone: both are builtins
// with a user-callable InitConfig, so a config PDA left default is claimable by
// the first initializer, hijacking the minter or repointing the emitter's
// outbox. 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.
// both emitters' pins are initialized on every zone: all three are builtins with
// a user-callable InitConfig, so a config PDA left default is claimable by the
// first initializer, hijacking the minter or repointing an emitter's outbox. 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 ping_sender_config_tx = std::iter::once(cross_zone::build_ping_sender_init_config_tx());
let bridge_lock_config_tx = std::iter::once(cross_zone::build_bridge_lock_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)
@@ -1552,6 +1553,7 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
let genesis_txs = wrapped_token_config_tx
.chain(ping_sender_config_tx)
.chain(bridge_lock_config_tx)
.chain(inbox_config_tx)
.chain(supply_txs)
.chain(std::iter::once(clock_invocation(0)))