fix(cross-zone)!: claim the source authority, and guard its precondition

BREAKING CHANGE: the targets claim the authority account on their first use of it, so both target image ids move.
This commit is contained in:
moudyellaz
2026-08-12 14:55:23 +02:00
parent b1681b854f
commit 23cd084bdb
7 changed files with 393 additions and 36 deletions
@@ -1313,6 +1313,41 @@ fn the_authority_can_renounce_itself_and_only_itself() {
"rejected for the wrong reason: {err:?}"
);
// Named but not signing. Separate from the case above, which fails one line
// earlier on the address.
let Err(unsigned) =
ValidatedStateDiff::from_public_transaction(&renounce(authority, &other_key), &state, 1, 0)
else {
panic!("naming the authority without its signature must not renounce it");
};
assert!(
format!("{unsigned:?}").contains("must authorize renouncing it"),
"rejected for the wrong reason: {unsigned:?}"
);
// Substituting another account for the config is refused. Renounce is the one
// instruction that destroys the authority for good, so this guard matters most
// here.
let substituted = Message::try_new(
wrapped_token_id,
vec![ping_record_pda(wrapped_token_id), authority],
vec![0_u128.into()],
wrapped_token_core::Instruction::RenounceAuthority,
)
.expect("build renounce message");
let swapped_tx = PublicTransaction::new(
substituted.clone(),
WitnessSet::for_message(&substituted, &[&key]),
);
let Err(swapped) = ValidatedStateDiff::from_public_transaction(&swapped_tx, &state, 1, 0)
else {
panic!("a renounce over a substituted config account must not execute");
};
assert!(
format!("{swapped:?}").contains("must be the wrapped-token config PDA"),
"rejected for the wrong reason: {swapped:?}"
);
let diff =
ValidatedStateDiff::from_public_transaction(&renounce(authority, &key), &state, 1, 0)
.expect("the authority renounces itself");
@@ -1342,8 +1377,9 @@ fn the_authority_can_renounce_itself_and_only_itself() {
wrapped_token_core::Instruction::UpdateSources { sources: vec![] },
)
.expect("build update message");
let tx = PublicTransaction::new(update.clone(), WitnessSet::for_message(&update, &[&key]));
let Err(frozen) = ValidatedStateDiff::from_public_transaction(&tx, &state, 2, 0) else {
let frozen_tx =
PublicTransaction::new(update.clone(), WitnessSet::for_message(&update, &[&key]));
let Err(frozen) = ValidatedStateDiff::from_public_transaction(&frozen_tx, &state, 2, 0) else {
panic!("a renounced authority must not still change sources");
};
assert!(
@@ -1352,6 +1388,253 @@ fn the_authority_can_renounce_itself_and_only_itself() {
);
}
/// The receiver's authority path is a mirror of the token's, and a mirror is
/// exactly where a copy-paste slip hides. Same battery, run against it.
#[test]
fn the_receiver_authority_path_holds() {
let receiver_id = programs::ping_receiver().id();
let config_id = receiver_config_account_id(receiver_id);
let src_zone = [2_u8; 32];
let sender_id = programs::ping_sender().id();
let key = PrivateKey::try_new([7; 32]).expect("valid key");
let authority = AccountId::from(&PublicKey::new_from_private_key(&key));
let other_key = PrivateKey::try_new([8; 32]).expect("valid key");
let other = AccountId::from(&PublicKey::new_from_private_key(&other_key));
let update = |account: AccountId, signer: &PrivateKey, nonce: u128| {
let message = Message::try_new(
receiver_id,
vec![config_id, account],
vec![nonce.into()],
ping_core::ReceiverInstruction::UpdateSources {
sources: vec![(src_zone, sender_id)],
},
)
.expect("build update message");
let witness = WitnessSet::for_message(&message, &[signer]);
PublicTransaction::new(message, witness)
};
let renounce = |account: AccountId, signer: &PrivateKey, nonce: u128| {
let message = Message::try_new(
receiver_id,
vec![config_id, account],
vec![nonce.into()],
ping_core::ReceiverInstruction::RenounceAuthority,
)
.expect("build renounce message");
let witness = WitnessSet::for_message(&message, &[signer]);
PublicTransaction::new(message, witness)
};
let rejects = |state: &V03State, tx: &PublicTransaction, expected: &str| {
let Err(err) = ValidatedStateDiff::from_public_transaction(tx, state, 1, 0) else {
panic!("expected a rejection mentioning {expected}");
};
assert!(
format!("{err:?}").contains(expected),
"rejected for the wrong reason: {err:?}"
);
};
// With no authority configured, nothing moves.
let mut unset = base_state();
seed_receiver_config(&mut unset, None, vec![]);
rejects(&unset, &update(authority, &key, 0), "fixed at genesis");
rejects(&unset, &renounce(authority, &key, 0), "already renounced");
// With one configured: the wrong account, and the right account without its
// own signature, are both refused for their own reasons.
let mut state = base_state();
seed_receiver_config(&mut state, Some(authority), vec![]);
rejects(
&state,
&update(other, &other_key, 0),
"must be the configured authority",
);
rejects(
&state,
&renounce(other, &other_key, 0),
"must be the configured authority",
);
rejects(
&state,
&update(authority, &other_key, 0),
"must authorize a source change",
);
rejects(
&state,
&renounce(authority, &other_key, 0),
"must authorize renouncing it",
);
// The authority itself works, and renouncing is one-way.
let diff =
ValidatedStateDiff::from_public_transaction(&update(authority, &key, 0), &state, 1, 0)
.expect("the configured authority changes sources");
state.apply_state_diff(diff);
let cfg = ping_core::ReceiverConfig::from_bytes(
&state.get_account_by_id(config_id).data.into_inner(),
)
.expect("config decodes");
assert_eq!(cfg.sources, vec![(src_zone, sender_id)]);
assert_eq!(cfg.deliverer, programs::cross_zone_inbox().id());
let renounce_diff =
ValidatedStateDiff::from_public_transaction(&renounce(authority, &key, 1), &state, 2, 0)
.expect("the authority renounces itself");
state.apply_state_diff(renounce_diff);
let renounced_cfg = ping_core::ReceiverConfig::from_bytes(
&state.get_account_by_id(config_id).data.into_inner(),
)
.expect("config decodes");
assert_eq!(renounced_cfg.authority, None, "the authority is gone");
assert_eq!(
renounced_cfg.sources,
vec![(src_zone, sender_id)],
"renouncing freezes the list it had"
);
rejects(&state, &update(authority, &key, 2), "fixed at genesis");
rejects(&state, &renounce(authority, &key, 2), "already renounced");
}
/// The guards that survive a deletion otherwise: the caller pins on renounce for
/// both targets and on the receiver's update, the config-address checks the
/// substitution cases miss, and the token's already-renounced branch.
#[test]
fn the_remaining_authority_guards_hold() {
let wrapped_token_id = programs::wrapped_token().id();
let receiver_id = programs::ping_receiver().id();
let inbox_id = programs::cross_zone_inbox().id();
let self_zone = [1_u8; 32];
let src_zone = [2_u8; 32];
let key = PrivateKey::try_new([7; 32]).expect("valid key");
let authority = AccountId::from(&PublicKey::new_from_private_key(&key));
let rejects = |state: &V03State, tx: &PublicTransaction, expected: &str| {
let Err(err) = ValidatedStateDiff::from_public_transaction(tx, state, 1, 0) else {
panic!("expected a rejection mentioning {expected}");
};
assert!(
format!("{err:?}").contains(expected),
"rejected for the wrong reason: {err:?}"
);
};
let signed = |program: lee_core::program::ProgramId,
accounts: Vec<AccountId>,
instruction_words: Vec<u32>| {
let message =
Message::new_preserialized(program, accounts, vec![0_u128.into()], instruction_words);
let witness = WitnessSet::for_message(&message, &[&key]);
PublicTransaction::new(message, witness)
};
let mut state = base_state();
seed_inbox_config(&mut state, self_zone);
seed_wrapped_config(&mut state, Some(authority), vec![]);
seed_receiver_config(&mut state, Some(authority), vec![]);
// Config address, on the instruction each target's substitution case misses.
rejects(
&state,
&signed(
wrapped_token_id,
vec![ping_record_pda(wrapped_token_id), authority],
risc0_zkvm::serde::to_vec(&wrapped_token_core::Instruction::UpdateSources {
sources: vec![(src_zone, programs::bridge_lock().id())],
})
.expect("serialize"),
),
"must be the wrapped-token config PDA",
);
for (words, expected) in [
(
risc0_zkvm::serde::to_vec(&ping_core::ReceiverInstruction::UpdateSources {
sources: vec![(src_zone, programs::ping_sender().id())],
})
.expect("serialize"),
"must be the receiver config PDA",
),
(
risc0_zkvm::serde::to_vec(&ping_core::ReceiverInstruction::RenounceAuthority)
.expect("serialize"),
"must be the receiver config PDA",
),
] {
rejects(
&state,
&signed(
receiver_id,
vec![ping_record_pda(receiver_id), authority],
words,
),
expected,
);
}
// Reached through the inbox rather than top-level, for the three caller pins
// that had no chained test.
for (target, config_id, words) in [
(
wrapped_token_id,
wrapped_token_core::config_account_id(wrapped_token_id),
risc0_zkvm::serde::to_vec(&wrapped_token_core::Instruction::RenounceAuthority)
.expect("serialize"),
),
(
receiver_id,
receiver_config_account_id(receiver_id),
risc0_zkvm::serde::to_vec(&ping_core::ReceiverInstruction::RenounceAuthority)
.expect("serialize"),
),
(
receiver_id,
receiver_config_account_id(receiver_id),
risc0_zkvm::serde::to_vec(&ping_core::ReceiverInstruction::UpdateSources {
sources: vec![(src_zone, programs::ping_sender().id())],
})
.expect("serialize"),
),
] {
let msg = CrossZoneMessage {
src_zone,
src_block_id: 5,
src_block_hash: SRC_BLOCK_HASH,
src_tx_index: 0,
src_program_id: programs::bridge_lock().id(),
target_program_id: target,
payload: words.iter().flat_map(|word| word.to_le_bytes()).collect(),
l1_inclusion_witness: None,
};
let message = Message::try_new(
inbox_id,
dispatch_accounts(inbox_id, &msg, vec![config_id, authority]),
vec![],
InboxInstruction::Dispatch(msg),
)
.expect("build dispatch message");
let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![]));
rejects(&state, &tx, "only invoked as a top-level transaction");
}
// The token's already-renounced branch, which only the receiver covered.
let mut unset = base_state();
seed_wrapped_config(&mut unset, None, vec![]);
rejects(
&unset,
&signed(
wrapped_token_id,
vec![
wrapped_token_core::config_account_id(wrapped_token_id),
authority,
],
risc0_zkvm::serde::to_vec(&wrapped_token_core::Instruction::RenounceAuthority)
.expect("serialize"),
),
"already renounced",
);
}
/// A token that authorizes nothing mints for nobody. The state a zone reaches with
/// no peers configured, where the config is still seeded so its PDA cannot be
/// claimed by a first initializer.
+19 -15
View File
@@ -204,15 +204,17 @@ pub fn build_holding_account(holder: AccountId, amount: Balance) -> (AccountId,
/// The `(src_zone, src_program_id)` pairs the operator's routes name for one
/// target.
///
/// Panics on a route naming a target this zone does not host. Nothing downstream
/// would notice otherwise: the route is dropped here, the watcher no longer
/// filters targets, and every delivery would fail at a program that does not
/// exist and dead-letter after three attempts. A typo in a config file should not
/// cost a channel silently.
/// Panics on a route naming a program that does not authorize cross-zone sources.
/// Nothing downstream would notice otherwise: the route is dropped here, the
/// watcher no longer filters targets, and every delivery would be refused by a
/// program that never opted in, dead-lettering after three attempts. A typo in a
/// config file should not cost a channel silently.
///
/// Only the sequencer builds genesis, so an indexer handed the same typo starts
/// normally and only the sequencer refuses to boot.
fn sources_for_target(
cross_zone: Option<&CrossZoneConfig>,
target_program_id: ProgramId,
hosted: &[ProgramId],
) -> Vec<(ZoneId, ProgramId)> {
let Some(cross_zone) = cross_zone else {
return Vec::new();
@@ -221,8 +223,9 @@ fn sources_for_target(
for peer in &cross_zone.peers {
for route in &peer.allowed_routes {
assert!(
hosted.contains(&route.target_program_id),
"cross-zone route names a target this zone does not host"
cross_zone_targets().contains(&route.target_program_id),
"cross-zone route names {:?}, which does not authorize cross-zone sources",
route.target_program_id
);
if route.target_program_id == target_program_id {
sources.push((peer.channel_id, route.src_program_id));
@@ -254,7 +257,7 @@ pub fn build_wrapped_token_init_config_tx(
cross_zone: Option<&CrossZoneConfig>,
) -> lee::PublicTransaction {
let wrapped_token_id = programs::wrapped_token().id();
let sources = sources_for_target(cross_zone, wrapped_token_id, &cross_zone_targets());
let sources = sources_for_target(cross_zone, wrapped_token_id);
genesis_public_tx(
wrapped_token_id,
vec![wrapped_token_core::config_account_id(wrapped_token_id)],
@@ -303,7 +306,7 @@ pub fn build_ping_receiver_init_config_tx(
cross_zone: Option<&CrossZoneConfig>,
) -> lee::PublicTransaction {
let receiver_id = programs::ping_receiver().id();
let sources = sources_for_target(cross_zone, receiver_id, &cross_zone_targets());
let sources = sources_for_target(cross_zone, receiver_id);
genesis_public_tx(
receiver_id,
vec![ping_core::receiver_config_account_id(receiver_id)],
@@ -335,12 +338,13 @@ fn genesis_public_tx<I: Serialize>(
mod tests {
use super::*;
/// A route naming a target this zone does not host is an operator typo that
/// nothing downstream would report: dropped here, unfiltered by the watcher,
/// and dead-lettered at a program that does not exist.
/// A route naming a program that never opted into cross-zone sources is an
/// operator typo that nothing downstream would report: the fan-out would drop
/// it, the watcher no longer filters targets, and every delivery would be
/// refused by the target and dead-lettered.
#[test]
#[should_panic(expected = "does not host")]
fn a_route_to_an_unhosted_target_is_refused_at_genesis() {
#[should_panic(expected = "does not authorize cross-zone sources")]
fn a_route_to_a_program_that_does_not_authorize_sources_is_refused() {
let cross_zone = CrossZoneConfig {
peers: vec![CrossZonePeer {
channel_id: [2; 32],
@@ -49,8 +49,8 @@ pub struct CrossZonePeer {
/// No longer enforced in transit: the inbox delivers to whatever a message
/// names, and the target refuses a source it did not authorize. This is the
/// operator's statement of intent, fanned out at genesis into each target's
/// own config. A route naming a target this zone does not host is dropped
/// there, silently.
/// own config. A route naming a program that does not authorize cross-zone
/// sources is refused there, at genesis.
pub allowed_routes: Vec<CrossZoneRoute>,
/// 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
@@ -74,6 +74,13 @@ pub struct CrossZoneConfig {
/// needs a config change and a restart on both the sequencer and the indexer,
/// and this field itself can only ever be set at genesis.
///
/// Must be a fresh, never-used account. The target claims it on first use,
/// because the state machine refuses an unowned post state whose pre-state is
/// not exactly default, and neither target has any instruction that moves this
/// account's balance afterwards, so anything sent to it is frozen for good and
/// no other program can ever claim it. Renouncing seizes it the same way, and
/// whichever target is used first is the one that ends up owning it.
///
/// It is a value-authorizing key: whoever holds it can authorize a source, and
/// a source can mint, so its compromise is theft rather than delay. One value
/// seeds every target, so setting it for one program grants it over all of
+8 -4
View File
@@ -56,10 +56,14 @@ fn main() {
/// instruction bytes and account ids the peer chose. So a program meant to be
/// reachable across zones MUST check the marker at position 0 against sources it
/// authorized itself, the way `wrapped_token` and `ping_receiver` do. A program
/// not meant to be reachable is protected only by its own asserts; several
/// builtins currently survive incidentally, through a claim check or an owner
/// check written for another reason, which is not the same as being safe by
/// design.
/// not meant to be reachable has only whatever its own code happens to do. Today
/// every other builtin refuses, but by three different accidents: four assert
/// `caller_program_id` is none; several run to completion and are stopped by the
/// host, either because they try to claim the marker without its authorization or
/// because they chain into its zero program id; and the rest are saved by an
/// address assert on a PDA. None of that was written with cross-zone delivery in
/// mind. User-deployed programs are reachable too, and were written with no
/// expectation of an inbox caller at all.
fn dispatch(
self_program_id: ProgramId,
caller_program_id: Option<ProgramId>,
+3 -5
View File
@@ -42,11 +42,9 @@ pub fn inbox_source_marker_account_id(
)
}
/// Seed of the source marker. Targets compare addresses rather than seeds, so
/// they use [`inbox_source_marker_account_id`]; this is exposed for callers that
/// need the seed itself.
#[must_use]
pub fn inbox_source_marker_seed(src_zone: &ZoneId, src_program_id: ProgramId) -> PdaSeed {
/// Seed of the source marker. Private: nothing claims this account, so no caller
/// needs the seed, only the address.
fn inbox_source_marker_seed(src_zone: &ZoneId, src_program_id: ProgramId) -> PdaSeed {
use risc0_zkvm::sha::{Impl, Sha256 as _};
let mut bytes = [0_u8; 96];
+36 -5
View File
@@ -1,7 +1,10 @@
use cross_zone_marker_core::inbox_source_marker_account_id;
use lee_core::{
account::{Account, AccountWithMetadata},
program::{AccountPostState, Claim, ProgramId, ProgramInput, ProgramOutput, read_lee_inputs},
program::{
AccountPostState, Claim, DEFAULT_PROGRAM_ID, ProgramId, ProgramInput, ProgramOutput,
read_lee_inputs,
},
};
use ping_core::{
ReceiverConfig, ReceiverInstruction, ping_record_pda, ping_record_seed,
@@ -64,7 +67,7 @@ fn record(
assert_eq!(
config.account_id,
receiver_config_account_id(self_program_id),
"Second account must be the receiver config PDA"
"second account must be the receiver config PDA"
);
let cfg = ReceiverConfig::from_bytes(&config.account.data.clone().into_inner())
.expect("config account holds a receiver config");
@@ -86,7 +89,7 @@ fn record(
assert_eq!(
record.account_id,
ping_record_pda(self_program_id),
"Third account must be the ping record PDA"
"third account must be the ping record PDA"
);
let mut post_account = record.account.clone();
@@ -138,6 +141,16 @@ fn renounce_authority(
authority.account_id, expected,
"second account must be the configured authority"
);
// The program claims this account, and a claim cannot rescue a first use: the
// state machine checks post states before it applies claims, so an unowned
// account that is not exactly default is refused and, since renouncing is
// refused for the same reason, the receiver source list would be frozen for the
// life of the zone. Say so here rather than let it surface as a rule number.
assert!(
authority.account == Account::default()
|| authority.account.program_owner != DEFAULT_PROGRAM_ID,
"the authority account must be untouched before its first use as one"
);
assert!(
authority.is_authorized,
"the configured authority must authorize renouncing it"
@@ -157,7 +170,11 @@ fn renounce_authority(
vec![config, authority.clone()],
vec![
AccountPostState::new(config_account),
AccountPostState::new(authority.account),
// Claimed on first use, not merely echoed: an unowned account whose
// pre-state is not exactly default cannot be returned with a default
// owner (state-machine rule 7), and the authority's own signature
// bumps its nonce, so echoing it works once and never again.
AccountPostState::new_claimed_if_default(authority.account, Claim::Authorized),
],
)
.write();
@@ -200,6 +217,16 @@ fn update_sources(
authority.account_id, expected,
"second account must be the configured authority"
);
// The program claims this account, and a claim cannot rescue a first use: the
// state machine checks post states before it applies claims, so an unowned
// account that is not exactly default is refused and, since renouncing is
// refused for the same reason, the receiver source list would be frozen for the
// life of the zone. Say so here rather than let it surface as a rule number.
assert!(
authority.account == Account::default()
|| authority.account.program_owner != DEFAULT_PROGRAM_ID,
"the authority account must be untouched before its first use as one"
);
assert!(
authority.is_authorized,
"the configured authority must authorize a source change"
@@ -219,7 +246,11 @@ fn update_sources(
vec![config, authority.clone()],
vec![
AccountPostState::new(config_account),
AccountPostState::new(authority.account),
// Claimed on first use, not merely echoed: an unowned account whose
// pre-state is not exactly default cannot be returned with a default
// owner (state-machine rule 7), and the authority's own signature
// bumps its nonce, so echoing it works once and never again.
AccountPostState::new_claimed_if_default(authority.account, Claim::Authorized),
],
)
.write();
+33 -3
View File
@@ -1,7 +1,9 @@
use cross_zone_marker_core::inbox_source_marker_account_id;
use lee_core::{
account::{Account, AccountWithMetadata},
program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs},
program::{
AccountPostState, Claim, DEFAULT_PROGRAM_ID, ProgramInput, ProgramOutput, read_lee_inputs,
},
};
use wrapped_token_core::{
Instruction, MAX_MINT_AMOUNT, WrappedTokenConfig, balance_bytes, config_account_id,
@@ -158,6 +160,16 @@ fn renounce_authority(
authority.account_id, expected,
"second account must be the configured authority"
);
// The program claims this account, and a claim cannot rescue a first use: the
// state machine checks post states before it applies claims, so an unowned
// account that is not exactly default is refused and, since renouncing is
// refused for the same reason, the wrapped-token source list would be frozen for the
// life of the zone. Say so here rather than let it surface as a rule number.
assert!(
authority.account == Account::default()
|| authority.account.program_owner != DEFAULT_PROGRAM_ID,
"the authority account must be untouched before its first use as one"
);
assert!(
authority.is_authorized,
"the configured authority must authorize renouncing it"
@@ -177,7 +189,11 @@ fn renounce_authority(
vec![config, authority.clone()],
vec![
AccountPostState::new(config_account),
AccountPostState::new(authority.account),
// Claimed on first use, not merely echoed: an unowned account whose
// pre-state is not exactly default cannot be returned with a default
// owner (state-machine rule 7), and the authority's own signature
// bumps its nonce, so echoing it works once and never again.
AccountPostState::new_claimed_if_default(authority.account, Claim::Authorized),
],
)
.write();
@@ -222,6 +238,16 @@ fn update_sources(
);
// Authorized rather than merely named, so a PDA held by a governance program
// works through the same delegation any signer would use.
// The program claims this account, and a claim cannot rescue a first use: the
// state machine checks post states before it applies claims, so an unowned
// account that is not exactly default is refused and, since renouncing is
// refused for the same reason, the wrapped-token source list would be frozen for the
// life of the zone. Say so here rather than let it surface as a rule number.
assert!(
authority.account == Account::default()
|| authority.account.program_owner != DEFAULT_PROGRAM_ID,
"the authority account must be untouched before its first use as one"
);
assert!(
authority.is_authorized,
"the configured authority must authorize a source change"
@@ -241,7 +267,11 @@ fn update_sources(
vec![config, authority.clone()],
vec![
AccountPostState::new(config_account),
AccountPostState::new(authority.account),
// Claimed on first use, not merely echoed: an unowned account whose
// pre-state is not exactly default cannot be returned with a default
// owner (state-machine rule 7), and the authority's own signature
// bumps its nonce, so echoing it works once and never again.
AccountPostState::new_claimed_if_default(authority.account, Claim::Authorized),
],
)
.write();