From a0bd8efb3f144951cbc184dd7961e8df8365b9bf Mon Sep 17 00:00:00 2001 From: gudnuf Date: Mon, 27 Apr 2026 19:27:50 -0700 Subject: [PATCH 1/2] fix(receive): don't crash when token's source mint is offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pasting a Cashu token whose source mint is unreachable was crashing the /receive/cashu/token flow with "Mint info not initialized; call loadMint or loadMintFromCache first" (Sentry: AGICASH-9S, 9Y, 93, 7G, 7F). buildAccountForMint read wallet.purpose before the !isOnline early return. The purpose getter calls getMintInfo() internally, which throws when mint info wasn't loaded. Add an 'unknown' AccountPurpose value (in-memory only — DB enum unchanged) and use it as the local purpose when offline. canSendToLightning then short-circuits correctly via its existing `purpose !== 'transactional'` guard, so no further changes to the lightning capability checks are needed. --- app/features/accounts/account-repository.ts | 9 +++++---- app/features/accounts/account-service.ts | 12 ++++++++++-- app/features/accounts/account.ts | 8 +++++++- app/features/receive/claim-cashu-token-service.ts | 9 ++++++++- app/features/receive/receive-cashu-token-hooks.ts | 6 +++++- app/features/receive/receive-cashu-token-service.ts | 10 +++++++--- app/features/user/user-repository.ts | 8 +++++++- 7 files changed, 49 insertions(+), 13 deletions(-) diff --git a/app/features/accounts/account-repository.ts b/app/features/accounts/account-repository.ts index b19c41e72..060eefc7c 100644 --- a/app/features/accounts/account-repository.ts +++ b/app/features/accounts/account-repository.ts @@ -25,7 +25,7 @@ import { getInitializedSparkWallet, sparkMnemonicQueryOptions, } from '../shared/spark'; -import type { Account, AccountPurpose, CashuAccount } from './account'; +import type { Account, CashuAccount, StoredAccountPurpose } from './account'; import type { CashuProof } from './cashu-account'; type AccountOmit< @@ -38,9 +38,10 @@ type AccountOmit< type AccountInput = { userId: string; + purpose: StoredAccountPurpose; } & (T extends CashuAccount - ? AccountOmit - : AccountOmit); + ? AccountOmit + : AccountOmit); type Options = { abortSignal?: AbortSignal; @@ -229,7 +230,7 @@ export class AccountRepository { private async getInitializedCashuWallet( mintUrl: string, currency: Currency, - purpose: AccountPurpose, + purpose: StoredAccountPurpose, ) { const seed = await this.getCashuWalletSeed(); return getInitializedCashuWallet({ diff --git a/app/features/accounts/account-service.ts b/app/features/accounts/account-service.ts index facfee6d9..07699b444 100644 --- a/app/features/accounts/account-service.ts +++ b/app/features/accounts/account-service.ts @@ -7,7 +7,12 @@ import { getKeysetExpiry, } from '~/lib/cashu'; import type { User } from '../user/user'; -import type { Account, CashuAccount, ExtendedAccount } from './account'; +import type { + Account, + CashuAccount, + ExtendedAccount, + StoredAccountPurpose, +} from './account'; import { type AccountRepository, useAccountRepository, @@ -53,7 +58,9 @@ export class AccountService { account, }: { userId: string; - account: DistributedOmit< + account: { + purpose: StoredAccountPurpose; + } & DistributedOmit< CashuAccount, | 'id' | 'createdAt' @@ -65,6 +72,7 @@ export class AccountService { | 'wallet' | 'isOnline' | 'state' + | 'purpose' >; }) { const isTestMint = checkIsTestMint(account.mintUrl); diff --git a/app/features/accounts/account.ts b/app/features/accounts/account.ts index ee1507e00..340e75801 100644 --- a/app/features/accounts/account.ts +++ b/app/features/accounts/account.ts @@ -11,11 +11,17 @@ export type AccountType = z.infer; export type AccountState = 'active' | 'expired'; -export const AccountPurposeSchema = z.enum([ +export const StoredAccountPurposeSchema = z.enum([ 'transactional', 'gift-card', 'offer', ]); +export type StoredAccountPurpose = z.infer; + +export const AccountPurposeSchema = z.enum([ + ...StoredAccountPurposeSchema.options, + 'unknown', +]); export type AccountPurpose = z.infer; export type Account = { diff --git a/app/features/receive/claim-cashu-token-service.ts b/app/features/receive/claim-cashu-token-service.ts index f9e38cd3c..3487757f3 100644 --- a/app/features/receive/claim-cashu-token-service.ts +++ b/app/features/receive/claim-cashu-token-service.ts @@ -118,9 +118,16 @@ export class ClaimCashuTokenService { } if (receiveAccount.isUnknown && receiveAccount.type === 'cashu') { + if (!receiveAccount.isOnline || receiveAccount.purpose === 'unknown') { + return { + success: false, + message: 'The mint that issued this ecash is offline', + }; + } + const { purpose } = receiveAccount; const addedAccount = await this.accountService.addCashuAccount({ userId: user.id, - account: receiveAccount, + account: { ...receiveAccount, purpose }, }); this.accountsCache.upsert(addedAccount); receiveAccount = { ...receiveAccount, ...addedAccount }; diff --git a/app/features/receive/receive-cashu-token-hooks.ts b/app/features/receive/receive-cashu-token-hooks.ts index b4ac00ba0..6d51b000a 100644 --- a/app/features/receive/receive-cashu-token-hooks.ts +++ b/app/features/receive/receive-cashu-token-hooks.ts @@ -218,7 +218,11 @@ export function useReceiveCashuTokenAccounts( ): Promise => { let newAccount: Account; if (accountToAdd.type === 'cashu') { - newAccount = await addCashuAccount(accountToAdd); + if (!accountToAdd.isOnline || accountToAdd.purpose === 'unknown') { + throw new Error('Cannot add a mint that is offline'); + } + const { purpose } = accountToAdd; + newAccount = await addCashuAccount({ ...accountToAdd, purpose }); } else { // Only cashu accounts can be unknown, this should never happen throw new Error('Invalid account type'); diff --git a/app/features/receive/receive-cashu-token-service.ts b/app/features/receive/receive-cashu-token-service.ts index 931a7c1c0..9bdc70fca 100644 --- a/app/features/receive/receive-cashu-token-service.ts +++ b/app/features/receive/receive-cashu-token-service.ts @@ -9,6 +9,7 @@ import { } from '~/lib/cashu'; import type { Currency } from '~/lib/money'; import { + type AccountPurpose, type ExtendedAccount, type ExtendedCashuAccount, canReceiveFromLightning, @@ -45,8 +46,11 @@ export class ReceiveCashuTokenService { currency, }); + // wallet.purpose throws when offline + const purpose: AccountPurpose = isOnline ? wallet.purpose : 'unknown'; + let expiresAt: string | null = null; - if (wallet.purpose === 'offer') { + if (purpose === 'offer') { const activeKeyset = findFirstActiveKeyset( wallet.keyChain.getKeysets(), currency, @@ -61,7 +65,7 @@ export class ReceiveCashuTokenService { const baseAccount = { id: 'cashu-account-placeholder-id', type: 'cashu' as const, - purpose: wallet.purpose, + purpose, state: isExpired ? ('expired' as const) : ('active' as const), name: mintUrl.replace('https://', '').replace('http://', ''), mintUrl, @@ -100,7 +104,7 @@ export class ReceiveCashuTokenService { const isValid = validationResult === true; const isGatedGiftCard = - wallet.purpose === 'gift-card' && !getFeatureFlag('GIFT_CARDS'); + purpose === 'gift-card' && !getFeatureFlag('GIFT_CARDS'); return { ...baseAccount, diff --git a/app/features/user/user-repository.ts b/app/features/user/user-repository.ts index fa18f6849..a17f29728 100644 --- a/app/features/user/user-repository.ts +++ b/app/features/user/user-repository.ts @@ -3,7 +3,11 @@ import type { DistributedOmit } from 'type-fest'; import type { z } from 'zod'; import { normalizeMintUrl } from '~/lib/cashu'; import type { Currency } from '~/lib/money'; -import type { Account, RedactedAccount } from '../accounts/account'; +import type { + Account, + RedactedAccount, + StoredAccountPurpose, +} from '../accounts/account'; import { type AccountRepository, useAccountRepository, @@ -42,6 +46,7 @@ type Options = { type AccountInput = { isDefault?: boolean; + purpose: StoredAccountPurpose; } & DistributedOmit< Account, | 'id' @@ -53,6 +58,7 @@ type AccountInput = { | 'isOnline' | 'balance' | 'state' + | 'purpose' >; /** From bde9b36f2afa4a61e356deb28f31ac3c60bb4049 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Tue, 28 Apr 2026 09:19:06 -0700 Subject: [PATCH 2/2] fix(receive): decode v1-keyset tokens without contacting the mint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decodeCashuToken unconditionally fetched the mint's keyset list to resolve v2 truncated keyset IDs, then handed them to getDecodedToken. For tokens with v1 keysets (full IDs embedded in the token) the network call is unnecessary, and when the mint is offline it caused decode to fail and the route loader to redirect away from /receive/cashu/token before the receive page could render. Try the bare decode first — succeeds for v1 keysets without any network. Only fall back to fetching keysets when the bare decode throws (the v2 truncated-ID case). Combined with the 'unknown' purpose fix in the previous commit, pasting a v1-keyset token from an offline mint now reaches the receive page, where useCashuTokenWithClaimableProofs already surfaces "The mint that issued this ecash is offline" via TokenErrorDisplay. --- app/features/shared/cashu.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/features/shared/cashu.ts b/app/features/shared/cashu.ts index 3e9548854..90b4a1f12 100644 --- a/app/features/shared/cashu.ts +++ b/app/features/shared/cashu.ts @@ -220,12 +220,19 @@ export const allMintKeysetsQueryOptions = (mintUrl: string) => /** * Extract and decode a cashu token from arbitrary content. - * Fetches keyset IDs from the token's mint for v2 keyset resolution. + * Tokens with v1 keyset IDs decode without contacting the mint. v2 tokens use + * truncated keyset IDs that have to be resolved against the mint's keyset list. */ export async function decodeCashuToken(content: string): Promise { const result = extractCashuToken(content); if (!result) return null; + try { + return getDecodedToken(result.encoded); + } catch { + // v2 keyset IDs are truncated and need resolution from the mint + } + try { const queryClient = getQueryClient(); const data = await queryClient.fetchQuery(