Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion constants/alchemy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,34 @@ export const ALCHEMY_NETWORKS: Record<number, string> = {
[bsc.id]: 'bnb-mainnet',
};

/**
* Chains the backend's price route (`POST /accounts/v1/prices`) prices by
* address, its own `ALCHEMY_PRICE_NETWORKS`. The route leaves out a chain it
* doesn't know, which the app would take for "no price", so tokens on a chain
* added to `ALCHEMY_NETWORKS` before the backend has it go to Alchemy directly.
*/
export const BACKEND_PRICE_CHAIN_IDS: ReadonlySet<number> = new Set([
mainnet.id,
base.id,
polygon.id,
arbitrum.id,
bsc.id,
]);

export const ALCHEMY_PRICES_URL = `https://api.g.alchemy.com/prices/v1/${EXPO_PUBLIC_ALCHEMY_API_KEY}/tokens`;

/** Addresses per `tokens/by-address` request — Alchemy rejects more than this. */
/**
* Symbols per `tokens/by-symbol` request, and addresses per `tokens/by-address`
* request — Alchemy rejects more than this. A request costs the same 40 CU and
* one unit of the hourly token_price quota however many it carries.
*/
export const ALCHEMY_PRICE_BATCH_SIZE = 25;

/**
* Distinct networks per `tokens/by-address` request, per Alchemy's docs. The
* endpoint accepts more today, but a request past a documented limit is one
* enforcement change away from failing whole.
*/
export const ALCHEMY_PRICE_MAX_NETWORKS = 3;

