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 (