-
Notifications
You must be signed in to change notification settings - Fork 5
fix(receive): probe mint reachability in route loader #1037
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<never>((_, reject) => { | ||
| setTimeout(() => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should we store this timeout to variable and clear it when the the Promise.race resolves? something like: I don't think it matters a lot but no need to still have the timeout scheduled when the mint info wins the race |
||
| queryClient.cancelQueries({ queryKey: mintInfoQueryKey(mintUrl) }); | ||
| queryClient.cancelQueries({ | ||
| queryKey: allMintKeysetsQueryKey(mintUrl), | ||
| }); | ||
| queryClient.cancelQueries({ queryKey: mintKeysQueryKey(mintUrl) }); | ||
| reject(new NetworkError('Mint request timed out')); | ||
| }, 10_000); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 10 seconds is too long. I would put it to 3 seconds or something like that. |
||
| }), | ||
| ]); | ||
| } | ||
|
|
||
| /** | ||
| * 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<never>((_, 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, { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
gudnuf marked this conversation as resolved.
|
||
| await fetchMintDataWithTimeout(extracted.metadata.mint, queryClient); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The only thing I am not sure with this approach is that now the page will load slower. Could we make it so that we only check if the token is supported in the loader and then handle offline mint differently but without changing the ui too much so there is no weird change to different page?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is not ideal especially in the timeout scenario because the page will show loading state for a long time and look broken.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } 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 <UnsupportedCashuTokenPage message={loaderData.message} />; | ||
| if (!loaderData.isClaimable) { | ||
| return <UnclaimableCashuTokenPage message={loaderData.message} />; | ||
| } | ||
|
|
||
| return ( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. async part of the decode process is only present for v2 keysets, right? if so, should we avoid these network requests when token is using v1? also can you remind me, why do we need to decode the token here?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yea only v2, but most tokens are v2 so not sure its worth handling v1. Also cashu-ts v4 makes keysetIds required https://github.com/cashubtc/cashu-ts/blob/main/src/utils/core.ts#L332-L336 |
||
|
|
||
| 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 <UnsupportedCashuTokenPage message={loaderData.message} />; | ||
| if (!loaderData.isClaimable) { | ||
| return <UnclaimableCashuTokenPage message={loaderData.message} />; | ||
| } | ||
|
|
||
| return ( | ||
|
|
||

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd call this just
fetchMintData