diff --git a/CHANGELOG.md b/CHANGELOG.md index ff1763d5a..f8cdfcd47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - For swap opportunities, the searcher sdks will now add a memo instruction to the bid transaction if the quote requester so desires. This allows the quote requester to track which on-chain transactions correspond to quotes they requested. [458](https://github.com/pyth-network/per/pull/458) +### Fixed + +- For swap opportunities, when a user wants to swap SOL but doesn't have enough funds, the sdk will never try to wrap (on behalf of the user) an amount exceeding the SOL balance of the user. + ## [Rust: 0.7.0, Python 0.22.0, Javascript 0.23.0] - 2025-03-25 ### Changed diff --git a/auction-server/src/api.rs b/auction-server/src/api.rs index 38c17bfc4..eb761832c 100644 --- a/auction-server/src/api.rs +++ b/auction-server/src/api.rs @@ -167,21 +167,21 @@ impl std::fmt::Display for InstructionError { write!( f, "Invalid from account in sol transfer instruction. Expected: {:?} found: {:?}", - found, expected + expected, found ) } InstructionError::InvalidToAccountTransferInstruction { expected, found } => { write!( f, "Invalid to account in sol transfer instruction. Expected: {:?} found: {:?}", - found, expected + expected, found ) } InstructionError::InvalidAmountTransferInstruction { expected, found } => { write!( f, "Invalid amount in sol transfer instruction. Expected: {:?} found: {:?}", - found, expected + expected, found ) } InstructionError::InvalidSyncNativeInstructionCount(address) => { @@ -208,42 +208,42 @@ impl std::fmt::Display for InstructionError { write!( f, "Invalid destination account in close account instruction. Expected: {:?} found: {:?}", - found, expected + expected, found ) } InstructionError::InvalidOwnerCloseAccountInstruction { expected, found } => { write!( f, "Invalid owner in close account instruction. Expected: {:?} found: {:?}", - found, expected + expected, found ) } InstructionError::InvalidMintInCreateAtaInstruction { expected, found } => { write!( f, "Invalid mint in create associated token account instruction. Expected: {:?} found: {:?}", - found, expected + expected, found ) } InstructionError::InvalidOwnerInCreateAtaInstruction { expected, found } => { write!( f, "Invalid owner in create associated token account instruction. Expected: {:?} found: {:?}", - found, expected + expected, found ) } InstructionError::InvalidPayerInCreateAtaInstruction { expected, found } => { write!( f, "Invalid payer in create associated token account instruction. Expected: {:?} found: {:?}", - found, expected + expected, found ) } InstructionError::InvalidTokenProgramInCreateAtaInstruction { expected, found } => { write!( f, "Invalid token program in create associated token account instruction. Expected: {:?} found: {:?}", - found, expected + expected, found ) } InstructionError::InvalidSystemProgramInCreateAtaInstruction(program) => { diff --git a/auction-server/src/auction/service/verification.rs b/auction-server/src/auction/service/verification.rs index bc2d0d427..574291d84 100644 --- a/auction-server/src/auction/service/verification.rs +++ b/auction-server/src/auction/service/verification.rs @@ -832,6 +832,7 @@ impl Service { tx: &VersionedTransaction, swap_data: &express_relay_svm::SwapArgs, swap_accounts: &SwapAccounts, + opportunity_swap_data: &OpportunitySvmProgramSwap, ) -> Result<(), RestError> { let transfer_instructions = self.extract_transfer_instructions(tx).await?; if transfer_instructions.len() > 1 { @@ -845,6 +846,11 @@ impl Service { // User have to wrap Sol if swap_accounts.mint_user == spl_token::native_mint::id() { + // Sometimes the user doesn't have enough SOL, but we want the transaction to fail in the Express Relay program with InsufficientUserFunds + // Therefore we allow the user to wrap less SOL than needed so it doesn't fail in the transfer instruction + let amount_user_to_wrap = + opportunity_swap_data.get_user_amount_to_wrap(swap_data.amount_user); + if transfer_instructions.len() != 1 { return Err(RestError::InvalidInstruction( None, @@ -874,11 +880,14 @@ impl Service { }, )); } - if swap_data.amount_user != transfer_instruction.lamports { + // todo: remove swap_data.amount_user != transfer_instruction.lamports once searchers have updated their sdk + if swap_data.amount_user != transfer_instruction.lamports + && amount_user_to_wrap != transfer_instruction.lamports + { return Err(RestError::InvalidInstruction( Some(transfer_instruction.index), InstructionError::InvalidAmountTransferInstruction { - expected: swap_data.amount_user, + expected: amount_user_to_wrap, found: transfer_instruction.lamports, }, )); @@ -1300,8 +1309,9 @@ impl Service { tx: &VersionedTransaction, swap_data: &express_relay_svm::SwapArgs, swap_accounts: &SwapAccounts, + opportunity_swap_data: &OpportunitySvmProgramSwap, ) -> Result<(), RestError> { - self.check_transfer_instruction(tx, swap_data, swap_accounts) + self.check_transfer_instruction(tx, swap_data, swap_accounts, opportunity_swap_data) .await?; if swap_accounts.mint_user == spl_token::native_mint::id() { // User has to wrap Sol @@ -1410,6 +1420,7 @@ impl Service { &bid_data.transaction, &swap_data, &swap_accounts, + opportunity_swap_data, ) .await?; @@ -1831,6 +1842,7 @@ mod tests { AccountMeta, Instruction, }, + native_token::LAMPORTS_PER_SOL, packet::PACKET_DATA_SIZE, pubkey::Pubkey, signature::Keypair, @@ -1985,7 +1997,7 @@ mod tests { token_program_searcher: spl_token::id(), fee_token: fee_token.clone(), referral_fee_bps, - user_mint_user_balance: 0, + user_mint_user_balance: LAMPORTS_PER_SOL, token_account_initialization_configs: TokenAccountInitializationConfigs::searcher_payer(), memo: None, @@ -2021,7 +2033,7 @@ mod tests { token_program_searcher: spl_token::id(), fee_token: fee_token.clone(), referral_fee_bps, - user_mint_user_balance: 0, + user_mint_user_balance: LAMPORTS_PER_SOL, token_account_initialization_configs: TokenAccountInitializationConfigs::searcher_payer(), memo: None, @@ -2057,7 +2069,7 @@ mod tests { token_program_searcher: spl_token::id(), fee_token: fee_token.clone(), referral_fee_bps, - user_mint_user_balance: 0, + user_mint_user_balance: LAMPORTS_PER_SOL, token_account_initialization_configs: TokenAccountInitializationConfigs { user_ata_mint_user: TokenAccountInitializationConfig::SearcherPayer, ..TokenAccountInitializationConfigs::searcher_payer() @@ -2095,7 +2107,7 @@ mod tests { token_program_searcher: spl_token::id(), fee_token: fee_token.clone(), referral_fee_bps, - user_mint_user_balance: 0, + user_mint_user_balance: LAMPORTS_PER_SOL, token_account_initialization_configs: TokenAccountInitializationConfigs::searcher_payer(), memo: None, @@ -2140,7 +2152,7 @@ mod tests { token_program_searcher: spl_token::id(), fee_token: fee_token.clone(), referral_fee_bps, - user_mint_user_balance: 0, + user_mint_user_balance: LAMPORTS_PER_SOL, token_account_initialization_configs: TokenAccountInitializationConfigs::searcher_payer(), memo: None, @@ -2177,7 +2189,7 @@ mod tests { token_program_searcher: spl_token::id(), fee_token: fee_token.clone(), referral_fee_bps, - user_mint_user_balance: 0, + user_mint_user_balance: LAMPORTS_PER_SOL, token_account_initialization_configs: TokenAccountInitializationConfigs { user_ata_mint_user: TokenAccountInitializationConfig::UserPayer, user_ata_mint_searcher: TokenAccountInitializationConfig::UserPayer, @@ -2216,7 +2228,7 @@ mod tests { token_program_searcher: spl_token::id(), fee_token, referral_fee_bps, - user_mint_user_balance: 0, + user_mint_user_balance: LAMPORTS_PER_SOL, token_account_initialization_configs: TokenAccountInitializationConfigs::searcher_payer(), memo: Some("memo".to_string()), diff --git a/auction-server/src/opportunity/entities/opportunity_svm.rs b/auction-server/src/opportunity/entities/opportunity_svm.rs index 07c0ffb5b..6647d2f57 100644 --- a/auction-server/src/opportunity/entities/opportunity_svm.rs +++ b/auction-server/src/opportunity/entities/opportunity_svm.rs @@ -19,9 +19,9 @@ use { }, ::express_relay::FeeToken as ProgramFeeToken, express_relay::state::FEE_SPLIT_PRECISION, - express_relay_api_types::{ - opportunity as api, - opportunity::QuoteTokensWithTokenPrograms, + express_relay_api_types::opportunity::{ + self as api, + QuoteTokensWithTokenPrograms, }, serde::{ Deserialize, @@ -29,8 +29,11 @@ use { }, solana_sdk::{ clock::Slot, + program_pack::Pack, pubkey::Pubkey, + rent::Rent, }, + spl_token_2022::state::Account as TokenAccount, std::ops::Deref, time::{ Duration, @@ -277,6 +280,28 @@ pub fn get_opportunity_swap_data(opp: &OpportunitySvm) -> &OpportunitySvmProgram } } +impl OpportunitySvmProgramSwap { + pub fn get_user_amount_to_wrap(&self, amount_user: u64) -> u64 { + let number_of_atas_paid_by_user = [ + &self.token_account_initialization_configs.user_ata_mint_user, + &self + .token_account_initialization_configs + .user_ata_mint_searcher, + ] + .iter() + .filter(|&&config| matches!(config, TokenAccountInitializationConfig::UserPayer)) + .count(); + + std::cmp::min( + amount_user, + self.user_mint_user_balance.saturating_sub( + number_of_atas_paid_by_user as u64 + * Rent::default().minimum_balance(TokenAccount::LEN), // todo: token2022 accounts can be bigger than this, this hack might not work for them + ), + ) + } +} + impl From for api::TokenAccountInitializationConfig { fn from(val: TokenAccountInitializationConfig) -> Self { match val { diff --git a/sdk/js/src/index.ts b/sdk/js/src/index.ts index c25d58a13..546253487 100644 --- a/sdk/js/src/index.ts +++ b/sdk/js/src/index.ts @@ -727,6 +727,7 @@ export class Client { opportunity.token_account_initialization_configs.user_ata_mint_user, }, memo: opportunity.memo ?? undefined, + userMintUserBalance: new anchor.BN(opportunity.user_mint_user_balance), }; } else { console.warn("Unsupported opportunity", opportunity); diff --git a/sdk/js/src/svm.ts b/sdk/js/src/svm.ts index 03969601a..34c875ef4 100644 --- a/sdk/js/src/svm.ts +++ b/sdk/js/src/svm.ts @@ -39,6 +39,7 @@ function getExpressRelayProgram(chain: string): PublicKey { } export const FEE_SPLIT_PRECISION = new anchor.BN(10000); +const RENT_TOKEN_ACCOUNT_LAMPORTS = 2039280; export function getConfigRouterPda( chain: string, @@ -250,6 +251,7 @@ function extractSwapInfo(swapOpportunity: OpportunitySvmSwap): { tokenProgramSearcher: PublicKey; tokenInitializationConfigs: TokenAccountInitializationConfigs; memo?: string; + userMintUserBalance: anchor.BN; } { const tokenProgramSearcher = swapOpportunity.tokens.tokenProgramSearcher; const tokenProgramUser = swapOpportunity.tokens.tokenProgramUser; @@ -263,6 +265,7 @@ function extractSwapInfo(swapOpportunity: OpportunitySvmSwap): { const router = swapOpportunity.routerAccount; const tokenInitializationConfigs = swapOpportunity.tokenInitializationConfigs; const memo = swapOpportunity.memo; + const userMintUserBalance = swapOpportunity.userMintUserBalance; return { searcherToken, tokenProgramSearcher, @@ -274,6 +277,7 @@ function extractSwapInfo(swapOpportunity: OpportunitySvmSwap): { router, tokenInitializationConfigs, memo, + userMintUserBalance, }; } @@ -426,6 +430,27 @@ function getBidAmountIncludingFees( return bidAmount; } +function getUserAmountToWrap( + amountUser: anchor.BN, + userMintUserBalance: anchor.BN, + tokenAccountInitializationConfigs: TokenAccountInitializationConfigs, +): anchor.BN { + const numberOfAtasPaidByUser = [ + tokenAccountInitializationConfigs.userAtaMintUser, + tokenAccountInitializationConfigs.userAtaMintSearcher, + ].filter((config) => config === "user_payer").length; + + return anchor.BN.min( + amountUser, + anchor.BN.max( + userMintUserBalance.sub( + new anchor.BN(numberOfAtasPaidByUser * RENT_TOKEN_ACCOUNT_LAMPORTS), + ), + new anchor.BN(0), + ), + ); +} + export async function constructSwapBid( tx: Transaction, searcher: PublicKey, @@ -436,8 +461,13 @@ export async function constructSwapBid( feeReceiverRelayer: PublicKey, relayerSigner: PublicKey, ): Promise { - const { userToken, searcherToken, user, tokenInitializationConfigs } = - extractSwapInfo(swapOpportunity); + const { + userToken, + searcherToken, + user, + tokenInitializationConfigs, + userMintUserBalance, + } = extractSwapInfo(swapOpportunity); if (swapOpportunity.memo) { tx.instructions.push(createMemoInstruction(swapOpportunity.memo)); @@ -462,27 +492,24 @@ export async function constructSwapBid( } if (userToken.equals(NATIVE_MINT)) { - if (swapOpportunity.tokens.type === "searcher_specified") { - tx.instructions.push( - ...getWrapSolInstructions( - searcher, - user, - getBidAmountIncludingFees(swapOpportunity, bidAmount), - false, - ), // this account creation is handled in the ata initialization section - ); - } else { - tx.instructions.push( - ...getWrapSolInstructions( - searcher, - user, - new anchor.BN( + const amountUser = + swapOpportunity.tokens.type === "user_specified" + ? new anchor.BN( swapOpportunity.tokens.userTokenAmountIncludingFees.toString(), - ), - ), - ); - } + ) + : getBidAmountIncludingFees(swapOpportunity, bidAmount); + + const amountUserToWrap = getUserAmountToWrap( + amountUser, + userMintUserBalance, + tokenInitializationConfigs, + ); + + tx.instructions.push( + ...getWrapSolInstructions(searcher, user, amountUserToWrap, false), // this account creation is handled in the ata initialization section + ); } + const swapInstruction = await constructSwapInstruction( searcher, swapOpportunity, diff --git a/sdk/js/src/types.ts b/sdk/js/src/types.ts index 01bf71cac..e08e6f633 100644 --- a/sdk/js/src/types.ts +++ b/sdk/js/src/types.ts @@ -3,6 +3,7 @@ import type { components } from "./serverTypes"; import { PublicKey, Transaction } from "@solana/web3.js"; import { OrderStateAndAddress } from "@kamino-finance/limo-sdk/dist/utils"; import { VersionedTransaction } from "@solana/web3.js"; +import * as anchor from "@coral-xyz/anchor"; /** * ERC20 token with contract address and amount @@ -158,6 +159,7 @@ export type OpportunitySvmSwap = { permissionAccount: PublicKey; routerAccount: PublicKey; userWalletAddress: PublicKey; + userMintUserBalance: anchor.BN; feeToken: "searcher_token" | "user_token"; referralFeeBps: number; platformFeeBps: number; diff --git a/sdk/python/express_relay/client.py b/sdk/python/express_relay/client.py index ec160f620..b1ce52c7f 100644 --- a/sdk/python/express_relay/client.py +++ b/sdk/python/express_relay/client.py @@ -60,6 +60,7 @@ from express_relay.svm.generated.express_relay.types.swap_args import SwapArgs from express_relay.svm.limo_client import LimoClient from express_relay.svm.token_utils import ( + RENT_TOKEN_ACCOUNT_LAMPORTS, create_associated_token_account_idempotent, get_ata, unwrap_sol, @@ -651,6 +652,32 @@ def get_token_accounts_to_create( ) ) + @staticmethod + def get_user_amount_to_wrap( + amount_user: int, + user_mint_user_balance: int, + token_account_initialization_configs: TokenAccountInitializationConfigs, + ) -> int: + number_of_atas_paid_by_user = len( + [ + x + for x in [ + token_account_initialization_configs.user_ata_mint_user, + token_account_initialization_configs.user_ata_mint_searcher, + ] + if x == "user_payer" + ] + ) + + return min( + amount_user, + max( + 0, + user_mint_user_balance + - number_of_atas_paid_by_user * RENT_TOKEN_ACCOUNT_LAMPORTS, + ), + ) + @staticmethod def extract_swap_info(swap_opportunity: SwapOpportunitySvm) -> SwapAccounts: token_program_searcher = swap_opportunity.tokens.token_program_searcher @@ -749,8 +776,13 @@ def get_svm_swap_instructions( ) if accs["user_token"] == WRAPPED_SOL_MINT: + amount_to_wrap_user = ExpressRelayClient.get_user_amount_to_wrap( + amount_user=amount_user, + user_mint_user_balance=swap_opportunity.user_mint_user_balance, + token_account_initialization_configs=swap_opportunity.token_account_initialization_configs, + ) instructions.extend( - wrap_sol(searcher, accs["user"], amount_user, create_ata=False) + wrap_sol(searcher, accs["user"], amount_to_wrap_user, create_ata=False) ) swap_ix = swap( { diff --git a/sdk/python/express_relay/models/svm.py b/sdk/python/express_relay/models/svm.py index d5560c7df..08734f389 100644 --- a/sdk/python/express_relay/models/svm.py +++ b/sdk/python/express_relay/models/svm.py @@ -360,6 +360,7 @@ class SwapOpportunitySvm(BaseOpportunitySvm): platform_fee_bps: int router_account: SvmAddress user_wallet_address: SvmAddress + user_mint_user_balance: int tokens: SwapTokensSearcherSpecified | SwapTokensUserSpecified token_account_initialization_configs: TokenAccountInitializationConfigs memo: str | None = Field(default=None) diff --git a/sdk/python/express_relay/svm/token_utils.py b/sdk/python/express_relay/svm/token_utils.py index 6f151099e..2c8ea9e3d 100644 --- a/sdk/python/express_relay/svm/token_utils.py +++ b/sdk/python/express_relay/svm/token_utils.py @@ -17,6 +17,8 @@ sync_native, ) +RENT_TOKEN_ACCOUNT_LAMPORTS = 2039280 + def get_ata( owner: Pubkey, token_mint_address: Pubkey, token_program_id: Pubkey diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs index a19f53c64..41f6bee7b 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -777,6 +777,7 @@ impl Biddable for api_types::opportunity::OpportunitySvm { } OpportunityParamsV1ProgramSvm::Swap { user_wallet_address, + user_mint_user_balance, tokens, fee_token, router_account, @@ -834,15 +835,20 @@ impl Biddable for api_types::opportunity::OpportunitySvm { fee_receiver_relayer: params.fee_receiver_relayer, referral_fee_bps, chain_id: opportunity_params.chain_id.clone(), - configs: token_account_initialization_configs, + configs: token_account_initialization_configs.clone(), }, )); if user_token == native_mint::id() { + let user_amount_to_wrap = svm::Svm::get_user_amount_to_wrap( + user_amount_including_fees, + user_mint_user_balance, + &token_account_initialization_configs, + ); instructions.extend(svm::Svm::get_wrap_sol_instructions( svm::GetWrapSolInstructionsParams { payer: params.payer, owner: user_wallet_address, - amount: user_amount_including_fees, + amount: user_amount_to_wrap, create_ata: false, }, )?); diff --git a/sdk/rust/src/svm.rs b/sdk/rust/src/svm.rs index 89ac840d5..22afafa9b 100644 --- a/sdk/rust/src/svm.rs +++ b/sdk/rust/src/svm.rs @@ -28,7 +28,9 @@ use { clock::Slot, hash::Hash, instruction::Instruction, + program_pack::Pack, pubkey::Pubkey, + rent::Rent, signature::Keypair, system_instruction::transfer, }, @@ -37,9 +39,12 @@ use { get_associated_token_address_with_program_id, instruction::create_associated_token_account_idempotent, }, - spl_token::instruction::{ - close_account, - sync_native, + spl_token::{ + instruction::{ + close_account, + sync_native, + }, + state::Account as TokenAccount, }, std::str::FromStr, }; @@ -469,6 +474,28 @@ impl Svm { }) } + pub fn get_user_amount_to_wrap( + amount_user: u64, + user_mint_user_balance: u64, + token_account_initialization_configs: &TokenAccountInitializationConfigs, + ) -> u64 { + let number_of_atas_paid_by_user = [ + &token_account_initialization_configs.user_ata_mint_user, + &token_account_initialization_configs.user_ata_mint_searcher, + ] + .iter() + .filter(|&&config| matches!(config, TokenAccountInitializationConfig::UserPayer)) + .count(); + + std::cmp::min( + amount_user, + user_mint_user_balance.saturating_sub( + number_of_atas_paid_by_user as u64 + * Rent::default().minimum_balance(TokenAccount::LEN), + ), + ) + } + /// Adjusts the bid amount in the case where the amount that needs to be provided by the searcher is specified and the fees are in the user token. /// In this case, searchers' bids represent how many tokens they would like to receive. /// However, for the searcher to receive `bidAmount`, the user needs to provide `bidAmount * (FEE_SPLIT_PRECISION / (FEE_SPLIT_PRECISION - fees))`