diff --git a/engine/tests/accounts.rs b/engine/tests/accounts.rs index 3e17cf96..28e0e739 100644 --- a/engine/tests/accounts.rs +++ b/engine/tests/accounts.rs @@ -10,6 +10,7 @@ use engine::{Engine, EngineError, testkit::TestEngine}; use keeper::testkit::{ V42_ID, load_v42_data, load_v42_lamports, patterned_bytes, store_v42, v42_builder, }; +use magic_root_interface::MagicRootInstruction; use solana_account::{AccountBuilder, AccountMode, OwnedAccount, ReadableAccount}; use solana_instruction_error::InstructionError; use solana_pubkey::Pubkey; @@ -261,34 +262,75 @@ async fn account_create_accepts_max_data_with_post_finalize() { te.close().await; } -// Replacing the seeded executable at a newer slot exercises MagicRoot's large -// account reconstruction and executable finalization, then proves the resulting -// program remains usable by executing it through the normal client path. +/// Proves program-cache entries follow the complete v42 account lifecycle: +/// transaction-local deletion hides a loaded program immediately but rolls back +/// on a later instruction failure, while committed deletion evicts the shared +/// entry so invalid executable data restored at the same key cannot use stale code. #[tokio::test(flavor = "multi_thread")] -async fn account_update_replaces_v42_program_at_new_slot() { +async fn account_program_cache_tracks_v42_lifecycle() { let te = TestEngine::new().await; - let program = te.get_account(V42_ID).expect("v42 program is seeded"); - let data = program.data().to_vec(); - let owner = *program.owner(); - let lamports = program.lamports(); - let slot = program.slot() + 1; - let replacement = AccountBuilder::from(program).slot(slot); - - te.account(V42_ID).update(replacement).await.unwrap(); - - let replaced = te.get_account(V42_ID).expect("replaced v42 program exists"); - assert_eq!(replaced.lamports(), lamports); - assert_eq!(replaced.owner(), &owner); - assert_eq!(replaced.slot(), slot); - assert!(replaced.executable()); - assert_eq!(replaced.data(), data); + let seeded = te.get_account(V42_ID).expect("v42 program is seeded"); + let program = Pubkey::new_unique(); + let closeable = AccountBuilder::from(seeded.clone()) + .mode(AccountMode::Ephemeral) + .slot(seeded.slot() + 1); + te.account(program).create(closeable, None).await.unwrap(); + + let output = Pubkey::new_unique(); + te.accounts() + .store(&[( + output, + v42_builder(0, AccountMode::Ephemeral).owner(program).build(), + )]) + .unwrap(); + let invoke = |value| { + let mut instruction = E::lit(value).compose(output, &[]); + instruction.program_id = program; + instruction.accounts.last_mut().unwrap().pubkey = program; + instruction + }; - let output = store_v42(&te, 0, AccountMode::Ephemeral); - te.execute(&[E::lit(42).compose(output, &[])]) + te.execute(&[invoke(42)]) .await - .expect("replaced v42 program executes"); + .expect("fresh v42 program executes and primes the shared cache"); assert_eq!(load_v42_data(&te, output), Some(42)); + let delete = MagicRootInstruction::Delete.compose(program).unwrap(); + assert_eq!( + te.execute(&[delete, invoke(7)]).await, + Err(TransactionError::InstructionError( + 1, + InstructionError::UnsupportedProgramId + )), + "deletion hides the program from later instructions in the transaction" + ); + assert!( + te.get_account(program).is_some(), + "failed transaction rolls back account deletion" + ); + + te.execute(&[invoke(7)]) + .await + .expect("rolled-back deletion preserves the shared cache entry"); + assert_eq!(load_v42_data(&te, output), Some(7)); + + te.account(program).delete().await.unwrap(); + assert!( + te.get_account(program).is_none(), + "committed deletion removes the account" + ); + + let invalid = AccountBuilder::from(seeded).mode(AccountMode::Ephemeral).data(vec![0]); + te.accounts().store(&[(program, invalid.build())]).unwrap(); + assert_eq!( + te.execute(&[invoke(9)]).await, + Err(TransactionError::InstructionError( + 0, + InstructionError::UnsupportedProgramId + )), + "invalid restored executable cannot run through a stale compiled entry" + ); + te.close().await; } diff --git a/programs/magic-root-interface/src/lib.rs b/programs/magic-root-interface/src/lib.rs index cd17c300..6d0d2811 100644 --- a/programs/magic-root-interface/src/lib.rs +++ b/programs/magic-root-interface/src/lib.rs @@ -15,7 +15,7 @@ pub enum MagicRootInstruction { /// Replace the target's complete flag value and, when executable, load it /// into the transaction's program cache. Does not change lamports. Finalize(StateFlags), - /// Close the target account. + /// Close the target account and hide any cached executable immediately. Delete, /// Run follow-up instructions immediately after finalizing the same target /// (e.g. initializing a freshly created account); each is invoked via CPI diff --git a/programs/magic-root-program/README.md b/programs/magic-root-program/README.md index 708af264..ca2abe63 100644 --- a/programs/magic-root-program/README.md +++ b/programs/magic-root-program/README.md @@ -27,7 +27,9 @@ authorization. The SVM's separate top-level-only privilege rule is unchanged. loads an executable target into the transaction program cache. It does not change lamports; failed executable loading rolls back the installed flags. - `Delete` transitions read-only, placeholder, or ephemeral targets to - `AccountMode::Closed`, and accountsdb removes them during writeback. Modes that + `AccountMode::Closed`, immediately hides any transaction-local cached program, + and removes its shared cache entry after successful execution and access + validation. Accountsdb removes closed accounts during writeback. Modes that cannot transition to closed are rejected. - `PostFinalize` invokes follow-up instructions through native CPI and is placed immediately after the target's `Finalize` by internal composers. It diff --git a/programs/magic-root-program/src/account.rs b/programs/magic-root-program/src/account.rs index 59955f48..325bee70 100644 --- a/programs/magic-root-program/src/account.rs +++ b/programs/magic-root-program/src/account.rs @@ -72,7 +72,7 @@ pub(crate) fn finalize( /// Marks the target account closed for removal from storage. pub(crate) fn delete( - ctx: &InvokeContext<'_, '_>, + ctx: &mut InvokeContext<'_, '_>, target: IndexOfAccount, ) -> Result<(), InstructionError> { let mut acc = ctx.transaction_context.accounts().try_borrow_mut(target)?; @@ -81,6 +81,11 @@ pub(crate) fn delete( ic_msg!(ctx, "MagicRoot: {}", error); return Err(InstructionError::InvalidArgument); } + let pubkey = *ctx.transaction_context.get_key_of_account_at_index(target)?; + if acc.executable() { + let entry = ProgramCacheEntry::default().into(); + ctx.program_cache_for_tx_batch.store_modified_entry(pubkey, entry); + } ic_msg!(ctx, "MagicRoot: removed"); Ok(()) } diff --git a/solana/program-runtime/src/loaded_programs.rs b/solana/program-runtime/src/loaded_programs.rs index 245d5d33..fd8b62ff 100644 --- a/solana/program-runtime/src/loaded_programs.rs +++ b/solana/program-runtime/src/loaded_programs.rs @@ -69,8 +69,6 @@ impl PartialEq for ProgramRuntimeEnvironment { impl Eq for ProgramRuntimeEnvironment {} -pub const MAX_LOADED_ENTRY_COUNT: usize = 512; - /// Actual payload of [ProgramCacheEntry]. #[derive(Default)] pub enum ProgramCacheEntryType { @@ -286,7 +284,11 @@ impl ProgramCache { pub fn merge(&self, modified_entries: &HashMap>) { for (key, entry) in modified_entries { - self.assign_program(*key, entry.clone()); + if matches!(&entry.program, ProgramCacheEntryType::Closed) { + self.index.remove_sync(key); + } else { + self.assign_program(*key, entry.clone()); + } } } }