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 (
-
+
);
}