From 5685e5360f459217c841e9a7ade6d8fe61c11dcf Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Mon, 9 Feb 2026 17:00:20 +0000 Subject: [PATCH 1/6] refactor trait --- .../client/src/interface/account_interface.rs | 24 ++++++++ .../src/interface/light_program_interface.rs | 31 +++------- sdk-libs/client/src/rpc/rpc_trait.rs | 50 +++------------- .../src/lib.rs | 25 +++----- .../tests/amm_stress_test.rs | 58 ++++++++++--------- .../tests/amm_test.rs | 10 ++-- 6 files changed, 84 insertions(+), 114 deletions(-) diff --git a/sdk-libs/client/src/interface/account_interface.rs b/sdk-libs/client/src/interface/account_interface.rs index 8c96e84d13..4fbb9300dc 100644 --- a/sdk-libs/client/src/interface/account_interface.rs +++ b/sdk-libs/client/src/interface/account_interface.rs @@ -404,3 +404,27 @@ impl From for AccountInterface { } } } + +impl TryFrom for TokenAccountInterface { + type Error = AccountInterfaceError; + + /// Convert an `AccountInterface` to `TokenAccountInterface`. + /// + /// For cold token accounts (ColdContext::Token), reconstructs the parsed PodAccount + /// from compressed token data. For hot accounts, parses PodAccount from raw bytes. + fn try_from(ai: AccountInterface) -> Result { + match ai.cold { + Some(ColdContext::Token(ct)) => { + let owner = ct.token.owner; + Ok(TokenAccountInterface::cold( + ai.key, + ct, + owner, + ai.account.owner, + )) + } + None => TokenAccountInterface::hot(ai.key, ai.account), + _ => Err(AccountInterfaceError::InvalidData), + } + } +} diff --git a/sdk-libs/client/src/interface/light_program_interface.rs b/sdk-libs/client/src/interface/light_program_interface.rs index 8ee0eb2880..92dc37002a 100644 --- a/sdk-libs/client/src/interface/light_program_interface.rs +++ b/sdk-libs/client/src/interface/light_program_interface.rs @@ -15,47 +15,30 @@ use solana_pubkey::Pubkey; use super::{AccountInterface, TokenAccountInterface}; use crate::indexer::{CompressedAccount, CompressedTokenAccount}; -/// Account descriptor for fetching. Routes to the correct indexer endpoint. +/// Account descriptor for fetching via the unified `getAccountInterface` endpoint. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum AccountToFetch { - /// PDA account - uses `get_account_interface(address, program_id)` - Pda { address: Pubkey, program_id: Pubkey }, - /// Token account (program-owned) - uses `get_token_account_interface(address)` - Token { address: Pubkey }, - /// ATA - uses `get_associated_token_account_interface(wallet_owner, mint)` + /// Fetch by address. Works for any account type (PDA, token, mint). + Address(Pubkey), + /// ATA: derives address from (wallet_owner, mint), then fetches. Ata { wallet_owner: Pubkey, mint: Pubkey }, - /// Light mint - uses `get_account_interface(address)` (clients parse mint data) - Mint { address: Pubkey }, } impl AccountToFetch { - pub fn pda(address: Pubkey, program_id: Pubkey) -> Self { - Self::Pda { - address, - program_id, - } - } - - pub fn token(address: Pubkey) -> Self { - Self::Token { address } + pub fn address(pubkey: Pubkey) -> Self { + Self::Address(pubkey) } pub fn ata(wallet_owner: Pubkey, mint: Pubkey) -> Self { Self::Ata { wallet_owner, mint } } - pub fn mint(address: Pubkey) -> Self { - Self::Mint { address } - } - /// Returns the primary pubkey for this fetch request. #[must_use] pub fn pubkey(&self) -> Pubkey { match self { - Self::Pda { address, .. } => *address, - Self::Token { address } => *address, + Self::Address(pubkey) => *pubkey, Self::Ata { wallet_owner, mint } => derive_token_ata(wallet_owner, mint).0, - Self::Mint { address } => *address, } } } diff --git a/sdk-libs/client/src/rpc/rpc_trait.rs b/sdk-libs/client/src/rpc/rpc_trait.rs index 0c3d61caa8..7002a6cdac 100644 --- a/sdk-libs/client/src/rpc/rpc_trait.rs +++ b/sdk-libs/client/src/rpc/rpc_trait.rs @@ -294,48 +294,14 @@ pub trait Rpc: Send + Sync + Debug + 'static { ) -> Result, RpcError> { let mut results = Vec::with_capacity(accounts.len()); for account in accounts { - let interface = match account { - AccountToFetch::Pda { address, .. } => self - .get_account_interface(address, config.clone()) - .await? - .value - .ok_or_else(|| { - RpcError::CustomError(format!("PDA account not found: {}", address)) - })?, - AccountToFetch::Token { address } => { - let tai = self - .get_token_account_interface(address, config.clone()) - .await? - .value - .ok_or_else(|| { - RpcError::CustomError(format!("Token account not found: {}", address)) - })?; - tai.into() - } - AccountToFetch::Ata { wallet_owner, mint } => { - let tai = self - .get_associated_token_account_interface(wallet_owner, mint, config.clone()) - .await? - .value - .ok_or_else(|| { - RpcError::CustomError(format!( - "ATA not found for owner {} mint {}", - wallet_owner, mint - )) - })?; - tai.into() - } - AccountToFetch::Mint { address } => { - let mi = self - .get_mint_interface(address, config.clone()) - .await? - .value - .ok_or_else(|| { - RpcError::CustomError(format!("Mint not found: {}", address)) - })?; - mi.into() - } - }; + let pubkey = account.pubkey(); + let interface = self + .get_account_interface(&pubkey, config.clone()) + .await? + .value + .ok_or_else(|| { + RpcError::CustomError(format!("Account not found: {}", pubkey)) + })?; results.push(interface); } Ok(results) diff --git a/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs b/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs index f773c00577..88c45a7092 100644 --- a/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs +++ b/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs @@ -32,7 +32,6 @@ pub type MintInterfaceMap = HashMap Vec { - let vault_0_req = AccountRequirement::new(self.token_0_vault, AccountKind::Token); - let vault_1_req = AccountRequirement::new(self.token_1_vault, AccountKind::Token); + let vault_0_req = AccountRequirement::new(self.token_0_vault, AccountKind::Pda); + let vault_1_req = AccountRequirement::new(self.token_1_vault, AccountKind::Pda); match ix { AmmInstruction::Swap => { @@ -345,13 +344,7 @@ impl LightProgramInterface for AmmSdk { fn get_accounts_to_update(&self, ix: &Self::Instruction) -> Vec { self.account_requirements(ix) .into_iter() - .filter_map(|req| match req.kind { - AccountKind::Pda => req - .pubkey - .map(|pubkey| AccountToFetch::pda(pubkey, PROGRAM_ID)), - AccountKind::Token => req.pubkey.map(AccountToFetch::token), - AccountKind::Mint => req.pubkey.map(AccountToFetch::mint), - }) + .filter_map(|req| req.pubkey.map(AccountToFetch::address)) .collect() } @@ -379,17 +372,15 @@ impl LightProgramInterface for AmmSdk { let mut specs = Vec::new(); for req in &requirements { - match req.kind { - AccountKind::Pda | AccountKind::Token => { - if let Some(pubkey) = req.pubkey { + if let Some(pubkey) = req.pubkey { + match req.kind { + AccountKind::Pda => { if let Some(spec) = self.program_owned_specs.get(&pubkey) { specs.push(AccountSpec::Pda(spec.clone())); } } - } - AccountKind::Mint => { - if let Some(mint_pubkey) = req.pubkey { - if let Some(spec) = self.mint_specs.get(&mint_pubkey) { + AccountKind::Mint => { + if let Some(spec) = self.mint_specs.get(&pubkey) { specs.push(AccountSpec::Mint(spec.clone())); } } diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs index 4b22c9622f..cf8202c0ea 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs @@ -19,8 +19,8 @@ use light_batched_merkle_tree::{ initialize_state_tree::InitStateTreeAccountsInstructionData, }; use light_client::interface::{ - create_load_instructions, get_create_accounts_proof, AccountInterface, AccountSpec, - CreateAccountsProofInput, InitializeRentFreeConfig, LightProgramInterface, + create_load_instructions, get_create_accounts_proof, AccountSpec, CreateAccountsProofInput, + InitializeRentFreeConfig, LightProgramInterface, TokenAccountInterface, }; use light_compressible::rent::SLOTS_PER_EPOCH; use light_program_test::{ @@ -512,48 +512,52 @@ async fn decompress_all(ctx: &mut AmmTestContext, pdas: &AmmPdas) { let specs = sdk.get_specs_for_instruction(&AmmInstruction::Deposit); - let creator_lp_interface = ctx + let creator_lp_interface: TokenAccountInterface = ctx .rpc - .get_associated_token_account_interface(&ctx.creator.pubkey(), &pdas.lp_mint, None) + .get_account_interface(&pdas.creator_lp_token, None) .await .expect("failed to get creator_lp_token") .value - .expect("creator_lp_token should exist"); + .expect("creator_lp_token should exist") + .try_into() + .expect("should convert to TokenAccountInterface"); // Creator's token_0 and token_1 ATAs also get compressed during epoch warp - let creator_token_0_interface = ctx + let creator_token_0_interface: TokenAccountInterface = ctx .rpc - .get_associated_token_account_interface(&ctx.creator.pubkey(), &ctx.token_0_mint, None) + .get_account_interface(&ctx.creator_token_0, None) .await .expect("failed to get creator_token_0") .value - .expect("creator_token_0 should exist"); + .expect("creator_token_0 should exist") + .try_into() + .expect("should convert to TokenAccountInterface"); - let creator_token_1_interface = ctx + let creator_token_1_interface: TokenAccountInterface = ctx .rpc - .get_associated_token_account_interface(&ctx.creator.pubkey(), &ctx.token_1_mint, None) + .get_account_interface(&ctx.creator_token_1, None) .await .expect("failed to get creator_token_1") .value - .expect("creator_token_1 should exist"); + .expect("creator_token_1 should exist") + .try_into() + .expect("should convert to TokenAccountInterface"); - let mint_0_account_iface = AccountInterface::from( - ctx.rpc - .get_mint_interface(&ctx.token_0_mint, None) - .await - .expect("failed to get token_0_mint") - .value - .expect("token_0_mint should exist"), - ); + let mint_0_account_iface = ctx + .rpc + .get_account_interface(&ctx.token_0_mint, None) + .await + .expect("failed to get token_0_mint") + .value + .expect("token_0_mint should exist"); - let mint_1_account_iface = AccountInterface::from( - ctx.rpc - .get_mint_interface(&ctx.token_1_mint, None) - .await - .expect("failed to get token_1_mint") - .value - .expect("token_1_mint should exist"), - ); + let mint_1_account_iface = ctx + .rpc + .get_account_interface(&ctx.token_1_mint, None) + .await + .expect("failed to get token_1_mint") + .value + .expect("token_1_mint should exist"); let mut all_specs = specs; all_specs.push(AccountSpec::Ata(creator_lp_interface)); diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs index bbb0e21b9d..69ce2781b6 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs @@ -655,16 +655,18 @@ async fn test_amm_full_lifecycle() { let specs = sdk.get_specs_for_instruction(&AmmInstruction::Deposit); - let creator_lp_interface = ctx + let creator_lp_account = ctx .rpc - .get_associated_token_account_interface(&ctx.creator.pubkey(), &pdas.lp_mint, None) + .get_account_interface(&pdas.creator_lp_token, None) .await .expect("failed to get creator_lp_token") .value .expect("creator_lp_token should exist"); - // add ata - use light_client::interface::AccountSpec; + use light_client::interface::{AccountSpec, TokenAccountInterface}; + let creator_lp_interface = TokenAccountInterface::try_from(creator_lp_account) + .expect("should convert to TokenAccountInterface"); + let mut all_specs = specs; all_specs.push(AccountSpec::Ata(creator_lp_interface)); From 45fd1d671e1185231a859a6c79c55cc3624eb2a0 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Mon, 9 Feb 2026 21:46:46 +0000 Subject: [PATCH 2/6] refactor --- .../src/interface/light_program_interface.rs | 104 +- sdk-libs/client/src/interface/mod.rs | 4 +- sdk-libs/client/src/rpc/rpc_trait.rs | 19 +- .../src/lib.rs | 491 ++----- .../tests/coverage.md | 623 --------- .../tests/trait_tests.rs | 1168 ++--------------- .../tests/amm_stress_test.rs | 22 +- .../tests/amm_test.rs | 26 +- 8 files changed, 303 insertions(+), 2154 deletions(-) delete mode 100644 sdk-tests/csdk-anchor-full-derived-test-sdk/tests/coverage.md diff --git a/sdk-libs/client/src/interface/light_program_interface.rs b/sdk-libs/client/src/interface/light_program_interface.rs index 92dc37002a..e645927005 100644 --- a/sdk-libs/client/src/interface/light_program_interface.rs +++ b/sdk-libs/client/src/interface/light_program_interface.rs @@ -1,48 +1,19 @@ -//! LightProgramInterface trait and supporting types for client-side SDK patterns. +//! LightProgram trait and supporting types for client-side cold account handling. //! //! Core types: //! - `ColdContext` - Cold data context (Account or Token) //! - `PdaSpec` - Spec for PDA loading with typed variant //! - `AccountSpec` - Unified spec enum for load instruction building -//! - `LightProgramInterface` - Trait for program SDKs +//! - `LightProgram` - Trait for program SDKs use std::fmt::Debug; use light_account::Pack; -use light_token::instruction::derive_token_ata; use solana_pubkey::Pubkey; use super::{AccountInterface, TokenAccountInterface}; use crate::indexer::{CompressedAccount, CompressedTokenAccount}; -/// Account descriptor for fetching via the unified `getAccountInterface` endpoint. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum AccountToFetch { - /// Fetch by address. Works for any account type (PDA, token, mint). - Address(Pubkey), - /// ATA: derives address from (wallet_owner, mint), then fetches. - Ata { wallet_owner: Pubkey, mint: Pubkey }, -} - -impl AccountToFetch { - pub fn address(pubkey: Pubkey) -> Self { - Self::Address(pubkey) - } - - pub fn ata(wallet_owner: Pubkey, mint: Pubkey) -> Self { - Self::Ata { wallet_owner, mint } - } - - /// Returns the primary pubkey for this fetch request. - #[must_use] - pub fn pubkey(&self) -> Pubkey { - match self { - Self::Address(pubkey) => *pubkey, - Self::Ata { wallet_owner, mint } => derive_token_ata(wallet_owner, mint).0, - } - } -} - /// Context for cold accounts. /// /// Three variants based on data structure: @@ -218,65 +189,38 @@ pub fn all_hot(specs: &[AccountSpec]) -> bool { specs.iter().all(|s| s.is_hot()) } -/// Trait for programs to give clients a unified API to load cold program accounts. -pub trait LightProgramInterface: Sized { - /// The program's interface account variant enum. +/// Trait for program SDKs to produce load specs for cold accounts. +/// +/// Implementors hold parsed program state (e.g., pool config, vault addresses, +/// seed values). The trait provides two methods: +/// - `instruction_accounts`: which pubkeys does this instruction reference? +/// - `load_specs`: given cold AccountInterfaces, build AccountSpec with variants. +/// +/// The caller handles construction, caching, and cold detection. +/// The trait only maps cold accounts to their variants for `create_load_instructions`. +pub trait LightProgram: Sized { + /// The program's account variant enum (macro-generated, carries PDA seeds). type Variant: Pack + Clone + Debug; /// Program-specific instruction enum. type Instruction; - /// Error type for SDK operations. - type Error: std::error::Error; - /// The program ID. - #[must_use] - fn program_id(&self) -> Pubkey; - - /// Construct SDK from root account(s). - fn from_keyed_accounts(accounts: &[AccountInterface]) -> Result; - - /// Returns pubkeys of accounts needed for an instruction. - #[must_use] - fn get_accounts_to_update(&self, ix: &Self::Instruction) -> Vec; + fn program_id() -> Pubkey; - /// Update internal cache with fetched account data. - fn update(&mut self, accounts: &[AccountInterface]) -> Result<(), Self::Error>; - - /// Get all cached specs. - #[must_use] - fn get_all_specs(&self) -> Vec>; - - /// Get specs filtered for a specific instruction. + /// Which compressible account pubkeys does this instruction reference? + /// Used by callers to check which accounts might need loading. #[must_use] - fn get_specs_for_instruction(&self, ix: &Self::Instruction) -> Vec>; + fn instruction_accounts(&self, ix: &Self::Instruction) -> Vec; - /// Get only cold specs from all cached specs. - #[must_use] - fn get_cold_specs(&self) -> Vec> { - self.get_all_specs() - .into_iter() - .filter(|s| s.is_cold()) - .collect() - } - - /// Get only cold specs for a specific instruction. - #[must_use] - fn get_cold_specs_for_instruction( + /// Build AccountSpec for cold accounts. + /// Matches each AccountInterface by pubkey, constructs the variant (seeds) + /// from internal parsed state, wraps in PdaSpec/AccountSpec. + /// Only called on the cold path. + fn load_specs( &self, - ix: &Self::Instruction, - ) -> Vec> { - self.get_specs_for_instruction(ix) - .into_iter() - .filter(|s| s.is_cold()) - .collect() - } - - /// Check if any accounts for this instruction are cold. - #[must_use] - fn needs_loading(&self, ix: &Self::Instruction) -> bool { - any_cold(&self.get_specs_for_instruction(ix)) - } + cold_accounts: &[AccountInterface], + ) -> Result>, Box>; } /// Extract 8-byte discriminator from account data. diff --git a/sdk-libs/client/src/interface/mod.rs b/sdk-libs/client/src/interface/mod.rs index 041e7f4973..c17c13fbee 100644 --- a/sdk-libs/client/src/interface/mod.rs +++ b/sdk-libs/client/src/interface/mod.rs @@ -21,8 +21,8 @@ pub use decompress_mint::{ pub use initialize_config::InitializeRentFreeConfig; pub use light_account::LightConfig; pub use light_program_interface::{ - all_hot, any_cold, discriminator, matches_discriminator, AccountSpec, AccountToFetch, - ColdContext, LightProgramInterface, PdaSpec, + all_hot, any_cold, discriminator, matches_discriminator, AccountSpec, ColdContext, LightProgram, + PdaSpec, }; pub use light_sdk_types::interface::CreateAccountsProof; pub use light_token::compat::TokenData; diff --git a/sdk-libs/client/src/rpc/rpc_trait.rs b/sdk-libs/client/src/rpc/rpc_trait.rs index 7002a6cdac..31c60ed8aa 100644 --- a/sdk-libs/client/src/rpc/rpc_trait.rs +++ b/sdk-libs/client/src/rpc/rpc_trait.rs @@ -19,7 +19,7 @@ use solana_transaction_status_client_types::TransactionStatus; use super::client::RpcUrl; use crate::{ indexer::{Indexer, IndexerRpcConfig, Response, TreeInfo}, - interface::{AccountInterface, AccountToFetch, MintInterface, TokenAccountInterface}, + interface::{AccountInterface, MintInterface, TokenAccountInterface}, rpc::errors::RpcError, }; @@ -280,23 +280,16 @@ pub trait Rpc: Send + Sync + Debug + 'static { config: Option, ) -> Result>, RpcError>; - /// Fetch multiple accounts using `AccountToFetch` descriptors. - /// - /// Routes each account to the correct method based on its variant: - /// - `Pda` -> `get_account_interface` - /// - `Token` -> `get_token_account_interface` - /// - `Ata` -> `get_associated_token_account_interface` - /// - `Mint` -> `get_mint_interface` + /// Fetch multiple accounts by pubkey via `get_account_interface`. async fn fetch_accounts( &self, - accounts: &[AccountToFetch], + pubkeys: &[Pubkey], config: Option, ) -> Result, RpcError> { - let mut results = Vec::with_capacity(accounts.len()); - for account in accounts { - let pubkey = account.pubkey(); + let mut results = Vec::with_capacity(pubkeys.len()); + for pubkey in pubkeys { let interface = self - .get_account_interface(&pubkey, config.clone()) + .get_account_interface(pubkey, config.clone()) .await? .value .ok_or_else(|| { diff --git a/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs b/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs index 88c45a7092..d654edbd93 100644 --- a/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs +++ b/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs @@ -1,9 +1,6 @@ //! Client SDK for the AMM test program. //! -//! Implements the `LightProgramInterface` trait to provide a Jupiter-style -//! interface for clients to build decompression instructions. - -use std::collections::HashMap; +//! Implements the `LightProgram` trait to produce load specs for cold accounts. use anchor_lang::AnchorDeserialize; use csdk_anchor_full_derived_test::{ @@ -14,39 +11,13 @@ use csdk_anchor_full_derived_test::{ }, }; use light_client::interface::{ - matches_discriminator, AccountInterface, AccountSpec, AccountToFetch, ColdContext, - LightProgramInterface, PdaSpec, + AccountInterface, AccountSpec, ColdContext, LightProgram, PdaSpec, }; -use light_sdk::LightDiscriminator; use solana_pubkey::Pubkey; /// Program ID for the AMM test program. pub const PROGRAM_ID: Pubkey = csdk_anchor_full_derived_test::ID; -/// Map of account pubkeys to program-owned specs. -pub type PdaSpecMap = HashMap, ahash::RandomState>; - -/// Map of account pubkeys to mint interfaces. -pub type MintInterfaceMap = HashMap; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AccountKind { - Pda, - Mint, -} - -#[derive(Debug, Clone, Copy)] -pub struct AccountRequirement { - pub pubkey: Option, - pub kind: AccountKind, -} - -impl AccountRequirement { - fn new(pubkey: Option, kind: AccountKind) -> Self { - Self { pubkey, kind } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AmmInstruction { Swap, @@ -57,387 +28,191 @@ pub enum AmmInstruction { #[derive(Debug, Clone)] pub enum AmmSdkError { ParseError(String), - UnknownDiscriminator([u8; 8]), - MissingField(&'static str), - PoolStateNotParsed, } impl std::fmt::Display for AmmSdkError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::ParseError(msg) => write!(f, "Parse error: {}", msg), - Self::UnknownDiscriminator(disc) => write!(f, "Unknown discriminator: {:?}", disc), - Self::MissingField(field) => write!(f, "Missing field: {}", field), - Self::PoolStateNotParsed => write!(f, "Pool state must be parsed first"), } } } impl std::error::Error for AmmSdkError {} +/// Flat SDK struct. All fields populated at construction from pool state data. +/// No Options, no HashMaps. Seeds and variants built on the fly in `load_specs`. #[derive(Debug)] pub struct AmmSdk { - pool_state_pubkey: Option, - amm_config: Option, - token_0_mint: Option, - token_1_mint: Option, - token_0_vault: Option, - token_1_vault: Option, - lp_mint: Option, - observation_key: Option, - authority: Option, - lp_mint_signer: Option, - program_owned_specs: PdaSpecMap, - mint_specs: MintInterfaceMap, -} - -impl Default for AmmSdk { - fn default() -> Self { - Self::new() - } + pub pool_state_pubkey: Pubkey, + pub amm_config: Pubkey, + pub token_0_mint: Pubkey, + pub token_1_mint: Pubkey, + pub token_0_vault: Pubkey, + pub token_1_vault: Pubkey, + pub lp_mint: Pubkey, + pub observation_key: Pubkey, + pub authority: Pubkey, + pub lp_mint_signer: Pubkey, } impl AmmSdk { - pub fn new() -> Self { - Self { - pool_state_pubkey: None, - amm_config: None, - token_0_mint: None, - token_1_mint: None, - token_0_vault: None, - token_1_vault: None, - lp_mint: None, - observation_key: None, - authority: None, - lp_mint_signer: None, - program_owned_specs: HashMap::with_hasher(ahash::RandomState::new()), - mint_specs: HashMap::with_hasher(ahash::RandomState::new()), - } - } - - pub fn pool_state_pubkey(&self) -> Option { - self.pool_state_pubkey - } - - pub fn lp_mint(&self) -> Option { - self.lp_mint - } - - pub fn lp_mint_signer(&self) -> Option { - self.lp_mint_signer - } - - fn parse_pool_state(&mut self, account: &AccountInterface) -> Result<(), AmmSdkError> { - let pool = PoolState::deserialize(&mut &account.data()[8..]) + /// Construct from pool state pubkey and its account data. + /// Parses PoolState once, extracts all dependent addresses. + pub fn new(pool_state_pubkey: Pubkey, pool_data: &[u8]) -> Result { + let pool = PoolState::deserialize(&mut &pool_data[8..]) .map_err(|e| AmmSdkError::ParseError(e.to_string()))?; - self.pool_state_pubkey = Some(account.key); - - self.amm_config = Some(pool.amm_config); - self.token_0_mint = Some(pool.token_0_mint); - self.token_1_mint = Some(pool.token_1_mint); - self.token_0_vault = Some(pool.token_0_vault); - self.token_1_vault = Some(pool.token_1_vault); - self.lp_mint = Some(pool.lp_mint); - self.observation_key = Some(pool.observation_key); - let (authority, _) = Pubkey::find_program_address(&[AUTH_SEED.as_bytes()], &PROGRAM_ID); - self.authority = Some(authority); - let (lp_mint_signer, _) = Pubkey::find_program_address( - &[POOL_LP_MINT_SIGNER_SEED, account.key.as_ref()], + &[POOL_LP_MINT_SIGNER_SEED, pool_state_pubkey.as_ref()], &PROGRAM_ID, ); - self.lp_mint_signer = Some(lp_mint_signer); - let variant = LightAccountVariant::PoolState { - seeds: PoolStateSeeds { - amm_config: self.amm_config.unwrap(), - token_0_mint: self.token_0_mint.unwrap(), - token_1_mint: self.token_1_mint.unwrap(), - }, - data: pool, - }; - - let spec = PdaSpec::new(account.clone(), variant, PROGRAM_ID); - self.program_owned_specs.insert(account.key, spec); - - Ok(()) + Ok(Self { + pool_state_pubkey, + amm_config: pool.amm_config, + token_0_mint: pool.token_0_mint, + token_1_mint: pool.token_1_mint, + token_0_vault: pool.token_0_vault, + token_1_vault: pool.token_1_vault, + lp_mint: pool.lp_mint, + observation_key: pool.observation_key, + authority, + lp_mint_signer, + }) } - fn parse_observation_state(&mut self, account: &AccountInterface) -> Result<(), AmmSdkError> { - let pool_state = self - .pool_state_pubkey - .ok_or(AmmSdkError::PoolStateNotParsed)?; - - let observation = ObservationState::deserialize(&mut &account.data()[8..]) - .map_err(|e| AmmSdkError::ParseError(e.to_string()))?; - - let variant = LightAccountVariant::ObservationState { - seeds: ObservationStateSeeds { pool_state }, - data: observation, - }; - - let spec = PdaSpec::new(account.clone(), variant, PROGRAM_ID); - self.program_owned_specs.insert(account.key, spec); - - Ok(()) + pub fn derive_lp_mint_compressed_address(&self, address_tree: &Pubkey) -> [u8; 32] { + light_compressed_token_sdk::compressed_token::create_compressed_mint::derive_mint_compressed_address( + &self.lp_mint_signer, + address_tree, + ) } - fn parse_token_vault( - &mut self, + /// Convert token vault ColdContext::Token -> ColdContext::Account. + /// Vaults are decompressed as PDAs, not as token accounts. + fn convert_vault_interface( account: &AccountInterface, - is_vault_0: bool, - ) -> Result<(), AmmSdkError> { - use light_account::{token::TokenDataWithSeeds, Token}; - - let pool_state = self - .pool_state_pubkey - .ok_or(AmmSdkError::PoolStateNotParsed)?; - - let token: Token = Token::deserialize(&mut &account.data()[..]) - .map_err(|e| AmmSdkError::ParseError(e.to_string()))?; - - let variant = if is_vault_0 { - let token_0_mint = self - .token_0_mint - .ok_or(AmmSdkError::MissingField("token_0_mint"))?; - LightAccountVariant::Token0Vault(TokenDataWithSeeds { - seeds: Token0VaultSeeds { - pool_state, - token_0_mint, - }, - token_data: token, - }) - } else { - let token_1_mint = self - .token_1_mint - .ok_or(AmmSdkError::MissingField("token_1_mint"))?; - LightAccountVariant::Token1Vault(TokenDataWithSeeds { - seeds: Token1VaultSeeds { - pool_state, - token_1_mint, - }, - token_data: token, - }) - }; - - // For token vaults, convert ColdContext::Token to ColdContext::Account - // because they're decompressed as PDAs, not as token accounts - let interface = if account.is_cold() { + ) -> Result { + if account.is_cold() { let compressed_account = match &account.cold { Some(ColdContext::Token(ct)) => ct.account.clone(), Some(ColdContext::Account(ca)) => ca.clone(), Some(ColdContext::Mint(_)) => { - return Err(AmmSdkError::MissingField("unexpected Mint cold context")) + return Err(AmmSdkError::ParseError( + "unexpected Mint cold context for vault".to_string(), + )) + } + None => { + return Err(AmmSdkError::ParseError( + "missing cold context for vault".to_string(), + )) } - None => return Err(AmmSdkError::MissingField("cold_context")), }; - AccountInterface { + Ok(AccountInterface { key: account.key, - account: account.account.clone(), // Keep original owner (SPL Token) + account: account.account.clone(), cold: Some(ColdContext::Account(compressed_account)), - } + }) } else { - account.clone() - }; - - // Decompression goes to PROGRAM_ID (AMM), not interface.account.owner (SPL/Light Token) - let spec = PdaSpec::new(interface, variant, PROGRAM_ID); - self.program_owned_specs.insert(account.key, spec); - - Ok(()) - } - - fn parse_account(&mut self, account: &AccountInterface) -> Result<(), AmmSdkError> { - if Some(account.key) == self.token_0_vault { - return self.parse_token_vault(account, true); - } - if Some(account.key) == self.token_1_vault { - return self.parse_token_vault(account, false); - } - - if matches_discriminator(account.data(), &PoolState::LIGHT_DISCRIMINATOR) { - return self.parse_pool_state(account); - } - if matches_discriminator(account.data(), &ObservationState::LIGHT_DISCRIMINATOR) { - return self.parse_observation_state(account); - } - - // Check if this is an LP mint by matching the signer - if let Some(lp_mint_signer) = self.lp_mint_signer { - if let Some(mint_signer) = account.mint_signer() { - if Pubkey::new_from_array(mint_signer) == lp_mint_signer { - return self.parse_mint(account); - } - } - } - - Ok(()) - } - - fn parse_mint(&mut self, account: &AccountInterface) -> Result<(), AmmSdkError> { - // Store AccountInterface directly - mints are just accounts with special data - self.mint_specs.insert(account.key, account.clone()); - Ok(()) - } - - pub fn derive_lp_mint_compressed_address(&self, address_tree: &Pubkey) -> Option<[u8; 32]> { - self.lp_mint_signer.map(|signer| { - light_compressed_token_sdk::compressed_token::create_compressed_mint::derive_mint_compressed_address( - &signer, - address_tree, - ) - }) - } - - fn account_requirements(&self, ix: &AmmInstruction) -> Vec { - let vault_0_req = AccountRequirement::new(self.token_0_vault, AccountKind::Pda); - let vault_1_req = AccountRequirement::new(self.token_1_vault, AccountKind::Pda); - - match ix { - AmmInstruction::Swap => { - vec![ - AccountRequirement::new(self.pool_state_pubkey, AccountKind::Pda), - vault_0_req, - vault_1_req, - AccountRequirement::new(self.observation_key, AccountKind::Pda), - ] - } - AmmInstruction::Deposit | AmmInstruction::Withdraw => { - vec![ - AccountRequirement::new(self.pool_state_pubkey, AccountKind::Pda), - vault_0_req, - vault_1_req, - AccountRequirement::new(self.observation_key, AccountKind::Pda), - AccountRequirement::new(self.lp_mint, AccountKind::Mint), - ] - } + Ok(account.clone()) } } } -impl LightProgramInterface for AmmSdk { +impl LightProgram for AmmSdk { type Variant = LightAccountVariant; type Instruction = AmmInstruction; - type Error = AmmSdkError; - fn program_id(&self) -> Pubkey { + fn program_id() -> Pubkey { PROGRAM_ID } - fn from_keyed_accounts(accounts: &[AccountInterface]) -> Result { - let mut sdk = Self::new(); - - for account in accounts { - // Parse pool_state first (needed for other accounts), then remaining - if matches_discriminator(account.data(), &PoolState::LIGHT_DISCRIMINATOR) { - sdk.parse_pool_state(account)?; - } else { - sdk.parse_account(account)?; - } - } - - Ok(sdk) - } - - fn get_accounts_to_update(&self, ix: &Self::Instruction) -> Vec { - self.account_requirements(ix) - .into_iter() - .filter_map(|req| req.pubkey.map(AccountToFetch::address)) - .collect() - } - - fn update(&mut self, accounts: &[AccountInterface]) -> Result<(), Self::Error> { - for account in accounts { - self.parse_account(account)?; + fn instruction_accounts(&self, ix: &Self::Instruction) -> Vec { + match ix { + AmmInstruction::Swap => vec![ + self.pool_state_pubkey, + self.token_0_vault, + self.token_1_vault, + self.observation_key, + ], + AmmInstruction::Deposit | AmmInstruction::Withdraw => vec![ + self.pool_state_pubkey, + self.token_0_vault, + self.token_1_vault, + self.observation_key, + self.lp_mint, + ], } - Ok(()) } - fn get_all_specs(&self) -> Vec> { - let mut specs = Vec::new(); - specs.extend( - self.program_owned_specs - .values() - .cloned() - .map(AccountSpec::Pda), - ); - specs.extend(self.mint_specs.values().cloned().map(AccountSpec::Mint)); - specs - } + fn load_specs( + &self, + cold_accounts: &[AccountInterface], + ) -> Result>, Box> { + use light_account::{token::TokenDataWithSeeds, Token}; - fn get_specs_for_instruction(&self, ix: &Self::Instruction) -> Vec> { - let requirements = self.account_requirements(ix); let mut specs = Vec::new(); - - for req in &requirements { - if let Some(pubkey) = req.pubkey { - match req.kind { - AccountKind::Pda => { - if let Some(spec) = self.program_owned_specs.get(&pubkey) { - specs.push(AccountSpec::Pda(spec.clone())); - } - } - AccountKind::Mint => { - if let Some(spec) = self.mint_specs.get(&pubkey) { - specs.push(AccountSpec::Mint(spec.clone())); - } - } - } + for account in cold_accounts { + if account.key == self.pool_state_pubkey { + let pool = PoolState::deserialize(&mut &account.data()[8..]) + .map_err(|e| AmmSdkError::ParseError(e.to_string()))?; + let variant = LightAccountVariant::PoolState { + seeds: PoolStateSeeds { + amm_config: self.amm_config, + token_0_mint: self.token_0_mint, + token_1_mint: self.token_1_mint, + }, + data: pool, + }; + specs.push(AccountSpec::Pda(PdaSpec::new( + account.clone(), + variant, + PROGRAM_ID, + ))); + } else if account.key == self.observation_key { + let observation = ObservationState::deserialize(&mut &account.data()[8..]) + .map_err(|e| AmmSdkError::ParseError(e.to_string()))?; + let variant = LightAccountVariant::ObservationState { + seeds: ObservationStateSeeds { + pool_state: self.pool_state_pubkey, + }, + data: observation, + }; + specs.push(AccountSpec::Pda(PdaSpec::new( + account.clone(), + variant, + PROGRAM_ID, + ))); + } else if account.key == self.token_0_vault { + let token: Token = Token::deserialize(&mut &account.data()[..]) + .map_err(|e| AmmSdkError::ParseError(e.to_string()))?; + let variant = LightAccountVariant::Token0Vault(TokenDataWithSeeds { + seeds: Token0VaultSeeds { + pool_state: self.pool_state_pubkey, + token_0_mint: self.token_0_mint, + }, + token_data: token, + }); + let interface = Self::convert_vault_interface(account)?; + specs.push(AccountSpec::Pda(PdaSpec::new(interface, variant, PROGRAM_ID))); + } else if account.key == self.token_1_vault { + let token: Token = Token::deserialize(&mut &account.data()[..]) + .map_err(|e| AmmSdkError::ParseError(e.to_string()))?; + let variant = LightAccountVariant::Token1Vault(TokenDataWithSeeds { + seeds: Token1VaultSeeds { + pool_state: self.pool_state_pubkey, + token_1_mint: self.token_1_mint, + }, + token_data: token, + }); + let interface = Self::convert_vault_interface(account)?; + specs.push(AccountSpec::Pda(PdaSpec::new(interface, variant, PROGRAM_ID))); + } else if account.key == self.lp_mint { + specs.push(AccountSpec::Mint(account.clone())); } } - - specs - } -} - -impl AmmSdk { - pub fn program_id(&self) -> Pubkey { - PROGRAM_ID - } - - pub fn pool_state_seeds(&self) -> Result { - Ok(PoolStateSeeds { - amm_config: self - .amm_config - .ok_or(AmmSdkError::MissingField("amm_config"))?, - token_0_mint: self - .token_0_mint - .ok_or(AmmSdkError::MissingField("token_0_mint"))?, - token_1_mint: self - .token_1_mint - .ok_or(AmmSdkError::MissingField("token_1_mint"))?, - }) - } - - pub fn observation_state_seeds(&self) -> Result { - Ok(ObservationStateSeeds { - pool_state: self - .pool_state_pubkey - .ok_or(AmmSdkError::PoolStateNotParsed)?, - }) - } - - pub fn token_0_vault_seeds(&self) -> Result { - Ok(Token0VaultSeeds { - pool_state: self - .pool_state_pubkey - .ok_or(AmmSdkError::PoolStateNotParsed)?, - token_0_mint: self - .token_0_mint - .ok_or(AmmSdkError::MissingField("token_0_mint"))?, - }) - } - - pub fn token_1_vault_seeds(&self) -> Result { - Ok(Token1VaultSeeds { - pool_state: self - .pool_state_pubkey - .ok_or(AmmSdkError::PoolStateNotParsed)?, - token_1_mint: self - .token_1_mint - .ok_or(AmmSdkError::MissingField("token_1_mint"))?, - }) + Ok(specs) } } diff --git a/sdk-tests/csdk-anchor-full-derived-test-sdk/tests/coverage.md b/sdk-tests/csdk-anchor-full-derived-test-sdk/tests/coverage.md deleted file mode 100644 index 18175cbaa5..0000000000 --- a/sdk-tests/csdk-anchor-full-derived-test-sdk/tests/coverage.md +++ /dev/null @@ -1,623 +0,0 @@ -# LightProgramInterface Trait Test Coverage Plan - -## Overview - -Comprehensive test coverage for the `LightProgramInterface` trait to ensure robust SDK implementations. - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ TEST COVERAGE ARCHITECTURE │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ UNIT TESTS │ │ INTEGRATION │ │ PROPERTY │ │ -│ │ (Trait Methods)│ │ (Multi-Op) │ │ (Invariants) │ │ -│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ -│ │ │ │ │ -│ v v v │ -│ ┌────────────────────────────────────────────────────────────┐ │ -│ │ LightProgramInterface Trait │ │ -│ │ ┌──────────────────┐ ┌──────────────────┐ │ │ -│ │ │from_keyed_accounts│ │get_accounts_to_ │ │ │ -│ │ │ │ │update │ │ │ -│ │ └──────────────────┘ └──────────────────┘ │ │ -│ │ ┌──────────────────┐ ┌──────────────────┐ │ │ -│ │ │update │ │get_all_specs │ │ │ -│ │ │ │ │ │ │ │ -│ │ └──────────────────┘ └──────────────────┘ │ │ -│ │ ┌──────────────────┐ │ │ -│ │ │get_specs_for_ │ │ │ -│ │ │operation │ │ │ -│ │ └──────────────────┘ │ │ -│ └────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 1. Core Trait Method Tests - -### 1.1 `from_keyed_accounts()` Tests - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ from_keyed_accounts() Test Matrix │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ INPUT │ EXPECTED │ TEST NAME │ -│ ───────────────────────────────────────────────────────────────────────── │ -│ Empty accounts [] │ Err or empty SDK │ empty_accounts │ -│ Single root (PoolState) │ SDK with extracted pubkeys│ single_root │ -│ Multiple roots │ SDK with merged state │ multiple_roots │ -│ Wrong discriminator │ Skip or error │ wrong_disc │ -│ Truncated data │ ParseError │ truncated_data │ -│ Hot root account │ SDK (no cold_context) │ hot_root │ -│ Cold root account │ SDK with cold_context │ cold_root │ -│ Missing required fields │ ParseError │ missing_fields │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -| Test ID | Test Name | Description | Priority | -|---------|-----------|-------------|----------| -| T1.1.1 | `test_from_keyed_empty_accounts` | Empty array returns error/empty SDK | HIGH | -| T1.1.2 | `test_from_keyed_single_root` | Single PoolState parses all pubkeys | HIGH | -| T1.1.3 | `test_from_keyed_cold_root` | Cold root sets up cold_context correctly | HIGH | -| T1.1.4 | `test_from_keyed_hot_root` | Hot root works without cold_context | HIGH | -| T1.1.5 | `test_from_keyed_wrong_discriminator` | Unknown discriminator handled gracefully | MEDIUM | -| T1.1.6 | `test_from_keyed_truncated_data` | Insufficient data returns ParseError | HIGH | -| T1.1.7 | `test_from_keyed_zero_length_data` | Zero-length data handled | MEDIUM | -| T1.1.8 | `test_from_keyed_multiple_roots` | Multiple root accounts merged correctly | MEDIUM | - -### 1.2 `get_accounts_to_update()` Tests - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ Operation -> Accounts Mapping Test Matrix │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ OPERATION │ EXPECTED ACCOUNTS │ OVERLAP WITH OTHERS │ -│ ──────────────────────────────────────────────────────────────────────────│ -│ Swap │ [vault_0, vault_1] │ Subset of Deposit │ -│ Deposit │ [vault_0, vault_1, obs, │ Superset of Swap │ -│ │ lp_mint] │ │ -│ Withdraw │ [vault_0, vault_1, obs, │ Same as Deposit │ -│ │ lp_mint] │ │ -│ │ -│ EDGE CASES: │ -│ - Before pool_state parsed → returns [] │ -│ - Pool has no vaults → returns [] for Swap │ -│ - Pool has no LP mint → Deposit excludes it │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -| Test ID | Test Name | Description | Priority | -|---------|-----------|-------------|----------| -| T1.2.1 | `test_get_accounts_swap` | Swap returns correct vaults | HIGH | -| T1.2.2 | `test_get_accounts_deposit` | Deposit returns vaults+obs+mint | HIGH | -| T1.2.3 | `test_get_accounts_withdraw` | Withdraw matches Deposit | HIGH | -| T1.2.4 | `test_get_accounts_before_init` | Returns empty before pool parsed | HIGH | -| T1.2.5 | `test_get_accounts_overlap` | Verify overlapping accounts deduplicated | MEDIUM | -| T1.2.6 | `test_get_accounts_partial_state` | Missing some optional fields | MEDIUM | - -### 1.3 `update()` Tests - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ update() State Transitions │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ INITIAL STATE INPUT FINAL STATE │ -│ ──────────────────────────────────────────────────────────────────────────│ -│ [PoolState parsed] + [vault_0] → specs: {pool, vault_0} │ -│ specs: {pool} │ -│ │ -│ [PoolState parsed] + [vault_0, → specs: {pool, vault_0, │ -│ specs: {pool} vault_1] vault_1} │ -│ │ -│ specs: {pool, v0} + [vault_0] → specs: {pool, v0} (updated) │ -│ (already has v0) (re-update) IDEMPOTENT │ -│ │ -│ specs: {} + [vault_0] → ERROR (pool not parsed) │ -│ (no pool yet) │ -│ │ -│ specs: {pool} + [unknown] → specs: {pool} (skipped) │ -│ (unrecognized) │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -| Test ID | Test Name | Description | Priority | -|---------|-----------|-------------|----------| -| T1.3.1 | `test_update_single_account` | Single vault updates correctly | HIGH | -| T1.3.2 | `test_update_multiple_accounts` | Multiple accounts batch | HIGH | -| T1.3.3 | `test_update_idempotent` | Same account twice is idempotent | HIGH | -| T1.3.4 | `test_update_before_root` | Error if updating before root parsed | HIGH | -| T1.3.5 | `test_update_unknown_account` | Unknown accounts skipped | MEDIUM | -| T1.3.6 | `test_update_mixed_hot_cold` | Mix of hot and cold accounts | HIGH | -| T1.3.7 | `test_update_overwrites_old` | Re-updating changes is_cold status | HIGH | -| T1.3.8 | `test_update_token_context` | Token accounts use token_context | HIGH | -| T1.3.9 | `test_update_pda_context` | PDA accounts use pda_context | HIGH | - -### 1.4 `get_all_specs()` Tests - -| Test ID | Test Name | Description | Priority | -|---------|-----------|-------------|----------| -| T1.4.1 | `test_get_all_empty` | Empty SDK returns empty specs | HIGH | -| T1.4.2 | `test_get_all_complete` | All parsed accounts returned | HIGH | -| T1.4.3 | `test_get_all_preserves_cold` | Cold status preserved in specs | HIGH | -| T1.4.4 | `test_get_all_categories` | Correct categorization (pda/ata/mint) | HIGH | - -### 1.5 `get_specs_for_operation()` Tests - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ Operation-Filtered Specs Visual │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ALL SPECS: │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ pool_state │ vault_0 │ vault_1 │ observation │ lp_mint │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ -│ SWAP FILTER: │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ pool_state │ vault_0 │ vault_1 │░░░░░░░░░░░░│░░░░░░░░░│ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ ↑ INCLUDED ↑ EXCLUDED │ -│ │ -│ DEPOSIT FILTER: │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ pool_state │ vault_0 │ vault_1 │ observation │ lp_mint │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ ↑ ALL INCLUDED │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -| Test ID | Test Name | Description | Priority | -|---------|-----------|-------------|----------| -| T1.5.1 | `test_specs_for_swap` | Swap returns vaults only | HIGH | -| T1.5.2 | `test_specs_for_deposit` | Deposit includes all | HIGH | -| T1.5.3 | `test_specs_for_operation_cold_filter` | Only cold accounts have context | HIGH | -| T1.5.4 | `test_specs_for_operation_missing_accounts` | Missing accounts not in specs | MEDIUM | - ---- - -## 2. Error Handling Tests - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ ERROR SCENARIOS MATRIX │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ERROR TYPE │ SCENARIO │ EXPECTED MESSAGE │ -│ ──────────────────────────────────────────────────────────────────────────│ -│ ParseError │ Invalid account data │ "Parse error: ..." │ -│ UnknownDiscriminator │ Unrecognized disc │ "Unknown disc: [..]"│ -│ MissingField │ Required field null │ "Missing: field_x" │ -│ PoolStateNotParsed │ Update before init │ "Pool state must..."│ -│ MissingContext │ Cold without context │ "Missing context" │ -│ │ -│ RECOVERY SCENARIOS: │ -│ - Partial parse failure → previously parsed state preserved │ -│ - Unknown account → skip silently, continue │ -│ - Hot account missing context → OK (no context needed) │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -| Test ID | Test Name | Description | Priority | -|---------|-----------|-------------|----------| -| T2.1 | `test_error_parse_invalid_data` | ParseError on invalid data | HIGH | -| T2.2 | `test_error_missing_field` | MissingField with field name | HIGH | -| T2.3 | `test_error_pool_not_parsed` | PoolStateNotParsed meaningful msg | HIGH | -| T2.4 | `test_error_display_impl` | All errors have Display impl | HIGH | -| T2.5 | `test_error_recovery_partial` | Partial failure preserves state | MEDIUM | -| T2.6 | `test_error_cold_without_context` | Cold account without context errors | HIGH | - ---- - -## 3. Multi-Operation Scenarios (Overlapping/Divergent Accounts) - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ MULTI-OPERATION ACCOUNT OVERLAP SCENARIOS │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ SCENARIO: Sequential Operations with Shared Accounts │ -│ │ -│ Timeline: │ -│ ─────────────────────────────────────────────────────────────────────────│ -│ T0: Initialize SDK with PoolState │ -│ └── specs: {pool_state} │ -│ │ -│ T1: get_accounts_to_update(Swap) → [vault_0, vault_1] │ -│ └── Fetch and update vaults │ -│ └── specs: {pool_state, vault_0, vault_1} │ -│ │ -│ T2: get_specs_for_operation(Swap) → {pool, v0, v1} │ -│ └── Execute Swap with these specs │ -│ │ -│ T3: get_accounts_to_update(Deposit) → [vault_0, vault_1, obs, lp_mint] │ -│ └── Already have vaults! Only need obs + lp_mint │ -│ └── Fetch obs + lp_mint, update │ -│ └── specs: {pool_state, vault_0, vault_1, obs, lp_mint} │ -│ │ -│ T4: get_specs_for_operation(Deposit) → {pool, v0, v1, obs, lp_mint} │ -│ │ -│ KEY INVARIANT: Shared accounts (vaults) use SAME spec instance │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -| Test ID | Test Name | Description | Priority | -|---------|-----------|-------------|----------| -| T3.1 | `test_multi_op_swap_then_deposit` | Specs preserved across ops | HIGH | -| T3.2 | `test_multi_op_shared_accounts` | Shared accounts not duplicated | HIGH | -| T3.3 | `test_multi_op_incremental_fetch` | Can skip already-fetched accounts | HIGH | -| T3.4 | `test_multi_op_state_refresh` | Re-fetching updates cold→hot | HIGH | -| T3.5 | `test_multi_op_interleaved` | Alternating ops work correctly | MEDIUM | - ---- - -## 4. Account Naming / Aliasing Tests - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ ACCOUNT NAMING EDGE CASES │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ PROBLEM: Same account address, different instruction names │ -│ │ -│ Example: │ -│ ┌─────────────────────────────────────────────────────────────────────┐ │ -│ │ Instruction: initialize │ │ -│ │ accounts: │ │ -│ │ - token_vault_0: CYLaS4pMLTb1gTrxf9YnMNkF6ta7vMopKgST5kDAWdU2 │ │ -│ │ - pool_state: 8qitTUf7KWgEwgsLnSfrt52GfTAcUmFRci4h5RdnJh5m │ │ -│ └─────────────────────────────────────────────────────────────────────┘ │ -│ ┌─────────────────────────────────────────────────────────────────────┐ │ -│ │ Instruction: swap │ │ -│ │ accounts: │ │ -│ │ - source_vault: CYLaS4pMLTb1gTrxf9YnMNkF6ta7vMopKgST5kDAWdU2 │ <── SAME! -│ │ - amm_pool: 8qitTUf7KWgEwgsLnSfrt52GfTAcUmFRci4h5RdnJh5m │ <── SAME! -│ └─────────────────────────────────────────────────────────────────────┘ │ -│ │ -│ SOLUTION: SDK keyed by PUBKEY, not name │ -│ - HashMap ensures same address = same spec │ -│ - Variant enum contains canonical data, not instruction-specific names │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -| Test ID | Test Name | Description | Priority | -|---------|-----------|-------------|----------| -| T4.1 | `test_same_address_different_name` | Same pubkey = same spec | HIGH | -| T4.2 | `test_spec_keyed_by_pubkey` | HashMap uses pubkey not name | HIGH | -| T4.3 | `test_variant_canonical_data` | Variant has canonical seeds | HIGH | -| T4.4 | `test_instruction_agnostic` | Works regardless of ix context | MEDIUM | - ---- - -## 5. Exhaustive Coverage Requirements - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ EXHAUSTIVE IMPLEMENTATION REQUIREMENTS │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ A valid LightProgramInterface implementation MUST: │ -│ │ -│ 1. VARIANT COMPLETENESS │ -│ □ LightAccountVariant covers ALL #[light_account] accounts │ -│ □ TokenAccountVariant covers ALL #[rentfree_token] accounts │ -│ □ No rentfree account left unrepresented │ -│ │ -│ 2. OPERATION COMPLETENESS │ -│ □ Operation enum covers all instruction types │ -│ □ Each operation returns correct account set │ -│ □ get_specs_for_operation returns superset of get_accounts_to_update │ -│ │ -│ 3. SEED VALUE COMPLETENESS │ -│ □ All seed fields populated from parsed state │ -│ □ Variant constructor includes all seed values │ -│ □ Seeds match what macros expect for address derivation │ -│ │ -│ 4. CONTEXT COMPLETENESS │ -│ □ Cold accounts have appropriate context (Pda/Token/Mint) │ -│ □ Hot accounts have no context (or empty) │ -│ □ Context types match account types │ -│ │ -│ VALIDATION CHECKS TO IMPLEMENT: │ -│ │ -│ ┌───────────────────────────────────────────────────────────────────┐ │ -│ │ fn validate_implementation() { │ │ -│ │ // 1. Create SDK from known root │ │ -│ │ // 2. For each Operation: │ │ -│ │ // - get_accounts_to_update returns non-empty │ │ -│ │ // - After update, get_specs_for_operation non-empty │ │ -│ │ // - All specs have valid variants │ │ -│ │ // 3. get_all_specs covers all accounts from all ops │ │ -│ │ } │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -| Test ID | Test Name | Description | Priority | -|---------|-----------|-------------|----------| -| T5.1 | `test_variant_covers_all_rentfree` | No rentfree account missing from variant | HIGH | -| T5.2 | `test_operation_covers_all_instructions` | All ix types have operation | HIGH | -| T5.3 | `test_seeds_complete` | All seed values populated | HIGH | -| T5.4 | `test_context_type_matches` | PDA→PdaContext, Token→TokenContext | HIGH | -| T5.5 | `test_all_specs_superset` | get_all_specs ⊇ union of all get_specs_for_op | HIGH | -| T5.6 | `test_no_orphan_accounts` | Every program account reachable via some op | MEDIUM | - ---- - -## 6. Property-Based / Invariant Tests - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ SDK INVARIANTS │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ INVARIANT 1: Idempotency │ -│ ─────────────────────────────────────────────────────────────────────────│ -│ ∀ accounts a: update(a); update(a) ≡ update(a) │ -│ (updating with same data twice has same effect as once) │ -│ │ -│ INVARIANT 2: Commutativity │ -│ ─────────────────────────────────────────────────────────────────────────│ -│ update([a, b]) ≡ update([a]); update([b]) ≡ update([b]); update([a]) │ -│ (order of updates doesn't matter for final state) │ -│ │ -│ INVARIANT 3: Spec Consistency │ -│ ─────────────────────────────────────────────────────────────────────────│ -│ ∀ op: get_accounts_to_update(op) ⊆ keys(get_specs_for_operation(op)) │ -│ (all accounts to update should appear in specs after update) │ -│ │ -│ INVARIANT 4: Address Uniqueness │ -│ ─────────────────────────────────────────────────────────────────────────│ -│ ∀ specs: |specs.addresses| = |unique(specs.addresses)| │ -│ (no duplicate addresses in specs) │ -│ │ -│ INVARIANT 5: Cold Context Presence │ -│ ─────────────────────────────────────────────────────────────────────────│ -│ ∀ spec: spec.is_cold ⟹ spec.cold_context.is_some() │ -│ (cold specs must have context) │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -| Test ID | Test Name | Description | Priority | -|---------|-----------|-------------|----------| -| T6.1 | `test_invariant_idempotent` | update(a);update(a) = update(a) | HIGH | -| T6.2 | `test_invariant_commutative` | Order doesn't matter | HIGH | -| T6.3 | `test_invariant_spec_consistency` | Accounts in specs after update | HIGH | -| T6.4 | `test_invariant_no_duplicates` | No duplicate addresses | HIGH | -| T6.5 | `test_invariant_cold_has_context` | Cold specs have context | HIGH | -| T6.6 | `test_invariant_hot_no_context_needed` | Hot specs work without context | MEDIUM | - ---- - -## 7. State Transition Tests - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ STATE TRANSITION DIAGRAM │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────┐ │ -│ │ EMPTY │ │ -│ │ SDK │ │ -│ └──────┬───────┘ │ -│ │ │ -│ │ from_keyed_accounts([pool]) │ -│ │ (parses root) │ -│ v │ -│ ┌──────────────┐ │ -│ │ ROOT PARSED │ │ -│ │ (pool only) │ │ -│ └──────┬───────┘ │ -│ │ │ -│ ┌──────────────────┼──────────────────┐ │ -│ │ │ │ │ -│ │ update([vaults]) │ update([obs]) │ update([mint]) │ -│ v v v │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ SWAP READY │ │ PARTIAL │ │ MINT READY │ │ -│ │ (vaults) │ │ (vaults+obs) │ │ (mint) │ │ -│ └──────────────┘ └──────────────┘ └──────────────┘ │ -│ │ │ │ │ -│ └──────────────────┼──────────────────┘ │ -│ │ update([remaining]) │ -│ v │ -│ ┌──────────────┐ │ -│ │ COMPLETE │ │ -│ │ (all specs) │ │ -│ └──────────────┘ │ -│ │ -│ TRANSITIONS: │ -│ - Any state → COMPLETE (by updating remaining accounts) │ -│ - Hot → Cold (account compressed externally, re-fetch) │ -│ - Cold → Hot (account decompressed, re-fetch) │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -| Test ID | Test Name | Description | Priority | -|---------|-----------|-------------|----------| -| T7.1 | `test_state_empty_to_root` | Empty → Root parsed | HIGH | -| T7.2 | `test_state_root_to_swap_ready` | Root → Swap ready (vaults) | HIGH | -| T7.3 | `test_state_incremental_to_complete` | Incremental updates to complete | HIGH | -| T7.4 | `test_state_hot_to_cold_refetch` | Re-fetch changes hot→cold | HIGH | -| T7.5 | `test_state_cold_to_hot_refetch` | Re-fetch changes cold→hot | HIGH | - ---- - -## 8. Edge Case Tests - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ EDGE CASES MATRIX │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ SCENARIO │ EXPECTED BEHAVIOR │ TEST │ -│ ──────────────────────────────────────────────────────────────────────────│ -│ Pool with zero vaults │ Swap returns empty │ zero_vaults │ -│ Pool without LP mint │ Deposit excludes mint │ no_lp_mint │ -│ All accounts hot │ all_hot() = true │ all_hot │ -│ All accounts cold │ has_cold() = true │ all_cold │ -│ Mixed hot/cold │ correct filtering │ mixed_state │ -│ Very large state data │ Handles without OOM │ large_data │ -│ Concurrent updates │ No race conditions │ concurrent │ -│ Null pubkeys in state │ Graceful handling │ null_pubkeys │ -│ Duplicate accounts in update │ Deduplicated │ duplicate_accts │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -| Test ID | Test Name | Description | Priority | -|---------|-----------|-------------|----------| -| T8.1 | `test_edge_zero_vaults` | Pool with no vaults | MEDIUM | -| T8.2 | `test_edge_no_lp_mint` | Pool without LP mint | MEDIUM | -| T8.3 | `test_edge_all_hot` | all_hot() works correctly | HIGH | -| T8.4 | `test_edge_all_cold` | has_cold() works correctly | HIGH | -| T8.5 | `test_edge_mixed_hot_cold` | Mixed state handled | HIGH | -| T8.6 | `test_edge_duplicate_accounts` | Duplicates deduplicated | MEDIUM | - ---- - -## 9. Same Type Different Instance Tests (CRITICAL) - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ SAME TYPE, DIFFERENT INSTANCE - SPEC SEPARATION │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ SCENARIO: vault_0 and vault_1 are BOTH TokenVault type │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────────┐ │ -│ │ VAULT_0 VAULT_1 │ │ -│ │ ───────────────────────────────────────────────────────────────── │ │ -│ │ pubkey: 0xAAAA... pubkey: 0xBBBB... │ │ -│ │ type: Token0Vault type: Token1Vault │ │ -│ │ seeds: [pool, mint_0] seeds: [pool, mint_1] │ │ -│ │ │ │ -│ │ ↓ DIFFERENT PUBKEYS = DIFFERENT SPECS ↓ │ │ -│ │ │ │ -│ │ ┌───────────────────────────────────────────────────────────┐ │ │ -│ │ │ HashMap │ │ │ -│ │ │ ──────────────────────────────────────────────────────── │ │ │ -│ │ │ 0xAAAA... → Spec { variant: Token0Vault, ... } │ │ │ -│ │ │ 0xBBBB... → Spec { variant: Token1Vault, ... } │ │ │ -│ │ └───────────────────────────────────────────────────────────┘ │ │ -│ └─────────────────────────────────────────────────────────────────────┘ │ -│ │ -│ KEY INVARIANTS: │ -│ 1. Pubkey is globally unique → HashMap key guarantees no mingling │ -│ 2. Variant enum encodes WHICH account via type + seed values │ -│ 3. Field name (vault_0, vault_1) unique across ALL instructions │ -│ 4. Updating vault_0 does NOT affect vault_1 │ -│ 5. get_specs_for_operation returns ALL required instances │ -│ │ -│ CROSS-INSTRUCTION NAMING: │ -│ ┌─────────────────────────────────────────────────────────────────────┐ │ -│ │ initialize.token_0_vault ──────┐ │ │ -│ │ ├──→ SAME pubkey = SAME spec │ │ -│ │ swap.input_vault ──────────────┘ │ │ -│ │ │ │ -│ │ SDK keys by PUBKEY, not field name, so same account │ │ -│ │ referenced by different names = single spec entry │ │ -│ └─────────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -| Test ID | Test Name | Description | Priority | -|---------|-----------|-------------|----------| -| T9.1 | `test_same_type_different_pubkey_separate_specs` | Two vaults with different pubkeys = two specs | CRITICAL | -| T9.2 | `test_variant_seed_values_distinguish_instances` | Variants contain different seed values | CRITICAL | -| T9.3 | `test_specs_contain_all_vaults_not_merged` | Specs returns BOTH vaults, not merged | CRITICAL | -| T9.4 | `test_field_name_uniqueness_across_instructions` | Same pubkey from different names = single spec | CRITICAL | -| T9.5 | `test_updating_vault_0_does_not_affect_vault_1` | Update isolation between vaults | CRITICAL | -| T9.6 | `test_operation_returns_all_required_instances` | Operation returns ALL needed instances | CRITICAL | -| T9.7 | `test_hashmap_keying_prevents_spec_mingling` | HashMap prevents mingling | CRITICAL | - ---- - -## Test Implementation Summary - -### Total Tests by Category - -| Category | Count | Priority HIGH | Priority CRITICAL | -|----------|-------|---------------|-------------------| -| 1. Core Methods | 22 | 18 | 0 | -| 2. Error Handling | 6 | 5 | 0 | -| 3. Multi-Operation | 5 | 4 | 0 | -| 4. Account Naming | 4 | 3 | 0 | -| 5. Exhaustive Coverage | 6 | 5 | 0 | -| 6. Invariants | 6 | 5 | 0 | -| 7. State Transitions | 5 | 5 | 0 | -| 8. Edge Cases | 6 | 3 | 0 | -| 9. Same Type Different Instance | 7 | 0 | **7** | -| **TOTAL** | **67** | **48** | **7** | - -### Currently Implemented Tests: **31 PASSING** - -``` -test test_all_specs_helpers ... ok -test test_edge_all_hot_check ... ok -test test_error_missing_field_names_field ... ok -test test_error_display_impl ... ok -test test_edge_duplicate_accounts_in_update ... ok -test test_error_parse_error_contains_cause ... ok -test test_field_name_uniqueness_across_instructions ... ok [T9.4] -test test_from_keyed_empty_accounts ... ok -test test_from_keyed_truncated_data ... ok -test test_from_keyed_wrong_discriminator ... ok -test test_from_keyed_zero_length_data ... ok -test test_get_accounts_before_init ... ok -test test_get_accounts_swap_vs_deposit ... ok -test test_get_accounts_to_update_typed_categories ... ok -test test_get_accounts_to_update_typed_empty ... ok -test test_get_all_empty ... ok -test test_hashmap_keying_prevents_spec_mingling ... ok [T9.7] -test test_invariant_cold_has_context ... ok -test test_invariant_hot_context_optional ... ok -test test_invariant_no_duplicate_addresses ... ok -test test_multi_op_deposit_superset_of_swap ... ok -test test_multi_op_withdraw_equals_deposit ... ok -test test_operation_returns_all_required_instances ... ok [T9.6] -test test_same_pubkey_same_spec ... ok -test test_same_type_different_pubkey_separate_specs ... ok [T9.1] -test test_specs_contain_all_vaults_not_merged ... ok [T9.3] -test test_update_idempotent ... ok -test test_update_before_root_errors ... ok -test test_update_unknown_account_skipped ... ok -test test_updating_vault_0_does_not_affect_vault_1 ... ok [T9.5] -test test_variant_seed_values_distinguish_instances ... ok [T9.2] -``` - -### Implementation Priority Order - -1. **Phase 0 (CRITICAL)**: T9.* (Same Type Different Instance - ALL IMPLEMENTED) -2. **Phase 1 (HIGH)**: T1.1.*, T1.3.*, T2.*, T6.* (Core + Error + Invariants) -3. **Phase 2 (IMPORTANT)**: T1.2.*, T1.4.*, T1.5.*, T3.*, T5.* (Ops + Multi-op) -4. **Phase 3 (ROBUSTNESS)**: T4.*, T7.*, T8.* (Naming + State + Edge) - ---- - -## File Structure - -``` -sdk-tests/csdk-anchor-full-derived-test-sdk/ -├── src/ -│ └── lib.rs # AmmSdk implementation -└── tests/ - └── trait_tests.rs # All trait unit tests (31 tests) -``` diff --git a/sdk-tests/csdk-anchor-full-derived-test-sdk/tests/trait_tests.rs b/sdk-tests/csdk-anchor-full-derived-test-sdk/tests/trait_tests.rs index 4dcb0a8267..e78a1c5be8 100644 --- a/sdk-tests/csdk-anchor-full-derived-test-sdk/tests/trait_tests.rs +++ b/sdk-tests/csdk-anchor-full-derived-test-sdk/tests/trait_tests.rs @@ -1,429 +1,158 @@ -//! LightProgramInterface trait unit tests for AmmSdk. +//! LightProgram trait unit tests for AmmSdk. //! //! Tests cover: -//! - Core trait methods (from_keyed_accounts, update, get_specs_for_instruction) -//! - Error handling and meaningful error messages -//! - Multi-operation scenarios with overlapping/divergent accounts -//! - Invariants (idempotency, commutativity, spec consistency) -//! - Edge cases (hot/cold mixed, missing accounts, etc.) +//! - instruction_accounts returns correct pubkeys per instruction type +//! - load_specs builds correct variants from cold accounts +//! - Helper functions (all_hot, any_cold) +//! - Invariants (no duplicate addresses, variant seed distinction) use std::collections::HashSet; -use csdk_anchor_full_derived_test::{ - amm_test::{ObservationState, PoolState}, - csdk_anchor_full_derived_test::{LightAccountVariant, ObservationStateSeeds}, +use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::{ + LightAccountVariant, ObservationStateSeeds, }; -use csdk_anchor_full_derived_test_sdk::{AmmInstruction, AmmSdk, AmmSdkError}; +use csdk_anchor_full_derived_test_sdk::{AmmInstruction, AmmSdk, AmmSdkError, PROGRAM_ID}; use light_client::interface::{ - all_hot, any_cold, Account, AccountInterface, AccountSpec, LightProgramInterface, PdaSpec, + all_hot, any_cold, Account, AccountInterface, AccountSpec, LightProgram, PdaSpec, }; -use light_sdk::LightDiscriminator; use solana_pubkey::Pubkey; // ============================================================================= // TEST HELPERS // ============================================================================= -/// Create a hot AccountInterface from data. -fn keyed_hot(pubkey: Pubkey, data: Vec) -> AccountInterface { - AccountInterface::hot( - pubkey, - Account { - lamports: 0, - data, - owner: csdk_anchor_full_derived_test_sdk::PROGRAM_ID, - executable: false, - rent_epoch: 0, - }, - ) +/// Build an AmmSdk with known pubkeys for unit testing (no deserialization). +fn test_sdk() -> AmmSdk { + AmmSdk { + pool_state_pubkey: Pubkey::new_unique(), + amm_config: Pubkey::new_unique(), + token_0_mint: Pubkey::new_unique(), + token_1_mint: Pubkey::new_unique(), + token_0_vault: Pubkey::new_unique(), + token_1_vault: Pubkey::new_unique(), + lp_mint: Pubkey::new_unique(), + observation_key: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + lp_mint_signer: Pubkey::new_unique(), + } } // ============================================================================= -// 1. CORE TRAIT METHOD TESTS: from_keyed_accounts +// 1. PROGRAM_ID // ============================================================================= #[test] -fn test_from_keyed_empty_accounts() { - // T1.1.1: Empty array should create empty SDK (no error, just no state) - let result = AmmSdk::from_keyed_accounts(&[]); - assert!(result.is_ok(), "Empty accounts should not error"); - - let sdk = result.unwrap(); - assert!( - sdk.pool_state_pubkey().is_none(), - "No pool state parsed from empty" - ); -} - -#[test] -fn test_from_keyed_wrong_discriminator() { - // T1.1.5: Unknown discriminator should be skipped - let mut data = vec![0u8; 100]; - data[..8].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]); - - let keyed = keyed_hot(Pubkey::new_unique(), data); - let result = AmmSdk::from_keyed_accounts(&[keyed]); - - assert!(result.is_ok(), "Unknown discriminator should not error"); - let sdk = result.unwrap(); - assert!( - sdk.pool_state_pubkey().is_none(), - "Unknown disc should be skipped" - ); -} - -#[test] -fn test_from_keyed_truncated_data() { - // T1.1.6: Truncated data should error on parse - let mut data = Vec::new(); - data.extend_from_slice(&PoolState::LIGHT_DISCRIMINATOR); - data.extend_from_slice(&[0u8; 10]); // Way too short - - let keyed = keyed_hot(Pubkey::new_unique(), data); - let result = AmmSdk::from_keyed_accounts(&[keyed]); - - // Should either skip or error depending on implementation - // Current impl: errors on parse - assert!( - result.is_err() || result.as_ref().unwrap().pool_state_pubkey().is_none(), - "Truncated data should error or skip" - ); -} - -#[test] -fn test_from_keyed_zero_length_data() { - // T1.1.7: Zero-length data should be skipped - let keyed = keyed_hot(Pubkey::new_unique(), vec![]); - let result = AmmSdk::from_keyed_accounts(&[keyed]); - - assert!(result.is_ok(), "Zero-length should not error"); - let sdk = result.unwrap(); - assert!( - sdk.pool_state_pubkey().is_none(), - "Zero-length should be skipped" - ); +fn test_program_id() { + assert_eq!(AmmSdk::program_id(), PROGRAM_ID); } // ============================================================================= -// 2. CORE TRAIT METHOD TESTS: get_accounts_to_update +// 2. INSTRUCTION_ACCOUNTS // ============================================================================= #[test] -fn test_get_accounts_before_init() { - // T1.2.4: Returns empty before pool parsed - let sdk = AmmSdk::new(); +fn test_swap_instruction_accounts() { + let sdk = test_sdk(); + let accounts = sdk.instruction_accounts(&AmmInstruction::Swap); - let swap_accounts = sdk.get_accounts_to_update(&AmmInstruction::Swap); - let deposit_accounts = sdk.get_accounts_to_update(&AmmInstruction::Deposit); - - assert!( - swap_accounts.is_empty(), - "Swap should return empty before init" - ); - assert!( - deposit_accounts.is_empty(), - "Deposit should return empty before init" - ); + assert_eq!(accounts.len(), 4, "Swap: pool_state, vault_0, vault_1, observation"); + assert!(accounts.contains(&sdk.pool_state_pubkey)); + assert!(accounts.contains(&sdk.token_0_vault)); + assert!(accounts.contains(&sdk.token_1_vault)); + assert!(accounts.contains(&sdk.observation_key)); + // Swap does not include lp_mint + assert!(!accounts.contains(&sdk.lp_mint)); } #[test] -fn test_get_accounts_swap_vs_deposit() { - // T1.2.1, T1.2.2: Compare Swap vs Deposit accounts - // Note: This test would need a properly parsed SDK - // For now, verify the behavior contract +fn test_deposit_instruction_accounts() { + let sdk = test_sdk(); + let accounts = sdk.instruction_accounts(&AmmInstruction::Deposit); - let sdk = AmmSdk::new(); - // Without pool state, both return empty - let _swap_accounts = sdk.get_accounts_to_update(&AmmInstruction::Swap); - let deposit_accounts = sdk.get_accounts_to_update(&AmmInstruction::Deposit); - let withdraw_accounts = sdk.get_accounts_to_update(&AmmInstruction::Withdraw); - - // Verify Deposit and Withdraw have same requirements - assert_eq!( - deposit_accounts, withdraw_accounts, - "Deposit and Withdraw should have same account requirements" - ); + assert_eq!(accounts.len(), 5, "Deposit: pool_state, vault_0, vault_1, observation, lp_mint"); + assert!(accounts.contains(&sdk.pool_state_pubkey)); + assert!(accounts.contains(&sdk.token_0_vault)); + assert!(accounts.contains(&sdk.token_1_vault)); + assert!(accounts.contains(&sdk.observation_key)); + assert!(accounts.contains(&sdk.lp_mint)); } -// ============================================================================= -// 3. CORE TRAIT METHOD TESTS: update -// ============================================================================= - #[test] -fn test_update_before_root_errors() { - // T1.3.4: Update before root parsed should error for accounts that need root - let mut sdk = AmmSdk::new(); - - // Try to update with a vault before pool state is parsed - let vault_data = vec![0u8; 165]; // TokenData size - let vault_keyed = keyed_hot(Pubkey::new_unique(), vault_data); - - // This should either error or skip (depending on implementation) - let result = sdk.update(&[vault_keyed]); - - // Current impl: skips unknown accounts, doesn't error - assert!(result.is_ok(), "Update with unknown should skip, not error"); +fn test_withdraw_equals_deposit() { + let sdk = test_sdk(); + let deposit = sdk.instruction_accounts(&AmmInstruction::Deposit); + let withdraw = sdk.instruction_accounts(&AmmInstruction::Withdraw); + assert_eq!(deposit, withdraw, "Deposit and Withdraw have identical account sets"); } #[test] -fn test_update_idempotent() { - // T1.3.3, T6.1: Same account twice should be idempotent - let mut sdk = AmmSdk::new(); - - let data = vec![0u8; 100]; - let keyed = keyed_hot(Pubkey::new_unique(), data.clone()); - - // Update twice with same data - let _ = sdk.update(std::slice::from_ref(&keyed)); - let specs_after_first = sdk.get_all_specs(); - - let _ = sdk.update(std::slice::from_ref(&keyed)); - let specs_after_second = sdk.get_all_specs(); +fn test_deposit_superset_of_swap() { + let sdk = test_sdk(); + let swap: HashSet = sdk.instruction_accounts(&AmmInstruction::Swap).into_iter().collect(); + let deposit: HashSet = sdk.instruction_accounts(&AmmInstruction::Deposit).into_iter().collect(); - // Should be same - assert_eq!( - specs_after_first.len(), - specs_after_second.len(), - "Idempotent: same spec count" - ); + assert!(swap.is_subset(&deposit), "Swap accounts must be a subset of Deposit accounts"); } #[test] -fn test_update_unknown_account_skipped() { - // T1.3.5: Unknown accounts should be skipped - let mut sdk = AmmSdk::new(); - - let unknown_data = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x00, 0x00, 0x00]; - let keyed = keyed_hot(Pubkey::new_unique(), unknown_data); - - let result = sdk.update(&[keyed]); - assert!(result.is_ok(), "Unknown account should be skipped"); - - let specs = sdk.get_all_specs(); - assert!(specs.is_empty(), "Unknown should not add spec"); +fn test_no_duplicate_pubkeys_in_instruction_accounts() { + let sdk = test_sdk(); + for ix in [AmmInstruction::Swap, AmmInstruction::Deposit, AmmInstruction::Withdraw] { + let accounts = sdk.instruction_accounts(&ix); + let unique: HashSet = accounts.iter().copied().collect(); + assert_eq!(accounts.len(), unique.len(), "No duplicate pubkeys for {:?}", ix); + } } // ============================================================================= -// 4. CORE TRAIT METHOD TESTS: get_all_specs / get_specs_for_instruction +// 3. LOAD_SPECS (empty input) // ============================================================================= #[test] -fn test_get_all_empty() { - // T1.4.1: Empty SDK returns empty specs - let sdk = AmmSdk::new(); - let specs = sdk.get_all_specs(); - +fn test_load_specs_empty_input() { + let sdk = test_sdk(); + let specs = sdk.load_specs(&[]).expect("empty input should succeed"); assert!(specs.is_empty()); - assert!(all_hot(&specs), "Empty specs should report all_hot"); -} - -#[test] -fn test_all_specs_helpers() { - // Test all_hot() and any_cold() helpers - let specs: Vec> = vec![]; - - assert!(all_hot(&specs), "Empty is all hot"); - assert!(!any_cold(&specs), "Empty has no cold"); } -// ============================================================================= -// 5. ERROR HANDLING TESTS -// ============================================================================= - #[test] -fn test_error_display_impl() { - // T2.4: All errors have Display impl with meaningful messages - let errors = vec![ - AmmSdkError::ParseError("test parse".to_string()), - AmmSdkError::UnknownDiscriminator([0u8; 8]), - AmmSdkError::MissingField("test_field"), - AmmSdkError::PoolStateNotParsed, - ]; - - for err in errors { - let msg = format!("{}", err); - assert!(!msg.is_empty(), "Error should have display message"); - println!("Error display: {}", msg); - } -} - -#[test] -fn test_error_parse_error_contains_cause() { - let err = AmmSdkError::ParseError("deserialization failed".to_string()); - let msg = format!("{}", err); - assert!( - msg.contains("deserialization"), - "ParseError should include cause" - ); -} - -#[test] -fn test_error_missing_field_names_field() { - let err = AmmSdkError::MissingField("amm_config"); - let msg = format!("{}", err); - assert!( - msg.contains("amm_config"), - "MissingField should name the field" - ); -} - -// ============================================================================= -// 6. INVARIANT TESTS -// ============================================================================= - -#[test] -fn test_invariant_no_duplicate_addresses() { - // T6.4: No duplicate addresses in specs - let sdk = AmmSdk::new(); - let specs = sdk.get_all_specs(); - - let addresses: Vec = specs.iter().map(|s| s.pubkey()).collect(); - let unique: HashSet = addresses.iter().copied().collect(); - - assert_eq!( - addresses.len(), - unique.len(), - "No duplicate addresses allowed" +fn test_load_specs_unknown_pubkey_skipped() { + let sdk = test_sdk(); + let unknown = AccountInterface::hot( + Pubkey::new_unique(), + Account { + lamports: 0, + data: vec![0; 100], + owner: PROGRAM_ID, + executable: false, + rent_epoch: 0, + }, ); -} - -#[test] -fn test_invariant_cold_has_context() { - // T6.5: Cold specs must have compressed data - let sdk = AmmSdk::new(); - let specs = sdk.get_all_specs(); - - for spec in &specs { - if spec.is_cold() { - match spec { - AccountSpec::Pda(s) => { - assert!( - s.compressed().is_some(), - "Cold PDA must have compressed: {}", - s.address() - ); - } - AccountSpec::Ata(s) => { - assert!( - s.compressed().is_some(), - "Cold ATA must have compressed: {}", - s.key - ); - } - AccountSpec::Mint(s) => { - assert!( - s.cold.is_some() && s.as_mint().is_some(), - "Cold mint must have cold context + mint_data: {}", - s.key - ); - } - } - } - } -} - -#[test] -fn test_invariant_hot_context_optional() { - // T6.6: Hot specs don't need compressed data (can be None) - let sdk = AmmSdk::new(); - let specs = sdk.get_all_specs(); - - for spec in &specs { - if !spec.is_cold() { - // Hot compressed can be None - this is valid - // Just verify the spec is accessible - let _ = spec.pubkey(); - } - } + let specs = sdk.load_specs(&[unknown]).expect("unknown pubkey should be skipped"); + assert!(specs.is_empty()); } // ============================================================================= -// 7. MULTI-OPERATION TESTS +// 4. HELPER FUNCTIONS // ============================================================================= #[test] -fn test_multi_op_deposit_superset_of_swap() { - // T3.1: Deposit accounts should be superset of Swap - let sdk = AmmSdk::new(); - - let swap_accounts: HashSet = sdk - .get_accounts_to_update(&AmmInstruction::Swap) - .into_iter() - .map(|a| a.pubkey()) - .collect(); - let deposit_accounts: HashSet = sdk - .get_accounts_to_update(&AmmInstruction::Deposit) - .into_iter() - .map(|a| a.pubkey()) - .collect(); - - // All swap accounts should be in deposit - for acc in &swap_accounts { - assert!( - deposit_accounts.contains(acc), - "Deposit should include all Swap accounts" - ); - } +fn test_all_hot_empty() { + let specs: Vec> = vec![]; + assert!(all_hot(&specs), "Empty is all hot"); + assert!(!any_cold(&specs), "Empty has no cold"); } #[test] -fn test_multi_op_withdraw_equals_deposit() { - // T3.1: Withdraw should have same accounts as Deposit - let sdk = AmmSdk::new(); - - let deposit_accounts = sdk.get_accounts_to_update(&AmmInstruction::Deposit); - let withdraw_accounts = sdk.get_accounts_to_update(&AmmInstruction::Withdraw); +fn test_all_hot_with_hot_spec() { + use csdk_anchor_full_derived_test::amm_test::ObservationState; - assert_eq!( - deposit_accounts, withdraw_accounts, - "Deposit and Withdraw should have identical account requirements" - ); -} - -// ============================================================================= -// 8. ACCOUNT NAMING TESTS -// ============================================================================= - -#[test] -fn test_same_pubkey_same_spec() { - // T4.1, T4.2: Same pubkey should always map to same spec - // Regardless of what name the instruction calls it - - let mut sdk = AmmSdk::new(); - let pubkey = Pubkey::new_unique(); - let data = vec![0u8; 100]; - - // Update with same pubkey twice (simulating different instruction contexts) - let keyed1 = keyed_hot(pubkey, data.clone()); - let keyed2 = keyed_hot(pubkey, data.clone()); - - let _ = sdk.update(&[keyed1]); - let specs_after_first = sdk.get_all_specs(); - - let _ = sdk.update(&[keyed2]); - let specs_after_second = sdk.get_all_specs(); - - // Should have same count (not doubled) - assert_eq!( - specs_after_first.len(), - specs_after_second.len(), - "Same pubkey should not create duplicate specs" - ); -} - -// ============================================================================= -// 9. EDGE CASE TESTS -// ============================================================================= - -#[test] -fn test_edge_all_hot_check() { - // T8.3: all_hot() returns true when all specs are hot let hot_interface = AccountInterface::hot( Pubkey::new_unique(), Account { lamports: 0, data: vec![0; 100], - owner: csdk_anchor_full_derived_test_sdk::PROGRAM_ID, + owner: PROGRAM_ID, executable: false, rent_epoch: 0, }, @@ -436,117 +165,31 @@ fn test_edge_all_hot_check() { }, data: ObservationState::default(), }, - csdk_anchor_full_derived_test_sdk::PROGRAM_ID, + PROGRAM_ID, ); let specs: Vec> = vec![AccountSpec::Pda(hot_spec)]; - assert!( - all_hot(&specs), - "All hot specs should return all_hot() = true" - ); - assert!( - !any_cold(&specs), - "All hot specs should return any_cold() = false" - ); -} - -#[test] -fn test_edge_duplicate_accounts_in_update() { - // T8.6: Duplicate accounts in single update should be deduplicated - let mut sdk = AmmSdk::new(); - let pubkey = Pubkey::new_unique(); - let data = vec![0u8; 100]; - - let keyed = keyed_hot(pubkey, data); - - // Update with same account twice in same call - let _ = sdk.update(&[keyed.clone(), keyed.clone()]); - - // Should not have duplicates in specs - let specs = sdk.get_all_specs(); - let addresses: Vec = specs.iter().map(|s| s.pubkey()).collect(); - let unique: HashSet = addresses.iter().copied().collect(); - - assert_eq!( - addresses.len(), - unique.len(), - "Duplicates should be deduplicated" - ); + assert!(all_hot(&specs)); + assert!(!any_cold(&specs)); } // ============================================================================= -// 10. TYPED FETCH HELPER TESTS +// 5. ERROR DISPLAY // ============================================================================= #[test] -fn test_get_accounts_to_update_empty() { - // get_accounts_to_update should return empty for uninitialized SDK - let sdk = AmmSdk::new(); - - let typed = sdk.get_accounts_to_update(&AmmInstruction::Swap); - assert!(typed.is_empty(), "Typed should be empty before init"); -} - -#[test] -fn test_get_accounts_to_update_categories() { - // Verify typed accounts have correct categories - use light_client::interface::AccountToFetch; - - let sdk = AmmSdk::new(); - let typed = sdk.get_accounts_to_update(&AmmInstruction::Deposit); - - // All should be one of Pda, Token, Ata, or Mint - for acc in &typed { - match acc { - AccountToFetch::Pda { .. } => {} - AccountToFetch::Token { .. } => {} - AccountToFetch::Ata { .. } => {} - AccountToFetch::Mint { .. } => {} - } - } +fn test_error_display() { + let err = AmmSdkError::ParseError("deserialization failed".to_string()); + let msg = format!("{}", err); + assert!(msg.contains("deserialization"), "ParseError should include cause"); } // ============================================================================= -// 11. SAME TYPE DIFFERENT INSTANCE TESTS +// 6. VARIANT SEED DISTINCTION // ============================================================================= -// Critical tests for ensuring vault_0 and vault_1 (same type, different seeds/values) -// are handled as separate specs and not mingled together. - -#[test] -fn test_same_type_different_pubkey_separate_specs() { - // CRITICAL: Two accounts of same type but different pubkeys must be stored separately. - // This is the case for vault_0 and vault_1 which are both token vaults - // but with different mints and therefore different pubkeys. - - // Create two different pubkeys (simulating vault_0 and vault_1) - let vault_0_pubkey = Pubkey::new_unique(); - let vault_1_pubkey = Pubkey::new_unique(); - - assert_ne!( - vault_0_pubkey, vault_1_pubkey, - "Vaults must have different pubkeys" - ); - - // In the SDK, these would be keyed by pubkey in HashMap - // Verify the design: each pubkey gets its own entry - let mut pubkey_set: HashSet = HashSet::new(); - pubkey_set.insert(vault_0_pubkey); - pubkey_set.insert(vault_1_pubkey); - - assert_eq!( - pubkey_set.len(), - 2, - "Two different pubkeys must create two entries" - ); -} #[test] fn test_variant_seed_values_distinguish_instances() { - // CRITICAL: Even if variants have same type name, the seed VALUES must differ. - // Example: Token0Vault{pool_state: A, token_0_mint: B} vs Token1Vault{pool_state: A, token_1_mint: C} - // - // The variant enum encodes WHICH account this is via the variant name AND seed values. - use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::{ Token0VaultSeeds, Token1VaultSeeds, }; @@ -583,8 +226,6 @@ fn test_variant_seed_values_distinguish_instances() { token_data: default_token, }); - // These are different enum variants (type-level distinction) - // Even if they were the same variant type, the seed values differ match (&variant_0, &variant_1) { (LightAccountVariant::Token0Vault(data_0), LightAccountVariant::Token1Vault(data_1)) => { assert_ne!( @@ -596,612 +237,31 @@ fn test_variant_seed_values_distinguish_instances() { } } -#[test] -fn test_specs_contain_all_vaults_not_merged() { - // CRITICAL: When getting specs for Swap, we must get BOTH vault_0 AND vault_1, - // not have them merged into a single spec. - - // The SDK stores specs in HashMap - // This test verifies the invariant that different pubkeys = different specs - - let sdk = AmmSdk::new(); - - // Before init, specs are empty - let specs = sdk.get_specs_for_instruction(&AmmInstruction::Swap); - - // Count of specs should match number of unique accounts - // When SDK is properly initialized with pool_state and vaults, - // Swap should return pool_state + vault_0 + vault_1 = 3 specs - - // For now, verify the empty case works correctly - assert_eq!(specs.len(), 0, "Uninitialized SDK should have 0 specs"); - - // The invariant we're testing: no duplicate addresses - let addresses: HashSet = specs.iter().map(|s| s.pubkey()).collect(); - assert_eq!( - specs.len(), - addresses.len(), - "Each spec must have unique address" - ); -} - -#[test] -fn test_field_name_uniqueness_across_instructions() { - // CRITICAL: Field names like "token_0_vault" must be globally unique across ALL instructions. - // The macros enforce this - same field name = same account = same spec. - // - // This test documents the design contract: - // - In initialize: token_0_vault refers to account at pubkey A - // - In swap: source_vault (if it's the same account) MUST have pubkey A - // - The SDK keys by pubkey, so same pubkey = same spec regardless of field name in instruction - - // Two instructions can call the same account different names: - // initialize.token_0_vault and swap.input_vault could be the SAME account - // The SDK correctly handles this by keying on pubkey, not field name - - let shared_pubkey = Pubkey::new_unique(); - - // If two instructions reference the same pubkey, they're the same account - // The SDK stores ONE spec for this pubkey, not two - let mut seen_pubkeys: HashSet = HashSet::new(); - - // "initialize.token_0_vault" -> shared_pubkey - seen_pubkeys.insert(shared_pubkey); - - // "swap.input_vault" -> shared_pubkey (same account, different name) - seen_pubkeys.insert(shared_pubkey); - - assert_eq!( - seen_pubkeys.len(), - 1, - "Same pubkey from different field names = single spec" - ); -} - -#[test] -fn test_updating_vault_0_does_not_affect_vault_1() { - // CRITICAL: Updating vault_0's spec must NOT affect vault_1's spec. - // They are independent entries in the HashMap. - - let mut sdk = AmmSdk::new(); - - // Create two different "vault" accounts - let vault_0_pubkey = Pubkey::new_unique(); - let vault_1_pubkey = Pubkey::new_unique(); - - let vault_0_data = vec![0xAAu8; 100]; - let vault_1_data = vec![0xBBu8; 100]; - - let vault_0_keyed = keyed_hot(vault_0_pubkey, vault_0_data); - let vault_1_keyed = keyed_hot(vault_1_pubkey, vault_1_data); - - // Update with both - let _ = sdk.update(&[vault_0_keyed.clone(), vault_1_keyed.clone()]); - - // Now update vault_0 again with different data - let vault_0_updated = keyed_hot(vault_0_pubkey, vec![0xCCu8; 100]); - let _ = sdk.update(&[vault_0_updated]); - - // Verify: vault_1 should still have its original data (if tracked) - // The key point: updating by pubkey only affects that specific entry - let specs = sdk.get_all_specs(); - - // Verify both are still separate entries (if they were recognized) - let addresses: HashSet = specs.iter().map(|s| s.pubkey()).collect(); - - // No duplicates - assert_eq!( - specs.len(), - addresses.len(), - "Each vault must remain separate" - ); -} - -#[test] -fn test_operation_returns_all_required_instances() { - // CRITICAL: get_specs_for_instruction(Swap) must return BOTH vault_0 AND vault_1, - // not just one of them. - - // Document the expected behavior: - // Swap operation needs: pool_state, vault_0, vault_1 - // Deposit operation needs: pool_state, vault_0, vault_1, observation, lp_mint - - let sdk = AmmSdk::new(); - - // Get accounts needed for Swap - let swap_accounts = sdk.get_accounts_to_update(&AmmInstruction::Swap); - - // Without pool state, this is empty, but document the contract: - // When properly initialized, Swap should request both vaults - // The SDK implementation does: vec![token_0_vault, token_1_vault].into_iter().flatten() - - // This confirms the design: BOTH vaults are requested, not just one - // Each vault is a separate entry, not merged - - // Verify Deposit requests more accounts than Swap - let deposit_accounts = sdk.get_accounts_to_update(&AmmInstruction::Deposit); - - // Even when empty, the contract holds: - // len(deposit_accounts) >= len(swap_accounts) because Deposit is a superset - assert!( - deposit_accounts.len() >= swap_accounts.len(), - "Deposit must request at least as many accounts as Swap" - ); -} - -#[test] -fn test_hashmap_keying_prevents_spec_mingling() { - // CRITICAL: The SDK uses HashMap which naturally prevents mingling. - // This test verifies the data structure choice is correct. - - use std::collections::HashMap; - - let vault_0_pubkey = Pubkey::new_unique(); - let vault_1_pubkey = Pubkey::new_unique(); - - // Simulate the SDK's internal storage - let mut specs: HashMap = HashMap::new(); - - // Insert vault_0 spec - specs.insert(vault_0_pubkey, "vault_0_spec".to_string()); - - // Insert vault_1 spec - specs.insert(vault_1_pubkey, "vault_1_spec".to_string()); - - // Verify: both are stored separately - assert_eq!(specs.len(), 2, "Two vaults = two entries"); - assert_eq!( - specs.get(&vault_0_pubkey), - Some(&"vault_0_spec".to_string()) - ); - assert_eq!( - specs.get(&vault_1_pubkey), - Some(&"vault_1_spec".to_string()) - ); - - // Updating vault_0 doesn't affect vault_1 - specs.insert(vault_0_pubkey, "vault_0_updated".to_string()); - assert_eq!( - specs.get(&vault_1_pubkey), - Some(&"vault_1_spec".to_string()), - "vault_1 must be unaffected" - ); -} - -// ============================================================================= -// 8. DIVERGENT NAMING TESTS: input_vault/output_vault vs token_0_vault/token_1_vault -// ============================================================================= - -#[test] -fn test_swap_returns_both_vaults_regardless_of_role() { - // CRITICAL: Swap instruction uses input_vault/output_vault names, - // but they are aliases for token_0_vault/token_1_vault. - // The SDK must return BOTH vaults for Swap, regardless of trade direction. - - let sdk = AmmSdk::new(); - - let swap_accounts = sdk.get_accounts_to_update(&AmmInstruction::Swap); - - // Without pool state initialized, this is empty, but the contract is: - // When pool_state has token_0_vault and token_1_vault set, - // get_accounts_to_update(Swap) returns BOTH. - // - // This is because the SDK doesn't know which vault will be "input" vs "output" - // at runtime - that depends on trade direction chosen by the user. - - // Document: accounts returned are keyed by CANONICAL pubkeys (token_0_vault, token_1_vault) - // NOT by instruction field names (input_vault, output_vault) - - // The Swap instruction's input_vault/output_vault are just ALIASES - // that map to the same underlying accounts. - assert!( - swap_accounts.is_empty(), - "SDK without pool state returns empty - but contract is to return both vaults when populated" - ); -} - -#[test] -fn test_directional_alias_same_pubkey_same_spec() { - // CRITICAL: input_vault and output_vault in Swap instruction point to - // the same underlying accounts (token_0_vault or token_1_vault). - // - // When ZeroForOne: input_vault = token_0_vault, output_vault = token_1_vault - // When OneForZero: input_vault = token_1_vault, output_vault = token_0_vault - // - // The SDK stores specs by PUBKEY, so the "role" (input/output) doesn't matter. - // The spec for token_0_vault is the same whether it's used as input or output. - - use std::collections::HashMap; - - // Simulate pool state with two vaults - let token_0_vault = Pubkey::new_unique(); - let token_1_vault = Pubkey::new_unique(); - - // Simulate SDK's HashMap - let mut specs: HashMap = HashMap::new(); - specs.insert(token_0_vault, "token_0_vault_spec"); - specs.insert(token_1_vault, "token_1_vault_spec"); - - // Swap ZeroForOne: input=token_0, output=token_1 - let input_vault_zero_for_one = token_0_vault; - let output_vault_zero_for_one = token_1_vault; - - // Swap OneForZero: input=token_1, output=token_0 - let input_vault_one_for_zero = token_1_vault; - let output_vault_one_for_zero = token_0_vault; - - // Regardless of direction, lookup by pubkey returns the same spec - assert_eq!( - specs.get(&input_vault_zero_for_one), - specs.get(&output_vault_one_for_zero), - "Same pubkey = same spec regardless of role" - ); - - assert_eq!( - specs.get(&output_vault_zero_for_one), - specs.get(&input_vault_one_for_zero), - "Same pubkey = same spec regardless of role" - ); -} - -#[test] -fn test_sdk_doesnt_need_trade_direction() { - // The SDK is DIRECTION-AGNOSTIC. - // It doesn't need to know if the user is swapping ZeroForOne or OneForZero. - // It just returns all necessary accounts and lets the client decide. - - let sdk = AmmSdk::new(); - - // Both directions use the same set of accounts from get_accounts_to_update - let accounts = sdk.get_accounts_to_update(&AmmInstruction::Swap); - - // The SDK's contract: return [token_0_vault, token_1_vault] for Swap - // The client then passes them to the instruction as input_vault/output_vault - // based on the desired trade direction. - - // This is the key insight: decompression is role-agnostic. - // We decompress the account regardless of how it will be used in the swap. - - // Direction independence: same accounts returned regardless of future use - // (accounts is empty for uninitialized SDK, non-empty when populated) - let _ = accounts; -} - -#[test] -fn test_decompression_instruction_role_agnostic() { - // Decompression doesn't care about instruction-level roles. - // When we build a decompression instruction, we specify: - // - The account pubkey - // - The seeds (for PDA verification) - // - The compressed account data - // - // We do NOT specify: - // - Whether it's an "input" or "output" vault - // - Which instruction will use it - // - What role it will play - // - // The decompression instruction is purely about restoring the account to on-chain state. - - // This test documents the separation of concerns: - // 1. SDK: returns specs keyed by canonical pubkey - // 2. Client: builds decompression instructions from specs - // 3. Program: uses decompressed accounts in any role - - // The SDK never sees "input_vault" or "output_vault" - only token_0_vault, token_1_vault - // The program's Swap instruction uses aliases, but that's transparent to the SDK. - - let sdk = AmmSdk::new(); - let specs = sdk.get_all_specs(); - - // All specs are keyed by pubkey, not by instruction field name - for spec in &specs { - // spec.pubkey() is the canonical pubkey - // There's no "role" field because roles are instruction-specific - assert!( - !spec.pubkey().to_bytes().iter().all(|&b| b == 0), - "Valid pubkey, no role information" - ); - } -} - -#[test] -fn test_swap_and_deposit_share_vault_specs() { - // Swap uses vaults as input/output - // Deposit also uses vaults (for receiving tokens) - // Both operations use the SAME underlying accounts, just different roles. - // - // The SDK must return the same specs for these shared accounts. - - let sdk = AmmSdk::new(); - - let swap_accounts = sdk.get_accounts_to_update(&AmmInstruction::Swap); - let deposit_accounts = sdk.get_accounts_to_update(&AmmInstruction::Deposit); - - // Swap: [token_0_vault, token_1_vault] - // Deposit: [token_0_vault, token_1_vault, observation, lp_mint] - // - // The vault pubkeys in swap_accounts should be a subset of deposit_accounts - // (when both are populated) - - // Verify the relationship contract - assert!( - deposit_accounts.len() >= swap_accounts.len(), - "Deposit accounts should be superset of Swap accounts" - ); -} - -#[test] -fn test_canonical_variant_independent_of_alias() { - // The LightAccountVariant enum uses CANONICAL names: - // - Token0Vault { pool_state, token_0_mint } - // - Token1Vault { pool_state, token_1_mint } - // - // NOT aliased names: - // - InputVault (NO - this would be instruction-specific) - // - OutputVault (NO - this would be instruction-specific) - // - // The variant encodes the TRUE identity of the account, - // not how it's used in a particular instruction. - - // Document the design principle: - // Variants are based on SEEDS (which are constant per account) - // NOT based on instruction roles (which vary per operation) - - // For example, token_0_vault always has these seeds: - // [POOL_VAULT_SEED, pool_state.key(), token_0_mint.key()] - // - // Whether it's used as input_vault or output_vault in Swap, - // the seeds are the same. The variant is Token0Vault, always. - - let sdk = AmmSdk::new(); - - // Get specs - let specs = sdk.get_specs_for_instruction(&AmmInstruction::Swap); - - // All specs should have canonical variants - for spec in &specs { - if let AccountSpec::Pda(pda) = spec { - match &pda.variant { - LightAccountVariant::PoolState { .. } => { - // Canonical: PoolState - } - LightAccountVariant::ObservationState { .. } => { - // Canonical: ObservationState - } - LightAccountVariant::Token0Vault(_) => { - // Canonical: Token0Vault - } - LightAccountVariant::Token1Vault(_) => { - // Canonical: Token1Vault - } - _ => { - // Other variants from the program (not AMM-related) - } - } - } - // No "InputVault" or "OutputVault" variants exist - by design - } -} - -#[test] -fn test_swap_loads_decompresses_before_execution() { - // The correct flow for Swap with cold vaults: - // - // 1. Client: Get accounts to load for Swap - // 2. SDK returns: [token_0_vault, token_1_vault] - // 3. Client: Build decompression transactions - // 4. Client: Execute decompression (vaults now on-chain) - // 5. Client: Build Swap instruction with: - // - input_vault = token_0_vault (for ZeroForOne) - // - output_vault = token_1_vault - // OR - // - input_vault = token_1_vault (for OneForZero) - // - output_vault = token_0_vault - // 6. Client: Execute Swap - // - // The decompression step (3-4) doesn't know about step 5's direction. - // It just decompresses both vaults. - - // This test documents the expected flow - let sdk = AmmSdk::new(); - - // Step 1-2: Get accounts - let _accounts = sdk.get_accounts_to_update(&AmmInstruction::Swap); - - // Step 3-4: Decompression (direction-agnostic) - // Both vaults decompressed regardless of which is input/output - - // Step 5-6: Swap execution (direction chosen here) - // The SDK has no involvement in determining direction -} - -#[test] -fn test_multiple_operations_same_underlying_account() { - // Multiple operations can reference the same account with different field names: - // - // | Operation | Field Name | Underlying Account | - // |-----------|----------------|-------------------| - // | Initialize| token_0_vault | 0xAAAA | - // | Deposit | token_0_vault | 0xAAAA | - // | Withdraw | token_0_vault | 0xAAAA | - // | Swap | input_vault | 0xAAAA (if ZeroForOne) | - // | Swap | output_vault | 0xAAAA (if OneForZero) | - // - // The SDK stores ONE spec for pubkey 0xAAAA, used by all operations. - - use std::collections::HashMap; - - let underlying_pubkey = Pubkey::new_unique(); - - // Simulate field name -> pubkey mapping - let field_mappings: HashMap<&str, Pubkey> = [ - ("token_0_vault", underlying_pubkey), // Initialize, Deposit, Withdraw - ("input_vault_zero_for_one", underlying_pubkey), // Swap ZeroForOne - ("output_vault_one_for_zero", underlying_pubkey), // Swap OneForZero - ] - .into_iter() - .collect(); - - // All map to the same pubkey - assert_eq!( - field_mappings.get("token_0_vault"), - field_mappings.get("input_vault_zero_for_one"), - "Different names, same account" - ); - assert_eq!( - field_mappings.get("token_0_vault"), - field_mappings.get("output_vault_one_for_zero"), - "Different names, same account" - ); - - // The SDK stores by pubkey, so ONE spec serves all aliases - let mut specs: HashMap = HashMap::new(); - specs.insert(underlying_pubkey, "the_one_and_only_spec"); - - assert_eq!( - specs.len(), - 1, - "One pubkey = one spec regardless of aliases" - ); -} - // ============================================================================= -// 9. SINGLE SOURCE OF TRUTH INVARIANT TESTS +// 7. DIRECTION-AGNOSTIC DESIGN // ============================================================================= #[test] -fn test_invariant_get_accounts_subset_of_specs() { - // INVARIANT: For all operations, get_accounts_to_update() pubkeys - // must be a subset of get_specs_for_instruction() addresses. - // - // This catches bugs where one method was updated but not the other. - - let sdk = AmmSdk::new(); - - for op in [ - AmmInstruction::Swap, - AmmInstruction::Deposit, - AmmInstruction::Withdraw, - ] { - let update_keys: HashSet<_> = sdk - .get_accounts_to_update(&op) - .into_iter() - .map(|a| a.pubkey()) - .collect(); - let spec_keys: HashSet<_> = sdk - .get_specs_for_instruction(&op) - .iter() - .map(|s| s.pubkey()) - .collect(); - - // When SDK is empty, both should be empty - assert!( - update_keys.is_subset(&spec_keys) || (update_keys.is_empty() && spec_keys.is_empty()), - "get_accounts_to_update must return subset of get_specs_for_instruction for {:?}\n update_keys: {:?}\n spec_keys: {:?}", - op, update_keys, spec_keys - ); - } -} - -#[test] -fn test_invariant_typed_matches_untyped_pubkeys() { - // INVARIANT: get_accounts_to_update() must return the same pubkeys - // as get_accounts_to_update(), just with type information. - // (Now they're the same method, so this test is essentially a no-op) - - let sdk = AmmSdk::new(); - - for op in [ - AmmInstruction::Swap, - AmmInstruction::Deposit, - AmmInstruction::Withdraw, - ] { - let untyped: HashSet<_> = sdk - .get_accounts_to_update(&op) - .into_iter() - .map(|a| a.pubkey()) - .collect(); - let typed: HashSet<_> = sdk - .get_accounts_to_update(&op) - .iter() - .map(|a| a.pubkey()) - .collect(); +fn test_swap_returns_both_vaults() { + let sdk = test_sdk(); + let accounts = sdk.instruction_accounts(&AmmInstruction::Swap); - assert_eq!( - untyped, typed, - "Typed and untyped must return same pubkeys for {:?}", - op - ); - } + assert!(accounts.contains(&sdk.token_0_vault), "Swap must include vault_0"); + assert!(accounts.contains(&sdk.token_1_vault), "Swap must include vault_1"); } #[test] -fn test_invariant_all_methods_derive_from_account_requirements() { - // DESIGN INVARIANT: All three methods must derive from account_requirements() - // - // get_accounts_to_update() -> account_requirements().map(pubkey) - // get_accounts_to_update() -> account_requirements().map(to_fetch) - // get_specs_for_instruction() -> account_requirements().filter_map(spec_lookup) - // - // This ensures they can NEVER drift out of sync. - - // Verify by code inspection: - // 1. get_accounts_to_update() calls self.account_requirements(op) - // 2. get_accounts_to_update() calls self.account_requirements(op) - // 3. get_specs_for_instruction() calls self.account_requirements(op) - // - // All derive from the SAME source. - - let sdk = AmmSdk::new(); +fn test_canonical_variant_not_aliased() { + // The LightAccountVariant enum uses canonical names (Token0Vault, Token1Vault), + // not instruction aliases (InputVault, OutputVault). + // The SDK is direction-agnostic: both vaults are always returned. + let sdk = test_sdk(); + let swap_accounts = sdk.instruction_accounts(&AmmInstruction::Swap); + let deposit_accounts = sdk.instruction_accounts(&AmmInstruction::Deposit); - // Sanity check: all operations return consistent empty results - for op in [ - AmmInstruction::Swap, - AmmInstruction::Deposit, - AmmInstruction::Withdraw, - ] { - let pubkeys = sdk.get_accounts_to_update(&op); - let typed = sdk.get_accounts_to_update(&op); - let specs = sdk.get_specs_for_instruction(&op); - - // All should be empty for uninitialized SDK - assert!(pubkeys.is_empty(), "Empty SDK should return no pubkeys"); - assert!( - typed.is_empty(), - "Empty SDK should return no typed accounts" - ); - assert!(specs.is_empty(), "Empty SDK should return no specs"); + // Both vaults appear in both Swap and Deposit + for vault in [&sdk.token_0_vault, &sdk.token_1_vault] { + assert!(swap_accounts.contains(vault)); + assert!(deposit_accounts.contains(vault)); } } - -#[test] -fn test_swap_observation_included_after_refactor() { - // Regression test: Swap must include observation after the single-source-of-truth refactor. - // - // Before fix: get_accounts_to_update(Swap) returned [vault_0, vault_1] - MISSING observation! - // After fix: get_accounts_to_update(Swap) returns [pool_state, vault_0, vault_1, observation] - - // Create a mock initialized SDK state - // We can't fully initialize without real data, but we can verify the count - - let sdk = AmmSdk::new(); - - // For an uninitialized SDK, both return empty - let swap_accounts = sdk.get_accounts_to_update(&AmmInstruction::Swap); - let deposit_accounts = sdk.get_accounts_to_update(&AmmInstruction::Deposit); - - // The key invariant: Swap and Deposit should now have the same number of - // non-mint accounts when pool_state is set (pool_state, vault_0, vault_1, observation) - // The only difference is Deposit has lp_mint. - - // When empty, both are empty - assert_eq!( - swap_accounts.len(), - deposit_accounts.len(), - "Both empty when uninitialized" - ); - - // Document the expected counts when initialized: - // Swap: pool_state, vault_0, vault_1, observation = 4 - // Deposit: pool_state, vault_0, vault_1, observation, lp_mint_signer = 5 -} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs index cf8202c0ea..f6bbeb2349 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs @@ -20,7 +20,7 @@ use light_batched_merkle_tree::{ }; use light_client::interface::{ create_load_instructions, get_create_accounts_proof, AccountSpec, CreateAccountsProofInput, - InitializeRentFreeConfig, LightProgramInterface, TokenAccountInterface, + InitializeRentFreeConfig, LightProgram, TokenAccountInterface, }; use light_compressible::rent::SLOTS_PER_EPOCH; use light_program_test::{ @@ -496,21 +496,21 @@ async fn decompress_all(ctx: &mut AmmTestContext, pdas: &AmmPdas) { .expect("pool_state should exist"); assert!(pool_interface.is_cold(), "pool_state should be cold"); - let mut sdk = AmmSdk::from_keyed_accounts(&[pool_interface]) - .expect("AmmSdk::from_keyed_accounts should succeed"); + let sdk = AmmSdk::new(pdas.pool_state, pool_interface.data()) + .expect("AmmSdk::new should succeed"); - let accounts_to_fetch = sdk.get_accounts_to_update(&AmmInstruction::Deposit); - - let keyed_accounts = ctx + let pubkeys = sdk.instruction_accounts(&AmmInstruction::Deposit); + let account_interfaces = ctx .rpc - .fetch_accounts(&accounts_to_fetch, None) + .fetch_accounts(&pubkeys, None) .await .expect("fetch_accounts should succeed"); + let cold_accounts: Vec<_> = account_interfaces + .into_iter() + .filter(|a| a.is_cold()) + .collect(); - sdk.update(&keyed_accounts) - .expect("sdk.update should succeed"); - - let specs = sdk.get_specs_for_instruction(&AmmInstruction::Deposit); + let specs = sdk.load_specs(&cold_accounts).expect("load_specs should succeed"); let creator_lp_interface: TokenAccountInterface = ctx .rpc diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs index 69ce2781b6..7e18d160fa 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs @@ -18,7 +18,7 @@ use csdk_anchor_full_derived_test::amm_test::{ use csdk_anchor_full_derived_test_sdk::{AmmInstruction, AmmSdk}; use light_client::interface::{ create_load_instructions, get_create_accounts_proof, CreateAccountsProofInput, - InitializeRentFreeConfig, LightProgramInterface, + InitializeRentFreeConfig, LightProgram, }; use light_compressible::rent::SLOTS_PER_EPOCH; use light_program_test::{ @@ -638,22 +638,23 @@ async fn test_amm_full_lifecycle() { .expect("pool_state should exist"); assert!(pool_interface.is_cold(), "pool_state should be cold"); - // Create Program Interface SDK. - let mut sdk = AmmSdk::from_keyed_accounts(&[pool_interface]) - .expect("ProgrammSdk::from_keyed_accounts should succeed"); + // Create SDK from pool state data. + let sdk = AmmSdk::new(pdas.pool_state, pool_interface.data()) + .expect("AmmSdk::new should succeed"); - let accounts_to_fetch = sdk.get_accounts_to_update(&AmmInstruction::Deposit); - - let keyed_accounts = ctx + // Fetch all instruction accounts and filter cold ones. + let pubkeys = sdk.instruction_accounts(&AmmInstruction::Deposit); + let account_interfaces = ctx .rpc - .fetch_accounts(&accounts_to_fetch, None) + .fetch_accounts(&pubkeys, None) .await .expect("fetch_accounts should succeed"); + let cold_accounts: Vec<_> = account_interfaces + .into_iter() + .filter(|a| a.is_cold()) + .collect(); - sdk.update(&keyed_accounts) - .expect("sdk.update should succeed"); - - let specs = sdk.get_specs_for_instruction(&AmmInstruction::Deposit); + let mut all_specs = sdk.load_specs(&cold_accounts).expect("load_specs should succeed"); let creator_lp_account = ctx .rpc @@ -667,7 +668,6 @@ async fn test_amm_full_lifecycle() { let creator_lp_interface = TokenAccountInterface::try_from(creator_lp_account) .expect("should convert to TokenAccountInterface"); - let mut all_specs = specs; all_specs.push(AccountSpec::Ata(creator_lp_interface)); let decompress_ixs = From 75441235812da1e8b94e6057562ae498ed5b2766 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Mon, 9 Feb 2026 22:02:01 +0000 Subject: [PATCH 3/6] remove fetch-accounts --- sdk-libs/client/src/rpc/rpc_trait.rs | 19 ------------------- .../tests/amm_stress_test.rs | 6 ++++-- .../tests/amm_test.rs | 6 ++++-- 3 files changed, 8 insertions(+), 23 deletions(-) diff --git a/sdk-libs/client/src/rpc/rpc_trait.rs b/sdk-libs/client/src/rpc/rpc_trait.rs index 31c60ed8aa..fbbfdbc72f 100644 --- a/sdk-libs/client/src/rpc/rpc_trait.rs +++ b/sdk-libs/client/src/rpc/rpc_trait.rs @@ -280,23 +280,4 @@ pub trait Rpc: Send + Sync + Debug + 'static { config: Option, ) -> Result>, RpcError>; - /// Fetch multiple accounts by pubkey via `get_account_interface`. - async fn fetch_accounts( - &self, - pubkeys: &[Pubkey], - config: Option, - ) -> Result, RpcError> { - let mut results = Vec::with_capacity(pubkeys.len()); - for pubkey in pubkeys { - let interface = self - .get_account_interface(pubkey, config.clone()) - .await? - .value - .ok_or_else(|| { - RpcError::CustomError(format!("Account not found: {}", pubkey)) - })?; - results.push(interface); - } - Ok(results) - } } diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs index f6bbeb2349..a9f688c95f 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs @@ -502,11 +502,13 @@ async fn decompress_all(ctx: &mut AmmTestContext, pdas: &AmmPdas) { let pubkeys = sdk.instruction_accounts(&AmmInstruction::Deposit); let account_interfaces = ctx .rpc - .fetch_accounts(&pubkeys, None) + .get_multiple_account_interfaces(pubkeys.iter().collect(), None) .await - .expect("fetch_accounts should succeed"); + .expect("get_multiple_account_interfaces should succeed"); let cold_accounts: Vec<_> = account_interfaces + .value .into_iter() + .flatten() .filter(|a| a.is_cold()) .collect(); diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs index 7e18d160fa..e00b9128b9 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs @@ -646,11 +646,13 @@ async fn test_amm_full_lifecycle() { let pubkeys = sdk.instruction_accounts(&AmmInstruction::Deposit); let account_interfaces = ctx .rpc - .fetch_accounts(&pubkeys, None) + .get_multiple_account_interfaces(pubkeys.iter().collect(), None) .await - .expect("fetch_accounts should succeed"); + .expect("get_multiple_account_interfaces should succeed"); let cold_accounts: Vec<_> = account_interfaces + .value .into_iter() + .flatten() .filter(|a| a.is_cold()) .collect(); From 29e1d35a2dc7cd55f0fe6368df3d216f72529ab9 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Tue, 10 Feb 2026 03:05:31 +0000 Subject: [PATCH 4/6] renaming, simplify trait --- sdk-libs/client/src/interface/light_program_interface.rs | 6 +++--- sdk-libs/client/src/interface/mod.rs | 3 ++- sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs | 6 +++--- .../csdk-anchor-full-derived-test-sdk/tests/trait_tests.rs | 4 ++-- .../csdk-anchor-full-derived-test/tests/amm_stress_test.rs | 2 +- sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs | 2 +- 6 files changed, 12 insertions(+), 11 deletions(-) diff --git a/sdk-libs/client/src/interface/light_program_interface.rs b/sdk-libs/client/src/interface/light_program_interface.rs index e645927005..022b4914ce 100644 --- a/sdk-libs/client/src/interface/light_program_interface.rs +++ b/sdk-libs/client/src/interface/light_program_interface.rs @@ -1,10 +1,10 @@ -//! LightProgram trait and supporting types for client-side cold account handling. +//! LightProgramInterface trait and supporting types for client-side cold account handling. //! //! Core types: //! - `ColdContext` - Cold data context (Account or Token) //! - `PdaSpec` - Spec for PDA loading with typed variant //! - `AccountSpec` - Unified spec enum for load instruction building -//! - `LightProgram` - Trait for program SDKs +//! - `LightProgramInterface` - Trait for program SDKs use std::fmt::Debug; @@ -198,7 +198,7 @@ pub fn all_hot(specs: &[AccountSpec]) -> bool { /// /// The caller handles construction, caching, and cold detection. /// The trait only maps cold accounts to their variants for `create_load_instructions`. -pub trait LightProgram: Sized { +pub trait LightProgramInterface: Sized { /// The program's account variant enum (macro-generated, carries PDA seeds). type Variant: Pack + Clone + Debug; diff --git a/sdk-libs/client/src/interface/mod.rs b/sdk-libs/client/src/interface/mod.rs index c17c13fbee..fe29fbc384 100644 --- a/sdk-libs/client/src/interface/mod.rs +++ b/sdk-libs/client/src/interface/mod.rs @@ -21,7 +21,8 @@ pub use decompress_mint::{ pub use initialize_config::InitializeRentFreeConfig; pub use light_account::LightConfig; pub use light_program_interface::{ - all_hot, any_cold, discriminator, matches_discriminator, AccountSpec, ColdContext, LightProgram, + all_hot, any_cold, discriminator, matches_discriminator, AccountSpec, ColdContext, + LightProgramInterface, PdaSpec, }; pub use light_sdk_types::interface::CreateAccountsProof; diff --git a/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs b/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs index d654edbd93..3d38c709bd 100644 --- a/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs +++ b/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs @@ -1,6 +1,6 @@ //! Client SDK for the AMM test program. //! -//! Implements the `LightProgram` trait to produce load specs for cold accounts. +//! Implements the `LightProgramInterface` trait to produce load specs for cold accounts. use anchor_lang::AnchorDeserialize; use csdk_anchor_full_derived_test::{ @@ -11,7 +11,7 @@ use csdk_anchor_full_derived_test::{ }, }; use light_client::interface::{ - AccountInterface, AccountSpec, ColdContext, LightProgram, PdaSpec, + AccountInterface, AccountSpec, ColdContext, LightProgramInterface, PdaSpec, }; use solana_pubkey::Pubkey; @@ -121,7 +121,7 @@ impl AmmSdk { } } -impl LightProgram for AmmSdk { +impl LightProgramInterface for AmmSdk { type Variant = LightAccountVariant; type Instruction = AmmInstruction; diff --git a/sdk-tests/csdk-anchor-full-derived-test-sdk/tests/trait_tests.rs b/sdk-tests/csdk-anchor-full-derived-test-sdk/tests/trait_tests.rs index e78a1c5be8..ea0e8267af 100644 --- a/sdk-tests/csdk-anchor-full-derived-test-sdk/tests/trait_tests.rs +++ b/sdk-tests/csdk-anchor-full-derived-test-sdk/tests/trait_tests.rs @@ -1,4 +1,4 @@ -//! LightProgram trait unit tests for AmmSdk. +//! LightProgramInterface trait unit tests for AmmSdk. //! //! Tests cover: //! - instruction_accounts returns correct pubkeys per instruction type @@ -13,7 +13,7 @@ use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::{ }; use csdk_anchor_full_derived_test_sdk::{AmmInstruction, AmmSdk, AmmSdkError, PROGRAM_ID}; use light_client::interface::{ - all_hot, any_cold, Account, AccountInterface, AccountSpec, LightProgram, PdaSpec, + all_hot, any_cold, Account, AccountInterface, AccountSpec, LightProgramInterface, PdaSpec, }; use solana_pubkey::Pubkey; diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs index a9f688c95f..8f4503c148 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs @@ -20,7 +20,7 @@ use light_batched_merkle_tree::{ }; use light_client::interface::{ create_load_instructions, get_create_accounts_proof, AccountSpec, CreateAccountsProofInput, - InitializeRentFreeConfig, LightProgram, TokenAccountInterface, + InitializeRentFreeConfig, LightProgramInterface, TokenAccountInterface, }; use light_compressible::rent::SLOTS_PER_EPOCH; use light_program_test::{ diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs index e00b9128b9..61221652f3 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs @@ -18,7 +18,7 @@ use csdk_anchor_full_derived_test::amm_test::{ use csdk_anchor_full_derived_test_sdk::{AmmInstruction, AmmSdk}; use light_client::interface::{ create_load_instructions, get_create_accounts_proof, CreateAccountsProofInput, - InitializeRentFreeConfig, LightProgram, + InitializeRentFreeConfig, LightProgramInterface, }; use light_compressible::rent::SLOTS_PER_EPOCH; use light_program_test::{ From 07549aefc079560f367ad32bc3d38e977c7b7624 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Tue, 10 Feb 2026 13:41:44 +0000 Subject: [PATCH 5/6] fomat --- sdk-libs/client/src/interface/mod.rs | 3 +- sdk-libs/client/src/rpc/rpc_trait.rs | 1 - .../src/lib.rs | 8 ++- .../tests/trait_tests.rs | 66 +++++++++++++++---- .../tests/amm_stress_test.rs | 8 ++- .../tests/amm_test.rs | 8 ++- 6 files changed, 70 insertions(+), 24 deletions(-) diff --git a/sdk-libs/client/src/interface/mod.rs b/sdk-libs/client/src/interface/mod.rs index fe29fbc384..9108574471 100644 --- a/sdk-libs/client/src/interface/mod.rs +++ b/sdk-libs/client/src/interface/mod.rs @@ -22,8 +22,7 @@ pub use initialize_config::InitializeRentFreeConfig; pub use light_account::LightConfig; pub use light_program_interface::{ all_hot, any_cold, discriminator, matches_discriminator, AccountSpec, ColdContext, - LightProgramInterface, - PdaSpec, + LightProgramInterface, PdaSpec, }; pub use light_sdk_types::interface::CreateAccountsProof; pub use light_token::compat::TokenData; diff --git a/sdk-libs/client/src/rpc/rpc_trait.rs b/sdk-libs/client/src/rpc/rpc_trait.rs index fbbfdbc72f..2ffa7d12a7 100644 --- a/sdk-libs/client/src/rpc/rpc_trait.rs +++ b/sdk-libs/client/src/rpc/rpc_trait.rs @@ -279,5 +279,4 @@ pub trait Rpc: Send + Sync + Debug + 'static { address: &Pubkey, config: Option, ) -> Result>, RpcError>; - } diff --git a/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs b/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs index 3d38c709bd..cead3370c4 100644 --- a/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs +++ b/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs @@ -196,7 +196,9 @@ impl LightProgramInterface for AmmSdk { token_data: token, }); let interface = Self::convert_vault_interface(account)?; - specs.push(AccountSpec::Pda(PdaSpec::new(interface, variant, PROGRAM_ID))); + specs.push(AccountSpec::Pda(PdaSpec::new( + interface, variant, PROGRAM_ID, + ))); } else if account.key == self.token_1_vault { let token: Token = Token::deserialize(&mut &account.data()[..]) .map_err(|e| AmmSdkError::ParseError(e.to_string()))?; @@ -208,7 +210,9 @@ impl LightProgramInterface for AmmSdk { token_data: token, }); let interface = Self::convert_vault_interface(account)?; - specs.push(AccountSpec::Pda(PdaSpec::new(interface, variant, PROGRAM_ID))); + specs.push(AccountSpec::Pda(PdaSpec::new( + interface, variant, PROGRAM_ID, + ))); } else if account.key == self.lp_mint { specs.push(AccountSpec::Mint(account.clone())); } diff --git a/sdk-tests/csdk-anchor-full-derived-test-sdk/tests/trait_tests.rs b/sdk-tests/csdk-anchor-full-derived-test-sdk/tests/trait_tests.rs index ea0e8267af..637c48e11a 100644 --- a/sdk-tests/csdk-anchor-full-derived-test-sdk/tests/trait_tests.rs +++ b/sdk-tests/csdk-anchor-full-derived-test-sdk/tests/trait_tests.rs @@ -55,7 +55,11 @@ fn test_swap_instruction_accounts() { let sdk = test_sdk(); let accounts = sdk.instruction_accounts(&AmmInstruction::Swap); - assert_eq!(accounts.len(), 4, "Swap: pool_state, vault_0, vault_1, observation"); + assert_eq!( + accounts.len(), + 4, + "Swap: pool_state, vault_0, vault_1, observation" + ); assert!(accounts.contains(&sdk.pool_state_pubkey)); assert!(accounts.contains(&sdk.token_0_vault)); assert!(accounts.contains(&sdk.token_1_vault)); @@ -69,7 +73,11 @@ fn test_deposit_instruction_accounts() { let sdk = test_sdk(); let accounts = sdk.instruction_accounts(&AmmInstruction::Deposit); - assert_eq!(accounts.len(), 5, "Deposit: pool_state, vault_0, vault_1, observation, lp_mint"); + assert_eq!( + accounts.len(), + 5, + "Deposit: pool_state, vault_0, vault_1, observation, lp_mint" + ); assert!(accounts.contains(&sdk.pool_state_pubkey)); assert!(accounts.contains(&sdk.token_0_vault)); assert!(accounts.contains(&sdk.token_1_vault)); @@ -82,25 +90,46 @@ fn test_withdraw_equals_deposit() { let sdk = test_sdk(); let deposit = sdk.instruction_accounts(&AmmInstruction::Deposit); let withdraw = sdk.instruction_accounts(&AmmInstruction::Withdraw); - assert_eq!(deposit, withdraw, "Deposit and Withdraw have identical account sets"); + assert_eq!( + deposit, withdraw, + "Deposit and Withdraw have identical account sets" + ); } #[test] fn test_deposit_superset_of_swap() { let sdk = test_sdk(); - let swap: HashSet = sdk.instruction_accounts(&AmmInstruction::Swap).into_iter().collect(); - let deposit: HashSet = sdk.instruction_accounts(&AmmInstruction::Deposit).into_iter().collect(); - - assert!(swap.is_subset(&deposit), "Swap accounts must be a subset of Deposit accounts"); + let swap: HashSet = sdk + .instruction_accounts(&AmmInstruction::Swap) + .into_iter() + .collect(); + let deposit: HashSet = sdk + .instruction_accounts(&AmmInstruction::Deposit) + .into_iter() + .collect(); + + assert!( + swap.is_subset(&deposit), + "Swap accounts must be a subset of Deposit accounts" + ); } #[test] fn test_no_duplicate_pubkeys_in_instruction_accounts() { let sdk = test_sdk(); - for ix in [AmmInstruction::Swap, AmmInstruction::Deposit, AmmInstruction::Withdraw] { + for ix in [ + AmmInstruction::Swap, + AmmInstruction::Deposit, + AmmInstruction::Withdraw, + ] { let accounts = sdk.instruction_accounts(&ix); let unique: HashSet = accounts.iter().copied().collect(); - assert_eq!(accounts.len(), unique.len(), "No duplicate pubkeys for {:?}", ix); + assert_eq!( + accounts.len(), + unique.len(), + "No duplicate pubkeys for {:?}", + ix + ); } } @@ -128,7 +157,9 @@ fn test_load_specs_unknown_pubkey_skipped() { rent_epoch: 0, }, ); - let specs = sdk.load_specs(&[unknown]).expect("unknown pubkey should be skipped"); + let specs = sdk + .load_specs(&[unknown]) + .expect("unknown pubkey should be skipped"); assert!(specs.is_empty()); } @@ -181,7 +212,10 @@ fn test_all_hot_with_hot_spec() { fn test_error_display() { let err = AmmSdkError::ParseError("deserialization failed".to_string()); let msg = format!("{}", err); - assert!(msg.contains("deserialization"), "ParseError should include cause"); + assert!( + msg.contains("deserialization"), + "ParseError should include cause" + ); } // ============================================================================= @@ -246,8 +280,14 @@ fn test_swap_returns_both_vaults() { let sdk = test_sdk(); let accounts = sdk.instruction_accounts(&AmmInstruction::Swap); - assert!(accounts.contains(&sdk.token_0_vault), "Swap must include vault_0"); - assert!(accounts.contains(&sdk.token_1_vault), "Swap must include vault_1"); + assert!( + accounts.contains(&sdk.token_0_vault), + "Swap must include vault_0" + ); + assert!( + accounts.contains(&sdk.token_1_vault), + "Swap must include vault_1" + ); } #[test] diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs index 8f4503c148..b3f302a7a0 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs @@ -496,8 +496,8 @@ async fn decompress_all(ctx: &mut AmmTestContext, pdas: &AmmPdas) { .expect("pool_state should exist"); assert!(pool_interface.is_cold(), "pool_state should be cold"); - let sdk = AmmSdk::new(pdas.pool_state, pool_interface.data()) - .expect("AmmSdk::new should succeed"); + let sdk = + AmmSdk::new(pdas.pool_state, pool_interface.data()).expect("AmmSdk::new should succeed"); let pubkeys = sdk.instruction_accounts(&AmmInstruction::Deposit); let account_interfaces = ctx @@ -512,7 +512,9 @@ async fn decompress_all(ctx: &mut AmmTestContext, pdas: &AmmPdas) { .filter(|a| a.is_cold()) .collect(); - let specs = sdk.load_specs(&cold_accounts).expect("load_specs should succeed"); + let specs = sdk + .load_specs(&cold_accounts) + .expect("load_specs should succeed"); let creator_lp_interface: TokenAccountInterface = ctx .rpc diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs index 61221652f3..6b21c0079f 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs @@ -639,8 +639,8 @@ async fn test_amm_full_lifecycle() { assert!(pool_interface.is_cold(), "pool_state should be cold"); // Create SDK from pool state data. - let sdk = AmmSdk::new(pdas.pool_state, pool_interface.data()) - .expect("AmmSdk::new should succeed"); + let sdk = + AmmSdk::new(pdas.pool_state, pool_interface.data()).expect("AmmSdk::new should succeed"); // Fetch all instruction accounts and filter cold ones. let pubkeys = sdk.instruction_accounts(&AmmInstruction::Deposit); @@ -656,7 +656,9 @@ async fn test_amm_full_lifecycle() { .filter(|a| a.is_cold()) .collect(); - let mut all_specs = sdk.load_specs(&cold_accounts).expect("load_specs should succeed"); + let mut all_specs = sdk + .load_specs(&cold_accounts) + .expect("load_specs should succeed"); let creator_lp_account = ctx .rpc From 212c3c5eb7c5f965217c2a960982b4a4d691ed39 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Tue, 10 Feb 2026 13:52:28 +0000 Subject: [PATCH 6/6] transfers checked --- js/compressed-token/src/index.ts | 3 + .../src/v3/actions/transfer-interface.ts | 196 +++++++++++--- .../src/v3/instructions/transfer-interface.ts | 115 ++++++++ js/compressed-token/src/v3/unified/index.ts | 57 +++- .../tests/e2e/transfer-interface.test.ts | 251 +++++++++++++++++- 5 files changed, 581 insertions(+), 41 deletions(-) diff --git a/js/compressed-token/src/index.ts b/js/compressed-token/src/index.ts index 4552e61466..40adc3cac1 100644 --- a/js/compressed-token/src/index.ts +++ b/js/compressed-token/src/index.ts @@ -65,7 +65,9 @@ export { createWrapInstruction, createDecompressInterfaceInstruction, createTransferInterfaceInstruction, + createTransferInterfaceCheckedInstruction, createCTokenTransferInstruction, + createCTokenTransferCheckedInstruction, // Types TokenMetadataInstructionData, CompressibleConfig, @@ -78,6 +80,7 @@ export { getAssociatedTokenAddressInterface, getOrCreateAtaInterface, transferInterface, + transferInterfaceChecked, decompressInterface, wrap, mintTo as mintToCToken, diff --git a/js/compressed-token/src/v3/actions/transfer-interface.ts b/js/compressed-token/src/v3/actions/transfer-interface.ts index 0e9b66612f..f0ebdabfb4 100644 --- a/js/compressed-token/src/v3/actions/transfer-interface.ts +++ b/js/compressed-token/src/v3/actions/transfer-interface.ts @@ -26,7 +26,9 @@ import BN from 'bn.js'; import { getAtaProgramId } from '../ata-utils'; import { createTransferInterfaceInstruction, + createTransferInterfaceCheckedInstruction, createCTokenTransferInstruction, + createCTokenTransferCheckedInstruction, } from '../instructions/transfer-interface'; import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../instructions/create-ata-interface'; import { getAssociatedTokenAddressInterface } from '../get-associated-token-address-interface'; @@ -77,24 +79,13 @@ function calculateComputeUnits( } /** - * Transfer tokens using the c-token interface. - * - * Matches SPL Token's transferChecked signature order. Destination must exist. + * Core transfer logic shared by transferInterface and transferInterfaceChecked. * - * @param rpc RPC connection - * @param payer Fee payer (signer) - * @param source Source c-token ATA address - * @param mint Mint address - * @param destination Destination c-token ATA address (must exist) - * @param owner Source owner (signer) - * @param amount Amount to transfer - * @param programId Token program ID (default: CTOKEN_PROGRAM_ID) - * @param confirmOptions Optional confirm options - * @param options Optional interface options - * @param wrap Include SPL/T22 wrapping (default: false) - * @returns Transaction signature + * When `checkedDecimals` is provided, uses transfer_checked instructions + * (discriminator 12, includes mint account, validates decimals on-chain). + * When undefined, uses basic transfer instructions (discriminator 3). */ -export async function transferInterface( +async function _transferInterfaceCore( rpc: Rpc, payer: Signer, source: PublicKey, @@ -102,10 +93,11 @@ export async function transferInterface( destination: PublicKey, owner: Signer, amount: number | bigint | BN, - programId: PublicKey = CTOKEN_PROGRAM_ID, - confirmOptions?: ConfirmOptions, - options?: InterfaceOptions, - wrap = false, + checkedDecimals: number | undefined, + programId: PublicKey, + confirmOptions: ConfirmOptions | undefined, + options: InterfaceOptions | undefined, + wrap: boolean, ): Promise { assertBetaEnabled(); @@ -129,16 +121,31 @@ export async function transferInterface( ); } - instructions.push( - createTransferInterfaceInstruction( - source, - destination, - owner.publicKey, - amountBigInt, - [], - programId, - ), - ); + if (checkedDecimals !== undefined) { + instructions.push( + createTransferInterfaceCheckedInstruction( + source, + mint, + destination, + owner.publicKey, + amountBigInt, + checkedDecimals, + [], + programId, + ), + ); + } else { + instructions.push( + createTransferInterfaceInstruction( + source, + destination, + owner.publicKey, + amountBigInt, + [], + programId, + ), + ); + } const { blockhash } = await rpc.getLatestBlockhash(); const tx = buildAndSignTx( @@ -350,14 +357,27 @@ export async function transferInterface( } // Transfer (destination must already exist - like SPL Token) - instructions.push( - createCTokenTransferInstruction( - source, - destination, - owner.publicKey, - amountBigInt, - ), - ); + if (checkedDecimals !== undefined) { + instructions.push( + createCTokenTransferCheckedInstruction( + source, + mint, + destination, + owner.publicKey, + amountBigInt, + checkedDecimals, + ), + ); + } else { + instructions.push( + createCTokenTransferInstruction( + source, + destination, + owner.publicKey, + amountBigInt, + ), + ); + } // Calculate compute units const computeUnits = calculateComputeUnits( @@ -382,3 +402,103 @@ export async function transferInterface( return sendAndConfirmTx(rpc, tx, confirmOptions); } + +/** + * Transfer tokens using the c-token interface. + * + * Destination must exist. + * + * @param rpc RPC connection + * @param payer Fee payer (signer) + * @param source Source c-token ATA address + * @param mint Mint address + * @param destination Destination c-token ATA address (must exist) + * @param owner Source owner (signer) + * @param amount Amount to transfer + * @param programId Token program ID (default: CTOKEN_PROGRAM_ID) + * @param confirmOptions Optional confirm options + * @param options Optional interface options + * @param wrap Include SPL/T22 wrapping (default: false) + * @returns Transaction signature + */ +export async function transferInterface( + rpc: Rpc, + payer: Signer, + source: PublicKey, + mint: PublicKey, + destination: PublicKey, + owner: Signer, + amount: number | bigint | BN, + programId: PublicKey = CTOKEN_PROGRAM_ID, + confirmOptions?: ConfirmOptions, + options?: InterfaceOptions, + wrap = false, +): Promise { + return _transferInterfaceCore( + rpc, + payer, + source, + mint, + destination, + owner, + amount, + undefined, + programId, + confirmOptions, + options, + wrap, + ); +} + +/** + * Transfer tokens using the c-token interface with decimals validation. + * + * Like SPL Token's transferChecked, the on-chain program validates that the + * provided `decimals` matches the mint's decimals field, preventing + * decimal-related transfer errors (e.g. sending 1e9 when you meant 1e6). + * + * Destination must exist. + * + * @param rpc RPC connection + * @param payer Fee payer (signer) + * @param source Source c-token ATA address + * @param mint Mint address + * @param destination Destination c-token ATA address (must exist) + * @param owner Source owner (signer) + * @param amount Amount to transfer + * @param decimals Expected decimals of the mint (validated on-chain) + * @param programId Token program ID (default: CTOKEN_PROGRAM_ID) + * @param confirmOptions Optional confirm options + * @param options Optional interface options + * @param wrap Include SPL/T22 wrapping (default: false) + * @returns Transaction signature + */ +export async function transferInterfaceChecked( + rpc: Rpc, + payer: Signer, + source: PublicKey, + mint: PublicKey, + destination: PublicKey, + owner: Signer, + amount: number | bigint | BN, + decimals: number, + programId: PublicKey = CTOKEN_PROGRAM_ID, + confirmOptions?: ConfirmOptions, + options?: InterfaceOptions, + wrap = false, +): Promise { + return _transferInterfaceCore( + rpc, + payer, + source, + mint, + destination, + owner, + amount, + decimals, + programId, + confirmOptions, + options, + wrap, + ); +} diff --git a/js/compressed-token/src/v3/instructions/transfer-interface.ts b/js/compressed-token/src/v3/instructions/transfer-interface.ts index 8cefbae9ac..0bfa363ea2 100644 --- a/js/compressed-token/src/v3/instructions/transfer-interface.ts +++ b/js/compressed-token/src/v3/instructions/transfer-interface.ts @@ -4,6 +4,7 @@ import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, createTransferInstruction as createSplTransferInstruction, + createTransferCheckedInstruction as createSplTransferCheckedInstruction, } from '@solana/spl-token'; /** @@ -11,6 +12,11 @@ import { */ const CTOKEN_TRANSFER_DISCRIMINATOR = 3; +/** + * c-token transfer_checked instruction discriminator (SPL-compatible) + */ +const CTOKEN_TRANSFER_CHECKED_DISCRIMINATOR = 12; + /** * Create a c-token transfer instruction. * @@ -96,3 +102,112 @@ export function createTransferInterfaceInstruction( throw new Error(`Unsupported program ID: ${programId.toBase58()}`); } + +/** + * Create a c-token transfer_checked instruction. + * + * Account order matches SPL Token's transferChecked: + * [source, mint, destination, authority] + * + * On-chain, the program validates that `decimals` matches the mint's decimals + * field, preventing decimal-related transfer errors. + * + * @param source Source c-token account + * @param mint Mint account (used for decimals validation) + * @param destination Destination c-token account + * @param owner Owner of the source account (signer, also pays for compressible extension top-ups) + * @param amount Amount to transfer + * @param decimals Expected decimals of the mint + * @returns Transaction instruction for c-token transfer_checked + */ +export function createCTokenTransferCheckedInstruction( + source: PublicKey, + mint: PublicKey, + destination: PublicKey, + owner: PublicKey, + amount: number | bigint, + decimals: number, +): TransactionInstruction { + // Instruction data format: + // byte 0: discriminator (12) + // bytes 1-8: amount (u64 LE) + // byte 9: decimals (u8) + const data = Buffer.alloc(10); + data.writeUInt8(CTOKEN_TRANSFER_CHECKED_DISCRIMINATOR, 0); + data.writeBigUInt64LE(BigInt(amount), 1); + data.writeUInt8(decimals, 9); + + const keys = [ + { pubkey: source, isSigner: false, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: destination, isSigner: false, isWritable: true }, + { pubkey: owner, isSigner: true, isWritable: true }, + ]; + + return new TransactionInstruction({ + programId: CTOKEN_PROGRAM_ID, + keys, + data, + }); +} + +/** + * Construct a transfer_checked instruction for SPL/T22/c-token. Defaults to + * c-token program. On-chain, validates that `decimals` matches the mint. + * + * @param source Source token account + * @param mint Mint account + * @param destination Destination token account + * @param owner Owner of the source account (signer) + * @param amount Amount to transfer + * @param decimals Expected decimals of the mint + * @param multiSigners Multi-signers (SPL/T22 only) + * @param programId Token program ID (default: CTOKEN_PROGRAM_ID) + * @returns instruction for transfer_checked + */ +export function createTransferInterfaceCheckedInstruction( + source: PublicKey, + mint: PublicKey, + destination: PublicKey, + owner: PublicKey, + amount: number | bigint, + decimals: number, + multiSigners: (Signer | PublicKey)[] = [], + programId: PublicKey = CTOKEN_PROGRAM_ID, +): TransactionInstruction { + if (programId.equals(CTOKEN_PROGRAM_ID)) { + if (multiSigners.length > 0) { + throw new Error( + 'c-token transfer does not support multi-signers. Use a single owner.', + ); + } + return createCTokenTransferCheckedInstruction( + source, + mint, + destination, + owner, + amount, + decimals, + ); + } + + if ( + programId.equals(TOKEN_PROGRAM_ID) || + programId.equals(TOKEN_2022_PROGRAM_ID) + ) { + return createSplTransferCheckedInstruction( + source, + mint, + destination, + owner, + amount, + decimals, + multiSigners.map(pk => + pk instanceof PublicKey ? pk : pk.publicKey, + ), + programId, + ); + } + + throw new Error(`Unsupported program ID: ${programId.toBase58()}`); +} diff --git a/js/compressed-token/src/v3/unified/index.ts b/js/compressed-token/src/v3/unified/index.ts index 88ade99f3a..09d6c8ac5a 100644 --- a/js/compressed-token/src/v3/unified/index.ts +++ b/js/compressed-token/src/v3/unified/index.ts @@ -29,7 +29,10 @@ import { loadAta as _loadAta, } from '../actions/load-ata'; import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../instructions/create-ata-interface'; -import { transferInterface as _transferInterface } from '../actions/transfer-interface'; +import { + transferInterface as _transferInterface, + transferInterfaceChecked as _transferInterfaceChecked, +} from '../actions/transfer-interface'; import { _getOrCreateAtaInterface } from '../actions/get-or-create-ata-interface'; import { getAtaProgramId } from '../ata-utils'; import { InterfaceOptions } from '..'; @@ -191,7 +194,7 @@ export async function loadAta( /** * Transfer tokens using the unified ata interface. * - * Matches SPL Token's transferChecked signature order. Destination must exist. + * Destination must exist. * * @param rpc RPC connection * @param payer Fee payer (signer) @@ -232,6 +235,54 @@ export async function transferInterface( ); } +/** + * Transfer tokens using the unified ata interface with decimals validation. + * + * Like SPL Token's transferChecked, the on-chain program validates that the + * provided `decimals` matches the mint's decimals field. Destination must exist. + * + * @param rpc RPC connection + * @param payer Fee payer (signer) + * @param source Source c-token ATA address + * @param mint Mint address + * @param destination Destination c-token ATA address (must exist) + * @param owner Source owner (signer) + * @param amount Amount to transfer + * @param decimals Expected decimals of the mint (validated on-chain) + * @param programId Token program ID (default: CTOKEN_PROGRAM_ID) + * @param confirmOptions Optional confirm options + * @param options Optional interface options + * @returns Transaction signature + */ +export async function transferInterfaceChecked( + rpc: Rpc, + payer: Signer, + source: PublicKey, + mint: PublicKey, + destination: PublicKey, + owner: Signer, + amount: number | bigint | BN, + decimals: number, + programId: PublicKey = CTOKEN_PROGRAM_ID, + confirmOptions?: ConfirmOptions, + options?: InterfaceOptions, +) { + return _transferInterfaceChecked( + rpc, + payer, + source, + mint, + destination, + owner, + amount, + decimals, + programId, + confirmOptions, + options, + true, + ); +} + /** * Get or create c-token ATA with unified balance detection and auto-loading. * @@ -339,7 +390,9 @@ export { createUnwrapInstruction, createDecompressInterfaceInstruction, createTransferInterfaceInstruction, + createTransferInterfaceCheckedInstruction, createCTokenTransferInstruction, + createCTokenTransferCheckedInstruction, // Types TokenMetadataInstructionData, CompressibleConfig, diff --git a/js/compressed-token/tests/e2e/transfer-interface.test.ts b/js/compressed-token/tests/e2e/transfer-interface.test.ts index 07385932f0..4e8a895a44 100644 --- a/js/compressed-token/tests/e2e/transfer-interface.test.ts +++ b/js/compressed-token/tests/e2e/transfer-interface.test.ts @@ -19,14 +19,19 @@ import { } from '../../src/utils/get-token-pool-infos'; import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; import { getOrCreateAtaInterface } from '../../src/v3/actions/get-or-create-ata-interface'; -import { transferInterface } from '../../src/v3/actions/transfer-interface'; +import { + transferInterface, + transferInterfaceChecked, +} from '../../src/v3/actions/transfer-interface'; import { loadAta, createLoadAtaInstructions, } from '../../src/v3/actions/load-ata'; import { createTransferInterfaceInstruction, + createTransferInterfaceCheckedInstruction, createCTokenTransferInstruction, + createCTokenTransferCheckedInstruction, } from '../../src/v3/instructions/transfer-interface'; featureFlags.version = VERSION.V2; @@ -507,4 +512,248 @@ describe('transfer-interface', () => { ); }); }); + + describe('createCTokenTransferCheckedInstruction', () => { + it('should create instruction with 4 accounts (source, mint, dest, authority)', () => { + const source = Keypair.generate().publicKey; + const destination = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + const amount = BigInt(1000); + + const ix = createCTokenTransferCheckedInstruction( + source, + mint, + destination, + owner, + amount, + TEST_TOKEN_DECIMALS, + ); + + expect(ix.programId.equals(CTOKEN_PROGRAM_ID)).toBe(true); + expect(ix.keys.length).toBe(4); + expect(ix.keys[0].pubkey.equals(source)).toBe(true); + expect(ix.keys[0].isWritable).toBe(true); + expect(ix.keys[1].pubkey.equals(mint)).toBe(true); + expect(ix.keys[1].isWritable).toBe(false); + expect(ix.keys[2].pubkey.equals(destination)).toBe(true); + expect(ix.keys[2].isWritable).toBe(true); + expect(ix.keys[3].pubkey.equals(owner)).toBe(true); + expect(ix.keys[3].isSigner).toBe(true); + expect(ix.keys[3].isWritable).toBe(true); + }); + + it('should encode discriminator 12, amount, and decimals', () => { + const source = Keypair.generate().publicKey; + const destination = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + const amount = BigInt(42000); + + const ix = createCTokenTransferCheckedInstruction( + source, + mint, + destination, + owner, + amount, + TEST_TOKEN_DECIMALS, + ); + + expect(ix.data.length).toBe(10); + expect(ix.data[0]).toBe(12); + expect(ix.data.readBigUInt64LE(1)).toBe(BigInt(42000)); + expect(ix.data[9]).toBe(TEST_TOKEN_DECIMALS); + }); + }); + + describe('createTransferInterfaceCheckedInstruction', () => { + it('should route to c-token checked instruction by default', () => { + const source = Keypair.generate().publicKey; + const destination = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + + const ix = createTransferInterfaceCheckedInstruction( + source, + mint, + destination, + owner, + BigInt(500), + TEST_TOKEN_DECIMALS, + ); + + expect(ix.programId.equals(CTOKEN_PROGRAM_ID)).toBe(true); + expect(ix.keys.length).toBe(4); + expect(ix.data[0]).toBe(12); + }); + }); + + describe('transferInterfaceChecked action', () => { + it('should transfer with correct decimals from hot balance', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + + // Mint and load sender + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(5000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + const senderAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); + await loadAta(rpc, senderAta, sender, mint); + + // Create recipient ATA + const recipientAta = await getOrCreateAtaInterface( + rpc, + payer, + mint, + recipient.publicKey, + ); + + const sourceAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); + + // transferInterfaceChecked with correct decimals + const signature = await transferInterfaceChecked( + rpc, + payer, + sourceAta, + mint, + recipientAta.parsed.address, + sender, + BigInt(1000), + TEST_TOKEN_DECIMALS, + ); + + expect(signature).toBeDefined(); + + // Verify balances + const senderAtaInfo = await rpc.getAccountInfo(sourceAta); + const senderBalance = senderAtaInfo!.data.readBigUInt64LE(64); + expect(senderBalance).toBe(BigInt(4000)); + + const recipientAtaInfo = await rpc.getAccountInfo( + recipientAta.parsed.address, + ); + const recipientBalance = recipientAtaInfo!.data.readBigUInt64LE(64); + expect(recipientBalance).toBe(BigInt(1000)); + }); + + it('should fail with wrong decimals', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + + // Mint and load sender + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(5000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + const senderAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); + await loadAta(rpc, senderAta, sender, mint); + + // Create recipient ATA + const recipientAta = await getOrCreateAtaInterface( + rpc, + payer, + mint, + recipient.publicKey, + ); + + const sourceAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); + + const wrongDecimals = TEST_TOKEN_DECIMALS + 1; + + // transferInterfaceChecked with wrong decimals should fail + await expect( + transferInterfaceChecked( + rpc, + payer, + sourceAta, + mint, + recipientAta.parsed.address, + sender, + BigInt(1000), + wrongDecimals, + ), + ).rejects.toThrow(); + }); + + it('should auto-load from cold with correct decimals', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + + // Mint compressed tokens (cold) - don't load + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(3000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Create recipient ATA + const recipientAta = await getOrCreateAtaInterface( + rpc, + payer, + mint, + recipient.publicKey, + ); + + const sourceAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); + + // Transfer should auto-load cold balance and use checked transfer + const signature = await transferInterfaceChecked( + rpc, + payer, + sourceAta, + mint, + recipientAta.parsed.address, + sender, + BigInt(2000), + TEST_TOKEN_DECIMALS, + CTOKEN_PROGRAM_ID, + undefined, + { splInterfaceInfos: tokenPoolInfos }, + ); + + expect(signature).toBeDefined(); + + // Verify recipient received tokens + const recipientAtaInfo = await rpc.getAccountInfo( + recipientAta.parsed.address, + ); + const recipientBalance = recipientAtaInfo!.data.readBigUInt64LE(64); + expect(recipientBalance).toBe(BigInt(2000)); + + // Sender should have change + const senderAtaInfo = await rpc.getAccountInfo(sourceAta); + const senderBalance = senderAtaInfo!.data.readBigUInt64LE(64); + expect(senderBalance).toBe(BigInt(1000)); + }); + }); });