From 4a0d969df1b3a29788daac1879326e91d13c6e2a Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Mon, 30 Oct 2023 15:58:05 -0700 Subject: [PATCH 1/3] Add retries to buycrypto --- packages/common/src/store/buy-crypto/sagas.ts | 566 ++++++++++++------ 1 file changed, 374 insertions(+), 192 deletions(-) diff --git a/packages/common/src/store/buy-crypto/sagas.ts b/packages/common/src/store/buy-crypto/sagas.ts index 5af93ab141c..2f7286e29bf 100644 --- a/packages/common/src/store/buy-crypto/sagas.ts +++ b/packages/common/src/store/buy-crypto/sagas.ts @@ -6,12 +6,21 @@ import { getAssociatedTokenAddressSync } from '@solana/spl-token' import { + Keypair, LAMPORTS_PER_SOL, PublicKey, SystemProgram, TransactionInstruction } from '@solana/web3.js' -import { call, put, race, select, take, takeLatest } from 'typed-redux-saga' +import { + call, + delay, + put, + race, + select, + take, + takeLatest +} from 'typed-redux-saga' import { Name } from 'models/Analytics' import { ErrorLevel } from 'models/ErrorReporting' @@ -118,6 +127,165 @@ function* getBuyCryptoRemoteConfig(mint: MintName) { } } +function* swapSolForCrypto({ + wallet, + mint, + feePayer, + quoteResponse, + userbank +}: { + wallet: Keypair + mint: MintName + feePayer: PublicKey + userbank: PublicKey + quoteResponse: Awaited> +}) { + const audiusBackendInstance = yield* getContext('audiusBackendInstance') + // Create a memo + // See: https://github.com/solana-labs/solana-program-library/blob/d6297495ea4dcc1bd48f3efdd6e3bbdaef25a495/memo/js/src/index.ts#L27 + const memoInstruction = new TransactionInstruction({ + keys: [ + { + pubkey: wallet.publicKey, + isSigner: true, + isWritable: true + } + ], + programId: MEMO_PROGRAM_ID, + data: Buffer.from(`In-App $${mint.toUpperCase()} Purchase: Link by Stripe`) + }) + + // Create a temp wSOL account + const walletSolTokenAccount = getAssociatedTokenAddressSync( + NATIVE_MINT, + wallet.publicKey + ) + const createWSOLInstruction = + createAssociatedTokenAccountIdempotentInstruction( + feePayer, // fee payer + walletSolTokenAccount, // account to create + wallet.publicKey, // owner + NATIVE_MINT // mint + ) + + // Transfer the SOL to the wSOL account + const transferWSOLInstruction = SystemProgram.transfer({ + fromPubkey: wallet.publicKey, + toPubkey: walletSolTokenAccount, + lamports: + quoteResponse.swapMode === 'ExactIn' + ? BigInt(quoteResponse.inAmount) + : BigInt(quoteResponse.otherAmountThreshold) + }) + const syncNativeInstruction = createSyncNativeInstruction( + walletSolTokenAccount + ) + + // Swap the new SOL amount into the desired token amount + const { swapInstruction, addressLookupTableAddresses } = yield* call( + [jupiterInstance, jupiterInstance.swapInstructionsPost], + { + swapRequest: { + quoteResponse, + userPublicKey: wallet.publicKey.toBase58(), + destinationTokenAccount: userbank.toBase58(), + useSharedAccounts: true + } + } + ) + + // Close the temporary wSOL account + const closeWSOLInstruction = createCloseAccountInstruction( + walletSolTokenAccount, // account to close + feePayer, // fee destination + wallet.publicKey // owner + ) + + const instructions = [ + memoInstruction, + createWSOLInstruction, + transferWSOLInstruction, + syncNativeInstruction, + parseJupiterInstruction(swapInstruction), + closeWSOLInstruction + ] + const { transaction, addressLookupTableAccounts } = yield* call( + createVersionedTransaction, + audiusBackendInstance, + { + instructions, + lookupTableAddresses: addressLookupTableAddresses, + feePayer + } + ) + transaction.sign([wallet]) + + return yield* call(relayVersionedTransaction, audiusBackendInstance, { + transaction, + addressLookupTableAccounts, + skipPreflight: true + }) +} + +function* getSwapSolForCryptoResult({ + transactionSignature, + sourceWallet, + destinationTokenAccount, + mintAddress +}: { + transactionSignature: string + sourceWallet: PublicKey + destinationTokenAccount: PublicKey + mintAddress: string +}) { + const audiusBackendInstance = yield* getContext('audiusBackendInstance') + const connection = yield* call(getSolanaConnection, audiusBackendInstance) + // Get the resulting output token amount by fetching the transaction + // and comparing pre and post balances + const transaction = yield* call( + [connection, connection.getTransaction], + transactionSignature, + { maxSupportedTransactionVersion: 0 } + ) + let balanceChange = 0 + const walletAccountIndex = + transaction?.transaction.message.staticAccountKeys.findIndex((pubkey) => + pubkey.equals(sourceWallet) + ) + if (!walletAccountIndex) { + balanceChange = 0 + } else { + const beforeBalance = + transaction?.meta?.preBalances[walletAccountIndex] ?? 0 + const afterBalance = + transaction?.meta?.postBalances[walletAccountIndex] ?? 0 + balanceChange = afterBalance - beforeBalance + } + + const userbankAccountIndex = + transaction?.transaction.message.staticAccountKeys.findIndex((pubkey) => + pubkey.equals(destinationTokenAccount!) + ) + const beforeTokenBalance = + transaction?.meta?.preTokenBalances?.find( + (balance) => + balance.mint === mintAddress && + balance.accountIndex === userbankAccountIndex + )?.uiTokenAmount.uiAmount ?? 0 + const afterTokenBalance = + transaction?.meta?.postTokenBalances?.find( + (balance) => + balance.mint === mintAddress && + balance.accountIndex === userbankAccountIndex + )?.uiTokenAmount.uiAmount ?? 0 + const outputTokenChange = afterTokenBalance - beforeTokenBalance + + return { + balanceChange, + outputTokenChange + } +} + function* doBuyCryptoViaSol({ payload: { amount, mint, provider } }: ReturnType) { @@ -181,7 +349,7 @@ function* doBuyCryptoViaSol({ recordAnalytics: track }) - // Get required SOL purchase amount via ExactOut + minRent + // Get required SOL purchase amount via ExactOut + minRent. const quoteResponse = yield* call( [jupiterInstance, jupiterInstance.quoteGet], { @@ -197,231 +365,245 @@ function* doBuyCryptoViaSol({ [connection, connection.getMinimumBalanceForRentExemption], 0 ) + // otherAmountThreshold is the max input amount for the given slippage. + // Note: ignores any existing SOL as it should be recovered and swapped into USDC + const requiredAmount = Number(quoteResponse.otherAmountThreshold) + minRent + + // Get min stripe purchase amount using USDC as quote for $1 + const minQuote = yield* call([jupiterInstance, jupiterInstance.quoteGet], { + inputMint: TOKEN_LISTING_MAP.SOL.address, + outputMint: TOKEN_LISTING_MAP.USDC.address, + amount: 1 * 10 ** TOKEN_LISTING_MAP.USDC.decimals, + swapMode: 'ExactOut', + slippageBps: config.slippageBps + }) + const minAmount = Number(minQuote.otherAmountThreshold) + if (requiredAmount < minAmount) { + console.warn( + `Stripe requires minimum purchase of $1 (${minAmount} lamports). Required lamports: ${requiredAmount}` + ) + } + const lamportsToPurchase = Math.max(requiredAmount, minAmount) + + // Open Stripe Modal + // TODO: Support coinbase similarly? + yield* put( + initializeStripeModal({ + amount: (lamportsToPurchase / LAMPORTS_PER_SOL).toString(), + destinationCurrency: 'sol', + destinationWallet: wallet.publicKey.toBase58(), + onrampCanceled, + onrampFailed, + onrampSucceeded + }) + ) + yield* put(setVisibility({ modal: 'StripeOnRamp', visible: true })) + + // TODO: Make unified BuyCrypto analytics? + yield* call( + track, + make({ + eventName: + mint === 'audio' + ? Name.BUY_AUDIO_ON_RAMP_OPENED + : Name.BUY_USDC_ON_RAMP_OPENED, + provider + }) + ) + + // Get initial balance const existingBalance = yield* call( [connection, connection.getBalance], wallet.publicKey ) - // otherAmountThreshold is the max input amount for the given slippage - const requiredAmount = - Number(quoteResponse.otherAmountThreshold) + minRent - existingBalance - - if (requiredAmount <= 0) { - console.debug('SOL already sufficient for swap. Skipping purchasing step') - } else { - // Get min stripe purchase amount using USDC as quote for $1 - const minQuote = yield* call( - [jupiterInstance, jupiterInstance.quoteGet], - { - inputMint: TOKEN_LISTING_MAP.SOL.address, - outputMint: TOKEN_LISTING_MAP.USDC.address, - amount: 1 * 10 ** TOKEN_LISTING_MAP.USDC.decimals, - swapMode: 'ExactOut', - slippageBps: config.slippageBps - } - ) - const minAmount = Number(minQuote.otherAmountThreshold) - if (requiredAmount < minAmount) { - console.warn( - `Stripe requires minimum purchase of $1 (${minAmount} lamports). Required lamports: ${requiredAmount}` - ) - } + const initialBalance = BigInt(existingBalance) - const lamportsToPurchase = Math.max(requiredAmount, minAmount) - - // Open Stripe Modal - // TODO: Support coinbase similarly? - yield* put( - initializeStripeModal({ - amount: (lamportsToPurchase / LAMPORTS_PER_SOL).toString(), - destinationCurrency: 'sol', - destinationWallet: wallet.publicKey.toBase58(), - onrampCanceled, - onrampFailed, - onrampSucceeded - }) - ) - yield* put(setVisibility({ modal: 'StripeOnRamp', visible: true })) + // Wait for on ramp finish + const result = yield* race({ + failure: take(onrampFailed), + success: take(onrampSucceeded), + canceled: take(onrampCanceled) + }) - // TODO: Make unified BuyCrypto analytics? + // If the user didn't complete the on ramp flow or it failed, return early + if (result.canceled) { yield* call( track, make({ eventName: mint === 'audio' - ? Name.BUY_AUDIO_ON_RAMP_OPENED - : Name.BUY_USDC_ON_RAMP_OPENED, + ? Name.BUY_AUDIO_ON_RAMP_CANCELED + : Name.BUY_USDC_ON_RAMP_CANCELED, provider }) ) + yield* put(buyCryptoCanceled()) + return + } else if (result.failure) { + const errorString = result.failure.payload?.error + ? result.failure.payload.error.message + : 'Unknown error' - // Get initial balance - const initialBalance = BigInt(existingBalance) - - // Wait for on ramp finish - const result = yield* race({ - failure: take(onrampFailed), - success: take(onrampSucceeded), - canceled: take(onrampCanceled) - }) - - // If the user didn't complete the on ramp flow or it failed, return early - if (result.canceled) { - yield* call( - track, - make({ - eventName: - mint === 'audio' - ? Name.BUY_AUDIO_ON_RAMP_CANCELED - : Name.BUY_USDC_ON_RAMP_CANCELED, - provider - }) - ) - yield* put(buyCryptoCanceled()) - return - } else if (result.failure) { - const errorString = result.failure.payload?.error - ? result.failure.payload.error.message - : 'Unknown error' - - yield* call( - track, - make({ - eventName: - mint === 'audio' - ? Name.BUY_AUDIO_ON_RAMP_CANCELED - : Name.BUY_USDC_ON_RAMP_FAILURE, - provider, - error: errorString - }) - ) - throw new BuyCryptoError(BuyCryptoErrorCode.ON_RAMP_ERROR, errorString) - } - - // Record analytics yield* call( track, make({ eventName: mint === 'audio' - ? Name.BUY_AUDIO_ON_RAMP_SUCCESS - : Name.BUY_USDC_ON_RAMP_SUCCESS, - provider + ? Name.BUY_AUDIO_ON_RAMP_CANCELED + : Name.BUY_USDC_ON_RAMP_FAILURE, + provider, + error: errorString }) ) + throw new BuyCryptoError(BuyCryptoErrorCode.ON_RAMP_ERROR, errorString) + } - // Wait for the funds to come through - const newBalance = yield* call( - pollForBalanceChange, - audiusBackendInstance, - { - wallet: wallet.publicKey, - initialBalance, - retryDelayMs: config.retryDelayMs, - maxRetryCount: config.maxRetryCount - } - ) + // Record analytics + yield* call( + track, + make({ + eventName: + mint === 'audio' + ? Name.BUY_AUDIO_ON_RAMP_SUCCESS + : Name.BUY_USDC_ON_RAMP_SUCCESS, + provider + }) + ) - // Check that we got the requested amount of SOL - const purchasedAmount = newBalance - initialBalance - if (purchasedAmount < BigInt(lamportsToPurchase)) { - console.warn( - `Warning: Purchased SOL amount differs from expected. Actual: ${ - newBalance - initialBalance - }. Expected: ${lamportsToPurchase}.` - ) + // Wait for the funds to come through + const newBalance = yield* call( + pollForBalanceChange, + audiusBackendInstance, + { + wallet: wallet.publicKey, + initialBalance, + retryDelayMs: config.retryDelayMs, + maxRetryCount: config.maxRetryCount } - } + ) - // Create a memo - // See: https://github.com/solana-labs/solana-program-library/blob/d6297495ea4dcc1bd48f3efdd6e3bbdaef25a495/memo/js/src/index.ts#L27 - const memoInstruction = new TransactionInstruction({ - keys: [ - { - pubkey: wallet.publicKey, - isSigner: true, - isWritable: true - } - ], - programId: MEMO_PROGRAM_ID, - data: Buffer.from( - `In-App $${mint.toUpperCase()} Purchase: Link by Stripe` + // Check that we got the requested amount of SOL + const purchasedAmount = newBalance - initialBalance + if (purchasedAmount < BigInt(lamportsToPurchase)) { + console.warn( + `Warning: Purchased SOL amount differs from expected. Actual: ${ + newBalance - initialBalance + }. Expected: ${lamportsToPurchase}.` ) - }) + } - // Create a temp wSOL account - const walletSolTokenAccount = getAssociatedTokenAddressSync( - NATIVE_MINT, - wallet.publicKey - ) - const createWSOLInstruction = - createAssociatedTokenAccountIdempotentInstruction( - feePayer, // fee payer - walletSolTokenAccount, // account to create - wallet.publicKey, // owner - NATIVE_MINT // mint + // Try the swap a few times in hopes the price comes back if it slipped + let swapError = null + let swapTransactionSignature: string | null = null + let retryCount = 0 + // TODO: Put these into optimizely? + const maxRetryCount = 3 + const retryDelayMs = 3000 + do { + const { res, error } = yield* call(swapSolForCrypto, { + feePayer, + mint, + wallet, + userbank, + quoteResponse + }) + swapError = error + swapTransactionSignature = res + if (swapError && retryCount < maxRetryCount) { + console.error( + `Failed to swap: ${swapError}. Retrying ${ + retryCount + 1 + } of ${maxRetryCount}...` + ) + yield* delay(retryDelayMs) + } + } while (!!swapError && retryCount++ < maxRetryCount) + + // If the swap fails for the input amount, try again to get whatever we can + // for the user in USDC. + if (swapError) { + // Record the initial error and bubble to sentry + console.error( + `Failed to swap for requested ${mint.toUpperCase()} amount. Trying to salvage...`, + swapError ) + reportToSentry({ + level: ErrorLevel.Error, + error: new BuyCryptoError( + BuyCryptoErrorCode.SWAP_ERROR, + `Failed to swap SOL to ${mint.toUpperCase()}: ${swapError}` + ), + additionalInfo: { + mode: 'ExactOut', + wallet: wallet.publicKey.toBase58(), + userbank: userbank?.toBase58() + } + }) - // Transfer the SOL to the wSOL account - const transferWSOLInstruction = SystemProgram.transfer({ - fromPubkey: wallet.publicKey, - toPubkey: walletSolTokenAccount, - lamports: BigInt(quoteResponse.otherAmountThreshold) - }) - const syncNativeInstruction = createSyncNativeInstruction( - walletSolTokenAccount - ) + // Get the amount to swap using the new balance less the min required for rent + // Note: This disregards the existing SOL balance, but there shouldn't be any + const newBalance = yield* call( + [connection, connection.getBalance], + wallet.publicKey + ) + const salvageInputAmount = newBalance - minRent - // Swap the new SOL amount into the desired token amount - const { swapInstruction, addressLookupTableAddresses } = yield* call( - [jupiterInstance, jupiterInstance.swapInstructionsPost], - { - swapRequest: { - quoteResponse, - userPublicKey: wallet.publicKey.toBase58(), - destinationTokenAccount: userbank.toBase58(), - useSharedAccounts: true + // Get a quote for swapping the entire balance + const exactInQuote = yield* call( + [jupiterInstance, jupiterInstance.quoteGet], + { + inputMint: TOKEN_LISTING_MAP.SOL.address, + outputMint: TOKEN_LISTING_MAP.USDC.address, + amount: salvageInputAmount, + swapMode: 'ExactIn', + slippageBps: config.slippageBps } - } - ) - - // Close the temporary wSOL account - const closeWSOLInstruction = createCloseAccountInstruction( - walletSolTokenAccount, // account to close - feePayer, // fee destination - wallet.publicKey // owner - ) + ) - const instructions = [ - memoInstruction, - createWSOLInstruction, - transferWSOLInstruction, - syncNativeInstruction, - parseJupiterInstruction(swapInstruction), - closeWSOLInstruction - ] - const { transaction, addressLookupTableAccounts } = yield* call( - createVersionedTransaction, - audiusBackendInstance, - { - instructions, - lookupTableAddresses: addressLookupTableAddresses, + // Do the swap. Just do it once, slippage shouldn't be a + // concern since the quote is fresh and the tolerance is high. + const { res, error: recoveryError } = yield* call(swapSolForCrypto, { + quoteResponse: exactInQuote, + mint, + wallet, + userbank, feePayer + }) + if (recoveryError) { + throw new BuyCryptoError( + BuyCryptoErrorCode.SWAP_ERROR, + `Failed to recover ${mint.toUpperCase()} from SOL: ${recoveryError}` + ) + } else if (res) { + // Read the results of the swap from the transaction + const { balanceChange, outputTokenChange } = yield* call( + getSwapSolForCryptoResult, + { + transactionSignature: res, + sourceWallet: wallet.publicKey, + destinationTokenAccount: userbank, + mintAddress: outputToken.address + } + ) + console.info( + `Salvaged ${balanceChange} SOL into ${outputTokenChange} ${mint.toUpperCase()}` + ) + // TODO: Some UI here? } - ) - transaction.sign([wallet]) - - const { error } = yield* call( - relayVersionedTransaction, - audiusBackendInstance, - { - transaction, - addressLookupTableAccounts, - skipPreflight: true - } - ) - - if (error) { - throw new BuyCryptoError(BuyCryptoErrorCode.SWAP_ERROR, error) + } else if (swapTransactionSignature) { + // Read the results of the swap from the transaction + const { balanceChange, outputTokenChange } = yield* call( + getSwapSolForCryptoResult, + { + transactionSignature: swapTransactionSignature, + sourceWallet: wallet.publicKey, + destinationTokenAccount: userbank, + mintAddress: outputToken.address + } + ) + console.info( + `Succesfully swapped ${balanceChange} SOL into ${outputTokenChange} ${mint.toUpperCase()}: ${swapTransactionSignature}` + ) } // Record success From edf0a97fb233fb439090be2471f0e6c6fec2e8d9 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Mon, 30 Oct 2023 18:20:45 -0700 Subject: [PATCH 2/3] fix balance change fetching --- .../src/services/audius-backend/solana.ts | 73 ++++++++++- packages/common/src/store/buy-crypto/sagas.ts | 120 ++++++++---------- 2 files changed, 127 insertions(+), 66 deletions(-) diff --git a/packages/common/src/services/audius-backend/solana.ts b/packages/common/src/services/audius-backend/solana.ts index 15c09ebe546..9af36bcac76 100644 --- a/packages/common/src/services/audius-backend/solana.ts +++ b/packages/common/src/services/audius-backend/solana.ts @@ -2,12 +2,15 @@ import { AudiusLibs } from '@audius/sdk' import { Account, createTransferCheckedInstruction } from '@solana/spl-token' import { AddressLookupTableAccount, + GetVersionedTransactionConfig, Keypair, PublicKey, Transaction, TransactionInstruction, TransactionMessage, - VersionedTransaction + TransactionResponse, + VersionedTransaction, + VersionedTransactionResponse } from '@solana/web3.js' import BN from 'bn.js' @@ -551,3 +554,71 @@ export const createVersionedTransaction = async ( addressLookupTableAccounts } } + +/** + * Sometimes fetching a transaction can turn up empty if the transaction + * hasn't been finalized and indexed by the RPC yet. This method polls the + * transaction a few times before it gives up and returns null. + */ +export const pollForTransaction = async ( + audiusBackendInstance: AudiusBackend, + transactionSignature: string, + config: GetVersionedTransactionConfig, + options?: { maxRetryCount: number; retryDelayMs: number } +) => { + let retryCount = 0 + const maxRetryCount = options?.maxRetryCount ?? 20 + const retryDelayMs = options?.retryDelayMs ?? 1000 + let transaction: VersionedTransactionResponse | TransactionResponse | null = + null + const connection = await getSolanaConnection(audiusBackendInstance) + do { + console.debug( + `Fetching transaction ${transactionSignature}: attempt ${retryCount} of ${maxRetryCount}` + ) + transaction = await connection.getTransaction(transactionSignature, config) + await delay(retryDelayMs) + } while (transaction === null && retryCount++ < maxRetryCount) + return transaction +} + +/** + * Maps both the wallet and token balance changes into a map keyed by the + * public key address of each account. + */ +export const getBalanceChanges = ( + transaction: VersionedTransactionResponse | TransactionResponse +) => { + const balanceChanges: Record = {} + const preTokenBalances: Record = {} + const tokenBalanceChanges: Record = {} + + const staticAccountKeys = transaction.transaction.message.staticAccountKeys + for (let i = 0; i < staticAccountKeys.length; i++) { + const pubkey = transaction.transaction.message.staticAccountKeys[i] + balanceChanges[pubkey.toBase58()] = + (transaction.meta?.postBalances[i] ?? 0) - + (transaction.meta?.preBalances[i] ?? 0) + } + for (const tokenBalance of transaction.meta?.preTokenBalances ?? []) { + const account = staticAccountKeys[tokenBalance.accountIndex] + if (account) { + preTokenBalances[account.toBase58()] = BigInt( + tokenBalance.uiTokenAmount.amount + ) + } + } + for (const tokenBalance of transaction.meta?.postTokenBalances ?? []) { + const account = staticAccountKeys[tokenBalance.accountIndex] + if (account) { + const preTokenBalance = preTokenBalances[account.toBase58()] ?? BigInt(0) + const postTokenBalance = BigInt(tokenBalance.uiTokenAmount.amount) + const change = postTokenBalance - preTokenBalance + tokenBalanceChanges[account.toBase58()] = change + } + } + return { + balanceChanges, + tokenBalanceChanges + } +} diff --git a/packages/common/src/store/buy-crypto/sagas.ts b/packages/common/src/store/buy-crypto/sagas.ts index 2f7286e29bf..af8ae60f026 100644 --- a/packages/common/src/store/buy-crypto/sagas.ts +++ b/packages/common/src/store/buy-crypto/sagas.ts @@ -31,9 +31,11 @@ import { MintName, createUserBankIfNeeded, createVersionedTransaction, + getBalanceChanges, getRootSolanaAccount, getSolanaConnection, pollForBalanceChange, + pollForTransaction, relayVersionedTransaction } from 'services/index' import { @@ -227,62 +229,37 @@ function* swapSolForCrypto({ }) } +/** + * Gets the balance changes of the relevant accounts for a swap transaction + */ function* getSwapSolForCryptoResult({ transactionSignature, sourceWallet, - destinationTokenAccount, - mintAddress + destinationTokenAccount }: { transactionSignature: string sourceWallet: PublicKey destinationTokenAccount: PublicKey - mintAddress: string }) { const audiusBackendInstance = yield* getContext('audiusBackendInstance') - const connection = yield* call(getSolanaConnection, audiusBackendInstance) - // Get the resulting output token amount by fetching the transaction - // and comparing pre and post balances - const transaction = yield* call( - [connection, connection.getTransaction], + const transactionResponse = yield* call( + pollForTransaction, + audiusBackendInstance, transactionSignature, - { maxSupportedTransactionVersion: 0 } + { + maxSupportedTransactionVersion: 0 + } ) - let balanceChange = 0 - const walletAccountIndex = - transaction?.transaction.message.staticAccountKeys.findIndex((pubkey) => - pubkey.equals(sourceWallet) - ) - if (!walletAccountIndex) { - balanceChange = 0 - } else { - const beforeBalance = - transaction?.meta?.preBalances[walletAccountIndex] ?? 0 - const afterBalance = - transaction?.meta?.postBalances[walletAccountIndex] ?? 0 - balanceChange = afterBalance - beforeBalance + if (!transactionResponse) { + return {} } - const userbankAccountIndex = - transaction?.transaction.message.staticAccountKeys.findIndex((pubkey) => - pubkey.equals(destinationTokenAccount!) - ) - const beforeTokenBalance = - transaction?.meta?.preTokenBalances?.find( - (balance) => - balance.mint === mintAddress && - balance.accountIndex === userbankAccountIndex - )?.uiTokenAmount.uiAmount ?? 0 - const afterTokenBalance = - transaction?.meta?.postTokenBalances?.find( - (balance) => - balance.mint === mintAddress && - balance.accountIndex === userbankAccountIndex - )?.uiTokenAmount.uiAmount ?? 0 - const outputTokenChange = afterTokenBalance - beforeTokenBalance + const { balanceChanges, tokenBalanceChanges } = + getBalanceChanges(transactionResponse) return { - balanceChange, - outputTokenChange + balanceChange: balanceChanges[sourceWallet.toBase58()], + outputTokenChange: tokenBalanceChanges[destinationTokenAccount.toBase58()] } } @@ -519,27 +496,13 @@ function* doBuyCryptoViaSol({ } } while (!!swapError && retryCount++ < maxRetryCount) - // If the swap fails for the input amount, try again to get whatever we can - // for the user in USDC. + // If the swap fails to get the desired ExactOut amount, fall back to + // swapping the amount of SOL the user bought into the target token. if (swapError) { - // Record the initial error and bubble to sentry console.error( - `Failed to swap for requested ${mint.toUpperCase()} amount. Trying to salvage...`, + `Failed to swap for requested ${amount} ${mint.toUpperCase()}. Attempting to salvage all ${mint.toUpperCase()} possible...`, swapError ) - reportToSentry({ - level: ErrorLevel.Error, - error: new BuyCryptoError( - BuyCryptoErrorCode.SWAP_ERROR, - `Failed to swap SOL to ${mint.toUpperCase()}: ${swapError}` - ), - additionalInfo: { - mode: 'ExactOut', - wallet: wallet.publicKey.toBase58(), - userbank: userbank?.toBase58() - } - }) - // Get the amount to swap using the new balance less the min required for rent // Note: This disregards the existing SOL balance, but there shouldn't be any const newBalance = yield* call( @@ -572,7 +535,7 @@ function* doBuyCryptoViaSol({ if (recoveryError) { throw new BuyCryptoError( BuyCryptoErrorCode.SWAP_ERROR, - `Failed to recover ${mint.toUpperCase()} from SOL: ${recoveryError}` + `Failed to recover ${mint.toUpperCase()} from SOL: ${recoveryError}. Initial Swap Error: ${swapError}` ) } else if (res) { // Read the results of the swap from the transaction @@ -581,14 +544,33 @@ function* doBuyCryptoViaSol({ { transactionSignature: res, sourceWallet: wallet.publicKey, - destinationTokenAccount: userbank, - mintAddress: outputToken.address + destinationTokenAccount: userbank } ) console.info( - `Salvaged ${balanceChange} SOL into ${outputTokenChange} ${mint.toUpperCase()}` + `Salvaged ${ + balanceChange === undefined ? '?' : Math.abs(balanceChange) + } SOL into ${outputTokenChange ?? '?'} ${mint.toUpperCase()}: ${res}` ) + // TODO: Some UI here? + + // Even though we recovered some USDC, bubble the initial error as a + // failure if the amount salvaged wasn't enough + if ( + outputTokenChange === undefined || + outputTokenChange < BigInt(quoteResponse.outAmount) + ) { + throw new BuyCryptoError( + BuyCryptoErrorCode.SWAP_ERROR, + `Failed to swap SOL to ${mint.toUpperCase()}: ${swapError}` + ) + } + } else { + throw new BuyCryptoError( + BuyCryptoErrorCode.UNKNOWN, + 'Unknown error during recovery swap' + ) } } else if (swapTransactionSignature) { // Read the results of the swap from the transaction @@ -597,12 +579,20 @@ function* doBuyCryptoViaSol({ { transactionSignature: swapTransactionSignature, sourceWallet: wallet.publicKey, - destinationTokenAccount: userbank, - mintAddress: outputToken.address + destinationTokenAccount: userbank } ) console.info( - `Succesfully swapped ${balanceChange} SOL into ${outputTokenChange} ${mint.toUpperCase()}: ${swapTransactionSignature}` + `Succesfully swapped ${ + balanceChange === undefined ? '?' : Math.abs(balanceChange) + } SOL into ${ + outputTokenChange ?? '?' + } ${mint.toUpperCase()}: ${swapTransactionSignature}` + ) + } else { + throw new BuyCryptoError( + BuyCryptoErrorCode.UNKNOWN, + 'Unknown error during initial swap' ) } From 9327f4b726c2565ebf39e86e07c444b12dab1ccf Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Mon, 30 Oct 2023 18:30:17 -0700 Subject: [PATCH 3/3] check for slippage error --- packages/common/src/services/Jupiter.ts | 7 +++++++ packages/common/src/store/buy-crypto/sagas.ts | 14 +++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/common/src/services/Jupiter.ts b/packages/common/src/services/Jupiter.ts index e495b1170ed..ee43f169c54 100644 --- a/packages/common/src/services/Jupiter.ts +++ b/packages/common/src/services/Jupiter.ts @@ -1,6 +1,13 @@ import { createJupiterApiClient, Instruction } from '@jup-ag/api' import { PublicKey, TransactionInstruction } from '@solana/web3.js' +/** + * The error that gets returned if the slippage is exceeded + * @see https://github.com/jup-ag/jupiter-cpi/blob/5eb897736d294767200302efd070b16343d8c618/idl.json#L2910-L2913 + * @see https://station.jup.ag/docs/additional-topics/troubleshooting#swap-execution + */ +export const SLIPPAGE_TOLERANCE_EXCEEDED_ERROR = 6001 + export const parseJupiterInstruction = (instruction: Instruction) => { return new TransactionInstruction({ programId: new PublicKey(instruction.programId), diff --git a/packages/common/src/store/buy-crypto/sagas.ts b/packages/common/src/store/buy-crypto/sagas.ts index af8ae60f026..081e28e1d8a 100644 --- a/packages/common/src/store/buy-crypto/sagas.ts +++ b/packages/common/src/store/buy-crypto/sagas.ts @@ -24,7 +24,11 @@ import { import { Name } from 'models/Analytics' import { ErrorLevel } from 'models/ErrorReporting' -import { jupiterInstance, parseJupiterInstruction } from 'services/Jupiter' +import { + SLIPPAGE_TOLERANCE_EXCEEDED_ERROR, + jupiterInstance, + parseJupiterInstruction +} from 'services/Jupiter' import { IntKeys, MEMO_PROGRAM_ID, @@ -486,8 +490,12 @@ function* doBuyCryptoViaSol({ }) swapError = error swapTransactionSignature = res - if (swapError && retryCount < maxRetryCount) { - console.error( + if ( + swapError && + retryCount < maxRetryCount && + swapError.includes(`${SLIPPAGE_TOLERANCE_EXCEEDED_ERROR}`) + ) { + console.warn( `Failed to swap: ${swapError}. Retrying ${ retryCount + 1 } of ${maxRetryCount}...`