From 4bbec15248323c542b2c54238359769f8d1699d9 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Tue, 7 Apr 2026 14:10:36 +0100 Subject: [PATCH 1/3] add ixns --- Cargo.lock | 1 + sdk-libs/token-client/src/actions/approve.rs | 51 +- .../token-client/src/actions/create_ata.rs | 36 +- .../token-client/src/actions/create_mint.rs | 204 +++-- sdk-libs/token-client/src/actions/load.rs | 320 +++++++ sdk-libs/token-client/src/actions/mint_to.rs | 39 +- sdk-libs/token-client/src/actions/mod.rs | 24 +- sdk-libs/token-client/src/actions/revoke.rs | 45 +- sdk-libs/token-client/src/actions/transfer.rs | 39 +- .../src/actions/transfer_checked.rs | 44 +- .../src/actions/transfer_interface.rs | 114 ++- sdk-libs/token-client/src/actions/unwrap.rs | 124 +-- sdk-libs/token-client/src/actions/wrap.rs | 119 +-- sdk-libs/token-client/src/lib.rs | 2 + sdk-libs/token-client/src/read.rs | 833 ++++++++++++++++++ sdk-tests/token-client-test/Cargo.toml | 1 + .../token-client-test/tests/test_read_load.rs | 142 +++ 17 files changed, 1820 insertions(+), 318 deletions(-) create mode 100644 sdk-libs/token-client/src/actions/load.rs create mode 100644 sdk-libs/token-client/src/read.rs create mode 100644 sdk-tests/token-client-test/tests/test_read_load.rs diff --git a/Cargo.lock b/Cargo.lock index 657f5422fa..8d6b3e0277 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11010,6 +11010,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" name = "token-client-test" version = "0.1.0" dependencies = [ + "anchor-spl", "borsh 0.10.4", "light-client", "light-program-test", diff --git a/sdk-libs/token-client/src/actions/approve.rs b/sdk-libs/token-client/src/actions/approve.rs index 63fdb84aff..806140696f 100644 --- a/sdk-libs/token-client/src/actions/approve.rs +++ b/sdk-libs/token-client/src/actions/approve.rs @@ -4,6 +4,7 @@ use light_client::rpc::{Rpc, RpcError}; use light_token::instruction::Approve as ApproveInstruction; +use solana_instruction::Instruction; use solana_keypair::Keypair; use solana_pubkey::Pubkey; use solana_signature::Signature; @@ -45,7 +46,33 @@ pub struct Approve { pub owner: Option, } +pub fn create_approve_instructions( + approve: &Approve, + fee_payer: Pubkey, + owner: Pubkey, +) -> Result, RpcError> { + let ix = ApproveInstruction { + token_account: approve.token_account, + delegate: approve.delegate, + owner, + amount: approve.amount, + fee_payer, + } + .instruction() + .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; + + Ok(vec![ix]) +} + impl Approve { + pub fn instructions( + &self, + fee_payer: Pubkey, + owner: Pubkey, + ) -> Result, RpcError> { + create_approve_instructions(self, fee_payer, owner) + } + /// Execute the approve action via RPC where payer is the owner. /// /// This method only supports cases where `owner == payer`. If you need a @@ -74,17 +101,9 @@ impl Approve { )); } - let ix = ApproveInstruction { - token_account: self.token_account, - delegate: self.delegate, - owner: owner_pubkey, - amount: self.amount, - fee_payer: payer.pubkey(), - } - .instruction() - .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; + let instructions = create_approve_instructions(&self, payer.pubkey(), owner_pubkey)?; - rpc.create_and_send_transaction(&[ix], &payer.pubkey(), &[payer]) + rpc.create_and_send_transaction(&instructions, &payer.pubkey(), &[payer]) .await } @@ -117,22 +136,14 @@ impl Approve { } } - let ix = ApproveInstruction { - token_account: self.token_account, - delegate: self.delegate, - owner: owner.pubkey(), - amount: self.amount, - fee_payer: payer.pubkey(), - } - .instruction() - .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; + let instructions = create_approve_instructions(&self, payer.pubkey(), owner.pubkey())?; let mut signers: Vec<&Keypair> = vec![payer]; if owner.pubkey() != payer.pubkey() { signers.push(owner); } - rpc.create_and_send_transaction(&[ix], &payer.pubkey(), &signers) + rpc.create_and_send_transaction(&instructions, &payer.pubkey(), &signers) .await } } diff --git a/sdk-libs/token-client/src/actions/create_ata.rs b/sdk-libs/token-client/src/actions/create_ata.rs index fbffe29650..5d3d91a97e 100644 --- a/sdk-libs/token-client/src/actions/create_ata.rs +++ b/sdk-libs/token-client/src/actions/create_ata.rs @@ -4,6 +4,7 @@ use light_client::rpc::{Rpc, RpcError}; use light_token::instruction::{get_associated_token_address, CreateAssociatedTokenAccount}; +use solana_instruction::Instruction; use solana_keypair::Keypair; use solana_pubkey::Pubkey; use solana_signature::Signature; @@ -37,7 +38,29 @@ pub struct CreateAta { pub idempotent: bool, } +pub fn create_ata_instructions( + create_ata: &CreateAta, + fee_payer: Pubkey, +) -> Result, RpcError> { + let mut instruction_builder = + CreateAssociatedTokenAccount::new(fee_payer, create_ata.owner, create_ata.mint); + + if create_ata.idempotent { + instruction_builder = instruction_builder.idempotent(); + } + + let ix = instruction_builder + .instruction() + .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; + + Ok(vec![ix]) +} + impl CreateAta { + pub fn instructions(&self, fee_payer: Pubkey) -> Result, RpcError> { + create_ata_instructions(self, fee_payer) + } + /// Execute the create_ata action via RPC. /// /// # Arguments @@ -51,19 +74,10 @@ impl CreateAta { rpc: &mut R, payer: &Keypair, ) -> Result<(Signature, Pubkey), RpcError> { - let mut instruction_builder = - CreateAssociatedTokenAccount::new(payer.pubkey(), self.owner, self.mint); - - if self.idempotent { - instruction_builder = instruction_builder.idempotent(); - } - - let ix = instruction_builder - .instruction() - .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; + let instructions = create_ata_instructions(&self, payer.pubkey())?; let signature = rpc - .create_and_send_transaction(&[ix], &payer.pubkey(), &[payer]) + .create_and_send_transaction(&instructions, &payer.pubkey(), &[payer]) .await?; Ok(( diff --git a/sdk-libs/token-client/src/actions/create_mint.rs b/sdk-libs/token-client/src/actions/create_mint.rs index e71a85d1a4..cebcc63559 100644 --- a/sdk-libs/token-client/src/actions/create_mint.rs +++ b/sdk-libs/token-client/src/actions/create_mint.rs @@ -14,6 +14,7 @@ use light_token_interface::{ instructions::extensions::{ExtensionInstructionData, TokenMetadataInstructionData}, state::AdditionalMetadata, }; +use solana_instruction::Instruction; use solana_keypair::Keypair; use solana_pubkey::Pubkey; use solana_signature::Signature; @@ -66,7 +67,117 @@ pub struct CreateMint { pub seed: Option, } +pub struct CreateMintInstructions { + pub instructions: Vec, + pub mint: Pubkey, + pub mint_seed: Keypair, +} + +pub async fn create_mint_instructions( + rpc: &R, + create_mint: CreateMint, + payer: Pubkey, + mint_authority: Pubkey, +) -> Result { + let mint_seed = create_mint.seed.unwrap_or_else(Keypair::new); + let address_tree = rpc.get_address_tree_v2(); + let output_queue = rpc.get_random_state_tree_info()?.queue; + + // Derive compression address + let compression_address = + derive_mint_compressed_address(&mint_seed.pubkey(), &address_tree.tree); + + // Find mint PDA + let (mint, bump) = find_mint_address(&mint_seed.pubkey()); + + // Get validity proof for the address + let rpc_result = rpc + .get_validity_proof( + vec![], + vec![AddressWithTree { + address: compression_address, + tree: address_tree.tree, + }], + None, + ) + .await + .map_err(|e| RpcError::CustomError(format!("Failed to get validity proof: {}", e)))? + .value; + + // Build extensions if token metadata is provided + let extensions = create_mint.token_metadata.map(|metadata| { + let additional_metadata = metadata.additional_metadata.map(|items| { + items + .into_iter() + .map(|(key, value)| AdditionalMetadata { + key: key.into_bytes(), + value: value.into_bytes(), + }) + .collect() + }); + + vec![ExtensionInstructionData::TokenMetadata( + TokenMetadataInstructionData { + update_authority: Some( + metadata + .update_authority + .unwrap_or(mint_authority) + .to_bytes() + .into(), + ), + name: metadata.name.into_bytes(), + symbol: metadata.symbol.into_bytes(), + uri: metadata.uri.into_bytes(), + additional_metadata, + }, + )] + }); + + // Build params + let params = CreateMintInstructionParams { + decimals: create_mint.decimals, + address_merkle_tree_root_index: rpc_result.addresses[0].root_index, + mint_authority, + proof: rpc_result.proof.0.ok_or_else(|| { + RpcError::CustomError("Validity proof is required for create_mint".to_string()) + })?, + compression_address, + mint, + bump, + freeze_authority: create_mint.freeze_authority, + extensions, + rent_payment: 16, // ~24 hours rent + write_top_up: 766, // ~3 hours per write + }; + + // Create instruction + let instruction = CreateMintInstruction::new( + params, + mint_seed.pubkey(), + payer, + address_tree.tree, + output_queue, + ) + .instruction() + .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; + + Ok(CreateMintInstructions { + instructions: vec![instruction], + mint, + mint_seed, + }) +} + impl CreateMint { + pub async fn instructions( + self, + rpc: &R, + payer: Pubkey, + mint_authority: Pubkey, + ) -> Result { + create_mint_instructions(rpc, self, payer, mint_authority).await + } + /// Execute the create_mint action via RPC. /// /// # Arguments @@ -82,99 +193,24 @@ impl CreateMint { payer: &Keypair, mint_authority: &Keypair, ) -> Result<(Signature, Pubkey), RpcError> { - let mint_seed = self.seed.unwrap_or_else(Keypair::new); - let address_tree = rpc.get_address_tree_v2(); - let output_queue = rpc.get_random_state_tree_info()?.queue; - - // Derive compression address - let compression_address = - derive_mint_compressed_address(&mint_seed.pubkey(), &address_tree.tree); - - // Find mint PDA - let (mint, bump) = find_mint_address(&mint_seed.pubkey()); - - // Get validity proof for the address - let rpc_result = rpc - .get_validity_proof( - vec![], - vec![AddressWithTree { - address: compression_address, - tree: address_tree.tree, - }], - None, - ) - .await - .map_err(|e| RpcError::CustomError(format!("Failed to get validity proof: {}", e)))? - .value; - - // Build extensions if token metadata is provided - let extensions = self.token_metadata.map(|metadata| { - let additional_metadata = metadata.additional_metadata.map(|items| { - items - .into_iter() - .map(|(key, value)| AdditionalMetadata { - key: key.into_bytes(), - value: value.into_bytes(), - }) - .collect() - }); - - vec![ExtensionInstructionData::TokenMetadata( - TokenMetadataInstructionData { - update_authority: Some( - metadata - .update_authority - .unwrap_or_else(|| mint_authority.pubkey()) - .to_bytes() - .into(), - ), - name: metadata.name.into_bytes(), - symbol: metadata.symbol.into_bytes(), - uri: metadata.uri.into_bytes(), - additional_metadata, - }, - )] - }); - - // Build params - let params = CreateMintInstructionParams { - decimals: self.decimals, - address_merkle_tree_root_index: rpc_result.addresses[0].root_index, - mint_authority: mint_authority.pubkey(), - proof: rpc_result.proof.0.ok_or_else(|| { - RpcError::CustomError("Validity proof is required for create_mint".to_string()) - })?, - compression_address, - mint, - bump, - freeze_authority: self.freeze_authority, - extensions, - rent_payment: 16, // ~24 hours rent - write_top_up: 766, // ~3 hours per write - }; - - // Create instruction - let instruction = CreateMintInstruction::new( - params, - mint_seed.pubkey(), - payer.pubkey(), - address_tree.tree, - output_queue, - ) - .instruction() - .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; + let instruction_bundle = + create_mint_instructions(rpc, self, payer.pubkey(), mint_authority.pubkey()).await?; // Build signers list - let mut signers: Vec<&Keypair> = vec![payer, &mint_seed]; + let mut signers: Vec<&Keypair> = vec![payer, &instruction_bundle.mint_seed]; if mint_authority.pubkey() != payer.pubkey() { signers.push(mint_authority); } // Send transaction let signature = rpc - .create_and_send_transaction(&[instruction], &payer.pubkey(), &signers) + .create_and_send_transaction( + &instruction_bundle.instructions, + &payer.pubkey(), + &signers, + ) .await?; - Ok((signature, mint)) + Ok((signature, instruction_bundle.mint)) } } diff --git a/sdk-libs/token-client/src/actions/load.rs b/sdk-libs/token-client/src/actions/load.rs new file mode 100644 index 0000000000..bc302771ad --- /dev/null +++ b/sdk-libs/token-client/src/actions/load.rs @@ -0,0 +1,320 @@ +//! Load action for light token accounts. +//! +//! Behavior mirrors JS token-interface `createLoadInstructions` for the common +//! light-ATA path: +//! - optional wrap of SPL/T22 ATA balances into light ATA +//! - optional decompress of one primary cold compressed account into light ATA +//! - owner/delegate authority checks + +use borsh::BorshDeserialize; +use light_client::{ + indexer::Indexer, + rpc::{Rpc, RpcError}, +}; +use light_token::{ + constants::{LIGHT_TOKEN_PROGRAM_ID, SPL_TOKEN_2022_PROGRAM_ID, SPL_TOKEN_PROGRAM_ID}, + instruction::{CreateAssociatedTokenAccount, Decompress, TransferFromSpl}, + spl_interface::{find_spl_interface_pda, has_restricted_extensions}, +}; +use solana_instruction::Instruction; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_signature::Signature; +use solana_signer::Signer; +use spl_token_2022::{solana_program::program_pack::Pack, state::Mint as SplMint}; + +use crate::read::{ + default_token_data_discriminator, filter_account_for_authority, get_ata_view_for_load_or_none, + is_authority_for_account, select_primary_cold_account_for_load, TokenAccountSourceType, +}; + +/// Parameters for loading a light token ATA. +#[derive(Clone, Debug)] +pub struct Load { + /// Wallet owner of the associated light token account. + pub owner: Pubkey, + /// Mint for the account. + pub mint: Pubkey, + /// If true, wrap SPL/T22 ATA balances into light ATA first. + pub wrap: bool, + /// If false, fail when any source is frozen. + pub allow_frozen: bool, + /// Optional decimals override. If omitted, fetched from mint account. + pub decimals: Option, +} + +pub async fn create_load_instructions( + rpc: &R, + load: &Load, + fee_payer: Pubkey, + authority: Pubkey, +) -> Result, RpcError> { + load.instructions(rpc, fee_payer, authority).await +} + +impl Default for Load { + fn default() -> Self { + Self { + owner: Pubkey::default(), + mint: Pubkey::default(), + wrap: true, + allow_frozen: false, + decimals: None, + } + } +} + +impl Load { + /// Build load instructions without sending. + pub async fn instructions( + &self, + rpc: &R, + payer: Pubkey, + authority: Pubkey, + ) -> Result, RpcError> { + let mut view = + match get_ata_view_for_load_or_none(rpc, self.owner, self.mint, self.wrap).await? { + Some(view) => view, + None => return Ok(vec![]), + }; + + if !self.allow_frozen && view.any_frozen { + return Err(RpcError::CustomError( + "Account is frozen. load is not allowed.".to_string(), + )); + } + + if authority != self.owner { + if !is_authority_for_account(&view, &authority) { + return Err(RpcError::CustomError( + "Signer is not the owner or a delegate of the account.".to_string(), + )); + } + view = filter_account_for_authority(&view, &authority); + } + + if view.sources.is_empty() { + return Ok(vec![]); + } + + let light_ata = view.address; + let has_light_hot = view + .sources + .iter() + .any(|source| source.source_type == TokenAccountSourceType::LightTokenHot); + let spl_source = view + .sources + .iter() + .find(|source| source.source_type == TokenAccountSourceType::Spl && source.amount > 0); + let t22_source = view.sources.iter().find(|source| { + source.source_type == TokenAccountSourceType::Token2022 && source.amount > 0 + }); + let primary_cold = select_primary_cold_account_for_load(&view.sources); + + if spl_source.is_none() && t22_source.is_none() && primary_cold.is_none() { + return Ok(vec![]); + } + + let mut instructions = Vec::::new(); + + let needs_light_ata = !has_light_hot + && (primary_cold.is_some() + || (self.wrap && (spl_source.is_some() || t22_source.is_some()))); + if needs_light_ata { + let create_ata_ix = CreateAssociatedTokenAccount::new(payer, self.owner, self.mint) + .idempotent() + .instruction() + .map_err(|error| { + RpcError::CustomError(format!( + "Failed to build light ATA create instruction: {error}" + )) + })?; + instructions.push(create_ata_ix); + } + + if self.wrap { + if spl_source.is_some() || t22_source.is_some() { + let decimals = match self.decimals { + Some(decimals) => decimals, + None => fetch_mint_decimals(rpc, self.mint).await?, + }; + + if let Some(source) = spl_source { + instructions.push( + build_wrap_instruction( + rpc, + source.address, + light_ata, + self.mint, + source.amount, + decimals, + payer, + authority, + SPL_TOKEN_PROGRAM_ID, + ) + .await?, + ); + } + if let Some(source) = t22_source { + instructions.push( + build_wrap_instruction( + rpc, + source.address, + light_ata, + self.mint, + source.amount, + decimals, + payer, + authority, + SPL_TOKEN_2022_PROGRAM_ID, + ) + .await?, + ); + } + } + } + + if let Some(primary_cold_account) = primary_cold { + let proof = rpc + .get_validity_proof(vec![primary_cold_account.account.hash], vec![], None) + .await + .map_err(|error| { + RpcError::CustomError(format!( + "Failed to fetch validity proof for load: {error}" + )) + })? + .value; + let proof_account = proof.accounts.first().ok_or_else(|| { + RpcError::CustomError("Validity proof did not include account inputs".to_string()) + })?; + + let decompress_ix = Decompress { + token_data: primary_cold_account.token.clone().into(), + discriminator: default_token_data_discriminator(&primary_cold_account), + merkle_tree: primary_cold_account.account.tree_info.tree, + queue: primary_cold_account.account.tree_info.queue, + leaf_index: primary_cold_account.account.leaf_index, + root_index: proof_account.root_index.root_index().unwrap_or_default(), + destination: light_ata, + payer, + signer: authority, + validity_proof: proof.proof, + } + .instruction() + .map_err(|error| { + RpcError::CustomError(format!("Failed to build decompress instruction: {error}")) + })?; + + instructions.push(decompress_ix); + } + + Ok(instructions) + } + + /// Build and send the load transaction. + pub async fn execute( + self, + rpc: &mut R, + payer: &Keypair, + authority: &Keypair, + ) -> Result, RpcError> { + let instructions = + create_load_instructions(rpc, &self, payer.pubkey(), authority.pubkey()).await?; + if instructions.is_empty() { + return Ok(None); + } + + let mut signers: Vec<&Keypair> = vec![payer]; + if authority.pubkey() != payer.pubkey() { + signers.push(authority); + } + + let signature = rpc + .create_and_send_transaction(&instructions, &payer.pubkey(), &signers) + .await?; + Ok(Some(signature)) + } +} + +async fn fetch_mint_decimals(rpc: &R, mint: Pubkey) -> Result { + let mint_account = rpc + .get_account(mint) + .await? + .ok_or_else(|| RpcError::CustomError("Mint account not found".to_string()))?; + + if mint_account.owner == SPL_TOKEN_PROGRAM_ID || mint_account.owner == SPL_TOKEN_2022_PROGRAM_ID + { + if mint_account.data.len() < SplMint::LEN { + return Err(RpcError::CustomError(format!( + "Mint account data too short: expected at least {}, got {}", + SplMint::LEN, + mint_account.data.len() + ))); + } + let mint_state = SplMint::unpack(&mint_account.data[..SplMint::LEN]).map_err(|error| { + RpcError::CustomError(format!("Failed to parse SPL mint account: {error}")) + })?; + return Ok(mint_state.decimals); + } + + if mint_account.owner == LIGHT_TOKEN_PROGRAM_ID { + let light_mint = light_token_interface::state::Mint::deserialize( + &mut &mint_account.data[..], + ) + .map_err(|error| { + RpcError::CustomError(format!("Failed to parse light mint account: {error}")) + })?; + return Ok(light_mint.base.decimals); + } + + Err(RpcError::CustomError(format!( + "Unsupported mint owner for decimals fetch: {}", + mint_account.owner + ))) +} + +async fn build_wrap_instruction( + rpc: &R, + source_spl_ata: Pubkey, + destination: Pubkey, + mint: Pubkey, + amount: u64, + decimals: u8, + payer: Pubkey, + authority: Pubkey, + spl_token_program: Pubkey, +) -> Result { + if spl_token_program != SPL_TOKEN_PROGRAM_ID && spl_token_program != SPL_TOKEN_2022_PROGRAM_ID { + return Err(RpcError::CustomError(format!( + "Unsupported SPL token program for wrap: {}", + spl_token_program + ))); + } + + let restricted = if spl_token_program == SPL_TOKEN_2022_PROGRAM_ID { + let mint_account = rpc + .get_account(mint) + .await? + .ok_or_else(|| RpcError::CustomError("Mint account not found".to_string()))?; + has_restricted_extensions(&mint_account.data) + } else { + false + }; + + let (spl_interface_pda, bump) = find_spl_interface_pda(&mint, restricted); + + TransferFromSpl { + amount, + spl_interface_pda_bump: bump, + decimals, + source_spl_token_account: source_spl_ata, + destination, + authority, + mint, + payer, + spl_interface_pda, + spl_token_program, + } + .instruction() + .map_err(|error| RpcError::CustomError(format!("Failed to build wrap instruction: {error}"))) +} diff --git a/sdk-libs/token-client/src/actions/mint_to.rs b/sdk-libs/token-client/src/actions/mint_to.rs index 0782b28e49..839e601bea 100644 --- a/sdk-libs/token-client/src/actions/mint_to.rs +++ b/sdk-libs/token-client/src/actions/mint_to.rs @@ -4,6 +4,7 @@ use light_client::rpc::{Rpc, RpcError}; use light_token::instruction::MintTo as MintToInstruction; +use solana_instruction::Instruction; use solana_keypair::Keypair; use solana_pubkey::Pubkey; use solana_signature::Signature; @@ -30,7 +31,33 @@ pub struct MintTo { pub amount: u64, } +pub fn create_mint_to_instructions( + mint_to: &MintTo, + fee_payer: Pubkey, + authority: Pubkey, +) -> Result, RpcError> { + let ix = MintToInstruction { + mint: mint_to.mint, + destination: mint_to.destination, + amount: mint_to.amount, + authority, + fee_payer, + } + .instruction() + .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; + + Ok(vec![ix]) +} + impl MintTo { + pub fn instructions( + &self, + fee_payer: Pubkey, + authority: Pubkey, + ) -> Result, RpcError> { + create_mint_to_instructions(self, fee_payer, authority) + } + /// Execute the mint_to action via RPC. /// /// # Arguments @@ -46,22 +73,14 @@ impl MintTo { payer: &Keypair, authority: &Keypair, ) -> Result { - let ix = MintToInstruction { - mint: self.mint, - destination: self.destination, - amount: self.amount, - authority: authority.pubkey(), - fee_payer: payer.pubkey(), - } - .instruction() - .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; + let instructions = create_mint_to_instructions(&self, payer.pubkey(), authority.pubkey())?; let mut signers: Vec<&Keypair> = vec![payer]; if authority.pubkey() != payer.pubkey() { signers.push(authority); } - rpc.create_and_send_transaction(&[ix], &payer.pubkey(), &signers) + rpc.create_and_send_transaction(&instructions, &payer.pubkey(), &signers) .await } } diff --git a/sdk-libs/token-client/src/actions/mod.rs b/sdk-libs/token-client/src/actions/mod.rs index 448bc0d459..ca7642d1c4 100644 --- a/sdk-libs/token-client/src/actions/mod.rs +++ b/sdk-libs/token-client/src/actions/mod.rs @@ -15,6 +15,7 @@ pub mod approve; pub mod create_ata; pub mod create_mint; +pub mod load; pub mod mint_to; pub mod revoke; pub mod transfer; @@ -24,17 +25,20 @@ pub mod unwrap; pub mod wrap; // Re-export all action structs -pub use approve::Approve; -pub use create_ata::CreateAta; -pub use create_mint::{CreateMint, TokenMetadata}; +pub use approve::{create_approve_instructions, Approve}; +pub use create_ata::{create_ata_instructions, CreateAta}; +pub use create_mint::{ + create_mint_instructions, CreateMint, CreateMintInstructions, TokenMetadata, +}; pub use light_token::instruction::{ derive_associated_token_account, get_associated_token_address, get_associated_token_address_and_bump, }; -pub use mint_to::MintTo; -pub use revoke::Revoke; -pub use transfer::Transfer; -pub use transfer_checked::TransferChecked; -pub use transfer_interface::TransferInterface; -pub use unwrap::Unwrap; -pub use wrap::Wrap; +pub use load::{create_load_instructions, Load}; +pub use mint_to::{create_mint_to_instructions, MintTo}; +pub use revoke::{create_revoke_instructions, Revoke}; +pub use transfer::{create_transfer_instructions, Transfer}; +pub use transfer_checked::{create_transfer_checked_instructions, TransferChecked}; +pub use transfer_interface::{create_transfer_interface_instructions, TransferInterface}; +pub use unwrap::{create_unwrap_instructions, Unwrap}; +pub use wrap::{create_wrap_instructions, Wrap}; diff --git a/sdk-libs/token-client/src/actions/revoke.rs b/sdk-libs/token-client/src/actions/revoke.rs index a82d0dc5c1..dda4bb05e7 100644 --- a/sdk-libs/token-client/src/actions/revoke.rs +++ b/sdk-libs/token-client/src/actions/revoke.rs @@ -4,6 +4,7 @@ use light_client::rpc::{Rpc, RpcError}; use light_token::instruction::Revoke as RevokeInstruction; +use solana_instruction::Instruction; use solana_keypair::Keypair; use solana_pubkey::Pubkey; use solana_signature::Signature; @@ -37,7 +38,31 @@ pub struct Revoke { pub owner: Option, } +pub fn create_revoke_instructions( + revoke: &Revoke, + fee_payer: Pubkey, + owner: Pubkey, +) -> Result, RpcError> { + let ix = RevokeInstruction { + token_account: revoke.token_account, + owner, + fee_payer, + } + .instruction() + .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; + + Ok(vec![ix]) +} + impl Revoke { + pub fn instructions( + &self, + fee_payer: Pubkey, + owner: Pubkey, + ) -> Result, RpcError> { + create_revoke_instructions(self, fee_payer, owner) + } + /// Execute the revoke action via RPC where payer is the owner. /// /// This method only supports cases where `owner == payer`. If you need a @@ -66,15 +91,9 @@ impl Revoke { )); } - let ix = RevokeInstruction { - token_account: self.token_account, - owner: owner_pubkey, - fee_payer: payer.pubkey(), - } - .instruction() - .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; + let instructions = create_revoke_instructions(&self, payer.pubkey(), owner_pubkey)?; - rpc.create_and_send_transaction(&[ix], &payer.pubkey(), &[payer]) + rpc.create_and_send_transaction(&instructions, &payer.pubkey(), &[payer]) .await } @@ -107,20 +126,14 @@ impl Revoke { } } - let ix = RevokeInstruction { - token_account: self.token_account, - owner: owner.pubkey(), - fee_payer: payer.pubkey(), - } - .instruction() - .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; + let instructions = create_revoke_instructions(&self, payer.pubkey(), owner.pubkey())?; let mut signers: Vec<&Keypair> = vec![payer]; if owner.pubkey() != payer.pubkey() { signers.push(owner); } - rpc.create_and_send_transaction(&[ix], &payer.pubkey(), &signers) + rpc.create_and_send_transaction(&instructions, &payer.pubkey(), &signers) .await } } diff --git a/sdk-libs/token-client/src/actions/transfer.rs b/sdk-libs/token-client/src/actions/transfer.rs index 9ab8ae9e43..7c7dc40aae 100644 --- a/sdk-libs/token-client/src/actions/transfer.rs +++ b/sdk-libs/token-client/src/actions/transfer.rs @@ -4,6 +4,7 @@ use light_client::rpc::{Rpc, RpcError}; use light_token::instruction::Transfer as TransferInstruction; +use solana_instruction::Instruction; use solana_keypair::Keypair; use solana_pubkey::Pubkey; use solana_signature::Signature; @@ -30,7 +31,33 @@ pub struct Transfer { pub amount: u64, } +pub fn create_transfer_instructions( + transfer: &Transfer, + fee_payer: Pubkey, + authority: Pubkey, +) -> Result, RpcError> { + let ix = TransferInstruction { + source: transfer.source, + destination: transfer.destination, + amount: transfer.amount, + authority, + fee_payer, + } + .instruction() + .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; + + Ok(vec![ix]) +} + impl Transfer { + pub fn instructions( + &self, + fee_payer: Pubkey, + authority: Pubkey, + ) -> Result, RpcError> { + create_transfer_instructions(self, fee_payer, authority) + } + /// Execute the transfer action via RPC. /// /// # Arguments @@ -46,22 +73,14 @@ impl Transfer { payer: &Keypair, authority: &Keypair, ) -> Result { - let ix = TransferInstruction { - source: self.source, - destination: self.destination, - amount: self.amount, - authority: authority.pubkey(), - fee_payer: payer.pubkey(), - } - .instruction() - .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; + let instructions = create_transfer_instructions(&self, payer.pubkey(), authority.pubkey())?; let mut signers = vec![payer]; if authority.pubkey() != payer.pubkey() { signers.push(authority); } - rpc.create_and_send_transaction(&[ix], &payer.pubkey(), &signers) + rpc.create_and_send_transaction(&instructions, &payer.pubkey(), &signers) .await } } diff --git a/sdk-libs/token-client/src/actions/transfer_checked.rs b/sdk-libs/token-client/src/actions/transfer_checked.rs index 63bef2cbb7..caa048fd62 100644 --- a/sdk-libs/token-client/src/actions/transfer_checked.rs +++ b/sdk-libs/token-client/src/actions/transfer_checked.rs @@ -4,6 +4,7 @@ use light_client::rpc::{Rpc, RpcError}; use light_token::instruction::TransferChecked as TransferCheckedInstruction; +use solana_instruction::Instruction; use solana_keypair::Keypair; use solana_pubkey::Pubkey; use solana_signature::Signature; @@ -39,7 +40,35 @@ pub struct TransferChecked { pub decimals: u8, } +pub fn create_transfer_checked_instructions( + transfer: &TransferChecked, + fee_payer: Pubkey, + authority: Pubkey, +) -> Result, RpcError> { + let ix = TransferCheckedInstruction { + source: transfer.source, + mint: transfer.mint, + destination: transfer.destination, + amount: transfer.amount, + decimals: transfer.decimals, + authority, + fee_payer, + } + .instruction() + .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; + + Ok(vec![ix]) +} + impl TransferChecked { + pub fn instructions( + &self, + fee_payer: Pubkey, + authority: Pubkey, + ) -> Result, RpcError> { + create_transfer_checked_instructions(self, fee_payer, authority) + } + /// Execute the transfer_checked action via RPC. /// /// # Arguments @@ -55,24 +84,15 @@ impl TransferChecked { payer: &Keypair, authority: &Keypair, ) -> Result { - let ix = TransferCheckedInstruction { - source: self.source, - mint: self.mint, - destination: self.destination, - amount: self.amount, - decimals: self.decimals, - authority: authority.pubkey(), - fee_payer: payer.pubkey(), - } - .instruction() - .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; + let instructions = + create_transfer_checked_instructions(&self, payer.pubkey(), authority.pubkey())?; let mut signers = vec![payer]; if authority.pubkey() != payer.pubkey() { signers.push(authority); } - rpc.create_and_send_transaction(&[ix], &payer.pubkey(), &signers) + rpc.create_and_send_transaction(&instructions, &payer.pubkey(), &signers) .await } } diff --git a/sdk-libs/token-client/src/actions/transfer_interface.rs b/sdk-libs/token-client/src/actions/transfer_interface.rs index a76ddaeb2d..19ed828e20 100644 --- a/sdk-libs/token-client/src/actions/transfer_interface.rs +++ b/sdk-libs/token-client/src/actions/transfer_interface.rs @@ -8,6 +8,7 @@ use light_token::{ instruction::{SplInterface, TransferInterface as TransferInterfaceInstruction}, spl_interface::find_spl_interface_pda_with_index, }; +use solana_instruction::Instruction; use solana_keypair::Keypair; use solana_pubkey::Pubkey; use solana_signature::Signature; @@ -51,7 +52,72 @@ pub struct TransferInterface { pub restricted: bool, } +pub async fn create_transfer_interface_instructions( + rpc: &R, + transfer: &TransferInterface, + payer: Pubkey, + authority: Pubkey, +) -> Result, RpcError> { + // Fetch account info to determine owners + let source_account = rpc.get_account(transfer.source).await?.ok_or_else(|| { + RpcError::CustomError(format!("Source account {} not found", transfer.source)) + })?; + + let destination_account = rpc + .get_account(transfer.destination) + .await? + .ok_or_else(|| { + RpcError::CustomError(format!( + "Destination account {} not found", + transfer.destination + )) + })?; + + let source_owner = source_account.owner; + let destination_owner = destination_account.owner; + + // Build SplInterface if needed for cross-interface transfers + let spl_interface = if let Some(spl_program) = transfer.spl_token_program { + let (spl_interface_pda, spl_interface_pda_bump) = + find_spl_interface_pda_with_index(&transfer.mint, 0, transfer.restricted); + Some(SplInterface { + mint: transfer.mint, + spl_token_program: spl_program, + spl_interface_pda, + spl_interface_pda_bump, + }) + } else { + None + }; + + let ix = TransferInterfaceInstruction { + source: transfer.source, + destination: transfer.destination, + amount: transfer.amount, + decimals: transfer.decimals, + authority, + payer, + mint: transfer.mint, + spl_interface, + source_owner, + destination_owner, + } + .instruction() + .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; + + Ok(vec![ix]) +} + impl TransferInterface { + pub async fn instructions( + &self, + rpc: &R, + payer: Pubkey, + authority: Pubkey, + ) -> Result, RpcError> { + create_transfer_interface_instructions(rpc, self, payer, authority).await + } + /// Execute the transfer_interface action via RPC. /// /// # Arguments @@ -67,56 +133,16 @@ impl TransferInterface { payer: &Keypair, authority: &Keypair, ) -> Result { - // Fetch account info to determine owners - let source_account = rpc.get_account(self.source).await?.ok_or_else(|| { - RpcError::CustomError(format!("Source account {} not found", self.source)) - })?; - - let destination_account = rpc.get_account(self.destination).await?.ok_or_else(|| { - RpcError::CustomError(format!( - "Destination account {} not found", - self.destination - )) - })?; - - let source_owner = source_account.owner; - let destination_owner = destination_account.owner; - - // Build SplInterface if needed for cross-interface transfers - let spl_interface = if let Some(spl_program) = self.spl_token_program { - let (spl_interface_pda, spl_interface_pda_bump) = - find_spl_interface_pda_with_index(&self.mint, 0, self.restricted); - Some(SplInterface { - mint: self.mint, - spl_token_program: spl_program, - spl_interface_pda, - spl_interface_pda_bump, - }) - } else { - None - }; - - let ix = TransferInterfaceInstruction { - source: self.source, - destination: self.destination, - amount: self.amount, - decimals: self.decimals, - authority: authority.pubkey(), - payer: payer.pubkey(), - mint: self.mint, - spl_interface, - source_owner, - destination_owner, - } - .instruction() - .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; + let instructions = + create_transfer_interface_instructions(rpc, &self, payer.pubkey(), authority.pubkey()) + .await?; let mut signers = vec![payer]; if authority.pubkey() != payer.pubkey() { signers.push(authority); } - rpc.create_and_send_transaction(&[ix], &payer.pubkey(), &signers) + rpc.create_and_send_transaction(&instructions, &payer.pubkey(), &signers) .await } } diff --git a/sdk-libs/token-client/src/actions/unwrap.rs b/sdk-libs/token-client/src/actions/unwrap.rs index 1ac030c075..5d96c84058 100644 --- a/sdk-libs/token-client/src/actions/unwrap.rs +++ b/sdk-libs/token-client/src/actions/unwrap.rs @@ -8,6 +8,7 @@ use light_token::{ instruction::TransferToSpl, spl_interface::{find_spl_interface_pda, has_restricted_extensions}, }; +use solana_instruction::Instruction; use solana_keypair::Keypair; use solana_pubkey::Pubkey; use solana_signature::Signature; @@ -41,7 +42,75 @@ pub struct Unwrap { pub decimals: u8, } +pub async fn create_unwrap_instructions( + rpc: &R, + unwrap: &Unwrap, + payer: Pubkey, + authority: Pubkey, +) -> Result, RpcError> { + // Get the destination account to determine the token program + let destination_account_info = rpc + .get_account(unwrap.destination_spl_ata) + .await? + .ok_or_else(|| { + RpcError::CustomError("Destination SPL token account not found".to_string()) + })?; + + let spl_token_program = destination_account_info.owner; + + // Validate that the destination account is owned by a supported SPL token program + if spl_token_program != SPL_TOKEN_PROGRAM_ID && spl_token_program != SPL_TOKEN_2022_PROGRAM_ID { + return Err(RpcError::CustomError(format!( + "Destination SPL token account {} is owned by an unsupported program {}. \ + Expected SPL Token ({}) or Token-2022 ({}).", + unwrap.destination_spl_ata, + destination_account_info.owner, + SPL_TOKEN_PROGRAM_ID, + SPL_TOKEN_2022_PROGRAM_ID + ))); + } + + // Check for restricted extensions if using Token-2022 + let restricted = if spl_token_program == SPL_TOKEN_2022_PROGRAM_ID { + let mint_account = rpc + .get_account(unwrap.mint) + .await? + .ok_or_else(|| RpcError::CustomError("Mint account not found".to_string()))?; + has_restricted_extensions(&mint_account.data) + } else { + false + }; + + let (spl_interface_pda, bump) = find_spl_interface_pda(&unwrap.mint, restricted); + + let ix = TransferToSpl { + source: unwrap.source, + destination_spl_token_account: unwrap.destination_spl_ata, + amount: unwrap.amount, + authority, + mint: unwrap.mint, + payer, + spl_interface_pda, + spl_interface_pda_bump: bump, + decimals: unwrap.decimals, + spl_token_program, + } + .instruction() + .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; + + Ok(vec![ix]) +} + impl Unwrap { + pub async fn instructions( + &self, + rpc: &R, + payer: Pubkey, + authority: Pubkey, + ) -> Result, RpcError> { + create_unwrap_instructions(rpc, self, payer, authority).await + } + /// Execute the unwrap action via RPC. /// /// # Arguments @@ -57,64 +126,15 @@ impl Unwrap { payer: &Keypair, authority: &Keypair, ) -> Result { - // Get the destination account to determine the token program - let destination_account_info = rpc - .get_account(self.destination_spl_ata) - .await? - .ok_or_else(|| { - RpcError::CustomError("Destination SPL token account not found".to_string()) - })?; - - let spl_token_program = destination_account_info.owner; - - // Validate that the destination account is owned by a supported SPL token program - if spl_token_program != SPL_TOKEN_PROGRAM_ID - && spl_token_program != SPL_TOKEN_2022_PROGRAM_ID - { - return Err(RpcError::CustomError(format!( - "Destination SPL token account {} is owned by an unsupported program {}. \ - Expected SPL Token ({}) or Token-2022 ({}).", - self.destination_spl_ata, - destination_account_info.owner, - SPL_TOKEN_PROGRAM_ID, - SPL_TOKEN_2022_PROGRAM_ID - ))); - } - - // Check for restricted extensions if using Token-2022 - let restricted = if spl_token_program == SPL_TOKEN_2022_PROGRAM_ID { - let mint_account = rpc - .get_account(self.mint) - .await? - .ok_or_else(|| RpcError::CustomError("Mint account not found".to_string()))?; - has_restricted_extensions(&mint_account.data) - } else { - false - }; - - let (spl_interface_pda, bump) = find_spl_interface_pda(&self.mint, restricted); - - let ix = TransferToSpl { - source: self.source, - destination_spl_token_account: self.destination_spl_ata, - amount: self.amount, - authority: authority.pubkey(), - mint: self.mint, - payer: payer.pubkey(), - spl_interface_pda, - spl_interface_pda_bump: bump, - decimals: self.decimals, - spl_token_program, - } - .instruction() - .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; + let instructions = + create_unwrap_instructions(rpc, &self, payer.pubkey(), authority.pubkey()).await?; let mut signers: Vec<&Keypair> = vec![payer]; if authority.pubkey() != payer.pubkey() { signers.push(authority); } - rpc.create_and_send_transaction(&[ix], &payer.pubkey(), &signers) + rpc.create_and_send_transaction(&instructions, &payer.pubkey(), &signers) .await } } diff --git a/sdk-libs/token-client/src/actions/wrap.rs b/sdk-libs/token-client/src/actions/wrap.rs index 17a058f895..6da6c84b44 100644 --- a/sdk-libs/token-client/src/actions/wrap.rs +++ b/sdk-libs/token-client/src/actions/wrap.rs @@ -8,6 +8,7 @@ use light_token::{ instruction::TransferFromSpl, spl_interface::{find_spl_interface_pda, has_restricted_extensions}, }; +use solana_instruction::Instruction; use solana_keypair::Keypair; use solana_pubkey::Pubkey; use solana_signature::Signature; @@ -42,7 +43,73 @@ pub struct Wrap { pub decimals: u8, } +pub async fn create_wrap_instructions( + rpc: &R, + wrap: &Wrap, + payer: Pubkey, + authority: Pubkey, +) -> Result, RpcError> { + // Get the source account to determine the token program + let source_account_info = rpc + .get_account(wrap.source_spl_ata) + .await? + .ok_or_else(|| RpcError::CustomError("Source SPL token account not found".to_string()))?; + + let spl_token_program = source_account_info.owner; + + // Validate that the source account is owned by a supported SPL token program + if spl_token_program != SPL_TOKEN_PROGRAM_ID && spl_token_program != SPL_TOKEN_2022_PROGRAM_ID { + return Err(RpcError::CustomError(format!( + "Source SPL token account {} is owned by an unsupported program {}. \ + Expected SPL Token ({}) or Token-2022 ({}).", + wrap.source_spl_ata, + source_account_info.owner, + SPL_TOKEN_PROGRAM_ID, + SPL_TOKEN_2022_PROGRAM_ID + ))); + } + + // Check for restricted extensions if using Token-2022 + let restricted = if spl_token_program == SPL_TOKEN_2022_PROGRAM_ID { + let mint_account = rpc + .get_account(wrap.mint) + .await? + .ok_or_else(|| RpcError::CustomError("Mint account not found".to_string()))?; + has_restricted_extensions(&mint_account.data) + } else { + false + }; + + let (spl_interface_pda, bump) = find_spl_interface_pda(&wrap.mint, restricted); + + let ix = TransferFromSpl { + amount: wrap.amount, + spl_interface_pda_bump: bump, + decimals: wrap.decimals, + source_spl_token_account: wrap.source_spl_ata, + destination: wrap.destination, + authority, + mint: wrap.mint, + payer, + spl_interface_pda, + spl_token_program, + } + .instruction() + .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; + + Ok(vec![ix]) +} + impl Wrap { + pub async fn instructions( + &self, + rpc: &R, + payer: Pubkey, + authority: Pubkey, + ) -> Result, RpcError> { + create_wrap_instructions(rpc, self, payer, authority).await + } + /// Execute the wrap action via RPC. /// /// # Arguments @@ -58,61 +125,15 @@ impl Wrap { payer: &Keypair, authority: &Keypair, ) -> Result { - // Get the source account to determine the token program - let source_account_info = rpc.get_account(self.source_spl_ata).await?.ok_or_else(|| { - RpcError::CustomError("Source SPL token account not found".to_string()) - })?; - - let spl_token_program = source_account_info.owner; - - // Validate that the source account is owned by a supported SPL token program - if spl_token_program != SPL_TOKEN_PROGRAM_ID - && spl_token_program != SPL_TOKEN_2022_PROGRAM_ID - { - return Err(RpcError::CustomError(format!( - "Source SPL token account {} is owned by an unsupported program {}. \ - Expected SPL Token ({}) or Token-2022 ({}).", - self.source_spl_ata, - source_account_info.owner, - SPL_TOKEN_PROGRAM_ID, - SPL_TOKEN_2022_PROGRAM_ID - ))); - } - - // Check for restricted extensions if using Token-2022 - let restricted = if spl_token_program == SPL_TOKEN_2022_PROGRAM_ID { - let mint_account = rpc - .get_account(self.mint) - .await? - .ok_or_else(|| RpcError::CustomError("Mint account not found".to_string()))?; - has_restricted_extensions(&mint_account.data) - } else { - false - }; - - let (spl_interface_pda, bump) = find_spl_interface_pda(&self.mint, restricted); - - let ix = TransferFromSpl { - amount: self.amount, - spl_interface_pda_bump: bump, - decimals: self.decimals, - source_spl_token_account: self.source_spl_ata, - destination: self.destination, - authority: authority.pubkey(), - mint: self.mint, - payer: payer.pubkey(), - spl_interface_pda, - spl_token_program, - } - .instruction() - .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; + let instructions = + create_wrap_instructions(rpc, &self, payer.pubkey(), authority.pubkey()).await?; let mut signers: Vec<&Keypair> = vec![payer]; if authority.pubkey() != payer.pubkey() { signers.push(authority); } - rpc.create_and_send_transaction(&[ix], &payer.pubkey(), &signers) + rpc.create_and_send_transaction(&instructions, &payer.pubkey(), &signers) .await } } diff --git a/sdk-libs/token-client/src/lib.rs b/sdk-libs/token-client/src/lib.rs index 31314a68ef..d35ff5c398 100644 --- a/sdk-libs/token-client/src/lib.rs +++ b/sdk-libs/token-client/src/lib.rs @@ -11,6 +11,7 @@ //! | [`Transfer`] | Transfer light-tokens between accounts | //! | [`TransferChecked`] | Transfer with decimal validation | //! | [`TransferInterface`] | Transfer between light-token, T22, and SPL accounts | +//! | [`Load`] | Load cold balances and optionally wrap SPL/T22 | //! | [`Approve`] | Approve a delegate | //! | [`Revoke`] | Revoke a delegate | //! | [`Wrap`] | Wrap SPL/T22 to light-token | @@ -19,6 +20,7 @@ //! pub mod actions; +pub mod read; // Re-export actions at crate root for convenience pub use actions::*; diff --git a/sdk-libs/token-client/src/read.rs b/sdk-libs/token-client/src/read.rs new file mode 100644 index 0000000000..7c1a239b2b --- /dev/null +++ b/sdk-libs/token-client/src/read.rs @@ -0,0 +1,833 @@ +//! Read helpers with JS token-interface parity semantics. +//! +//! This module provides: +//! - `get_ata` / `get_ata_or_none` for unified light ATA reads (hot + primary cold) +//! - source-level account view helpers used by `Load` +//! - authority and cold-selection helpers mirroring JS token-interface behavior + +use std::cmp::Ordering; + +use borsh::BorshDeserialize; +use light_client::{ + indexer::{ + CompressedTokenAccount, GetCompressedTokenAccountsByOwnerOrDelegateOptions, Indexer, + }, + rpc::{Rpc, RpcError}, +}; +use light_token::{ + compat::{AccountState as CompressedAccountState, TokenData}, + constants::{LIGHT_TOKEN_PROGRAM_ID, SPL_TOKEN_2022_PROGRAM_ID, SPL_TOKEN_PROGRAM_ID}, + instruction::get_associated_token_address, +}; +use light_token_interface::state::{ExtensionStruct, Token}; +use solana_pubkey::{pubkey, Pubkey}; +use spl_token_2022::{ + solana_program::{program_option::COption, program_pack::Pack}, + state::{Account as SplTokenAccount, AccountState as SplAccountState}, +}; + +const ASSOCIATED_TOKEN_PROGRAM_ID: Pubkey = pubkey!("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"); +const SHA_FLAT_DISCRIMINATOR: [u8; 8] = [0, 0, 0, 0, 0, 0, 0, 4]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TokenAccountSourceType { + Spl, + Token2022, + SplCold, + Token2022Cold, + LightTokenHot, + LightTokenCold, +} + +impl TokenAccountSourceType { + #[inline] + pub fn is_cold(self) -> bool { + matches!( + self, + Self::SplCold | Self::Token2022Cold | Self::LightTokenCold + ) + } + + #[inline] + fn priority(self) -> u8 { + match self { + Self::LightTokenHot => 0, + Self::LightTokenCold => 1, + Self::Spl => 2, + Self::Token2022 => 3, + Self::SplCold => 4, + Self::Token2022Cold => 5, + } + } +} + +#[derive(Debug, Clone)] +pub struct TokenAccountSource { + pub source_type: TokenAccountSourceType, + pub address: Pubkey, + pub amount: u64, + pub delegate: Option, + pub delegated_amount: u64, + pub is_initialized: bool, + pub is_frozen: bool, + /// Present for cold sources. + pub compressed: Option, +} + +impl TokenAccountSource { + #[inline] + pub fn is_cold(&self) -> bool { + self.source_type.is_cold() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TokenInterfaceParsedAta { + pub address: Pubkey, + pub owner: Pubkey, + pub mint: Pubkey, + pub amount: u64, + pub delegate: Option, + pub delegated_amount: u64, + pub is_initialized: bool, + pub is_frozen: bool, +} + +#[derive(Debug, Clone)] +pub struct TokenInterfaceAccount { + pub address: Pubkey, + pub owner: Pubkey, + pub mint: Pubkey, + pub amount: u64, + pub hot_amount: u64, + pub compressed_amount: u64, + pub has_hot_account: bool, + pub requires_load: bool, + pub parsed: TokenInterfaceParsedAta, + pub compressed_account: Option, + pub ignored_compressed_accounts: Vec, + pub ignored_compressed_amount: u64, +} + +#[derive(Debug, Clone)] +pub struct LoadAccountView { + pub address: Pubkey, + pub owner: Pubkey, + pub mint: Pubkey, + pub amount: u64, + pub delegate: Option, + pub delegated_amount: u64, + pub any_frozen: bool, + pub sources: Vec, +} + +#[derive(Debug, Clone, Copy)] +struct ParsedSourceData { + amount: u64, + delegate: Option, + delegated_amount: u64, + is_initialized: bool, + is_frozen: bool, +} + +fn clamp_delegated_amount(amount: u64, delegated_amount: u64) -> u64 { + amount.min(delegated_amount) +} + +fn delegated_contribution(source: &TokenAccountSource) -> u64 { + clamp_delegated_amount(source.amount, source.delegated_amount) +} + +fn coption_to_option(coption: COption) -> Option { + match coption { + COption::Some(pk) => Some(pk), + COption::None => None, + } +} + +fn get_compressed_only_delegated_amount(token_data: &TokenData) -> Option { + token_data.tlv.as_ref().and_then(|extensions| { + extensions.iter().find_map(|extension| match extension { + ExtensionStruct::CompressedOnly(compressed_only) => { + Some(compressed_only.delegated_amount) + } + _ => None, + }) + }) +} + +fn parse_light_hot_source_data(data: &[u8]) -> Option { + let token = Token::deserialize(&mut &data[..]).ok()?; + Some(ParsedSourceData { + amount: token.amount, + delegate: token + .delegate + .map(|delegate| Pubkey::new_from_array(delegate.to_bytes())), + delegated_amount: token.delegated_amount, + is_initialized: token.state != light_token_interface::state::AccountState::Uninitialized, + is_frozen: token.state == light_token_interface::state::AccountState::Frozen, + }) +} + +fn parse_spl_source_data(data: &[u8], owner: &Pubkey, mint: &Pubkey) -> Option { + if data.len() < SplTokenAccount::LEN { + return None; + } + + let account = SplTokenAccount::unpack(&data[..SplTokenAccount::LEN]).ok()?; + if &account.owner != owner || &account.mint != mint { + return None; + } + + let delegate = coption_to_option(account.delegate); + let delegated_amount = if delegate.is_some() { + account.delegated_amount + } else { + 0 + }; + + Some(ParsedSourceData { + amount: account.amount, + delegate, + delegated_amount, + is_initialized: account.state != SplAccountState::Uninitialized, + is_frozen: account.state == SplAccountState::Frozen, + }) +} + +fn parse_cold_source_data(token_data: &TokenData) -> ParsedSourceData { + let delegated_amount = get_compressed_only_delegated_amount(token_data).unwrap_or_else(|| { + if token_data.delegate.is_some() { + token_data.amount + } else { + 0 + } + }); + + ParsedSourceData { + amount: token_data.amount, + delegate: token_data.delegate, + delegated_amount, + is_initialized: true, + is_frozen: token_data.state == CompressedAccountState::Frozen, + } +} + +fn compute_canonical_delegate(sources: &[TokenAccountSource]) -> (Option, u64) { + let hot_delegate_source = sources + .iter() + .find(|source| !source.is_cold() && source.delegate.is_some()); + + if let Some(source) = hot_delegate_source { + let delegate = source.delegate; + let delegated_amount = sources + .iter() + .filter(|candidate| candidate.delegate == delegate) + .fold(0u64, |sum, candidate| { + sum.saturating_add(delegated_contribution(candidate)) + }); + return (delegate, delegated_amount); + } + + let cold_delegate_source = sources + .iter() + .find(|source| source.is_cold() && source.delegate.is_some()); + + if let Some(source) = cold_delegate_source { + let delegate = source.delegate; + let delegated_amount = sources + .iter() + .filter(|candidate| candidate.is_cold() && candidate.delegate == delegate) + .fold(0u64, |sum, candidate| { + sum.saturating_add(delegated_contribution(candidate)) + }); + return (delegate, delegated_amount); + } + + (None, 0) +} + +fn sort_sources_by_priority(sources: &mut [TokenAccountSource]) { + sources.sort_by_key(|source| source.source_type.priority()); +} + +fn build_load_account_view( + address: Pubkey, + owner: Pubkey, + mint: Pubkey, + mut sources: Vec, +) -> LoadAccountView { + sort_sources_by_priority(&mut sources); + + let amount = sources + .iter() + .fold(0u64, |sum, source| sum.saturating_add(source.amount)); + let any_frozen = sources.iter().any(|source| source.is_frozen); + let (delegate, delegated_amount) = compute_canonical_delegate(&sources); + + LoadAccountView { + address, + owner, + mint, + amount, + delegate, + delegated_amount: clamp_delegated_amount(amount, delegated_amount), + any_frozen, + sources, + } +} + +fn build_parsed_ata( + address: Pubkey, + owner: Pubkey, + mint: Pubkey, + hot: Option, + cold: Option, +) -> TokenInterfaceParsedAta { + let hot_amount = hot.map(|value| value.amount).unwrap_or(0); + let compressed_amount = cold.map(|value| value.amount).unwrap_or(0); + let amount = hot_amount.saturating_add(compressed_amount); + + let mut delegate = None; + let mut delegated_amount = 0u64; + + if let Some(hot_source) = hot { + if hot_source.delegate.is_some() { + delegate = hot_source.delegate; + delegated_amount = hot_source.delegated_amount; + if let Some(cold_source) = cold { + if cold_source.delegate == delegate { + delegated_amount = delegated_amount.saturating_add(clamp_delegated_amount( + cold_source.amount, + cold_source.delegated_amount, + )); + } + } + } + } else if let Some(cold_source) = cold { + if cold_source.delegate.is_some() { + delegate = cold_source.delegate; + delegated_amount = + clamp_delegated_amount(cold_source.amount, cold_source.delegated_amount); + } + } + + TokenInterfaceParsedAta { + address, + owner, + mint, + amount, + delegate, + delegated_amount: clamp_delegated_amount(amount, delegated_amount), + is_initialized: hot.map(|value| value.is_initialized).unwrap_or(false) || cold.is_some(), + is_frozen: hot.map(|value| value.is_frozen).unwrap_or(false) + || cold.map(|value| value.is_frozen).unwrap_or(false), + } +} + +fn sorted_primary_cold_candidates( + accounts: &[CompressedTokenAccount], +) -> Vec { + let mut candidates = accounts + .iter() + .filter(|account| { + account.account.owner == LIGHT_TOKEN_PROGRAM_ID + && account + .account + .data + .as_ref() + .is_some_and(|data| !data.data.is_empty()) + && account.token.amount > 0 + }) + .cloned() + .collect::>(); + + candidates.sort_by(|left, right| { + let amount_cmp = right.token.amount.cmp(&left.token.amount); + if amount_cmp != Ordering::Equal { + return amount_cmp; + } + right.account.leaf_index.cmp(&left.account.leaf_index) + }); + + candidates +} + +fn derive_spl_associated_token_address( + owner: &Pubkey, + mint: &Pubkey, + token_program_id: &Pubkey, +) -> Pubkey { + Pubkey::find_program_address( + &[owner.as_ref(), token_program_id.as_ref(), mint.as_ref()], + &ASSOCIATED_TOKEN_PROGRAM_ID, + ) + .0 +} + +pub fn is_authority_for_account(view: &LoadAccountView, authority: &Pubkey) -> bool { + *authority == view.owner || view.delegate.is_some_and(|delegate| delegate == *authority) +} + +pub fn filter_account_for_authority(view: &LoadAccountView, authority: &Pubkey) -> LoadAccountView { + if *authority == view.owner { + return view.clone(); + } + + if view.delegate != Some(*authority) { + return LoadAccountView { + address: view.address, + owner: view.owner, + mint: view.mint, + amount: 0, + delegate: view.delegate, + delegated_amount: 0, + any_frozen: view.any_frozen, + sources: Vec::new(), + }; + } + + let filtered_sources = view + .sources + .iter() + .filter(|source| source.delegate == Some(*authority)) + .cloned() + .collect::>(); + + if filtered_sources.is_empty() { + return LoadAccountView { + address: view.address, + owner: view.owner, + mint: view.mint, + amount: 0, + delegate: view.delegate, + delegated_amount: 0, + any_frozen: view.any_frozen, + sources: Vec::new(), + }; + } + + build_load_account_view(view.address, view.owner, view.mint, filtered_sources) +} + +pub fn select_primary_cold_account_for_load( + sources: &[TokenAccountSource], +) -> Option { + let mut candidates = sources + .iter() + .filter_map(|source| { + if source.is_cold() && source.amount > 0 { + source.compressed.clone() + } else { + None + } + }) + .collect::>(); + + candidates.sort_by(|left, right| { + let amount_cmp = right.token.amount.cmp(&left.token.amount); + if amount_cmp != Ordering::Equal { + return amount_cmp; + } + right.account.leaf_index.cmp(&left.account.leaf_index) + }); + + candidates.into_iter().next() +} + +pub async fn get_ata_or_none( + rpc: &R, + owner: Pubkey, + mint: Pubkey, +) -> Result, RpcError> { + let light_ata = get_associated_token_address(&owner, &mint); + let hot_account = rpc.get_account(light_ata).await?; + let compressed_response = rpc + .get_compressed_token_accounts_by_owner( + &owner, + Some(GetCompressedTokenAccountsByOwnerOrDelegateOptions::new( + Some(mint), + )), + None, + ) + .await + .map_err(|error| { + RpcError::CustomError(format!("Failed to fetch compressed accounts: {error}")) + })?; + + let hot_parsed = hot_account.as_ref().and_then(|account| { + if account.owner == LIGHT_TOKEN_PROGRAM_ID { + parse_light_hot_source_data(&account.data) + } else { + None + } + }); + + let sorted_candidates = sorted_primary_cold_candidates(&compressed_response.value.items); + let selected_cold = sorted_candidates.first().cloned(); + let ignored_cold = sorted_candidates + .iter() + .skip(1) + .cloned() + .collect::>(); + let ignored_compressed_amount = ignored_cold.iter().fold(0u64, |sum, account| { + sum.saturating_add(account.token.amount) + }); + let cold_parsed = selected_cold + .as_ref() + .map(|account| parse_cold_source_data(&account.token)); + + if hot_parsed.is_none() && cold_parsed.is_none() { + return Ok(None); + } + + let parsed = build_parsed_ata(light_ata, owner, mint, hot_parsed, cold_parsed); + + Ok(Some(TokenInterfaceAccount { + address: light_ata, + owner, + mint, + amount: parsed.amount, + hot_amount: hot_parsed.map(|value| value.amount).unwrap_or(0), + compressed_amount: cold_parsed.map(|value| value.amount).unwrap_or(0), + has_hot_account: hot_parsed.is_some(), + requires_load: selected_cold.is_some(), + parsed, + compressed_account: selected_cold, + ignored_compressed_accounts: ignored_cold, + ignored_compressed_amount, + })) +} + +pub async fn get_ata( + rpc: &R, + owner: Pubkey, + mint: Pubkey, +) -> Result { + get_ata_or_none(rpc, owner, mint) + .await? + .ok_or_else(|| RpcError::CustomError("Associated token account not found".to_string())) +} + +pub async fn get_ata_view_for_load_or_none( + rpc: &R, + owner: Pubkey, + mint: Pubkey, + wrap: bool, +) -> Result, RpcError> { + let light_ata = get_associated_token_address(&owner, &mint); + let mut sources = Vec::::new(); + + let light_hot_account = rpc.get_account(light_ata).await?; + if let Some(account) = light_hot_account { + if account.owner == LIGHT_TOKEN_PROGRAM_ID { + if let Some(parsed) = parse_light_hot_source_data(&account.data) { + sources.push(TokenAccountSource { + source_type: TokenAccountSourceType::LightTokenHot, + address: light_ata, + amount: parsed.amount, + delegate: parsed.delegate, + delegated_amount: parsed.delegated_amount, + is_initialized: parsed.is_initialized, + is_frozen: parsed.is_frozen, + compressed: None, + }); + } + } + } + + if wrap { + let spl_ata = derive_spl_associated_token_address(&owner, &mint, &SPL_TOKEN_PROGRAM_ID); + if let Some(account) = rpc.get_account(spl_ata).await? { + if account.owner == SPL_TOKEN_PROGRAM_ID { + if let Some(parsed) = parse_spl_source_data(&account.data, &owner, &mint) { + sources.push(TokenAccountSource { + source_type: TokenAccountSourceType::Spl, + address: spl_ata, + amount: parsed.amount, + delegate: parsed.delegate, + delegated_amount: parsed.delegated_amount, + is_initialized: parsed.is_initialized, + is_frozen: parsed.is_frozen, + compressed: None, + }); + } + } + } + + let t22_ata = + derive_spl_associated_token_address(&owner, &mint, &SPL_TOKEN_2022_PROGRAM_ID); + if let Some(account) = rpc.get_account(t22_ata).await? { + if account.owner == SPL_TOKEN_2022_PROGRAM_ID { + if let Some(parsed) = parse_spl_source_data(&account.data, &owner, &mint) { + sources.push(TokenAccountSource { + source_type: TokenAccountSourceType::Token2022, + address: t22_ata, + amount: parsed.amount, + delegate: parsed.delegate, + delegated_amount: parsed.delegated_amount, + is_initialized: parsed.is_initialized, + is_frozen: parsed.is_frozen, + compressed: None, + }); + } + } + } + } + + let compressed_response = rpc + .get_compressed_token_accounts_by_owner( + &owner, + Some(GetCompressedTokenAccountsByOwnerOrDelegateOptions::new( + Some(mint), + )), + None, + ) + .await + .map_err(|error| { + RpcError::CustomError(format!("Failed to fetch compressed accounts: {error}")) + })?; + + for account in compressed_response.value.items { + if account.account.owner != LIGHT_TOKEN_PROGRAM_ID { + continue; + } + if account + .account + .data + .as_ref() + .map(|data| data.data.is_empty()) + .unwrap_or(true) + { + continue; + } + let parsed = parse_cold_source_data(&account.token); + sources.push(TokenAccountSource { + source_type: TokenAccountSourceType::LightTokenCold, + address: light_ata, + amount: parsed.amount, + delegate: parsed.delegate, + delegated_amount: parsed.delegated_amount, + is_initialized: parsed.is_initialized, + is_frozen: parsed.is_frozen, + compressed: Some(account), + }); + } + + if sources.is_empty() { + return Ok(None); + } + + Ok(Some(build_load_account_view( + light_ata, owner, mint, sources, + ))) +} + +pub fn default_token_data_discriminator(compressed_account: &CompressedTokenAccount) -> [u8; 8] { + compressed_account + .account + .data + .as_ref() + .map(|data| data.discriminator) + .unwrap_or(SHA_FLAT_DISCRIMINATOR) +} + +#[cfg(test)] +mod tests { + use light_client::indexer::{CompressedAccount, CompressedTokenAccount, TreeInfo}; + use light_compressed_account::{compressed_account::CompressedAccountData, TreeType}; + + use super::*; + + fn make_source( + source_type: TokenAccountSourceType, + amount: u64, + delegate: Option, + delegated_amount: u64, + is_frozen: bool, + ) -> TokenAccountSource { + TokenAccountSource { + source_type, + address: Pubkey::new_unique(), + amount, + delegate, + delegated_amount, + is_initialized: true, + is_frozen, + compressed: None, + } + } + + fn make_cold_source(amount: u64, leaf_index: u32) -> TokenAccountSource { + let mint = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let compressed = CompressedTokenAccount { + token: TokenData { + mint, + owner, + amount, + delegate: None, + state: CompressedAccountState::Initialized, + tlv: None, + }, + account: CompressedAccount { + address: None, + data: Some(CompressedAccountData { + discriminator: SHA_FLAT_DISCRIMINATOR, + data: vec![1, 2, 3], + data_hash: [0u8; 32], + }), + hash: [0u8; 32], + lamports: 0, + leaf_index, + owner: LIGHT_TOKEN_PROGRAM_ID, + prove_by_index: false, + seq: None, + slot_created: 0, + tree_info: TreeInfo { + cpi_context: None, + next_tree_info: None, + queue: Pubkey::new_unique(), + tree: Pubkey::new_unique(), + tree_type: TreeType::StateV2, + }, + }, + }; + + TokenAccountSource { + source_type: TokenAccountSourceType::LightTokenCold, + address: Pubkey::new_unique(), + amount, + delegate: None, + delegated_amount: 0, + is_initialized: true, + is_frozen: false, + compressed: Some(compressed), + } + } + + #[test] + fn js_parity_select_primary_cold_prefers_amount_then_leaf() { + let sources = vec![ + make_cold_source(50, 10), + make_cold_source(75, 2), + make_cold_source(75, 9), + make_cold_source(10, 99), + ]; + + let selected = select_primary_cold_account_for_load(&sources).expect("must select"); + assert_eq!(selected.token.amount, 75); + assert_eq!(selected.account.leaf_index, 9); + } + + #[test] + fn js_parity_delegate_prefers_hot_delegate_and_sums_matching_sources() { + let delegate = Pubkey::new_unique(); + let sources = vec![ + make_source( + TokenAccountSourceType::LightTokenHot, + 100, + Some(delegate), + 80, + false, + ), + make_source( + TokenAccountSourceType::LightTokenCold, + 60, + Some(delegate), + 50, + false, + ), + make_source( + TokenAccountSourceType::LightTokenCold, + 40, + Some(Pubkey::new_unique()), + 40, + false, + ), + ]; + + let view = build_load_account_view( + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + sources, + ); + + assert_eq!(view.delegate, Some(delegate)); + assert_eq!(view.delegated_amount, 130); + } + + #[test] + fn js_parity_delegate_uses_first_cold_when_no_hot_delegate() { + let first_delegate = Pubkey::new_unique(); + let sources = vec![ + make_source( + TokenAccountSourceType::LightTokenCold, + 70, + Some(first_delegate), + 70, + false, + ), + make_source( + TokenAccountSourceType::LightTokenCold, + 30, + Some(first_delegate), + 10, + false, + ), + make_source(TokenAccountSourceType::Spl, 20, None, 0, false), + ]; + + let view = build_load_account_view( + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + sources, + ); + + assert_eq!(view.delegate, Some(first_delegate)); + assert_eq!(view.delegated_amount, 80); + } + + #[test] + fn js_parity_filter_for_authority_keeps_delegate_sources_only() { + let owner = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + let other_delegate = Pubkey::new_unique(); + let sources = vec![ + make_source( + TokenAccountSourceType::LightTokenHot, + 100, + Some(delegate), + 100, + false, + ), + make_source( + TokenAccountSourceType::LightTokenCold, + 40, + Some(delegate), + 40, + false, + ), + make_source( + TokenAccountSourceType::LightTokenCold, + 30, + Some(other_delegate), + 30, + false, + ), + ]; + + let view = + build_load_account_view(Pubkey::new_unique(), owner, Pubkey::new_unique(), sources); + let filtered = filter_account_for_authority(&view, &delegate); + + assert_eq!(filtered.sources.len(), 2); + assert!(filtered + .sources + .iter() + .all(|source| source.delegate == Some(delegate))); + assert_eq!(filtered.amount, 140); + } +} diff --git a/sdk-tests/token-client-test/Cargo.toml b/sdk-tests/token-client-test/Cargo.toml index afad75afa6..b19272474a 100644 --- a/sdk-tests/token-client-test/Cargo.toml +++ b/sdk-tests/token-client-test/Cargo.toml @@ -20,6 +20,7 @@ tokio = { workspace = true } solana-sdk = { workspace = true } borsh = { workspace = true } spl-token = { workspace = true } +anchor-spl = { workspace = true } [lints.rust.unexpected_cfgs] level = "allow" diff --git a/sdk-tests/token-client-test/tests/test_read_load.rs b/sdk-tests/token-client-test/tests/test_read_load.rs new file mode 100644 index 0000000000..7c30c437fe --- /dev/null +++ b/sdk-tests/token-client-test/tests/test_read_load.rs @@ -0,0 +1,142 @@ +//! Tests for read/load parity surfaces in light-token-client. + +use borsh::BorshDeserialize; +use light_client::rpc::Rpc; +use light_program_test::{LightProgramTest, ProgramTestConfig}; +use light_test_utils::spl::{ + create_mint_helper, create_token_account, mint_spl_tokens, CREATE_MINT_HELPER_DECIMALS, +}; +use light_token::instruction::derive_token_ata; +use light_token_client::{ + actions::{CreateAta, Load, Wrap}, + read::get_ata, +}; +use light_token_interface::state::Token; +use solana_sdk::{program_pack::Pack, signature::Keypair, signer::Signer}; +use spl_token::state::Account as SplTokenAccount; + +#[tokio::test] +async fn test_get_ata_hot_balance_view() { + let config = ProgramTestConfig::new_v2(true, None); + let mut rpc = LightProgramTest::new(config).await.unwrap(); + let payer = rpc.get_payer().insecure_clone(); + let owner = payer.pubkey(); + let decimals = CREATE_MINT_HELPER_DECIMALS; + + // SPL mint used by Light Token wrap flow + let mint = create_mint_helper(&mut rpc, &payer).await; + let source_spl = Keypair::new(); + create_token_account(&mut rpc, &mint, &source_spl, &payer) + .await + .unwrap(); + + let ata = derive_token_ata(&owner, &mint); + + CreateAta { + mint, + owner, + idempotent: true, + } + .execute(&mut rpc, &payer) + .await + .unwrap(); + + // Create a hot light balance by wrapping from SPL. + let amount = 800u64; + mint_spl_tokens( + &mut rpc, + &mint, + &source_spl.pubkey(), + &owner, + &payer, + amount, + false, + ) + .await + .unwrap(); + + Wrap { + source_spl_ata: source_spl.pubkey(), + destination: ata, + mint, + amount, + decimals, + } + .execute(&mut rpc, &payer, &payer) + .await + .unwrap(); + + let view = get_ata(&rpc, owner, mint).await.unwrap(); + assert_eq!(view.address, ata); + assert_eq!(view.amount, amount); + assert_eq!(view.hot_amount, amount); + assert_eq!(view.compressed_amount, 0); + assert!(view.has_hot_account); + assert!(!view.requires_load); + assert_eq!(view.parsed.amount, amount); + assert!(view.parsed.is_initialized); + assert!(!view.parsed.is_frozen); +} + +#[tokio::test] +async fn test_load_wraps_spl_ata_into_light_ata() { + use anchor_spl::associated_token::{ + get_associated_token_address, spl_associated_token_account, + }; + + let config = ProgramTestConfig::new_v2(true, None); + let mut rpc = LightProgramTest::new(config).await.unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let owner = payer.pubkey(); + let mint = create_mint_helper(&mut rpc, &payer).await; + let spl_ata = get_associated_token_address(&owner, &mint); + let light_ata = derive_token_ata(&owner, &mint); + + let create_spl_ata_ix = + spl_associated_token_account::instruction::create_associated_token_account( + &payer.pubkey(), + &owner, + &mint, + &anchor_spl::token::ID, + ); + rpc.create_and_send_transaction(&[create_spl_ata_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + let mint_amount = 1_000u64; + mint_spl_tokens( + &mut rpc, + &mint, + &spl_ata, + &owner, + &payer, + mint_amount, + false, + ) + .await + .unwrap(); + + assert!(rpc.get_account(light_ata).await.unwrap().is_none()); + + let signature = Load { + owner, + mint, + wrap: true, + allow_frozen: false, + decimals: Some(CREATE_MINT_HELPER_DECIMALS), + } + .execute(&mut rpc, &payer, &payer) + .await + .unwrap(); + + assert!(signature.is_some(), "load should submit transaction"); + + let light_account = rpc.get_account(light_ata).await.unwrap().unwrap(); + let light_state = Token::deserialize(&mut &light_account.data[..]).unwrap(); + assert_eq!(light_state.amount, mint_amount); + + let spl_account = rpc.get_account(spl_ata).await.unwrap().unwrap(); + let spl_state = SplTokenAccount::unpack(&spl_account.data).unwrap(); + assert_eq!(spl_state.amount, 0); +} From 790f0c911dd56f09bffff008012ad16dd2816eb7 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Tue, 7 Apr 2026 14:20:08 +0100 Subject: [PATCH 2/3] fixes --- .../token-client/src/actions/create_mint.rs | 152 +++++++++++------- sdk-libs/token-client/src/actions/mod.rs | 4 +- 2 files changed, 93 insertions(+), 63 deletions(-) diff --git a/sdk-libs/token-client/src/actions/create_mint.rs b/sdk-libs/token-client/src/actions/create_mint.rs index cebcc63559..d34f3794cb 100644 --- a/sdk-libs/token-client/src/actions/create_mint.rs +++ b/sdk-libs/token-client/src/actions/create_mint.rs @@ -67,28 +67,56 @@ pub struct CreateMint { pub seed: Option, } -pub struct CreateMintInstructions { - pub instructions: Vec, - pub mint: Pubkey, - pub mint_seed: Keypair, +fn build_token_metadata_extensions( + token_metadata: Option<&TokenMetadata>, + mint_authority: Pubkey, +) -> Option> { + token_metadata.map(|metadata| { + let additional_metadata = metadata.additional_metadata.as_ref().map(|items| { + items + .iter() + .map(|(key, value)| AdditionalMetadata { + key: key.clone().into_bytes(), + value: value.clone().into_bytes(), + }) + .collect() + }); + + vec![ExtensionInstructionData::TokenMetadata( + TokenMetadataInstructionData { + update_authority: Some( + metadata + .update_authority + .unwrap_or(mint_authority) + .to_bytes() + .into(), + ), + name: metadata.name.clone().into_bytes(), + symbol: metadata.symbol.clone().into_bytes(), + uri: metadata.uri.clone().into_bytes(), + additional_metadata, + }, + )] + }) } -pub async fn create_mint_instructions( +async fn build_create_mint_instructions( rpc: &R, - create_mint: CreateMint, + decimals: u8, + freeze_authority: Option, + token_metadata: Option<&TokenMetadata>, + mint_seed_pubkey: Pubkey, payer: Pubkey, mint_authority: Pubkey, -) -> Result { - let mint_seed = create_mint.seed.unwrap_or_else(Keypair::new); +) -> Result<(Vec, Pubkey), RpcError> { let address_tree = rpc.get_address_tree_v2(); let output_queue = rpc.get_random_state_tree_info()?.queue; // Derive compression address - let compression_address = - derive_mint_compressed_address(&mint_seed.pubkey(), &address_tree.tree); + let compression_address = derive_mint_compressed_address(&mint_seed_pubkey, &address_tree.tree); // Find mint PDA - let (mint, bump) = find_mint_address(&mint_seed.pubkey()); + let (mint, bump) = find_mint_address(&mint_seed_pubkey); // Get validity proof for the address let rpc_result = rpc @@ -104,38 +132,9 @@ pub async fn create_mint_instructions( .map_err(|e| RpcError::CustomError(format!("Failed to get validity proof: {}", e)))? .value; - // Build extensions if token metadata is provided - let extensions = create_mint.token_metadata.map(|metadata| { - let additional_metadata = metadata.additional_metadata.map(|items| { - items - .into_iter() - .map(|(key, value)| AdditionalMetadata { - key: key.into_bytes(), - value: value.into_bytes(), - }) - .collect() - }); - - vec![ExtensionInstructionData::TokenMetadata( - TokenMetadataInstructionData { - update_authority: Some( - metadata - .update_authority - .unwrap_or(mint_authority) - .to_bytes() - .into(), - ), - name: metadata.name.into_bytes(), - symbol: metadata.symbol.into_bytes(), - uri: metadata.uri.into_bytes(), - additional_metadata, - }, - )] - }); - // Build params let params = CreateMintInstructionParams { - decimals: create_mint.decimals, + decimals, address_merkle_tree_root_index: rpc_result.addresses[0].root_index, mint_authority, proof: rpc_result.proof.0.ok_or_else(|| { @@ -144,8 +143,8 @@ pub async fn create_mint_instructions( compression_address, mint, bump, - freeze_authority: create_mint.freeze_authority, - extensions, + freeze_authority, + extensions: build_token_metadata_extensions(token_metadata, mint_authority), rent_payment: 16, // ~24 hours rent write_top_up: 766, // ~3 hours per write }; @@ -153,7 +152,7 @@ pub async fn create_mint_instructions( // Create instruction let instruction = CreateMintInstruction::new( params, - mint_seed.pubkey(), + mint_seed_pubkey, payer, address_tree.tree, output_queue, @@ -161,20 +160,42 @@ pub async fn create_mint_instructions( .instruction() .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; - Ok(CreateMintInstructions { - instructions: vec![instruction], - mint, - mint_seed, - }) + Ok((vec![instruction], mint)) +} + +pub async fn create_mint_instructions( + rpc: &R, + create_mint: &CreateMint, + payer: Pubkey, + mint_authority: Pubkey, +) -> Result, RpcError> { + let mint_seed_pubkey = create_mint.seed.as_ref().map(|seed| seed.pubkey()).ok_or_else(|| { + RpcError::CustomError( + "create_mint_instructions requires CreateMint.seed = Some(Keypair) so caller can sign; use execute() for auto-generated seed".to_string(), + ) + })?; + + let (instructions, _) = build_create_mint_instructions( + rpc, + create_mint.decimals, + create_mint.freeze_authority, + create_mint.token_metadata.as_ref(), + mint_seed_pubkey, + payer, + mint_authority, + ) + .await?; + + Ok(instructions) } impl CreateMint { pub async fn instructions( - self, + &self, rpc: &R, payer: Pubkey, mint_authority: Pubkey, - ) -> Result { + ) -> Result, RpcError> { create_mint_instructions(rpc, self, payer, mint_authority).await } @@ -193,24 +214,35 @@ impl CreateMint { payer: &Keypair, mint_authority: &Keypair, ) -> Result<(Signature, Pubkey), RpcError> { - let instruction_bundle = - create_mint_instructions(rpc, self, payer.pubkey(), mint_authority.pubkey()).await?; + let CreateMint { + decimals, + freeze_authority, + token_metadata, + seed, + } = self; + let mint_seed = seed.unwrap_or_else(Keypair::new); + let (instructions, mint) = build_create_mint_instructions( + rpc, + decimals, + freeze_authority, + token_metadata.as_ref(), + mint_seed.pubkey(), + payer.pubkey(), + mint_authority.pubkey(), + ) + .await?; // Build signers list - let mut signers: Vec<&Keypair> = vec![payer, &instruction_bundle.mint_seed]; + let mut signers: Vec<&Keypair> = vec![payer, &mint_seed]; if mint_authority.pubkey() != payer.pubkey() { signers.push(mint_authority); } // Send transaction let signature = rpc - .create_and_send_transaction( - &instruction_bundle.instructions, - &payer.pubkey(), - &signers, - ) + .create_and_send_transaction(&instructions, &payer.pubkey(), &signers) .await?; - Ok((signature, instruction_bundle.mint)) + Ok((signature, mint)) } } diff --git a/sdk-libs/token-client/src/actions/mod.rs b/sdk-libs/token-client/src/actions/mod.rs index ca7642d1c4..5f7d32d2a6 100644 --- a/sdk-libs/token-client/src/actions/mod.rs +++ b/sdk-libs/token-client/src/actions/mod.rs @@ -27,9 +27,7 @@ pub mod wrap; // Re-export all action structs pub use approve::{create_approve_instructions, Approve}; pub use create_ata::{create_ata_instructions, CreateAta}; -pub use create_mint::{ - create_mint_instructions, CreateMint, CreateMintInstructions, TokenMetadata, -}; +pub use create_mint::{create_mint_instructions, CreateMint, TokenMetadata}; pub use light_token::instruction::{ derive_associated_token_account, get_associated_token_address, get_associated_token_address_and_bump, From 4628bfc756f6993f6c316bdf1288085ed8d39a5f Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Tue, 7 Apr 2026 22:58:16 +0100 Subject: [PATCH 3/3] upd create mint --- .../token-client/src/actions/create_mint.rs | 207 +++++++++++------- 1 file changed, 122 insertions(+), 85 deletions(-) diff --git a/sdk-libs/token-client/src/actions/create_mint.rs b/sdk-libs/token-client/src/actions/create_mint.rs index d34f3794cb..f315b725e2 100644 --- a/sdk-libs/token-client/src/actions/create_mint.rs +++ b/sdk-libs/token-client/src/actions/create_mint.rs @@ -67,11 +67,43 @@ pub struct CreateMint { pub seed: Option, } -fn build_token_metadata_extensions( - token_metadata: Option<&TokenMetadata>, +pub async fn create_mint_instructions( + rpc: &R, + create_mint: &CreateMint, + payer: Pubkey, mint_authority: Pubkey, -) -> Option> { - token_metadata.map(|metadata| { +) -> Result, RpcError> { + let mint_seed_pubkey = create_mint.seed.as_ref().map(|seed| seed.pubkey()).ok_or_else(|| { + RpcError::CustomError( + "create_mint_instructions requires CreateMint.seed = Some(Keypair) so caller can sign; use execute() for auto-generated seed".to_string(), + ) + })?; + + let address_tree = rpc.get_address_tree_v2(); + let output_queue = rpc.get_random_state_tree_info()?.queue; + + // Derive compression address + let compression_address = derive_mint_compressed_address(&mint_seed_pubkey, &address_tree.tree); + + // Find mint PDA + let (mint, bump) = find_mint_address(&mint_seed_pubkey); + + // Get validity proof for the address + let rpc_result = rpc + .get_validity_proof( + vec![], + vec![AddressWithTree { + address: compression_address, + tree: address_tree.tree, + }], + None, + ) + .await + .map_err(|e| RpcError::CustomError(format!("Failed to get validity proof: {}", e)))? + .value; + + // Build extensions if token metadata is provided + let extensions = create_mint.token_metadata.as_ref().map(|metadata| { let additional_metadata = metadata.additional_metadata.as_ref().map(|items| { items .iter() @@ -97,44 +129,11 @@ fn build_token_metadata_extensions( additional_metadata, }, )] - }) -} - -async fn build_create_mint_instructions( - rpc: &R, - decimals: u8, - freeze_authority: Option, - token_metadata: Option<&TokenMetadata>, - mint_seed_pubkey: Pubkey, - payer: Pubkey, - mint_authority: Pubkey, -) -> Result<(Vec, Pubkey), RpcError> { - let address_tree = rpc.get_address_tree_v2(); - let output_queue = rpc.get_random_state_tree_info()?.queue; - - // Derive compression address - let compression_address = derive_mint_compressed_address(&mint_seed_pubkey, &address_tree.tree); - - // Find mint PDA - let (mint, bump) = find_mint_address(&mint_seed_pubkey); - - // Get validity proof for the address - let rpc_result = rpc - .get_validity_proof( - vec![], - vec![AddressWithTree { - address: compression_address, - tree: address_tree.tree, - }], - None, - ) - .await - .map_err(|e| RpcError::CustomError(format!("Failed to get validity proof: {}", e)))? - .value; + }); // Build params let params = CreateMintInstructionParams { - decimals, + decimals: create_mint.decimals, address_merkle_tree_root_index: rpc_result.addresses[0].root_index, mint_authority, proof: rpc_result.proof.0.ok_or_else(|| { @@ -143,8 +142,8 @@ async fn build_create_mint_instructions( compression_address, mint, bump, - freeze_authority, - extensions: build_token_metadata_extensions(token_metadata, mint_authority), + freeze_authority: create_mint.freeze_authority, + extensions, rent_payment: 16, // ~24 hours rent write_top_up: 766, // ~3 hours per write }; @@ -157,36 +156,10 @@ async fn build_create_mint_instructions( address_tree.tree, output_queue, ) - .instruction() - .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; - - Ok((vec![instruction], mint)) -} - -pub async fn create_mint_instructions( - rpc: &R, - create_mint: &CreateMint, - payer: Pubkey, - mint_authority: Pubkey, -) -> Result, RpcError> { - let mint_seed_pubkey = create_mint.seed.as_ref().map(|seed| seed.pubkey()).ok_or_else(|| { - RpcError::CustomError( - "create_mint_instructions requires CreateMint.seed = Some(Keypair) so caller can sign; use execute() for auto-generated seed".to_string(), - ) - })?; - - let (instructions, _) = build_create_mint_instructions( - rpc, - create_mint.decimals, - create_mint.freeze_authority, - create_mint.token_metadata.as_ref(), - mint_seed_pubkey, - payer, - mint_authority, - ) - .await?; + .instruction() + .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; - Ok(instructions) + Ok(vec![instruction]) } impl CreateMint { @@ -214,23 +187,87 @@ impl CreateMint { payer: &Keypair, mint_authority: &Keypair, ) -> Result<(Signature, Pubkey), RpcError> { - let CreateMint { - decimals, - freeze_authority, - token_metadata, - seed, - } = self; - let mint_seed = seed.unwrap_or_else(Keypair::new); - let (instructions, mint) = build_create_mint_instructions( - rpc, - decimals, - freeze_authority, - token_metadata.as_ref(), + let mint_seed = self.seed.unwrap_or_else(Keypair::new); + let address_tree = rpc.get_address_tree_v2(); + let output_queue = rpc.get_random_state_tree_info()?.queue; + + // Derive compression address + let compression_address = + derive_mint_compressed_address(&mint_seed.pubkey(), &address_tree.tree); + + // Find mint PDA + let (mint, bump) = find_mint_address(&mint_seed.pubkey()); + + // Get validity proof for the address + let rpc_result = rpc + .get_validity_proof( + vec![], + vec![AddressWithTree { + address: compression_address, + tree: address_tree.tree, + }], + None, + ) + .await + .map_err(|e| RpcError::CustomError(format!("Failed to get validity proof: {}", e)))? + .value; + + // Build extensions if token metadata is provided + let extensions = self.token_metadata.map(|metadata| { + let additional_metadata = metadata.additional_metadata.map(|items| { + items + .into_iter() + .map(|(key, value)| AdditionalMetadata { + key: key.into_bytes(), + value: value.into_bytes(), + }) + .collect() + }); + + vec![ExtensionInstructionData::TokenMetadata( + TokenMetadataInstructionData { + update_authority: Some( + metadata + .update_authority + .unwrap_or_else(|| mint_authority.pubkey()) + .to_bytes() + .into(), + ), + name: metadata.name.into_bytes(), + symbol: metadata.symbol.into_bytes(), + uri: metadata.uri.into_bytes(), + additional_metadata, + }, + )] + }); + + // Build params + let params = CreateMintInstructionParams { + decimals: self.decimals, + address_merkle_tree_root_index: rpc_result.addresses[0].root_index, + mint_authority: mint_authority.pubkey(), + proof: rpc_result.proof.0.ok_or_else(|| { + RpcError::CustomError("Validity proof is required for create_mint".to_string()) + })?, + compression_address, + mint, + bump, + freeze_authority: self.freeze_authority, + extensions, + rent_payment: 16, // ~24 hours rent + write_top_up: 766, // ~3 hours per write + }; + + // Create instruction + let instruction = CreateMintInstruction::new( + params, mint_seed.pubkey(), payer.pubkey(), - mint_authority.pubkey(), + address_tree.tree, + output_queue, ) - .await?; + .instruction() + .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {}", e)))?; // Build signers list let mut signers: Vec<&Keypair> = vec![payer, &mint_seed]; @@ -240,7 +277,7 @@ impl CreateMint { // Send transaction let signature = rpc - .create_and_send_transaction(&instructions, &payer.pubkey(), &signers) + .create_and_send_transaction(&[instruction], &payer.pubkey(), &signers) .await?; Ok((signature, mint))