diff --git a/components/App.tsx b/components/App.tsx index a199520b8..c066ce559 100644 --- a/components/App.tsx +++ b/components/App.tsx @@ -105,7 +105,7 @@ export function AppContents(props: Props) { handleRouterHistory() useHandleGovernanceAssetsStore() useEffect(() => { - tokenPriceService.fetchSolanaTokenList() + tokenPriceService.fetchSolanaTokenListV2() }, []) const { getOwnedDeposits, resetDepositState } = useDepositStore() diff --git a/components/TreasuryAccount/AccountItem.tsx b/components/TreasuryAccount/AccountItem.tsx index 4fdd1e30c..d9f712255 100644 --- a/components/TreasuryAccount/AccountItem.tsx +++ b/components/TreasuryAccount/AccountItem.tsx @@ -1,5 +1,5 @@ -import { useMemo } from 'react' -import { getTreasuryAccountItemInfoV2 } from '@utils/treasuryTools' +import { useEffect, useState, useMemo } from 'react' +import { getTreasuryAccountItemInfoV2Async } from '@utils/treasuryTools' import { AssetAccount } from '@utils/uiTypes/assets' import TokenIcon from '@components/treasuryV2/icons/TokenIcon' import { useTokenMetadata } from '@hooks/queries/tokenMetadata' @@ -9,23 +9,38 @@ const AccountItem = ({ }: { governedAccountTokenAccount: AssetAccount }) => { - const { - amountFormatted, - logo, - name, - symbol, - displayPrice, - } = getTreasuryAccountItemInfoV2(governedAccountTokenAccount) + const [accountAssetInfo, setAccountAssetInfo] = useState({ + amountFormatted: '', + logo: '', + name: '', + symbol: '', + displayPrice: '', + }) + + useEffect(() => { + const fetchAccounAssetInfo = async () => { + try { + const info = await getTreasuryAccountItemInfoV2Async(governedAccountTokenAccount) + setAccountAssetInfo(info) + } catch (error) { + console.error('Error fetching treasury account info:', error) + } + } + + fetchAccounAssetInfo() + }, [governedAccountTokenAccount]) const { data } = useTokenMetadata( governedAccountTokenAccount.extensions.mint?.publicKey, - !logo + !accountAssetInfo.logo ) - + const symbolFromMeta = useMemo(() => { return data?.symbol }, [data?.symbol]) + const { amountFormatted, logo, name, symbol, displayPrice } = accountAssetInfo + return (
{logo ? ( @@ -51,14 +66,10 @@ const AccountItem = ({
{amountFormatted} {symbolFromMeta ? symbolFromMeta : symbol}
- {displayPrice ? ( -
≈${displayPrice}
- ) : ( - '' - )} +
≈${displayPrice || 0}
) } -export default AccountItem +export default AccountItem \ No newline at end of file diff --git a/components/TreasuryAccount/AccountsItems.tsx b/components/TreasuryAccount/AccountsItems.tsx index 04d4afbbd..0f3757446 100644 --- a/components/TreasuryAccount/AccountsItems.tsx +++ b/components/TreasuryAccount/AccountsItems.tsx @@ -1,39 +1,70 @@ import useGovernanceAssets from '@hooks/useGovernanceAssets' -import { getTreasuryAccountItemInfoV2 } from '@utils/treasuryTools' -import React from 'react' +import { getTreasuryAccountItemInfoV2Async } from '@utils/treasuryTools' +import React, { useEffect, useState } from 'react' import AccountItem from './AccountItem' +import { AssetAccount } from '@utils/uiTypes/assets' +import Loading from '@components/Loading' const AccountsItems = () => { const { governedTokenAccountsWithoutNfts, auxiliaryTokenAccounts, } = useGovernanceAssets() - const accounts = [ - ...governedTokenAccountsWithoutNfts, - ...auxiliaryTokenAccounts, - ] - const accountsSorted = accounts - .sort((a, b) => { - const infoA = getTreasuryAccountItemInfoV2(a) - const infoB = getTreasuryAccountItemInfoV2(b) - return infoB.totalPrice - infoA.totalPrice - }) - .splice( - 0, - Number(process?.env?.MAIN_VIEW_SHOW_MAX_TOP_TOKENS_NUM || accounts.length) - ) + + const [sortedAccounts, setSortedAccounts] = useState([]) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + const sortAccounts = async () => { + try { + setIsLoading(true) + const accounts = [ + ...governedTokenAccountsWithoutNfts, + ...auxiliaryTokenAccounts, + ] + + // Get all account info in parallel + const accountsWithInfo = await Promise.all( + accounts.map(async (account) => ({ + account, + info: await getTreasuryAccountItemInfoV2Async(account) + })) + ) + + // Sort based on the fetched info + const sorted = accountsWithInfo + .sort((a, b) => b.info.totalPrice - a.info.totalPrice) + .map(({ account }) => account) + .splice( + 0, + Number(process?.env?.MAIN_VIEW_SHOW_MAX_TOP_TOKENS_NUM || accounts.length) + ) + + setSortedAccounts(sorted) + } catch (error) { + console.error('Error sorting accounts:', error) + } finally { + setIsLoading(false) + } + } + + sortAccounts() + }, []) + + if (isLoading) { + return
+ } + return (
- {accountsSorted.map((account) => { - return ( - - ) - })} + {sortedAccounts.map((account) => ( + + ))}
) } -export default AccountsItems +export default AccountsItems \ No newline at end of file diff --git a/components/instructions/programs/stake.tsx b/components/instructions/programs/stake.tsx index 4b97a7b49..cbc50f8e6 100644 --- a/components/instructions/programs/stake.tsx +++ b/components/instructions/programs/stake.tsx @@ -17,7 +17,12 @@ export const STAKE_INSTRUCTIONS = { BufferLayout.u32('instruction'), BufferLayout.ns64('lamports'), ]).decode(Buffer.from(_data)) - const rent = await _connection.getMinimumBalanceForRentExemption(200) + + // const rent = await _connection.getMinimumBalanceForRentExemption(200) + + // > solana rent 200 --lamports + // Rent-exempt minimum: 2282880 lamports + const rent = 2282880 return ( <>

diff --git a/hooks/queries/jupiterPrice.ts b/hooks/queries/jupiterPrice.ts index 0c153837c..8fcc90d43 100644 --- a/hooks/queries/jupiterPrice.ts +++ b/hooks/queries/jupiterPrice.ts @@ -2,23 +2,51 @@ import { PublicKey } from '@solana/web3.js' import { useQuery } from '@tanstack/react-query' import queryClient from './queryClient' -const URL = 'https://price.jup.ag/v4/price' +const URL = 'https://api.jup.ag/price/v2' /* example query -GET https://price.jup.ag/v4/price?ids=SOL -response: {"data":{"SOL":{"id":"So11111111111111111111111111111111111111112","mintSymbol":"SOL","vsToken":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","vsTokenSymbol":"USDC","price":26.649616441}},"timeTaken":0.0002587199999766199} +# Unit price of 1 JUP & 1 SOL based on the Derived Price in USDC +https://api.jup.ag/price/v2?ids=JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN,So11111111111111111111111111111111111111112 + +{ + "data": { + "So11111111111111111111111111111111111111112": { + "id": "So11111111111111111111111111111111111111112", + "type": "derivedPrice", + "price": "133.890945000" + }, + "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN": { + "id": "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN", + "type": "derivedPrice", + "price": "0.751467" + } + }, + "timeTaken": 0.00395219 +} */ /* example intentionally broken query -GET https://price.jup.ag/v4/price?ids=bingus -response: {"data":{},"timeTaken":0.00010941000005004753} +curl -X 'GET' 'https://api.jup.ag/price/v2?ids=So11111111111111111111111111111111111111112&showExtraInfo=true' +{ + "data": { + "So11111111111111111111111111111111111111112": { + "id": "So11111111111111111111111111111111111111112", + "type": "derivedPrice", + "price": "134.170633378" + }, + "8agCopCHWdpj7mHk3JUWrzt8pHAxMiPX5hLVDJh9TXWv": null + }, + "timeTaken": 0.003186833 +} */ type Price = { id: string // pubkey, - mintSymbol: string - vsToken: string // pubkey, - vsTokenSymbol: string + // price is in USD price: number + // removed in v2 API + // mintSymbol: string + // vsToken: string // pubkey, + // vsTokenSymbol: string } type Response = { data: Record //uses whatever you input (so, pubkey OR symbol). no entry if data not found @@ -76,11 +104,13 @@ export const getJupiterPriceSync = (mint: PublicKey) => export const useJupiterPricesByMintsQuery = (mints: PublicKey[]) => { const enabled = mints.length > 0 + const deduped = new Set(mints) + const dedupedMints = Array.from(deduped) return useQuery({ enabled, - queryKey: jupiterPriceQueryKeys.byMints(mints), + queryKey: jupiterPriceQueryKeys.byMints(dedupedMints), queryFn: async () => { - const batches = [...chunks(mints, 100)] + const batches = [...chunks(dedupedMints, 100)] const responses = await Promise.all( batches.map(async (batch) => { const x = await fetch(`${URL}?ids=${batch.join(',')}`) @@ -106,7 +136,7 @@ export const useJupiterPricesByMintsQuery = (mints: PublicKey[]) => { return data }, onSuccess: (data) => { - mints.forEach((mint) => + dedupedMints.forEach((mint) => queryClient.setQueryData( jupiterPriceQueryKeys.byMint(mint), data[mint.toString()] @@ -117,3 +147,30 @@ export const useJupiterPricesByMintsQuery = (mints: PublicKey[]) => { }, }) } + +// function is used to get fresh token prices +export const getJupiterPricesByMintStrings = async (mints: string[]) => { + if (mints.length === 0) return {} + const deduped = new Set(mints) + const dedupedMints = Array.from(deduped) + try { + const x = await fetch(`${URL}?ids=${dedupedMints.join(',')}`) + const response = (await x.json()) as Response + const data = response.data + + //override chai price if its broken + const chaiMint = '3jsFX1tx2Z8ewmamiwSU851GzyzM2DJMq7KWW5DM8Py3' + const chaiData = data[chaiMint] + + if (chaiData?.price && (chaiData.price > 1.3 || chaiData.price < 0.9)) { + data[chaiMint] = { + ...chaiData, + price: 1, + } + } + return data + } catch (error) { + console.error('Error fetching Jupiter prices:', error) + throw error + } +} \ No newline at end of file diff --git a/hooks/useLocalStorage.ts b/hooks/useLocalStorage.ts new file mode 100644 index 000000000..8d87fe0a0 --- /dev/null +++ b/hooks/useLocalStorage.ts @@ -0,0 +1,33 @@ +type UseStorageReturnValue = { + getItem: (key: string) => string; + setItem: (key: string, value: string) => boolean; + removeItem: (key: string) => void; + }; + + export const useLocalStorage = (): UseStorageReturnValue => { + const isBrowser: boolean = ((): boolean => typeof window !== "undefined")(); + + const getItem = (key: string): string => { + return isBrowser ? window.localStorage[key] : ""; + }; + + const setItem = (key: string, value: string): boolean => { + if (isBrowser) { + window.localStorage.setItem(key, value); + return true; + } + + return false; + }; + + const removeItem = (key: string): void => { + window.localStorage.removeItem(key); + }; + + return { + getItem, + setItem, + removeItem, + }; + }; + \ No newline at end of file diff --git a/hooks/useTreasuryInfo/convertAccountToAsset.tsx b/hooks/useTreasuryInfo/convertAccountToAsset.tsx index 86611b7f6..a03f0446a 100644 --- a/hooks/useTreasuryInfo/convertAccountToAsset.tsx +++ b/hooks/useTreasuryInfo/convertAccountToAsset.tsx @@ -2,7 +2,7 @@ import { BigNumber } from 'bignumber.js' import { AccountType, AssetAccount } from '@utils/uiTypes/assets' import { AssetType, Asset } from '@models/treasury/Asset' -import { getTreasuryAccountItemInfoV2 } from '@utils/treasuryTools' +import { getTreasuryAccountItemInfoV2Async } from '@utils/treasuryTools' import TokenIcon from '@components/treasuryV2/icons/TokenIcon' import { WSOL_MINT } from '@components/instructions/tools' import { abbreviateAddress } from '@utils/formatting' @@ -10,13 +10,14 @@ import { abbreviateAddress } from '@utils/formatting' import { getAccountAssetCount } from './getAccountAssetCount' import { fetchJupiterPrice } from '@hooks/queries/jupiterPrice' import { getAccountValue, getStakeAccountValue } from './getAccountValue' +import tokenPriceService from '@utils/services/tokenPrice' export const convertAccountToAsset = async ( account: AssetAccount, councilMintAddress?: string, communityMintAddress?: string ): Promise => { - const info = getTreasuryAccountItemInfoV2(account) + const info = await getTreasuryAccountItemInfoV2Async(account) switch (account.type) { case AccountType.AUXILIARY_TOKEN: @@ -65,8 +66,7 @@ export const convertAccountToAsset = async ( ), price: account.extensions.mint ? new BigNumber( - (await fetchJupiterPrice(account.extensions.mint.publicKey)) - .result?.price ?? 0 + await tokenPriceService.fetchTokenPrice(account.extensions.mint.publicKey.toString()) ?? 0 ) : undefined, raw: account, @@ -89,8 +89,7 @@ export const convertAccountToAsset = async ( name: info.accountName || info.info?.name || info.name || info.symbol, price: account.extensions.mint ? new BigNumber( - (await fetchJupiterPrice(account.extensions.mint.publicKey)) - .result?.price ?? 0 + await tokenPriceService.fetchTokenPrice(account.extensions.mint.publicKey.toString()) ?? 0 ) : undefined, raw: account, diff --git a/hooks/useTreasuryInfo/index.tsx b/hooks/useTreasuryInfo/index.tsx index b9f3d8b8a..e28b235f2 100644 --- a/hooks/useTreasuryInfo/index.tsx +++ b/hooks/useTreasuryInfo/index.tsx @@ -67,7 +67,6 @@ export default function useTreasuryInfo( if (!loadingGovernedAccounts && accounts.length && getNftsAndDomains) { setDomainsLoading(true) setBuildingWallets(true) - getDomains( accounts.filter((acc) => acc.isSol), connection.current diff --git a/hub/components/GlobalStats/data/index.ts b/hub/components/GlobalStats/data/index.ts index 6347d7f7d..8146e645a 100644 --- a/hub/components/GlobalStats/data/index.ts +++ b/hub/components/GlobalStats/data/index.ts @@ -275,8 +275,8 @@ export async function fetchData( logger.log('fetching tokens and prices...'); logger.log(`token count: ${tokenAmountMap.size}`); - - await tokenPriceService.fetchSolanaTokenList(); + // already called inside fetchTokenPrices() + // await tokenPriceService.fetchSolanaTokenListV2(); const relevantTokens = Array.from(tokenAmountMap.keys()).filter( (key) => !!tokenAmountMap.get(key)?.isGreaterThan(1), ); diff --git a/pages/api/daoStatistics.api.ts b/pages/api/daoStatistics.api.ts index dc59aa37e..2f43304e6 100644 --- a/pages/api/daoStatistics.api.ts +++ b/pages/api/daoStatistics.api.ts @@ -166,7 +166,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { console.log('fetching tokens and prices...') console.log('token count', tokenAmountMap.size) - await tokenPriceService.fetchSolanaTokenList() + // already called inside fetchTokenPrices() + // await tokenPriceService.fetchSolanaTokenListV2() for (const chunk of chunks([...tokenAmountMap.keys()], 50)) { await tokenPriceService.fetchTokenPrices(chunk) diff --git a/pages/dao/[symbol]/params/components/AccountsView.tsx b/pages/dao/[symbol]/params/components/AccountsView.tsx index 46005357e..94298356d 100644 --- a/pages/dao/[symbol]/params/components/AccountsView.tsx +++ b/pages/dao/[symbol]/params/components/AccountsView.tsx @@ -1,4 +1,4 @@ -import { getTreasuryAccountItemInfoV2 } from '@utils/treasuryTools' +import { getTreasuryAccountItemInfoV2Async } from '@utils/treasuryTools' import { AccountType } from '@utils/uiTypes/assets' import { AddressField } from '../index' import useGovernanceAssets from '@hooks/useGovernanceAssets' @@ -20,8 +20,8 @@ const AccountsView = ({ activeGovernance.pubkey.toBase58() : auxiliaryMode ) - .map((x) => { - const info = getTreasuryAccountItemInfoV2(x) + .map(async (x) => { + const info = await getTreasuryAccountItemInfoV2Async(x) if (x.isToken || x.isSol || x.type === AccountType.AUXILIARY_TOKEN) { return (

{ + const getAssetAccountMetadata = async ( + mintAssetAccount: AssetAccount, + base: boolean + ) => { + const { + logo, + name, + symbol, + displayPrice, + } = await getTreasuryAccountItemInfoV2Async(mintAssetAccount) + if (base) { + setBaseMetadata({ + logo, + name, + symbol, + displayPrice, + decimals: mintAssetAccount.extensions.mint!.account.decimals, + }) + } else { + setQuoteMetadata({ + logo, + name, + symbol, + displayPrice, + decimals: mintAssetAccount.extensions.mint!.account.decimals, + }) + } + } function getInstruction(): Promise { return getConfigInstruction({ connection, @@ -75,37 +104,21 @@ const StakingOption = ({ { governedAccount: governedAccount, getInstruction }, index ) - if (form.baseTreasury && form.baseTreasury.extensions.mint && form.baseTreasury.extensions.mint.account.decimals) { - const { - logo, - name, - symbol, - displayPrice, - } = getTreasuryAccountItemInfoV2(form.baseTreasury) - setBaseMetadata({ - logo, - name, - symbol, - displayPrice, - decimals: form.baseTreasury.extensions.mint.account.decimals - }) + if ( + form.baseTreasury && + form.baseTreasury.extensions.mint && + form.baseTreasury.extensions.mint.account.decimals + ) { + getAssetAccountMetadata(form.baseTreasury, true) } else { setBaseMetadata(undefined) } - if (form.quoteTreasury && form.quoteTreasury.extensions.mint && form.quoteTreasury.extensions.mint.account.decimals) { - const { - logo, - name, - symbol, - displayPrice, - } = getTreasuryAccountItemInfoV2(form.quoteTreasury) - setQuoteMetadata({ - logo, - name, - symbol, - displayPrice, - decimals: form.quoteTreasury.extensions.mint.account.decimals - }) + if ( + form.quoteTreasury && + form.quoteTreasury.extensions.mint && + form.quoteTreasury.extensions.mint.account.decimals + ) { + getAssetAccountMetadata(form.quoteTreasury, false) } else { setQuoteMetadata(undefined) } @@ -255,26 +268,27 @@ const StakingOption = ({ {baseMetadata && quoteMetadata && ( <>
- {form.strike / form.lotSize * 10 ** (-quoteMetadata.decimals + baseMetadata.decimals) * (Number(form.numTokens) / 10 ** baseMetadata.decimals)} - { - currentTarget.onerror = null // prevents looping - currentTarget.hidden = true - }} - /> - = - {Number(form.numTokens) / 10 ** baseMetadata.decimals} - { - currentTarget.onerror = null // prevents looping - currentTarget.hidden = true - }} - /> - + {(form.strike / form.lotSize) * + 10 ** (-quoteMetadata.decimals + baseMetadata.decimals) * + (Number(form.numTokens) / 10 ** baseMetadata.decimals)} + { + currentTarget.onerror = null // prevents looping + currentTarget.hidden = true + }} + /> + ={Number(form.numTokens) / 10 ** baseMetadata.decimals} + { + currentTarget.onerror = null // prevents looping + currentTarget.hidden = true + }} + /> +
)} diff --git a/stores/useGovernanceAssetsStore.tsx b/stores/useGovernanceAssetsStore.tsx index 766079496..2427e584a 100644 --- a/stores/useGovernanceAssetsStore.tsx +++ b/stores/useGovernanceAssetsStore.tsx @@ -546,10 +546,12 @@ const getSolAccountObj = async ( } } - const minRentAmount = await connection.current.getMinimumBalanceForRentExemption( - 0 - ) - + // const minRentAmount = await connection.current.getMinimumBalanceForRentExemption( + // 0 + // ) + // > solana rent 0 --lamports + // Rent-exempt minimum: 890880 lamports + const minRentAmount = 890880 const solAccount = acc as AccountInfoGen solAccount.lamports = diff --git a/utils/services/tokenPrice.tsx b/utils/services/tokenPrice.tsx index 1cc3bf6f0..2a649c187 100644 --- a/utils/services/tokenPrice.tsx +++ b/utils/services/tokenPrice.tsx @@ -7,11 +7,14 @@ import overrides from 'public/realms/token-overrides.json' import { Price, TokenInfo } from './types' import { chunks } from '@utils/helpers' import { USDC_MINT } from '@blockworks-foundation/mango-v4' +import { useLocalStorage } from '@hooks/useLocalStorage' +import { getJupiterPricesByMintStrings } from '@hooks/queries/jupiterPrice' //this service provide prices it is not recommended to get anything more from here besides token name or price. //decimals from metadata can be different from the realm on chain one -const priceEndpoint = 'https://price.jup.ag/v4/price' -const tokenListUrl = 'https://token.jup.ag/strict' +// const priceEndpoint = 'https://price.jup.ag/v4/price' +const tokenListUrl = 'https://tokens.jup.ag/tokens?tags=verified' +const CACHE_TTL_MS = 1000 * 60 * 60 * 24 // 24 hours export type TokenInfoWithoutDecimals = Omit @@ -21,9 +24,12 @@ class TokenPriceService { _tokenPriceToUSDlist: { [mintAddress: string]: Price } + _unverifiedTokenCache: { [mintAddress: string]: TokenInfoWithoutDecimals } + constructor() { this._tokenList = [] this._tokenPriceToUSDlist = {} + this._unverifiedTokenCache = {} } async fetchSolanaTokenList() { try { @@ -48,20 +54,76 @@ class TokenPriceService { }) } } + + async fetchSolanaTokenListV2(): Promise { + const storage = useLocalStorage() + const tokenListRaw = storage.getItem('tokenList') + const ttl = storage.getItem('tokenListTTL') + + let tokenList: TokenInfo[] = [] + + try { + // If the data is not in storage, or the TTL has expired + if (!tokenListRaw || !ttl || Date.now() > Number(ttl)) { + // Fetch fresh data + const response = await axios.get(tokenListUrl) + const tokens = response.data as TokenInfo[] + + if (tokens && tokens.length) { + tokenList = tokens.map((token) => { + const override = overrides[token.address] + + if (override) { + return mergeDeepRight(token, override) + } + + return token + }) + + // Update cache and TTL + storage.setItem('tokenList', JSON.stringify(tokenList)) + storage.setItem('tokenListTTL', String(Date.now() + CACHE_TTL_MS)) + } + } else { + // Use cached data + tokenList = JSON.parse(tokenListRaw) + } + + this._tokenList = tokenList + return tokenList + } catch (e) { + console.log(e) + notify({ + type: 'error', + message: 'Unable to fetch token list', + }) + return [] + } + } + async fetchTokenPrices(mintAddresses: string[]) { if (mintAddresses.length) { + const tokenList = await this.fetchSolanaTokenListV2() + const symbolMap = Object.fromEntries( + tokenList.map((token) => [token.address, token.symbol]) + ) + //can query only 100 at once const mintAddressesWithSol = chunks([...mintAddresses, WSOL_MINT], 100) for (const mintChunk of mintAddressesWithSol) { - const symbols = mintChunk.join(',') try { - const response = await axios.get(`${priceEndpoint}?ids=${symbols}`) - const priceToUsd: Price[] = response?.data?.data - ? Object.values(response.data.data) + const response = await getJupiterPricesByMintStrings(mintChunk) + const priceToUsd: Price[] = response + ? Object.entries(response).map(([address, data]) => ({ + ...data, + mintSymbol: symbolMap[address] || 'Unknown', + vsToken: USDC_MINT.toBase58(), + vsTokenSymbol: 'USDC', + })) : [] + const keyValue = Object.fromEntries( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - Object.entries(priceToUsd).map(([key, val]) => [val.id, val]) + priceToUsd.map((val) => [val.id, val]) ) this._tokenPriceToUSDlist = { @@ -69,12 +131,14 @@ class TokenPriceService { ...keyValue, } } catch (e) { + console.error(e) notify({ type: 'error', message: 'unable to fetch token prices', }) } } + const USDC_MINT_BASE = USDC_MINT.toBase58() if (!this._tokenPriceToUSDlist[USDC_MINT_BASE]) { this._tokenPriceToUSDlist[USDC_MINT_BASE] = { @@ -98,6 +162,36 @@ class TokenPriceService { } } } + async fetchTokenPrice(mintAddress: string): Promise { + try { + if (this._tokenPriceToUSDlist[mintAddress]) { + return this._tokenPriceToUSDlist[mintAddress].price + } + + const response = await getJupiterPricesByMintStrings([mintAddress]) + + if (!response || !response[mintAddress]) { + return null + } + + let tokenInfo: TokenInfoWithoutDecimals | undefined = + this._unverifiedTokenCache[mintAddress] || undefined + if (!tokenInfo) { + tokenInfo = (await this.getTokenInfoAsync(mintAddress)) || undefined + } + this._tokenPriceToUSDlist[mintAddress] = { + id: mintAddress, + mintSymbol: tokenInfo?.symbol || 'Unknown', + price: response[mintAddress].price, + vsToken: USDC_MINT.toBase58(), + vsTokenSymbol: 'USDC', + } + return response[mintAddress].price + } catch (e) { + console.error('Error fetching token price:', e) + return null + } + } /** * @deprecated * seriously do not use this. use fetchJupiterPrice @@ -114,6 +208,64 @@ class TokenPriceService { ) return tokenListRecord } + + // This async method is used to lookup additional tokens not on JUP's strict list + async getTokenInfoAsync( + mintAddress: string + ): Promise { + let tokenInfo: TokenInfoWithoutDecimals | undefined = + this._unverifiedTokenCache[mintAddress] || undefined + + if (tokenInfo) { + return tokenInfo + } + if (!mintAddress || mintAddress.trim() === '') { + return undefined + } + // Check the strict token list first + let tokenListRecord = this._tokenList?.find( + (x) => x.address === mintAddress + ) + if (tokenListRecord) { + return tokenListRecord + } + // Check the unverified token list cache next to avoid repeatedly loading token metadata + if (this._unverifiedTokenCache[mintAddress]) { + return this._unverifiedTokenCache[mintAddress] + } + // Get the token data from JUP's api + try { + const requestURL = `https://tokens.jup.ag/token/${mintAddress}` + const response = await axios.get(requestURL) + if (response.data) { + // Remove decimals and add chainId to match the TokenInfoWithoutDecimals struct + const { decimals, ...tokenInfoWithoutDecimals } = response.data + + const finalTokenInfo = { + ...tokenInfoWithoutDecimals, + chainId: 101, + } + // Add to unverified token cache + this._unverifiedTokenCache[mintAddress] = finalTokenInfo + return finalTokenInfo + } else { + console.error(`Metadata retrieving failed for ${mintAddress}`) + return undefined + } + } catch { + console.error(`Metadata retrieving failed for ${mintAddress}`) + return undefined + } + } + catch(e) { + console.error(e) + notify({ + type: 'error', + message: 'Unable to fetch token information', + }) + return undefined + } + /** * For decimals use on chain tryGetMint */ diff --git a/utils/treasuryTools.tsx b/utils/treasuryTools.tsx index dcb18f659..460c465f8 100644 --- a/utils/treasuryTools.tsx +++ b/utils/treasuryTools.tsx @@ -4,9 +4,20 @@ import { PublicKey } from '@solana/web3.js' import { getMintDecimalAmountFromNatural } from '@tools/sdk/units' import BigNumber from 'bignumber.js' import { abbreviateAddress } from './formatting' -import tokenPriceService from './services/tokenPrice' +import tokenPriceService, { TokenInfoWithoutDecimals } from './services/tokenPrice' import { AccountType, AssetAccount } from './uiTypes/assets' +interface TreasuryAccountInfo { + accountName: string; + amountFormatted: string; + logo: string; + name: string; + displayPrice: string; + info: TokenInfoWithoutDecimals | undefined; + symbol: string; + totalPrice: number; +} + export const getTreasuryAccountItemInfoV2 = (account: AssetAccount) => { const mintAddress = account.type === AccountType.SOL @@ -30,7 +41,6 @@ export const getTreasuryAccountItemInfoV2 = (account: AssetAccount) => { ? new BigNumber(totalPrice).toFormat(0) : '' const info = tokenPriceService.getTokenInfo(mintAddress!) - const symbol = account.type === AccountType.NFT ? 'NFTS' @@ -69,3 +79,101 @@ export const getTreasuryAccountItemInfoV2 = (account: AssetAccount) => { totalPrice, } } +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes in milliseconds + +export const getTreasuryAccountItemInfoV2Async = async (account: AssetAccount) => { + const cacheKey = `tokenAccountInfoItem_${account.pubkey.toString()}`; + + // Check cache first + const cachedData = localStorage.getItem(cacheKey); + const cacheTTL = localStorage.getItem("tokenAccountInfoItem_ttl"); + + // If we have valid cached data, return it + if (cachedData && cacheTTL && Date.now() < Number(cacheTTL)) { + return JSON.parse(cachedData) as TreasuryAccountInfo; + } + + // If no cache or expired, fetch fresh data + // this is safe because we are using a browser local storage + await tokenPriceService.fetchSolanaTokenListV2(); + + const mintAddress = + account.type === AccountType.SOL + ? WSOL_MINT + : account.extensions.mint?.publicKey.toBase58(); + + const amount = + account.extensions.amount && account.extensions.mint + ? getMintDecimalAmountFromNatural( + account.extensions.mint.account, + new BN( + account.isSol + ? account.extensions.solAccount!.lamports + : account.extensions.amount + ) + ).toNumber() + : 0; + + let price = tokenPriceService.getUSDTokenPrice(mintAddress!) || null; + if (!price) { + price = await tokenPriceService.fetchTokenPrice(mintAddress!) || 0; + } + + const totalPrice = amount * price; + const totalPriceFormatted = amount + ? new BigNumber(totalPrice).toFormat(0) + : ''; + + let info = tokenPriceService.getTokenInfo(mintAddress!); + if (!info) { + info = await tokenPriceService.getTokenInfoAsync(mintAddress!); + } + + const symbol = + account.type === AccountType.NFT + ? 'NFTS' + : account.type === AccountType.SOL + ? 'SOL' + : info?.symbol + ? info.address === WSOL_MINT + ? 'wSOL' + : info?.symbol + : account.extensions.mint + ? abbreviateAddress(account.extensions.mint.publicKey) + : ''; + + const amountFormatted = new BigNumber(amount).toFormat(); + const logo = info?.logoURI || ''; + const accountName = account.pubkey ? getAccountName(account.pubkey) : ''; + const name = accountName + ? accountName + : account.extensions.transferAddress + ? abbreviateAddress(account.extensions.transferAddress as PublicKey) + : ''; + + const displayPrice = + totalPriceFormatted && totalPriceFormatted !== '0' + ? totalPriceFormatted + : ''; + + const result: TreasuryAccountInfo = { + accountName, + amountFormatted, + logo, + name, + displayPrice, + info, + symbol, + totalPrice, + }; + + try { + // Store in cache with TTL + localStorage.setItem(cacheKey, JSON.stringify(result)); + localStorage.setItem(`tokenAccountInfoItem_ttl`, String(Date.now() + CACHE_TTL_MS)); + } catch (e) { + console.warn('Failed to cache treasury account info:', e); + } + + return result; +}; \ No newline at end of file