From 6e43d5c46d9bf30b49dd8dbb98f59e6c97785019 Mon Sep 17 00:00:00 2001 From: Dodecahedr0x Date: Fri, 7 Aug 2026 11:53:33 +0200 Subject: [PATCH] fix: let a post-delegation bundle create an account it does not yet hold --- .../magic-root-program/src/post_finalize.rs | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/programs/magic-root-program/src/post_finalize.rs b/programs/magic-root-program/src/post_finalize.rs index 3fa50363..591bb930 100644 --- a/programs/magic-root-program/src/post_finalize.rs +++ b/programs/magic-root-program/src/post_finalize.rs @@ -1,3 +1,4 @@ +use solana_account::ReadableAccount; use solana_instruction::Instruction; use solana_instruction_error::InstructionError; use solana_program_runtime::invoke_context::InvokeContext; @@ -6,6 +7,13 @@ use solana_svm_log_collector::ic_msg; /// Runs the post-finalize follow-up instructions: rejects any writable /// instruction account that is not mutable, then invokes each action via CPI, /// vouching for exactly the signers that action itself declares. +/// +/// "Not mutable" is about *existing* state the rollup only holds read-only. +/// An account that does not exist yet is not that, and an action is allowed to +/// create one — which is how e.g. a group receipt comes into being. Creation +/// runs through the magic program's own `CreateEphemeralAccount`, which has its +/// own rules (a delegated, funded sponsor), so nothing is waved through here +/// that is not checked there. pub(crate) fn process( ctx: &mut InvokeContext<'_, '_>, actions: Vec, @@ -16,10 +24,23 @@ pub(crate) fn process( for i in 0..count { let index = instruction.get_index_of_instruction_account_in_transaction(i)?; let account = ctx.transaction_context.accounts().try_borrow(index)?; - if instruction.is_instruction_account_writable(i)? && !account.mutable() { + // Exactly the shape `validate_new_ephemeral` demands of a creation + // target: nothing to protect, because there is nothing there. + let uncreated = account.lamports() == 0 + && account.data().is_empty() + // The system program id is the all-zero pubkey; comparing against + // the default avoids pulling `solana-sdk-ids` in as a runtime dep + // for one constant. + && *account.owner() == Default::default(); + if instruction.is_instruction_account_writable(i)? && !account.mutable() && !uncreated { + // Name the account. A bare "something was immutable" leaves the + // caller to guess which of a bundle's twenty-odd accounts it was, + // and the answer is the whole diagnosis. + let pubkey = ctx.transaction_context.get_key_of_account_at_index(index)?; ic_msg!( ctx, - "MagicRoot: post-finalize rejected immutable writable account" + "MagicRoot: post-finalize rejected immutable writable account {}", + pubkey ); return Err(InstructionError::Immutable); }