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
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ type Props = {
message: string;
};

export function UnsupportedCashuTokenPage({ message }: Props) {
export function UnclaimableCashuTokenPage({ message }: Props) {
return (
<Page>
<PageHeader>
Expand Down
51 changes: 30 additions & 21 deletions app/features/shared/cashu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Copy link
Copy Markdown
Collaborator

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

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(() => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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:

 let timer: ReturnType<typeof setTimeout>;
  try {
    return await Promise.race([
      Promise.all([...]),
      new Promise<never>((_, reject) => {
        timer = setTimeout(() => { /* cancel + reject */ }, 10_000);
      }),
    ]);
  } finally {
    timer && clearTimeout(timer);
  }

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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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.
Expand Down Expand Up @@ -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, {
Expand Down
35 changes: 29 additions & 6 deletions app/routes/_protected.receive.cashu_.token.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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 {
Comment thread
gudnuf marked this conversation as resolved.
await fetchMintDataWithTimeout(extracted.metadata.mint, queryClient);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have to load keysets for decodiing v2 tokens. What if we just rely on the error that decodeCashuToken will throw. So if loading the keyset throws a network error then we can say the mint is not reachable

image

} 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');
Expand All @@ -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,
};
}
Expand Down Expand Up @@ -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;
Expand All @@ -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 (
Expand Down
33 changes: 27 additions & 6 deletions app/routes/_public.receive-cashu-token.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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) {
Expand All @@ -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;
Expand All @@ -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 (
Expand Down
Loading