diff --git a/src/app/actions/tokens.ts b/src/app/actions/tokens.ts index f0d6ad166..0b7335cd4 100644 --- a/src/app/actions/tokens.ts +++ b/src/app/actions/tokens.ts @@ -2,10 +2,10 @@ import { unstable_cache } from 'next/cache' import { fetchWithSentry, isAddressZero, estimateIfIsStableCoinFromPrice } from '@/utils' import { type ITokenPriceData } from '@/interfaces' -import { parseAbi } from 'viem' +import { parseAbi, formatUnits } from 'viem' import { type ChainId, getPublicClient } from '@/app/actions/clients' -import { getTokenDetails, NATIVE_TOKEN_ADDRESS, isStableCoin } from '@/utils' -import { formatUnits } from 'viem' +import { getTokenDetails, isStableCoin, NATIVE_TOKEN_ADDRESS, areEvmAddressesEqual } from '@/utils' +import { IUserBalance } from '@/interfaces' import type { Address, Hex } from 'viem' type IMobulaMarketData = { @@ -51,6 +51,49 @@ type IMobulaMarketData = { } } +type IMobulaContractBalanceData = { + address: string //of the contract + balance: number + balanceRaw: string + chainId: string // this chainId is og the type evm: + decimals: number +} + +type IMobulaCrossChainBalanceData = { + balance: number + balanceRaw: string + chainId: string + address: string //of the token +} + +type IMobulaAsset = { + id: number + name: string + symbol: string + logo: string + decimals: string[] + contracts: string[] + blockchains: string[] +} + +type IMobulaAssetData = { + contracts_balances: IMobulaContractBalanceData[] + cross_chain_balances: Record // key is the same as in asset.blockchains price_change_24h: number + estimated_balance: number + price: number + token_balance: number + allocation: number + asset: IMobulaAsset + wallets: string[] +} + +type IMobulaPortfolioData = { + total_wallet_balance: number + wallets: string[] + assets: IMobulaAssetData[] + balances_length: number +} + const ERC20_DATA_ABI = parseAbi([ 'function symbol() view returns (string)', 'function name() view returns (string)', @@ -214,3 +257,61 @@ export async function estimateTransactionCostUsd( return 0.01 } } + +export const fetchWalletBalances = unstable_cache( + async (address: string): Promise<{ balances: IUserBalance[]; totalBalance: number }> => { + const mobulaResponse = await fetchWithSentry(`https://api.mobula.io/api/1/wallet/portfolio?wallet=${address}`, { + headers: { + 'Content-Type': 'application/json', + authorization: process.env.MOBULA_API_KEY!, + }, + }) + + if (!mobulaResponse.ok) throw new Error('Failed to fetch wallet balances') + + const json: { data: IMobulaPortfolioData } = await mobulaResponse.json() + const assets = json.data.assets + .filter((a: IMobulaAssetData) => !!a.price) + .filter((a: IMobulaAssetData) => !!a.token_balance) + const balances = [] + for (const asset of assets) { + const symbol = asset.asset.symbol + const price = isStableCoin(symbol) || estimateIfIsStableCoinFromPrice(asset.price) ? 1 : asset.price + /* + Mobula returns balances per asset, IE: USDC on arbitrum, mainnet + and optimism are all part of the same "asset", here we need to + divide it + */ + for (const chain of asset.asset.blockchains) { + const address = asset.cross_chain_balances[chain].address + const contractInfo = asset.contracts_balances.find((c) => areEvmAddressesEqual(c.address, address)) + const crossChainBalance = asset.cross_chain_balances[chain] + balances.push({ + chainId: crossChainBalance.chainId, + address, + name: asset.asset.name, + symbol, + decimals: contractInfo!.decimals, + price, + amount: crossChainBalance.balance, + currency: 'usd', + logoURI: asset.asset.logo, + value: (crossChainBalance.balance * price).toString(), + }) + } + } + const totalBalance = balances.reduce( + (acc: number, balance: IUserBalance) => acc + balance.amount * balance.price, + 0 + ) + return { + balances, + totalBalance, + } + }, + ['fetchWalletBalances'], + { + tags: ['fetchWalletBalances'], + revalidate: 5, // 5 seconds + } +) diff --git a/src/app/api/walletconnect/fetch-wallet-balance/route.ts b/src/app/api/walletconnect/fetch-wallet-balance/route.ts deleted file mode 100644 index 740234417..000000000 --- a/src/app/api/walletconnect/fetch-wallet-balance/route.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { NextRequest } from 'next/server' -import { fetchWithSentry } from '@/utils' - -export async function POST(request: NextRequest) { - try { - const body = await request.json() - const projectID = process.env.NEXT_PUBLIC_WC_PROJECT_ID ?? '' - - if (!projectID) throw new Error('API_KEY not found in env') - - const apiResponse = await fetchWithSentry( - `https://rpc.walletconnect.com/v1/account/${body.address}/balance?currency=usd&projectId=${projectID}`, - { - method: 'GET', - // mode: 'no-cors', // Enable this locally - headers: { - 'Content-Type': 'application/json', - 'x-sdk-version': '4.1.5', - }, - } - ) - - if (!apiResponse.ok) return new Response('Internal Server Error', { status: 500 }) - - const apiResponseJson = await apiResponse.text() - - return new Response(apiResponseJson, { - status: 200, - headers: { - 'Content-Type': 'application/json', - }, - }) - } catch (error) { - console.error('Error in fetching wallet balance:', error) - return new Response('Internal Server Error', { status: 500 }) - } -} diff --git a/src/components/Global/TokenSelector/TokenSelector.tsx b/src/components/Global/TokenSelector/TokenSelector.tsx index 064fd411a..76f719789 100644 --- a/src/components/Global/TokenSelector/TokenSelector.tsx +++ b/src/components/Global/TokenSelector/TokenSelector.tsx @@ -11,7 +11,7 @@ import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.co import { tokenSelectorContext } from '@/context' import { useDynamicHeight } from '@/hooks/ui/useDynamicHeight' import { IToken, IUserBalance } from '@/interfaces' -import { areEvmAddressesEqual, fetchWalletBalances, formatTokenAmount, isNativeCurrency } from '@/utils' +import { areEvmAddressesEqual, formatTokenAmount, isNativeCurrency } from '@/utils' import { SQUID_ETH_ADDRESS } from '@/utils/token.utils' import { useAppKit, useAppKitAccount, useDisconnect } from '@reown/appkit/react' import EmptyState from '../EmptyStates/EmptyState' @@ -26,6 +26,7 @@ import { TOKEN_SELECTOR_POPULAR_NETWORK_IDS, TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS, } from './TokenSelector.consts' +import { fetchWalletBalances } from '@/app/actions/tokens' interface SectionProps { title: string diff --git a/src/utils/balance.utils.ts b/src/utils/balance.utils.ts index 227e2953d..1b644c002 100644 --- a/src/utils/balance.utils.ts +++ b/src/utils/balance.utils.ts @@ -1,88 +1,7 @@ import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants' import { ChainValue, IUserBalance } from '@/interfaces' -import { areEvmAddressesEqual, fetchWithSentry, isAddressZero } from '@/utils' import * as Sentry from '@sentry/nextjs' import { formatUnits } from 'viem' -import { NATIVE_TOKEN_ADDRESS } from './token.utils' - -export async function fetchWalletBalances( - address: string -): Promise<{ balances: IUserBalance[]; totalBalance: number }> { - try { - const apiResponse = await fetchWithSentry('/api/walletconnect/fetch-wallet-balance', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ address }), - }) - - if (!apiResponse.ok) { - throw new Error('API request failed') - } - - const apiResponseJson = await apiResponse.json() - - const processedBalances = apiResponseJson.balances - .filter((balance: any) => balance.value > 0.009) - .map((item: any) => ({ - chainId: item?.chainId ? item.chainId.split(':')[1] : '1', - address: item?.address ? item.address.split(':')[2] : NATIVE_TOKEN_ADDRESS, - name: item.name, - symbol: item.symbol, - decimals: parseInt(item.quantity.decimals), - price: item.price, - amount: parseFloat(item.quantity.numeric), - currency: 'usd', - logoURI: item.iconUrl, - value: item.value.toString(), - })) - .map((balance: any) => - balance.chainId === '8508132' - ? { ...balance, chainId: '534352' } - : balance.chainId === '81032' - ? { ...balance, chainId: '81457' } - : balance.chainId === '59160' - ? { ...balance, chainId: '59144' } - : balance - ) - .sort((a: any, b: any) => { - const valueA = parseFloat(a.value) - const valueB = parseFloat(b.value) - - if (valueA === valueB) { - if (isAddressZero(a.address)) return -1 - if (isAddressZero(b.address)) return 1 - return b.amount - a.amount - } - return valueB - valueA - }) - - const totalBalance = processedBalances.reduce((acc: number, balance: any) => acc + Number(balance.value), 0) - - return { - balances: processedBalances, - totalBalance, - } - } catch (error) { - console.error('Error fetching wallet balances:', error) - if (error instanceof Error && error.message !== 'API request failed') { - Sentry.captureException(error) - } - return { balances: [], totalBalance: 0 } - } -} - -export function balanceByToken( - balances: IUserBalance[], - chainId: string, - tokenAddress: string -): IUserBalance | undefined { - if (!chainId || !tokenAddress) return undefined - return balances.find( - (balance) => balance.chainId === chainId && areEvmAddressesEqual(balance.address, tokenAddress) - ) -} export function calculateValuePerChain(balances: IUserBalance[]): ChainValue[] { let result: ChainValue[] = []