diff --git a/.envrc b/.envrc index 96a845fde..7ca75ea45 100644 --- a/.envrc +++ b/.envrc @@ -5,3 +5,6 @@ use devenv # Install Node.js version from .nvmrc eval "$(fnm env)" fnm use --install-if-missing + +# Use parent .envrc, if exists, as well. Useful for distinct configuration shared by all Agicash projects, but not by other projects on the host machine. (e.g. claude account setup) +source_up_if_exists diff --git a/app/entry.client.tsx b/app/entry.client.tsx index 912afaddb..839cea94f 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -38,7 +38,13 @@ configure({ // Start Breez WASM fetch/compile as early as possible so it overlaps with // hydration, Sentry init, and the auth query — by the time the _protected // middleware awaits it, init is often already done. -ensureBreezWasm(); +// Swallow the rejection here; the middleware re-awaits the same cached promise +// and surfaces the error through the route ErrorBoundary, where we render a +// friendly message. Without this `.catch`, the rejection also fires +// `unhandledrejection` and is reported to Sentry as a duplicate. +ensureBreezWasm().catch(() => { + // Surfaced via _protected middleware → route ErrorBoundary. +}); // Prefetch feature flags as early as possible. // Before login the DB client uses the anon key (no access token), diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 96c4eb50f..0702f7729 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -13,7 +13,6 @@ import type { RenderToPipeableStreamOptions } from 'react-dom/server'; import { renderToPipeableStream } from 'react-dom/server'; import type { EntryContext, RouterContextProvider } from 'react-router'; import { ServerRouter } from 'react-router'; -import { PRERENDERED_PATHS } from './prerender-paths'; export const streamTimeout = 5_000; @@ -35,17 +34,6 @@ function handleRequest( // react-router's prerender constructs requests with no user-agent header. const isBuildTimePrerender = !userAgent; - // Vercel currently doesn't serve prerendered HTML as static files when - // ssr:true (remix-run/react-router#14281) — requests for these paths - // fall through to the SSR function. Match them here so the outlining - // override below applies at runtime SSR too. Once the upstream adapter - // bug is fixed, this path-list check can be dropped; the no-UA check - // alone will cover real prerender invocations. - const pathname = new URL(request.url).pathname.replace(/\/+$/, '') || '/'; - const isPrerenderRoute = PRERENDERED_PATHS.includes(pathname); - - const shouldDisableOutlining = isBuildTimePrerender || isPrerenderRoute; - // Ensure requests from bots and SPA Mode renders wait for all content to load before responding // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation const readyOption: keyof RenderToPipeableStreamOptions = @@ -67,16 +55,12 @@ function handleRequest( // // This trades a fallback flash for faster first paint. It's a real // win for streaming SSR with slow data — a skeleton beats a blank - // page. But for prerender-eligible routes it's pure cost: when - // served as static (the intent), the file is buffered and there's - // no opportunity for early flushing — the fallback-then-swap dance - // still happens in the file, so the user sees a fallback flash on - // first paint with nothing gained. When falling through to runtime - // SSR (current Vercel behavior, see above), the same flash returns - // for a different reason but the same fix applies. Setting an - // effectively-infinite threshold disables outlining for these - // requests, so the content renders inline in the shell. - progressiveChunkSize: shouldDisableOutlining + // page. But for prerendered routes it's pure cost: when served as + // static (the intent), the file is buffered and there's no opportunity + // for early flushing — the fallback-then-swap dance still happens in + // the file, so the user sees a fallback flash on first paint with + // nothing gained. + progressiveChunkSize: isBuildTimePrerender ? Number.POSITIVE_INFINITY : undefined, [readyOption]() { diff --git a/app/features/accounts/account-repository.ts b/app/features/accounts/account-repository.ts index c51715458..6477016a3 100644 --- a/app/features/accounts/account-repository.ts +++ b/app/features/accounts/account-repository.ts @@ -1,6 +1,6 @@ import { type QueryClient, useQueryClient } from '@tanstack/react-query'; import type { DistributedOmit } from 'type-fest'; -import { z } from 'zod'; +import { z } from 'zod/mini'; import { normalizeMintUrl } from '~/lib/cashu'; import { ProofSchema } from '~/lib/cashu/types'; import type { Currency } from '~/lib/money'; diff --git a/app/features/accounts/account.ts b/app/features/accounts/account.ts index f553ebc5b..8efe73239 100644 --- a/app/features/accounts/account.ts +++ b/app/features/accounts/account.ts @@ -1,6 +1,6 @@ import type { BreezSdk } from '@agicash/breez-sdk-spark'; import type { DistributedOmit } from 'type-fest'; -import { z } from 'zod'; +import { z } from 'zod/mini'; import { type ExtendedCashuWallet, getCashuUnit, sumProofs } from '~/lib/cashu'; import { type Currency, Money } from '~/lib/money'; import type { SparkNetwork } from '../agicash-db/json-models/spark-account-details-db-data'; diff --git a/app/features/accounts/cashu-account.ts b/app/features/accounts/cashu-account.ts index eb51d9ac0..edf069b2d 100644 --- a/app/features/accounts/cashu-account.ts +++ b/app/features/accounts/cashu-account.ts @@ -1,5 +1,5 @@ import type { Proof } from '@cashu/cashu-ts'; -import { z } from 'zod'; +import { z } from 'zod/mini'; import { ProofSchema } from '~/lib/cashu/types'; export const CashuProofSchema = z.object({ @@ -16,8 +16,8 @@ export const CashuProofSchema = z.object({ state: z.enum(['UNSPENT', 'RESERVED', 'SPENT']), version: z.number(), createdAt: z.string(), - reservedAt: z.string().nullable().optional(), - spentAt: z.string().nullable().optional(), + reservedAt: z.optional(z.nullable(z.string())), + spentAt: z.optional(z.nullable(z.string())), }); export type CashuProof = z.infer; diff --git a/app/features/agicash-db/json-models/account-details-db-data.ts b/app/features/agicash-db/json-models/account-details-db-data.ts index d89ef6fba..4bcf40816 100644 --- a/app/features/agicash-db/json-models/account-details-db-data.ts +++ b/app/features/agicash-db/json-models/account-details-db-data.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { CashuAccountDetailsDbDataSchema } from './cashu-account-details-db-data'; import { SparkAccountDetailsDbDataSchema } from './spark-account-details-db-data'; diff --git a/app/features/agicash-db/json-models/cashu-account-details-db-data.ts b/app/features/agicash-db/json-models/cashu-account-details-db-data.ts index 6ccc6eafc..f45d7c159 100644 --- a/app/features/agicash-db/json-models/cashu-account-details-db-data.ts +++ b/app/features/agicash-db/json-models/cashu-account-details-db-data.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; export const CashuAccountDetailsDbDataSchema = z.object({ /** diff --git a/app/features/agicash-db/json-models/cashu-lightning-receive-db-data.ts b/app/features/agicash-db/json-models/cashu-lightning-receive-db-data.ts index 374ca5948..afc1767cf 100644 --- a/app/features/agicash-db/json-models/cashu-lightning-receive-db-data.ts +++ b/app/features/agicash-db/json-models/cashu-lightning-receive-db-data.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { Money } from '~/lib/money'; import { CashuTokenMeltDbDataSchema } from './cashu-token-melt-db-data'; @@ -22,23 +22,23 @@ export const CashuLightningReceiveDbDataSchema = z.object({ /** * The description of the transaction. */ - description: z.string().optional(), + description: z.optional(z.string()), /** * Optional fee charged by the mint to deposit money into the account. * The payer of the lightning invoice pays this fee. */ - mintingFee: z.instanceof(Money).optional(), + mintingFee: z.optional(z.instanceof(Money)), /** * Amounts for each blinded message created for this receive. * Will be set only when the receive quote gets paid. */ - outputAmounts: z.array(z.number()).optional(), + outputAmounts: z.optional(z.array(z.number())), /** * The data of the cashu token melted for the receive. * This will be set only for cashu token receives when the destination account is not the mint that issued the token (the token was melted to * pay the lightning invoice of the mint quote from the destination mint). */ - cashuTokenMeltData: CashuTokenMeltDbDataSchema.optional(), + cashuTokenMeltData: z.optional(CashuTokenMeltDbDataSchema), /** * The total fee for the transaction. * For lightning receives this will equal to `mintingFee` or zero if the mint has no minting fee. diff --git a/app/features/agicash-db/json-models/cashu-lightning-send-db-data.ts b/app/features/agicash-db/json-models/cashu-lightning-send-db-data.ts index 96b304298..1751c026d 100644 --- a/app/features/agicash-db/json-models/cashu-lightning-send-db-data.ts +++ b/app/features/agicash-db/json-models/cashu-lightning-send-db-data.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { DestinationDetailsSchema } from '~/features/send/cashu-send-quote'; import { Money } from '~/lib/money'; @@ -52,32 +52,32 @@ export const CashuLightningSendDbDataSchema = z.object({ * Destination details of the send. * This will be undefined if the send is directly paying a bolt11. */ - destinationDetails: DestinationDetailsSchema.optional(), + destinationDetails: z.optional(DestinationDetailsSchema), /** * Preimage of the lightning payment. * Will be set only when the send is completed. * If the lightning payment is settled internally in the mint, this will be an empty string or '0x0000000000000000000000000000000000000000000000000000000000000000'. */ - paymentPreimage: z.string().optional(), + paymentPreimage: z.optional(z.string()), /** * Amount spent on the send. * This is the sum of `amountReceived` and `totalFee`. * Available only when the send is completed. */ - amountSpent: z.instanceof(Money).optional(), + amountSpent: z.optional(z.instanceof(Money)), /** * The actual Lightning Network fee that was charged after the transaction completed. * This may be less than the `lightningFeeReserve` if the payment was cheaper than expected. * The difference between `lightningFeeReserve` and `lightningFee`, along with any overpaid inputs, is returned as change to the user. * Available only when the send is completed. */ - lightningFee: z.instanceof(Money).optional(), + lightningFee: z.optional(z.instanceof(Money)), /** * The actual fee for the transaction. * This is the sum of `lightningFee` and `cashuSendFee`. * Available only when the send is completed. */ - totalFee: z.instanceof(Money).optional(), + totalFee: z.optional(z.instanceof(Money)), }); /** diff --git a/app/features/agicash-db/json-models/cashu-swap-receive-db-data.ts b/app/features/agicash-db/json-models/cashu-swap-receive-db-data.ts index a731ce29a..b380ee951 100644 --- a/app/features/agicash-db/json-models/cashu-swap-receive-db-data.ts +++ b/app/features/agicash-db/json-models/cashu-swap-receive-db-data.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { ProofSchema } from '~/lib/cashu'; import { Money } from '~/lib/money'; @@ -22,7 +22,7 @@ export const CashuSwapReceiveDbDataSchema = z.object({ /** * The description (memo) of the token being swapped. */ - tokenDescription: z.string().optional(), + tokenDescription: z.optional(z.string()), /** * The amount credited to the account. */ diff --git a/app/features/agicash-db/json-models/cashu-swap-send-db-data.ts b/app/features/agicash-db/json-models/cashu-swap-send-db-data.ts index b06f9a304..1882a96f2 100644 --- a/app/features/agicash-db/json-models/cashu-swap-send-db-data.ts +++ b/app/features/agicash-db/json-models/cashu-swap-send-db-data.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { Money } from '~/lib/money'; /** @@ -51,8 +51,8 @@ export const CashuSwapSendDbDataSchema = z.object({ * The swap output amounts used for the send and change. * Will be defined only when the swap is needed (`inputAmount` is greater than the `amountToSend`). */ - outputAmounts: z - .object({ + outputAmounts: z.optional( + z.object({ /** * The swap output amounts used for the send. * Proofs with these amounts are then used to create a token which is shared with the receiver. @@ -63,8 +63,8 @@ export const CashuSwapSendDbDataSchema = z.object({ * Proofs with these amounts are then returned to the source account as change. */ change: z.array(z.number()), - }) - .optional(), + }), + ), }); /** diff --git a/app/features/agicash-db/json-models/cashu-token-melt-db-data.ts b/app/features/agicash-db/json-models/cashu-token-melt-db-data.ts index 1b7503ff3..97b9fe6a8 100644 --- a/app/features/agicash-db/json-models/cashu-token-melt-db-data.ts +++ b/app/features/agicash-db/json-models/cashu-token-melt-db-data.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { ProofSchema } from '~/lib/cashu'; import { Money } from '~/lib/money'; diff --git a/app/features/agicash-db/json-models/spark-account-details-db-data.ts b/app/features/agicash-db/json-models/spark-account-details-db-data.ts index c7d399938..d18333d38 100644 --- a/app/features/agicash-db/json-models/spark-account-details-db-data.ts +++ b/app/features/agicash-db/json-models/spark-account-details-db-data.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; export const SparkAccountDetailsDbDataSchema = z.object({ /** diff --git a/app/features/agicash-db/json-models/spark-lightning-receive-db-data.ts b/app/features/agicash-db/json-models/spark-lightning-receive-db-data.ts index 829bfec96..331dfb1d9 100644 --- a/app/features/agicash-db/json-models/spark-lightning-receive-db-data.ts +++ b/app/features/agicash-db/json-models/spark-lightning-receive-db-data.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { Money } from '~/lib/money'; import { CashuTokenMeltDbDataSchema } from './cashu-token-melt-db-data'; @@ -18,17 +18,17 @@ export const SparkLightningReceiveDbDataSchema = z.object({ /** * The description of the transaction. */ - description: z.string().optional(), + description: z.optional(z.string()), /** * The payment preimage of the lightning payment. * Will be set only when the receive quote gets paid. */ - paymentPreimage: z.string().optional(), + paymentPreimage: z.optional(z.string()), /** * The data of the cashu token melted for the receive. * This will be set only for cashu token receives to spark accounts. */ - cashuTokenMeltData: CashuTokenMeltDbDataSchema.optional(), + cashuTokenMeltData: z.optional(CashuTokenMeltDbDataSchema), /** * The total fee for the transaction. * For lightning receives this will be zero. diff --git a/app/features/agicash-db/json-models/spark-lightning-send-db-data.ts b/app/features/agicash-db/json-models/spark-lightning-send-db-data.ts index 8030d68d5..69cbe27ec 100644 --- a/app/features/agicash-db/json-models/spark-lightning-send-db-data.ts +++ b/app/features/agicash-db/json-models/spark-lightning-send-db-data.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { Money } from '~/lib/money'; /** @@ -23,17 +23,17 @@ export const SparkLightningSendDbDataSchema = z.object({ * This is the amount to send plus the actual fee paid to the lightning network. * Available only when the send is initiated. */ - amountSpent: z.instanceof(Money).optional(), + amountSpent: z.optional(z.instanceof(Money)), /** * The actual Lightning Network fee that was charged for the transaction. * Will be set only when the send is initiated. */ - lightningFee: z.instanceof(Money).optional(), + lightningFee: z.optional(z.instanceof(Money)), /** * Preimage of the lightning payment. * Will be set only when the send is completed. */ - paymentPreimage: z.string().optional(), + paymentPreimage: z.optional(z.string()), }); /** diff --git a/app/features/buy/buy-checkout.tsx b/app/features/buy/buy-checkout.tsx index 388dbc9d5..22f7dbbc5 100644 --- a/app/features/buy/buy-checkout.tsx +++ b/app/features/buy/buy-checkout.tsx @@ -20,8 +20,8 @@ import { type SparkAccount, getAccountHomePath, } from '../accounts/account'; -import { useCashuReceiveQuote } from '../receive/cashu-receive-quote-hooks'; -import { useSparkReceiveQuote } from '../receive/spark-receive-quote-hooks'; +import { useTrackCashuReceiveQuote } from '../receive/cashu-receive-quote-hooks'; +import { useTrackSparkReceiveQuote } from '../receive/spark-receive-quote-hooks'; import { getDefaultUnit } from '../shared/currencies'; import { MoneyWithConvertedAmount } from '../shared/money-with-converted-amount'; import type { BuyQuote } from './buy-store'; @@ -225,7 +225,7 @@ export function BuyCheckoutCashu({ const { redirectTo } = useRedirectTo(getAccountHomePath(account)); const navigateToTransaction = useNavigateToTransaction(redirectTo); - const { status: quotePaymentStatus } = useCashuReceiveQuote({ + const { status: quotePaymentStatus } = useTrackCashuReceiveQuote({ quoteId: quote.id, onPaid: (cashuQuote) => { navigateToTransaction(cashuQuote.transactionId); @@ -255,7 +255,7 @@ export function BuyCheckoutSpark({ const { redirectTo } = useRedirectTo(getAccountHomePath(account)); const navigateToTransaction = useNavigateToTransaction(redirectTo); - const { status: quotePaymentStatus } = useSparkReceiveQuote({ + const { status: quotePaymentStatus } = useTrackSparkReceiveQuote({ quoteId: quote.id, onPaid: (sparkQuote) => { navigateToTransaction(sparkQuote.transactionId); diff --git a/app/features/contacts/contact.ts b/app/features/contacts/contact.ts index c09f922e4..e9c7a34c6 100644 --- a/app/features/contacts/contact.ts +++ b/app/features/contacts/contact.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; const ContactSchema = z.object({ id: z.string(), diff --git a/app/features/email/welcome-email-service.ts b/app/features/email/welcome-email-service.ts index 14f7f8860..6914fe854 100644 --- a/app/features/email/welcome-email-service.ts +++ b/app/features/email/welcome-email-service.ts @@ -25,7 +25,6 @@ export async function sendWelcomeEmail(data: { if (!resendApiKey || !resendWelcomeTemplateId) { Sentry.captureException( new Error('Missing RESEND_API_KEY or RESEND_WELCOME_TEMPLATE_ID'), - { extra: { code: 'server_misconfigured', userId } }, ); return; } @@ -60,8 +59,8 @@ export async function sendWelcomeEmail(data: { userId, }); } catch (error) { - Sentry.captureException(error, { - extra: { code: 'email_send_failed', userId }, - }); + Sentry.captureException( + new Error('Failed to send welcome email', { cause: error }), + ); } } diff --git a/app/features/gift-cards/gift-card-config.ts b/app/features/gift-cards/gift-card-config.ts index f2ae707e4..9edccc03d 100644 --- a/app/features/gift-cards/gift-card-config.ts +++ b/app/features/gift-cards/gift-card-config.ts @@ -1,26 +1,29 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; export const GiftCardConfigSchema = z.array( z.object({ url: z.url(), - name: z.string().min(1), + name: z.string().check(z.minLength(1)), currency: z.enum(['USD', 'BTC']), purpose: z.enum(['gift-card', 'offer']), isDiscoverable: z.boolean(), - addCardDisclaimer: z.string().optional(), - validPaymentDestinations: z - .object({ + addCardDisclaimer: z.optional(z.string()), + validPaymentDestinations: z.optional( + z.object({ descriptions: z.array(z.string()), - nodePubkeys: z.array(z.string().toLowerCase()), - }) - .optional(), + nodePubkeys: z.array(z.string().check(z.toLowerCase())), + }), + ), }), ); -export const JsonGiftCardConfigSchema = z - .string() - .transform((str) => JSON.parse(str)) - .pipe(GiftCardConfigSchema); +export const JsonGiftCardConfigSchema = z.pipe( + z.pipe( + z.string(), + z.transform((str) => JSON.parse(str)), + ), + GiftCardConfigSchema, +); export type GiftCardConfig = z.infer[number]; diff --git a/app/features/gift-cards/use-restore-scroll-position.ts b/app/features/gift-cards/use-restore-scroll-position.ts index e64e07417..07cdc016c 100644 --- a/app/features/gift-cards/use-restore-scroll-position.ts +++ b/app/features/gift-cards/use-restore-scroll-position.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react'; import { useLocation } from 'react-router'; -import { z } from 'zod'; +import { z } from 'zod/mini'; /** * Persists and restores the horizontal scroll position of a container across diff --git a/app/features/receive/cashu-receive-quote-hooks.ts b/app/features/receive/cashu-receive-quote-hooks.ts index 6ad4a7a12..e9ae44a1d 100644 --- a/app/features/receive/cashu-receive-quote-hooks.ts +++ b/app/features/receive/cashu-receive-quote-hooks.ts @@ -196,13 +196,13 @@ export function useCashuReceiveQuoteCache() { return useMemo(() => new CashuReceiveQuoteCache(queryClient), [queryClient]); } -type UseCashuReceiveQuoteProps = { +type UseTrackCashuReceiveQuoteProps = { quoteId?: string; onPaid?: (quote: CashuReceiveQuote) => void; onExpired?: (quote: CashuReceiveQuote) => void; }; -type UseCashuReceiveQuoteResponse = +type UseTrackCashuReceiveQuoteResponse = | { status: 'LOADING'; quote?: undefined; @@ -212,11 +212,11 @@ type UseCashuReceiveQuoteResponse = quote: CashuReceiveQuote; }; -export function useCashuReceiveQuote({ +export function useTrackCashuReceiveQuote({ quoteId, onPaid, onExpired, -}: UseCashuReceiveQuoteProps): UseCashuReceiveQuoteResponse { +}: UseTrackCashuReceiveQuoteProps): UseTrackCashuReceiveQuoteResponse { const enabled = !!quoteId; const onPaidRef = useLatest(onPaid); const onExpiredRef = useLatest(onExpired); diff --git a/app/features/receive/cashu-receive-quote-repository.server.ts b/app/features/receive/cashu-receive-quote-repository.server.ts index 6fabda867..fe329884e 100644 --- a/app/features/receive/cashu-receive-quote-repository.server.ts +++ b/app/features/receive/cashu-receive-quote-repository.server.ts @@ -1,4 +1,4 @@ -import type { z } from 'zod'; +import type { z } from 'zod/mini'; import type { Money } from '~/lib/money'; import { computeSHA256 } from '~/lib/sha256'; import type { AgicashDb } from '../agicash-db/database'; diff --git a/app/features/receive/cashu-receive-quote-repository.ts b/app/features/receive/cashu-receive-quote-repository.ts index c2f9c0f0d..99e3aaf7e 100644 --- a/app/features/receive/cashu-receive-quote-repository.ts +++ b/app/features/receive/cashu-receive-quote-repository.ts @@ -1,5 +1,5 @@ import type { Proof } from '@cashu/cashu-ts'; -import type { z } from 'zod'; +import type { z } from 'zod/mini'; import { proofToY } from '~/lib/cashu'; import { computeSHA256 } from '~/lib/sha256'; import type { AllUnionFieldsRequired } from '~/lib/type-utils'; diff --git a/app/features/receive/cashu-receive-quote.ts b/app/features/receive/cashu-receive-quote.ts index cfbf2e241..eb62a10eb 100644 --- a/app/features/receive/cashu-receive-quote.ts +++ b/app/features/receive/cashu-receive-quote.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { Money } from '~/lib/money'; import { CashuTokenMeltDataSchema } from './cashu-token-melt-data'; @@ -28,7 +28,7 @@ const CashuReceiveQuoteBaseSchema = z.object({ /** * Description of the receive. */ - description: z.string().optional(), + description: z.optional(z.string()), /** * Date and time the receive quote was created in ISO 8601 format. */ @@ -61,7 +61,7 @@ const CashuReceiveQuoteBaseSchema = z.object({ * This amount is added to the payment request amount so the amount in the payment request is equal to `amount` plus `mintingFee`. * The sender pays the fee. The receiver will receive `amount` worth of ecash, while the mint keeps the `mintingFee`. */ - mintingFee: z.instanceof(Money).optional(), + mintingFee: z.optional(z.instanceof(Money)), /** * The total fee for the transaction. * For receives of type LIGHTNING, this will be zero. diff --git a/app/features/receive/cashu-receive-swap-repository.ts b/app/features/receive/cashu-receive-swap-repository.ts index bf5d7ebf8..8cadb93c1 100644 --- a/app/features/receive/cashu-receive-swap-repository.ts +++ b/app/features/receive/cashu-receive-swap-repository.ts @@ -1,5 +1,5 @@ import type { Proof, Token } from '@cashu/cashu-ts'; -import type z from 'zod'; +import type { z } from 'zod/mini'; import { proofToY } from '~/lib/cashu'; import type { Money } from '~/lib/money'; import type { AllUnionFieldsRequired } from '~/lib/type-utils'; diff --git a/app/features/receive/cashu-receive-swap.ts b/app/features/receive/cashu-receive-swap.ts index ab203bb4b..823825bc1 100644 --- a/app/features/receive/cashu-receive-swap.ts +++ b/app/features/receive/cashu-receive-swap.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { ProofSchema } from '~/lib/cashu'; import { Money } from '~/lib/money'; @@ -26,7 +26,7 @@ const CashuReceiveSwapBaseSchema = z.object({ /** * Description (memo) of the token being received. */ - tokenDescription: z.string().optional(), + tokenDescription: z.optional(z.string()), /** * UUID of the user receiving the token. */ diff --git a/app/features/receive/cashu-token-melt-data.ts b/app/features/receive/cashu-token-melt-data.ts index a9a359c4b..d4a1985a2 100644 --- a/app/features/receive/cashu-token-melt-data.ts +++ b/app/features/receive/cashu-token-melt-data.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { ProofSchema } from '~/lib/cashu'; import { Money } from '~/lib/money'; @@ -43,7 +43,7 @@ export const CashuTokenMeltDataSchema = z.object({ * For cashu token receives over lightning, we are currently not returning the change to the user. * Available only when the melt is completed. */ - lightningFee: z.instanceof(Money).optional(), + lightningFee: z.optional(z.instanceof(Money)), }); export type CashuTokenMeltData = z.infer; diff --git a/app/features/receive/claim-cashu-token-service.ts b/app/features/receive/claim-cashu-token-service.ts index f9e38cd3c..7fff9c8cb 100644 --- a/app/features/receive/claim-cashu-token-service.ts +++ b/app/features/receive/claim-cashu-token-service.ts @@ -1,5 +1,6 @@ import type { Payment } from '@agicash/breez-sdk-spark'; import type { Token } from '@cashu/cashu-ts'; +import * as Sentry from '@sentry/react-router'; import type { QueryClient } from '@tanstack/react-query'; import { getExchangeRate } from '~/hooks/use-exchange-rate'; import type { Account, CashuAccount, SparkAccount } from '../accounts/account'; @@ -72,8 +73,7 @@ export class ClaimCashuTokenService { } const message = 'Unexpected error while claiming the token'; - // TODO: do we need to send this error to Sentry or Sentry can make alert from error log? - console.error(message, { cause: error, time: new Date().toISOString() }); + Sentry.captureException(new Error(message, { cause: error })); return { success: false, message, diff --git a/app/features/receive/lightning-address-service.ts b/app/features/receive/lightning-address-service.ts index 6d224bf70..a660d8af2 100644 --- a/app/features/receive/lightning-address-service.ts +++ b/app/features/receive/lightning-address-service.ts @@ -1,7 +1,8 @@ -import { hexToBytes } from '@noble/hashes/utils'; +import { sha256 } from '@noble/hashes/sha2'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; import { base64url } from '@scure/base'; import type { QueryClient } from '@tanstack/react-query'; -import { z } from 'zod'; +import { z } from 'zod/mini'; import { getCashuWallet } from '~/lib/cashu'; import { ExchangeRateService } from '~/lib/exchange-rate/exchange-rate-service'; import type { @@ -118,11 +119,7 @@ export class LightningAddressService { } const callback = `${this.baseUrl}/api/lnurlp/callback/${user.id}`; - const address = `${user.username}@${new URL(this.baseUrl).host}`; - const metadata = JSON.stringify([ - ['text/plain', `Pay to ${address}`], - ['text/identifier', address], - ]); + const metadata = this.buildLnurlpMetadata(user.username); return { callback, @@ -192,6 +189,8 @@ export class LightningAddressService { } if (account.type === 'cashu') { + // cashu does not support setting the description_hash of an invoice. + // Read more here: https://github.com/cashubtc/nuts/issues/110#issuecomment-2062898765 const lightningQuote = await getLightningQuote({ wallet: account.wallet, amount: amountToReceive, @@ -227,10 +226,16 @@ export class LightningAddressService { new SparkReceiveQuoteRepositoryServer(this.db), ); + const metadata = this.buildLnurlpMetadata(user.username); + const descriptionHash = bytesToHex( + sha256(new TextEncoder().encode(metadata)), + ); + const lightningQuote = await sparkReceiveQuoteService.getLightningQuote({ wallet: account.wallet, amount: amountToReceive, receiverIdentityPubkey: user.sparkIdentityPublicKey, + descriptionHash, }); await sparkReceiveQuoteService.createReceiveQuote({ @@ -346,6 +351,14 @@ export class LightningAddressService { }; } + private buildLnurlpMetadata(username: string): string { + const address = `${username}@${new URL(this.baseUrl).host}`; + return JSON.stringify([ + ['text/plain', `Pay to ${address}`], + ['text/identifier', address], + ]); + } + private encryptLnurlVerifyQuoteData(payload: LnurlVerifyQuoteData): string { const data = new TextEncoder().encode(JSON.stringify(payload)); const encrypted = encryptXChaCha20Poly1305(data, encryptionKeyBytes); diff --git a/app/features/receive/receive-cashu.tsx b/app/features/receive/receive-cashu.tsx index ec8955273..cdefc22d5 100644 --- a/app/features/receive/receive-cashu.tsx +++ b/app/features/receive/receive-cashu.tsx @@ -28,8 +28,8 @@ import { getDefaultUnit } from '../shared/currencies'; import { MoneyWithConvertedAmount } from '../shared/money-with-converted-amount'; import type { CashuReceiveQuote } from './cashu-receive-quote'; import { - useCashuReceiveQuote, useCreateCashuReceiveQuote, + useTrackCashuReceiveQuote, } from './cashu-receive-quote-hooks'; type CreateQuoteProps = { @@ -46,7 +46,7 @@ const useCreateQuote = ({ account, amount, onPaid }: CreateQuoteProps) => { error, } = useCreateCashuReceiveQuote(); - const { quote, status: quotePaymentStatus } = useCashuReceiveQuote({ + const { quote, status: quotePaymentStatus } = useTrackCashuReceiveQuote({ quoteId: createdQuote?.id, onPaid: onPaid, }); diff --git a/app/features/receive/receive-spark.tsx b/app/features/receive/receive-spark.tsx index c8071f347..8b879a689 100644 --- a/app/features/receive/receive-spark.tsx +++ b/app/features/receive/receive-spark.tsx @@ -23,7 +23,7 @@ import { MoneyWithConvertedAmount } from '../shared/money-with-converted-amount' import type { SparkReceiveQuote } from './spark-receive-quote'; import { useCreateSparkReceiveQuote, - useSparkReceiveQuote, + useTrackSparkReceiveQuote, } from './spark-receive-quote-hooks'; type Props = { @@ -47,7 +47,7 @@ const useCreateQuote = ({ error, } = useCreateSparkReceiveQuote(); - const { quote, status: quotePaymentStatus } = useSparkReceiveQuote({ + const { quote, status: quotePaymentStatus } = useTrackSparkReceiveQuote({ quoteId: createdQuote?.id, onPaid, }); diff --git a/app/features/receive/spark-receive-quote-core.ts b/app/features/receive/spark-receive-quote-core.ts index 874858f4c..aa8049b0f 100644 --- a/app/features/receive/spark-receive-quote-core.ts +++ b/app/features/receive/spark-receive-quote-core.ts @@ -50,9 +50,14 @@ export type GetLightningQuoteParams = { */ receiverIdentityPubkey?: string; /** - * The description of the receive request. + * The description of the receive request (BOLT11 `d` tag). */ description?: string; + /** + * Hex-encoded SHA-256 commitment to a description (BOLT11 `h` tag). + * When set, Breez SDK emits `h` only and drops `description`. + */ + descriptionHash?: string; }; export type CreateQuoteBaseParams = { @@ -228,6 +233,7 @@ export async function getLightningQuote({ amount, receiverIdentityPubkey, description, + descriptionHash, }: GetLightningQuoteParams): Promise { const response = await measureOperation('BreezSdk.receivePayment', () => wallet.receivePayment({ @@ -236,6 +242,7 @@ export async function getLightningQuote({ description: description ?? '', amountSats: amount.toNumber('sat'), receiverIdentityPubkey, + descriptionHash, }, }), ); diff --git a/app/features/receive/spark-receive-quote-hooks.ts b/app/features/receive/spark-receive-quote-hooks.ts index a413ee058..43d298d7f 100644 --- a/app/features/receive/spark-receive-quote-hooks.ts +++ b/app/features/receive/spark-receive-quote-hooks.ts @@ -68,13 +68,13 @@ export function useSparkReceiveQuoteCache() { return useMemo(() => new SparkReceiveQuoteCache(queryClient), [queryClient]); } -type UseSparkReceiveQuoteProps = { +type UseTrackSparkReceiveQuoteProps = { quoteId?: string; onPaid?: (quote: SparkReceiveQuote) => void; onExpired?: (quote: SparkReceiveQuote) => void; }; -type UseSparkReceiveQuoteResponse = +type UseTrackSparkReceiveQuoteResponse = | { status: 'LOADING'; quote?: undefined; @@ -84,11 +84,11 @@ type UseSparkReceiveQuoteResponse = quote: SparkReceiveQuote; }; -export function useSparkReceiveQuote({ +export function useTrackSparkReceiveQuote({ quoteId, onPaid, onExpired, -}: UseSparkReceiveQuoteProps): UseSparkReceiveQuoteResponse { +}: UseTrackSparkReceiveQuoteProps): UseTrackSparkReceiveQuoteResponse { const enabled = !!quoteId; const onPaidRef = useLatest(onPaid); const onExpiredRef = useLatest(onExpired); diff --git a/app/features/receive/spark-receive-quote-repository.server.ts b/app/features/receive/spark-receive-quote-repository.server.ts index 7cebd8a76..685b32c95 100644 --- a/app/features/receive/spark-receive-quote-repository.server.ts +++ b/app/features/receive/spark-receive-quote-repository.server.ts @@ -1,4 +1,4 @@ -import type { z } from 'zod'; +import type { z } from 'zod/mini'; import type { Money } from '~/lib/money'; import type { AgicashDb } from '../agicash-db/database'; import { SparkLightningReceiveDbDataSchema } from '../agicash-db/json-models'; diff --git a/app/features/receive/spark-receive-quote-repository.ts b/app/features/receive/spark-receive-quote-repository.ts index d901ccce3..7a738a15b 100644 --- a/app/features/receive/spark-receive-quote-repository.ts +++ b/app/features/receive/spark-receive-quote-repository.ts @@ -1,4 +1,4 @@ -import type { z } from 'zod'; +import type { z } from 'zod/mini'; import type { AllUnionFieldsRequired } from '~/lib/type-utils'; import type { AgicashDb, diff --git a/app/features/receive/spark-receive-quote.ts b/app/features/receive/spark-receive-quote.ts index 8ce842096..f06b05fe9 100644 --- a/app/features/receive/spark-receive-quote.ts +++ b/app/features/receive/spark-receive-quote.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { Money } from '~/lib/money'; import { CashuTokenMeltDataSchema } from './cashu-token-melt-data'; @@ -30,7 +30,7 @@ const SparkReceiveQuoteBaseSchema = z.object({ /** * Description of the receive. */ - description: z.string().optional(), + description: z.optional(z.string()), /** * Bolt 11 payment request. */ @@ -42,7 +42,7 @@ const SparkReceiveQuoteBaseSchema = z.object({ /** * Optional public key of the wallet receiving the lightning invoice. */ - receiverIdentityPubkey: z.string().optional(), + receiverIdentityPubkey: z.optional(z.string()), /** * UUID of the corresponding transaction. */ diff --git a/app/features/send/cashu-send-quote-repository.ts b/app/features/send/cashu-send-quote-repository.ts index 0bfeff44c..53f65d24c 100644 --- a/app/features/send/cashu-send-quote-repository.ts +++ b/app/features/send/cashu-send-quote-repository.ts @@ -1,5 +1,5 @@ import type { Proof } from '@cashu/cashu-ts'; -import type { z } from 'zod'; +import type { z } from 'zod/mini'; import { proofToY } from '~/lib/cashu'; import type { Money } from '~/lib/money'; import { computeSHA256 } from '~/lib/sha256'; diff --git a/app/features/send/cashu-send-quote.ts b/app/features/send/cashu-send-quote.ts index 8e4132112..c6db3b9ed 100644 --- a/app/features/send/cashu-send-quote.ts +++ b/app/features/send/cashu-send-quote.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { Money } from '~/lib/money'; import { CashuProofSchema } from '../accounts/cashu-account'; @@ -116,7 +116,7 @@ const CashuSendQuoteBaseSchema = z.object({ * Destination details of the send. * This will be undefined if the send is directly paying a bolt11. */ - destinationDetails: DestinationDetailsSchema.optional(), + destinationDetails: z.optional(DestinationDetailsSchema), /** * ID of the keyset used for the send. */ diff --git a/app/features/send/cashu-send-swap-repository.ts b/app/features/send/cashu-send-swap-repository.ts index b6ab3c7f7..7f0c3f354 100644 --- a/app/features/send/cashu-send-swap-repository.ts +++ b/app/features/send/cashu-send-swap-repository.ts @@ -1,5 +1,5 @@ import type { Proof } from '@cashu/cashu-ts'; -import type { z } from 'zod'; +import type { z } from 'zod/mini'; import { proofToY } from '~/lib/cashu'; import type { Money } from '~/lib/money'; import type { AllUnionFieldsRequired } from '~/lib/type-utils'; diff --git a/app/features/send/cashu-send-swap.ts b/app/features/send/cashu-send-swap.ts index d0c8e031c..545604b2f 100644 --- a/app/features/send/cashu-send-swap.ts +++ b/app/features/send/cashu-send-swap.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { Money } from '~/lib/money'; import { CashuProofSchema } from '../accounts/cashu-account'; @@ -44,18 +44,18 @@ const CashuSendSwapBaseSchema = z.object({ * The keyset id used to generate the output data at the time the swap was created. * This will be defined only when the cashu swap is needed to get the exact amount of proofs to send. */ - keysetId: z.string().optional(), + keysetId: z.optional(z.string()), /** * The keyset counter used to generate the output data at the time the swap was created. * This will be defined only when the cashu swap is needed to get the exact amount of proofs to send. */ - keysetCounter: z.number().optional(), + keysetCounter: z.optional(z.number()), /** * The output data used for deterministic outputs when we swap the inputProofs for proofsToSend. * This will be defined only when the cashu swap is needed to get the exact amount of proofs to send. */ - outputAmounts: z - .object({ + outputAmounts: z.optional( + z.object({ /** * The output amounts to use when constructing the send output data. */ @@ -64,8 +64,8 @@ const CashuSendSwapBaseSchema = z.object({ * The output amounts to use when constructing the change output data. */ change: z.array(z.number()), - }) - .optional(), + }), + ), /** * The sum of the inputProofs. */ @@ -113,15 +113,18 @@ const CashuSendSwapBaseSchema = z.object({ version: z.number(), }); -const CashuSendSwapDraftStateSchema = CashuSendSwapBaseSchema.pick({ - keysetId: true, - keysetCounter: true, - outputAmounts: true, -}) - .required() - .extend({ +const CashuSendSwapDraftStateSchema = z.extend( + z.required( + z.pick(CashuSendSwapBaseSchema, { + keysetId: true, + keysetCounter: true, + outputAmounts: true, + }), + ), + { state: z.literal('DRAFT'), - }); + }, +); const CashuSendSwapPendingCompletedStateSchema = z.object({ state: z.enum(['PENDING', 'COMPLETED']), diff --git a/app/features/send/spark-send-quote-repository.ts b/app/features/send/spark-send-quote-repository.ts index 977e43415..e7b16fab4 100644 --- a/app/features/send/spark-send-quote-repository.ts +++ b/app/features/send/spark-send-quote-repository.ts @@ -1,4 +1,4 @@ -import type { z } from 'zod'; +import type { z } from 'zod/mini'; import type { Money } from '~/lib/money'; import type { AllUnionFieldsRequired } from '~/lib/type-utils'; import type { diff --git a/app/features/send/spark-send-quote.ts b/app/features/send/spark-send-quote.ts index 03291a913..37437746c 100644 --- a/app/features/send/spark-send-quote.ts +++ b/app/features/send/spark-send-quote.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { Money } from '~/lib/money'; /** @@ -19,7 +19,7 @@ const SparkSendQuoteBaseSchema = z.object({ /** * Date and time the send quote expires in ISO 8601 format. */ - expiresAt: z.string().nullish(), + expiresAt: z.nullish(z.string()), /** * Amount being sent. */ @@ -108,15 +108,15 @@ const SparkSendQuoteFailedStateSchema = z.object({ /** * ID of the send request in spark system. */ - sparkId: z.string().optional(), + sparkId: z.optional(z.string()), /** * Spark transfer ID. */ - sparkTransferId: z.string().optional(), + sparkTransferId: z.optional(z.string()), /** * Actual fee of the lightning payment. */ - fee: z.instanceof(Money).optional(), + fee: z.optional(z.instanceof(Money)), }); /** diff --git a/app/features/send/utils.ts b/app/features/send/utils.ts index 55d2e2da4..10a2ac044 100644 --- a/app/features/send/utils.ts +++ b/app/features/send/utils.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { ProofSchema } from '~/lib/cashu/types'; import type { CashuProof } from '../accounts/cashu-account'; import type { AgicashDbCashuProof } from '../agicash-db/database'; diff --git a/app/features/transactions/transaction-details/cashu-lightning-receive-transaction-details.ts b/app/features/transactions/transaction-details/cashu-lightning-receive-transaction-details.ts index b256f9809..9b7aa1a25 100644 --- a/app/features/transactions/transaction-details/cashu-lightning-receive-transaction-details.ts +++ b/app/features/transactions/transaction-details/cashu-lightning-receive-transaction-details.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { CashuLightningReceiveDbDataSchema } from '~/features/agicash-db/json-models'; import { Money } from '~/lib/money'; import { TransactionStateSchema } from '../transaction-enums'; @@ -20,12 +20,12 @@ export const CashuLightningReceiveTransactionDetailsSchema = z.object({ /** * The description of the transaction. */ - description: z.string().optional(), + description: z.optional(z.string()), /** * Optional fee charged by the mint to deposit money into the account. * The payer of the lightning invoice pays this fee. */ - mintingFee: z.instanceof(Money).optional(), + mintingFee: z.optional(z.instanceof(Money)), /** * The amount credited to the account. */ @@ -38,25 +38,25 @@ export const CashuLightningReceiveTransactionDetailsSchema = z.object({ /** * UUID linking paired send/receive transactions for internal transfers. */ - transferId: z.string().optional(), + transferId: z.optional(z.string()), }); export type CashuLightningReceiveTransactionDetails = z.infer< typeof CashuLightningReceiveTransactionDetailsSchema >; -export const CashuLightningReceiveTransactionDetailsParser = z - .object({ +export const CashuLightningReceiveTransactionDetailsParser = z.pipe( + z.object({ type: z.literal('CASHU_LIGHTNING'), direction: z.literal('RECEIVE'), state: TransactionStateSchema, transactionDetails: z.object({ paymentHash: z.string(), - transferId: z.string().optional(), + transferId: z.optional(z.string()), }), decryptedTransactionDetails: CashuLightningReceiveDbDataSchema, - }) - .transform( + }), + z.transform( ({ transactionDetails, decryptedTransactionDetails, @@ -71,4 +71,5 @@ export const CashuLightningReceiveTransactionDetailsParser = z transferId: transactionDetails.transferId, }; }, - ) satisfies TransactionDetailsParserShape; + ), +) satisfies TransactionDetailsParserShape; diff --git a/app/features/transactions/transaction-details/cashu-lightning-send-transaction-details.ts b/app/features/transactions/transaction-details/cashu-lightning-send-transaction-details.ts index 8752f1dce..80f9adf12 100644 --- a/app/features/transactions/transaction-details/cashu-lightning-send-transaction-details.ts +++ b/app/features/transactions/transaction-details/cashu-lightning-send-transaction-details.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { CashuLightningSendDbDataSchema } from '~/features/agicash-db/json-models'; import { DestinationDetailsSchema } from '~/features/send/cashu-send-quote'; import { Money } from '~/lib/money'; @@ -21,7 +21,7 @@ export const IncompleteCashuLightningSendTransactionDetailsSchema = z.object({ * Additional details related to the transaction. * This will be undefined if the send is directly paying a bolt11. */ - destinationDetails: DestinationDetailsSchema.optional(), + destinationDetails: z.optional(DestinationDetailsSchema), /** * The amount reserved for the send. * This is the sum of all proofs used as inputs to the cashu melt operation. @@ -61,7 +61,7 @@ export const IncompleteCashuLightningSendTransactionDetailsSchema = z.object({ /** * UUID linking paired send/receive transactions for internal transfers. */ - transferId: z.string().optional(), + transferId: z.optional(z.string()), }); export type IncompleteCashuLightningSendTransactionDetails = z.infer< @@ -71,8 +71,9 @@ export type IncompleteCashuLightningSendTransactionDetails = z.infer< /** * Schema for completed cashu lightning send transaction. */ -export const CompletedCashuLightningSendTransactionDetailsSchema = - IncompleteCashuLightningSendTransactionDetailsSchema.extend({ +export const CompletedCashuLightningSendTransactionDetailsSchema = z.extend( + IncompleteCashuLightningSendTransactionDetailsSchema, + { /** * The preimage of the lightning payment. * If the lightning payment is settled internally in the mint, this will be an empty string or '0x0000000000000000000000000000000000000000000000000000000000000000' @@ -89,7 +90,8 @@ export const CompletedCashuLightningSendTransactionDetailsSchema = * This is the sum of `lightningFee` and `cashuSendFee`. */ totalFee: z.instanceof(Money), - }); + }, +); export type CompletedCashuLightningSendTransactionDetails = z.infer< typeof CompletedCashuLightningSendTransactionDetailsSchema @@ -104,18 +106,18 @@ export type CashuLightningSendTransactionDetails = z.infer< typeof CashuLightningSendTransactionDetailsSchema >; -export const CashuLightningSendTransactionDetailsParser = z - .object({ +export const CashuLightningSendTransactionDetailsParser = z.pipe( + z.object({ type: z.literal('CASHU_LIGHTNING'), direction: z.literal('SEND'), state: TransactionStateSchema, transactionDetails: z.object({ paymentHash: z.string(), - transferId: z.string().optional(), + transferId: z.optional(z.string()), }), decryptedTransactionDetails: CashuLightningSendDbDataSchema, - }) - .transform( + }), + z.transform( ({ state, transactionDetails, @@ -155,4 +157,5 @@ export const CashuLightningSendTransactionDetailsParser = z return incompleteDetails; }, - ) satisfies TransactionDetailsParserShape; + ), +) satisfies TransactionDetailsParserShape; diff --git a/app/features/transactions/transaction-details/cashu-token-receive-transaction-details.ts b/app/features/transactions/transaction-details/cashu-token-receive-transaction-details.ts index 5c6f532cb..f1cdcc608 100644 --- a/app/features/transactions/transaction-details/cashu-token-receive-transaction-details.ts +++ b/app/features/transactions/transaction-details/cashu-token-receive-transaction-details.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { CashuLightningReceiveDbDataSchema, CashuSwapReceiveDbDataSchema, @@ -23,7 +23,7 @@ export const CashuTokenReceiveTransactionDetailsSchema = z.object({ /** * The description of the transaction. */ - description: z.string().optional(), + description: z.optional(z.string()), /** * Amount credited to the account. * This is the `tokenAmount` minus `totalFee`. @@ -41,13 +41,13 @@ export const CashuTokenReceiveTransactionDetailsSchema = z.object({ * but only if the destination mint has a minting fee. * In this case the receiving account creates a mint quote, and then the ln invoice of the mint quote is paid by melting the token proofs. */ - mintingFee: z.instanceof(Money).optional(), + mintingFee: z.optional(z.instanceof(Money)), /** * The fee reserved for the lightning payment. * This is defined when receving the token to spark account or cashu account with mint different than the one that issued the token being * received. */ - lightningFeeReserve: z.instanceof(Money).optional(), + lightningFeeReserve: z.optional(z.instanceof(Money)), /** * The total fee for the transaction. * This is the sum of `cashuReceiveFee`, `lightningFeeReserve`, and `mintingFee`. @@ -74,14 +74,14 @@ export type CashuTokenReceiveTransactionDetails = z.infer< * Thus CashuTokenReceiveTransactionDetailsParser will be a union of three parsers, one for each of the three cases. */ -const CashuSwapReceiveParser = z - .object({ +const CashuSwapReceiveParser = z.pipe( + z.object({ type: z.literal('CASHU_TOKEN'), direction: z.literal('RECEIVE'), state: TransactionStateSchema, decryptedTransactionDetails: CashuSwapReceiveDbDataSchema, - }) - .transform( + }), + z.transform( ({ decryptedTransactionDetails }): CashuTokenReceiveTransactionDetails => ({ tokenAmount: decryptedTransactionDetails.tokenAmount, tokenMintUrl: decryptedTransactionDetails.tokenMintUrl, @@ -90,18 +90,19 @@ const CashuSwapReceiveParser = z cashuReceiveFee: decryptedTransactionDetails.cashuReceiveFee, totalFee: decryptedTransactionDetails.cashuReceiveFee, }), - ) satisfies TransactionDetailsParserShape; + ), +) satisfies TransactionDetailsParserShape; -const CashuLightningReceiveParser: TransactionDetailsParserShape = z - .object({ +const CashuLightningReceiveParser: TransactionDetailsParserShape = z.pipe( + z.object({ type: z.literal('CASHU_TOKEN'), direction: z.literal('RECEIVE'), state: TransactionStateSchema, - decryptedTransactionDetails: CashuLightningReceiveDbDataSchema.required({ + decryptedTransactionDetails: z.required(CashuLightningReceiveDbDataSchema, { cashuTokenMeltData: true, }), - }) - .transform( + }), + z.transform( ({ decryptedTransactionDetails }): CashuTokenReceiveTransactionDetails => ({ tokenAmount: decryptedTransactionDetails.cashuTokenMeltData.tokenAmount, tokenMintUrl: decryptedTransactionDetails.cashuTokenMeltData.tokenMintUrl, @@ -114,18 +115,19 @@ const CashuLightningReceiveParser: TransactionDetailsParserShape = z decryptedTransactionDetails.cashuTokenMeltData.lightningFeeReserve, totalFee: decryptedTransactionDetails.totalFee, }), - ) satisfies TransactionDetailsParserShape; + ), +) satisfies TransactionDetailsParserShape; -const SparkLightningReceiveParser = z - .object({ +const SparkLightningReceiveParser = z.pipe( + z.object({ type: z.literal('CASHU_TOKEN'), direction: z.literal('RECEIVE'), state: TransactionStateSchema, - decryptedTransactionDetails: SparkLightningReceiveDbDataSchema.required({ + decryptedTransactionDetails: z.required(SparkLightningReceiveDbDataSchema, { cashuTokenMeltData: true, }), - }) - .transform( + }), + z.transform( ({ decryptedTransactionDetails }): CashuTokenReceiveTransactionDetails => ({ tokenAmount: decryptedTransactionDetails.cashuTokenMeltData.tokenAmount, tokenMintUrl: decryptedTransactionDetails.cashuTokenMeltData.tokenMintUrl, @@ -137,7 +139,8 @@ const SparkLightningReceiveParser = z decryptedTransactionDetails.cashuTokenMeltData.lightningFeeReserve, totalFee: decryptedTransactionDetails.totalFee, }), - ) satisfies TransactionDetailsParserShape; + ), +) satisfies TransactionDetailsParserShape; export const CashuTokenReceiveTransactionDetailsParser = z.union([ CashuSwapReceiveParser, diff --git a/app/features/transactions/transaction-details/cashu-token-send-transaction-details.ts b/app/features/transactions/transaction-details/cashu-token-send-transaction-details.ts index 06baaa436..fea824e76 100644 --- a/app/features/transactions/transaction-details/cashu-token-send-transaction-details.ts +++ b/app/features/transactions/transaction-details/cashu-token-send-transaction-details.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { CashuSwapSendDbDataSchema } from '~/features/agicash-db/json-models'; import { Money } from '~/lib/money'; import { TransactionStateSchema } from '../transaction-enums'; @@ -58,14 +58,14 @@ export type CashuTokenSendTransactionDetails = z.infer< typeof CashuTokenSendTransactionDetailsSchema >; -export const CashuTokenSendTransactionDetailsParser = z - .object({ +export const CashuTokenSendTransactionDetailsParser = z.pipe( + z.object({ type: z.literal('CASHU_TOKEN'), direction: z.literal('SEND'), state: TransactionStateSchema, decryptedTransactionDetails: CashuSwapSendDbDataSchema, - }) - .transform( + }), + z.transform( ({ state, decryptedTransactionDetails, @@ -84,4 +84,5 @@ export const CashuTokenSendTransactionDetailsParser = z totalFee: decryptedTransactionDetails.totalFee, }; }, - ) satisfies TransactionDetailsParserShape; + ), +) satisfies TransactionDetailsParserShape; diff --git a/app/features/transactions/transaction-details/spark-lightning-receive-transaction-details.ts b/app/features/transactions/transaction-details/spark-lightning-receive-transaction-details.ts index 54b555a7e..6163658e9 100644 --- a/app/features/transactions/transaction-details/spark-lightning-receive-transaction-details.ts +++ b/app/features/transactions/transaction-details/spark-lightning-receive-transaction-details.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { SparkLightningReceiveDbDataSchema } from '~/features/agicash-db/json-models'; import { Money } from '~/lib/money'; import { TransactionStateSchema } from '../transaction-enums'; @@ -24,7 +24,7 @@ export const IncompleteSparkLightningReceiveTransactionDetailsSchema = z.object( /** * The description of the transaction. */ - description: z.string().optional(), + description: z.optional(z.string()), /** * Amount credited to the account. * This is the amount of the bolt 11 payment request. @@ -33,7 +33,7 @@ export const IncompleteSparkLightningReceiveTransactionDetailsSchema = z.object( /** * UUID linking paired send/receive transactions for internal transfers. */ - transferId: z.string().optional(), + transferId: z.optional(z.string()), }, ); @@ -44,8 +44,9 @@ export type IncompleteSparkLightningReceiveTransactionDetails = z.infer< /** * Schema for completed Spark lightning receive transaction. */ -export const CompletedSparkLightningReceiveTransactionDetailsSchema = - IncompleteSparkLightningReceiveTransactionDetailsSchema.extend({ +export const CompletedSparkLightningReceiveTransactionDetailsSchema = z.extend( + IncompleteSparkLightningReceiveTransactionDetailsSchema, + { /** * The payment preimage of the lightning payment. */ @@ -54,7 +55,8 @@ export const CompletedSparkLightningReceiveTransactionDetailsSchema = * The ID of the transfer in Spark system. */ sparkTransferId: z.string(), - }); + }, +); export type CompletedSparkLightningReceiveTransactionDetails = z.infer< typeof CompletedSparkLightningReceiveTransactionDetailsSchema @@ -69,20 +71,20 @@ export type SparkLightningReceiveTransactionDetails = z.infer< typeof SparkLightningReceiveTransactionDetailsSchema >; -export const SparkLightningReceiveTransactionDetailsParser = z - .object({ +export const SparkLightningReceiveTransactionDetailsParser = z.pipe( + z.object({ type: z.literal('SPARK_LIGHTNING'), direction: z.literal('RECEIVE'), state: TransactionStateSchema, transactionDetails: z.object({ paymentHash: z.string(), sparkId: z.string(), - sparkTransferId: z.string().optional(), - transferId: z.string().optional(), + sparkTransferId: z.optional(z.string()), + transferId: z.optional(z.string()), }), decryptedTransactionDetails: SparkLightningReceiveDbDataSchema, - }) - .transform( + }), + z.transform( ({ state, transactionDetails, @@ -114,4 +116,5 @@ export const SparkLightningReceiveTransactionDetailsParser = z return incompleteDetails; }, - ) satisfies TransactionDetailsParserShape; + ), +) satisfies TransactionDetailsParserShape; diff --git a/app/features/transactions/transaction-details/spark-lightning-send-transaction-details.ts b/app/features/transactions/transaction-details/spark-lightning-send-transaction-details.ts index c6337ac62..462bf77c5 100644 --- a/app/features/transactions/transaction-details/spark-lightning-send-transaction-details.ts +++ b/app/features/transactions/transaction-details/spark-lightning-send-transaction-details.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { SparkLightningSendDbDataSchema } from '~/features/agicash-db/json-models'; import { Money } from '~/lib/money'; import { TransactionStateSchema } from '../transaction-enums'; @@ -44,16 +44,16 @@ export const IncompleteSparkLightningSendTransactionDetailsSchema = z.object({ * The ID of the send request in Spark system. * Available after the payment is initiated. */ - sparkId: z.string().optional(), + sparkId: z.optional(z.string()), /** * The ID of the transfer in Spark system. * Available after the payment is initiated. */ - sparkTransferId: z.string().optional(), + sparkTransferId: z.optional(z.string()), /** * UUID linking paired send/receive transactions for internal transfers. */ - transferId: z.string().optional(), + transferId: z.optional(z.string()), }); export type IncompleteSparkLightningSendTransactionDetails = z.infer< @@ -63,13 +63,15 @@ export type IncompleteSparkLightningSendTransactionDetails = z.infer< /** * Schema for a Spark lightning send transaction that is completed. */ -export const CompletedSparkLightningSendTransactionDetailsSchema = - IncompleteSparkLightningSendTransactionDetailsSchema.required().extend({ +export const CompletedSparkLightningSendTransactionDetailsSchema = z.extend( + z.required(IncompleteSparkLightningSendTransactionDetailsSchema), + { /** The preimage of the lightning payment. */ paymentPreimage: z.string(), /** transferId remains optional — only present for TRANSFER transactions. */ - transferId: z.string().optional(), - }); + transferId: z.optional(z.string()), + }, +); export type CompletedSparkLightningSendTransactionDetails = z.infer< typeof CompletedSparkLightningSendTransactionDetailsSchema @@ -84,20 +86,20 @@ export type SparkLightningSendTransactionDetails = z.infer< typeof SparkLightningSendTransactionDetailsSchema >; -export const SparkLightningSendTransactionDetailsParser = z - .object({ +export const SparkLightningSendTransactionDetailsParser = z.pipe( + z.object({ type: z.literal('SPARK_LIGHTNING'), direction: z.literal('SEND'), state: TransactionStateSchema, transactionDetails: z.object({ paymentHash: z.string(), - sparkId: z.string().optional(), - sparkTransferId: z.string().optional(), - transferId: z.string().optional(), + sparkId: z.optional(z.string()), + sparkTransferId: z.optional(z.string()), + transferId: z.optional(z.string()), }), decryptedTransactionDetails: SparkLightningSendDbDataSchema, - }) - .transform( + }), + z.transform( ({ state, transactionDetails, @@ -139,4 +141,5 @@ export const SparkLightningSendTransactionDetailsParser = z return incompleteDetails; }, - ) satisfies TransactionDetailsParserShape; + ), +) satisfies TransactionDetailsParserShape; diff --git a/app/features/transactions/transaction-details/transaction-details-parser.ts b/app/features/transactions/transaction-details/transaction-details-parser.ts index 3235a23f1..1f728ad5a 100644 --- a/app/features/transactions/transaction-details/transaction-details-parser.ts +++ b/app/features/transactions/transaction-details/transaction-details-parser.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { CashuLightningReceiveTransactionDetailsParser } from './cashu-lightning-receive-transaction-details'; import { CashuLightningSendTransactionDetailsParser } from './cashu-lightning-send-transaction-details'; import { CashuTokenReceiveTransactionDetailsParser } from './cashu-token-receive-transaction-details'; diff --git a/app/features/transactions/transaction-details/transaction-details-types.ts b/app/features/transactions/transaction-details/transaction-details-types.ts index 635d18da8..010568d51 100644 --- a/app/features/transactions/transaction-details/transaction-details-types.ts +++ b/app/features/transactions/transaction-details/transaction-details-types.ts @@ -1,5 +1,5 @@ import type { Json } from 'supabase/database.types'; -import { z } from 'zod'; +import { z } from 'zod/mini'; import { CashuLightningReceiveDbDataSchema, CashuLightningSendDbDataSchema, @@ -50,7 +50,7 @@ export type TransactionDetailsParserInput = { type TransactionDetailsParserOutput = TransactionDetails; -export type TransactionDetailsParserShape = z.ZodType< +export type TransactionDetailsParserShape = z.ZodMiniType< TransactionDetailsParserOutput, TransactionDetailsParserInput >; diff --git a/app/features/transactions/transaction-enums.ts b/app/features/transactions/transaction-enums.ts index 099b21954..a3a7b0997 100644 --- a/app/features/transactions/transaction-enums.ts +++ b/app/features/transactions/transaction-enums.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; export const TransactionDirectionSchema = z.enum(['SEND', 'RECEIVE']); diff --git a/app/features/transactions/transaction-repository.ts b/app/features/transactions/transaction-repository.ts index 43674b25f..e0a01fd00 100644 --- a/app/features/transactions/transaction-repository.ts +++ b/app/features/transactions/transaction-repository.ts @@ -1,4 +1,4 @@ -import type { z } from 'zod'; +import type { z } from 'zod/mini'; import type { AgicashDb, AgicashDbTransaction } from '../agicash-db/database'; import { agicashDbClient } from '../agicash-db/database.client'; import { useEncryption } from '../shared/encryption'; diff --git a/app/features/transactions/transaction.ts b/app/features/transactions/transaction.ts index aca9b4cf1..bd6f39e19 100644 --- a/app/features/transactions/transaction.ts +++ b/app/features/transactions/transaction.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { Money } from '~/lib/money'; import { AccountPurposeSchema, AccountTypeSchema } from '../accounts/account'; import { CashuLightningReceiveTransactionDetailsSchema } from './transaction-details/cashu-lightning-receive-transaction-details'; @@ -62,7 +62,7 @@ export const BaseTransactionSchema = z.object({ * accountName/Type/Purpose fields still describe the account at the time * of the transaction. */ - accountId: z.string().nullable(), + accountId: z.nullable(z.string()), /** * Name of the account at the time this transaction was created. */ @@ -86,7 +86,7 @@ export const BaseTransactionSchema = z.object({ /** * UUID of the transaction that is reversed by this transaction. */ - reversedTransactionId: z.string().nullish(), + reversedTransactionId: z.nullish(z.string()), /** * The purpose of this transaction (e.g. a Cash App buy or an internal transfer). * Defaults to 'PAYMENT' for organic send/receive transactions. @@ -98,7 +98,7 @@ export const BaseTransactionSchema = z.object({ * - `pending`: The transaction has entered a state where the user should acknowledge it. * - `acknowledged`: The transaction has been acknowledged by the user. */ - acknowledgmentStatus: z.enum(['pending', 'acknowledged']).nullable(), + acknowledgmentStatus: z.nullable(z.enum(['pending', 'acknowledged'])), /** * Date and time the transaction was created in ISO 8601 format. */ @@ -106,19 +106,19 @@ export const BaseTransactionSchema = z.object({ /** * Date and time the transaction was set to pending in ISO 8601 format. */ - pendingAt: z.string().nullish(), + pendingAt: z.nullish(z.string()), /** * Date and time the transaction was completed in ISO 8601 format. */ - completedAt: z.string().nullish(), + completedAt: z.nullish(z.string()), /** * Date and time the transaction failed in ISO 8601 format. */ - failedAt: z.string().nullish(), + failedAt: z.nullish(z.string()), /** * Date and time the transaction was reversed in ISO 8601 format. */ - reversedAt: z.string().nullish(), + reversedAt: z.nullish(z.string()), /** * Version of the transaction. * Incremented on every update. @@ -126,71 +126,83 @@ export const BaseTransactionSchema = z.object({ version: z.number(), }); -const CashuTokenSendTransactionSchema = BaseTransactionSchema.extend({ +const CashuTokenSendTransactionSchema = z.extend(BaseTransactionSchema, { type: z.literal('CASHU_TOKEN'), direction: z.literal('SEND'), details: CashuTokenSendTransactionDetailsSchema, }); -const CashuTokenReceiveTransactionSchema = BaseTransactionSchema.extend({ +const CashuTokenReceiveTransactionSchema = z.extend(BaseTransactionSchema, { type: z.literal('CASHU_TOKEN'), direction: z.literal('RECEIVE'), details: CashuTokenReceiveTransactionDetailsSchema, }); -const IncompleteCashuLightningSendTransactionSchema = - BaseTransactionSchema.extend({ +const IncompleteCashuLightningSendTransactionSchema = z.extend( + BaseTransactionSchema, + { type: z.literal('CASHU_LIGHTNING'), direction: z.literal('SEND'), state: z.enum(['PENDING', 'FAILED']), details: IncompleteCashuLightningSendTransactionDetailsSchema, - }); + }, +); -const CompletedCashuLightningSendTransactionSchema = - BaseTransactionSchema.extend({ +const CompletedCashuLightningSendTransactionSchema = z.extend( + BaseTransactionSchema, + { type: z.literal('CASHU_LIGHTNING'), direction: z.literal('SEND'), state: z.literal('COMPLETED'), details: CompletedCashuLightningSendTransactionDetailsSchema, - }); + }, +); -const CashuLightningReceiveTransactionSchema = BaseTransactionSchema.extend({ +const CashuLightningReceiveTransactionSchema = z.extend(BaseTransactionSchema, { type: z.literal('CASHU_LIGHTNING'), direction: z.literal('RECEIVE'), details: CashuLightningReceiveTransactionDetailsSchema, }); -const IncompleteSparkLightningReceiveTransactionSchema = - BaseTransactionSchema.extend({ +const IncompleteSparkLightningReceiveTransactionSchema = z.extend( + BaseTransactionSchema, + { type: z.literal('SPARK_LIGHTNING'), direction: z.literal('RECEIVE'), state: z.enum(['DRAFT', 'PENDING', 'FAILED']), details: SparkLightningReceiveTransactionDetailsSchema, - }); + }, +); -const CompletedSparkLightningReceiveTransactionSchema = - BaseTransactionSchema.extend({ +const CompletedSparkLightningReceiveTransactionSchema = z.extend( + BaseTransactionSchema, + { type: z.literal('SPARK_LIGHTNING'), direction: z.literal('RECEIVE'), state: z.literal('COMPLETED'), details: CompletedSparkLightningReceiveTransactionDetailsSchema, - }); + }, +); -const IncompleteSparkLightningSendTransactionSchema = - BaseTransactionSchema.extend({ +const IncompleteSparkLightningSendTransactionSchema = z.extend( + BaseTransactionSchema, + { type: z.literal('SPARK_LIGHTNING'), direction: z.literal('SEND'), state: z.enum(['DRAFT', 'PENDING', 'FAILED']), details: IncompleteSparkLightningSendTransactionDetailsSchema, - }); + }, +); -const CompletedSparkLightningSendTransactionSchema = - BaseTransactionSchema.extend({ +const CompletedSparkLightningSendTransactionSchema = z.extend( + BaseTransactionSchema, + { type: z.literal('SPARK_LIGHTNING'), direction: z.literal('SEND'), state: z.literal('COMPLETED'), details: CompletedSparkLightningSendTransactionDetailsSchema, - }); + }, +); /** * Union of all transaction type/direction/state variants. @@ -211,13 +223,15 @@ const TransactionByTypeSchema = z.union([ * Schema for all transaction types. */ export const TransactionSchema = z.union([ - TransactionByTypeSchema.and( + z.intersection( + TransactionByTypeSchema, z.object({ purpose: z.literal('TRANSFER'), details: z.object({ transferId: z.string() }), }), ), - TransactionByTypeSchema.and( + z.intersection( + TransactionByTypeSchema, z.object({ purpose: z.enum(['PAYMENT', 'BUY_CASHAPP']), }), diff --git a/app/features/user/guest-account-storage.ts b/app/features/user/guest-account-storage.ts index cc8422465..4a03fdc04 100644 --- a/app/features/user/guest-account-storage.ts +++ b/app/features/user/guest-account-storage.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { safeJsonParse } from '~/lib/json'; const storageKey = 'guestAccount'; diff --git a/app/features/user/require-session-hint.server.ts b/app/features/user/require-session-hint.server.ts index 392901a75..f28c2b92f 100644 --- a/app/features/user/require-session-hint.server.ts +++ b/app/features/user/require-session-hint.server.ts @@ -11,6 +11,18 @@ import { sessionHintCookie } from './session-hint-cookie'; * constraint](https://reactrouter.com/api/framework-conventions/route-module#splitting-route-modules). */ export const requireSessionHintOrRedirect = (request: Request): void => { + // Only redirect on top-level document navigations (typed URL, refresh, + // link from an external page). Client-side navigations within the SPA + // come in as single-fetch data requests, and the existing + // clientMiddleware validates the JWT against localStorage on those — so + // letting them pass here is correct and also prevents trapping users + // whose cookies don't persist (e.g. cookies disabled, evicted from + // storage, manually cleared). Sec-Fetch-Mode is sent by all modern + // browsers; if it's absent we treat the request as non-navigation to + // avoid trapping older browsers in a redirect loop. + if (request.headers.get('Sec-Fetch-Mode') !== 'navigate') { + return; + } if (sessionHintCookie.isPresent(request.headers.get('Cookie'))) { return; } diff --git a/app/features/user/user-repository.ts b/app/features/user/user-repository.ts index fa18f6849..c65a6f729 100644 --- a/app/features/user/user-repository.ts +++ b/app/features/user/user-repository.ts @@ -1,6 +1,6 @@ import type { QueryClient } from '@tanstack/react-query'; import type { DistributedOmit } from 'type-fest'; -import type { z } from 'zod'; +import type { z } from 'zod/mini'; import { normalizeMintUrl } from '~/lib/cashu'; import type { Currency } from '~/lib/money'; import type { Account, RedactedAccount } from '../accounts/account'; diff --git a/app/lib/canonical-origin.server.ts b/app/lib/canonical-origin.server.ts new file mode 100644 index 000000000..246495a03 --- /dev/null +++ b/app/lib/canonical-origin.server.ts @@ -0,0 +1,31 @@ +function getProductionDeploymentUrl(): string | undefined { + const host = process.env.VERCEL_PROJECT_PRODUCTION_URL; + return host ? `https://${host}` : undefined; +} + +function getPreviewDeploymentUrl(): string | undefined { + const host = process.env.VERCEL_BRANCH_URL ?? process.env.VERCEL_URL; + return host ? `https://${host}` : undefined; +} + +/** + * The site's reachable origin, for absolute URLs that leave the app — OG/Twitter + * images, shared token links, profile URLs, `username@domain` Lightning + * Addresses. On Vercel it returns the production domain (or the branch/deploy + * URL on preview); elsewhere it falls back to the request origin. We avoid the + * request origin because at build-time prerender (`/home`, `/terms`) react-router + * uses `http://localhost`, which is frozen into the static HTML and — since the + * root loader never revalidates — served for the whole session. Precedence + * mirrors Next.js's metadataBase fallback. + * @see https://github.com/vercel/next.js/pull/65089 + * @param requestOrigin fallback used off Vercel (local dev / non-Vercel SSR). + */ +export function getCanonicalOrigin(requestOrigin: string): string { + if (process.env.VERCEL_ENV === 'preview') { + return getPreviewDeploymentUrl() ?? requestOrigin; + } + if (process.env.VERCEL_ENV === 'production') { + return getProductionDeploymentUrl() ?? requestOrigin; + } + return requestOrigin; +} diff --git a/app/lib/cashu/error-codes.ts b/app/lib/cashu/error-codes.ts index 44037207f..65fb677c8 100644 --- a/app/lib/cashu/error-codes.ts +++ b/app/lib/cashu/error-codes.ts @@ -3,34 +3,40 @@ */ export enum CashuErrorCodes { /** - * Blinded message of output already signed - * Relevant nuts: @see [NUT-03](https://github.com/cashubtc/nuts/blob/main/03.md), [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + * Proof verification failed + * Relevant nuts: @see [NUT-03](https://github.com/cashubtc/nuts/blob/main/03.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) */ - OUTPUT_ALREADY_SIGNED = 10002, + TOKEN_VERIFICATION_FAILED = 10001, /** - * Token could not be verified + * Proofs already spent * Relevant nuts: @see [NUT-03](https://github.com/cashubtc/nuts/blob/main/03.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) */ - TOKEN_VERIFICATION_FAILED = 10003, + TOKEN_ALREADY_SPENT = 11001, /** - * Token is already spent + * Proofs are pending (in flight in a parallel operation) * Relevant nuts: @see [NUT-03](https://github.com/cashubtc/nuts/blob/main/03.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) */ - TOKEN_ALREADY_SPENT = 11001, + PROOFS_ARE_PENDING = 11002, /** - * Transaction is not balanced (inputs != outputs) - * Relevant nuts: @see [NUT-02](https://github.com/cashubtc/nuts/blob/main/02.md), [NUT-03](https://github.com/cashubtc/nuts/blob/main/03.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + * Blinded message of output already signed + * Relevant nuts: @see [NUT-03](https://github.com/cashubtc/nuts/blob/main/03.md), [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) */ - TRANSACTION_NOT_BALANCED = 11002, + OUTPUT_ALREADY_SIGNED = 11003, /** - * Unit in request is not supported - * Relevant nuts: @see [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + * Outputs are pending (in flight in a parallel operation) + * Relevant nuts: @see [NUT-03](https://github.com/cashubtc/nuts/blob/main/03.md), [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) */ - UNIT_NOT_SUPPORTED = 11005, + OUTPUTS_ARE_PENDING = 11004, + + /** + * Transaction is not balanced (inputs != outputs) + * Relevant nuts: @see [NUT-02](https://github.com/cashubtc/nuts/blob/main/02.md), [NUT-03](https://github.com/cashubtc/nuts/blob/main/03.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + TRANSACTION_NOT_BALANCED = 11005, /** * Amount outside of limit range @@ -62,6 +68,46 @@ export enum CashuErrorCodes { */ UNIT_MISMATCH = 11010, + /** + * Amountless invoice is not supported + * Relevant nuts: @see [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + AMOUNTLESS_INVOICE_UNSUPPORTED = 11011, + + /** + * Amount in request does not equal invoice amount + * Relevant nuts: @see [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + AMOUNT_MISMATCH = 11012, + + /** + * Unit in request is not supported + * Relevant nuts: @see [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + UNIT_NOT_SUPPORTED = 11013, + + /** + * Maximum number of inputs exceeded for a single request + * Relevant nuts: @see [NUT-03](https://github.com/cashubtc/nuts/blob/main/03.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + MAX_INPUTS_EXCEEDED = 11014, + + /** + * Maximum number of outputs exceeded for a single request + * Relevant nuts: @see [NUT-03](https://github.com/cashubtc/nuts/blob/main/03.md), [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + MAX_OUTPUTS_EXCEEDED = 11015, + + /** + * Duplicate quote IDs provided in a batched request + */ + DUPLICATE_QUOTE_IDS = 11016, + + /** + * Maximum batch size exceeded for a batched request + */ + MAX_BATCH_SIZE_EXCEEDED = 11017, + /** * Keyset is not known * Relevant nuts: @see [NUT-02](https://github.com/cashubtc/nuts/blob/main/02.md), [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md) @@ -74,6 +120,12 @@ export enum CashuErrorCodes { */ KEYSET_INACTIVE = 12002, + /** + * Keyset has expired + * Relevant nuts: @see [NUT-02](https://github.com/cashubtc/nuts/blob/main/02.md) + */ + KEYSET_EXPIRED = 12003, + /** * Quote request is not paid * Relevant nuts: @see [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md) diff --git a/app/lib/cashu/mint-validation.ts b/app/lib/cashu/mint-validation.ts index 0a36f91b9..e0f6fe74d 100644 --- a/app/lib/cashu/mint-validation.ts +++ b/app/lib/cashu/mint-validation.ts @@ -1,5 +1,5 @@ import type { MintInfo, MintKeyset, WebSocketSupport } from '@cashu/cashu-ts'; -import { z } from 'zod'; +import { z } from 'zod/mini'; import { CASHU_PROTOCOL_UNITS, type CashuProtocolUnit, @@ -21,7 +21,7 @@ export const MintBlocklistSchema = z.array( z.object({ mintUrl: z.url(), /** If null, the entire mint is blocked */ - unit: z.enum(CASHU_PROTOCOL_UNITS).nullable(), + unit: z.nullable(z.enum(CASHU_PROTOCOL_UNITS)), }), ); diff --git a/app/lib/cashu/types.ts b/app/lib/cashu/types.ts index 37db9cb50..a1d2573ce 100644 --- a/app/lib/cashu/types.ts +++ b/app/lib/cashu/types.ts @@ -1,19 +1,19 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; import { nullToUndefined } from '../zod'; const SerializedDLEQSchema = z.object({ s: z.string(), e: z.string(), - r: z.string().optional(), + r: z.optional(z.string()), }); const P2PKWitnessSchema = z.object({ - signatures: z.array(z.string()).optional(), + signatures: z.optional(z.array(z.string())), }); const HTLCWitnessSchema = z.object({ preimage: z.string(), - signatures: z.array(z.string()).optional(), + signatures: z.optional(z.array(z.string())), }); const WitnessSchema = z.union([ @@ -36,9 +36,9 @@ export const ProofSchema = z.object({ /** The unblinded signature for this secret, signed by the mints private key. */ C: z.string(), /** DLEQ proof. */ - dleq: nullToUndefined(SerializedDLEQSchema.optional()).optional(), + dleq: z.optional(nullToUndefined(z.optional(SerializedDLEQSchema))), /** Witness for P2PK or HTLC spending conditions. */ - witness: nullToUndefined(WitnessSchema.optional()).optional(), + witness: z.optional(nullToUndefined(z.optional(WitnessSchema))), }); /** diff --git a/app/lib/spark/wasm.ts b/app/lib/spark/wasm.ts index 7cf065253..998767e89 100644 --- a/app/lib/spark/wasm.ts +++ b/app/lib/spark/wasm.ts @@ -2,13 +2,31 @@ import initBreezWasm from '@agicash/breez-sdk-spark'; let wasmInitPromise: ReturnType | null = null; +/** + * Thrown when `WebAssembly` is not available in the current browser session. + * Most commonly caused by iOS Lockdown Mode disabling WASM in WebKit; can also + * occur in restricted in-app WebViews on Android. + */ +export class WebAssemblyUnavailableError extends Error { + constructor() { + super('WebAssembly is not available in this browser session'); + this.name = 'WebAssemblyUnavailableError'; + } +} + /** * Initializes the Breez SDK WASM module exactly once, even across concurrent * callers. The SDK's own init only short-circuits after completion, so parallel * calls mid-init would each run a full fetch/compile/__wbindgen_start. Caching * the promise here makes the second caller await the same in-flight init. + * + * If `WebAssembly` is not exposed by the runtime, rejects with + * `WebAssemblyUnavailableError` before invoking any wasm-bindgen code. */ export const ensureBreezWasm = () => { + if (typeof WebAssembly === 'undefined') { + return Promise.reject(new WebAssemblyUnavailableError()); + } wasmInitPromise ??= initBreezWasm(); return wasmInitPromise; }; diff --git a/app/lib/zod.ts b/app/lib/zod.ts index 4ffaeaff9..6341b97e0 100644 --- a/app/lib/zod.ts +++ b/app/lib/zod.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/mini'; /** * Converts null values to undefined before passing the value to schema. @@ -8,8 +8,8 @@ import { z } from 'zod'; * @param schema - The schema to do the preprocessing for. * @returns The schema with preprocessing null to undefined. */ -export const nullToUndefined = (schema: T) => - z.preprocess | undefined, T, z.infer | null>( - (v) => (v === null ? undefined : v), - schema, +export const nullToUndefined = (schema: T) => + z.pipe( + z.transform((v: unknown): unknown => (v === null ? undefined : v)), + schema as z.ZodMiniType, unknown>, ); diff --git a/app/prerender-paths.ts b/app/prerender-paths.ts deleted file mode 100644 index 2e9efa22d..000000000 --- a/app/prerender-paths.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Paths prerendered at build time. Consumed by `react-router.config.ts` - * (the actual prerender directive) and `app/entry.server.tsx` (where the - * list is used to disable React's Suspense outlining for runtime SSR of - * these routes — Vercel currently doesn't serve them as static files when - * `ssr:true`, see remix-run/react-router#14281). - */ -export const PRERENDERED_PATHS = [ - '/terms', - '/terms/wallet', - '/terms/mint', - '/privacy', - '/privacy/wallet', - '/privacy/mint', - '/mint-risks', - '/home', -]; diff --git a/app/root.tsx b/app/root.tsx index 3b62e84bf..7bb0491ee 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -29,6 +29,8 @@ import { ThemeProvider, useTheme } from '~/features/theme'; import { getBgColorForTheme } from '~/features/theme/colors'; import { getThemeCookies } from '~/features/theme/theme-cookies.server'; import { getThemeScript } from '~/features/theme/theme-script'; +import { getCanonicalOrigin } from '~/lib/canonical-origin.server'; +import { WebAssemblyUnavailableError } from '~/lib/spark'; import { SupabaseRealtimeError } from '~/lib/supabase/supabase-realtime-hooks'; import { transitionStyles, useViewTransitionEffect } from '~/lib/transitions'; import stylesheet from '~/tailwind.css?url'; @@ -85,23 +87,22 @@ export async function loader({ request }: Route.LoaderArgs) { const cookieSettings = getThemeCookies(request); const userAgentString = request.headers.get('user-agent'); const url = new URL(request.url); + const origin = getCanonicalOrigin(url.origin); return { cookieSettings: cookieSettings || null, userAgentString: userAgentString || '', - origin: url.origin, - domain: url.host, + origin, + domain: new URL(origin).host, }; } export const meta = ({ loaderData }: Route.MetaArgs) => { - const { origin } = loaderData || {}; + const origin = loaderData?.origin ?? 'https://agi.cash'; const title = 'Agicash'; const description = 'The easiest way to send and receive cash.'; - const image = origin - ? `${origin}/og/agicash-card.webp` - : '/og/agicash-card.webp'; + const image = `${origin}/og/agicash-card.webp`; const imageWidth = '900'; const imageHeight = '473'; const imageType = 'image/webp'; @@ -304,6 +305,41 @@ const useErrorDetails = (error: unknown) => { } } + if (error instanceof WebAssemblyUnavailableError) { + // All iOS browsers run on WebKit, so iOS Lockdown Mode disables WASM + // everywhere on iOS. On macOS, Lockdown Mode only restricts WebKit + // (Safari and embeds); Chrome/Firefox use their own engines and aren't + // affected. Detect at error time, not at boot — UA can lie, but it's + // only used to tailor the copy. + const ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''; + const isIOS = /iPhone|iPad|iPod/.test(ua); + const isMacSafari = + /Macintosh/.test(ua) && + /Safari\//.test(ua) && + !/Chrome\/|CriOS\/|FxiOS\/|Edg\//.test(ua); + const lockdownLikely = isIOS || isMacSafari; + return { + title: 'Browser not supported', + message: lockdownLikely + ? 'Agicash needs WebAssembly, which is disabled on this device. ' + + "If you've turned on Lockdown Mode, add agi.cash as an exception " + + 'in Settings → Privacy & Security → Lockdown Mode, then reload.' + : "Agicash needs WebAssembly, but it isn't available in this browser. " + + 'Try opening agi.cash in a recent version of Chrome, Safari, or ' + + 'Firefox (not an in-app browser).', + footer: ( + + ), + }; + } + if (error instanceof Error) { return { message: 'An unexpected error occurred. Please try again later.', diff --git a/app/routes/_protected.tsx b/app/routes/_protected.tsx index 944412607..372296daa 100644 --- a/app/routes/_protected.tsx +++ b/app/routes/_protected.tsx @@ -1,6 +1,6 @@ import type { QueryClient } from '@tanstack/react-query'; import { Outlet, redirect } from 'react-router'; -import { ZodError } from 'zod'; +import { core } from 'zod/mini'; import { AccountsCache } from '~/features/accounts/account-hooks'; import { AccountRepository } from '~/features/accounts/account-repository'; import { agicashDbClient } from '~/features/agicash-db/database.client'; @@ -137,7 +137,7 @@ const ensureUserData = async ( giftCardMintTermsAcceptedAt, }), retry: (attemptIndex, error) => { - if (error instanceof ZodError) { + if (error instanceof core.$ZodError) { return false; } return attemptIndex < 2; diff --git a/app/routes/_public.receive-cashu-token.tsx b/app/routes/_public.receive-cashu-token.tsx index 76062ede5..71a6658e9 100644 --- a/app/routes/_public.receive-cashu-token.tsx +++ b/app/routes/_public.receive-cashu-token.tsx @@ -47,7 +47,8 @@ export function meta({ location, matches }: Route.MetaArgs): MetaDescriptor[] { if (!preview) return rootMatch?.meta ?? []; const origin = - (rootMatch?.data as { origin?: string } | undefined)?.origin ?? ''; + (rootMatch?.data as { origin?: string } | undefined)?.origin ?? + 'https://agi.cash'; const imageUrl = `${origin}${preview.ogImage}`; const { title, description } = preview; diff --git a/app/routes/api.events.ts b/app/routes/api.events.ts index cb0a89897..471e55b64 100644 --- a/app/routes/api.events.ts +++ b/app/routes/api.events.ts @@ -2,7 +2,7 @@ import { timingSafeEqual } from 'node:crypto'; import { hmac } from '@noble/hashes/hmac'; import { sha256 } from '@noble/hashes/sha2'; import { bytesToHex } from '@noble/hashes/utils'; -import { z } from 'zod'; +import { z } from 'zod/mini'; import type { AgicashDbUser } from '~/features/agicash-db/database'; import { sendWelcomeEmail } from '~/features/email/welcome-email-service'; import { safeJsonParse } from '~/lib/json'; @@ -13,9 +13,9 @@ const MAX_SIGNATURE_AGE_SECONDS = 300; // -- Schemas -- const userDataSchema = z.object({ - id: z.string().uuid(), - email: z.string().nullable(), -}) satisfies z.ZodType>; + id: z.uuid(), + email: z.nullable(z.string()), +}) satisfies z.ZodMiniType>; const eventSchema = z.intersection( z.object({ @@ -34,9 +34,11 @@ const eventSchema = z.intersection( // the whole parse rather than falling through here as `data: unknown`. z .object({ type: z.string(), data: z.unknown() }) - .refine((v) => v.type !== 'user.email_verified', { - message: 'known event type with invalid data', - }), + .check( + z.refine((v) => v.type !== 'user.email_verified', { + message: 'known event type with invalid data', + }), + ), ]), ); diff --git a/react-router.config.ts b/react-router.config.ts index 77c2ca087..1a65173ea 100644 --- a/react-router.config.ts +++ b/react-router.config.ts @@ -2,7 +2,6 @@ import type { Config, Preset } from '@react-router/dev/config'; import { sentryOnBuildEnd } from '@sentry/react-router'; import { vercelPreset } from '@vercel/react-router/vite'; import type { NavigateOptions, To } from 'react-router'; -import { PRERENDERED_PATHS } from './app/prerender-paths'; // Check https://reactrouter.com/api/hooks/useNavigate#return-type-augmentation to see why this is needed declare module 'react-router' { @@ -25,7 +24,16 @@ export default { (preset): preset is Preset => Boolean(preset), ), async prerender() { - return [...PRERENDERED_PATHS]; + return [ + '/terms', + '/terms/wallet', + '/terms/mint', + '/privacy', + '/privacy/wallet', + '/privacy/mint', + '/mint-risks', + '/home', + ]; }, future: { v8_middleware: true,