From 795af31f73d3e7864f0230e08e35b43f39c7406f Mon Sep 17 00:00:00 2001 From: gudnuf Date: Tue, 28 Apr 2026 10:57:34 -0700 Subject: [PATCH] fix(receive): probe mint reachability in route loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Cashu token's source mint is unreachable, detect it in the clientLoader before mounting the receive component, so the receive flow itself never has to know about offline mints. The loader pre-fetches mintInfo, allMintKeysets, and mintKeys with a 10s timeout race. On NetworkError it returns a discriminated { kind: 'mint-offline', mintUrl } and the component renders an inline . On success the queryClient cache is warm, so getInitializedCashuWallet resolves from cache when the component mounts — no race window where the wallet ends up with unloaded mint info. Same pattern in both the protected and public token-receive routes. TokenErrorDisplay is lifted into its own file so the routes can import it without pulling in receive-cashu-token internals. Fixes Sentry: AGICASH-9S, 9Y, 93, 7G, 7F. --- ...e.tsx => unclaimable-cashu-token-page.tsx} | 2 +- app/features/shared/cashu.ts | 51 +++++++++++-------- .../_protected.receive.cashu_.token.tsx | 35 ++++++++++--- app/routes/_public.receive-cashu-token.tsx | 33 +++++++++--- 4 files changed, 87 insertions(+), 34 deletions(-) rename app/features/receive/{unsupported-cashu-token-page.tsx => unclaimable-cashu-token-page.tsx} (88%) diff --git a/app/features/receive/unsupported-cashu-token-page.tsx b/app/features/receive/unclaimable-cashu-token-page.tsx similarity index 88% rename from app/features/receive/unsupported-cashu-token-page.tsx rename to app/features/receive/unclaimable-cashu-token-page.tsx index ad83fe538..6f5e2aa02 100644 --- a/app/features/receive/unsupported-cashu-token-page.tsx +++ b/app/features/receive/unclaimable-cashu-token-page.tsx @@ -10,7 +10,7 @@ type Props = { message: string; }; -export function UnsupportedCashuTokenPage({ message }: Props) { +export function UnclaimableCashuTokenPage({ message }: Props) { return ( diff --git a/app/features/shared/cashu.ts b/app/features/shared/cashu.ts index 3e9548854..d587e9b45 100644 --- a/app/features/shared/cashu.ts +++ b/app/features/shared/cashu.ts @@ -254,6 +254,34 @@ export const mintKeysQueryOptions = (mintUrl: string, keysetId?: string) => staleTime: 1000 * 60 * 60, // 1 hour }); +/** + * Fetches mint info, keysets, and active keys with a 10s timeout. Cancels any + * in-flight queries on timeout so they don't populate the cache after the fact. + * Throws `NetworkError` if the mint is unreachable or the timeout elapses. + */ +export async function fetchMintDataWithTimeout( + mintUrl: string, + queryClient: QueryClient, +): Promise<[ExtendedMintInfo, GetKeysetsResponse, GetKeysResponse]> { + return Promise.race([ + Promise.all([ + queryClient.fetchQuery(mintInfoQueryOptions(mintUrl)), + queryClient.fetchQuery(allMintKeysetsQueryOptions(mintUrl)), + queryClient.fetchQuery(mintKeysQueryOptions(mintUrl)), + ]), + new Promise((_, reject) => { + setTimeout(() => { + queryClient.cancelQueries({ queryKey: mintInfoQueryKey(mintUrl) }); + queryClient.cancelQueries({ + queryKey: allMintKeysetsQueryKey(mintUrl), + }); + queryClient.cancelQueries({ queryKey: mintKeysQueryKey(mintUrl) }); + reject(new NetworkError('Mint request timed out')); + }, 10_000); + }), + ]); +} + /** * Initializes a Cashu wallet with offline handling. * If the mint is offline or times out, returns a minimal wallet with isOnline: false. @@ -284,27 +312,8 @@ export async function getInitializedCashuWallet({ let mintActiveKeys: GetKeysResponse; try { - [mintInfo, allMintKeysets, mintActiveKeys] = await Promise.race([ - Promise.all([ - queryClient.fetchQuery(mintInfoQueryOptions(mintUrl)), - queryClient.fetchQuery(allMintKeysetsQueryOptions(mintUrl)), - queryClient.fetchQuery(mintKeysQueryOptions(mintUrl)), - ]), - new Promise((_, reject) => { - setTimeout(() => { - queryClient.cancelQueries({ - queryKey: mintInfoQueryKey(mintUrl), - }); - queryClient.cancelQueries({ - queryKey: allMintKeysetsQueryKey(mintUrl), - }); - queryClient.cancelQueries({ - queryKey: mintKeysQueryKey(mintUrl), - }); - reject(new NetworkError('Mint request timed out')); - }, 10_000); - }), - ]); + [mintInfo, allMintKeysets, mintActiveKeys] = + await fetchMintDataWithTimeout(mintUrl, queryClient); } catch (error) { if (error instanceof NetworkError) { const wallet = getCashuWallet(mintUrl, { diff --git a/app/routes/_protected.receive.cashu_.token.tsx b/app/routes/_protected.receive.cashu_.token.tsx index 283b6a8fc..ab70afb6d 100644 --- a/app/routes/_protected.receive.cashu_.token.tsx +++ b/app/routes/_protected.receive.cashu_.token.tsx @@ -15,9 +15,10 @@ import { ReceiveCashuTokenQuoteService } from '~/features/receive/receive-cashu- import { ReceiveCashuTokenService } from '~/features/receive/receive-cashu-token-service'; import { SparkReceiveQuoteRepository } from '~/features/receive/spark-receive-quote-repository'; import { SparkReceiveQuoteService } from '~/features/receive/spark-receive-quote-service'; -import { UnsupportedCashuTokenPage } from '~/features/receive/unsupported-cashu-token-page'; +import { UnclaimableCashuTokenPage } from '~/features/receive/unclaimable-cashu-token-page'; import { decodeCashuToken, + fetchMintDataWithTimeout, getCashuCryptography, seedQueryOptions, } from '~/features/shared/cashu'; @@ -33,6 +34,7 @@ import { WriteUserRepository } from '~/features/user/user-repository'; import { UserService } from '~/features/user/user-service'; import { toast } from '~/hooks/use-toast'; import { validateCashuToken } from '~/lib/cashu'; +import { extractCashuToken } from '~/lib/cashu/token'; import type { Route } from './+types/_protected.receive.cashu_.token'; import { ReceiveCashuTokenSkeleton } from './receive-cashu-token-skeleton'; @@ -110,7 +112,28 @@ const getClaimTo = ( export async function clientLoader({ request }: Route.ClientLoaderArgs) { // Request url doesn't include hash so we need to read it from the window location instead - const token = await decodeCashuToken(window.location.hash); + const hash = window.location.hash; + + const extracted = extractCashuToken(hash); + if (!extracted) { + throw redirect('/receive'); + } + + const queryClient = getQueryClient(); + + // Probe mint reachability before mounting the receive flow. Side effect: primes + // the query cache so the component's wallet init resolves without a round-trip. + try { + await fetchMintDataWithTimeout(extracted.metadata.mint, queryClient); + } catch (error) { + console.error('Failed to probe mint', error); + return { + isClaimable: false as const, + message: 'The mint that issued this ecash is currently offline', + }; + } + + const token = await decodeCashuToken(hash); if (!token) { throw redirect('/receive'); @@ -120,7 +143,7 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { if (!validation.isTokenSupported) { return { - isTokenSupported: false as const, + isClaimable: false as const, message: validation.message, }; } @@ -162,7 +185,7 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { throw redirect(redirectTo); } - return { isTokenSupported: true as const, token, selectedAccountId }; + return { isClaimable: true as const, token, selectedAccountId }; } clientLoader.hydrate = true as const; @@ -174,8 +197,8 @@ export function HydrateFallback() { export default function ProtectedReceiveCashuToken({ loaderData, }: Route.ComponentProps) { - if (!loaderData.isTokenSupported) { - return ; + if (!loaderData.isClaimable) { + return ; } return ( diff --git a/app/routes/_public.receive-cashu-token.tsx b/app/routes/_public.receive-cashu-token.tsx index 76062ede5..778038436 100644 --- a/app/routes/_public.receive-cashu-token.tsx +++ b/app/routes/_public.receive-cashu-token.tsx @@ -3,11 +3,15 @@ import { Page } from '~/components/page'; import { getGiftCardByUrl } from '~/features/gift-cards/use-discover-cards'; import { LoadingScreen } from '~/features/loading/LoadingScreen'; import { PublicReceiveCashuToken } from '~/features/receive/receive-cashu-token'; -import { UnsupportedCashuTokenPage } from '~/features/receive/unsupported-cashu-token-page'; -import { decodeCashuToken } from '~/features/shared/cashu'; +import { UnclaimableCashuTokenPage } from '~/features/receive/unclaimable-cashu-token-page'; +import { + decodeCashuToken, + fetchMintDataWithTimeout, +} from '~/features/shared/cashu'; import { getQueryClient } from '~/features/shared/query-client'; import { authQueryOptions } from '~/features/user/auth'; import { validateCashuToken } from '~/lib/cashu'; +import { extractCashuToken } from '~/lib/cashu/token'; import { normalizeMintUrl } from '~/lib/cashu/utils'; import type { Route } from './+types/_public.receive-cashu-token'; @@ -84,6 +88,23 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { throw redirect(`/receive/cashu/token${location.search}${hash}`); } + const extracted = extractCashuToken(hash); + if (!extracted) { + throw redirect('/home'); + } + + // Probe mint reachability before mounting the receive flow. Side effect: primes + // the query cache so the component's wallet init resolves without a round-trip. + try { + await fetchMintDataWithTimeout(extracted.metadata.mint, queryClient); + } catch (error) { + console.error('Failed to probe mint', error); + return { + isClaimable: false as const, + message: 'The mint that issued this ecash is currently offline', + }; + } + const token = await decodeCashuToken(hash); if (!token) { @@ -94,12 +115,12 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { if (!validation.isTokenSupported) { return { - isTokenSupported: false as const, + isClaimable: false as const, message: validation.message, }; } - return { isTokenSupported: true as const, token }; + return { isClaimable: true as const, token }; } clientLoader.hydrate = true as const; @@ -111,8 +132,8 @@ export function HydrateFallback() { export default function ReceiveCashuTokenPage({ loaderData, }: Route.ComponentProps) { - if (!loaderData.isTokenSupported) { - return ; + if (!loaderData.isClaimable) { + return ; } return (