From e4d5af0e1b4507f3b504b76e0ffaed1830041f83 Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Thu, 24 Apr 2025 11:37:36 -0500 Subject: [PATCH 01/22] Init commit --- learnings.md | 65 +++++ .../common/src/api/tan-query/useSwapTokens.ts | 265 ++++++++++++++++++ .../src/api/tan-query/useTokenExchangeRate.ts | 119 ++++++++ .../src/services/JupiterTokenExchange.ts | 106 +++++++ .../buy-sell-modal/BuySellModal.tsx | 121 +++++++- .../src/components/buy-sell-modal/BuyTab.tsx | 129 +++++++-- .../src/components/buy-sell-modal/SellTab.tsx | 131 +++++++-- .../buy-sell-modal/hooks/useTokenSwapForm.ts | 171 +++++++++++ .../components/CashWallet.tsx | 19 +- plan.md | 121 ++++++++ 10 files changed, 1195 insertions(+), 52 deletions(-) create mode 100644 learnings.md create mode 100644 packages/common/src/api/tan-query/useSwapTokens.ts create mode 100644 packages/common/src/api/tan-query/useTokenExchangeRate.ts create mode 100644 packages/common/src/services/JupiterTokenExchange.ts create mode 100644 packages/web/src/components/buy-sell-modal/hooks/useTokenSwapForm.ts create mode 100644 plan.md diff --git a/learnings.md b/learnings.md new file mode 100644 index 00000000000..69b2dbb676b --- /dev/null +++ b/learnings.md @@ -0,0 +1,65 @@ +# Learnings from BuySellModal Implementation + +## Code Organization & Structure + +1. **Shared Code Should Not Reference Platform-Specific Code**: + + - Don't import from `@audius/web` or `@audius/mobile` packages in code that should be shared. + - Instead, place shared functionality in the `@audius/common` package and import from there. + +2. **Organize by Function, Not by Type**: + + - Group related functionality together in service files, rather than splitting by type. + - Example: Jupiter token exchange functionality belongs in a dedicated service, not mixed with other token utilities. + +3. **Respect Existing Patterns**: + - Use `tan-query` hooks for data fetching and mutations when possible. + - Look for existing hooks before creating new ones (e.g., `useAudioBalance`, `useUSDCBalance`). + +## Code Modification Best Practices + +1. **Don't Modify Unrelated Code**: + + - When implementing new features, only change code directly related to the task. + - Avoid refactoring unrelated code, even if it seems beneficial. + +2. **Don't Delete Existing Code Without Clear Direction**: + + - Preserve existing code unless explicitly instructed to remove it. + - If uncertain, create new files rather than modifying existing ones. + +3. **Create New Files When Appropriate**: + - When adding substantial new functionality, create dedicated files. + - Example: Creating `JupiterTokenExchange.ts` for token exchange functionality. + +## Technical Learnings + +1. **Jupiter Integration**: + + - Use direct access to Jupiter API via `@jup-ag/api` for cross-platform compatibility. + - Make token swap functionality platform-agnostic. + +2. **Remote Config Access**: + + - Be careful with circular dependencies when accessing remote config. + - Default values are acceptable for first implementations when config access is problematic. + +3. **Proper Type Definitions**: + - Define and export proper types for API parameters and responses. + - Use existing type definitions where available. + +## Implementation Strategy + +1. **Break Down Complex Tasks**: + + - Implement one component at a time (e.g., exchange rate hook before swap hook). + - Follow the defined plan to maintain progress tracking. + +2. **Reuse Existing Code**: + + - Check for existing hooks and services before implementing new ones. + - Leverage existing patterns in the codebase. + +3. **Optimize for Maintainability**: + - Make code easy to maintain by separating concerns. + - Keep components focused on their specific responsibilities. diff --git a/packages/common/src/api/tan-query/useSwapTokens.ts b/packages/common/src/api/tan-query/useSwapTokens.ts new file mode 100644 index 00000000000..b2569dfbea5 --- /dev/null +++ b/packages/common/src/api/tan-query/useSwapTokens.ts @@ -0,0 +1,265 @@ +import { createJupiterApiClient, Instruction } from '@jup-ag/api' +import { + createAssociatedTokenAccountInstruction, + getAssociatedTokenAddressSync +} from '@solana/spl-token' +import { PublicKey, Transaction, TransactionInstruction } from '@solana/web3.js' +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { useAudiusQueryContext } from '~/audius-query/AudiusQueryContext' +import { Feature } from '~/models/ErrorReporting' +import { + SLIPPAGE_TOLERANCE_EXCEEDED_ERROR, + parseJupiterInstruction +} from '~/services/Jupiter' +import { + getJupiterQuote, + JupiterQuoteParams +} from '~/services/JupiterTokenExchange' +import { TOKEN_LISTING_MAP } from '~/store/ui/buy-audio/constants' + +export enum SwapStatus { + IDLE = 'IDLE', + GETTING_QUOTE = 'GETTING_QUOTE', + PREPARING_TRANSACTION = 'PREPARING_TRANSACTION', + CONFIRMING_TRANSACTION = 'CONFIRMING_TRANSACTION', + SUCCESS = 'SUCCESS', + ERROR = 'ERROR' +} + +export enum SwapErrorType { + INSUFFICIENT_BALANCE = 'INSUFFICIENT_BALANCE', + SLIPPAGE_EXCEEDED = 'SLIPPAGE_EXCEEDED', + TRANSACTION_FAILED = 'TRANSACTION_FAILED', + WALLET_ERROR = 'WALLET_ERROR', + QUOTE_FAILED = 'QUOTE_FAILED', + UNKNOWN = 'UNKNOWN' +} + +export type SwapTokensParams = Omit & { + /** + * Slippage tolerance in basis points (e.g., 50 = 0.5%) + * Defaults to 50 if not provided + */ + slippageBps?: number +} + +export type SwapTokensResult = { + status: SwapStatus + signature?: string + error?: { + type: SwapErrorType + message: string + } + inputAmount?: { + amount: number + uiAmount: number + } + outputAmount?: { + amount: number + uiAmount: number + } +} + +/** + * Hook for executing token swaps using Jupiter + */ +export const useSwapTokens = () => { + const queryClient = useQueryClient() + const { solanaWalletService, reportToSentry, audiusSdk } = + useAudiusQueryContext() + + return useMutation({ + mutationFn: async (params): Promise => { + try { + // Default slippage is 50 basis points (0.5%) + const slippageBps = params.slippageBps ?? 50 + + // Step 1: Get the wallet keypair + const keypair = await solanaWalletService.getKeypair() + if (!keypair) { + throw new Error('Failed to get wallet keypair') + } + + // Step 2: Get a quote from Jupiter + let quoteResult + try { + quoteResult = await getJupiterQuote({ + ...params, + slippageBps + }) + } catch (error) { + reportToSentry({ + name: 'JupiterSwapQuoteError', + error: error as Error, + feature: Feature.TanQuery, + additionalInfo: { params } + }) + return { + status: SwapStatus.ERROR, + error: { + type: SwapErrorType.QUOTE_FAILED, + message: 'Failed to get swap quote' + } + } + } + + // Step 3: Initialize Jupiter client for swap instructions + const jupiter = createJupiterApiClient() + const outputToken = TOKEN_LISTING_MAP[params.outputTokenSymbol] + const userPublicKey = new PublicKey(keypair.publicKey.toBase58()) + + // Step 4: Check if user has an associated token account for the output token + // and create one if it doesn't exist + const sdk = await audiusSdk() + const connection = sdk.services.solanaClient.connection + + // Get destination token account address + const destinationTokenAccount = getAssociatedTokenAddressSync( + new PublicKey(outputToken.address), + userPublicKey + ) + + // Check if the associated token account exists + let destinationTokenAccountExists = false + try { + await connection.getAccountInfo(destinationTokenAccount) + destinationTokenAccountExists = true + } catch (e) { + destinationTokenAccountExists = false + } + + // Step 5: Get swap instructions from Jupiter + const swapResponse = await jupiter.swapInstructionsPost({ + swapRequest: { + quoteResponse: quoteResult.quote, + userPublicKey: userPublicKey.toString(), + destinationTokenAccount: destinationTokenAccount.toString(), + wrapAndUnwrapSol: true, + useSharedAccounts: true + } + }) + + // Step 6: Build the transaction + const instructions: TransactionInstruction[] = [] + + // If destination token account doesn't exist, create it + if (!destinationTokenAccountExists) { + instructions.push( + createAssociatedTokenAccountInstruction( + userPublicKey, + destinationTokenAccount, + userPublicKey, + new PublicKey(outputToken.address) + ) + ) + } + + // Add Jupiter instructions + const jupiterInstructions = [ + swapResponse.tokenLedgerInstruction, + ...swapResponse.computeBudgetInstructions, + ...swapResponse.setupInstructions, + swapResponse.swapInstruction, + swapResponse.cleanupInstruction + ] + .filter( + (instruction): instruction is Instruction => + instruction !== undefined + ) + .map(parseJupiterInstruction) + + instructions.push(...jupiterInstructions) + + // Step 7: Create and sign the transaction + const transaction = new Transaction() + transaction.add(...instructions) + transaction.feePayer = userPublicKey + + // Get the latest blockhash + const { blockhash } = await connection.getLatestBlockhash() + transaction.recentBlockhash = blockhash + + // Sign the transaction + transaction.sign(keypair) + + // Step 8: Send the transaction + const signature = await connection.sendRawTransaction( + transaction.serialize(), + { skipPreflight: false } + ) + + // Step 9: Confirm the transaction + try { + await connection.confirmTransaction(signature, 'confirmed') + + // Invalidate balance queries + queryClient.invalidateQueries({ queryKey: ['audioBalance'] }) + queryClient.invalidateQueries({ queryKey: ['usdcBalance'] }) + + return { + status: SwapStatus.SUCCESS, + signature, + inputAmount: quoteResult.inputAmount, + outputAmount: quoteResult.outputAmount + } + } catch (error) { + // Check if error is due to slippage + if ( + error instanceof Error && + error.message.includes(SLIPPAGE_TOLERANCE_EXCEEDED_ERROR.toString()) + ) { + return { + status: SwapStatus.ERROR, + signature, + error: { + type: SwapErrorType.SLIPPAGE_EXCEEDED, + message: 'Slippage tolerance exceeded' + }, + inputAmount: quoteResult.inputAmount, + outputAmount: quoteResult.outputAmount + } + } + + reportToSentry({ + name: 'JupiterSwapConfirmationError', + error: error as Error, + feature: Feature.TanQuery, + additionalInfo: { signature, params } + }) + + return { + status: SwapStatus.ERROR, + signature, + error: { + type: SwapErrorType.TRANSACTION_FAILED, + message: 'Transaction failed' + }, + inputAmount: quoteResult.inputAmount, + outputAmount: quoteResult.outputAmount + } + } + } catch (error) { + reportToSentry({ + name: 'JupiterSwapError', + error: error as Error, + feature: Feature.TanQuery, + additionalInfo: { params } + }) + + return { + status: SwapStatus.ERROR, + error: { + type: SwapErrorType.UNKNOWN, + message: error instanceof Error ? error.message : 'Unknown error' + } + } + } + }, + onMutate: () => { + return { + status: SwapStatus.GETTING_QUOTE + } + } + }) +} diff --git a/packages/common/src/api/tan-query/useTokenExchangeRate.ts b/packages/common/src/api/tan-query/useTokenExchangeRate.ts new file mode 100644 index 00000000000..a15aea6779e --- /dev/null +++ b/packages/common/src/api/tan-query/useTokenExchangeRate.ts @@ -0,0 +1,119 @@ +import { useMemo } from 'react' + +import { QuoteResponse } from '@jup-ag/api' +import { useQuery } from '@tanstack/react-query' + +import { + JupiterTokenSymbol, + getJupiterQuote +} from '~/services/JupiterTokenExchange' + +import { QueryOptions, type QueryKey } from './types' + +export type TokenExchangeRateParams = { + inputTokenSymbol: JupiterTokenSymbol + outputTokenSymbol: JupiterTokenSymbol + inputAmount?: number + swapMode?: 'ExactIn' | 'ExactOut' +} + +export type TokenExchangeRateResponse = { + rate: number + inputAmount: { + amount: number + uiAmount: number + } + outputAmount: { + amount: number + uiAmount: number + } + priceImpactPct: number + quote: QuoteResponse +} + +// Define exchange rate query key +export const getTokenExchangeRateQueryKey = ({ + inputTokenSymbol, + outputTokenSymbol, + inputAmount, + swapMode +}: TokenExchangeRateParams) => + [ + 'tokenExchangeRate', + inputTokenSymbol, + outputTokenSymbol, + inputAmount ?? 1, + swapMode ?? 'ExactIn' + ] as unknown as QueryKey + +/** + * Hook to get the exchange rate between two tokens using Jupiter + * + * @param params Parameters for the token exchange rate query + * @param options Optional query configuration + * @returns The exchange rate data + */ +export const useTokenExchangeRate = ( + params: TokenExchangeRateParams, + options?: QueryOptions +) => { + // Default to 1 unit of input token if no amount specified + const inputAmount = params.inputAmount ?? 1 + + // Get appropriate slippage value based on swap direction + const slippageBps = useMemo(() => { + // Default slippage is 50 basis points (0.5%) + // We're not using remote config for now to avoid dependency issues + return 50 + }, []) + + return useQuery({ + queryKey: getTokenExchangeRateQueryKey({ + inputTokenSymbol: params.inputTokenSymbol, + outputTokenSymbol: params.outputTokenSymbol, + inputAmount, + swapMode: params.swapMode + }), + queryFn: async () => { + try { + // Get quote from Jupiter using the shared service + const quoteResult = await getJupiterQuote({ + inputTokenSymbol: params.inputTokenSymbol, + outputTokenSymbol: params.outputTokenSymbol, + inputAmount, + slippageBps, + swapMode: params.swapMode ?? 'ExactIn', + onlyDirectRoutes: false + }) + + // Calculate exchange rate (how many output tokens per 1 input token) + const rate = + quoteResult.outputAmount.uiAmount / quoteResult.inputAmount.uiAmount + + // Calculate price impact percentage + const priceImpactPct = + quoteResult.quote.priceImpactPct !== undefined + ? quoteResult.quote.priceImpactPct + : 0 + + return { + rate, + inputAmount: quoteResult.inputAmount, + outputAmount: quoteResult.outputAmount, + priceImpactPct, + quote: quoteResult.quote + } + } catch (error) { + console.error('Failed to fetch token exchange rate:', error) + throw error + } + }, + // Stale time of 30 seconds - rates are fetched frequently but not too often + staleTime: 30 * 1000, + // Retain cached data for 1 minute + gcTime: 60 * 1000, + // Default to enabled + ...options, + enabled: options?.enabled !== false + }) +} diff --git a/packages/common/src/services/JupiterTokenExchange.ts b/packages/common/src/services/JupiterTokenExchange.ts new file mode 100644 index 00000000000..c6991b48a58 --- /dev/null +++ b/packages/common/src/services/JupiterTokenExchange.ts @@ -0,0 +1,106 @@ +import { createJupiterApiClient, QuoteResponse } from '@jup-ag/api' + +import { TOKEN_LISTING_MAP } from '~/store/ui/buy-audio/constants' +import { convertBigIntToAmountObject } from '~/utils' + +// Define JupiterTokenSymbol type here since we can't import it directly +export type JupiterTokenSymbol = keyof typeof TOKEN_LISTING_MAP + +// Jupiter API client singleton (lazy loaded) +let jupiterClient: ReturnType | null = null + +export const getJupiterClient = () => { + if (!jupiterClient) { + jupiterClient = createJupiterApiClient() + } + return jupiterClient +} + +export type JupiterQuoteParams = { + inputTokenSymbol: JupiterTokenSymbol + outputTokenSymbol: JupiterTokenSymbol + inputAmount: number + slippageBps: number + swapMode?: 'ExactIn' | 'ExactOut' + onlyDirectRoutes?: boolean +} + +export type JupiterQuoteResult = { + inputAmount: { + amount: number + amountString: string + uiAmount: number + uiAmountString: string + } + outputAmount: { + amount: number + amountString: string + uiAmount: number + uiAmountString: string + } + otherAmountThreshold: { + amount: number + amountString: string + uiAmount: number + uiAmountString: string + } + quote: QuoteResponse +} + +/** + * Gets a quote from Jupiter for an exchange between tokens + */ +export const getJupiterQuote = async ({ + inputTokenSymbol, + outputTokenSymbol, + inputAmount, + slippageBps, + swapMode = 'ExactIn', + onlyDirectRoutes = false +}: JupiterQuoteParams): Promise => { + const inputToken = TOKEN_LISTING_MAP[inputTokenSymbol] + const outputToken = TOKEN_LISTING_MAP[outputTokenSymbol] + + if (!inputToken || !outputToken) { + throw new Error( + `Tokens not found: ${inputTokenSymbol} => ${outputTokenSymbol}` + ) + } + + // Calculate amount with proper decimal precision + const amount = + swapMode === 'ExactIn' + ? Math.ceil(inputAmount * 10 ** inputToken.decimals) + : Math.floor(inputAmount * 10 ** outputToken.decimals) + + // Get quote from Jupiter + const jupiter = getJupiterClient() + const quote = await jupiter.quoteGet({ + inputMint: inputToken.address, + outputMint: outputToken.address, + amount, + slippageBps, + swapMode, + onlyDirectRoutes + }) + + if (!quote) { + throw new Error('Failed to get Jupiter quote') + } + + return { + inputAmount: convertBigIntToAmountObject( + BigInt(quote.inAmount), + inputToken.decimals + ), + outputAmount: convertBigIntToAmountObject( + BigInt(quote.outAmount), + outputToken.decimals + ), + otherAmountThreshold: convertBigIntToAmountObject( + BigInt(quote.otherAmountThreshold), + swapMode === 'ExactIn' ? outputToken.decimals : inputToken.decimals + ), + quote + } +} diff --git a/packages/web/src/components/buy-sell-modal/BuySellModal.tsx b/packages/web/src/components/buy-sell-modal/BuySellModal.tsx index aeb1361a3b2..262c1b7a91f 100644 --- a/packages/web/src/components/buy-sell-modal/BuySellModal.tsx +++ b/packages/web/src/components/buy-sell-modal/BuySellModal.tsx @@ -1,6 +1,7 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useState, useContext } from 'react' import { buySellMessages as messages } from '@audius/common/messages' +import { useSwapTokens } from '@audius/common/src/api/tan-query/useSwapTokens' import { useBuySellModal } from '@audius/common/store' import { Button, @@ -17,9 +18,12 @@ import { } from '@audius/harmony' import { useTheme } from '@emotion/react' +import LoadingSpinner from 'components/loading-spinner/LoadingSpinner' +import { ToastContext } from 'components/toast/ToastContext' + import { BuyTab } from './BuyTab' import { SellTab } from './SellTab' -import { SUPPORTED_TOKEN_PAIRS, TOKENS } from './constants' +import { SUPPORTED_TOKEN_PAIRS } from './constants' import { BuySellTab } from './types' // import { useIsMobile } from 'hooks/useIsMobile' // Keep for potential mobile-specific adjustments - Removing for now @@ -32,29 +36,102 @@ type TabOption = { export const BuySellModal = () => { const { isOpen, onClose } = useBuySellModal() const { spacing, color } = useTheme() + const { toast } = useContext(ToastContext) // const isMobile = useIsMobile() // Keep for potential mobile-specific adjustments - Removing for now const [activeTab, setActiveTab] = useState('buy') // selectedPairIndex will be used in future when multiple token pairs are supported const [selectedPairIndex] = useState(0) + // Transaction state + const [transactionData, setTransactionData] = useState<{ + inputAmount: number + outputAmount: number + isValid: boolean + } | null>(null) + + // Get the swap tokens mutation + const { + mutate: swapTokens, + status: swapStatus, + error: swapError + } = useSwapTokens() + const tabs: TabOption[] = [ { key: 'buy', text: messages.buy }, { key: 'sell', text: messages.sell } ] - // Update token balances (placeholder - would connect to wallet) - useEffect(() => { - // This would be replaced with actual balance fetching logic - TOKENS.AUDIO.balance = 15000.0 - TOKENS.USDC.balance = 100.0 - }, []) - const selectedPair = SUPPORTED_TOKEN_PAIRS[selectedPairIndex] const handleActiveTabChange = useCallback((newTab: BuySellTab) => { setActiveTab(newTab) + // Reset transaction data when changing tabs + setTransactionData(null) }, []) + const handleTransactionDataChange = useCallback( + (data: { inputAmount: number; outputAmount: number; isValid: boolean }) => { + setTransactionData(data) + }, + [] + ) + + // Handle continue button click + const handleContinueClick = useCallback(() => { + if (!transactionData || !transactionData.isValid) return + + const { inputAmount } = transactionData + + // Default slippage is 50 basis points (0.5%) + const slippageBps = 50 + + // Determine swap direction based on active tab + if (activeTab === 'buy') { + // Buy AUDIO with USDC + swapTokens({ + inputTokenSymbol: 'USDC', + outputTokenSymbol: 'AUDIO', + inputAmount, + slippageBps + }) + } else { + // Sell AUDIO for USDC + swapTokens({ + inputTokenSymbol: 'AUDIO', + outputTokenSymbol: 'USDC', + inputAmount, + slippageBps + }) + } + }, [activeTab, transactionData, swapTokens]) + + // Handle swap status changes + useEffect(() => { + if (swapStatus === 'success') { + toast( + activeTab === 'buy' + ? 'Successfully purchased AUDIO!' + : 'Successfully sold AUDIO!', + 3000 + ) + + // Close modal after 1 second on success + const timer = setTimeout(() => { + onClose() + }, 1000) + + return () => clearTimeout(timer) + } else if (swapStatus === 'error') { + toast(swapError?.message || 'Transaction failed. Please try again.', 5000) + } + }, [swapStatus, swapError, activeTab, onClose, toast]) + + // Determine button state + const isContinueButtonDisabled = + !transactionData?.isValid || swapStatus === 'pending' + + const isContinueButtonLoading = swapStatus === 'pending' + return ( @@ -72,9 +149,15 @@ export const BuySellModal = () => { {activeTab === 'buy' ? ( - + ) : ( - + )} @@ -87,8 +170,20 @@ export const BuySellModal = () => { - diff --git a/packages/web/src/components/buy-sell-modal/BuyTab.tsx b/packages/web/src/components/buy-sell-modal/BuyTab.tsx index 5b79741e82e..8a6c4cdf665 100644 --- a/packages/web/src/components/buy-sell-modal/BuyTab.tsx +++ b/packages/web/src/components/buy-sell-modal/BuyTab.tsx @@ -1,51 +1,142 @@ -import { useCallback, useState } from 'react' +import { useMemo } from 'react' -import { Flex } from '@audius/harmony' +import { useTokenExchangeRate } from '@audius/common/src/api/tan-query/useTokenExchangeRate' +import { useUSDCBalance } from '@audius/common/src/hooks/useUSDCBalance' +import { Status } from '@audius/common/src/models/Status' +import { Flex, Text } from '@audius/harmony' + +import LoadingSpinner from 'components/loading-spinner/LoadingSpinner' import { TokenAmountSection } from './TokenAmountSection' +import { useTokenSwapForm } from './hooks/useTokenSwapForm' import { TokenPair } from './types' type BuyTabProps = { tokenPair: TokenPair + onTransactionDataChange?: (data: { + inputAmount: number + outputAmount: number + isValid: boolean + }) => void } -export const BuyTab = ({ tokenPair }: BuyTabProps) => { - const { baseToken, quoteToken, exchangeRate } = tokenPair - const [quoteAmount, setQuoteAmount] = useState('') - const receivedBaseAmount = parseFloat(quoteAmount || '0') / exchangeRate || 0 +// Min and max amounts in USDC +const MIN_AMOUNT = 1 // $1 minimum +const MAX_AMOUNT = 5000 // $5000 maximum + +export const BuyTab = ({ tokenPair, onTransactionDataChange }: BuyTabProps) => { + const { baseToken, quoteToken } = tokenPair - const handleQuoteAmountChange = useCallback((value: string) => { - // Allow only valid number input - if (value === '' || /^\d*\.?\d*$/.test(value)) { - setQuoteAmount(value) + // Fetch real USDC balance + const { status: balanceStatus, data: usdcBalance } = useUSDCBalance({ + isPolling: false + }) + + // Get USDC balance in UI format + const getUsdcBalance = useMemo(() => { + return () => { + if (balanceStatus === Status.SUCCESS && usdcBalance) { + return parseFloat(usdcBalance.toString()) / 10 ** quoteToken.decimals + } + return undefined } - }, []) + }, [balanceStatus, usdcBalance, quoteToken.decimals]) - const handleMaxClick = useCallback(() => { - setQuoteAmount(quoteToken.balance.toString()) - }, [quoteToken.balance]) + // Use the shared hook for form logic + const { + numericInputAmount, + outputAmount, + error, + exchangeRateError, + isExchangeRateLoading, + isBalanceLoading: ignoredIsBalanceLoading, // We use our own balance loading indicator + availableBalance, + currentExchangeRate, + handleInputAmountChange, + handleMaxClick + } = useTokenSwapForm({ + inputToken: quoteToken, + outputToken: baseToken, + inputTokenSymbol: 'USDC', + outputTokenSymbol: 'AUDIO', + minAmount: MIN_AMOUNT, + maxAmount: MAX_AMOUNT, + getInputBalance: getUsdcBalance, + isBalanceLoading: balanceStatus === Status.LOADING, + formatBalanceError: () => 'Insufficient balance', + getExchangeRate: useTokenExchangeRate, + defaultExchangeRate: tokenPair.exchangeRate, + onTransactionDataChange + }) return ( + {/* Show loading state while fetching balance */} + {balanceStatus === Status.LOADING && !usdcBalance && ( + + + + )} + + {/* Show error from exchange rate fetch */} + {exchangeRateError && ( + + Unable to fetch exchange rate. Please try again. + + )} + + {/* Input amount section */} + {/* Show validation error */} + {error && ( + + {error} + + )} + + {/* Output amount section */} + + {/* Loading indicator for exchange rate */} + {isExchangeRateLoading && numericInputAmount > 0 && ( + + + + )} ) } diff --git a/packages/web/src/components/buy-sell-modal/SellTab.tsx b/packages/web/src/components/buy-sell-modal/SellTab.tsx index c4c8867d2b8..a5d25b788e6 100644 --- a/packages/web/src/components/buy-sell-modal/SellTab.tsx +++ b/packages/web/src/components/buy-sell-modal/SellTab.tsx @@ -1,51 +1,144 @@ -import { useCallback, useState } from 'react' +import { useMemo } from 'react' -import { Flex } from '@audius/harmony' +import { useTokenExchangeRate } from '@audius/common/src/api/tan-query/useTokenExchangeRate' +import { useTotalBalanceWithFallback } from '@audius/common/src/hooks/useAudioBalance' +import { isNullOrUndefined } from '@audius/common/src/utils' +import { AUDIO } from '@audius/fixed-decimal' +import { Flex, Text } from '@audius/harmony' + +import LoadingSpinner from 'components/loading-spinner/LoadingSpinner' import { TokenAmountSection } from './TokenAmountSection' +import { useTokenSwapForm } from './hooks/useTokenSwapForm' import { TokenPair } from './types' type SellTabProps = { tokenPair: TokenPair + onTransactionDataChange?: (data: { + inputAmount: number + outputAmount: number + isValid: boolean + }) => void } -export const SellTab = ({ tokenPair }: SellTabProps) => { - const { baseToken, quoteToken, exchangeRate } = tokenPair - const [baseAmount, setBaseAmount] = useState('') - const receivedQuoteAmount = parseFloat(baseAmount || '0') * exchangeRate || 0 +// Min and max amounts in AUDIO +const MIN_AMOUNT = 5 // 5 AUDIO minimum +const MAX_AMOUNT = 2500 // 2500 AUDIO maximum + +export const SellTab = ({ + tokenPair, + onTransactionDataChange +}: SellTabProps) => { + const { baseToken, quoteToken } = tokenPair - const handleBaseAmountChange = useCallback((value: string) => { - // Allow only valid number input - if (value === '' || /^\d*\.?\d*$/.test(value)) { - setBaseAmount(value) + // Fetch real AUDIO balance using Redux (more reliable than tan-query in this context) + const totalBalance = useTotalBalanceWithFallback() + const isBalanceLoading = isNullOrUndefined(totalBalance) + + // Get AUDIO balance in UI format + const getAudioBalance = useMemo(() => { + return () => { + if (!isBalanceLoading && totalBalance) { + return parseFloat(AUDIO(totalBalance).toString()) + } + return undefined } - }, []) + }, [totalBalance, isBalanceLoading]) - const handleMaxClick = useCallback(() => { - setBaseAmount(baseToken.balance.toString()) - }, [baseToken.balance]) + // Use the shared hook for form logic + const { + numericInputAmount, + outputAmount, + error, + exchangeRateError, + isExchangeRateLoading, + availableBalance, + currentExchangeRate, + handleInputAmountChange, + handleMaxClick + } = useTokenSwapForm({ + inputToken: baseToken, + outputToken: quoteToken, + inputTokenSymbol: 'AUDIO', + outputTokenSymbol: 'USDC', + minAmount: MIN_AMOUNT, + maxAmount: MAX_AMOUNT, + getInputBalance: getAudioBalance, + isBalanceLoading, + formatBalanceError: () => 'Insufficient balance', + getExchangeRate: useTokenExchangeRate, + defaultExchangeRate: tokenPair.exchangeRate, + onTransactionDataChange + }) return ( + {/* Show loading state while fetching balance */} + {isBalanceLoading && ( + + + + )} + + {/* Show error from exchange rate fetch */} + {exchangeRateError && ( + + Unable to fetch exchange rate. Please try again. + + )} + + {/* Input amount section */} + {/* Show validation error */} + {error && ( + + {error} + + )} + + {/* Output amount section */} + + {/* Loading indicator for exchange rate */} + {isExchangeRateLoading && numericInputAmount > 0 && ( + + + + )} ) } diff --git a/packages/web/src/components/buy-sell-modal/hooks/useTokenSwapForm.ts b/packages/web/src/components/buy-sell-modal/hooks/useTokenSwapForm.ts new file mode 100644 index 00000000000..7099268bda5 --- /dev/null +++ b/packages/web/src/components/buy-sell-modal/hooks/useTokenSwapForm.ts @@ -0,0 +1,171 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { TokenExchangeRateParams } from '@audius/common/src/api/tan-query/useTokenExchangeRate' +import { JupiterTokenSymbol } from '@audius/common/src/services/JupiterTokenExchange' + +import { TokenInfo } from '../types' + +export type TokenSwapDirection = 'buy' | 'sell' + +export type TokenSwapFormProps = { + inputToken: TokenInfo + outputToken: TokenInfo + inputTokenSymbol: JupiterTokenSymbol + outputTokenSymbol: JupiterTokenSymbol + minAmount: number + maxAmount: number + getInputBalance: () => number | undefined + isBalanceLoading: boolean + formatBalanceError: (amount: number) => string + getExchangeRate: (params: TokenExchangeRateParams) => { + data: { rate: number } | undefined + isLoading: boolean + error: Error | null + } + defaultExchangeRate: number + onTransactionDataChange?: (data: { + inputAmount: number + outputAmount: number + isValid: boolean + }) => void +} + +/** + * A hook to manage the common functionality for both Buy and Sell tabs + */ +export const useTokenSwapForm = ({ + inputToken, + outputToken, + inputTokenSymbol, + outputTokenSymbol, + minAmount, + maxAmount, + getInputBalance, + isBalanceLoading, + formatBalanceError, + getExchangeRate, + defaultExchangeRate, + onTransactionDataChange +}: TokenSwapFormProps) => { + const [inputAmount, setInputAmount] = useState('') + const [error, setError] = useState(null) + + // Calculate the numeric value of the input amount + const numericInputAmount = useMemo(() => { + const parsed = parseFloat(inputAmount || '0') + return isNaN(parsed) ? 0 : parsed + }, [inputAmount]) + + // Get the available balance + const availableBalance = useMemo(() => { + const balance = getInputBalance() + return balance !== undefined ? balance : inputToken.balance + }, [getInputBalance, inputToken.balance]) + + // Use Jupiter API to get real-time exchange rate + const { + data: exchangeRateData, + isLoading: isExchangeRateLoading, + error: exchangeRateError + } = getExchangeRate({ + inputTokenSymbol, + outputTokenSymbol, + inputAmount: numericInputAmount > 0 ? numericInputAmount : 1 + }) + + // Calculate the output amount based on the exchange rate + const outputAmount = useMemo(() => { + if (numericInputAmount <= 0) return 0 + if (isExchangeRateLoading || !exchangeRateData) return 0 + + return exchangeRateData.rate * numericInputAmount + }, [numericInputAmount, exchangeRateData, isExchangeRateLoading]) + + // Validate the input amount + useEffect(() => { + if (numericInputAmount === 0) { + setError(null) + return + } + + if (numericInputAmount < minAmount) { + setError(`Minimum amount is ${minAmount} ${inputToken.symbol}`) + return + } + + if (numericInputAmount > maxAmount) { + setError(`Maximum amount is ${maxAmount} ${inputToken.symbol}`) + return + } + + // Check if user has enough balance + const balance = getInputBalance() + if (balance !== undefined && numericInputAmount > balance) { + setError(formatBalanceError(numericInputAmount)) + return + } + + setError(null) + }, [ + numericInputAmount, + minAmount, + maxAmount, + getInputBalance, + formatBalanceError, + inputToken.symbol + ]) + + // Update the parent component with transaction data + useEffect(() => { + if (onTransactionDataChange) { + onTransactionDataChange({ + inputAmount: numericInputAmount, + outputAmount, + isValid: numericInputAmount > 0 && !error && !isExchangeRateLoading + }) + } + }, [ + numericInputAmount, + outputAmount, + error, + isExchangeRateLoading, + onTransactionDataChange + ]) + + // Handle input changes + const handleInputAmountChange = useCallback((value: string) => { + // Allow only valid number input + if (value === '' || /^\d*\.?\d*$/.test(value)) { + setInputAmount(value) + } + }, []) + + // Handle max button click + const handleMaxClick = useCallback(() => { + const balance = getInputBalance() + if (balance !== undefined) { + // Limit to MAX_AMOUNT + const finalAmount = Math.min(balance, maxAmount) + setInputAmount(finalAmount.toString()) + } + }, [getInputBalance, maxAmount]) + + // Use the real exchange rate if available, otherwise use the default + const currentExchangeRate = exchangeRateData + ? exchangeRateData.rate + : defaultExchangeRate + + return { + inputAmount, + numericInputAmount, + outputAmount, + error, + exchangeRateError, + isExchangeRateLoading, + isBalanceLoading, + availableBalance, + currentExchangeRate, + handleInputAmountChange, + handleMaxClick + } +} diff --git a/packages/web/src/pages/pay-and-earn-page/components/CashWallet.tsx b/packages/web/src/pages/pay-and-earn-page/components/CashWallet.tsx index 3414911197b..2b3be80dbfb 100644 --- a/packages/web/src/pages/pay-and-earn-page/components/CashWallet.tsx +++ b/packages/web/src/pages/pay-and-earn-page/components/CashWallet.tsx @@ -10,7 +10,8 @@ import { TRANSACTION_HISTORY_PAGE } from '@audius/common/src/utils/route' import { WithdrawUSDCModalPages, useWithdrawUSDCModal, - useAddFundsModal + useAddFundsModal, + useBuySellModal } from '@audius/common/store' import { Button, @@ -36,6 +37,7 @@ export const CashWallet = () => { const isManagedAccount = useIsManagedAccount() const { onOpen: openWithdrawUSDCModal } = useWithdrawUSDCModal() const { onOpen: openAddFundsModal } = useAddFundsModal() + const { onOpen: openBuySellModal } = useBuySellModal() const { balanceFormatted, usdcValue, isLoading } = useFormattedUSDCBalance() const [, setPayoutWalletModalOpen] = useModalState('PayoutWallet') @@ -65,6 +67,11 @@ export const CashWallet = () => { ) } + const handleBuySell = () => { + openBuySellModal() + // TODO: Add analytics tracking if needed + } + const handlePayoutWalletClick = useCallback(() => { setPayoutWalletModalOpen(true) }, [setPayoutWalletModalOpen]) @@ -168,6 +175,16 @@ export const CashWallet = () => { > {walletMessages.addFunds} + ) : null} diff --git a/plan.md b/plan.md new file mode 100644 index 00000000000..a34f4188e1d --- /dev/null +++ b/plan.md @@ -0,0 +1,121 @@ +# BuySellModal Implementation Plan + +## Overview + +This document outlines the implementation plan for making the BuySellModal, BuyTab, and SellTab components functional. The goal is to allow users to buy and sell AUDIO tokens using USDC via Jupiter swaps, using their internal Hedgehog wallet for balances and transactions. + +## Current State Analysis + +1. The UI components (BuySellModal, BuyTab, SellTab) are already implemented with proper layout and structure +2. Token constants are defined in `constants.ts` and are correct as is +3. Jupiter integration is partially implemented in the codebase through `JupiterSingleton` service +4. Hedgehog wallet is used for Solana transactions in other areas of the application +5. Existing hooks (`useAudioBalance` and `useUSDCBalance`) already provide token balance functionality + +## Implementation Steps + +### 1. Create TAN Query Hooks for Jupiter Integration + +#### 1.1 Leverage Existing Balance Hooks + +Instead of creating a new token balance hook, we will use the existing hooks: + +- `useAudioBalance` from `packages/common/src/api/tan-query/useAudioBalance.ts` - Already provides AUDIO balances for the user's wallets including Hedgehog +- `useUSDCBalance` from `packages/common/src/hooks/useUSDCBalance.ts` - Already provides USDC balance with polling capabilities + +#### 1.2 Create a hook for fetching exchange rate + +Create a new tan-query hook `useTokenExchangeRate.ts` that will: + +- Leverage existing `JupiterSingleton.getQuote` function to fetch real-time exchange rates +- Accept token pair parameters (AUDIO/USDC) for both buy and sell directions +- Return formatted exchange rate data +- Include a small amount for estimation (e.g., 1 USDC worth of AUDIO) + +#### 1.3 Create a hook for executing token swaps + +Create a new tan-query hook `useSwapTokens.ts` that will: + +- Be a mutation hook that executes a Jupiter swap +- Accept parameters for token pair, amount, slippage, and direction +- Use the `JupiterSingleton.getSwapInstructions` to create the transaction +- Execute the transaction using the Hedgehog wallet via `solanaWalletService` +- Handle transaction confirmation and error states +- Return transaction status and result + +### 2. Update Existing Components + +#### 2.1 Update BuySellModal.tsx + +- Connect to the Hedgehog wallet service to ensure we have access to the wallet +- Add state for tracking transaction status (idle, loading, success, error) +- Implement the "Continue" button functionality to execute swaps +- Add proper error handling and success messaging +- Make sure the modal closes properly after transactions + +#### 2.2 Update BuyTab.tsx + +- Use `useUSDCBalance` to display real USDC balance +- Use `useTokenExchangeRate` to display accurate exchange rate +- Enable balance polling during transactions using the built-in polling feature +- Add input validation (min/max amounts, available balance) +- Update state in parent component for executing transaction + +#### 2.3 Update SellTab.tsx + +- Use `useAudioBalance` to display real AUDIO balance (specifically the `totalBalance` value) +- Use `useTokenExchangeRate` to display accurate exchange rate +- Add a refresh mechanism to update balances after transactions +- Add input validation (min/max amounts, available balance) +- Update state in parent component for executing transaction + +### 3. Additional Considerations + +#### 3.1 Slippage Handling + +- Add a configurable slippage setting (can be hidden in MVP) +- Default to a safe value from remote config: `BUY_TOKEN_VIA_SOL_SLIPPAGE_BPS` and `BUY_SOL_VIA_TOKEN_SLIPPAGE_BPS` + +#### 3.2 Transaction Confirmation + +- Add transaction confirmation handling +- Display transaction status and links to explorer + +#### 3.3 Analytics + +- Add analytics tracking for: + - Modal opens + - Quote requests + - Swap attempts + - Swap successes/failures + +## Implementation Details + +### API Structure + +The new tan-query hooks will be created in the appropriate directory structure: + +``` +packages/common/src/api/tan-query/ + - useTokenExchangeRate.ts + - useSwapTokens.ts +``` + +### Utilizing Existing Services + +We will make use of existing services and hooks: + +- `useAudioBalance` and `useUSDCBalance` for token balances +- `solanaWalletService` for accessing the Hedgehog wallet +- `JupiterSingleton` for quotes and swap instructions +- Existing token constants from `TOKEN_LISTING_MAP` + +### Error Handling + +- Implement proper error handling for network issues +- Add user-friendly error messages for common failure scenarios +- Handle insufficient balance errors gracefully + +## Conclusion + +This implementation will enable users to buy and sell AUDIO tokens using USDC through Jupiter swaps, all while using their internal Hedgehog wallet. The approach leverages existing infrastructure and follows the project's patterns for tan-query hooks rather than relying on Redux. From 69695f2acd10223ec320a23219cae00d25ce1dfd Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Thu, 24 Apr 2025 16:42:25 -0500 Subject: [PATCH 02/22] Refactor to more readable code --- .../src/components/buy-sell-modal/BuyTab.tsx | 118 ++-------------- .../src/components/buy-sell-modal/SellTab.tsx | 117 ++-------------- .../src/components/buy-sell-modal/SwapTab.tsx | 131 ++++++++++++++++++ .../buy-sell-modal/TokenAmountSection.tsx | 2 +- .../components/buy-sell-modal/constants.ts | 6 +- .../buy-sell-modal/hooks/useTokenSwapForm.ts | 97 +++++++------ .../src/components/buy-sell-modal/types.ts | 6 +- 7 files changed, 219 insertions(+), 258 deletions(-) create mode 100644 packages/web/src/components/buy-sell-modal/SwapTab.tsx diff --git a/packages/web/src/components/buy-sell-modal/BuyTab.tsx b/packages/web/src/components/buy-sell-modal/BuyTab.tsx index 8a6c4cdf665..c8d710c838c 100644 --- a/packages/web/src/components/buy-sell-modal/BuyTab.tsx +++ b/packages/web/src/components/buy-sell-modal/BuyTab.tsx @@ -1,14 +1,9 @@ import { useMemo } from 'react' -import { useTokenExchangeRate } from '@audius/common/src/api/tan-query/useTokenExchangeRate' import { useUSDCBalance } from '@audius/common/src/hooks/useUSDCBalance' import { Status } from '@audius/common/src/models/Status' -import { Flex, Text } from '@audius/harmony' -import LoadingSpinner from 'components/loading-spinner/LoadingSpinner' - -import { TokenAmountSection } from './TokenAmountSection' -import { useTokenSwapForm } from './hooks/useTokenSwapForm' +import { SwapTab } from './SwapTab' import { TokenPair } from './types' type BuyTabProps = { @@ -20,13 +15,9 @@ type BuyTabProps = { }) => void } -// Min and max amounts in USDC -const MIN_AMOUNT = 1 // $1 minimum -const MAX_AMOUNT = 5000 // $5000 maximum - export const BuyTab = ({ tokenPair, onTransactionDataChange }: BuyTabProps) => { + // Extract the tokens from the pair const { baseToken, quoteToken } = tokenPair - // Fetch real USDC balance const { status: balanceStatus, data: usdcBalance } = useUSDCBalance({ isPolling: false @@ -42,101 +33,16 @@ export const BuyTab = ({ tokenPair, onTransactionDataChange }: BuyTabProps) => { } }, [balanceStatus, usdcBalance, quoteToken.decimals]) - // Use the shared hook for form logic - const { - numericInputAmount, - outputAmount, - error, - exchangeRateError, - isExchangeRateLoading, - isBalanceLoading: ignoredIsBalanceLoading, // We use our own balance loading indicator - availableBalance, - currentExchangeRate, - handleInputAmountChange, - handleMaxClick - } = useTokenSwapForm({ - inputToken: quoteToken, - outputToken: baseToken, - inputTokenSymbol: 'USDC', - outputTokenSymbol: 'AUDIO', - minAmount: MIN_AMOUNT, - maxAmount: MAX_AMOUNT, - getInputBalance: getUsdcBalance, - isBalanceLoading: balanceStatus === Status.LOADING, - formatBalanceError: () => 'Insufficient balance', - getExchangeRate: useTokenExchangeRate, - defaultExchangeRate: tokenPair.exchangeRate, - onTransactionDataChange - }) - return ( - - {/* Show loading state while fetching balance */} - {balanceStatus === Status.LOADING && !usdcBalance && ( - - - - )} - - {/* Show error from exchange rate fetch */} - {exchangeRateError && ( - - Unable to fetch exchange rate. Please try again. - - )} - - {/* Input amount section */} - - - {/* Show validation error */} - {error && ( - - {error} - - )} - - {/* Output amount section */} - - - {/* Loading indicator for exchange rate */} - {isExchangeRateLoading && numericInputAmount > 0 && ( - - - - )} - + 'Insufficient balance' + }} + onTransactionDataChange={onTransactionDataChange} + /> ) } diff --git a/packages/web/src/components/buy-sell-modal/SellTab.tsx b/packages/web/src/components/buy-sell-modal/SellTab.tsx index a5d25b788e6..1968e9cd5ca 100644 --- a/packages/web/src/components/buy-sell-modal/SellTab.tsx +++ b/packages/web/src/components/buy-sell-modal/SellTab.tsx @@ -1,15 +1,10 @@ import { useMemo } from 'react' -import { useTokenExchangeRate } from '@audius/common/src/api/tan-query/useTokenExchangeRate' import { useTotalBalanceWithFallback } from '@audius/common/src/hooks/useAudioBalance' import { isNullOrUndefined } from '@audius/common/src/utils' import { AUDIO } from '@audius/fixed-decimal' -import { Flex, Text } from '@audius/harmony' -import LoadingSpinner from 'components/loading-spinner/LoadingSpinner' - -import { TokenAmountSection } from './TokenAmountSection' -import { useTokenSwapForm } from './hooks/useTokenSwapForm' +import { SwapTab } from './SwapTab' import { TokenPair } from './types' type SellTabProps = { @@ -21,16 +16,12 @@ type SellTabProps = { }) => void } -// Min and max amounts in AUDIO -const MIN_AMOUNT = 5 // 5 AUDIO minimum -const MAX_AMOUNT = 2500 // 2500 AUDIO maximum - export const SellTab = ({ tokenPair, onTransactionDataChange }: SellTabProps) => { + // Extract the tokens from the pair const { baseToken, quoteToken } = tokenPair - // Fetch real AUDIO balance using Redux (more reliable than tan-query in this context) const totalBalance = useTotalBalanceWithFallback() const isBalanceLoading = isNullOrUndefined(totalBalance) @@ -45,100 +36,16 @@ export const SellTab = ({ } }, [totalBalance, isBalanceLoading]) - // Use the shared hook for form logic - const { - numericInputAmount, - outputAmount, - error, - exchangeRateError, - isExchangeRateLoading, - availableBalance, - currentExchangeRate, - handleInputAmountChange, - handleMaxClick - } = useTokenSwapForm({ - inputToken: baseToken, - outputToken: quoteToken, - inputTokenSymbol: 'AUDIO', - outputTokenSymbol: 'USDC', - minAmount: MIN_AMOUNT, - maxAmount: MAX_AMOUNT, - getInputBalance: getAudioBalance, - isBalanceLoading, - formatBalanceError: () => 'Insufficient balance', - getExchangeRate: useTokenExchangeRate, - defaultExchangeRate: tokenPair.exchangeRate, - onTransactionDataChange - }) - return ( - - {/* Show loading state while fetching balance */} - {isBalanceLoading && ( - - - - )} - - {/* Show error from exchange rate fetch */} - {exchangeRateError && ( - - Unable to fetch exchange rate. Please try again. - - )} - - {/* Input amount section */} - - - {/* Show validation error */} - {error && ( - - {error} - - )} - - {/* Output amount section */} - - - {/* Loading indicator for exchange rate */} - {isExchangeRateLoading && numericInputAmount > 0 && ( - - - - )} - + 'Insufficient balance' + }} + onTransactionDataChange={onTransactionDataChange} + /> ) } diff --git a/packages/web/src/components/buy-sell-modal/SwapTab.tsx b/packages/web/src/components/buy-sell-modal/SwapTab.tsx new file mode 100644 index 00000000000..56ff5f8b0a1 --- /dev/null +++ b/packages/web/src/components/buy-sell-modal/SwapTab.tsx @@ -0,0 +1,131 @@ +import { Flex, Text } from '@audius/harmony' + +import LoadingSpinner from 'components/loading-spinner/LoadingSpinner' + +import { TokenAmountSection } from './TokenAmountSection' +import { useTokenSwapForm } from './hooks/useTokenSwapForm' +import { TokenInfo } from './types' + +export type SwapTabProps = { + inputToken: TokenInfo + outputToken: TokenInfo + min?: number + max?: number + balance: { + get: () => number | undefined + loading: boolean + formatError: () => string + } + + onTransactionDataChange?: (data: { + inputAmount: number + outputAmount: number + isValid: boolean + }) => void +} + +export const SwapTab = ({ + inputToken, + outputToken, + min, + max, + balance, + onTransactionDataChange +}: SwapTabProps) => { + // Use the shared hook for form logic + const { + numericInputAmount, + outputAmount, + error, + exchangeRateError, + isExchangeRateLoading, + isBalanceLoading, + availableBalance, + currentExchangeRate, + handleInputAmountChange, + handleMaxClick + } = useTokenSwapForm({ + inputToken, + outputToken, + min, + max, + balance, + + onTransactionDataChange + }) + + // Show initial loading state if we don't have a balance or exchange rate yet + const isInitialLoading = + isBalanceLoading || (isExchangeRateLoading && !currentExchangeRate) + + return ( + + {/* Show loading state while fetching balance or initial exchange rate */} + {isInitialLoading && ( + + + + )} + + {/* Show error from exchange rate fetch */} + {exchangeRateError && ( + + Unable to fetch exchange rate. Please try again. + + )} + + {/* Input amount section */} + + + {/* Show validation error */} + {error && ( + + {error} + + )} + + {/* Output amount section */} + + + {/* Loading indicator for exchange rate */} + {isExchangeRateLoading && numericInputAmount > 0 && !isInitialLoading && ( + + + + )} + + ) +} diff --git a/packages/web/src/components/buy-sell-modal/TokenAmountSection.tsx b/packages/web/src/components/buy-sell-modal/TokenAmountSection.tsx index a8528d135b4..46d8116860b 100644 --- a/packages/web/src/components/buy-sell-modal/TokenAmountSection.tsx +++ b/packages/web/src/components/buy-sell-modal/TokenAmountSection.tsx @@ -84,7 +84,7 @@ export const TokenAmountSection = ({ {tokenTicker} - {exchangeRate && ( + {exchangeRate !== null && exchangeRate !== undefined && ( ({isStablecoin ? '$' : ''} {exchangeRate}) diff --git a/packages/web/src/components/buy-sell-modal/constants.ts b/packages/web/src/components/buy-sell-modal/constants.ts index ef7ebd1fa02..4877020a277 100644 --- a/packages/web/src/components/buy-sell-modal/constants.ts +++ b/packages/web/src/components/buy-sell-modal/constants.ts @@ -9,7 +9,7 @@ export const TOKENS: Record = { name: 'Audius', icon: IconLogoCircle, decimals: 18, - balance: 0, // This will be updated with actual balance + balance: null, // Changed from 0 to null isStablecoin: false }, USDC: { @@ -17,7 +17,7 @@ export const TOKENS: Record = { name: 'USD Coin', icon: IconLogoCircleUSDC, decimals: 6, - balance: 0, // This will be updated with actual balance + balance: null, // Changed from 0 to null isStablecoin: true } } @@ -27,6 +27,6 @@ export const SUPPORTED_TOKEN_PAIRS: TokenPair[] = [ { baseToken: TOKENS.AUDIO, quoteToken: TOKENS.USDC, - exchangeRate: 0.082 // Default rate - will be updated with actual rate + exchangeRate: null // Changed from 0.082 to null } ] diff --git a/packages/web/src/components/buy-sell-modal/hooks/useTokenSwapForm.ts b/packages/web/src/components/buy-sell-modal/hooks/useTokenSwapForm.ts index 7099268bda5..11b89e01172 100644 --- a/packages/web/src/components/buy-sell-modal/hooks/useTokenSwapForm.ts +++ b/packages/web/src/components/buy-sell-modal/hooks/useTokenSwapForm.ts @@ -1,28 +1,40 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { TokenExchangeRateParams } from '@audius/common/src/api/tan-query/useTokenExchangeRate' +import { useTokenExchangeRate } from '@audius/common/src/api/tan-query/useTokenExchangeRate' import { JupiterTokenSymbol } from '@audius/common/src/services/JupiterTokenExchange' import { TokenInfo } from '../types' -export type TokenSwapDirection = 'buy' | 'sell' +export type BalanceConfig = { + get: () => number | undefined + loading: boolean + formatError: (amount: number) => string +} export type TokenSwapFormProps = { + /** + * The token the user is paying with (input) + */ inputToken: TokenInfo + /** + * The token the user is receiving (output) + */ outputToken: TokenInfo - inputTokenSymbol: JupiterTokenSymbol - outputTokenSymbol: JupiterTokenSymbol - minAmount: number - maxAmount: number - getInputBalance: () => number | undefined - isBalanceLoading: boolean - formatBalanceError: (amount: number) => string - getExchangeRate: (params: TokenExchangeRateParams) => { - data: { rate: number } | undefined - isLoading: boolean - error: Error | null - } - defaultExchangeRate: number + /** + * Minimum amount allowed for input (optional) + */ + min?: number + /** + * Maximum amount allowed for input (optional) + */ + max?: number + /** + * Configuration for handling the input token balance + */ + balance: BalanceConfig + /** + * Callback for when transaction data changes + */ onTransactionDataChange?: (data: { inputAmount: number outputAmount: number @@ -31,22 +43,26 @@ export type TokenSwapFormProps = { } /** - * A hook to manage the common functionality for both Buy and Sell tabs + * A hook to manage the common functionality for token swaps */ export const useTokenSwapForm = ({ inputToken, outputToken, - inputTokenSymbol, - outputTokenSymbol, - minAmount, - maxAmount, - getInputBalance, - isBalanceLoading, - formatBalanceError, - getExchangeRate, - defaultExchangeRate, + min = 0, + max = Number.MAX_SAFE_INTEGER, + balance, onTransactionDataChange }: TokenSwapFormProps) => { + // Get token symbols for the exchange rate API + const inputTokenSymbol = inputToken.symbol as JupiterTokenSymbol + const outputTokenSymbol = outputToken.symbol as JupiterTokenSymbol + + // Destructure the balance config for easier access + const { + get: getInputBalance, + loading: isBalanceLoading, + formatError: formatBalanceError + } = balance const [inputAmount, setInputAmount] = useState('') const [error, setError] = useState(null) @@ -59,7 +75,7 @@ export const useTokenSwapForm = ({ // Get the available balance const availableBalance = useMemo(() => { const balance = getInputBalance() - return balance !== undefined ? balance : inputToken.balance + return balance !== undefined ? balance : (inputToken.balance ?? 0) }, [getInputBalance, inputToken.balance]) // Use Jupiter API to get real-time exchange rate @@ -67,7 +83,7 @@ export const useTokenSwapForm = ({ data: exchangeRateData, isLoading: isExchangeRateLoading, error: exchangeRateError - } = getExchangeRate({ + } = useTokenExchangeRate({ inputTokenSymbol, outputTokenSymbol, inputAmount: numericInputAmount > 0 ? numericInputAmount : 1 @@ -88,13 +104,13 @@ export const useTokenSwapForm = ({ return } - if (numericInputAmount < minAmount) { - setError(`Minimum amount is ${minAmount} ${inputToken.symbol}`) + if (numericInputAmount < min) { + setError(`Minimum amount is ${min} ${inputToken.symbol}`) return } - if (numericInputAmount > maxAmount) { - setError(`Maximum amount is ${maxAmount} ${inputToken.symbol}`) + if (numericInputAmount > max) { + setError(`Maximum amount is ${max} ${inputToken.symbol}`) return } @@ -108,8 +124,8 @@ export const useTokenSwapForm = ({ setError(null) }, [ numericInputAmount, - minAmount, - maxAmount, + min, + max, getInputBalance, formatBalanceError, inputToken.symbol @@ -145,15 +161,13 @@ export const useTokenSwapForm = ({ const balance = getInputBalance() if (balance !== undefined) { // Limit to MAX_AMOUNT - const finalAmount = Math.min(balance, maxAmount) + const finalAmount = Math.min(balance, max) setInputAmount(finalAmount.toString()) } - }, [getInputBalance, maxAmount]) + }, [getInputBalance, max]) - // Use the real exchange rate if available, otherwise use the default - const currentExchangeRate = exchangeRateData - ? exchangeRateData.rate - : defaultExchangeRate + // Use the real exchange rate if available, otherwise null (no default fallback) + const currentExchangeRate = exchangeRateData ? exchangeRateData.rate : null return { inputAmount, @@ -166,6 +180,9 @@ export const useTokenSwapForm = ({ availableBalance, currentExchangeRate, handleInputAmountChange, - handleMaxClick + handleMaxClick, + // Including these for the component that consumes this hook + inputToken, + outputToken } } diff --git a/packages/web/src/components/buy-sell-modal/types.ts b/packages/web/src/components/buy-sell-modal/types.ts index d0ec5d3e761..d96daf6fec0 100644 --- a/packages/web/src/components/buy-sell-modal/types.ts +++ b/packages/web/src/components/buy-sell-modal/types.ts @@ -7,7 +7,7 @@ export type TokenInfo = { name: string // e.g., 'Audius', 'USD Coin', 'Wrapped Ether' icon: React.ComponentType // Component for the token's icon decimals: number // Number of decimal places (e.g., 18 for ETH) - balance: number // User's balance for this token + balance: number | null // User's balance for this token address?: string // Optional contract address isStablecoin?: boolean // Flag for UI formatting ($ prefix, etc.) } @@ -15,7 +15,7 @@ export type TokenInfo = { export type TokenPair = { baseToken: TokenInfo // The token being priced (e.g., AUDIO) quoteToken: TokenInfo // The token used for pricing (e.g., USDC) - exchangeRate: number // Rate of baseToken in terms of quoteToken + exchangeRate: number | null // Rate of baseToken in terms of quoteToken } export type TokenAmountSectionProps = { @@ -26,6 +26,6 @@ export type TokenAmountSectionProps = { onAmountChange?: (value: string) => void onMaxClick?: () => void availableBalance: number - exchangeRate?: number + exchangeRate?: number | null placeholder?: string } From 6a1da5fbf15b494e5d4b393a82848f4a76abbf3d Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Thu, 24 Apr 2025 17:02:45 -0500 Subject: [PATCH 03/22] Add skeletons --- .../buy-sell-modal/BuySellModal.tsx | 7 ++-- .../src/components/buy-sell-modal/SwapTab.tsx | 34 +++++++++++++------ .../components/buy-sell-modal/constants.ts | 6 ++-- 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/packages/web/src/components/buy-sell-modal/BuySellModal.tsx b/packages/web/src/components/buy-sell-modal/BuySellModal.tsx index 262c1b7a91f..2ba19e5ef50 100644 --- a/packages/web/src/components/buy-sell-modal/BuySellModal.tsx +++ b/packages/web/src/components/buy-sell-modal/BuySellModal.tsx @@ -126,12 +126,11 @@ export const BuySellModal = () => { } }, [swapStatus, swapError, activeTab, onClose, toast]) - // Determine button state - const isContinueButtonDisabled = - !transactionData?.isValid || swapStatus === 'pending' - const isContinueButtonLoading = swapStatus === 'pending' + const isContinueButtonDisabled = + !transactionData?.isValid || isContinueButtonLoading + return ( diff --git a/packages/web/src/components/buy-sell-modal/SwapTab.tsx b/packages/web/src/components/buy-sell-modal/SwapTab.tsx index 56ff5f8b0a1..c0e1c2a2d1a 100644 --- a/packages/web/src/components/buy-sell-modal/SwapTab.tsx +++ b/packages/web/src/components/buy-sell-modal/SwapTab.tsx @@ -1,11 +1,29 @@ -import { Flex, Text } from '@audius/harmony' - -import LoadingSpinner from 'components/loading-spinner/LoadingSpinner' +import { Flex, Text, Skeleton } from '@audius/harmony' import { TokenAmountSection } from './TokenAmountSection' import { useTokenSwapForm } from './hooks/useTokenSwapForm' import { TokenInfo } from './types' +const TokenSectionSkeleton = ({ title }: { title: string }) => ( + + + + +) + +const ExchangeRateSkeleton = () => ( + + + +) + +const SwapFormSkeleton = () => ( + + + + +) + export type SwapTabProps = { inputToken: TokenInfo outputToken: TokenInfo @@ -61,11 +79,7 @@ export const SwapTab = ({ return ( {/* Show loading state while fetching balance or initial exchange rate */} - {isInitialLoading && ( - - - - )} + {isInitialLoading && } {/* Show error from exchange rate fetch */} {exchangeRateError && ( @@ -122,9 +136,7 @@ export const SwapTab = ({ {/* Loading indicator for exchange rate */} {isExchangeRateLoading && numericInputAmount > 0 && !isInitialLoading && ( - - - + )} ) diff --git a/packages/web/src/components/buy-sell-modal/constants.ts b/packages/web/src/components/buy-sell-modal/constants.ts index 4877020a277..a4447138ade 100644 --- a/packages/web/src/components/buy-sell-modal/constants.ts +++ b/packages/web/src/components/buy-sell-modal/constants.ts @@ -9,7 +9,7 @@ export const TOKENS: Record = { name: 'Audius', icon: IconLogoCircle, decimals: 18, - balance: null, // Changed from 0 to null + balance: null, isStablecoin: false }, USDC: { @@ -17,7 +17,7 @@ export const TOKENS: Record = { name: 'USD Coin', icon: IconLogoCircleUSDC, decimals: 6, - balance: null, // Changed from 0 to null + balance: null, isStablecoin: true } } @@ -27,6 +27,6 @@ export const SUPPORTED_TOKEN_PAIRS: TokenPair[] = [ { baseToken: TOKENS.AUDIO, quoteToken: TOKENS.USDC, - exchangeRate: null // Changed from 0.082 to null + exchangeRate: null } ] From 5592cdadefbea5c09cadd8bf5f66d70563a06e06 Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Thu, 24 Apr 2025 19:43:08 -0500 Subject: [PATCH 04/22] Update hook in buy tab --- packages/web/src/components/buy-sell-modal/BuyTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/components/buy-sell-modal/BuyTab.tsx b/packages/web/src/components/buy-sell-modal/BuyTab.tsx index c8d710c838c..3270e050311 100644 --- a/packages/web/src/components/buy-sell-modal/BuyTab.tsx +++ b/packages/web/src/components/buy-sell-modal/BuyTab.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react' -import { useUSDCBalance } from '@audius/common/src/hooks/useUSDCBalance' +import { useUSDCBalance } from '@audius/common/src/api/tan-query/useUSDCBalance' import { Status } from '@audius/common/src/models/Status' import { SwapTab } from './SwapTab' From b93b9bf6e0374006ab7c21c5b93d2807b1f705c7 Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Thu, 1 May 2025 16:38:09 -0500 Subject: [PATCH 05/22] Qucik stash --- packages/common/src/api/index.ts | 2 + .../common/src/api/tan-query/useSwapTokens.ts | 383 +++++++++++------- .../src/api/tan-query/useTokenExchangeRate.ts | 23 +- .../src/services/JupiterTokenExchange.ts | 147 ++++++- .../buy-sell-modal/BuySellModal.tsx | 15 +- .../src/components/buy-sell-modal/BuyTab.tsx | 2 +- 6 files changed, 413 insertions(+), 159 deletions(-) diff --git a/packages/common/src/api/index.ts b/packages/common/src/api/index.ts index bff8dcec58c..94923e6cb60 100644 --- a/packages/common/src/api/index.ts +++ b/packages/common/src/api/index.ts @@ -124,6 +124,8 @@ export * from './tan-query/wallets/useTokenPrice' export * from './tan-query/wallets/useWalletCollectibles' export * from './tan-query/wallets/useWalletOwner' export * from './tan-query/wallets/useUSDCBalance' +export * from './tan-query/useSwapTokens' +export * from './tan-query/useTokenExchangeRate' // Saga fetch utils, remove when migration is complete export * from './tan-query/saga-utils' diff --git a/packages/common/src/api/tan-query/useSwapTokens.ts b/packages/common/src/api/tan-query/useSwapTokens.ts index b2569dfbea5..ae97d774e21 100644 --- a/packages/common/src/api/tan-query/useSwapTokens.ts +++ b/packages/common/src/api/tan-query/useSwapTokens.ts @@ -1,47 +1,49 @@ -import { createJupiterApiClient, Instruction } from '@jup-ag/api' -import { - createAssociatedTokenAccountInstruction, - getAssociatedTokenAddressSync -} from '@solana/spl-token' -import { PublicKey, Transaction, TransactionInstruction } from '@solana/web3.js' +// File: /tan-query/useSwapTokens.ts +import { PublicKey, VersionedTransaction } from '@solana/web3.js' import { useMutation, useQueryClient } from '@tanstack/react-query' -import { useAudiusQueryContext } from '~/audius-query/AudiusQueryContext' -import { Feature } from '~/models/ErrorReporting' -import { - SLIPPAGE_TOLERANCE_EXCEEDED_ERROR, - parseJupiterInstruction -} from '~/services/Jupiter' +import { useAudiusQueryContext } from '~/audius-query' +import { Feature } from '~/models' import { - getJupiterQuote, - JupiterQuoteParams + getJupiterQuoteByMint, + JupiterTokenExchange, + SLIPPAGE_TOLERANCE_EXCEEDED_ERROR } from '~/services/JupiterTokenExchange' -import { TOKEN_LISTING_MAP } from '~/store/ui/buy-audio/constants' +// Enums and Types defined earlier in the provided context export enum SwapStatus { IDLE = 'IDLE', GETTING_QUOTE = 'GETTING_QUOTE', - PREPARING_TRANSACTION = 'PREPARING_TRANSACTION', + BUILDING_TRANSACTION = 'BUILDING_TRANSACTION', // Added for clarity + RELAYING_TRANSACTION = 'RELAYING_TRANSACTION', // Added for clarity CONFIRMING_TRANSACTION = 'CONFIRMING_TRANSACTION', SUCCESS = 'SUCCESS', ERROR = 'ERROR' } export enum SwapErrorType { - INSUFFICIENT_BALANCE = 'INSUFFICIENT_BALANCE', + INSUFFICIENT_BALANCE = 'INSUFFICIENT_BALANCE', // Good to add pre-check later SLIPPAGE_EXCEEDED = 'SLIPPAGE_EXCEEDED', - TRANSACTION_FAILED = 'TRANSACTION_FAILED', - WALLET_ERROR = 'WALLET_ERROR', - QUOTE_FAILED = 'QUOTE_FAILED', + TRANSACTION_FAILED = 'TRANSACTION_FAILED', // Generic relay/confirm failure + WALLET_ERROR = 'WALLET_ERROR', // e.g., couldn't get keypair + QUOTE_FAILED = 'QUOTE_FAILED', // Jupiter API quote failure + BUILD_FAILED = 'BUILD_FAILED', // Failed during instruction/tx build + RELAY_FAILED = 'RELAY_FAILED', // Relay service specific error UNKNOWN = 'UNKNOWN' } -export type SwapTokensParams = Omit & { - /** - * Slippage tolerance in basis points (e.g., 50 = 0.5%) - * Defaults to 50 if not provided - */ +// Use the more generic params structure +export type SwapTokensParams = { + inputMint: string // SPL mint address or 'SOL' + outputMint: string // SPL mint address or 'SOL' + /** Amount of input token in UI units (e.g., 1.5 SOL, 10 AUDIO) */ + amountUi: number + /** Slippage tolerance in basis points (e.g., 50 = 0.5%). Defaults to 50. */ slippageBps?: number + /** Allow Jupiter to wrap/unwrap SOL automatically. Defaults to true. */ + wrapUnwrapSol?: boolean + // Add computeUnitPriceMicroLamports if needed, defaults usually fine + // computeUnitPriceMicroLamports?: number; } export type SwapTokensResult = { @@ -51,18 +53,20 @@ export type SwapTokensResult = { type: SwapErrorType message: string } + // Keep input/output amounts for display/confirmation inputAmount?: { - amount: number - uiAmount: number + amount: number // Lamports/Wei + uiAmount: number // User-friendly units } outputAmount?: { - amount: number - uiAmount: number + amount: number // Lamports/Wei + uiAmount: number // User-friendly units } } /** - * Hook for executing token swaps using Jupiter + * Hook for executing token swaps using Jupiter. + * Swaps any supported SPL token (or SOL) for another. */ export const useSwapTokens = () => { const queryClient = useQueryClient() @@ -71,195 +75,286 @@ export const useSwapTokens = () => { return useMutation({ mutationFn: async (params): Promise => { - try { - // Default slippage is 50 basis points (0.5%) - const slippageBps = params.slippageBps ?? 50 + const { inputMint, outputMint, amountUi } = params + // Default slippage is 50 basis points (0.5%) + const slippageBps = params.slippageBps ?? 50 + const wrapUnwrapSol = params.wrapUnwrapSol ?? true - // Step 1: Get the wallet keypair + let quoteResult + let signature: string | undefined + + try { + // ---------- 1. Get Wallet Keypair ---------- const keypair = await solanaWalletService.getKeypair() if (!keypair) { - throw new Error('Failed to get wallet keypair') + console.error('useSwapTokens: Wallet not initialised') + return { + status: SwapStatus.ERROR, + error: { + type: SwapErrorType.WALLET_ERROR, + message: 'Wallet not initialised' + } + } } + const userPublicKey = keypair.publicKey - // Step 2: Get a quote from Jupiter - let quoteResult + // ---------- 2. Get Quote from Jupiter ---------- try { - quoteResult = await getJupiterQuote({ - ...params, - slippageBps + quoteResult = await getJupiterQuoteByMint({ + inputMint, + outputMint, + amountUi, + slippageBps, + swapMode: 'ExactIn' }) - } catch (error) { + } catch (error: any) { + console.error('useSwapTokens: Error getting Jupiter quote:', error) reportToSentry({ name: 'JupiterSwapQuoteError', - error: error as Error, - feature: Feature.TanQuery, + error, + feature: Feature.TanQuery, // Or a more specific feature additionalInfo: { params } }) return { status: SwapStatus.ERROR, error: { type: SwapErrorType.QUOTE_FAILED, - message: 'Failed to get swap quote' + message: error?.message ?? 'Failed to get swap quote from Jupiter' } } } - // Step 3: Initialize Jupiter client for swap instructions - const jupiter = createJupiterApiClient() - const outputToken = TOKEN_LISTING_MAP[params.outputTokenSymbol] - const userPublicKey = new PublicKey(keypair.publicKey.toBase58()) - - // Step 4: Check if user has an associated token account for the output token - // and create one if it doesn't exist + // ---------- 3. Build Transaction ---------- + let swapTx: VersionedTransaction const sdk = await audiusSdk() - const connection = sdk.services.solanaClient.connection - - // Get destination token account address - const destinationTokenAccount = getAssociatedTokenAddressSync( - new PublicKey(outputToken.address), - userPublicKey - ) - - // Check if the associated token account exists - let destinationTokenAccountExists = false try { - await connection.getAccountInfo(destinationTokenAccount) - destinationTokenAccountExists = true - } catch (e) { - destinationTokenAccountExists = false - } + // instructions are now already TransactionInstruction[] + const { instructions, lookupTableAddresses } = + await JupiterTokenExchange.getSwapInstructions({ + quote: quoteResult.quote, + userPublicKey: userPublicKey.toBase58(), + wrapAndUnwrapSol: wrapUnwrapSol + }) - // Step 5: Get swap instructions from Jupiter - const swapResponse = await jupiter.swapInstructionsPost({ - swapRequest: { - quoteResponse: quoteResult.quote, - userPublicKey: userPublicKey.toString(), - destinationTokenAccount: destinationTokenAccount.toString(), - wrapAndUnwrapSol: true, - useSharedAccounts: true - } - }) - - // Step 6: Build the transaction - const instructions: TransactionInstruction[] = [] + const feePayer = await sdk.services.solanaRelay.getFeePayer() - // If destination token account doesn't exist, create it - if (!destinationTokenAccountExists) { - instructions.push( - createAssociatedTokenAccountInstruction( - userPublicKey, - destinationTokenAccount, - userPublicKey, - new PublicKey(outputToken.address) + // Build the transaction with the pre-converted instructions + swapTx = await sdk.services.solanaClient.buildTransaction({ + feePayer, + instructions, // Pass directly + addressLookupTables: lookupTableAddresses.map( + (address: string) => new PublicKey(address) ) - ) + // Let relay handle priority fees and compute limits as needed + // priorityFee: null, + // computeLimit: null + }) + } catch (error: any) { + console.error('useSwapTokens: Error building transaction:', error) + reportToSentry({ + name: 'JupiterSwapBuildError', + error, + feature: Feature.TanQuery, + additionalInfo: { params, quoteResponse: quoteResult.quote } + }) + return { + status: SwapStatus.ERROR, + error: { + type: SwapErrorType.BUILD_FAILED, + message: error?.message ?? 'Failed to build swap transaction' + }, + inputAmount: quoteResult.inputAmount, + outputAmount: quoteResult.outputAmount + } } - // Add Jupiter instructions - const jupiterInstructions = [ - swapResponse.tokenLedgerInstruction, - ...swapResponse.computeBudgetInstructions, - ...swapResponse.setupInstructions, - swapResponse.swapInstruction, - swapResponse.cleanupInstruction - ] - .filter( - (instruction): instruction is Instruction => - instruction !== undefined - ) - .map(parseJupiterInstruction) - - instructions.push(...jupiterInstructions) - - // Step 7: Create and sign the transaction - const transaction = new Transaction() - transaction.add(...instructions) - transaction.feePayer = userPublicKey + // ---------- 4. Sign Transaction ---------- + // The Audius relay requires the fee payer (slot 0) signature to be cleared + // and the transaction to be signed by the actual user (which we do here). + // The relay service will then sign as the fee payer. + // swapTx.sign([keypair]) // Removed this line - letting the relay handle signing - // Get the latest blockhash - const { blockhash } = await connection.getLatestBlockhash() - transaction.recentBlockhash = blockhash - - // Sign the transaction - transaction.sign(keypair) - - // Step 8: Send the transaction - const signature = await connection.sendRawTransaction( - transaction.serialize(), - { skipPreflight: false } - ) - - // Step 9: Confirm the transaction + // ---------- Debug: Log transaction instructions ---------- try { - await connection.confirmTransaction(signature, 'confirmed') - - // Invalidate balance queries - queryClient.invalidateQueries({ queryKey: ['audioBalance'] }) - queryClient.invalidateQueries({ queryKey: ['usdcBalance'] }) + const message = swapTx.message + // For v0 transactions, use compiledInstructions and staticAccountKeys + const programIds = message.compiledInstructions.map((ix) => { + // Map programIdIndex to staticAccountKeys if available + const programId = + message.staticAccountKeys?.[ix.programIdIndex]?.toBase58?.() || + `idx:${ix.programIdIndex}` + return programId + }) + // Print all program IDs in order + console.debug( + 'SWAP RELAY DEBUG: Transaction program IDs:', + programIds + ) + // Optionally, print the raw instruction data for further debugging + console.debug( + 'SWAP RELAY DEBUG: Compiled instructions:', + message.compiledInstructions + ) + } catch (e) { + console.warn( + 'SWAP RELAY DEBUG: Failed to log transaction instructions', + e + ) + } + // ---------- 5. Relay Transaction ---------- + try { + const relayResult = await sdk.services.solanaRelay.relay({ + transaction: swapTx, + sendOptions: { skipPreflight: false } // Preflight checks happen during build/quote + }) + signature = relayResult.signature + } catch (error: any) { + console.error('useSwapTokens: Error relaying transaction:', error) + reportToSentry({ + name: 'JupiterSwapRelayError', + error, + feature: Feature.TanQuery, + additionalInfo: { params, quoteResponse: quoteResult.quote } + }) + // Use the error message from the relay if available + const relayErrorMessage = + error?.response?.data?.error ?? error?.message return { - status: SwapStatus.SUCCESS, - signature, + status: SwapStatus.ERROR, + error: { + type: SwapErrorType.RELAY_FAILED, + message: relayErrorMessage ?? 'Failed to relay swap transaction' + }, inputAmount: quoteResult.inputAmount, outputAmount: quoteResult.outputAmount } - } catch (error) { - // Check if error is due to slippage + } + + // ---------- 6. Confirm Transaction ---------- + const connection = sdk.services.solanaClient.connection + try { + await connection.confirmTransaction(signature, 'confirmed') + } catch (error: any) { + console.error( + `useSwapTokens: Transaction confirmation error (Sig: ${signature}):`, + error + ) + // Check specifically for slippage error text if possible if ( error instanceof Error && - error.message.includes(SLIPPAGE_TOLERANCE_EXCEEDED_ERROR.toString()) + error.message.includes(SLIPPAGE_TOLERANCE_EXCEEDED_ERROR) ) { + console.warn('useSwapTokens: Slippage tolerance exceeded.') + reportToSentry({ + name: 'JupiterSwapSlippageError', + error, + feature: Feature.TanQuery, + additionalInfo: { signature, params } + }) return { status: SwapStatus.ERROR, signature, error: { type: SwapErrorType.SLIPPAGE_EXCEEDED, - message: 'Slippage tolerance exceeded' + message: 'Price moved too much during swap (slippage exceeded)' }, inputAmount: quoteResult.inputAmount, outputAmount: quoteResult.outputAmount } } + // Generic confirmation failure reportToSentry({ name: 'JupiterSwapConfirmationError', - error: error as Error, + error, feature: Feature.TanQuery, additionalInfo: { signature, params } }) - return { status: SwapStatus.ERROR, signature, error: { type: SwapErrorType.TRANSACTION_FAILED, - message: 'Transaction failed' + message: + error?.message ?? 'Failed to confirm swap transaction on-chain' }, inputAmount: quoteResult.inputAmount, outputAmount: quoteResult.outputAmount } } - } catch (error) { + + // ---------- 7. Success & Invalidation ---------- + // Generate dynamic query keys based on mints + // Assuming your balance hooks use keys like ['audioBalance', userId] or ['usdcBalance', userId] + // We need a more generic pattern if swapping arbitrary tokens. + // Example: ['tokenBalance', userId, mintAddress] + // If using simpler keys like ['audioBalance'], adjust accordingly. + // SOL balance might need invalidation too if SOL was input/output. + const inputBalanceKey = + inputMint === 'SOL' ? 'solBalance' : 'tokenBalance' // Adapt as needed + const outputBalanceKey = + outputMint === 'SOL' ? 'solBalance' : 'tokenBalance' // Adapt as needed + + queryClient.invalidateQueries({ queryKey: [inputBalanceKey] }) // Invalidate broadly for now + queryClient.invalidateQueries({ queryKey: [outputBalanceKey] }) + // Or more specifically if your keys include the mint address: + // queryClient.invalidateQueries({ queryKey: [inputBalanceKey, inputMint] }); + // queryClient.invalidateQueries({ queryKey: [outputBalanceKey, outputMint] }); + + return { + status: SwapStatus.SUCCESS, + signature, + inputAmount: quoteResult.inputAmount, + outputAmount: quoteResult.outputAmount + } + } catch (error: any) { + // Catch-all for unexpected errors during the process + console.error('useSwapTokens: Unknown error during swap:', error) reportToSentry({ - name: 'JupiterSwapError', - error: error as Error, + name: 'JupiterSwapUnknownError', + error, feature: Feature.TanQuery, - additionalInfo: { params } + additionalInfo: { params, signature } // Signature might be undefined here }) - return { status: SwapStatus.ERROR, error: { type: SwapErrorType.UNKNOWN, - message: error instanceof Error ? error.message : 'Unknown error' + message: error?.message ?? 'An unknown error occurred during swap' } } } }, + // Provide initial status feedback onMutate: () => { return { - status: SwapStatus.GETTING_QUOTE + status: SwapStatus.GETTING_QUOTE, + inputAmount: undefined, + outputAmount: undefined } - } + }, + // Centralized error reporting for mutation failures + onError: (error: Error, variables) => { + // This catches errors thrown *before* returning a structured SwapTokensResult + // (e.g., failure in `getKeypair`, or unhandled exceptions). + // Errors handled *within* mutationFn return structured results and don't hit this onError. + console.error( + 'useSwapTokens: Unhandled mutation error:', + error, + 'with variables:', + variables + ) + reportToSentry({ + name: 'JupiterSwapUnhandledMutationError', + error, + feature: Feature.TanQuery, + additionalInfo: { params: variables } + }) + // Optionally, you could return a default error state here to update the mutation cache, + // but usually, letting the mutation stay in 'error' state is sufficient. + }, + // Add meta for React Query DevTools + meta: { feature: 'Swap Tokens using Jupiter' } }) } diff --git a/packages/common/src/api/tan-query/useTokenExchangeRate.ts b/packages/common/src/api/tan-query/useTokenExchangeRate.ts index a15aea6779e..61e3fb9d569 100644 --- a/packages/common/src/api/tan-query/useTokenExchangeRate.ts +++ b/packages/common/src/api/tan-query/useTokenExchangeRate.ts @@ -5,8 +5,9 @@ import { useQuery } from '@tanstack/react-query' import { JupiterTokenSymbol, - getJupiterQuote + getJupiterQuoteByMint } from '~/services/JupiterTokenExchange' +import { TOKEN_LISTING_MAP } from '~/store/ui/buy-audio/constants' import { QueryOptions, type QueryKey } from './types' @@ -76,11 +77,21 @@ export const useTokenExchangeRate = ( }), queryFn: async () => { try { - // Get quote from Jupiter using the shared service - const quoteResult = await getJupiterQuote({ - inputTokenSymbol: params.inputTokenSymbol, - outputTokenSymbol: params.outputTokenSymbol, - inputAmount, + // Get token mint addresses + const inputToken = TOKEN_LISTING_MAP[params.inputTokenSymbol] + const outputToken = TOKEN_LISTING_MAP[params.outputTokenSymbol] + + if (!inputToken || !outputToken) { + throw new Error( + `Token not found: ${params.inputTokenSymbol} or ${params.outputTokenSymbol}` + ) + } + + // Get quote from Jupiter using the mint addresses + const quoteResult = await getJupiterQuoteByMint({ + inputMint: inputToken.address, + outputMint: outputToken.address, + amountUi: inputAmount, slippageBps, swapMode: params.swapMode ?? 'ExactIn', onlyDirectRoutes: false diff --git a/packages/common/src/services/JupiterTokenExchange.ts b/packages/common/src/services/JupiterTokenExchange.ts index c6991b48a58..4094729464c 100644 --- a/packages/common/src/services/JupiterTokenExchange.ts +++ b/packages/common/src/services/JupiterTokenExchange.ts @@ -1,4 +1,5 @@ -import { createJupiterApiClient, QuoteResponse } from '@jup-ag/api' +import { createJupiterApiClient, QuoteResponse, Instruction } from '@jup-ag/api' +import { PublicKey, TransactionInstruction } from '@solana/web3.js' import { TOKEN_LISTING_MAP } from '~/store/ui/buy-audio/constants' import { convertBigIntToAmountObject } from '~/utils' @@ -25,6 +26,18 @@ export type JupiterQuoteParams = { onlyDirectRoutes?: boolean } +// Add support for mint-based parameters for the useSwapTokens hook +export type JupiterMintQuoteParams = { + inputMint: string + outputMint: string + amountUi: number + slippageBps: number + swapMode?: 'ExactIn' | 'ExactOut' + onlyDirectRoutes?: boolean +} + +export const SLIPPAGE_TOLERANCE_EXCEEDED_ERROR = 'Slippage tolerance exceeded' + export type JupiterQuoteResult = { inputAmount: { amount: number @@ -104,3 +117,135 @@ export const getJupiterQuote = async ({ quote } } + +/** + * Gets a quote from Jupiter using mint addresses directly + * This version is used by the useSwapTokens hook + */ +export const getJupiterQuoteByMint = async ({ + inputMint, + outputMint, + amountUi, + slippageBps, + swapMode = 'ExactIn', + onlyDirectRoutes = false +}: JupiterMintQuoteParams): Promise => { + // Get quote from Jupiter + const jupiter = getJupiterClient() + + // Look up token decimals from TOKEN_LISTING_MAP + // We'll find tokens by their address to get the correct decimals + const inputToken = Object.values(TOKEN_LISTING_MAP).find( + (token) => token.address === inputMint + ) + const outputToken = Object.values(TOKEN_LISTING_MAP).find( + (token) => token.address === outputMint + ) + + // Default to 9 decimals if tokens aren't found (fallback for safety) + const inputDecimals = inputToken?.decimals ?? 9 + const outputDecimals = outputToken?.decimals ?? 9 + + const amount = + swapMode === 'ExactIn' + ? Math.ceil(amountUi * 10 ** inputDecimals) + : Math.floor(amountUi * 10 ** outputDecimals) + + const quote = await jupiter.quoteGet({ + inputMint, + outputMint, + amount, + slippageBps, + swapMode, + onlyDirectRoutes + }) + + if (!quote) { + throw new Error('Failed to get Jupiter quote') + } + + return { + inputAmount: convertBigIntToAmountObject( + BigInt(quote.inAmount), + inputDecimals + ), + outputAmount: convertBigIntToAmountObject( + BigInt(quote.outAmount), + outputDecimals + ), + otherAmountThreshold: convertBigIntToAmountObject( + BigInt(quote.otherAmountThreshold), + swapMode === 'ExactIn' ? outputDecimals : inputDecimals + ), + quote + } +} + +/** + * Gets swap instructions from Jupiter for executing a token swap + * Converts raw Jupiter instructions to Solana TransactionInstructions + */ +export const getSwapInstructions = async ({ + quote, + userPublicKey, + wrapAndUnwrapSol = true +}: { + quote: QuoteResponse + userPublicKey: string + wrapAndUnwrapSol?: boolean +}) => { + const jupiter = getJupiterClient() + const response = await jupiter.swapInstructionsPost({ + swapRequest: { + quoteResponse: quote, + userPublicKey, + wrapAndUnwrapSol, + computeUnitPriceMicroLamports: 100000, + useSharedAccounts: true + } + }) + + const { + tokenLedgerInstruction, + computeBudgetInstructions, + setupInstructions, + swapInstruction, + cleanupInstruction, + addressLookupTableAddresses + } = response + + // Flatten and filter out undefined instructions + const instructions = [ + tokenLedgerInstruction, + ...(computeBudgetInstructions || []), + ...(setupInstructions || []), + swapInstruction, + cleanupInstruction + ].filter((i): i is Instruction => i !== undefined) + + // Convert to Solana TransactionInstruction format + const transactionInstructions = instructions.map((i) => { + return { + programId: new PublicKey(i.programId), + data: Buffer.from(i.data, 'base64'), + keys: i.accounts.map((a) => { + return { + pubkey: new PublicKey(a.pubkey), + isSigner: a.isSigner, + isWritable: a.isWritable + } + }) + } as TransactionInstruction + }) + + return { + instructions: transactionInstructions, + lookupTableAddresses: addressLookupTableAddresses || [] + } +} + +// Export a singleton for convenient access +export const JupiterTokenExchange = { + getQuote: getJupiterQuoteByMint, + getSwapInstructions +} diff --git a/packages/web/src/components/buy-sell-modal/BuySellModal.tsx b/packages/web/src/components/buy-sell-modal/BuySellModal.tsx index 2ba19e5ef50..9c63ef95dcc 100644 --- a/packages/web/src/components/buy-sell-modal/BuySellModal.tsx +++ b/packages/web/src/components/buy-sell-modal/BuySellModal.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useState, useContext } from 'react' +import { useSwapTokens } from '@audius/common/api' import { buySellMessages as messages } from '@audius/common/messages' -import { useSwapTokens } from '@audius/common/src/api/tan-query/useSwapTokens' +import { TOKEN_LISTING_MAP } from '@audius/common/src/store/ui/buy-audio/constants' import { useBuySellModal } from '@audius/common/store' import { Button, @@ -89,17 +90,17 @@ export const BuySellModal = () => { if (activeTab === 'buy') { // Buy AUDIO with USDC swapTokens({ - inputTokenSymbol: 'USDC', - outputTokenSymbol: 'AUDIO', - inputAmount, + inputMint: TOKEN_LISTING_MAP.USDC.address, + outputMint: TOKEN_LISTING_MAP.AUDIO.address, + amountUi: inputAmount, slippageBps }) } else { // Sell AUDIO for USDC swapTokens({ - inputTokenSymbol: 'AUDIO', - outputTokenSymbol: 'USDC', - inputAmount, + inputMint: TOKEN_LISTING_MAP.AUDIO.address, + outputMint: TOKEN_LISTING_MAP.USDC.address, + amountUi: inputAmount, slippageBps }) } diff --git a/packages/web/src/components/buy-sell-modal/BuyTab.tsx b/packages/web/src/components/buy-sell-modal/BuyTab.tsx index 3270e050311..2ed4d201579 100644 --- a/packages/web/src/components/buy-sell-modal/BuyTab.tsx +++ b/packages/web/src/components/buy-sell-modal/BuyTab.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react' -import { useUSDCBalance } from '@audius/common/src/api/tan-query/useUSDCBalance' +import { useUSDCBalance } from '@audius/common/api' import { Status } from '@audius/common/src/models/Status' import { SwapTab } from './SwapTab' From 11d2ce9ce9b7e2e824082c2957e33b138bc9e5b8 Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Thu, 1 May 2025 20:51:05 -0500 Subject: [PATCH 06/22] Remove md files --- learnings.md | 65 --------------------------- plan.md | 121 --------------------------------------------------- 2 files changed, 186 deletions(-) delete mode 100644 learnings.md delete mode 100644 plan.md diff --git a/learnings.md b/learnings.md deleted file mode 100644 index 69b2dbb676b..00000000000 --- a/learnings.md +++ /dev/null @@ -1,65 +0,0 @@ -# Learnings from BuySellModal Implementation - -## Code Organization & Structure - -1. **Shared Code Should Not Reference Platform-Specific Code**: - - - Don't import from `@audius/web` or `@audius/mobile` packages in code that should be shared. - - Instead, place shared functionality in the `@audius/common` package and import from there. - -2. **Organize by Function, Not by Type**: - - - Group related functionality together in service files, rather than splitting by type. - - Example: Jupiter token exchange functionality belongs in a dedicated service, not mixed with other token utilities. - -3. **Respect Existing Patterns**: - - Use `tan-query` hooks for data fetching and mutations when possible. - - Look for existing hooks before creating new ones (e.g., `useAudioBalance`, `useUSDCBalance`). - -## Code Modification Best Practices - -1. **Don't Modify Unrelated Code**: - - - When implementing new features, only change code directly related to the task. - - Avoid refactoring unrelated code, even if it seems beneficial. - -2. **Don't Delete Existing Code Without Clear Direction**: - - - Preserve existing code unless explicitly instructed to remove it. - - If uncertain, create new files rather than modifying existing ones. - -3. **Create New Files When Appropriate**: - - When adding substantial new functionality, create dedicated files. - - Example: Creating `JupiterTokenExchange.ts` for token exchange functionality. - -## Technical Learnings - -1. **Jupiter Integration**: - - - Use direct access to Jupiter API via `@jup-ag/api` for cross-platform compatibility. - - Make token swap functionality platform-agnostic. - -2. **Remote Config Access**: - - - Be careful with circular dependencies when accessing remote config. - - Default values are acceptable for first implementations when config access is problematic. - -3. **Proper Type Definitions**: - - Define and export proper types for API parameters and responses. - - Use existing type definitions where available. - -## Implementation Strategy - -1. **Break Down Complex Tasks**: - - - Implement one component at a time (e.g., exchange rate hook before swap hook). - - Follow the defined plan to maintain progress tracking. - -2. **Reuse Existing Code**: - - - Check for existing hooks and services before implementing new ones. - - Leverage existing patterns in the codebase. - -3. **Optimize for Maintainability**: - - Make code easy to maintain by separating concerns. - - Keep components focused on their specific responsibilities. diff --git a/plan.md b/plan.md deleted file mode 100644 index a34f4188e1d..00000000000 --- a/plan.md +++ /dev/null @@ -1,121 +0,0 @@ -# BuySellModal Implementation Plan - -## Overview - -This document outlines the implementation plan for making the BuySellModal, BuyTab, and SellTab components functional. The goal is to allow users to buy and sell AUDIO tokens using USDC via Jupiter swaps, using their internal Hedgehog wallet for balances and transactions. - -## Current State Analysis - -1. The UI components (BuySellModal, BuyTab, SellTab) are already implemented with proper layout and structure -2. Token constants are defined in `constants.ts` and are correct as is -3. Jupiter integration is partially implemented in the codebase through `JupiterSingleton` service -4. Hedgehog wallet is used for Solana transactions in other areas of the application -5. Existing hooks (`useAudioBalance` and `useUSDCBalance`) already provide token balance functionality - -## Implementation Steps - -### 1. Create TAN Query Hooks for Jupiter Integration - -#### 1.1 Leverage Existing Balance Hooks - -Instead of creating a new token balance hook, we will use the existing hooks: - -- `useAudioBalance` from `packages/common/src/api/tan-query/useAudioBalance.ts` - Already provides AUDIO balances for the user's wallets including Hedgehog -- `useUSDCBalance` from `packages/common/src/hooks/useUSDCBalance.ts` - Already provides USDC balance with polling capabilities - -#### 1.2 Create a hook for fetching exchange rate - -Create a new tan-query hook `useTokenExchangeRate.ts` that will: - -- Leverage existing `JupiterSingleton.getQuote` function to fetch real-time exchange rates -- Accept token pair parameters (AUDIO/USDC) for both buy and sell directions -- Return formatted exchange rate data -- Include a small amount for estimation (e.g., 1 USDC worth of AUDIO) - -#### 1.3 Create a hook for executing token swaps - -Create a new tan-query hook `useSwapTokens.ts` that will: - -- Be a mutation hook that executes a Jupiter swap -- Accept parameters for token pair, amount, slippage, and direction -- Use the `JupiterSingleton.getSwapInstructions` to create the transaction -- Execute the transaction using the Hedgehog wallet via `solanaWalletService` -- Handle transaction confirmation and error states -- Return transaction status and result - -### 2. Update Existing Components - -#### 2.1 Update BuySellModal.tsx - -- Connect to the Hedgehog wallet service to ensure we have access to the wallet -- Add state for tracking transaction status (idle, loading, success, error) -- Implement the "Continue" button functionality to execute swaps -- Add proper error handling and success messaging -- Make sure the modal closes properly after transactions - -#### 2.2 Update BuyTab.tsx - -- Use `useUSDCBalance` to display real USDC balance -- Use `useTokenExchangeRate` to display accurate exchange rate -- Enable balance polling during transactions using the built-in polling feature -- Add input validation (min/max amounts, available balance) -- Update state in parent component for executing transaction - -#### 2.3 Update SellTab.tsx - -- Use `useAudioBalance` to display real AUDIO balance (specifically the `totalBalance` value) -- Use `useTokenExchangeRate` to display accurate exchange rate -- Add a refresh mechanism to update balances after transactions -- Add input validation (min/max amounts, available balance) -- Update state in parent component for executing transaction - -### 3. Additional Considerations - -#### 3.1 Slippage Handling - -- Add a configurable slippage setting (can be hidden in MVP) -- Default to a safe value from remote config: `BUY_TOKEN_VIA_SOL_SLIPPAGE_BPS` and `BUY_SOL_VIA_TOKEN_SLIPPAGE_BPS` - -#### 3.2 Transaction Confirmation - -- Add transaction confirmation handling -- Display transaction status and links to explorer - -#### 3.3 Analytics - -- Add analytics tracking for: - - Modal opens - - Quote requests - - Swap attempts - - Swap successes/failures - -## Implementation Details - -### API Structure - -The new tan-query hooks will be created in the appropriate directory structure: - -``` -packages/common/src/api/tan-query/ - - useTokenExchangeRate.ts - - useSwapTokens.ts -``` - -### Utilizing Existing Services - -We will make use of existing services and hooks: - -- `useAudioBalance` and `useUSDCBalance` for token balances -- `solanaWalletService` for accessing the Hedgehog wallet -- `JupiterSingleton` for quotes and swap instructions -- Existing token constants from `TOKEN_LISTING_MAP` - -### Error Handling - -- Implement proper error handling for network issues -- Add user-friendly error messages for common failure scenarios -- Handle insufficient balance errors gracefully - -## Conclusion - -This implementation will enable users to buy and sell AUDIO tokens using USDC through Jupiter swaps, all while using their internal Hedgehog wallet. The approach leverages existing infrastructure and follows the project's patterns for tan-query hooks rather than relying on Redux. From c0dc6211b36c0c8b74929631a1f364cebe2378a3 Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Thu, 1 May 2025 20:54:42 -0500 Subject: [PATCH 07/22] Small --- packages/common/src/api/tan-query/useSwapTokens.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/common/src/api/tan-query/useSwapTokens.ts b/packages/common/src/api/tan-query/useSwapTokens.ts index ae97d774e21..24fc4a0e331 100644 --- a/packages/common/src/api/tan-query/useSwapTokens.ts +++ b/packages/common/src/api/tan-query/useSwapTokens.ts @@ -128,7 +128,6 @@ export const useSwapTokens = () => { let swapTx: VersionedTransaction const sdk = await audiusSdk() try { - // instructions are now already TransactionInstruction[] const { instructions, lookupTableAddresses } = await JupiterTokenExchange.getSwapInstructions({ quote: quoteResult.quote, @@ -141,13 +140,12 @@ export const useSwapTokens = () => { // Build the transaction with the pre-converted instructions swapTx = await sdk.services.solanaClient.buildTransaction({ feePayer, - instructions, // Pass directly + instructions, addressLookupTables: lookupTableAddresses.map( (address: string) => new PublicKey(address) - ) - // Let relay handle priority fees and compute limits as needed - // priorityFee: null, - // computeLimit: null + ), + priorityFee: null, + computeLimit: null }) } catch (error: any) { console.error('useSwapTokens: Error building transaction:', error) @@ -172,7 +170,7 @@ export const useSwapTokens = () => { // The Audius relay requires the fee payer (slot 0) signature to be cleared // and the transaction to be signed by the actual user (which we do here). // The relay service will then sign as the fee payer. - // swapTx.sign([keypair]) // Removed this line - letting the relay handle signing + swapTx.sign([keypair]) // Removed this line - letting the relay handle signing // ---------- Debug: Log transaction instructions ---------- try { From e2693b1679f21996528b78de9d3d0b463d09f1bf Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Thu, 1 May 2025 21:09:56 -0500 Subject: [PATCH 08/22] Yeet --- packages/common/src/api/tan-query/useSwapTokens.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/common/src/api/tan-query/useSwapTokens.ts b/packages/common/src/api/tan-query/useSwapTokens.ts index 24fc4a0e331..e1a5c3f0c6d 100644 --- a/packages/common/src/api/tan-query/useSwapTokens.ts +++ b/packages/common/src/api/tan-query/useSwapTokens.ts @@ -170,7 +170,7 @@ export const useSwapTokens = () => { // The Audius relay requires the fee payer (slot 0) signature to be cleared // and the transaction to be signed by the actual user (which we do here). // The relay service will then sign as the fee payer. - swapTx.sign([keypair]) // Removed this line - letting the relay handle signing + swapTx.sign([keypair]) // ---------- Debug: Log transaction instructions ---------- try { From da47a06d2e96fe2880f862fc65cf9c031ba36f2d Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Thu, 1 May 2025 21:10:39 -0500 Subject: [PATCH 09/22] yeeet --- packages/common/src/api/tan-query/useSwapTokens.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/common/src/api/tan-query/useSwapTokens.ts b/packages/common/src/api/tan-query/useSwapTokens.ts index e1a5c3f0c6d..3280032dd26 100644 --- a/packages/common/src/api/tan-query/useSwapTokens.ts +++ b/packages/common/src/api/tan-query/useSwapTokens.ts @@ -1,4 +1,3 @@ -// File: /tan-query/useSwapTokens.ts import { PublicKey, VersionedTransaction } from '@solana/web3.js' import { useMutation, useQueryClient } from '@tanstack/react-query' From f84624c363a82722a08b5bdd187f5fad54eba096 Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Fri, 2 May 2025 10:17:08 -0500 Subject: [PATCH 10/22] Prime files --- .../common/src/api/tan-query/useSwapTokens.ts | 29 +- .../src/api/tan-query/useTokenExchangeRate.ts | 10 +- packages/common/src/services/Jupiter.ts | 248 ++++++++++++++++- .../src/services/JupiterTokenExchange.ts | 251 ------------------ .../buy-sell-modal/hooks/useTokenSwapForm.ts | 2 +- 5 files changed, 250 insertions(+), 290 deletions(-) delete mode 100644 packages/common/src/services/JupiterTokenExchange.ts diff --git a/packages/common/src/api/tan-query/useSwapTokens.ts b/packages/common/src/api/tan-query/useSwapTokens.ts index 3280032dd26..ffd9c9c391f 100644 --- a/packages/common/src/api/tan-query/useSwapTokens.ts +++ b/packages/common/src/api/tan-query/useSwapTokens.ts @@ -3,11 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { useAudiusQueryContext } from '~/audius-query' import { Feature } from '~/models' -import { - getJupiterQuoteByMint, - JupiterTokenExchange, - SLIPPAGE_TOLERANCE_EXCEEDED_ERROR -} from '~/services/JupiterTokenExchange' +import { getJupiterQuoteByMint, JupiterTokenExchange } from '~/services/Jupiter' // Enums and Types defined earlier in the provided context export enum SwapStatus { @@ -237,29 +233,6 @@ export const useSwapTokens = () => { `useSwapTokens: Transaction confirmation error (Sig: ${signature}):`, error ) - // Check specifically for slippage error text if possible - if ( - error instanceof Error && - error.message.includes(SLIPPAGE_TOLERANCE_EXCEEDED_ERROR) - ) { - console.warn('useSwapTokens: Slippage tolerance exceeded.') - reportToSentry({ - name: 'JupiterSwapSlippageError', - error, - feature: Feature.TanQuery, - additionalInfo: { signature, params } - }) - return { - status: SwapStatus.ERROR, - signature, - error: { - type: SwapErrorType.SLIPPAGE_EXCEEDED, - message: 'Price moved too much during swap (slippage exceeded)' - }, - inputAmount: quoteResult.inputAmount, - outputAmount: quoteResult.outputAmount - } - } // Generic confirmation failure reportToSentry({ diff --git a/packages/common/src/api/tan-query/useTokenExchangeRate.ts b/packages/common/src/api/tan-query/useTokenExchangeRate.ts index 61e3fb9d569..2e31d760337 100644 --- a/packages/common/src/api/tan-query/useTokenExchangeRate.ts +++ b/packages/common/src/api/tan-query/useTokenExchangeRate.ts @@ -3,10 +3,7 @@ import { useMemo } from 'react' import { QuoteResponse } from '@jup-ag/api' import { useQuery } from '@tanstack/react-query' -import { - JupiterTokenSymbol, - getJupiterQuoteByMint -} from '~/services/JupiterTokenExchange' +import { JupiterTokenSymbol, getJupiterQuoteByMint } from '~/services/Jupiter' import { TOKEN_LISTING_MAP } from '~/store/ui/buy-audio/constants' import { QueryOptions, type QueryKey } from './types' @@ -119,11 +116,6 @@ export const useTokenExchangeRate = ( throw error } }, - // Stale time of 30 seconds - rates are fetched frequently but not too often - staleTime: 30 * 1000, - // Retain cached data for 1 minute - gcTime: 60 * 1000, - // Default to enabled ...options, enabled: options?.enabled !== false }) diff --git a/packages/common/src/services/Jupiter.ts b/packages/common/src/services/Jupiter.ts index bdb584110c0..3eeb33aa43a 100644 --- a/packages/common/src/services/Jupiter.ts +++ b/packages/common/src/services/Jupiter.ts @@ -1,8 +1,10 @@ -import { createJupiterApiClient, Instruction } from '@jup-ag/api' +import { createJupiterApiClient, Instruction, QuoteResponse } from '@jup-ag/api' import { PublicKey, TransactionInstruction } from '@solana/web3.js' import { Name } from '~/models/Analytics' import { CommonStoreContext } from '~/store/storeContext' +import { TOKEN_LISTING_MAP } from '~/store/ui/buy-audio/constants' +import { convertBigIntToAmountObject } from '~/utils' /** * The error that gets returned if the slippage is exceeded @@ -11,6 +13,9 @@ import { CommonStoreContext } from '~/store/storeContext' */ export const SLIPPAGE_TOLERANCE_EXCEEDED_ERROR = 6001 +// Define JupiterTokenSymbol type here since we can't import it directly +export type JupiterTokenSymbol = keyof typeof TOKEN_LISTING_MAP + export const parseJupiterInstruction = (instruction: Instruction) => { return new TransactionInstruction({ programId: new PublicKey(instruction.programId), @@ -23,8 +28,59 @@ export const parseJupiterInstruction = (instruction: Instruction) => { }) } +let jupiterClient: ReturnType | null = null + +export const getJupiterClient = () => { + if (!jupiterClient) { + jupiterClient = createJupiterApiClient() + } + return jupiterClient +} + +// Legacy instance for backward compatibility export const jupiterInstance = createJupiterApiClient() +export type JupiterQuoteParams = { + inputTokenSymbol: JupiterTokenSymbol + outputTokenSymbol: JupiterTokenSymbol + inputAmount: number + slippageBps: number + swapMode?: 'ExactIn' | 'ExactOut' + onlyDirectRoutes?: boolean +} + +// Add support for mint-based parameters for the useSwapTokens hook +export type JupiterMintQuoteParams = { + inputMint: string + outputMint: string + amountUi: number + slippageBps: number + swapMode?: 'ExactIn' | 'ExactOut' + onlyDirectRoutes?: boolean +} + +export type JupiterQuoteResult = { + inputAmount: { + amount: number + amountString: string + uiAmount: number + uiAmountString: string + } + outputAmount: { + amount: number + amountString: string + uiAmount: number + uiAmountString: string + } + otherAmountThreshold: { + amount: number + amountString: string + uiAmount: number + uiAmountString: string + } + quote: QuoteResponse +} + export const quoteWithAnalytics = async ({ quoteArgs, track, @@ -59,3 +115,193 @@ export const quoteWithAnalytics = async ({ ) return quoteResponse } + +/** + * Gets a quote from Jupiter for an exchange between tokens + */ +export const getJupiterQuote = async ({ + inputTokenSymbol, + outputTokenSymbol, + inputAmount, + slippageBps, + swapMode = 'ExactIn', + onlyDirectRoutes = false +}: JupiterQuoteParams): Promise => { + const inputToken = TOKEN_LISTING_MAP[inputTokenSymbol] + const outputToken = TOKEN_LISTING_MAP[outputTokenSymbol] + + if (!inputToken || !outputToken) { + throw new Error( + `Tokens not found: ${inputTokenSymbol} => ${outputTokenSymbol}` + ) + } + + // Calculate amount with proper decimal precision + const amount = + swapMode === 'ExactIn' + ? Math.ceil(inputAmount * 10 ** inputToken.decimals) + : Math.floor(inputAmount * 10 ** outputToken.decimals) + + // Get quote from Jupiter + const jupiter = getJupiterClient() + const quote = await jupiter.quoteGet({ + inputMint: inputToken.address, + outputMint: outputToken.address, + amount, + slippageBps, + swapMode, + onlyDirectRoutes + }) + + if (!quote) { + throw new Error('Failed to get Jupiter quote') + } + + return { + inputAmount: convertBigIntToAmountObject( + BigInt(quote.inAmount), + inputToken.decimals + ), + outputAmount: convertBigIntToAmountObject( + BigInt(quote.outAmount), + outputToken.decimals + ), + otherAmountThreshold: convertBigIntToAmountObject( + BigInt(quote.otherAmountThreshold), + swapMode === 'ExactIn' ? outputToken.decimals : inputToken.decimals + ), + quote + } +} + +/** + * Gets a quote from Jupiter using mint addresses directly + * This version is used by the useSwapTokens hook + */ +export const getJupiterQuoteByMint = async ({ + inputMint, + outputMint, + amountUi, + slippageBps, + swapMode = 'ExactIn', + onlyDirectRoutes = false +}: JupiterMintQuoteParams): Promise => { + // Get quote from Jupiter + const jupiter = getJupiterClient() + + // Look up token decimals from TOKEN_LISTING_MAP + // We'll find tokens by their address to get the correct decimals + const inputToken = Object.values(TOKEN_LISTING_MAP).find( + (token) => token.address === inputMint + ) + const outputToken = Object.values(TOKEN_LISTING_MAP).find( + (token) => token.address === outputMint + ) + + // Default to 9 decimals if tokens aren't found (fallback for safety) + const inputDecimals = inputToken?.decimals ?? 9 + const outputDecimals = outputToken?.decimals ?? 9 + + const amount = + swapMode === 'ExactIn' + ? Math.ceil(amountUi * 10 ** inputDecimals) + : Math.floor(amountUi * 10 ** outputDecimals) + + const quote = await jupiter.quoteGet({ + inputMint, + outputMint, + amount, + slippageBps, + swapMode, + onlyDirectRoutes + }) + + if (!quote) { + throw new Error('Failed to get Jupiter quote') + } + + return { + inputAmount: convertBigIntToAmountObject( + BigInt(quote.inAmount), + inputDecimals + ), + outputAmount: convertBigIntToAmountObject( + BigInt(quote.outAmount), + outputDecimals + ), + otherAmountThreshold: convertBigIntToAmountObject( + BigInt(quote.otherAmountThreshold), + swapMode === 'ExactIn' ? outputDecimals : inputDecimals + ), + quote + } +} + +/** + * Gets swap instructions from Jupiter for executing a token swap + * Converts raw Jupiter instructions to Solana TransactionInstructions + */ +export const getSwapInstructions = async ({ + quote, + userPublicKey, + wrapAndUnwrapSol = true +}: { + quote: QuoteResponse + userPublicKey: string + wrapAndUnwrapSol?: boolean +}) => { + const jupiter = getJupiterClient() + const response = await jupiter.swapInstructionsPost({ + swapRequest: { + quoteResponse: quote, + userPublicKey, + wrapAndUnwrapSol, + computeUnitPriceMicroLamports: 100000, + useSharedAccounts: true + } + }) + + const { + tokenLedgerInstruction, + computeBudgetInstructions, + setupInstructions, + swapInstruction, + cleanupInstruction, + addressLookupTableAddresses + } = response + + // Flatten and filter out undefined instructions + const instructions = [ + tokenLedgerInstruction, + ...(computeBudgetInstructions || []), + ...(setupInstructions || []), + swapInstruction, + cleanupInstruction + ].filter((i): i is Instruction => i !== undefined) + + // Convert to Solana TransactionInstruction format + const transactionInstructions = instructions.map((i) => { + return { + programId: new PublicKey(i.programId), + data: Buffer.from(i.data, 'base64'), + keys: i.accounts.map((a) => { + return { + pubkey: new PublicKey(a.pubkey), + isSigner: a.isSigner, + isWritable: a.isWritable + } + }) + } as TransactionInstruction + }) + + return { + instructions: transactionInstructions, + lookupTableAddresses: addressLookupTableAddresses || [] + } +} + +// Export a singleton for convenient access +export const JupiterTokenExchange = { + getQuote: getJupiterQuoteByMint, + getSwapInstructions +} diff --git a/packages/common/src/services/JupiterTokenExchange.ts b/packages/common/src/services/JupiterTokenExchange.ts deleted file mode 100644 index 4094729464c..00000000000 --- a/packages/common/src/services/JupiterTokenExchange.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { createJupiterApiClient, QuoteResponse, Instruction } from '@jup-ag/api' -import { PublicKey, TransactionInstruction } from '@solana/web3.js' - -import { TOKEN_LISTING_MAP } from '~/store/ui/buy-audio/constants' -import { convertBigIntToAmountObject } from '~/utils' - -// Define JupiterTokenSymbol type here since we can't import it directly -export type JupiterTokenSymbol = keyof typeof TOKEN_LISTING_MAP - -// Jupiter API client singleton (lazy loaded) -let jupiterClient: ReturnType | null = null - -export const getJupiterClient = () => { - if (!jupiterClient) { - jupiterClient = createJupiterApiClient() - } - return jupiterClient -} - -export type JupiterQuoteParams = { - inputTokenSymbol: JupiterTokenSymbol - outputTokenSymbol: JupiterTokenSymbol - inputAmount: number - slippageBps: number - swapMode?: 'ExactIn' | 'ExactOut' - onlyDirectRoutes?: boolean -} - -// Add support for mint-based parameters for the useSwapTokens hook -export type JupiterMintQuoteParams = { - inputMint: string - outputMint: string - amountUi: number - slippageBps: number - swapMode?: 'ExactIn' | 'ExactOut' - onlyDirectRoutes?: boolean -} - -export const SLIPPAGE_TOLERANCE_EXCEEDED_ERROR = 'Slippage tolerance exceeded' - -export type JupiterQuoteResult = { - inputAmount: { - amount: number - amountString: string - uiAmount: number - uiAmountString: string - } - outputAmount: { - amount: number - amountString: string - uiAmount: number - uiAmountString: string - } - otherAmountThreshold: { - amount: number - amountString: string - uiAmount: number - uiAmountString: string - } - quote: QuoteResponse -} - -/** - * Gets a quote from Jupiter for an exchange between tokens - */ -export const getJupiterQuote = async ({ - inputTokenSymbol, - outputTokenSymbol, - inputAmount, - slippageBps, - swapMode = 'ExactIn', - onlyDirectRoutes = false -}: JupiterQuoteParams): Promise => { - const inputToken = TOKEN_LISTING_MAP[inputTokenSymbol] - const outputToken = TOKEN_LISTING_MAP[outputTokenSymbol] - - if (!inputToken || !outputToken) { - throw new Error( - `Tokens not found: ${inputTokenSymbol} => ${outputTokenSymbol}` - ) - } - - // Calculate amount with proper decimal precision - const amount = - swapMode === 'ExactIn' - ? Math.ceil(inputAmount * 10 ** inputToken.decimals) - : Math.floor(inputAmount * 10 ** outputToken.decimals) - - // Get quote from Jupiter - const jupiter = getJupiterClient() - const quote = await jupiter.quoteGet({ - inputMint: inputToken.address, - outputMint: outputToken.address, - amount, - slippageBps, - swapMode, - onlyDirectRoutes - }) - - if (!quote) { - throw new Error('Failed to get Jupiter quote') - } - - return { - inputAmount: convertBigIntToAmountObject( - BigInt(quote.inAmount), - inputToken.decimals - ), - outputAmount: convertBigIntToAmountObject( - BigInt(quote.outAmount), - outputToken.decimals - ), - otherAmountThreshold: convertBigIntToAmountObject( - BigInt(quote.otherAmountThreshold), - swapMode === 'ExactIn' ? outputToken.decimals : inputToken.decimals - ), - quote - } -} - -/** - * Gets a quote from Jupiter using mint addresses directly - * This version is used by the useSwapTokens hook - */ -export const getJupiterQuoteByMint = async ({ - inputMint, - outputMint, - amountUi, - slippageBps, - swapMode = 'ExactIn', - onlyDirectRoutes = false -}: JupiterMintQuoteParams): Promise => { - // Get quote from Jupiter - const jupiter = getJupiterClient() - - // Look up token decimals from TOKEN_LISTING_MAP - // We'll find tokens by their address to get the correct decimals - const inputToken = Object.values(TOKEN_LISTING_MAP).find( - (token) => token.address === inputMint - ) - const outputToken = Object.values(TOKEN_LISTING_MAP).find( - (token) => token.address === outputMint - ) - - // Default to 9 decimals if tokens aren't found (fallback for safety) - const inputDecimals = inputToken?.decimals ?? 9 - const outputDecimals = outputToken?.decimals ?? 9 - - const amount = - swapMode === 'ExactIn' - ? Math.ceil(amountUi * 10 ** inputDecimals) - : Math.floor(amountUi * 10 ** outputDecimals) - - const quote = await jupiter.quoteGet({ - inputMint, - outputMint, - amount, - slippageBps, - swapMode, - onlyDirectRoutes - }) - - if (!quote) { - throw new Error('Failed to get Jupiter quote') - } - - return { - inputAmount: convertBigIntToAmountObject( - BigInt(quote.inAmount), - inputDecimals - ), - outputAmount: convertBigIntToAmountObject( - BigInt(quote.outAmount), - outputDecimals - ), - otherAmountThreshold: convertBigIntToAmountObject( - BigInt(quote.otherAmountThreshold), - swapMode === 'ExactIn' ? outputDecimals : inputDecimals - ), - quote - } -} - -/** - * Gets swap instructions from Jupiter for executing a token swap - * Converts raw Jupiter instructions to Solana TransactionInstructions - */ -export const getSwapInstructions = async ({ - quote, - userPublicKey, - wrapAndUnwrapSol = true -}: { - quote: QuoteResponse - userPublicKey: string - wrapAndUnwrapSol?: boolean -}) => { - const jupiter = getJupiterClient() - const response = await jupiter.swapInstructionsPost({ - swapRequest: { - quoteResponse: quote, - userPublicKey, - wrapAndUnwrapSol, - computeUnitPriceMicroLamports: 100000, - useSharedAccounts: true - } - }) - - const { - tokenLedgerInstruction, - computeBudgetInstructions, - setupInstructions, - swapInstruction, - cleanupInstruction, - addressLookupTableAddresses - } = response - - // Flatten and filter out undefined instructions - const instructions = [ - tokenLedgerInstruction, - ...(computeBudgetInstructions || []), - ...(setupInstructions || []), - swapInstruction, - cleanupInstruction - ].filter((i): i is Instruction => i !== undefined) - - // Convert to Solana TransactionInstruction format - const transactionInstructions = instructions.map((i) => { - return { - programId: new PublicKey(i.programId), - data: Buffer.from(i.data, 'base64'), - keys: i.accounts.map((a) => { - return { - pubkey: new PublicKey(a.pubkey), - isSigner: a.isSigner, - isWritable: a.isWritable - } - }) - } as TransactionInstruction - }) - - return { - instructions: transactionInstructions, - lookupTableAddresses: addressLookupTableAddresses || [] - } -} - -// Export a singleton for convenient access -export const JupiterTokenExchange = { - getQuote: getJupiterQuoteByMint, - getSwapInstructions -} diff --git a/packages/web/src/components/buy-sell-modal/hooks/useTokenSwapForm.ts b/packages/web/src/components/buy-sell-modal/hooks/useTokenSwapForm.ts index 11b89e01172..f4c30c16ad1 100644 --- a/packages/web/src/components/buy-sell-modal/hooks/useTokenSwapForm.ts +++ b/packages/web/src/components/buy-sell-modal/hooks/useTokenSwapForm.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useTokenExchangeRate } from '@audius/common/src/api/tan-query/useTokenExchangeRate' -import { JupiterTokenSymbol } from '@audius/common/src/services/JupiterTokenExchange' +import { JupiterTokenSymbol } from '@audius/common/src/services/Jupiter' import { TokenInfo } from '../types' From fde4c80cad9c4cf8cb60f838b76e371396dc82df Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Mon, 5 May 2025 12:27:33 -0500 Subject: [PATCH 11/22] Update useSwapTokens --- .../common/src/api/tan-query/useSwapTokens.ts | 168 +++++++++++------- 1 file changed, 101 insertions(+), 67 deletions(-) diff --git a/packages/common/src/api/tan-query/useSwapTokens.ts b/packages/common/src/api/tan-query/useSwapTokens.ts index ffd9c9c391f..1bb5cace2d2 100644 --- a/packages/common/src/api/tan-query/useSwapTokens.ts +++ b/packages/common/src/api/tan-query/useSwapTokens.ts @@ -1,17 +1,23 @@ +import { + createCloseAccountInstruction, + createTransferInstruction, + getAssociatedTokenAddressSync +} from '@solana/spl-token' import { PublicKey, VersionedTransaction } from '@solana/web3.js' import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useGetCurrentUser } from '~/api' import { useAudiusQueryContext } from '~/audius-query' import { Feature } from '~/models' import { getJupiterQuoteByMint, JupiterTokenExchange } from '~/services/Jupiter' +import { TOKEN_LISTING_MAP } from '~/store/ui/buy-audio/constants' // Enums and Types defined earlier in the provided context export enum SwapStatus { IDLE = 'IDLE', GETTING_QUOTE = 'GETTING_QUOTE', BUILDING_TRANSACTION = 'BUILDING_TRANSACTION', // Added for clarity - RELAYING_TRANSACTION = 'RELAYING_TRANSACTION', // Added for clarity - CONFIRMING_TRANSACTION = 'CONFIRMING_TRANSACTION', + SENDING_TRANSACTION = 'SENDING_TRANSACTION', // Updated from RELAYING_TRANSACTION SUCCESS = 'SUCCESS', ERROR = 'ERROR' } @@ -23,7 +29,7 @@ export enum SwapErrorType { WALLET_ERROR = 'WALLET_ERROR', // e.g., couldn't get keypair QUOTE_FAILED = 'QUOTE_FAILED', // Jupiter API quote failure BUILD_FAILED = 'BUILD_FAILED', // Failed during instruction/tx build - RELAY_FAILED = 'RELAY_FAILED', // Relay service specific error + SEND_FAILED = 'SEND_FAILED', // Failed to send transaction UNKNOWN = 'UNKNOWN' } @@ -67,6 +73,7 @@ export const useSwapTokens = () => { const queryClient = useQueryClient() const { solanaWalletService, reportToSentry, audiusSdk } = useAudiusQueryContext() + const { data: user } = useGetCurrentUser({}) return useMutation({ mutationFn: async (params): Promise => { @@ -120,19 +127,87 @@ export const useSwapTokens = () => { } // ---------- 3. Build Transaction ---------- - let swapTx: VersionedTransaction const sdk = await audiusSdk() - try { - const { instructions, lookupTableAddresses } = - await JupiterTokenExchange.getSwapInstructions({ - quote: quoteResult.quote, - userPublicKey: userPublicKey.toBase58(), - wrapAndUnwrapSol: wrapUnwrapSol + + // Get the transaction instructions from Jupiter + const { instructions: jupiterInstructions, lookupTableAddresses } = + await JupiterTokenExchange.getSwapInstructions({ + quote: quoteResult.quote, + userPublicKey: userPublicKey.toBase58(), + wrapAndUnwrapSol: wrapUnwrapSol + }) + + // Create a copy of the instructions array + const instructions = [...jupiterInstructions] + + // Check if this is an AUDIO -> USDC swap using the tokens from constants + const audioMintAddress = TOKEN_LISTING_MAP.AUDIO.address + const usdcMintAddress = TOKEN_LISTING_MAP.USDC.address + + const isAudioToUsdc = + inputMint.toUpperCase() === audioMintAddress.toUpperCase() && + outputMint.toUpperCase() === usdcMintAddress.toUpperCase() + + // For AUDIO -> USDC swaps, add instructions to transfer USDC to userbank and close ATA + if (isAudioToUsdc && user?.wallet) { + try { + console.debug( + 'SWAP: Adding USDC userbank transfer for AUDIO -> USDC swap' + ) + + const ethAddress = user.wallet + + const usdcAssociatedTokenAccount = getAssociatedTokenAddressSync( + new PublicKey(usdcMintAddress), + userPublicKey, + true + ) + + const userBank = + await sdk.services.claimableTokensClient.deriveUserBank({ + ethWallet: ethAddress, + mint: 'USDC' + }) + + const transferToUserbankInstruction = createTransferInstruction( + usdcAssociatedTokenAccount, + userBank, + userPublicKey, + BigInt(quoteResult.outputAmount.amount) + ) + + const closeAccountInstruction = createCloseAccountInstruction( + usdcAssociatedTokenAccount, + userPublicKey, + userPublicKey, + [] + ) + + instructions.push( + transferToUserbankInstruction, + closeAccountInstruction + ) + + console.debug('SWAP: Added userbank transfer instructions', { + usdcAta: usdcAssociatedTokenAccount.toBase58(), + userBank: userBank.toBase58(), + amount: quoteResult.outputAmount.amount }) + } catch (error) { + console.error( + 'SWAP: Failed to add USDC userbank transfer instructions:', + error + ) + // Continue with the swap even if we can't add these instructions + // Better to have USDC in an ATA than to fail the swap entirely + } + } - const feePayer = await sdk.services.solanaRelay.getFeePayer() + const feePayer = await sdk.services.solanaRelay.getFeePayer() - // Build the transaction with the pre-converted instructions + // Build the transaction with all instructions + let swapTx: VersionedTransaction + try { swapTx = await sdk.services.solanaClient.buildTransaction({ feePayer, instructions, @@ -162,9 +237,7 @@ export const useSwapTokens = () => { } // ---------- 4. Sign Transaction ---------- - // The Audius relay requires the fee payer (slot 0) signature to be cleared - // and the transaction to be signed by the actual user (which we do here). - // The relay service will then sign as the fee payer. + // The transaction needs to be signed by the actual user (which we do here). swapTx.sign([keypair]) // ---------- Debug: Log transaction instructions ---------- @@ -179,82 +252,43 @@ export const useSwapTokens = () => { return programId }) // Print all program IDs in order - console.debug( - 'SWAP RELAY DEBUG: Transaction program IDs:', - programIds - ) + console.debug('SWAP DEBUG: Transaction program IDs:', programIds) // Optionally, print the raw instruction data for further debugging console.debug( - 'SWAP RELAY DEBUG: Compiled instructions:', + 'SWAP DEBUG: Compiled instructions:', message.compiledInstructions ) } catch (e) { - console.warn( - 'SWAP RELAY DEBUG: Failed to log transaction instructions', - e - ) + console.warn('SWAP DEBUG: Failed to log transaction instructions', e) } - // ---------- 5. Relay Transaction ---------- + // ---------- 5. Send Transaction ---------- try { - const relayResult = await sdk.services.solanaRelay.relay({ - transaction: swapTx, - sendOptions: { skipPreflight: false } // Preflight checks happen during build/quote + signature = await sdk.services.solanaClient.sendTransaction(swapTx, { + skipPreflight: false // Preflight checks happen during build/quote }) - signature = relayResult.signature } catch (error: any) { - console.error('useSwapTokens: Error relaying transaction:', error) + console.error('useSwapTokens: Error sending transaction:', error) reportToSentry({ - name: 'JupiterSwapRelayError', + name: 'JupiterSwapSendError', error, feature: Feature.TanQuery, additionalInfo: { params, quoteResponse: quoteResult.quote } }) - // Use the error message from the relay if available - const relayErrorMessage = - error?.response?.data?.error ?? error?.message - return { - status: SwapStatus.ERROR, - error: { - type: SwapErrorType.RELAY_FAILED, - message: relayErrorMessage ?? 'Failed to relay swap transaction' - }, - inputAmount: quoteResult.inputAmount, - outputAmount: quoteResult.outputAmount - } - } - - // ---------- 6. Confirm Transaction ---------- - const connection = sdk.services.solanaClient.connection - try { - await connection.confirmTransaction(signature, 'confirmed') - } catch (error: any) { - console.error( - `useSwapTokens: Transaction confirmation error (Sig: ${signature}):`, - error - ) - - // Generic confirmation failure - reportToSentry({ - name: 'JupiterSwapConfirmationError', - error, - feature: Feature.TanQuery, - additionalInfo: { signature, params } - }) + // Use the error message if available + const errorMessage = error?.message return { status: SwapStatus.ERROR, - signature, error: { - type: SwapErrorType.TRANSACTION_FAILED, - message: - error?.message ?? 'Failed to confirm swap transaction on-chain' + type: SwapErrorType.SEND_FAILED, + message: errorMessage ?? 'Failed to send swap transaction' }, inputAmount: quoteResult.inputAmount, outputAmount: quoteResult.outputAmount } } - // ---------- 7. Success & Invalidation ---------- + // ---------- 6. Success & Invalidation ---------- // Generate dynamic query keys based on mints // Assuming your balance hooks use keys like ['audioBalance', userId] or ['usdcBalance', userId] // We need a more generic pattern if swapping arbitrary tokens. From 4864b37263b38df78f22317895d293bc62575711 Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Tue, 6 May 2025 11:41:17 -0500 Subject: [PATCH 12/22] Succesful swap with lower bytes --- .../common/src/api/tan-query/useSwapTokens.ts | 455 ++++++++++++++---- packages/common/src/services/Jupiter.ts | 3 + 2 files changed, 352 insertions(+), 106 deletions(-) diff --git a/packages/common/src/api/tan-query/useSwapTokens.ts b/packages/common/src/api/tan-query/useSwapTokens.ts index 1bb5cace2d2..98a0fd244a4 100644 --- a/packages/common/src/api/tan-query/useSwapTokens.ts +++ b/packages/common/src/api/tan-query/useSwapTokens.ts @@ -1,9 +1,16 @@ import { + ASSOCIATED_TOKEN_PROGRAM_ID, + createAssociatedTokenAccountIdempotentInstruction, createCloseAccountInstruction, - createTransferInstruction, + createTransferCheckedInstruction, + getAccount, getAssociatedTokenAddressSync } from '@solana/spl-token' -import { PublicKey, VersionedTransaction } from '@solana/web3.js' +import { + PublicKey, + TransactionInstruction, + VersionedTransaction +} from '@solana/web3.js' import { useMutation, useQueryClient } from '@tanstack/react-query' import { useGetCurrentUser } from '~/api' @@ -23,13 +30,11 @@ export enum SwapStatus { } export enum SwapErrorType { - INSUFFICIENT_BALANCE = 'INSUFFICIENT_BALANCE', // Good to add pre-check later - SLIPPAGE_EXCEEDED = 'SLIPPAGE_EXCEEDED', - TRANSACTION_FAILED = 'TRANSACTION_FAILED', // Generic relay/confirm failure - WALLET_ERROR = 'WALLET_ERROR', // e.g., couldn't get keypair - QUOTE_FAILED = 'QUOTE_FAILED', // Jupiter API quote failure - BUILD_FAILED = 'BUILD_FAILED', // Failed during instruction/tx build - SEND_FAILED = 'SEND_FAILED', // Failed to send transaction + WALLET_ERROR = 'WALLET_ERROR', + QUOTE_FAILED = 'QUOTE_FAILED', + BUILD_FAILED = 'BUILD_FAILED', + RELAY_FAILED = 'RELAY_FAILED', + SIMULATION_FAILED = 'SIMULATION_FAILED', UNKNOWN = 'UNKNOWN' } @@ -107,7 +112,8 @@ export const useSwapTokens = () => { outputMint, amountUi, slippageBps, - swapMode: 'ExactIn' + swapMode: 'ExactIn', + onlyDirectRoutes: true }) } catch (error: any) { console.error('useSwapTokens: Error getting Jupiter quote:', error) @@ -129,81 +135,365 @@ export const useSwapTokens = () => { // ---------- 3. Build Transaction ---------- const sdk = await audiusSdk() + const audioMintAddress = TOKEN_LISTING_MAP.AUDIO.address + const usdcMintAddress = TOKEN_LISTING_MAP.USDC.address + + // Create a copy of the instructions array for all transaction instructions + const instructions: TransactionInstruction[] = [] + + // Check if this is an AUDIO -> any token swap + const isAudioSwap = + inputMint.toUpperCase() === audioMintAddress.toUpperCase() + const isAudioToUsdc = + isAudioSwap && + outputMint.toUpperCase() === usdcMintAddress.toUpperCase() + + // For AUDIO based swaps, first transfer from user bank to standard ATA + if (isAudioSwap && user?.wallet) { + try { + console.debug('SWAP: Setting up AUDIO transfer from user bank') + const ethAddress = user.wallet + + // Create the AUDIO ATA if it doesn't already exist + const audioMint = new PublicKey(audioMintAddress) + const audioAta = getAssociatedTokenAddressSync( + audioMint, + userPublicKey, + true + ) + + // Check if ATA exists before trying to create it + try { + await getAccount(sdk.services.solanaClient.connection, audioAta) + console.debug('SWAP: Source AUDIO ATA already exists') + } catch (e) { + // If getAccount throws, ATA doesn't exist, add instruction to create + console.debug( + 'SWAP: Source AUDIO ATA does not exist, adding create instruction' + ) + const feePayer = await sdk.services.solanaRelay.getFeePayer() + const createAudioAtaInstruction = + createAssociatedTokenAccountIdempotentInstruction( + feePayer, + audioAta, + userPublicKey, + audioMint + ) + instructions.push(createAudioAtaInstruction) + } + + // Create instructions to transfer from userbank to ATA + const secpTransferInstruction = + await sdk.services.claimableTokensClient.createTransferSecpInstruction( + { + amount: BigInt(quoteResult.inputAmount.amount), + ethWallet: ethAddress, + mint: 'wAUDIO', + destination: audioAta, + instructionIndex: instructions.length + } + ) + + // Add the instruction to actually move the tokens + const transferInstruction = + await sdk.services.claimableTokensClient.createTransferInstruction( + { + ethWallet: ethAddress, + mint: 'wAUDIO', + destination: audioAta + // No amount needed here, it uses the verified Secp amount + } + ) + instructions.push(secpTransferInstruction) + instructions.push(transferInstruction) + + console.debug( + 'SWAP: Added AUDIO userbank to ATA secp + transfer instructions', + { + audioAta: audioAta.toBase58(), + amount: BigInt(quoteResult.inputAmount.amount) + } + ) + } catch (error) { + console.error( + 'SWAP: Failed to add AUDIO userbank transfer instructions:', + error + ) + return { + status: SwapStatus.ERROR, + error: { + type: SwapErrorType.BUILD_FAILED, + message: 'Failed to set up AUDIO transfer from user bank' + }, + inputAmount: quoteResult.inputAmount, + outputAmount: quoteResult.outputAmount + } + } + } + + const feePayer = await sdk.services.solanaRelay.getFeePayer() + + // Pre-determine the USDC userbank for AUDIO -> USDC swaps to use as destination + let usdcUserBank: PublicKey | undefined + if (isAudioToUsdc && user?.wallet) { + try { + usdcUserBank = + await sdk.services.claimableTokensClient.deriveUserBank({ + ethWallet: user.wallet, + mint: 'USDC' + }) + console.debug('SWAP: Using USDC userbank as destination', { + userBank: usdcUserBank.toBase58() + }) + } catch (error) { + console.error('SWAP: Failed to derive USDC userbank:', error) + // Continue with the swap even if we can't derive the userbank + // We'll fall back to standard ATA in this case + } + } + // Get the transaction instructions from Jupiter const { instructions: jupiterInstructions, lookupTableAddresses } = await JupiterTokenExchange.getSwapInstructions({ quote: quoteResult.quote, userPublicKey: userPublicKey.toBase58(), + destinationTokenAccount: usdcUserBank?.toBase58(), // Pass the userbank as destination for AUDIO -> USDC wrapAndUnwrapSol: wrapUnwrapSol }) - // Create a copy of the instructions array - const instructions = [...jupiterInstructions] + // Log the intended destination if we provided one + console.debug('SWAP: Requested Jupiter destination:', { + destination: usdcUserBank?.toBase58() ?? 'Default ATA' + }) - // Check if this is an AUDIO -> USDC swap using the tokens from constants - const audioMintAddress = TOKEN_LISTING_MAP.AUDIO.address - const usdcMintAddress = TOKEN_LISTING_MAP.USDC.address + // Find the actual Jupiter swap instruction to log the real destination + const jupiterSwapInstruction = jupiterInstructions.find( + (ix) => + ix.programId.toBase58() === + 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4' + ) + let actualJupiterDestination: PublicKey | undefined + if (jupiterSwapInstruction) { + // Heuristic: In Jupiter's SharedAccountsRoute, the 6th key is often the final destination token account + // A more robust method would involve decoding the specific instruction data based on its ID. + const destKeyMeta = jupiterSwapInstruction.keys[6] + if (destKeyMeta) { + actualJupiterDestination = destKeyMeta.pubkey + console.debug('SWAP: Actual Jupiter destination account:', { + destination: actualJupiterDestination.toBase58() + }) + } else { + console.warn( + 'SWAP: Could not determine actual Jupiter destination account from keys.' + ) + } + } else { + console.warn('SWAP: Could not find Jupiter swap instruction in list.') + } - const isAudioToUsdc = - inputMint.toUpperCase() === audioMintAddress.toUpperCase() && - outputMint.toUpperCase() === usdcMintAddress.toUpperCase() + // --- Modification Start --- + // Find and update the fee payer for Jupiter's ATA creation instruction + for (const ix of jupiterInstructions) { + // Check if it's the Associated Token Program + if (ix.programId.equals(ASSOCIATED_TOKEN_PROGRAM_ID)) { + // Basic check for createAssociatedTokenAccountIdempotentInstruction: + // It typically has 7 keys, and the first key is the fee payer. + // We assume Jupiter might use the userPublicKey as the default fee payer here. + if ( + ix.keys.length >= 1 && // Ensure keys exist + ix.keys[0].pubkey.equals(userPublicKey) && // Check if fee payer is the user + ix.keys[0].isSigner && // Fee payer must be a signer + ix.keys[0].isWritable // Fee payer must be writable + ) { + console.debug( + 'SWAP: Updating fee payer for Jupiter ATA creation instruction' + ) + // Update the first key (fee payer) to use the relay fee payer + ix.keys[0].pubkey = feePayer + // Ensure the user is no longer marked as signer/writable *for this instruction's fee payer role* + // Find the user's key if it exists elsewhere in the instruction and ensure it's not the fee payer + // Note: The userPublicKey *will* still be a signer for the overall transaction later. + // This modification only changes who pays the fee *for this specific ATA instruction*. + // We don't need to remove the user as a signer generally, just ensure the *first* key (fee payer) is correct. + // A more robust check could involve decoding instruction data, but this heuristic is common. + break // Assuming only one such instruction from Jupiter per swap + } + } + } + // --- Modification End --- + + // Find the ATA created by Jupiter's setup instruction for potential closure later + let jupiterSetupAta: PublicKey | undefined + const createAtaInstruction = jupiterInstructions.find( + (ix) => + ix.programId.equals(ASSOCIATED_TOKEN_PROGRAM_ID) && + // Check keys length or instruction data for more specific match if needed + ix.keys.length >= 4 && // Basic check for create instructions + ix.keys[3].pubkey.toBase58() === usdcMintAddress // Check if it's for the USDC mint + ) + if (createAtaInstruction) { + jupiterSetupAta = createAtaInstruction.keys[1].pubkey // ATA address is typically the 2nd key + console.debug('SWAP: Identified Jupiter setup ATA:', { + ata: jupiterSetupAta.toBase58() + }) + } else { + console.warn( + 'SWAP: Could not identify Jupiter setup ATA instruction.' + ) + } + + // Add Jupiter instructions (potentially modified) to our instruction array + instructions.push(...jupiterInstructions) + + // Determine if we need the fallback transfer/close logic + const needsFallback = + isAudioToUsdc && + user?.wallet && + (!usdcUserBank || + !actualJupiterDestination || + !actualJupiterDestination.equals(usdcUserBank)) // For AUDIO -> USDC swaps, add instructions to transfer USDC to userbank and close ATA - if (isAudioToUsdc && user?.wallet) { + // Only add these if we couldn't use the userbank as the direct destination + if (needsFallback) { try { console.debug( - 'SWAP: Adding USDC userbank transfer for AUDIO -> USDC swap' + 'SWAP: Using fallback: Adding USDC transfer + close instructions.', + { + intendedUserBank: usdcUserBank?.toBase58(), + actualJupiterDestination: actualJupiterDestination?.toBase58() + } ) const ethAddress = user.wallet - const usdcAssociatedTokenAccount = getAssociatedTokenAddressSync( - new PublicKey(usdcMintAddress), - userPublicKey, - true - ) + // The source for this transfer is the actual destination Jupiter used + const sourceAccount = + actualJupiterDestination ?? + getAssociatedTokenAddressSync( + new PublicKey(usdcMintAddress), + userPublicKey, + true + ) - const userBank = - await sdk.services.claimableTokensClient.deriveUserBank({ + // The destination is always the userbank in the fallback + const finalUserBankDestination = + usdcUserBank ?? + (await sdk.services.claimableTokensClient.deriveUserBank({ ethWallet: ethAddress, mint: 'USDC' - }) + })) - const transferToUserbankInstruction = createTransferInstruction( - usdcAssociatedTokenAccount, - userBank, - userPublicKey, - BigInt(quoteResult.outputAmount.amount) - ) + // Use TransferChecked instead of Transfer + const usdcMintPublicKey = new PublicKey(usdcMintAddress) + const usdcDecimals = TOKEN_LISTING_MAP.USDC.decimals // Assuming decimals are available - const closeAccountInstruction = createCloseAccountInstruction( - usdcAssociatedTokenAccount, - userPublicKey, - userPublicKey, - [] + const transferToUserbankInstruction = + createTransferCheckedInstruction( + sourceAccount, // source (actual Jupiter destination) + usdcMintPublicKey, // mint + finalUserBankDestination, // destination (userbank) + userPublicKey, // owner + BigInt(quoteResult.outputAmount.amount), // amount + usdcDecimals // decimals + ) + + const closeUsdcAccountInstruction = createCloseAccountInstruction( + sourceAccount, // Close the actual Jupiter destination + feePayer, + userPublicKey ) instructions.push( transferToUserbankInstruction, - closeAccountInstruction + closeUsdcAccountInstruction ) - console.debug('SWAP: Added userbank transfer instructions', { - usdcAta: usdcAssociatedTokenAccount.toBase58(), - userBank: userBank.toBase58(), - amount: quoteResult.outputAmount.amount - }) + console.debug( + 'SWAP: Added fallback userbank transfer instructions', + { + source: sourceAccount.toBase58(), + destination: finalUserBankDestination.toBase58(), + amount: quoteResult.outputAmount.amount + } + ) + + // For AUDIO swaps, optionally close the AUDIO ATA if we created it only for this swap + // This needs to happen even in the fallback case + if (isAudioSwap) { + console.debug( + 'SWAP: Adding instruction to close AUDIO ATA after swap (fallback path)' + ) + const audioAta = getAssociatedTokenAddressSync( + new PublicKey(audioMintAddress), + userPublicKey, + true + ) + + const closeAudioAccountInstruction = + createCloseAccountInstruction(audioAta, feePayer, userPublicKey) + + instructions.push(closeAudioAccountInstruction) + } } catch (error) { console.error( - 'SWAP: Failed to add USDC userbank transfer instructions:', + 'SWAP: Failed to add fallback USDC userbank transfer instructions:', error ) // Continue with the swap even if we can't add these instructions // Better to have USDC in an ATA than to fail the swap entirely } } + // Close AUDIO ATA if we used direct destination successfully + else if ( + isAudioToUsdc && + isAudioSwap && + usdcUserBank && + actualJupiterDestination?.equals(usdcUserBank) + ) { + // Even if we're sending directly to userbank, we still need to close the AUDIO ATA + // and the potentially orphaned ATA created by Jupiter's setup + try { + console.debug( + 'SWAP: Direct deposit successful. Adding instructions to close AUDIO ATA and Jupiter setup ATA.' + ) + // Close AUDIO ATA + const audioAta = getAssociatedTokenAddressSync( + new PublicKey(audioMintAddress), + userPublicKey, + true + ) + const closeAudioAccountInstruction = createCloseAccountInstruction( + audioAta, + feePayer, + userPublicKey + ) + instructions.push(closeAudioAccountInstruction) - const feePayer = await sdk.services.solanaRelay.getFeePayer() + // Close Jupiter Setup ATA (if identified) + if (jupiterSetupAta) { + const closeJupiterSetupAtaInstruction = + createCloseAccountInstruction( + jupiterSetupAta, // The ATA created by Jupiter's setup + feePayer, // Destination for rent refund + userPublicKey // Owner/Authority of the ATA + ) + instructions.push(closeJupiterSetupAtaInstruction) + console.debug( + 'SWAP: Added instruction to close Jupiter setup ATA', + { + ata: jupiterSetupAta.toBase58() + } + ) + } + } catch (error) { + console.error( + 'SWAP: Failed to add ATA close instruction(s) (direct path):', + error + ) + // Continue with the swap even if we can't close the ATAs + } + } // Build the transaction with all instructions let swapTx: VersionedTransaction @@ -240,48 +530,28 @@ export const useSwapTokens = () => { // The transaction needs to be signed by the actual user (which we do here). swapTx.sign([keypair]) - // ---------- Debug: Log transaction instructions ---------- - try { - const message = swapTx.message - // For v0 transactions, use compiledInstructions and staticAccountKeys - const programIds = message.compiledInstructions.map((ix) => { - // Map programIdIndex to staticAccountKeys if available - const programId = - message.staticAccountKeys?.[ix.programIdIndex]?.toBase58?.() || - `idx:${ix.programIdIndex}` - return programId - }) - // Print all program IDs in order - console.debug('SWAP DEBUG: Transaction program IDs:', programIds) - // Optionally, print the raw instruction data for further debugging - console.debug( - 'SWAP DEBUG: Compiled instructions:', - message.compiledInstructions - ) - } catch (e) { - console.warn('SWAP DEBUG: Failed to log transaction instructions', e) - } - // ---------- 5. Send Transaction ---------- try { signature = await sdk.services.solanaClient.sendTransaction(swapTx, { - skipPreflight: false // Preflight checks happen during build/quote + skipPreflight: true }) + console.debug(`Swap completed with signature: ${signature}`) } catch (error: any) { - console.error('useSwapTokens: Error sending transaction:', error) + console.error('useSwapTokens: Failed to relay transaction', error) reportToSentry({ - name: 'JupiterSwapSendError', + name: 'JupiterSwapRelayError', error, feature: Feature.TanQuery, - additionalInfo: { params, quoteResponse: quoteResult.quote } + additionalInfo: { + params, + quoteResponse: quoteResult.quote + } }) - // Use the error message if available - const errorMessage = error?.message return { status: SwapStatus.ERROR, error: { - type: SwapErrorType.SEND_FAILED, - message: errorMessage ?? 'Failed to send swap transaction' + type: SwapErrorType.RELAY_FAILED, + message: error?.message ?? 'Failed to relay transaction' }, inputAmount: quoteResult.inputAmount, outputAmount: quoteResult.outputAmount @@ -330,35 +600,8 @@ export const useSwapTokens = () => { } } }, - // Provide initial status feedback onMutate: () => { - return { - status: SwapStatus.GETTING_QUOTE, - inputAmount: undefined, - outputAmount: undefined - } - }, - // Centralized error reporting for mutation failures - onError: (error: Error, variables) => { - // This catches errors thrown *before* returning a structured SwapTokensResult - // (e.g., failure in `getKeypair`, or unhandled exceptions). - // Errors handled *within* mutationFn return structured results and don't hit this onError. - console.error( - 'useSwapTokens: Unhandled mutation error:', - error, - 'with variables:', - variables - ) - reportToSentry({ - name: 'JupiterSwapUnhandledMutationError', - error, - feature: Feature.TanQuery, - additionalInfo: { params: variables } - }) - // Optionally, you could return a default error state here to update the mutation cache, - // but usually, letting the mutation stay in 'error' state is sufficient. - }, - // Add meta for React Query DevTools - meta: { feature: 'Swap Tokens using Jupiter' } + return { status: SwapStatus.SENDING_TRANSACTION } + } }) } diff --git a/packages/common/src/services/Jupiter.ts b/packages/common/src/services/Jupiter.ts index 3eeb33aa43a..934ab65e3a0 100644 --- a/packages/common/src/services/Jupiter.ts +++ b/packages/common/src/services/Jupiter.ts @@ -244,10 +244,12 @@ export const getJupiterQuoteByMint = async ({ export const getSwapInstructions = async ({ quote, userPublicKey, + destinationTokenAccount, wrapAndUnwrapSol = true }: { quote: QuoteResponse userPublicKey: string + destinationTokenAccount?: string wrapAndUnwrapSol?: boolean }) => { const jupiter = getJupiterClient() @@ -255,6 +257,7 @@ export const getSwapInstructions = async ({ swapRequest: { quoteResponse: quote, userPublicKey, + destinationTokenAccount, wrapAndUnwrapSol, computeUnitPriceMicroLamports: 100000, useSharedAccounts: true From 75855d5b589eefe03606dc74404ebd3261fb4b0c Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Tue, 6 May 2025 14:04:26 -0500 Subject: [PATCH 13/22] Refactor --- packages/common/src/api/index.ts | 4 +- .../src/api/tan-query/jupiter/constants.ts | 25 + .../common/src/api/tan-query/jupiter/types.ts | 48 ++ .../api/tan-query/jupiter/useSwapTokens.ts | 360 +++++++++++ .../{ => jupiter}/useTokenExchangeRate.ts | 2 +- .../common/src/api/tan-query/jupiter/utils.ts | 174 +++++ .../common/src/api/tan-query/useSwapTokens.ts | 607 ------------------ packages/common/src/services/Jupiter.ts | 38 +- .../src/components/buy-sell-modal/SwapTab.tsx | 3 +- .../buy-sell-modal/hooks/useTokenSwapForm.ts | 11 +- .../src/components/buy-sell-modal/types.ts | 2 +- 11 files changed, 631 insertions(+), 643 deletions(-) create mode 100644 packages/common/src/api/tan-query/jupiter/constants.ts create mode 100644 packages/common/src/api/tan-query/jupiter/types.ts create mode 100644 packages/common/src/api/tan-query/jupiter/useSwapTokens.ts rename packages/common/src/api/tan-query/{ => jupiter}/useTokenExchangeRate.ts (98%) create mode 100644 packages/common/src/api/tan-query/jupiter/utils.ts delete mode 100644 packages/common/src/api/tan-query/useSwapTokens.ts diff --git a/packages/common/src/api/index.ts b/packages/common/src/api/index.ts index 1122af701f6..6d2c90dfbb9 100644 --- a/packages/common/src/api/index.ts +++ b/packages/common/src/api/index.ts @@ -124,8 +124,8 @@ export * from './tan-query/wallets/useTokenPrice' export * from './tan-query/wallets/useWalletCollectibles' export * from './tan-query/wallets/useWalletOwner' export * from './tan-query/wallets/useUSDCBalance' -export * from './tan-query/useSwapTokens' -export * from './tan-query/useTokenExchangeRate' +export * from './tan-query/jupiter/useSwapTokens' +export * from './tan-query/jupiter/useTokenExchangeRate' // Saga fetch utils, remove when migration is complete export * from './tan-query/saga-utils' diff --git a/packages/common/src/api/tan-query/jupiter/constants.ts b/packages/common/src/api/tan-query/jupiter/constants.ts new file mode 100644 index 00000000000..f886278c94f --- /dev/null +++ b/packages/common/src/api/tan-query/jupiter/constants.ts @@ -0,0 +1,25 @@ +import { PublicKey } from '@solana/web3.js' + +import { TOKEN_LISTING_MAP } from '~/store/ui/buy-audio/constants' + +import { UserBankManagedTokenInfo } from './types' + +export const JUPITER_PROGRAM_ID = new PublicKey( + 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4' +) + +export const USER_BANK_MANAGED_TOKENS: Record< + string, + UserBankManagedTokenInfo +> = { + [TOKEN_LISTING_MAP.AUDIO.address.toUpperCase()]: { + mintAddress: TOKEN_LISTING_MAP.AUDIO.address, + claimableTokenMint: 'wAUDIO', + decimals: TOKEN_LISTING_MAP.AUDIO.decimals + }, + [TOKEN_LISTING_MAP.USDC.address.toUpperCase()]: { + mintAddress: TOKEN_LISTING_MAP.USDC.address, + claimableTokenMint: 'USDC', + decimals: TOKEN_LISTING_MAP.USDC.decimals + } +} diff --git a/packages/common/src/api/tan-query/jupiter/types.ts b/packages/common/src/api/tan-query/jupiter/types.ts new file mode 100644 index 00000000000..a873cef0b1a --- /dev/null +++ b/packages/common/src/api/tan-query/jupiter/types.ts @@ -0,0 +1,48 @@ +export enum SwapStatus { + IDLE = 'IDLE', + GETTING_QUOTE = 'GETTING_QUOTE', + BUILDING_TRANSACTION = 'BUILDING_TRANSACTION', + SENDING_TRANSACTION = 'SENDING_TRANSACTION', + SUCCESS = 'SUCCESS', + ERROR = 'ERROR' +} + +export enum SwapErrorType { + WALLET_ERROR = 'WALLET_ERROR', + QUOTE_FAILED = 'QUOTE_FAILED', + BUILD_FAILED = 'BUILD_FAILED', + RELAY_FAILED = 'RELAY_FAILED', + SIMULATION_FAILED = 'SIMULATION_FAILED', + UNKNOWN = 'UNKNOWN' +} + +export type SwapTokensParams = { + inputMint: string + outputMint: string + amountUi: number + slippageBps?: number + wrapUnwrapSol?: boolean +} + +export type SwapTokensResult = { + status: SwapStatus + signature?: string + error?: { + type: SwapErrorType + message: string + } + inputAmount?: { + amount: number + uiAmount: number + } + outputAmount?: { + amount: number + uiAmount: number + } +} + +export interface UserBankManagedTokenInfo { + mintAddress: string + claimableTokenMint: 'wAUDIO' | 'USDC' + decimals: number +} diff --git a/packages/common/src/api/tan-query/jupiter/useSwapTokens.ts b/packages/common/src/api/tan-query/jupiter/useSwapTokens.ts new file mode 100644 index 00000000000..80d337b64f8 --- /dev/null +++ b/packages/common/src/api/tan-query/jupiter/useSwapTokens.ts @@ -0,0 +1,360 @@ +import { createCloseAccountInstruction } from '@solana/spl-token' +import { + PublicKey, + TransactionInstruction, + VersionedTransaction +} from '@solana/web3.js' +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { useGetCurrentUser } from '~/api' +import { useAudiusQueryContext } from '~/audius-query' +import { Feature } from '~/models' +import { getJupiterQuoteByMint, JupiterTokenExchange } from '~/services/Jupiter' + +import { QUERY_KEYS } from '../queryKeys' + +import { USER_BANK_MANAGED_TOKENS } from './constants' +import { + SwapErrorType, + SwapStatus, + SwapTokensParams, + SwapTokensResult +} from './types' +import { + addAtaToUserBankInstructions, + addUserBankToAtaInstructions, + findActualJupiterDestination, + findJupiterTemporarySetupAta, + updateJupiterAtaCreationFeePayer +} from './utils' + +/** + * Hook for executing token swaps using Jupiter. + * Swaps any supported SPL token (or SOL) for another. + */ +export const useSwapTokens = () => { + const queryClient = useQueryClient() + const { solanaWalletService, reportToSentry, audiusSdk } = + useAudiusQueryContext() + const { data: user } = useGetCurrentUser({}) + + return useMutation({ + mutationFn: async (params): Promise => { + const { + inputMint: inputMintUiAddress, + outputMint: outputMintUiAddress, + amountUi + } = params + const slippageBps = params.slippageBps ?? 50 + const wrapUnwrapSol = params.wrapUnwrapSol ?? true + + let quoteResult + let signature: string | undefined + const instructions: TransactionInstruction[] = [] + + try { + // ---------- 1. Initialize Dependencies & Wallet ---------- + const [sdk, keypair] = await Promise.all([ + audiusSdk(), + solanaWalletService.getKeypair() + ]) + + if (!keypair) { + return { + status: SwapStatus.ERROR, + error: { + type: SwapErrorType.WALLET_ERROR, + message: 'Wallet not initialised' + } + } + } + const userPublicKey = keypair.publicKey + const feePayer = await sdk.services.solanaRelay.getFeePayer() + const ethAddress = user?.wallet + + // ---------- 2. Get Quote from Jupiter ---------- + try { + quoteResult = await getJupiterQuoteByMint({ + inputMint: inputMintUiAddress, + outputMint: outputMintUiAddress, + amountUi, + slippageBps, + swapMode: 'ExactIn', + onlyDirectRoutes: true + }) + } catch (error: any) { + reportToSentry({ + name: 'JupiterSwapQuoteError', + error, + feature: Feature.TanQuery, + additionalInfo: { params } + }) + return { + status: SwapStatus.ERROR, + error: { + type: SwapErrorType.QUOTE_FAILED, + message: error?.message ?? 'Failed to get swap quote' + } + } + } + + // ---------- 3. Prepare Transaction Instructions ---------- + const inputTokenConfig = + USER_BANK_MANAGED_TOKENS[inputMintUiAddress.toUpperCase()] + const outputTokenConfig = + USER_BANK_MANAGED_TOKENS[outputMintUiAddress.toUpperCase()] + + let sourceAtaForJupiter: PublicKey | undefined + let isSourceAtaTemporary = false + + // --- 3a. Handle Input Token (if user bank managed) --- + const isInputUserBankManaged = !!( + inputTokenConfig && + ethAddress && + inputMintUiAddress.toUpperCase() !== 'SOL' + ) + if (isInputUserBankManaged) { + try { + sourceAtaForJupiter = await addUserBankToAtaInstructions({ + tokenInfo: inputTokenConfig!, + userPublicKey, + ethAddress: ethAddress!, + amountLamports: BigInt(quoteResult.inputAmount.amount), + sdk, + feePayer, + instructions + }) + isSourceAtaTemporary = true + } catch (error: any) { + reportToSentry({ + name: 'JupiterSwapInputPrepError', + error, + feature: Feature.TanQuery, + additionalInfo: { params } + }) + return { + status: SwapStatus.ERROR, + error: { + type: SwapErrorType.BUILD_FAILED, + message: `Failed to prepare input token ${inputTokenConfig!.claimableTokenMint}: ${error.message}` + }, + inputAmount: quoteResult.inputAmount, + outputAmount: quoteResult.outputAmount + } + } + } + + // --- 3b. Determine Jupiter's Destination Token Account --- + let preferredJupiterDestination: string | undefined + let outputUserBankAddress: PublicKey | undefined + + const isOutputUserBankManaged = !!( + outputTokenConfig && + ethAddress && + outputMintUiAddress.toUpperCase() !== 'SOL' + ) + if (isOutputUserBankManaged) { + try { + outputUserBankAddress = + await sdk.services.claimableTokensClient.deriveUserBank({ + ethWallet: ethAddress!, + mint: outputTokenConfig!.claimableTokenMint + }) + preferredJupiterDestination = outputUserBankAddress.toBase58() + } catch (error: any) { + reportToSentry({ + name: 'JupiterSwapOutputUserBankDerivationError', + error, + feature: Feature.TanQuery, + additionalInfo: { params } + }) + } + } + + // --- 3c. Get Jupiter Swap Instructions --- + const { instructions: jupiterInstructions, lookupTableAddresses } = + await JupiterTokenExchange.getSwapInstructions({ + quote: quoteResult.quote, + userPublicKey: userPublicKey.toBase58(), + destinationTokenAccount: preferredJupiterDestination, + wrapAndUnwrapSol: wrapUnwrapSol + }) + + updateJupiterAtaCreationFeePayer( + jupiterInstructions, + userPublicKey, + feePayer + ) + instructions.push(...jupiterInstructions) + + // --- 3d. Identify Actual Jupiter Destination & Potential Jupiter-Created Temporary ATA --- + const actualJupiterDestination = + findActualJupiterDestination(jupiterInstructions) + const jupiterTemporarySetupAta = outputTokenConfig + ? findJupiterTemporarySetupAta( + jupiterInstructions, + outputTokenConfig.mintAddress, + actualJupiterDestination + ) + : undefined + + // --- 3e. Handle Output Token (if user bank managed and not directly deposited by Jupiter) --- + let wasOutputDepositedToUserBank = false + if ( + outputUserBankAddress && + actualJupiterDestination?.equals(outputUserBankAddress) + ) { + wasOutputDepositedToUserBank = true + } + + const needsManualTransferToOutputUserBank = + isOutputUserBankManaged && + actualJupiterDestination && + !wasOutputDepositedToUserBank + + if (needsManualTransferToOutputUserBank) { + try { + await addAtaToUserBankInstructions({ + tokenInfo: outputTokenConfig!, + userPublicKey, + ethAddress: ethAddress!, + amountLamports: BigInt(quoteResult.outputAmount.amount), + sourceAta: actualJupiterDestination!, + sdk, + feePayer, + instructions + }) + wasOutputDepositedToUserBank = true + } catch (error: any) { + reportToSentry({ + name: 'JupiterSwapOutputPostProcessError', + error, + feature: Feature.TanQuery, + additionalInfo: { params } + }) + } + } + + // --- 3f. Add Cleanup Instructions for Temporary ATAs --- + const atasToClose: PublicKey[] = [] + + // Add source ATA if it's temporary + if (sourceAtaForJupiter && isSourceAtaTemporary) { + atasToClose.push(sourceAtaForJupiter) + } + + // Determine if Jupiter setup ATA should be closed + const shouldCloseJupiterSetupAta = + jupiterTemporarySetupAta && + (!actualJupiterDestination || + !jupiterTemporarySetupAta.equals(actualJupiterDestination)) && + !( + needsManualTransferToOutputUserBank && + actualJupiterDestination && + jupiterTemporarySetupAta.equals(actualJupiterDestination) + ) + + if (shouldCloseJupiterSetupAta) { + atasToClose.push(jupiterTemporarySetupAta!) + } + + // Add close account instructions for all ATAs that need to be closed + for (const ataToClose of atasToClose) { + instructions.push( + createCloseAccountInstruction(ataToClose, feePayer, userPublicKey) + ) + } + + // ---------- 4. Build and Sign Transaction ---------- + let swapTx: VersionedTransaction + try { + swapTx = await sdk.services.solanaClient.buildTransaction({ + feePayer, + instructions, + addressLookupTables: lookupTableAddresses.map( + (addr: string) => new PublicKey(addr) + ) + }) + } catch (error: any) { + reportToSentry({ + name: 'JupiterSwapBuildError', + error, + feature: Feature.TanQuery, + additionalInfo: { params, quoteResponse: quoteResult.quote } + }) + return { + status: SwapStatus.ERROR, + error: { + type: SwapErrorType.BUILD_FAILED, + message: error?.message ?? 'Failed to build transaction' + }, + inputAmount: quoteResult.inputAmount, + outputAmount: quoteResult.outputAmount + } + } + + swapTx.sign([keypair]) + + // ---------- 5. Send Transaction ---------- + try { + signature = await sdk.services.solanaClient.sendTransaction(swapTx, { + skipPreflight: true + }) + } catch (error: any) { + reportToSentry({ + name: 'JupiterSwapRelayError', + error, + feature: Feature.TanQuery, + additionalInfo: { + params, + quoteResponse: quoteResult.quote + } + }) + return { + status: SwapStatus.ERROR, + error: { + type: SwapErrorType.RELAY_FAILED, + message: error?.message ?? 'Failed to relay transaction' + }, + inputAmount: quoteResult.inputAmount, + outputAmount: quoteResult.outputAmount + } + } + + // ---------- 6. Success & Invalidation ---------- + if (user?.wallet) { + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.usdcBalance, user.wallet] + }) + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.audioBalance] + }) + } + + return { + status: SwapStatus.SUCCESS, + signature, + inputAmount: quoteResult.inputAmount, + outputAmount: quoteResult.outputAmount + } + } catch (error: any) { + reportToSentry({ + name: 'JupiterSwapUnknownError', + error, + feature: Feature.TanQuery, + additionalInfo: { params, signature } + }) + return { + status: SwapStatus.ERROR, + error: { + type: SwapErrorType.UNKNOWN, + message: error?.message ?? 'An unknown error occurred' + } + } + } + }, + onMutate: () => { + return { status: SwapStatus.SENDING_TRANSACTION } + } + }) +} diff --git a/packages/common/src/api/tan-query/useTokenExchangeRate.ts b/packages/common/src/api/tan-query/jupiter/useTokenExchangeRate.ts similarity index 98% rename from packages/common/src/api/tan-query/useTokenExchangeRate.ts rename to packages/common/src/api/tan-query/jupiter/useTokenExchangeRate.ts index 2e31d760337..56911aa5f45 100644 --- a/packages/common/src/api/tan-query/useTokenExchangeRate.ts +++ b/packages/common/src/api/tan-query/jupiter/useTokenExchangeRate.ts @@ -6,7 +6,7 @@ import { useQuery } from '@tanstack/react-query' import { JupiterTokenSymbol, getJupiterQuoteByMint } from '~/services/Jupiter' import { TOKEN_LISTING_MAP } from '~/store/ui/buy-audio/constants' -import { QueryOptions, type QueryKey } from './types' +import { QueryOptions, type QueryKey } from '../types' export type TokenExchangeRateParams = { inputTokenSymbol: JupiterTokenSymbol diff --git a/packages/common/src/api/tan-query/jupiter/utils.ts b/packages/common/src/api/tan-query/jupiter/utils.ts new file mode 100644 index 00000000000..a6a604d9e9e --- /dev/null +++ b/packages/common/src/api/tan-query/jupiter/utils.ts @@ -0,0 +1,174 @@ +import { AudiusSdk } from '@audius/sdk' +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + createAssociatedTokenAccountIdempotentInstruction, + createCloseAccountInstruction, + createTransferCheckedInstruction, + getAccount, + getAssociatedTokenAddressSync +} from '@solana/spl-token' +import { PublicKey, TransactionInstruction } from '@solana/web3.js' + +import { JUPITER_PROGRAM_ID } from './constants' +import { UserBankManagedTokenInfo } from './types' + +export async function addUserBankToAtaInstructions({ + tokenInfo, + userPublicKey, + ethAddress, + amountLamports, + sdk, + feePayer, + instructions +}: { + tokenInfo: UserBankManagedTokenInfo + userPublicKey: PublicKey + ethAddress: string + amountLamports: bigint + sdk: any + feePayer: PublicKey + instructions: TransactionInstruction[] +}): Promise { + const mint = new PublicKey(tokenInfo.mintAddress) + const ata = getAssociatedTokenAddressSync(mint, userPublicKey, true) + + try { + await getAccount(sdk.services.solanaClient.connection, ata) + } catch (e) { + instructions.push( + createAssociatedTokenAccountIdempotentInstruction( + feePayer, + ata, + userPublicKey, + mint + ) + ) + } + + const secpTransferInstruction = + await sdk.services.claimableTokensClient.createTransferSecpInstruction({ + amount: amountLamports, + ethWallet: ethAddress, + mint: tokenInfo.claimableTokenMint, + destination: ata, + instructionIndex: instructions.length + }) + const transferInstruction = + await sdk.services.claimableTokensClient.createTransferInstruction({ + ethWallet: ethAddress, + mint: tokenInfo.claimableTokenMint, + destination: ata + }) + + instructions.push(secpTransferInstruction, transferInstruction) + return ata +} + +export async function addAtaToUserBankInstructions({ + tokenInfo, + userPublicKey, + ethAddress, + amountLamports, + sourceAta, + sdk, + feePayer, + instructions +}: { + tokenInfo: UserBankManagedTokenInfo + userPublicKey: PublicKey + ethAddress: string + amountLamports: bigint + sourceAta: PublicKey + sdk: AudiusSdk + feePayer: PublicKey + instructions: TransactionInstruction[] +}): Promise { + const mint = new PublicKey(tokenInfo.mintAddress) + const userBankAddress = + await sdk.services.claimableTokensClient.deriveUserBank({ + ethWallet: ethAddress, + mint: tokenInfo.claimableTokenMint + }) + + instructions.push( + createTransferCheckedInstruction( + sourceAta, + mint, + userBankAddress, + userPublicKey, + amountLamports, + tokenInfo.decimals + ), + createCloseAccountInstruction(sourceAta, feePayer, userPublicKey) + ) + return userBankAddress +} + +export function updateJupiterAtaCreationFeePayer( + jupiterInstructions: TransactionInstruction[], + userPublicKey: PublicKey, + relayFeePayer: PublicKey +): void { + for (const ix of jupiterInstructions) { + const isAssociatedTokenProgram = ix.programId.equals( + ASSOCIATED_TOKEN_PROGRAM_ID + ) + const isPayerTheUser = + ix.keys.length > 0 && + ix.keys[0].pubkey.equals(userPublicKey) && + ix.keys[0].isSigner && + ix.keys[0].isWritable + + if (isAssociatedTokenProgram && isPayerTheUser) { + ix.keys[0].pubkey = relayFeePayer + break + } + } +} + +export function findActualJupiterDestination( + jupiterInstructions: TransactionInstruction[] +): PublicKey | undefined { + const jupiterSwapInstruction = jupiterInstructions.find((ix) => + ix.programId.equals(JUPITER_PROGRAM_ID) + ) + if (jupiterSwapInstruction) { + const destinationKeyIndex = 6 + if (jupiterSwapInstruction.keys.length > destinationKeyIndex) { + return jupiterSwapInstruction.keys[destinationKeyIndex].pubkey + } + } + return undefined +} + +export function findJupiterTemporarySetupAta( + jupiterInstructions: TransactionInstruction[], + outputMintAddress: string, + actualFinalJupiterDestination?: PublicKey +): PublicKey | undefined { + const createAtaInstruction = jupiterInstructions.find((ix) => { + const isAssociatedTokenProgram = ix.programId.equals( + ASSOCIATED_TOKEN_PROGRAM_ID + ) + const isForOutputMint = + ix.keys.length >= 4 && + ix.keys[3].pubkey.toBase58().toUpperCase() === + outputMintAddress.toUpperCase() + const createdAtaAddress = + ix.keys.length >= 2 ? ix.keys[1].pubkey : undefined + + const isNotTheFinalDestination = + !actualFinalJupiterDestination || + (createdAtaAddress && + !createdAtaAddress.equals(actualFinalJupiterDestination)) + + return ( + isAssociatedTokenProgram && + isForOutputMint && + isNotTheFinalDestination && + createdAtaAddress + ) + }) + + return createAtaInstruction ? createAtaInstruction.keys[1].pubkey : undefined +} diff --git a/packages/common/src/api/tan-query/useSwapTokens.ts b/packages/common/src/api/tan-query/useSwapTokens.ts deleted file mode 100644 index 98a0fd244a4..00000000000 --- a/packages/common/src/api/tan-query/useSwapTokens.ts +++ /dev/null @@ -1,607 +0,0 @@ -import { - ASSOCIATED_TOKEN_PROGRAM_ID, - createAssociatedTokenAccountIdempotentInstruction, - createCloseAccountInstruction, - createTransferCheckedInstruction, - getAccount, - getAssociatedTokenAddressSync -} from '@solana/spl-token' -import { - PublicKey, - TransactionInstruction, - VersionedTransaction -} from '@solana/web3.js' -import { useMutation, useQueryClient } from '@tanstack/react-query' - -import { useGetCurrentUser } from '~/api' -import { useAudiusQueryContext } from '~/audius-query' -import { Feature } from '~/models' -import { getJupiterQuoteByMint, JupiterTokenExchange } from '~/services/Jupiter' -import { TOKEN_LISTING_MAP } from '~/store/ui/buy-audio/constants' - -// Enums and Types defined earlier in the provided context -export enum SwapStatus { - IDLE = 'IDLE', - GETTING_QUOTE = 'GETTING_QUOTE', - BUILDING_TRANSACTION = 'BUILDING_TRANSACTION', // Added for clarity - SENDING_TRANSACTION = 'SENDING_TRANSACTION', // Updated from RELAYING_TRANSACTION - SUCCESS = 'SUCCESS', - ERROR = 'ERROR' -} - -export enum SwapErrorType { - WALLET_ERROR = 'WALLET_ERROR', - QUOTE_FAILED = 'QUOTE_FAILED', - BUILD_FAILED = 'BUILD_FAILED', - RELAY_FAILED = 'RELAY_FAILED', - SIMULATION_FAILED = 'SIMULATION_FAILED', - UNKNOWN = 'UNKNOWN' -} - -// Use the more generic params structure -export type SwapTokensParams = { - inputMint: string // SPL mint address or 'SOL' - outputMint: string // SPL mint address or 'SOL' - /** Amount of input token in UI units (e.g., 1.5 SOL, 10 AUDIO) */ - amountUi: number - /** Slippage tolerance in basis points (e.g., 50 = 0.5%). Defaults to 50. */ - slippageBps?: number - /** Allow Jupiter to wrap/unwrap SOL automatically. Defaults to true. */ - wrapUnwrapSol?: boolean - // Add computeUnitPriceMicroLamports if needed, defaults usually fine - // computeUnitPriceMicroLamports?: number; -} - -export type SwapTokensResult = { - status: SwapStatus - signature?: string - error?: { - type: SwapErrorType - message: string - } - // Keep input/output amounts for display/confirmation - inputAmount?: { - amount: number // Lamports/Wei - uiAmount: number // User-friendly units - } - outputAmount?: { - amount: number // Lamports/Wei - uiAmount: number // User-friendly units - } -} - -/** - * Hook for executing token swaps using Jupiter. - * Swaps any supported SPL token (or SOL) for another. - */ -export const useSwapTokens = () => { - const queryClient = useQueryClient() - const { solanaWalletService, reportToSentry, audiusSdk } = - useAudiusQueryContext() - const { data: user } = useGetCurrentUser({}) - - return useMutation({ - mutationFn: async (params): Promise => { - const { inputMint, outputMint, amountUi } = params - // Default slippage is 50 basis points (0.5%) - const slippageBps = params.slippageBps ?? 50 - const wrapUnwrapSol = params.wrapUnwrapSol ?? true - - let quoteResult - let signature: string | undefined - - try { - // ---------- 1. Get Wallet Keypair ---------- - const keypair = await solanaWalletService.getKeypair() - if (!keypair) { - console.error('useSwapTokens: Wallet not initialised') - return { - status: SwapStatus.ERROR, - error: { - type: SwapErrorType.WALLET_ERROR, - message: 'Wallet not initialised' - } - } - } - const userPublicKey = keypair.publicKey - - // ---------- 2. Get Quote from Jupiter ---------- - try { - quoteResult = await getJupiterQuoteByMint({ - inputMint, - outputMint, - amountUi, - slippageBps, - swapMode: 'ExactIn', - onlyDirectRoutes: true - }) - } catch (error: any) { - console.error('useSwapTokens: Error getting Jupiter quote:', error) - reportToSentry({ - name: 'JupiterSwapQuoteError', - error, - feature: Feature.TanQuery, // Or a more specific feature - additionalInfo: { params } - }) - return { - status: SwapStatus.ERROR, - error: { - type: SwapErrorType.QUOTE_FAILED, - message: error?.message ?? 'Failed to get swap quote from Jupiter' - } - } - } - - // ---------- 3. Build Transaction ---------- - const sdk = await audiusSdk() - - const audioMintAddress = TOKEN_LISTING_MAP.AUDIO.address - const usdcMintAddress = TOKEN_LISTING_MAP.USDC.address - - // Create a copy of the instructions array for all transaction instructions - const instructions: TransactionInstruction[] = [] - - // Check if this is an AUDIO -> any token swap - const isAudioSwap = - inputMint.toUpperCase() === audioMintAddress.toUpperCase() - const isAudioToUsdc = - isAudioSwap && - outputMint.toUpperCase() === usdcMintAddress.toUpperCase() - - // For AUDIO based swaps, first transfer from user bank to standard ATA - if (isAudioSwap && user?.wallet) { - try { - console.debug('SWAP: Setting up AUDIO transfer from user bank') - const ethAddress = user.wallet - - // Create the AUDIO ATA if it doesn't already exist - const audioMint = new PublicKey(audioMintAddress) - const audioAta = getAssociatedTokenAddressSync( - audioMint, - userPublicKey, - true - ) - - // Check if ATA exists before trying to create it - try { - await getAccount(sdk.services.solanaClient.connection, audioAta) - console.debug('SWAP: Source AUDIO ATA already exists') - } catch (e) { - // If getAccount throws, ATA doesn't exist, add instruction to create - console.debug( - 'SWAP: Source AUDIO ATA does not exist, adding create instruction' - ) - const feePayer = await sdk.services.solanaRelay.getFeePayer() - const createAudioAtaInstruction = - createAssociatedTokenAccountIdempotentInstruction( - feePayer, - audioAta, - userPublicKey, - audioMint - ) - instructions.push(createAudioAtaInstruction) - } - - // Create instructions to transfer from userbank to ATA - const secpTransferInstruction = - await sdk.services.claimableTokensClient.createTransferSecpInstruction( - { - amount: BigInt(quoteResult.inputAmount.amount), - ethWallet: ethAddress, - mint: 'wAUDIO', - destination: audioAta, - instructionIndex: instructions.length - } - ) - - // Add the instruction to actually move the tokens - const transferInstruction = - await sdk.services.claimableTokensClient.createTransferInstruction( - { - ethWallet: ethAddress, - mint: 'wAUDIO', - destination: audioAta - // No amount needed here, it uses the verified Secp amount - } - ) - instructions.push(secpTransferInstruction) - instructions.push(transferInstruction) - - console.debug( - 'SWAP: Added AUDIO userbank to ATA secp + transfer instructions', - { - audioAta: audioAta.toBase58(), - amount: BigInt(quoteResult.inputAmount.amount) - } - ) - } catch (error) { - console.error( - 'SWAP: Failed to add AUDIO userbank transfer instructions:', - error - ) - return { - status: SwapStatus.ERROR, - error: { - type: SwapErrorType.BUILD_FAILED, - message: 'Failed to set up AUDIO transfer from user bank' - }, - inputAmount: quoteResult.inputAmount, - outputAmount: quoteResult.outputAmount - } - } - } - - const feePayer = await sdk.services.solanaRelay.getFeePayer() - - // Pre-determine the USDC userbank for AUDIO -> USDC swaps to use as destination - let usdcUserBank: PublicKey | undefined - if (isAudioToUsdc && user?.wallet) { - try { - usdcUserBank = - await sdk.services.claimableTokensClient.deriveUserBank({ - ethWallet: user.wallet, - mint: 'USDC' - }) - console.debug('SWAP: Using USDC userbank as destination', { - userBank: usdcUserBank.toBase58() - }) - } catch (error) { - console.error('SWAP: Failed to derive USDC userbank:', error) - // Continue with the swap even if we can't derive the userbank - // We'll fall back to standard ATA in this case - } - } - - // Get the transaction instructions from Jupiter - const { instructions: jupiterInstructions, lookupTableAddresses } = - await JupiterTokenExchange.getSwapInstructions({ - quote: quoteResult.quote, - userPublicKey: userPublicKey.toBase58(), - destinationTokenAccount: usdcUserBank?.toBase58(), // Pass the userbank as destination for AUDIO -> USDC - wrapAndUnwrapSol: wrapUnwrapSol - }) - - // Log the intended destination if we provided one - console.debug('SWAP: Requested Jupiter destination:', { - destination: usdcUserBank?.toBase58() ?? 'Default ATA' - }) - - // Find the actual Jupiter swap instruction to log the real destination - const jupiterSwapInstruction = jupiterInstructions.find( - (ix) => - ix.programId.toBase58() === - 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4' - ) - let actualJupiterDestination: PublicKey | undefined - if (jupiterSwapInstruction) { - // Heuristic: In Jupiter's SharedAccountsRoute, the 6th key is often the final destination token account - // A more robust method would involve decoding the specific instruction data based on its ID. - const destKeyMeta = jupiterSwapInstruction.keys[6] - if (destKeyMeta) { - actualJupiterDestination = destKeyMeta.pubkey - console.debug('SWAP: Actual Jupiter destination account:', { - destination: actualJupiterDestination.toBase58() - }) - } else { - console.warn( - 'SWAP: Could not determine actual Jupiter destination account from keys.' - ) - } - } else { - console.warn('SWAP: Could not find Jupiter swap instruction in list.') - } - - // --- Modification Start --- - // Find and update the fee payer for Jupiter's ATA creation instruction - for (const ix of jupiterInstructions) { - // Check if it's the Associated Token Program - if (ix.programId.equals(ASSOCIATED_TOKEN_PROGRAM_ID)) { - // Basic check for createAssociatedTokenAccountIdempotentInstruction: - // It typically has 7 keys, and the first key is the fee payer. - // We assume Jupiter might use the userPublicKey as the default fee payer here. - if ( - ix.keys.length >= 1 && // Ensure keys exist - ix.keys[0].pubkey.equals(userPublicKey) && // Check if fee payer is the user - ix.keys[0].isSigner && // Fee payer must be a signer - ix.keys[0].isWritable // Fee payer must be writable - ) { - console.debug( - 'SWAP: Updating fee payer for Jupiter ATA creation instruction' - ) - // Update the first key (fee payer) to use the relay fee payer - ix.keys[0].pubkey = feePayer - // Ensure the user is no longer marked as signer/writable *for this instruction's fee payer role* - // Find the user's key if it exists elsewhere in the instruction and ensure it's not the fee payer - // Note: The userPublicKey *will* still be a signer for the overall transaction later. - // This modification only changes who pays the fee *for this specific ATA instruction*. - // We don't need to remove the user as a signer generally, just ensure the *first* key (fee payer) is correct. - // A more robust check could involve decoding instruction data, but this heuristic is common. - break // Assuming only one such instruction from Jupiter per swap - } - } - } - // --- Modification End --- - - // Find the ATA created by Jupiter's setup instruction for potential closure later - let jupiterSetupAta: PublicKey | undefined - const createAtaInstruction = jupiterInstructions.find( - (ix) => - ix.programId.equals(ASSOCIATED_TOKEN_PROGRAM_ID) && - // Check keys length or instruction data for more specific match if needed - ix.keys.length >= 4 && // Basic check for create instructions - ix.keys[3].pubkey.toBase58() === usdcMintAddress // Check if it's for the USDC mint - ) - if (createAtaInstruction) { - jupiterSetupAta = createAtaInstruction.keys[1].pubkey // ATA address is typically the 2nd key - console.debug('SWAP: Identified Jupiter setup ATA:', { - ata: jupiterSetupAta.toBase58() - }) - } else { - console.warn( - 'SWAP: Could not identify Jupiter setup ATA instruction.' - ) - } - - // Add Jupiter instructions (potentially modified) to our instruction array - instructions.push(...jupiterInstructions) - - // Determine if we need the fallback transfer/close logic - const needsFallback = - isAudioToUsdc && - user?.wallet && - (!usdcUserBank || - !actualJupiterDestination || - !actualJupiterDestination.equals(usdcUserBank)) - - // For AUDIO -> USDC swaps, add instructions to transfer USDC to userbank and close ATA - // Only add these if we couldn't use the userbank as the direct destination - if (needsFallback) { - try { - console.debug( - 'SWAP: Using fallback: Adding USDC transfer + close instructions.', - { - intendedUserBank: usdcUserBank?.toBase58(), - actualJupiterDestination: actualJupiterDestination?.toBase58() - } - ) - - const ethAddress = user.wallet - - // The source for this transfer is the actual destination Jupiter used - const sourceAccount = - actualJupiterDestination ?? - getAssociatedTokenAddressSync( - new PublicKey(usdcMintAddress), - userPublicKey, - true - ) - - // The destination is always the userbank in the fallback - const finalUserBankDestination = - usdcUserBank ?? - (await sdk.services.claimableTokensClient.deriveUserBank({ - ethWallet: ethAddress, - mint: 'USDC' - })) - - // Use TransferChecked instead of Transfer - const usdcMintPublicKey = new PublicKey(usdcMintAddress) - const usdcDecimals = TOKEN_LISTING_MAP.USDC.decimals // Assuming decimals are available - - const transferToUserbankInstruction = - createTransferCheckedInstruction( - sourceAccount, // source (actual Jupiter destination) - usdcMintPublicKey, // mint - finalUserBankDestination, // destination (userbank) - userPublicKey, // owner - BigInt(quoteResult.outputAmount.amount), // amount - usdcDecimals // decimals - ) - - const closeUsdcAccountInstruction = createCloseAccountInstruction( - sourceAccount, // Close the actual Jupiter destination - feePayer, - userPublicKey - ) - - instructions.push( - transferToUserbankInstruction, - closeUsdcAccountInstruction - ) - - console.debug( - 'SWAP: Added fallback userbank transfer instructions', - { - source: sourceAccount.toBase58(), - destination: finalUserBankDestination.toBase58(), - amount: quoteResult.outputAmount.amount - } - ) - - // For AUDIO swaps, optionally close the AUDIO ATA if we created it only for this swap - // This needs to happen even in the fallback case - if (isAudioSwap) { - console.debug( - 'SWAP: Adding instruction to close AUDIO ATA after swap (fallback path)' - ) - const audioAta = getAssociatedTokenAddressSync( - new PublicKey(audioMintAddress), - userPublicKey, - true - ) - - const closeAudioAccountInstruction = - createCloseAccountInstruction(audioAta, feePayer, userPublicKey) - - instructions.push(closeAudioAccountInstruction) - } - } catch (error) { - console.error( - 'SWAP: Failed to add fallback USDC userbank transfer instructions:', - error - ) - // Continue with the swap even if we can't add these instructions - // Better to have USDC in an ATA than to fail the swap entirely - } - } - // Close AUDIO ATA if we used direct destination successfully - else if ( - isAudioToUsdc && - isAudioSwap && - usdcUserBank && - actualJupiterDestination?.equals(usdcUserBank) - ) { - // Even if we're sending directly to userbank, we still need to close the AUDIO ATA - // and the potentially orphaned ATA created by Jupiter's setup - try { - console.debug( - 'SWAP: Direct deposit successful. Adding instructions to close AUDIO ATA and Jupiter setup ATA.' - ) - // Close AUDIO ATA - const audioAta = getAssociatedTokenAddressSync( - new PublicKey(audioMintAddress), - userPublicKey, - true - ) - const closeAudioAccountInstruction = createCloseAccountInstruction( - audioAta, - feePayer, - userPublicKey - ) - instructions.push(closeAudioAccountInstruction) - - // Close Jupiter Setup ATA (if identified) - if (jupiterSetupAta) { - const closeJupiterSetupAtaInstruction = - createCloseAccountInstruction( - jupiterSetupAta, // The ATA created by Jupiter's setup - feePayer, // Destination for rent refund - userPublicKey // Owner/Authority of the ATA - ) - instructions.push(closeJupiterSetupAtaInstruction) - console.debug( - 'SWAP: Added instruction to close Jupiter setup ATA', - { - ata: jupiterSetupAta.toBase58() - } - ) - } - } catch (error) { - console.error( - 'SWAP: Failed to add ATA close instruction(s) (direct path):', - error - ) - // Continue with the swap even if we can't close the ATAs - } - } - - // Build the transaction with all instructions - let swapTx: VersionedTransaction - try { - swapTx = await sdk.services.solanaClient.buildTransaction({ - feePayer, - instructions, - addressLookupTables: lookupTableAddresses.map( - (address: string) => new PublicKey(address) - ), - priorityFee: null, - computeLimit: null - }) - } catch (error: any) { - console.error('useSwapTokens: Error building transaction:', error) - reportToSentry({ - name: 'JupiterSwapBuildError', - error, - feature: Feature.TanQuery, - additionalInfo: { params, quoteResponse: quoteResult.quote } - }) - return { - status: SwapStatus.ERROR, - error: { - type: SwapErrorType.BUILD_FAILED, - message: error?.message ?? 'Failed to build swap transaction' - }, - inputAmount: quoteResult.inputAmount, - outputAmount: quoteResult.outputAmount - } - } - - // ---------- 4. Sign Transaction ---------- - // The transaction needs to be signed by the actual user (which we do here). - swapTx.sign([keypair]) - - // ---------- 5. Send Transaction ---------- - try { - signature = await sdk.services.solanaClient.sendTransaction(swapTx, { - skipPreflight: true - }) - console.debug(`Swap completed with signature: ${signature}`) - } catch (error: any) { - console.error('useSwapTokens: Failed to relay transaction', error) - reportToSentry({ - name: 'JupiterSwapRelayError', - error, - feature: Feature.TanQuery, - additionalInfo: { - params, - quoteResponse: quoteResult.quote - } - }) - return { - status: SwapStatus.ERROR, - error: { - type: SwapErrorType.RELAY_FAILED, - message: error?.message ?? 'Failed to relay transaction' - }, - inputAmount: quoteResult.inputAmount, - outputAmount: quoteResult.outputAmount - } - } - - // ---------- 6. Success & Invalidation ---------- - // Generate dynamic query keys based on mints - // Assuming your balance hooks use keys like ['audioBalance', userId] or ['usdcBalance', userId] - // We need a more generic pattern if swapping arbitrary tokens. - // Example: ['tokenBalance', userId, mintAddress] - // If using simpler keys like ['audioBalance'], adjust accordingly. - // SOL balance might need invalidation too if SOL was input/output. - const inputBalanceKey = - inputMint === 'SOL' ? 'solBalance' : 'tokenBalance' // Adapt as needed - const outputBalanceKey = - outputMint === 'SOL' ? 'solBalance' : 'tokenBalance' // Adapt as needed - - queryClient.invalidateQueries({ queryKey: [inputBalanceKey] }) // Invalidate broadly for now - queryClient.invalidateQueries({ queryKey: [outputBalanceKey] }) - // Or more specifically if your keys include the mint address: - // queryClient.invalidateQueries({ queryKey: [inputBalanceKey, inputMint] }); - // queryClient.invalidateQueries({ queryKey: [outputBalanceKey, outputMint] }); - - return { - status: SwapStatus.SUCCESS, - signature, - inputAmount: quoteResult.inputAmount, - outputAmount: quoteResult.outputAmount - } - } catch (error: any) { - // Catch-all for unexpected errors during the process - console.error('useSwapTokens: Unknown error during swap:', error) - reportToSentry({ - name: 'JupiterSwapUnknownError', - error, - feature: Feature.TanQuery, - additionalInfo: { params, signature } // Signature might be undefined here - }) - return { - status: SwapStatus.ERROR, - error: { - type: SwapErrorType.UNKNOWN, - message: error?.message ?? 'An unknown error occurred during swap' - } - } - } - }, - onMutate: () => { - return { status: SwapStatus.SENDING_TRANSACTION } - } - }) -} diff --git a/packages/common/src/services/Jupiter.ts b/packages/common/src/services/Jupiter.ts index 934ab65e3a0..2c7b331a6ac 100644 --- a/packages/common/src/services/Jupiter.ts +++ b/packages/common/src/services/Jupiter.ts @@ -1,4 +1,9 @@ -import { createJupiterApiClient, Instruction, QuoteResponse } from '@jup-ag/api' +import { + createJupiterApiClient, + Instruction, + QuoteResponse, + SwapMode +} from '@jup-ag/api' import { PublicKey, TransactionInstruction } from '@solana/web3.js' import { Name } from '~/models/Analytics' @@ -16,36 +21,18 @@ export const SLIPPAGE_TOLERANCE_EXCEEDED_ERROR = 6001 // Define JupiterTokenSymbol type here since we can't import it directly export type JupiterTokenSymbol = keyof typeof TOKEN_LISTING_MAP -export const parseJupiterInstruction = (instruction: Instruction) => { - return new TransactionInstruction({ - programId: new PublicKey(instruction.programId), - keys: instruction.accounts.map((a) => ({ - pubkey: new PublicKey(a.pubkey), - isSigner: a.isSigner, - isWritable: a.isWritable - })), - data: Buffer.from(instruction.data, 'base64') - }) -} - -let jupiterClient: ReturnType | null = null - -export const getJupiterClient = () => { - if (!jupiterClient) { - jupiterClient = createJupiterApiClient() - } - return jupiterClient -} +// Jupiter API instance - using the default URL +const jupiterInstance = createJupiterApiClient() -// Legacy instance for backward compatibility -export const jupiterInstance = createJupiterApiClient() +// Get the Jupiter API client +export const getJupiterClient = () => jupiterInstance export type JupiterQuoteParams = { inputTokenSymbol: JupiterTokenSymbol outputTokenSymbol: JupiterTokenSymbol inputAmount: number slippageBps: number - swapMode?: 'ExactIn' | 'ExactOut' + swapMode?: SwapMode onlyDirectRoutes?: boolean } @@ -55,7 +42,7 @@ export type JupiterMintQuoteParams = { outputMint: string amountUi: number slippageBps: number - swapMode?: 'ExactIn' | 'ExactOut' + swapMode?: SwapMode onlyDirectRoutes?: boolean } @@ -259,7 +246,6 @@ export const getSwapInstructions = async ({ userPublicKey, destinationTokenAccount, wrapAndUnwrapSol, - computeUnitPriceMicroLamports: 100000, useSharedAccounts: true } }) diff --git a/packages/web/src/components/buy-sell-modal/SwapTab.tsx b/packages/web/src/components/buy-sell-modal/SwapTab.tsx index c0e1c2a2d1a..e0463e25a82 100644 --- a/packages/web/src/components/buy-sell-modal/SwapTab.tsx +++ b/packages/web/src/components/buy-sell-modal/SwapTab.tsx @@ -52,6 +52,7 @@ export const SwapTab = ({ }: SwapTabProps) => { // Use the shared hook for form logic const { + inputAmount, numericInputAmount, outputAmount, error, @@ -101,7 +102,7 @@ export const SwapTab = ({ title='You Pay' tokenInfo={inputToken} isInput={true} - amount={numericInputAmount} + amount={inputAmount} onAmountChange={handleInputAmountChange} onMaxClick={handleMaxClick} availableBalance={availableBalance} diff --git a/packages/web/src/components/buy-sell-modal/hooks/useTokenSwapForm.ts b/packages/web/src/components/buy-sell-modal/hooks/useTokenSwapForm.ts index f4c30c16ad1..9062694ff24 100644 --- a/packages/web/src/components/buy-sell-modal/hooks/useTokenSwapForm.ts +++ b/packages/web/src/components/buy-sell-modal/hooks/useTokenSwapForm.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { useTokenExchangeRate } from '@audius/common/src/api/tan-query/useTokenExchangeRate' +import { useTokenExchangeRate } from '@audius/common/src/api' import { JupiterTokenSymbol } from '@audius/common/src/services/Jupiter' import { TokenInfo } from '../types' @@ -68,7 +68,8 @@ export const useTokenSwapForm = ({ // Calculate the numeric value of the input amount const numericInputAmount = useMemo(() => { - const parsed = parseFloat(inputAmount || '0') + if (!inputAmount) return 0 + const parsed = parseFloat(inputAmount) return isNaN(parsed) ? 0 : parsed }, [inputAmount]) @@ -150,8 +151,8 @@ export const useTokenSwapForm = ({ // Handle input changes const handleInputAmountChange = useCallback((value: string) => { - // Allow only valid number input - if (value === '' || /^\d*\.?\d*$/.test(value)) { + // Allow only valid number input with better decimal handling + if (value === '' || /^(\d*\.?\d*|\d+\.)$/.test(value)) { setInputAmount(value) } }, []) @@ -170,7 +171,7 @@ export const useTokenSwapForm = ({ const currentExchangeRate = exchangeRateData ? exchangeRateData.rate : null return { - inputAmount, + inputAmount, // Raw string input for display numericInputAmount, outputAmount, error, diff --git a/packages/web/src/components/buy-sell-modal/types.ts b/packages/web/src/components/buy-sell-modal/types.ts index d96daf6fec0..6e387bfc04b 100644 --- a/packages/web/src/components/buy-sell-modal/types.ts +++ b/packages/web/src/components/buy-sell-modal/types.ts @@ -22,7 +22,7 @@ export type TokenAmountSectionProps = { title: string tokenInfo: TokenInfo isInput: boolean - amount: number + amount: number | string onAmountChange?: (value: string) => void onMaxClick?: () => void availableBalance: number From d53ce27e9dfa65e01b1cca44b4a0574c5a0269dc Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Thu, 8 May 2025 10:55:22 -0500 Subject: [PATCH 14/22] Refactor --- .../api/tan-query/jupiter/useSwapTokens.ts | 29 ++- packages/common/src/services/Jupiter.ts | 182 +++--------------- 2 files changed, 50 insertions(+), 161 deletions(-) diff --git a/packages/common/src/api/tan-query/jupiter/useSwapTokens.ts b/packages/common/src/api/tan-query/jupiter/useSwapTokens.ts index 80d337b64f8..001b896bc5c 100644 --- a/packages/common/src/api/tan-query/jupiter/useSwapTokens.ts +++ b/packages/common/src/api/tan-query/jupiter/useSwapTokens.ts @@ -9,7 +9,11 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { useGetCurrentUser } from '~/api' import { useAudiusQueryContext } from '~/audius-query' import { Feature } from '~/models' -import { getJupiterQuoteByMint, JupiterTokenExchange } from '~/services/Jupiter' +import { + convertJupiterInstructions, + getJupiterQuoteByMint, + jupiterInstance +} from '~/services/Jupiter' import { QUERY_KEYS } from '../queryKeys' @@ -172,13 +176,24 @@ export const useSwapTokens = () => { } // --- 3c. Get Jupiter Swap Instructions --- - const { instructions: jupiterInstructions, lookupTableAddresses } = - await JupiterTokenExchange.getSwapInstructions({ - quote: quoteResult.quote, + const { + tokenLedgerInstruction, + swapInstruction, + addressLookupTableAddresses + } = await jupiterInstance.swapInstructionsPost({ + swapRequest: { + quoteResponse: quoteResult.quote, userPublicKey: userPublicKey.toBase58(), destinationTokenAccount: preferredJupiterDestination, - wrapAndUnwrapSol: wrapUnwrapSol - }) + wrapAndUnwrapSol: wrapUnwrapSol, + useSharedAccounts: true + } + }) + + const jupiterInstructions = convertJupiterInstructions([ + tokenLedgerInstruction, + swapInstruction + ]) updateJupiterAtaCreationFeePayer( jupiterInstructions, @@ -271,7 +286,7 @@ export const useSwapTokens = () => { swapTx = await sdk.services.solanaClient.buildTransaction({ feePayer, instructions, - addressLookupTables: lookupTableAddresses.map( + addressLookupTables: addressLookupTableAddresses.map( (addr: string) => new PublicKey(addr) ) }) diff --git a/packages/common/src/services/Jupiter.ts b/packages/common/src/services/Jupiter.ts index 2c7b331a6ac..f014873d1ff 100644 --- a/packages/common/src/services/Jupiter.ts +++ b/packages/common/src/services/Jupiter.ts @@ -6,8 +6,6 @@ import { } from '@jup-ag/api' import { PublicKey, TransactionInstruction } from '@solana/web3.js' -import { Name } from '~/models/Analytics' -import { CommonStoreContext } from '~/store/storeContext' import { TOKEN_LISTING_MAP } from '~/store/ui/buy-audio/constants' import { convertBigIntToAmountObject } from '~/utils' @@ -21,11 +19,25 @@ export const SLIPPAGE_TOLERANCE_EXCEEDED_ERROR = 6001 // Define JupiterTokenSymbol type here since we can't import it directly export type JupiterTokenSymbol = keyof typeof TOKEN_LISTING_MAP -// Jupiter API instance - using the default URL -const jupiterInstance = createJupiterApiClient() +let _jup: ReturnType -// Get the Jupiter API client -export const getJupiterClient = () => jupiterInstance +const initJupiter = () => { + try { + return createJupiterApiClient() + } catch (e) { + console.error('Jupiter failed to initialize', e) + throw e + } +} + +const getInstance = () => { + if (!_jup) { + _jup = initJupiter() + } + return _jup +} + +export const jupiterInstance = getInstance() export type JupiterQuoteParams = { inputTokenSymbol: JupiterTokenSymbol @@ -68,99 +80,6 @@ export type JupiterQuoteResult = { quote: QuoteResponse } -export const quoteWithAnalytics = async ({ - quoteArgs, - track, - make -}: { - quoteArgs: Parameters[0] - track: CommonStoreContext['analytics']['track'] - make: CommonStoreContext['analytics']['make'] -}) => { - await track( - make({ - eventName: Name.JUPITER_QUOTE_REQUEST, - inputMint: quoteArgs.inputMint, - outputMint: quoteArgs.outputMint, - swapMode: quoteArgs.swapMode, - slippageBps: quoteArgs.slippageBps, - amount: quoteArgs.amount - }) - ) - const quoteResponse = await jupiterInstance.quoteGet(quoteArgs) - await track( - make({ - eventName: Name.JUPITER_QUOTE_RESPONSE, - inputMint: quoteResponse.inputMint, - outputMint: quoteResponse.outputMint, - swapMode: quoteResponse.swapMode, - slippageBps: quoteResponse.slippageBps, - otherAmountThreshold: Number(quoteResponse.otherAmountThreshold), - inAmount: Number(quoteResponse.inAmount), - outAmount: Number(quoteResponse.outAmount) - }) - ) - return quoteResponse -} - -/** - * Gets a quote from Jupiter for an exchange between tokens - */ -export const getJupiterQuote = async ({ - inputTokenSymbol, - outputTokenSymbol, - inputAmount, - slippageBps, - swapMode = 'ExactIn', - onlyDirectRoutes = false -}: JupiterQuoteParams): Promise => { - const inputToken = TOKEN_LISTING_MAP[inputTokenSymbol] - const outputToken = TOKEN_LISTING_MAP[outputTokenSymbol] - - if (!inputToken || !outputToken) { - throw new Error( - `Tokens not found: ${inputTokenSymbol} => ${outputTokenSymbol}` - ) - } - - // Calculate amount with proper decimal precision - const amount = - swapMode === 'ExactIn' - ? Math.ceil(inputAmount * 10 ** inputToken.decimals) - : Math.floor(inputAmount * 10 ** outputToken.decimals) - - // Get quote from Jupiter - const jupiter = getJupiterClient() - const quote = await jupiter.quoteGet({ - inputMint: inputToken.address, - outputMint: outputToken.address, - amount, - slippageBps, - swapMode, - onlyDirectRoutes - }) - - if (!quote) { - throw new Error('Failed to get Jupiter quote') - } - - return { - inputAmount: convertBigIntToAmountObject( - BigInt(quote.inAmount), - inputToken.decimals - ), - outputAmount: convertBigIntToAmountObject( - BigInt(quote.outAmount), - outputToken.decimals - ), - otherAmountThreshold: convertBigIntToAmountObject( - BigInt(quote.otherAmountThreshold), - swapMode === 'ExactIn' ? outputToken.decimals : inputToken.decimals - ), - quote - } -} - /** * Gets a quote from Jupiter using mint addresses directly * This version is used by the useSwapTokens hook @@ -174,8 +93,6 @@ export const getJupiterQuoteByMint = async ({ onlyDirectRoutes = false }: JupiterMintQuoteParams): Promise => { // Get quote from Jupiter - const jupiter = getJupiterClient() - // Look up token decimals from TOKEN_LISTING_MAP // We'll find tokens by their address to get the correct decimals const inputToken = Object.values(TOKEN_LISTING_MAP).find( @@ -194,7 +111,7 @@ export const getJupiterQuoteByMint = async ({ ? Math.ceil(amountUi * 10 ** inputDecimals) : Math.floor(amountUi * 10 ** outputDecimals) - const quote = await jupiter.quoteGet({ + const quote = await jupiterInstance.quoteGet({ inputMint, outputMint, amount, @@ -225,51 +142,19 @@ export const getJupiterQuoteByMint = async ({ } /** - * Gets swap instructions from Jupiter for executing a token swap - * Converts raw Jupiter instructions to Solana TransactionInstructions + * Converts an array of Jupiter instructions to Solana TransactionInstructions + * Filters out undefined instructions and handles the conversion */ -export const getSwapInstructions = async ({ - quote, - userPublicKey, - destinationTokenAccount, - wrapAndUnwrapSol = true -}: { - quote: QuoteResponse - userPublicKey: string - destinationTokenAccount?: string - wrapAndUnwrapSol?: boolean -}) => { - const jupiter = getJupiterClient() - const response = await jupiter.swapInstructionsPost({ - swapRequest: { - quoteResponse: quote, - userPublicKey, - destinationTokenAccount, - wrapAndUnwrapSol, - useSharedAccounts: true - } - }) - - const { - tokenLedgerInstruction, - computeBudgetInstructions, - setupInstructions, - swapInstruction, - cleanupInstruction, - addressLookupTableAddresses - } = response - +export const convertJupiterInstructions = ( + instructions: (Instruction | undefined)[] +): TransactionInstruction[] => { // Flatten and filter out undefined instructions - const instructions = [ - tokenLedgerInstruction, - ...(computeBudgetInstructions || []), - ...(setupInstructions || []), - swapInstruction, - cleanupInstruction - ].filter((i): i is Instruction => i !== undefined) + const filteredInstructions = instructions.filter( + (i): i is Instruction => i !== undefined + ) // Convert to Solana TransactionInstruction format - const transactionInstructions = instructions.map((i) => { + return filteredInstructions.map((i) => { return { programId: new PublicKey(i.programId), data: Buffer.from(i.data, 'base64'), @@ -282,15 +167,4 @@ export const getSwapInstructions = async ({ }) } as TransactionInstruction }) - - return { - instructions: transactionInstructions, - lookupTableAddresses: addressLookupTableAddresses || [] - } -} - -// Export a singleton for convenient access -export const JupiterTokenExchange = { - getQuote: getJupiterQuoteByMint, - getSwapInstructions } From 1bfa1dba655ea8188cfc5abf1741d9e3b5dbcbc8 Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Thu, 8 May 2025 12:08:40 -0500 Subject: [PATCH 15/22] Update form --- .../services/remote-config/feature-flags.ts | 6 +- .../src/components/buy-sell-modal/SwapTab.tsx | 112 +++++++--------- .../buy-sell-modal/TokenAmountSection.tsx | 38 +++++- .../components/buy-sell-modal/hooks/index.ts | 2 + .../hooks/useTokenAmountFormatting.ts | 63 +++++++++ .../buy-sell-modal/hooks/useTokenSwapForm.ts | 125 ++++++++---------- .../buy-sell-modal/schemas/swapFormSchema.ts | 42 ++++++ 7 files changed, 247 insertions(+), 141 deletions(-) create mode 100644 packages/web/src/components/buy-sell-modal/hooks/index.ts create mode 100644 packages/web/src/components/buy-sell-modal/hooks/useTokenAmountFormatting.ts create mode 100644 packages/web/src/components/buy-sell-modal/schemas/swapFormSchema.ts diff --git a/packages/common/src/services/remote-config/feature-flags.ts b/packages/common/src/services/remote-config/feature-flags.ts index 035c43db05e..d647578ad85 100644 --- a/packages/common/src/services/remote-config/feature-flags.ts +++ b/packages/common/src/services/remote-config/feature-flags.ts @@ -31,7 +31,8 @@ export enum FeatureFlags { REMIX_CONTEST = 'remix_contest', WALLET_UI_UPDATE = 'wallet_ui_update', SEARCH_EXPLORE = 'search_explore', - EXPLORE_REMIX_SECTION = 'explore_remix_section' + EXPLORE_REMIX_SECTION = 'explore_remix_section', + WALLET_UI_BUY_SELL = 'wallet_ui_buy_sell' } type FlagDefaults = Record @@ -78,5 +79,6 @@ export const flagDefaults: FlagDefaults = { [FeatureFlags.REMIX_CONTEST]: false, [FeatureFlags.WALLET_UI_UPDATE]: false, [FeatureFlags.SEARCH_EXPLORE]: false, - [FeatureFlags.EXPLORE_REMIX_SECTION]: false + [FeatureFlags.EXPLORE_REMIX_SECTION]: false, + [FeatureFlags.WALLET_UI_BUY_SELL]: false } diff --git a/packages/web/src/components/buy-sell-modal/SwapTab.tsx b/packages/web/src/components/buy-sell-modal/SwapTab.tsx index e0463e25a82..d3116946777 100644 --- a/packages/web/src/components/buy-sell-modal/SwapTab.tsx +++ b/packages/web/src/components/buy-sell-modal/SwapTab.tsx @@ -1,4 +1,7 @@ -import { Flex, Text, Skeleton } from '@audius/harmony' +import { useRef } from 'react' + +import { Flex, Skeleton } from '@audius/harmony' +import { Form, FormikProvider } from 'formik' import { TokenAmountSection } from './TokenAmountSection' import { useTokenSwapForm } from './hooks/useTokenSwapForm' @@ -52,11 +55,10 @@ export const SwapTab = ({ }: SwapTabProps) => { // Use the shared hook for form logic const { + formik, inputAmount, numericInputAmount, outputAmount, - error, - exchangeRateError, isExchangeRateLoading, isBalanceLoading, availableBalance, @@ -69,76 +71,56 @@ export const SwapTab = ({ min, max, balance, - onTransactionDataChange }) - // Show initial loading state if we don't have a balance or exchange rate yet + // Track if an exchange rate has ever been successfully fetched + const hasRateEverBeenFetched = useRef(false) + if (currentExchangeRate !== null) { + hasRateEverBeenFetched.current = true + } + + // Show initial loading state if balance is loading, + // OR if exchange rate is loading AND we've never fetched a rate before. const isInitialLoading = - isBalanceLoading || (isExchangeRateLoading && !currentExchangeRate) + isBalanceLoading || + (isExchangeRateLoading && !hasRateEverBeenFetched.current) return ( - - {/* Show loading state while fetching balance or initial exchange rate */} - {isInitialLoading && } + +
+ + {isInitialLoading ? ( + + ) : ( + <> + - {/* Show error from exchange rate fetch */} - {exchangeRateError && ( - - Unable to fetch exchange rate. Please try again. - - )} - - {/* Input amount section */} - + + + )} - {/* Show validation error */} - {error && ( - - {error} + {isExchangeRateLoading && + numericInputAmount > 0 && + !isInitialLoading && } - )} - - {/* Output amount section */} - - - {/* Loading indicator for exchange rate */} - {isExchangeRateLoading && numericInputAmount > 0 && !isInitialLoading && ( - - )} - +
+
) } diff --git a/packages/web/src/components/buy-sell-modal/TokenAmountSection.tsx b/packages/web/src/components/buy-sell-modal/TokenAmountSection.tsx index 46d8116860b..d1d41740fe8 100644 --- a/packages/web/src/components/buy-sell-modal/TokenAmountSection.tsx +++ b/packages/web/src/components/buy-sell-modal/TokenAmountSection.tsx @@ -1,3 +1,4 @@ +import { getCurrencyDecimalPlaces } from '@audius/common/utils' import { Button, Divider, @@ -8,8 +9,23 @@ import { } from '@audius/harmony' import { useTheme } from '@emotion/react' +import { useTokenAmountFormatting } from './hooks' import { TokenAmountSectionProps } from './types' +const messages = { + available: 'Available', + max: 'MAX', + amountInputLabel: (symbol: string) => `Amount (${symbol})`, + exchangeRate: (rate: number, isTokenStablecoin: boolean) => { + const decimalPlaces = isTokenStablecoin ? 2 : getCurrencyDecimalPlaces(rate) + const formattedRateStr = rate.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: decimalPlaces + }) + return `(${isTokenStablecoin ? '$' : ''}${formattedRateStr} ea.)` + } +} + export const TokenAmountSection = ({ title, tokenInfo, @@ -26,6 +42,15 @@ export const TokenAmountSection = ({ const { icon: TokenIcon, symbol, isStablecoin } = tokenInfo const tokenTicker = isStablecoin ? symbol : `$${symbol}` + const { formattedAvailableBalance, formattedAmount } = + useTokenAmountFormatting({ + amount, + availableBalance, + exchangeRate, + isStablecoin: !!isStablecoin, + placeholder + }) + return ( @@ -38,7 +63,7 @@ export const TokenAmountSection = ({ onAmountChange?.(e.target.value)} @@ -50,7 +75,7 @@ export const TokenAmountSection = ({ }} onClick={onMaxClick} > - MAX + {messages.max} - Available + {messages.available} {isStablecoin ? '$' : ''} - {availableBalance.toFixed(2)} + {formattedAvailableBalance} @@ -78,7 +103,7 @@ export const TokenAmountSection = ({ - {amount} + {formattedAmount} @@ -86,8 +111,7 @@ export const TokenAmountSection = ({ {exchangeRate !== null && exchangeRate !== undefined && ( - ({isStablecoin ? '$' : ''} - {exchangeRate}) + {messages.exchangeRate(exchangeRate, !!isStablecoin)} )} diff --git a/packages/web/src/components/buy-sell-modal/hooks/index.ts b/packages/web/src/components/buy-sell-modal/hooks/index.ts new file mode 100644 index 00000000000..0701de03d89 --- /dev/null +++ b/packages/web/src/components/buy-sell-modal/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useTokenAmountFormatting' +export * from './useTokenSwapForm' diff --git a/packages/web/src/components/buy-sell-modal/hooks/useTokenAmountFormatting.ts b/packages/web/src/components/buy-sell-modal/hooks/useTokenAmountFormatting.ts new file mode 100644 index 00000000000..66436422413 --- /dev/null +++ b/packages/web/src/components/buy-sell-modal/hooks/useTokenAmountFormatting.ts @@ -0,0 +1,63 @@ +import { useCallback, useMemo } from 'react' + +import { getCurrencyDecimalPlaces } from '@audius/common/utils' + +export type UseTokenAmountFormattingProps = { + amount?: string | number + availableBalance: number + exchangeRate?: number | null + isStablecoin: boolean + placeholder?: string +} + +const defaultDecimalPlaces = 2 + +export const useTokenAmountFormatting = ({ + amount, + availableBalance, + exchangeRate, + isStablecoin, + placeholder = '0.00' +}: UseTokenAmountFormattingProps) => { + const getDisplayDecimalPlaces = useCallback( + (currentExchangeRate: number | null | undefined) => { + if (isStablecoin) return defaultDecimalPlaces + if (currentExchangeRate != null) { + return getCurrencyDecimalPlaces(currentExchangeRate) + } + return defaultDecimalPlaces + }, + [isStablecoin] + ) + + const formattedAvailableBalance = useMemo(() => { + if (isNaN(availableBalance)) return placeholder + + const decimals = getDisplayDecimalPlaces(exchangeRate) + + return availableBalance.toLocaleString('en-US', { + minimumFractionDigits: defaultDecimalPlaces, + maximumFractionDigits: decimals + }) + }, [availableBalance, exchangeRate, getDisplayDecimalPlaces, placeholder]) + + const formattedAmount = useMemo(() => { + if (!amount && amount !== 0) return placeholder + const numericAmount = + typeof amount === 'string' ? parseFloat(amount) : amount + if (isNaN(numericAmount)) return placeholder + + const decimals = getDisplayDecimalPlaces(exchangeRate) + + return numericAmount.toLocaleString('en-US', { + minimumFractionDigits: defaultDecimalPlaces, + maximumFractionDigits: decimals + }) + }, [amount, exchangeRate, getDisplayDecimalPlaces, placeholder]) + + return { + formattedAvailableBalance, + formattedAmount, + getDisplayDecimalPlaces + } +} diff --git a/packages/web/src/components/buy-sell-modal/hooks/useTokenSwapForm.ts b/packages/web/src/components/buy-sell-modal/hooks/useTokenSwapForm.ts index 9062694ff24..ce60549dd4e 100644 --- a/packages/web/src/components/buy-sell-modal/hooks/useTokenSwapForm.ts +++ b/packages/web/src/components/buy-sell-modal/hooks/useTokenSwapForm.ts @@ -1,8 +1,11 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo } from 'react' import { useTokenExchangeRate } from '@audius/common/src/api' import { JupiterTokenSymbol } from '@audius/common/src/services/Jupiter' +import { useFormik } from 'formik' +import { toFormikValidationSchema } from 'zod-formik-adapter' +import { createSwapFormSchema, SwapFormValues } from '../schemas/swapFormSchema' import { TokenInfo } from '../types' export type BalanceConfig = { @@ -57,29 +60,43 @@ export const useTokenSwapForm = ({ const inputTokenSymbol = inputToken.symbol as JupiterTokenSymbol const outputTokenSymbol = outputToken.symbol as JupiterTokenSymbol - // Destructure the balance config for easier access - const { - get: getInputBalance, - loading: isBalanceLoading, - formatError: formatBalanceError - } = balance - const [inputAmount, setInputAmount] = useState('') - const [error, setError] = useState(null) - - // Calculate the numeric value of the input amount - const numericInputAmount = useMemo(() => { - if (!inputAmount) return 0 - const parsed = parseFloat(inputAmount) - return isNaN(parsed) ? 0 : parsed - }, [inputAmount]) + const { get: getInputBalance, loading: isBalanceLoading } = balance - // Get the available balance const availableBalance = useMemo(() => { const balance = getInputBalance() return balance !== undefined ? balance : (inputToken.balance ?? 0) }, [getInputBalance, inputToken.balance]) - // Use Jupiter API to get real-time exchange rate + // Create validation schema + const validationSchema = useMemo(() => { + return toFormikValidationSchema( + createSwapFormSchema(min, max, availableBalance, inputToken.symbol) + ) + }, [min, max, availableBalance, inputToken.symbol]) + + // Initialize form with Formik + const formik = useFormik({ + initialValues: { + inputAmount: '' + }, + validationSchema, + validateOnBlur: true, + validateOnChange: true, + onSubmit: () => { + // The form is never actually submitted - we just use Formik for validation + // and state management + } + }) + + const { values, errors, touched, setFieldValue, setFieldTouched } = formik + + // Calculate the numeric value of the input amount + const numericInputAmount = useMemo(() => { + if (!values.inputAmount) return 0 + const parsed = parseFloat(values.inputAmount) + return isNaN(parsed) ? 0 : parsed + }, [values.inputAmount]) + const { data: exchangeRateData, isLoading: isExchangeRateLoading, @@ -98,80 +115,54 @@ export const useTokenSwapForm = ({ return exchangeRateData.rate * numericInputAmount }, [numericInputAmount, exchangeRateData, isExchangeRateLoading]) - // Validate the input amount - useEffect(() => { - if (numericInputAmount === 0) { - setError(null) - return - } - - if (numericInputAmount < min) { - setError(`Minimum amount is ${min} ${inputToken.symbol}`) - return - } - - if (numericInputAmount > max) { - setError(`Maximum amount is ${max} ${inputToken.symbol}`) - return - } - - // Check if user has enough balance - const balance = getInputBalance() - if (balance !== undefined && numericInputAmount > balance) { - setError(formatBalanceError(numericInputAmount)) - return - } - - setError(null) - }, [ - numericInputAmount, - min, - max, - getInputBalance, - formatBalanceError, - inputToken.symbol - ]) - - // Update the parent component with transaction data useEffect(() => { if (onTransactionDataChange) { + const isValid = + numericInputAmount > 0 && !errors.inputAmount && !isExchangeRateLoading + onTransactionDataChange({ inputAmount: numericInputAmount, outputAmount, - isValid: numericInputAmount > 0 && !error && !isExchangeRateLoading + isValid }) } }, [ numericInputAmount, outputAmount, - error, + errors.inputAmount, isExchangeRateLoading, onTransactionDataChange ]) // Handle input changes - const handleInputAmountChange = useCallback((value: string) => { - // Allow only valid number input with better decimal handling - if (value === '' || /^(\d*\.?\d*|\d+\.)$/.test(value)) { - setInputAmount(value) - } - }, []) + const handleInputAmountChange = useCallback( + (value: string) => { + // Allow only valid number input with better decimal handling + if (value === '' || /^(\d*\.?\d*|\d+\.)$/.test(value)) { + setFieldValue('inputAmount', value, true) + setFieldTouched('inputAmount', true, false) + } + }, + [setFieldValue, setFieldTouched] + ) // Handle max button click const handleMaxClick = useCallback(() => { const balance = getInputBalance() if (balance !== undefined) { - // Limit to MAX_AMOUNT const finalAmount = Math.min(balance, max) - setInputAmount(finalAmount.toString()) + setFieldValue('inputAmount', finalAmount.toString(), true) + setFieldTouched('inputAmount', true, false) } - }, [getInputBalance, max]) + }, [getInputBalance, max, setFieldValue, setFieldTouched]) - // Use the real exchange rate if available, otherwise null (no default fallback) const currentExchangeRate = exchangeRateData ? exchangeRateData.rate : null + const error = + touched.inputAmount && errors.inputAmount ? errors.inputAmount : null + return { - inputAmount, // Raw string input for display + inputAmount: values.inputAmount, // Raw string input for display numericInputAmount, outputAmount, error, @@ -182,7 +173,7 @@ export const useTokenSwapForm = ({ currentExchangeRate, handleInputAmountChange, handleMaxClick, - // Including these for the component that consumes this hook + formik, inputToken, outputToken } diff --git a/packages/web/src/components/buy-sell-modal/schemas/swapFormSchema.ts b/packages/web/src/components/buy-sell-modal/schemas/swapFormSchema.ts new file mode 100644 index 00000000000..56a202f3751 --- /dev/null +++ b/packages/web/src/components/buy-sell-modal/schemas/swapFormSchema.ts @@ -0,0 +1,42 @@ +import { z } from 'zod' + +const messages = { + inputAmountRequired: 'Amount is required', + invalidAmount: 'Please enter a valid amount', + minAmount: (min: number, symbol: string) => + `Minimum amount is ${min} ${symbol}`, + maxAmount: (max: number, symbol: string) => + `Maximum amount is ${max} ${symbol}`, + insufficientBalance: (symbol: string) => `Insufficient ${symbol} balance` +} + +/** + * Schema for validating token swap input + */ +export const createSwapFormSchema = ( + min: number = 0, + max: number = Number.MAX_SAFE_INTEGER, + balance: number | undefined = undefined, + tokenSymbol: string = '' +) => + z.object({ + inputAmount: z + .string() + .min(1, { message: messages.inputAmountRequired }) + .refine((val) => !isNaN(parseFloat(val)), { + message: messages.invalidAmount + }) + .refine((val) => parseFloat(val) >= min, { + message: messages.minAmount(min, tokenSymbol) + }) + .refine((val) => parseFloat(val) <= max, { + message: messages.maxAmount(max, tokenSymbol) + }) + .refine((val) => !balance || parseFloat(val) <= balance, { + message: messages.insufficientBalance(tokenSymbol) + }) + }) + +export type SwapFormValues = { + inputAmount: string +} From 02216c0c5867590aa0f72deff50f84c75e62f13c Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Thu, 8 May 2025 12:12:13 -0500 Subject: [PATCH 16/22] Add Jupiter logo --- .../harmony/src/assets/icons/JupiterLogo.svg | 51 +++++++++++++++++++ packages/harmony/src/icons/utilityIcons.ts | 2 + packages/mobile/src/harmony-native/icons.tsx | 1 + .../buy-sell-modal/BuySellModal.tsx | 2 + 4 files changed, 56 insertions(+) create mode 100644 packages/harmony/src/assets/icons/JupiterLogo.svg diff --git a/packages/harmony/src/assets/icons/JupiterLogo.svg b/packages/harmony/src/assets/icons/JupiterLogo.svg new file mode 100644 index 00000000000..9bf17022e51 --- /dev/null +++ b/packages/harmony/src/assets/icons/JupiterLogo.svg @@ -0,0 +1,51 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/harmony/src/icons/utilityIcons.ts b/packages/harmony/src/icons/utilityIcons.ts index 0c4a0576267..ecede050716 100644 --- a/packages/harmony/src/icons/utilityIcons.ts +++ b/packages/harmony/src/icons/utilityIcons.ts @@ -56,6 +56,7 @@ import IconHeartSVG from '../assets/icons/Heart.svg' import IconImageSVG from '../assets/icons/Image.svg' import IconIndentSVG from '../assets/icons/Indent.svg' import IconInfoSVG from '../assets/icons/Info.svg' +import IconJupiterLogoSVG from '../assets/icons/JupiterLogo.svg' import IconKebabHorizontalSVG from '../assets/icons/KebabHorizontal.svg' import IconKeySVG from '../assets/icons/Key.svg' import IconLibrarySVG from '../assets/icons/Library.svg' @@ -313,3 +314,4 @@ export const IconSupport = IconSupportSVG as IconComponent export const IconPlaybackPause = IconPlaybackPauseSVG as IconComponent export const IconPlaybackPlay = IconPlaybackPlaySVG as IconComponent export const IconQrCode = IconQrCodeSVG as IconComponent +export const IconJupiterLogo = IconJupiterLogoSVG as IconComponent diff --git a/packages/mobile/src/harmony-native/icons.tsx b/packages/mobile/src/harmony-native/icons.tsx index 54e984cfc46..4f874d48615 100644 --- a/packages/mobile/src/harmony-native/icons.tsx +++ b/packages/mobile/src/harmony-native/icons.tsx @@ -162,6 +162,7 @@ export { default as IconMoneyBracket } from '@audius/harmony/src/assets/icons/Mo export { default as IconPin } from '@audius/harmony/src/assets/icons/Pin.svg' export { default as IconPaperAirplane } from '@audius/harmony/src/assets/icons/PaperAirplane.svg' export { default as IconArrowUpToLine } from '@audius/harmony/src/assets/icons/ArrowUpToLine.svg' +export { default as IconJupiterLogo } from '@audius/harmony/src/assets/icons/JupiterLogo.svg' // Two Tone / Special Styling diff --git a/packages/web/src/components/buy-sell-modal/BuySellModal.tsx b/packages/web/src/components/buy-sell-modal/BuySellModal.tsx index 9c63ef95dcc..25184a8b281 100644 --- a/packages/web/src/components/buy-sell-modal/BuySellModal.tsx +++ b/packages/web/src/components/buy-sell-modal/BuySellModal.tsx @@ -8,6 +8,7 @@ import { Button, Flex, Hint, + IconJupiterLogo, Modal, ModalContent, ModalFooter, @@ -198,6 +199,7 @@ export const BuySellModal = () => { {messages.poweredBy} +
) From f4a32024873a92f77d0b3df9011dff4528cb8de2 Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Thu, 8 May 2025 12:21:53 -0500 Subject: [PATCH 17/22] Address comments --- .../tan-query/jupiter/useTokenExchangeRate.ts | 21 ++++-------- packages/common/src/services/Jupiter.ts | 32 ++++++++++--------- .../src/components/buy-sell-modal/SwapTab.tsx | 13 +++++--- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/packages/common/src/api/tan-query/jupiter/useTokenExchangeRate.ts b/packages/common/src/api/tan-query/jupiter/useTokenExchangeRate.ts index 56911aa5f45..2a777e40981 100644 --- a/packages/common/src/api/tan-query/jupiter/useTokenExchangeRate.ts +++ b/packages/common/src/api/tan-query/jupiter/useTokenExchangeRate.ts @@ -1,6 +1,4 @@ -import { useMemo } from 'react' - -import { QuoteResponse } from '@jup-ag/api' +import { QuoteResponse, SwapMode } from '@jup-ag/api' import { useQuery } from '@tanstack/react-query' import { JupiterTokenSymbol, getJupiterQuoteByMint } from '~/services/Jupiter' @@ -12,7 +10,7 @@ export type TokenExchangeRateParams = { inputTokenSymbol: JupiterTokenSymbol outputTokenSymbol: JupiterTokenSymbol inputAmount?: number - swapMode?: 'ExactIn' | 'ExactOut' + swapMode?: SwapMode } export type TokenExchangeRateResponse = { @@ -29,6 +27,9 @@ export type TokenExchangeRateResponse = { quote: QuoteResponse } +// Default slippage is 50 basis points (0.5%) +const SLIPPAGE_BPS = 50 + // Define exchange rate query key export const getTokenExchangeRateQueryKey = ({ inputTokenSymbol, @@ -58,13 +59,6 @@ export const useTokenExchangeRate = ( // Default to 1 unit of input token if no amount specified const inputAmount = params.inputAmount ?? 1 - // Get appropriate slippage value based on swap direction - const slippageBps = useMemo(() => { - // Default slippage is 50 basis points (0.5%) - // We're not using remote config for now to avoid dependency issues - return 50 - }, []) - return useQuery({ queryKey: getTokenExchangeRateQueryKey({ inputTokenSymbol: params.inputTokenSymbol, @@ -89,7 +83,7 @@ export const useTokenExchangeRate = ( inputMint: inputToken.address, outputMint: outputToken.address, amountUi: inputAmount, - slippageBps, + slippageBps: SLIPPAGE_BPS, swapMode: params.swapMode ?? 'ExactIn', onlyDirectRoutes: false }) @@ -116,7 +110,6 @@ export const useTokenExchangeRate = ( throw error } }, - ...options, - enabled: options?.enabled !== false + ...options }) } diff --git a/packages/common/src/services/Jupiter.ts b/packages/common/src/services/Jupiter.ts index f014873d1ff..25cd24bff78 100644 --- a/packages/common/src/services/Jupiter.ts +++ b/packages/common/src/services/Jupiter.ts @@ -7,7 +7,7 @@ import { import { PublicKey, TransactionInstruction } from '@solana/web3.js' import { TOKEN_LISTING_MAP } from '~/store/ui/buy-audio/constants' -import { convertBigIntToAmountObject } from '~/utils' +import { convertBigIntToAmountObject, removeNullable } from '~/utils' /** * The error that gets returned if the slippage is exceeded @@ -39,6 +39,15 @@ const getInstance = () => { export const jupiterInstance = getInstance() +/** + * Helper function to find a token by its mint address + */ +const findTokenByMint = (mintAddress: string) => { + return Object.values(TOKEN_LISTING_MAP).find( + (token) => token.address === mintAddress + ) +} + export type JupiterQuoteParams = { inputTokenSymbol: JupiterTokenSymbol outputTokenSymbol: JupiterTokenSymbol @@ -80,6 +89,8 @@ export type JupiterQuoteResult = { quote: QuoteResponse } +const DEFAULT_DECIMALS = 9 + /** * Gets a quote from Jupiter using mint addresses directly * This version is used by the useSwapTokens hook @@ -92,19 +103,12 @@ export const getJupiterQuoteByMint = async ({ swapMode = 'ExactIn', onlyDirectRoutes = false }: JupiterMintQuoteParams): Promise => { - // Get quote from Jupiter - // Look up token decimals from TOKEN_LISTING_MAP - // We'll find tokens by their address to get the correct decimals - const inputToken = Object.values(TOKEN_LISTING_MAP).find( - (token) => token.address === inputMint - ) - const outputToken = Object.values(TOKEN_LISTING_MAP).find( - (token) => token.address === outputMint - ) + const inputToken = findTokenByMint(inputMint) + const outputToken = findTokenByMint(outputMint) // Default to 9 decimals if tokens aren't found (fallback for safety) - const inputDecimals = inputToken?.decimals ?? 9 - const outputDecimals = outputToken?.decimals ?? 9 + const inputDecimals = inputToken?.decimals ?? DEFAULT_DECIMALS + const outputDecimals = outputToken?.decimals ?? DEFAULT_DECIMALS const amount = swapMode === 'ExactIn' @@ -149,9 +153,7 @@ export const convertJupiterInstructions = ( instructions: (Instruction | undefined)[] ): TransactionInstruction[] => { // Flatten and filter out undefined instructions - const filteredInstructions = instructions.filter( - (i): i is Instruction => i !== undefined - ) + const filteredInstructions = instructions.filter(removeNullable) // Convert to Solana TransactionInstruction format return filteredInstructions.map((i) => { diff --git a/packages/web/src/components/buy-sell-modal/SwapTab.tsx b/packages/web/src/components/buy-sell-modal/SwapTab.tsx index d3116946777..abc170fd597 100644 --- a/packages/web/src/components/buy-sell-modal/SwapTab.tsx +++ b/packages/web/src/components/buy-sell-modal/SwapTab.tsx @@ -7,6 +7,12 @@ import { TokenAmountSection } from './TokenAmountSection' import { useTokenSwapForm } from './hooks/useTokenSwapForm' import { TokenInfo } from './types' +const messages = { + youPay: 'You Pay', + youReceive: 'You Receive', + placeholder: '0.00' +} + const TokenSectionSkeleton = ({ title }: { title: string }) => ( @@ -53,7 +59,6 @@ export const SwapTab = ({ balance, onTransactionDataChange }: SwapTabProps) => { - // Use the shared hook for form logic const { formik, inputAmount, @@ -95,18 +100,18 @@ export const SwapTab = ({ ) : ( <> Date: Thu, 8 May 2025 12:41:20 -0500 Subject: [PATCH 18/22] Add header --- .../components/YourCoins.tsx | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/web/src/pages/pay-and-earn-page/components/YourCoins.tsx b/packages/web/src/pages/pay-and-earn-page/components/YourCoins.tsx index 12252b81350..5d5aa59d5c6 100644 --- a/packages/web/src/pages/pay-and-earn-page/components/YourCoins.tsx +++ b/packages/web/src/pages/pay-and-earn-page/components/YourCoins.tsx @@ -1,15 +1,17 @@ import { useCallback } from 'react' -import { useFormattedAudioBalance } from '@audius/common/hooks' +import { useFeatureFlag, useFormattedAudioBalance } from '@audius/common/hooks' +import { FeatureFlags } from '@audius/common/services' import { route } from '@audius/common/utils' import { + Button, Flex, IconCaretRight, IconTokenAUDIO, Paper, Text, - useTheme, - useMedia + useMedia, + useTheme } from '@audius/harmony' import { useDispatch } from 'react-redux' import { push } from 'redux-first-history' @@ -18,13 +20,38 @@ const DIMENSIONS = 64 const { WALLET_AUDIO_PAGE } = route const messages = { - audio: '$AUDIO' + audio: '$AUDIO', + yourCoins: 'Your Coins', + buySell: 'Buy/sell' +} + +const TokensHeader = () => { + const { color } = useTheme() + + return ( + + + {messages.yourCoins} + + + + ) } export const YourCoins = () => { const dispatch = useDispatch() const { color, spacing, cornerRadius } = useTheme() const { isMobile, isExtraSmall } = useMedia() + const { isEnabled: isWalletUIBuySellEnabled } = useFeatureFlag( + FeatureFlags.WALLET_UI_BUY_SELL + ) const { audioBalanceFormatted, @@ -46,6 +73,7 @@ export const YourCoins = () => { borderRadius='l' css={{ overflow: 'hidden' }} > + {isWalletUIBuySellEnabled ? : null} Date: Thu, 8 May 2025 12:44:16 -0500 Subject: [PATCH 19/22] Refactor --- .../web/src/pages/pay-and-earn-page/components/YourCoins.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web/src/pages/pay-and-earn-page/components/YourCoins.tsx b/packages/web/src/pages/pay-and-earn-page/components/YourCoins.tsx index 5d5aa59d5c6..2d07c73f293 100644 --- a/packages/web/src/pages/pay-and-earn-page/components/YourCoins.tsx +++ b/packages/web/src/pages/pay-and-earn-page/components/YourCoins.tsx @@ -47,7 +47,7 @@ const TokensHeader = () => { export const YourCoins = () => { const dispatch = useDispatch() - const { color, spacing, cornerRadius } = useTheme() + const { color, spacing, cornerRadius, motion } = useTheme() const { isMobile, isExtraSmall } = useMedia() const { isEnabled: isWalletUIBuySellEnabled } = useFeatureFlag( FeatureFlags.WALLET_UI_BUY_SELL @@ -100,7 +100,7 @@ export const YourCoins = () => { gap='xs' css={{ opacity: isLoading ? 0 : 1, - transition: 'opacity 0.3s ease' + transition: `opacity ${motion.expressive}` }} > From 420f78cd2c44b1457da9dcd89d348a03a19370f1 Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Thu, 8 May 2025 12:46:33 -0500 Subject: [PATCH 20/22] Add modal open --- .../components/CashWallet.tsx | 19 +------------------ .../components/YourCoins.tsx | 8 +++++++- 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/packages/web/src/pages/pay-and-earn-page/components/CashWallet.tsx b/packages/web/src/pages/pay-and-earn-page/components/CashWallet.tsx index 09f5661de7d..e2361b0ab77 100644 --- a/packages/web/src/pages/pay-and-earn-page/components/CashWallet.tsx +++ b/packages/web/src/pages/pay-and-earn-page/components/CashWallet.tsx @@ -10,8 +10,7 @@ import { TRANSACTION_HISTORY_PAGE } from '@audius/common/src/utils/route' import { WithdrawUSDCModalPages, useWithdrawUSDCModal, - useAddFundsModal, - useBuySellModal + useAddFundsModal } from '@audius/common/store' import { Button, @@ -38,7 +37,6 @@ export const CashWallet = () => { const isManagedAccount = useIsManagedAccount() const { onOpen: openWithdrawUSDCModal } = useWithdrawUSDCModal() const { onOpen: openAddFundsModal } = useAddFundsModal() - const { onOpen: openBuySellModal } = useBuySellModal() const { balanceFormatted, usdcValue, isLoading } = useFormattedUSDCBalance() const [, setPayoutWalletModalOpen] = useModalState('PayoutWallet') @@ -68,11 +66,6 @@ export const CashWallet = () => { ) } - const handleBuySell = () => { - openBuySellModal() - // TODO: Add analytics tracking if needed - } - const handlePayoutWalletClick = useCallback(() => { setPayoutWalletModalOpen(true) }, [setPayoutWalletModalOpen]) @@ -188,16 +181,6 @@ export const CashWallet = () => { > {walletMessages.addFunds} - ) : null} diff --git a/packages/web/src/pages/pay-and-earn-page/components/YourCoins.tsx b/packages/web/src/pages/pay-and-earn-page/components/YourCoins.tsx index 2d07c73f293..fb31d9876b1 100644 --- a/packages/web/src/pages/pay-and-earn-page/components/YourCoins.tsx +++ b/packages/web/src/pages/pay-and-earn-page/components/YourCoins.tsx @@ -2,6 +2,7 @@ import { useCallback } from 'react' import { useFeatureFlag, useFormattedAudioBalance } from '@audius/common/hooks' import { FeatureFlags } from '@audius/common/services' +import { useBuySellModal } from '@audius/common/store' import { route } from '@audius/common/utils' import { Button, @@ -27,6 +28,11 @@ const messages = { const TokensHeader = () => { const { color } = useTheme() + const { onOpen: openBuySellModal } = useBuySellModal() + + const handleBuySellClick = useCallback(() => { + openBuySellModal() + }, [openBuySellModal]) return ( { {messages.yourCoins} - From e689ddb713ec46750626202c9bff869dca8d5bdc Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Thu, 8 May 2025 13:20:40 -0500 Subject: [PATCH 21/22] Address comments --- packages/common/src/messages/buySell.ts | 6 ++- .../buy-sell-modal/BuySellModal.tsx | 43 ++++++------------- 2 files changed, 17 insertions(+), 32 deletions(-) diff --git a/packages/common/src/messages/buySell.ts b/packages/common/src/messages/buySell.ts index 46017e24023..85b38a7fe3c 100644 --- a/packages/common/src/messages/buySell.ts +++ b/packages/common/src/messages/buySell.ts @@ -14,5 +14,9 @@ export const buySellMessages = { poweredBy: 'POWERED BY', helpCenter: 'Check out our help center for more info!', walletGuide: 'Wallet Guide', - selectPair: 'Select Token Pair' + selectPair: 'Select Token Pair', + buySuccess: 'Successfully purchased AUDIO!', + sellSuccess: 'Successfully sold AUDIO!', + transactionSuccess: 'Transaction successful!', + transactionFailed: 'Transaction failed. Please try again.' } diff --git a/packages/web/src/components/buy-sell-modal/BuySellModal.tsx b/packages/web/src/components/buy-sell-modal/BuySellModal.tsx index 25184a8b281..9eb15de5139 100644 --- a/packages/web/src/components/buy-sell-modal/BuySellModal.tsx +++ b/packages/web/src/components/buy-sell-modal/BuySellModal.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState, useContext } from 'react' +import { useCallback, useContext, useEffect, useState } from 'react' import { useSwapTokens } from '@audius/common/api' import { buySellMessages as messages } from '@audius/common/messages' @@ -20,7 +20,6 @@ import { } from '@audius/harmony' import { useTheme } from '@emotion/react' -import LoadingSpinner from 'components/loading-spinner/LoadingSpinner' import { ToastContext } from 'components/toast/ToastContext' import { BuyTab } from './BuyTab' @@ -28,18 +27,18 @@ import { SellTab } from './SellTab' import { SUPPORTED_TOKEN_PAIRS } from './constants' import { BuySellTab } from './types' -// import { useIsMobile } from 'hooks/useIsMobile' // Keep for potential mobile-specific adjustments - Removing for now - type TabOption = { key: BuySellTab text: string } +// Default slippage is 50 basis points (0.5%) +const DEFAULT_SLIPPAGE_BPS = 50 + export const BuySellModal = () => { const { isOpen, onClose } = useBuySellModal() const { spacing, color } = useTheme() const { toast } = useContext(ToastContext) - // const isMobile = useIsMobile() // Keep for potential mobile-specific adjustments - Removing for now const [activeTab, setActiveTab] = useState('buy') // selectedPairIndex will be used in future when multiple token pairs are supported const [selectedPairIndex] = useState(0) @@ -71,22 +70,12 @@ export const BuySellModal = () => { setTransactionData(null) }, []) - const handleTransactionDataChange = useCallback( - (data: { inputAmount: number; outputAmount: number; isValid: boolean }) => { - setTransactionData(data) - }, - [] - ) - // Handle continue button click const handleContinueClick = useCallback(() => { if (!transactionData || !transactionData.isValid) return const { inputAmount } = transactionData - // Default slippage is 50 basis points (0.5%) - const slippageBps = 50 - // Determine swap direction based on active tab if (activeTab === 'buy') { // Buy AUDIO with USDC @@ -94,7 +83,7 @@ export const BuySellModal = () => { inputMint: TOKEN_LISTING_MAP.USDC.address, outputMint: TOKEN_LISTING_MAP.AUDIO.address, amountUi: inputAmount, - slippageBps + slippageBps: DEFAULT_SLIPPAGE_BPS }) } else { // Sell AUDIO for USDC @@ -102,7 +91,7 @@ export const BuySellModal = () => { inputMint: TOKEN_LISTING_MAP.AUDIO.address, outputMint: TOKEN_LISTING_MAP.USDC.address, amountUi: inputAmount, - slippageBps + slippageBps: DEFAULT_SLIPPAGE_BPS }) } }, [activeTab, transactionData, swapTokens]) @@ -111,9 +100,7 @@ export const BuySellModal = () => { useEffect(() => { if (swapStatus === 'success') { toast( - activeTab === 'buy' - ? 'Successfully purchased AUDIO!' - : 'Successfully sold AUDIO!', + activeTab === 'buy' ? messages.buySuccess : messages.sellSuccess, 3000 ) @@ -124,7 +111,7 @@ export const BuySellModal = () => { return () => clearTimeout(timer) } else if (swapStatus === 'error') { - toast(swapError?.message || 'Transaction failed. Please try again.', 5000) + toast(swapError?.message || messages.transactionFailed, 5000) } }, [swapStatus, swapError, activeTab, onClose, toast]) @@ -152,12 +139,12 @@ export const BuySellModal = () => { {activeTab === 'buy' ? ( ) : ( )} @@ -175,16 +162,10 @@ export const BuySellModal = () => { variant='primary' fullWidth disabled={isContinueButtonDisabled} + isLoading={isContinueButtonLoading} onClick={handleContinueClick} > - {isContinueButtonLoading ? ( - - - Processing... - - ) : ( - messages.continue - )} + {messages.continue} From 06f79ddcfe42425c00253dc80f6f8fb9d7f5b324 Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Fri, 9 May 2025 15:28:11 -0500 Subject: [PATCH 22/22] Address comments --- .../api/tan-query/jupiter/useSwapTokens.ts | 272 ++++-------------- .../common/src/api/tan-query/jupiter/utils.ts | 127 ++++---- 2 files changed, 115 insertions(+), 284 deletions(-) diff --git a/packages/common/src/api/tan-query/jupiter/useSwapTokens.ts b/packages/common/src/api/tan-query/jupiter/useSwapTokens.ts index 001b896bc5c..9b2e35c9d03 100644 --- a/packages/common/src/api/tan-query/jupiter/useSwapTokens.ts +++ b/packages/common/src/api/tan-query/jupiter/useSwapTokens.ts @@ -24,13 +24,7 @@ import { SwapTokensParams, SwapTokensResult } from './types' -import { - addAtaToUserBankInstructions, - addUserBankToAtaInstructions, - findActualJupiterDestination, - findJupiterTemporarySetupAta, - updateJupiterAtaCreationFeePayer -} from './utils' +import { addUserBankToAtaInstructions, getSwapErrorResponse } from './utils' /** * Hook for executing token swaps using Jupiter. @@ -54,10 +48,12 @@ export const useSwapTokens = () => { let quoteResult let signature: string | undefined + let errorStage = 'UNKNOWN' const instructions: TransactionInstruction[] = [] try { // ---------- 1. Initialize Dependencies & Wallet ---------- + errorStage = 'WALLET_INITIALIZATION' const [sdk, keypair] = await Promise.all([ audiusSdk(), solanaWalletService.getKeypair() @@ -73,34 +69,19 @@ export const useSwapTokens = () => { } } const userPublicKey = keypair.publicKey - const feePayer = await sdk.services.solanaRelay.getFeePayer() + const feePayer = await sdk.services.solanaClient.getFeePayer() const ethAddress = user?.wallet // ---------- 2. Get Quote from Jupiter ---------- - try { - quoteResult = await getJupiterQuoteByMint({ - inputMint: inputMintUiAddress, - outputMint: outputMintUiAddress, - amountUi, - slippageBps, - swapMode: 'ExactIn', - onlyDirectRoutes: true - }) - } catch (error: any) { - reportToSentry({ - name: 'JupiterSwapQuoteError', - error, - feature: Feature.TanQuery, - additionalInfo: { params } - }) - return { - status: SwapStatus.ERROR, - error: { - type: SwapErrorType.QUOTE_FAILED, - message: error?.message ?? 'Failed to get swap quote' - } - } - } + errorStage = 'QUOTE_RETRIEVAL' + quoteResult = await getJupiterQuoteByMint({ + inputMint: inputMintUiAddress, + outputMint: outputMintUiAddress, + amountUi, + slippageBps, + swapMode: 'ExactIn', + onlyDirectRoutes: true + }) // ---------- 3. Prepare Transaction Instructions ---------- const inputTokenConfig = @@ -108,74 +89,32 @@ export const useSwapTokens = () => { const outputTokenConfig = USER_BANK_MANAGED_TOKENS[outputMintUiAddress.toUpperCase()] - let sourceAtaForJupiter: PublicKey | undefined - let isSourceAtaTemporary = false - // --- 3a. Handle Input Token (if user bank managed) --- - const isInputUserBankManaged = !!( - inputTokenConfig && - ethAddress && - inputMintUiAddress.toUpperCase() !== 'SOL' - ) - if (isInputUserBankManaged) { - try { - sourceAtaForJupiter = await addUserBankToAtaInstructions({ - tokenInfo: inputTokenConfig!, - userPublicKey, - ethAddress: ethAddress!, - amountLamports: BigInt(quoteResult.inputAmount.amount), - sdk, - feePayer, - instructions - }) - isSourceAtaTemporary = true - } catch (error: any) { - reportToSentry({ - name: 'JupiterSwapInputPrepError', - error, - feature: Feature.TanQuery, - additionalInfo: { params } - }) - return { - status: SwapStatus.ERROR, - error: { - type: SwapErrorType.BUILD_FAILED, - message: `Failed to prepare input token ${inputTokenConfig!.claimableTokenMint}: ${error.message}` - }, - inputAmount: quoteResult.inputAmount, - outputAmount: quoteResult.outputAmount - } - } - } + errorStage = 'INPUT_TOKEN_PREPARATION' + + const sourceAtaForJupiter = await addUserBankToAtaInstructions({ + tokenInfo: inputTokenConfig!, + userPublicKey, + ethAddress: ethAddress!, + amountLamports: BigInt(quoteResult.inputAmount.amount), + sdk, + feePayer, + instructions + }) // --- 3b. Determine Jupiter's Destination Token Account --- - let preferredJupiterDestination: string | undefined - let outputUserBankAddress: PublicKey | undefined + errorStage = 'OUTPUT_TOKEN_PREPARATION' - const isOutputUserBankManaged = !!( - outputTokenConfig && - ethAddress && - outputMintUiAddress.toUpperCase() !== 'SOL' - ) - if (isOutputUserBankManaged) { - try { - outputUserBankAddress = - await sdk.services.claimableTokensClient.deriveUserBank({ - ethWallet: ethAddress!, - mint: outputTokenConfig!.claimableTokenMint - }) - preferredJupiterDestination = outputUserBankAddress.toBase58() - } catch (error: any) { - reportToSentry({ - name: 'JupiterSwapOutputUserBankDerivationError', - error, - feature: Feature.TanQuery, - additionalInfo: { params } - }) - } - } + const result = + await sdk.services.claimableTokensClient.getOrCreateUserBank({ + ethWallet: ethAddress!, + mint: outputTokenConfig!.claimableTokenMint + }) + const outputUserBankAddress = result.userBank + const preferredJupiterDestination = outputUserBankAddress.toBase58() // --- 3c. Get Jupiter Swap Instructions --- + errorStage = 'SWAP_INSTRUCTION_RETRIEVAL' const { tokenLedgerInstruction, swapInstruction, @@ -195,84 +134,16 @@ export const useSwapTokens = () => { swapInstruction ]) - updateJupiterAtaCreationFeePayer( - jupiterInstructions, - userPublicKey, - feePayer - ) instructions.push(...jupiterInstructions) - // --- 3d. Identify Actual Jupiter Destination & Potential Jupiter-Created Temporary ATA --- - const actualJupiterDestination = - findActualJupiterDestination(jupiterInstructions) - const jupiterTemporarySetupAta = outputTokenConfig - ? findJupiterTemporarySetupAta( - jupiterInstructions, - outputTokenConfig.mintAddress, - actualJupiterDestination - ) - : undefined - - // --- 3e. Handle Output Token (if user bank managed and not directly deposited by Jupiter) --- - let wasOutputDepositedToUserBank = false - if ( - outputUserBankAddress && - actualJupiterDestination?.equals(outputUserBankAddress) - ) { - wasOutputDepositedToUserBank = true - } - - const needsManualTransferToOutputUserBank = - isOutputUserBankManaged && - actualJupiterDestination && - !wasOutputDepositedToUserBank - - if (needsManualTransferToOutputUserBank) { - try { - await addAtaToUserBankInstructions({ - tokenInfo: outputTokenConfig!, - userPublicKey, - ethAddress: ethAddress!, - amountLamports: BigInt(quoteResult.outputAmount.amount), - sourceAta: actualJupiterDestination!, - sdk, - feePayer, - instructions - }) - wasOutputDepositedToUserBank = true - } catch (error: any) { - reportToSentry({ - name: 'JupiterSwapOutputPostProcessError', - error, - feature: Feature.TanQuery, - additionalInfo: { params } - }) - } - } - // --- 3f. Add Cleanup Instructions for Temporary ATAs --- const atasToClose: PublicKey[] = [] // Add source ATA if it's temporary - if (sourceAtaForJupiter && isSourceAtaTemporary) { + if (sourceAtaForJupiter) { atasToClose.push(sourceAtaForJupiter) } - // Determine if Jupiter setup ATA should be closed - const shouldCloseJupiterSetupAta = - jupiterTemporarySetupAta && - (!actualJupiterDestination || - !jupiterTemporarySetupAta.equals(actualJupiterDestination)) && - !( - needsManualTransferToOutputUserBank && - actualJupiterDestination && - jupiterTemporarySetupAta.equals(actualJupiterDestination) - ) - - if (shouldCloseJupiterSetupAta) { - atasToClose.push(jupiterTemporarySetupAta!) - } - // Add close account instructions for all ATAs that need to be closed for (const ataToClose of atasToClose) { instructions.push( @@ -281,60 +152,21 @@ export const useSwapTokens = () => { } // ---------- 4. Build and Sign Transaction ---------- - let swapTx: VersionedTransaction - try { - swapTx = await sdk.services.solanaClient.buildTransaction({ + errorStage = 'TRANSACTION_BUILD' + const swapTx: VersionedTransaction = + await sdk.services.solanaClient.buildTransaction({ feePayer, instructions, addressLookupTables: addressLookupTableAddresses.map( (addr: string) => new PublicKey(addr) ) }) - } catch (error: any) { - reportToSentry({ - name: 'JupiterSwapBuildError', - error, - feature: Feature.TanQuery, - additionalInfo: { params, quoteResponse: quoteResult.quote } - }) - return { - status: SwapStatus.ERROR, - error: { - type: SwapErrorType.BUILD_FAILED, - message: error?.message ?? 'Failed to build transaction' - }, - inputAmount: quoteResult.inputAmount, - outputAmount: quoteResult.outputAmount - } - } swapTx.sign([keypair]) // ---------- 5. Send Transaction ---------- - try { - signature = await sdk.services.solanaClient.sendTransaction(swapTx, { - skipPreflight: true - }) - } catch (error: any) { - reportToSentry({ - name: 'JupiterSwapRelayError', - error, - feature: Feature.TanQuery, - additionalInfo: { - params, - quoteResponse: quoteResult.quote - } - }) - return { - status: SwapStatus.ERROR, - error: { - type: SwapErrorType.RELAY_FAILED, - message: error?.message ?? 'Failed to relay transaction' - }, - inputAmount: quoteResult.inputAmount, - outputAmount: quoteResult.outputAmount - } - } + errorStage = 'TRANSACTION_RELAY' + signature = await sdk.services.solanaClient.sendTransaction(swapTx) // ---------- 6. Success & Invalidation ---------- if (user?.wallet) { @@ -352,20 +184,24 @@ export const useSwapTokens = () => { inputAmount: quoteResult.inputAmount, outputAmount: quoteResult.outputAmount } - } catch (error: any) { + } catch (error: unknown) { reportToSentry({ - name: 'JupiterSwapUnknownError', - error, + name: `JupiterSwap${errorStage}Error`, + error: error as Error, feature: Feature.TanQuery, - additionalInfo: { params, signature } - }) - return { - status: SwapStatus.ERROR, - error: { - type: SwapErrorType.UNKNOWN, - message: error?.message ?? 'An unknown error occurred' + additionalInfo: { + params, + signature, + quoteResponse: quoteResult?.quote } - } + }) + + return getSwapErrorResponse({ + errorStage, + error: error as Error, + inputAmount: quoteResult?.inputAmount, + outputAmount: quoteResult?.outputAmount + }) } }, onMutate: () => { diff --git a/packages/common/src/api/tan-query/jupiter/utils.ts b/packages/common/src/api/tan-query/jupiter/utils.ts index a6a604d9e9e..0597edfad6b 100644 --- a/packages/common/src/api/tan-query/jupiter/utils.ts +++ b/packages/common/src/api/tan-query/jupiter/utils.ts @@ -1,6 +1,5 @@ import { AudiusSdk } from '@audius/sdk' import { - ASSOCIATED_TOKEN_PROGRAM_ID, createAssociatedTokenAccountIdempotentInstruction, createCloseAccountInstruction, createTransferCheckedInstruction, @@ -9,8 +8,7 @@ import { } from '@solana/spl-token' import { PublicKey, TransactionInstruction } from '@solana/web3.js' -import { JUPITER_PROGRAM_ID } from './constants' -import { UserBankManagedTokenInfo } from './types' +import { SwapErrorType, SwapStatus, UserBankManagedTokenInfo } from './types' export async function addUserBankToAtaInstructions({ tokenInfo, @@ -104,71 +102,68 @@ export async function addAtaToUserBankInstructions({ return userBankAddress } -export function updateJupiterAtaCreationFeePayer( - jupiterInstructions: TransactionInstruction[], - userPublicKey: PublicKey, - relayFeePayer: PublicKey -): void { - for (const ix of jupiterInstructions) { - const isAssociatedTokenProgram = ix.programId.equals( - ASSOCIATED_TOKEN_PROGRAM_ID - ) - const isPayerTheUser = - ix.keys.length > 0 && - ix.keys[0].pubkey.equals(userPublicKey) && - ix.keys[0].isSigner && - ix.keys[0].isWritable - - if (isAssociatedTokenProgram && isPayerTheUser) { - ix.keys[0].pubkey = relayFeePayer - break - } +/** + * Get the appropriate error response for a swap error based on the error stage. + */ +export function getSwapErrorResponse(params: { + errorStage: string + error: Error + inputAmount?: { + amount: number + uiAmount: number } -} + outputAmount?: { + amount: number + uiAmount: number + } +}) { + const { errorStage, error, inputAmount, outputAmount } = params -export function findActualJupiterDestination( - jupiterInstructions: TransactionInstruction[] -): PublicKey | undefined { - const jupiterSwapInstruction = jupiterInstructions.find((ix) => - ix.programId.equals(JUPITER_PROGRAM_ID) - ) - if (jupiterSwapInstruction) { - const destinationKeyIndex = 6 - if (jupiterSwapInstruction.keys.length > destinationKeyIndex) { - return jupiterSwapInstruction.keys[destinationKeyIndex].pubkey + if (errorStage === 'QUOTE_RETRIEVAL') { + return { + status: SwapStatus.ERROR, + error: { + type: SwapErrorType.QUOTE_FAILED, + message: error?.message ?? 'Failed to get swap quote' + } + } + } else if (errorStage === 'INPUT_TOKEN_PREPARATION') { + return { + status: SwapStatus.ERROR, + error: { + type: SwapErrorType.BUILD_FAILED, + message: `Failed to prepare input token: ${error.message}` + }, + inputAmount, + outputAmount + } + } else if (errorStage === 'TRANSACTION_BUILD') { + return { + status: SwapStatus.ERROR, + error: { + type: SwapErrorType.BUILD_FAILED, + message: error?.message ?? 'Failed to build transaction' + }, + inputAmount, + outputAmount + } + } else if (errorStage === 'TRANSACTION_RELAY') { + return { + status: SwapStatus.ERROR, + error: { + type: SwapErrorType.RELAY_FAILED, + message: error?.message ?? 'Failed to relay transaction' + }, + inputAmount, + outputAmount + } + } else { + return { + status: SwapStatus.ERROR, + error: { + type: SwapErrorType.UNKNOWN, + message: error?.message ?? 'An unknown error occurred' + } } } - return undefined -} - -export function findJupiterTemporarySetupAta( - jupiterInstructions: TransactionInstruction[], - outputMintAddress: string, - actualFinalJupiterDestination?: PublicKey -): PublicKey | undefined { - const createAtaInstruction = jupiterInstructions.find((ix) => { - const isAssociatedTokenProgram = ix.programId.equals( - ASSOCIATED_TOKEN_PROGRAM_ID - ) - const isForOutputMint = - ix.keys.length >= 4 && - ix.keys[3].pubkey.toBase58().toUpperCase() === - outputMintAddress.toUpperCase() - const createdAtaAddress = - ix.keys.length >= 2 ? ix.keys[1].pubkey : undefined - - const isNotTheFinalDestination = - !actualFinalJupiterDestination || - (createdAtaAddress && - !createdAtaAddress.equals(actualFinalJupiterDestination)) - - return ( - isAssociatedTokenProgram && - isForOutputMint && - isNotTheFinalDestination && - createdAtaAddress - ) - }) - - return createAtaInstruction ? createAtaInstruction.keys[1].pubkey : undefined }