mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-08-26 11:51:13 +00:00
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:
Generated
+1
@@ -1225,6 +1225,7 @@ name = "bridge_lock_core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"lee_core",
|
||||
"risc0-zkvm",
|
||||
"serde",
|
||||
]
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -10,3 +10,6 @@ workspace = true
|
||||
[dependencies]
|
||||
lee_core.workspace = true
|
||||
serde = { workspace = true, features = ["alloc"] }
|
||||
|
||||
[dev-dependencies]
|
||||
risc0-zkvm.workspace = true
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user