diff --git a/packages/common/src/api/index.ts b/packages/common/src/api/index.ts index 1b896b7ef5d..470435887ea 100644 --- a/packages/common/src/api/index.ts +++ b/packages/common/src/api/index.ts @@ -155,6 +155,7 @@ export * from './tan-query/wallets/useUserCoinBalance' export * from './tan-query/wallets/useWalletCollectibles' export * from './tan-query/wallets/useWalletOwner' export * from './tan-query/wallets/useUSDCBalance' +export * from './tan-query/wallets/useTokenBalance' export * from './tan-query/jupiter/useSwapTokens' export * from './tan-query/jupiter/useTokenExchangeRate' export * from './tan-query/jupiter/utils' diff --git a/packages/common/src/api/tan-query/jupiter/constants.ts b/packages/common/src/api/tan-query/jupiter/constants.ts index bc357d61dc7..420dc8ac19c 100644 --- a/packages/common/src/api/tan-query/jupiter/constants.ts +++ b/packages/common/src/api/tan-query/jupiter/constants.ts @@ -1,13 +1,17 @@ import { PublicKey } from '@solana/web3.js' import { Env } from '~/services/env' -import { - createTokenListingMap, - TOKEN_LISTING_MAP -} from '~/store/ui/shared/tokenConstants' +import { getOrInitializeRegistry, type TokenConfig } from '~/services/tokens' import { UserBankManagedTokenInfo } from './types' +const CLAIMABLE_TOKEN_MINTS = ['wAUDIO', 'USDC', 'BONK'] as const +type ClaimableTokenMint = (typeof CLAIMABLE_TOKEN_MINTS)[number] + +const isClaimableTokenMint = (symbol: string): symbol is ClaimableTokenMint => { + return CLAIMABLE_TOKEN_MINTS.includes(symbol as ClaimableTokenMint) +} + export const JUPITER_PROGRAM_ID = new PublicKey( 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4' ) @@ -15,33 +19,22 @@ export const JUPITER_PROGRAM_ID = new PublicKey( export const createUserBankManagedTokens = ( env: Env ): Record => { - const tokenListingMap = createTokenListingMap(env) - return { - [tokenListingMap.AUDIO.address.toUpperCase()]: { - mintAddress: tokenListingMap.AUDIO.address, - claimableTokenMint: 'wAUDIO', - decimals: tokenListingMap.AUDIO.decimals - }, - [tokenListingMap.USDC.address.toUpperCase()]: { - mintAddress: tokenListingMap.USDC.address, - claimableTokenMint: 'USDC', - decimals: tokenListingMap.USDC.decimals + const registry = getOrInitializeRegistry(env.ENVIRONMENT) + + // Get all userbank-enabled tokens from registry + const userbankTokens: TokenConfig[] = registry.getUserbankTokens() + const managedTokens: Record = {} + + // Convert registry tokens to UserBankManagedTokenInfo format + userbankTokens.forEach((token: TokenConfig) => { + if (isClaimableTokenMint(token.symbol)) { + managedTokens[token.address.toUpperCase()] = { + mintAddress: token.address, + claimableTokenMint: token.symbol, + decimals: token.decimals + } } - } -} + }) -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 - } + return managedTokens } diff --git a/packages/common/src/api/tan-query/jupiter/types.ts b/packages/common/src/api/tan-query/jupiter/types.ts index a873cef0b1a..a8024d3b2dc 100644 --- a/packages/common/src/api/tan-query/jupiter/types.ts +++ b/packages/common/src/api/tan-query/jupiter/types.ts @@ -41,8 +41,10 @@ export type SwapTokensResult = { } } +export type ClaimableTokenMint = 'wAUDIO' | 'USDC' | 'BONK' + export interface UserBankManagedTokenInfo { mintAddress: string - claimableTokenMint: 'wAUDIO' | 'USDC' + claimableTokenMint: ClaimableTokenMint decimals: number } diff --git a/packages/common/src/api/tan-query/jupiter/utils.ts b/packages/common/src/api/tan-query/jupiter/utils.ts index 61296fdda78..90e3861188f 100644 --- a/packages/common/src/api/tan-query/jupiter/utils.ts +++ b/packages/common/src/api/tan-query/jupiter/utils.ts @@ -255,8 +255,12 @@ export function formatUSDCValue( * @returns Formatted price string */ export function formatTokenPrice(price: string, decimalPlaces: number): string { + // USDC constructor uses 6 decimal places, so we need to constrain the display + // to not exceed what's available in the FixedDecimal representation + const maxDecimalPlaces = Math.min(decimalPlaces, 6) + return USDC(price.replace(/,/g, '')).toLocaleString('en-US', { minimumFractionDigits: 2, - maximumFractionDigits: decimalPlaces + maximumFractionDigits: maxDecimalPlaces }) } diff --git a/packages/common/src/api/tan-query/queryKeys.ts b/packages/common/src/api/tan-query/queryKeys.ts index 2bad526be13..f2cc1a1ae50 100644 --- a/packages/common/src/api/tan-query/queryKeys.ts +++ b/packages/common/src/api/tan-query/queryKeys.ts @@ -95,6 +95,7 @@ export const QUERY_KEYS = { eventsByEntityId: 'eventsByEntityId', walletOwner: 'walletOwner', tokenPrice: 'tokenPrice', + tokenBalance: 'tokenBalance', usdcBalance: 'usdcBalance', fileSizes: 'fileSizes', managedAccounts: 'managedAccounts', diff --git a/packages/common/src/api/tan-query/wallets/useTokenBalance.ts b/packages/common/src/api/tan-query/wallets/useTokenBalance.ts new file mode 100644 index 00000000000..de68442a3f4 --- /dev/null +++ b/packages/common/src/api/tan-query/wallets/useTokenBalance.ts @@ -0,0 +1,145 @@ +import { useCallback } from 'react' + +import { FixedDecimal } from '@audius/fixed-decimal' +import { TokenAccountNotFoundError } from '@solana/spl-token' +import { Commitment } from '@solana/web3.js' +import { useQuery, useQueryClient } from '@tanstack/react-query' + +import { useCurrentAccountUser } from '~/api' +import { useQueryContext } from '~/api/tan-query/utils' +import { Status } from '~/models/Status' +import { MintName } from '~/services/audius-backend/solana' +import { getUserbankAccountInfo, getTokenBySymbol } from '~/services/index' + +import { QUERY_KEYS } from '../queryKeys' +import { QueryOptions, type QueryKey } from '../types' + +const createTokenBalance = ( + amount: string | number | bigint | null | undefined, + decimals: number +): FixedDecimal | null => { + if (amount === null || amount === undefined) { + return null + } + return new FixedDecimal(BigInt(amount.toString()), decimals) +} + +export const getTokenBalanceQueryKey = ( + ethAddress: string | null, + token: MintName, + commitment: Commitment +) => + [ + QUERY_KEYS.tokenBalance, + ethAddress, + token, + commitment + ] as unknown as QueryKey + +/** + * Hook to get the balance for any supported token for the current user. + * Uses TanStack Query for data fetching and caching. + * + * @param token The token to fetch balance for + * @param options Options for the query and polling + * @returns Object with status, data, refresh, and cancelPolling + */ +export const useTokenBalance = ({ + token, + isPolling, + pollingInterval = 1000, + commitment = 'processed', + ...queryOptions +}: { + token: MintName + isPolling?: boolean + pollingInterval?: number + commitment?: Commitment +} & QueryOptions) => { + const { audiusSdk, env } = useQueryContext() + const { data: user } = useCurrentAccountUser() + const ethAddress = user?.wallet ?? null + const queryClient = useQueryClient() + + const result = useQuery({ + queryKey: getTokenBalanceQueryKey(ethAddress, token, commitment), + queryFn: async () => { + const sdk = await audiusSdk() + if (!ethAddress || !token) { + return null + } + + try { + const account = await getUserbankAccountInfo( + sdk, + { + ethAddress, + mint: token + }, + commitment + ) + + // Get token configuration from registry to get decimal places + const tokenConfig = getTokenBySymbol(env, token) + if (!tokenConfig) { + console.warn( + `Token not found in registry: ${token}, returning null balance` + ) + return null + } + + return createTokenBalance(account?.amount, tokenConfig.decimals) + } catch (e) { + // If user doesn't have a token account yet, return 0 balance + if (e instanceof TokenAccountNotFoundError) { + const tokenConfig = getTokenBySymbol(env, token) + if (!tokenConfig) { + console.warn( + `Token not found in registry: ${token}, returning null balance` + ) + return null + } + return createTokenBalance(BigInt(0), tokenConfig.decimals) + } + console.error(`Error fetching ${token} balance:`, e) + // Return null instead of throwing to prevent infinite loading + return null + } + }, + enabled: !!ethAddress && !!token, + // TanStack Query's built-in polling - only poll when isPolling is true + refetchInterval: isPolling ? pollingInterval : false, + // Prevent refetching when window regains focus during polling to avoid conflicts + refetchOnWindowFocus: !isPolling, + ...queryOptions + }) + + // Map TanStack Query states to the Status enum for API compatibility + let status = Status.IDLE + if (result.isPending) { + status = Status.LOADING + } else if (result.isError) { + status = Status.ERROR + } else if (result.isSuccess) { + status = Status.SUCCESS + } + + // For compatibility with existing code + const data = result.data ?? null + + // Function to cancel polling by invalidating and refetching the query + // This effectively stops the current polling cycle + const cancelPolling = useCallback(() => { + queryClient.cancelQueries({ + queryKey: getTokenBalanceQueryKey(ethAddress, token, commitment) + }) + }, [queryClient, ethAddress, token, commitment]) + + return { + status, + data, + error: result.error, + refresh: result.refetch, + cancelPolling + } +} diff --git a/packages/common/src/services/audius-backend/AudiusBackend.ts b/packages/common/src/services/audius-backend/AudiusBackend.ts index 2f2eae9b416..3c2a636e500 100644 --- a/packages/common/src/services/audius-backend/AudiusBackend.ts +++ b/packages/common/src/services/audius-backend/AudiusBackend.ts @@ -43,6 +43,7 @@ import { PushNotifications } from '../../store' import { getErrorMessage, uuid, Maybe, Nullable } from '../../utils' +import { getTokenBySymbol } from '../tokens' import { MintName } from './solana' import { MonitoringCallbacks } from './types' @@ -164,6 +165,14 @@ export const audiusBackend = ({ } } + function getMintAddress(mint: MintName): PublicKey { + const token = getTokenBySymbol(env, mint) + if (!token) { + throw new Error(`Token not found: ${mint}`) + } + return new PublicKey(token.address) + } + async function recordTrackListen({ userId, trackId, @@ -998,10 +1007,7 @@ export const audiusBackend = ({ mint: MintName }) { const solanaTokenProgramKey = new PublicKey(TOKEN_PROGRAM_ID) - const mintKey = - mint === 'wAUDIO' - ? new PublicKey(env.WAUDIO_MINT_ADDRESS) - : new PublicKey(env.USDC_MINT_ADDRESS) + const mintKey = getMintAddress(mint) const addresses = PublicKey.findProgramAddressSync( [ solanaWalletKey.toBuffer(), @@ -1072,10 +1078,7 @@ export const audiusBackend = ({ solanaWalletKey, mint }) - const mintKey = - mint === 'wAUDIO' - ? new PublicKey(env.WAUDIO_MINT_ADDRESS) - : new PublicKey(env.USDC_MINT_ADDRESS) + const mintKey = getMintAddress(mint) const accounts = [ // 0. `[sw]` Funding account (must be a system account) { diff --git a/packages/common/src/services/audius-backend/solana.ts b/packages/common/src/services/audius-backend/solana.ts index 3b3c05849c3..5af6594ce55 100644 --- a/packages/common/src/services/audius-backend/solana.ts +++ b/packages/common/src/services/audius-backend/solana.ts @@ -40,7 +40,7 @@ export const MEMO_PROGRAM_ID = new PublicKey( 'Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo' ) -export type MintName = 'wAUDIO' | 'USDC' +export type MintName = 'wAUDIO' | 'USDC' | 'BONK' export const DEFAULT_MINT: MintName = 'wAUDIO' type UserBankConfig = { diff --git a/packages/common/src/store/ui/buy-sell/constants.ts b/packages/common/src/store/ui/buy-sell/constants.ts index 4c600812025..41a991ecb47 100644 --- a/packages/common/src/store/ui/buy-sell/constants.ts +++ b/packages/common/src/store/ui/buy-sell/constants.ts @@ -1,9 +1,10 @@ import { Env } from '~/services/env' - import { - createTokenListingMap, - TOKEN_LISTING_MAP -} from '../shared/tokenConstants' + createTokenInfoObjects, + generateTokenPairs, + safeGetTokens, + tokenConfigToTokenInfo +} from '~/services/tokens' import { TokenInfo, TokenPair } from './types' @@ -13,64 +14,59 @@ export const MAX_SWAP_AMOUNT_USD = 10000 // $10,000 // Create tokens using environment variables export const createTokens = (env: Env): Record => { - const tokenListingMap = createTokenListingMap(env) - return { - AUDIO: { - symbol: 'AUDIO', - name: 'Audius', - decimals: tokenListingMap.AUDIO.decimals, - balance: null, - isStablecoin: false, - address: tokenListingMap.AUDIO.address - }, - USDC: { - symbol: 'USDC', - name: 'USD Coin', - decimals: tokenListingMap.USDC.decimals, - balance: null, - isStablecoin: true, - address: tokenListingMap.USDC.address - } - } + return createTokenInfoObjects(env) } -// Token metadata without icons (to avoid circular dependency with harmony) -export const TOKENS: Record = { - AUDIO: { - symbol: 'AUDIO', - name: 'Audius', - decimals: TOKEN_LISTING_MAP.AUDIO.decimals, - balance: null, - isStablecoin: false, - address: TOKEN_LISTING_MAP.AUDIO.address - }, - USDC: { - symbol: 'USDC', - name: 'USD Coin', - decimals: TOKEN_LISTING_MAP.USDC.decimals, - balance: null, - isStablecoin: true, - address: TOKEN_LISTING_MAP.USDC.address - } +// Cache for token pairs to avoid repeated computation +const tokenPairsCache = new Map() + +/** + * Clear token pairs cache (useful for testing or configuration changes) + */ +export const clearTokenPairsCache = () => { + tokenPairsCache.clear() } // Create supported token pairs using environment variables export const createSupportedTokenPairs = (env: Env): TokenPair[] => { - const tokens = createTokens(env) - return [ - { - baseToken: tokens.AUDIO, - quoteToken: tokens.USDC, - exchangeRate: null - } - ] -} + const cacheKey = env.ENVIRONMENT + + // Return cached result if available + if (tokenPairsCache.has(cacheKey)) { + return tokenPairsCache.get(cacheKey)! + } -// Define supported token pairs without icons -export const SUPPORTED_TOKEN_PAIRS: TokenPair[] = [ - { - baseToken: TOKENS.AUDIO, - quoteToken: TOKENS.USDC, - exchangeRate: null + // Get all tradeable tokens (purchasable and sellable) + const tradeableTokens = safeGetTokens( + env, + (token) => token.purchasable || token.sellable + ).map(tokenConfigToTokenInfo) + + // Find specific tokens for explicit ordering + const audioToken = tradeableTokens.find((token) => token.symbol === 'AUDIO') + const usdcToken = tradeableTokens.find((token) => token.symbol === 'USDC') + + // Create pairs with explicit ordering to ensure USDC -> AUDIO is first + const pairs: TokenPair[] = [] + + // First pair: AUDIO/USDC (for Buy: USDC -> AUDIO, Sell: AUDIO -> USDC) + if (audioToken && usdcToken) { + pairs.push({ + baseToken: audioToken, + quoteToken: usdcToken, + exchangeRate: null + }) } -] + + // Generate remaining pairs, excluding the AUDIO/USDC pair we already added + const remainingPairs = generateTokenPairs(tradeableTokens).filter( + (pair) => + !(pair.baseToken.symbol === 'AUDIO' && pair.quoteToken.symbol === 'USDC') + ) as TokenPair[] + + pairs.push(...remainingPairs) + + // Cache the result + tokenPairsCache.set(cacheKey, pairs) + return pairs +} diff --git a/packages/common/src/store/ui/buy-sell/types.ts b/packages/common/src/store/ui/buy-sell/types.ts index 96b0052bb7a..28b31f256cd 100644 --- a/packages/common/src/store/ui/buy-sell/types.ts +++ b/packages/common/src/store/ui/buy-sell/types.ts @@ -6,7 +6,7 @@ export type BuySellTab = 'buy' | 'sell' export type Screen = 'input' | 'confirm' | 'success' -export type TokenType = 'AUDIO' | 'USDC' +export type TokenType = 'AUDIO' | 'USDC' | 'BONK' export type TokenInfo = { symbol: string // e.g., 'AUDIO', 'USDC', 'WETH' diff --git a/packages/common/src/store/ui/buy-sell/useBuySellSwap.ts b/packages/common/src/store/ui/buy-sell/useBuySellSwap.ts index 972d8f0e116..ed5026ae0bd 100644 --- a/packages/common/src/store/ui/buy-sell/useBuySellSwap.ts +++ b/packages/common/src/store/ui/buy-sell/useBuySellSwap.ts @@ -1,33 +1,38 @@ -import { useCallback, useEffect, useState, useRef } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { - SLIPPAGE_BPS, - useSwapTokens, - useCurrentAccountUser, - useQueryContext -} from '~/api' +import { SLIPPAGE_BPS, useCurrentAccountUser, useSwapTokens } from '~/api' import { SwapStatus } from '~/api/tan-query/jupiter/types' import { QUERY_KEYS } from '~/api/tan-query/queryKeys' -import { createTokenListingMap } from '../shared/tokenConstants' - -import type { BuySellTab, Screen, SwapResult, TransactionData } from './types' +import type { + BuySellTab, + Screen, + SwapResult, + TokenPair, + TransactionData +} from './types' type UseBuySellSwapProps = { transactionData: TransactionData currentScreen: Screen setCurrentScreen: (screen: Screen) => void activeTab: BuySellTab + selectedPair: TokenPair onClose: () => void } export const useBuySellSwap = (props: UseBuySellSwapProps) => { - const { transactionData, currentScreen, setCurrentScreen, activeTab } = props + const { + transactionData, + currentScreen, + setCurrentScreen, + activeTab, + selectedPair + } = props const queryClient = useQueryClient() const { data: user } = useCurrentAccountUser() - const { env } = useQueryContext() const [swapResult, setSwapResult] = useState(null) const [retryCount, setRetryCount] = useState(0) const [isRetrying, setIsRetrying] = useState(false) @@ -48,30 +53,38 @@ export const useBuySellSwap = (props: UseBuySellSwapProps) => { if (!transactionData || !transactionData.isValid) return const { inputAmount } = transactionData - const tokenListingMap = createTokenListingMap(env) + + // Get the correct input and output token addresses based on the selected pair and active tab + let inputMintAddress: string + let outputMintAddress: string if (activeTab === 'buy') { - swapTokens({ - inputMint: tokenListingMap.USDC.address, - outputMint: tokenListingMap.AUDIO.address, - amountUi: inputAmount, - slippageBps: SLIPPAGE_BPS - }) + // Buy: pay with quote token, receive base token + inputMintAddress = selectedPair.quoteToken.address ?? '' + outputMintAddress = selectedPair.baseToken.address ?? '' } else { - swapTokens({ - inputMint: tokenListingMap.AUDIO.address, - outputMint: tokenListingMap.USDC.address, - amountUi: inputAmount, - slippageBps: SLIPPAGE_BPS - }) + // Sell: pay with base token, receive quote token + inputMintAddress = selectedPair.baseToken.address ?? '' + outputMintAddress = selectedPair.quoteToken.address ?? '' } + + swapTokens({ + inputMint: inputMintAddress, + outputMint: outputMintAddress, + amountUi: inputAmount, + slippageBps: SLIPPAGE_BPS + }) } const invalidateBalances = () => { if (user?.wallet) { + // Invalidate balances for all token types that could be involved queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.usdcBalance, user.wallet] }) + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.tokenBalance, user.wallet] + }) } if (user?.spl_wallet) { queryClient.invalidateQueries({ diff --git a/packages/common/src/store/ui/buy-sell/useTokenAmountFormatting.ts b/packages/common/src/store/ui/buy-sell/useTokenAmountFormatting.ts index 91bb2d78526..b09044b91b0 100644 --- a/packages/common/src/store/ui/buy-sell/useTokenAmountFormatting.ts +++ b/packages/common/src/store/ui/buy-sell/useTokenAmountFormatting.ts @@ -2,11 +2,8 @@ import { useCallback, useMemo } from 'react' import { AUDIO } from '@audius/fixed-decimal' -import { formatUSDCValue } from '../../../api' -import { - getAudioBalanceDecimalPlaces, - getCurrencyDecimalPlaces -} from '../../../utils' +import { formatUSDCValue } from '~/api' +import { getTokenDecimalPlaces, getCurrencyDecimalPlaces } from '~/utils' export type UseTokenAmountFormattingProps = { amount?: string | number @@ -57,7 +54,7 @@ export const useTokenAmountFormatting = ({ // Use AUDIO for non-stablecoins for now, when we expand to other tokens // we will need to use FixedDecimal itself const audioAmount = AUDIO(availableBalance) - const decimals = getAudioBalanceDecimalPlaces(availableBalance) + const decimals = getTokenDecimalPlaces(availableBalance) return audioAmount.toLocaleString('en-US', { maximumFractionDigits: decimals @@ -76,7 +73,7 @@ export const useTokenAmountFormatting = ({ } const audioAmount = AUDIO(safeNumericAmount) - const decimals = getAudioBalanceDecimalPlaces(safeNumericAmount) + const decimals = getTokenDecimalPlaces(safeNumericAmount) return audioAmount.toLocaleString('en-US', { maximumFractionDigits: decimals diff --git a/packages/common/src/store/ui/shared/tokenConstants.ts b/packages/common/src/store/ui/shared/tokenConstants.ts index 594dc8e5206..d5c22857f8a 100644 --- a/packages/common/src/store/ui/shared/tokenConstants.ts +++ b/packages/common/src/store/ui/shared/tokenConstants.ts @@ -1,4 +1,5 @@ import { Env } from '~/services/env' +import { getOrInitializeRegistry } from '~/services/tokens' import { JupiterTokenListing } from '../buy-audio/types' @@ -30,6 +31,14 @@ const BASE_TOKEN_METADATA = { decimals: 6, logoURI: 'https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v/logo.png' + }, + BONK: { + chainId: 101, + symbol: 'BONK', + name: 'Bonk', + decimals: 5, + logoURI: + 'https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263/logo.png' } } as const @@ -38,19 +47,33 @@ const BASE_TOKEN_METADATA = { */ export const createTokenListingMap = ( env: Env -): Record => ({ - AUDIO: { - ...BASE_TOKEN_METADATA.AUDIO, - address: env.WAUDIO_MINT_ADDRESS - }, - SOL: { +): Record => { + const registry = getOrInitializeRegistry(env.ENVIRONMENT) + + // Get all tokens from registry + const allTokens = registry.getAllTokens() + const tokenMap: Record = {} + + // Add tokens from registry + allTokens.forEach((token) => { + const baseMetadata = + BASE_TOKEN_METADATA[token.symbol as keyof typeof BASE_TOKEN_METADATA] + if (baseMetadata) { + tokenMap[token.symbol] = { + ...baseMetadata, + address: token.address, + decimals: token.decimals + } + } + }) + + // Add SOL which is not in the token registry + tokenMap.SOL = { ...BASE_TOKEN_METADATA.SOL - }, - USDC: { - ...BASE_TOKEN_METADATA.USDC, - address: env.USDC_MINT_ADDRESS } -}) + + return tokenMap +} /** * Legacy token listing map with hardcoded addresses for backward compatibility @@ -66,5 +89,9 @@ export const TOKEN_LISTING_MAP: Record = { USDC: { ...BASE_TOKEN_METADATA.USDC, address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' + }, + BONK: { + ...BASE_TOKEN_METADATA.BONK, + address: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263' } } diff --git a/packages/common/src/utils/decimal.ts b/packages/common/src/utils/decimal.ts index 6ddaf9c31f0..b17946f1bbd 100644 --- a/packages/common/src/utils/decimal.ts +++ b/packages/common/src/utils/decimal.ts @@ -81,13 +81,13 @@ export const getCurrencyDecimalPlaces = (priceUSD: number) => { * @returns Number of decimal places to show (minimum 2) * * @example - * getAudioBalanceDecimalPlaces(1234.56) // 2 → "1,234.56" - * getAudioBalanceDecimalPlaces(92.0253) // 2 → "92.02" - * getAudioBalanceDecimalPlaces(1.2345) // 2 → "1.23" - * getAudioBalanceDecimalPlaces(0.1234) // 3 → "0.123" - * getAudioBalanceDecimalPlaces(0.00123) // 5 → "0.00123" + * getTokenDecimalPlaces(1234.56) // 2 → "1,234.56" + * getTokenDecimalPlaces(92.0253) // 2 → "92.02" + * getTokenDecimalPlaces(1.2345) // 2 → "1.23" + * getTokenDecimalPlaces(0.1234) // 3 → "0.123" + * getTokenDecimalPlaces(0.00123) // 5 → "0.00123" */ -export const getAudioBalanceDecimalPlaces = (balance: number) => { +export const getTokenDecimalPlaces = (balance: number) => { const absBalance = Math.abs(balance) if (absBalance >= 1000) { @@ -121,7 +121,7 @@ export const formatAudioBalance = ( locale: string = 'en-US' ): string => { const balanceNumber = Number(AUDIO(balance).toString()) - const decimalPlaces = getAudioBalanceDecimalPlaces(balanceNumber) + const decimalPlaces = getTokenDecimalPlaces(balanceNumber) return AUDIO(balance).toLocaleString(locale, { maximumFractionDigits: decimalPlaces, diff --git a/packages/mobile/src/screens/buy-sell-screen/BuySellFlow.tsx b/packages/mobile/src/screens/buy-sell-screen/BuySellFlow.tsx index cc637ece843..febee650baf 100644 --- a/packages/mobile/src/screens/buy-sell-screen/BuySellFlow.tsx +++ b/packages/mobile/src/screens/buy-sell-screen/BuySellFlow.tsx @@ -9,7 +9,7 @@ import { useBuySellTabs, useBuySellTransactionData, useSwapDisplayData, - SUPPORTED_TOKEN_PAIRS, + createSupportedTokenPairs, useAddCashModal, getSwapTokens } from '@audius/common/store' @@ -18,6 +18,7 @@ import { useFocusEffect } from '@react-navigation/native' import { Button, Flex, Hint, TextLink } from '@audius/harmony-native' import { SegmentedControl } from 'app/components/core' import { useNavigation } from 'app/hooks/useNavigation' +import { env } from 'app/services/env' import { BuyScreen, SellScreen } from './components' @@ -85,20 +86,22 @@ export const BuySellFlow = ({ })) } + const [selectedPairIndex] = useState(0) + const supportedTokenPairs = useMemo(() => createSupportedTokenPairs(env), []) + const selectedPair = supportedTokenPairs[selectedPairIndex] + const { handleShowConfirmation, isContinueButtonLoading } = useBuySellSwap({ transactionData, currentScreen, setCurrentScreen, activeTab, + selectedPair, onClose }) // Track if user has attempted to submit the form const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false) - const [selectedPairIndex] = useState(0) - const selectedPair = SUPPORTED_TOKEN_PAIRS[selectedPairIndex] - const swapTokens = useMemo( () => getSwapTokens(activeTab, selectedPair), [activeTab, selectedPair] diff --git a/packages/mobile/src/screens/buy-sell-screen/ConfirmSwapScreen.tsx b/packages/mobile/src/screens/buy-sell-screen/ConfirmSwapScreen.tsx index e02fb89c142..79fd5bdc7cf 100644 --- a/packages/mobile/src/screens/buy-sell-screen/ConfirmSwapScreen.tsx +++ b/packages/mobile/src/screens/buy-sell-screen/ConfirmSwapScreen.tsx @@ -5,7 +5,7 @@ import { useBuySellAnalytics } from '@audius/common/hooks' import { buySellMessages as baseMessages } from '@audius/common/messages' import type { TokenInfo } from '@audius/common/store' import { - SUPPORTED_TOKEN_PAIRS, + createSupportedTokenPairs, useBuySellScreen, useBuySellSwap, useSwapDisplayData, @@ -28,6 +28,7 @@ import { FixedFooterContent } from 'app/components/core' import { useNavigation } from 'app/hooks/useNavigation' +import { env } from 'app/services/env' import { SwapBalanceSection } from '../../components/buy-sell' @@ -129,6 +130,10 @@ export const ConfirmSwapScreen = ({ route }: ConfirmSwapScreenProps) => { // Determine if this is a buy or sell based on token types const activeTab = payTokenInfo.symbol === 'USDC' ? 'buy' : 'sell' + const [selectedPairIndex] = useState(0) + const supportedTokenPairs = useMemo(() => createSupportedTokenPairs(env), []) + const selectedPair = supportedTokenPairs[selectedPairIndex] + const { handleConfirmSwap, isConfirmButtonLoading, @@ -140,12 +145,10 @@ export const ConfirmSwapScreen = ({ route }: ConfirmSwapScreenProps) => { currentScreen, setCurrentScreen, activeTab, + selectedPair, onClose: () => navigation.goBack() }) - const [selectedPairIndex] = useState(0) - const selectedPair = SUPPORTED_TOKEN_PAIRS[selectedPairIndex] - const swapTokens = useMemo( () => getSwapTokens(activeTab, selectedPair), [activeTab, selectedPair] diff --git a/packages/web/src/components/buy-sell-modal/BuySellFlow.tsx b/packages/web/src/components/buy-sell-modal/BuySellFlow.tsx index f6aca512479..5a68969be44 100644 --- a/packages/web/src/components/buy-sell-modal/BuySellFlow.tsx +++ b/packages/web/src/components/buy-sell-modal/BuySellFlow.tsx @@ -10,7 +10,7 @@ import { useSwapDisplayData, BuySellTab, Screen, - getSwapTokens + TokenInfo } from '@audius/common/store' import { Button, Flex, Hint, SegmentedControl, TextLink } from '@audius/harmony' @@ -22,7 +22,7 @@ import { BuyTab } from './BuyTab' import { ConfirmSwapScreen } from './ConfirmSwapScreen' import { SellTab } from './SellTab' import { TransactionSuccessScreen } from './TransactionSuccessScreen' -import { SUPPORTED_TOKEN_PAIRS } from './constants' +import { SUPPORTED_TOKEN_PAIRS, TOKENS } from './constants' const WALLET_GUIDE_URL = 'https://help.audius.co/product/wallet-guide' @@ -76,6 +76,104 @@ export const BuySellFlow = (props: BuySellFlowProps) => { })) } + // Handle token changes + const handleInputTokenChange = (symbol: string) => { + if (activeTab === 'sell') { + // On sell tab, input token change means base token change + setBaseTokenSymbol(symbol) + } else { + // On buy tab, input token change means quote token change + setQuoteTokenSymbol(symbol) + } + // Reset transaction data when tokens change + resetTransactionData() + } + + const handleOutputTokenChange = (symbol: string) => { + if (activeTab === 'buy') { + // On buy tab, output token change means base token change + setBaseTokenSymbol(symbol) + } else { + // On sell tab, output token change means quote token change + setQuoteTokenSymbol(symbol) + } + // Reset transaction data when tokens change + resetTransactionData() + } + + // Track if user has attempted to submit the form + const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false) + + const selectedPair = SUPPORTED_TOKEN_PAIRS[0] + + // State for dynamic token selection - using base/quote terminology + const [baseTokenSymbol, setBaseTokenSymbol] = useState( + selectedPair.baseToken.symbol // AUDIO by default + ) + const [quoteTokenSymbol, setQuoteTokenSymbol] = useState( + selectedPair.quoteToken.symbol // USDC by default + ) + + // Get all available tokens + const availableTokens = useMemo(() => { + const tokensSet = new Set() + SUPPORTED_TOKEN_PAIRS.forEach((pair) => { + tokensSet.add(pair.baseToken.symbol) + tokensSet.add(pair.quoteToken.symbol) + }) + return Array.from(tokensSet) + .map((symbol) => Object.values(TOKENS).find((t) => t.symbol === symbol)) + .filter(Boolean) as TokenInfo[] + }, []) + + // Create current token pair based on selected base and quote tokens + const currentTokenPair = useMemo(() => { + const baseTokenInfo = availableTokens.find( + (t) => t.symbol === baseTokenSymbol + ) + const quoteTokenInfo = availableTokens.find( + (t) => t.symbol === quoteTokenSymbol + ) + + if (!baseTokenInfo || !quoteTokenInfo) { + return selectedPair + } + + // Find existing pair that matches our tokens + const pair = SUPPORTED_TOKEN_PAIRS.find( + (p) => + p.baseToken.symbol === baseTokenSymbol && + p.quoteToken.symbol === quoteTokenSymbol + ) + + if (pair) { + return pair + } + + // Create a dynamic pair if no exact match found + return { + baseToken: baseTokenInfo, + quoteToken: quoteTokenInfo, + exchangeRate: null + } + }, [baseTokenSymbol, quoteTokenSymbol, availableTokens, selectedPair]) + + const swapTokens = useMemo( + () => ({ + inputToken: activeTab === 'buy' ? quoteTokenSymbol : baseTokenSymbol, + outputToken: activeTab === 'buy' ? baseTokenSymbol : quoteTokenSymbol, + inputTokenInfo: + activeTab === 'buy' + ? currentTokenPair.quoteToken + : currentTokenPair.baseToken, + outputTokenInfo: + activeTab === 'buy' + ? currentTokenPair.baseToken + : currentTokenPair.quoteToken + }), + [activeTab, baseTokenSymbol, quoteTokenSymbol, currentTokenPair] + ) + const { handleShowConfirmation, handleConfirmSwap, @@ -89,20 +187,10 @@ export const BuySellFlow = (props: BuySellFlowProps) => { currentScreen, setCurrentScreen, activeTab, + selectedPair: currentTokenPair, onClose }) - // Track if user has attempted to submit the form - const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false) - - const [selectedPairIndex] = useState(0) - const selectedPair = SUPPORTED_TOKEN_PAIRS[selectedPairIndex] - - const swapTokens = useMemo( - () => getSwapTokens(activeTab, selectedPair), - [activeTab, selectedPair] - ) - const currentExchangeRate = useMemo( () => transactionData?.exchangeRate ?? undefined, [transactionData?.exchangeRate] @@ -160,7 +248,7 @@ export const BuySellFlow = (props: BuySellFlowProps) => { transactionData, swapResult, activeTab, - selectedPair + selectedPair: currentTokenPair }) // Track swap success when success screen is shown @@ -258,21 +346,37 @@ export const BuySellFlow = (props: BuySellFlowProps) => { {activeTab === 'buy' ? ( t.symbol !== baseTokenSymbol + )} + availableOutputTokens={availableTokens.filter( + (t) => t.symbol !== quoteTokenSymbol + )} + onInputTokenChange={handleInputTokenChange} + onOutputTokenChange={handleOutputTokenChange} /> ) : ( t.symbol !== quoteTokenSymbol + )} + availableOutputTokens={availableTokens.filter( + (t) => t.symbol !== baseTokenSymbol + )} + onInputTokenChange={handleInputTokenChange} + onOutputTokenChange={handleOutputTokenChange} /> )} @@ -325,7 +429,7 @@ export const BuySellFlow = (props: BuySellFlowProps) => { onConfirm={handleConfirmSwap} isConfirming={isConfirmButtonLoading} activeTab={activeTab} - selectedPair={selectedPair} + selectedPair={currentTokenPair} /> ) : null} diff --git a/packages/web/src/components/buy-sell-modal/BuyTab.tsx b/packages/web/src/components/buy-sell-modal/BuyTab.tsx index 4adfdb0694e..0f038a97bb5 100644 --- a/packages/web/src/components/buy-sell-modal/BuyTab.tsx +++ b/packages/web/src/components/buy-sell-modal/BuyTab.tsx @@ -1,9 +1,10 @@ import { useMemo } from 'react' -import { useTokenPrice, useUSDCBalance } from '@audius/common/api' +import { useTokenBalance, useTokenPrice } from '@audius/common/api' import { Status } from '@audius/common/models' -import { TokenPair } from '@audius/common/store' +import { TokenInfo, TokenPair } from '@audius/common/store' import { getCurrencyDecimalPlaces } from '@audius/common/utils' +import { FixedDecimal } from '@audius/fixed-decimal' import { SwapTab } from './SwapTab' @@ -20,6 +21,10 @@ type BuyTabProps = { errorMessage?: string initialInputValue?: string onInputValueChange?: (value: string) => void + availableInputTokens?: TokenInfo[] + availableOutputTokens?: TokenInfo[] + onInputTokenChange?: (symbol: string) => void + onOutputTokenChange?: (symbol: string) => void } export const BuyTab = ({ @@ -28,10 +33,17 @@ export const BuyTab = ({ error, errorMessage, initialInputValue, - onInputValueChange + onInputValueChange, + availableInputTokens, + availableOutputTokens, + onInputTokenChange, + onOutputTokenChange }: BuyTabProps) => { const { baseToken, quoteToken } = tokenPair - const { status: balanceStatus, data: usdcBalance } = useUSDCBalance() + + const { status: balanceStatus, data: tokenBalanceData } = useTokenBalance({ + token: 'USDC' + }) const { data: tokenPriceData, isPending: isTokenPriceLoading } = useTokenPrice(baseToken.address) @@ -43,21 +55,21 @@ export const BuyTab = ({ return getCurrencyDecimalPlaces(parseFloat(tokenPrice)) }, [tokenPrice]) - const getUsdcBalance = useMemo(() => { + const getBalance = useMemo(() => { return () => { - if (balanceStatus === Status.SUCCESS && usdcBalance) { - return parseFloat(usdcBalance.toString()) / 10 ** quoteToken.decimals + if (balanceStatus === Status.SUCCESS && tokenBalanceData) { + return Number(new FixedDecimal(tokenBalanceData.toString())) } return undefined } - }, [balanceStatus, usdcBalance, quoteToken.decimals]) + }, [balanceStatus, tokenBalanceData]) return ( 'Insufficient balance' }} @@ -69,6 +81,8 @@ export const BuyTab = ({ tokenPriceDecimalPlaces={decimalPlaces} initialInputValue={initialInputValue} onInputValueChange={onInputValueChange} + availableOutputTokens={availableOutputTokens} + onOutputTokenChange={onOutputTokenChange} /> ) } diff --git a/packages/web/src/components/buy-sell-modal/SellTab.tsx b/packages/web/src/components/buy-sell-modal/SellTab.tsx index e74c59453e1..025ae66eb97 100644 --- a/packages/web/src/components/buy-sell-modal/SellTab.tsx +++ b/packages/web/src/components/buy-sell-modal/SellTab.tsx @@ -1,9 +1,10 @@ import { useMemo } from 'react' -import { useAudioBalance } from '@audius/common/api' -import { TokenPair } from '@audius/common/store' +import { useAudioBalance, useTokenBalance } from '@audius/common/api' +import { Status } from '@audius/common/models' +import { TokenInfo, TokenPair } from '@audius/common/store' import { isNullOrUndefined } from '@audius/common/utils' -import { AUDIO } from '@audius/fixed-decimal' +import { AUDIO, FixedDecimal } from '@audius/fixed-decimal' import { SwapTab } from './SwapTab' @@ -20,6 +21,10 @@ type SellTabProps = { errorMessage?: string initialInputValue?: string onInputValueChange?: (value: string) => void + availableInputTokens?: TokenInfo[] + availableOutputTokens?: TokenInfo[] + onInputTokenChange?: (symbol: string) => void + onOutputTokenChange?: (symbol: string) => void } export const SellTab = ({ @@ -28,29 +33,55 @@ export const SellTab = ({ error, errorMessage, initialInputValue, - onInputValueChange + onInputValueChange, + availableInputTokens, + availableOutputTokens, + onInputTokenChange, + onOutputTokenChange }: SellTabProps) => { // Extract the tokens from the pair const { baseToken, quoteToken } = tokenPair + + // For AUDIO, use the specialized hook for compatibility const { accountBalance } = useAudioBalance({ includeConnectedWallets: false }) - const isBalanceLoading = isNullOrUndefined(accountBalance) + const { data: tokenBalanceData, status: tokenBalanceStatus } = + useTokenBalance({ + token: 'wAUDIO' + }) + + const isBalanceLoading = + baseToken.symbol === 'AUDIO' + ? isNullOrUndefined(accountBalance) + : tokenBalanceStatus === Status.LOADING - // Get AUDIO balance in UI format - const getAudioBalance = useMemo(() => { + // Get balance in UI format + const getBalance = useMemo(() => { return () => { - if (!isBalanceLoading && accountBalance) { - return parseFloat(AUDIO(accountBalance).toString()) + if (baseToken.symbol === 'AUDIO') { + if (!isBalanceLoading && accountBalance) { + return Number(AUDIO(accountBalance).toString()) + } + } else { + if (tokenBalanceStatus === Status.SUCCESS && tokenBalanceData) { + return Number(new FixedDecimal(tokenBalanceData.toString())) + } } return undefined } - }, [accountBalance, isBalanceLoading]) + }, [ + accountBalance, + isBalanceLoading, + baseToken.symbol, + tokenBalanceData, + tokenBalanceStatus + ]) return ( 'Insufficient balance' }} @@ -61,6 +92,10 @@ export const SellTab = ({ tooltipPlacement='right' initialInputValue={initialInputValue} onInputValueChange={onInputValueChange} + availableInputTokens={availableInputTokens} + availableOutputTokens={availableOutputTokens} + onInputTokenChange={onInputTokenChange} + onOutputTokenChange={onOutputTokenChange} /> ) } diff --git a/packages/web/src/components/buy-sell-modal/SwapTab.tsx b/packages/web/src/components/buy-sell-modal/SwapTab.tsx index e3da416f2e9..de16a2b6ca0 100644 --- a/packages/web/src/components/buy-sell-modal/SwapTab.tsx +++ b/packages/web/src/components/buy-sell-modal/SwapTab.tsx @@ -54,6 +54,10 @@ export type SwapTabProps = { tooltipPlacement?: TooltipPlacement initialInputValue?: string onInputValueChange?: (value: string) => void + availableInputTokens?: TokenInfo[] + availableOutputTokens?: TokenInfo[] + onInputTokenChange?: (symbol: string) => void + onOutputTokenChange?: (symbol: string) => void } export const SwapTab = ({ @@ -71,7 +75,11 @@ export const SwapTab = ({ tokenPriceDecimalPlaces = 2, tooltipPlacement, initialInputValue, - onInputValueChange + onInputValueChange, + availableInputTokens, + availableOutputTokens, + onInputTokenChange, + onOutputTokenChange }: SwapTabProps) => { const { formik, diff --git a/packages/web/src/components/buy-sell-modal/TokenAmountSection.tsx b/packages/web/src/components/buy-sell-modal/TokenAmountSection.tsx index 45fecedb74f..62a5e8417bf 100644 --- a/packages/web/src/components/buy-sell-modal/TokenAmountSection.tsx +++ b/packages/web/src/components/buy-sell-modal/TokenAmountSection.tsx @@ -61,7 +61,6 @@ const StackedBalanceSection = ({ tokenInfo, isStablecoin }: BalanceSectionProps) => { - const { cornerRadius } = useTheme() const { icon: TokenIcon, symbol } = tokenInfo if (!formattedAvailableBalance || !TokenIcon) { @@ -89,7 +88,7 @@ const StackedBalanceSection = ({ {/* We need the border radius to be circle here because the AUDIO icon is a square image */} - + ) @@ -106,7 +105,7 @@ const CryptoAmountSection = ({ isStablecoin: boolean priceDisplay?: string }) => { - const { spacing, cornerRadius } = useTheme() + const { spacing } = useTheme() const { icon: TokenIcon, symbol } = tokenInfo const tokenTicker = messages.tokenTicker(symbol, !!isStablecoin) @@ -116,11 +115,7 @@ const CryptoAmountSection = ({ return ( - + diff --git a/packages/web/src/components/buy-sell-modal/constants.ts b/packages/web/src/components/buy-sell-modal/constants.ts index 2ea6fef339a..a0b8fea5dbc 100644 --- a/packages/web/src/components/buy-sell-modal/constants.ts +++ b/packages/web/src/components/buy-sell-modal/constants.ts @@ -1,27 +1,52 @@ -import { TOKENS as BASE_TOKENS } from '@audius/common/src/store/ui/buy-sell' +import { + createTokens, + createSupportedTokenPairs +} from '@audius/common/src/store/ui/buy-sell' import { TokenInfo, TokenPair } from '@audius/common/src/store/ui/buy-sell/types' -import { IconLogoCircleUSDC, IconTokenAUDIO } from '@audius/harmony' - -// Token metadata with icons for web -export const TOKENS: Record = { - AUDIO: { - ...BASE_TOKENS.AUDIO, - icon: IconTokenAUDIO - }, - USDC: { - ...BASE_TOKENS.USDC, - icon: IconLogoCircleUSDC +import { + IconLogoCircleUSDC, + IconTokenAUDIO, + IconTokenBonk +} from '@audius/harmony' + +import { env } from 'services/env' + +// Create tokens from centralized configuration with icons for web +const createTokensWithIcons = (): Record => { + const baseTokens = createTokens(env) + const iconMap = { + AUDIO: IconTokenAUDIO, + USDC: IconLogoCircleUSDC, + BONK: IconTokenBonk } + + const tokensWithIcons: Record = {} + Object.entries(baseTokens).forEach(([symbol, token]) => { + tokensWithIcons[symbol] = { + ...token, + icon: iconMap[symbol as keyof typeof iconMap] + } + }) + + return tokensWithIcons } -// Define supported token pairs with icons for web -export const SUPPORTED_TOKEN_PAIRS: TokenPair[] = [ - { - baseToken: TOKENS.AUDIO, - quoteToken: TOKENS.USDC, - exchangeRate: null - } -] +export const TOKENS: Record = createTokensWithIcons() + +// Create supported token pairs dynamically with icons for web +const createSupportedTokenPairsWithIcons = (): TokenPair[] => { + const basePairs = createSupportedTokenPairs(env) + const tokensWithIcons = TOKENS + + return basePairs.map((pair) => ({ + ...pair, + baseToken: tokensWithIcons[pair.baseToken.symbol] || pair.baseToken, + quoteToken: tokensWithIcons[pair.quoteToken.symbol] || pair.quoteToken + })) +} + +export const SUPPORTED_TOKEN_PAIRS: TokenPair[] = + createSupportedTokenPairsWithIcons()