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/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( 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' >; /**