From e2835ebd9261911e292d89471682e5264b98c1fd Mon Sep 17 00:00:00 2001 From: orveth Date: Wed, 6 May 2026 10:33:04 -0700 Subject: [PATCH] fix(receive): show unsupported-unit page instead of crashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pasting or deep-linking a Cashu token whose unit isn't sat or usd crashed /receive/cashu/token because tokenToMoney threw synchronously inside render-path hooks. The route loaders now run validateCashuToken right after decode and short-circuit to UnsupportedCashuTokenPage with a reason-specific message. Send flow is unaffected — internally-generated tokens always have a supported unit derived from Currency. validateCashuToken returns a discriminated union so future checks (empty proofs, blocklisted mint, etc.) plug in as additional early-return branches. --- .../receive/unsupported-cashu-token-page.tsx | 25 +++++++++++++++++++ app/lib/cashu/utils.ts | 23 ++++++++++++++++- .../_protected.receive.cashu_.token.tsx | 21 +++++++++++++--- app/routes/_public.receive-cashu-token.tsx | 19 +++++++++++--- 4 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 app/features/receive/unsupported-cashu-token-page.tsx diff --git a/app/features/receive/unsupported-cashu-token-page.tsx b/app/features/receive/unsupported-cashu-token-page.tsx new file mode 100644 index 000000000..ad83fe538 --- /dev/null +++ b/app/features/receive/unsupported-cashu-token-page.tsx @@ -0,0 +1,25 @@ +import { + ClosePageButton, + Page, + PageContent, + PageHeader, + PageHeaderTitle, +} from '~/components/page'; + +type Props = { + message: string; +}; + +export function UnsupportedCashuTokenPage({ message }: Props) { + return ( + + + + Oops! + + +

{message}

+
+
+ ); +} diff --git a/app/lib/cashu/utils.ts b/app/lib/cashu/utils.ts index d353fdd6c..8b6db6204 100644 --- a/app/lib/cashu/utils.ts +++ b/app/lib/cashu/utils.ts @@ -9,6 +9,7 @@ import { Wallet, splitAmount, } from '@cashu/cashu-ts'; +import type { Token } from '@cashu/cashu-ts'; import type { DistributedOmit } from 'type-fest'; import type { Currency, CurrencyUnit } from '../money'; import { @@ -16,7 +17,7 @@ import { type ExtendedMintQuoteBolt11Response, type MintPurpose, } from './protocol-extensions'; -import type { CashuProtocolUnit } from './types'; +import { CASHU_PROTOCOL_UNITS, type CashuProtocolUnit } from './types'; const knownTestMints = [ 'https://testnut.cashu.space', @@ -73,6 +74,26 @@ export const getCashuProtocolUnit = (currency: Currency) => { return currencyToCashuProtocolUnit[currency]; }; +export type CashuTokenValidation = + | { isTokenSupported: true } + | { isTokenSupported: false; message: string }; + +/** + * Validates that a decoded Cashu token is one Agicash supports. + */ +export const validateCashuToken = (token: Token): CashuTokenValidation => { + if ( + token.unit === undefined || + !(token.unit in cashuProtocolUnitToCurrency) + ) { + return { + isTokenSupported: false, + message: `This token's unit isn't supported. Supported units: ${CASHU_PROTOCOL_UNITS.join(', ')}.`, + }; + } + return { isTokenSupported: true }; +}; + /** * Determines the purpose of a mint based on its info. */ diff --git a/app/routes/_protected.receive.cashu_.token.tsx b/app/routes/_protected.receive.cashu_.token.tsx index e9dde2c68..283b6a8fc 100644 --- a/app/routes/_protected.receive.cashu_.token.tsx +++ b/app/routes/_protected.receive.cashu_.token.tsx @@ -15,6 +15,7 @@ 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 { decodeCashuToken, getCashuCryptography, @@ -31,6 +32,7 @@ import { getUserFromCacheOrThrow } from '~/features/user/user-hooks'; 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 type { Route } from './+types/_protected.receive.cashu_.token'; import { ReceiveCashuTokenSkeleton } from './receive-cashu-token-skeleton'; @@ -114,6 +116,15 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { throw redirect('/receive'); } + const validation = validateCashuToken(token); + + if (!validation.isTokenSupported) { + return { + isTokenSupported: false as const, + message: validation.message, + }; + } + const location = new URL(request.url); const selectedAccountId = location.searchParams.get('selectedAccountId') ?? undefined; @@ -151,7 +162,7 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { throw redirect(redirectTo); } - return { token, selectedAccountId }; + return { isTokenSupported: true as const, token, selectedAccountId }; } clientLoader.hydrate = true as const; @@ -163,14 +174,16 @@ export function HydrateFallback() { export default function ProtectedReceiveCashuToken({ loaderData, }: Route.ComponentProps) { - const { token, selectedAccountId } = loaderData; + if (!loaderData.isTokenSupported) { + return ; + } return ( }> diff --git a/app/routes/_public.receive-cashu-token.tsx b/app/routes/_public.receive-cashu-token.tsx index b0a999d2a..76062ede5 100644 --- a/app/routes/_public.receive-cashu-token.tsx +++ b/app/routes/_public.receive-cashu-token.tsx @@ -3,9 +3,11 @@ 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 { getQueryClient } from '~/features/shared/query-client'; import { authQueryOptions } from '~/features/user/auth'; +import { validateCashuToken } from '~/lib/cashu'; import { normalizeMintUrl } from '~/lib/cashu/utils'; import type { Route } from './+types/_public.receive-cashu-token'; @@ -88,7 +90,16 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { throw redirect('/home'); } - return { token }; + const validation = validateCashuToken(token); + + if (!validation.isTokenSupported) { + return { + isTokenSupported: false as const, + message: validation.message, + }; + } + + return { isTokenSupported: true as const, token }; } clientLoader.hydrate = true as const; @@ -100,11 +111,13 @@ export function HydrateFallback() { export default function ReceiveCashuTokenPage({ loaderData, }: Route.ComponentProps) { - const { token } = loaderData; + if (!loaderData.isTokenSupported) { + return ; + } return ( - + ); }