From 8dee08abcc0d7d3568a26bee665614b6cba6f9e5 Mon Sep 17 00:00:00 2001 From: febo Date: Tue, 2 Sep 2025 14:22:38 +0100 Subject: [PATCH 01/11] Add processor --- p-token/src/entrypoint.rs | 7 +++ p-token/src/processor/mod.rs | 2 + p-token/src/processor/unwrap_lamports.rs | 66 ++++++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 p-token/src/processor/unwrap_lamports.rs diff --git a/p-token/src/entrypoint.rs b/p-token/src/entrypoint.rs index 9a0f4bd2..d234a35a 100644 --- a/p-token/src/entrypoint.rs +++ b/p-token/src/entrypoint.rs @@ -486,6 +486,13 @@ fn inner_process_remaining_instruction( process_withdraw_excess_lamports(accounts) } + // 39 - UnwrapLamports + 39 => { + #[cfg(feature = "logging")] + pinocchio::msg!("Instruction: UnwrapLamports"); + + process_unwrap_lamports(accounts, instruction_data) + } _ => Err(TokenError::InvalidInstruction.into()), } } diff --git a/p-token/src/processor/mod.rs b/p-token/src/processor/mod.rs index 7e8ea476..b71d1d63 100644 --- a/p-token/src/processor/mod.rs +++ b/p-token/src/processor/mod.rs @@ -41,6 +41,7 @@ pub mod thaw_account; pub mod transfer; pub mod transfer_checked; pub mod ui_amount_to_amount; +pub mod unwrap_lamports; pub mod withdraw_excess_lamports; // Shared processors. pub mod shared; @@ -61,6 +62,7 @@ pub use { set_authority::process_set_authority, sync_native::process_sync_native, thaw_account::process_thaw_account, transfer::process_transfer, transfer_checked::process_transfer_checked, ui_amount_to_amount::process_ui_amount_to_amount, + unwrap_lamports::process_unwrap_lamports, withdraw_excess_lamports::process_withdraw_excess_lamports, }; diff --git a/p-token/src/processor/unwrap_lamports.rs b/p-token/src/processor/unwrap_lamports.rs new file mode 100644 index 00000000..38bf7378 --- /dev/null +++ b/p-token/src/processor/unwrap_lamports.rs @@ -0,0 +1,66 @@ +use { + super::validate_owner, + crate::processor::{check_account_owner, unpack_amount}, + pinocchio::{account_info::AccountInfo, program_error::ProgramError, ProgramResult}, + pinocchio_token_interface::{ + error::TokenError, + state::{account::Account, load_mut}, + }, +}; + +#[allow(clippy::arithmetic_side_effects)] +pub fn process_unwrap_lamports(accounts: &[AccountInfo], instruction_data: &[u8]) -> ProgramResult { + // Amount being unwrapped. + let amount = unpack_amount(instruction_data)?; + + let [source_account_info, destination_account_info, authority_info, remaining @ ..] = accounts + else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + // SAFETY: single immutable borrow to `source_account_info` account data + let source_account = + unsafe { load_mut::(source_account_info.borrow_mut_data_unchecked())? }; + + if !source_account.is_native() { + return Err(TokenError::NonNativeNotSupported.into()); + } + + // SAFETY: `authority_info` is not currently borrowed; in the case `authority_info` is + // the same as `source_account_info`, then it cannot be a multisig. + unsafe { validate_owner(&source_account.owner, authority_info, remaining)? }; + + let remaining_amount = source_account + .amount() + .checked_sub(amount) + .ok_or(TokenError::InsufficientFunds)?; + + // Comparing whether the AccountInfo's "point" to the same account or + // not - this is a faster comparison since it just checks the internal + // raw pointer. + let self_transfer = source_account_info == destination_account_info; + + if self_transfer || amount == 0 { + // Validates the token account owner since we are not writing + // to the account. + check_account_owner(source_account_info)?; + } else { + source_account.set_amount(remaining_amount); + + // SAFETY: single mutable borrow to `source_account_info` lamports. + let source_lamports = unsafe { source_account_info.borrow_mut_lamports_unchecked() }; + // Note: The amount of a source token account is already validated and the + // `lamports` on the account is always greater than `amount`. + *source_lamports -= amount; + + // SAFETY: single mutable borrow to `destination_account_info` lamports; the + // account is already validated to be different from + // `source_account_info`. + let destination_lamports = + unsafe { destination_account_info.borrow_mut_lamports_unchecked() }; + // Note: The total lamports supply is bound to `u64::MAX`. + *destination_lamports += amount; + } + + Ok(()) +} From dff658a715f7d2fcc62cd13695ea46ae5ef73b03 Mon Sep 17 00:00:00 2001 From: febo Date: Tue, 2 Sep 2025 14:22:54 +0100 Subject: [PATCH 02/11] Add tests --- p-token/tests/unwrap_lamports.rs | 427 +++++++++++++++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 p-token/tests/unwrap_lamports.rs diff --git a/p-token/tests/unwrap_lamports.rs b/p-token/tests/unwrap_lamports.rs new file mode 100644 index 00000000..16ac5892 --- /dev/null +++ b/p-token/tests/unwrap_lamports.rs @@ -0,0 +1,427 @@ +mod setup; + +use { + crate::setup::TOKEN_PROGRAM_ID, + mollusk_svm::{result::Check, Mollusk}, + pinocchio_token_interface::{ + error::TokenError, + native_mint, + state::{ + account::Account as TokenAccount, account_state::AccountState, load_mut_unchecked, + }, + }, + solana_account::Account, + solana_instruction::{error::InstructionError, AccountMeta, Instruction}, + solana_program_error::ProgramError, + solana_program_pack::Pack, + solana_program_test::tokio, + solana_pubkey::Pubkey, + solana_rent::Rent, + solana_sdk_ids::bpf_loader_upgradeable, +}; + +fn create_token_account( + mint: &Pubkey, + owner: &Pubkey, + is_native: bool, + amount: u64, + program_owner: &Pubkey, +) -> Account { + let space = size_of::(); + let mut lamports = Rent::default().minimum_balance(space); + + let mut data: Vec = vec![0u8; space]; + let token = unsafe { load_mut_unchecked::(data.as_mut_slice()).unwrap() }; + token.set_account_state(AccountState::Initialized); + token.mint = *mint.as_array(); + token.owner = *owner.as_array(); + token.set_amount(amount); + token.set_native(is_native); + + if is_native { + token.set_native_amount(lamports); + lamports = lamports.saturating_add(amount); + } + + Account { + lamports, + data, + owner: *program_owner, + executable: false, + ..Default::default() + } +} + +fn unwrap_lamports_instruction( + source: &Pubkey, + destination: &Pubkey, + authority: &Pubkey, + amount: u64, +) -> Result { + let accounts = vec![ + AccountMeta::new(*source, false), + AccountMeta::new(*destination, false), + AccountMeta::new_readonly(*authority, true), + ]; + + // Start with the batch discriminator + let mut data: Vec = vec![39u8]; + data.extend_from_slice(&amount.to_le_bytes()); + + Ok(Instruction { + program_id: spl_token::ID, + data, + accounts, + }) +} + +/// Creates a Mollusk instance with the default feature set, excluding the +/// `bpf_account_data_direct_mapping` feature. +fn mollusk() -> Mollusk { + let mut mollusk = Mollusk::default(); + mollusk.add_program( + &TOKEN_PROGRAM_ID, + "pinocchio_token_program", + &bpf_loader_upgradeable::id(), + ); + mollusk +} + +#[tokio::test] +async fn unwrap_lamports() { + let native_mint = Pubkey::new_from_array(native_mint::ID); + let authority_key = Pubkey::new_unique(); + let destination_account_key = Pubkey::new_unique(); + + // native account: + // - amount: 2_000_000_000 + let source_account_key = Pubkey::new_unique(); + let source_account = create_token_account( + &native_mint, + &authority_key, + true, + 2_000_000_000, + &TOKEN_PROGRAM_ID, + ); + + let instruction = unwrap_lamports_instruction( + &source_account_key, + &destination_account_key, + &authority_key, + 2_000_000_000, + ) + .unwrap(); + + // It should succeed to unwrap 2_000_000_000 lamports. + + let result = mollusk().process_and_validate_instruction( + &instruction, + &[ + (source_account_key, source_account), + (destination_account_key, Account::default()), + (authority_key, Account::default()), + ], + &[ + Check::success(), + Check::account(&destination_account_key) + .lamports(2_000_000_000) + .build(), + Check::account(&source_account_key) + .lamports(Rent::default().minimum_balance(size_of::())) + .build(), + ], + ); + + // And the remaining amount must be 0. + + result.resulting_accounts.iter().for_each(|(key, account)| { + if *key == source_account_key { + let token_account = spl_token::state::Account::unpack(&account.data).unwrap(); + assert_eq!(token_account.amount, 0); + } + }); +} + +#[tokio::test] +async fn fail_unwrap_lamports_with_insufficient_funds() { + let native_mint = Pubkey::new_from_array(native_mint::ID); + let authority_key = Pubkey::new_unique(); + let destination_account_key = Pubkey::new_unique(); + + // native account: + // - amount: 1_000_000_000 + let source_account_key = Pubkey::new_unique(); + let source_account = create_token_account( + &native_mint, + &authority_key, + true, + 1_000_000_000, + &TOKEN_PROGRAM_ID, + ); + + let instruction = unwrap_lamports_instruction( + &source_account_key, + &destination_account_key, + &authority_key, + 2_000_000_000, + ) + .unwrap(); + + // When we try to unwrap 2_000_000_000 lamports, we expect a + // `TokenError::InsufficientFunds` error. + + mollusk().process_and_validate_instruction( + &instruction, + &[ + (source_account_key, source_account), + (destination_account_key, Account::default()), + (authority_key, Account::default()), + ], + &[Check::err(ProgramError::Custom( + TokenError::InsufficientFunds as u32, + ))], + ); +} + +#[tokio::test] +async fn unwrap_lamports_with_parial_amount() { + let native_mint = Pubkey::new_from_array(native_mint::ID); + let authority_key = Pubkey::new_unique(); + let destination_account_key = Pubkey::new_unique(); + + // native account: + // - amount: 2_000_000_000 + let source_account_key = Pubkey::new_unique(); + let source_account = create_token_account( + &native_mint, + &authority_key, + true, + 2_000_000_000, + &TOKEN_PROGRAM_ID, + ); + + let instruction = unwrap_lamports_instruction( + &source_account_key, + &destination_account_key, + &authority_key, + 1_000_000_000, + ) + .unwrap(); + + // It should succeed to unwrap 1_000_000_000 lamports. + + let result = mollusk().process_and_validate_instruction( + &instruction, + &[ + (source_account_key, source_account), + (destination_account_key, Account::default()), + (authority_key, Account::default()), + ], + &[ + Check::success(), + Check::account(&destination_account_key) + .lamports(1_000_000_000) + .build(), + Check::account(&source_account_key) + .lamports( + Rent::default().minimum_balance(size_of::()) + 1_000_000_000, + ) + .build(), + ], + ); + + // And the remaining amount must be 1_000_000_000. + + result.resulting_accounts.iter().for_each(|(key, account)| { + if *key == source_account_key { + let token_account = spl_token::state::Account::unpack(&account.data).unwrap(); + assert_eq!(token_account.amount, 1_000_000_000); + } + }); +} + +#[tokio::test] +async fn fail_unwrap_lamports_with_invalid_authority() { + let native_mint = Pubkey::new_from_array(native_mint::ID); + let authority_key = Pubkey::new_unique(); + let destination_account_key = Pubkey::new_unique(); + let fake_authority_key = Pubkey::new_unique(); + + // native account: + // - amount: 1_000_000_000 + let source_account_key = Pubkey::new_unique(); + let source_account = create_token_account( + &native_mint, + &authority_key, + true, + 1_000_000_000, + &TOKEN_PROGRAM_ID, + ); + + let instruction = unwrap_lamports_instruction( + &source_account_key, + &destination_account_key, + &fake_authority_key, // <-- wrong authority + 2_000_000_000, + ) + .unwrap(); + + // When we try to unwrap lamports with an invalid authority, we expect a + // `TokenError::OwnerMismatch` error. + + mollusk().process_and_validate_instruction( + &instruction, + &[ + (source_account_key, source_account), + (destination_account_key, Account::default()), + (fake_authority_key, Account::default()), + ], + &[Check::err(ProgramError::Custom( + TokenError::OwnerMismatch as u32, + ))], + ); +} + +#[tokio::test] +async fn fail_unwrap_lamports_with_non_native_account() { + let mint = Pubkey::new_unique(); + let authority_key = Pubkey::new_unique(); + let destination_account_key = Pubkey::new_unique(); + + // non-native account: + // - amount: 2_000_000_000 + let source_account_key = Pubkey::new_unique(); + let mut source_account = create_token_account( + &mint, + &authority_key, + false, // <-- non-native account + 2_000_000_000, + &TOKEN_PROGRAM_ID, + ); + source_account.lamports += 2_000_000_000; + + let instruction = unwrap_lamports_instruction( + &source_account_key, + &destination_account_key, + &authority_key, + 1_000_000_000, + ) + .unwrap(); + + // When we try to unwrap lamports from a non-native account, we expect a + // `TokenError::NonNativeNotSupported` error. + + mollusk().process_and_validate_instruction( + &instruction, + &[ + (source_account_key, source_account), + (destination_account_key, Account::default()), + (authority_key, Account::default()), + ], + &[ + Check::err(ProgramError::Custom( + TokenError::NonNativeNotSupported as u32, + )), + Check::account(&source_account_key) + .lamports( + Rent::default().minimum_balance(size_of::()) + 2_000_000_000, + ) + .build(), + Check::account(&destination_account_key).lamports(0).build(), + ], + ); +} + +#[tokio::test] +async fn unwrap_lamports_with_self_transfer() { + let native_mint = Pubkey::new_from_array(native_mint::ID); + let authority_key = Pubkey::new_unique(); + + // native account: + // - amount: 2_000_000_000 + let source_account_key = Pubkey::new_unique(); + let source_account = create_token_account( + &native_mint, + &authority_key, + true, + 2_000_000_000, + &TOKEN_PROGRAM_ID, + ); + + let instruction = unwrap_lamports_instruction( + &source_account_key, + &source_account_key, // <-- destination same as source + &authority_key, + 1_000_000_000, + ) + .unwrap(); + + // It should succeed to unwrap lamports with the same source and destination + // accounts. + + let result = mollusk().process_and_validate_instruction( + &instruction, + &[ + (source_account_key, source_account), + (authority_key, Account::default()), + ], + &[ + Check::success(), + Check::account(&source_account_key) + .lamports( + Rent::default().minimum_balance(size_of::()) + 2_000_000_000, + ) + .build(), + ], + ); + + result.resulting_accounts.iter().for_each(|(key, account)| { + if *key == source_account_key { + let token_account = spl_token::state::Account::unpack(&account.data).unwrap(); + assert_eq!(token_account.amount, 2_000_000_000); + } + }); +} + +#[tokio::test] +async fn fail_unwrap_lamports_with_invalid_native_account() { + let native_mint = Pubkey::new_from_array(native_mint::ID); + let authority_key = Pubkey::new_unique(); + let destination_account_key = Pubkey::new_unique(); + let invalid_program_owner = Pubkey::new_unique(); + + // non-native account: + // - amount: 2_000_000_000 + let source_account_key = Pubkey::new_unique(); + let mut source_account = create_token_account( + &native_mint, + &authority_key, + true, + 2_000_000_000, + &invalid_program_owner, // <-- invalid program owner + ); + source_account.lamports += 2_000_000_000; + + let instruction = unwrap_lamports_instruction( + &source_account_key, + &destination_account_key, + &authority_key, + 1_000_000_000, + ) + .unwrap(); + + // When we try to unwrap lamports with an invalid native account, we expect + // a `InstructionError::ExternalAccountDataModified` error. + + mollusk().process_and_validate_instruction( + &instruction, + &[ + (source_account_key, source_account), + (destination_account_key, Account::default()), + (authority_key, Account::default()), + ], + &[Check::instruction_err( + InstructionError::ExternalAccountDataModified, + )], + ); +} From 5c269ee5a46784e38a7eb6ec47e46df2d605e2ad Mon Sep 17 00:00:00 2001 From: febo Date: Tue, 2 Sep 2025 14:23:42 +0100 Subject: [PATCH 03/11] Add instruction --- p-interface/src/instruction.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/p-interface/src/instruction.rs b/p-interface/src/instruction.rs index 7ebcc074..630393cf 100644 --- a/p-interface/src/instruction.rs +++ b/p-interface/src/instruction.rs @@ -498,6 +498,21 @@ pub enum TokenInstruction { /// 3. `..+M` `[signer]` M signer accounts. WithdrawExcessLamports = 38, + /// 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. + /// + /// Data expected by this instruction: + /// + /// - `u64` The amount of lamports to transfer. + UnwrapLamports, + /// Executes a batch of instructions. The instructions to be executed are /// specified in sequence on the instruction data. Each instruction /// provides: From d86a46d2b0bf45c8802d3e2b3df2094eadaadf1a Mon Sep 17 00:00:00 2001 From: febo Date: Tue, 2 Sep 2025 14:32:34 +0100 Subject: [PATCH 04/11] Fix formatting --- p-token/src/processor/unwrap_lamports.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/p-token/src/processor/unwrap_lamports.rs b/p-token/src/processor/unwrap_lamports.rs index 38bf7378..28228803 100644 --- a/p-token/src/processor/unwrap_lamports.rs +++ b/p-token/src/processor/unwrap_lamports.rs @@ -26,8 +26,9 @@ pub fn process_unwrap_lamports(accounts: &[AccountInfo], instruction_data: &[u8] return Err(TokenError::NonNativeNotSupported.into()); } - // SAFETY: `authority_info` is not currently borrowed; in the case `authority_info` is - // the same as `source_account_info`, then it cannot be a multisig. + // SAFETY: `authority_info` is not currently borrowed; in the case + // `authority_info` is the same as `source_account_info`, then it cannot be + // a multisig. unsafe { validate_owner(&source_account.owner, authority_info, remaining)? }; let remaining_amount = source_account From 994fba4fe745bc90e7bb9058343a2e4dfe78b157 Mon Sep 17 00:00:00 2001 From: febo Date: Tue, 2 Sep 2025 22:27:47 +0100 Subject: [PATCH 05/11] Fix discriminator value --- p-interface/src/instruction.rs | 2 +- p-token/src/entrypoint.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/p-interface/src/instruction.rs b/p-interface/src/instruction.rs index 630393cf..bfad3982 100644 --- a/p-interface/src/instruction.rs +++ b/p-interface/src/instruction.rs @@ -511,7 +511,7 @@ pub enum TokenInstruction { /// Data expected by this instruction: /// /// - `u64` The amount of lamports to transfer. - UnwrapLamports, + UnwrapLamports = 45, /// Executes a batch of instructions. The instructions to be executed are /// specified in sequence on the instruction data. Each instruction diff --git a/p-token/src/entrypoint.rs b/p-token/src/entrypoint.rs index d234a35a..af3023be 100644 --- a/p-token/src/entrypoint.rs +++ b/p-token/src/entrypoint.rs @@ -486,8 +486,8 @@ fn inner_process_remaining_instruction( process_withdraw_excess_lamports(accounts) } - // 39 - UnwrapLamports - 39 => { + // 45 - UnwrapLamports + 45 => { #[cfg(feature = "logging")] pinocchio::msg!("Instruction: UnwrapLamports"); From 20e8122b1fa66376bce49f1d168420bea1238ec2 Mon Sep 17 00:00:00 2001 From: febo Date: Tue, 2 Sep 2025 22:29:16 +0100 Subject: [PATCH 06/11] Add check for unwrap lamports --- p-token/src/processor/batch.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/p-token/src/processor/batch.rs b/p-token/src/processor/batch.rs index 94482641..44144303 100644 --- a/p-token/src/processor/batch.rs +++ b/p-token/src/processor/batch.rs @@ -84,7 +84,8 @@ pub fn process_batch(mut accounts: &[AccountInfo], mut instruction_data: &[u8]) // 13 - ApproveChecked // 22 - InitializeImmutableOwner // 38 - WithdrawExcessLamports - 4..=13 | 22 | 38 => { + // 45 - UnwrapLamports + 4..=13 | 22 | 38 | 45 => { let [a0, ..] = ix_accounts else { return Err(ProgramError::NotEnoughAccountKeys); }; From acc60c44b443574143f82e784a29347c8c487090 Mon Sep 17 00:00:00 2001 From: febo Date: Wed, 3 Sep 2025 01:06:27 +0100 Subject: [PATCH 07/11] Update discriminator on tests --- p-token/tests/unwrap_lamports.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/p-token/tests/unwrap_lamports.rs b/p-token/tests/unwrap_lamports.rs index 16ac5892..f3a1e930 100644 --- a/p-token/tests/unwrap_lamports.rs +++ b/p-token/tests/unwrap_lamports.rs @@ -5,6 +5,7 @@ use { mollusk_svm::{result::Check, Mollusk}, pinocchio_token_interface::{ error::TokenError, + instruction::TokenInstruction, native_mint, state::{ account::Account as TokenAccount, account_state::AccountState, load_mut_unchecked, @@ -65,7 +66,7 @@ fn unwrap_lamports_instruction( ]; // Start with the batch discriminator - let mut data: Vec = vec![39u8]; + let mut data: Vec = vec![TokenInstruction::UnwrapLamports as u8]; data.extend_from_slice(&amount.to_le_bytes()); Ok(Instruction { From db034c27b8f520415a73b6b8fdd882703be39d77 Mon Sep 17 00:00:00 2001 From: febo Date: Fri, 5 Sep 2025 01:16:52 +0100 Subject: [PATCH 08/11] Make amount optional --- p-interface/src/instruction.rs | 4 +- p-token/src/processor/unwrap_lamports.rs | 46 ++++++++--- p-token/tests/unwrap_lamports.rs | 97 +++++++++++++++++++----- 3 files changed, 116 insertions(+), 31 deletions(-) diff --git a/p-interface/src/instruction.rs b/p-interface/src/instruction.rs index bfad3982..749dd65e 100644 --- a/p-interface/src/instruction.rs +++ b/p-interface/src/instruction.rs @@ -510,7 +510,9 @@ pub enum TokenInstruction { /// /// Data expected by this instruction: /// - /// - `u64` The amount of lamports to transfer. + /// - `Option` The amount of lamports to transfer. When an amount is + /// not specified, the entire balance of the source account will be + /// transferred. UnwrapLamports = 45, /// Executes a batch of instructions. The instructions to be executed are diff --git a/p-token/src/processor/unwrap_lamports.rs b/p-token/src/processor/unwrap_lamports.rs index 28228803..661837db 100644 --- a/p-token/src/processor/unwrap_lamports.rs +++ b/p-token/src/processor/unwrap_lamports.rs @@ -1,17 +1,31 @@ use { super::validate_owner, - crate::processor::{check_account_owner, unpack_amount}, + crate::processor::{check_account_owner, U64_BYTES}, pinocchio::{account_info::AccountInfo, program_error::ProgramError, ProgramResult}, pinocchio_token_interface::{ error::TokenError, + likely, state::{account::Account, load_mut}, }, }; #[allow(clippy::arithmetic_side_effects)] pub fn process_unwrap_lamports(accounts: &[AccountInfo], instruction_data: &[u8]) -> ProgramResult { - // Amount being unwrapped. - let amount = unpack_amount(instruction_data)?; + // instruction data: expected u8 (1) + optional u64 (8) + let [has_amount, maybe_amount @ ..] = instruction_data else { + return Err(TokenError::InvalidInstruction.into()); + }; + + let maybe_amount = if likely(*has_amount == 0) { + None + } else if maybe_amount.len() >= 8 { + // SAFETY: The slice is guaranteed to be at least 8 bytes long. + Some(u64::from_le_bytes(unsafe { + *(maybe_amount.as_ptr() as *const [u8; U64_BYTES]) + })) + } else { + return Err(TokenError::InvalidInstruction.into()); + }; let [source_account_info, destination_account_info, authority_info, remaining @ ..] = accounts else { @@ -31,10 +45,19 @@ pub fn process_unwrap_lamports(accounts: &[AccountInfo], instruction_data: &[u8] // a multisig. unsafe { validate_owner(&source_account.owner, authority_info, remaining)? }; - let remaining_amount = source_account - .amount() - .checked_sub(amount) - .ok_or(TokenError::InsufficientFunds)?; + // If we have an amount, we need to validate whether there are enough lamports + // to unwrap or not; otherwise we just use the full amount. + let (amount, remaining_amount) = if let Some(amount) = maybe_amount { + ( + amount, + source_account + .amount() + .checked_sub(amount) + .ok_or(TokenError::InsufficientFunds)?, + ) + } else { + (source_account.amount(), 0) + }; // Comparing whether the AccountInfo's "point" to the same account or // not - this is a faster comparison since it just checks the internal @@ -44,7 +67,7 @@ pub fn process_unwrap_lamports(accounts: &[AccountInfo], instruction_data: &[u8] if self_transfer || amount == 0 { // Validates the token account owner since we are not writing // to the account. - check_account_owner(source_account_info)?; + check_account_owner(source_account_info) } else { source_account.set_amount(remaining_amount); @@ -55,13 +78,12 @@ pub fn process_unwrap_lamports(accounts: &[AccountInfo], instruction_data: &[u8] *source_lamports -= amount; // SAFETY: single mutable borrow to `destination_account_info` lamports; the - // account is already validated to be different from - // `source_account_info`. + // account is already validated to be different from `source_account_info`. let destination_lamports = unsafe { destination_account_info.borrow_mut_lamports_unchecked() }; // Note: The total lamports supply is bound to `u64::MAX`. *destination_lamports += amount; - } - Ok(()) + Ok(()) + } } diff --git a/p-token/tests/unwrap_lamports.rs b/p-token/tests/unwrap_lamports.rs index f3a1e930..fa03c7eb 100644 --- a/p-token/tests/unwrap_lamports.rs +++ b/p-token/tests/unwrap_lamports.rs @@ -53,11 +53,23 @@ fn create_token_account( } } +/// Creates a Mollusk instance with the default feature set, excluding the +/// `bpf_account_data_direct_mapping` feature. +fn mollusk() -> Mollusk { + let mut mollusk = Mollusk::default(); + mollusk.add_program( + &TOKEN_PROGRAM_ID, + "pinocchio_token_program", + &bpf_loader_upgradeable::id(), + ); + mollusk +} + fn unwrap_lamports_instruction( source: &Pubkey, destination: &Pubkey, authority: &Pubkey, - amount: u64, + amount: Option, ) -> Result { let accounts = vec![ AccountMeta::new(*source, false), @@ -67,7 +79,13 @@ fn unwrap_lamports_instruction( // Start with the batch discriminator let mut data: Vec = vec![TokenInstruction::UnwrapLamports as u8]; - data.extend_from_slice(&amount.to_le_bytes()); + + if let Some(amount) = amount { + data.push(1); + data.extend_from_slice(&amount.to_le_bytes()); + } else { + data.push(0); + } Ok(Instruction { program_id: spl_token::ID, @@ -76,20 +94,63 @@ fn unwrap_lamports_instruction( }) } -/// Creates a Mollusk instance with the default feature set, excluding the -/// `bpf_account_data_direct_mapping` feature. -fn mollusk() -> Mollusk { - let mut mollusk = Mollusk::default(); - mollusk.add_program( +#[tokio::test] +async fn unwrap_lamports() { + let native_mint = Pubkey::new_from_array(native_mint::ID); + let authority_key = Pubkey::new_unique(); + let destination_account_key = Pubkey::new_unique(); + + // native account: + // - amount: 2_000_000_000 + let source_account_key = Pubkey::new_unique(); + let source_account = create_token_account( + &native_mint, + &authority_key, + true, + 2_000_000_000, &TOKEN_PROGRAM_ID, - "pinocchio_token_program", - &bpf_loader_upgradeable::id(), ); - mollusk + + let instruction = unwrap_lamports_instruction( + &source_account_key, + &destination_account_key, + &authority_key, + None, + ) + .unwrap(); + + // It should succeed to unwrap 2_000_000_000 lamports. + + let result = mollusk().process_and_validate_instruction( + &instruction, + &[ + (source_account_key, source_account), + (destination_account_key, Account::default()), + (authority_key, Account::default()), + ], + &[ + Check::success(), + Check::account(&destination_account_key) + .lamports(2_000_000_000) + .build(), + Check::account(&source_account_key) + .lamports(Rent::default().minimum_balance(size_of::())) + .build(), + ], + ); + + // And the remaining amount must be 0. + + result.resulting_accounts.iter().for_each(|(key, account)| { + if *key == source_account_key { + let token_account = spl_token::state::Account::unpack(&account.data).unwrap(); + assert_eq!(token_account.amount, 0); + } + }); } #[tokio::test] -async fn unwrap_lamports() { +async fn unwrap_lamports_with_amount() { let native_mint = Pubkey::new_from_array(native_mint::ID); let authority_key = Pubkey::new_unique(); let destination_account_key = Pubkey::new_unique(); @@ -109,7 +170,7 @@ async fn unwrap_lamports() { &source_account_key, &destination_account_key, &authority_key, - 2_000_000_000, + Some(2_000_000_000), ) .unwrap(); @@ -164,7 +225,7 @@ async fn fail_unwrap_lamports_with_insufficient_funds() { &source_account_key, &destination_account_key, &authority_key, - 2_000_000_000, + Some(2_000_000_000), ) .unwrap(); @@ -205,7 +266,7 @@ async fn unwrap_lamports_with_parial_amount() { &source_account_key, &destination_account_key, &authority_key, - 1_000_000_000, + Some(1_000_000_000), ) .unwrap(); @@ -263,7 +324,7 @@ async fn fail_unwrap_lamports_with_invalid_authority() { &source_account_key, &destination_account_key, &fake_authority_key, // <-- wrong authority - 2_000_000_000, + Some(2_000_000_000), ) .unwrap(); @@ -305,7 +366,7 @@ async fn fail_unwrap_lamports_with_non_native_account() { &source_account_key, &destination_account_key, &authority_key, - 1_000_000_000, + Some(1_000_000_000), ) .unwrap(); @@ -353,7 +414,7 @@ async fn unwrap_lamports_with_self_transfer() { &source_account_key, &source_account_key, // <-- destination same as source &authority_key, - 1_000_000_000, + Some(1_000_000_000), ) .unwrap(); @@ -407,7 +468,7 @@ async fn fail_unwrap_lamports_with_invalid_native_account() { &source_account_key, &destination_account_key, &authority_key, - 1_000_000_000, + Some(1_000_000_000), ) .unwrap(); From 6cff2a7700778d9889739777fd2361dca4c0ba63 Mon Sep 17 00:00:00 2001 From: febo Date: Thu, 11 Sep 2025 12:54:50 +0100 Subject: [PATCH 09/11] Address review comments --- p-token/tests/unwrap_lamports.rs | 250 ++++++++++++++++++++++++------- 1 file changed, 195 insertions(+), 55 deletions(-) diff --git a/p-token/tests/unwrap_lamports.rs b/p-token/tests/unwrap_lamports.rs index fa03c7eb..33dcca14 100644 --- a/p-token/tests/unwrap_lamports.rs +++ b/p-token/tests/unwrap_lamports.rs @@ -15,7 +15,6 @@ use { solana_instruction::{error::InstructionError, AccountMeta, Instruction}, solana_program_error::ProgramError, solana_program_pack::Pack, - solana_program_test::tokio, solana_pubkey::Pubkey, solana_rent::Rent, solana_sdk_ids::bpf_loader_upgradeable, @@ -53,8 +52,7 @@ fn create_token_account( } } -/// Creates a Mollusk instance with the default feature set, excluding the -/// `bpf_account_data_direct_mapping` feature. +/// Creates a Mollusk instance with the default feature set. fn mollusk() -> Mollusk { let mut mollusk = Mollusk::default(); mollusk.add_program( @@ -94,8 +92,8 @@ fn unwrap_lamports_instruction( }) } -#[tokio::test] -async fn unwrap_lamports() { +#[test] +fn unwrap_lamports() { let native_mint = Pubkey::new_from_array(native_mint::ID); let authority_key = Pubkey::new_unique(); let destination_account_key = Pubkey::new_unique(); @@ -141,16 +139,16 @@ async fn unwrap_lamports() { // And the remaining amount must be 0. - result.resulting_accounts.iter().for_each(|(key, account)| { - if *key == source_account_key { - let token_account = spl_token::state::Account::unpack(&account.data).unwrap(); - assert_eq!(token_account.amount, 0); - } - }); + let account = result.get_account(&source_account_key); + assert!(account.is_some()); + + let account = account.unwrap(); + let token_account = spl_token::state::Account::unpack(&account.data).unwrap(); + assert_eq!(token_account.amount, 0); } -#[tokio::test] -async fn unwrap_lamports_with_amount() { +#[test] +fn unwrap_lamports_with_amount() { let native_mint = Pubkey::new_from_array(native_mint::ID); let authority_key = Pubkey::new_unique(); let destination_account_key = Pubkey::new_unique(); @@ -196,16 +194,16 @@ async fn unwrap_lamports_with_amount() { // And the remaining amount must be 0. - result.resulting_accounts.iter().for_each(|(key, account)| { - if *key == source_account_key { - let token_account = spl_token::state::Account::unpack(&account.data).unwrap(); - assert_eq!(token_account.amount, 0); - } - }); + let account = result.get_account(&source_account_key); + assert!(account.is_some()); + + let account = account.unwrap(); + let token_account = spl_token::state::Account::unpack(&account.data).unwrap(); + assert_eq!(token_account.amount, 0); } -#[tokio::test] -async fn fail_unwrap_lamports_with_insufficient_funds() { +#[test] +fn fail_unwrap_lamports_with_insufficient_funds() { let native_mint = Pubkey::new_from_array(native_mint::ID); let authority_key = Pubkey::new_unique(); let destination_account_key = Pubkey::new_unique(); @@ -245,8 +243,8 @@ async fn fail_unwrap_lamports_with_insufficient_funds() { ); } -#[tokio::test] -async fn unwrap_lamports_with_parial_amount() { +#[test] +fn unwrap_lamports_with_parial_amount() { let native_mint = Pubkey::new_from_array(native_mint::ID); let authority_key = Pubkey::new_unique(); let destination_account_key = Pubkey::new_unique(); @@ -294,16 +292,16 @@ async fn unwrap_lamports_with_parial_amount() { // And the remaining amount must be 1_000_000_000. - result.resulting_accounts.iter().for_each(|(key, account)| { - if *key == source_account_key { - let token_account = spl_token::state::Account::unpack(&account.data).unwrap(); - assert_eq!(token_account.amount, 1_000_000_000); - } - }); + let account = result.get_account(&source_account_key); + assert!(account.is_some()); + + let account = account.unwrap(); + let token_account = spl_token::state::Account::unpack(&account.data).unwrap(); + assert_eq!(token_account.amount, 1_000_000_000); } -#[tokio::test] -async fn fail_unwrap_lamports_with_invalid_authority() { +#[test] +fn fail_unwrap_lamports_with_invalid_authority() { let native_mint = Pubkey::new_from_array(native_mint::ID); let authority_key = Pubkey::new_unique(); let destination_account_key = Pubkey::new_unique(); @@ -344,8 +342,8 @@ async fn fail_unwrap_lamports_with_invalid_authority() { ); } -#[tokio::test] -async fn fail_unwrap_lamports_with_non_native_account() { +#[test] +fn fail_unwrap_lamports_with_non_native_account() { let mint = Pubkey::new_unique(); let authority_key = Pubkey::new_unique(); let destination_account_key = Pubkey::new_unique(); @@ -380,22 +378,14 @@ async fn fail_unwrap_lamports_with_non_native_account() { (destination_account_key, Account::default()), (authority_key, Account::default()), ], - &[ - Check::err(ProgramError::Custom( - TokenError::NonNativeNotSupported as u32, - )), - Check::account(&source_account_key) - .lamports( - Rent::default().minimum_balance(size_of::()) + 2_000_000_000, - ) - .build(), - Check::account(&destination_account_key).lamports(0).build(), - ], + &[Check::err(ProgramError::Custom( + TokenError::NonNativeNotSupported as u32, + ))], ); } -#[tokio::test] -async fn unwrap_lamports_with_self_transfer() { +#[test] +fn unwrap_lamports_with_self_transfer() { let native_mint = Pubkey::new_from_array(native_mint::ID); let authority_key = Pubkey::new_unique(); @@ -437,22 +427,22 @@ async fn unwrap_lamports_with_self_transfer() { ], ); - result.resulting_accounts.iter().for_each(|(key, account)| { - if *key == source_account_key { - let token_account = spl_token::state::Account::unpack(&account.data).unwrap(); - assert_eq!(token_account.amount, 2_000_000_000); - } - }); + let account = result.get_account(&source_account_key); + assert!(account.is_some()); + + let account = account.unwrap(); + let token_account = spl_token::state::Account::unpack(&account.data).unwrap(); + assert_eq!(token_account.amount, 2_000_000_000); } -#[tokio::test] -async fn fail_unwrap_lamports_with_invalid_native_account() { +#[test] +fn fail_unwrap_lamports_with_invalid_native_account() { let native_mint = Pubkey::new_from_array(native_mint::ID); let authority_key = Pubkey::new_unique(); let destination_account_key = Pubkey::new_unique(); let invalid_program_owner = Pubkey::new_unique(); - // non-native account: + // native account: // - amount: 2_000_000_000 let source_account_key = Pubkey::new_unique(); let mut source_account = create_token_account( @@ -487,3 +477,153 @@ async fn fail_unwrap_lamports_with_invalid_native_account() { )], ); } + +#[test] +fn unwrap_lamports_to_native_account() { + let native_mint = Pubkey::new_from_array(native_mint::ID); + let authority_key = Pubkey::new_unique(); + + // native account: + // - amount: 2_000_000_000 + let source_account_key = Pubkey::new_unique(); + let source_account = create_token_account( + &native_mint, + &authority_key, + true, + 2_000_000_000, + &TOKEN_PROGRAM_ID, + ); + + // destination native account: + // - amount: 0 + let destination_account_key = Pubkey::new_unique(); + let destination_account = + create_token_account(&native_mint, &authority_key, true, 0, &TOKEN_PROGRAM_ID); + + let instruction = unwrap_lamports_instruction( + &source_account_key, + &destination_account_key, + &authority_key, + None, + ) + .unwrap(); + + // It should succeed to unwrap 2_000_000_000 lamports. + + let result = mollusk().process_and_validate_instruction( + &instruction, + &[ + (source_account_key, source_account), + (destination_account_key, destination_account), + (authority_key, Account::default()), + ], + &[ + Check::success(), + Check::account(&destination_account_key) + .lamports( + Rent::default().minimum_balance(size_of::()) + 2_000_000_000, + ) + .build(), + Check::account(&source_account_key) + .lamports(Rent::default().minimum_balance(size_of::())) + .build(), + ], + ); + + // And the remaining amount on the source account must be 0. + + let account = result.get_account(&source_account_key); + assert!(account.is_some()); + + let account = account.unwrap(); + let token_account = spl_token::state::Account::unpack(&account.data).unwrap(); + assert_eq!(token_account.amount, 0); + + // And the amount on the destination account must be 0 since we transferred + // lamports directly to the account. + + let account = result.get_account(&destination_account_key); + assert!(account.is_some()); + + let account = account.unwrap(); + let token_account = spl_token::state::Account::unpack(&account.data).unwrap(); + assert_eq!(token_account.amount, 0); +} + +#[test] +fn unwrap_lamports_to_token_account() { + let native_mint = Pubkey::new_from_array(native_mint::ID); + let authority_key = Pubkey::new_unique(); + let non_native_mint = Pubkey::new_unique(); + + // native account: + // - amount: 2_000_000_000 + let source_account_key = Pubkey::new_unique(); + let source_account = create_token_account( + &native_mint, + &authority_key, + true, + 2_000_000_000, + &TOKEN_PROGRAM_ID, + ); + + // destination non-native account: + // - amount: 0 + let destination_account_key = Pubkey::new_unique(); + let destination_account = create_token_account( + &non_native_mint, + &authority_key, + false, + 0, + &TOKEN_PROGRAM_ID, + ); + + let instruction = unwrap_lamports_instruction( + &source_account_key, + &destination_account_key, + &authority_key, + None, + ) + .unwrap(); + + // It should succeed to unwrap 2_000_000_000 lamports. + + let result = mollusk().process_and_validate_instruction( + &instruction, + &[ + (source_account_key, source_account), + (destination_account_key, destination_account), + (authority_key, Account::default()), + ], + &[ + Check::success(), + Check::account(&destination_account_key) + .lamports( + Rent::default().minimum_balance(size_of::()) + 2_000_000_000, + ) + .build(), + Check::account(&source_account_key) + .lamports(Rent::default().minimum_balance(size_of::())) + .build(), + ], + ); + + // And the remaining amount on the source account must be 0. + + let account = result.get_account(&source_account_key); + assert!(account.is_some()); + + let account = account.unwrap(); + let token_account = spl_token::state::Account::unpack(&account.data).unwrap(); + assert_eq!(token_account.amount, 0); + + // And the amount on the destination account must be 0 since we transferred + // lamports directly to the account. + + let account = result.get_account(&destination_account_key); + assert!(account.is_some()); + + let account = account.unwrap(); + let token_account = spl_token::state::Account::unpack(&account.data).unwrap(); + assert_eq!(token_account.amount, 0); +} From 2bc848a8b1080afe7c977dce8c1044560889254c Mon Sep 17 00:00:00 2001 From: febo Date: Sat, 13 Sep 2025 00:16:37 +0100 Subject: [PATCH 10/11] Review comments --- p-token/src/processor/unwrap_lamports.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/p-token/src/processor/unwrap_lamports.rs b/p-token/src/processor/unwrap_lamports.rs index 661837db..004a7147 100644 --- a/p-token/src/processor/unwrap_lamports.rs +++ b/p-token/src/processor/unwrap_lamports.rs @@ -1,6 +1,6 @@ use { super::validate_owner, - crate::processor::{check_account_owner, U64_BYTES}, + crate::processor::{check_account_owner, unpack_amount}, pinocchio::{account_info::AccountInfo, program_error::ProgramError, ProgramResult}, pinocchio_token_interface::{ error::TokenError, @@ -18,11 +18,8 @@ pub fn process_unwrap_lamports(accounts: &[AccountInfo], instruction_data: &[u8] let maybe_amount = if likely(*has_amount == 0) { None - } else if maybe_amount.len() >= 8 { - // SAFETY: The slice is guaranteed to be at least 8 bytes long. - Some(u64::from_le_bytes(unsafe { - *(maybe_amount.as_ptr() as *const [u8; U64_BYTES]) - })) + } else if *has_amount == 1 { + Some(unpack_amount(maybe_amount)?) } else { return Err(TokenError::InvalidInstruction.into()); }; From 4db31a0073335f71eb4ebdc726653345d13eee93 Mon Sep 17 00:00:00 2001 From: febo Date: Sat, 13 Sep 2025 15:24:05 +0100 Subject: [PATCH 11/11] Update import --- p-token/src/processor/unwrap_lamports.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/p-token/src/processor/unwrap_lamports.rs b/p-token/src/processor/unwrap_lamports.rs index 004a7147..3bf91659 100644 --- a/p-token/src/processor/unwrap_lamports.rs +++ b/p-token/src/processor/unwrap_lamports.rs @@ -1,10 +1,11 @@ use { super::validate_owner, crate::processor::{check_account_owner, unpack_amount}, - pinocchio::{account_info::AccountInfo, program_error::ProgramError, ProgramResult}, + pinocchio::{ + account_info::AccountInfo, hint::likely, program_error::ProgramError, ProgramResult, + }, pinocchio_token_interface::{ error::TokenError, - likely, state::{account::Account, load_mut}, }, };