Skip to content
Open
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
15 changes: 12 additions & 3 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions hooks/useBalances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
});

Expand Down
6 changes: 5 additions & 1 deletion hooks/useCardDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
11 changes: 7 additions & 4 deletions hooks/useTotalSavingsUSD.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
74 changes: 50 additions & 24 deletions hooks/useUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -313,7 +331,7 @@ const useUser = (): UseUserReturn => {
},
user: {
id: user._id,
address: smartAccountClient.account.address,
address: derivedSafeAddress,
},
});
}
Expand All @@ -323,7 +341,7 @@ const useUser = (): UseUserReturn => {
}

const selectedUser: User = {
safeAddress: smartAccountClient.account.address,
safeAddress,
walletAddress: user.walletAddress,
username: user.username,
userId: user._id,
Expand Down Expand Up @@ -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',
Expand All @@ -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,
Expand All @@ -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(),
Expand Down
28 changes: 18 additions & 10 deletions hooks/useVault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand Down
34 changes: 28 additions & 6 deletions lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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',
Expand Down Expand Up @@ -1970,7 +1986,9 @@ export const getLifiQuote = async ({
toToken = 'USDC',
order = LifiOrder.FASTEST,
}: GetLifiQuoteParams): Promise<LifiQuoteResponse> => {
const response = await axios.get<LifiQuoteResponse>(`${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<LifiQuoteResponse>(`${EXPO_PUBLIC_LIFI_API_URL}/quote`, {
params: {
fromAddress,
fromChain,
Expand All @@ -1987,11 +2005,15 @@ export const getLifiQuote = async ({
};

export const checkBridgeStatus = async (bridgeTxHash: string): Promise<LifiStatusResponse> => {
const response = await axios.get<LifiStatusResponse>(`${EXPO_PUBLIC_LIFI_API_URL}/status`, {
params: {
txHash: bridgeTxHash,
// externalAxios: third-party host, see getLifiQuote above.
const response = await externalAxios.get<LifiStatusResponse>(
`${EXPO_PUBLIC_LIFI_API_URL}/status`,
{
params: {
txHash: bridgeTxHash,
},
},
});
);

return response?.data;
};
Expand Down
Loading
Loading