diff --git a/.github/packages/npm-package/package.json b/.github/packages/npm-package/package.json index eac4b3ef5..cbdb165a5 100644 --- a/.github/packages/npm-package/package.json +++ b/.github/packages/npm-package/package.json @@ -1,6 +1,6 @@ { "name": "@magicblock-labs/ephemeral-validator", - "version": "0.6.2", + "version": "0.7.0", "description": "MagicBlock Ephemeral Validator", "homepage": "https://github.com/magicblock-labs/magicblock-validator#readme", "bugs": { @@ -30,10 +30,10 @@ "typescript": "^4.9.4" }, "optionalDependencies": { - "@magicblock-labs/ephemeral-validator-darwin-arm64": "0.6.2", - "@magicblock-labs/ephemeral-validator-darwin-x64": "0.6.2", - "@magicblock-labs/ephemeral-validator-linux-arm64": "0.6.2", - "@magicblock-labs/ephemeral-validator-linux-x64": "0.6.2", + "@magicblock-labs/ephemeral-validator-darwin-arm64": "0.7.0", + "@magicblock-labs/ephemeral-validator-darwin-x64": "0.7.0", + "@magicblock-labs/ephemeral-validator-linux-arm64": "0.7.0", + "@magicblock-labs/ephemeral-validator-linux-x64": "0.7.0", "@magicblock-labs/vrf-oracle-linux-x64": "0.2.3", "@magicblock-labs/vrf-oracle-linux-arm64": "0.2.3", "@magicblock-labs/vrf-oracle-darwin-x64": "0.2.3", diff --git a/.github/packages/npm-package/package.json.tmpl b/.github/packages/npm-package/package.json.tmpl index 88ea285e3..cee3228d4 100644 --- a/.github/packages/npm-package/package.json.tmpl +++ b/.github/packages/npm-package/package.json.tmpl @@ -1,7 +1,7 @@ { "name": "@magicblock-labs/${node_pkg}", "description": "Ephemeral Validator (${node_pkg})", - "version": "0.6.2", + "version": "0.7.0", "repository": { "type": "git", "url": "git+https://github.com/magicblock-labs/magicblock-validator.git" diff --git a/Cargo.lock b/Cargo.lock index 48ddb1189..895be58f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1836,7 +1836,7 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "guinea" -version = "0.6.2" +version = "0.7.0" dependencies = [ "bincode", "magicblock-magic-program-api", @@ -2778,7 +2778,7 @@ dependencies = [ [[package]] name = "magicblock-account-cloner" -version = "0.6.2" +version = "0.7.0" dependencies = [ "async-trait", "bincode", @@ -2810,7 +2810,7 @@ dependencies = [ [[package]] name = "magicblock-accounts" -version = "0.6.2" +version = "0.7.0" dependencies = [ "async-trait", "magicblock-account-cloner", @@ -2832,7 +2832,7 @@ dependencies = [ [[package]] name = "magicblock-accounts-db" -version = "0.6.2" +version = "0.7.0" dependencies = [ "lmdb-rkv", "magicblock-config", @@ -2849,7 +2849,7 @@ dependencies = [ [[package]] name = "magicblock-aperture" -version = "0.6.2" +version = "0.7.0" dependencies = [ "agave-geyser-plugin-interface", "arc-swap", @@ -2902,7 +2902,7 @@ dependencies = [ [[package]] name = "magicblock-api" -version = "0.6.2" +version = "0.7.0" dependencies = [ "anyhow", "borsh 1.6.0", @@ -2957,7 +2957,7 @@ dependencies = [ [[package]] name = "magicblock-chainlink" -version = "0.6.2" +version = "0.7.0" dependencies = [ "arc-swap", "assert_matches", @@ -3013,7 +3013,7 @@ dependencies = [ [[package]] name = "magicblock-committor-program" -version = "0.6.2" +version = "0.7.0" dependencies = [ "borsh 1.6.0", "paste", @@ -3025,7 +3025,7 @@ dependencies = [ [[package]] name = "magicblock-committor-service" -version = "0.6.2" +version = "0.7.0" dependencies = [ "async-trait", "base64 0.21.7", @@ -3075,7 +3075,7 @@ dependencies = [ [[package]] name = "magicblock-config" -version = "0.6.2" +version = "0.7.0" dependencies = [ "clap 4.5.53", "derive_more", @@ -3095,7 +3095,7 @@ dependencies = [ [[package]] name = "magicblock-core" -version = "0.6.2" +version = "0.7.0" dependencies = [ "flume", "magicblock-magic-program-api", @@ -3141,7 +3141,7 @@ dependencies = [ [[package]] name = "magicblock-ledger" -version = "0.6.2" +version = "0.7.0" dependencies = [ "arc-swap", "bincode", @@ -3184,7 +3184,7 @@ dependencies = [ [[package]] name = "magicblock-magic-program-api" -version = "0.6.2" +version = "0.7.0" dependencies = [ "bincode", "serde", @@ -3193,7 +3193,7 @@ dependencies = [ [[package]] name = "magicblock-metrics" -version = "0.6.2" +version = "0.7.0" dependencies = [ "http-body-util", "hyper 1.8.1", @@ -3207,13 +3207,14 @@ dependencies = [ [[package]] name = "magicblock-processor" -version = "0.6.2" +version = "0.7.0" dependencies = [ "bincode", "guinea", "magicblock-accounts-db", "magicblock-core", "magicblock-ledger", + "magicblock-magic-program-api", "magicblock-metrics", "magicblock-program", "parking_lot", @@ -3248,7 +3249,7 @@ dependencies = [ [[package]] name = "magicblock-program" -version = "0.6.2" +version = "0.7.0" dependencies = [ "assert_matches", "bincode", @@ -3284,7 +3285,7 @@ dependencies = [ [[package]] name = "magicblock-rpc-client" -version = "0.6.2" +version = "0.7.0" dependencies = [ "solana-account", "solana-account-decoder-client-types", @@ -3306,7 +3307,7 @@ dependencies = [ [[package]] name = "magicblock-table-mania" -version = "0.6.2" +version = "0.7.0" dependencies = [ "ed25519-dalek", "magicblock-metrics", @@ -3332,7 +3333,7 @@ dependencies = [ [[package]] name = "magicblock-task-scheduler" -version = "0.6.2" +version = "0.7.0" dependencies = [ "bincode", "chrono", @@ -3358,7 +3359,7 @@ dependencies = [ [[package]] name = "magicblock-validator" -version = "0.6.2" +version = "0.7.0" dependencies = [ "console-subscriber", "magicblock-api", @@ -3373,7 +3374,7 @@ dependencies = [ [[package]] name = "magicblock-validator-admin" -version = "0.6.2" +version = "0.7.0" dependencies = [ "magicblock-delegation-program", "magicblock-program", @@ -3390,7 +3391,7 @@ dependencies = [ [[package]] name = "magicblock-version" -version = "0.6.2" +version = "0.7.0" dependencies = [ "git-version", "rustc_version", @@ -5298,7 +5299,7 @@ dependencies = [ [[package]] name = "solana-account" version = "2.2.1" -source = "git+https://github.com/magicblock-labs/solana-account.git?rev=2246929#2246929c6614f60d9909fdd117aab3bc454a9775" +source = "git+https://github.com/magicblock-labs/solana-account.git?rev=6eae52b#6eae52bde25e90b3c79d4935ce2b267e35338945" dependencies = [ "bincode", "qualifier_attr", @@ -7070,7 +7071,7 @@ dependencies = [ [[package]] name = "solana-storage-proto" -version = "0.6.2" +version = "0.7.0" dependencies = [ "bincode", "bs58", @@ -7094,7 +7095,7 @@ dependencies = [ [[package]] name = "solana-svm" version = "2.2.1" -source = "git+https://github.com/magicblock-labs/magicblock-svm.git?rev=3e9456ec4#3e9456ec4d5798ad8281537501c1e777d6888ba3" +source = "git+https://github.com/magicblock-labs/magicblock-svm.git?rev=bdbaac86#bdbaac86043180fd946cd2fb9a88cf23dea47f97" dependencies = [ "ahash 0.8.12", "log", @@ -8119,7 +8120,7 @@ dependencies = [ [[package]] name = "test-kit" -version = "0.6.2" +version = "0.7.0" dependencies = [ "guinea", "magicblock-accounts-db", diff --git a/Cargo.toml b/Cargo.toml index a1844cc59..c40f0df6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ resolver = "2" [workspace.package] # Solana Version (2.2.x) -version = "0.6.2" +version = "0.7.0" authors = ["MagicBlock Maintainers "] repository = "https://github.com/magicblock-labs/ephemeral-validator" homepage = "https://www.magicblock.xyz" @@ -138,7 +138,7 @@ serde_json = "1.0" serde_with = "3.16" serial_test = "3.2" sha3 = "0.10.8" -solana-account = { git = "https://github.com/magicblock-labs/solana-account.git", rev = "2246929" } +solana-account = { git = "https://github.com/magicblock-labs/solana-account.git", rev = "6eae52b" } solana-account-decoder = { version = "2.2" } solana-account-decoder-client-types = { version = "2.2" } solana-account-info = { version = "2.2" } @@ -217,7 +217,7 @@ spl-token-2022 = "7.0" [workspace.dependencies.solana-svm] git = "https://github.com/magicblock-labs/magicblock-svm.git" -rev = "3e9456ec4" +rev = "bdbaac86" features = ["dev-context-only-utils"] [workspace.dependencies.rocksdb] @@ -229,9 +229,9 @@ version = "0.22.0" # some solana dependencies have solana-storage-proto as dependency # we need to patch them with our version, because they use protobuf-src v1.1.0 # and we use protobuf-src v2.1.1. Otherwise compilation fails -solana-account = { git = "https://github.com/magicblock-labs/solana-account.git", rev = "2246929" } +solana-account = { git = "https://github.com/magicblock-labs/solana-account.git", rev = "6eae52b" } solana-storage-proto = { path = "./storage-proto" } -solana-svm = { git = "https://github.com/magicblock-labs/magicblock-svm.git", rev = "3e9456ec4" } +solana-svm = { git = "https://github.com/magicblock-labs/magicblock-svm.git", rev = "bdbaac86" } # Fork is used to enable `disable_manual_compaction` usage # Fork is based on commit d4e9e16 of rocksdb (parent commit of 0.23.0 release) # without patching update isn't possible due to conflict with solana deps diff --git a/magicblock-accounts-db/src/lib.rs b/magicblock-accounts-db/src/lib.rs index 033617536..b1437a24a 100644 --- a/magicblock-accounts-db/src/lib.rs +++ b/magicblock-accounts-db/src/lib.rs @@ -20,7 +20,7 @@ pub type AccountsDbResult = Result; /// A global lock used to suspend all write operations during critical /// sections (like snapshots). -pub type GlobalWriteLock = Arc>; +pub type GlobalSyncLock = Arc>; pub const ACCOUNTSDB_DIR: &str = "accountsdb"; @@ -41,7 +41,7 @@ pub struct AccountsDb { /// Global lock ensures atomic snapshots by pausing writes. /// Note: Reads are generally wait-free/lock-free via mmap, /// unless they require index cursor stability. - write_lock: GlobalWriteLock, + write_lock: GlobalSyncLock, /// Configured interval (in slots) for creating snapshots. snapshot_frequency: u64, } @@ -86,7 +86,7 @@ impl AccountsDb { storage, index, snapshot_manager, - write_lock: GlobalWriteLock::default(), + write_lock: GlobalSyncLock::default(), snapshot_frequency: config.snapshot_frequency, }; @@ -169,6 +169,11 @@ impl AccountsDb { } }; } + // The ephemeral account has been closed, remove it from DB + if account.ephemeral() && account.owner() == &Pubkey::default() { + self.index.remove(pubkey, txn!())?; + return Ok(()); + } match account { AccountSharedData::Borrowed(acc) => { if acc.owner_changed() { @@ -366,7 +371,7 @@ impl AccountsDb { self.index.flush(); } - pub fn write_lock(&self) -> GlobalWriteLock { + pub fn write_lock(&self) -> GlobalSyncLock { self.write_lock.clone() } } diff --git a/magicblock-api/src/fund_account.rs b/magicblock-api/src/fund_account.rs index 8e609e270..12583887b 100644 --- a/magicblock-api/src/fund_account.rs +++ b/magicblock-api/src/fund_account.rs @@ -6,6 +6,7 @@ use magicblock_program::MagicContext; use solana_account::{AccountSharedData, WritableAccount}; use solana_keypair::Keypair; use solana_pubkey::Pubkey; +use solana_rent::Rent; use solana_signer::Signer; use crate::{ @@ -82,3 +83,15 @@ pub(crate) fn fund_magic_context(accountsdb: &AccountsDb) { let _ = accountsdb .insert_account(&magic_program::MAGIC_CONTEXT_PUBKEY, &magic_context); } + +pub(crate) fn fund_ephemeral_vault(accountsdb: &AccountsDb) { + let lamports = Rent::default().minimum_balance(0); + fund_account(accountsdb, &magic_program::EPHEMERAL_VAULT_PUBKEY, lamports); + let mut vault = accountsdb + .get_account(&magic_program::EPHEMERAL_VAULT_PUBKEY) + .expect("vault should have been created"); + vault.set_ephemeral(true); + vault.set_owner(magic_program::ID); + let _ = accountsdb + .insert_account(&magic_program::EPHEMERAL_VAULT_PUBKEY, &vault); +} diff --git a/magicblock-api/src/magic_validator.rs b/magicblock-api/src/magic_validator.rs index 3d54f17ff..aca45c5eb 100644 --- a/magicblock-api/src/magic_validator.rs +++ b/magicblock-api/src/magic_validator.rs @@ -83,7 +83,8 @@ use crate::{ domain_registry_manager::DomainRegistryManager, errors::{ApiError, ApiResult}, fund_account::{ - fund_magic_context, funded_faucet, init_validator_identity, + fund_ephemeral_vault, fund_magic_context, funded_faucet, + init_validator_identity, }, genesis_utils::{create_genesis_config_with_leader, GenesisConfigInfo}, ledger::{ @@ -179,6 +180,7 @@ impl MagicValidator { init_validator_identity(&accountsdb, &validator_pubkey); fund_magic_context(&accountsdb); + fund_ephemeral_vault(&accountsdb); let faucet_keypair = funded_faucet(&accountsdb, ledger.ledger_path().as_path())?; diff --git a/magicblock-chainlink/src/chainlink/blacklisted_accounts.rs b/magicblock-chainlink/src/chainlink/blacklisted_accounts.rs index cdb1c090a..963debe48 100644 --- a/magicblock-chainlink/src/chainlink/blacklisted_accounts.rs +++ b/magicblock-chainlink/src/chainlink/blacklisted_accounts.rs @@ -29,6 +29,7 @@ pub fn blacklisted_accounts( blacklisted_accounts.insert(magic_program::ID); blacklisted_accounts.insert(magic_program::MAGIC_CONTEXT_PUBKEY); + blacklisted_accounts.insert(magic_program::EPHEMERAL_VAULT_PUBKEY); blacklisted_accounts.insert(*validator_id); blacklisted_accounts.insert(*faucet_id); blacklisted_accounts diff --git a/magicblock-magic-program-api/src/instruction.rs b/magicblock-magic-program-api/src/instruction.rs index 859cc9768..73e86aa25 100644 --- a/magicblock-magic-program-api/src/instruction.rs +++ b/magicblock-magic-program-api/src/instruction.rs @@ -147,6 +147,37 @@ pub enum MagicBlockInstruction { /// The embedded [`MagicIntentBundleArgs`] encodes account references by indices into the /// accounts array. ScheduleIntentBundle(MagicIntentBundleArgs), + + /// Creates a new ephemeral account with rent paid by a sponsor. + /// The account is automatically owned by the calling program (CPI caller). + /// + /// # Account references + /// - **0.** `[WRITE]` Sponsor account (pays rent, can be PDA or oncurve) + /// - **1.** `[WRITE]` Ephemeral account to create (must have 0 lamports) + /// - **2.** `[WRITE]` Vault account (receives rent payment) + CreateEphemeralAccount { + /// Initial data length in bytes + data_len: u32, + }, + + /// Resizes an existing ephemeral account, adjusting rent accordingly. + /// + /// # Account references + /// - **0.** `[WRITE]` Sponsor account (pays/receives rent difference) + /// - **1.** `[WRITE]` Ephemeral account to resize + /// - **2.** `[WRITE]` Vault account (holds/receives lamports for rent transfer) + ResizeEphemeralAccount { + /// New data length in bytes + new_data_len: u32, + }, + + /// Closes an ephemeral account, refunding rent to the sponsor. + /// + /// # Account references + /// - **0.** `[WRITE]` Sponsor account (receives rent refund) + /// - **1.** `[WRITE]` Ephemeral account to close + /// - **2.** `[WRITE]` Vault account (source of rent refund) + CloseEphemeralAccount, } impl MagicBlockInstruction { diff --git a/magicblock-magic-program-api/src/lib.rs b/magicblock-magic-program-api/src/lib.rs index 79bf67ced..0a78964a2 100644 --- a/magicblock-magic-program-api/src/lib.rs +++ b/magicblock-magic-program-api/src/lib.rs @@ -8,9 +8,17 @@ declare_id!("Magic11111111111111111111111111111111111111"); pub const MAGIC_CONTEXT_PUBKEY: Pubkey = pubkey!("MagicContext1111111111111111111111111111111"); +/// Vault account that collects rent for ephemeral accounts. +pub const EPHEMERAL_VAULT_PUBKEY: Pubkey = + pubkey!("MagicVau1t999999999999999999999999999999999"); + /// We believe 5MB should be enough to store all scheduled commits within a /// slot. Once we store more data in the magic context we need to reconsicer /// this size. /// NOTE: the default max accumulated account size per transaction is 64MB. /// See: MAX_LOADED_ACCOUNTS_DATA_SIZE_BYTES inside program-runtime/src/compute_budget_processor.rs pub const MAGIC_CONTEXT_SIZE: usize = 1024 * 1024 * 5; // 5 MB + +/// Rent rate for ephemeral accounts: 32 lamports per byte. +/// This is ~109x cheaper than Solana's base rent (3,480 lamports/byte). +pub const EPHEMERAL_RENT_PER_BYTE: u64 = 32; diff --git a/magicblock-processor/Cargo.toml b/magicblock-processor/Cargo.toml index 8344f8c54..4bb50e9d6 100644 --- a/magicblock-processor/Cargo.toml +++ b/magicblock-processor/Cargo.toml @@ -44,6 +44,7 @@ rustc-hash = { workspace = true } [dev-dependencies] guinea = { workspace = true } +magicblock-magic-program-api = { workspace = true } solana-keypair = { workspace = true } solana-signature = { workspace = true } solana-signer = { workspace = true } diff --git a/magicblock-processor/src/executor/mod.rs b/magicblock-processor/src/executor/mod.rs index 412c03b95..5cabbea94 100644 --- a/magicblock-processor/src/executor/mod.rs +++ b/magicblock-processor/src/executor/mod.rs @@ -3,7 +3,7 @@ use std::{ sync::{Arc, RwLock}, }; -use magicblock_accounts_db::{AccountsDb, GlobalWriteLock}; +use magicblock_accounts_db::{AccountsDb, GlobalSyncLock}; use magicblock_core::link::{ accounts::AccountUpdateTx, transactions::{ @@ -37,7 +37,7 @@ pub(super) struct TransactionExecutor { accountsdb: Arc, ledger: Arc, block: LatestBlock, - sync: GlobalWriteLock, + sync: GlobalSyncLock, // SVM Components processor: TransactionBatchProcessor, diff --git a/magicblock-processor/src/executor/processing.rs b/magicblock-processor/src/executor/processing.rs index 2ba085764..e8a753e71 100644 --- a/magicblock-processor/src/executor/processing.rs +++ b/magicblock-processor/src/executor/processing.rs @@ -383,20 +383,21 @@ impl super::TransactionExecutor { // 2. Confined Account Integrity Check // Confined accounts must not have their lamport balance changed. for (pubkey, acc) in &txn.accounts { - if acc.confined() { - let lamports_changed = acc - .as_borrowed() - .map(|a| a.lamports_changed()) - .unwrap_or(true); - - if lamports_changed { - executed.execution_details.status = - Err(TransactionError::UnbalancedTransaction); - logs.push(format!( - "Confined account {pubkey} has been illegally modified" - )); - break; - } + if !acc.confined() { + continue; + } + let lamports_changed = acc + .as_borrowed() + .map(|a| a.lamports_changed()) + .unwrap_or(true); + + if lamports_changed { + executed.execution_details.status = + Err(TransactionError::UnbalancedTransaction); + logs.push(format!( + "Confined account {pubkey} has been illegally modified" + )); + break; } } } diff --git a/magicblock-processor/tests/ephemeral_accounts.rs b/magicblock-processor/tests/ephemeral_accounts.rs new file mode 100644 index 000000000..b072ca8f5 --- /dev/null +++ b/magicblock-processor/tests/ephemeral_accounts.rs @@ -0,0 +1,1115 @@ +use guinea::GuineaInstruction; +use magicblock_accounts_db::traits::AccountsBank; +use magicblock_magic_program_api::{ + instruction::MagicBlockInstruction, EPHEMERAL_RENT_PER_BYTE, + EPHEMERAL_VAULT_PUBKEY, ID as MAGIC_PROGRAM_ID, +}; +use solana_account::{AccountSharedData, ReadableAccount, WritableAccount}; +use solana_keypair::Keypair; +use solana_program::instruction::{AccountMeta, Instruction}; +use solana_pubkey::Pubkey; +use test_kit::{ExecutionTestEnv, Signer}; + +/// Calculates rent for an ephemeral account (same logic as magic program) +fn rent_for(data_len: u32) -> u64 { + (u64::from(data_len) + AccountSharedData::ACCOUNT_STATIC_SIZE as u64) + * EPHEMERAL_RENT_PER_BYTE +} + +/// Test context with common setup +struct TestContext { + env: ExecutionTestEnv, + sponsor: Pubkey, + ephemeral: Keypair, +} + +/// Sets up a test with vault, sponsor, and ephemeral account +fn setup_test() -> TestContext { + let env = ExecutionTestEnv::new_with_config(0, 1, false); + init_vault(&env); + + let sponsor = env.get_payer().pubkey; + init_sponsor(&env, sponsor); + let ephemeral = env.create_account(0); + + TestContext { + env, + sponsor, + ephemeral, + } +} + +/// Executes an instruction and returns the result +async fn execute_instruction( + env: &ExecutionTestEnv, + ix: Instruction, +) -> Result<(), solana_transaction_error::TransactionError> { + let txn = env.build_transaction(&[ix]); + env.execute_transaction(txn).await +} + +/// Executes an instruction with additional signers +async fn execute_instruction_with_signers( + env: &ExecutionTestEnv, + ix: Instruction, + signers: &[&Keypair], +) -> Result<(), solana_transaction_error::TransactionError> { + let txn = env.build_transaction_with_signers(&[ix], signers); + env.execute_transaction(txn).await +} + +/// Helper to initialize the ephemeral vault account in the accounts database +fn init_vault(env: &ExecutionTestEnv) { + // Create vault with enough balance to be rent-exempt (covers the account overhead) + // Using a fixed amount that's sufficient for rent-exempt status + const VAULT_RENT_EXEMPT_BALANCE: u64 = 1_000_000; + env.fund_account_with_owner( + EPHEMERAL_VAULT_PUBKEY, + VAULT_RENT_EXEMPT_BALANCE, + MAGIC_PROGRAM_ID, + ); + let mut vault = env.get_account(EPHEMERAL_VAULT_PUBKEY); + vault.set_ephemeral(true); + vault.commit(); +} + +/// Helper to initialize the sponsor account as delegated +fn init_sponsor(env: &ExecutionTestEnv, sponsor: Pubkey) { + // Ensure sponsor is marked as delegated so it can be modified in gasless mode + let mut sponsor_acc = env.get_account(sponsor); + if !sponsor_acc.delegated() { + sponsor_acc.set_delegated(true); + sponsor_acc.commit(); + } +} + +/// Helper to create an instruction that calls guinea to create an ephemeral account +fn create_ephemeral_account_ix( + magic_program: Pubkey, + sponsor: Pubkey, + ephemeral: Pubkey, + vault: Pubkey, + data_len: u32, +) -> Instruction { + Instruction::new_with_bincode( + guinea::ID, + &GuineaInstruction::CreateEphemeralAccount { data_len }, + vec![ + AccountMeta::new_readonly(magic_program, false), + AccountMeta::new(sponsor, true), + AccountMeta::new(ephemeral, true), + AccountMeta::new(vault, false), + ], + ) +} + +/// Helper to create an instruction that calls guinea to resize an ephemeral account +fn resize_ephemeral_account_ix( + magic_program: Pubkey, + sponsor: Pubkey, + ephemeral: Pubkey, + vault: Pubkey, + new_data_len: u32, +) -> Instruction { + Instruction::new_with_bincode( + guinea::ID, + &GuineaInstruction::ResizeEphemeralAccount { new_data_len }, + vec![ + AccountMeta::new_readonly(magic_program, false), + AccountMeta::new(sponsor, true), + AccountMeta::new(ephemeral, false), + AccountMeta::new(vault, false), + ], + ) +} + +/// Helper to create an instruction that calls guinea to close an ephemeral account +fn close_ephemeral_account_ix( + magic_program: Pubkey, + sponsor: Pubkey, + ephemeral: Pubkey, + vault: Pubkey, +) -> Instruction { + Instruction::new_with_bincode( + guinea::ID, + &GuineaInstruction::CloseEphemeralAccount, + vec![ + AccountMeta::new_readonly(magic_program, false), + AccountMeta::new(sponsor, true), + AccountMeta::new(ephemeral, false), + AccountMeta::new(vault, false), + ], + ) +} + +#[tokio::test] +async fn test_create_ephemeral_account_via_cpi() { + let env = ExecutionTestEnv::new_with_config(0, 1, false); + init_vault(&env); + + // Use the payer (which is a signer) as the sponsor + let sponsor = env.get_payer().pubkey; + init_sponsor(&env, sponsor); + let ephemeral = env.create_account(0); // Must start with 0 lamports + + let data_len = 1000; + let expected_rent = rent_for(data_len); + + // Check balances BEFORE operation + let sponsor_before = env.get_account(sponsor); + let vault_before = env.get_account(EPHEMERAL_VAULT_PUBKEY); + let ephemeral_before = env.get_account(ephemeral.pubkey()); + let total_before = sponsor_before.lamports() + + vault_before.lamports() + + ephemeral_before.lamports(); + + println!("=== BEFORE CREATE ==="); + println!("Sponsor: {}", sponsor_before.lamports()); + println!("Vault: {}", vault_before.lamports()); + println!("Ephemeral: {}", ephemeral_before.lamports()); + println!("Total: {}", total_before); + println!("Expected rent: {}", expected_rent); + + let ix = create_ephemeral_account_ix( + magicblock_magic_program_api::ID, + sponsor, + ephemeral.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + data_len, + ); + let txn = env.build_transaction_with_signers(&[ix], &[&ephemeral]); + + let result = env.execute_transaction(txn).await; + if let Err(e) = &result { + println!("Error executing transaction: {:?}", e); + } + assert!(result.is_ok()); + + // Check balances AFTER operation + let sponsor_after = env.get_account(sponsor); + let vault_after = env.get_account(EPHEMERAL_VAULT_PUBKEY); + let ephemeral_after = env.get_account(ephemeral.pubkey()); + let total_after = sponsor_after.lamports() + + vault_after.lamports() + + ephemeral_after.lamports(); + + println!("=== AFTER CREATE ==="); + println!("Sponsor: {}", sponsor_after.lamports()); + println!("Vault: {}", vault_after.lamports()); + println!("Ephemeral: {}", ephemeral_after.lamports()); + println!("Total: {}", total_after); + + // Verify the ephemeral account was created correctly + assert!( + ephemeral_after.ephemeral(), + "Account should be marked as ephemeral" + ); + assert!( + ephemeral_after.delegated(), + "Account should be marked as delegated" + ); + assert_eq!( + *ephemeral_after.owner(), + guinea::ID, + "Owner should be guinea program" + ); + assert_eq!( + ephemeral_after.data().len(), + data_len as usize, + "Data length should match" + ); + assert_eq!( + ephemeral_after.lamports(), + 0, + "Ephemeral account should have 0 lamports" + ); + + // CONSERVATION CHECK: Total lamports should not change + assert_eq!( + total_after, total_before, + "Total lamports should be conserved" + ); + + // VAULT CHECK: Vault should receive exactly the rent amount + assert_eq!( + vault_after.lamports(), + vault_before.lamports() + expected_rent, + "Vault should receive exactly {} lamports", + expected_rent + ); + + // SPONSOR CHECK: Sponsor should be charged exactly the rent amount + assert_eq!( + sponsor_after.lamports(), + sponsor_before.lamports() - expected_rent, + "Sponsor should be charged exactly {} lamports", + expected_rent + ); +} + +#[tokio::test] +async fn test_resize_ephemeral_account_via_cpi() { + let env = ExecutionTestEnv::new_with_config(0, 1, false); + init_vault(&env); + + // Use the payer (which is a signer) as the sponsor + let sponsor = env.get_payer().pubkey; + init_sponsor(&env, sponsor); + let ephemeral = env.create_account(0); + + let initial_data_len = 1000; + let ix = create_ephemeral_account_ix( + magicblock_magic_program_api::ID, + sponsor, + ephemeral.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + initial_data_len, + ); + let txn = env.build_transaction_with_signers(&[ix], &[&ephemeral]); + assert!(env.execute_transaction(txn).await.is_ok()); + + // Check balances BEFORE resize + let sponsor_before_resize = env.get_account(sponsor); + let vault_before_resize = env.get_account(EPHEMERAL_VAULT_PUBKEY); + let ephemeral_before_resize = env.get_account(ephemeral.pubkey()); + let total_before_resize = sponsor_before_resize.lamports() + + vault_before_resize.lamports() + + ephemeral_before_resize.lamports(); + + println!("=== BEFORE RESIZE (GROW) ==="); + println!("Sponsor: {}", sponsor_before_resize.lamports()); + println!("Vault: {}", vault_before_resize.lamports()); + println!("Ephemeral: {}", ephemeral_before_resize.lamports()); + println!("Total: {}", total_before_resize); + + // Resize the account + let new_data_len = 2000; + let initial_rent = rent_for(initial_data_len); + let new_rent = rent_for(new_data_len); + let rent_difference = new_rent - initial_rent; + + println!("Expected rent difference: {}", rent_difference); + + let ix = resize_ephemeral_account_ix( + magicblock_magic_program_api::ID, + sponsor, + ephemeral.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + new_data_len, + ); + let txn = env.build_transaction(&[ix]); + assert!(env.execute_transaction(txn).await.is_ok()); + + // Check balances AFTER resize + let sponsor_after_resize = env.get_account(sponsor); + let vault_after_resize = env.get_account(EPHEMERAL_VAULT_PUBKEY); + let ephemeral_after_resize = env.get_account(ephemeral.pubkey()); + let total_after_resize = sponsor_after_resize.lamports() + + vault_after_resize.lamports() + + ephemeral_after_resize.lamports(); + + println!("=== AFTER RESIZE (GROW) ==="); + println!("Sponsor: {}", sponsor_after_resize.lamports()); + println!("Vault: {}", vault_after_resize.lamports()); + println!("Ephemeral: {}", ephemeral_after_resize.lamports()); + println!("Total: {}", total_after_resize); + + // Verify the account was resized + assert_eq!( + ephemeral_after_resize.data().len(), + new_data_len as usize, + "Data length should be updated" + ); + assert!( + ephemeral_after_resize.ephemeral(), + "Account should still be ephemeral" + ); + + // CONSERVATION CHECK: Total lamports should not change + assert_eq!( + total_after_resize, total_before_resize, + "Total lamports should be conserved" + ); + + // VAULT CHECK: Vault should receive exactly the rent difference + assert_eq!( + vault_after_resize.lamports(), + vault_before_resize.lamports() + rent_difference, + "Vault should receive exactly {} lamports", + rent_difference + ); + + // SPONSOR CHECK: Sponsor should be charged exactly the rent difference + assert_eq!( + sponsor_after_resize.lamports(), + sponsor_before_resize.lamports() - rent_difference, + "Sponsor should be charged exactly {} lamports", + rent_difference + ); +} + +#[tokio::test] +async fn test_close_ephemeral_account_via_cpi() { + let env = ExecutionTestEnv::new_with_config(0, 1, false); + init_vault(&env); + + // Use the payer (which is a signer) as the sponsor + let sponsor = env.get_payer().pubkey; + init_sponsor(&env, sponsor); + let ephemeral = env.create_account(0); + + let data_len = 1000; + let ix = create_ephemeral_account_ix( + magicblock_magic_program_api::ID, + sponsor, + ephemeral.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + data_len, + ); + let txn = env.build_transaction_with_signers(&[ix], &[&ephemeral]); + assert!(env.execute_transaction(txn).await.is_ok()); + + let expected_rent = rent_for(data_len); + + // Check balances BEFORE close + let sponsor_before_close = env.get_account(sponsor); + let vault_before_close = env.get_account(EPHEMERAL_VAULT_PUBKEY); + let ephemeral_before_close = env.get_account(ephemeral.pubkey()); + let total_before_close = sponsor_before_close.lamports() + + vault_before_close.lamports() + + ephemeral_before_close.lamports(); + + println!("=== BEFORE CLOSE ==="); + println!("Sponsor: {}", sponsor_before_close.lamports()); + println!("Vault: {}", vault_before_close.lamports()); + println!("Ephemeral: {}", ephemeral_before_close.lamports()); + println!("Total: {}", total_before_close); + println!("Expected refund: {}", expected_rent); + + // Close the ephemeral account + let ix = close_ephemeral_account_ix( + magicblock_magic_program_api::ID, + sponsor, + ephemeral.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + ); + let txn = env.build_transaction(&[ix]); + + let result = env.execute_transaction(txn).await; + if let Err(e) = &result { + println!("Error closing account: {:?}", e); + } + assert!(result.is_ok()); + + // Check balances AFTER close + let sponsor_after_close = env.get_account(sponsor); + let vault_after_close = env.get_account(EPHEMERAL_VAULT_PUBKEY); + + // Closed ephemeral accounts are removed from the DB + assert!( + env.try_get_account(ephemeral.pubkey()).is_none(), + "Closed ephemeral account should be removed from DB" + ); + + let total_after_close = + sponsor_after_close.lamports() + vault_after_close.lamports(); + + println!("=== AFTER CLOSE ==="); + println!("Sponsor: {}", sponsor_after_close.lamports()); + println!("Vault: {}", vault_after_close.lamports()); + println!("Total: {}", total_after_close); + + // CONSERVATION CHECK: Total lamports should not change + // (ephemeral had 0 lamports, so sponsor + vault should equal prior total) + assert_eq!( + total_after_close, total_before_close, + "Total lamports should be conserved" + ); + + // VAULT CHECK: Vault should pay out exactly the expected rent + assert_eq!( + vault_after_close.lamports(), + vault_before_close.lamports() - expected_rent, + "Vault should pay out exactly {} lamports", + expected_rent + ); + + // SPONSOR CHECK: Sponsor should receive exactly the expected refund + assert_eq!( + sponsor_after_close.lamports(), + sponsor_before_close.lamports() + expected_rent, + "Sponsor should receive exactly {} lamports refund", + expected_rent + ); +} + +#[tokio::test] +async fn test_resize_smaller_via_cpi() { + let env = ExecutionTestEnv::new_with_config(0, 1, false); + init_vault(&env); + + // Use the payer (which is a signer) as the sponsor + let sponsor = env.get_payer().pubkey; + init_sponsor(&env, sponsor); + let ephemeral = env.create_account(0); + + let initial_data_len = 2000; + let ix = create_ephemeral_account_ix( + magicblock_magic_program_api::ID, + sponsor, + ephemeral.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + initial_data_len, + ); + let txn = env.build_transaction_with_signers(&[ix], &[&ephemeral]); + assert!(env.execute_transaction(txn).await.is_ok()); + + // Check balances BEFORE resize (shrink) + let sponsor_before_resize = env.get_account(sponsor); + let vault_before_resize = env.get_account(EPHEMERAL_VAULT_PUBKEY); + let ephemeral_before_resize = env.get_account(ephemeral.pubkey()); + let total_before_resize = sponsor_before_resize.lamports() + + vault_before_resize.lamports() + + ephemeral_before_resize.lamports(); + + println!("=== BEFORE RESIZE (SHRINK) ==="); + println!("Sponsor: {}", sponsor_before_resize.lamports()); + println!("Vault: {}", vault_before_resize.lamports()); + println!("Ephemeral: {}", ephemeral_before_resize.lamports()); + println!("Total: {}", total_before_resize); + + // Resize to smaller + let new_data_len = 1000; + let initial_rent = rent_for(initial_data_len); + let new_rent = rent_for(new_data_len); + let rent_refund = initial_rent - new_rent; + + println!("Expected refund: {}", rent_refund); + + let ix = resize_ephemeral_account_ix( + magicblock_magic_program_api::ID, + sponsor, + ephemeral.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + new_data_len, + ); + let txn = env.build_transaction(&[ix]); + assert!(env.execute_transaction(txn).await.is_ok()); + + // Check balances AFTER resize + let sponsor_after_resize = env.get_account(sponsor); + let vault_after_resize = env.get_account(EPHEMERAL_VAULT_PUBKEY); + let ephemeral_after_resize = env.get_account(ephemeral.pubkey()); + let total_after_resize = sponsor_after_resize.lamports() + + vault_after_resize.lamports() + + ephemeral_after_resize.lamports(); + + println!("=== AFTER RESIZE (SHRINK) ==="); + println!("Sponsor: {}", sponsor_after_resize.lamports()); + println!("Vault: {}", vault_after_resize.lamports()); + println!("Ephemeral: {}", ephemeral_after_resize.lamports()); + println!("Total: {}", total_after_resize); + + // Verify the account was resized + assert_eq!( + ephemeral_after_resize.data().len(), + new_data_len as usize, + "Data length should be updated" + ); + + // CONSERVATION CHECK: Total lamports should not change + assert_eq!( + total_after_resize, total_before_resize, + "Total lamports should be conserved" + ); + + // VAULT CHECK: Vault should pay out exactly the refund + assert_eq!( + vault_after_resize.lamports(), + vault_before_resize.lamports() - rent_refund, + "Vault should pay out exactly {} lamports", + rent_refund + ); + + // SPONSOR CHECK: Sponsor should receive exactly the refund + assert_eq!( + sponsor_after_resize.lamports(), + sponsor_before_resize.lamports() + rent_refund, + "Sponsor should receive exactly {} lamports refund", + rent_refund + ); +} + +// ============================================================================ +// Failure & Validation Tests +// ============================================================================ + +/// Helper to create a direct magic-program instruction (not via CPI) +fn direct_create_instruction( + sponsor: Pubkey, + ephemeral: Pubkey, + vault: Pubkey, + data_len: u32, +) -> Instruction { + Instruction::new_with_bincode( + magicblock_magic_program_api::ID, + &MagicBlockInstruction::CreateEphemeralAccount { data_len }, + vec![ + AccountMeta::new(sponsor, true), + AccountMeta::new(ephemeral, false), + AccountMeta::new(vault, false), + ], + ) +} + +#[tokio::test] +async fn test_direct_call_rejected() { + let env = ExecutionTestEnv::new_with_config(0, 1, false); + init_vault(&env); + + let sponsor = env.get_payer().pubkey; + init_sponsor(&env, sponsor); + let ephemeral = env.create_account(0); + + // Call magic-program directly (NOT via CPI) - should fail + let ix = direct_create_instruction( + sponsor, + ephemeral.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + 1000, + ); + let txn = env.build_transaction(&[ix]); + + let result = env.execute_transaction(txn).await; + assert!(result.is_err(), "Direct call should be rejected"); +} + +#[tokio::test] +async fn test_create_with_non_zero_lamports_fails() { + let env = ExecutionTestEnv::new_with_config(0, 1, false); + init_vault(&env); + + let sponsor = env.get_payer().pubkey; + init_sponsor(&env, sponsor); + let ephemeral = env.create_account(100); // Non-zero lamports! + + let result = execute_instruction_with_signers( + &env, + create_ephemeral_account_ix( + magicblock_magic_program_api::ID, + sponsor, + ephemeral.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + 1000, + ), + &[&ephemeral], + ) + .await; + + assert!(result.is_err(), "Should fail with non-zero lamports"); +} + +#[tokio::test] +async fn test_create_already_ephemeral_fails() { + let ctx = setup_test(); + + // Simulate an existing ephemeral account (owned by a program, not system) + let mut acc = ctx.env.get_account(ctx.ephemeral.pubkey()); + acc.set_owner(guinea::ID); + acc.set_ephemeral(true); + acc.commit(); + + let result = execute_instruction_with_signers( + &ctx.env, + create_ephemeral_account_ix( + magicblock_magic_program_api::ID, + ctx.sponsor, + ctx.ephemeral.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + 1000, + ), + &[&ctx.ephemeral], + ) + .await; + + assert!(result.is_err(), "Should fail - already ephemeral"); +} + +#[tokio::test] +async fn test_create_with_wrong_vault_fails() { + let env = ExecutionTestEnv::new_with_config(0, 1, false); + init_vault(&env); + + let sponsor = env.get_payer().pubkey; + init_sponsor(&env, sponsor); + let ephemeral = env.create_account(0).pubkey(); + + // Use wrong vault (not owned by magic-program) + let wrong_vault = env.create_account(1000).pubkey(); + + let ix = Instruction::new_with_bincode( + guinea::ID, + &GuineaInstruction::CreateEphemeralAccount { data_len: 1000 }, + vec![ + AccountMeta::new_readonly(magicblock_magic_program_api::ID, false), + AccountMeta::new(sponsor, true), + AccountMeta::new(ephemeral, false), + AccountMeta::new(wrong_vault, false), + ], + ); + + let result = execute_instruction(&env, ix).await; + assert!(result.is_err(), "Should fail with wrong vault"); +} + +#[tokio::test] +async fn test_resize_non_ephemeral_fails() { + let ctx = setup_test(); + let regular = ctx.env.create_account(0).pubkey(); + + // Try to resize a non-ephemeral account + let result = execute_instruction( + &ctx.env, + resize_ephemeral_account_ix( + magicblock_magic_program_api::ID, + ctx.sponsor, + regular, + EPHEMERAL_VAULT_PUBKEY, + 1000, + ), + ) + .await; + + assert!(result.is_err(), "Should fail - not ephemeral"); +} + +#[tokio::test] +async fn test_close_non_ephemeral_fails() { + let ctx = setup_test(); + let regular = ctx.env.create_account(0).pubkey(); + + // Try to close a non-ephemeral account + let result = execute_instruction( + &ctx.env, + close_ephemeral_account_ix( + magicblock_magic_program_api::ID, + ctx.sponsor, + regular, + EPHEMERAL_VAULT_PUBKEY, + ), + ) + .await; + + assert!(result.is_err(), "Should fail - not ephemeral"); +} + +#[tokio::test] +async fn test_resize_to_zero_size() { + let env = ExecutionTestEnv::new_with_config(0, 1, false); + init_vault(&env); + + let sponsor = env.get_payer().pubkey; + init_sponsor(&env, sponsor); + let ephemeral = env.create_account(0); + + // Create with initial size + let ix = create_ephemeral_account_ix( + magicblock_magic_program_api::ID, + sponsor, + ephemeral.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + 1000, + ); + let txn = env.build_transaction_with_signers(&[ix], &[&ephemeral]); + assert!(env.execute_transaction(txn).await.is_ok()); + + // Resize to zero + let ix = resize_ephemeral_account_ix( + magicblock_magic_program_api::ID, + sponsor, + ephemeral.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + 0, + ); + let txn = env.build_transaction(&[ix]); + + let result = env.execute_transaction(txn).await; + assert!(result.is_ok(), "Should allow resize to zero"); + + let ephemeral_after = env.get_account(ephemeral.pubkey()); + assert_eq!(ephemeral_after.data().len(), 0); +} + +#[tokio::test] +async fn test_close_already_closed() { + let env = ExecutionTestEnv::new_with_config(0, 1, false); + init_vault(&env); + + let sponsor = env.get_payer().pubkey; + init_sponsor(&env, sponsor); + let ephemeral = env.create_account(0); + + // Create and close + let create_ix = create_ephemeral_account_ix( + magicblock_magic_program_api::ID, + sponsor, + ephemeral.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + 1000, + ); + let txn = env.build_transaction_with_signers(&[create_ix], &[&ephemeral]); + assert!(env.execute_transaction(txn).await.is_ok()); + + let close_ix = close_ephemeral_account_ix( + magicblock_magic_program_api::ID, + sponsor, + ephemeral.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + ); + let txn = env.build_transaction(&[close_ix]); + assert!(env.execute_transaction(txn).await.is_ok()); + + // Try to close again - should fail because account is now owned by system-program + let close_ix2 = close_ephemeral_account_ix( + magicblock_magic_program_api::ID, + sponsor, + ephemeral.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + ); + let txn = env.build_transaction(&[close_ix2]); + + let result = env.execute_transaction(txn).await; + assert!( + result.is_err(), + "Re-close should fail (account owned by system-program)" + ); +} + +#[tokio::test] +async fn test_close_already_closed_double_spend() { + let env = ExecutionTestEnv::new_with_config(0, 1, false); + init_vault(&env); + + let sponsor = env.get_payer().pubkey; + init_sponsor(&env, sponsor); + let ephemeral = env.create_account(0); + + // Create and close + let ix = create_ephemeral_account_ix( + magicblock_magic_program_api::ID, + sponsor, + ephemeral.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + 1000, + ); + let txn = env.build_transaction_with_signers(&[ix], &[&ephemeral]); + assert!(env.execute_transaction(txn).await.is_ok()); + + // Track balances before first close + let sponsor_before = + env.accountsdb.get_account(&sponsor).unwrap().lamports(); + let vault_before = env + .accountsdb + .get_account(&EPHEMERAL_VAULT_PUBKEY) + .unwrap() + .lamports(); + + let close_ix = close_ephemeral_account_ix( + magicblock_magic_program_api::ID, + sponsor, + ephemeral.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + ); + let txn = env.build_transaction(&[close_ix]); + assert!(env.execute_transaction(txn).await.is_ok()); + + let sponsor_after_close = + env.accountsdb.get_account(&sponsor).unwrap().lamports(); + let vault_after_close = env + .accountsdb + .get_account(&EPHEMERAL_VAULT_PUBKEY) + .unwrap() + .lamports(); + + let first_refund = sponsor_after_close - sponsor_before; + let first_vault_debit = vault_before - vault_after_close; + + println!( + "First close - Sponsor refund: {}, Vault debit: {}", + first_refund, first_vault_debit + ); + + let close_ix2 = close_ephemeral_account_ix( + magicblock_magic_program_api::ID, + sponsor, + ephemeral.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + ); + let txn = env.build_transaction(&[close_ix2]); + let result = env.execute_transaction(txn).await; + + assert!( + result.is_err(), + "Re-close should fail (account not owned by magic-program)" + ); + + // Verify no additional refund was given + let sponsor_after_close2 = + env.accountsdb.get_account(&sponsor).unwrap().lamports(); + let vault_after_close2 = env + .accountsdb + .get_account(&EPHEMERAL_VAULT_PUBKEY) + .unwrap() + .lamports(); + + assert_eq!( + sponsor_after_close2, sponsor_after_close, + "Sponsor should not get second refund" + ); + assert_eq!( + vault_after_close2, vault_after_close, + "Vault should not be debited twice" + ); +} + +#[tokio::test] +async fn test_insufficient_balance_fails() { + let env = ExecutionTestEnv::new_with_config(0, 1, false); + init_vault(&env); + + // Use the payer but give it very low balance + let sponsor = env.get_payer().pubkey; + + // Set sponsor balance to very low amount + let mut sponsor_acc = env.get_account(sponsor); + sponsor_acc.set_lamports(100); // Only 100 lamports + sponsor_acc.commit(); + + let ephemeral = env.create_account(0); + + let data_len = 1000; + let required_rent = rent_for(data_len); + + assert!(required_rent > 100, "Rent should exceed sponsor balance"); + + let ix = create_ephemeral_account_ix( + magicblock_magic_program_api::ID, + sponsor, + ephemeral.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + data_len, + ); + let txn = env.build_transaction_with_signers(&[ix], &[&ephemeral]); + + let result = env.execute_transaction(txn).await; + assert!(result.is_err(), "Should fail - insufficient balance"); +} + +// Tests creating an ephemeral account with a PDA sponsor. +// The guinea program uses `invoke_signed` with proper seeds to sign for the PDA. +#[tokio::test] +async fn test_create_with_pda_sponsor() { + let env = ExecutionTestEnv::new_with_config(0, 1, false); + init_vault(&env); + + // 1. Derive the global sponsor PDA (same seed as in guinea program) + let (global_sponsor_pda, _bump) = + Pubkey::find_program_address(&[b"global_sponsor"], &guinea::ID); + + // 2. Insert into accountsdb with 1 SOL and 32 bytes data, owned by guinea + let mut account = AccountSharedData::new(1_000_000_000, 32, &guinea::ID); + account.set_delegated(true); + let _ = env.accountsdb.insert_account(&global_sponsor_pda, &account); + + let ephemeral = env.create_account(0); + + // 3. Try creating ephemeral account with PDA sponsor using the regular instruction + // guinea will detect the PDA and patch the instruction to use invoke_signed + let ix = Instruction::new_with_bincode( + guinea::ID, + &GuineaInstruction::CreateEphemeralAccount { data_len: 1000 }, + vec![ + AccountMeta::new_readonly(magicblock_magic_program_api::ID, false), + AccountMeta::new(global_sponsor_pda, false), // PDA (not a signer in transaction) + AccountMeta::new(ephemeral.pubkey(), true), // Ephemeral must sign + AccountMeta::new(EPHEMERAL_VAULT_PUBKEY, false), + ], + ); + let txn = env.build_transaction_with_signers(&[ix], &[&ephemeral]); + + let result = env.execute_transaction(txn).await; + + // Should succeed - guinea patches the instruction and uses invoke_signed + match result { + Ok(_) => { + // Verify ephemeral account was created successfully + let ephemeral_acc = env.get_account(ephemeral.pubkey()); + assert!(ephemeral_acc.ephemeral(), "Account should be ephemeral"); + assert_eq!( + ephemeral_acc.data().len(), + 1000, + "Account should have 1000 bytes" + ); + } + Err(e) => { + panic!("PDA sponsor test failed with: {:?}", e); + } + } +} + +#[tokio::test] +async fn test_pda_wrong_owner_fails() { + let env = ExecutionTestEnv::new_with_config(0, 1, false); + init_vault(&env); + + // Create a PDA owned by system program (not guinea) + let (pda, _bump) = Pubkey::find_program_address( + &[b"wrong"], + &solana_sdk_ids::system_program::id(), + ); + let mut account = + AccountSharedData::new(0, 0, &solana_sdk_ids::system_program::id()); + account.set_delegated(true); + let _ = env.accountsdb.insert_account(&pda, &account); + + let ephemeral = env.create_account(0); + + let ix = Instruction::new_with_bincode( + guinea::ID, + &GuineaInstruction::CreateEphemeralAccount { data_len: 1000 }, + vec![ + AccountMeta::new_readonly(magicblock_magic_program_api::ID, false), + AccountMeta::new(pda, false), // NOT a signer, not owned by guinea + AccountMeta::new(ephemeral.pubkey(), false), + AccountMeta::new(EPHEMERAL_VAULT_PUBKEY, false), + ], + ); + let txn = env.build_transaction(&[ix]); + + let result = env.execute_transaction(txn).await; + assert!(result.is_err(), "Should fail - PDA not owned by caller"); +} + +#[tokio::test] +async fn test_non_signer_oncurve_sponsor_fails() { + let env = ExecutionTestEnv::new_with_config(0, 1, false); + init_vault(&env); + + // Create oncurve account that is NOT a signer + let non_signer = env.create_account(1000); + let ephemeral = env.create_account(0); + + let ix = Instruction::new_with_bincode( + guinea::ID, + &GuineaInstruction::CreateEphemeralAccount { data_len: 1000 }, + vec![ + AccountMeta::new_readonly(magicblock_magic_program_api::ID, false), + AccountMeta::new(non_signer.pubkey(), false), // NOT a signer + AccountMeta::new(ephemeral.pubkey(), false), + AccountMeta::new(EPHEMERAL_VAULT_PUBKEY, false), + ], + ); + let txn = env.build_transaction(&[ix]); + + let result = env.execute_transaction(txn).await; + assert!(result.is_err(), "Should fail - non-signer oncurve account"); +} + +#[tokio::test] +async fn test_full_lifecycle() { + let env = ExecutionTestEnv::new_with_config(0, 1, false); + init_vault(&env); + + let sponsor = env.get_payer().pubkey; + init_sponsor(&env, sponsor); + let ephemeral = env.create_account(0); + + // Create → Resize (grow) → Resize (shrink) → Close + let create_ix = create_ephemeral_account_ix( + magicblock_magic_program_api::ID, + sponsor, + ephemeral.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + 1000, + ); + let txn = env.build_transaction_with_signers(&[create_ix], &[&ephemeral]); + assert!(env.execute_transaction(txn).await.is_ok()); + + let grow_ix = resize_ephemeral_account_ix( + magicblock_magic_program_api::ID, + sponsor, + ephemeral.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + 2000, + ); + let txn = env.build_transaction(&[grow_ix]); + assert!(env.execute_transaction(txn).await.is_ok()); + + let shrink_ix = resize_ephemeral_account_ix( + magicblock_magic_program_api::ID, + sponsor, + ephemeral.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + 500, + ); + let txn = env.build_transaction(&[shrink_ix]); + assert!(env.execute_transaction(txn).await.is_ok()); + + let close_ix = close_ephemeral_account_ix( + magicblock_magic_program_api::ID, + sponsor, + ephemeral.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + ); + let txn = env.build_transaction(&[close_ix]); + assert!(env.execute_transaction(txn).await.is_ok()); + + // Closed ephemeral accounts are removed from the DB + assert!( + env.try_get_account(ephemeral.pubkey()).is_none(), + "Closed ephemeral account should be removed from DB" + ); +} + +#[tokio::test] +async fn test_multiple_accounts_same_sponsor() { + let env = ExecutionTestEnv::new_with_config(0, 1, false); + init_vault(&env); + + // Use payer[0] as sponsor - need to be explicit about which payer + let sponsor = env.payers[0].pubkey(); + init_sponsor(&env, sponsor); + + let eph1 = env.create_account(0); + let eph2 = env.create_account(0); + let eph3 = env.create_account(0); + + let total_rent = rent_for(1000) + rent_for(2000) + rent_for(500); + + // Get initial balance directly from AccountsDB to avoid caching issues + let sponsor_balance_before = + env.accountsdb.get_account(&sponsor).unwrap().lamports(); + + // Create 3 accounts with different sizes + for (eph, len) in [(&eph1, 1000), (&eph2, 2000), (&eph3, 500)] { + let ix = create_ephemeral_account_ix( + magicblock_magic_program_api::ID, + sponsor, + eph.pubkey(), + EPHEMERAL_VAULT_PUBKEY, + len, + ); + let txn = env.build_transaction_with_signers(&[ix], &[eph]); + assert!(env.execute_transaction(txn).await.is_ok()); + } + + // Get final balance directly from AccountsDB + let sponsor_balance_after = + env.accountsdb.get_account(&sponsor).unwrap().lamports(); + + assert_eq!( + sponsor_balance_before - sponsor_balance_after, + total_rent, + "Sponsor should be charged total rent for all accounts" + ); +} diff --git a/programs/elfs/guinea.so b/programs/elfs/guinea.so index 62c6cedb4..cea822f08 100755 Binary files a/programs/elfs/guinea.so and b/programs/elfs/guinea.so differ diff --git a/programs/guinea/src/lib.rs b/programs/guinea/src/lib.rs index 453e168dd..91856f086 100644 --- a/programs/guinea/src/lib.rs +++ b/programs/guinea/src/lib.rs @@ -3,6 +3,7 @@ use core::slice; use magicblock_magic_program_api::{ args::ScheduleTaskArgs, instruction::MagicBlockInstruction, + EPHEMERAL_VAULT_PUBKEY, }; use serde::{Deserialize, Serialize}; use solana_program::{ @@ -11,7 +12,7 @@ use solana_program::{ entrypoint::{self, ProgramResult}, instruction::{AccountMeta, Instruction}, log, - program::{invoke, set_return_data}, + program::{invoke, invoke_signed, set_return_data}, program_error::ProgramError, pubkey::Pubkey, rent::Rent, @@ -21,6 +22,39 @@ use solana_program::{ entrypoint::entrypoint!(process_instruction); declare_id!("GuineaeT4SgZ512pT3a5jfiG2gqBih6yVy2axJ2zo38C"); +/// Global PDA sponsor for testing ephemeral accounts +const GLOBAL_SPONSOR_SEED: &[u8] = b"global_sponsor"; + +/// Derives the global PDA sponsor address +fn global_sponsor_pda() -> (Pubkey, u8) { + Pubkey::find_program_address(&[GLOBAL_SPONSOR_SEED], &crate::ID) +} + +/// Helper to invoke or invoke_signed depending on sponsor type. +/// Takes ownership of the instruction and patches the signer flag for PDAs. +fn invoke_with_sponsor( + mut instruction: Instruction, + account_infos: &[AccountInfo], + sponsor_info: &AccountInfo, +) -> ProgramResult { + // Check if sponsor is the global PDA + let (expected_pda, bump) = global_sponsor_pda(); + if sponsor_info.key == &expected_pda { + // PDA sponsor: patch instruction and use invoke_signed + instruction.accounts[0].is_signer = true; + + let bump_bytes = &[bump]; + let seeds_for_signer: Vec<&[u8]> = + vec![GLOBAL_SPONSOR_SEED, bump_bytes]; + let signer_seeds: &[&[&[u8]]] = &[&seeds_for_signer[..]]; + + invoke_signed(&instruction, account_infos, signer_seeds) + } else { + // Regular signer sponsor + invoke(&instruction, account_infos) + } +} + #[derive(Serialize, Deserialize)] pub enum GuineaInstruction { ComputeBalances, @@ -31,6 +65,9 @@ pub enum GuineaInstruction { Resize(usize), ScheduleTask(ScheduleTaskArgs), CancelTask(i64), + CreateEphemeralAccount { data_len: u32 }, + ResizeEphemeralAccount { new_data_len: u32 }, + CloseEphemeralAccount, } fn compute_balances(accounts: slice::Iter) { @@ -165,6 +202,121 @@ fn cancel_task( Ok(()) } +fn validate_ephemeral_accounts( + magic_program_info: &AccountInfo, + vault_info: &AccountInfo, +) -> ProgramResult { + if magic_program_info.key != &magicblock_magic_program_api::ID { + return Err(ProgramError::InvalidAccountData); + } + if *vault_info.key != EPHEMERAL_VAULT_PUBKEY { + return Err(ProgramError::InvalidAccountData); + } + Ok(()) +} + +fn create_ephemeral_account( + mut accounts: slice::Iter, + data_len: u32, +) -> ProgramResult { + let magic_program_info = next_account_info(&mut accounts)?; + let sponsor_info = next_account_info(&mut accounts)?; + let ephemeral_info = next_account_info(&mut accounts)?; + let vault_info = next_account_info(&mut accounts)?; + + validate_ephemeral_accounts(magic_program_info, vault_info)?; + + let account_infos = &[ + sponsor_info.clone(), + ephemeral_info.clone(), + vault_info.clone(), + ]; + + // Ephemeral must be a signer (prevents pubkey squatting) + if !ephemeral_info.is_signer { + return Err(ProgramError::MissingRequiredSignature); + } + + let ix = Instruction::new_with_bincode( + magicblock_magic_program_api::ID, + &MagicBlockInstruction::CreateEphemeralAccount { data_len }, + vec![ + AccountMeta::new(*sponsor_info.key, sponsor_info.is_signer), + AccountMeta::new(*ephemeral_info.key, true), + AccountMeta::new(EPHEMERAL_VAULT_PUBKEY, false), + ], + ); + + invoke_with_sponsor(ix, account_infos, sponsor_info)?; + + Ok(()) +} + +fn resize_ephemeral_account( + mut accounts: slice::Iter, + new_data_len: u32, +) -> ProgramResult { + let magic_program_info = next_account_info(&mut accounts)?; + let sponsor_info = next_account_info(&mut accounts)?; + let ephemeral_info = next_account_info(&mut accounts)?; + let vault_info = next_account_info(&mut accounts)?; + + validate_ephemeral_accounts(magic_program_info, vault_info)?; + + let account_infos = &[ + sponsor_info.clone(), + ephemeral_info.clone(), + vault_info.clone(), + ]; + + // Create instruction (signer flag will be patched by helper if needed) + let ix = Instruction::new_with_bincode( + magicblock_magic_program_api::ID, + &MagicBlockInstruction::ResizeEphemeralAccount { new_data_len }, + vec![ + AccountMeta::new(*sponsor_info.key, true), + AccountMeta::new(*ephemeral_info.key, false), + AccountMeta::new(EPHEMERAL_VAULT_PUBKEY, false), + ], + ); + + invoke_with_sponsor(ix, account_infos, sponsor_info)?; + + Ok(()) +} + +fn close_ephemeral_account( + mut accounts: slice::Iter, +) -> ProgramResult { + let magic_program_info = next_account_info(&mut accounts)?; + let sponsor_info = next_account_info(&mut accounts)?; + let ephemeral_info = next_account_info(&mut accounts)?; + let vault_info = next_account_info(&mut accounts)?; + + validate_ephemeral_accounts(magic_program_info, vault_info)?; + + let account_infos = &[ + sponsor_info.clone(), + ephemeral_info.clone(), + vault_info.clone(), + ]; + + // Create instruction (signer flag will be patched by helper if needed) + let ix = Instruction::new_with_bincode( + magicblock_magic_program_api::ID, + &MagicBlockInstruction::CloseEphemeralAccount, + vec![ + AccountMeta::new(*sponsor_info.key, true), + AccountMeta::new(*ephemeral_info.key, false), + AccountMeta::new(EPHEMERAL_VAULT_PUBKEY, false), + ], + ); + + invoke_with_sponsor(ix, account_infos, sponsor_info)?; + + Ok(()) +} + fn process_instruction( _program_id: &Pubkey, accounts: &[AccountInfo], @@ -194,6 +346,15 @@ fn process_instruction( GuineaInstruction::CancelTask(task_id) => { cancel_task(accounts, task_id)? } + GuineaInstruction::CreateEphemeralAccount { data_len } => { + create_ephemeral_account(accounts, data_len)? + } + GuineaInstruction::ResizeEphemeralAccount { new_data_len } => { + resize_ephemeral_account(accounts, new_data_len)? + } + GuineaInstruction::CloseEphemeralAccount => { + close_ephemeral_account(accounts)? + } } Ok(()) } diff --git a/programs/magicblock/src/ephemeral_accounts/mod.rs b/programs/magicblock/src/ephemeral_accounts/mod.rs new file mode 100644 index 000000000..ff6151b02 --- /dev/null +++ b/programs/magicblock/src/ephemeral_accounts/mod.rs @@ -0,0 +1,77 @@ +//! Ephemeral account instruction processors +//! +//! Ephemeral accounts are zero-balance accounts with rent paid by a sponsor. +//! Rent is charged at 32 lamports/byte (109x cheaper than Solana base rent). + +mod process_close; +mod process_create; +mod process_resize; +mod validation; + +use std::cell::RefCell; + +use magicblock_magic_program_api::EPHEMERAL_RENT_PER_BYTE; +pub(crate) use process_close::process_close_ephemeral_account; +pub(crate) use process_create::process_create_ephemeral_account; +pub(crate) use process_resize::process_resize_ephemeral_account; +use solana_account::{AccountSharedData, ReadableAccount}; +use solana_instruction::error::InstructionError; +use solana_transaction_context::TransactionContext; + +use crate::utils::accounts; + +// ----- Account indices shared by validation and processors ----- + +/// Instruction account index for the sponsor (rent payer). +const SPONSOR_IDX: u16 = 0; +/// Instruction account index for the ephemeral account. +const EPHEMERAL_IDX: u16 = 1; +/// Instruction account index for the vault. +const VAULT_IDX: u16 = 2; + +// ----- Shared helpers ----- + +/// Maximum allowed data length for ephemeral accounts (10 MB, matching Solana's limit) +const MAX_DATA_LEN: u32 = 10 * 1024 * 1024; + +/// Calculates rent for an ephemeral account based on its data length. +fn rent_for(data_len: u32) -> Result { + let total_size = u64::from(data_len) + .checked_add(AccountSharedData::ACCOUNT_STATIC_SIZE as u64) + .ok_or(InstructionError::ArithmeticOverflow)?; + total_size + .checked_mul(EPHEMERAL_RENT_PER_BYTE) + .ok_or(InstructionError::ArithmeticOverflow) +} + +/// Returns the data length of an ephemeral account as a `u32`. +fn get_ephemeral_data_len( + ephemeral: &RefCell, +) -> Result { + ephemeral + .borrow() + .data() + .len() + .try_into() + .map_err(|_| InstructionError::ArithmeticOverflow) +} + +/// Transfers rent between sponsor and vault. +/// +/// Positive `amount` moves lamports from sponsor to vault (creation / growth). +/// Negative `amount` moves lamports from vault to sponsor (close / shrink). +fn transfer_rent( + tc: &TransactionContext, + amount: i64, +) -> Result<(), InstructionError> { + if amount > 0 { + let abs = amount as u64; + accounts::debit_instruction_account_at_index(tc, SPONSOR_IDX, abs)?; + accounts::credit_instruction_account_at_index(tc, VAULT_IDX, abs)?; + } else { + let abs = amount.unsigned_abs(); + accounts::credit_instruction_account_at_index(tc, SPONSOR_IDX, abs)?; + accounts::debit_instruction_account_at_index(tc, VAULT_IDX, abs)?; + } + Ok(()) +} diff --git a/programs/magicblock/src/ephemeral_accounts/process_close.rs b/programs/magicblock/src/ephemeral_accounts/process_close.rs new file mode 100644 index 000000000..1cab8e3e6 --- /dev/null +++ b/programs/magicblock/src/ephemeral_accounts/process_close.rs @@ -0,0 +1,36 @@ +//! Close ephemeral account instruction processor + +use solana_account::WritableAccount; +use solana_instruction::error::InstructionError; +use solana_log_collector::ic_msg; +use solana_program_runtime::invoke_context::InvokeContext; +use solana_sdk_ids::system_program; +use solana_transaction_context::TransactionContext; + +use super::{ + get_ephemeral_data_len, rent_for, transfer_rent, + validation::{validate_common, validate_existing_ephemeral}, +}; + +/// Closes an ephemeral account, refunding rent to the sponsor. +pub(crate) fn process_close_ephemeral_account( + invoke_context: &InvokeContext, + transaction_context: &TransactionContext, +) -> Result<(), InstructionError> { + let caller_program_id = validate_common(transaction_context)?; + let ephemeral = + validate_existing_ephemeral(transaction_context, &caller_program_id)?; + + let data_len = get_ephemeral_data_len(ephemeral)?; + let refund = rent_for(data_len)?; + transfer_rent(transaction_context, -(refund as i64))?; + + // Reset account to empty state + let mut acc = ephemeral.borrow_mut(); + acc.set_lamports(0); + acc.set_owner(system_program::id()); + acc.resize(0, 0); + + ic_msg!(invoke_context, "Closed ephemeral, refunded: {}", refund); + Ok(()) +} diff --git a/programs/magicblock/src/ephemeral_accounts/process_create.rs b/programs/magicblock/src/ephemeral_accounts/process_create.rs new file mode 100644 index 000000000..e6958296d --- /dev/null +++ b/programs/magicblock/src/ephemeral_accounts/process_create.rs @@ -0,0 +1,50 @@ +//! Create ephemeral account instruction processor + +use solana_account::WritableAccount; +use solana_instruction::error::InstructionError; +use solana_log_collector::ic_msg; +use solana_program_runtime::invoke_context::InvokeContext; +use solana_transaction_context::TransactionContext; + +use super::{ + rent_for, transfer_rent, + validation::{ + validate_common, validate_ephemeral_signer, validate_new_ephemeral, + }, + MAX_DATA_LEN, +}; + +/// Creates a new ephemeral account with rent paid by the sponsor. +/// The account is owned by the calling program (inferred from CPI context). +pub(crate) fn process_create_ephemeral_account( + invoke_context: &InvokeContext, + transaction_context: &TransactionContext, + data_len: u32, +) -> Result<(), InstructionError> { + if data_len > MAX_DATA_LEN { + return Err(InstructionError::InvalidArgument); + } + + let caller_program_id = validate_common(transaction_context)?; + validate_ephemeral_signer(transaction_context)?; + let ephemeral = validate_new_ephemeral(transaction_context)?; + + let rent = rent_for(data_len)?; + transfer_rent(transaction_context, rent as i64)?; + + // Initialize ephemeral account + let mut acc = ephemeral.borrow_mut(); + acc.set_lamports(0); + acc.set_owner(caller_program_id); + acc.resize(data_len as usize, 0); + acc.set_ephemeral(true); + + ic_msg!( + invoke_context, + "Created ephemeral: {} bytes, {} rent, owner: {}", + data_len, + rent, + caller_program_id + ); + Ok(()) +} diff --git a/programs/magicblock/src/ephemeral_accounts/process_resize.rs b/programs/magicblock/src/ephemeral_accounts/process_resize.rs new file mode 100644 index 000000000..9481d8d4b --- /dev/null +++ b/programs/magicblock/src/ephemeral_accounts/process_resize.rs @@ -0,0 +1,45 @@ +//! Resize ephemeral account instruction processor + +use solana_instruction::error::InstructionError; +use solana_log_collector::ic_msg; +use solana_program_runtime::invoke_context::InvokeContext; +use solana_transaction_context::TransactionContext; + +use super::{ + get_ephemeral_data_len, rent_for, transfer_rent, + validation::{validate_common, validate_existing_ephemeral}, + MAX_DATA_LEN, +}; + +/// Resizes an existing ephemeral account, adjusting rent accordingly. +pub(crate) fn process_resize_ephemeral_account( + invoke_context: &InvokeContext, + transaction_context: &TransactionContext, + new_data_len: u32, +) -> Result<(), InstructionError> { + if new_data_len > MAX_DATA_LEN { + return Err(InstructionError::InvalidArgument); + } + + let caller_program_id = validate_common(transaction_context)?; + let ephemeral = + validate_existing_ephemeral(transaction_context, &caller_program_id)?; + + let old_len = get_ephemeral_data_len(ephemeral)?; + let new_rent = rent_for(new_data_len)?; + let old_rent = rent_for(old_len)?; + let delta = new_rent as i64 - old_rent as i64; + + transfer_rent(transaction_context, delta)?; + + ephemeral.borrow_mut().resize(new_data_len as usize, 0); + + ic_msg!( + invoke_context, + "Resized: {} -> {} bytes, delta: {}", + old_len, + new_data_len, + delta + ); + Ok(()) +} diff --git a/programs/magicblock/src/ephemeral_accounts/validation.rs b/programs/magicblock/src/ephemeral_accounts/validation.rs new file mode 100644 index 000000000..a8d153ebd --- /dev/null +++ b/programs/magicblock/src/ephemeral_accounts/validation.rs @@ -0,0 +1,113 @@ +//! Validation helpers for ephemeral account instructions. + +use std::cell::RefCell; + +use magicblock_magic_program_api::EPHEMERAL_VAULT_PUBKEY; +use solana_account::{AccountSharedData, ReadableAccount}; +use solana_instruction::error::InstructionError; +use solana_pubkey::Pubkey; +use solana_sdk_ids::system_program; +use solana_transaction_context::TransactionContext; + +use super::{EPHEMERAL_IDX, SPONSOR_IDX, VAULT_IDX}; +use crate::utils::{ + accounts, instruction_context_frames::InstructionContextFrames, +}; + +/// Returns the program ID of the CPI caller. +/// +/// Fails with [`InstructionError::IncorrectProgramId`] when invoked +/// outside of CPI, so this implicitly rejects direct top-level calls. +fn get_caller_program_id( + tc: &TransactionContext, +) -> Result { + let frames = InstructionContextFrames::try_from(tc)?; + frames + .find_program_id_of_parent_of_current_instruction() + .copied() + .ok_or(InstructionError::IncorrectProgramId) +} + +/// Validates that the sponsor account is a signer. +/// PDAs may satisfy this via `invoke_signed`. +fn validate_sponsor(tc: &TransactionContext) -> Result<(), InstructionError> { + let ix_ctx = tc.get_current_instruction_context()?; + if !ix_ctx.is_instruction_account_signer(SPONSOR_IDX)? { + return Err(InstructionError::MissingRequiredSignature); + } + Ok(()) +} + +/// Validates the vault account matches the expected pubkey. +fn validate_vault(tc: &TransactionContext) -> Result<(), InstructionError> { + let vault_pubkey = + accounts::get_instruction_pubkey_with_idx(tc, VAULT_IDX)?; + if *vault_pubkey != EPHEMERAL_VAULT_PUBKEY { + return Err(InstructionError::InvalidAccountData); + } + Ok(()) +} + +// ----- Public helpers consumed by the three processors ----- + +/// Common validation sequence shared by all ephemeral account instructions. +/// +/// Checks CPI context, sponsor signature, and vault identity. +/// Returns the caller program ID on success. +pub(super) fn validate_common( + tc: &TransactionContext, +) -> Result { + let caller_program_id = get_caller_program_id(tc)?; + validate_sponsor(tc)?; + validate_vault(tc)?; + Ok(caller_program_id) +} + +/// Validates that the ephemeral account is a signer (prevents pubkey squatting). +/// Only required for account creation. +pub(super) fn validate_ephemeral_signer( + tc: &TransactionContext, +) -> Result<(), InstructionError> { + let ix_ctx = tc.get_current_instruction_context()?; + if !ix_ctx.is_instruction_account_signer(EPHEMERAL_IDX)? { + return Err(InstructionError::MissingRequiredSignature); + } + Ok(()) +} + +/// Validates that the account at [`EPHEMERAL_IDX`] is an empty system-owned +/// account (0 lamports, system program owner). Returns the account for +/// initialization. +pub(super) fn validate_new_ephemeral( + tc: &TransactionContext, +) -> Result<&RefCell, InstructionError> { + let ephemeral = + accounts::get_instruction_account_with_idx(tc, EPHEMERAL_IDX)?; + let acc = ephemeral.borrow(); + if acc.lamports() != 0 || *acc.owner() != system_program::ID { + return Err(InstructionError::InvalidAccountData); + } + drop(acc); + Ok(ephemeral) +} + +/// Validates an existing ephemeral account is marked ephemeral and owned by +/// the caller program. Returns the account for further operations. +pub(super) fn validate_existing_ephemeral<'a>( + tc: &'a TransactionContext, + caller_program_id: &Pubkey, +) -> Result<&'a RefCell, InstructionError> { + let ephemeral = + accounts::get_instruction_account_with_idx(tc, EPHEMERAL_IDX)?; + + let acc = ephemeral.borrow(); + if !acc.ephemeral() { + return Err(InstructionError::InvalidAccountData); + } + if acc.owner() != caller_program_id { + return Err(InstructionError::InvalidAccountOwner); + } + drop(acc); + + Ok(ephemeral) +} diff --git a/programs/magicblock/src/lib.rs b/programs/magicblock/src/lib.rs index 76d21bde8..33888f27a 100644 --- a/programs/magicblock/src/lib.rs +++ b/programs/magicblock/src/lib.rs @@ -1,3 +1,4 @@ +mod ephemeral_accounts; pub mod errors; mod magic_context; mod mutate_accounts; diff --git a/programs/magicblock/src/magicblock_processor.rs b/programs/magicblock/src/magicblock_processor.rs index 30bbaec9a..640d08f2d 100644 --- a/programs/magicblock/src/magicblock_processor.rs +++ b/programs/magicblock/src/magicblock_processor.rs @@ -2,6 +2,10 @@ use magicblock_magic_program_api::instruction::MagicBlockInstruction; use solana_program_runtime::declare_process_instruction; use crate::{ + ephemeral_accounts::{ + process_close_ephemeral_account, process_create_ephemeral_account, + process_resize_ephemeral_account, + }, mutate_accounts::process_mutate_accounts, process_scheduled_commit_sent, schedule_task::{process_cancel_task, process_schedule_task}, @@ -88,6 +92,24 @@ declare_process_instruction!( EnableExecutableCheck => { process_toggle_executable_check(signers, invoke_context, true) } + CreateEphemeralAccount { data_len } => { + process_create_ephemeral_account( + invoke_context, + transaction_context, + data_len, + ) + } + ResizeEphemeralAccount { new_data_len } => { + process_resize_ephemeral_account( + invoke_context, + transaction_context, + new_data_len, + ) + } + CloseEphemeralAccount => process_close_ephemeral_account( + invoke_context, + transaction_context, + ), Noop(_) => Ok(()), } } diff --git a/programs/magicblock/src/mutate_accounts/process_mutate_accounts.rs b/programs/magicblock/src/mutate_accounts/process_mutate_accounts.rs index 475a77f9e..17f29ccb7 100644 --- a/programs/magicblock/src/mutate_accounts/process_mutate_accounts.rs +++ b/programs/magicblock/src/mutate_accounts/process_mutate_accounts.rs @@ -38,7 +38,7 @@ pub(crate) fn process_mutate_accounts( ic_msg!( invoke_context, "Validator identity '{}' not in signers", - &validator_authority_id.to_string() + validator_authority_id ); return Err(InstructionError::MissingRequiredSignature); } @@ -107,6 +107,19 @@ pub(crate) fn process_mutate_accounts( .get_index_of_instruction_account_in_transaction(account_idx)?; let account = transaction_context .get_account_at_index(account_transaction_index)?; + // we do not allow for account modification if the + // account is ephemeral (i.e. exists locally on ER) + if account.borrow().ephemeral() { + let key = transaction_context + .get_key_of_account_at_index(account_transaction_index)?; + account_mods.remove(key); + ic_msg!( + invoke_context, + "MutateAccounts: skipping ephemeral account {}", + key + ); + continue; + } let account_key = transaction_context .get_key_of_account_at_index(account_transaction_index)?; @@ -305,7 +318,7 @@ pub(crate) fn process_mutate_accounts( // Now it is super unlikely for the transaction to fail since all checks passed. // The only option would be if another instruction runs after it which at this point - // is impossible since we create/send them from insider our validator. + // is impossible since we create/send them from inside of our validator. // Thus we can persist the applied data mods to make them available for ledger replay. for resolved_data in memory_data_mods { resolved_data diff --git a/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs b/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs index 77f57de3e..8817e5985 100644 --- a/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs +++ b/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs @@ -142,6 +142,16 @@ pub(crate) fn process_schedule_commit( return Err(InstructionError::InvalidAccountData); } + // Prevent ephemeral accounts from being committed to base chain + if acc.borrow().ephemeral() { + ic_msg!( + invoke_context, + "ScheduleCommit ERR: account {} is ephemeral and cannot be committed to base chain", + acc_pubkey + ); + return Err(InstructionError::InvalidAccountData); + } + { let is_delegated = acc.borrow().delegated(); diff --git a/programs/magicblock/src/test_utils/mod.rs b/programs/magicblock/src/test_utils/mod.rs index dafd8dc97..7929fc14a 100644 --- a/programs/magicblock/src/test_utils/mod.rs +++ b/programs/magicblock/src/test_utils/mod.rs @@ -9,6 +9,7 @@ use std::{ }; use magicblock_core::traits::PersistsAccountModData; +use magicblock_magic_program_api::{id, EPHEMERAL_VAULT_PUBKEY}; use solana_account::AccountSharedData; use solana_instruction::{error::InstructionError, AccountMeta}; use solana_log_collector::log::debug; @@ -30,6 +31,13 @@ pub fn ensure_started_validator(map: &mut HashMap) { AccountSharedData::new(AUTHORITY_BALANCE, 0, &system_program::id()) }); + // Ensure ephemeral vault account exists + map.entry(EPHEMERAL_VAULT_PUBKEY).or_insert_with(|| { + let mut vault = AccountSharedData::new(0, 0, &id()); + vault.set_ephemeral(true); + vault + }); + let stub = Arc::new(PersisterStub::default()); init_persister(stub); diff --git a/programs/magicblock/src/utils/instruction_context_frames.rs b/programs/magicblock/src/utils/instruction_context_frames.rs index 213ef9377..d6b038dbf 100644 --- a/programs/magicblock/src/utils/instruction_context_frames.rs +++ b/programs/magicblock/src/utils/instruction_context_frames.rs @@ -1,4 +1,3 @@ -#![cfg(not(test))] use solana_instruction::error::InstructionError; use solana_pubkey::Pubkey; use solana_transaction_context::{InstructionContext, TransactionContext}; @@ -144,7 +143,8 @@ impl<'a> TryFrom<&'a TransactionContext> for InstructionContextFrames<'a> { frames.push(frame); } - let current_frame_idx = current_frame_idx.expect("current frame not found in frames which is invalid validator behavior"); + let current_frame_idx = + current_frame_idx.ok_or(InstructionError::InvalidAccountData)?; Ok(InstructionContextFrames::new(frames, current_frame_idx)) } } diff --git a/programs/magicblock/src/utils/mod.rs b/programs/magicblock/src/utils/mod.rs index a05af586d..0af80b180 100644 --- a/programs/magicblock/src/utils/mod.rs +++ b/programs/magicblock/src/utils/mod.rs @@ -2,7 +2,6 @@ use solana_pubkey::Pubkey; pub mod account_actions; pub mod accounts; -#[cfg(not(test))] pub(crate) mod instruction_context_frames; pub mod instruction_utils; diff --git a/test-integration/Cargo.lock b/test-integration/Cargo.lock index 11c887e53..4c98c8b78 100644 --- a/test-integration/Cargo.lock +++ b/test-integration/Cargo.lock @@ -2241,10 +2241,10 @@ dependencies = [ [[package]] name = "guinea" -version = "0.6.2" +version = "0.7.0" dependencies = [ "bincode", - "magicblock-magic-program-api 0.6.2", + "magicblock-magic-program-api 0.7.0", "serde", "solana-program", ] @@ -3257,7 +3257,7 @@ dependencies = [ [[package]] name = "magicblock-account-cloner" -version = "0.6.2" +version = "0.7.0" dependencies = [ "async-trait", "bincode", @@ -3267,7 +3267,7 @@ dependencies = [ "magicblock-config", "magicblock-core", "magicblock-ledger", - "magicblock-magic-program-api 0.6.2", + "magicblock-magic-program-api 0.7.0", "magicblock-program", "magicblock-rpc-client", "rand 0.9.2", @@ -3289,7 +3289,7 @@ dependencies = [ [[package]] name = "magicblock-accounts" -version = "0.6.2" +version = "0.7.0" dependencies = [ "async-trait", "magicblock-account-cloner", @@ -3311,7 +3311,7 @@ dependencies = [ [[package]] name = "magicblock-accounts-db" -version = "0.6.2" +version = "0.7.0" dependencies = [ "lmdb-rkv", "magicblock-config", @@ -3326,7 +3326,7 @@ dependencies = [ [[package]] name = "magicblock-aperture" -version = "0.6.2" +version = "0.7.0" dependencies = [ "agave-geyser-plugin-interface", "arc-swap", @@ -3374,7 +3374,7 @@ dependencies = [ [[package]] name = "magicblock-api" -version = "0.6.2" +version = "0.7.0" dependencies = [ "anyhow", "borsh 1.6.0", @@ -3389,7 +3389,7 @@ dependencies = [ "magicblock-config", "magicblock-core", "magicblock-ledger", - "magicblock-magic-program-api 0.6.2", + "magicblock-magic-program-api 0.7.0", "magicblock-metrics", "magicblock-processor", "magicblock-program", @@ -3429,7 +3429,7 @@ dependencies = [ [[package]] name = "magicblock-chainlink" -version = "0.6.2" +version = "0.7.0" dependencies = [ "arc-swap", "async-trait", @@ -3441,7 +3441,7 @@ dependencies = [ "magicblock-config", "magicblock-core", "magicblock-delegation-program 1.1.3 (git+https://github.com/magicblock-labs/delegation-program.git?rev=1874b4f5f5f55cb9ab54b64de2cc0d41107d1435)", - "magicblock-magic-program-api 0.6.2", + "magicblock-magic-program-api 0.7.0", "magicblock-metrics", "parking_lot", "scc", @@ -3483,7 +3483,7 @@ dependencies = [ [[package]] name = "magicblock-committor-program" -version = "0.6.2" +version = "0.7.0" dependencies = [ "borsh 1.6.0", "paste", @@ -3495,7 +3495,7 @@ dependencies = [ [[package]] name = "magicblock-committor-service" -version = "0.6.2" +version = "0.7.0" dependencies = [ "async-trait", "base64 0.21.7", @@ -3539,7 +3539,7 @@ dependencies = [ [[package]] name = "magicblock-config" -version = "0.6.2" +version = "0.7.0" dependencies = [ "clap", "derive_more", @@ -3557,10 +3557,10 @@ dependencies = [ [[package]] name = "magicblock-core" -version = "0.6.2" +version = "0.7.0" dependencies = [ "flume", - "magicblock-magic-program-api 0.6.2", + "magicblock-magic-program-api 0.7.0", "solana-account", "solana-account-decoder", "solana-hash", @@ -3616,7 +3616,7 @@ dependencies = [ [[package]] name = "magicblock-ledger" -version = "0.6.2" +version = "0.7.0" dependencies = [ "arc-swap", "bincode", @@ -3666,7 +3666,7 @@ dependencies = [ [[package]] name = "magicblock-magic-program-api" -version = "0.6.2" +version = "0.7.0" dependencies = [ "bincode", "serde", @@ -3675,7 +3675,7 @@ dependencies = [ [[package]] name = "magicblock-metrics" -version = "0.6.2" +version = "0.7.0" dependencies = [ "http-body-util", "hyper 1.8.1", @@ -3689,7 +3689,7 @@ dependencies = [ [[package]] name = "magicblock-processor" -version = "0.6.2" +version = "0.7.0" dependencies = [ "bincode", "magicblock-accounts-db", @@ -3712,7 +3712,7 @@ dependencies = [ "solana-pubkey", "solana-rent-collector", "solana-sdk-ids", - "solana-svm 2.2.1 (git+https://github.com/magicblock-labs/magicblock-svm.git?rev=3e9456ec4)", + "solana-svm 2.2.1 (git+https://github.com/magicblock-labs/magicblock-svm.git?rev=bdbaac86)", "solana-svm-transaction", "solana-system-program", "solana-transaction", @@ -3725,12 +3725,12 @@ dependencies = [ [[package]] name = "magicblock-program" -version = "0.6.2" +version = "0.7.0" dependencies = [ "bincode", "lazy_static", "magicblock-core", - "magicblock-magic-program-api 0.6.2", + "magicblock-magic-program-api 0.7.0", "num-derive", "num-traits", "parking_lot", @@ -3757,7 +3757,7 @@ dependencies = [ [[package]] name = "magicblock-rpc-client" -version = "0.6.2" +version = "0.7.0" dependencies = [ "solana-account", "solana-account-decoder-client-types", @@ -3779,7 +3779,7 @@ dependencies = [ [[package]] name = "magicblock-table-mania" -version = "0.6.2" +version = "0.7.0" dependencies = [ "ed25519-dalek", "magicblock-metrics", @@ -3805,7 +3805,7 @@ dependencies = [ [[package]] name = "magicblock-task-scheduler" -version = "0.6.2" +version = "0.7.0" dependencies = [ "bincode", "chrono", @@ -3831,7 +3831,7 @@ dependencies = [ [[package]] name = "magicblock-validator-admin" -version = "0.6.2" +version = "0.7.0" dependencies = [ "magicblock-delegation-program 1.1.3 (git+https://github.com/magicblock-labs/delegation-program.git?rev=1874b4f5f5f55cb9ab54b64de2cc0d41107d1435)", "magicblock-program", @@ -3848,7 +3848,7 @@ dependencies = [ [[package]] name = "magicblock-version" -version = "0.6.2" +version = "0.7.0" dependencies = [ "git-version", "rustc_version", @@ -4748,7 +4748,7 @@ dependencies = [ "bincode", "borsh 1.6.0", "ephemeral-rollups-sdk", - "magicblock-magic-program-api 0.6.2", + "magicblock-magic-program-api 0.7.0", "serde", "solana-program", ] @@ -4772,7 +4772,7 @@ dependencies = [ "borsh 1.6.0", "ephemeral-rollups-sdk", "magicblock-delegation-program 1.1.3 (git+https://github.com/magicblock-labs/delegation-program.git?rev=1874b4f5f5f55cb9ab54b64de2cc0d41107d1435)", - "magicblock-magic-program-api 0.6.2", + "magicblock-magic-program-api 0.7.0", "rkyv 0.7.45", "solana-program", "static_assertions", @@ -5806,7 +5806,7 @@ dependencies = [ "ephemeral-rollups-sdk", "integration-test-tools", "magicblock-core", - "magicblock-magic-program-api 0.6.2", + "magicblock-magic-program-api 0.7.0", "program-schedulecommit", "rand 0.8.5", "schedulecommit-client", @@ -5824,7 +5824,7 @@ version = "0.0.0" dependencies = [ "integration-test-tools", "magicblock-core", - "magicblock-magic-program-api 0.6.2", + "magicblock-magic-program-api 0.7.0", "program-schedulecommit", "program-schedulecommit-security", "schedulecommit-client", @@ -6225,7 +6225,7 @@ dependencies = [ [[package]] name = "solana-account" version = "2.2.1" -source = "git+https://github.com/magicblock-labs/solana-account.git?rev=2246929#2246929c6614f60d9909fdd117aab3bc454a9775" +source = "git+https://github.com/magicblock-labs/solana-account.git?rev=6eae52b#6eae52bde25e90b3c79d4935ce2b267e35338945" dependencies = [ "bincode", "qualifier_attr", @@ -8543,7 +8543,7 @@ dependencies = [ [[package]] name = "solana-storage-proto" -version = "0.6.2" +version = "0.7.0" dependencies = [ "bincode", "bs58", @@ -8658,7 +8658,7 @@ dependencies = [ [[package]] name = "solana-svm" version = "2.2.1" -source = "git+https://github.com/magicblock-labs/magicblock-svm.git?rev=3e9456ec4#3e9456ec4d5798ad8281537501c1e777d6888ba3" +source = "git+https://github.com/magicblock-labs/magicblock-svm.git?rev=bdbaac86#bdbaac86043180fd946cd2fb9a88cf23dea47f97" dependencies = [ "ahash 0.8.12", "log", @@ -10044,7 +10044,7 @@ dependencies = [ [[package]] name = "test-kit" -version = "0.6.2" +version = "0.7.0" dependencies = [ "guinea", "magicblock-accounts-db", diff --git a/test-integration/Cargo.toml b/test-integration/Cargo.toml index 406a37b0b..823294242 100644 --- a/test-integration/Cargo.toml +++ b/test-integration/Cargo.toml @@ -78,7 +78,7 @@ rkyv = "0.7.45" schedulecommit-client = { path = "schedulecommit/client" } serde = "1.0.217" serial_test = "3.2.0" -solana-account = { git = "https://github.com/magicblock-labs/solana-account.git", rev = "2246929" } +solana-account = { git = "https://github.com/magicblock-labs/solana-account.git", rev = "6eae52b" } solana-loader-v2-interface = "2.2" solana-loader-v3-interface = "4.0" solana-loader-v4-interface = "2.1" @@ -114,4 +114,4 @@ url = "2.5.0" solana-storage-proto = { path = "../storage-proto" } # same reason as above rocksdb = { git = "https://github.com/magicblock-labs/rust-rocksdb.git", rev = "6d975197" } -solana-account = { git = "https://github.com/magicblock-labs/solana-account.git", rev = "2246929" } +solana-account = { git = "https://github.com/magicblock-labs/solana-account.git", rev = "6eae52b" } diff --git a/test-kit/src/lib.rs b/test-kit/src/lib.rs index c29fb2011..aadf1520c 100644 --- a/test-kit/src/lib.rs +++ b/test-kit/src/lib.rs @@ -262,6 +262,26 @@ impl ExecutionTestEnv { ) } + /// Builds a transaction with additional signers beyond the payer. + pub fn build_transaction_with_signers( + &self, + ixs: &[Instruction], + additional_signers: &[&Keypair], + ) -> Transaction { + let payer = { + let index = self.payer_index.fetch_add(1, Ordering::Relaxed); + &self.payers[index % self.payers.len()] + }; + let mut signers: Vec<&Keypair> = vec![payer]; + signers.extend(additional_signers); + Transaction::new_signed_with_payer( + ixs, + Some(&payer.pubkey()), + &signers, + self.ledger.latest_blockhash(), + ) + } + /// Submits a transaction for execution and waits for its result. #[instrument(skip(self, txn))] pub async fn execute_transaction( @@ -322,6 +342,19 @@ impl ExecutionTestEnv { } } + pub fn try_get_account( + &self, + pubkey: Pubkey, + ) -> Option> { + self.accountsdb + .get_account(&pubkey) + .map(|account| CommitableAccount { + pubkey, + account, + db: &self.accountsdb, + }) + } + pub fn get_payer(&self) -> CommitableAccount<'_> { let payer = { let index = self.payer_index.load(Ordering::Relaxed);