From 44acb4ba739b510eaf5c2d1449500033af0c46a5 Mon Sep 17 00:00:00 2001 From: moudyellaz Date: Wed, 12 Aug 2026 15:44:29 +0200 Subject: [PATCH] feat(cross-zone)!: let the authority act through a configured governance program BREAKING CHANGE: WrappedTokenConfig and ReceiverConfig gain a governance field and CrossZoneConfig gains source_governance, so the config layouts change and every program image id moves. --- integration_tests/tests/cross_zone_bridge.rs | 1 + integration_tests/tests/cross_zone_ping.rs | 1 + .../tests/cross_zone_state_machine.rs | 1171 +++++++++++------ .../tests/cross_zone_verified.rs | 1 + .../tests/cross_zone_watcher_restart.rs | 1 + lez/cross_zone/src/lib.rs | 3 + lez/programs/cross_zone_inbox/core/src/lib.rs | 33 +- lez/programs/ping_core/src/lib.rs | 4 + lez/programs/ping_receiver/src/main.rs | 87 +- lez/programs/wrapped_token/core/src/lib.rs | 17 +- lez/programs/wrapped_token/src/main.rs | 89 +- lez/sequencer/core/src/tests.rs | 1 + .../guest/src/bin/authority_proxy.rs | 57 + test_programs/src/lib.rs | 10 + tools/cross_zone_chat/src/main.rs | 1 + 15 files changed, 941 insertions(+), 536 deletions(-) create mode 100644 test_programs/guest/src/bin/authority_proxy.rs diff --git a/integration_tests/tests/cross_zone_bridge.rs b/integration_tests/tests/cross_zone_bridge.rs index 7178ca4ee..8e3f703c8 100644 --- a/integration_tests/tests/cross_zone_bridge.rs +++ b/integration_tests/tests/cross_zone_bridge.rs @@ -63,6 +63,7 @@ async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> { expected_block_signing_pubkey: None, }], source_authority: None, + source_governance: None, }; // Zone A seeds the holder's bridgeable balance. Zone B runs the watcher on its diff --git a/integration_tests/tests/cross_zone_ping.rs b/integration_tests/tests/cross_zone_ping.rs index bfd6ca9cd..842558022 100644 --- a/integration_tests/tests/cross_zone_ping.rs +++ b/integration_tests/tests/cross_zone_ping.rs @@ -59,6 +59,7 @@ async fn ping_crosses_from_zone_a_to_zone_b() -> Result<()> { expected_block_signing_pubkey: None, }], source_authority: None, + source_governance: None, }; let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr) diff --git a/integration_tests/tests/cross_zone_state_machine.rs b/integration_tests/tests/cross_zone_state_machine.rs index 4599635f9..7cb98a3ec 100644 --- a/integration_tests/tests/cross_zone_state_machine.rs +++ b/integration_tests/tests/cross_zone_state_machine.rs @@ -26,6 +26,15 @@ use ping_core::{ sender_config_account_id, }; +/// Serializes an instruction to the risc0 word form the guests read. A macro +/// because the serde trait a generic fn would have to name is not a dependency +/// of this crate. +macro_rules! words_of { + ($instruction:expr) => { + risc0_zkvm::serde::to_vec($instruction).expect("serialize instruction") + }; +} + const INITIAL_BALANCE: u128 = 100; const LOCK_AMOUNT: u128 = 30; const RECIPIENT: [u8; 32] = [9; 32]; @@ -68,10 +77,21 @@ fn seed_wrapped_config( state: &mut V03State, authority: Option, sources: Vec<([u8; 32], lee_core::program::ProgramId)>, +) { + seed_wrapped_config_with_governance(state, None, authority, sources); +} + +/// The same, naming a program allowed to act for the authority through a chain. +fn seed_wrapped_config_with_governance( + state: &mut V03State, + governance: Option, + authority: Option, + sources: Vec<([u8; 32], lee_core::program::ProgramId)>, ) { let wrapped_token_id = programs::wrapped_token().id(); let config = wrapped_token_core::WrappedTokenConfig { minter: programs::cross_zone_inbox().id(), + governance, authority, sources, }; @@ -94,10 +114,21 @@ fn seed_receiver_config( state: &mut V03State, authority: Option, sources: Vec<([u8; 32], lee_core::program::ProgramId)>, +) { + seed_receiver_config_with_governance(state, None, authority, sources); +} + +/// The same, naming a program allowed to act for the authority through a chain. +fn seed_receiver_config_with_governance( + state: &mut V03State, + governance: Option, + authority: Option, + sources: Vec<([u8; 32], lee_core::program::ProgramId)>, ) { let receiver_id = programs::ping_receiver().id(); let config = ping_core::ReceiverConfig { deliverer: programs::cross_zone_inbox().id(), + governance, authority, sources, }; @@ -167,6 +198,81 @@ fn dispatch_accounts( ids } +/// Asserts the transaction fails at `block` with an error mentioning `expected`, +/// so a refusal for an unrelated reason cannot keep a guard test green. +fn rejects_at(state: &V03State, tx: &PublicTransaction, block: u64, expected: &str) { + let Err(err) = ValidatedStateDiff::from_public_transaction(tx, state, block, 0) else { + panic!("expected a rejection mentioning {expected}"); + }; + assert!( + format!("{err:?}").contains(expected), + "rejected for the wrong reason: {err:?}" + ); +} + +/// A top-level authority transaction: the instruction words over `accounts`, +/// signed by `key` at `nonce`. +fn signed_tx( + program: lee_core::program::ProgramId, + accounts: Vec, + nonce: u128, + words: Vec, + key: &PrivateKey, +) -> PublicTransaction { + let message = Message::new_preserialized(program, accounts, vec![nonce.into()], words); + let witness = WitnessSet::for_message(&message, &[key]); + PublicTransaction::new(message, witness) +} + +/// An unsigned call through the governance proxy, delegating `delegated` (or +/// nothing) on the chained call into `target`. +fn via_proxy( + proxy_id: lee_core::program::ProgramId, + target: lee_core::program::ProgramId, + config: AccountId, + authority: AccountId, + delegated: Option, + words: Vec, +) -> PublicTransaction { + let message = Message::try_new( + proxy_id, + vec![config, authority], + vec![], + (target, words, delegated), + ) + .expect("build proxy message"); + PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])) +} + +/// An authority instruction delivered through the inbox, as a peer would have to +/// send it: the dispatch shape over the target's config and authority accounts. +fn chained_via_inbox( + target: lee_core::program::ProgramId, + config_id: AccountId, + authority: AccountId, + words: Vec, +) -> PublicTransaction { + let inbox_id = programs::cross_zone_inbox().id(); + let msg = CrossZoneMessage { + src_zone: [2; 32], + 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.into_iter().flat_map(u32::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"); + PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])) +} + /// 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, target_zone: [u8; 32], ordinal: u32) -> PublicTransaction { @@ -955,46 +1061,11 @@ fn the_outbox_pin_is_written_once_and_replayable() { ); } -/// Seeded unset, so the sources a zone starts with are the sources it keeps until -/// a governance program exists to hold the authority. +/// The token's authority path, end to end: each guard refuses for its own +/// reason, the signed path works more than once through the claimed account, and +/// renouncing is one-way. The receiver battery mirrors this one. #[test] -fn updating_sources_is_refused_when_no_authority_is_configured() { - let wrapped_token_id = programs::wrapped_token().id(); - let src_zone = [2_u8; 32]; - - let mut state = base_state(); - seed_wrapped_config(&mut state, None, vec![]); - - let key = PrivateKey::try_new([7; 32]).expect("valid key"); - let signer = AccountId::from(&PublicKey::new_from_private_key(&key)); - let message = Message::try_new( - wrapped_token_id, - vec![ - wrapped_token_core::config_account_id(wrapped_token_id), - signer, - ], - vec![0_u128.into()], - wrapped_token_core::Instruction::UpdateSources { - sources: vec![(src_zone, programs::bridge_lock().id())], - }, - ) - .expect("build update message"); - let tx = PublicTransaction::new(message.clone(), WitnessSet::for_message(&message, &[&key])); - - let Err(err) = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) else { - panic!("a source change with no authority configured must not execute"); - }; - assert!( - format!("{err:?}").contains("fixed at genesis"), - "rejected for the wrong reason: {err:?}" - ); -} - -/// With an authority set, only that account may change the sources, and only by -/// authorizing the transaction. Anyone able to do this can authorize a mint, so -/// both halves are load-bearing. -#[test] -fn only_the_configured_authority_changes_sources() { +fn the_token_authority_path_holds() { let wrapped_token_id = programs::wrapped_token().id(); let config_id = wrapped_token_core::config_account_id(wrapped_token_id); let src_zone = [2_u8; 32]; @@ -1004,155 +1075,177 @@ fn only_the_configured_authority_changes_sources() { let other_key = PrivateKey::try_new([8; 32]).expect("valid key"); let other = AccountId::from(&PublicKey::new_from_private_key(&other_key)); - let mut state = base_state(); - seed_wrapped_config(&mut state, Some(authority), vec![]); - - let update = |account: AccountId, signer: &PrivateKey, nonce: u128| { - let message = Message::try_new( + let update = |account: AccountId, + signer: &PrivateKey, + nonce: u128, + sources: Vec<([u8; 32], lee_core::program::ProgramId)>| { + signed_tx( wrapped_token_id, vec![config_id, account], - vec![nonce.into()], - wrapped_token_core::Instruction::UpdateSources { - sources: vec![(src_zone, programs::bridge_lock().id())], - }, + nonce, + words_of!(&wrapped_token_core::Instruction::UpdateSources { sources }), + signer, ) - .expect("build update message"); - let witness = WitnessSet::for_message(&message, &[signer]); - PublicTransaction::new(message, witness) }; + let renounce = |account: AccountId, signer: &PrivateKey, nonce: u128| { + signed_tx( + wrapped_token_id, + vec![config_id, account], + nonce, + words_of!(&wrapped_token_core::Instruction::RenounceAuthority), + signer, + ) + }; + let bridge_source = vec![(src_zone, programs::bridge_lock().id())]; - // Another account, correctly signed by itself, is still not the authority. - let Err(err) = - ValidatedStateDiff::from_public_transaction(&update(other, &other_key, 0), &state, 1, 0) - else { - panic!("an account that is not the authority must not change sources"); - }; - assert!( - format!("{err:?}").contains("must be the configured authority"), - "rejected for the wrong reason: {err:?}" + // With no authority configured, nothing moves in either direction. + let mut unset = base_state(); + seed_wrapped_config(&mut unset, None, vec![]); + rejects_at( + &unset, + &update(authority, &key, 0, bridge_source.clone()), + 1, + "fixed at genesis", + ); + rejects_at( + &unset, + &renounce(authority, &key, 0), + 1, + "already renounced", ); - let diff = - ValidatedStateDiff::from_public_transaction(&update(authority, &key, 0), &state, 1, 0) - .expect("the configured authority changes sources"); + // With one configured: the wrong account, and the right account without its + // own signature, are refused for their own reasons. + let mut state = base_state(); + seed_wrapped_config(&mut state, Some(authority), vec![]); + rejects_at( + &state, + &update(other, &other_key, 0, bridge_source.clone()), + 1, + "second account must be the configured authority", + ); + rejects_at( + &state, + &renounce(other, &other_key, 0), + 1, + "second account must be the configured authority", + ); + rejects_at( + &state, + &update(authority, &other_key, 0, bridge_source.clone()), + 1, + "must authorize a source change", + ); + rejects_at( + &state, + &renounce(authority, &other_key, 0), + 1, + "must authorize renouncing it", + ); + + // Substituting another account for the config is refused rather than read, + // on both instructions. + let substituted = |words: Vec| { + signed_tx( + wrapped_token_id, + vec![ping_record_pda(wrapped_token_id), authority], + 0, + words, + &key, + ) + }; + rejects_at( + &state, + &substituted(words_of!(&wrapped_token_core::Instruction::UpdateSources { + sources: bridge_source.clone(), + })), + 1, + "must be the wrapped-token config PDA", + ); + rejects_at( + &state, + &substituted(words_of!( + &wrapped_token_core::Instruction::RenounceAuthority + )), + 1, + "must be the wrapped-token config PDA", + ); + + // The authority itself works, and more than once: the first use claims the + // account for the target, and the second runs on the claimed path. + let diff = ValidatedStateDiff::from_public_transaction( + &update(authority, &key, 0, bridge_source.clone()), + &state, + 1, + 0, + ) + .expect("the configured authority changes sources"); state.apply_state_diff(diff); let cfg = wrapped_token_core::WrappedTokenConfig::from_bytes( &state.get_account_by_id(config_id).data.into_inner(), ) - .expect("config still decodes"); + .expect("config decodes"); + assert_eq!(cfg.sources, bridge_source, "the new source is authorized"); assert_eq!( - cfg.sources, - vec![(src_zone, programs::bridge_lock().id())], - "the new source is authorized" + state.get_account_by_id(authority).program_owner, + wrapped_token_id, + "the first use claims the authority account for the target" ); - assert_eq!(cfg.authority, Some(authority), "the authority is unchanged"); + + let sender_source = vec![(src_zone, programs::ping_sender().id())]; + let second = ValidatedStateDiff::from_public_transaction( + &update(authority, &key, 1, sender_source.clone()), + &state, + 2, + 0, + ) + .expect("the authority acts again"); + state.apply_state_diff(second); + let updated_cfg = wrapped_token_core::WrappedTokenConfig::from_bytes( + &state.get_account_by_id(config_id).data.into_inner(), + ) + .expect("config decodes"); assert_eq!( - cfg.minter, + updated_cfg.sources, sender_source, + "the second change took effect" + ); + assert_eq!( + updated_cfg.authority, + Some(authority), + "the authority is unchanged" + ); + + // Renouncing is one-way: the sources freeze at their last value and nothing + // moves afterwards, in either direction. + let renounced = + ValidatedStateDiff::from_public_transaction(&renounce(authority, &key, 2), &state, 3, 0) + .expect("the authority renounces itself"); + state.apply_state_diff(renounced); + let renounced_cfg = wrapped_token_core::WrappedTokenConfig::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, sender_source, + "renouncing leaves the sources it froze" + ); + assert_eq!( + renounced_cfg.minter, programs::cross_zone_inbox().id(), "the minter is unchanged" ); -} - -/// Naming the authority is not the same as the authority consenting. Signers come -/// from the witness set, not the account list, so without the `is_authorized` -/// check anyone could list the authority's account and sign with their own key. -#[test] -fn naming_the_authority_without_its_signature_changes_nothing() { - let wrapped_token_id = programs::wrapped_token().id(); - let src_zone = [2_u8; 32]; - - let authority_key = PrivateKey::try_new([7; 32]).expect("valid key"); - let authority = AccountId::from(&PublicKey::new_from_private_key(&authority_key)); - let attacker_key = PrivateKey::try_new([8; 32]).expect("valid key"); - - let mut state = base_state(); - seed_wrapped_config(&mut state, Some(authority), vec![]); - - // The authority's account is named and the attacker signs. Signers come from - // the witness set, so the attacker never appears in the account list. - let message = Message::try_new( - wrapped_token_id, - vec![ - wrapped_token_core::config_account_id(wrapped_token_id), - authority, - ], - vec![0_u128.into()], - wrapped_token_core::Instruction::UpdateSources { - sources: vec![(src_zone, programs::bridge_lock().id())], - }, - ) - .expect("build update message"); - let tx = PublicTransaction::new( - message.clone(), - WitnessSet::for_message(&message, &[&attacker_key]), + rejects_at( + &state, + &update(authority, &key, 3, bridge_source), + 4, + "fixed at genesis", ); - - let Err(err) = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) else { - panic!("naming the authority without its signature must not change sources"); - }; - assert!( - format!("{err:?}").contains("must authorize a source change"), - "rejected for the wrong reason: {err:?}" - ); -} - -/// Reachable only top-level. Authorization is unioned down a call chain, so a -/// program the authority signed for could otherwise rewrite the list itself. -#[test] -fn a_chained_update_sources_is_refused() { - let wrapped_token_id = programs::wrapped_token().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 mut state = base_state(); - seed_inbox_config(&mut state, self_zone); - seed_wrapped_config(&mut state, Some(authority), vec![]); - - // Delivered through the inbox, which is the one program that chain-calls a - // target, carrying an UpdateSources payload instead of a Mint. - let words = risc0_zkvm::serde::to_vec(&wrapped_token_core::Instruction::UpdateSources { - sources: vec![(src_zone, programs::bridge_lock().id())], - }) - .expect("serialize update"); - 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: wrapped_token_id, - 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![ - wrapped_token_core::config_account_id(wrapped_token_id), - authority, - ], - ), - vec![], - InboxInstruction::Dispatch(msg), - ) - .expect("build dispatch message"); - let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); - - // Matched on the pin's own message: the caller check runs before the account - // destructure, so an arity failure would be a different error and this test - // would be proving nothing. - let Err(err) = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) else { - panic!("a chained UpdateSources must not execute"); - }; - assert!( - format!("{err:?}").contains("only invoked as a top-level transaction"), - "rejected for the wrong reason: {err:?}" + rejects_at( + &state, + &renounce(authority, &key, 3), + 4, + "already renounced", ); } @@ -1270,124 +1363,6 @@ fn the_inbox_refuses_a_marker_that_does_not_match_the_message() { ); } -/// Renounce is the only move on the authority, and it is one-way. A leaked key -/// that could reassign would lock the real holder out; this way the worst either -/// party achieves is freezing the list, which is the no-authority default. -#[test] -fn the_authority_can_renounce_itself_and_only_itself() { - let wrapped_token_id = programs::wrapped_token().id(); - let config_id = wrapped_token_core::config_account_id(wrapped_token_id); - 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 other_key = PrivateKey::try_new([8; 32]).expect("valid key"); - let other = AccountId::from(&PublicKey::new_from_private_key(&other_key)); - - let mut state = base_state(); - seed_wrapped_config( - &mut state, - Some(authority), - vec![(src_zone, programs::bridge_lock().id())], - ); - - let renounce = |account: AccountId, signer: &PrivateKey| { - let message = Message::try_new( - wrapped_token_id, - vec![config_id, account], - vec![0_u128.into()], - wrapped_token_core::Instruction::RenounceAuthority, - ) - .expect("build renounce message"); - let witness = WitnessSet::for_message(&message, &[signer]); - PublicTransaction::new(message, witness) - }; - - let Err(err) = - ValidatedStateDiff::from_public_transaction(&renounce(other, &other_key), &state, 1, 0) - else { - panic!("an account that is not the authority must not renounce it"); - }; - assert!( - format!("{err:?}").contains("must be the configured authority"), - "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"); - state.apply_state_diff(diff); - - let cfg = wrapped_token_core::WrappedTokenConfig::from_bytes( - &state.get_account_by_id(config_id).data.into_inner(), - ) - .expect("config still decodes"); - assert_eq!(cfg.authority, None, "the authority is gone"); - assert_eq!( - cfg.sources, - vec![(src_zone, programs::bridge_lock().id())], - "renouncing leaves the sources it froze" - ); - assert_eq!( - cfg.minter, - programs::cross_zone_inbox().id(), - "the minter is unchanged" - ); - - // And nothing can move it afterwards, in either direction. - let update = Message::try_new( - wrapped_token_id, - vec![config_id, authority], - vec![1_u128.into()], - wrapped_token_core::Instruction::UpdateSources { sources: vec![] }, - ) - .expect("build update message"); - 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!( - format!("{frozen:?}").contains("fixed at genesis"), - "rejected for the wrong reason: {frozen:?}" - ); -} - /// 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] @@ -1403,67 +1378,52 @@ fn the_receiver_authority_path_holds() { let other = AccountId::from(&PublicKey::new_from_private_key(&other_key)); let update = |account: AccountId, signer: &PrivateKey, nonce: u128| { - let message = Message::try_new( + signed_tx( receiver_id, vec![config_id, account], - vec![nonce.into()], - ping_core::ReceiverInstruction::UpdateSources { + nonce, + words_of!(&ping_core::ReceiverInstruction::UpdateSources { sources: vec![(src_zone, sender_id)], - }, + }), + signer, ) - .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( + signed_tx( receiver_id, vec![config_id, account], - vec![nonce.into()], - ping_core::ReceiverInstruction::RenounceAuthority, + nonce, + words_of!(&ping_core::ReceiverInstruction::RenounceAuthority), + signer, ) - .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. + // 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( + rejects_at( &state, &update(other, &other_key, 0), + 1, "must be the configured authority", ); - rejects( + rejects_at( &state, &renounce(other, &other_key, 0), + 1, "must be the configured authority", ); - rejects( + rejects_at( &state, &update(authority, &other_key, 0), + 1, "must authorize a source change", ); - rejects( + rejects_at( &state, &renounce(authority, &other_key, 0), + 1, "must authorize renouncing it", ); @@ -1493,146 +1453,517 @@ fn the_receiver_authority_path_holds() { 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"); + rejects_at(&state, &update(authority, &key, 2), 3, "fixed at genesis"); + rejects_at( + &state, + &renounce(authority, &key, 2), + 3, + "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. +/// The inbox cannot reach the authority instructions, named as governance or not: +/// it prepends the source marker to every chained call, so the config never lands +/// where these instructions read it. Worth pinning, because the inbox is the only +/// program that chain-calls a target today, so this is what actually keeps a peer +/// away from the source list. #[test] -fn the_remaining_authority_guards_hold() { +fn the_inbox_cannot_reach_the_authority_instructions() { let wrapped_token_id = programs::wrapped_token().id(); - let receiver_id = programs::ping_receiver().id(); let inbox_id = programs::cross_zone_inbox().id(); + let config_id = wrapped_token_core::config_account_id(wrapped_token_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 update = || { + chained_via_inbox( + wrapped_token_id, + config_id, + authority, + words_of!(&wrapped_token_core::Instruction::UpdateSources { + sources: vec![(src_zone, programs::bridge_lock().id())], + }), + ) + }; + + // No governance named: the chained call is refused. + let mut closed = base_state(); + seed_inbox_config(&mut closed, self_zone); + seed_wrapped_config(&mut closed, Some(authority), vec![]); + rejects_at( + &closed, + &update(), + 1, + "must be the wrapped-token config PDA", + ); + + // Naming the inbox as governance changes nothing: the obstacle is structural, + // not the caller check. The prepended marker sits at index 0, so with or + // without the inbox named as governance the call dies on the config-address + // check, before the caller check is even reached. + let mut open = base_state(); + seed_inbox_config(&mut open, self_zone); + seed_wrapped_config_with_governance(&mut open, Some(inbox_id), Some(authority), vec![]); + rejects_at(&open, &update(), 1, "must be the wrapped-token config PDA"); +} + +/// A program-held authority acts through the governance program delegating its +/// PDA on the chained call: the first use has the target claim the account, the +/// second runs on the claimed path, and renouncing through it is as total as +/// renouncing top-level. +#[test] +fn the_governance_path_holds() { + let wrapped_token_id = programs::wrapped_token().id(); + let proxy_id = test_programs::authority_proxy().id(); + let config_id = wrapped_token_core::config_account_id(wrapped_token_id); + let src_zone = [2_u8; 32]; + + let seed = lee_core::program::PdaSeed::new([3; 32]); + let authority = AccountId::for_public_pda(&proxy_id, &seed); + + let mut state = base_state().with_programs([test_programs::authority_proxy()]); + seed_wrapped_config_with_governance(&mut state, Some(proxy_id), Some(authority), vec![]); + + let update = |sources: Vec<([u8; 32], lee_core::program::ProgramId)>| { + via_proxy( + proxy_id, + wrapped_token_id, + config_id, + authority, + Some(seed), + words_of!(&wrapped_token_core::Instruction::UpdateSources { sources }), + ) + }; + let renounce = || { + via_proxy( + proxy_id, + wrapped_token_id, + config_id, + authority, + Some(seed), + words_of!(&wrapped_token_core::Instruction::RenounceAuthority), + ) + }; + + let first = ValidatedStateDiff::from_public_transaction( + &update(vec![(src_zone, programs::bridge_lock().id())]), + &state, + 1, + 0, + ) + .expect("the governance path changes sources"); + state.apply_state_diff(first); + + let cfg = wrapped_token_core::WrappedTokenConfig::from_bytes( + &state.get_account_by_id(config_id).data.into_inner(), + ) + .expect("config decodes"); + assert_eq!(cfg.sources, vec![(src_zone, programs::bridge_lock().id())]); + assert_eq!( + state.get_account_by_id(authority).program_owner, + wrapped_token_id, + "the first use claims the delegated PDA for the target" + ); + + let second = ValidatedStateDiff::from_public_transaction(&update(vec![]), &state, 2, 0) + .expect("the governance path acts again"); + state.apply_state_diff(second); + let cleared_cfg = wrapped_token_core::WrappedTokenConfig::from_bytes( + &state.get_account_by_id(config_id).data.into_inner(), + ) + .expect("config decodes"); + assert!( + cleared_cfg.sources.is_empty(), + "the second change took effect" + ); + assert_eq!(cleared_cfg.authority, Some(authority)); + + let renounced = ValidatedStateDiff::from_public_transaction(&renounce(), &state, 3, 0) + .expect("the governance path renounces"); + state.apply_state_diff(renounced); + let renounced_cfg = wrapped_token_core::WrappedTokenConfig::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"); + + rejects_at( + &state, + &update(vec![(src_zone, programs::bridge_lock().id())]), + 4, + "fixed at genesis", + ); + rejects_at(&state, &renounce(), 4, "already renounced"); +} + +/// Each governance-path guard fails on its own: a caller other than the +/// configured governance program is refused with the delegation in order, no +/// configured governance refuses every chained caller (on the token's update +/// and on its three sibling handlers), and the governance program without +/// delegating finds the authority unauthorized. +#[test] +fn the_governance_path_guards_hold() { + let wrapped_token_id = programs::wrapped_token().id(); + let receiver_id = programs::ping_receiver().id(); + let proxy_id = test_programs::authority_proxy().id(); + let config_id = wrapped_token_core::config_account_id(wrapped_token_id); + let src_zone = [2_u8; 32]; + + let seed = lee_core::program::PdaSeed::new([3; 32]); + let authority = AccountId::for_public_pda(&proxy_id, &seed); + + let call = |delegated: Option| { + via_proxy( + proxy_id, + wrapped_token_id, + config_id, + authority, + delegated, + words_of!(&wrapped_token_core::Instruction::UpdateSources { + sources: vec![(src_zone, programs::bridge_lock().id())], + }), + ) + }; + + // A perfect call shape from a program that is not the configured governance. + let mut other = base_state().with_programs([test_programs::authority_proxy()]); + seed_wrapped_config_with_governance( + &mut other, + Some(programs::ping_sender().id()), + Some(authority), + vec![], + ); + rejects_at( + &other, + &call(Some(seed)), + 1, + "through the configured governance program", + ); + + // No governance configured: every chained caller is refused. + let mut closed = base_state().with_programs([test_programs::authority_proxy()]); + seed_wrapped_config(&mut closed, Some(authority), vec![]); + seed_receiver_config(&mut closed, Some(authority), vec![]); + rejects_at( + &closed, + &call(Some(seed)), + 1, + "through the configured governance program", + ); + + // The same pin guards the three sibling handlers, both renounces and the + // receiver's update, each of which would otherwise accept the delegated + // authority and succeed. + for (target, config, words) in [ + ( + wrapped_token_id, + config_id, + words_of!(&wrapped_token_core::Instruction::RenounceAuthority), + ), + ( + receiver_id, + receiver_config_account_id(receiver_id), + words_of!(&ping_core::ReceiverInstruction::UpdateSources { + sources: vec![(src_zone, programs::ping_sender().id())], + }), + ), + ( + receiver_id, + receiver_config_account_id(receiver_id), + words_of!(&ping_core::ReceiverInstruction::RenounceAuthority), + ), + ] { + rejects_at( + &closed, + &via_proxy(proxy_id, target, config, authority, Some(seed), words), + 1, + "through the configured governance program", ); - }; - let signed = |program: lee_core::program::ProgramId, - accounts: Vec, - instruction_words: Vec| { - let message = - Message::new_preserialized(program, accounts, vec![0_u128.into()], instruction_words); - let witness = WitnessSet::for_message(&message, &[&key]); - PublicTransaction::new(message, witness) - }; + } + + // The configured governance itself, but not delegating the authority. + let mut undelegated = base_state().with_programs([test_programs::authority_proxy()]); + seed_wrapped_config_with_governance(&mut undelegated, Some(proxy_id), Some(authority), vec![]); + rejects_at( + &undelegated, + &call(None), + 1, + "must authorize a source change", + ); +} + +/// The receiver's governance path works the same way; without this its config +/// never carries a governance in any test. +#[test] +fn the_receiver_governance_path_holds() { + let receiver_id = programs::ping_receiver().id(); + let proxy_id = test_programs::authority_proxy().id(); + let config_id = receiver_config_account_id(receiver_id); + let src_zone = [2_u8; 32]; + + let seed = lee_core::program::PdaSeed::new([3; 32]); + let authority = AccountId::for_public_pda(&proxy_id, &seed); + + let mut state = base_state().with_programs([test_programs::authority_proxy()]); + seed_receiver_config_with_governance(&mut state, Some(proxy_id), Some(authority), vec![]); + + let tx = via_proxy( + proxy_id, + receiver_id, + config_id, + authority, + Some(seed), + words_of!(&ping_core::ReceiverInstruction::UpdateSources { + sources: vec![(src_zone, programs::ping_sender().id())], + }), + ); + + let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) + .expect("the receiver governance path 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, programs::ping_sender().id())]); + assert_eq!( + state.get_account_by_id(authority).program_owner, + receiver_id, + "the first use claims the delegated PDA for the receiver" + ); +} + +/// One authority seeds both targets at genesis, and the config doc promises that +/// whichever target is used first owns the account while the other keeps +/// working. Claim through the token, then act and renounce on the receiver. +#[test] +fn a_shared_authority_survives_the_first_claim() { + let wrapped_token_id = programs::wrapped_token().id(); + let receiver_id = programs::ping_receiver().id(); + let proxy_id = test_programs::authority_proxy().id(); + let token_config_id = wrapped_token_core::config_account_id(wrapped_token_id); + let receiver_config_id = receiver_config_account_id(receiver_id); + let src_zone = [2_u8; 32]; + + let seed = lee_core::program::PdaSeed::new([3; 32]); + let authority = AccountId::for_public_pda(&proxy_id, &seed); + + let mut state = base_state().with_programs([test_programs::authority_proxy()]); + seed_wrapped_config_with_governance(&mut state, Some(proxy_id), Some(authority), vec![]); + seed_receiver_config_with_governance(&mut state, Some(proxy_id), Some(authority), vec![]); + + let token_update = via_proxy( + proxy_id, + wrapped_token_id, + token_config_id, + authority, + Some(seed), + words_of!(&wrapped_token_core::Instruction::UpdateSources { + sources: vec![(src_zone, programs::bridge_lock().id())], + }), + ); + let first = ValidatedStateDiff::from_public_transaction(&token_update, &state, 1, 0) + .expect("the token claims the shared authority"); + state.apply_state_diff(first); + assert_eq!( + state.get_account_by_id(authority).program_owner, + wrapped_token_id, + "the first target to be used owns the account" + ); + + let receiver_update = via_proxy( + proxy_id, + receiver_id, + receiver_config_id, + authority, + Some(seed), + words_of!(&ping_core::ReceiverInstruction::UpdateSources { + sources: vec![(src_zone, programs::ping_sender().id())], + }), + ); + let second = ValidatedStateDiff::from_public_transaction(&receiver_update, &state, 2, 0) + .expect("the other target still acts on the token-owned authority"); + state.apply_state_diff(second); + let receiver_cfg = ping_core::ReceiverConfig::from_bytes( + &state + .get_account_by_id(receiver_config_id) + .data + .into_inner(), + ) + .expect("config decodes"); + assert_eq!( + receiver_cfg.sources, + vec![(src_zone, programs::ping_sender().id())] + ); + assert_eq!( + state.get_account_by_id(authority).program_owner, + wrapped_token_id, + "the receiver never takes the account over" + ); + + let receiver_renounce = via_proxy( + proxy_id, + receiver_id, + receiver_config_id, + authority, + Some(seed), + words_of!(&ping_core::ReceiverInstruction::RenounceAuthority), + ); + let third = ValidatedStateDiff::from_public_transaction(&receiver_renounce, &state, 3, 0) + .expect("the other target renounces on the token-owned authority"); + state.apply_state_diff(third); + let renounced_cfg = ping_core::ReceiverConfig::from_bytes( + &state + .get_account_by_id(receiver_config_id) + .data + .into_inner(), + ) + .expect("config decodes"); + assert_eq!(renounced_cfg.authority, None, "the receiver side is gone"); + let token_cfg = wrapped_token_core::WrappedTokenConfig::from_bytes( + &state.get_account_by_id(token_config_id).data.into_inner(), + ) + .expect("config decodes"); + assert_eq!( + token_cfg.authority, + Some(authority), + "renouncing one target leaves the other's grant alone" + ); +} + +/// An authority account with any history can never be claimed, so all four +/// authority handlers refuse it, and say why rather than surfacing a rule number. +#[test] +fn an_authority_account_with_history_is_refused() { + let wrapped_token_id = programs::wrapped_token().id(); + let receiver_id = programs::ping_receiver().id(); + 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 mut state = base_state(); + seed_wrapped_config(&mut state, Some(authority), vec![]); + seed_receiver_config(&mut state, Some(authority), vec![]); + // Unowned but already used: exactly what one prior signature leaves behind. + state = state.with_public_accounts([( + authority, + Account { + nonce: 1_u128.into(), + ..Default::default() + }, + )]); + + for (program, config_id, words) in [ + ( + wrapped_token_id, + wrapped_token_core::config_account_id(wrapped_token_id), + words_of!(&wrapped_token_core::Instruction::UpdateSources { + sources: vec![(src_zone, programs::bridge_lock().id())], + }), + ), + ( + wrapped_token_id, + wrapped_token_core::config_account_id(wrapped_token_id), + words_of!(&wrapped_token_core::Instruction::RenounceAuthority), + ), + ( + receiver_id, + receiver_config_account_id(receiver_id), + words_of!(&ping_core::ReceiverInstruction::UpdateSources { + sources: vec![(src_zone, programs::ping_sender().id())], + }), + ), + ( + receiver_id, + receiver_config_account_id(receiver_id), + words_of!(&ping_core::ReceiverInstruction::RenounceAuthority), + ), + ] { + rejects_at( + &state, + &signed_tx(program, vec![config_id, authority], 1, words, &key), + 1, + "must be untouched before its first use", + ); + } +} + +/// The guards that survive a deletion otherwise: the receiver's config-address +/// checks its substitution cases miss, and the three caller pins that are only +/// reachable through the inbox. +#[test] +fn the_remaining_authority_guards_hold() { + let wrapped_token_id = programs::wrapped_token().id(); + let receiver_id = programs::ping_receiver().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 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", - ), + // Config address, on both receiver instructions. + for words in [ + words_of!(&ping_core::ReceiverInstruction::UpdateSources { + sources: vec![(src_zone, programs::ping_sender().id())], + }), + words_of!(&ping_core::ReceiverInstruction::RenounceAuthority), ] { - rejects( + rejects_at( &state, - &signed( + &signed_tx( receiver_id, vec![ping_record_pda(receiver_id), authority], + 0, words, + &key, ), - expected, + 1, + "must be the receiver config PDA", ); } - // Reached through the inbox rather than top-level, for the three caller pins - // that had no chained test. - for (target, config_id, words) in [ + // Reached through the inbox rather than top-level: the prepended marker sits + // at index 0, so each call dies on the target's config-address check. The + // caller pins themselves are exercised through the proxy in + // the_governance_path_guards_hold, where the account list is well formed. + for (target, config_id, words, expected) 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"), + words_of!(&wrapped_token_core::Instruction::RenounceAuthority), + "must be the wrapped-token config PDA", ), ( receiver_id, receiver_config_account_id(receiver_id), - risc0_zkvm::serde::to_vec(&ping_core::ReceiverInstruction::RenounceAuthority) - .expect("serialize"), + words_of!(&ping_core::ReceiverInstruction::RenounceAuthority), + "must be the receiver config PDA", ), ( receiver_id, receiver_config_account_id(receiver_id), - risc0_zkvm::serde::to_vec(&ping_core::ReceiverInstruction::UpdateSources { + words_of!(&ping_core::ReceiverInstruction::UpdateSources { sources: vec![(src_zone, programs::ping_sender().id())], - }) - .expect("serialize"), + }), + "must be the receiver config PDA", ), ] { - 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"); + rejects_at( + &state, + &chained_via_inbox(target, config_id, authority, words), + 1, + expected, + ); } - - // 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 diff --git a/integration_tests/tests/cross_zone_verified.rs b/integration_tests/tests/cross_zone_verified.rs index bc3d16306..6e84694dd 100644 --- a/integration_tests/tests/cross_zone_verified.rs +++ b/integration_tests/tests/cross_zone_verified.rs @@ -56,6 +56,7 @@ async fn indexer_verifies_and_delivers_cross_zone_ping() -> Result<()> { expected_block_signing_pubkey: None, }], source_authority: None, + source_governance: None, }; // Zone A: source. Zone B: destination, with the watcher on its sequencer and diff --git a/integration_tests/tests/cross_zone_watcher_restart.rs b/integration_tests/tests/cross_zone_watcher_restart.rs index 0dfbd1e3e..43a0051fa 100644 --- a/integration_tests/tests/cross_zone_watcher_restart.rs +++ b/integration_tests/tests/cross_zone_watcher_restart.rs @@ -65,6 +65,7 @@ async fn restarted_watcher_resumes_instead_of_replaying_the_peer_channel() -> Re expected_block_signing_pubkey: None, }], source_authority: None, + source_governance: None, }; let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr) diff --git a/lez/cross_zone/src/lib.rs b/lez/cross_zone/src/lib.rs index 90a9ac031..dcd5dc006 100644 --- a/lez/cross_zone/src/lib.rs +++ b/lez/cross_zone/src/lib.rs @@ -263,6 +263,7 @@ pub fn build_wrapped_token_init_config_tx( vec![wrapped_token_core::config_account_id(wrapped_token_id)], wrapped_token_core::Instruction::InitConfig(wrapped_token_core::WrappedTokenConfig { minter: programs::cross_zone_inbox().id(), + governance: cross_zone.and_then(|cross_zone| cross_zone.source_governance), authority: cross_zone.and_then(|cross_zone| cross_zone.source_authority), sources, }), @@ -312,6 +313,7 @@ pub fn build_ping_receiver_init_config_tx( vec![ping_core::receiver_config_account_id(receiver_id)], ping_core::ReceiverInstruction::InitConfig(ping_core::ReceiverConfig { deliverer: programs::cross_zone_inbox().id(), + governance: cross_zone.and_then(|cross_zone| cross_zone.source_governance), authority: cross_zone.and_then(|cross_zone| cross_zone.source_authority), sources, }), @@ -355,6 +357,7 @@ mod tests { expected_block_signing_pubkey: None, }], source_authority: None, + source_governance: None, }; let _tx = build_wrapped_token_init_config_tx(Some(&cross_zone)); } diff --git a/lez/programs/cross_zone_inbox/core/src/lib.rs b/lez/programs/cross_zone_inbox/core/src/lib.rs index 6857fd184..74ab168b4 100644 --- a/lez/programs/cross_zone_inbox/core/src/lib.rs +++ b/lez/programs/cross_zone_inbox/core/src/lib.rs @@ -64,31 +64,28 @@ pub struct CrossZonePeer { /// operator intends each target to accept. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CrossZoneConfig { + /// Read once at startup by the watchers and the verifier, so adding a peer + /// zone needs a config change and a restart on both sequencer and indexer. pub peers: Vec, /// Account allowed to change which peer sources each target program accepts, /// seeded into every target's own config at genesis. /// - /// Unset by default, which leaves those lists fixed at genesis. Setting it - /// lets a source be authorized later without a reset. Adding a peer zone is - /// more than that: the watchers read the peer list once at startup, so it also - /// 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. + /// Unset by default, which leaves those lists fixed at genesis. It can only + /// ever be set at genesis and there is no rotation. One value seeds every + /// target, including the ones that mint, and whoever holds it can authorize + /// a source, so its compromise is theft rather than delay. /// - /// 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 - /// them, including the ones that mint. There is no rotation: changing it means - /// a new genesis. An `AccountId` rather than a key so a PDA of a governance - /// program can hold it later without changing this format. + /// Must be a fresh, never-used account: the first use has the target claim + /// it, renouncing seizes it the same way, whichever target acts first owns + /// it, and anything sent to it is frozen for good. An `AccountId` rather + /// than a key so a governance program's PDA can hold it later. #[serde(default)] pub source_authority: Option, + /// Program allowed to act on the source authority's behalf through a chained + /// call, seeded into every target's config at genesis. Needed only for a PDA + /// authority, which cannot sign; unset means the authority acts at top level. + #[serde(default)] + pub source_governance: Option, } /// A finalized outbound message observed on a peer zone, addressed to a program diff --git a/lez/programs/ping_core/src/lib.rs b/lez/programs/ping_core/src/lib.rs index 527959b03..b83665766 100644 --- a/lez/programs/ping_core/src/lib.rs +++ b/lez/programs/ping_core/src/lib.rs @@ -55,6 +55,9 @@ pub enum ReceiverInstruction { pub struct ReceiverConfig { /// The program allowed to call `Record`: the cross-zone inbox. pub deliverer: ProgramId, + /// The program allowed to reach the authority instructions through a chained + /// call, or `None` for top-level only. See `WrappedTokenConfig::governance`. + pub governance: Option, /// The account allowed to change `sources`, or `None` for a list fixed at /// genesis. Seeded unset; see `WrappedTokenConfig::authority` for why. pub authority: Option, @@ -195,6 +198,7 @@ mod tests { fn receiver_config_round_trips() { let config = ReceiverConfig { deliverer: [1; 8], + governance: None, authority: None, sources: vec![([7; 32], [9; 8])], }; diff --git a/lez/programs/ping_receiver/src/main.rs b/lez/programs/ping_receiver/src/main.rs index 0a7e2ff3d..19d804e54 100644 --- a/lez/programs/ping_receiver/src/main.rs +++ b/lez/programs/ping_receiver/src/main.rs @@ -118,22 +118,28 @@ fn renounce_authority( pre_states: Vec, instruction_words: Vec, ) { - assert!( - caller_program_id.is_none(), - "RenounceAuthority is only invoked as a top-level transaction" - ); - - // pre_states: [config PDA, authority account]. - let [config, authority] = <[AccountWithMetadata; 2]>::try_from(pre_states) - .expect("RenounceAuthority requires the config and authority accounts"); + // The config is read before the account list is validated, so who may call + // is decided first; an inbox-delivered call fails here on its prepended marker. + let config_meta = pre_states + .first() + .expect("RenounceAuthority requires the config account"); assert_eq!( - config.account_id, + config_meta.account_id, receiver_config_account_id(self_program_id), "first account must be the receiver config PDA" ); - - let mut cfg = ReceiverConfig::from_bytes(&config.account.data.clone().into_inner()) + let mut cfg = ReceiverConfig::from_bytes(&config_meta.account.data.clone().into_inner()) .expect("config account holds a receiver config"); + // Top-level, or the governance program the config names; see + // `ReceiverConfig::governance` for why the escape hatch exists. + assert!( + caller_program_id.is_none() || caller_program_id == cfg.governance, + "the authority acts at top level, or through the configured governance program" + ); + + let [config, authority] = <[AccountWithMetadata; 2]>::try_from(pre_states) + .expect("this instruction requires exactly the config and authority accounts"); + let Some(expected) = cfg.authority else { panic!("receiver authority is already renounced"); }; @@ -141,11 +147,8 @@ 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. + // Claims apply after post-state validation, so a first use must find the + // account untouched; an unowned account with history is refused for good. assert!( authority.account == Account::default() || authority.account.program_owner != DEFAULT_PROGRAM_ID, @@ -170,10 +173,8 @@ fn renounce_authority( vec![config, authority.clone()], vec![ AccountPostState::new(config_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. + // Claimed on first use: the authority's own signature bumps its + // nonce, so merely echoing it would work once and never again. AccountPostState::new_claimed_if_default(authority.account, Claim::Authorized), ], ) @@ -189,27 +190,28 @@ fn update_sources( instruction_words: Vec, sources: Vec<([u8; 32], ProgramId)>, ) { - // Top-level only. An account stays authorized for every call below the one - // that authorized it, so a program the authority signed for could otherwise - // chain in here and rewrite the list without the holder intending it. A - // governance PDA holding this needs the caller pinned to that program - // instead, which is a guest change that work has to make anyway. - assert!( - caller_program_id.is_none(), - "UpdateSources is only invoked as a top-level transaction" - ); - - // pre_states: [config PDA, authority account]. - let [config, authority] = <[AccountWithMetadata; 2]>::try_from(pre_states) - .expect("UpdateSources requires the config and authority accounts"); + // The config is read before the account list is validated, so who may call + // is decided first; an inbox-delivered call fails here on its prepended marker. + let config_meta = pre_states + .first() + .expect("UpdateSources requires the config account"); assert_eq!( - config.account_id, + config_meta.account_id, receiver_config_account_id(self_program_id), "first account must be the receiver config PDA" ); - - let mut cfg = ReceiverConfig::from_bytes(&config.account.data.clone().into_inner()) + let mut cfg = ReceiverConfig::from_bytes(&config_meta.account.data.clone().into_inner()) .expect("config account holds a receiver config"); + // Top-level, or the governance program the config names; see + // `ReceiverConfig::governance` for why the escape hatch exists. + assert!( + caller_program_id.is_none() || caller_program_id == cfg.governance, + "the authority acts at top level, or through the configured governance program" + ); + + let [config, authority] = <[AccountWithMetadata; 2]>::try_from(pre_states) + .expect("this instruction requires exactly the config and authority accounts"); + let Some(expected) = cfg.authority else { panic!("receiver sources are fixed at genesis: no authority is configured"); }; @@ -217,11 +219,8 @@ 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. + // Claims apply after post-state validation, so a first use must find the + // account untouched; an unowned account with history is refused for good. assert!( authority.account == Account::default() || authority.account.program_owner != DEFAULT_PROGRAM_ID, @@ -246,10 +245,8 @@ fn update_sources( vec![config, authority.clone()], vec![ AccountPostState::new(config_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. + // Claimed on first use: the authority's own signature bumps its + // nonce, so merely echoing it would work once and never again. AccountPostState::new_claimed_if_default(authority.account, Claim::Authorized), ], ) diff --git a/lez/programs/wrapped_token/core/src/lib.rs b/lez/programs/wrapped_token/core/src/lib.rs index e27df34dd..d5ffa9a21 100644 --- a/lez/programs/wrapped_token/core/src/lib.rs +++ b/lez/programs/wrapped_token/core/src/lib.rs @@ -66,16 +66,20 @@ pub enum Instruction { pub struct WrappedTokenConfig { /// The program allowed to call `Mint`: the cross-zone inbox. pub minter: ProgramId, + /// The program allowed to reach `UpdateSources` and `RenounceAuthority` + /// through a chained call, or `None` for top-level only. + /// + /// Exists because a PDA cannot sign: a program-held authority acts only by + /// its own program delegating it on a chained call. Unset closes the ambient + /// path where any program the authority signed for could rewrite the list. + pub governance: Option, /// The account allowed to change `sources`, or `None` for a list fixed at /// genesis. /// /// Whoever holds this can authorize a new source, and a source can mint, so - /// its compromise is theft rather than delay. That is the key class the - /// central route table was rejected for, and the reason this is seeded unset: - /// the mechanism exists so the config format is final, and points at nothing - /// until there is a governance program worth pointing it at. An `AccountId` - /// rather than a key, so a PDA of such a program can hold it and be - /// authorized by delegation when it calls. + /// its compromise is theft rather than delay; it is seeded unset until there + /// is a governance program worth pointing it at. An `AccountId` rather than + /// a key, so a PDA of such a program can hold it and act by delegation. pub authority: Option, /// The `(src_zone, src_program_id)` pairs a mint may originate from. Empty on /// a zone with no peers, which authorizes nothing. @@ -148,6 +152,7 @@ mod tests { fn config_round_trips() { let config = WrappedTokenConfig { minter: [1, 2, 3, 4, 5, 6, 7, 8], + governance: Some([2; 8]), authority: Some(AccountId::new([5; 32])), sources: vec![([7; 32], [9; 8]), ([8; 32], [4; 8])], }; diff --git a/lez/programs/wrapped_token/src/main.rs b/lez/programs/wrapped_token/src/main.rs index 427d8d49e..6c367d86f 100644 --- a/lez/programs/wrapped_token/src/main.rs +++ b/lez/programs/wrapped_token/src/main.rs @@ -137,22 +137,28 @@ fn renounce_authority( pre_states: Vec, instruction_words: Vec, ) { - assert!( - caller_program_id.is_none(), - "RenounceAuthority is only invoked as a top-level transaction" - ); - - // pre_states: [config PDA, authority account]. - let [config, authority] = <[AccountWithMetadata; 2]>::try_from(pre_states) - .expect("RenounceAuthority requires the config and authority accounts"); + // The config is read before the account list is validated, so who may call + // is decided first; an inbox-delivered call fails here on its prepended marker. + let config_meta = pre_states + .first() + .expect("RenounceAuthority requires the config account"); assert_eq!( - config.account_id, + config_meta.account_id, config_account_id(self_program_id), "first account must be the wrapped-token config PDA" ); - - let mut cfg = WrappedTokenConfig::from_bytes(&config.account.data.clone().into_inner()) + let mut cfg = WrappedTokenConfig::from_bytes(&config_meta.account.data.clone().into_inner()) .expect("config account holds a wrapped-token config"); + // Top-level, or the governance program the config names; see + // `WrappedTokenConfig::governance` for why the escape hatch exists. + assert!( + caller_program_id.is_none() || caller_program_id == cfg.governance, + "the authority acts at top level, or through the configured governance program" + ); + + let [config, authority] = <[AccountWithMetadata; 2]>::try_from(pre_states) + .expect("this instruction requires exactly the config and authority accounts"); + let Some(expected) = cfg.authority else { panic!("wrapped-token authority is already renounced"); }; @@ -160,11 +166,8 @@ 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. + // Claims apply after post-state validation, so a first use must find the + // account untouched; an unowned account with history is refused for good. assert!( authority.account == Account::default() || authority.account.program_owner != DEFAULT_PROGRAM_ID, @@ -189,10 +192,8 @@ fn renounce_authority( vec![config, authority.clone()], vec![ AccountPostState::new(config_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. + // Claimed on first use: the authority's own signature bumps its + // nonce, so merely echoing it would work once and never again. AccountPostState::new_claimed_if_default(authority.account, Claim::Authorized), ], ) @@ -208,27 +209,28 @@ fn update_sources( instruction_words: Vec, sources: Vec<([u8; 32], lee_core::program::ProgramId)>, ) { - // Top-level only. An account stays authorized for every call below the one - // that authorized it, so a program the authority signed for could otherwise - // chain in here and rewrite the list without the holder intending it. A - // governance PDA holding this needs the caller pinned to that program - // instead, which is a guest change that work has to make anyway. - assert!( - caller_program_id.is_none(), - "UpdateSources is only invoked as a top-level transaction" - ); - - // pre_states: [config PDA, authority account]. - let [config, authority] = <[AccountWithMetadata; 2]>::try_from(pre_states) - .expect("UpdateSources requires the config and authority accounts"); + // The config is read before the account list is validated, so who may call + // is decided first; an inbox-delivered call fails here on its prepended marker. + let config_meta = pre_states + .first() + .expect("UpdateSources requires the config account"); assert_eq!( - config.account_id, + config_meta.account_id, config_account_id(self_program_id), "first account must be the wrapped-token config PDA" ); - - let mut cfg = WrappedTokenConfig::from_bytes(&config.account.data.clone().into_inner()) + let mut cfg = WrappedTokenConfig::from_bytes(&config_meta.account.data.clone().into_inner()) .expect("config account holds a wrapped-token config"); + // Top-level, or the governance program the config names; see + // `WrappedTokenConfig::governance` for why the escape hatch exists. + assert!( + caller_program_id.is_none() || caller_program_id == cfg.governance, + "the authority acts at top level, or through the configured governance program" + ); + + let [config, authority] = <[AccountWithMetadata; 2]>::try_from(pre_states) + .expect("this instruction requires exactly the config and authority accounts"); + let Some(expected) = cfg.authority else { panic!("wrapped-token sources are fixed at genesis: no authority is configured"); }; @@ -236,13 +238,8 @@ fn update_sources( authority.account_id, expected, "second account must be the configured authority" ); - // 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. + // Claims apply after post-state validation, so a first use must find the + // account untouched; an unowned account with history is refused for good. assert!( authority.account == Account::default() || authority.account.program_owner != DEFAULT_PROGRAM_ID, @@ -267,10 +264,8 @@ fn update_sources( vec![config, authority.clone()], vec![ AccountPostState::new(config_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. + // Claimed on first use: the authority's own signature bumps its + // nonce, so merely echoing it would work once and never again. AccountPostState::new_claimed_if_default(authority.account, Claim::Authorized), ], ) diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index 0563fb467..40e5368d1 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -186,6 +186,7 @@ fn cross_zone_test_config() -> SequencerConfig { expected_block_signing_pubkey: None, }], source_authority: None, + source_governance: None, }), ..setup_sequencer_config() } diff --git a/test_programs/guest/src/bin/authority_proxy.rs b/test_programs/guest/src/bin/authority_proxy.rs new file mode 100644 index 000000000..cdda55f96 --- /dev/null +++ b/test_programs/guest/src/bin/authority_proxy.rs @@ -0,0 +1,57 @@ +use lee_core::{ + account::AccountId, + program::{ + AccountPostState, ChainedCall, PdaSeed, ProgramId, ProgramInput, ProgramOutput, + read_lee_inputs, + }, +}; + +/// Chain-calls an arbitrary target with caller-supplied instruction words, +/// forwarding every account it was given. With a seed, the PDA derived from +/// `(self, seed)` is delegated through `pda_seeds` and flagged authorized in the +/// call, which is how a program-held authority acts on a callee. +type Instruction = (ProgramId, Vec, Option); + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: (target_program_id, target_instruction_words, pda_seed), + }, + instruction_words, + ) = read_lee_inputs::(); + + let mut call_pre_states = pre_states.clone(); + if let Some(seed) = pda_seed { + let delegated = AccountId::for_public_pda(&self_program_id, &seed); + for pre in &mut call_pre_states { + if pre.account_id == delegated { + pre.is_authorized = true; + } + } + } + + let chained_call = ChainedCall { + program_id: target_program_id, + instruction_data: target_instruction_words, + pre_states: call_pre_states, + pda_seeds: pda_seed.into_iter().collect(), + }; + + let post_states = pre_states + .iter() + .map(|pre| AccountPostState::new(pre.account.clone())) + .collect(); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states, + post_states, + ) + .with_chained_calls(vec![chained_call]) + .write(); +} diff --git a/test_programs/src/lib.rs b/test_programs/src/lib.rs index c13a10282..07147fc5e 100644 --- a/test_programs/src/lib.rs +++ b/test_programs/src/lib.rs @@ -21,6 +21,16 @@ pub const fn chain_caller() -> Program { Program::new_unchecked(CHAIN_CALLER_ID, Cow::Borrowed(CHAIN_CALLER_ELF)) } +#[must_use] +#[inline] +pub const fn authority_proxy() -> Program { + use guests::{AUTHORITY_PROXY_ELF, AUTHORITY_PROXY_ID, AUTHORITY_PROXY_PATH}; + + let _unused = AUTHORITY_PROXY_PATH; + + Program::new_unchecked(AUTHORITY_PROXY_ID, Cow::Borrowed(AUTHORITY_PROXY_ELF)) +} + #[must_use] #[inline] pub const fn claimer() -> Program { diff --git a/tools/cross_zone_chat/src/main.rs b/tools/cross_zone_chat/src/main.rs index 00dcb7369..23e2d03cf 100644 --- a/tools/cross_zone_chat/src/main.rs +++ b/tools/cross_zone_chat/src/main.rs @@ -376,6 +376,7 @@ fn watch_peer(peer: ZoneId, receiver_id: ProgramId) -> CrossZoneConfig { expected_block_signing_pubkey: None, }], source_authority: None, + source_governance: None, } }