diff --git a/app/features/accounts/account-hooks.ts b/app/features/accounts/account-hooks.ts index 2f58d4819..dded0c43c 100644 --- a/app/features/accounts/account-hooks.ts +++ b/app/features/accounts/account-hooks.ts @@ -165,15 +165,15 @@ export const accountsQueryOptions = ({ type UseAccountsSelect< T extends AccountType = AccountType, P extends AccountPurpose = AccountPurpose, -> = P extends 'gift-card' +> = P extends 'gift-card' | 'offer' ? { /** Filter by currency (e.g., 'BTC', 'USD') */ currency?: Currency; - /** Must be 'cashu' when purpose is 'gift-card'. */ + /** Must be 'cashu' when purpose is 'gift-card' or 'offer'. */ type?: 'cashu'; /** Filter by online status */ isOnline?: boolean; - /** Filter for gift-card accounts. Returns `CashuAccount[]` since gift cards are always cashu. */ + /** Filter for gift-card or offer accounts. Returns `CashuAccount[]` since these are always cashu. */ purpose: P; } : { @@ -190,6 +190,9 @@ type UseAccountsSelect< export function useAccounts( select: UseAccountsSelect<'cashu', 'gift-card'>, ): UseSuspenseQueryResult[]>; +export function useAccounts( + select: UseAccountsSelect<'cashu', 'offer'>, +): UseSuspenseQueryResult[]>; export function useAccounts< T extends AccountType = AccountType, P extends AccountPurpose = AccountPurpose, diff --git a/app/features/accounts/account-repository.ts b/app/features/accounts/account-repository.ts index dd6717bef..06dcad4a2 100644 --- a/app/features/accounts/account-repository.ts +++ b/app/features/accounts/account-repository.ts @@ -122,6 +122,7 @@ export class AccountRepository { mint_url: accountInput.mintUrl, is_test_mint: accountInput.isTestMint, keyset_counters: accountInput.keysetCounters, + expires_at: accountInput.expiresAt, } satisfies z.input); } else { details = SparkAccountDetailsDbDataSchema.parse({ @@ -192,6 +193,7 @@ export class AccountRepository { mintUrl: details.mint_url, isTestMint: details.is_test_mint, keysetCounters: details.keyset_counters, + expiresAt: details.expires_at ?? null, proofs, wallet, } as T; diff --git a/app/features/accounts/account-service.ts b/app/features/accounts/account-service.ts index 139997cb5..7d6cc7cd6 100644 --- a/app/features/accounts/account-service.ts +++ b/app/features/accounts/account-service.ts @@ -1,5 +1,11 @@ +import { type QueryClient, useQueryClient } from '@tanstack/react-query'; import type { DistributedOmit } from 'type-fest'; -import { checkIsTestMint } from '~/lib/cashu'; +import { getOfferExpiresAt } from '~/lib/cashu'; +import { + allMintKeysetsQueryOptions, + isTestMintQueryOptions, + mintInfoQueryOptions, +} from '../shared/cashu'; import type { User } from '../user/user'; import type { Account, CashuAccount, ExtendedAccount } from './account'; import { @@ -8,7 +14,10 @@ import { } from './account-repository'; export class AccountService { - constructor(private readonly accountRepository: AccountRepository) {} + constructor( + private readonly accountRepository: AccountRepository, + private readonly queryClient: QueryClient, + ) {} /** * Returns true if the account is the user's default account for the respective currency. @@ -50,18 +59,34 @@ export class AccountService { | 'createdAt' | 'isTestMint' | 'keysetCounters' + | 'expiresAt' | 'proofs' | 'version' | 'wallet' | 'isOnline' >; }) { - const isTestMint = await checkIsTestMint(account.mintUrl); + const mintInfo = await this.queryClient.fetchQuery( + mintInfoQueryOptions(account.mintUrl), + ); + + const isTestMint = await this.queryClient.fetchQuery( + isTestMintQueryOptions(account.mintUrl, mintInfo), + ); + + let expiresAt: string | null = null; + if (account.purpose === 'offer') { + const { keysets } = await this.queryClient.fetchQuery( + allMintKeysetsQueryOptions(account.mintUrl), + ); + expiresAt = getOfferExpiresAt(keysets, account.currency); + } return this.accountRepository.create({ ...account, userId, isTestMint, + expiresAt, keysetCounters: {}, }); } @@ -69,5 +94,6 @@ export class AccountService { export function useAccountService() { const accountRepository = useAccountRepository(); - return new AccountService(accountRepository); + const queryClient = useQueryClient(); + return new AccountService(accountRepository, queryClient); } diff --git a/app/features/accounts/account.ts b/app/features/accounts/account.ts index 1aa8c1928..8977e0f38 100644 --- a/app/features/accounts/account.ts +++ b/app/features/accounts/account.ts @@ -4,17 +4,17 @@ import type { } from '@buildonspark/spark-sdk'; import type { DistributedOmit } from 'type-fest'; import { type ExtendedCashuWallet, getCashuUnit, sumProofs } from '~/lib/cashu'; +import type { MintPurpose } from '~/lib/cashu/protocol-extensions'; import { type Currency, Money } from '~/lib/money'; import type { CashuProof } from './cashu-account'; export type AccountType = 'cashu' | 'spark'; /** - * The purpose of this account. - * - 'transactional': Regular accounts for sending/receiving payments - * - 'gift-card': Closed-loop accounts for mints that are issuing gift cards + * Account purpose. Includes MintPurpose for cashu accounts, + * plus 'transactional' which also applies to non-cashu account types (e.g. Spark). */ -export type AccountPurpose = 'transactional' | 'gift-card'; +export type AccountPurpose = 'transactional' | MintPurpose; export type Account = { id: string; @@ -38,6 +38,12 @@ export type Account = { * Holds counter value for each mint keyset. Key is the keyset id, value is counter value. */ keysetCounters: Record; + /** + * ISO 8601 timestamp when the account's ecash expires (for offer accounts). + * Converted from the active keyset's `final_expiry` unix epoch (NUT-02). + * Null for non-offer accounts. + */ + expiresAt: string | null; /** * Holds all cashu proofs for the account. * Amounts are denominated in the cashu units (e.g. sats for BTC accounts, cents for USD accounts). @@ -77,21 +83,28 @@ export type RedactedCashuAccount = Extract; /** * Returns true if the account can send payments through the Lightning network. - * Returns false for test mints and gift-card accounts. + * Returns false for offline wallets, test mints, non-transactional accounts, + * and mints with melting disabled (NUT-05). */ export const canSendToLightning = (account: Account): boolean => { if (account.type === 'spark') { return true; } - return !account.isTestMint && account.purpose === 'transactional'; + if (!account.isOnline) return false; + if (account.isTestMint) return false; + if (account.purpose !== 'transactional') return false; + return !account.wallet.getMintInfo().isSupported(5).disabled; }; /** * Returns true if the account can receive payments via the Lightning network. - * Returns false for test mints only. + * Returns false for offline wallets, test mints, and mints with minting disabled (NUT-04). */ export const canReceiveFromLightning = (account: Account): boolean => { - return account.type === 'spark' || !account.isTestMint; + if (account.type === 'spark') return true; + if (!account.isOnline) return false; + if (account.isTestMint) return false; + return !account.wallet.getMintInfo().isSupported(4).disabled; }; export const getAccountBalance = (account: Account) => { 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..c818c498c 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 @@ -13,6 +13,12 @@ export const CashuAccountDetailsDbDataSchema = z.object({ * Holds counter value for each mint keyset. Key is the keyset id, value is counter value. */ keyset_counters: z.record(z.string(), z.number()), + /** + * ISO 8601 timestamp when the account's ecash expires (for offer accounts). + * Converted from the active keyset's `final_expiry` unix epoch (NUT-02). + * Null for non-offer accounts or when the keyset has no expiry. + */ + expires_at: z.string().nullable().optional(), }); export type CashuAccountDetailsDbData = z.infer< diff --git a/app/features/receive/receive-cashu-token-service.ts b/app/features/receive/receive-cashu-token-service.ts index f05b75548..fecbab7c9 100644 --- a/app/features/receive/receive-cashu-token-service.ts +++ b/app/features/receive/receive-cashu-token-service.ts @@ -1,6 +1,10 @@ import type { Token } from '@cashu/cashu-ts'; import { type QueryClient, useQueryClient } from '@tanstack/react-query'; -import { areMintUrlsEqual, getCashuProtocolUnit } from '~/lib/cashu'; +import { + areMintUrlsEqual, + getCashuProtocolUnit, + getOfferExpiresAt, +} from '~/lib/cashu'; import type { Currency } from '~/lib/money'; import { type ExtendedAccount, @@ -41,6 +45,14 @@ export class ReceiveCashuTokenService { undefined, ); + const mintKeysets = wallet.keyChain + .getKeysets() + .map((ks) => ks.toMintKeyset()); + const expiresAt = + wallet.purpose === 'offer' + ? getOfferExpiresAt(mintKeysets, currency) + : null; + const baseAccount = { id: 'cashu-account-placeholder-id', type: 'cashu' as const, @@ -51,6 +63,7 @@ export class ReceiveCashuTokenService { currency, version: 0, keysetCounters: {}, + expiresAt, proofs: [], isDefault: false, isSource: true, @@ -77,7 +90,7 @@ export class ReceiveCashuTokenService { ); const isTestMint = await this.queryClient.fetchQuery( - isTestMintQueryOptions(mintUrl), + isTestMintQueryOptions(mintUrl, mintInfo), ); const isValid = validationResult === true; diff --git a/app/features/send/send-input.tsx b/app/features/send/send-input.tsx index ee0b06b2a..f7ccc3c98 100644 --- a/app/features/send/send-input.tsx +++ b/app/features/send/send-input.tsx @@ -246,7 +246,7 @@ export function SendInput() { - {sendAccount.purpose !== 'gift-card' && ( + {sendAccount.purpose === 'transactional' && ( staleTime: 1000 * 60 * 60, // 1 hour }); -export const isTestMintQueryOptions = (mintUrl: string) => - queryOptions({ - queryKey: ['is-test-mint', mintUrl], - queryFn: async () => checkIsTestMint(mintUrl), +export const isTestMintQueryOptions = ( + mintUrl: string, + mintInfo: ExtendedMintInfo, +) => { + return queryOptions({ + queryKey: ['is-test-mint', mintUrl, mintInfo.isSupported(4).disabled], + queryFn: async () => + checkIsTestMint(mintUrl, mintInfo.isSupported(4).disabled), staleTime: Number.POSITIVE_INFINITY, }); +}; /** * Initializes a Cashu wallet with offline handling. diff --git a/app/features/user/user-hooks.tsx b/app/features/user/user-hooks.tsx index e63622f6f..4a040b003 100644 --- a/app/features/user/user-hooks.tsx +++ b/app/features/user/user-hooks.tsx @@ -95,6 +95,7 @@ export const defaultAccounts = [ isTestMint: true, isDefault: false, purpose: 'transactional', + expiresAt: null, }, { type: 'cashu', @@ -104,6 +105,7 @@ export const defaultAccounts = [ isTestMint: true, isDefault: true, purpose: 'transactional', + expiresAt: null, }, ] as const) : []), diff --git a/app/features/user/user-repository.ts b/app/features/user/user-repository.ts index 522ae25f4..2af4532ba 100644 --- a/app/features/user/user-repository.ts +++ b/app/features/user/user-repository.ts @@ -292,6 +292,7 @@ export class ReadUserDefaultAccountRepository { mintUrl: details.mint_url, isTestMint: details.is_test_mint, keysetCounters: details.keyset_counters, + expiresAt: details.expires_at ?? null, wallet, }; } diff --git a/app/lib/cashu/PROTOCOL_EXTENSIONS.md b/app/lib/cashu/PROTOCOL_EXTENSIONS.md index 3152fae16..d265f0f44 100644 --- a/app/lib/cashu/PROTOCOL_EXTENSIONS.md +++ b/app/lib/cashu/PROTOCOL_EXTENSIONS.md @@ -17,7 +17,7 @@ Example: { "...other fields", "agicash": { - "closed_loop": true, + "purpose": "gift-card", "minting_fee": { "type": "basis_points", "value": 100 @@ -26,19 +26,25 @@ Example: } ``` -## Closed Loop +### Purpose -The `closed_loop` field is a boolean that indicates whether the mint operates in closed-loop mode. -When `true`, the mint will only process payments to destinations within its loop. The loop may include -invoices generated by the mint itself or other configured destinations. +The `purpose` field signals to wallets what type of account to create for this mint. +Defaults to `"transactional"` when absent. -```json -{ - "agicash": { - "closed_loop": true - } -} -``` +| Value | Description | +|-------|-------------| +| `"transactional"` | Regular mint for sending and receiving payments. | +| `"gift-card"` | Closed-loop mint issuing gift cards. The mint only processes payments to destinations within its loop. The loop may include invoices generated by the mint itself or other configured destinations. | +| `"offer"` | Closed-loop promotional ecash with an expiry. Users cannot mint from these mints (`nuts.4.disabled: true`); ecash is distributed by the mint operator. The mint only processes payments to destinations within its loop. The keyset's `final_expiry` field indicates when the ecash expires. | + +### Offer Mints + +Offer mints are also closed-loop. They still include standard NUT-04 metadata in the NUT-06 info response, but user minting is disabled via `nuts.4.disabled: true`. +Wallets should: + +- **Not** show Lightning receive options for offer accounts. +- Read `final_expiry` from the mint's active keyset (via `/v1/keysets`) to determine when the offer expires. +- Hide expired offer accounts from the UI. ## Minting Fees (extends NUT-23) @@ -69,4 +75,3 @@ When the fee type is `basis_points`, the fee is calculated as: ``` fee = amount * (basis_points / 10000) ``` - diff --git a/app/lib/cashu/index.ts b/app/lib/cashu/index.ts index c0eb31476..878ff3ff1 100644 --- a/app/lib/cashu/index.ts +++ b/app/lib/cashu/index.ts @@ -3,7 +3,7 @@ export * from './secret'; export * from './token'; export * from './utils'; export * from './error-codes'; -export { ExtendedMintInfo } from './protocol-extensions'; +export { ExtendedMintInfo, type MintPurpose } from './protocol-extensions'; export { ProofSchema } from './types'; export * from './payment-request'; export * from './melt-quote-subscription'; diff --git a/app/lib/cashu/mint-validation.ts b/app/lib/cashu/mint-validation.ts index fbbc0a428..0a36f91b9 100644 --- a/app/lib/cashu/mint-validation.ts +++ b/app/lib/cashu/mint-validation.ts @@ -185,13 +185,6 @@ const validateBolt11Support = ( const nut = operation === 'minting' ? 4 : 5; const status = info.isSupported(nut); - if (status.disabled) { - return { - isValid: false, - message: `${operation} is disabled on this mint`, - }; - } - const hasBolt11Support = status.params.some( (method) => method.method === 'bolt11' && method.unit === unit, ); diff --git a/app/lib/cashu/protocol-extensions.ts b/app/lib/cashu/protocol-extensions.ts index 46e4072dd..356a92790 100644 --- a/app/lib/cashu/protocol-extensions.ts +++ b/app/lib/cashu/protocol-extensions.ts @@ -21,16 +21,20 @@ type MintQuoteFee = { fee?: number; }; +/** + * The purpose of a Cashu mint as advertised in its info response. + * - 'transactional': Regular mint for sending/receiving payments + * - 'gift-card': Closed-loop mint issuing gift cards + * - 'offer': Promotional ecash with an expiry + */ +export type MintPurpose = 'transactional' | 'gift-card' | 'offer'; + /** * Agicash-specific mint info extension. * This is included in the mint's info response under the "agicash" key. */ export type AgicashMintExtension = { - /** - * When true, the mint operates in closed-loop mode and will only process - * payments to destinations within its loop. - */ - closed_loop?: boolean; + purpose?: MintPurpose; }; /** diff --git a/app/lib/cashu/utils.ts b/app/lib/cashu/utils.ts index e997f12a7..f887711bf 100644 --- a/app/lib/cashu/utils.ts +++ b/app/lib/cashu/utils.ts @@ -2,6 +2,7 @@ import { type MeltQuoteBolt11Response, MeltQuoteState, type Mint, + type MintKeyset, type MintQuoteBolt11Response, type Proof, Wallet, @@ -13,6 +14,7 @@ import type { Currency, CurrencyUnit } from '../money'; import { ExtendedMintInfo, type ExtendedMintQuoteBolt11Response, + type MintPurpose, } from './protocol-extensions'; import type { CashuProtocolUnit } from './types'; @@ -76,8 +78,25 @@ export const getCashuProtocolUnit = (currency: Currency) => { */ export const getMintPurpose = ( mintInfo: ExtendedMintInfo | null | undefined, -): 'gift-card' | 'transactional' => { - return mintInfo?.agicash?.closed_loop ? 'gift-card' : 'transactional'; +): MintPurpose => { + return mintInfo?.agicash?.purpose ?? 'transactional'; +}; + +/** + * For offer mints, finds the active keyset's expiry for the given currency. + * Only meaningful for offers — for transactional/gift-card mints, keyset expiry + * triggers rotation, not account expiration. + * Assumes one active keyset per unit on offer mints. + * @returns The ISO 8601 timestamp when the offer expires, or null if the keyset has no expiry. + */ +export const getOfferExpiresAt = ( + keysets: MintKeyset[], + currency: Currency, +): string | null => { + const unit = getCashuProtocolUnit(currency); + const activeKeyset = keysets.find((ks) => ks.unit === unit && ks.active); + if (!activeKeyset?.final_expiry) return null; + return new Date(activeKeyset.final_expiry * 1000).toISOString(); }; export const getWalletCurrency = (wallet: Wallet) => { @@ -127,7 +146,7 @@ export class ExtendedCashuWallet extends Wallet { /** * Gets the purpose of this mint based on its configuration. */ - get purpose(): 'gift-card' | 'transactional' { + get purpose(): MintPurpose { return getMintPurpose(this.getMintInfo()); } @@ -245,21 +264,28 @@ export const getCashuWallet = ( /** * Check if a mint is a test mint by checking the network of the mint quote - * and also checking if the mint is in the list of known test mints + * and also checking if the mint is in the list of known test mints. * - * Known test mints: - * - https://testnut.cashu.space - * - https://nofees.testnut.cashu.space + * If the mint has minting disabled (NUT-04), it cannot create a mint quote, + * so we fall back to the known-mints check only (assumes mainnet). * * @param mintUrl - The URL of the mint + * @param isMintingDisabled - Whether NUT-04 minting is disabled on the mint * @returns True if the mint is not on mainnet */ -export const checkIsTestMint = async (mintUrl: string): Promise => { +export const checkIsTestMint = async ( + mintUrl: string, + isMintingDisabled: boolean, +): Promise => { // Normalize URL by removing trailing slash and converting to lowercase const normalizedUrl = mintUrl.toLowerCase().replace(/\/+$/, ''); if (knownTestMints.includes(normalizedUrl)) { return true; } + + if (isMintingDisabled) { + return false; + } const wallet = getCashuWallet(mintUrl); const { request: bolt11 } = await wallet.createMintQuoteBolt11(1); const { network } = decodeBolt11(bolt11); diff --git a/app/routes/_protected.receive.cashu_.token.tsx b/app/routes/_protected.receive.cashu_.token.tsx index c3939b8fc..450d6c49d 100644 --- a/app/routes/_protected.receive.cashu_.token.tsx +++ b/app/routes/_protected.receive.cashu_.token.tsx @@ -51,7 +51,7 @@ const getClaimCashuTokenService = async () => { getCashuWalletSeed, getSparkWalletMnemonic, ); - const accountService = new AccountService(accountRepository); + const accountService = new AccountService(accountRepository, queryClient); const receiveSwapRepository = new CashuReceiveSwapRepository( agicashDbClient, encryption, diff --git a/supabase/database.types.ts b/supabase/database.types.ts index da35b50d9..8f077cb78 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -1600,7 +1600,7 @@ export type Database = { } } Enums: { - account_purpose: "transactional" | "gift-card" + account_purpose: "transactional" | "gift-card" | "offer" account_type: "cashu" | "spark" acknowledgment_status: "pending" | "acknowledged" cashu_proof_state: "UNSPENT" | "RESERVED" | "SPENT" @@ -1886,7 +1886,7 @@ export type CompositeTypes< export const Constants = { wallet: { Enums: { - account_purpose: ["transactional", "gift-card"], + account_purpose: ["transactional", "gift-card", "offer"], account_type: ["cashu", "spark"], acknowledgment_status: ["pending", "acknowledged"], cashu_proof_state: ["UNSPENT", "RESERVED", "SPENT"], diff --git a/supabase/migrations/20260320120000_add_offer_account_purpose.sql b/supabase/migrations/20260320120000_add_offer_account_purpose.sql new file mode 100644 index 000000000..e1043f157 --- /dev/null +++ b/supabase/migrations/20260320120000_add_offer_account_purpose.sql @@ -0,0 +1,2 @@ +-- Add 'offer' to the account_purpose enum for promotional ecash accounts +alter type wallet.account_purpose add value if not exists 'offer';