From 44a657203521476eb0516f6cd03b799fc510ff20 Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Fri, 21 Nov 2025 13:45:53 +0100 Subject: [PATCH 01/39] Add COption pack/unpack helpers --- interface/src/instruction.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/interface/src/instruction.rs b/interface/src/instruction.rs index cabd7ce56..914f7fb08 100644 --- a/interface/src/instruction.rs +++ b/interface/src/instruction.rs @@ -1088,6 +1088,27 @@ impl<'a> TokenInstruction<'a> { } } + pub(crate) fn unpack_u64_option(input: &[u8]) -> Result<(COption, &[u8]), ProgramError> { + match input.split_first() { + Option::Some((&0, rest)) => Ok((COption::None, rest)), + Option::Some((&1, rest)) => { + let (value, rest) = Self::unpack_u64(rest)?; + Ok((COption::Some(value), rest)) + } + _ => Err(TokenError::InvalidInstruction.into()), + } + } + + pub(crate) fn pack_u64_option(value: &COption, buf: &mut Vec) { + match *value { + COption::Some(ref amount) => { + buf.push(1); + buf.extend_from_slice(&amount.to_le_bytes()); + } + COption::None => buf.push(0), + } + } + pub(crate) fn unpack_u16(input: &[u8]) -> Result<(u16, &[u8]), ProgramError> { let value = input .get(..U16_BYTES) From e2bba6f0b7037491f6e96e1ddb4f3e3b1e2f769d Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Fri, 21 Nov 2025 13:46:25 +0100 Subject: [PATCH 02/39] Add instruction builder --- interface/src/instruction.rs | 76 ++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/interface/src/instruction.rs b/interface/src/instruction.rs index 914f7fb08..311fc14ef 100644 --- a/interface/src/instruction.rs +++ b/interface/src/instruction.rs @@ -731,6 +731,24 @@ pub enum TokenInstruction<'a> { ScaledUiAmountExtension, /// Instruction prefix for instructions to the pausable extension PausableExtension, + /// 45 + /// Transfer lamports from a native SOL account to a destination account. + /// + /// This is useful to unwrap lamports from a wrapped SOL account. + /// + /// Accounts expected by this instruction: + /// + /// 0. `[writable]` The source account. + /// 1. `[writable]` The destination account. + /// 2. `[signer]` The source account's owner/delegate. + /// + UnwrapLamports { + /// The amount of lamports to transfer. When an amount is + /// not specified, the entire balance of the source account will be + /// transferred. + #[cfg_attr(feature = "serde", serde(with = "coption_fromstr"))] + amount: COption, + }, } impl<'a> TokenInstruction<'a> { /// Unpacks a byte buffer into a @@ -873,6 +891,10 @@ impl<'a> TokenInstruction<'a> { 42 => Self::ConfidentialMintBurnExtension, 43 => Self::ScaledUiAmountExtension, 44 => Self::PausableExtension, + 45 => { + let (amount, _rest) = Self::unpack_u64_option(rest)?; + Self::UnwrapLamports { amount } + } _ => return Err(TokenError::InvalidInstruction.into()), }) } @@ -1053,6 +1075,10 @@ impl<'a> TokenInstruction<'a> { &Self::PausableExtension => { buf.push(44); } + &Self::UnwrapLamports { amount } => { + buf.push(45); + Self::pack_u64_option(&amount, &mut buf); + } }; buf } @@ -2084,6 +2110,37 @@ pub fn withdraw_excess_lamports( }) } +/// Creates an `UnwrapLamports` instruction +pub fn unwrap_lamports( + token_program_id: &Pubkey, + source_pubkey: &Pubkey, + destination_pubkey: &Pubkey, + authority_pubkey: &Pubkey, + signer_pubkeys: &[&Pubkey], + amount: Option, +) -> Result { + check_program_account(token_program_id)?; + let amount = amount.into(); + let data = TokenInstruction::UnwrapLamports { amount }.pack(); + + let mut accounts = Vec::with_capacity(3 + signer_pubkeys.len()); + accounts.push(AccountMeta::new(*source_pubkey, false)); + accounts.push(AccountMeta::new(*destination_pubkey, false)); + accounts.push(AccountMeta::new_readonly( + *authority_pubkey, + signer_pubkeys.is_empty(), + )); + for signer_pubkey in signer_pubkeys.iter() { + accounts.push(AccountMeta::new_readonly(**signer_pubkey, true)); + } + + Ok(Instruction { + program_id: *token_program_id, + accounts, + data, + }) +} + #[cfg(test)] mod test { use {super::*, proptest::prelude::*}; @@ -2458,6 +2515,25 @@ mod test { assert_eq!(unpacked, check); } + #[test] + fn test_unwrap_lamports_packing() { + let amount = COption::None; + let check = TokenInstruction::UnwrapLamports { amount }; + let packed = check.pack(); + let expect = Vec::from([45u8, 0]); + assert_eq!(packed, expect); + let unpacked = TokenInstruction::unpack(&expect).unwrap(); + assert_eq!(unpacked, check); + + let amount = COption::Some(1); + let check = TokenInstruction::UnwrapLamports { amount }; + let packed = check.pack(); + let expect = Vec::from([45u8, 1, 1, 0, 0, 0, 0, 0, 0, 0]); + assert_eq!(packed, expect); + let unpacked = TokenInstruction::unpack(&expect).unwrap(); + assert_eq!(unpacked, check); + } + macro_rules! test_instruction { ($a:ident($($b:tt)*)) => { let instruction_v3 = spl_token_interface::instruction::$a($($b)*).unwrap(); From fec6d0c5a2c4e74c89f74c97de4b2c9e1ad27f39 Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Fri, 21 Nov 2025 14:24:00 +0100 Subject: [PATCH 03/39] Add serde tests for new instruction using coption_u64 --- interface/tests/serialization.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/interface/tests/serialization.rs b/interface/tests/serialization.rs index 6965f7da1..4be3bf017 100644 --- a/interface/tests/serialization.rs +++ b/interface/tests/serialization.rs @@ -40,6 +40,30 @@ fn serde_instruction_coption_pubkey_with_none() { serde_json::from_str::(&serialized).unwrap(); } +#[test] +fn serde_instruction_coption_u64() { + let inst = instruction::TokenInstruction::UnwrapLamports { + amount: COption::Some(1), + }; + + let serialized = serde_json::to_string(&inst).unwrap(); + assert_eq!(&serialized, "{\"unwrapLamports\":{\"amount\":\"1\"}}"); + + serde_json::from_str::(&serialized).unwrap(); +} + +#[test] +fn serde_instruction_coption_u64_with_none() { + let inst = instruction::TokenInstruction::UnwrapLamports { + amount: COption::None, + }; + + let serialized = serde_json::to_string(&inst).unwrap(); + assert_eq!(&serialized, "{\"unwrapLamports\":{\"amount\":null}}"); + + serde_json::from_str::(&serialized).unwrap(); +} + #[test] fn serde_instruction_optional_nonzero_pubkeys_podbool() { // tests serde of ix containing OptionalNonZeroPubkey, PodBool and From f9de3ebd5230b5bc64a3c512bf30223d01f2bcf0 Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Wed, 26 Nov 2025 10:35:47 +0100 Subject: [PATCH 04/39] Corrected instruction docs --- interface/src/instruction.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interface/src/instruction.rs b/interface/src/instruction.rs index 311fc14ef..50f55c869 100644 --- a/interface/src/instruction.rs +++ b/interface/src/instruction.rs @@ -731,7 +731,7 @@ pub enum TokenInstruction<'a> { ScaledUiAmountExtension, /// Instruction prefix for instructions to the pausable extension PausableExtension, - /// 45 + // 45 /// Transfer lamports from a native SOL account to a destination account. /// /// This is useful to unwrap lamports from a wrapped SOL account. From ab7de00c5106d7e1bd8fb0347578d8a7be6799c8 Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Wed, 26 Nov 2025 10:42:24 +0100 Subject: [PATCH 05/39] Add PodTokenInstruction helpers --- program/src/pod_instruction.rs | 46 ++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/program/src/pod_instruction.rs b/program/src/pod_instruction.rs index 308c0cd04..bb484738e 100644 --- a/program/src/pod_instruction.rs +++ b/program/src/pod_instruction.rs @@ -131,6 +131,21 @@ fn unpack_pubkey_option(input: &[u8]) -> Result, ProgramError } } +const U64_BYTES: usize = 8; +fn unpack_u64_option(input: &[u8]) -> Result, ProgramError> { + match input.split_first() { + Option::Some((&0, _)) => Ok(PodCOption::none()), + Option::Some((&1, rest)) => { + let amount = rest + .get(..U64_BYTES) + .and_then(|x| x.try_into().map(u64::from_le_bytes).ok()) + .ok_or(ProgramError::InvalidInstructionData)?; + Ok(PodCOption::some(amount)) + } + _ => Err(ProgramError::InvalidInstructionData), + } +} + /// Specialty function for deserializing `Pod` data and a `COption` /// /// `COption` is not `Pod` compatible when serialized in an instruction, but @@ -147,6 +162,22 @@ pub(crate) fn decode_instruction_data_with_coption_pubkey( Ok((value, pubkey)) } +/// Specialty function for deserializing `Pod` data and a `COption` +/// +/// `COption` is not `Pod` compatible when serialized in an instruction, but +/// since it is always at the end of an instruction, so we can do this safely +pub(crate) fn decode_instruction_data_with_coption_u64( + input_with_type: &[u8], +) -> Result<(&T, PodCOption), ProgramError> { + let end_of_t = pod_get_packed_len::().saturating_add(1); + let value = input_with_type + .get(1..end_of_t) + .ok_or(ProgramError::InvalidInstructionData) + .and_then(pod_from_bytes)?; + let amount = unpack_u64_option(&input_with_type[end_of_t..])?; + Ok((value, amount)) +} + #[cfg(test)] mod tests { use { @@ -195,6 +226,9 @@ mod tests { | PodTokenInstruction::BurnChecked => { let _ = decode_instruction_data::(input)?; } + PodTokenInstruction::UnwrapLamports => { + let _ = decode_instruction_data_with_coption_u64::<()>(input)?; + } PodTokenInstruction::InitializeMintCloseAuthority => { let _ = decode_instruction_data_with_coption_pubkey::<()>(input)?; } @@ -600,6 +634,18 @@ mod tests { assert_eq!(pod_close_authority, close_authority.into()); } + #[test] + fn test_unwrap_lamports_packing() { + let amount = COption::Some(1); + let check = TokenInstruction::UnwrapLamports { amount }; + let packed = check.pack(); + + let instruction_type = decode_instruction_type::(&packed).unwrap(); + assert_eq!(instruction_type, PodTokenInstruction::UnwrapLamports); + let (_, pod_amount) = decode_instruction_data_with_coption_u64::<()>(&packed).unwrap(); + assert_eq!(pod_amount, amount.into()); + } + #[test] fn test_create_native_mint_packing() { let check = TokenInstruction::CreateNativeMint; From 4e8c8bcc6480872d6e130244208f2069e45787f7 Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Wed, 26 Nov 2025 10:45:35 +0100 Subject: [PATCH 06/39] Add unwrap lamports processor --- program/src/pod_instruction.rs | 2 + program/src/processor.rs | 750 ++++++++++++++++++++++++++++++++- 2 files changed, 750 insertions(+), 2 deletions(-) diff --git a/program/src/pod_instruction.rs b/program/src/pod_instruction.rs index bb484738e..19c033982 100644 --- a/program/src/pod_instruction.rs +++ b/program/src/pod_instruction.rs @@ -115,6 +115,8 @@ pub(crate) enum PodTokenInstruction { ConfidentialMintBurnExtension, ScaledUiAmountExtension, PausableExtension, + // 45 + UnwrapLamports, } fn unpack_pubkey_option(input: &[u8]) -> Result, ProgramError> { diff --git a/program/src/processor.rs b/program/src/processor.rs index 479621926..fd5d60209 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -11,8 +11,9 @@ use { transfer_fee, transfer_hook, }, pod_instruction::{ - decode_instruction_data_with_coption_pubkey, AmountCheckedData, AmountData, - InitializeMintData, InitializeMultisigData, PodTokenInstruction, SetAuthorityData, + decode_instruction_data_with_coption_pubkey, decode_instruction_data_with_coption_u64, + AmountCheckedData, AmountData, InitializeMintData, InitializeMultisigData, + PodTokenInstruction, SetAuthorityData, }, }, solana_account_info::{next_account_info, AccountInfo}, @@ -1641,6 +1642,91 @@ impl Processor { Ok(()) } + /// Processes a [`UnwrapLamports`](enum.TokenInstruction.html) + /// instruction + pub fn process_unwrap_lamports( + program_id: &Pubkey, + accounts: &[AccountInfo], + amount: PodCOption, + ) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let source_account_info = next_account_info(account_info_iter)?; + let destination_account_info = next_account_info(account_info_iter)?; + let authority_info = next_account_info(account_info_iter)?; + let authority_info_data_len = authority_info.data_len(); + + let mut source_account_data = source_account_info.data.borrow_mut(); + let source_account = + PodStateWithExtensionsMut::::unpack(&mut source_account_data)?; + + let (amount, remaining_amount) = match amount { + PodCOption { + option: PodCOption::::SOME, + value: amount, + } => ( + amount, + Into::::into(source_account.base.amount) + .checked_sub(amount) + .ok_or(TokenError::InsufficientFunds)?, + ), + PodCOption { + option: PodCOption::::NONE, + value: _, + } => (source_account_info.lamports(), 0), + _ => return Err(ProgramError::InvalidInstructionData), + }; + + if !source_account.base.is_native() { + return Err(TokenError::NonNativeNotSupported.into()); + } + + let self_transfer = source_account_info.key == destination_account_info.key; + if let Ok(cpi_guard) = source_account.get_extension::() { + if *authority_info.key == source_account.base.owner + && cpi_guard.lock_cpi.into() + && in_cpi() + { + return Err(TokenError::CpiGuardTransferBlocked.into()); + } + } + + Self::validate_owner( + program_id, + &source_account.base.owner, + authority_info, + authority_info_data_len, + account_info_iter.as_slice(), + )?; + + // Revisit this later to see if it's worth adding a check to reduce + // compute costs, ie: + // if self_transfer || amount == 0 + check_program_account(source_account_info.owner)?; + + // This check MUST occur just before the amounts are manipulated + // to ensure self-transfers are fully validated + if self_transfer { + if memo_required(&source_account) { + check_previous_sibling_instruction_is_memo()?; + } + return Ok(()); + } + + let source_starting_lamports = source_account_info.lamports(); + **source_account_info.lamports.borrow_mut() = source_starting_lamports + .checked_sub(amount) + .ok_or(TokenError::Overflow)?; + + let destination_starting_lamports = destination_account_info.lamports(); + **destination_account_info.lamports.borrow_mut() = destination_starting_lamports + .checked_add(amount) + .ok_or(TokenError::Overflow)?; + + source_account.base.amount = remaining_amount.into(); + + Ok(()) + } + /// Processes an [`Instruction`](enum.Instruction.html). pub fn process(program_id: &Pubkey, accounts: &[AccountInfo], input: &[u8]) -> ProgramResult { if let Ok(instruction_type) = decode_instruction_type(input) { @@ -1942,6 +2028,11 @@ impl Processor { msg!("Instruction: PausableExtension"); pausable::processor::process_instruction(program_id, accounts, &input[1..]) } + PodTokenInstruction::UnwrapLamports => { + msg!("Instruction: UnwrapLamports"); + let (_, amount) = decode_instruction_data_with_coption_u64::<()>(input)?; + Self::process_unwrap_lamports(program_id, accounts, amount) + } } } else if let Ok(instruction) = TokenMetadataInstruction::unpack(input) { token_metadata::processor::process_instruction(program_id, accounts, instruction) @@ -2040,6 +2131,7 @@ mod tests { serial_test::serial, solana_account::{ create_account_for_test, create_is_signer_account_infos, Account as SolanaAccount, + ReadableAccount, }, solana_account_info::IntoAccountInfo, solana_clock::Clock, @@ -2172,6 +2264,24 @@ mod tests { mint_account } + fn native_mint_to(account: &AccountInfo, amount: u64) -> ProgramResult { + let mut buffer = account.try_borrow_mut_data()?; + let mut account_account = Account::unpack(&buffer)?; + + if !account_account.is_native() { + return Err(TokenError::NonNativeNotSupported.into()); + } + + let mut lamports = account.try_borrow_mut_lamports()?; + **lamports += amount; + + account_account.amount += amount; + + Account::pack(account_account, &mut buffer)?; + + Ok(()) + } + #[test] fn test_error_as_custom() { assert_eq!( @@ -3736,6 +3846,642 @@ mod tests { assert_eq!(account.amount, 1000); } + #[test] + fn test_unwrap_lamports_dups() { + let program_id = crate::id(); + let account1_key = Pubkey::new_unique(); + let mut account1_account = SolanaAccount::new( + account_minimum_balance(), + Account::get_packed_len(), + &program_id, + ); + let mut account1_info: AccountInfo = (&account1_key, true, &mut account1_account).into(); + let account2_key = Pubkey::new_unique(); + let mut account2_account = SolanaAccount::new( + account_minimum_balance(), + Account::get_packed_len(), + &program_id, + ); + let mut account2_info: AccountInfo = (&account2_key, false, &mut account2_account).into(); + let account3_key = Pubkey::new_unique(); + let mut account3_account = SolanaAccount::new( + account_minimum_balance(), + Account::get_packed_len(), + &program_id, + ); + let account3_info: AccountInfo = (&account3_key, false, &mut account3_account).into(); + let account4_key = Pubkey::new_unique(); + let mut account4_account = SolanaAccount::new( + account_minimum_balance(), + Account::get_packed_len(), + &program_id, + ); + let account4_info: AccountInfo = (&account4_key, true, &mut account4_account).into(); + let multisig_key = Pubkey::new_unique(); + let mut multisig_account = SolanaAccount::new( + multisig_minimum_balance(), + Multisig::get_packed_len(), + &program_id, + ); + let multisig_info: AccountInfo = (&multisig_key, true, &mut multisig_account).into(); + let owner_key = Pubkey::new_unique(); + let mut owner_account = SolanaAccount::default(); + let owner_info: AccountInfo = (&owner_key, true, &mut owner_account).into(); + let mint_key = native_mint::id(); + let mut mint_account = native_mint(); + let mint_info: AccountInfo = (&mint_key, false, &mut mint_account).into(); + let rent_key = rent::id(); + let mut rent_sysvar = rent_sysvar(); + let rent_info: AccountInfo = (&rent_key, false, &mut rent_sysvar).into(); + + // create account + do_process_instruction_dups( + initialize_account(&program_id, &account1_key, &mint_key, &account1_key).unwrap(), + vec![ + account1_info.clone(), + mint_info.clone(), + account1_info.clone(), + rent_info.clone(), + ], + ) + .unwrap(); + + // create another account + do_process_instruction_dups( + initialize_account(&program_id, &account2_key, &mint_key, &owner_key).unwrap(), + vec![ + account2_info.clone(), + mint_info.clone(), + owner_info.clone(), + rent_info.clone(), + ], + ) + .unwrap(); + + // mint to account + native_mint_to(&account1_info, 1000).unwrap(); + + // source-owner unwrap lamports + do_process_instruction_dups( + unwrap_lamports( + &program_id, + &account1_key, + &account2_key, + &account1_key, + &[], + Some(500), + ) + .unwrap(), + vec![ + account1_info.clone(), + account2_info.clone(), + account1_info.clone(), + ], + ) + .unwrap(); + + // test destination-owner unwrap lamports + do_process_instruction_dups( + initialize_account(&program_id, &account3_key, &mint_key, &account2_key).unwrap(), + vec![ + account3_info.clone(), + mint_info.clone(), + account2_info.clone(), + rent_info.clone(), + ], + ) + .unwrap(); + native_mint_to(&account3_info, 1000).unwrap(); + + account1_info.is_signer = false; + account2_info.is_signer = true; + do_process_instruction_dups( + unwrap_lamports( + &program_id, + &account3_key, + &account2_key, + &account2_key, + &[], + Some(500), + ) + .unwrap(), + vec![ + account3_info.clone(), + account2_info.clone(), + account2_info.clone(), + ], + ) + .unwrap(); + + // test source-multisig signer + do_process_instruction_dups( + initialize_multisig(&program_id, &multisig_key, &[&account4_key], 1).unwrap(), + vec![ + multisig_info.clone(), + rent_info.clone(), + account4_info.clone(), + ], + ) + .unwrap(); + + do_process_instruction_dups( + initialize_account(&program_id, &account4_key, &mint_key, &multisig_key).unwrap(), + vec![ + account4_info.clone(), + mint_info.clone(), + multisig_info.clone(), + rent_info.clone(), + ], + ) + .unwrap(); + native_mint_to(&account4_info, 1000).unwrap(); + + // source-multisig-signer unwrap lamports + do_process_instruction_dups( + unwrap_lamports( + &program_id, + &account4_key, + &account2_key, + &multisig_key, + &[&account4_key], + Some(500), + ) + .unwrap(), + vec![ + account4_info.clone(), + account2_info.clone(), + multisig_info.clone(), + account4_info.clone(), + ], + ) + .unwrap(); + } + + #[test] + fn test_unwrap_lamports() { + let program_id = crate::id(); + let zero_space_rent_exempt_balance = Rent::default().minimum_balance(0); + let account_key = Pubkey::new_unique(); + let mut account_account = SolanaAccount::new( + account_minimum_balance(), + Account::get_packed_len(), + &program_id, + ); + let account2_key = Pubkey::new_unique(); + let mut account2_account = SolanaAccount::new( + zero_space_rent_exempt_balance, + Account::get_packed_len(), + &program_id, + ); + let mismatch_account_key = Pubkey::new_unique(); + let mut mismatch_account_account = SolanaAccount::new( + account_minimum_balance(), + Account::get_packed_len(), + &program_id, + ); + let owner_key = Pubkey::new_unique(); + let mut owner_account = SolanaAccount::default(); + let owner2_key = Pubkey::new_unique(); + let mut owner2_account = SolanaAccount::default(); + let mint_key = native_mint::id(); + let mut mint_account = native_mint(); + let mismatch_mint_key = Pubkey::new_unique(); + let mut mismatch_mint_account = + SolanaAccount::new(mint_minimum_balance(), Mint::get_packed_len(), &program_id); + let mut rent_sysvar = rent_sysvar(); + + // create mismatch mint + do_process_instruction( + initialize_mint(&program_id, &mismatch_mint_key, &owner_key, None, 2).unwrap(), + vec![&mut mismatch_mint_account, &mut rent_sysvar], + ) + .unwrap(); + + // create account + do_process_instruction( + initialize_account(&program_id, &account_key, &mint_key, &owner_key).unwrap(), + vec![ + &mut account_account, + &mut mint_account, + &mut owner_account, + &mut rent_sysvar, + ], + ) + .unwrap(); + + // create mismatch account + do_process_instruction( + initialize_account( + &program_id, + &mismatch_account_key, + &mismatch_mint_key, + &owner_key, + ) + .unwrap(), + vec![ + &mut mismatch_account_account, + &mut mismatch_mint_account, + &mut owner_account, + &mut rent_sysvar, + ], + ) + .unwrap(); + + // mint to account + let account_info = (&account_key, false, &mut account_account).into(); + native_mint_to(&account_info, 1000).unwrap(); + + // missing signer + let mut instruction = unwrap_lamports( + &program_id, + &account_key, + &account2_key, + &owner_key, + &[], + Some(1000), + ) + .unwrap(); + instruction.accounts[2].is_signer = false; + assert_eq!( + Err(ProgramError::MissingRequiredSignature), + do_process_instruction( + instruction, + vec![ + &mut account_account, + &mut account2_account, + &mut owner_account, + ], + ) + ); + + // non native mint + assert_eq!( + Err(TokenError::NonNativeNotSupported.into()), + do_process_instruction( + unwrap_lamports( + &program_id, + &mismatch_account_key, + &account_key, + &owner_key, + &[], + None + ) + .unwrap(), + vec![ + &mut mismatch_account_account, + &mut account_account, + &mut owner_account, + ], + ) + ); + + // missing owner + assert_eq!( + Err(TokenError::OwnerMismatch.into()), + do_process_instruction( + unwrap_lamports( + &program_id, + &account_key, + &account2_key, + &owner2_key, + &[], + Some(1000) + ) + .unwrap(), + vec![ + &mut account_account, + &mut account2_account, + &mut owner2_account, + ], + ) + ); + + // account not owned by program + let not_program_id = Pubkey::new_unique(); + account_account.owner = not_program_id; + assert_eq!( + Err(ProgramError::IncorrectProgramId), + do_process_instruction( + unwrap_lamports( + &program_id, + &account_key, + &account2_key, + &owner_key, + &[], + Some(0) + ) + .unwrap(), + vec![ + &mut account_account, + &mut account2_account, + &mut owner2_account, + ], + ) + ); + account_account.owner = program_id; + + // unwrap Some(500) lamports + do_process_instruction( + unwrap_lamports( + &program_id, + &account_key, + &account2_key, + &owner_key, + &[], + Some(500), + ) + .unwrap(), + vec![ + &mut account_account, + &mut account2_account, + &mut owner_account, + ], + ) + .unwrap(); + // 500 amount balance change... + let account = Account::unpack_unchecked(&account_account.data).unwrap(); + assert_eq!(account.amount, 500); + // 500 lamports balance change... + assert_eq!(account_account.lamports(), 500 + account_minimum_balance()); + assert_eq!( + account2_account.lamports(), + zero_space_rent_exempt_balance + 500 + ); + + // insufficient funds + assert_eq!( + Err(TokenError::InsufficientFunds.into()), + do_process_instruction( + unwrap_lamports( + &program_id, + &account_key, + &account2_key, + &owner_key, + &[], + Some(501) + ) + .unwrap(), + vec![ + &mut account_account, + &mut account2_account, + &mut owner_account, + ], + ) + ); + + // unwrap None lamports + do_process_instruction( + unwrap_lamports( + &program_id, + &account_key, + &account2_key, + &owner_key, + &[], + None, + ) + .unwrap(), + vec![ + &mut account_account, + &mut account2_account, + &mut owner_account, + ], + ) + .unwrap(); + // 500 amount balance change... + let account = Account::unpack_unchecked(&account_account.data).unwrap(); + assert_eq!(account.amount, 0); + // 500 + account_minimum_balance() lamports balance change... + assert_eq!(account_account.lamports(), 0); + assert_eq!( + account2_account.lamports(), + zero_space_rent_exempt_balance + 500 + 500 + account_minimum_balance() + ); + } + + #[test] + fn test_self_unwrap_lamports() { + let program_id = crate::id(); + let account_key = Pubkey::new_unique(); + let mut account_account = SolanaAccount::new( + account_minimum_balance(), + Account::get_packed_len(), + &program_id, + ); + let mismatch_account_key = Pubkey::new_unique(); + let mut mismatch_account_account = SolanaAccount::new( + account_minimum_balance(), + Account::get_packed_len(), + &program_id, + ); + let owner_key = Pubkey::new_unique(); + let mut owner_account = SolanaAccount::default(); + let owner2_key = Pubkey::new_unique(); + let mut owner2_account = SolanaAccount::default(); + let mint_key = native_mint::id(); + let mut mint_account = native_mint(); + let mismatch_mint_key = Pubkey::new_unique(); + let mut mismatch_mint_account = + SolanaAccount::new(mint_minimum_balance(), Mint::get_packed_len(), &program_id); + let mut rent_sysvar = rent_sysvar(); + + // create mismatch mint + do_process_instruction( + initialize_mint(&program_id, &mismatch_mint_key, &owner_key, None, 2).unwrap(), + vec![&mut mismatch_mint_account, &mut rent_sysvar], + ) + .unwrap(); + + // create account + do_process_instruction( + initialize_account(&program_id, &account_key, &mint_key, &owner_key).unwrap(), + vec![ + &mut account_account, + &mut mint_account, + &mut owner_account, + &mut rent_sysvar, + ], + ) + .unwrap(); + + // create mismatch account + do_process_instruction( + initialize_account( + &program_id, + &mismatch_account_key, + &mismatch_mint_key, + &owner_key, + ) + .unwrap(), + vec![ + &mut mismatch_account_account, + &mut mismatch_mint_account, + &mut owner_account, + &mut rent_sysvar, + ], + ) + .unwrap(); + + let account_info = (&account_key, false, &mut account_account).into_account_info(); + let mismatch_account_info = + (&mismatch_account_key, false, &mut mismatch_account_account).into_account_info(); + let owner_info = (&owner_key, true, &mut owner_account).into_account_info(); + let owner2_info = (&owner2_key, true, &mut owner2_account).into_account_info(); + + // mint to account + native_mint_to(&account_info, 1000).unwrap(); + + // unwrap Some(1000) lamports + let instruction = unwrap_lamports( + &program_id, + account_info.key, + account_info.key, + owner_info.key, + &[], + Some(1000), + ) + .unwrap(); + assert_eq!( + Ok(()), + Processor::process( + &instruction.program_id, + &[ + account_info.clone(), + account_info.clone(), + owner_info.clone(), + ], + &instruction.data, + ) + ); + // no amount balance change... + let account = Account::unpack_unchecked(&account_info.try_borrow_data().unwrap()).unwrap(); + assert_eq!(account.amount, 1000); + // no lamport balance change... + assert_eq!(account_info.lamports(), 1000 + account_minimum_balance()); + + // unwrap None lamports + let instruction = unwrap_lamports( + &program_id, + account_info.key, + account_info.key, + owner_info.key, + &[], + None, + ) + .unwrap(); + assert_eq!( + Ok(()), + Processor::process( + &instruction.program_id, + &[ + account_info.clone(), + account_info.clone(), + owner_info.clone(), + ], + &instruction.data, + ) + ); + // no amount balance change... + let account = Account::unpack_unchecked(&account_info.try_borrow_data().unwrap()).unwrap(); + assert_eq!(account.amount, 1000); + // no lamport balance change... + assert_eq!(account_info.lamports(), 1000 + account_minimum_balance()); + + // missing signer + let mut owner_no_sign_info = owner_info.clone(); + let mut instruction = unwrap_lamports( + &program_id, + account_info.key, + account_info.key, + owner_no_sign_info.key, + &[], + Some(1000), + ) + .unwrap(); + instruction.accounts[2].is_signer = false; + owner_no_sign_info.is_signer = false; + assert_eq!( + Err(ProgramError::MissingRequiredSignature), + Processor::process( + &instruction.program_id, + &[ + account_info.clone(), + account_info.clone(), + owner_no_sign_info.clone(), + ], + &instruction.data, + ) + ); + + // non native mint + let instruction = unwrap_lamports( + &program_id, + mismatch_account_info.key, + mismatch_account_info.key, + owner_info.key, + &[], + None, + ) + .unwrap(); + assert_eq!( + Err(TokenError::NonNativeNotSupported.into()), + Processor::process( + &instruction.program_id, + &[ + mismatch_account_info.clone(), + mismatch_account_info.clone(), + owner_info.clone(), + ], + &instruction.data, + ) + ); + + // missing owner + let instruction = unwrap_lamports( + &program_id, + account_info.key, + account_info.key, + owner2_info.key, + &[], + Some(1000), + ) + .unwrap(); + assert_eq!( + Err(TokenError::OwnerMismatch.into()), + Processor::process( + &instruction.program_id, + &[ + account_info.clone(), + account_info.clone(), + owner2_info.clone(), + ], + &instruction.data, + ) + ); + + // insufficient funds + let instruction = unwrap_lamports( + &program_id, + account_info.key, + account_info.key, + owner_info.key, + &[], + Some(1001), + ) + .unwrap(); + assert_eq!( + Err(TokenError::InsufficientFunds.into()), + Processor::process( + &instruction.program_id, + &[ + account_info.clone(), + account_info.clone(), + owner_info.clone(), + ], + &instruction.data, + ) + ); + } + #[test] fn test_mintable_token_with_zero_supply() { let program_id = crate::id(); From b242440d2f3bbde0a357f3fb0cfaf5921802714d Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Wed, 26 Nov 2025 10:51:48 +0100 Subject: [PATCH 07/39] Add unwrap lamports cli clap_app parser --- clients/cli/src/clap_app.rs | 42 +++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/clients/cli/src/clap_app.rs b/clients/cli/src/clap_app.rs index 86f2a23cc..f2be2b376 100644 --- a/clients/cli/src/clap_app.rs +++ b/clients/cli/src/clap_app.rs @@ -172,6 +172,7 @@ pub enum CommandName { UpdateUiAmountMultiplier, Pause, Resume, + UnwrapLamports, } impl fmt::Display for CommandName { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { @@ -1721,6 +1722,47 @@ pub fn app<'a>( .nonce_args(true) .offline_args(), ) + .subcommand( + SubCommand::with_name(CommandName::UnwrapLamports.into()) + .about("Unwrap lamports from a SOL token account") + .arg( + Arg::with_name("amount") + .value_parser(Amount::parse) + .value_name("TOKEN_AMOUNT") + .takes_value(true) + .index(1) + .required(true) + .help("Amount to unwrap, in tokens; accepts keyword ALL"), + ) + .arg( + Arg::with_name("recipient") + .validator(|s| is_valid_pubkey(s)) + .value_name("RECIPIENT_ACCOUNT_ADDRESS") + .takes_value(true) + .index(2) + .help("Specify the address to recieve the unwrapped SOL. \ + Defaults to the owner address.") + ) + .arg( + Arg::with_name("from") + .validator(|s| is_valid_pubkey(s)) + .value_name("NATIVE_TOKEN_ACCOUNT_ADDRESS") + .takes_value(true) + .long("from") + .help("Specify the token account that contains the wrapped SOL. \ + [default: owner's associated token account]") + ) + .arg(owner_keypair_arg_with_value_name("NATIVE_TOKEN_OWNER_KEYPAIR") + .help( + "Specify the keypair for the wallet which owns the wrapped SOL. \ + This may be a keypair file or the ASK keyword. \ + Defaults to the client keypair.", + ), + ) + .arg(multisig_signer_arg()) + .nonce_args(true) + .offline_args(), + ) .subcommand( SubCommand::with_name(CommandName::Approve.into()) .about("Approve a delegate for a token account") From 8613dfba1f6123ce6ee268b763d92cca430397d2 Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Wed, 26 Nov 2025 10:56:31 +0100 Subject: [PATCH 08/39] Add unwrap lamports cli handler --- clients/cli/src/command.rs | 103 +++++++++++++++++++++++++++++++++++ clients/cli/tests/command.rs | 66 ++++++++++++++++++++++ 2 files changed, 169 insertions(+) diff --git a/clients/cli/src/command.rs b/clients/cli/src/command.rs index 7820d6061..263de29d9 100644 --- a/clients/cli/src/command.rs +++ b/clients/cli/src/command.rs @@ -2099,6 +2099,95 @@ async fn command_unwrap( }) } +async fn command_unwrap_lamports( + config: &Config<'_>, + ui_amount: Amount, + source_owner: Pubkey, + source_account: Option, + destination_account: Option, + bulk_signers: BulkSigners, +) -> CommandResult { + let use_associated_account = source_account.is_none(); + let token = native_token_client_from_config(config)?; + + let source_account = + source_account.unwrap_or_else(|| token.get_associated_token_address(&source_owner)); + + let destination_account = destination_account.unwrap_or(source_owner); + + let amount = match ui_amount.sol_to_lamport() { + Amount::Raw(ui_amount) => Some(ui_amount), + Amount::Decimal(_) => unreachable!(), + Amount::All => None, + }; + + let display_amount = amount + .map(|amount| amount.to_string()) + .unwrap_or_else(|| "all".to_string()); + + println_display( + config, + format!( + "Unwrapping {} lamports to {}", + display_amount, destination_account + ), + ); + + if !config.sign_only { + let account_data = config.get_account_checked(&source_account).await?; + + if !use_associated_account { + let account_state = StateWithExtensionsOwned::::unpack(account_data.data)?; + + if account_state.base.mint != *token.get_address() { + return Err(format!("{} is not a native token account", source_account).into()); + } + } + + if account_data.lamports == 0 { + if use_associated_account { + return Err("No wrapped SOL in associated account; did you mean to specify an auxiliary address?".to_string().into()); + } else { + return Err(format!("No wrapped SOL in {}", source_account).into()); + } + } + + println_display( + config, + format!( + " Amount: {} SOL", + build_balance_message(account_data.lamports, false, false) + ), + ); + + // TODO: check if the destination account exists and if it doesn't check + // if the amount being transferred covers the rent exempt balance, then add + // a flag to fund the destination account + } + + println_display(config, format!(" Recipient: {}", &destination_account)); + + let res = token + .unwrap_lamports( + &source_account, + &destination_account, + &source_owner, + amount, + &bulk_signers, + ) + .await?; + + let tx_return = finish_tx(config, &res, false).await?; + Ok(match tx_return { + TransactionReturnData::CliSignature(signature) => { + config.output_format.formatted_string(&signature) + } + TransactionReturnData::CliSignOnlyData(sign_only_data) => { + config.output_format.formatted_string(&sign_only_data) + } + }) +} + #[allow(clippy::too_many_arguments)] async fn command_approve( config: &Config<'_>, @@ -4273,6 +4362,20 @@ pub async fn process_command( let account = pubkey_of_signer(arg_matches, "account", &mut wallet_manager).unwrap(); command_unwrap(config, wallet_address, account, bulk_signers).await } + (CommandName::UnwrapLamports, arg_matches) => { + let (owner_signer, owner) = + config.signer_or_default(arg_matches, "owner", &mut wallet_manager); + if config.multisigner_pubkeys.is_empty() { + push_signer_with_dedup(owner_signer, &mut bulk_signers); + } + + let amount = *arg_matches.get_one::("amount").unwrap(); + let source = pubkey_of_signer(arg_matches, "from", &mut wallet_manager).unwrap(); + let recipient = + pubkey_of_signer(arg_matches, "recipient", &mut wallet_manager).unwrap(); + + command_unwrap_lamports(config, amount, owner, source, recipient, bulk_signers).await + } (CommandName::Approve, arg_matches) => { let (owner_signer, owner_address) = config.signer_or_default(arg_matches, "owner", &mut wallet_manager); diff --git a/clients/cli/tests/command.rs b/clients/cli/tests/command.rs index 5c64b6983..966cde092 100644 --- a/clients/cli/tests/command.rs +++ b/clients/cli/tests/command.rs @@ -842,6 +842,72 @@ async fn accounts_with_owner(test_validator: &TestValidator, payer: &Keypair) { } async fn wrapped_sol(test_validator: &TestValidator, payer: &Keypair) { + // both tests use the same ata so they can't run together + unwrap_lamports(test_validator, payer).await; + wrap_unwrap_sol(test_validator, payer).await; +} + +async fn unwrap_lamports(test_validator: &TestValidator, payer: &Keypair) { + let program_id = &spl_token_2022_interface::id(); + let config = test_config_with_default_signer(test_validator, payer, program_id); + let native_mint = *Token::new_native( + config.program_client.clone(), + program_id, + config.fee_payer().unwrap().clone(), + ) + .get_address(); + + process_test_command( + &config, + payer, + &["spl-token", CommandName::Wrap.into(), "10.0"], + ) + .await + .unwrap(); + + let wrapped_account = get_associated_token_address_with_program_id( + &payer.pubkey(), + &native_mint, + &config.program_id, + ); + let new_account = Keypair::new(); + + process_test_command( + &config, + payer, + &[ + "spl-token", + CommandName::UnwrapLamports.into(), + "5.0", + &new_account.pubkey().to_string(), + "--from", + &wrapped_account.to_string(), + ], + ) + .await + .unwrap(); + let balance = config + .rpc_client + .get_balance(&new_account.pubkey()) + .await + .unwrap(); + assert_eq!(balance, 5000000000); + + process_test_command( + &config, + payer, + &["spl-token", CommandName::UnwrapLamports.into(), "ALL"], + ) + .await + .unwrap(); + config + .rpc_client + .get_account(&wrapped_account) + .await + .unwrap_err(); +} + +async fn wrap_unwrap_sol(test_validator: &TestValidator, payer: &Keypair) { for program_id in VALID_TOKEN_PROGRAM_IDS.iter() { let config = test_config_with_default_signer(test_validator, payer, program_id); let native_mint = *Token::new_native( From e53c1f9bb9ecd7c865b388daaa21679c7e57bf2a Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Wed, 26 Nov 2025 10:59:03 +0100 Subject: [PATCH 09/39] Add auto-generated js code --- clients/js/src/generated/accounts/mint.ts | 3 + clients/js/src/generated/accounts/multisig.ts | 3 + clients/js/src/generated/accounts/token.ts | 3 + .../js/src/generated/instructions/index.ts | 1 + .../generated/instructions/unwrapLamports.ts | 236 ++++++++++++++++++ .../js/src/generated/programs/token2022.ts | 10 +- interface/idl.json | 94 +++++++ 7 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 clients/js/src/generated/instructions/unwrapLamports.ts diff --git a/clients/js/src/generated/accounts/mint.ts b/clients/js/src/generated/accounts/mint.ts index 35b1a19dd..c6a5158bd 100644 --- a/clients/js/src/generated/accounts/mint.ts +++ b/clients/js/src/generated/accounts/mint.ts @@ -92,6 +92,7 @@ export type MintArgs = { extensions: OptionOrNullable>; }; +/** Gets the encoder for {@link MintArgs} account data. */ export function getMintEncoder(): Encoder { return getStructEncoder([ [ @@ -124,6 +125,7 @@ export function getMintEncoder(): Encoder { ]); } +/** Gets the decoder for {@link Mint} account data. */ export function getMintDecoder(): Decoder { return getStructDecoder([ [ @@ -156,6 +158,7 @@ export function getMintDecoder(): Decoder { ]); } +/** Gets the codec for {@link Mint} account data. */ export function getMintCodec(): Codec { return combineCodec(getMintEncoder(), getMintDecoder()); } diff --git a/clients/js/src/generated/accounts/multisig.ts b/clients/js/src/generated/accounts/multisig.ts index 1ef430e12..faa0404f7 100644 --- a/clients/js/src/generated/accounts/multisig.ts +++ b/clients/js/src/generated/accounts/multisig.ts @@ -48,6 +48,7 @@ export type Multisig = { export type MultisigArgs = Multisig; +/** Gets the encoder for {@link MultisigArgs} account data. */ export function getMultisigEncoder(): FixedSizeEncoder { return getStructEncoder([ ['m', getU8Encoder()], @@ -57,6 +58,7 @@ export function getMultisigEncoder(): FixedSizeEncoder { ]); } +/** Gets the decoder for {@link Multisig} account data. */ export function getMultisigDecoder(): FixedSizeDecoder { return getStructDecoder([ ['m', getU8Decoder()], @@ -66,6 +68,7 @@ export function getMultisigDecoder(): FixedSizeDecoder { ]); } +/** Gets the codec for {@link Multisig} account data. */ export function getMultisigCodec(): FixedSizeCodec { return combineCodec(getMultisigEncoder(), getMultisigDecoder()); } diff --git a/clients/js/src/generated/accounts/token.ts b/clients/js/src/generated/accounts/token.ts index 45cf91e76..1e1d75cc4 100644 --- a/clients/js/src/generated/accounts/token.ts +++ b/clients/js/src/generated/accounts/token.ts @@ -112,6 +112,7 @@ export type TokenArgs = { extensions: OptionOrNullable>; }; +/** Gets the encoder for {@link TokenArgs} account data. */ export function getTokenEncoder(): Encoder { return getStructEncoder([ ['mint', getAddressEncoder()], @@ -153,6 +154,7 @@ export function getTokenEncoder(): Encoder { ]); } +/** Gets the decoder for {@link Token} account data. */ export function getTokenDecoder(): Decoder { return getStructDecoder([ ['mint', getAddressDecoder()], @@ -194,6 +196,7 @@ export function getTokenDecoder(): Decoder { ]); } +/** Gets the codec for {@link Token} account data. */ export function getTokenCodec(): Codec { return combineCodec(getTokenEncoder(), getTokenDecoder()); } diff --git a/clients/js/src/generated/instructions/index.ts b/clients/js/src/generated/instructions/index.ts index 8b526eaa4..25583b8e6 100644 --- a/clients/js/src/generated/instructions/index.ts +++ b/clients/js/src/generated/instructions/index.ts @@ -79,6 +79,7 @@ export * from './transfer'; export * from './transferChecked'; export * from './transferCheckedWithFee'; export * from './uiAmountToAmount'; +export * from './unwrapLamports'; export * from './updateConfidentialTransferMint'; export * from './updateDefaultAccountState'; export * from './updateGroupMemberPointer'; diff --git a/clients/js/src/generated/instructions/unwrapLamports.ts b/clients/js/src/generated/instructions/unwrapLamports.ts new file mode 100644 index 000000000..dfa57201a --- /dev/null +++ b/clients/js/src/generated/instructions/unwrapLamports.ts @@ -0,0 +1,236 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + AccountRole, + combineCodec, + getOptionDecoder, + getOptionEncoder, + getStructDecoder, + getStructEncoder, + getU64Decoder, + getU64Encoder, + getU8Decoder, + getU8Encoder, + transformEncoder, + type AccountMeta, + type AccountSignerMeta, + type Address, + type Codec, + type Decoder, + type Encoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type Option, + type OptionOrNullable, + type ReadonlyAccount, + type ReadonlySignerAccount, + type ReadonlyUint8Array, + type TransactionSigner, + type WritableAccount, +} from '@solana/kit'; +import { TOKEN_2022_PROGRAM_ADDRESS } from '../programs'; +import { getAccountMetaFactory, type ResolvedAccount } from '../shared'; + +export const UNWRAP_LAMPORTS_DISCRIMINATOR = 45; + +export function getUnwrapLamportsDiscriminatorBytes() { + return getU8Encoder().encode(UNWRAP_LAMPORTS_DISCRIMINATOR); +} + +export type UnwrapLamportsInstruction< + TProgram extends string = typeof TOKEN_2022_PROGRAM_ADDRESS, + TAccountSource extends string | AccountMeta = string, + TAccountDestination extends string | AccountMeta = string, + TAccountAuthority extends string | AccountMeta = string, + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountSource extends string + ? WritableAccount + : TAccountSource, + TAccountDestination extends string + ? WritableAccount + : TAccountDestination, + TAccountAuthority extends string + ? ReadonlyAccount + : TAccountAuthority, + ...TRemainingAccounts, + ] + >; + +export type UnwrapLamportsInstructionData = { + discriminator: number; + /** The amount of lamports to transfer. */ + amount: Option; +}; + +export type UnwrapLamportsInstructionDataArgs = { + /** The amount of lamports to transfer. */ + amount: OptionOrNullable; +}; + +export function getUnwrapLamportsInstructionDataEncoder(): Encoder { + return transformEncoder( + getStructEncoder([ + ['discriminator', getU8Encoder()], + ['amount', getOptionEncoder(getU64Encoder())], + ]), + (value) => ({ ...value, discriminator: UNWRAP_LAMPORTS_DISCRIMINATOR }) + ); +} + +export function getUnwrapLamportsInstructionDataDecoder(): Decoder { + return getStructDecoder([ + ['discriminator', getU8Decoder()], + ['amount', getOptionDecoder(getU64Decoder())], + ]); +} + +export function getUnwrapLamportsInstructionDataCodec(): Codec< + UnwrapLamportsInstructionDataArgs, + UnwrapLamportsInstructionData +> { + return combineCodec( + getUnwrapLamportsInstructionDataEncoder(), + getUnwrapLamportsInstructionDataDecoder() + ); +} + +export type UnwrapLamportsInput< + TAccountSource extends string = string, + TAccountDestination extends string = string, + TAccountAuthority extends string = string, +> = { + /** The source account. */ + source: Address; + /** The destination account. */ + destination: Address; + /** The source account's owner or its multisignature account. */ + authority: Address | TransactionSigner; + amount: UnwrapLamportsInstructionDataArgs['amount']; + multiSigners?: Array; +}; + +export function getUnwrapLamportsInstruction< + TAccountSource extends string, + TAccountDestination extends string, + TAccountAuthority extends string, + TProgramAddress extends Address = typeof TOKEN_2022_PROGRAM_ADDRESS, +>( + input: UnwrapLamportsInput< + TAccountSource, + TAccountDestination, + TAccountAuthority + >, + config?: { programAddress?: TProgramAddress } +): UnwrapLamportsInstruction< + TProgramAddress, + TAccountSource, + TAccountDestination, + (typeof input)['authority'] extends TransactionSigner + ? ReadonlySignerAccount & + AccountSignerMeta + : TAccountAuthority +> { + // Program address. + const programAddress = config?.programAddress ?? TOKEN_2022_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + source: { value: input.source ?? null, isWritable: true }, + destination: { value: input.destination ?? null, isWritable: true }, + authority: { value: input.authority ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record< + keyof typeof originalAccounts, + ResolvedAccount + >; + + // Original args. + const args = { ...input }; + + // Remaining accounts. + const remainingAccounts: AccountMeta[] = (args.multiSigners ?? []).map( + (signer) => ({ + address: signer.address, + role: AccountRole.READONLY_SIGNER, + signer, + }) + ); + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + return Object.freeze({ + accounts: [ + getAccountMeta(accounts.source), + getAccountMeta(accounts.destination), + getAccountMeta(accounts.authority), + ...remainingAccounts, + ], + data: getUnwrapLamportsInstructionDataEncoder().encode( + args as UnwrapLamportsInstructionDataArgs + ), + programAddress, + } as UnwrapLamportsInstruction< + TProgramAddress, + TAccountSource, + TAccountDestination, + (typeof input)['authority'] extends TransactionSigner + ? ReadonlySignerAccount & + AccountSignerMeta + : TAccountAuthority + >); +} + +export type ParsedUnwrapLamportsInstruction< + TProgram extends string = typeof TOKEN_2022_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + /** The source account. */ + source: TAccountMetas[0]; + /** The destination account. */ + destination: TAccountMetas[1]; + /** The source account's owner or its multisignature account. */ + authority: TAccountMetas[2]; + }; + data: UnwrapLamportsInstructionData; +}; + +export function parseUnwrapLamportsInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData +): ParsedUnwrapLamportsInstruction { + if (instruction.accounts.length < 3) { + // TODO: Coded error. + throw new Error('Not enough accounts'); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { + source: getNextAccount(), + destination: getNextAccount(), + authority: getNextAccount(), + }, + data: getUnwrapLamportsInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/clients/js/src/generated/programs/token2022.ts b/clients/js/src/generated/programs/token2022.ts index 510b3b398..c2b5f518b 100644 --- a/clients/js/src/generated/programs/token2022.ts +++ b/clients/js/src/generated/programs/token2022.ts @@ -83,6 +83,7 @@ import { type ParsedTransferCheckedWithFeeInstruction, type ParsedTransferInstruction, type ParsedUiAmountToAmountInstruction, + type ParsedUnwrapLamportsInstruction, type ParsedUpdateConfidentialTransferMintInstruction, type ParsedUpdateDefaultAccountStateInstruction, type ParsedUpdateGroupMemberPointerInstruction, @@ -217,6 +218,7 @@ export enum Token2022Instruction { UpdateTokenGroupMaxSize, UpdateTokenGroupUpdateAuthority, InitializeTokenGroupMember, + UnwrapLamports, } export function identifyToken2022Instruction( @@ -671,6 +673,9 @@ export function identifyToken2022Instruction( ) { return Token2022Instruction.InitializeTokenGroupMember; } + if (containsBytes(data, getU8Encoder().encode(45), 0)) { + return Token2022Instruction.UnwrapLamports; + } throw new Error( 'The provided instruction could not be identified as a token-2022 instruction.' ); @@ -939,4 +944,7 @@ export type ParsedToken2022Instruction< } & ParsedUpdateTokenGroupUpdateAuthorityInstruction) | ({ instructionType: Token2022Instruction.InitializeTokenGroupMember; - } & ParsedInitializeTokenGroupMemberInstruction); + } & ParsedInitializeTokenGroupMemberInstruction) + | ({ + instructionType: Token2022Instruction.UnwrapLamports; + } & ParsedUnwrapLamportsInstruction); diff --git a/interface/idl.json b/interface/idl.json index 1b5b08fb7..83d303fb7 100644 --- a/interface/idl.json +++ b/interface/idl.json @@ -8453,6 +8453,100 @@ "offset": 0 } ] + }, + { + "kind": "instructionNode", + "name": "unwrapLamports", + "docs": [ + "Transfer lamports from a native SOL account to a destination account." + ], + "optionalAccountStrategy": "programId", + "accounts": [ + { + "kind": "instructionAccountNode", + "name": "source", + "isWritable": true, + "isSigner": false, + "isOptional": false, + "docs": ["The source account."] + }, + { + "kind": "instructionAccountNode", + "name": "destination", + "isWritable": true, + "isSigner": false, + "isOptional": false, + "docs": ["The destination account."] + }, + { + "kind": "instructionAccountNode", + "name": "authority", + "isWritable": false, + "isSigner": "either", + "isOptional": false, + "docs": [ + "The source account's owner or its multisignature account." + ], + "defaultValue": { + "kind": "identityValueNode" + } + } + ], + "arguments": [ + { + "kind": "instructionArgumentNode", + "name": "discriminator", + "defaultValueStrategy": "omitted", + "docs": [], + "type": { + "kind": "numberTypeNode", + "format": "u8", + "endian": "le" + }, + "defaultValue": { + "kind": "numberValueNode", + "number": 45 + } + }, + { + "kind": "instructionArgumentNode", + "name": "amount", + "docs": ["The amount of lamports to transfer."], + "type": { + "kind": "optionTypeNode", + "fixed": false, + "item": { + "kind": "numberTypeNode", + "format": "u64", + "endian": "le" + }, + "prefix": { + "kind": "numberTypeNode", + "format": "u8", + "endian": "le" + } + } + } + ], + "remainingAccounts": [ + { + "kind": "instructionRemainingAccountsNode", + "isOptional": true, + "isSigner": true, + "docs": [], + "value": { + "kind": "argumentValueNode", + "name": "multiSigners" + } + } + ], + "discriminators": [ + { + "kind": "fieldDiscriminatorNode", + "name": "discriminator", + "offset": 0 + } + ] } ], "definedTypes": [ From 2783e8f17d7370b87459c810dc3a5af160f62a78 Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Wed, 26 Nov 2025 11:05:54 +0100 Subject: [PATCH 10/39] Add js COption serialization helper --- clients/js-legacy/src/serialization.ts | 38 +++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/clients/js-legacy/src/serialization.ts b/clients/js-legacy/src/serialization.ts index 5de38e0f5..669eaffd9 100644 --- a/clients/js-legacy/src/serialization.ts +++ b/clients/js-legacy/src/serialization.ts @@ -1,5 +1,5 @@ import { Layout } from '@solana/buffer-layout'; -import { publicKey } from '@solana/buffer-layout-utils'; +import { publicKey, u64 } from '@solana/buffer-layout-utils'; import type { PublicKey } from '@solana/web3.js'; export class COptionPublicKeyLayout extends Layout { @@ -37,3 +37,39 @@ export class COptionPublicKeyLayout extends Layout { throw new RangeError('Buffer must be provided'); } } + +export class COptionU64Layout extends Layout { + private u64Layout: Layout; + + constructor(property?: string | undefined) { + super(-1, property); + this.u64Layout = u64(); + } + + decode(buffer: Uint8Array, offset: number = 0): bigint | null { + const option = buffer[offset]; + if (option === 0) { + return null; + } + return this.u64Layout.decode(buffer, offset + 1); + } + + encode(src: bigint | null, buffer: Uint8Array, offset: number = 0): number { + if (src === null) { + buffer[offset] = 0; + return 1; + } else { + buffer[offset] = 1; + this.u64Layout.encode(src, buffer, offset + 1); + return 9; + } + } + + getSpan(buffer?: Uint8Array, offset: number = 0): number { + if (buffer) { + const option = buffer[offset]; + return option === 0 ? 1 : 1 + this.u64Layout.span; + } + throw new RangeError('Buffer must be provided'); + } +} From 5a1672f0777ae79eee78ae4b346f843f0baa0dad Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Wed, 26 Nov 2025 11:08:13 +0100 Subject: [PATCH 11/39] Add unwrap lamports js action & instruction --- clients/js-legacy/src/actions/index.ts | 1 + .../js-legacy/src/actions/unwrapLamports.ts | 40 +++++ clients/js-legacy/src/instructions/types.ts | 1 + .../src/instructions/unwrapLamports.ts | 156 ++++++++++++++++++ 4 files changed, 198 insertions(+) create mode 100644 clients/js-legacy/src/actions/unwrapLamports.ts create mode 100644 clients/js-legacy/src/instructions/unwrapLamports.ts diff --git a/clients/js-legacy/src/actions/index.ts b/clients/js-legacy/src/actions/index.ts index 03f24f25d..88781bb28 100644 --- a/clients/js-legacy/src/actions/index.ts +++ b/clients/js-legacy/src/actions/index.ts @@ -23,3 +23,4 @@ export * from './thawAccount.js'; export * from './transfer.js'; export * from './transferChecked.js'; export * from './uiAmountToAmount.js'; +export * from './unwrapLamports.js'; diff --git a/clients/js-legacy/src/actions/unwrapLamports.ts b/clients/js-legacy/src/actions/unwrapLamports.ts new file mode 100644 index 000000000..0c92ed7fe --- /dev/null +++ b/clients/js-legacy/src/actions/unwrapLamports.ts @@ -0,0 +1,40 @@ +import type { ConfirmOptions, Connection, PublicKey, Signer, TransactionSignature } from '@solana/web3.js'; +import { sendAndConfirmTransaction, Transaction } from '@solana/web3.js'; +import { TOKEN_2022_PROGRAM_ID } from '../constants.js'; +import { getSigners } from './internal.js'; +import { createUnwrapLamportsInstruction } from '../instructions/unwrapLamports.js'; + +/** + * Unwrap lamports to an account + * + * @param connection Connection to use + * @param payer Payer of the transaction fees + * @param source Native source account + * @param destination Account receiving the lamports + * @param owner Owner of the source account + * @param amount Amount of lamports to unwrap + * @param multiSigners Signing accounts if `authority` is a multisig + * @param confirmOptions Options for confirming the transaction + * @param programId SPL Token program account + * + * @return Signature of the confirmed transaction + */ +export async function unwrapLamports( + connection: Connection, + payer: Signer, + source: PublicKey, + destination: PublicKey, + owner: Signer | PublicKey, + amount: bigint | null, + multiSigners: Signer[] = [], + confirmOptions?: ConfirmOptions, + programId = TOKEN_2022_PROGRAM_ID, +): Promise { + const [ownerPublicKey, signers] = getSigners(owner, multiSigners); + + const transaction = new Transaction().add( + createUnwrapLamportsInstruction(source, destination, ownerPublicKey, amount, multiSigners, programId), + ); + + return await sendAndConfirmTransaction(connection, transaction, [payer, ...signers], confirmOptions); +} diff --git a/clients/js-legacy/src/instructions/types.ts b/clients/js-legacy/src/instructions/types.ts index 7a93b7c8b..3befcfa7e 100644 --- a/clients/js-legacy/src/instructions/types.ts +++ b/clients/js-legacy/src/instructions/types.ts @@ -45,4 +45,5 @@ export enum TokenInstruction { // ConfidentialMintBurnExtension = 42, ScaledUiAmountExtension = 43, PausableExtension = 44, + UnwrapLamports = 45, } diff --git a/clients/js-legacy/src/instructions/unwrapLamports.ts b/clients/js-legacy/src/instructions/unwrapLamports.ts new file mode 100644 index 000000000..43f6bd186 --- /dev/null +++ b/clients/js-legacy/src/instructions/unwrapLamports.ts @@ -0,0 +1,156 @@ +import { struct, u8 } from '@solana/buffer-layout'; +import type { AccountMeta, PublicKey, Signer } from '@solana/web3.js'; +import { TransactionInstruction } from '@solana/web3.js'; +import { TOKEN_PROGRAM_ID } from '../constants.js'; +import { + TokenInvalidInstructionDataError, + TokenInvalidInstructionKeysError, + TokenInvalidInstructionProgramError, + TokenInvalidInstructionTypeError, +} from '../errors.js'; +import { addSigners } from './internal.js'; +import { TokenInstruction } from './types.js'; +import { COptionU64Layout } from '../serialization.js'; + +/** TODO: docs */ +export interface UnwrapLamportsInstructionData { + instruction: TokenInstruction.UnwrapLamports; + amount: bigint | null; +} + +/** TODO: docs */ +export const unwrapLamportsInstructionData = struct([ + u8('instruction'), + new COptionU64Layout('amount'), +]); + +/** + * Construct a UnwrapLamports instruction + * + * @param source Native source account + * @param destination Account receiving the lamports + * @param owner Owner of the source account + * @param amount Amount of lamports to unwrap + * @param multiSigners Signing accounts if `authority` is a multisig + * @param programId SPL Token program account + * + * @return Instruction to add to a transaction + */ +export function createUnwrapLamportsInstruction( + source: PublicKey, + destination: PublicKey, + owner: PublicKey, + amount: bigint | null, + multiSigners: (Signer | PublicKey)[] = [], + programId: PublicKey, +): TransactionInstruction { + const keys = addSigners( + [ + { pubkey: source, isSigner: false, isWritable: true }, + { pubkey: destination, isSigner: false, isWritable: true }, + ], + owner, + multiSigners, + ); + + const data = Buffer.alloc(10); // worst-case + unwrapLamportsInstructionData.encode( + { + instruction: TokenInstruction.UnwrapLamports, + amount, + }, + data, + ); + + return new TransactionInstruction({ keys, programId, data }); +} + +/** A decoded, valid UnwrapLamports instruction */ +export interface DecodedUnwrapLamportsInstruction { + programId: PublicKey; + keys: { + source: AccountMeta; + destination: AccountMeta; + owner: AccountMeta; + multiSigners: AccountMeta[]; + }; + data: { + instruction: TokenInstruction.UnwrapLamports; + amount: bigint | null; + }; +} + +/** + * Decode a UnwrapLamports instruction and validate it + * + * @param instruction Transaction instruction to decode + * @param programId SPL Token program account + * + * @return Decoded, valid instruction + */ +export function decodeUnwrapLamportsInstruction( + instruction: TransactionInstruction, + programId: PublicKey, +): DecodedUnwrapLamportsInstruction { + if (!instruction.programId.equals(programId)) throw new TokenInvalidInstructionProgramError(); + if (instruction.data.length !== unwrapLamportsInstructionData.span) throw new TokenInvalidInstructionDataError(); + + const { + keys: { source, destination, owner, multiSigners }, + data, + } = decodeUnwrapLamportsInstructionUnchecked(instruction); + if (data.instruction !== TokenInstruction.UnwrapLamports) throw new TokenInvalidInstructionTypeError(); + if (!source || !destination || !owner) throw new TokenInvalidInstructionKeysError(); + + // TODO: key checks? + + return { + programId, + keys: { + source, + destination, + owner, + multiSigners, + }, + data, + }; +} + +/** A decoded, non-validated UnwrapLamports instruction */ +export interface DecodedUnwrapLamportsInstructionUnchecked { + programId: PublicKey; + keys: { + source: AccountMeta | undefined; + destination: AccountMeta | undefined; + owner: AccountMeta | undefined; + multiSigners: AccountMeta[]; + }; + data: { + instruction: number; + amount: bigint | null; + }; +} + +/** + * Decode a UnwrapLamports instruction without validating it + * + * @param instruction Transaction instruction to decode + * + * @return Decoded, non-validated instruction + */ +export function decodeUnwrapLamportsInstructionUnchecked({ + programId, + keys: [source, destination, owner, ...multiSigners], + data, +}: TransactionInstruction): DecodedUnwrapLamportsInstructionUnchecked { + return { + programId, + keys: { + source, + destination, + owner, + multiSigners, + }, + data: unwrapLamportsInstructionData.decode(data), + }; +} From 3f4e9514c2ee239ce4ba4b93af82991a33c2dcba Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Wed, 26 Nov 2025 11:13:55 +0100 Subject: [PATCH 12/39] Add unwrap lamports js e2e tests --- .../test/e2e-2022/unwrapLamports.test.ts | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 clients/js-legacy/test/e2e-2022/unwrapLamports.test.ts diff --git a/clients/js-legacy/test/e2e-2022/unwrapLamports.test.ts b/clients/js-legacy/test/e2e-2022/unwrapLamports.test.ts new file mode 100644 index 000000000..a68420b8c --- /dev/null +++ b/clients/js-legacy/test/e2e-2022/unwrapLamports.test.ts @@ -0,0 +1,104 @@ +import type { Connection, Signer } from '@solana/web3.js'; +import { PublicKey } from '@solana/web3.js'; +import { Keypair } from '@solana/web3.js'; + +import { + getMint, + getAccount, + createWrappedNativeAccount, + NATIVE_MINT_2022, + createNativeMint, + getAccountLen, + ExtensionType, +} from '../../src'; + +import { TEST_PROGRAM_ID, newAccountWithLamports, getConnection } from '../common'; +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { unwrapLamports } from '../../src/actions/unwrapLamports'; +use(chaiAsPromised); + +describe('unwrapLamports', () => { + let connection: Connection; + let payer: Signer; + let owner: Keypair; + let account1: PublicKey; + let account2: PublicKey; + let balance: number; + before(async () => { + connection = await getConnection(); + payer = await newAccountWithLamports(connection, 1500000000); + + try { + await getMint(connection, NATIVE_MINT_2022, undefined, TEST_PROGRAM_ID); + } catch (err) { + // would throw an error if it doesn't exist + await createNativeMint(connection, payer, undefined, NATIVE_MINT_2022, TEST_PROGRAM_ID); + } + }); + beforeEach(async () => { + owner = Keypair.generate(); + balance = 500000000; + account1 = await createWrappedNativeAccount( + connection, + payer, + owner.publicKey, + balance, + undefined, + undefined, + TEST_PROGRAM_ID, + NATIVE_MINT_2022, + ); + account2 = PublicKey.unique(); + }); + it('unwrapLamports with Some', async () => { + let amount = balance / 2; + await unwrapLamports( + connection, + payer, + account1, + account2, + owner, + BigInt(amount), + [], + undefined, + TEST_PROGRAM_ID, + ); + + const destLamports = await connection.getBalance(account2); + expect(BigInt(destLamports)).to.eql(BigInt(amount)); + + balance = balance - amount; + + const sourceAccountInfo = await getAccount(connection, account1, undefined, TEST_PROGRAM_ID); + expect(sourceAccountInfo.amount).to.eql(BigInt(balance)); + + amount = balance + 1; + expect( + unwrapLamports( + connection, + payer, + account1, + account2, + owner, + BigInt(amount), + [], + undefined, + TEST_PROGRAM_ID, + ), + ).to.be.rejectedWith(Error); + }); + it('unwrapLamports with None', async () => { + const amount = null; + await unwrapLamports(connection, payer, account1, account2, owner, amount, [], undefined, TEST_PROGRAM_ID); + + const wrappedAccountSpace = getAccountLen([ExtensionType.ImmutableOwner]); // source account is an ata + const wrappedAccountLamports = await connection.getMinimumBalanceForRentExemption(wrappedAccountSpace); + + const destLamports = await connection.getBalance(account2); + expect(destLamports).to.eql(balance + wrappedAccountLamports); + + const sourceLamports = await connection.getBalance(account1); + expect(sourceLamports).to.eql(0); + }); +}); From 3a48e1d9791bfe1528846bac347fc7b22651e6ee Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Wed, 26 Nov 2025 11:19:33 +0100 Subject: [PATCH 13/39] Add unwrap lamports rust-legacy processor --- clients/rust-legacy/src/token.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/clients/rust-legacy/src/token.rs b/clients/rust-legacy/src/token.rs index e3a845cad..dd0c38631 100644 --- a/clients/rust-legacy/src/token.rs +++ b/clients/rust-legacy/src/token.rs @@ -1578,6 +1578,32 @@ where Ok(instructions) } + /// Unwrap lamports from native account + pub async fn unwrap_lamports( + &self, + source: &Pubkey, + destination: &Pubkey, + authority: &Pubkey, + amount: Option, + signing_keypairs: &S, + ) -> TokenResult { + let signing_pubkeys = signing_keypairs.pubkeys(); + let multisig_signers = self.get_multisig_signers(authority, &signing_pubkeys); + + self.process_ixs( + &[instruction::unwrap_lamports( + &self.program_id, + source, + destination, + authority, + &multisig_signers, + amount, + )?], + signing_keypairs, + ) + .await + } + /// Sync native account lamports pub async fn sync_native(&self, account: &Pubkey) -> TokenResult { self.process_ixs::<[&dyn Signer; 0]>( From ae8215b5bb4da5ac5beef389a40f30b1eea983c4 Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Wed, 26 Nov 2025 11:22:02 +0100 Subject: [PATCH 14/39] Updated rust-legacy cpi guard helpers & tests --- clients/rust-legacy/tests/cpi_guard.rs | 142 +++++++++++++++++++++++-- 1 file changed, 132 insertions(+), 10 deletions(-) diff --git a/clients/rust-legacy/tests/cpi_guard.rs b/clients/rust-legacy/tests/cpi_guard.rs index 3a4f02d1a..a6b03a1c8 100644 --- a/clients/rust-legacy/tests/cpi_guard.rs +++ b/clients/rust-legacy/tests/cpi_guard.rs @@ -1,13 +1,14 @@ mod program_test; use { program_test::{keypair_clone, TestContext, TokenContext}, + solana_program_pack::Pack, solana_program_test::{ tokio::{self, sync::Mutex}, ProgramTest, }, solana_sdk::{ - instruction::InstructionError, pubkey::Pubkey, signature::Signer, signer::keypair::Keypair, - transaction::TransactionError, transport::TransportError, + instruction::InstructionError, pubkey::Pubkey, rent::Rent, signature::Signer, + signer::keypair::Keypair, transaction::TransactionError, transport::TransportError, }, spl_instruction_padding_interface::instruction::wrap_instruction, spl_token_2022_interface::{ @@ -17,6 +18,7 @@ use { BaseStateWithExtensions, ExtensionType, }, instruction::{self, AuthorityType}, + state::Account, }, spl_token_client::{ client::ProgramBanksClientProcessTransaction, @@ -46,10 +48,14 @@ async fn make_context() -> TestContext { let program_context = program_test.start_with_context().await; let program_context = Arc::new(Mutex::new(program_context)); - let mut test_context = TestContext { + TestContext { context: program_context, token_context: None, - }; + } +} + +async fn make_context_with_new_mint() -> TestContext { + let mut test_context = make_context().await; test_context.init_token_with_mint(vec![]).await.unwrap(); let token_context = test_context.token_context.as_ref().unwrap(); @@ -73,6 +79,37 @@ async fn make_context() -> TestContext { test_context } +async fn make_context_with_native_mint(amount: u64) -> TestContext { + let mut test_context = make_context().await; + + test_context.init_token_with_native_mint().await.unwrap(); + let token_context = test_context.token_context.as_ref().unwrap(); + + token_context + .token + .wrap_with_mutable_ownership( + &token_context.alice.pubkey(), + &token_context.alice.pubkey(), + amount, + &[&token_context.alice], + ) + .await + .unwrap(); + + token_context + .token + .wrap_with_mutable_ownership( + &token_context.bob.pubkey(), + &token_context.bob.pubkey(), + amount, + &[&token_context.bob], + ) + .await + .unwrap(); + + test_context +} + fn client_error(token_error: TokenError) -> TokenClientError { TokenClientError::Client(Box::new(TransportError::TransactionError( TransactionError::InstructionError(0, InstructionError::Custom(token_error as u32)), @@ -81,7 +118,7 @@ fn client_error(token_error: TokenError) -> TokenClientError { #[tokio::test] async fn test_cpi_guard_enable_disable() { - let context = make_context().await; + let context = make_context_with_new_mint().await; let TokenContext { token, alice, bob, .. } = context.token_context.unwrap(); @@ -185,7 +222,7 @@ async fn test_cpi_guard_enable_disable() { #[tokio::test] async fn test_cpi_guard_transfer() { - let context = make_context().await; + let context = make_context_with_new_mint().await; let TokenContext { token, token_unchecked, @@ -333,7 +370,7 @@ async fn test_cpi_guard_transfer() { #[tokio::test] async fn test_cpi_guard_burn() { - let context = make_context().await; + let context = make_context_with_new_mint().await; let TokenContext { token, token_unchecked, @@ -473,7 +510,7 @@ async fn test_cpi_guard_burn() { #[tokio::test] async fn test_cpi_guard_approve() { - let context = make_context().await; + let context = make_context_with_new_mint().await; let TokenContext { token, token_unchecked, @@ -565,6 +602,91 @@ async fn test_cpi_guard_approve() { } } +#[tokio::test] +async fn test_cpi_guard_unwrap_lamports() { + let mut amount = 100; + let total_amount = amount + Rent::default().minimum_balance(Account::get_packed_len()); + + let context = make_context_with_native_mint(total_amount).await; + let TokenContext { + token, alice, bob, .. + } = context.token_context.unwrap(); + + let mk_unwrap_lamports = [wrap_instruction( + spl_instruction_padding_interface::id(), + instruction::unwrap_lamports( + &spl_token_2022_interface::id(), + &alice.pubkey(), + &bob.pubkey(), + &alice.pubkey(), + &[], + Some(1), + ) + .unwrap(), + vec![], + 0, + ) + .unwrap()]; + + token + .reallocate( + &alice.pubkey(), + &alice.pubkey(), + &[ExtensionType::CpiGuard], + &[&alice], + ) + .await + .unwrap(); + + token + .enable_cpi_guard(&alice.pubkey(), &alice.pubkey(), &[&alice]) + .await + .unwrap(); + + token.sync_native(&alice.pubkey()).await.unwrap(); + + // unwrap lamports works normally with cpi guard enabled + token + .unwrap_lamports( + &alice.pubkey(), + &bob.pubkey(), + &alice.pubkey(), + Some(1), + &[&alice], + ) + .await + .unwrap(); + amount -= 1; + + let alice_state = token.get_account_info(&alice.pubkey()).await.unwrap(); + assert_eq!(alice_state.base.amount, amount); + + // user-auth cpi unwrap lamport with cpi guard doesn't work + let error = token + .process_ixs(&mk_unwrap_lamports, &[&alice]) + .await + .unwrap_err(); + assert_eq!(error, client_error(TokenError::CpiGuardTransferBlocked)); + + let alice_state = token.get_account_info(&alice.pubkey()).await.unwrap(); + assert_eq!(alice_state.base.amount, amount); + + // unwrap lamports still works through cpi with cpi guard off + token + .disable_cpi_guard(&alice.pubkey(), &alice.pubkey(), &[&alice]) + .await + .unwrap(); + + token + .process_ixs(&mk_unwrap_lamports, &[&alice]) + .await + .unwrap(); + amount -= 1; + + let alice_state = token.get_account_info(&alice.pubkey()).await.unwrap(); + assert_eq!(alice_state.base.amount, amount); +} + async fn make_close_test_account( token: &Token, owner: &S, @@ -604,7 +726,7 @@ async fn make_close_test_account( #[tokio::test] async fn test_cpi_guard_close_account() { - let context = make_context().await; + let context = make_context_with_new_mint().await; let TokenContext { token, alice, bob, .. } = context.token_context.unwrap(); @@ -689,7 +811,7 @@ enum SetAuthTest { #[tokio::test] async fn test_cpi_guard_set_authority() { - let context = make_context().await; + let context = make_context_with_new_mint().await; let TokenContext { token, alice, bob, .. } = context.token_context.unwrap(); From 8a2d9aa9301be45736633d743a7e4c699e9c5d37 Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Wed, 26 Nov 2025 11:23:51 +0100 Subject: [PATCH 15/39] Added unwrap lamport rust-legacy tests --- clients/rust-legacy/tests/unwrap_lamports.rs | 338 +++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 clients/rust-legacy/tests/unwrap_lamports.rs diff --git a/clients/rust-legacy/tests/unwrap_lamports.rs b/clients/rust-legacy/tests/unwrap_lamports.rs new file mode 100644 index 000000000..4f17990b3 --- /dev/null +++ b/clients/rust-legacy/tests/unwrap_lamports.rs @@ -0,0 +1,338 @@ +mod program_test; +use { + program_test::{TestContext, TokenContext}, + solana_program_test::tokio, + solana_sdk::{ + instruction::InstructionError, signature::Signer, signer::keypair::Keypair, + transaction::TransactionError, transport::TransportError, + }, + spl_token_2022_interface::error::TokenError, + spl_token_client::token::TokenError as TokenClientError, +}; + +#[derive(PartialEq)] +enum TestMode { + Regular, + WithImmutableOwner, +} + +async fn run_basic_unwrap_lamports(context: TestContext, test_mode: TestMode) { + let TokenContext { + token, alice, bob, .. + } = context.token_context.unwrap(); + + let amount = 1000000000; + + let alice_account = Keypair::new(); + match test_mode { + TestMode::WithImmutableOwner => { + token + .wrap( + &alice_account.pubkey(), + &alice.pubkey(), + amount, + &[&alice_account], + ) + .await + .unwrap(); + } + TestMode::Regular => { + token + .wrap_with_mutable_ownership( + &alice_account.pubkey(), + &alice.pubkey(), + amount, + &[&alice_account], + ) + .await + .unwrap(); + } + } + let alice_account = alice_account.pubkey(); + let bob_account = Keypair::new(); + match test_mode { + TestMode::WithImmutableOwner => { + token + .wrap( + &bob_account.pubkey(), + &bob.pubkey(), + amount, + &[&bob_account], + ) + .await + .unwrap(); + } + TestMode::Regular => { + token + .wrap_with_mutable_ownership( + &bob_account.pubkey(), + &bob.pubkey(), + amount, + &[&bob_account], + ) + .await + .unwrap(); + } + } + let bob_account = bob_account.pubkey(); + + // unwrap Some(1) lamports is ok + token + .unwrap_lamports( + &alice_account, + &bob_account, + &alice.pubkey(), + Some(1), + &[&alice], + ) + .await + .unwrap(); + + // unwrap too much lamports is not ok + let error = token + .unwrap_lamports( + &alice_account, + &bob_account, + &alice.pubkey(), + Some(amount), + &[&alice], + ) + .await + .unwrap_err(); + assert_eq!( + error, + TokenClientError::Client(Box::new(TransportError::TransactionError( + TransactionError::InstructionError( + 0, + InstructionError::Custom(TokenError::InsufficientFunds as u32) + ) + ))) + ); + + // wrong signer + let error = token + .unwrap_lamports( + &alice_account, + &bob_account, + &bob.pubkey(), + Some(1), + &[&bob], + ) + .await + .unwrap_err(); + assert_eq!( + error, + TokenClientError::Client(Box::new(TransportError::TransactionError( + TransactionError::InstructionError( + 0, + InstructionError::Custom(TokenError::OwnerMismatch as u32) + ) + ))) + ); + + // unwrap None lamports is ok + token + .unwrap_lamports( + &alice_account, + &bob_account, + &alice.pubkey(), + None, + &[&alice], + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn basic() { + let mut context = TestContext::new().await; + context.init_token_with_native_mint().await.unwrap(); + run_basic_unwrap_lamports(context, TestMode::Regular).await; +} + +#[tokio::test] +async fn basic_with_extensions() { + let mut context = TestContext::new().await; + context.init_token_with_native_mint().await.unwrap(); + run_basic_unwrap_lamports(context, TestMode::WithImmutableOwner).await; +} + +async fn run_self_unwrap_lamports(context: TestContext, test_mode: TestMode) { + let TokenContext { token, alice, .. } = context.token_context.unwrap(); + + let amount = 1000000000; + + let alice_account = Keypair::new(); + match test_mode { + TestMode::WithImmutableOwner => { + token + .wrap( + &alice_account.pubkey(), + &alice.pubkey(), + amount, + &[&alice_account], + ) + .await + .unwrap(); + } + TestMode::Regular => { + token + .wrap_with_mutable_ownership( + &alice_account.pubkey(), + &alice.pubkey(), + amount, + &[&alice_account], + ) + .await + .unwrap(); + } + } + let alice_account = alice_account.pubkey(); + + // unwrap Some(1) lamports is ok + token + .unwrap_lamports( + &alice_account, + &alice_account, + &alice.pubkey(), + Some(1), + &[&alice], + ) + .await + .unwrap(); + + // unwrap too much lamports is not ok + let error = token + .unwrap_lamports( + &alice_account, + &alice_account, + &alice.pubkey(), + Some(amount), + &[&alice], + ) + .await + .unwrap_err(); + assert_eq!( + error, + TokenClientError::Client(Box::new(TransportError::TransactionError( + TransactionError::InstructionError( + 0, + InstructionError::Custom(TokenError::InsufficientFunds as u32) + ) + ))) + ); + + // unwrap None lamports is ok + token + .unwrap_lamports( + &alice_account, + &alice_account, + &alice.pubkey(), + None, + &[&alice], + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn self_unwrap_lamports() { + let mut context = TestContext::new().await; + context.init_token_with_native_mint().await.unwrap(); + run_self_unwrap_lamports(context, TestMode::Regular).await; +} + +#[tokio::test] +async fn self_unwrap_lamports_with_extension() { + let mut context = TestContext::new().await; + context.init_token_with_native_mint().await.unwrap(); + run_basic_unwrap_lamports(context, TestMode::WithImmutableOwner).await; +} + +async fn run_self_owned_unwrap_lamports(context: TestContext, test_mode: TestMode) { + let TokenContext { + token, alice, bob, .. + } = context.token_context.unwrap(); + + let amount = 1000000000; + + match test_mode { + TestMode::WithImmutableOwner => { + token + .wrap(&alice.pubkey(), &alice.pubkey(), amount, &[&alice]) + .await + .unwrap(); + } + TestMode::Regular => { + token + .wrap_with_mutable_ownership(&alice.pubkey(), &alice.pubkey(), amount, &[&alice]) + .await + .unwrap(); + } + } + let alice_account = alice.pubkey(); + let bob_account = Keypair::new(); + match test_mode { + TestMode::WithImmutableOwner => { + token + .wrap( + &bob_account.pubkey(), + &bob.pubkey(), + amount, + &[&bob_account], + ) + .await + .unwrap(); + } + TestMode::Regular => { + token + .wrap_with_mutable_ownership( + &bob_account.pubkey(), + &bob.pubkey(), + amount, + &[&bob_account], + ) + .await + .unwrap(); + } + } + let bob_account = bob_account.pubkey(); + + // unwrap Some(1) lamports is ok + token + .unwrap_lamports( + &alice_account, + &bob_account, + &alice.pubkey(), + Some(1), + &[&alice], + ) + .await + .unwrap(); + + // self unwrap None lamports is ok + token + .unwrap_lamports( + &alice_account, + &alice_account, + &alice.pubkey(), + None, + &[&alice], + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn self_owned() { + let mut context = TestContext::new().await; + context.init_token_with_native_mint().await.unwrap(); + run_self_owned_unwrap_lamports(context, TestMode::Regular).await; +} + +#[tokio::test] +async fn self_owned_with_extensions() { + let mut context = TestContext::new().await; + context.init_token_with_native_mint().await.unwrap(); + run_self_owned_unwrap_lamports(context, TestMode::WithImmutableOwner).await; +} From b756a7d17d28a5849ae6f3281b1fd1cc38e399c9 Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Wed, 26 Nov 2025 11:25:24 +0100 Subject: [PATCH 16/39] Remove spl-token-2022-interface patch --- Cargo.lock | 42 +++++++++++++++++++++++++++++++++++------- Cargo.toml | 1 - 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ff2cc9f7f..93bc5fe40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5761,7 +5761,7 @@ dependencies = [ "solana-sysvar", "solana-vote-interface", "spl-generic-token", - "spl-token-2022-interface", + "spl-token-2022-interface 2.1.0 (registry+https://github.com/rust-lang/crates.io-index)", "spl-token-group-interface", "spl-token-interface", "spl-token-metadata-interface", @@ -7987,7 +7987,7 @@ dependencies = [ "solana-vote", "solana-vote-program", "spl-generic-token", - "spl-token-2022-interface", + "spl-token-2022-interface 2.1.0 (registry+https://github.com/rust-lang/crates.io-index)", "spl-token-interface", "stream-cancel", "thiserror 2.0.17", @@ -9162,7 +9162,7 @@ dependencies = [ "solana-vote-interface", "spl-associated-token-account-interface", "spl-memo-interface", - "spl-token-2022-interface", + "spl-token-2022-interface 2.1.0 (registry+https://github.com/rust-lang/crates.io-index)", "spl-token-group-interface", "spl-token-interface", "spl-token-metadata-interface", @@ -9829,7 +9829,7 @@ dependencies = [ "spl-memo-interface", "spl-pod", "spl-tlv-account-resolution", - "spl-token-2022-interface", + "spl-token-2022-interface 2.1.0", "spl-token-confidential-transfer-ciphertext-arithmetic", "spl-token-confidential-transfer-proof-extraction", "spl-token-confidential-transfer-proof-generation", @@ -9863,7 +9863,7 @@ dependencies = [ "solana-sdk-ids", "solana-zk-sdk", "spl-pod", - "spl-token-2022-interface", + "spl-token-2022-interface 2.1.0", "spl-token-confidential-transfer-proof-extraction", "spl-token-confidential-transfer-proof-generation", "spl-token-group-interface", @@ -9875,6 +9875,34 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "spl-token-2022-interface" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcd81188211f4b3c8a5eba7fd534c7142f9dd026123b3472492782cc72f4dc6" +dependencies = [ + "arrayref", + "bytemuck", + "num-derive", + "num-traits", + "num_enum", + "solana-account-info", + "solana-instruction", + "solana-program-error", + "solana-program-option", + "solana-program-pack", + "solana-pubkey", + "solana-sdk-ids", + "solana-zk-sdk", + "spl-pod", + "spl-token-confidential-transfer-proof-extraction", + "spl-token-confidential-transfer-proof-generation", + "spl-token-group-interface", + "spl-token-metadata-interface", + "spl-type-length-value", + "thiserror 2.0.17", +] + [[package]] name = "spl-token-cli" version = "5.5.0" @@ -9907,7 +9935,7 @@ dependencies = [ "spl-memo-interface", "spl-pod", "spl-token-2022", - "spl-token-2022-interface", + "spl-token-2022-interface 2.1.0", "spl-token-client", "spl-token-confidential-transfer-proof-generation", "spl-token-group-interface", @@ -9959,7 +9987,7 @@ dependencies = [ "spl-record", "spl-tlv-account-resolution", "spl-token-2022", - "spl-token-2022-interface", + "spl-token-2022-interface 2.1.0", "spl-token-client", "spl-token-confidential-transfer-proof-extraction", "spl-token-confidential-transfer-proof-generation", diff --git a/Cargo.toml b/Cargo.toml index efa1b964d..0a545af36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,4 +62,3 @@ consolidate-commits = false [patch.crates-io] spl-token-confidential-transfer-proof-extraction = { path = "confidential/proof-extraction" } spl-token-confidential-transfer-proof-generation = { path = "confidential/proof-generation" } -spl-token-2022-interface = { path = "interface" } From e8151083016607d0897b0c87474402c3cc3435cf Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Wed, 26 Nov 2025 11:44:37 +0100 Subject: [PATCH 17/39] Remove memo code from unwrap lamports processor --- program/src/processor.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/program/src/processor.rs b/program/src/processor.rs index fd5d60209..54cd8c0ad 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -1703,15 +1703,6 @@ impl Processor { // if self_transfer || amount == 0 check_program_account(source_account_info.owner)?; - // This check MUST occur just before the amounts are manipulated - // to ensure self-transfers are fully validated - if self_transfer { - if memo_required(&source_account) { - check_previous_sibling_instruction_is_memo()?; - } - return Ok(()); - } - let source_starting_lamports = source_account_info.lamports(); **source_account_info.lamports.borrow_mut() = source_starting_lamports .checked_sub(amount) From 1f22cb6d4a8cd9b5a5ca654666d10dbe0471d55d Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Wed, 26 Nov 2025 11:51:24 +0100 Subject: [PATCH 18/39] Fix self transfer in unwrap lamports processor --- program/src/processor.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/program/src/processor.rs b/program/src/processor.rs index 54cd8c0ad..96654750c 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -1703,6 +1703,10 @@ impl Processor { // if self_transfer || amount == 0 check_program_account(source_account_info.owner)?; + if self_transfer { + return Ok(()); + } + let source_starting_lamports = source_account_info.lamports(); **source_account_info.lamports.borrow_mut() = source_starting_lamports .checked_sub(amount) From a290908670eb801202d37e6c7c46b03f072f1ee1 Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Wed, 26 Nov 2025 11:59:45 +0100 Subject: [PATCH 19/39] Refactor self transfer in unwrap lamports processor --- program/src/processor.rs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/program/src/processor.rs b/program/src/processor.rs index 96654750c..9515704ca 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -1703,21 +1703,19 @@ impl Processor { // if self_transfer || amount == 0 check_program_account(source_account_info.owner)?; - if self_transfer { - return Ok(()); - } - - let source_starting_lamports = source_account_info.lamports(); - **source_account_info.lamports.borrow_mut() = source_starting_lamports + if !self_transfer { + let source_starting_lamports = source_account_info.lamports(); + **source_account_info.lamports.borrow_mut() = source_starting_lamports .checked_sub(amount) .ok_or(TokenError::Overflow)?; - - let destination_starting_lamports = destination_account_info.lamports(); - **destination_account_info.lamports.borrow_mut() = destination_starting_lamports + + let destination_starting_lamports = destination_account_info.lamports(); + **destination_account_info.lamports.borrow_mut() = destination_starting_lamports .checked_add(amount) .ok_or(TokenError::Overflow)?; - - source_account.base.amount = remaining_amount.into(); + + source_account.base.amount = remaining_amount.into(); + } Ok(()) } From be2db1cf2c33cef72310486977cf35657dcbe5fe Mon Sep 17 00:00:00 2001 From: abelmarnk <151375781+abelmarnk@users.noreply.github.com> Date: Thu, 27 Nov 2025 19:21:30 +0100 Subject: [PATCH 20/39] Fix help description for UnwrapLamports amount argument Co-authored-by: Jon C --- clients/cli/src/clap_app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/cli/src/clap_app.rs b/clients/cli/src/clap_app.rs index f2be2b376..019095a38 100644 --- a/clients/cli/src/clap_app.rs +++ b/clients/cli/src/clap_app.rs @@ -1732,7 +1732,7 @@ pub fn app<'a>( .takes_value(true) .index(1) .required(true) - .help("Amount to unwrap, in tokens; accepts keyword ALL"), + .help("Amount to unwrap, in SOL; accepts keyword ALL"), ) .arg( Arg::with_name("recipient") From 59b290506806e598f6d25e5a7679865d76c9b5dd Mon Sep 17 00:00:00 2001 From: abelmarnk <151375781+abelmarnk@users.noreply.github.com> Date: Thu, 27 Nov 2025 19:23:41 +0100 Subject: [PATCH 21/39] Fix None lamport amount for UnwrapLamports Co-authored-by: Jon C --- program/src/processor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/program/src/processor.rs b/program/src/processor.rs index 9515704ca..188bcf894 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -1672,7 +1672,7 @@ impl Processor { PodCOption { option: PodCOption::::NONE, value: _, - } => (source_account_info.lamports(), 0), + } => (source_account.base.amount, 0), _ => return Err(ProgramError::InvalidInstructionData), }; From 86eae0a2eff33e4bb3fee857b974bc9be011e362 Mon Sep 17 00:00:00 2001 From: abelmarnk <151375781+abelmarnk@users.noreply.github.com> Date: Tue, 2 Dec 2025 08:14:01 +0100 Subject: [PATCH 22/39] Remove CPI-guard delegate allowance check Co-authored-by: Fernando Otero --- program/src/processor.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/program/src/processor.rs b/program/src/processor.rs index 188bcf894..f831d8681 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -1682,8 +1682,7 @@ impl Processor { let self_transfer = source_account_info.key == destination_account_info.key; if let Ok(cpi_guard) = source_account.get_extension::() { - if *authority_info.key == source_account.base.owner - && cpi_guard.lock_cpi.into() + if cpi_guard.lock_cpi.into() && in_cpi() { return Err(TokenError::CpiGuardTransferBlocked.into()); From f8be78c26056229813167fc2684d161ab704c627 Mon Sep 17 00:00:00 2001 From: abelmarnk <151375781+abelmarnk@users.noreply.github.com> Date: Tue, 2 Dec 2025 12:34:59 +0100 Subject: [PATCH 23/39] Relax token program account check in instruction builder Co-authored-by: Jon C --- interface/src/instruction.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interface/src/instruction.rs b/interface/src/instruction.rs index 50f55c869..141e67d3a 100644 --- a/interface/src/instruction.rs +++ b/interface/src/instruction.rs @@ -2119,7 +2119,7 @@ pub fn unwrap_lamports( signer_pubkeys: &[&Pubkey], amount: Option, ) -> Result { - check_program_account(token_program_id)?; + check_spl_token_program_account(token_program_id)?; let amount = amount.into(); let data = TokenInstruction::UnwrapLamports { amount }.pack(); From 3ede2b772a668bfa8200df05cf24a9ec4bcf2ebf Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Tue, 2 Dec 2025 13:54:55 +0100 Subject: [PATCH 24/39] Racfactor and update processor for the self-transfer case --- program/src/processor.rs | 88 +++++++++++++++++++--------------------- 1 file changed, 42 insertions(+), 46 deletions(-) diff --git a/program/src/processor.rs b/program/src/processor.rs index f831d8681..f1b54c5e8 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -1672,7 +1672,7 @@ impl Processor { PodCOption { option: PodCOption::::NONE, value: _, - } => (source_account.base.amount, 0), + } => (source_account.base.amount.into(), 0), _ => return Err(ProgramError::InvalidInstructionData), }; @@ -1680,15 +1680,6 @@ impl Processor { return Err(TokenError::NonNativeNotSupported.into()); } - let self_transfer = source_account_info.key == destination_account_info.key; - if let Ok(cpi_guard) = source_account.get_extension::() { - if cpi_guard.lock_cpi.into() - && in_cpi() - { - return Err(TokenError::CpiGuardTransferBlocked.into()); - } - } - Self::validate_owner( program_id, &source_account.base.owner, @@ -1697,24 +1688,29 @@ impl Processor { account_info_iter.as_slice(), )?; - // Revisit this later to see if it's worth adding a check to reduce - // compute costs, ie: - // if self_transfer || amount == 0 - check_program_account(source_account_info.owner)?; + let self_transfer = source_account_info.key == destination_account_info.key; + if let Ok(cpi_guard) = source_account.get_extension::() { + if cpi_guard.lock_cpi.into() && in_cpi() { + return Err(TokenError::CpiGuardTransferBlocked.into()); + } + } - if !self_transfer { - let source_starting_lamports = source_account_info.lamports(); - **source_account_info.lamports.borrow_mut() = source_starting_lamports - .checked_sub(amount) - .ok_or(TokenError::Overflow)?; - - let destination_starting_lamports = destination_account_info.lamports(); - **destination_account_info.lamports.borrow_mut() = destination_starting_lamports - .checked_add(amount) - .ok_or(TokenError::Overflow)?; - + if amount == 0 { + check_program_account(source_account_info.owner)?; + } else { + if !self_transfer { + let source_starting_lamports = source_account_info.lamports(); + **source_account_info.lamports.borrow_mut() = source_starting_lamports + .checked_sub(amount) + .ok_or(TokenError::Overflow)?; + + let destination_starting_lamports = destination_account_info.lamports(); + **destination_account_info.lamports.borrow_mut() = destination_starting_lamports + .checked_add(amount) + .ok_or(TokenError::Overflow)?; + } source_account.base.amount = remaining_amount.into(); - } + } Ok(()) } @@ -4242,11 +4238,11 @@ mod tests { // 500 amount balance change... let account = Account::unpack_unchecked(&account_account.data).unwrap(); assert_eq!(account.amount, 0); - // 500 + account_minimum_balance() lamports balance change... - assert_eq!(account_account.lamports(), 0); + // 500 lamports balance change... + assert_eq!(account_account.lamports(), account_minimum_balance()); assert_eq!( account2_account.lamports(), - zero_space_rent_exempt_balance + 500 + 500 + account_minimum_balance() + zero_space_rent_exempt_balance + 500 + 500 ); } @@ -4322,14 +4318,14 @@ mod tests { // mint to account native_mint_to(&account_info, 1000).unwrap(); - // unwrap Some(1000) lamports + // unwrap Some(500) lamports let instruction = unwrap_lamports( &program_id, account_info.key, account_info.key, owner_info.key, &[], - Some(1000), + Some(500), ) .unwrap(); assert_eq!( @@ -4344,24 +4340,24 @@ mod tests { &instruction.data, ) ); - // no amount balance change... + // 500 amount balance change... let account = Account::unpack_unchecked(&account_info.try_borrow_data().unwrap()).unwrap(); - assert_eq!(account.amount, 1000); + assert_eq!(account.amount, 500); // no lamport balance change... assert_eq!(account_info.lamports(), 1000 + account_minimum_balance()); - // unwrap None lamports + // insufficient funds let instruction = unwrap_lamports( &program_id, account_info.key, account_info.key, owner_info.key, &[], - None, + Some(501), ) .unwrap(); assert_eq!( - Ok(()), + Err(TokenError::InsufficientFunds.into()), Processor::process( &instruction.program_id, &[ @@ -4372,11 +4368,6 @@ mod tests { &instruction.data, ) ); - // no amount balance change... - let account = Account::unpack_unchecked(&account_info.try_borrow_data().unwrap()).unwrap(); - assert_eq!(account.amount, 1000); - // no lamport balance change... - assert_eq!(account_info.lamports(), 1000 + account_minimum_balance()); // missing signer let mut owner_no_sign_info = owner_info.clone(); @@ -4386,7 +4377,7 @@ mod tests { account_info.key, owner_no_sign_info.key, &[], - Some(1000), + Some(500), ) .unwrap(); instruction.accounts[2].is_signer = false; @@ -4434,7 +4425,7 @@ mod tests { account_info.key, owner2_info.key, &[], - Some(1000), + Some(500), ) .unwrap(); assert_eq!( @@ -4450,18 +4441,18 @@ mod tests { ) ); - // insufficient funds + // unwrap None lamports let instruction = unwrap_lamports( &program_id, account_info.key, account_info.key, owner_info.key, &[], - Some(1001), + None, ) .unwrap(); assert_eq!( - Err(TokenError::InsufficientFunds.into()), + Ok(()), Processor::process( &instruction.program_id, &[ @@ -4472,6 +4463,11 @@ mod tests { &instruction.data, ) ); + // 500 amount balance change... + let account = Account::unpack_unchecked(&account_info.try_borrow_data().unwrap()).unwrap(); + assert_eq!(account.amount, 0); + // no lamport balance change... + assert_eq!(account_info.lamports(), 1000 + account_minimum_balance()); } #[test] From 1e047faacf0d0ab547579af1f3c3badeb5f47684 Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Tue, 2 Dec 2025 13:59:29 +0100 Subject: [PATCH 25/39] Add rust COptionU64 de-serialization helper --- interface/src/instruction.rs | 4 +-- interface/src/serialization.rs | 62 ++++++++++++++++++++++++++++++++ interface/tests/serialization.rs | 2 +- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/interface/src/instruction.rs b/interface/src/instruction.rs index 141e67d3a..79d1f9267 100644 --- a/interface/src/instruction.rs +++ b/interface/src/instruction.rs @@ -6,7 +6,7 @@ #[cfg(feature = "serde")] use { - crate::serialization::coption_fromstr, + crate::serialization::{coption_fromstr, coption_u64_fromval}, serde::{Deserialize, Serialize}, serde_with::{As, DisplayFromStr}, }; @@ -746,7 +746,7 @@ pub enum TokenInstruction<'a> { /// The amount of lamports to transfer. When an amount is /// not specified, the entire balance of the source account will be /// transferred. - #[cfg_attr(feature = "serde", serde(with = "coption_fromstr"))] + #[cfg_attr(feature = "serde", serde(with = "coption_u64_fromval"))] amount: COption, }, } diff --git a/interface/src/serialization.rs b/interface/src/serialization.rs index 99207f7ea..edf9eccea 100644 --- a/interface/src/serialization.rs +++ b/interface/src/serialization.rs @@ -76,6 +76,68 @@ pub mod coption_fromstr { } } +/// Helper function to serialize / deserialize a `COption` u64 value +pub mod coption_u64_fromval { + use { + serde::{ + de::{Error, Visitor}, + Deserializer, Serializer, + }, + solana_program_option::COption, + std::fmt, + }; + + /// Serialize u64 wrapped in `COption` + pub fn serialize(x: &COption, s: S) -> Result + where + S: Serializer, + { + match *x { + COption::Some(ref value) => s.serialize_some(value), + COption::None => s.serialize_none(), + } + } + + struct COptionU64Visitor {} + + impl<'de> Visitor<'de> for COptionU64Visitor { + type Value = COption; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a u64 type") + } + + fn visit_some(self, d: D) -> Result + where + D: Deserializer<'de>, + { + d.deserialize_u64(self) + } + + fn visit_u64(self, v: u64) -> Result + where + E: Error, + { + Ok(COption::Some(v)) + } + + fn visit_none(self) -> Result + where + E: Error, + { + Ok(COption::None) + } + } + + /// Deserialize u64 in `COption` + pub fn deserialize<'de, D>(d: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + d.deserialize_option(COptionU64Visitor {}) + } +} + /// Helper to serialize / deserialize `PodAeCiphertext` values pub mod aeciphertext_fromstr { use { diff --git a/interface/tests/serialization.rs b/interface/tests/serialization.rs index 4be3bf017..89a3bb83f 100644 --- a/interface/tests/serialization.rs +++ b/interface/tests/serialization.rs @@ -47,7 +47,7 @@ fn serde_instruction_coption_u64() { }; let serialized = serde_json::to_string(&inst).unwrap(); - assert_eq!(&serialized, "{\"unwrapLamports\":{\"amount\":\"1\"}}"); + assert_eq!(&serialized, "{\"unwrapLamports\":{\"amount\":1}}"); serde_json::from_str::(&serialized).unwrap(); } From 247f78dc581ae20927205ca6d7dabc3983cf4e0e Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Tue, 2 Dec 2025 14:01:58 +0100 Subject: [PATCH 26/39] Refactor and extend rust legacy tests --- clients/rust-legacy/tests/cpi_guard.rs | 8 +- clients/rust-legacy/tests/unwrap_lamports.rs | 100 +++++++++++++++++-- 2 files changed, 97 insertions(+), 11 deletions(-) diff --git a/clients/rust-legacy/tests/cpi_guard.rs b/clients/rust-legacy/tests/cpi_guard.rs index a6b03a1c8..c2c3061fd 100644 --- a/clients/rust-legacy/tests/cpi_guard.rs +++ b/clients/rust-legacy/tests/cpi_guard.rs @@ -612,7 +612,7 @@ async fn test_cpi_guard_unwrap_lamports() { token, alice, bob, .. } = context.token_context.unwrap(); - let mk_unwrap_lamports = [wrap_instruction( + let unwrap_lamports = [wrap_instruction( spl_instruction_padding_interface::id(), instruction::unwrap_lamports( &spl_token_2022_interface::id(), @@ -643,8 +643,6 @@ async fn test_cpi_guard_unwrap_lamports() { .await .unwrap(); - token.sync_native(&alice.pubkey()).await.unwrap(); - // unwrap lamports works normally with cpi guard enabled token .unwrap_lamports( @@ -663,7 +661,7 @@ async fn test_cpi_guard_unwrap_lamports() { // user-auth cpi unwrap lamport with cpi guard doesn't work let error = token - .process_ixs(&mk_unwrap_lamports, &[&alice]) + .process_ixs(&unwrap_lamports, &[&alice]) .await .unwrap_err(); assert_eq!(error, client_error(TokenError::CpiGuardTransferBlocked)); @@ -678,7 +676,7 @@ async fn test_cpi_guard_unwrap_lamports() { .unwrap(); token - .process_ixs(&mk_unwrap_lamports, &[&alice]) + .process_ixs(&unwrap_lamports, &[&alice]) .await .unwrap(); amount -= 1; diff --git a/clients/rust-legacy/tests/unwrap_lamports.rs b/clients/rust-legacy/tests/unwrap_lamports.rs index 4f17990b3..7cabacc2d 100644 --- a/clients/rust-legacy/tests/unwrap_lamports.rs +++ b/clients/rust-legacy/tests/unwrap_lamports.rs @@ -1,12 +1,15 @@ +#![allow(clippy::arithmetic_side_effects)] mod program_test; use { program_test::{TestContext, TokenContext}, + solana_program_pack::Pack, solana_program_test::tokio, solana_sdk::{ - instruction::InstructionError, signature::Signer, signer::keypair::Keypair, + instruction::InstructionError, rent::Rent, signature::Signer, signer::keypair::Keypair, transaction::TransactionError, transport::TransportError, }, - spl_token_2022_interface::error::TokenError, + spl_token_2022::extension::ExtensionType, + spl_token_2022_interface::{error::TokenError, state::Account}, spl_token_client::token::TokenError as TokenClientError, }; @@ -21,7 +24,16 @@ async fn run_basic_unwrap_lamports(context: TestContext, test_mode: TestMode) { token, alice, bob, .. } = context.token_context.unwrap(); - let amount = 1000000000; + let amount = 10000000000; + let account_space = match test_mode { + TestMode::Regular => Account::get_packed_len(), + TestMode::WithImmutableOwner => { + ExtensionType::try_calculate_account_len::(&[ExtensionType::ImmutableOwner]) + .unwrap() + } + }; + + let rent_exempt_lamports = Rent::default().minimum_balance(account_space); let alice_account = Keypair::new(); match test_mode { @@ -88,6 +100,17 @@ async fn run_basic_unwrap_lamports(context: TestContext, test_mode: TestMode) { .await .unwrap(); + let alice_account_account = token.get_account(alice_account).await.unwrap(); + let alice_account_token_account = token.get_account_info(&alice_account).await.unwrap(); + assert_eq!(alice_account_account.lamports, amount - 1); + assert_eq!( + alice_account_token_account.base.amount, + amount - (rent_exempt_lamports + 1) + ); + + let bob_account_account = token.get_account(bob_account).await.unwrap(); + assert_eq!(bob_account_account.lamports, amount + 1); + // unwrap too much lamports is not ok let error = token .unwrap_lamports( @@ -141,6 +164,17 @@ async fn run_basic_unwrap_lamports(context: TestContext, test_mode: TestMode) { ) .await .unwrap(); + + let alice_account_account = token.get_account(alice_account).await.unwrap(); + let alice_account_token_account = token.get_account_info(&alice_account).await.unwrap(); + assert_eq!(alice_account_account.lamports, rent_exempt_lamports); + assert_eq!(alice_account_token_account.base.amount, 0); + + let bob_account_account = token.get_account(bob_account).await.unwrap(); + assert_eq!( + bob_account_account.lamports, + amount + (amount - rent_exempt_lamports) + ); } #[tokio::test] @@ -160,11 +194,17 @@ async fn basic_with_extensions() { async fn run_self_unwrap_lamports(context: TestContext, test_mode: TestMode) { let TokenContext { token, alice, .. } = context.token_context.unwrap(); - let amount = 1000000000; + let amount = 10000000000; + let account_space; let alice_account = Keypair::new(); match test_mode { TestMode::WithImmutableOwner => { + account_space = ExtensionType::try_calculate_account_len::(&[ + ExtensionType::ImmutableOwner, + ]) + .unwrap(); + token .wrap( &alice_account.pubkey(), @@ -176,6 +216,8 @@ async fn run_self_unwrap_lamports(context: TestContext, test_mode: TestMode) { .unwrap(); } TestMode::Regular => { + account_space = Account::get_packed_len(); + token .wrap_with_mutable_ownership( &alice_account.pubkey(), @@ -187,6 +229,8 @@ async fn run_self_unwrap_lamports(context: TestContext, test_mode: TestMode) { .unwrap(); } } + let rent_exempt_lamports = Rent::default().minimum_balance(account_space); + let alice_account = alice_account.pubkey(); // unwrap Some(1) lamports is ok @@ -201,6 +245,14 @@ async fn run_self_unwrap_lamports(context: TestContext, test_mode: TestMode) { .await .unwrap(); + let alice_account_account = token.get_account(alice_account).await.unwrap(); + let alice_account_token_account = token.get_account_info(&alice_account).await.unwrap(); + assert_eq!(alice_account_account.lamports, amount); + assert_eq!( + alice_account_token_account.base.amount, + (amount - 1) - rent_exempt_lamports, + ); + // unwrap too much lamports is not ok let error = token .unwrap_lamports( @@ -233,6 +285,11 @@ async fn run_self_unwrap_lamports(context: TestContext, test_mode: TestMode) { ) .await .unwrap(); + + let alice_account_account = token.get_account(alice_account).await.unwrap(); + let alice_account_token_account = token.get_account_info(&alice_account).await.unwrap(); + assert_eq!(alice_account_account.lamports, amount); + assert_eq!(alice_account_token_account.base.amount, 0); } #[tokio::test] @@ -254,7 +311,16 @@ async fn run_self_owned_unwrap_lamports(context: TestContext, test_mode: TestMod token, alice, bob, .. } = context.token_context.unwrap(); - let amount = 1000000000; + let amount = 10000000000; + let account_space = match test_mode { + TestMode::Regular => Account::get_packed_len(), + TestMode::WithImmutableOwner => { + ExtensionType::try_calculate_account_len::(&[ExtensionType::ImmutableOwner]) + .unwrap() + } + }; + + let rent_exempt_lamports = Rent::default().minimum_balance(account_space); match test_mode { TestMode::WithImmutableOwner => { @@ -310,17 +376,39 @@ async fn run_self_owned_unwrap_lamports(context: TestContext, test_mode: TestMod .await .unwrap(); + let alice_account_account = token.get_account(alice_account).await.unwrap(); + let alice_account_token_account = token.get_account_info(&alice_account).await.unwrap(); + assert_eq!(alice_account_account.lamports, amount - 1); + assert_eq!( + alice_account_token_account.base.amount, + amount - (rent_exempt_lamports + 1) + ); + + let bob_account_account = token.get_account(bob_account).await.unwrap(); + assert_eq!(bob_account_account.lamports, amount + 1); + // self unwrap None lamports is ok token .unwrap_lamports( &alice_account, - &alice_account, + &bob_account, &alice.pubkey(), None, &[&alice], ) .await .unwrap(); + + let alice_account_account = token.get_account(alice_account).await.unwrap(); + let alice_account_token_account = token.get_account_info(&alice_account).await.unwrap(); + assert_eq!(alice_account_account.lamports, rent_exempt_lamports); + assert_eq!(alice_account_token_account.base.amount, 0); + + let bob_account_account = token.get_account(bob_account).await.unwrap(); + assert_eq!( + bob_account_account.lamports, + amount + (amount - rent_exempt_lamports) + ); } #[tokio::test] From 5be274d00f2ba042f45aeda9dc4e07ba804e83f1 Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Tue, 2 Dec 2025 14:07:46 +0100 Subject: [PATCH 27/39] Fix unwrap lamports js docs, imports & comments --- clients/js-legacy/src/actions/unwrapLamports.ts | 2 +- clients/js-legacy/src/instructions/unwrapLamports.ts | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/clients/js-legacy/src/actions/unwrapLamports.ts b/clients/js-legacy/src/actions/unwrapLamports.ts index 0c92ed7fe..5e5164433 100644 --- a/clients/js-legacy/src/actions/unwrapLamports.ts +++ b/clients/js-legacy/src/actions/unwrapLamports.ts @@ -13,7 +13,7 @@ import { createUnwrapLamportsInstruction } from '../instructions/unwrapLamports. * @param destination Account receiving the lamports * @param owner Owner of the source account * @param amount Amount of lamports to unwrap - * @param multiSigners Signing accounts if `authority` is a multisig + * @param multiSigners Signing accounts if `owner` is a multisig * @param confirmOptions Options for confirming the transaction * @param programId SPL Token program account * diff --git a/clients/js-legacy/src/instructions/unwrapLamports.ts b/clients/js-legacy/src/instructions/unwrapLamports.ts index 43f6bd186..e32246e6e 100644 --- a/clients/js-legacy/src/instructions/unwrapLamports.ts +++ b/clients/js-legacy/src/instructions/unwrapLamports.ts @@ -1,7 +1,6 @@ import { struct, u8 } from '@solana/buffer-layout'; import type { AccountMeta, PublicKey, Signer } from '@solana/web3.js'; import { TransactionInstruction } from '@solana/web3.js'; -import { TOKEN_PROGRAM_ID } from '../constants.js'; import { TokenInvalidInstructionDataError, TokenInvalidInstructionKeysError, @@ -31,7 +30,7 @@ export const unwrapLamportsInstructionData = struct Date: Tue, 2 Dec 2025 14:10:01 +0100 Subject: [PATCH 28/39] Fix instruction data check in unwrap lamports js instruction deconder --- clients/js-legacy/src/instructions/unwrapLamports.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/clients/js-legacy/src/instructions/unwrapLamports.ts b/clients/js-legacy/src/instructions/unwrapLamports.ts index e32246e6e..65f04dae1 100644 --- a/clients/js-legacy/src/instructions/unwrapLamports.ts +++ b/clients/js-legacy/src/instructions/unwrapLamports.ts @@ -92,7 +92,8 @@ export function decodeUnwrapLamportsInstruction( programId: PublicKey, ): DecodedUnwrapLamportsInstruction { if (!instruction.programId.equals(programId)) throw new TokenInvalidInstructionProgramError(); - if (instruction.data.length !== unwrapLamportsInstructionData.span) throw new TokenInvalidInstructionDataError(); + if (instruction.data.length !== unwrapLamportsInstructionData.getSpan(instruction.data)) + throw new TokenInvalidInstructionDataError(); const { keys: { source, destination, owner, multiSigners }, From fe8dc706115a07042cf468c78542459e112b63b6 Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Tue, 2 Dec 2025 14:13:28 +0100 Subject: [PATCH 29/39] Update js umwrap lamports test --- .../js-legacy/test/e2e-2022/unwrapLamports.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/clients/js-legacy/test/e2e-2022/unwrapLamports.test.ts b/clients/js-legacy/test/e2e-2022/unwrapLamports.test.ts index a68420b8c..328797fb7 100644 --- a/clients/js-legacy/test/e2e-2022/unwrapLamports.test.ts +++ b/clients/js-legacy/test/e2e-2022/unwrapLamports.test.ts @@ -1,6 +1,5 @@ import type { Connection, Signer } from '@solana/web3.js'; -import { PublicKey } from '@solana/web3.js'; -import { Keypair } from '@solana/web3.js'; +import { PublicKey, Keypair } from '@solana/web3.js'; import { getMint, @@ -70,8 +69,13 @@ describe('unwrapLamports', () => { balance = balance - amount; + const wrappedAccountSpace = getAccountLen([ExtensionType.ImmutableOwner]); // source account is an ata + const wrappedAccountLamports = await connection.getMinimumBalanceForRentExemption(wrappedAccountSpace); + const sourceAccountInfo = await getAccount(connection, account1, undefined, TEST_PROGRAM_ID); + const sourceLamports = await connection.getBalance(account1); expect(sourceAccountInfo.amount).to.eql(BigInt(balance)); + expect(sourceLamports).to.eql(wrappedAccountLamports + balance); amount = balance + 1; expect( @@ -96,9 +100,11 @@ describe('unwrapLamports', () => { const wrappedAccountLamports = await connection.getMinimumBalanceForRentExemption(wrappedAccountSpace); const destLamports = await connection.getBalance(account2); - expect(destLamports).to.eql(balance + wrappedAccountLamports); + expect(destLamports).to.eql(balance); + const sourceAccountInfo = await getAccount(connection, account1, undefined, TEST_PROGRAM_ID); const sourceLamports = await connection.getBalance(account1); - expect(sourceLamports).to.eql(0); + expect(sourceAccountInfo.amount).to.eql(0n); + expect(sourceLamports).to.eql(wrappedAccountLamports); }); }); From 4f355dd9f284bc7eeb760ab74daf96523205fbf2 Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Tue, 2 Dec 2025 14:17:25 +0100 Subject: [PATCH 30/39] Rename UnwrapLamports to UnwrapSol in cli parser --- clients/cli/src/clap_app.rs | 4 ++-- clients/cli/src/command.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/clients/cli/src/clap_app.rs b/clients/cli/src/clap_app.rs index 019095a38..c6d640c62 100644 --- a/clients/cli/src/clap_app.rs +++ b/clients/cli/src/clap_app.rs @@ -172,7 +172,7 @@ pub enum CommandName { UpdateUiAmountMultiplier, Pause, Resume, - UnwrapLamports, + UnwrapSol, } impl fmt::Display for CommandName { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { @@ -1723,7 +1723,7 @@ pub fn app<'a>( .offline_args(), ) .subcommand( - SubCommand::with_name(CommandName::UnwrapLamports.into()) + SubCommand::with_name(CommandName::UnwrapSol.into()) .about("Unwrap lamports from a SOL token account") .arg( Arg::with_name("amount") diff --git a/clients/cli/src/command.rs b/clients/cli/src/command.rs index 263de29d9..48ec666ed 100644 --- a/clients/cli/src/command.rs +++ b/clients/cli/src/command.rs @@ -4362,7 +4362,7 @@ pub async fn process_command( let account = pubkey_of_signer(arg_matches, "account", &mut wallet_manager).unwrap(); command_unwrap(config, wallet_address, account, bulk_signers).await } - (CommandName::UnwrapLamports, arg_matches) => { + (CommandName::UnwrapSol, arg_matches) => { let (owner_signer, owner) = config.signer_or_default(arg_matches, "owner", &mut wallet_manager); if config.multisigner_pubkeys.is_empty() { From f4f1026279efc096b80e943b22c6c9f1d6fd8519 Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Tue, 2 Dec 2025 14:20:41 +0100 Subject: [PATCH 31/39] Add allow-unfunded-recipient flag --- clients/cli/src/clap_app.rs | 6 ++++++ clients/cli/src/command.rs | 14 +++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/clients/cli/src/clap_app.rs b/clients/cli/src/clap_app.rs index c6d640c62..1bec49ed9 100644 --- a/clients/cli/src/clap_app.rs +++ b/clients/cli/src/clap_app.rs @@ -1759,6 +1759,12 @@ pub fn app<'a>( Defaults to the client keypair.", ), ) + .arg( + Arg::with_name("allow_unfunded_recipient") + .long("allow-unfunded-recipient") + .takes_value(false) + .help("Complete the transfer even if the recipient address is not funded") + ) .arg(multisig_signer_arg()) .nonce_args(true) .offline_args(), diff --git a/clients/cli/src/command.rs b/clients/cli/src/command.rs index 48ec666ed..fc1b4758d 100644 --- a/clients/cli/src/command.rs +++ b/clients/cli/src/command.rs @@ -2105,6 +2105,7 @@ async fn command_unwrap_lamports( source_owner: Pubkey, source_account: Option, destination_account: Option, + allow_unfunded_recipient: bool, bulk_signers: BulkSigners, ) -> CommandResult { let use_associated_account = source_account.is_none(); @@ -4374,7 +4375,18 @@ pub async fn process_command( let recipient = pubkey_of_signer(arg_matches, "recipient", &mut wallet_manager).unwrap(); - command_unwrap_lamports(config, amount, owner, source, recipient, bulk_signers).await + let allow_unfunded_recipient = arg_matches.is_present("allow_unfunded_recipient"); + + command_unwrap_lamports( + config, + amount, + owner, + source, + recipient, + allow_unfunded_recipient, + bulk_signers, + ) + .await } (CommandName::Approve, arg_matches) => { let (owner_signer, owner_address) = From 831ef27ee957ddb6510333bb998495d04a10c733 Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Tue, 2 Dec 2025 14:28:33 +0100 Subject: [PATCH 32/39] Update cli unwrap lamports test & add unwrap lamports multisig test --- clients/cli/tests/command.rs | 273 +++++++++++++++++++++++++++++++++-- 1 file changed, 259 insertions(+), 14 deletions(-) diff --git a/clients/cli/tests/command.rs b/clients/cli/tests/command.rs index 966cde092..57009db70 100644 --- a/clients/cli/tests/command.rs +++ b/clients/cli/tests/command.rs @@ -31,7 +31,7 @@ use { scaled_ui_amount::ScaledUiAmountConfig, transfer_fee::{TransferFeeAmount, TransferFeeConfig}, transfer_hook::TransferHook, - BaseStateWithExtensions, StateWithExtensionsOwned, + BaseStateWithExtensions, ExtensionType, StateWithExtensionsOwned, }, instruction::create_native_mint, solana_zk_sdk::encryption::pod::elgamal::PodElGamalPubkey, @@ -842,8 +842,9 @@ async fn accounts_with_owner(test_validator: &TestValidator, payer: &Keypair) { } async fn wrapped_sol(test_validator: &TestValidator, payer: &Keypair) { - // both tests use the same ata so they can't run together + // the tests use the same ata so they can't run together unwrap_lamports(test_validator, payer).await; + multisig_unwrap_lamports(test_validator, payer).await; wrap_unwrap_sol(test_validator, payer).await; } @@ -865,46 +866,290 @@ async fn unwrap_lamports(test_validator: &TestValidator, payer: &Keypair) { .await .unwrap(); - let wrapped_account = get_associated_token_address_with_program_id( + let funded_amount = spl_token_2022::ui_amount_to_amount(10.0, TEST_DECIMALS); + + let wrapped_address = get_associated_token_address_with_program_id( &payer.pubkey(), &native_mint, &config.program_id, ); - let new_account = Keypair::new(); + let new_address = Pubkey::new_unique(); + + // unwrap fails to unfunded recipient without flag + process_test_command( + &config, + payer, + &[ + "spl-token", + CommandName::UnwrapSol.into(), + "5.0", + &new_address.to_string(), + "--from", + &wrapped_address.to_string(), + ], + ) + .await + .unwrap_err(); + // with unfunded flag, unwrap goes through process_test_command( &config, payer, &[ "spl-token", - CommandName::UnwrapLamports.into(), + CommandName::UnwrapSol.into(), "5.0", - &new_account.pubkey().to_string(), + &new_address.to_string(), "--from", - &wrapped_account.to_string(), + &wrapped_address.to_string(), + "--allow-unfunded-recipient", ], ) .await .unwrap(); - let balance = config + + let amount = spl_token_2022::ui_amount_to_amount(5.0, TEST_DECIMALS); + + let account_space = + ExtensionType::try_calculate_account_len::(&[ExtensionType::ImmutableOwner]) + .unwrap(); + let rent_exempt_lamports = config + .rpc_client + .get_minimum_balance_for_rent_exemption(account_space) + .await + .unwrap(); + let zero_space_rent_exempt_lamports = config .rpc_client - .get_balance(&new_account.pubkey()) + .get_minimum_balance_for_rent_exemption(0) .await .unwrap(); - assert_eq!(balance, 5000000000); + let balance = config.rpc_client.get_balance(&new_address).await.unwrap(); + assert_eq!(balance, amount + zero_space_rent_exempt_lamports); // we fund the recipient first + + let wrapped_account = config + .rpc_client + .get_account(&wrapped_address) + .await + .unwrap(); + let wrapped_token_account = + StateWithExtensionsOwned::::unpack(wrapped_account.data).unwrap(); + assert_eq!(wrapped_account.lamports, funded_amount - amount); + assert_eq!( + wrapped_token_account.base.amount, + (funded_amount - amount) - rent_exempt_lamports + ); process_test_command( &config, payer, - &["spl-token", CommandName::UnwrapLamports.into(), "ALL"], + &["spl-token", CommandName::UnwrapSol.into(), "ALL"], ) .await .unwrap(); - config + + let wrapped_account = config .rpc_client - .get_account(&wrapped_account) + .get_account(&wrapped_address) .await - .unwrap_err(); + .unwrap(); + let wrapped_token_account = + StateWithExtensionsOwned::::unpack(wrapped_account.data).unwrap(); + assert_eq!(wrapped_account.lamports, rent_exempt_lamports); + assert_eq!(wrapped_token_account.base.amount, 0); + + // close the native account + process_test_command( + &config, + payer, + &[ + "spl-token", + CommandName::Close.into(), + "--address", + &wrapped_address.to_string(), + "--recipient", + &payer.pubkey().to_string(), + ], + ) + .await + .unwrap(); +} + +async fn multisig_unwrap_lamports(test_validator: &TestValidator, payer: &Keypair) { + let m = 3; + let n = 5u8; + + let (multisig_members, multisig_paths): (Vec<_>, Vec<_>) = + std::iter::once(clone_keypair(payer)) + .chain(std::iter::repeat_with(Keypair::new).take((n - 2) as usize)) + .map(|s| { + let keypair_file = NamedTempFile::new().unwrap(); + write_keypair_file(&s, &keypair_file).unwrap(); + (s.pubkey(), keypair_file) + }) + .unzip(); + let program_id = &spl_token_2022_interface::id(); + + let config = test_config_with_default_signer(test_validator, payer, program_id); + let native_mint = *Token::new_native( + config.program_client.clone(), + program_id, + config.fee_payer().unwrap().clone(), + ) + .get_address(); + + let multisig = Arc::new(Keypair::new()); + let multisig_pubkey = multisig.pubkey(); + + process_test_command( + &config, + payer, + &["spl-token", CommandName::Wrap.into(), "10.0"], + ) + .await + .unwrap(); + + let payer_wrapped_address = get_associated_token_address_with_program_id( + &payer.pubkey(), + &native_mint, + &config.program_id, + ); + + let multisig_wrapped_address = get_associated_token_address_with_program_id( + &multisig_pubkey, + &native_mint, + &config.program_id, + ); + + let new_address = Pubkey::new_unique(); + + // we have to do this before we create the multisig or the transfer would fail + process_test_command( + &config, + payer, + &[ + "spl-token", + CommandName::Transfer.into(), + "--fund-recipient", + "--allow-unfunded-recipient", + &native_mint.to_string(), + "9.5", + &multisig_pubkey.to_string(), + ], + ) + .await + .unwrap(); + + let funded_amount = spl_token_2022::ui_amount_to_amount(9.5, TEST_DECIMALS); + + let multisig_members = std::iter::once(multisig_pubkey) + .chain(multisig_members.iter().cloned()) + .collect::>(); + let multisig_path = NamedTempFile::new().unwrap(); + write_keypair_file(&multisig, &multisig_path).unwrap(); + let multisig_paths = std::iter::once(&multisig_path) + .chain(multisig_paths.iter()) + .collect::>(); + + let multisig_strings = multisig_members + .iter() + .map(|p| p.to_string()) + .collect::>(); + process_test_command( + &config, + payer, + [ + "spl-token", + CommandName::CreateMultisig.into(), + "--address-keypair", + multisig_path.path().to_str().unwrap(), + "--program-id", + &program_id.to_string(), + &m.to_string(), + ] + .into_iter() + .chain(multisig_strings.iter().map(|p| p.as_str())), + ) + .await + .unwrap(); + + let account = config + .rpc_client + .get_account(&multisig_pubkey) + .await + .unwrap(); + let multisig = Multisig::unpack(&account.data).unwrap(); + assert_eq!(multisig.m, m); + assert_eq!(multisig.n, n); + + exec_test_cmd( + &config, + &[ + "spl-token", + CommandName::UnwrapSol.into(), + "5", + &new_address.to_string(), + "--allow-unfunded-recipient", + "--multisig-signer", + multisig_paths[0].path().to_str().unwrap(), + "--multisig-signer", + multisig_paths[1].path().to_str().unwrap(), + "--multisig-signer", + multisig_paths[2].path().to_str().unwrap(), + "--owner", + &multisig_pubkey.to_string(), + "--program-2022", + "--fee-payer", + multisig_paths[1].path().to_str().unwrap(), // Set the `payer` to the fee payer + ], + ) + .await + .unwrap(); + + let amount = spl_token_2022::ui_amount_to_amount(5.0, TEST_DECIMALS); + + let account_space = + ExtensionType::try_calculate_account_len::(&[ExtensionType::ImmutableOwner]) + .unwrap(); + let rent_exempt_lamports = config + .rpc_client + .get_minimum_balance_for_rent_exemption(account_space) + .await + .unwrap(); + let zero_space_rent_exempt_lamports = config + .rpc_client + .get_minimum_balance_for_rent_exemption(0) + .await + .unwrap(); + let new_account_balance = config.rpc_client.get_balance(&new_address).await.unwrap(); + assert_eq!( + new_account_balance, + amount + zero_space_rent_exempt_lamports // we fund the account before the unwrap + ); + let wrapped_account = config + .rpc_client + .get_account(&multisig_wrapped_address) + .await + .unwrap(); + let wrapped_token_account = + StateWithExtensionsOwned::::unpack(wrapped_account.data).unwrap(); + assert_eq!( + wrapped_account.lamports, + funded_amount + rent_exempt_lamports - amount + ); + assert_eq!(wrapped_token_account.base.amount, funded_amount - amount); + + // close the native account + process_test_command( + &config, + payer, + &[ + "spl-token", + CommandName::Unwrap.into(), + &payer_wrapped_address.to_string(), + ], + ) + .await + .unwrap(); } async fn wrap_unwrap_sol(test_validator: &TestValidator, payer: &Keypair) { From 210d576d994776ebff2b992b16acb6563ec9b981 Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Tue, 2 Dec 2025 14:30:37 +0100 Subject: [PATCH 33/39] Allow for unfunded recipients in unwrap lamports cli processor & update display messages --- clients/cli/src/command.rs | 99 ++++++++++++++++++++++++++------------ 1 file changed, 67 insertions(+), 32 deletions(-) diff --git a/clients/cli/src/command.rs b/clients/cli/src/command.rs index fc1b4758d..34e3538e0 100644 --- a/clients/cli/src/command.rs +++ b/clients/cli/src/command.rs @@ -31,8 +31,9 @@ use { program_option::COption, pubkey::Pubkey, signature::{Keypair, Signer}, + transaction::Transaction, }, - solana_system_interface::program as system_program, + solana_system_interface::{instruction::transfer, program as system_program}, spl_associated_token_account_interface::address::get_associated_token_address_with_program_id, spl_pod::optional_keys::OptionalNonZeroPubkey, spl_token_2022::extension::confidential_transfer::account_info::{ @@ -2121,30 +2122,11 @@ async fn command_unwrap_lamports( Amount::Decimal(_) => unreachable!(), Amount::All => None, }; - - let display_amount = amount - .map(|amount| amount.to_string()) - .unwrap_or_else(|| "all".to_string()); - - println_display( - config, - format!( - "Unwrapping {} lamports to {}", - display_amount, destination_account - ), - ); + let mut balance = None; if !config.sign_only { let account_data = config.get_account_checked(&source_account).await?; - if !use_associated_account { - let account_state = StateWithExtensionsOwned::::unpack(account_data.data)?; - - if account_state.base.mint != *token.get_address() { - return Err(format!("{} is not a native token account", source_account).into()); - } - } - if account_data.lamports == 0 { if use_associated_account { return Err("No wrapped SOL in associated account; did you mean to specify an auxiliary address?".to_string().into()); @@ -2153,20 +2135,73 @@ async fn command_unwrap_lamports( } } - println_display( - config, - format!( - " Amount: {} SOL", - build_balance_message(account_data.lamports, false, false) - ), - ); + let account_state = StateWithExtensionsOwned::::unpack(account_data.data)?; - // TODO: check if the destination account exists and if it doesn't check - // if the amount being transferred covers the rent exempt balance, then add - // a flag to fund the destination account + if let Some(amount) = amount { + if account_state.base.amount < amount { + return Err(format!( + "Error: Sender has insufficient funds, current balance is {} SOL", + build_balance_message(account_state.base.amount, false, false) + ) + .into()); + } + } + + balance = Some(account_state.base.amount); + + if !use_associated_account && account_state.base.mint != *token.get_address() { + return Err(format!("{} is not a native token account", source_account).into()); + } + + if config.rpc_client.get_balance(&destination_account).await? == 0 { + // if it doesn't exist, we gate transfer with a different flag + if allow_unfunded_recipient { + println_display( + config, + format!("Funding recipient: {}", destination_account,), + ); + + let rent_exempt_lamports = config + .rpc_client + .get_minimum_balance_for_rent_exemption(0) + .await?; + let fee_payer = config.fee_payer()?; + let instruction = transfer( + &fee_payer.pubkey(), + &destination_account, + rent_exempt_lamports, + ); + let recent_blockhash = config.rpc_client.get_latest_blockhash().await?; + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&fee_payer.pubkey()), + &[fee_payer], + recent_blockhash, + ); + config + .rpc_client + .send_and_confirm_transaction(&transaction) + .await?; + } else { + return Err("Error: The recipient address is not funded. \ + Add `--allow-unfunded-recipient` to complete the transfer." + .into()); + } + } } - println_display(config, format!(" Recipient: {}", &destination_account)); + let display_amount = amount + .or(balance) + .map(|amount| build_balance_message(amount, false, false)) + .unwrap_or_else(|| "all".to_string()); + + println_display( + config, + format!( + "Unwrapping {} SOL to {}", + display_amount, destination_account + ), + ); let res = token .unwrap_lamports( From c90b2ff930f6cf9c7264f8c9a8ba6b0abf2d6d88 Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Tue, 2 Dec 2025 21:23:16 +0100 Subject: [PATCH 34/39] Remove SOL transfer funding in cli unwrap lamports unfunded case --- clients/cli/src/command.rs | 32 ++------------------------------ clients/cli/tests/command.rs | 18 +++--------------- 2 files changed, 5 insertions(+), 45 deletions(-) diff --git a/clients/cli/src/command.rs b/clients/cli/src/command.rs index 34e3538e0..ffa02d103 100644 --- a/clients/cli/src/command.rs +++ b/clients/cli/src/command.rs @@ -31,9 +31,8 @@ use { program_option::COption, pubkey::Pubkey, signature::{Keypair, Signer}, - transaction::Transaction, }, - solana_system_interface::{instruction::transfer, program as system_program}, + solana_system_interface::program as system_program, spl_associated_token_account_interface::address::get_associated_token_address_with_program_id, spl_pod::optional_keys::OptionalNonZeroPubkey, spl_token_2022::extension::confidential_transfer::account_info::{ @@ -2155,34 +2154,7 @@ async fn command_unwrap_lamports( if config.rpc_client.get_balance(&destination_account).await? == 0 { // if it doesn't exist, we gate transfer with a different flag - if allow_unfunded_recipient { - println_display( - config, - format!("Funding recipient: {}", destination_account,), - ); - - let rent_exempt_lamports = config - .rpc_client - .get_minimum_balance_for_rent_exemption(0) - .await?; - let fee_payer = config.fee_payer()?; - let instruction = transfer( - &fee_payer.pubkey(), - &destination_account, - rent_exempt_lamports, - ); - let recent_blockhash = config.rpc_client.get_latest_blockhash().await?; - let transaction = Transaction::new_signed_with_payer( - &[instruction], - Some(&fee_payer.pubkey()), - &[fee_payer], - recent_blockhash, - ); - config - .rpc_client - .send_and_confirm_transaction(&transaction) - .await?; - } else { + if !allow_unfunded_recipient { return Err("Error: The recipient address is not funded. \ Add `--allow-unfunded-recipient` to complete the transfer." .into()); diff --git a/clients/cli/tests/command.rs b/clients/cli/tests/command.rs index 57009db70..a98ac25ca 100644 --- a/clients/cli/tests/command.rs +++ b/clients/cli/tests/command.rs @@ -918,13 +918,9 @@ async fn unwrap_lamports(test_validator: &TestValidator, payer: &Keypair) { .get_minimum_balance_for_rent_exemption(account_space) .await .unwrap(); - let zero_space_rent_exempt_lamports = config - .rpc_client - .get_minimum_balance_for_rent_exemption(0) - .await - .unwrap(); + let balance = config.rpc_client.get_balance(&new_address).await.unwrap(); - assert_eq!(balance, amount + zero_space_rent_exempt_lamports); // we fund the recipient first + assert_eq!(balance, amount); let wrapped_account = config .rpc_client @@ -1115,16 +1111,8 @@ async fn multisig_unwrap_lamports(test_validator: &TestValidator, payer: &Keypai .get_minimum_balance_for_rent_exemption(account_space) .await .unwrap(); - let zero_space_rent_exempt_lamports = config - .rpc_client - .get_minimum_balance_for_rent_exemption(0) - .await - .unwrap(); let new_account_balance = config.rpc_client.get_balance(&new_address).await.unwrap(); - assert_eq!( - new_account_balance, - amount + zero_space_rent_exempt_lamports // we fund the account before the unwrap - ); + assert_eq!(new_account_balance, amount); let wrapped_account = config .rpc_client .get_account(&multisig_wrapped_address) From a647851a4d772906b1b842abcdd6edc2d31c7bda Mon Sep 17 00:00:00 2001 From: abelmarnk <151375781+abelmarnk@users.noreply.github.com> Date: Tue, 2 Dec 2025 21:26:23 +0100 Subject: [PATCH 35/39] Update clients/cli/src/clap_app.rs Co-authored-by: Jon C --- clients/cli/src/clap_app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/cli/src/clap_app.rs b/clients/cli/src/clap_app.rs index 1bec49ed9..ebeb55bb9 100644 --- a/clients/cli/src/clap_app.rs +++ b/clients/cli/src/clap_app.rs @@ -1724,7 +1724,7 @@ pub fn app<'a>( ) .subcommand( SubCommand::with_name(CommandName::UnwrapSol.into()) - .about("Unwrap lamports from a SOL token account") + .about("Unwrap SOL from a wrapped SOL token account") .arg( Arg::with_name("amount") .value_parser(Amount::parse) From 3cb35b8bfdb3b0782d89915b164279dded85939e Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Wed, 3 Dec 2025 05:25:23 +0100 Subject: [PATCH 36/39] Fix client generated docs --- clients/js/src/generated/accounts/mint.ts | 3 --- clients/js/src/generated/accounts/multisig.ts | 3 --- clients/js/src/generated/accounts/token.ts | 3 --- 3 files changed, 9 deletions(-) diff --git a/clients/js/src/generated/accounts/mint.ts b/clients/js/src/generated/accounts/mint.ts index c6a5158bd..35b1a19dd 100644 --- a/clients/js/src/generated/accounts/mint.ts +++ b/clients/js/src/generated/accounts/mint.ts @@ -92,7 +92,6 @@ export type MintArgs = { extensions: OptionOrNullable>; }; -/** Gets the encoder for {@link MintArgs} account data. */ export function getMintEncoder(): Encoder { return getStructEncoder([ [ @@ -125,7 +124,6 @@ export function getMintEncoder(): Encoder { ]); } -/** Gets the decoder for {@link Mint} account data. */ export function getMintDecoder(): Decoder { return getStructDecoder([ [ @@ -158,7 +156,6 @@ export function getMintDecoder(): Decoder { ]); } -/** Gets the codec for {@link Mint} account data. */ export function getMintCodec(): Codec { return combineCodec(getMintEncoder(), getMintDecoder()); } diff --git a/clients/js/src/generated/accounts/multisig.ts b/clients/js/src/generated/accounts/multisig.ts index faa0404f7..1ef430e12 100644 --- a/clients/js/src/generated/accounts/multisig.ts +++ b/clients/js/src/generated/accounts/multisig.ts @@ -48,7 +48,6 @@ export type Multisig = { export type MultisigArgs = Multisig; -/** Gets the encoder for {@link MultisigArgs} account data. */ export function getMultisigEncoder(): FixedSizeEncoder { return getStructEncoder([ ['m', getU8Encoder()], @@ -58,7 +57,6 @@ export function getMultisigEncoder(): FixedSizeEncoder { ]); } -/** Gets the decoder for {@link Multisig} account data. */ export function getMultisigDecoder(): FixedSizeDecoder { return getStructDecoder([ ['m', getU8Decoder()], @@ -68,7 +66,6 @@ export function getMultisigDecoder(): FixedSizeDecoder { ]); } -/** Gets the codec for {@link Multisig} account data. */ export function getMultisigCodec(): FixedSizeCodec { return combineCodec(getMultisigEncoder(), getMultisigDecoder()); } diff --git a/clients/js/src/generated/accounts/token.ts b/clients/js/src/generated/accounts/token.ts index 1e1d75cc4..45cf91e76 100644 --- a/clients/js/src/generated/accounts/token.ts +++ b/clients/js/src/generated/accounts/token.ts @@ -112,7 +112,6 @@ export type TokenArgs = { extensions: OptionOrNullable>; }; -/** Gets the encoder for {@link TokenArgs} account data. */ export function getTokenEncoder(): Encoder { return getStructEncoder([ ['mint', getAddressEncoder()], @@ -154,7 +153,6 @@ export function getTokenEncoder(): Encoder { ]); } -/** Gets the decoder for {@link Token} account data. */ export function getTokenDecoder(): Decoder { return getStructDecoder([ ['mint', getAddressDecoder()], @@ -196,7 +194,6 @@ export function getTokenDecoder(): Decoder { ]); } -/** Gets the codec for {@link Token} account data. */ export function getTokenCodec(): Codec { return combineCodec(getTokenEncoder(), getTokenDecoder()); } From 7c8a9716f57714c00f75221da890a0c5c8105051 Mon Sep 17 00:00:00 2001 From: abelmarnk <151375781+abelmarnk@users.noreply.github.com> Date: Thu, 4 Dec 2025 17:03:31 +0100 Subject: [PATCH 37/39] Aid compiler. Co-authored-by: Fernando Otero --- program/src/processor.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/program/src/processor.rs b/program/src/processor.rs index f1b54c5e8..bdbed5d11 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -1696,9 +1696,10 @@ impl Processor { } if amount == 0 { - check_program_account(source_account_info.owner)?; + check_program_account(source_account_info.owner) } else { - if !self_transfer { + source_account.base.amount = remaining_amount.into(); + if source_account_info.key != destination_account_info.key { let source_starting_lamports = source_account_info.lamports(); **source_account_info.lamports.borrow_mut() = source_starting_lamports .checked_sub(amount) @@ -1709,10 +1710,8 @@ impl Processor { .checked_add(amount) .ok_or(TokenError::Overflow)?; } - source_account.base.amount = remaining_amount.into(); + Ok(()) } - - Ok(()) } /// Processes an [`Instruction`](enum.Instruction.html). From 570309e27dbef4a9f8f1529fce0ba9adca0795b8 Mon Sep 17 00:00:00 2001 From: abelmarnk <151375781+abelmarnk@users.noreply.github.com> Date: Thu, 4 Dec 2025 17:14:57 +0100 Subject: [PATCH 38/39] Remove unused variable Co-authored-by: Fernando Otero --- program/src/processor.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/program/src/processor.rs b/program/src/processor.rs index bdbed5d11..d4d93b8ec 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -1688,7 +1688,6 @@ impl Processor { account_info_iter.as_slice(), )?; - let self_transfer = source_account_info.key == destination_account_info.key; if let Ok(cpi_guard) = source_account.get_extension::() { if cpi_guard.lock_cpi.into() && in_cpi() { return Err(TokenError::CpiGuardTransferBlocked.into()); From 2f0207a0cb0ac40d285e2205e7b24a0545f6e206 Mon Sep 17 00:00:00 2001 From: Abel Marnk Date: Thu, 4 Dec 2025 17:17:25 +0100 Subject: [PATCH 39/39] Renaming changes --- clients/cli/src/command.rs | 4 ++-- clients/cli/tests/command.rs | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/clients/cli/src/command.rs b/clients/cli/src/command.rs index ffa02d103..43a6e3d02 100644 --- a/clients/cli/src/command.rs +++ b/clients/cli/src/command.rs @@ -2099,7 +2099,7 @@ async fn command_unwrap( }) } -async fn command_unwrap_lamports( +async fn command_unwrap_sol( config: &Config<'_>, ui_amount: Amount, source_owner: Pubkey, @@ -4384,7 +4384,7 @@ pub async fn process_command( let allow_unfunded_recipient = arg_matches.is_present("allow_unfunded_recipient"); - command_unwrap_lamports( + command_unwrap_sol( config, amount, owner, diff --git a/clients/cli/tests/command.rs b/clients/cli/tests/command.rs index a98ac25ca..1c00463fa 100644 --- a/clients/cli/tests/command.rs +++ b/clients/cli/tests/command.rs @@ -843,12 +843,12 @@ async fn accounts_with_owner(test_validator: &TestValidator, payer: &Keypair) { async fn wrapped_sol(test_validator: &TestValidator, payer: &Keypair) { // the tests use the same ata so they can't run together - unwrap_lamports(test_validator, payer).await; - multisig_unwrap_lamports(test_validator, payer).await; + unwrap_sol(test_validator, payer).await; + multisig_unwrap_sol(test_validator, payer).await; wrap_unwrap_sol(test_validator, payer).await; } -async fn unwrap_lamports(test_validator: &TestValidator, payer: &Keypair) { +async fn unwrap_sol(test_validator: &TestValidator, payer: &Keypair) { let program_id = &spl_token_2022_interface::id(); let config = test_config_with_default_signer(test_validator, payer, program_id); let native_mint = *Token::new_native( @@ -970,7 +970,7 @@ async fn unwrap_lamports(test_validator: &TestValidator, payer: &Keypair) { .unwrap(); } -async fn multisig_unwrap_lamports(test_validator: &TestValidator, payer: &Keypair) { +async fn multisig_unwrap_sol(test_validator: &TestValidator, payer: &Keypair) { let m = 3; let n = 5u8;