Skip to content
1 change: 1 addition & 0 deletions packages/common/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
55 changes: 24 additions & 31 deletions packages/common/src/api/tan-query/jupiter/constants.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,40 @@
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]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whoa cool


const isClaimableTokenMint = (symbol: string): symbol is ClaimableTokenMint => {
return CLAIMABLE_TOKEN_MINTS.includes(symbol as ClaimableTokenMint)
}

export const JUPITER_PROGRAM_ID = new PublicKey(
'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'
)

export const createUserBankManagedTokens = (
env: Env
): Record<string, UserBankManagedTokenInfo> => {
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<string, UserBankManagedTokenInfo> = {}

// Convert registry tokens to UserBankManagedTokenInfo format
userbankTokens.forEach((token: TokenConfig) => {
if (isClaimableTokenMint(token.symbol)) {
managedTokens[token.address.toUpperCase()] = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think solana addresses are case sensitive?

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
}
4 changes: 3 additions & 1 deletion packages/common/src/api/tan-query/jupiter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
6 changes: 5 additions & 1 deletion packages/common/src/api/tan-query/jupiter/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
}
1 change: 1 addition & 0 deletions packages/common/src/api/tan-query/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export const QUERY_KEYS = {
eventsByEntityId: 'eventsByEntityId',
walletOwner: 'walletOwner',
tokenPrice: 'tokenPrice',
tokenBalance: 'tokenBalance',
usdcBalance: 'usdcBalance',
fileSizes: 'fileSizes',
managedAccounts: 'managedAccounts',
Expand Down
145 changes: 145 additions & 0 deletions packages/common/src/api/tan-query/wallets/useTokenBalance.ts
Original file line number Diff line number Diff line change
@@ -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 = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: is this rly the cleanest way? can we not do something like:

return !!amount ? new FixedDecimal(BigInt(amount.toString()), decimals) : null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idk i guess that's kind of annoying too..

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<FixedDecimal | null>

/**
* 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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

smart

...queryOptions
})

// Map TanStack Query states to the Status enum for API compatibility

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

damn is this another migration we need to do

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
}
}
19 changes: 11 additions & 8 deletions packages/common/src/services/audius-backend/AudiusBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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)
{
Expand Down
2 changes: 1 addition & 1 deletion packages/common/src/services/audius-backend/solana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading