From c66ec513b2aa0f8d3c858ae9f01677517f547a75 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Fri, 20 Mar 2026 10:16:36 -0700 Subject: [PATCH 1/7] 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 00000000..1b261ec8 --- /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 2/7] 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 1b261ec8..5e0cd330 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 3/7] 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 00000000..392efb98 --- /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 4/7] 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 00000000..80cd2618 --- /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 5/7] 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 392efb98..00000000 --- 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 5e0cd330..00000000 --- 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 6/7] 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 80cd2618..a73f4548 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 7/7] 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 a73f4548..b1ae4199 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