diff --git a/app/_layout.tsx b/app/_layout.tsx index 3adb6f0b..76b2f31e 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -68,8 +68,15 @@ Sentry.init({ sendDefaultPii: true, // Performance Monitoring - configured upfront, integrations added later + // + // Profiling and session replay are the two settings here that cost something + // on every frame rather than only when an event is sent, and half of all + // sessions were carrying both at once (plus Amplitude's own replay plugin, see + // `lib/analytics.ts`). On the low-end Android hardware most of our sessions + // run on, that competes with the app itself. A tenth of sessions is still a + // large sample at our volume. tracesSampleRate: 0.5, - profilesSampleRate: 0.5, + profilesSampleRate: 0.1, // Release Health enableAutoSessionTracking: true, @@ -103,8 +110,10 @@ Sentry.init({ return event; }, - // Configure Session Replay - rates set upfront, integration added later - replaysSessionSampleRate: 0.5, + // Configure Session Replay - rates set upfront, integration added later. + // See the profiling note above for why this is no longer half of all sessions; + // replays on error are unaffected, so nothing is lost from an incident. + replaysSessionSampleRate: 0.1, replaysOnErrorSampleRate: 1, // Integrations diff --git a/hooks/useBalances.ts b/hooks/useBalances.ts index ae2b6092..cd0c047c 100644 --- a/hooks/useBalances.ts +++ b/hooks/useBalances.ts @@ -640,14 +640,19 @@ export const useBalances = (): BalanceData => { queryFn: () => fetchTokenBalances(user?.safeAddress!), enabled: !!user?.safeAddress, // TanStack Query handles all the manual logic: - staleTime: 5_000, + staleTime: 30_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, + // SSE handles real-time updates; polling is fallback for missed events or SSE + // failure. One run of this query is 19 parallel requests plus the token + // processing below, so at the previous 5s it was ~228 requests a minute per + // idle client — and the CPU cost landed on exactly the low-end devices that + // could least afford it. `useActivitySSE` invalidates `tokenBalances` + // directly, so a real balance change still lands immediately. + refetchInterval: 60_000, refetchIntervalInBackground: false, // Don't refetch when app is backgrounded (saves battery) }); diff --git a/hooks/useCardDetails.ts b/hooks/useCardDetails.ts index 37f33081..09aa230a 100644 --- a/hooks/useCardDetails.ts +++ b/hooks/useCardDetails.ts @@ -30,7 +30,11 @@ export const useCardDetails = () => { queryFn: () => withRefreshToken(() => getCardBalance()), enabled: !isDummyUser && provider === CardProvider.RAIN && !!detailsQuery.data, retry: false, - refetchInterval: 5000, + // Rain's spending power, polled as a background freshness check rather than + // a live feed — this query is mounted on the home screen too, so at 5s it + // was 12 requests a minute from every session regardless of whether the card + // was on screen. + refetchInterval: 20_000, }); const mergedData = useMemo((): CardDetailsResponseDto | undefined => { diff --git a/hooks/useTotalSavingsUSD.ts b/hooks/useTotalSavingsUSD.ts index b801fa50..cf1c4ce4 100644 --- a/hooks/useTotalSavingsUSD.ts +++ b/hooks/useTotalSavingsUSD.ts @@ -52,19 +52,22 @@ export const useTotalSavingsUSD = (): { data: number | undefined; isLoading: boo const hasFuseBalance = !!fuseVault && (balanceFuse ?? 0) > 0; const hasEthBalance = !!ethVault && (balanceEth ?? 0) > 0; + // Native token prices, used only to value a savings balance in USD. A minute + // of staleness is well inside the rounding of the figure being displayed; at + // 5s these were two more requests every five seconds from every session. const { data: fusePriceUsd, isLoading: isLoadingFusePrice } = useQuery({ queryKey: ['fusePriceUsd'], queryFn: fetchFusePrice, enabled: hasFuseBalance, - staleTime: 5_000, - refetchInterval: 5_000, + staleTime: 60_000, + refetchInterval: 60_000, }); const { data: ethPriceUsd, isLoading: isLoadingEthPrice } = useQuery({ queryKey: ['ethPriceUsd'], queryFn: fetchEthPrice, enabled: hasEthBalance, - staleTime: 5_000, - refetchInterval: 5_000, + staleTime: 60_000, + refetchInterval: 60_000, }); const isLoading = diff --git a/hooks/useUser.ts b/hooks/useUser.ts index 6106f2e0..8bb7d3c4 100644 --- a/hooks/useUser.ts +++ b/hooks/useUser.ts @@ -292,17 +292,35 @@ const useUser = (): UseUserReturn => { // Step 3: Authenticate with our backend using the signed request const user = await login(stamp); - const smartAccountClient = await safeAA(mainnet, user.subOrganizationId, user.walletAddress); + // The login response already carries the account's safe address, so the + // usual path no longer derives it locally. Deriving it means a Turnkey + // `createAccount` round trip plus the RPC calls `toSafeSmartAccount` makes + // — all of it in front of the user, who is watching a spinner, to arrive + // at an address the server just sent us. The smart-account *client* is + // only needed to sign a transaction, and that is built on demand by the + // flows that actually send one. + // + // The fallback below covers the one case that genuinely has no address on + // file, which is the same condition that already triggered the sync. + let safeAddress = user.safeAddress; + + if (!safeAddress) { + const smartAccountClient = await safeAA( + mainnet, + user.subOrganizationId, + user.walletAddress, + ); + // Bound to a const so the closure below captures the derived address + // itself rather than the reassignable outer binding. + const derivedSafeAddress = smartAccountClient.account.address; + safeAddress = derivedSafeAddress; - if (!user.safeAddress || user.safeAddress === '') { console.warn('[useUser] updating safe address on login (missing on user)', { userId: user._id, - safeAddress: smartAccountClient.account.address, + safeAddress: derivedSafeAddress, }); - const resp = await withRefreshToken(() => - updateSafeAddress(smartAccountClient.account.address), - ); + const resp = await withRefreshToken(() => updateSafeAddress(derivedSafeAddress)); if (!resp) { const error = new Error('Error updating safe address on login'); @@ -313,7 +331,7 @@ const useUser = (): UseUserReturn => { }, user: { id: user._id, - address: smartAccountClient.account.address, + address: derivedSafeAddress, }, }); } @@ -323,7 +341,7 @@ const useUser = (): UseUserReturn => { } const selectedUser: User = { - safeAddress: smartAccountClient.account.address, + safeAddress, walletAddress: user.walletAddress, username: user.username, userId: user._id, @@ -359,12 +377,16 @@ const useUser = (): UseUserReturn => { }); } - await checkBalance(selectedUser); + // Started, not awaited. This is a subgraph query whose only job is to set + // `isDeposited` on the stored user; the home screen derives the same flag + // from its own queries, and `usePostSignupInit` runs this again on mount. + // Blocking navigation on it just moved the wait in front of the user. + void checkBalance(selectedUser); // Identify user in analytics with full attribution context trackIdentity(user.userId, { username: user.username, - safe_address: smartAccountClient.account.address, + safe_address: safeAddress, email: user.email, has_referral_code: !!user.referralCode, login_method: 'passkey', @@ -374,25 +396,29 @@ const useUser = (): UseUserReturn => { attribution_channel: getAttributionChannel(attributionData), }); - // Fetch points after successful login - try { - const { fetchPoints } = usePointsStore.getState(); - await fetchPoints(); - } catch (error) { - console.warn('Failed to fetch points:', error); - Sentry.captureException(new Error('Error fetching points'), { - extra: { - error, - }, + // Fetch points after successful login — in the background. Points are not + // on the first screen the user lands on, and the summary behind this is + // one of the slower endpoints we have, so awaiting it here meant the + // login button stayed busy for a number nothing was waiting to render. + // `usePostSignupInit` also fetches these once the app is up. + usePointsStore + .getState() + .fetchPoints() + .catch(error => { + console.warn('Failed to fetch points:', error); + Sentry.captureException(new Error('Error fetching points'), { + extra: { + error, + }, + }); + // Don't fail login if points fetch fails }); - // Don't fail login if points fetch fails - } setLoginInfo({ status: Status.SUCCESS }); track(TRACKING_EVENTS.LOGGED_IN, { user_id: user.userId, username: user.username, - safe_address: smartAccountClient.account.address, + safe_address: safeAddress, has_email: !!user.email, is_deposited: !!user.isDeposited, device_id: deviceId, @@ -403,7 +429,7 @@ const useUser = (): UseUserReturn => { // Update user properties on login with attribution trackIdentity(user.userId, { username: user.username, - safe_address: smartAccountClient.account.address, + safe_address: safeAddress, has_email: !!user.email, is_deposited: !!user.isDeposited, last_login_date: new Date().toISOString(), diff --git a/hooks/useVault.ts b/hooks/useVault.ts index c45623a4..e1dbea8e 100644 --- a/hooks/useVault.ts +++ b/hooks/useVault.ts @@ -10,10 +10,17 @@ import { ADDRESSES } from '@/lib/config'; import { Vault } from '@/lib/types'; import { config } from '@/lib/wagmi'; -// Cache configuration for vault queries -const VAULT_STALE_TIME = secondsToMilliseconds(3); // Consider data fresh for 3 seconds +// Cache configuration for vault queries. +// +// These are a *fallback*, not the live path: `useActivitySSE` invalidates the +// `vault` keys as soon as a balance event arrives (debounced 200ms for +// deposits), so a balance still updates in near real time without polling. +// Polling every 3s only mattered when the stream was down — and at that rate +// each active client was issuing ~100 contract reads a minute, on every screen +// that watches a vault, forever. +const VAULT_STALE_TIME = secondsToMilliseconds(15); // Consider data fresh for 15 seconds const VAULT_GC_TIME = secondsToMilliseconds(300); // Keep in cache for 5 minutes -const VAULT_REFETCH_INTERVAL = secondsToMilliseconds(3); // Poll every 3 seconds for near-realtime updates +const VAULT_REFETCH_INTERVAL = secondsToMilliseconds(30); // Fallback poll; SSE drives live updates export const VAULT = 'vault'; @@ -141,16 +148,17 @@ export const useTotalVaultBalance = (safeAddress: Address) => { return useQuery({ queryKey: [VAULT, 'balanceTotal', safeAddress], queryFn: async () => { - let total = 0; - for (const vault of VAULTS) { - const balances = await Promise.all( + // One round trip for every vault on every chain, rather than a round per + // vault: the reads are independent, so awaiting each vault's group in turn + // made this as slow as the sum of the chains instead of the slowest one. + const balances = await Promise.all( + VAULTS.flatMap(vault => (vault.vaults ?? []).map(v => fetchVaultBalance(queryClient, safeAddress, v.chainId, v.address, vault.decimals), ), - ); - total += balances.reduce((acc, curr) => acc + curr, 0); - } - return total; + ), + ); + return balances.reduce((acc, curr) => acc + curr, 0); }, enabled: !!safeAddress, staleTime: VAULT_STALE_TIME, diff --git a/lib/api.ts b/lib/api.ts index da0d5b13..310cec47 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -169,6 +169,17 @@ const getRefreshToken = (): string | null => { return null; }; +/** The Solid services a request can legitimately expect to be authenticated. */ +const SOLID_API_BASE_URLS = [ + EXPO_PUBLIC_FLASH_API_BASE_URL, + EXPO_PUBLIC_FLASH_ANALYTICS_API_BASE_URL, + EXPO_PUBLIC_FLASH_REWARDS_API_BASE_URL, + EXPO_PUBLIC_FLASH_VAULT_MANAGER_API_BASE_URL, +].filter(Boolean); + +const isSolidApiUrl = (url: string | undefined): boolean => + !!url && SOLID_API_BASE_URLS.some(base => url.startsWith(base)); + // Set up axios interceptor to add headers to all axios requests axios.interceptors.request.use(config => { const platformHeaders = getPlatformHeaders(); @@ -183,7 +194,12 @@ axios.interceptors.request.use(config => { if (jwtToken) { config.headers['Authorization'] = `Bearer ${jwtToken}`; - } else { + } else if (isSolidApiUrl(config.url)) { + // Only a call to one of our own services is *supposed* to carry a token, + // so only that case is worth reporting. Reporting every tokenless request + // meant public endpoints and third-party hosts — which have no business + // holding a Solid access token — raised a warning apiece, burying the + // genuine auth failures this is here to catch. console.error('No JWT token found'); Sentry.captureMessage('No JWT token found', { level: 'warning', @@ -1970,7 +1986,9 @@ export const getLifiQuote = async ({ toToken = 'USDC', order = LifiOrder.FASTEST, }: GetLifiQuoteParams): Promise => { - const response = await axios.get(`${EXPO_PUBLIC_LIFI_API_URL}/quote`, { + // externalAxios: LI.FI is a third party and must not receive the user's Solid + // access token, which the global instance's interceptor attaches on native. + const response = await externalAxios.get(`${EXPO_PUBLIC_LIFI_API_URL}/quote`, { params: { fromAddress, fromChain, @@ -1987,11 +2005,15 @@ export const getLifiQuote = async ({ }; export const checkBridgeStatus = async (bridgeTxHash: string): Promise => { - const response = await axios.get(`${EXPO_PUBLIC_LIFI_API_URL}/status`, { - params: { - txHash: bridgeTxHash, + // externalAxios: third-party host, see getLifiQuote above. + const response = await externalAxios.get( + `${EXPO_PUBLIC_LIFI_API_URL}/status`, + { + params: { + txHash: bridgeTxHash, + }, }, - }); + ); return response?.data; }; diff --git a/lib/data-source.ts b/lib/data-source.ts index c7f5e61c..e20f5600 100644 --- a/lib/data-source.ts +++ b/lib/data-source.ts @@ -4,6 +4,7 @@ import { arbitrum, base, fuse, mainnet, polygon } from 'viem/chains'; import { isAlchemyChain } from '@/constants/alchemy'; import { explorerUrls } from '@/constants/explorers'; import { fetchAlchemyTokenBalances, fetchAlchemyTokenTransfers } from '@/lib/alchemy'; +import { fetchWithTimeout } from '@/lib/fetchWithTimeout'; import { BlockscoutTransactions } from '@/lib/types'; import type { BlockscoutTokenBalance } from '@/hooks/useBalances'; @@ -13,6 +14,39 @@ import type { BlockscoutTokenBalance } from '@/hooks/useBalances'; * Fuse (122) is always Blockscout (not supported by Alchemy). */ +/** + * Deadline for a Blockscout request. + * + * These calls gate the wallet balance skeleton, and nothing times out a bare + * `fetch` on its own — so an explorer that accepts the connection and then + * stops responding used to hold the home screen in its loading state + * indefinitely rather than failing over or rendering what it has. Matches the + * timeout already applied to Alchemy. + */ +const BLOCKSCOUT_REQUEST_TIMEOUT_MS = 10_000; + +/** + * A dedicated axios instance for Blockscout. + * + * The global axios in `lib/api.ts` carries a request interceptor that attaches + * the user's Solid backend JWT to every request on iOS/Android. Blockscout is a + * third-party explorer that has no use for that token and should never receive + * it — the same reasoning behind `externalAxios` and `alchemyAxios`. Declared + * here rather than imported from `lib/api.ts` because that module imports this + * one, so sharing the instance would close an import cycle. + * + * `axios.create()` starts with no interceptors, so this sends neither the JWT + * nor the platform headers, and carries its own deadline. + */ +const blockscoutAxios = axios.create({ timeout: BLOCKSCOUT_REQUEST_TIMEOUT_MS }); + +/** + * A Blockscout instance that is up but slow should not cost the user the + * balances every *other* chain already returned. `fetchTokenBalances` collects + * these with `Promise.allSettled`, so a rejection here degrades one chain; + * hanging degrades the whole screen. + */ + const BLOCKSCOUT_URLS: Record = { [mainnet.id]: 'https://eth.blockscout.com', [base.id]: 'https://base.blockscout.com', @@ -29,9 +63,11 @@ const fetchBlockscoutTokenBalances = async ( ): Promise => { const url = blockscoutUrlForChain(chainId); if (!url) return []; - const response = await fetch(`${url}/api/v2/addresses/${address}/token-balances`, { - headers: { accept: 'application/json' }, - }); + const response = await fetchWithTimeout( + `${url}/api/v2/addresses/${address}/token-balances`, + { headers: { accept: 'application/json' } }, + BLOCKSCOUT_REQUEST_TIMEOUT_MS, + ); if (!response.ok) { throw new Error(`Blockscout token-balances ${response.status} for chain ${chainId}`); } @@ -55,7 +91,7 @@ const fetchBlockscoutTokenTransfers = async ({ const params: string[] = ['type=ERC-20']; if (filter) params.push(`filter=${filter}`); if (token) params.push(`token=${token}`); - const response = await axios.get( + const response = await blockscoutAxios.get( `${url}/api/v2/addresses/${address}/token-transfers?${params.join('&')}`, ); return response.data; diff --git a/lib/wagmi.ts b/lib/wagmi.ts index ba909cfb..bf54c344 100644 --- a/lib/wagmi.ts +++ b/lib/wagmi.ts @@ -2,15 +2,7 @@ import { Platform } from 'react-native'; import { Chain, createPublicClient } from 'viem'; import { createConfig, http } from 'wagmi'; import { getWalletClient } from 'wagmi/actions'; -import { - arbitrum, - base, - baseSepolia, - bsc, - fuse, - mainnet, - polygon, -} from 'wagmi/chains'; +import { arbitrum, base, baseSepolia, bsc, fuse, mainnet, polygon } from 'wagmi/chains'; import { EXPO_PUBLIC_ALCHEMY_API_KEY } from './config'; @@ -42,12 +34,40 @@ const transports: Record> = { [bsc.id]: http(rpcUrls[bsc.id]), }; -export const publicClient = (chainId: number) => +/** + * Public clients, one per chain, built on first use and then reused. + * + * This used to construct a fresh client (and a fresh transport with it) on every + * call. `fetchTokenBalances` alone calls it seven times per run, and that run + * repeats on a timer — so the app was rebuilding the same object graph + * continuously, and each throwaway transport also threw away whatever request + * de-duplication it had accumulated. + * + * Keyed by chain id; the client is stateless with respect to the caller, so + * sharing one is safe and is what viem expects. + */ +const createChainClient = (chainId: number) => createPublicClient({ chain: chains.find(chain => chain.id === chainId), transport: http(rpcUrls[chainId]), }); +// Typed off `createChainClient` rather than `createPublicClient` so the cache +// carries the precise client type viem infers from these arguments. Naming the +// generic-erased `ReturnType` here would widen it, +// and callers that pass the client on — `toSafeSmartAccount`, for one — reject +// the widened type. +const publicClients = new Map>(); + +export const publicClient = (chainId: number) => { + const cached = publicClients.get(chainId); + if (cached) return cached; + + const client = createChainClient(chainId); + publicClients.set(chainId, client); + return client; +}; + export const getWallet = (chainId: number) => { return getWalletClient(config, { chainId }); }; @@ -55,7 +75,17 @@ export const getWallet = (chainId: number) => { export const config = createConfig({ chains, transports, - // batch: { multicall: true }, + // Multicall batching is deliberately not set here: `createConfig` already + // defaults `batch` to `{ multicall: true }` (see `@wagmi/core`'s + // `createConfig`, `batch: properties.batch ?? { multicall: true }`), so every + // `eth_call` routed through this config — the vault `balanceOf` reads, the + // accountant `getRate` reads — is already aggregated per chain. Passing it + // explicitly would change nothing. + // + // Note this only covers calls made *through this config*. The standalone + // clients from `publicClient()` above are plain viem clients and batch + // nothing; that is fine for what they do (`eth_getBalance` is not + // multicall-able, and their `eth_call`s are one per chain). }); export const fuseConfig = createConfig({ diff --git a/metro.config.js b/metro.config.js index d2510a69..75dccb95 100644 --- a/metro.config.js +++ b/metro.config.js @@ -83,5 +83,56 @@ config.transformer.minifierConfig = { }, }; +/** + * Defer module evaluation until a module is first used. + * + * Sentry's `app.start.cold` spans put ~94% of a cold start in "JS Bundle + * Execution Before React Root" — the app is not waiting on the network at that + * point, it is evaluating every module in the graph, because without this Metro + * hoists every `require` to the top of its module. That means thirdweb, viem, + * wagmi, permissionless, the Turnkey and Sumsub SDKs, Intercom, Lottie and the + * rest are all constructed before the first screen renders, whether or not the + * user will ever reach a screen that needs them. + * + * `inlineRequires` rewrites each `require` to its first point of use, so a + * module is evaluated when something actually reads from it. + * + * The trade-off is import-time side effects: a module imported purely so its + * top-level code runs, and whose exports are never referenced, would never be + * evaluated. `nonInlinedRequires` pins those. The list below is Metro's default + * set plus this app's own ordering-sensitive imports — see `index.js`, which + * deliberately loads Reanimated, then the crypto polyfill, then the thirdweb + * adapter, before Expo Router. + */ +config.transformer.getTransformOptions = async () => ({ + transform: { + // Matches the Expo default (see @expo/metro-config's ExpoMetroConfig); + // repeated here because returning this object replaces Expo's own + // getTransformOptions rather than merging with it. + experimentalImportSupport: true, + inlineRequires: true, + nonInlinedRequires: [ + // Metro's defaults — the module registry and React's runtime must be + // initialised eagerly. + '@react-native/js-polyfills', + 'React', + 'react', + 'react-compiler-runtime', + 'react/jsx-dev-runtime', + 'react/jsx-runtime', + 'react-native', + // Ordering-sensitive polyfills from index.js. Each is imported for the + // side effect of installing globals that later modules assume are + // present, so none may be deferred to first use. + 'react-native-reanimated', + 'react-native-get-random-values', + 'react-native-quick-crypto', + '@thirdweb-dev/react-native-adapter', + // Installs the global CSS interop that NativeWind's styling depends on. + 'react-native-css-interop', + ], + }, +}); + // inlineRem: NativeWind defaults to 14px on native. Set to 16 to match web rem sizing. module.exports = withNativeWind(config, { input: './global.css', inlineRem: 16 });