diff --git a/integration_tests/tests/cross_zone_state_machine.rs b/integration_tests/tests/cross_zone_state_machine.rs index 0a10287ab..4599635f9 100644 --- a/integration_tests/tests/cross_zone_state_machine.rs +++ b/integration_tests/tests/cross_zone_state_machine.rs @@ -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, + 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) + }; + + 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. diff --git a/lez/cross_zone/src/lib.rs b/lez/cross_zone/src/lib.rs index d7781c672..90a9ac031 100644 --- a/lez/cross_zone/src/lib.rs +++ b/lez/cross_zone/src/lib.rs @@ -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( 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], diff --git a/lez/programs/cross_zone_inbox/core/src/lib.rs b/lez/programs/cross_zone_inbox/core/src/lib.rs index 100ba3832..6857fd184 100644 --- a/lez/programs/cross_zone_inbox/core/src/lib.rs +++ b/lez/programs/cross_zone_inbox/core/src/lib.rs @@ -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, /// 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 diff --git a/lez/programs/cross_zone_inbox/src/main.rs b/lez/programs/cross_zone_inbox/src/main.rs index feba814a6..55eb15cff 100644 --- a/lez/programs/cross_zone_inbox/src/main.rs +++ b/lez/programs/cross_zone_inbox/src/main.rs @@ -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, diff --git a/lez/programs/cross_zone_marker/src/lib.rs b/lez/programs/cross_zone_marker/src/lib.rs index e03e2e68b..4490a950c 100644 --- a/lez/programs/cross_zone_marker/src/lib.rs +++ b/lez/programs/cross_zone_marker/src/lib.rs @@ -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]; diff --git a/lez/programs/ping_receiver/src/main.rs b/lez/programs/ping_receiver/src/main.rs index a01c8bc0d..0a7e2ff3d 100644 --- a/lez/programs/ping_receiver/src/main.rs +++ b/lez/programs/ping_receiver/src/main.rs @@ -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(); diff --git a/lez/programs/wrapped_token/src/main.rs b/lez/programs/wrapped_token/src/main.rs index 4b8700268..427d8d49e 100644 --- a/lez/programs/wrapped_token/src/main.rs +++ b/lez/programs/wrapped_token/src/main.rs @@ -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();