From 7234d1b775b19eb01733a2371a98151ad9381d77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josip=20Boj=C4=8Di=C4=87?= Date: Wed, 20 May 2026 14:15:41 +0200 Subject: [PATCH 01/12] Remove workaround added because of Vercel issue that was causing prerendered pages to also hit server --- app/entry.server.tsx | 28 ++++++---------------------- app/prerender-paths.ts | 17 ----------------- react-router.config.ts | 12 ++++++++++-- 3 files changed, 16 insertions(+), 41 deletions(-) delete mode 100644 app/prerender-paths.ts 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/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/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, From 2c13814f848dfb53c5ec571209b97a7b6bfe44ef Mon Sep 17 00:00:00 2001 From: Ditto Date: Thu, 21 May 2026 16:34:46 +0200 Subject: [PATCH 02/12] fix(spark): handle missing WebAssembly with friendly error UI When WebAssembly is unavailable (iOS Lockdown Mode disables WASM in WebKit; restricted in-app WebViews on Android can too), the Breez SDK crashed deep in wasm-bindgen with an unrecognizable ReferenceError that landed in the generic error card and was reported to Sentry. - Gate ensureBreezWasm with a typed WebAssemblyUnavailableError so the failure has a stable identity instead of a minified bindings stack. - Catch the fire-and-forget call in entry.client.tsx so the rejection doesn't double-report via unhandledrejection. - Render a platform-aware message in the route ErrorBoundary: name Lockdown Mode explicitly for iOS / macOS Safari, generic browser- unsupported copy otherwise. Skip Sentry on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/entry.client.tsx | 8 +++++++- app/lib/spark/wasm.ts | 18 ++++++++++++++++++ app/root.tsx | 31 +++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) 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/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/root.tsx b/app/root.tsx index 3b62e84bf..3a8b3d359 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -29,6 +29,7 @@ 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 { WebAssemblyUnavailableError } from '~/lib/spark'; import { SupabaseRealtimeError } from '~/lib/supabase/supabase-realtime-hooks'; import { transitionStyles, useViewTransitionEffect } from '~/lib/transitions'; import stylesheet from '~/tailwind.css?url'; @@ -304,6 +305,36 @@ 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.', From accb0dbe813ce7a7bd6c2bd1258fa1f304b2aa9f Mon Sep 17 00:00:00 2001 From: Ditto Date: Thu, 21 May 2026 16:50:42 +0200 Subject: [PATCH 03/12] fix(root): add mt-4 to WebAssembly error reload button Match the spacing used by the storage-blocked case so the button isn't crowding the message. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/root.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/root.tsx b/app/root.tsx index 3a8b3d359..9480f7d60 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -328,7 +328,12 @@ const useErrorDetails = (error: unknown) => { 'Try opening agi.cash in a recent version of Chrome, Safari, or ' + 'Firefox (not an in-app browser).', footer: ( - ), From 36b65b57cf30d068117bebee0d38fba2bb53bda6 Mon Sep 17 00:00:00 2001 From: orveth Date: Thu, 21 May 2026 10:36:40 -0700 Subject: [PATCH 04/12] fix(cashu): sync error-codes enum to current NUT spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two enum values currently collide with current-spec codes, actively misclassifying spec-compliant mints: - `TRANSACTION_NOT_BALANCED = 11002` collides with spec `11002 Proofs are pending` (an already-resolved-shaped condition) - `UNIT_NOT_SUPPORTED = 11005` collides with spec `11005 Transaction is not balanced` Drift fixes (4 codes — names unchanged, values updated to current spec): - `TOKEN_VERIFICATION_FAILED` `10003` -> `10001` - `OUTPUT_ALREADY_SIGNED` `10002` -> `11003` - `TRANSACTION_NOT_BALANCED` `11002` -> `11005` - `UNIT_NOT_SUPPORTED` `11005` -> `11013` New codes added from current spec (10 entries): - `11002 PROOFS_ARE_PENDING` - `11004 OUTPUTS_ARE_PENDING` - `11011 AMOUNTLESS_INVOICE_UNSUPPORTED` - `11012 AMOUNT_MISMATCH` - `11014 MAX_INPUTS_EXCEEDED` - `11015 MAX_OUTPUTS_EXCEEDED` - `11016 DUPLICATE_QUOTE_IDS` - `11017 MAX_BATCH_SIZE_EXCEEDED` - `12003 KEYSET_EXPIRED` Renumbering is safe: every callsite uses `CashuErrorCodes.NAME`, zero raw-number references (verified via grep). Names did not change. No behaviour changes — service-level recovery branches still match the same constants they matched before; only the numeric values they expand to have shifted. Phase 1 of the spec at docs/cashu-error-classifier.md (#1104). Co-Authored-By: Claude Opus 4.7 --- app/lib/cashu/error-codes.ts | 78 ++++++++++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 13 deletions(-) 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) From a5e8281e3615bf1c5f770d95369c69811c1c3134 Mon Sep 17 00:00:00 2001 From: orveth Date: Wed, 20 May 2026 17:20:05 -0700 Subject: [PATCH 05/12] refactor(hooks): rename use*ReceiveQuote -> useTrack*ReceiveQuote Aligns the two receive-quote entity-tracker hooks with the useTrackCashuSendSwap naming convention. Closes #414. Co-Authored-By: Claude Opus 4.7 --- app/features/buy/buy-checkout.tsx | 8 ++++---- app/features/receive/cashu-receive-quote-hooks.ts | 8 ++++---- app/features/receive/receive-cashu.tsx | 4 ++-- app/features/receive/receive-spark.tsx | 4 ++-- app/features/receive/spark-receive-quote-hooks.ts | 8 ++++---- 5 files changed, 16 insertions(+), 16 deletions(-) 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/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/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-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); From 5bd7874c074d15814ad25f136d0832914e7bc131 Mon Sep 17 00:00:00 2001 From: Ditto Date: Fri, 22 May 2026 16:40:01 +0200 Subject: [PATCH 06/12] fix(auth): only redirect on document navigations to avoid trapping users without cookies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session hint cookie added in 71e8a3de short-circuits SSR for unauthenticated visits, but the loader redirected on every request — including the single-fetch data requests that drive client-side navigation. That broke recovery for any user whose hint cookie wasn't present even though they were really authenticated: cookies disabled, evicted from storage, cleared manually, or logged in before the cookie existed. Their JWT was still in localStorage, but every navigation through the SPA bounced them back to /home before clientMiddleware could validate it. Gate the redirect on Sec-Fetch-Mode === 'navigate' so only top-level document navigations are intercepted. Single-fetch requests pass through and clientMiddleware does its normal JWT check. The fresh-visit "no flicker" path is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/features/user/require-session-hint.server.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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; } From 8ba0cecd15b1d51ed34d11ebad400c5093ac5b8d Mon Sep 17 00:00:00 2001 From: orveth Date: Wed, 20 May 2026 17:41:39 -0700 Subject: [PATCH 07/12] fix(receive): capture claim error to Sentry --- app/features/email/welcome-email-service.ts | 7 +++---- app/features/receive/claim-cashu-token-service.ts | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) 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/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, From a266e7c436aeeaa69563498ae378291e445a92f3 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Wed, 20 May 2026 21:21:50 -0700 Subject: [PATCH 08/12] refactor: migrate from Zod to Zod Mini for smaller bundle Switch all schema files from `zod` to the tree-shakable `zod/mini` API (same `zod` package, no dependency change). Method chaining is replaced with standalone functions: `.optional()` -> `z.optional()`, `.transform()` -> `z.pipe(s, z.transform())`, `.refine()` -> `.check(z.refine())`, `.extend()`/`.and()` -> `z.extend()`/ `z.intersection()`, etc. Closes #733 --- app/features/accounts/account-repository.ts | 2 +- app/features/accounts/account.ts | 2 +- app/features/accounts/cashu-account.ts | 6 +- .../json-models/account-details-db-data.ts | 2 +- .../cashu-account-details-db-data.ts | 2 +- .../cashu-lightning-receive-db-data.ts | 10 +-- .../cashu-lightning-send-db-data.ts | 12 +-- .../json-models/cashu-swap-receive-db-data.ts | 4 +- .../json-models/cashu-swap-send-db-data.ts | 10 +-- .../json-models/cashu-token-melt-db-data.ts | 2 +- .../spark-account-details-db-data.ts | 2 +- .../spark-lightning-receive-db-data.ts | 8 +- .../spark-lightning-send-db-data.ts | 8 +- app/features/contacts/contact.ts | 2 +- app/features/gift-cards/gift-card-config.ts | 27 ++++--- .../gift-cards/use-restore-scroll-position.ts | 2 +- .../cashu-receive-quote-repository.server.ts | 2 +- .../receive/cashu-receive-quote-repository.ts | 2 +- app/features/receive/cashu-receive-quote.ts | 6 +- .../receive/cashu-receive-swap-repository.ts | 2 +- app/features/receive/cashu-receive-swap.ts | 4 +- app/features/receive/cashu-token-melt-data.ts | 4 +- .../receive/lightning-address-service.ts | 2 +- .../spark-receive-quote-repository.server.ts | 2 +- .../receive/spark-receive-quote-repository.ts | 2 +- app/features/receive/spark-receive-quote.ts | 6 +- .../send/cashu-send-quote-repository.ts | 2 +- app/features/send/cashu-send-quote.ts | 4 +- .../send/cashu-send-swap-repository.ts | 2 +- app/features/send/cashu-send-swap.ts | 33 ++++---- .../send/spark-send-quote-repository.ts | 2 +- app/features/send/spark-send-quote.ts | 10 +-- app/features/send/utils.ts | 2 +- ...u-lightning-receive-transaction-details.ts | 21 ++--- ...ashu-lightning-send-transaction-details.ts | 27 ++++--- ...cashu-token-receive-transaction-details.ts | 45 ++++++----- .../cashu-token-send-transaction-details.ts | 13 ++-- ...k-lightning-receive-transaction-details.ts | 29 +++---- ...park-lightning-send-transaction-details.ts | 35 +++++---- .../transaction-details-parser.ts | 2 +- .../transaction-details-types.ts | 4 +- .../transactions/transaction-enums.ts | 2 +- .../transactions/transaction-repository.ts | 2 +- app/features/transactions/transaction.ts | 76 +++++++++++-------- app/features/user/guest-account-storage.ts | 2 +- app/features/user/user-repository.ts | 2 +- app/lib/cashu/mint-validation.ts | 4 +- app/lib/cashu/types.ts | 12 +-- app/lib/zod.ts | 10 +-- app/routes/_protected.tsx | 4 +- app/routes/api.events.ts | 16 ++-- 51 files changed, 265 insertions(+), 229 deletions(-) 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/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/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-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/lightning-address-service.ts b/app/features/receive/lightning-address-service.ts index 6d224bf70..c34aed0e0 100644 --- a/app/features/receive/lightning-address-service.ts +++ b/app/features/receive/lightning-address-service.ts @@ -1,7 +1,7 @@ import { 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 { 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/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/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/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/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/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', + }), + ), ]), ); From 50cfd7668e42d510845d01826fd4002550298958 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Tue, 26 May 2026 13:44:12 -0700 Subject: [PATCH 09/12] feat(spark): support LUD-06 description_hash on LNURL-pay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sets the BOLT11 `h` tag on spark-account invoices created via LNURL-pay so wallets can verify the invoice commits to the metadata returned in the LUD-16 response. Extracts a shared `buildLnurlpMetadata` helper so both endpoints hash identical bytes. getLightningQuote no longer forces an empty description when descriptionHash is set — both fields are passed through to the Breez SDK as-is, which emits only the `h` tag when descriptionHash is provided (BOLT11's "d and h are alternatives" rule). The Cashu path is unchanged (blocked on CDK; documented inline). Closes #989 --- .../receive/lightning-address-service.ts | 25 ++++++++++++++----- .../receive/spark-receive-quote-core.ts | 9 ++++++- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/app/features/receive/lightning-address-service.ts b/app/features/receive/lightning-address-service.ts index c34aed0e0..a660d8af2 100644 --- a/app/features/receive/lightning-address-service.ts +++ b/app/features/receive/lightning-address-service.ts @@ -1,4 +1,5 @@ -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/mini'; @@ -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/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, }, }), ); From 89eb98e3e6697c981d624f6c033c2348fdf09c07 Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Wed, 27 May 2026 09:25:00 +0200 Subject: [PATCH 10/12] chore: enabled parent .envrc support --- .envrc | 3 +++ 1 file changed, 3 insertions(+) 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 From 18e2f026a2405f3a064fd9f8ff29cbbb25f59c9f Mon Sep 17 00:00:00 2001 From: gudnuf Date: Wed, 27 May 2026 11:39:57 -0700 Subject: [PATCH 11/12] fix(og): use Vercel deployment URL for absolute metadata URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prerendered routes (/home, /terms, ...) baked the request origin into their static HTML, but at build-time prerender that origin is http://localhost — so og:image/twitter:image/og:url pointed at an unreachable host and link previews showed "Image failed to load". Resolve the origin from Vercel's system env vars (production domain, or branch/deploy URL on previews) with the request origin as fallback, mirroring Next.js's metadataBase precedence. Keep the request origin for in-app links and Lightning Address generation, which must reflect the actual serving host. --- app/lib/canonical-origin.server.ts | 28 ++++++++++++++++++++++ app/root.tsx | 10 ++++---- app/routes/_public.receive-cashu-token.tsx | 7 +++--- 3 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 app/lib/canonical-origin.server.ts diff --git a/app/lib/canonical-origin.server.ts b/app/lib/canonical-origin.server.ts new file mode 100644 index 000000000..8cb83723d --- /dev/null +++ b/app/lib/canonical-origin.server.ts @@ -0,0 +1,28 @@ +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; +} + +/** + * Origin for absolute URLs that external services must fetch (OG/Twitter + * images, `og:url`). Prefers Vercel's deployment URL over the request origin + * because at build-time prerender (`/home`, `/terms`) react-router uses + * `http://localhost`, which gets frozen into the static HTML and is + * unreachable by crawlers. Precedence mirrors Next.js's metadataBase fallback. + * @see https://github.com/vercel/next.js/pull/65089 + * @param requestOrigin used outside 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/root.tsx b/app/root.tsx index 9480f7d60..f88e5020e 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -29,6 +29,7 @@ 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'; @@ -91,18 +92,17 @@ export async function loader({ request }: Route.LoaderArgs) { cookieSettings: cookieSettings || null, userAgentString: userAgentString || '', origin: url.origin, + canonicalOrigin: getCanonicalOrigin(url.origin), domain: url.host, }; } export const meta = ({ loaderData }: Route.MetaArgs) => { - const { origin } = loaderData || {}; + const canonicalOrigin = loaderData?.canonicalOrigin ?? '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 = `${canonicalOrigin}/og/agicash-card.webp`; const imageWidth = '900'; const imageHeight = '473'; const imageType = 'image/webp'; @@ -137,7 +137,7 @@ export const meta = ({ loaderData }: Route.MetaArgs) => { content: imageType, }, { property: 'og:type', content: 'website' }, - { property: 'og:url', content: origin }, + { property: 'og:url', content: canonicalOrigin }, { property: 'og:site_name', content: ogSiteName }, // Twitter Card meta tags diff --git a/app/routes/_public.receive-cashu-token.tsx b/app/routes/_public.receive-cashu-token.tsx index 76062ede5..e1650e56e 100644 --- a/app/routes/_public.receive-cashu-token.tsx +++ b/app/routes/_public.receive-cashu-token.tsx @@ -46,9 +46,10 @@ export function meta({ location, matches }: Route.MetaArgs): MetaDescriptor[] { const preview = mintUrl ? resolveSharePreview(mintUrl) : undefined; if (!preview) return rootMatch?.meta ?? []; - const origin = - (rootMatch?.data as { origin?: string } | undefined)?.origin ?? ''; - const imageUrl = `${origin}${preview.ogImage}`; + const canonicalOrigin = + (rootMatch?.data as { canonicalOrigin?: string } | undefined) + ?.canonicalOrigin ?? 'https://agi.cash'; + const imageUrl = `${canonicalOrigin}${preview.ogImage}`; const { title, description } = preview; return [ From f0d06174fc9afb44b9390d16fc3dd3d62e3c2219 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Wed, 27 May 2026 14:15:01 -0700 Subject: [PATCH 12/12] fix(root): resolve canonical origin in the loader so shared links aren't localhost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared token links, profile URLs and `username@domain` Lightning Addresses were coming out as `http://localhost/...`. They all read `origin`/`domain` from the root loader (via useLocationData), which returned the raw request origin. At build-time prerender (`/home`, `/terms`) that origin is `http://localhost`, baked into the static HTML; because the root loader never revalidates, clients that enter via a prerendered route keep `localhost` for the whole session. This surfaced once Vercel began serving prerendered routes as static files instead of falling through to runtime SSR. #1110 fixed the symptom for OG meta only by adding a separate `canonicalOrigin` field. Fix it at the source instead: the loader now resolves `origin` (and derives `domain`) through getCanonicalOrigin, so every consumer — meta, share links, contacts, Lightning Address — gets a reachable host with no per-call changes. Drops the now-redundant `canonicalOrigin` field and reverts the meta functions to reading `origin`. --- app/lib/canonical-origin.server.ts | 15 +++++++++------ app/root.tsx | 12 ++++++------ app/routes/_public.receive-cashu-token.tsx | 8 ++++---- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/app/lib/canonical-origin.server.ts b/app/lib/canonical-origin.server.ts index 8cb83723d..246495a03 100644 --- a/app/lib/canonical-origin.server.ts +++ b/app/lib/canonical-origin.server.ts @@ -9,13 +9,16 @@ function getPreviewDeploymentUrl(): string | undefined { } /** - * Origin for absolute URLs that external services must fetch (OG/Twitter - * images, `og:url`). Prefers Vercel's deployment URL over the request origin - * because at build-time prerender (`/home`, `/terms`) react-router uses - * `http://localhost`, which gets frozen into the static HTML and is - * unreachable by crawlers. Precedence mirrors Next.js's metadataBase fallback. + * 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 used outside Vercel (local dev / non-Vercel SSR). + * @param requestOrigin fallback used off Vercel (local dev / non-Vercel SSR). */ export function getCanonicalOrigin(requestOrigin: string): string { if (process.env.VERCEL_ENV === 'preview') { diff --git a/app/root.tsx b/app/root.tsx index f88e5020e..7bb0491ee 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -87,22 +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, - canonicalOrigin: getCanonicalOrigin(url.origin), - domain: url.host, + origin, + domain: new URL(origin).host, }; } export const meta = ({ loaderData }: Route.MetaArgs) => { - const canonicalOrigin = loaderData?.canonicalOrigin ?? 'https://agi.cash'; + const origin = loaderData?.origin ?? 'https://agi.cash'; const title = 'Agicash'; const description = 'The easiest way to send and receive cash.'; - const image = `${canonicalOrigin}/og/agicash-card.webp`; + const image = `${origin}/og/agicash-card.webp`; const imageWidth = '900'; const imageHeight = '473'; const imageType = 'image/webp'; @@ -137,7 +137,7 @@ export const meta = ({ loaderData }: Route.MetaArgs) => { content: imageType, }, { property: 'og:type', content: 'website' }, - { property: 'og:url', content: canonicalOrigin }, + { property: 'og:url', content: origin }, { property: 'og:site_name', content: ogSiteName }, // Twitter Card meta tags diff --git a/app/routes/_public.receive-cashu-token.tsx b/app/routes/_public.receive-cashu-token.tsx index e1650e56e..71a6658e9 100644 --- a/app/routes/_public.receive-cashu-token.tsx +++ b/app/routes/_public.receive-cashu-token.tsx @@ -46,10 +46,10 @@ export function meta({ location, matches }: Route.MetaArgs): MetaDescriptor[] { const preview = mintUrl ? resolveSharePreview(mintUrl) : undefined; if (!preview) return rootMatch?.meta ?? []; - const canonicalOrigin = - (rootMatch?.data as { canonicalOrigin?: string } | undefined) - ?.canonicalOrigin ?? 'https://agi.cash'; - const imageUrl = `${canonicalOrigin}${preview.ogImage}`; + const origin = + (rootMatch?.data as { origin?: string } | undefined)?.origin ?? + 'https://agi.cash'; + const imageUrl = `${origin}${preview.ogImage}`; const { title, description } = preview; return [