From c66ec513b2aa0f8d3c858ae9f01677517f547a75 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Fri, 20 Mar 2026 10:16:36 -0700 Subject: [PATCH 01/11] Add design spec for cashu v2 keyset ID support Two bugs with v2 keysets: getDecodedToken fails without keyset IDs for resolution, and re-encoded tokens have truncated IDs that can't be looked up. Fix uses dependency-injected keyset resolver with cache-first strategy, plus raw token string passthrough in paste/scan. --- ...26-03-20-cashu-v2-keyset-support-design.md | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-20-cashu-v2-keyset-support-design.md diff --git a/docs/superpowers/specs/2026-03-20-cashu-v2-keyset-support-design.md b/docs/superpowers/specs/2026-03-20-cashu-v2-keyset-support-design.md new file mode 100644 index 000000000..1b261ec86 --- /dev/null +++ b/docs/superpowers/specs/2026-03-20-cashu-v2-keyset-support-design.md @@ -0,0 +1,207 @@ +# Cashu V2 Keyset ID Support + +## Problem + +Two bugs when interacting with mints that use v2 keysets (NUT-02, `01`-prefixed IDs): + +1. **Pasting a v2 token shows "invalid"** — `getDecodedToken()` from cashu-ts requires keyset IDs to resolve v2 keyset IDs. Our code calls it without keyset IDs, so it always fails for v2 tokens (both cashuA and cashuB formats). + +2. **Reclaiming a token fails with "Could not get fee. No keyset found"** — `getEncodedToken()` truncates v2 keyset IDs from 66 to 16 hex chars for v4 token encoding. When the token is later decoded without keyset resolution, proofs retain the short 16-char IDs. The wallet's KeyChain stores keysets by full 66-char IDs, so `getFeesForProofs()` fails on exact-match lookup. + +## Background + +### V1 vs V2 Keyset IDs + +| Property | V1 (`00` prefix) | V2 (`01` prefix) | +|----------|-------------------|-------------------| +| Length | 16 hex chars (8 bytes) | 66 hex chars (33 bytes) | +| Derivation | SHA-256 of concatenated pubkeys, truncated to 7 bytes | SHA-256 of `{amount}:{pubkey}` pairs + unit + fee + expiry, full hash | +| Token encoding | Stored as-is (already short) | Truncated to 16 chars (short ID) in v4 tokens | + +### How cashu-ts Decodes Tokens + +`getDecodedToken(token, keysetIds?)` internally: +1. Strips prefix, decodes base64url/CBOR → Token with proofs +2. Runs `mapShortKeysetIds(proofs, keysetIds)` which: + - V1 IDs (`0x00` first byte): passes through unchanged + - V2 IDs (`0x01` first byte): prefix-matches against provided keyset IDs + - Rejects ambiguous matches (multiple full IDs match same short ID) + - Throws if no keyset IDs provided or no match found + +This resolution step runs for ALL v2 IDs — even full-length 66-char IDs in cashuA tokens. Without keyset IDs, any token with v2 keysets fails. + +### cashu-ts Utility: `getTokenMetadata(token)` + +Exported function that decodes a token WITHOUT keyset resolution. Returns `{ mint, unit, amount, memo?, incompleteProofs }` where proofs lack the `id` field. Safe to call without any keyset knowledge. Used to extract the mint URL for cache lookup. + +### Security of Short ID Resolution + +- Keyset IDs are SHA-256 derived from public keys + metadata — unforgeable +- `mapShortKeysetIds` rejects ambiguous matches (spec requirement from NUT-02) +- Resolution uses keysets from the token's declared mint only +- The mint validates all operations server-side regardless + +### Reference: cashu.me Implementation + +cashu.me (PR #470) uses a two-tier decode: +- `decode()` — sync, uses `getTokenMetadata()` for UI preview (no resolution) +- `decodeFull()` — async, tries cached keyset IDs first, falls back to network fetch + +## Design + +### Approach: Fix at Decode Time with Dependency Injection + +Keep all decode logic in `lib/cashu/token.ts` (which can only import from `@cashu/cashu-ts`). Inject keyset resolution as a callback to respect the import hierarchy (`lib` → `features` → `routes`). + +### 1. Token Extraction Functions (`app/lib/cashu/token.ts`) + +Three exported functions: + +**`extractCashuTokenString(content: string): string | null`** + +Regex-only extraction. Returns the raw encoded token string without any decoding. Used by paste/scan handlers to navigate with the original token string, avoiding the lossy decode-then-re-encode cycle. + +```typescript +export function extractCashuTokenString(content: string): string | null { + const tokenMatch = content.match(/cashu[AB][A-Za-z0-9_-]+={0,2}/); + return tokenMatch?.[0] ?? null; +} +``` + +**`extractCashuToken(content: string, getKeysetIds?: (mintUrl: string) => string[] | undefined): Token | null`** + +Synchronous v2-aware decode with optional keyset resolver. + +Flow: +1. Extract token string via regex +2. Try `getDecodedToken(tokenString)` — succeeds for v1 keysets +3. If fails, call `getTokenMetadata(tokenString)` to get mint URL without keyset resolution +4. Call injected `getKeysetIds(mintUrl)` to get cached keyset IDs +5. Retry `getDecodedToken(tokenString, keysetIds)` with resolution + +If no resolver is provided or cache misses, returns null. + +**`extractCashuTokenAsync(content: string, fetchKeysetIds: (mintUrl: string) => Promise): Promise`** + +Async variant with network fallback. Same flow as sync version but the injected resolver can fetch from the network. Used when the sync version returns null (unknown mint, cache miss). + +### 2. Keyset Resolver Factory (`app/features/shared/cashu.ts`) + +Provides the resolver functions that bridge `lib` and `features`: + +```typescript +export function createKeysetIdsResolver(queryClient: QueryClient) { + return { + fromCache: (mintUrl: string): string[] | undefined => { + const data = queryClient.getQueryData( + allMintKeysetsQueryKey(mintUrl), + ); + return data?.keysets.map((k) => k.id); + }, + fromNetwork: async (mintUrl: string): Promise => { + const data = await queryClient.fetchQuery( + allMintKeysetsQueryOptions(mintUrl), + ); + return data.keysets.map(k => k.id); + }, + }; +} +``` + +`fromCache` reads synchronously from the TanStack Query cache. Keysets are already cached from wallet initialization (`getInitializedCashuWallet` calls `allMintKeysetsQueryOptions` with 1-hour staleTime). + +`fromNetwork` uses `fetchQuery` which returns cached data if fresh, or fetches from the mint if stale/missing. + +### 3. Paste/Scan Handlers + +Four call sites: `receive-input.tsx`, `receive-scanner.tsx`, `transfer-input.tsx`, `transfer-scanner.tsx`. + +Current flow (broken for v2): +``` +extractCashuToken(content) → getEncodedToken(token) → navigate with hash +``` + +The re-encoding step is lossy: `getEncodedToken` truncates v2 keyset IDs to 16 chars. When the destination route decodes the hash, it faces the same v2 resolution problem. + +New flow: +``` +extractCashuTokenString(content) → navigate with raw string +``` + +No decoding or re-encoding. The raw token string preserves the original keyset ID format. Validation (is this actually a cashu token?) now happens in the destination route's clientLoader where async decode is available. + +The regex `/cashu[AB][A-Za-z0-9_-]+={0,2}/` is specific enough that false positives are negligible. + +### 4. Route ClientLoaders + +Two call sites: `_protected.receive.cashu_.token.tsx`, `_public.receive-cashu-token.tsx`. + +Both are already async (`clientLoader` is an async function). New flow: + +```typescript +const queryClient = getQueryClient(); +const resolver = createKeysetIdsResolver(queryClient); + +// Try sync (cache hit for known mints) +let token = extractCashuToken(hash, resolver.fromCache); + +// Fall back to async (network fetch for unknown mints) +if (!token) { + token = await extractCashuTokenAsync(hash, resolver.fromNetwork); +} + +if (!token) { + throw redirect('/receive'); +} +``` + +For the user's own mints (reclaim flow), keysets are always cached — no network request. For tokens from unknown mints, the network fetch is unavoidable but happens only once (then cached for 1 hour). + +### 5. No Changes to Downstream Operations + +If tokens are decoded properly at the entry points (paste/scan/route), proofs carry full v2 keyset IDs (66 chars). All downstream code — `getFeesForProofs`, `wallet.ops.receive()`, `wallet.getKeyset()` — works as-is because the KeyChain stores keysets by full ID. + +No `resolveTokenKeysetIds` safety net is needed since no v2 tokens are in production yet. + +### 6. Re-encoding is Intentional, Not a Bug + +Several places in the app call `getEncodedToken(token)` which truncates v2 keyset IDs to 16 chars: +- `receive-cashu-token.tsx` — copy-to-clipboard and "Log In and Claim" redirect link +- `share-cashu-token.tsx` — shareable link and QR code +- `getTokenHash` — token deduplication + +This truncation is correct v4 encoding behavior (short IDs for size). It is NOT a problem because every re-encoded token eventually goes through a decode step that uses our v2-aware decode with keyset resolution: +- Login redirect → protected route clientLoader → v2-aware decode +- Shared token → recipient pastes → navigate → clientLoader → v2-aware decode +- `getTokenHash` → `getEncodedToken` normalizes deterministically (same Token always produces same encoding) + +### 7. Public Route Always Takes Async Path + +The public route (`_public.receive-cashu-token.tsx`) runs before the user logs in, so `getInitializedCashuWallet` has never been called and the TanStack Query cache has no keyset data. For v2 tokens, `resolver.fromCache` will miss and `resolver.fromNetwork` will fetch keysets from the mint. This adds one network request to the public receive flow — acceptable since the receive UI already makes network calls (proof state checks, mint info). + +### 8. UX Note for Paste/Scan Validation + +With `extractCashuTokenString`, paste/scan handlers no longer validate the full token structure before navigating. Validation moves to the destination route's clientLoader. For structurally malformed tokens that happen to match the regex, the user sees a brief navigation then redirect back, rather than an immediate toast. The regex `/cashu[AB][A-Za-z0-9_-]+={0,2}/` is specific enough that this rarely occurs in practice, and the tradeoff enables v2 support without making paste/scan handlers async. + +## Files Changed + +| File | Change | +|------|--------| +| `app/lib/cashu/token.ts` | Add `extractCashuTokenString`, `extractCashuTokenAsync`. Modify `extractCashuToken` to accept keyset resolver. Import `getTokenMetadata` from cashu-ts. | +| `app/features/shared/cashu.ts` | Add `createKeysetIdsResolver` factory function. | +| `app/routes/_protected.receive.cashu_.token.tsx` | Use async decode with resolver in clientLoader. | +| `app/routes/_public.receive-cashu-token.tsx` | Use async decode with resolver in clientLoader. | +| `app/features/receive/receive-input.tsx` | Use `extractCashuTokenString` in paste handler. Remove `getEncodedToken` re-encoding. | +| `app/features/receive/receive-scanner.tsx` | Use `extractCashuTokenString` in scan callback. Remove `getEncodedToken` re-encoding. | +| `app/features/transfer/transfer-input.tsx` | Use `extractCashuTokenString` in paste handler. Remove `getEncodedToken` re-encoding. | +| `app/features/transfer/transfer-scanner.tsx` | Use `extractCashuTokenString` in scan callback. Remove `getEncodedToken` re-encoding. | + +## Testing + +- Unit test `extractCashuToken` with v1 and v2 tokens (both cashuA and cashuB formats) +- Unit test `extractCashuTokenString` extracts token from various content formats +- Unit test keyset resolver returns correct IDs from mock cache +- Manual test: paste a v2 cashuB token → should decode and show receive UI +- Manual test: create and reclaim a token from a v2 keyset mint → should swap successfully +- Manual test: receive a token from an unknown v2 mint → should fetch keysets and decode +- Unit test round-trip: decode v2 token → `getEncodedToken` (truncates IDs) → decode again with resolver → should produce identical Token From 374d786e654c4fe88842bc65c25ba93eaaadbe7d Mon Sep 17 00:00:00 2001 From: gudnuf Date: Fri, 20 Mar 2026 10:30:59 -0700 Subject: [PATCH 02/11] Update v2 keyset spec: validate with getTokenMetadata, try/catch decode - extractCashuTokenString validates via getTokenMetadata (not just regex) - extractCashuToken uses try/catch like cashu.me for v2 detection - Preserves immediate validation UX in paste/scan handlers --- ...26-03-20-cashu-v2-keyset-support-design.md | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-03-20-cashu-v2-keyset-support-design.md b/docs/superpowers/specs/2026-03-20-cashu-v2-keyset-support-design.md index 1b261ec86..5e0cd330e 100644 --- a/docs/superpowers/specs/2026-03-20-cashu-v2-keyset-support-design.md +++ b/docs/superpowers/specs/2026-03-20-cashu-v2-keyset-support-design.md @@ -59,31 +59,38 @@ Three exported functions: **`extractCashuTokenString(content: string): string | null`** -Regex-only extraction. Returns the raw encoded token string without any decoding. Used by paste/scan handlers to navigate with the original token string, avoiding the lossy decode-then-re-encode cycle. +Extracts and validates a cashu token string from arbitrary content. Uses regex to find the token, then `getTokenMetadata()` to validate it's a structurally valid token (not just a regex match). Returns the raw encoded token string without full decoding. Used by paste/scan handlers to navigate with the original token string, avoiding the lossy decode-then-re-encode cycle. ```typescript export function extractCashuTokenString(content: string): string | null { const tokenMatch = content.match(/cashu[AB][A-Za-z0-9_-]+={0,2}/); - return tokenMatch?.[0] ?? null; + if (!tokenMatch) return null; + + try { + getTokenMetadata(tokenMatch[0]); // validates token structure + return tokenMatch[0]; + } catch { + return null; + } } ``` **`extractCashuToken(content: string, getKeysetIds?: (mintUrl: string) => string[] | undefined): Token | null`** -Synchronous v2-aware decode with optional keyset resolver. +Synchronous v2-aware decode with optional keyset resolver. Follows the cashu.me pattern: try standard decode first, fall back to keyset-resolved decode on failure. Flow: -1. Extract token string via regex +1. Extract and validate token string via `extractCashuTokenString` 2. Try `getDecodedToken(tokenString)` — succeeds for v1 keysets -3. If fails, call `getTokenMetadata(tokenString)` to get mint URL without keyset resolution +3. If fails (v2 keyset), use `getTokenMetadata(tokenString)` to get mint URL without keyset resolution 4. Call injected `getKeysetIds(mintUrl)` to get cached keyset IDs -5. Retry `getDecodedToken(tokenString, keysetIds)` with resolution +5. Decode with `getDecodedToken(tokenString, keysetIds)` for v2 resolution If no resolver is provided or cache misses, returns null. **`extractCashuTokenAsync(content: string, fetchKeysetIds: (mintUrl: string) => Promise): Promise`** -Async variant with network fallback. Same flow as sync version but the injected resolver can fetch from the network. Used when the sync version returns null (unknown mint, cache miss). +Async variant with network fallback. Same flow but the injected resolver can fetch from the network. Used when the sync version returns null (unknown mint, cache miss). ### 2. Keyset Resolver Factory (`app/features/shared/cashu.ts`) @@ -179,9 +186,9 @@ This truncation is correct v4 encoding behavior (short IDs for size). It is NOT The public route (`_public.receive-cashu-token.tsx`) runs before the user logs in, so `getInitializedCashuWallet` has never been called and the TanStack Query cache has no keyset data. For v2 tokens, `resolver.fromCache` will miss and `resolver.fromNetwork` will fetch keysets from the mint. This adds one network request to the public receive flow — acceptable since the receive UI already makes network calls (proof state checks, mint info). -### 8. UX Note for Paste/Scan Validation +### 8. Paste/Scan Validation Preserved -With `extractCashuTokenString`, paste/scan handlers no longer validate the full token structure before navigating. Validation moves to the destination route's clientLoader. For structurally malformed tokens that happen to match the regex, the user sees a brief navigation then redirect back, rather than an immediate toast. The regex `/cashu[AB][A-Za-z0-9_-]+={0,2}/` is specific enough that this rarely occurs in practice, and the tradeoff enables v2 support without making paste/scan handlers async. +`extractCashuTokenString` validates tokens via `getTokenMetadata()` — not just regex. This catches malformed tokens immediately in paste/scan handlers (same UX as today: instant toast for invalid input). Only structurally valid tokens trigger navigation. Full v2 keyset resolution then happens in the destination route's async clientLoader. ## Files Changed From 79e870fbad4280d35a2c110fecfa8a16d00973c3 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Fri, 20 Mar 2026 10:39:48 -0700 Subject: [PATCH 03/11] Add implementation plan for cashu v2 keyset support 4 tasks: token functions + tests, resolver factory, paste/scan handlers, route clientLoaders. Includes v2-specific tests with round-trip verification. --- .../2026-03-20-cashu-v2-keyset-support.md | 696 ++++++++++++++++++ 1 file changed, 696 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-20-cashu-v2-keyset-support.md diff --git a/docs/superpowers/plans/2026-03-20-cashu-v2-keyset-support.md b/docs/superpowers/plans/2026-03-20-cashu-v2-keyset-support.md new file mode 100644 index 000000000..392efb989 --- /dev/null +++ b/docs/superpowers/plans/2026-03-20-cashu-v2-keyset-support.md @@ -0,0 +1,696 @@ +# Cashu V2 Keyset ID Support — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix token decoding to support v2 keyset IDs (NUT-02) so users can paste, receive, and reclaim tokens from mints with v2 keysets. + +**Architecture:** Add v2-aware decode functions to `lib/cashu/token.ts` using dependency injection for keyset resolution. Paste/scan handlers pass raw token strings instead of decode-then-re-encode. Route clientLoaders use cache-first async decode. See `docs/superpowers/specs/2026-03-20-cashu-v2-keyset-support-design.md` for full spec. + +**Tech Stack:** cashu-ts v3.6.1 (`getDecodedToken`, `getTokenMetadata`), TanStack Query v5 (keyset caching), React Router v7 (clientLoaders) + +--- + +### Task 1: Add `extractCashuTokenString` and update `extractCashuToken` + +**Files:** +- Modify: `app/lib/cashu/token.ts` +- Create: `app/lib/cashu/token.test.ts` + +- [ ] **Step 1: Write failing tests for the new token functions** + +Create `app/lib/cashu/token.test.ts`. We need real v1 tokens to test with. Generate them inline using cashu-ts, and mock v2 behavior via the keyset resolver. + +```typescript +import { describe, expect, test } from 'bun:test'; +import { + type Token, + getDecodedToken, + getEncodedToken, + getTokenMetadata, +} from '@cashu/cashu-ts'; +import { + extractCashuToken, + extractCashuTokenAsync, + extractCashuTokenString, +} from './token'; + +// A real v1 cashuA token (v1 keyset ID starts with "00") +// Generate one by encoding a minimal token: +const V1_TOKEN: Token = { + mint: 'https://mint.example.com', + proofs: [ + { + id: '009a1f293253e41e', + amount: 1, + secret: 'test-secret-1', + C: '02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904', + }, + ], + unit: 'sat', +}; + +const V1_ENCODED_A = getEncodedToken(V1_TOKEN, { version: 3 }); +const V1_ENCODED_B = getEncodedToken(V1_TOKEN, { version: 4 }); + +describe('extractCashuTokenString', () => { + test('extracts a valid cashuA token string from content', () => { + const result = extractCashuTokenString(`check this out: ${V1_ENCODED_A}`); + expect(result).toBe(V1_ENCODED_A); + }); + + test('extracts a valid cashuB token string from content', () => { + const result = extractCashuTokenString(`here: ${V1_ENCODED_B}`); + expect(result).toBe(V1_ENCODED_B); + }); + + test('returns null for content with no token', () => { + expect(extractCashuTokenString('hello world')).toBeNull(); + }); + + test('returns null for malformed token that matches regex but fails metadata parse', () => { + expect(extractCashuTokenString('cashuBinvaliddata')).toBeNull(); + }); + + test('extracts token from URL with hash', () => { + const result = extractCashuTokenString(`#${V1_ENCODED_B}`); + expect(result).toBe(V1_ENCODED_B); + }); +}); + +describe('extractCashuToken', () => { + test('decodes a v1 cashuA token without a resolver', () => { + const token = extractCashuToken(V1_ENCODED_A); + expect(token).not.toBeNull(); + expect(token!.mint).toBe('https://mint.example.com'); + expect(token!.proofs[0].id).toBe('009a1f293253e41e'); + }); + + test('decodes a v1 cashuB token without a resolver', () => { + const token = extractCashuToken(V1_ENCODED_B); + expect(token).not.toBeNull(); + expect(token!.mint).toBe('https://mint.example.com'); + }); + + test('returns null for invalid content', () => { + expect(extractCashuToken('not a token')).toBeNull(); + }); + + test('does not call resolver for v1 tokens', () => { + const calls: string[] = []; + const resolver = (mintUrl: string) => { + calls.push(mintUrl); + return undefined; + }; + extractCashuToken(V1_ENCODED_A, resolver); + expect(calls).toHaveLength(0); + }); +}); + +// V2 keyset ID tests — exercise the resolver fallback path. +// We construct tokens with a fake v2 keyset ID (prefix "01", 66 hex chars). +// getDecodedToken(token) will fail for these, triggering the resolver. +const V2_KEYSET_ID = '01' + 'a'.repeat(64); // 66 chars, v2 format + +const V2_TOKEN: Token = { + mint: 'https://v2mint.example.com', + proofs: [ + { + id: V2_KEYSET_ID, + amount: 1, + secret: 'test-secret-v2', + C: '02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904', + }, + ], + unit: 'sat', +}; + +// cashuA preserves full keyset IDs in the JSON +const V2_ENCODED_A = getEncodedToken(V2_TOKEN, { version: 3 }); +// cashuB truncates v2 keyset IDs to 16 chars (short ID) +const V2_ENCODED_B = getEncodedToken(V2_TOKEN, { version: 4 }); + +describe('extractCashuToken with v2 keyset IDs', () => { + test('returns null for v2 token without resolver', () => { + expect(extractCashuToken(V2_ENCODED_A)).toBeNull(); + }); + + test('decodes v2 cashuA token with resolver providing full keyset ID', () => { + const resolver = (mintUrl: string) => { + expect(mintUrl).toBe('https://v2mint.example.com'); + return [V2_KEYSET_ID]; + }; + + const token = extractCashuToken(V2_ENCODED_A, resolver); + expect(token).not.toBeNull(); + expect(token!.mint).toBe('https://v2mint.example.com'); + expect(token!.proofs[0].id).toBe(V2_KEYSET_ID); + }); + + test('decodes v2 cashuB token (short ID) with resolver', () => { + const resolver = (_mintUrl: string) => [V2_KEYSET_ID]; + + const token = extractCashuToken(V2_ENCODED_B, resolver); + expect(token).not.toBeNull(); + expect(token!.proofs[0].id).toBe(V2_KEYSET_ID); // resolved to full ID + }); + + test('returns null when resolver returns no matching keysets', () => { + const resolver = (_mintUrl: string) => ['00deadbeefcafe00']; // wrong keyset + expect(extractCashuToken(V2_ENCODED_A, resolver)).toBeNull(); + }); +}); + +describe('extractCashuToken v2 round-trip', () => { + test('decode v2 → encode (truncates) → decode with resolver → same token', () => { + // Decode the original v2 cashuA token (full IDs) + const resolver = (_mintUrl: string) => [V2_KEYSET_ID]; + const original = extractCashuToken(V2_ENCODED_A, resolver); + expect(original).not.toBeNull(); + + // Re-encode: getEncodedToken truncates v2 IDs to 16 chars (cashuB format) + const reEncoded = getEncodedToken(original!); + + // Decode the re-encoded token — needs resolver to resolve short IDs + const roundTripped = extractCashuToken(reEncoded, resolver); + expect(roundTripped).not.toBeNull(); + expect(roundTripped!.mint).toBe(original!.mint); + expect(roundTripped!.proofs[0].id).toBe(V2_KEYSET_ID); + expect(roundTripped!.proofs[0].amount).toBe(original!.proofs[0].amount); + }); +}); + +describe('extractCashuTokenAsync', () => { + test('decodes a v1 token without fetching', async () => { + const fetcher = async (_mintUrl: string) => { + throw new Error('should not be called for v1'); + }; + const token = await extractCashuTokenAsync(V1_ENCODED_A, fetcher); + expect(token).not.toBeNull(); + expect(token!.mint).toBe('https://mint.example.com'); + }); + + test('decodes a v2 token by fetching keyset IDs', async () => { + const fetcher = async (mintUrl: string) => { + expect(mintUrl).toBe('https://v2mint.example.com'); + return [V2_KEYSET_ID]; + }; + const token = await extractCashuTokenAsync(V2_ENCODED_B, fetcher); + expect(token).not.toBeNull(); + expect(token!.proofs[0].id).toBe(V2_KEYSET_ID); + }); + + test('returns null for invalid content', async () => { + const fetcher = async (_mintUrl: string) => []; + const token = await extractCashuTokenAsync('not a token', fetcher); + expect(token).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test app/lib/cashu/token.test.ts` +Expected: FAIL — `extractCashuTokenString` and `extractCashuTokenAsync` are not exported from `./token`. + +- [ ] **Step 3: Implement the token functions** + +Replace the contents of `app/lib/cashu/token.ts` with: + +```typescript +import { + CheckStateEnum, + type Proof, + type Token, + Wallet, + getDecodedToken, + getTokenMetadata, +} from '@cashu/cashu-ts'; +import { proofToY } from './proof'; + +/** + * A token consists of a set of proofs, and each proof can be in one of three states: + * spent, pending, or unspent. When claiming a token, all that we care about is the unspent proofs. + * The rest of the proofs will not be claimable. + * + * This function returns the set of proofs that are unspent + * @param token - The token to get the unspent proofs from + * @returns The set of unspent proofs + */ +export const getUnspentProofsFromToken = async ( + token: Token, +): Promise => { + const wallet = new Wallet(token.mint, { + unit: token.unit, + }); + const states = await wallet.checkProofsStates(token.proofs); + + return token.proofs.filter((proof) => { + const Y = proofToY(proof); + const state = states.find((s) => s.Y === Y); + return state?.state === CheckStateEnum.UNSPENT; + }); +}; + +const TOKEN_REGEX = /cashu[AB][A-Za-z0-9_-]+={0,2}/; + +/** + * Extract and validate a cashu token string from arbitrary content. + * Uses regex to find the token, then getTokenMetadata() to validate it's structurally valid. + * Returns the raw encoded string without full decoding (no keyset resolution). + * @param content - The content to search for a cashu token (URL, clipboard text, etc.) + * @returns The encoded token string if found and valid, otherwise null. + */ +export function extractCashuTokenString(content: string): string | null { + const tokenMatch = content.match(TOKEN_REGEX); + if (!tokenMatch) return null; + + try { + getTokenMetadata(tokenMatch[0]); + return tokenMatch[0]; + } catch { + return null; + } +} + +/** + * Extract and decode a cashu token from arbitrary content. + * Supports both v1 and v2 keyset IDs. For v2, an optional keyset resolver is used + * to map short keyset IDs to full IDs (cashu.me pattern: try without, fall back with). + * + * @param content - The content to extract the encoded cashu token from. + * @param getKeysetIds - Optional sync resolver: given a mint URL, returns keyset IDs from cache. + * Used to resolve v2 short keyset IDs. If not provided, only v1 tokens can be decoded. + * @returns The decoded token if found and valid, otherwise null. + */ +export function extractCashuToken( + content: string, + getKeysetIds?: (mintUrl: string) => string[] | undefined, +): Token | null { + const tokenString = extractCashuTokenString(content); + if (!tokenString) return null; + + // Try standard decode — succeeds for v1 keyset IDs + try { + return getDecodedToken(tokenString); + } catch { + // V2 keyset IDs require resolution — fall through + } + + // V2 fallback: get mint URL from metadata, resolve keyset IDs, retry + if (!getKeysetIds) return null; + + try { + const { mint } = getTokenMetadata(tokenString); + const keysetIds = getKeysetIds(mint); + if (!keysetIds?.length) return null; + return getDecodedToken(tokenString, keysetIds); + } catch { + return null; + } +} + +/** + * Async variant of extractCashuToken with network fallback. + * Tries standard decode first (v1), then fetches keyset IDs from the mint for v2 resolution. + * + * @param content - The content to extract the encoded cashu token from. + * @param fetchKeysetIds - Async resolver: given a mint URL, fetches keyset IDs (cache-first via TanStack Query). + * @returns The decoded token if found and valid, otherwise null. + */ +export async function extractCashuTokenAsync( + content: string, + fetchKeysetIds: (mintUrl: string) => Promise, +): Promise { + const tokenString = extractCashuTokenString(content); + if (!tokenString) return null; + + // Try standard decode — succeeds for v1 keyset IDs + try { + return getDecodedToken(tokenString); + } catch { + // V2 keyset IDs require resolution — fall through + } + + // V2 fallback: get mint URL from metadata, fetch keyset IDs, retry + try { + const { mint } = getTokenMetadata(tokenString); + const keysetIds = await fetchKeysetIds(mint); + if (!keysetIds.length) return null; + return getDecodedToken(tokenString, keysetIds); + } catch { + return null; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test app/lib/cashu/token.test.ts` +Expected: All tests PASS. + +- [ ] **Step 5: Run typecheck** + +Run: `bun run fix:all` +Expected: No type errors related to token.ts changes. + +- [ ] **Step 6: Commit** + +```bash +git add app/lib/cashu/token.ts app/lib/cashu/token.test.ts +git commit -m "feat: add v2 keyset support to token extraction functions + +Add extractCashuTokenString (validates via getTokenMetadata) and +extractCashuTokenAsync (network fallback). Update extractCashuToken +to accept optional keyset resolver for v2 short ID resolution." +``` + +--- + +### Task 2: Add keyset resolver factory + +**Files:** +- Modify: `app/features/shared/cashu.ts` + +- [ ] **Step 1: Add `createKeysetIdsResolver` to `app/features/shared/cashu.ts`** + +Add this function after the existing `allMintKeysetsQueryOptions` definition (around line 207). Import `GetKeysetsResponse` from cashu-ts if not already imported. + +```typescript +/** + * Creates keyset ID resolver functions for v2 token decoding. + * The sync resolver reads from TanStack Query cache (no network). + * The async resolver uses fetchQuery (returns cached if fresh, fetches if stale/missing). + */ +export function createKeysetIdsResolver(queryClient: QueryClient) { + return { + fromCache: (mintUrl: string): string[] | undefined => { + const data = queryClient.getQueryData( + allMintKeysetsQueryKey(mintUrl), + ); + return data?.keysets.map((k) => k.id); + }, + fromNetwork: async (mintUrl: string): Promise => { + const data = await queryClient.fetchQuery( + allMintKeysetsQueryOptions(mintUrl), + ); + return data.keysets.map((k) => k.id); + }, + }; +} +``` + +Verify that `GetKeysetsResponse` is already imported from cashu-ts (it's at line 6 of the existing imports). No new import needed. + +- [ ] **Step 2: Run typecheck** + +Run: `bun run fix:all` +Expected: No errors. + +- [ ] **Step 3: Commit** + +```bash +git add app/features/shared/cashu.ts +git commit -m "feat: add createKeysetIdsResolver for v2 token decode + +Bridges lib/cashu (token decode) and features (TanStack Query cache). +Sync resolver reads cached keysets, async resolver fetches from mint." +``` + +--- + +### Task 3: Update paste/scan handlers to use `extractCashuTokenString` + +**Files:** +- Modify: `app/features/receive/receive-input.tsx` +- Modify: `app/features/receive/receive-scanner.tsx` +- Modify: `app/features/transfer/transfer-input.tsx` +- Modify: `app/features/transfer/transfer-scanner.tsx` + +All four files follow the same pattern: replace `extractCashuToken` + `getEncodedToken` with `extractCashuTokenString`. + +- [ ] **Step 1: Update `app/features/receive/receive-input.tsx`** + +Change the import at line 25 from: +```typescript +import { extractCashuToken } from '~/lib/cashu'; +``` +to: +```typescript +import { extractCashuTokenString } from '~/lib/cashu'; +``` + +Remove the `getEncodedToken` import from `@cashu/cashu-ts` at line 1 (it's no longer needed in this file). + +Replace the `handlePaste` body (lines 88-118) with: + +```typescript + const handlePaste = async () => { + const clipboardContent = await readClipboard(); + if (!clipboardContent) { + return; + } + + const tokenString = extractCashuTokenString(clipboardContent); + if (!tokenString) { + toast({ + title: 'Invalid input', + description: 'Please paste a valid cashu token', + variant: 'destructive', + }); + return; + } + + const hash = `#${tokenString}`; + + // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it + // See https://github.com/remix-run/remix/discussions/10721 + window.history.replaceState(null, '', hash); + navigate( + { + ...buildLinkWithSearchParams('/receive/cashu/token', { + selectedAccountId: receiveAccountId, + }), + hash, + }, + { transition: 'slideLeft', applyTo: 'newView' }, + ); + }; +``` + +- [ ] **Step 2: Update `app/features/receive/receive-scanner.tsx`** + +Change the import at line 11 from: +```typescript +import { extractCashuToken } from '~/lib/cashu'; +``` +to: +```typescript +import { extractCashuTokenString } from '~/lib/cashu'; +``` + +Remove the `getEncodedToken` import from `@cashu/cashu-ts` at line 1. + +Replace the `onDecode` callback body (lines 33-58) with: + +```typescript + onDecode={(scannedContent) => { + const tokenString = extractCashuTokenString(scannedContent); + if (!tokenString) { + toast({ + title: 'Invalid input', + description: 'Please scan a valid cashu token', + variant: 'destructive', + }); + return; + } + + const hash = `#${tokenString}`; + + // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it + // See https://github.com/remix-run/remix/discussions/10721 + window.history.replaceState(null, '', hash); + navigate( + { + ...buildLinkWithSearchParams('/receive/cashu/token', { + selectedAccountId: receiveAccountId, + }), + hash, + }, + { transition: 'slideLeft', applyTo: 'newView' }, + ); + }} +``` + +- [ ] **Step 3: Update `app/features/transfer/transfer-input.tsx`** + +Same pattern. Change import at line 23 from `extractCashuToken` to `extractCashuTokenString`. Remove `getEncodedToken` import from `@cashu/cashu-ts` at line 1. + +In the `handlePaste` function (around line 107-135), replace: +```typescript + const token = extractCashuToken(clipboardContent); + if (!token) { +``` +with: +```typescript + const tokenString = extractCashuTokenString(clipboardContent); + if (!tokenString) { +``` + +And replace: +```typescript + const encodedToken = getEncodedToken(token); + const hash = `#${encodedToken}`; +``` +with: +```typescript + const hash = `#${tokenString}`; +``` + +- [ ] **Step 4: Update `app/features/transfer/transfer-scanner.tsx`** + +Same pattern. Change import at line 11 from `extractCashuToken` to `extractCashuTokenString`. Remove `getEncodedToken` import from `@cashu/cashu-ts` at line 1. + +Replace lines 34-42: +```typescript + const token = extractCashuToken(scannedContent); + if (!token) { +``` +with: +```typescript + const tokenString = extractCashuTokenString(scannedContent); + if (!tokenString) { +``` + +Replace lines 44-45: +```typescript + const encodedToken = getEncodedToken(token); + const hash = `#${encodedToken}`; +``` +with: +```typescript + const hash = `#${tokenString}`; +``` + +- [ ] **Step 5: Run typecheck** + +Run: `bun run fix:all` +Expected: No errors. Verify that `getEncodedToken` import is removed from all four files and no unused imports remain. + +- [ ] **Step 6: Commit** + +```bash +git add app/features/receive/receive-input.tsx app/features/receive/receive-scanner.tsx app/features/transfer/transfer-input.tsx app/features/transfer/transfer-scanner.tsx +git commit -m "refactor: pass raw token strings in paste/scan handlers + +Replace extractCashuToken + getEncodedToken with extractCashuTokenString. +Avoids lossy decode-then-re-encode cycle that truncates v2 keyset IDs. +Token validation preserved via getTokenMetadata in extractCashuTokenString." +``` + +--- + +### Task 4: Update route clientLoaders with v2-aware decode + +**Files:** +- Modify: `app/routes/_protected.receive.cashu_.token.tsx` +- Modify: `app/routes/_public.receive-cashu-token.tsx` + +- [ ] **Step 1: Update protected route clientLoader** + +In `app/routes/_protected.receive.cashu_.token.tsx`: + +Change the import at line 33 from: +```typescript +import { extractCashuToken } from '~/lib/cashu'; +``` +to: +```typescript +import { extractCashuToken, extractCashuTokenAsync } from '~/lib/cashu'; +``` + +Add import for the resolver factory: +```typescript +import { createKeysetIdsResolver } from '~/features/shared/cashu'; +``` + +Also import `getQueryClient` if not already imported (it is imported on line 27 via `~/features/shared/query-client`). + +Replace lines 108-114 (the token extraction in clientLoader) with: + +```typescript +export async function clientLoader({ request }: Route.ClientLoaderArgs) { + const queryClient = getQueryClient(); + const resolver = createKeysetIdsResolver(queryClient); + + // Request url doesn't include hash so we need to read it from the window location instead + let token = extractCashuToken(window.location.hash, resolver.fromCache); + + if (!token) { + token = await extractCashuTokenAsync( + window.location.hash, + resolver.fromNetwork, + ); + } + + if (!token) { + throw redirect('/receive'); + } +``` + +The rest of the function (lines 116 onward) stays the same. + +- [ ] **Step 2: Update public route clientLoader** + +In `app/routes/_public.receive-cashu-token.tsx`: + +Change the import at line 7 from: +```typescript +import { extractCashuToken } from '~/lib/cashu'; +``` +to: +```typescript +import { extractCashuToken, extractCashuTokenAsync } from '~/lib/cashu'; +``` + +Add import: +```typescript +import { createKeysetIdsResolver } from '~/features/shared/cashu'; +``` + +Replace lines 23-27 (the token extraction) with: + +```typescript + const resolver = createKeysetIdsResolver(queryClient); + + let token = extractCashuToken(hash, resolver.fromCache); + + if (!token) { + token = await extractCashuTokenAsync(hash, resolver.fromNetwork); + } + + if (!token) { + throw redirect('/home'); + } +``` + +Note: `queryClient` is already available from line 14. + +- [ ] **Step 3: Run typecheck** + +Run: `bun run fix:all` +Expected: No errors. + +- [ ] **Step 4: Run all tests** + +Run: `bun test` +Expected: All tests pass, including the new token tests from Task 1. + +- [ ] **Step 5: Commit** + +```bash +git add app/routes/_protected.receive.cashu_.token.tsx app/routes/_public.receive-cashu-token.tsx +git commit -m "feat: use v2-aware token decode in route clientLoaders + +Try sync cache-first decode, fall back to async network fetch for +unknown mints. Completes v2 keyset ID support for receive flows." +``` From 4ca710736f697a4ff996ada0f09fdef8eb718e8f Mon Sep 17 00:00:00 2001 From: gudnuf Date: Thu, 26 Mar 2026 01:16:26 -0700 Subject: [PATCH 04/11] Add account state lifecycle spec Design doc for adding active/expired/deleted lifecycle to wallet.accounts. Covers DB migration (enum, transition trigger, partial unique index, RLS, pg_cron auto-expiry), app-layer changes, and data flow diagrams. For review/discussion before implementation. --- docs/plans/account-state-lifecycle.md | 260 ++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 docs/plans/account-state-lifecycle.md diff --git a/docs/plans/account-state-lifecycle.md b/docs/plans/account-state-lifecycle.md new file mode 100644 index 000000000..80cd26187 --- /dev/null +++ b/docs/plans/account-state-lifecycle.md @@ -0,0 +1,260 @@ +# Account State Lifecycle Spec + +## Summary + +Add a `state` column to `wallet.accounts` supporting the lifecycle `active -> expired -> deleted` (soft delete). This enables automatic expiry of offer accounts when their keyset's `expires_at` passes, soft delete so expired/deleted accounts don't block creation of future accounts at the same mint, and a uniqueness constraint scoped only to active accounts. + +## Design Decisions + +### 1. Where to filter `deleted` accounts + +**Decision: RLS (restrictive SELECT policy).** + +A restrictive RLS policy makes `deleted` accounts invisible for SELECT. Every caller -- `getAll()`, `get()`, realtime subscriptions -- automatically excludes deleted rows without any app-layer change. The `enforce_accounts_limit` trigger must also be updated to exclude `deleted` accounts from its count. + +### 2. Where to filter `expired` accounts + +**Decision: App layer.** + +Expired accounts remain visible in `getAll()` -- the RLS policy does not hide them. The existing `useActiveOffers()` filter in `gift-cards.tsx` is simplified from the `expiresAt > now()` check to `account.state === 'active'`. + +### 3. Auto-expiry mechanism + +**Decision: pg_cron, hourly.** + +A cron job updates `state = 'expired'` for accounts where `state = 'active'` and `expires_at <= now()`. This UPDATE fires `broadcast_accounts_changes_trigger`, emitting `ACCOUNT_UPDATED` to connected clients. + +pg_cron is already installed and used for 8 daily cleanup jobs. No new infrastructure needed. Hourly frequency because expiry visibility matters within an hour. The client-side filter hides visually expired offers immediately; the DB catches up within an hour. + +### 4. Soft delete + +**Decision: Client-initiated app-layer mutation.** + +A new `wallet.soft_delete_account(p_account_id uuid)` DB function sets `state = 'deleted'` and bumps `version`. The `ACCOUNT_UPDATED` realtime event fires; the client removes the account from the cache. + +### 5. Transitions are one-way + +Valid: `active -> expired`, `active -> deleted`, `expired -> deleted`. No reactivation. An expired offer account's keyset has expired at the Cashu protocol level -- reactivating it would be misleading. New ecash at the same mint creates a new `active` account (the updated unique index allows this). + +Enforced by a BEFORE UPDATE trigger at the DB level. + +### 6. Realtime handling for deleted accounts + +The `ACCOUNT_UPDATED` handler must detect `state === 'deleted'` in the broadcast payload and call `accountCache.remove(id)` rather than `accountCache.update(account)`. + +## DB Migration + +**File:** `supabase/migrations/20260325120000_add_account_state.sql` + +### New enum + column + +```sql +create type "wallet"."account_state" as enum ('active', 'expired', 'deleted'); + +alter table "wallet"."accounts" + add column "state" "wallet"."account_state" not null default 'active'; +``` + +### State transition enforcement trigger + +```sql +create or replace function "wallet"."enforce_account_state_transition"() +returns trigger +language plpgsql +security invoker +set search_path = '' +as $function$ +begin + if old.state = 'deleted' then + raise exception + using + hint = 'INVALID_TRANSITION', + message = 'Cannot transition out of deleted state.'; + end if; + + if old.state = 'expired' and new.state not in ('expired', 'deleted') then + raise exception + using + hint = 'INVALID_TRANSITION', + message = format('Invalid account state transition: %s -> %s', old.state, new.state); + end if; + + return new; +end; +$function$; + +create trigger "enforce_account_state_transition" + before update of state on "wallet"."accounts" + for each row + when (old.state is distinct from new.state) + execute function "wallet"."enforce_account_state_transition"(); +``` + +### Index changes + +```sql +drop index "wallet"."cashu_accounts_user_currency_mint_url_unique"; + +create unique index "cashu_accounts_active_user_currency_mint_url_unique" + on "wallet"."accounts" using btree ( + "user_id", + "currency", + (("details" ->> 'mint_url'::text)) + ) + where ("type" = 'cashu' and "state" = 'active'); + +-- Supporting index for the cron job (index on the cast expression so Postgres can use it) +create index "idx_accounts_active_expires_at" + on "wallet"."accounts" using btree ((("details" ->> 'expires_at')::timestamptz)) + where ("state" = 'active' and ("details" ->> 'expires_at') is not null); +``` + +### RLS: hide deleted accounts + +```sql +create policy "Exclude deleted accounts from select" +on "wallet"."accounts" +as restrictive +for select +to authenticated +using (state != 'deleted'::wallet.account_state); +``` + +### enforce_accounts_limit (deferred) + +The current trigger counts all accounts regardless of state. Deleted accounts will count toward the 200-account quota. Changing this limit is a separate discussion — the limit exists for a reason and adjusting what counts toward it has implications beyond this feature. For now, soft-deleted accounts are rare (only offer accounts) and won't meaningfully impact the quota. + +### Soft delete DB function + +```sql +create or replace function "wallet"."soft_delete_account"(p_account_id uuid) +returns void +language plpgsql +security invoker +set search_path = '' +as $function$ +begin + update wallet.accounts + set state = 'deleted', version = version + 1 + where id = p_account_id; + + if not found then + raise exception + using + hint = 'NOT_FOUND', + message = format('Account with id %s not found.', p_account_id); + end if; +end; +$function$; +``` + +### pg_cron job for auto-expiry + +```sql +select cron.schedule('expire-offer-accounts', '0 * * * *', $$ + update wallet.accounts + set + state = 'expired', + version = version + 1 + where + state = 'active' + and (details ->> 'expires_at') is not null + and (details ->> 'expires_at')::timestamptz <= now(); +$$); +``` + +## App Code Changes + +### `account.ts` -- Add state to type + +```typescript +export type AccountState = 'active' | 'expired' | 'deleted'; + +// Add to Account base type: +state: AccountState; +``` + +### `account-repository.ts` -- Map state, add delete + +Map `state` in `toAccount()` commonData. Add `deleteAccount(id)` calling `soft_delete_account` RPC. + +### `account-hooks.ts` -- Cache removal + realtime handling + +- Add `AccountsCache.remove(id)` method +- Update `ACCOUNT_UPDATED` handler: if `payload.state === 'deleted'`, call `remove` instead of `update` +- Add `useDeleteAccount` hook + +### `gift-cards.tsx` -- Simplify filter + +```typescript +function useActiveOffers() { + const { data: offerAccounts } = useAccounts({ purpose: 'offer' }); + return offerAccounts.filter((account) => account.state === 'active'); +} +``` + +### Files requiring no changes + +- `account-service.ts` -- New accounts default to `active` via DB column default +- `offer-details.tsx` -- Already handles missing offer gracefully +- `all-accounts.tsx` -- Filters by `purpose: 'transactional'`, unaffected +- All DB quote functions -- Operate on specific account IDs, no state awareness needed +- `to_account_with_proofs` -- Uses `select *`, state included automatically + +## Data Flow + +### active -> expired (automatic, hourly) + +``` +pg_cron -> UPDATE state='expired', version+1 + -> broadcast_accounts_changes_trigger fires + -> realtime ACCOUNT_UPDATED to client + -> accountCache.update(account) [version higher, accepted] + -> useActiveOffers() re-renders, filtered by state === 'active' +``` + +### active/expired -> deleted (user-initiated) + +``` +useDeleteAccount()(accountId) + -> db.rpc('soft_delete_account', { p_account_id: id }) + -> broadcast ACCOUNT_UPDATED with state='deleted' + -> client: accountCache.remove(id) + -> account gone from all UI +``` + +### New offer after prior expiry + +``` +User receives new offer token for same mint + -> INSERT (state defaults to 'active') + -> unique index only covers WHERE state='active' + -> no conflict with expired/deleted account + -> new active account created +``` + +## Implementation Phases + +### Phase 1: DB Migration +- [ ] Write migration file +- [ ] Ask user to apply +- [ ] Run `bun run db:generate-types` + +### Phase 2: Types and Repository +- [ ] Add `AccountState` type and `state` field to `account.ts` +- [ ] Map `data.state` in `AccountRepository.toAccount()` +- [ ] Add `AccountRepository.deleteAccount(id)` calling RPC +- [ ] Add `AccountsCache.remove(id)` +- [ ] Update `ACCOUNT_UPDATED` handler for deleted state +- [ ] Add `useDeleteAccount` hook +- [ ] Run `bun run fix:all` + +### Phase 3: UI +- [ ] Update `useActiveOffers()` to filter by `state === 'active'` +- [ ] Run `bun run fix:all` + +## Open Questions + +- **Delete UI placement**: The hook is specced; UX (which screen, what confirmation) is a separate decision. +- **Expired balance recovery**: Proofs may still be swappable depending on mint's keyset expiry enforcement. Separate feature. +- **Offer re-use on receive**: When a user receives a new offer token for a mint that already has an `active` offer account, existing behavior routes proofs to the existing account. Unchanged by this migration. From 237f0219befb9859992f9b63cfd4e73fec4f57d3 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Thu, 26 Mar 2026 01:25:50 -0700 Subject: [PATCH 05/11] Remove v2 keyset plans and specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No longer needed — account state lifecycle replaces this approach. --- .../2026-03-20-cashu-v2-keyset-support.md | 696 ------------------ ...26-03-20-cashu-v2-keyset-support-design.md | 214 ------ 2 files changed, 910 deletions(-) delete mode 100644 docs/superpowers/plans/2026-03-20-cashu-v2-keyset-support.md delete mode 100644 docs/superpowers/specs/2026-03-20-cashu-v2-keyset-support-design.md diff --git a/docs/superpowers/plans/2026-03-20-cashu-v2-keyset-support.md b/docs/superpowers/plans/2026-03-20-cashu-v2-keyset-support.md deleted file mode 100644 index 392efb989..000000000 --- a/docs/superpowers/plans/2026-03-20-cashu-v2-keyset-support.md +++ /dev/null @@ -1,696 +0,0 @@ -# Cashu V2 Keyset ID Support — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Fix token decoding to support v2 keyset IDs (NUT-02) so users can paste, receive, and reclaim tokens from mints with v2 keysets. - -**Architecture:** Add v2-aware decode functions to `lib/cashu/token.ts` using dependency injection for keyset resolution. Paste/scan handlers pass raw token strings instead of decode-then-re-encode. Route clientLoaders use cache-first async decode. See `docs/superpowers/specs/2026-03-20-cashu-v2-keyset-support-design.md` for full spec. - -**Tech Stack:** cashu-ts v3.6.1 (`getDecodedToken`, `getTokenMetadata`), TanStack Query v5 (keyset caching), React Router v7 (clientLoaders) - ---- - -### Task 1: Add `extractCashuTokenString` and update `extractCashuToken` - -**Files:** -- Modify: `app/lib/cashu/token.ts` -- Create: `app/lib/cashu/token.test.ts` - -- [ ] **Step 1: Write failing tests for the new token functions** - -Create `app/lib/cashu/token.test.ts`. We need real v1 tokens to test with. Generate them inline using cashu-ts, and mock v2 behavior via the keyset resolver. - -```typescript -import { describe, expect, test } from 'bun:test'; -import { - type Token, - getDecodedToken, - getEncodedToken, - getTokenMetadata, -} from '@cashu/cashu-ts'; -import { - extractCashuToken, - extractCashuTokenAsync, - extractCashuTokenString, -} from './token'; - -// A real v1 cashuA token (v1 keyset ID starts with "00") -// Generate one by encoding a minimal token: -const V1_TOKEN: Token = { - mint: 'https://mint.example.com', - proofs: [ - { - id: '009a1f293253e41e', - amount: 1, - secret: 'test-secret-1', - C: '02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904', - }, - ], - unit: 'sat', -}; - -const V1_ENCODED_A = getEncodedToken(V1_TOKEN, { version: 3 }); -const V1_ENCODED_B = getEncodedToken(V1_TOKEN, { version: 4 }); - -describe('extractCashuTokenString', () => { - test('extracts a valid cashuA token string from content', () => { - const result = extractCashuTokenString(`check this out: ${V1_ENCODED_A}`); - expect(result).toBe(V1_ENCODED_A); - }); - - test('extracts a valid cashuB token string from content', () => { - const result = extractCashuTokenString(`here: ${V1_ENCODED_B}`); - expect(result).toBe(V1_ENCODED_B); - }); - - test('returns null for content with no token', () => { - expect(extractCashuTokenString('hello world')).toBeNull(); - }); - - test('returns null for malformed token that matches regex but fails metadata parse', () => { - expect(extractCashuTokenString('cashuBinvaliddata')).toBeNull(); - }); - - test('extracts token from URL with hash', () => { - const result = extractCashuTokenString(`#${V1_ENCODED_B}`); - expect(result).toBe(V1_ENCODED_B); - }); -}); - -describe('extractCashuToken', () => { - test('decodes a v1 cashuA token without a resolver', () => { - const token = extractCashuToken(V1_ENCODED_A); - expect(token).not.toBeNull(); - expect(token!.mint).toBe('https://mint.example.com'); - expect(token!.proofs[0].id).toBe('009a1f293253e41e'); - }); - - test('decodes a v1 cashuB token without a resolver', () => { - const token = extractCashuToken(V1_ENCODED_B); - expect(token).not.toBeNull(); - expect(token!.mint).toBe('https://mint.example.com'); - }); - - test('returns null for invalid content', () => { - expect(extractCashuToken('not a token')).toBeNull(); - }); - - test('does not call resolver for v1 tokens', () => { - const calls: string[] = []; - const resolver = (mintUrl: string) => { - calls.push(mintUrl); - return undefined; - }; - extractCashuToken(V1_ENCODED_A, resolver); - expect(calls).toHaveLength(0); - }); -}); - -// V2 keyset ID tests — exercise the resolver fallback path. -// We construct tokens with a fake v2 keyset ID (prefix "01", 66 hex chars). -// getDecodedToken(token) will fail for these, triggering the resolver. -const V2_KEYSET_ID = '01' + 'a'.repeat(64); // 66 chars, v2 format - -const V2_TOKEN: Token = { - mint: 'https://v2mint.example.com', - proofs: [ - { - id: V2_KEYSET_ID, - amount: 1, - secret: 'test-secret-v2', - C: '02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904', - }, - ], - unit: 'sat', -}; - -// cashuA preserves full keyset IDs in the JSON -const V2_ENCODED_A = getEncodedToken(V2_TOKEN, { version: 3 }); -// cashuB truncates v2 keyset IDs to 16 chars (short ID) -const V2_ENCODED_B = getEncodedToken(V2_TOKEN, { version: 4 }); - -describe('extractCashuToken with v2 keyset IDs', () => { - test('returns null for v2 token without resolver', () => { - expect(extractCashuToken(V2_ENCODED_A)).toBeNull(); - }); - - test('decodes v2 cashuA token with resolver providing full keyset ID', () => { - const resolver = (mintUrl: string) => { - expect(mintUrl).toBe('https://v2mint.example.com'); - return [V2_KEYSET_ID]; - }; - - const token = extractCashuToken(V2_ENCODED_A, resolver); - expect(token).not.toBeNull(); - expect(token!.mint).toBe('https://v2mint.example.com'); - expect(token!.proofs[0].id).toBe(V2_KEYSET_ID); - }); - - test('decodes v2 cashuB token (short ID) with resolver', () => { - const resolver = (_mintUrl: string) => [V2_KEYSET_ID]; - - const token = extractCashuToken(V2_ENCODED_B, resolver); - expect(token).not.toBeNull(); - expect(token!.proofs[0].id).toBe(V2_KEYSET_ID); // resolved to full ID - }); - - test('returns null when resolver returns no matching keysets', () => { - const resolver = (_mintUrl: string) => ['00deadbeefcafe00']; // wrong keyset - expect(extractCashuToken(V2_ENCODED_A, resolver)).toBeNull(); - }); -}); - -describe('extractCashuToken v2 round-trip', () => { - test('decode v2 → encode (truncates) → decode with resolver → same token', () => { - // Decode the original v2 cashuA token (full IDs) - const resolver = (_mintUrl: string) => [V2_KEYSET_ID]; - const original = extractCashuToken(V2_ENCODED_A, resolver); - expect(original).not.toBeNull(); - - // Re-encode: getEncodedToken truncates v2 IDs to 16 chars (cashuB format) - const reEncoded = getEncodedToken(original!); - - // Decode the re-encoded token — needs resolver to resolve short IDs - const roundTripped = extractCashuToken(reEncoded, resolver); - expect(roundTripped).not.toBeNull(); - expect(roundTripped!.mint).toBe(original!.mint); - expect(roundTripped!.proofs[0].id).toBe(V2_KEYSET_ID); - expect(roundTripped!.proofs[0].amount).toBe(original!.proofs[0].amount); - }); -}); - -describe('extractCashuTokenAsync', () => { - test('decodes a v1 token without fetching', async () => { - const fetcher = async (_mintUrl: string) => { - throw new Error('should not be called for v1'); - }; - const token = await extractCashuTokenAsync(V1_ENCODED_A, fetcher); - expect(token).not.toBeNull(); - expect(token!.mint).toBe('https://mint.example.com'); - }); - - test('decodes a v2 token by fetching keyset IDs', async () => { - const fetcher = async (mintUrl: string) => { - expect(mintUrl).toBe('https://v2mint.example.com'); - return [V2_KEYSET_ID]; - }; - const token = await extractCashuTokenAsync(V2_ENCODED_B, fetcher); - expect(token).not.toBeNull(); - expect(token!.proofs[0].id).toBe(V2_KEYSET_ID); - }); - - test('returns null for invalid content', async () => { - const fetcher = async (_mintUrl: string) => []; - const token = await extractCashuTokenAsync('not a token', fetcher); - expect(token).toBeNull(); - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `bun test app/lib/cashu/token.test.ts` -Expected: FAIL — `extractCashuTokenString` and `extractCashuTokenAsync` are not exported from `./token`. - -- [ ] **Step 3: Implement the token functions** - -Replace the contents of `app/lib/cashu/token.ts` with: - -```typescript -import { - CheckStateEnum, - type Proof, - type Token, - Wallet, - getDecodedToken, - getTokenMetadata, -} from '@cashu/cashu-ts'; -import { proofToY } from './proof'; - -/** - * A token consists of a set of proofs, and each proof can be in one of three states: - * spent, pending, or unspent. When claiming a token, all that we care about is the unspent proofs. - * The rest of the proofs will not be claimable. - * - * This function returns the set of proofs that are unspent - * @param token - The token to get the unspent proofs from - * @returns The set of unspent proofs - */ -export const getUnspentProofsFromToken = async ( - token: Token, -): Promise => { - const wallet = new Wallet(token.mint, { - unit: token.unit, - }); - const states = await wallet.checkProofsStates(token.proofs); - - return token.proofs.filter((proof) => { - const Y = proofToY(proof); - const state = states.find((s) => s.Y === Y); - return state?.state === CheckStateEnum.UNSPENT; - }); -}; - -const TOKEN_REGEX = /cashu[AB][A-Za-z0-9_-]+={0,2}/; - -/** - * Extract and validate a cashu token string from arbitrary content. - * Uses regex to find the token, then getTokenMetadata() to validate it's structurally valid. - * Returns the raw encoded string without full decoding (no keyset resolution). - * @param content - The content to search for a cashu token (URL, clipboard text, etc.) - * @returns The encoded token string if found and valid, otherwise null. - */ -export function extractCashuTokenString(content: string): string | null { - const tokenMatch = content.match(TOKEN_REGEX); - if (!tokenMatch) return null; - - try { - getTokenMetadata(tokenMatch[0]); - return tokenMatch[0]; - } catch { - return null; - } -} - -/** - * Extract and decode a cashu token from arbitrary content. - * Supports both v1 and v2 keyset IDs. For v2, an optional keyset resolver is used - * to map short keyset IDs to full IDs (cashu.me pattern: try without, fall back with). - * - * @param content - The content to extract the encoded cashu token from. - * @param getKeysetIds - Optional sync resolver: given a mint URL, returns keyset IDs from cache. - * Used to resolve v2 short keyset IDs. If not provided, only v1 tokens can be decoded. - * @returns The decoded token if found and valid, otherwise null. - */ -export function extractCashuToken( - content: string, - getKeysetIds?: (mintUrl: string) => string[] | undefined, -): Token | null { - const tokenString = extractCashuTokenString(content); - if (!tokenString) return null; - - // Try standard decode — succeeds for v1 keyset IDs - try { - return getDecodedToken(tokenString); - } catch { - // V2 keyset IDs require resolution — fall through - } - - // V2 fallback: get mint URL from metadata, resolve keyset IDs, retry - if (!getKeysetIds) return null; - - try { - const { mint } = getTokenMetadata(tokenString); - const keysetIds = getKeysetIds(mint); - if (!keysetIds?.length) return null; - return getDecodedToken(tokenString, keysetIds); - } catch { - return null; - } -} - -/** - * Async variant of extractCashuToken with network fallback. - * Tries standard decode first (v1), then fetches keyset IDs from the mint for v2 resolution. - * - * @param content - The content to extract the encoded cashu token from. - * @param fetchKeysetIds - Async resolver: given a mint URL, fetches keyset IDs (cache-first via TanStack Query). - * @returns The decoded token if found and valid, otherwise null. - */ -export async function extractCashuTokenAsync( - content: string, - fetchKeysetIds: (mintUrl: string) => Promise, -): Promise { - const tokenString = extractCashuTokenString(content); - if (!tokenString) return null; - - // Try standard decode — succeeds for v1 keyset IDs - try { - return getDecodedToken(tokenString); - } catch { - // V2 keyset IDs require resolution — fall through - } - - // V2 fallback: get mint URL from metadata, fetch keyset IDs, retry - try { - const { mint } = getTokenMetadata(tokenString); - const keysetIds = await fetchKeysetIds(mint); - if (!keysetIds.length) return null; - return getDecodedToken(tokenString, keysetIds); - } catch { - return null; - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `bun test app/lib/cashu/token.test.ts` -Expected: All tests PASS. - -- [ ] **Step 5: Run typecheck** - -Run: `bun run fix:all` -Expected: No type errors related to token.ts changes. - -- [ ] **Step 6: Commit** - -```bash -git add app/lib/cashu/token.ts app/lib/cashu/token.test.ts -git commit -m "feat: add v2 keyset support to token extraction functions - -Add extractCashuTokenString (validates via getTokenMetadata) and -extractCashuTokenAsync (network fallback). Update extractCashuToken -to accept optional keyset resolver for v2 short ID resolution." -``` - ---- - -### Task 2: Add keyset resolver factory - -**Files:** -- Modify: `app/features/shared/cashu.ts` - -- [ ] **Step 1: Add `createKeysetIdsResolver` to `app/features/shared/cashu.ts`** - -Add this function after the existing `allMintKeysetsQueryOptions` definition (around line 207). Import `GetKeysetsResponse` from cashu-ts if not already imported. - -```typescript -/** - * Creates keyset ID resolver functions for v2 token decoding. - * The sync resolver reads from TanStack Query cache (no network). - * The async resolver uses fetchQuery (returns cached if fresh, fetches if stale/missing). - */ -export function createKeysetIdsResolver(queryClient: QueryClient) { - return { - fromCache: (mintUrl: string): string[] | undefined => { - const data = queryClient.getQueryData( - allMintKeysetsQueryKey(mintUrl), - ); - return data?.keysets.map((k) => k.id); - }, - fromNetwork: async (mintUrl: string): Promise => { - const data = await queryClient.fetchQuery( - allMintKeysetsQueryOptions(mintUrl), - ); - return data.keysets.map((k) => k.id); - }, - }; -} -``` - -Verify that `GetKeysetsResponse` is already imported from cashu-ts (it's at line 6 of the existing imports). No new import needed. - -- [ ] **Step 2: Run typecheck** - -Run: `bun run fix:all` -Expected: No errors. - -- [ ] **Step 3: Commit** - -```bash -git add app/features/shared/cashu.ts -git commit -m "feat: add createKeysetIdsResolver for v2 token decode - -Bridges lib/cashu (token decode) and features (TanStack Query cache). -Sync resolver reads cached keysets, async resolver fetches from mint." -``` - ---- - -### Task 3: Update paste/scan handlers to use `extractCashuTokenString` - -**Files:** -- Modify: `app/features/receive/receive-input.tsx` -- Modify: `app/features/receive/receive-scanner.tsx` -- Modify: `app/features/transfer/transfer-input.tsx` -- Modify: `app/features/transfer/transfer-scanner.tsx` - -All four files follow the same pattern: replace `extractCashuToken` + `getEncodedToken` with `extractCashuTokenString`. - -- [ ] **Step 1: Update `app/features/receive/receive-input.tsx`** - -Change the import at line 25 from: -```typescript -import { extractCashuToken } from '~/lib/cashu'; -``` -to: -```typescript -import { extractCashuTokenString } from '~/lib/cashu'; -``` - -Remove the `getEncodedToken` import from `@cashu/cashu-ts` at line 1 (it's no longer needed in this file). - -Replace the `handlePaste` body (lines 88-118) with: - -```typescript - const handlePaste = async () => { - const clipboardContent = await readClipboard(); - if (!clipboardContent) { - return; - } - - const tokenString = extractCashuTokenString(clipboardContent); - if (!tokenString) { - toast({ - title: 'Invalid input', - description: 'Please paste a valid cashu token', - variant: 'destructive', - }); - return; - } - - const hash = `#${tokenString}`; - - // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it - // See https://github.com/remix-run/remix/discussions/10721 - window.history.replaceState(null, '', hash); - navigate( - { - ...buildLinkWithSearchParams('/receive/cashu/token', { - selectedAccountId: receiveAccountId, - }), - hash, - }, - { transition: 'slideLeft', applyTo: 'newView' }, - ); - }; -``` - -- [ ] **Step 2: Update `app/features/receive/receive-scanner.tsx`** - -Change the import at line 11 from: -```typescript -import { extractCashuToken } from '~/lib/cashu'; -``` -to: -```typescript -import { extractCashuTokenString } from '~/lib/cashu'; -``` - -Remove the `getEncodedToken` import from `@cashu/cashu-ts` at line 1. - -Replace the `onDecode` callback body (lines 33-58) with: - -```typescript - onDecode={(scannedContent) => { - const tokenString = extractCashuTokenString(scannedContent); - if (!tokenString) { - toast({ - title: 'Invalid input', - description: 'Please scan a valid cashu token', - variant: 'destructive', - }); - return; - } - - const hash = `#${tokenString}`; - - // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it - // See https://github.com/remix-run/remix/discussions/10721 - window.history.replaceState(null, '', hash); - navigate( - { - ...buildLinkWithSearchParams('/receive/cashu/token', { - selectedAccountId: receiveAccountId, - }), - hash, - }, - { transition: 'slideLeft', applyTo: 'newView' }, - ); - }} -``` - -- [ ] **Step 3: Update `app/features/transfer/transfer-input.tsx`** - -Same pattern. Change import at line 23 from `extractCashuToken` to `extractCashuTokenString`. Remove `getEncodedToken` import from `@cashu/cashu-ts` at line 1. - -In the `handlePaste` function (around line 107-135), replace: -```typescript - const token = extractCashuToken(clipboardContent); - if (!token) { -``` -with: -```typescript - const tokenString = extractCashuTokenString(clipboardContent); - if (!tokenString) { -``` - -And replace: -```typescript - const encodedToken = getEncodedToken(token); - const hash = `#${encodedToken}`; -``` -with: -```typescript - const hash = `#${tokenString}`; -``` - -- [ ] **Step 4: Update `app/features/transfer/transfer-scanner.tsx`** - -Same pattern. Change import at line 11 from `extractCashuToken` to `extractCashuTokenString`. Remove `getEncodedToken` import from `@cashu/cashu-ts` at line 1. - -Replace lines 34-42: -```typescript - const token = extractCashuToken(scannedContent); - if (!token) { -``` -with: -```typescript - const tokenString = extractCashuTokenString(scannedContent); - if (!tokenString) { -``` - -Replace lines 44-45: -```typescript - const encodedToken = getEncodedToken(token); - const hash = `#${encodedToken}`; -``` -with: -```typescript - const hash = `#${tokenString}`; -``` - -- [ ] **Step 5: Run typecheck** - -Run: `bun run fix:all` -Expected: No errors. Verify that `getEncodedToken` import is removed from all four files and no unused imports remain. - -- [ ] **Step 6: Commit** - -```bash -git add app/features/receive/receive-input.tsx app/features/receive/receive-scanner.tsx app/features/transfer/transfer-input.tsx app/features/transfer/transfer-scanner.tsx -git commit -m "refactor: pass raw token strings in paste/scan handlers - -Replace extractCashuToken + getEncodedToken with extractCashuTokenString. -Avoids lossy decode-then-re-encode cycle that truncates v2 keyset IDs. -Token validation preserved via getTokenMetadata in extractCashuTokenString." -``` - ---- - -### Task 4: Update route clientLoaders with v2-aware decode - -**Files:** -- Modify: `app/routes/_protected.receive.cashu_.token.tsx` -- Modify: `app/routes/_public.receive-cashu-token.tsx` - -- [ ] **Step 1: Update protected route clientLoader** - -In `app/routes/_protected.receive.cashu_.token.tsx`: - -Change the import at line 33 from: -```typescript -import { extractCashuToken } from '~/lib/cashu'; -``` -to: -```typescript -import { extractCashuToken, extractCashuTokenAsync } from '~/lib/cashu'; -``` - -Add import for the resolver factory: -```typescript -import { createKeysetIdsResolver } from '~/features/shared/cashu'; -``` - -Also import `getQueryClient` if not already imported (it is imported on line 27 via `~/features/shared/query-client`). - -Replace lines 108-114 (the token extraction in clientLoader) with: - -```typescript -export async function clientLoader({ request }: Route.ClientLoaderArgs) { - const queryClient = getQueryClient(); - const resolver = createKeysetIdsResolver(queryClient); - - // Request url doesn't include hash so we need to read it from the window location instead - let token = extractCashuToken(window.location.hash, resolver.fromCache); - - if (!token) { - token = await extractCashuTokenAsync( - window.location.hash, - resolver.fromNetwork, - ); - } - - if (!token) { - throw redirect('/receive'); - } -``` - -The rest of the function (lines 116 onward) stays the same. - -- [ ] **Step 2: Update public route clientLoader** - -In `app/routes/_public.receive-cashu-token.tsx`: - -Change the import at line 7 from: -```typescript -import { extractCashuToken } from '~/lib/cashu'; -``` -to: -```typescript -import { extractCashuToken, extractCashuTokenAsync } from '~/lib/cashu'; -``` - -Add import: -```typescript -import { createKeysetIdsResolver } from '~/features/shared/cashu'; -``` - -Replace lines 23-27 (the token extraction) with: - -```typescript - const resolver = createKeysetIdsResolver(queryClient); - - let token = extractCashuToken(hash, resolver.fromCache); - - if (!token) { - token = await extractCashuTokenAsync(hash, resolver.fromNetwork); - } - - if (!token) { - throw redirect('/home'); - } -``` - -Note: `queryClient` is already available from line 14. - -- [ ] **Step 3: Run typecheck** - -Run: `bun run fix:all` -Expected: No errors. - -- [ ] **Step 4: Run all tests** - -Run: `bun test` -Expected: All tests pass, including the new token tests from Task 1. - -- [ ] **Step 5: Commit** - -```bash -git add app/routes/_protected.receive.cashu_.token.tsx app/routes/_public.receive-cashu-token.tsx -git commit -m "feat: use v2-aware token decode in route clientLoaders - -Try sync cache-first decode, fall back to async network fetch for -unknown mints. Completes v2 keyset ID support for receive flows." -``` diff --git a/docs/superpowers/specs/2026-03-20-cashu-v2-keyset-support-design.md b/docs/superpowers/specs/2026-03-20-cashu-v2-keyset-support-design.md deleted file mode 100644 index 5e0cd330e..000000000 --- a/docs/superpowers/specs/2026-03-20-cashu-v2-keyset-support-design.md +++ /dev/null @@ -1,214 +0,0 @@ -# Cashu V2 Keyset ID Support - -## Problem - -Two bugs when interacting with mints that use v2 keysets (NUT-02, `01`-prefixed IDs): - -1. **Pasting a v2 token shows "invalid"** — `getDecodedToken()` from cashu-ts requires keyset IDs to resolve v2 keyset IDs. Our code calls it without keyset IDs, so it always fails for v2 tokens (both cashuA and cashuB formats). - -2. **Reclaiming a token fails with "Could not get fee. No keyset found"** — `getEncodedToken()` truncates v2 keyset IDs from 66 to 16 hex chars for v4 token encoding. When the token is later decoded without keyset resolution, proofs retain the short 16-char IDs. The wallet's KeyChain stores keysets by full 66-char IDs, so `getFeesForProofs()` fails on exact-match lookup. - -## Background - -### V1 vs V2 Keyset IDs - -| Property | V1 (`00` prefix) | V2 (`01` prefix) | -|----------|-------------------|-------------------| -| Length | 16 hex chars (8 bytes) | 66 hex chars (33 bytes) | -| Derivation | SHA-256 of concatenated pubkeys, truncated to 7 bytes | SHA-256 of `{amount}:{pubkey}` pairs + unit + fee + expiry, full hash | -| Token encoding | Stored as-is (already short) | Truncated to 16 chars (short ID) in v4 tokens | - -### How cashu-ts Decodes Tokens - -`getDecodedToken(token, keysetIds?)` internally: -1. Strips prefix, decodes base64url/CBOR → Token with proofs -2. Runs `mapShortKeysetIds(proofs, keysetIds)` which: - - V1 IDs (`0x00` first byte): passes through unchanged - - V2 IDs (`0x01` first byte): prefix-matches against provided keyset IDs - - Rejects ambiguous matches (multiple full IDs match same short ID) - - Throws if no keyset IDs provided or no match found - -This resolution step runs for ALL v2 IDs — even full-length 66-char IDs in cashuA tokens. Without keyset IDs, any token with v2 keysets fails. - -### cashu-ts Utility: `getTokenMetadata(token)` - -Exported function that decodes a token WITHOUT keyset resolution. Returns `{ mint, unit, amount, memo?, incompleteProofs }` where proofs lack the `id` field. Safe to call without any keyset knowledge. Used to extract the mint URL for cache lookup. - -### Security of Short ID Resolution - -- Keyset IDs are SHA-256 derived from public keys + metadata — unforgeable -- `mapShortKeysetIds` rejects ambiguous matches (spec requirement from NUT-02) -- Resolution uses keysets from the token's declared mint only -- The mint validates all operations server-side regardless - -### Reference: cashu.me Implementation - -cashu.me (PR #470) uses a two-tier decode: -- `decode()` — sync, uses `getTokenMetadata()` for UI preview (no resolution) -- `decodeFull()` — async, tries cached keyset IDs first, falls back to network fetch - -## Design - -### Approach: Fix at Decode Time with Dependency Injection - -Keep all decode logic in `lib/cashu/token.ts` (which can only import from `@cashu/cashu-ts`). Inject keyset resolution as a callback to respect the import hierarchy (`lib` → `features` → `routes`). - -### 1. Token Extraction Functions (`app/lib/cashu/token.ts`) - -Three exported functions: - -**`extractCashuTokenString(content: string): string | null`** - -Extracts and validates a cashu token string from arbitrary content. Uses regex to find the token, then `getTokenMetadata()` to validate it's a structurally valid token (not just a regex match). Returns the raw encoded token string without full decoding. Used by paste/scan handlers to navigate with the original token string, avoiding the lossy decode-then-re-encode cycle. - -```typescript -export function extractCashuTokenString(content: string): string | null { - const tokenMatch = content.match(/cashu[AB][A-Za-z0-9_-]+={0,2}/); - if (!tokenMatch) return null; - - try { - getTokenMetadata(tokenMatch[0]); // validates token structure - return tokenMatch[0]; - } catch { - return null; - } -} -``` - -**`extractCashuToken(content: string, getKeysetIds?: (mintUrl: string) => string[] | undefined): Token | null`** - -Synchronous v2-aware decode with optional keyset resolver. Follows the cashu.me pattern: try standard decode first, fall back to keyset-resolved decode on failure. - -Flow: -1. Extract and validate token string via `extractCashuTokenString` -2. Try `getDecodedToken(tokenString)` — succeeds for v1 keysets -3. If fails (v2 keyset), use `getTokenMetadata(tokenString)` to get mint URL without keyset resolution -4. Call injected `getKeysetIds(mintUrl)` to get cached keyset IDs -5. Decode with `getDecodedToken(tokenString, keysetIds)` for v2 resolution - -If no resolver is provided or cache misses, returns null. - -**`extractCashuTokenAsync(content: string, fetchKeysetIds: (mintUrl: string) => Promise): Promise`** - -Async variant with network fallback. Same flow but the injected resolver can fetch from the network. Used when the sync version returns null (unknown mint, cache miss). - -### 2. Keyset Resolver Factory (`app/features/shared/cashu.ts`) - -Provides the resolver functions that bridge `lib` and `features`: - -```typescript -export function createKeysetIdsResolver(queryClient: QueryClient) { - return { - fromCache: (mintUrl: string): string[] | undefined => { - const data = queryClient.getQueryData( - allMintKeysetsQueryKey(mintUrl), - ); - return data?.keysets.map((k) => k.id); - }, - fromNetwork: async (mintUrl: string): Promise => { - const data = await queryClient.fetchQuery( - allMintKeysetsQueryOptions(mintUrl), - ); - return data.keysets.map(k => k.id); - }, - }; -} -``` - -`fromCache` reads synchronously from the TanStack Query cache. Keysets are already cached from wallet initialization (`getInitializedCashuWallet` calls `allMintKeysetsQueryOptions` with 1-hour staleTime). - -`fromNetwork` uses `fetchQuery` which returns cached data if fresh, or fetches from the mint if stale/missing. - -### 3. Paste/Scan Handlers - -Four call sites: `receive-input.tsx`, `receive-scanner.tsx`, `transfer-input.tsx`, `transfer-scanner.tsx`. - -Current flow (broken for v2): -``` -extractCashuToken(content) → getEncodedToken(token) → navigate with hash -``` - -The re-encoding step is lossy: `getEncodedToken` truncates v2 keyset IDs to 16 chars. When the destination route decodes the hash, it faces the same v2 resolution problem. - -New flow: -``` -extractCashuTokenString(content) → navigate with raw string -``` - -No decoding or re-encoding. The raw token string preserves the original keyset ID format. Validation (is this actually a cashu token?) now happens in the destination route's clientLoader where async decode is available. - -The regex `/cashu[AB][A-Za-z0-9_-]+={0,2}/` is specific enough that false positives are negligible. - -### 4. Route ClientLoaders - -Two call sites: `_protected.receive.cashu_.token.tsx`, `_public.receive-cashu-token.tsx`. - -Both are already async (`clientLoader` is an async function). New flow: - -```typescript -const queryClient = getQueryClient(); -const resolver = createKeysetIdsResolver(queryClient); - -// Try sync (cache hit for known mints) -let token = extractCashuToken(hash, resolver.fromCache); - -// Fall back to async (network fetch for unknown mints) -if (!token) { - token = await extractCashuTokenAsync(hash, resolver.fromNetwork); -} - -if (!token) { - throw redirect('/receive'); -} -``` - -For the user's own mints (reclaim flow), keysets are always cached — no network request. For tokens from unknown mints, the network fetch is unavoidable but happens only once (then cached for 1 hour). - -### 5. No Changes to Downstream Operations - -If tokens are decoded properly at the entry points (paste/scan/route), proofs carry full v2 keyset IDs (66 chars). All downstream code — `getFeesForProofs`, `wallet.ops.receive()`, `wallet.getKeyset()` — works as-is because the KeyChain stores keysets by full ID. - -No `resolveTokenKeysetIds` safety net is needed since no v2 tokens are in production yet. - -### 6. Re-encoding is Intentional, Not a Bug - -Several places in the app call `getEncodedToken(token)` which truncates v2 keyset IDs to 16 chars: -- `receive-cashu-token.tsx` — copy-to-clipboard and "Log In and Claim" redirect link -- `share-cashu-token.tsx` — shareable link and QR code -- `getTokenHash` — token deduplication - -This truncation is correct v4 encoding behavior (short IDs for size). It is NOT a problem because every re-encoded token eventually goes through a decode step that uses our v2-aware decode with keyset resolution: -- Login redirect → protected route clientLoader → v2-aware decode -- Shared token → recipient pastes → navigate → clientLoader → v2-aware decode -- `getTokenHash` → `getEncodedToken` normalizes deterministically (same Token always produces same encoding) - -### 7. Public Route Always Takes Async Path - -The public route (`_public.receive-cashu-token.tsx`) runs before the user logs in, so `getInitializedCashuWallet` has never been called and the TanStack Query cache has no keyset data. For v2 tokens, `resolver.fromCache` will miss and `resolver.fromNetwork` will fetch keysets from the mint. This adds one network request to the public receive flow — acceptable since the receive UI already makes network calls (proof state checks, mint info). - -### 8. Paste/Scan Validation Preserved - -`extractCashuTokenString` validates tokens via `getTokenMetadata()` — not just regex. This catches malformed tokens immediately in paste/scan handlers (same UX as today: instant toast for invalid input). Only structurally valid tokens trigger navigation. Full v2 keyset resolution then happens in the destination route's async clientLoader. - -## Files Changed - -| File | Change | -|------|--------| -| `app/lib/cashu/token.ts` | Add `extractCashuTokenString`, `extractCashuTokenAsync`. Modify `extractCashuToken` to accept keyset resolver. Import `getTokenMetadata` from cashu-ts. | -| `app/features/shared/cashu.ts` | Add `createKeysetIdsResolver` factory function. | -| `app/routes/_protected.receive.cashu_.token.tsx` | Use async decode with resolver in clientLoader. | -| `app/routes/_public.receive-cashu-token.tsx` | Use async decode with resolver in clientLoader. | -| `app/features/receive/receive-input.tsx` | Use `extractCashuTokenString` in paste handler. Remove `getEncodedToken` re-encoding. | -| `app/features/receive/receive-scanner.tsx` | Use `extractCashuTokenString` in scan callback. Remove `getEncodedToken` re-encoding. | -| `app/features/transfer/transfer-input.tsx` | Use `extractCashuTokenString` in paste handler. Remove `getEncodedToken` re-encoding. | -| `app/features/transfer/transfer-scanner.tsx` | Use `extractCashuTokenString` in scan callback. Remove `getEncodedToken` re-encoding. | - -## Testing - -- Unit test `extractCashuToken` with v1 and v2 tokens (both cashuA and cashuB formats) -- Unit test `extractCashuTokenString` extracts token from various content formats -- Unit test keyset resolver returns correct IDs from mock cache -- Manual test: paste a v2 cashuB token → should decode and show receive UI -- Manual test: create and reclaim a token from a v2 keyset mint → should swap successfully -- Manual test: receive a token from an unknown v2 mint → should fetch keysets and decode -- Unit test round-trip: decode v2 token → `getEncodedToken` (truncates IDs) → decode again with resolver → should produce identical Token From f2a5fbf3a5c5bfff1d0a3b146b99e8911e0d515c Mon Sep 17 00:00:00 2001 From: gudnuf Date: Thu, 26 Mar 2026 01:25:55 -0700 Subject: [PATCH 06/11] Add eager expiry on user assertion Client-side expiry in upsert_user_with_accounts transitions stale accounts before returning them. pg_cron remains as background cleanup for offline users. --- docs/plans/account-state-lifecycle.md | 39 ++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/docs/plans/account-state-lifecycle.md b/docs/plans/account-state-lifecycle.md index 80cd26187..a73f45483 100644 --- a/docs/plans/account-state-lifecycle.md +++ b/docs/plans/account-state-lifecycle.md @@ -20,11 +20,11 @@ Expired accounts remain visible in `getAll()` -- the RLS policy does not hide th ### 3. Auto-expiry mechanism -**Decision: pg_cron, hourly.** +**Decision: Two layers — eager on user assertion, pg_cron as background cleanup.** -A cron job updates `state = 'expired'` for accounts where `state = 'active'` and `expires_at <= now()`. This UPDATE fires `broadcast_accounts_changes_trigger`, emitting `ACCOUNT_UPDATED` to connected clients. +**Eager (on login):** `upsert_user_with_accounts` expires stale accounts before returning them. When a user opens the app, any account with `state = 'active'` and `expires_at <= now()` is transitioned to `expired` within the same transaction. The client gets correct state on first load — no stale-then-update flicker. -pg_cron is already installed and used for 8 daily cleanup jobs. No new infrastructure needed. Hourly frequency because expiry visibility matters within an hour. The client-side filter hides visually expired offers immediately; the DB catches up within an hour. +**Background (pg_cron, hourly):** A cron job catches accounts for users who haven't opened the app. This keeps the DB consistent for realtime broadcasts and prevents stale `active` accounts from accumulating. pg_cron is already installed and used for 8 daily cleanup jobs — no new infrastructure. ### 4. Soft delete @@ -148,6 +148,23 @@ end; $function$; ``` +### Eager expiry in upsert_user_with_accounts + +Add an UPDATE before the account fetch in `upsert_user_with_accounts` to transition stale accounts: + +```sql +-- Expire stale accounts before returning them to the client +update wallet.accounts +set state = 'expired', version = version + 1 +where + user_id = p_user_id + and state = 'active' + and (details ->> 'expires_at') is not null + and (details ->> 'expires_at')::timestamptz <= now(); +``` + +This runs inside the existing transaction, before the `accounts_with_proofs` CTE that fetches accounts. The client receives already-expired accounts with `state = 'expired'` — no second round-trip needed. + ### pg_cron job for auto-expiry ```sql @@ -203,12 +220,22 @@ function useActiveOffers() { ## Data Flow -### active -> expired (automatic, hourly) +### active -> expired (on login, eager) + +``` +User opens app + -> upsert_user_with_accounts(...) + -> UPDATE stale accounts to state='expired', version+1 (same transaction) + -> accounts returned already have state='expired' + -> client renders correct state immediately, no flicker +``` + +### active -> expired (background cleanup, hourly) ``` -pg_cron -> UPDATE state='expired', version+1 +pg_cron -> UPDATE state='expired', version+1 (for users who haven't logged in) -> broadcast_accounts_changes_trigger fires - -> realtime ACCOUNT_UPDATED to client + -> realtime ACCOUNT_UPDATED to connected clients -> accountCache.update(account) [version higher, accepted] -> useActiveOffers() re-renders, filtered by state === 'active' ``` From 7ab30e41fb7a6400f395566876dd2a9ef3bf7e4b Mon Sep 17 00:00:00 2001 From: gudnuf Date: Thu, 26 Mar 2026 01:34:24 -0700 Subject: [PATCH 07/11] Drop state transition trigger, enforce by construction Each DB function's WHERE clause only matches valid source states. soft_delete_account gains AND state != 'deleted' guard. --- docs/plans/account-state-lifecycle.md | 40 ++------------------------- 1 file changed, 3 insertions(+), 37 deletions(-) diff --git a/docs/plans/account-state-lifecycle.md b/docs/plans/account-state-lifecycle.md index a73f45483..b1ae4199f 100644 --- a/docs/plans/account-state-lifecycle.md +++ b/docs/plans/account-state-lifecycle.md @@ -36,7 +36,7 @@ A new `wallet.soft_delete_account(p_account_id uuid)` DB function sets `state = Valid: `active -> expired`, `active -> deleted`, `expired -> deleted`. No reactivation. An expired offer account's keyset has expired at the Cashu protocol level -- reactivating it would be misleading. New ecash at the same mint creates a new `active` account (the updated unique index allows this). -Enforced by a BEFORE UPDATE trigger at the DB level. +Enforced by construction: each DB function's WHERE clause only matches valid source states. No trigger needed — `upsert_user_with_accounts` only transitions `active → expired`, and `soft_delete_account` only transitions `active/expired → deleted`. ### 6. Realtime handling for deleted accounts @@ -55,41 +55,6 @@ alter table "wallet"."accounts" add column "state" "wallet"."account_state" not null default 'active'; ``` -### State transition enforcement trigger - -```sql -create or replace function "wallet"."enforce_account_state_transition"() -returns trigger -language plpgsql -security invoker -set search_path = '' -as $function$ -begin - if old.state = 'deleted' then - raise exception - using - hint = 'INVALID_TRANSITION', - message = 'Cannot transition out of deleted state.'; - end if; - - if old.state = 'expired' and new.state not in ('expired', 'deleted') then - raise exception - using - hint = 'INVALID_TRANSITION', - message = format('Invalid account state transition: %s -> %s', old.state, new.state); - end if; - - return new; -end; -$function$; - -create trigger "enforce_account_state_transition" - before update of state on "wallet"."accounts" - for each row - when (old.state is distinct from new.state) - execute function "wallet"."enforce_account_state_transition"(); -``` - ### Index changes ```sql @@ -136,7 +101,8 @@ as $function$ begin update wallet.accounts set state = 'deleted', version = version + 1 - where id = p_account_id; + where id = p_account_id + and state != 'deleted'; if not found then raise exception From 9daa1c865a12f7955a922e639acf3112282e5765 Mon Sep 17 00:00:00 2001 From: orveth Date: Mon, 6 Apr 2026 13:53:04 -0700 Subject: [PATCH 08/11] Update account state lifecycle spec with settled decisions - Drop eager expiry from upsert_user_with_accounts, use 1-minute pg_cron only - Mark deleted state as pending decision with Option A/B - Rename soft_delete_account to delete_account - Update enforce_accounts_limit to count only active accounts - Add idx_accounts_state index - Note useQuery select pattern for filtering - Add graceful error handling for expired keysets - Note PR #959 (offer mints) is merged Co-Authored-By: Claude Opus 4.6 --- docs/plans/account-state-lifecycle.md | 118 ++++++++++++++------------ 1 file changed, 65 insertions(+), 53 deletions(-) diff --git a/docs/plans/account-state-lifecycle.md b/docs/plans/account-state-lifecycle.md index b1ae4199f..36c3010fb 100644 --- a/docs/plans/account-state-lifecycle.md +++ b/docs/plans/account-state-lifecycle.md @@ -2,15 +2,22 @@ ## Summary -Add a `state` column to `wallet.accounts` supporting the lifecycle `active -> expired -> deleted` (soft delete). This enables automatic expiry of offer accounts when their keyset's `expires_at` passes, soft delete so expired/deleted accounts don't block creation of future accounts at the same mint, and a uniqueness constraint scoped only to active accounts. +Add a `state` column to `wallet.accounts` supporting the lifecycle `active -> expired` (with `deleted` state under discussion). This enables automatic expiry of offer accounts when their keyset's `expires_at` passes, prevents expired accounts from blocking creation of future accounts at the same mint, and a uniqueness constraint scoped only to active accounts. ## Design Decisions -### 1. Where to filter `deleted` accounts +### 1. Where to filter `deleted` accounts — PENDING DECISION -**Decision: RLS (restrictive SELECT policy).** +> **Status:** Under discussion between gudnuf and josip. Two options are being considered: +> +> - **Option A: Ship expiry-only.** `expired` is a terminal state. Expired accounts are hidden immediately. No `deleted` state in this migration. +> - **Option B: Keep both states.** Show expired accounts briefly in the UI, then a second cron job moves `expired -> deleted` after N days, hiding them permanently. +> +> The design below retains `deleted` for completeness, but it may be scoped out of the initial migration. -A restrictive RLS policy makes `deleted` accounts invisible for SELECT. Every caller -- `getAll()`, `get()`, realtime subscriptions -- automatically excludes deleted rows without any app-layer change. The `enforce_accounts_limit` trigger must also be updated to exclude `deleted` accounts from its count. +**Proposed: RLS (restrictive SELECT policy).** + +A restrictive RLS policy makes `deleted` accounts invisible for SELECT. Every caller -- `getAll()`, `get()`, realtime subscriptions -- automatically excludes deleted rows without any app-layer change. ### 2. Where to filter `expired` accounts @@ -20,23 +27,27 @@ Expired accounts remain visible in `getAll()` -- the RLS policy does not hide th ### 3. Auto-expiry mechanism -**Decision: Two layers — eager on user assertion, pg_cron as background cleanup.** +**Decision: pg_cron background job only (every minute).** + +A single pg_cron job runs every minute (`'* * * * *'`) and transitions any account with `state = 'active'` and `expires_at <= now()` to `expired`. The existing `broadcast_accounts_changes_trigger` fires realtime events automatically when the cron updates state, so connected clients receive updates without additional plumbing. + +No eager expiry in `upsert_user_with_accounts` — the 1-minute cron granularity is sufficient for the UX. At most, a user sees a stale active account for up to 60 seconds before the next cron run corrects it. -**Eager (on login):** `upsert_user_with_accounts` expires stale accounts before returning them. When a user opens the app, any account with `state = 'active'` and `expires_at <= now()` is transitioned to `expired` within the same transaction. The client gets correct state on first load — no stale-then-update flicker. +**Graceful error handling:** If the client attempts to use an expired keyset and the mint rejects it, show the user "this offer has expired" and trigger a cache refresh to pull the updated account state. -**Background (pg_cron, hourly):** A cron job catches accounts for users who haven't opened the app. This keeps the DB consistent for realtime broadcasts and prevents stale `active` accounts from accumulating. pg_cron is already installed and used for 8 daily cleanup jobs — no new infrastructure. +### 4. Delete — PENDING DECISION -### 4. Soft delete +> **Status:** Depends on the outcome of decision #1 above. If Option A is chosen, this section is deferred. If Option B is chosen, the design below applies. -**Decision: Client-initiated app-layer mutation.** +**Proposed: Client-initiated app-layer mutation.** -A new `wallet.soft_delete_account(p_account_id uuid)` DB function sets `state = 'deleted'` and bumps `version`. The `ACCOUNT_UPDATED` realtime event fires; the client removes the account from the cache. +A new `wallet.delete_account(p_account_id uuid)` DB function sets `state = 'deleted'` and bumps `version`. The `ACCOUNT_UPDATED` realtime event fires; the client removes the account from the cache. ### 5. Transitions are one-way -Valid: `active -> expired`, `active -> deleted`, `expired -> deleted`. No reactivation. An expired offer account's keyset has expired at the Cashu protocol level -- reactivating it would be misleading. New ecash at the same mint creates a new `active` account (the updated unique index allows this). +Valid: `active -> expired`. If the `deleted` state is included: `active -> deleted`, `expired -> deleted`. No reactivation. An expired offer account's keyset has expired at the Cashu protocol level -- reactivating it would be misleading. New ecash at the same mint creates a new `active` account (the updated unique index allows this). -Enforced by construction: each DB function's WHERE clause only matches valid source states. No trigger needed — `upsert_user_with_accounts` only transitions `active → expired`, and `soft_delete_account` only transitions `active/expired → deleted`. +Enforced by construction: each DB function's WHERE clause only matches valid source states. No trigger needed — the pg_cron job only transitions `active → expired`, and `delete_account` (if included) only transitions `active/expired → deleted`. ### 6. Realtime handling for deleted accounts @@ -72,6 +83,9 @@ create unique index "cashu_accounts_active_user_currency_mint_url_unique" create index "idx_accounts_active_expires_at" on "wallet"."accounts" using btree ((("details" ->> 'expires_at')::timestamptz)) where ("state" = 'active' and ("details" ->> 'expires_at') is not null); + +-- General state index for queries filtering by state +create index idx_accounts_state on wallet.accounts(state); ``` ### RLS: hide deleted accounts @@ -85,14 +99,16 @@ to authenticated using (state != 'deleted'::wallet.account_state); ``` -### enforce_accounts_limit (deferred) +### enforce_accounts_limit — count only active + +Update `enforce_accounts_limit` in this migration to count only `state = 'active'` accounts toward the 200-account quota. Expired (and deleted, if included) accounts should not block users from creating new accounts at the same mint. -The current trigger counts all accounts regardless of state. Deleted accounts will count toward the 200-account quota. Changing this limit is a separate discussion — the limit exists for a reason and adjusting what counts toward it has implications beyond this feature. For now, soft-deleted accounts are rare (only offer accounts) and won't meaningfully impact the quota. +### Delete DB function — PENDING DECISION -### Soft delete DB function +> Included if the `deleted` state is kept (see decision #1). ```sql -create or replace function "wallet"."soft_delete_account"(p_account_id uuid) +create or replace function "wallet"."delete_account"(p_account_id uuid) returns void language plpgsql security invoker @@ -114,27 +130,10 @@ end; $function$; ``` -### Eager expiry in upsert_user_with_accounts - -Add an UPDATE before the account fetch in `upsert_user_with_accounts` to transition stale accounts: - -```sql --- Expire stale accounts before returning them to the client -update wallet.accounts -set state = 'expired', version = version + 1 -where - user_id = p_user_id - and state = 'active' - and (details ->> 'expires_at') is not null - and (details ->> 'expires_at')::timestamptz <= now(); -``` - -This runs inside the existing transaction, before the `accounts_with_proofs` CTE that fetches accounts. The client receives already-expired accounts with `state = 'expired'` — no second round-trip needed. - -### pg_cron job for auto-expiry +### pg_cron job for auto-expiry (every minute) ```sql -select cron.schedule('expire-offer-accounts', '0 * * * *', $$ +select cron.schedule('expire-offer-accounts', '* * * * *', $$ update wallet.accounts set state = 'expired', @@ -146,6 +145,8 @@ select cron.schedule('expire-offer-accounts', '0 * * * *', $$ $$); ``` +The existing `broadcast_accounts_changes_trigger` fires automatically on these updates, pushing realtime events to connected clients. + ## App Code Changes ### `account.ts` -- Add state to type @@ -159,7 +160,7 @@ state: AccountState; ### `account-repository.ts` -- Map state, add delete -Map `state` in `toAccount()` commonData. Add `deleteAccount(id)` calling `soft_delete_account` RPC. +Map `state` in `toAccount()` commonData. If the `deleted` state is included, add `deleteAccount(id)` calling `delete_account` RPC. ### `account-hooks.ts` -- Cache removal + realtime handling @@ -169,10 +170,15 @@ Map `state` in `toAccount()` commonData. Add `deleteAccount(id)` calling `soft_d ### `gift-cards.tsx` -- Simplify filter +Use the `useQuery` `select` option to filter at the query level rather than manual `.filter()`: + ```typescript function useActiveOffers() { - const { data: offerAccounts } = useAccounts({ purpose: 'offer' }); - return offerAccounts.filter((account) => account.state === 'active'); + const { data: offerAccounts } = useAccounts({ + purpose: 'offer', + select: (accounts) => accounts.filter((a) => a.state === 'active'), + }); + return offerAccounts; } ``` @@ -186,31 +192,32 @@ function useActiveOffers() { ## Data Flow -### active -> expired (on login, eager) +### active -> expired (pg_cron, every minute) ``` -User opens app - -> upsert_user_with_accounts(...) - -> UPDATE stale accounts to state='expired', version+1 (same transaction) - -> accounts returned already have state='expired' - -> client renders correct state immediately, no flicker +pg_cron (every minute) -> UPDATE state='expired', version+1 + -> broadcast_accounts_changes_trigger fires automatically + -> realtime ACCOUNT_UPDATED to connected clients + -> accountCache.update(account) [version higher, accepted] + -> useActiveOffers() re-renders, select filters by state === 'active' ``` -### active -> expired (background cleanup, hourly) +### Expired keyset error handling ``` -pg_cron -> UPDATE state='expired', version+1 (for users who haven't logged in) - -> broadcast_accounts_changes_trigger fires - -> realtime ACCOUNT_UPDATED to connected clients - -> accountCache.update(account) [version higher, accepted] - -> useActiveOffers() re-renders, filtered by state === 'active' +Client tries to use account with expired keyset + -> Mint rejects operation + -> Show "this offer has expired" + -> Trigger cache refresh to pull updated account state ``` -### active/expired -> deleted (user-initiated) +### active/expired -> deleted (user-initiated) — PENDING DECISION + +> Included if the `deleted` state is kept (see decision #1). ``` useDeleteAccount()(accountId) - -> db.rpc('soft_delete_account', { p_account_id: id }) + -> db.rpc('delete_account', { p_account_id: id }) -> broadcast ACCOUNT_UPDATED with state='deleted' -> client: accountCache.remove(id) -> account gone from all UI @@ -246,8 +253,13 @@ User receives new offer token for same mint - [ ] Update `useActiveOffers()` to filter by `state === 'active'` - [ ] Run `bun run fix:all` +## Prerequisites + +- **PR #959 (offer mints) — MERGED.** The prerequisite for this migration has landed. + ## Open Questions -- **Delete UI placement**: The hook is specced; UX (which screen, what confirmation) is a separate decision. +- **Delete state**: See decision #1 above. gudnuf and josip are still deciding whether to include `deleted` in this migration or ship expiry-only. +- **Delete UI placement**: If the `deleted` state is included, the hook is specced but UX (which screen, what confirmation) is a separate decision. - **Expired balance recovery**: Proofs may still be swappable depending on mint's keyset expiry enforcement. Separate feature. - **Offer re-use on receive**: When a user receives a new offer token for a mint that already has an `active` offer account, existing behavior routes proofs to the existing account. Unchanged by this migration. From 660b190f46087988b9c9c0d96b4bf469d2772131 Mon Sep 17 00:00:00 2001 From: orveth Date: Mon, 6 Apr 2026 14:06:53 -0700 Subject: [PATCH 09/11] =?UTF-8?q?Remove=20deleted=20state=20from=20account?= =?UTF-8?q?-state-lifecycle=20spec=20=E2=80=94=20ship=20expiry-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision: expired is the terminal state for v1. Removes the deleted enum value, RLS restrictive policy, delete_account DB function, useDeleteAccount hook, cache removal logic, and all PENDING DECISION sections. Simplifies transitions to active -> expired only. Delete functionality deferred to future PRs. Co-Authored-By: Claude Opus 4.6 --- docs/plans/account-state-lifecycle.md | 108 ++++---------------------- 1 file changed, 15 insertions(+), 93 deletions(-) diff --git a/docs/plans/account-state-lifecycle.md b/docs/plans/account-state-lifecycle.md index 36c3010fb..f6e844b5e 100644 --- a/docs/plans/account-state-lifecycle.md +++ b/docs/plans/account-state-lifecycle.md @@ -2,22 +2,13 @@ ## Summary -Add a `state` column to `wallet.accounts` supporting the lifecycle `active -> expired` (with `deleted` state under discussion). This enables automatic expiry of offer accounts when their keyset's `expires_at` passes, prevents expired accounts from blocking creation of future accounts at the same mint, and a uniqueness constraint scoped only to active accounts. +Add a `state` column to `wallet.accounts` supporting the lifecycle `active -> expired`. This enables automatic expiry of offer accounts when their keyset's `expires_at` passes, prevents expired accounts from blocking creation of future accounts at the same mint, and a uniqueness constraint scoped only to active accounts. ## Design Decisions -### 1. Where to filter `deleted` accounts — PENDING DECISION +### 1. Expiry-only for v1 -> **Status:** Under discussion between gudnuf and josip. Two options are being considered: -> -> - **Option A: Ship expiry-only.** `expired` is a terminal state. Expired accounts are hidden immediately. No `deleted` state in this migration. -> - **Option B: Keep both states.** Show expired accounts briefly in the UI, then a second cron job moves `expired -> deleted` after N days, hiding them permanently. -> -> The design below retains `deleted` for completeness, but it may be scoped out of the initial migration. - -**Proposed: RLS (restrictive SELECT policy).** - -A restrictive RLS policy makes `deleted` accounts invisible for SELECT. Every caller -- `getAll()`, `get()`, realtime subscriptions -- automatically excludes deleted rows without any app-layer change. +`expired` is the terminal state. Expired accounts are hidden from the active UI immediately. No `deleted` state in this migration. Future iterations may add delete functionality, an "expired cards" list, or notifications for approaching expiry -- but those are out of scope for this PR. ### 2. Where to filter `expired` accounts @@ -35,23 +26,11 @@ No eager expiry in `upsert_user_with_accounts` — the 1-minute cron granularity **Graceful error handling:** If the client attempts to use an expired keyset and the mint rejects it, show the user "this offer has expired" and trigger a cache refresh to pull the updated account state. -### 4. Delete — PENDING DECISION - -> **Status:** Depends on the outcome of decision #1 above. If Option A is chosen, this section is deferred. If Option B is chosen, the design below applies. - -**Proposed: Client-initiated app-layer mutation.** - -A new `wallet.delete_account(p_account_id uuid)` DB function sets `state = 'deleted'` and bumps `version`. The `ACCOUNT_UPDATED` realtime event fires; the client removes the account from the cache. - -### 5. Transitions are one-way +### 4. Transitions are one-way -Valid: `active -> expired`. If the `deleted` state is included: `active -> deleted`, `expired -> deleted`. No reactivation. An expired offer account's keyset has expired at the Cashu protocol level -- reactivating it would be misleading. New ecash at the same mint creates a new `active` account (the updated unique index allows this). +Valid: `active -> expired`. No reactivation. An expired offer account's keyset has expired at the Cashu protocol level -- reactivating it would be misleading. New ecash at the same mint creates a new `active` account (the updated unique index allows this). -Enforced by construction: each DB function's WHERE clause only matches valid source states. No trigger needed — the pg_cron job only transitions `active → expired`, and `delete_account` (if included) only transitions `active/expired → deleted`. - -### 6. Realtime handling for deleted accounts - -The `ACCOUNT_UPDATED` handler must detect `state === 'deleted'` in the broadcast payload and call `accountCache.remove(id)` rather than `accountCache.update(account)`. +Enforced by construction: the pg_cron job's WHERE clause only matches `state = 'active'`, so no invalid transitions are possible. ## DB Migration @@ -60,7 +39,7 @@ The `ACCOUNT_UPDATED` handler must detect `state === 'deleted'` in the broadcast ### New enum + column ```sql -create type "wallet"."account_state" as enum ('active', 'expired', 'deleted'); +create type "wallet"."account_state" as enum ('active', 'expired'); alter table "wallet"."accounts" add column "state" "wallet"."account_state" not null default 'active'; @@ -88,47 +67,9 @@ create index "idx_accounts_active_expires_at" create index idx_accounts_state on wallet.accounts(state); ``` -### RLS: hide deleted accounts - -```sql -create policy "Exclude deleted accounts from select" -on "wallet"."accounts" -as restrictive -for select -to authenticated -using (state != 'deleted'::wallet.account_state); -``` - ### enforce_accounts_limit — count only active -Update `enforce_accounts_limit` in this migration to count only `state = 'active'` accounts toward the 200-account quota. Expired (and deleted, if included) accounts should not block users from creating new accounts at the same mint. - -### Delete DB function — PENDING DECISION - -> Included if the `deleted` state is kept (see decision #1). - -```sql -create or replace function "wallet"."delete_account"(p_account_id uuid) -returns void -language plpgsql -security invoker -set search_path = '' -as $function$ -begin - update wallet.accounts - set state = 'deleted', version = version + 1 - where id = p_account_id - and state != 'deleted'; - - if not found then - raise exception - using - hint = 'NOT_FOUND', - message = format('Account with id %s not found.', p_account_id); - end if; -end; -$function$; -``` +Update `enforce_accounts_limit` in this migration to count only `state = 'active'` accounts toward the 200-account quota. Expired accounts should not block users from creating new accounts at the same mint. ### pg_cron job for auto-expiry (every minute) @@ -152,21 +93,19 @@ The existing `broadcast_accounts_changes_trigger` fires automatically on these u ### `account.ts` -- Add state to type ```typescript -export type AccountState = 'active' | 'expired' | 'deleted'; +export type AccountState = 'active' | 'expired'; // Add to Account base type: state: AccountState; ``` -### `account-repository.ts` -- Map state, add delete +### `account-repository.ts` -- Map state -Map `state` in `toAccount()` commonData. If the `deleted` state is included, add `deleteAccount(id)` calling `delete_account` RPC. +Map `state` in `toAccount()` commonData. -### `account-hooks.ts` -- Cache removal + realtime handling +### `account-hooks.ts` -- Realtime handling -- Add `AccountsCache.remove(id)` method -- Update `ACCOUNT_UPDATED` handler: if `payload.state === 'deleted'`, call `remove` instead of `update` -- Add `useDeleteAccount` hook +- Update `ACCOUNT_UPDATED` handler: expired accounts trigger a cache update (state changes from `active` to `expired`), and `useActiveOffers()` re-renders to exclude them. ### `gift-cards.tsx` -- Simplify filter @@ -211,25 +150,13 @@ Client tries to use account with expired keyset -> Trigger cache refresh to pull updated account state ``` -### active/expired -> deleted (user-initiated) — PENDING DECISION - -> Included if the `deleted` state is kept (see decision #1). - -``` -useDeleteAccount()(accountId) - -> db.rpc('delete_account', { p_account_id: id }) - -> broadcast ACCOUNT_UPDATED with state='deleted' - -> client: accountCache.remove(id) - -> account gone from all UI -``` - ### New offer after prior expiry ``` User receives new offer token for same mint -> INSERT (state defaults to 'active') -> unique index only covers WHERE state='active' - -> no conflict with expired/deleted account + -> no conflict with expired account -> new active account created ``` @@ -243,10 +170,6 @@ User receives new offer token for same mint ### Phase 2: Types and Repository - [ ] Add `AccountState` type and `state` field to `account.ts` - [ ] Map `data.state` in `AccountRepository.toAccount()` -- [ ] Add `AccountRepository.deleteAccount(id)` calling RPC -- [ ] Add `AccountsCache.remove(id)` -- [ ] Update `ACCOUNT_UPDATED` handler for deleted state -- [ ] Add `useDeleteAccount` hook - [ ] Run `bun run fix:all` ### Phase 3: UI @@ -259,7 +182,6 @@ User receives new offer token for same mint ## Open Questions -- **Delete state**: See decision #1 above. gudnuf and josip are still deciding whether to include `deleted` in this migration or ship expiry-only. -- **Delete UI placement**: If the `deleted` state is included, the hook is specced but UX (which screen, what confirmation) is a separate decision. - **Expired balance recovery**: Proofs may still be swappable depending on mint's keyset expiry enforcement. Separate feature. - **Offer re-use on receive**: When a user receives a new offer token for a mint that already has an `active` offer account, existing behavior routes proofs to the existing account. Unchanged by this migration. +- **Future enhancements**: Notifications for approaching expiry, an "expired cards" list in the UI, or user-initiated delete — all deferred to future PRs. From db9992ba7d614595779b4cefa1eb75cf8bfb18b3 Mon Sep 17 00:00:00 2001 From: orveth Date: Mon, 6 Apr 2026 15:29:50 -0700 Subject: [PATCH 10/11] Filter expired accounts in getAll() query, not app layer or RLS Move expired account filtering from client-side select to AccountRepository.getAll() with .eq('state', 'active'). RLS is not viable because security invoker DB functions need expired accounts for in-flight operations. get(id) remains unfiltered for the same reason. Simplify useActiveOffers() and update implementation phases. Co-Authored-By: Claude Opus 4.6 --- docs/plans/account-state-lifecycle.md | 37 +++++++++++++++++---------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/docs/plans/account-state-lifecycle.md b/docs/plans/account-state-lifecycle.md index f6e844b5e..06c55c296 100644 --- a/docs/plans/account-state-lifecycle.md +++ b/docs/plans/account-state-lifecycle.md @@ -12,9 +12,13 @@ Add a `state` column to `wallet.accounts` supporting the lifecycle `active -> ex ### 2. Where to filter `expired` accounts -**Decision: App layer.** +**Decision: Repository query layer.** -Expired accounts remain visible in `getAll()` -- the RLS policy does not hide them. The existing `useActiveOffers()` filter in `gift-cards.tsx` is simplified from the `expiresAt > now()` check to `account.state === 'active'`. +Add `.eq('state', 'active')` to the Supabase query in `AccountRepository.getAll()` so expired accounts never reach the client. + +**Why not RLS?** All DB functions (`get_account_with_proofs`, `create_cashu_send_quote`, etc.) are `security invoker`, so they execute with the calling user's RLS context. A restrictive RLS policy hiding expired accounts would break any in-flight quote or swap operation whose account expires mid-transaction. The DB functions need to see expired accounts to complete already-started operations. + +**Why not a `useQuery` `select` filter?** Since `getAll()` excludes expired accounts at the query level, there is no need for a client-side `select` filter -- the data simply never arrives at the client. ### 3. Auto-expiry mechanism @@ -24,7 +28,7 @@ A single pg_cron job runs every minute (`'* * * * *'`) and transitions any accou No eager expiry in `upsert_user_with_accounts` — the 1-minute cron granularity is sufficient for the UX. At most, a user sees a stale active account for up to 60 seconds before the next cron run corrects it. -**Graceful error handling:** If the client attempts to use an expired keyset and the mint rejects it, show the user "this offer has expired" and trigger a cache refresh to pull the updated account state. +**Graceful error handling:** If the client somehow triggers an operation on an expired account (race condition between cron expiring the account and user initiating an action), catch the mint rejection and show the user "this offer has expired". Trigger a cache invalidation to pull fresh account state from the server. ### 4. Transitions are one-way @@ -99,28 +103,32 @@ export type AccountState = 'active' | 'expired'; state: AccountState; ``` -### `account-repository.ts` -- Map state +### `account-repository.ts` -- Map state and filter expired Map `state` in `toAccount()` commonData. +- `getAll()` adds `.eq('state', 'active')` to the Supabase query, so expired accounts are excluded at the repository level and never reach the client. +- `get(id)` does NOT filter by state -- individual account lookups (used by DB functions returning account data) still work for expired accounts if needed by in-flight operations. + ### `account-hooks.ts` -- Realtime handling - Update `ACCOUNT_UPDATED` handler: expired accounts trigger a cache update (state changes from `active` to `expired`), and `useActiveOffers()` re-renders to exclude them. ### `gift-cards.tsx` -- Simplify filter -Use the `useQuery` `select` option to filter at the query level rather than manual `.filter()`: +`useActiveOffers()` no longer needs to filter by `state === 'active'` since `getAll()` already excludes expired accounts at the query level. It becomes a simple query for `purpose: 'offer'` accounts: ```typescript function useActiveOffers() { const { data: offerAccounts } = useAccounts({ purpose: 'offer', - select: (accounts) => accounts.filter((a) => a.state === 'active'), }); return offerAccounts; } ``` +Remove the old client-side `expires_at` Date check -- it is redundant now that the cron job sets `state = 'expired'` and `getAll()` filters by `state = 'active'`. + ### Files requiring no changes - `account-service.ts` -- New accounts default to `active` via DB column default @@ -138,16 +146,17 @@ pg_cron (every minute) -> UPDATE state='expired', version+1 -> broadcast_accounts_changes_trigger fires automatically -> realtime ACCOUNT_UPDATED to connected clients -> accountCache.update(account) [version higher, accepted] - -> useActiveOffers() re-renders, select filters by state === 'active' + -> useActiveOffers() re-renders, getAll() already excludes expired ``` -### Expired keyset error handling +### Expired keyset error handling (race condition) ``` -Client tries to use account with expired keyset - -> Mint rejects operation - -> Show "this offer has expired" - -> Trigger cache refresh to pull updated account state +Client triggers operation on account just before cron expires it + -> Mint rejects operation (keyset expired) + -> Catch mint rejection, show "this offer has expired" + -> Trigger cache invalidation to pull fresh state + -> getAll() returns without the now-expired account ``` ### New offer after prior expiry @@ -170,10 +179,12 @@ User receives new offer token for same mint ### Phase 2: Types and Repository - [ ] Add `AccountState` type and `state` field to `account.ts` - [ ] Map `data.state` in `AccountRepository.toAccount()` +- [ ] Add `.eq('state', 'active')` to `getAll()` query (leave `get(id)` unfiltered) - [ ] Run `bun run fix:all` ### Phase 3: UI -- [ ] Update `useActiveOffers()` to filter by `state === 'active'` +- [ ] Remove client-side `expires_at` Date check in `gift-cards.tsx` +- [ ] Simplify `useActiveOffers()` — no `select` filter needed, `getAll()` handles it - [ ] Run `bun run fix:all` ## Prerequisites From 2cbfcea2ef23475252d197e87d654a8e1d889327 Mon Sep 17 00:00:00 2001 From: orveth Date: Mon, 6 Apr 2026 19:47:33 -0700 Subject: [PATCH 11/11] update account state spec: error handling, rename useOfferCards, remove redundant section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add detailed graceful error handling for KEYSET_INACTIVE (12002) race window - Scope error handling to offer accounts only via account.purpose check - Rename useActiveOffers → useOfferCards throughout - Remove redundant "Why not useQuery select" paragraph - Replace explicit cache invalidation with realtime event handling - Add 12002 catch task to Phase 3 Co-Authored-By: Claude Opus 4.6 --- docs/plans/account-state-lifecycle.md | 36 ++++++++++++++++----------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/docs/plans/account-state-lifecycle.md b/docs/plans/account-state-lifecycle.md index 06c55c296..73941c728 100644 --- a/docs/plans/account-state-lifecycle.md +++ b/docs/plans/account-state-lifecycle.md @@ -18,8 +18,6 @@ Add `.eq('state', 'active')` to the Supabase query in `AccountRepository.getAll( **Why not RLS?** All DB functions (`get_account_with_proofs`, `create_cashu_send_quote`, etc.) are `security invoker`, so they execute with the calling user's RLS context. A restrictive RLS policy hiding expired accounts would break any in-flight quote or swap operation whose account expires mid-transaction. The DB functions need to see expired accounts to complete already-started operations. -**Why not a `useQuery` `select` filter?** Since `getAll()` excludes expired accounts at the query level, there is no need for a client-side `select` filter -- the data simply never arrives at the client. - ### 3. Auto-expiry mechanism **Decision: pg_cron background job only (every minute).** @@ -28,7 +26,14 @@ A single pg_cron job runs every minute (`'* * * * *'`) and transitions any accou No eager expiry in `upsert_user_with_accounts` — the 1-minute cron granularity is sufficient for the UX. At most, a user sees a stale active account for up to 60 seconds before the next cron run corrects it. -**Graceful error handling:** If the client somehow triggers an operation on an expired account (race condition between cron expiring the account and user initiating an action), catch the mint rejection and show the user "this offer has expired". Trigger a cache invalidation to pull fresh account state from the server. +**Graceful error handling:** There's a race window of up to 60 seconds where a keyset has expired at the mint but the cron job hasn't marked the account expired yet. If a user initiates an operation during this window, the mint rejects with `KEYSET_INACTIVE` (12002), already defined in `error-codes.ts`. The fix is a single targeted catch in the service layer: when catching `MintOperationError` with code 12002, check `account.purpose === 'offer'` and throw `DomainError("This offer has expired")`. Non-offer accounts fall through to existing generic error handling. No pre-operation checks, no `expires_at` comparisons, no new utilities. + +The affected code paths: +- **Lightning send (melt):** `cashu-send-quote-service.ts` → `initiateSend()` → `wallet.meltProofsBolt11()` → mint returns 12002 +- **Cashu token send (swap):** `cashu-send-swap-service.ts` → `swapForProofsToSend()` → `mint.swap()` → mint returns 12002 +- **Receive swap:** `cashu-receive-swap-service.ts` → `completeSwap()` → `mint.swap()` → mint returns 12002 + +There's also a client-side variant where cashu-ts throws `Error("No active keyset found")` before hitting the mint, but this is rarer and existing generic error handling covers it. The realtime `ACCOUNT_UPDATED` event from the cron job handles cache updates — no explicit invalidation needed. ### 4. Transitions are one-way @@ -112,14 +117,14 @@ Map `state` in `toAccount()` commonData. ### `account-hooks.ts` -- Realtime handling -- Update `ACCOUNT_UPDATED` handler: expired accounts trigger a cache update (state changes from `active` to `expired`), and `useActiveOffers()` re-renders to exclude them. +- Update `ACCOUNT_UPDATED` handler: expired accounts trigger a cache update (state changes from `active` to `expired`), and `useOfferCards()` re-renders to exclude them. ### `gift-cards.tsx` -- Simplify filter -`useActiveOffers()` no longer needs to filter by `state === 'active'` since `getAll()` already excludes expired accounts at the query level. It becomes a simple query for `purpose: 'offer'` accounts: +`useOfferCards()` no longer needs to filter by `state === 'active'` since `getAll()` already excludes expired accounts at the query level. It becomes a simple query for `purpose: 'offer'` accounts: ```typescript -function useActiveOffers() { +function useOfferCards() { const { data: offerAccounts } = useAccounts({ purpose: 'offer', }); @@ -146,17 +151,19 @@ pg_cron (every minute) -> UPDATE state='expired', version+1 -> broadcast_accounts_changes_trigger fires automatically -> realtime ACCOUNT_UPDATED to connected clients -> accountCache.update(account) [version higher, accepted] - -> useActiveOffers() re-renders, getAll() already excludes expired + -> useOfferCards() re-renders, getAll() already excludes expired ``` ### Expired keyset error handling (race condition) ``` -Client triggers operation on account just before cron expires it - -> Mint rejects operation (keyset expired) - -> Catch mint rejection, show "this offer has expired" - -> Trigger cache invalidation to pull fresh state - -> getAll() returns without the now-expired account +User initiates send/receive on offer account during <=60s race window + -> Mint rejects with KEYSET_INACTIVE (12002) + -> Service layer catches MintOperationError, checks account.purpose === 'offer' + -> Throws DomainError("This offer has expired") -> toast shown to user + -> (Non-offer accounts: 12002 falls through to generic error handling) + -> Realtime ACCOUNT_UPDATED event from cron updates cache + -> useOfferCards() re-renders, expired account excluded ``` ### New offer after prior expiry @@ -182,9 +189,10 @@ User receives new offer token for same mint - [ ] Add `.eq('state', 'active')` to `getAll()` query (leave `get(id)` unfiltered) - [ ] Run `bun run fix:all` -### Phase 3: UI +### Phase 3: UI + Error Handling - [ ] Remove client-side `expires_at` Date check in `gift-cards.tsx` -- [ ] Simplify `useActiveOffers()` — no `select` filter needed, `getAll()` handles it +- [ ] Simplify `useOfferCards()` — no `select` filter needed, `getAll()` handles it +- [ ] Add 12002 catch in `cashu-send-quote-service.ts`, `cashu-send-swap-service.ts`, `cashu-receive-swap-service.ts` — offer accounts get `DomainError("This offer has expired")`, non-offer fall through - [ ] Run `bun run fix:all` ## Prerequisites