diff --git a/p-interface/src/instruction.rs b/p-interface/src/instruction.rs index 7ebcc074..749dd65e 100644 --- a/p-interface/src/instruction.rs +++ b/p-interface/src/instruction.rs @@ -498,6 +498,23 @@ 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: + /// + /// - `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 /// specified in sequence on the instruction data. Each instruction /// provides: diff --git a/p-token/src/entrypoint.rs b/p-token/src/entrypoint.rs index 9a0f4bd2..af3023be 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) } + // 45 - UnwrapLamports + 45 => { + #[cfg(feature = "logging")] + pinocchio::msg!("Instruction: UnwrapLamports"); + + process_unwrap_lamports(accounts, instruction_data) + } _ => Err(TokenError::InvalidInstruction.into()), } } 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); }; 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..3bf91659 --- /dev/null +++ b/p-token/src/processor/unwrap_lamports.rs @@ -0,0 +1,87 @@ +use { + super::validate_owner, + crate::processor::{check_account_owner, unpack_amount}, + pinocchio::{ + account_info::AccountInfo, hint::likely, 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 { + // 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 *has_amount == 1 { + Some(unpack_amount(maybe_amount)?) + } else { + return Err(TokenError::InvalidInstruction.into()); + }; + + 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)? }; + + // 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 + // 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(()) + } +} diff --git a/p-token/tests/unwrap_lamports.rs b/p-token/tests/unwrap_lamports.rs new file mode 100644 index 00000000..33dcca14 --- /dev/null +++ b/p-token/tests/unwrap_lamports.rs @@ -0,0 +1,629 @@ +mod setup; + +use { + crate::setup::TOKEN_PROGRAM_ID, + 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, + }, + }, + solana_account::Account, + solana_instruction::{error::InstructionError, AccountMeta, Instruction}, + solana_program_error::ProgramError, + solana_program_pack::Pack, + 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() + } +} + +/// Creates a Mollusk instance with the default feature set. +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: Option, +) -> 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![TokenInstruction::UnwrapLamports as u8]; + + 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, + data, + accounts, + }) +} + +#[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(); + + // 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, + 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. + + 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); +} + +#[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(); + + // 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, + Some(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. + + 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); +} + +#[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(); + + // 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, + Some(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, + ))], + ); +} + +#[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(); + + // 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, + Some(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. + + 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); +} + +#[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(); + 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 + Some(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, + ))], + ); +} + +#[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(); + + // 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, + Some(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, + ))], + ); +} + +#[test] +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, + Some(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(), + ], + ); + + 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); +} + +#[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(); + + // 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, + Some(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, + )], + ); +} + +#[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); +}