diff --git a/.github/packages/npm-package/package.json b/.github/packages/npm-package/package.json index f4913640d..9ecbbedae 100644 --- a/.github/packages/npm-package/package.json +++ b/.github/packages/npm-package/package.json @@ -2,13 +2,13 @@ "name": "@magicblock-labs/ephemeral-validator", "version": "0.3.1", "description": "MagicBlock Ephemeral Validator", - "homepage": "https://github.com/magicblock-labs/ephemeral-validator#readme", + "homepage": "https://github.com/magicblock-labs/magicblock-validator#readme", "bugs": { - "url": "https://github.com/magicblock-labs/ephemeral-validator/issues" + "url": "https://github.com/magicblock-labs/magicblock-validator/issues" }, "repository": { "type": "git", - "url": "https://github.com/magicblock-labs/ephemeral-validator.git" + "url": "https://github.com/magicblock-labs/magicblock-validator.git" }, "license": "Business Source License 1.1", "bin": { diff --git a/.github/packages/npm-package/package.json.tmpl b/.github/packages/npm-package/package.json.tmpl index bce48a465..96e7615a7 100644 --- a/.github/packages/npm-package/package.json.tmpl +++ b/.github/packages/npm-package/package.json.tmpl @@ -4,10 +4,10 @@ "version": "0.3.1", "repository": { "type": "git", - "url": "git+https://github.com/magicblock-labs/ephemeral-validator.git" + "url": "git+https://github.com/magicblock-labs/magicblock-validator.git" }, "bugs": { - "url": "https://github.com/magicblock-labs/ephemeral-validator/issues" + "url": "https://github.com/magicblock-labs/magicblock-validator/issues" }, "license": "Business Source License 1.1", "private": false, diff --git a/Cargo.lock b/Cargo.lock index 0757a67ec..9e1ae28a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2735,6 +2735,7 @@ dependencies = [ "solana-account-decoder-client-types", "solana-loader-v3-interface", "solana-loader-v4-interface", + "solana-program", "solana-pubkey", "solana-pubsub-client", "solana-rpc-client", @@ -2744,6 +2745,8 @@ dependencies = [ "solana-signer", "solana-system-interface", "solana-transaction-error", + "spl-token", + "spl-token-2022 6.0.0", "thiserror 1.0.69", "tokio", "tokio-stream", diff --git a/magicblock-chainlink/Cargo.toml b/magicblock-chainlink/Cargo.toml index 50fd35318..ae8957a78 100644 --- a/magicblock-chainlink/Cargo.toml +++ b/magicblock-chainlink/Cargo.toml @@ -21,6 +21,7 @@ solana-account-decoder = { workspace = true } solana-account-decoder-client-types = { workspace = true } solana-loader-v3-interface = { workspace = true, features = ["serde"] } solana-loader-v4-interface = { workspace = true, features = ["serde"] } +solana-program = { workspace = true } solana-pubkey = { workspace = true } solana-pubsub-client = { workspace = true } solana-rpc-client = { workspace = true } @@ -30,6 +31,8 @@ solana-sdk-ids = { workspace = true } solana-signer = { workspace = true } solana-system-interface = { workspace = true } solana-transaction-error = { workspace = true } +spl-token = { workspace = true } +spl-token-2022 = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["full"] } tokio-stream = { workspace = true } diff --git a/magicblock-chainlink/src/chainlink/fetch_cloner.rs b/magicblock-chainlink/src/chainlink/fetch_cloner.rs index 55e61a438..155ed891d 100644 --- a/magicblock-chainlink/src/chainlink/fetch_cloner.rs +++ b/magicblock-chainlink/src/chainlink/fetch_cloner.rs @@ -13,9 +13,11 @@ use dlp::{ use log::*; use magicblock_core::traits::AccountsBank; use magicblock_metrics::metrics::{self, AccountFetchOrigin}; -use solana_account::{AccountSharedData, ReadableAccount}; +use solana_account::{Account, AccountSharedData, ReadableAccount}; +use solana_program::{program_option::COption, program_pack::Pack}; use solana_pubkey::Pubkey; -use solana_sdk::system_program; +use solana_sdk::{pubkey, rent::Rent, system_program}; +use spl_token::state::{Account as SplAccount, AccountState}; use tokio::{ sync::{mpsc, oneshot}, task, @@ -664,14 +666,15 @@ where trace!("Fetched {accs:?}"); - let (not_found, plain, owned_by_deleg, programs) = + let (not_found, plain, owned_by_deleg, programs, atas) = accs.into_iter().zip(pubkeys).fold( - (vec![], vec![], vec![], vec![]), + (vec![], vec![], vec![], vec![], vec![]), |( mut not_found, mut plain, mut owned_by_deleg, mut programs, + mut atas, ), (acc, &pubkey)| { use RemoteAccount::*; @@ -698,8 +701,7 @@ where // to fail if !account_shared_data .owner() - .eq(&solana_sdk::native_loader::id( - )) + .eq(&solana_sdk::native_loader::id()) { programs.push(( pubkey, @@ -711,6 +713,13 @@ where "Not cloning native loader program account: {pubkey} (should have been blacklisted)", ); } + } else if let Some(ata) = is_ata(&pubkey, &account_shared_data) { + atas.push(( + pubkey, + account_shared_data, + ata, + slot, + )); } else { plain.push(AccountCloneRequest { pubkey, @@ -725,7 +734,7 @@ where }; } } - (not_found, plain, owned_by_deleg, programs) + (not_found, plain, owned_by_deleg, programs, atas) }, ); @@ -746,8 +755,12 @@ where .iter() .map(|(p, _, _)| p.to_string()) .collect::>(); + let atas = atas + .iter() + .map(|(a, _, _, _)| a.to_string()) + .collect::>(); trace!( - "Fetched accounts: \nnot_found: {not_found:?} \nplain: {plain:?} \nowned_by_deleg: {owned_by_deleg:?}\nprograms: {programs:?}", + "Fetched accounts: \nnot_found: {not_found:?} \nplain: {plain:?} \nowned_by_deleg: {owned_by_deleg:?}\nprograms: {programs:?} \natas: {atas:?}", ); } @@ -808,7 +821,7 @@ where let mut missing_delegation_record = vec![]; // We remove all new subs for accounts that were not found or already in the bank - let (accounts_to_clone, record_subs) = { + let (mut accounts_to_clone, record_subs) = { let joined = fetch_with_delegation_record_join_set.join_all().await; let (errors, accounts_fully_resolved) = joined.into_iter().fold( (vec![], vec![]), @@ -871,7 +884,7 @@ where // NOTE: failing here is fine when resolving all accounts for a transaction // since if something is off we better not run it anyways // However we may consider a different behavior when user is getting - // mutliple accounts. + // multiple accounts. let delegation_record = match Self::parse_delegation_record( delegation_record_data.data(), delegation_record_pubkey, @@ -1091,14 +1104,102 @@ where )); } - // Cancel new subs for accounts we don't clone + // We will compute subscription cancellations after ATA handling, once accounts_to_clone is finalized + + // Handle ATAs: for each detected ATA, we derive the eATA PDA, subscribe to both, + // and, if the ATA is delegated to us and the eATA exists, we clone the eATA data + // into the ATA in the bank. + if !atas.is_empty() { + let mut ata_join_set = JoinSet::new(); + + // Subscribe first so subsequent fetches are kept up-to-date + for (ata_pubkey, _, ata_info, slot_for_ata) in &atas { + let _ = self.subscribe_to_account(ata_pubkey).await; + if let Some((eata, _)) = try_derive_eata_address_and_bump( + &ata_info.owner, + &ata_info.mint, + ) { + let _ = self.subscribe_to_account(&eata).await; + + let effective_slot = + if let Some(min_slot) = min_context_slot { + min_slot.max(*slot_for_ata) + } else { + *slot_for_ata + }; + ata_join_set.spawn(self.task_to_fetch_with_companion( + *ata_pubkey, + eata, + effective_slot, + fetch_origin, + )); + } + } + + let ata_results = ata_join_set.join_all().await; + for res in ata_results.into_iter() { + match res { + Ok(Ok(AccountWithCompanion { + pubkey: ata_pubkey, + account: ata_account, + companion_pubkey: eata_pubkey, + companion_account: maybe_eata_account, + })) => { + // Convert to AccountSharedData using the bank snapshot + let mut account_to_clone = + ata_account.account_shared_data_cloned(); + let mut commit_frequency_ms = None; + if let Some(eata_acc) = maybe_eata_account { + let eata_shared = + eata_acc.account_shared_data_cloned(); + if let Some(deleg) = self + .fetch_and_parse_delegation_record( + eata_pubkey, + self.remote_account_provider.chain_slot(), + fetch_origin, + ) + .await + { + let is_delegated_to_us = deleg + .authority + .eq(&self.validator_pubkey) + || deleg.authority.eq(&Pubkey::default()); + if is_delegated_to_us { + if let Some(projected_ata) = + eata_shared.maybe_into_ata(deleg) + { + account_to_clone = projected_ata; + account_to_clone.set_delegated(true); + commit_frequency_ms = + Some(deleg.commit_frequency_ms); + } + } + } + } + + accounts_to_clone.push(AccountCloneRequest { + pubkey: ata_pubkey, + account: account_to_clone, + commit_frequency_ms, + }); + } + Ok(Err(err)) => { + warn!("Failed to resolve ATA/eATA companion: {err}"); + } + Err(join_err) => { + warn!("Failed to join ATA/eATA fetch task: {join_err}"); + } + } + } + } + + // Compute sub cancellations now since we may potentially fail during a cloning step let acc_subs = pubkeys.iter().filter(|pubkey| { !accounts_to_clone .iter() .any(|request| request.pubkey.eq(pubkey)) && !loaded_programs.iter().any(|p| p.program_id.eq(pubkey)) }); - // Cancel subs for delegated accounts (accounts we clone but don't need to watch) let delegated_acc_subs: HashSet = accounts_to_clone .iter() @@ -1111,7 +1212,6 @@ where }) .collect(); - // Handle sub cancelation now since we may potentially fail during a cloning step cancel_subs( &self.remote_account_provider, CancelStrategy::Hybrid { @@ -3227,3 +3327,152 @@ mod tests { ); } } + +/// Information about an Associated Token Account (ATA) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct AtaInfo { + pub mint: Pubkey, + pub owner: Pubkey, +} + +/// Returns Some(AtaInfo) if the given account is an Associated Token Account (ATA) +/// for the mint/owner contained in its SPL Token account data. +/// Supports both spl-token and spl-token-2022 program owners. +pub(crate) fn is_ata( + account_pubkey: &Pubkey, + account: &AccountSharedData, +) -> Option { + // The account must be owned by the SPL Token program (legacy) or Token-2022 + let token_program_owner = account.owner(); + let is_spl_token = *token_program_owner == spl_token::id(); + let is_token_2022 = *token_program_owner == spl_token_2022::id(); + if !(is_spl_token || is_token_2022) { + return None; + } + + // Parse the token account data to extract mint and token owner + // Layout (at least the first 64 bytes): + // 0..32 -> mint Pubkey + // 32..64 -> owner Pubkey (the wallet the ATA belongs to) + let data = account.data(); + if data.len() < 64 { + return None; + } + + let mint = Pubkey::new_from_array(match data[0..32].try_into() { + Ok(a) => a, + Err(_) => return None, + }); + let wallet_owner = Pubkey::new_from_array(match data[32..64].try_into() { + Ok(a) => a, + Err(_) => return None, + }); + + // Associated token program id (constant) + // ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL + const ASSOCIATED_TOKEN_PROGRAM_ID: Pubkey = + pubkey!("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"); + + // Seeds per SPL ATA derivation: [wallet_owner, token_program_id, mint] + let (derived, _bump) = Pubkey::find_program_address( + &[ + wallet_owner.as_ref(), + token_program_owner.as_ref(), + mint.as_ref(), + ], + &ASSOCIATED_TOKEN_PROGRAM_ID, + ); + + if derived == *account_pubkey { + Some(AtaInfo { + mint, + owner: wallet_owner, + }) + } else { + None + } +} + +/// Derive the Enhanced ATA (eATA) PDA for a given ATA owner and mint. +/// Seeds: [owner, mint, b"5iC4wKZizyxrKh271Xzx3W4Vn2xUyYvSGHeoB2mdw5HA"], program: dlp::id() +const EATA_PROGRAM_ID: Pubkey = + pubkey!("5iC4wKZizyxrKh271Xzx3W4Vn2xUyYvSGHeoB2mdw5HA"); + +pub(crate) fn try_derive_eata_address_and_bump( + owner: &Pubkey, + mint: &Pubkey, +) -> Option<(Pubkey, u8)> { + Pubkey::try_find_program_address( + &[owner.as_ref(), mint.as_ref()], + &EATA_PROGRAM_ID, + ) +} + +/// Utility trait to attempt conversion of an eata into an ata +pub(crate) trait MaybeIntoAta { + fn maybe_into_ata(&self, record: DelegationRecord) -> Option; +} + +#[repr(C)] +pub struct EphemeralAta { + /// The owner (wallet) this ATA belongs to + pub owner: Pubkey, + /// The mint associated with this account + pub mint: Pubkey, + /// The amount of tokens this account holds. + pub amount: u64, +} + +impl Into for EphemeralAta { + fn into(self) -> AccountSharedData { + let token_account = SplAccount { + mint: self.mint, + owner: self.owner, + amount: self.amount, + delegate: COption::None, + state: AccountState::Initialized, + is_native: COption::None, + delegated_amount: 0, + close_authority: COption::None, + }; + + let mut data = vec![0u8; SplAccount::LEN]; + SplAccount::pack(token_account, &mut data) + .expect("pack spl token account"); + let lamports = Rent::default().minimum_balance(data.len()); + + let account = Account { + owner: spl_token::id(), + data, + lamports, + executable: false, + ..Default::default() + }; + + AccountSharedData::from(account) + } +} + +impl MaybeIntoAta for AccountSharedData { + fn maybe_into_ata( + &self, + record: DelegationRecord, + ) -> Option { + if !record.owner.ne(&EATA_PROGRAM_ID) { + return None; + } + let data = self.data(); + if data.len() < 40 { + return None; + } + let owner = Pubkey::new_from_array(data[0..32].try_into().ok()?); + let mint = Pubkey::new_from_array(data[32..64].try_into().ok()?); + let amount = u64::from_le_bytes(data[64..72].try_into().ok()?); + let eata = EphemeralAta { + owner, + mint, + amount, + }; + Some(eata.into()) + } +} diff --git a/magicblock-chainlink/src/testing/eatas.rs b/magicblock-chainlink/src/testing/eatas.rs new file mode 100644 index 000000000..cc9f8409d --- /dev/null +++ b/magicblock-chainlink/src/testing/eatas.rs @@ -0,0 +1,90 @@ +use solana_account::Account; +use solana_program::{program_option::COption, program_pack::Pack}; +use solana_pubkey::{pubkey, Pubkey}; +use solana_sdk::rent::Rent; +use spl_token::state::{Account as SplAccount, AccountState}; + +const SPL_TOKEN_PROGRAM_ID: Pubkey = + pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); + +// Associated Token Program id +const ASSOCIATED_TOKEN_PROGRAM_ID: Pubkey = + pubkey!("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"); + +// eATA PDA derivation seed copied from fetch_cloner.rs +const EATA_PROGRAM_ID: Pubkey = + pubkey!("5iC4wKZizyxrKh271Xzx3W4Vn2xUyYvSGHeoB2mdw5HA"); + +pub fn derive_ata(owner: &Pubkey, mint: &Pubkey) -> Pubkey { + let (addr, _bump) = Pubkey::find_program_address( + &[owner.as_ref(), SPL_TOKEN_PROGRAM_ID.as_ref(), mint.as_ref()], + &ASSOCIATED_TOKEN_PROGRAM_ID, + ); + addr +} + +pub fn derive_eata(owner: &Pubkey, mint: &Pubkey) -> Pubkey { + let (addr, _bump) = Pubkey::find_program_address( + &[owner.as_ref(), mint.as_ref()], + &EATA_PROGRAM_ID, + ); + addr +} + +pub fn create_ata_account(owner: &Pubkey, mint: &Pubkey) -> Account { + let token_account = SplAccount { + mint: *mint, + owner: *owner, + amount: 0, + delegate: COption::None, + state: AccountState::Initialized, + is_native: COption::None, + delegated_amount: 0, + close_authority: COption::None, + }; + + let mut data = vec![0u8; SplAccount::LEN]; + SplAccount::pack(token_account, &mut data).expect("pack spl token account"); + let lamports = Rent::default().minimum_balance(data.len()); + + Account { + owner: SPL_TOKEN_PROGRAM_ID, + data, + lamports, + executable: false, + ..Default::default() + } +} + +pub fn create_eata_account( + owner: &Pubkey, + mint: &Pubkey, + amount: u64, + delegate: bool, +) -> Account { + let mut data = Vec::with_capacity(64 + 8); + data.extend_from_slice(owner.as_ref()); + data.extend_from_slice(mint.as_ref()); + data.extend_from_slice(&amount.to_le_bytes()); + let lamports = Rent::default().minimum_balance(data.len()); + + let owner = if delegate { dlp::ID } else { EATA_PROGRAM_ID }; + + Account { + owner, + data, + lamports, + ..Default::default() + } +} + +/// Internal representation of a token account data. +#[repr(C)] +pub struct EphemeralAta { + /// The owner of the eata + pub owner: Pubkey, + /// The mint associated with this account + pub mint: Pubkey, + /// The amount of tokens this account holds. + pub amount: u64, +} diff --git a/magicblock-chainlink/src/testing/mod.rs b/magicblock-chainlink/src/testing/mod.rs index c924633c4..0ef13ae84 100644 --- a/magicblock-chainlink/src/testing/mod.rs +++ b/magicblock-chainlink/src/testing/mod.rs @@ -7,6 +7,8 @@ pub mod cloner_stub; #[cfg(any(test, feature = "dev-context"))] pub mod deleg; #[cfg(any(test, feature = "dev-context"))] +pub mod eatas; +#[cfg(any(test, feature = "dev-context"))] pub mod rpc_client_mock; #[cfg(any(test, feature = "dev-context"))] pub mod utils; diff --git a/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs b/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs index 92759eac7..c25e8b840 100644 --- a/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs +++ b/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs @@ -36,6 +36,32 @@ pub(crate) fn process_schedule_commit( invoke_context: &mut InvokeContext, opts: ProcessScheduleCommitOptions, ) -> Result<(), InstructionError> { + // SPL Token and ATA/eATA program ids + // Tokenkeg... (SPL Token), ATokenG... (Associated Token Program), 5iC4wK... (eATA program) + const SPL_TOKEN_PROGRAM_ID: Pubkey = + solana_sdk::pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); + const ASSOCIATED_TOKEN_PROGRAM_ID: Pubkey = + solana_sdk::pubkey!("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"); + const EATA_PROGRAM_ID: Pubkey = + solana_sdk::pubkey!("5iC4wKZizyxrKh271Xzx3W4Vn2xUyYvSGHeoB2mdw5HA"); + + // Derive the standard ATA address for given wallet owner and mint + fn derive_ata(owner: &Pubkey, mint: &Pubkey) -> Pubkey { + Pubkey::find_program_address( + &[owner.as_ref(), SPL_TOKEN_PROGRAM_ID.as_ref(), mint.as_ref()], + &ASSOCIATED_TOKEN_PROGRAM_ID, + ) + .0 + } + + // Derive the eATA PDA for given wallet owner and mint + fn derive_eata(owner: &Pubkey, mint: &Pubkey) -> Pubkey { + Pubkey::find_program_address( + &[owner.as_ref(), mint.as_ref()], + &EATA_PROGRAM_ID, + ) + .0 + } const PAYER_IDX: u16 = 0; const MAGIC_CONTEXT_IDX: u16 = PAYER_IDX + 1; @@ -179,9 +205,45 @@ pub(crate) fn process_schedule_commit( let mut account: Account = acc.borrow().to_owned().into(); account.owner = parent_program_id.cloned().unwrap_or(account.owner); + // If this is a delegated SPL Token ATA that was cloned from an eATA, + // we should commit/undelegate the corresponding eATA instead. + let mut target_pubkey = *acc_pubkey; + let acc_borrow = acc.borrow(); + if acc_borrow.delegated() + && acc_borrow.owner() == &SPL_TOKEN_PROGRAM_ID + { + let data = acc_borrow.data(); + if data.len() >= 64 { + // spl-token Account layout: [0..32]=mint, [32..64]=owner + let mint = + Pubkey::new_from_array(match data[0..32].try_into() { + Ok(a) => a, + Err(_) => [0u8; 32], + }); + let wallet_owner = + Pubkey::new_from_array(match data[32..64].try_into() { + Ok(a) => a, + Err(_) => [0u8; 32], + }); + + // Verify that the current pubkey matches the derived ATA + let ata_addr = derive_ata(&wallet_owner, &mint); + if ata_addr == *acc_pubkey { + // Remap to eATA PDA + target_pubkey = derive_eata(&wallet_owner, &mint); + ic_msg!( + invoke_context, + "ScheduleCommit: remapping ATA {} -> eATA {} for commit/undelegate", + acc_pubkey, + target_pubkey + ); + } + } + } + #[allow(clippy::unnecessary_literal_unwrap)] committed_accounts.push(CommittedAccount { - pubkey: *acc_pubkey, + pubkey: target_pubkey, account, }); } diff --git a/programs/magicblock/src/schedule_transactions/process_schedule_commit_tests.rs b/programs/magicblock/src/schedule_transactions/process_schedule_commit_tests.rs index f6e328c24..6d41df877 100644 --- a/programs/magicblock/src/schedule_transactions/process_schedule_commit_tests.rs +++ b/programs/magicblock/src/schedule_transactions/process_schedule_commit_tests.rs @@ -260,6 +260,47 @@ mod tests { use super::*; use crate::utils::instruction_utils::InstructionUtils; + // ---------- Helpers for ATA/eATA remapping tests ---------- + // SPL Token and ATA/eATA program ids + const SPL_TOKEN_PROGRAM_ID: Pubkey = + solana_sdk::pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); + const ASSOCIATED_TOKEN_PROGRAM_ID: Pubkey = + solana_sdk::pubkey!("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"); + const EATA_PROGRAM_ID: Pubkey = + solana_sdk::pubkey!("5iC4wKZizyxrKh271Xzx3W4Vn2xUyYvSGHeoB2mdw5HA"); + + fn derive_ata(owner: &Pubkey, mint: &Pubkey) -> Pubkey { + Pubkey::find_program_address( + &[owner.as_ref(), SPL_TOKEN_PROGRAM_ID.as_ref(), mint.as_ref()], + &ASSOCIATED_TOKEN_PROGRAM_ID, + ) + .0 + } + + fn derive_eata(owner: &Pubkey, mint: &Pubkey) -> Pubkey { + Pubkey::find_program_address( + &[owner.as_ref(), mint.as_ref()], + &EATA_PROGRAM_ID, + ) + .0 + } + + fn make_delegated_spl_ata_account( + owner: &Pubkey, + mint: &Pubkey, + ) -> AccountSharedData { + // Minimal SPL token account data: first 32 bytes mint, next 32 owner + let mut data = vec![0u8; 72]; + data[0..32].copy_from_slice(mint.as_ref()); + data[32..64].copy_from_slice(owner.as_ref()); + + let mut acc = + AccountSharedData::new(0, data.len(), &SPL_TOKEN_PROGRAM_ID); + acc.set_data_from_slice(&data); + acc.set_delegated(true); + acc + } + #[test] fn test_schedule_commit_single_account_success() { init_logger!(); @@ -431,6 +472,171 @@ mod tests { assert_eq!(*committed_account.owner(), DELEGATION_PROGRAM_ID); } + #[test] + fn test_schedule_commit_remaps_delegated_ata_to_eata() { + init_logger!(); + + let payer = + Keypair::from_seed(b"schedule_commit_remap_ata_to_eata").unwrap(); + let wallet_owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let ata_pubkey = derive_ata(&wallet_owner, &mint); + let eata_pubkey = derive_eata(&wallet_owner, &mint); + + // 1) Prepare transaction with our ATA as the only committee + let (mut account_data, mut transaction_accounts) = + prepare_transaction_with_single_committee( + &payer, + Pubkey::new_unique(), + ata_pubkey, + ); + + // Replace the committee account with a delegated SPL-Token ATA layout + account_data.insert( + ata_pubkey, + make_delegated_spl_ata_account(&wallet_owner, &mint), + ); + + // Build ScheduleCommit instruction using the ATA pubkey + let ix = InstructionUtils::schedule_commit_instruction( + &payer.pubkey(), + vec![ata_pubkey], + ); + extend_transaction_accounts_from_ix( + &ix, + &mut account_data, + &mut transaction_accounts, + ); + + // Execute scheduling + let processed_scheduled = process_instruction( + ix.data.as_slice(), + transaction_accounts.clone(), + ix.accounts.clone(), + Ok(()), + ); + + // Extract magic context and then accept scheduled commits + let magic_context_acc = assert_non_accepted_actions( + &processed_scheduled, + &payer.pubkey(), + 1, + ); + + let ix_accept = + InstructionUtils::accept_scheduled_commits_instruction(); + let (mut account_data2, mut transaction_accounts2) = + prepare_transaction_with_single_committee( + &payer, + Pubkey::new_unique(), + ata_pubkey, + ); + extend_transaction_accounts_from_ix_adding_magic_context( + &ix_accept, + magic_context_acc, + &mut account_data2, + &mut transaction_accounts2, + ); + let processed_accepted = process_instruction( + ix_accept.data.as_slice(), + transaction_accounts2, + ix_accept.accounts, + Ok(()), + ); + + let scheduled = + assert_accepted_actions(&processed_accepted, &payer.pubkey(), 1); + // Verify the committed pubkey remapped to eATA + assert_eq!( + scheduled[0].base_intent.get_committed_pubkeys().unwrap(), + vec![eata_pubkey] + ); + } + + #[test] + fn test_schedule_commit_and_undelegate_remaps_delegated_ata_to_eata() { + init_logger!(); + + let payer = + Keypair::from_seed(b"schedule_commit_undelegate_remap_ata_eata") + .unwrap(); + let wallet_owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let ata_pubkey = derive_ata(&wallet_owner, &mint); + let eata_pubkey = derive_eata(&wallet_owner, &mint); + + // 1) Prepare transaction with our ATA as the only committee + let (mut account_data, mut transaction_accounts) = + prepare_transaction_with_single_committee( + &payer, + Pubkey::new_unique(), + ata_pubkey, + ); + + // Replace the committee account with a delegated SPL-Token ATA layout + account_data.insert( + ata_pubkey, + make_delegated_spl_ata_account(&wallet_owner, &mint), + ); + + // Build ScheduleCommitAndUndelegate instruction using the ATA pubkey (writable) + let ix = InstructionUtils::schedule_commit_and_undelegate_instruction( + &payer.pubkey(), + vec![ata_pubkey], + ); + extend_transaction_accounts_from_ix( + &ix, + &mut account_data, + &mut transaction_accounts, + ); + + // Execute scheduling + let processed_scheduled = process_instruction( + ix.data.as_slice(), + transaction_accounts.clone(), + ix.accounts.clone(), + Ok(()), + ); + + // Extract magic context and then accept scheduled commits + let magic_context_acc = assert_non_accepted_actions( + &processed_scheduled, + &payer.pubkey(), + 1, + ); + + let ix_accept = + InstructionUtils::accept_scheduled_commits_instruction(); + let (mut account_data2, mut transaction_accounts2) = + prepare_transaction_with_single_committee( + &payer, + Pubkey::new_unique(), + ata_pubkey, + ); + extend_transaction_accounts_from_ix_adding_magic_context( + &ix_accept, + magic_context_acc, + &mut account_data2, + &mut transaction_accounts2, + ); + let processed_accepted = process_instruction( + ix_accept.data.as_slice(), + transaction_accounts2, + ix_accept.accounts, + Ok(()), + ); + + let scheduled = + assert_accepted_actions(&processed_accepted, &payer.pubkey(), 1); + // Verify the committed pubkey remapped to eATA + assert_eq!( + scheduled[0].base_intent.get_committed_pubkeys().unwrap(), + vec![eata_pubkey] + ); + // And the intent contains undelegation + assert!(scheduled[0].base_intent.is_undelegate()); + } + #[test] fn test_schedule_commit_three_accounts_success() { init_logger!(); diff --git a/test-integration/Cargo.lock b/test-integration/Cargo.lock index a2b6ffbb3..40152dee8 100644 --- a/test-integration/Cargo.lock +++ b/test-integration/Cargo.lock @@ -3121,169 +3121,6 @@ dependencies = [ "solana-inline-spl", "solana-rpc-client", "solana-sdk", - "thiserror 1.0.69", - "tokio", - "tokio-util 0.7.15", -] - -[[package]] -name = "magicblock-chainlink" -version = "0.4.1" -dependencies = [ - "arc-swap", - "async-trait", - "bincode", - "env_logger 0.11.8", - "futures-util", - "log", - "lru", - "magicblock-config", - "magicblock-core", - "magicblock-delegation-program", - "magicblock-magic-program-api 0.4.1", - "magicblock-metrics", - "solana-account", - "solana-account-decoder", - "solana-account-decoder-client-types", - "solana-loader-v3-interface 3.0.0", - "solana-loader-v4-interface", - "solana-pubkey", - "solana-pubsub-client", - "solana-rpc-client", - "solana-rpc-client-api", - "solana-sdk", - "solana-sdk-ids", - "solana-signer", - "solana-system-interface", - "solana-transaction-error", - "thiserror 1.0.69", - "tokio", - "tokio-stream", - "tokio-util 0.7.15", -] - -[[package]] -name = "magicblock-committor-program" -version = "0.4.1" -dependencies = [ - "borsh 1.5.7", - "paste", - "solana-account", - "solana-program", - "solana-pubkey", - "thiserror 1.0.69", -] - -[[package]] -name = "magicblock-committor-service" -version = "0.4.1" -dependencies = [ - "async-trait", - "base64 0.21.7", - "bincode", - "borsh 1.5.7", - "dyn-clone", - "futures-util", - "log", - "lru", - "magicblock-committor-program", - "magicblock-delegation-program", - "magicblock-metrics", - "magicblock-program", - "magicblock-rpc-client", - "magicblock-table-mania", - "rusqlite", - "solana-account", - "solana-pubkey", - "solana-rpc-client", - "solana-rpc-client-api", - "solana-sdk", - "solana-transaction-status-client-types", - "static_assertions", - "tempfile", - "thiserror 1.0.69", - "tokio", - "tokio-util 0.7.15", -] - -[[package]] -name = "magicblock-config" -version = "0.4.1" -dependencies = [ - "clap", - "derive_more", - "figment", - "humantime-serde", - "isocountry", - "serde", - "serde_with", - "solana-keypair", - "solana-pubkey", - "solana-signer", - "toml 0.8.23", - "url", -] - -[[package]] -name = "magicblock-core" -version = "0.4.1" -dependencies = [ - "flume", - "magicblock-magic-program-api 0.4.1", - "solana-account", - "solana-account-decoder", - "solana-hash", - "solana-program", - "solana-pubkey", - "solana-signature", - "solana-transaction", - "solana-transaction-context", - "solana-transaction-error", - "tokio", -] - -[[package]] -name = "magicblock-delegation-program" -version = "1.1.0" -source = "git+https://github.com/magicblock-labs/delegation-program.git?rev=aa1de56d90c#aa1de56d90c8a242377accd59899f272f0131f8c" -dependencies = [ - "bincode", - "borsh 1.5.7", - "bytemuck", - "num_enum", - "paste", - "pinocchio", - "pinocchio-log", - "pinocchio-pubkey", - "pinocchio-system", - "solana-curve25519", - "solana-program", - "solana-security-txt", - "thiserror 1.0.69", -] - -[[package]] -name = "magicblock-ledger" -version = "0.4.1" -dependencies = [ - "arc-swap", - "bincode", - "byteorder", - "fs_extra", - "libc", - "log", - "magicblock-core", - "magicblock-metrics", - "num-format", - "num_cpus", - "prost", - "rocksdb", - "scc", - "serde", - "solana-account-decoder", - "solana-measure", - "solana-metrics", - "solana-sdk", "solana-storage-proto", "solana-transaction-status", "thiserror 1.0.69", @@ -9271,6 +9108,7 @@ dependencies = [ "solana-sdk", "solana-sdk-ids", "solana-system-interface", + "spl-token", "tokio", ] diff --git a/test-integration/Cargo.toml b/test-integration/Cargo.toml index 0bcb42cc3..8734cddd3 100644 --- a/test-integration/Cargo.toml +++ b/test-integration/Cargo.toml @@ -89,6 +89,7 @@ solana-sdk-ids = { version = "2.2" } solana-system-interface = "1.0" solana-transaction-status = "2.2" spl-memo-interface = "1.0" +spl-token = "=7.0" teepee = "0.0.1" tempfile = "3.10.1" test-chainlink = { path = "./test-chainlink" } diff --git a/test-integration/test-chainlink/Cargo.toml b/test-integration/test-chainlink/Cargo.toml index b2f0da442..ca31c5ceb 100644 --- a/test-integration/test-chainlink/Cargo.toml +++ b/test-integration/test-chainlink/Cargo.toml @@ -22,5 +22,6 @@ solana-rpc-client-api = { workspace = true } solana-sdk = { workspace = true } solana-sdk-ids = { workspace = true } solana-system-interface = { workspace = true } +spl-token = { workspace = true } integration-test-tools = { workspace = true } tokio = { workspace = true, features = ["full"] } diff --git a/test-integration/test-chainlink/tests/ix_ata_eata_replace.rs b/test-integration/test-chainlink/tests/ix_ata_eata_replace.rs new file mode 100644 index 000000000..f2c5eecd5 --- /dev/null +++ b/test-integration/test-chainlink/tests/ix_ata_eata_replace.rs @@ -0,0 +1,161 @@ +use log::debug; +use magicblock_chainlink::{ + testing::{deleg::add_delegation_record_for, init_logger}, + AccountFetchOrigin, +}; +use solana_account::{ReadableAccount}; +use solana_pubkey::{pubkey, Pubkey}; +use solana_sdk::signature::{Keypair, Signer}; +use spl_token::solana_program::program_pack::Pack; +use spl_token::state::AccountState; +use magicblock_chainlink::testing::eatas::{create_ata_account, create_eata_account, derive_ata, derive_eata}; +use test_chainlink::test_context::TestContext; + + +#[tokio::test] +async fn ixtest_ata_eata_replace_when_delegated_to_us() { + init_logger(); + + // Use mocked TestContext (no external RPC) + let slot = 100u64; + let ctx = TestContext::init(slot).await; + + // Wallet owner and mint + let wallet_owner = Keypair::new().pubkey(); + let mint = pubkey!("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); + let amount = 200; + + // Derive ATA and eATA addresses + let ata_pubkey = derive_ata(&wallet_owner, &mint); + let eata_pubkey = derive_eata(&wallet_owner, &mint); + + // Create mock ATA and eATA accounts + let ata = create_ata_account(&wallet_owner, &mint); + let eata = create_eata_account(&wallet_owner, &mint, amount, true); + + ctx.rpc_client.add_account( + ata_pubkey, + ata.clone(), + ); + ctx.rpc_client.add_account( + eata_pubkey, + eata.clone(), + ); + + // Add delegation record for ATA delegated to our validator + let validator = ctx.validator_pubkey; + add_delegation_record_for(&ctx.rpc_client, eata_pubkey, validator, wallet_owner); + + // Ensure account (this triggers fetch_cloner logic including ATA/eATA handling) + let pubkeys = [ata_pubkey]; + let res = ctx + .chainlink + .ensure_accounts(&pubkeys, None, AccountFetchOrigin::GetAccount, None) + .await + .expect("ensure_accounts ok"); + debug!("res: {:?}", res); + + // Cloned account should match eATA data (replacement) + let cloned = ctx + .cloner + .get_account(&ata_pubkey) + .expect("ATA should be cloned into bank"); + let spl_token_account = spl_token::state::Account::unpack_from_slice(cloned.data()).unwrap(); + assert_eq!(spl_token_account.mint, mint); + assert_eq!(spl_token_account.amount, amount); + assert_eq!(spl_token_account.owner, wallet_owner); + assert!(spl_token_account.close_authority.is_none()); + assert_eq!(spl_token_account.state, AccountState::Initialized); + assert_eq!(spl_token_account.delegated_amount, 0); + assert!(spl_token_account.is_native.is_none()); + assert!(cloned.delegated()) +} + +#[tokio::test] +async fn ixtest_ata_eata_no_replace_when_not_delegated() { + init_logger(); + + let slot = 101u64; + let ctx = TestContext::init(slot).await; + + let wallet_owner = Keypair::new().pubkey(); + let mint = pubkey!("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); + + let ata_pubkey = derive_ata(&wallet_owner, &mint); + let ata = create_ata_account(&wallet_owner, &mint); + + ctx.rpc_client.add_account( + ata_pubkey, + ata.clone(), + ); + + // Note: No delegation record added here + let pubkeys = [ata_pubkey]; + let _res = ctx + .chainlink + .ensure_accounts(&pubkeys, None, AccountFetchOrigin::GetAccount, None) + .await + .expect("ensure_accounts ok"); + + let cloned = ctx + .cloner + .get_account(&ata_pubkey) + .expect("ATA should be cloned"); + + // Should keep original ATA data since not delegated + assert_eq!(cloned.data(), ata.data()); + assert!(!cloned.delegated()) +} + +#[tokio::test] +async fn ixtest_ata_eata_no_replace_when_not_delegated_to_us() { + init_logger(); + + // Use mocked TestContext (no external RPC) + let slot = 100u64; + let ctx = TestContext::init(slot).await; + + // Wallet owner and mint + let wallet_owner = Keypair::new().pubkey(); + let mint = Pubkey::new_unique(); + let amount = 200; + + // Derive ATA and eATA addresses + let ata_pubkey = derive_ata(&wallet_owner, &mint); + let eata_pubkey = derive_eata(&wallet_owner, &mint); + + // Create mock ATA and eATA accounts + let ata = create_ata_account(&wallet_owner, &mint); + let eata = create_eata_account(&wallet_owner, &mint, amount, true); + + ctx.rpc_client.add_account( + ata_pubkey, + ata.clone(), + ); + ctx.rpc_client.add_account( + eata_pubkey, + eata.clone(), + ); + + // Add delegation record to a random validator + add_delegation_record_for(&ctx.rpc_client, eata_pubkey, Keypair::new().pubkey(), wallet_owner); + + // Ensure account (this triggers fetch_cloner logic including ATA/eATA handling) + let pubkeys = [ata_pubkey]; + let res = ctx + .chainlink + .ensure_accounts(&pubkeys, None, AccountFetchOrigin::GetAccount, None) + .await + .expect("ensure_accounts ok"); + debug!("res: {:?}", res); + + // Cloned account should still be the ata, since the eata is not delegated to our validator + let cloned = ctx + .cloner + .get_account(&ata_pubkey) + .expect("ATA should be cloned into bank"); + + // Should keep original ATA data since not delegated to us + assert_eq!(cloned.data(), ata.data()); + assert!(!cloned.delegated()) +}