export const ALCHEMY_REQUEST_TIMEOUT_MS = 10_000;
72 changes: 33 additions & 39 deletions hooks/useBalances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
fetchCoinSimplePrice,
fetchTokenList,
fetchTokenPricesByAddress,
fetchTokenPriceUsd,
fetchTokenPricesBySymbol,
} from '@/lib/api';
import { ADDRESSES } from '@/lib/config';
import { fetchTokenBalancesWithFallback } from '@/lib/data-source';
Expand Down Expand Up @@ -108,9 +108,10 @@ const symbols = {

/**
* Native-token USD price per chain. Uses the shared fetcher (Alchemy by symbol,
* CoinGecko on failure) rather than a bare Alchemy call: FUSE's "symbol" here is
* a CoinGecko coin id that Alchemy's by-symbol endpoint never resolves, so on
* its own it yields no price at all.
* CoinGecko on failure) rather than a bare Alchemy call, so a chain still gets
* a price while Alchemy has none or is rate-limiting us. The five lookups start
* in the same tick, so they ride one Prices API request — ETH is asked for once,
* not once per chain — and are served from cache for the next minute.
*/
const NATIVE_PRICE_FETCHERS: Record<number, () => Promise<string | undefined>> = {
[mainnet.id]: makeNativePriceFetcher(mainnet.id),
Expand Down Expand Up @@ -571,7 +572,8 @@ const fetchTokenBalances = async (safeAddress: string) => {
}
}

// Fallback 3: Alchemy by symbol for tokens still at 0 (no tokenId)
// Fallback 3: Alchemy by symbol for tokens still at 0 (no tokenId). One
// request carries up to 25 symbols; this used to be a request per symbol.
const stillZero = allTokens.filter(
t =>
isZeroRate(t.quoteRate) &&
Expand All @@ -580,25 +582,13 @@ const fetchTokenBalances = async (safeAddress: string) => {
);
const symbolsToFetch = [...new Set(stillZero.map(t => t.contractTickerSymbol))];
if (symbolsToFetch.length > 0) {
try {
const results = await Promise.allSettled(symbolsToFetch.map(s => fetchTokenPriceUsd(s)));
const symbolToPrice: Record<string, number> = {};
symbolsToFetch.forEach((sym, i) => {
const r = results[i];
if (r.status === 'fulfilled') {
const p = parsePrice(r.value);
if (p != null && p > 0) symbolToPrice[sym] = p;
}
});
allTokens = allTokens.map(t => {
if (!isZeroRate(t.quoteRate) || isUnderlyingPricedShare(t.contractAddress)) return t;
const p = t.contractTickerSymbol && symbolToPrice[t.contractTickerSymbol];
if (typeof p === 'number') return { ...t, quoteRate: p };
return t;
});
} catch (e) {
console.warn('Alchemy fallback price failed:', e);
}
const symbolToPrice = await fetchTokenPricesBySymbol(symbolsToFetch);
allTokens = allTokens.map(t => {
if (!isZeroRate(t.quoteRate) || isUnderlyingPricedShare(t.contractAddress)) return t;
const p = t.contractTickerSymbol && symbolToPrice[t.contractTickerSymbol];
if (typeof p === 'number') return { ...t, quoteRate: p };
return t;
});
}

// Helper function to calculate token value
Expand Down Expand Up @@ -732,22 +722,34 @@ const EMPTY_BALANCE_DATA = {
unifiedTokens: [] as UnifiedTokenBalance[],
};

/** Shared by useBalances and the protected layout's prefetch. */
export const tokenBalancesQueryOptions = (safeAddress: string | undefined) => ({
queryKey: ['tokenBalances', safeAddress],
queryFn: () => fetchTokenBalances(safeAddress!),
enabled: !!safeAddress,
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
});

/**
* SSE handles real-time updates (useActivitySSE invalidates ['tokenBalances']
* on every balance event); polling is the fallback for missed events or SSE
* failure. The price lookups inside a refresh are cached for a minute, so this
* interval doesn't drive Prices API usage.
*/
const BALANCES_POLL_INTERVAL_MS = 5_000;

export const useBalances = (): BalanceData => {
const { user } = useUser();

const { data, isLoading, isRefetching, error, refetch } = useQuery({
queryKey: ['tokenBalances', user?.safeAddress],
queryFn: () => fetchTokenBalances(user?.safeAddress!),
enabled: !!user?.safeAddress,
// TanStack Query handles all the manual logic:
...tokenBalancesQueryOptions(user?.safeAddress),
staleTime: 5_000,
gcTime: 5 * 60 * 1000, // 5 minutes - data stays in cache for 5 minutes when unused
retry: 3, // retry up to 3 times on failure
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff
refetchOnWindowFocus: true, // refetch when user returns to tab
refetchOnReconnect: true, // refetch when network reconnects
// SSE handles real-time updates; polling is fallback for missed events or SSE failure
refetchInterval: 5_000,
refetchInterval: BALANCES_POLL_INTERVAL_MS,
refetchIntervalInBackground: false, // Don't refetch when app is backgrounded (saves battery)
});

Expand All @@ -761,11 +763,3 @@ export const useBalances = (): BalanceData => {
retry: refetch,
};
};

export const tokenBalancesQueryOptions = (safeAddress: string | undefined) => ({
queryKey: ['tokenBalances', safeAddress],
queryFn: () => fetchTokenBalances(safeAddress!),
enabled: !!safeAddress,
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
});
3 changes: 3 additions & 0 deletions hooks/useEstimateGas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ async function estimateGasCost(
const fastGasPrice = (baseGasPrice * 195n) / 100n; // 85% above base for fast transactions

const tokenPriceUsd = await fetchTokenPriceUsd(token);
// The price lookup reports "no price" instead of throwing; fail the query as
// before, so the cost shows as 0 rather than NaN.
if (tokenPriceUsd === undefined) throw new Error(`No USD price for ${token}`);

const gasCostInWei = gasEstimate * fastGasPrice;

Expand Down
25 changes: 18 additions & 7 deletions hooks/useNativePriceUsd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import { useQuery } from '@tanstack/react-query';
import { NATIVE_COINGECKO_TOKENS, NATIVE_TOKENS } from '@/constants/tokens';
import { fetchCoinSimplePrice, fetchTokenPriceUsd } from '@/lib/api';

/**
* How often native-token prices refresh. Polling this often costs no Prices API
* quota of its own: the price lookups are cached for a minute, in the app and
* by the backend's shared cache.
*/
export const NATIVE_PRICE_REFRESH_MS = 5_000;

/**
* Native-token USD price fetcher for a chain (Alchemy first, CoinGecko fallback).
* Shared by useTotalSavingsUSD (FUSE + ETH terms) and the per-vault savings card.
Expand All @@ -24,17 +31,21 @@ export const makeNativePriceFetcher =
};

/**
* USD price of a chain's native token as a number (0 when disabled/unavailable).
* `queryKey` is shared with useTotalSavingsUSD ('fusePriceUsd' / 'ethPriceUsd')
* so the two hooks reuse one cached request.
* Query for a chain's native-token USD price. `queryKey` is shared by every
* caller for the same chain ('fusePriceUsd' / 'ethPriceUsd'), so they all read
* one cached request.
*/
export const useNativePriceUsd = (chainId: number, queryKey: string, enabled: boolean): number => {
const { data } = useQuery({
export const useNativePriceQuery = (chainId: number, queryKey: string, enabled: boolean) =>
useQuery({
queryKey: [queryKey],
queryFn: makeNativePriceFetcher(chainId),
enabled,
staleTime: 5_000,
refetchInterval: 5_000,
staleTime: NATIVE_PRICE_REFRESH_MS,
refetchInterval: NATIVE_PRICE_REFRESH_MS,
});

/** USD price of a chain's native token as a number (0 when disabled/unavailable). */
export const useNativePriceUsd = (chainId: number, queryKey: string, enabled: boolean): number => {
const { data } = useNativePriceQuery(chainId, queryKey, enabled);
return enabled ? Number(data) || 0 : 0;
};
5 changes: 3 additions & 2 deletions hooks/useToken.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Address, erc20Abi, formatUnits } from 'viem';
import { mainnet } from 'viem/chains';
import { readContractQueryOptions } from 'wagmi/query';
Expand All @@ -21,7 +21,8 @@ type Balance = {
export const tokenPriceUsdQueryOptions = (tokenId: string) => {
return {
queryKey: ['tokenPriceUsd', tokenId],
queryFn: () => fetchTokenPriceUsd(tokenId),
// null, not undefined: TanStack Query rejects undefined as query data.
queryFn: async () => (await fetchTokenPriceUsd(tokenId)) ?? null,
enabled: !!tokenId,
};
};
Expand Down
30 changes: 11 additions & 19 deletions hooks/useTotalSavingsUSD.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Address } from 'viem';
import { fuse, mainnet } from 'viem/chains';

import { VAULTS } from '@/constants/vaults';
import { makeNativePriceFetcher } from '@/hooks/useNativePriceUsd';
import { useNativePriceQuery } from '@/hooks/useNativePriceUsd';
import useUser from '@/hooks/useUser';
import { useVaultBalance } from '@/hooks/useVault';
import { useVaultExchangeRate } from '@/hooks/useVaultExchangeRate';
Expand All @@ -15,9 +14,6 @@ const usdcVault = ACTIVE_VAULTS[0];
const fuseVault = ACTIVE_VAULTS[1];
const ethVault = ACTIVE_VAULTS[2];

const fetchFusePrice = makeNativePriceFetcher(fuse.id);
const fetchEthPrice = makeNativePriceFetcher(mainnet.id);

/**
* Total redeemable savings in USD across ALL vaults: soUSD + soFUSE + soETH.
* USDC uses its soUSD→USD rate directly; FUSE and ETH multiply their
Expand Down Expand Up @@ -59,20 +55,16 @@ export const useTotalSavingsUSD = (): {
const hasFuseBalance = !!fuseVault && (balanceFuse ?? 0) > 0;
const hasEthBalance = !!ethVault && (balanceEth ?? 0) > 0;

const { data: fusePriceUsd, isLoading: isLoadingFusePrice } = useQuery({
queryKey: ['fusePriceUsd'],
queryFn: fetchFusePrice,
enabled: hasFuseBalance,
staleTime: 5_000,
refetchInterval: 5_000,
});
const { data: ethPriceUsd, isLoading: isLoadingEthPrice } = useQuery({
queryKey: ['ethPriceUsd'],
queryFn: fetchEthPrice,
enabled: hasEthBalance,
staleTime: 5_000,
refetchInterval: 5_000,
});
const { data: fusePriceUsd, isLoading: isLoadingFusePrice } = useNativePriceQuery(
fuse.id,
'fusePriceUsd',
hasFuseBalance,
);
const { data: ethPriceUsd, isLoading: isLoadingEthPrice } = useNativePriceQuery(
mainnet.id,
'ethPriceUsd',
hasEthBalance,
);

const isLoading =
isLoadingBalanceUsdc ||
Expand Down
6 changes: 5 additions & 1 deletion hooks/useVault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ export const fetchVaultBalance = async (
args: [safeAddress],
chainId: chainId,
}),
staleTime: VAULT_STALE_TIME,
// Always read the chain (concurrent reads of one vault still share a
// request): the vault queries are refetched when SSE reports a balance
// change, and a read cached from just before the transaction would answer
// that refetch with the old balance until the next poll.
staleTime: 0,
});

return Number(formatUnits(balance, decimals)) || 0;
Expand Down
Loading
Loading