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" } diff --git a/clients/cli/src/clap_app.rs b/clients/cli/src/clap_app.rs index 86f2a23cc..ebeb55bb9 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, + UnwrapSol, } impl fmt::Display for CommandName { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { @@ -1721,6 +1722,53 @@ pub fn app<'a>( .nonce_args(true) .offline_args(), ) + .subcommand( + SubCommand::with_name(CommandName::UnwrapSol.into()) + .about("Unwrap SOL from a wrapped 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 SOL; 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( + 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(), + ) .subcommand( SubCommand::with_name(CommandName::Approve.into()) .about("Approve a delegate for a token account") diff --git a/clients/cli/src/command.rs b/clients/cli/src/command.rs index 7820d6061..43a6e3d02 100644 --- a/clients/cli/src/command.rs +++ b/clients/cli/src/command.rs @@ -2099,6 +2099,103 @@ async fn command_unwrap( }) } +async fn command_unwrap_sol( + config: &Config<'_>, + ui_amount: Amount, + 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(); + 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 mut balance = None; + + if !config.sign_only { + let account_data = config.get_account_checked(&source_account).await?; + + 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()); + } + } + + let account_state = StateWithExtensionsOwned::::unpack(account_data.data)?; + + 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 { + return Err("Error: The recipient address is not funded. \ + Add `--allow-unfunded-recipient` to complete the transfer." + .into()); + } + } + } + + 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( + &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 +4370,31 @@ 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::UnwrapSol, 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(); + + let allow_unfunded_recipient = arg_matches.is_present("allow_unfunded_recipient"); + + command_unwrap_sol( + config, + amount, + owner, + source, + recipient, + allow_unfunded_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..1c00463fa 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,6 +842,305 @@ 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_sol(test_validator, payer).await; + multisig_unwrap_sol(test_validator, payer).await; + wrap_unwrap_sol(test_validator, payer).await; +} + +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( + 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 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_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::UnwrapSol.into(), + "5.0", + &new_address.to_string(), + "--from", + &wrapped_address.to_string(), + "--allow-unfunded-recipient", + ], + ) + .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 balance = config.rpc_client.get_balance(&new_address).await.unwrap(); + assert_eq!(balance, amount); + + 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::UnwrapSol.into(), "ALL"], + ) + .await + .unwrap(); + + 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, 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_sol(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 new_account_balance = config.rpc_client.get_balance(&new_address).await.unwrap(); + assert_eq!(new_account_balance, amount); + 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) { 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( 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..5e5164433 --- /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 `owner` 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..65f04dae1 --- /dev/null +++ b/clients/js-legacy/src/instructions/unwrapLamports.ts @@ -0,0 +1,154 @@ +import { struct, u8 } from '@solana/buffer-layout'; +import type { AccountMeta, PublicKey, Signer } from '@solana/web3.js'; +import { TransactionInstruction } from '@solana/web3.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 `owner` 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.getSpan(instruction.data)) + 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(); + + 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), + }; +} 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'); + } +} 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..328797fb7 --- /dev/null +++ b/clients/js-legacy/test/e2e-2022/unwrapLamports.test.ts @@ -0,0 +1,110 @@ +import type { Connection, Signer } from '@solana/web3.js'; +import { PublicKey, 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 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( + 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); + + const sourceAccountInfo = await getAccount(connection, account1, undefined, TEST_PROGRAM_ID); + const sourceLamports = await connection.getBalance(account1); + expect(sourceAccountInfo.amount).to.eql(0n); + expect(sourceLamports).to.eql(wrappedAccountLamports); + }); +}); 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/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]>( diff --git a/clients/rust-legacy/tests/cpi_guard.rs b/clients/rust-legacy/tests/cpi_guard.rs index 3a4f02d1a..c2c3061fd 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,89 @@ 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 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(); + + // 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(&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(&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 +724,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 +809,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(); diff --git a/clients/rust-legacy/tests/unwrap_lamports.rs b/clients/rust-legacy/tests/unwrap_lamports.rs new file mode 100644 index 000000000..7cabacc2d --- /dev/null +++ b/clients/rust-legacy/tests/unwrap_lamports.rs @@ -0,0 +1,426 @@ +#![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, rent::Rent, signature::Signer, signer::keypair::Keypair, + transaction::TransactionError, transport::TransportError, + }, + spl_token_2022::extension::ExtensionType, + spl_token_2022_interface::{error::TokenError, state::Account}, + 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 = 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 { + 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(); + + 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( + &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(); + + 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] +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 = 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(), + &alice.pubkey(), + amount, + &[&alice_account], + ) + .await + .unwrap(); + } + TestMode::Regular => { + account_space = Account::get_packed_len(); + + token + .wrap_with_mutable_ownership( + &alice_account.pubkey(), + &alice.pubkey(), + amount, + &[&alice_account], + ) + .await + .unwrap(); + } + } + let rent_exempt_lamports = Rent::default().minimum_balance(account_space); + + 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(); + + 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( + &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(); + + 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] +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 = 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 => { + 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(); + + 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, + &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] +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; +} 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": [ diff --git a/interface/src/instruction.rs b/interface/src/instruction.rs index cabd7ce56..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}, }; @@ -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_u64_fromval"))] + 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 } @@ -1088,6 +1114,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) @@ -2063,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_spl_token_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::*}; @@ -2437,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(); 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 6965f7da1..89a3bb83f 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 diff --git a/program/src/pod_instruction.rs b/program/src/pod_instruction.rs index 308c0cd04..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> { @@ -131,6 +133,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 +164,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 +228,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 +636,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; diff --git a/program/src/processor.rs b/program/src/processor.rs index 479621926..d4d93b8ec 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,77 @@ 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.base.amount.into(), 0), + _ => return Err(ProgramError::InvalidInstructionData), + }; + + if !source_account.base.is_native() { + return Err(TokenError::NonNativeNotSupported.into()); + } + + Self::validate_owner( + program_id, + &source_account.base.owner, + authority_info, + authority_info_data_len, + account_info_iter.as_slice(), + )?; + + if let Ok(cpi_guard) = source_account.get_extension::() { + if cpi_guard.lock_cpi.into() && in_cpi() { + return Err(TokenError::CpiGuardTransferBlocked.into()); + } + } + + if amount == 0 { + check_program_account(source_account_info.owner) + } else { + 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) + .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)?; + } + 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 +2014,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 +2117,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 +2250,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 +3832,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 lamports balance change... + assert_eq!(account_account.lamports(), account_minimum_balance()); + assert_eq!( + account2_account.lamports(), + zero_space_rent_exempt_balance + 500 + 500 + ); + } + + #[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(500) lamports + let instruction = unwrap_lamports( + &program_id, + account_info.key, + account_info.key, + owner_info.key, + &[], + Some(500), + ) + .unwrap(); + assert_eq!( + Ok(()), + Processor::process( + &instruction.program_id, + &[ + account_info.clone(), + account_info.clone(), + owner_info.clone(), + ], + &instruction.data, + ) + ); + // 500 amount balance change... + let account = Account::unpack_unchecked(&account_info.try_borrow_data().unwrap()).unwrap(); + assert_eq!(account.amount, 500); + // no lamport balance change... + assert_eq!(account_info.lamports(), 1000 + account_minimum_balance()); + + // insufficient funds + let instruction = unwrap_lamports( + &program_id, + account_info.key, + account_info.key, + owner_info.key, + &[], + Some(501), + ) + .unwrap(); + assert_eq!( + Err(TokenError::InsufficientFunds.into()), + Processor::process( + &instruction.program_id, + &[ + account_info.clone(), + account_info.clone(), + owner_info.clone(), + ], + &instruction.data, + ) + ); + + // 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(500), + ) + .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(500), + ) + .unwrap(); + assert_eq!( + Err(TokenError::OwnerMismatch.into()), + Processor::process( + &instruction.program_id, + &[ + account_info.clone(), + account_info.clone(), + owner2_info.clone(), + ], + &instruction.data, + ) + ); + + // 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, + ) + ); + // 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] fn test_mintable_token_with_zero_supply() { let program_id = crate::id();