From f9b3fe56ab23e21ed3e22b247076287fd4629f56 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 22 May 2026 11:25:49 -0700 Subject: [PATCH 1/8] docs(migrations): cashu-ts v4 scope audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope classification doc for the @cashu/cashu-ts 3.6.1 → v4 bump: breaking changes summary, file-by-file audit, per-bucket counts, 10 open questions, recommended 3-PR sequencing. No code changes. --- docs/migrations/cashu-ts-v4-scope.md | 298 +++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 docs/migrations/cashu-ts-v4-scope.md diff --git a/docs/migrations/cashu-ts-v4-scope.md b/docs/migrations/cashu-ts-v4-scope.md new file mode 100644 index 000000000..ab398f80d --- /dev/null +++ b/docs/migrations/cashu-ts-v4-scope.md @@ -0,0 +1,298 @@ +# cashu-ts v3.6.1 → v4.x upgrade — Scope & Classification + +> Branch: `cashu-ts-v4`. No code changes yet — this is a planning doc to scope a clean-start migration. +> Current pin: `@cashu/cashu-ts: 3.6.1`. Latest: `v4.5.0` (2026-05-21). + +## TL;DR + +- Recommended target: **v4.5.0** (or the latest stable at merge time). +- Upstream ships **both** a human migration guide (`migration-4.0.0.md`) **and** an agent playbook (`migration-4.0.0.SKILL.md`) — use the SKILL doc as the line-by-line checklist. +- Size: **Medium-Large.** ~26 first-party files import cashu-ts. The work is mechanical but touches every send/receive service, the encryption-at-rest schema, the Money/Amount boundary, and a handful of Zod schemas. Code volume is small, blast radius is broad, and there are persisted-shape questions that need product answers. +- Two foundational questions block sequencing: **(Q1)** Amount strategy — adopt natively or convert at boundary; **(Q2)** how to handle the `Proof.amount: number → bigint/Amount` change in the encrypted DB payload. + +--- + +## Section 1 — v4 breaking changes summary + +Sources: +- Release index: https://github.com/cashubtc/cashu-ts/releases +- Human guide: https://github.com/cashubtc/cashu-ts/blob/main/migration-4.0.0.md +- Agent playbook: https://github.com/cashubtc/cashu-ts/blob/main/migration-4.0.0.SKILL.md +- v4.0.0 notes: https://github.com/cashubtc/cashu-ts/releases/tag/v4.0.0 + +### 1.1 The big four (everything else falls out of these) + +**(A) `Amount` value object replaces `number` on most APIs.** Immutable, bigint-backed, non-negative. Has `.add/.subtract/.multiplyBy/.divideBy/.toNumber()/.toBigInt()/.toJSON()` plus finance helpers (`scaledBy`, `clamp`, `inRange`, `ceilPercent`, `floorPercent`). `Amount.from(x)` accepts `number | bigint | string | Amount`. `AmountLike` is the boundary union. `toJSON()` always emits a decimal string — so `JSON.stringify(amount)` no longer produces a bare number. Affected fields: + +- `Proof.amount: number → Amount` (and `Proof` literals must use `Amount.from(...)`) +- `MintQuoteBolt11Response.amount`, `MeltQuoteBolt11Response.amount`, `MeltQuote*.fee_reserve` +- `sumProofs() → Amount`, `getTokenMetadata().amount → Amount` +- `splitAmount() → Amount[]`, `getKeysetAmounts() → Amount[]` +- `wallet.getFeesForProofs() → Amount`, `wallet.getFeesForKeyset() → Amount` +- `OutputData.sumOutputAmounts() → Amount` +- `SwapPreview.amount/.fees → Amount` +- `PaymentRequest.amount → Amount | undefined` +- `SerializedBlindedMessage.amount → Amount` + +v4.1 also widened `selectProofsToSend` / `groupProofsByState` to accept `ProofLike[]` (proof with `amount: AmountLike`). v4.4 added an opt-in `AmountWithUnit` value object for multi-unit apps. + +**(B) Token encoding no longer mutates input.** PR cashubtc/cashu-ts#536 (authored by gudnuf) landed in v4.0. `getEncodedToken()` deep-clones proofs before encoding, so our `encodeToken` wrapper in `app/lib/cashu/token.ts` becomes obsolete. Related: `getEncodedTokenV3` removed entirely; `{ version: 3 }` option on `getEncodedToken` removed; `getEncodedTokenV4` made internal — call `getEncodedToken` instead. Encoding a token whose proofs carry legacy base64 keyset IDs now **throws** ("Proofs contain a legacy keyset ID and cannot be encoded. Swap them at the mint first.") — fresh-swap via `wallet.receive(legacyProofs)` first. + +**(C) `getDecodedToken` requires a `keysetIds: readonly string[]` second arg.** Passing `[]` is unsafe (throws on v2 short keyset IDs). Two new safe paths: +- `getTokenMetadata(str)` — pre-wallet decoder (mint, unit, amount, incompleteProofs). Always safe. +- `wallet.decodeToken(str)` — post-wallet decoder, returns fully-hydrated `Token`. + +**(D) Proof serialization helpers + `ProofLike` type.** `serializeProofs(proofs): string[]` and `deserializeProofs(json | string[] | ProofLike[]): Proof[]` are the supported JSON-safe round-trip helpers. `normalizeProofAmounts(raw: ProofLike[])` is the lower-level normalizer. Wallet APIs accept `ProofLike[]` directly — you can pass DB-loaded proofs with `amount: number` straight into `wallet.send/receive/meltProofsBolt11/selectProofsToSend/groupProofsByState/signP2PKProofs` without converting first. + +### 1.2 API drift (removed / renamed) + +| Old | New / Replacement | +|-----|-------------------| +| `getEncodedTokenV3`, `getEncodedTokenV4` | `getEncodedToken` only | +| `getEncodedToken(token, { version: 3 })` | option removed | +| `getDecodedToken(str)` | `getDecodedToken(str, keysetIds)` or use `getTokenMetadata`/`wallet.decodeToken` | +| `wallet.swap` | `wallet.send` | +| `wallet.createMintQuote/checkMintQuote/mintProofs` | `*Bolt11` variants | +| `wallet.createMeltQuote/checkMeltQuote/meltProofs` | `*Bolt11` variants | +| `MeltBlanks` / `meltBlanksCreated` / `preferAsync` (TS) | `prepareMelt()` + `completeMelt()` (or `prefer_async` in payload) | +| `new Wallet(mint, { keys, keysets, mintInfo })` preload | `wallet.loadMintFromCache(cache)` | +| `KeyChain.fromCache(mint, cache)` | `KeyChain.fromCache(mint, unit, cache)` — **unit is now explicit** | +| `KeyChain.mintToCacheDTO(unit, url, keysets, keys)` | `KeyChain.mintToCacheDTO(url, keysets, keys)` — **unit removed** | +| `KeyChainCache.unit` field | removed; cache covers all units; new `savedAt?: number` | +| `chain.getCache()` | `chain.cache` | +| `keyset.active / .input_fee_ppk / .final_expiry` | `.isActive / .fee / .expiry` (on `Keyset` domain class; raw `MintKeyset` DTO still uses old names) | +| `handleTokens`, `MessageQueue`, `MessageNode`, `getKeepAmounts`, `bytesToNumber`, `verifyKeysetId`, `deriveKeysetId`, `mergeUInt8Arrays`, `hasNonHexId`, `checkResponse`, `deepEqual` | removed / made internal | +| `RawProof`, `constructProofFromPromise`, `createRandomBlindedMessage`, `verifyProof`, `BlindedMessage`, `SerializedProof`, `serializeProof`, `deserializeProof` | renamed: `UnblindedSignature`, `constructUnblindedSignature`, `createRandomRawBlindedMessage`, `verifyUnblindedSignature`, `RawBlindedMessage`; serialization removed (use `Proof` directly) | +| `createBlindSignature(B_, sk, amount, id)` | `createBlindSignature(B_, sk, id)` (amount param dropped) | +| `BlindSignature.amount` field | removed | +| Low-level P2PK getters (`getP2PKWitnessPubkeys`, `getP2PKLockState`, `getP2PKNSigs`, etc.) | `verifyP2PKSpendingConditions(proof)` returns `{ main: { pubkeys, requiredSigners, receivedSigners }, refund: { ... }, lockState, locktime }` | +| `signP2PKSecret` | `schnorrSignMessage` | +| `mintInfo.supportsBolt12Description` | `mintInfo.supportsNut04Description('bolt12')` | +| `wsConnection.closeSubscription(id)` | `wsConnection.cancelSubscription(id)` | +| `OutputDataFactory` generic | removed; `amount: number → AmountLike` | +| `OutputDataLike` generic | removed | +| `MintPreview.quote: string` | `MintPreview.quote: TQuote` (full object) | +| `sanitizeUrl` (public) | `normalizeUrl` (internal); renamed v4.2 | +| `ConnectionManager` singleton | removed (transport refactor) | +| CJS build | **gone** — ESM only | +| `Proof.amount` in `serialize/deserializeProofs` round-trip | new `ProofLike` boundary type | + +### 1.3 New features Agicash may want + +- **`RateLimitError` + `parseRetryAfter` + `ResponseMeta` callback** (v4.0) — explicit 429 handling. Today the codebase greps `HttpResponseError && error.status === 429` in one place (`cashu-receive-quote-hooks.ts:387`); this could become typed. +- **`requireSigDleq` Wallet option** (v4.0) — strict DLEQ signature enforcement. +- **`createEphemeralCounterSource(initial)`** (v4.1) — DX win; not load-bearing for us (we use `{ type: 'custom' }`). +- **`maxSpendableAfterFees`** (v4.3) — primitive for "send max" workflows. +- **`createMeltChangeProofs`** (v4.3) — async melt-change handling. Could replace our `matchBlindSignaturesToOutputData` wrapper in `app/lib/cashu/blind-signature-matching.ts`. Worth a follow-up audit. +- **`AmountWithUnit`** (v4.4) — unit-aware arithmetic; not needed today, but conceptually overlaps with `Money`. +- **`CTSError` base class** (v4.0/v4.3) — new common ancestor across cashu-ts errors. Useful for the PR #1090 classifier. +- **Anti-fingerprinting header overrides** (v4.0) — request headers now consumer-overridable. + +### 1.4 Minor / spec-alignment + +- `expiry` fields on `MintQuoteBolt11Response` / `Bolt12Response` may be `null` (was always `number`). This bites us — see `cashu-receive-quote-core.ts:274` (`new Date(mintQuoteResponse.expiry * 1000)`) and `cashu-send-quote-service.ts:240` (`new Date(meltQuote.expiry * 1000)`). Both will crash on `expiry: null` and need null-guards. +- `T | null` propagation on other nullable wire fields (v4.3). +- BOLT12 quote `amount` may be `null` (we don't use BOLT12 yet). +- `P2PKBuilder.requireLockSignatures` now **throws** on non-positive integers (was silent clamp). + +--- + +## Section 2 — File-by-file audit + +26 first-party files (50 grep hits — 24 are package.json/bun.lock/docs/CLAUDE.md). Grouped by area. **Buckets** key: A=Amount migration, B=Encoding fix, C=Error surface, D=Other API drift, E=Money-unit cleanup opportunity. Numbers in parens after a bucket = approximate hot spots in that file. + +### 2.1 Core lib (`app/lib/cashu/`) + +| File | Imports | Buckets | +|---|---|---| +| `utils.ts` | `Keyset, MeltQuoteBolt11Response, MeltQuoteState, Mint, MintKeyset, MintQuoteBolt11Response, Proof, Wallet, splitAmount, Token` | A(3), D(2), E(1) — `splitAmount → Amount[]`; `ExtendedCashuWallet.getFeesEstimateToReceiveAtLeast(amount: number)` boundary; `meltProofsIdempotent` change-proof return + `meltQuote.amount` arithmetic; existing TODO at line 133 about `cent`/`usd` mismatch is exactly the cleanup gudnuf has in mind | +| `token.ts` | `CheckStateEnum, Proof, Token, TokenMetadata, Wallet, getEncodedToken, getTokenMetadata` | **B (DELETE wrapper)** — `encodeToken()` at lines 44-51 has an explicit TODO citing cashu-ts#536 (the v4 fix). Delete this function entirely and inline call sites to `getEncodedToken`. Also: `new Wallet(token.mint, { unit: token.unit })` then `wallet.checkProofsStates(token.proofs)` will keep working but consider `wallet.decodeToken` pattern | +| `proof.ts` | `Proof, hashToCurve` | A(1) — `sumProofs` reduces `acc + proof.amount` with `acc = 0`. v4 `Proof.amount` is `Amount` (or `AmountLike`), so this needs `Amount.zero()` + `.add()` or just defer to the new upstream `sumProofs` | +| `proof.test.ts` | `Proof` | A — test fixtures use literal `amount: number` | +| `secret.ts` | `Secret, SecretKind, parseSecret` | — no v4 impact | +| `secret.test.ts` | — | — | +| `payment-request.ts` | `PaymentRequest, decodePaymentRequest` | A(1) — `PaymentRequest.amount` is now `Amount \| undefined` | +| `payment-request.test.ts` | — | A — test fixtures | +| `mint-validation.ts` | `MintInfo, MintKeyset, WebSocketSupport` | — no v4 impact (raw DTOs unchanged) | +| `protocol-extensions.ts` | `GetInfoResponse, MintInfo, MintQuoteBolt11Response` | D — `MintQuoteFee.fee?: number` still works, but if our agicash fork eventually returns `Amount`, this becomes A too | +| `error-codes.ts` | local enum only | C(0 direct) — but it's *the* point of intersection with PR #1090. v4 introduced `CTSError` base class — we can keep the enum but consider routing through `CTSError` for classification | +| `melt-quote-subscription.ts` | `MeltQuoteBolt11Response, MeltQuoteState` | A(2) — `meltQuote.amount > quoteData.inputAmount` comparison at line 71; `meltQuote.amount` is now `Amount`, `inputAmount` is `number` | +| `melt-quote-subscription-manager.ts` | `MeltQuoteBolt11Response` | — type-only | +| `mint-quote-subscription-manager.ts` | `MintQuoteBolt11Response` | — type-only | +| `blind-signature-matching.ts` | `HasKeysetKeys, OutputData, Proof, SerializedBlindedSignature, pointFromHex, verifyDLEQProof_reblind` | A(1) — `sig.amount` is used as object key in `keyset.keys[sig.amount]` (line 50). `Amount` is not a valid object key — need `.toNumber()` or `.toString()` | +| `blind-signature-matching.test.ts` | `OutputData, SerializedBlindedSignature, createBlindSignature, createDLEQProof, pointFromHex` | D(1) — `createBlindSignature` lost its `amount` param in v4. Test fixtures must drop the arg | +| `types.ts` | local zod schemas | A — `ProofSchema.amount = z.number()` (line 34) needs a decision (see Q2) | + +### 2.2 Send / Receive services (the hot path) + +| File | Imports | Buckets | +|---|---|---| +| `features/send/cashu-send-quote-service.ts` | `MeltQuoteBolt11Response, MeltQuoteState, OutputData` | **A(many), D(1)** — `meltQuote.amount + meltQuote.fee_reserve` (lines 152, 252); `sumOfSendProofs < amountWithLightningFee` (line 173); `selectProofs(account, amount: number)` and `getFeesForProofs(send) → Amount` mismatch (lines 533, 565); `meltQuote.expiry * 1000` (line 240) — v4 allows `null` | +| `features/send/cashu-send-swap-service.ts` | `MintOperationError, OutputData, Proof, Wallet, splitAmount` | A(many), C(1) — same Amount churn; `getFeesForProofs` is the main hot spot | +| `features/send/cashu-send-quote-hooks.ts` | `MeltQuoteBolt11Response, MintOperationError` | C(1) — `error instanceof MintOperationError` retry gate. Unchanged in v4 | +| `features/send/cashu-send-quote-repository.ts` | `Proof` (type) | A — proof persistence shape (see Q2) | +| `features/send/cashu-send-swap-repository.ts` | `Proof` (type) | A — proof persistence shape (see Q2) | +| `features/send/proof-state-subscription-manager.ts` | `Proof, ProofState` | — type-only; `ProofState` unchanged | +| `features/send/share-cashu-token.tsx` | `Token` | — type-only | +| `features/receive/cashu-receive-quote-service.ts` | `MintOperationError, MintQuoteState, OutputData, Proof, splitAmount` | **A(many), C(2), D(1)** — `quote.amount.toNumber(cashuUnit)` already converts to Money internally; `splitAmount(...) → Amount[]` (line 249) needs `.map(a => a.toNumber())` if `outputAmounts: number[]` stays in the DB; `MintOperationError && error.code === CashuErrorCodes.X` retry gate (line 333); `wallet.ops.mintBolt11(amount, { ... amount, ... }).asCustom(outputData).run()` — `amount` field on the manually-constructed quote object is now `Amount` | +| `features/receive/cashu-receive-swap-service.ts` | `MintOperationError, OutputData, Token, Wallet, splitAmount` | A(many), C(1) — `wallet.getFeesForProofs(token.proofs) → Amount`; `sumProofs(token.proofs) - fee` (line 74) Amount-vs-Amount arithmetic; `splitAmount → Amount[]` | +| `features/receive/cashu-receive-quote-hooks.ts` | `HttpResponseError, MintOperationError, MintQuoteBolt11Response, NetworkError, WebSocketSupport` | **C(2)** — `error instanceof HttpResponseError && error.status === 429` (line 387) — candidate for `RateLimitError` + `parseRetryAfter`; `MintOperationError` retry gate (line 705); `meltProofsIdempotent({ ..., amount: quote.amount.toNumber(cashuUnit) })` — partial melt-quote shape passes `amount` as raw number to wallet, will break v4's `MeltQuoteBaseResponse.amount: Amount` typing | +| `features/receive/cashu-receive-quote-core.ts` | `MintQuoteBolt11Response, Proof` | A(2), D(1) — `wallet.createLockedMintQuote(amount.toNumber(cashuUnit), ...)` returns `MintQuoteBolt11Response` whose `.amount` and `.expiry` are now `Amount` / nullable; `mintQuoteResponse.expiry * 1000` (line 274) crashes on `null`; `mintQuoteResponse.fee` is local agicash CDK extension — keep typed as `number` | +| `features/receive/cashu-receive-quote-repository.ts` | `Proof` (type) | A — `proofs.flatMap((x) => [x.amount, x.secret])` (line 319) — `x.amount` becomes `Amount`, currently encrypted as `number` | +| `features/receive/cashu-receive-quote-service.server.ts` | `MintQuoteState` | — enum-only | +| `features/receive/cashu-receive-swap-repository.ts` | `Proof, Token` | A — same proof persistence shape | +| `features/receive/cashu-receive-swap-hooks.ts` | `Token` | — type-only | +| `features/receive/receive-cashu-token-service.ts` | `Token` | — type-only | +| `features/receive/receive-cashu-token-quote-service.ts` | `MeltQuoteBolt11Response, Token` | A — `MeltQuoteBolt11Response.amount/fee_reserve` | +| `features/receive/receive-cashu-token-hooks.ts` | `NetworkError, Proof, Token` | C(1) — `error instanceof NetworkError` | +| `features/receive/receive-cashu-token.tsx` | `Token` | — type-only | +| `features/receive/claim-cashu-token-service.ts` | `Token` | — type-only | +| `features/receive/spark-receive-quote-core.ts` | `Proof` (type) | A — `Proof` in our own type literals | +| `features/receive/spark-receive-quote-hooks.ts` | `MintOperationError, NetworkError` | C(1) | + +### 2.3 Shared / shell + +| File | Imports | Buckets | +|---|---|---| +| `features/shared/cashu.ts` | `AuthProvider, GetKeysResponse, GetKeysetsResponse, KeyChain, Mint, NetworkError, Token, getDecodedToken` | **D (high)** — `getDecodedToken(result.encoded, keysetIds)` (line 235) — already passes keyset IDs (good), but should consider moving to `getTokenMetadata` + `wallet.decodeToken` for the cleaner path; `KeyChain.mintToCacheDTO(wallet.unit, mintUrl, unitKeysets, [activeKeysForUnit])` (line 344) — first arg `unit` REMOVED in v4; `tokenToMoney` converts `sumProofs(token.proofs)` — sumProofs now returns `Amount`; `encodeToken` import comes from local wrapper (to be deleted, see B) | +| `features/shared/agicash-mint-auth-provider.ts` | `AuthProvider` (type) | — type-only | +| `features/accounts/cashu-account.ts` | `Proof` (type) | **A (load-bearing)** — `CashuProofSchema.amount = z.number()` (line 10), `toProof(proof: CashuProof): Proof` builds a `Proof` literal with `amount: proof.amount` (number). v4 `Proof.amount` is `Amount`. Either: (a) keep DB as `number`, wrap with `Amount.from()` in `toProof`; or (b) propagate `Amount` deeper | +| `features/settings/accounts/account-proofs.tsx` | `CheckStateEnum` | — enum-only | +| `features/settings/accounts/add-mint-form.tsx` | `MintKeyset` (type) | — type-only | +| `features/transactions/transaction-additional-details.tsx` | `CheckStateEnum, Proof` | A — `Proof` literals | +| `features/scan/classify-input.test.ts` | `Token, getEncodedToken` | **D (TEST BREAK)** — `getEncodedToken(CASHU_TOKEN, { version: 3 })` and `{ version: 4 }` at lines 20-21. Both options REMOVED in v4. Plus test `Token` literal with `amount: 1` (number) needs `Amount.from(1)` | + +### 2.4 Docs + +- `docs/migrations/cashu-ts-v3.md`, `cashu-ts-v3-api-audit.md`, `cashu-ts-v3-todo.md` — existing v3 migration docs. Leave in place; create new `docs/migrations/cashu-ts-v4.md` mirror as the project journal. + +--- + +## Section 3 — Per-bucket counts and pain ranking + +### Bucket A — Amount-type migration (~17 files touched, dozens of sites) +**Top 3 most painful:** +1. **`app/features/accounts/cashu-account.ts`** — `CashuProofSchema` is the DB-encryption boundary. Decision needed before code changes (see Q2). Every other Proof-touching site downstream depends on this. +2. **`app/features/send/cashu-send-quote-service.ts`** — densest arithmetic (lines 152, 252, 254, 286, 446). Money/Amount/number all collide in `selectProofs`, change-proof calc, and insufficient-balance comparisons. +3. **`app/features/receive/cashu-receive-quote-service.ts`** — splits across three units: persisted `Money.toNumber(cashuUnit)`, mint API `Amount`, manually-constructed mint-quote object literal at line 318. Subtle. + +### Bucket B — Encoding fix (1 file + downstream) +**Top 3 most painful:** +1. **`app/lib/cashu/token.ts`** — `encodeToken()` wrapper at lines 44-51 has the explicit TODO referencing cashu-ts#536. Delete the function, update the single import in `features/shared/cashu.ts:38`. +2. (None else — this is a one-shot cleanup.) + +### Bucket C — Error surface (5 files, intersects PR #1090) +**Top 3 most painful:** +1. **`app/features/receive/cashu-receive-quote-hooks.ts`** — only call site that already inspects `HttpResponseError.status === 429`. Either adopt v4's `RateLimitError` + `parseRetryAfter` here, or leave alone. This is also where `meltProofsIdempotent` is invoked from the React layer. +2. **`app/features/receive/cashu-receive-quote-service.ts`** + **`cashu-receive-swap-service.ts`** + **`cashu-send-swap-service.ts`** — all three do `error instanceof MintOperationError && [LIST].includes(error.code)` for restore-recovery. These are exactly the patterns PR #1090's classifier needs to consume. Coordinate landing order. +3. **`app/features/send/cashu-send-quote-hooks.ts`** + **`spark-receive-quote-hooks.ts`** + **`receive-cashu-token-hooks.ts`** — simpler `instanceof` retry gates that don't read `.code`. Probably untouched by v4, but worth verifying `MintOperationError` / `NetworkError` / `HttpResponseError` shapes stayed stable (memory says yes — `MintOperationError` is not in v4's rename table; `CTSError` is the new base class). + +### Bucket D — Other API drift (5+ sites) +**Top 3 most painful:** +1. **`app/features/shared/cashu.ts:344`** — `KeyChain.mintToCacheDTO(wallet.unit, mintUrl, unitKeysets, [activeKeysForUnit])` — first arg `unit` REMOVED. Plus cache no longer per-unit — could simplify the 3-query TanStack setup (see follow-up in v3 todo doc). +2. **`app/features/scan/classify-input.test.ts:20-21`** — `{ version: 3 }` and `{ version: 4 }` options on `getEncodedToken` are gone; will break tests immediately. +3. **`app/features/receive/cashu-receive-quote-core.ts:274`** + **`app/features/send/cashu-send-quote-service.ts:240`** — `quote.expiry` may now be `null`; both unconditionally do `new Date(expiry * 1000)`. Null-guards required. + +### Bucket E — Money-unit cleanup opportunity (architectural, not strictly required) +**Top 3 most painful (but most rewarding):** +1. **`app/lib/cashu/utils.ts`** — already has TODO at line 133 calling out the `cent`/`usd` mismatch. v4's `AmountWithUnit` (v4.4) is exactly the primitive that could replace `getCashuUnit/getCashuProtocolUnit` plumbing — but it's a different abstraction from `Money` (which carries currency + display formatting). Need a design conversation. (See Q1.) +2. **`app/lib/money/money.ts`** + **`types.ts`** — agicash's `Money` is `Big`-backed (`NumberInput = number | string | Big`). v4's `Amount` is `bigint`-backed. There's no direct value-object interop — `Money.toNumber(cashuUnit)` will still be how we cross the boundary. The real question is whether `Money` should grow a `Money.toAmount()` helper and stop us from passing raw `number` to cashu-ts. +3. **`app/features/receive/cashu-receive-quote-service.ts`** + **`cashu-receive-swap-service.ts`** + **`cashu-send-swap-service.ts`** — the `getCashuUnit(currency)` + `money.toNumber(cashuUnit)` dance appears identically in ~6 places. After v4, this becomes a perfect refactor target. + +--- + +## Section 4 — Open questions for gudnuf + +> These should be answered before any code lands. The first two are blocking. + +**Q1 (BLOCKING) — Amount strategy?** +The v4 SKILL doc forces a choice up-front: +- **(a) Adopt `Amount` natively** — propagate `Amount` / `AmountLike` through agicash domain code; use `Amount` helpers (`.add`, `.scaledBy`, etc.) for arithmetic; call `.toNumber()` only at genuine number-only boundaries (e.g. `Money` constructor, DB, React props). +- **(b) Convert at the boundary** — call `.toNumber()` immediately on every `Amount` the library returns; leave all internal types as `number`. Simpler, but reintroduces precision risk at scale. + +Recommendation: **(b)** for the migration PR itself (mechanical, minimal diff), then a follow-up audit to migrate the 3 hot files in Bucket E to native `Amount` where it improves the Money/cashu boundary. + +**Q2 (BLOCKING) — `Proof.amount` in encrypted DB payload?** +`CashuProofSchema.amount = z.number()` and `toProof()` produces `amount: number`. v4 `Proof.amount` is `Amount`. Three options: +- (a) Keep DB as `number`. `toProof()` calls `Amount.from(proof.amount)`. Repositories decrypt and store `number`. Simplest; works because wallet APIs accept `ProofLike[]` (proof with `amount: AmountLike`). +- (b) Migrate DB rows to store `Amount.toJSON()` string. Forward-compatible with bigint, but requires a data migration and breaks the current encryption-batch shape (`proofs.flatMap((x) => [x.amount, x.secret])` at `cashu-receive-quote-repository.ts:319` would need to stringify). +- (c) Store as `bigint`. Cleanest in-memory, but Postgres BIGINT vs JS bigint vs `Money`(Big.js) is a three-way headache. + +Recommendation: **(a)**. Keep DB shape, only normalize at read time. + +**Q3 — Are we OK with the v4 `expiry: null` semantics for amountless / never-expiring quotes?** +Two sites currently assume non-null (`receive/cashu-receive-quote-core.ts:274`, `send/cashu-send-quote-service.ts:240`). Need a product call on what `expiry: null` means at the wallet UI level — "never expires" or "show no countdown"? + +**Q4 — Adopt `RateLimitError` + `ResponseMeta` now or follow-up?** +v4.0 ships explicit 429 handling. Today we have one site that inspects `HttpResponseError.status === 429`. Cheap to convert during the migration; bigger payoff if we also wire `ResponseMeta` for per-mint observability — but that's a separate PR. + +**Q5 — `wallet.decodeToken` adoption?** +Today `features/shared/cashu.ts:235` already calls `getDecodedToken(encoded, keysetIds)` with the keyset list from TanStack Query — it's safe and explicit. v4's idiomatic replacement is `getTokenMetadata(str)` to get the mint URL, then build the wallet, then `wallet.decodeToken(str)`. We already build the wallet right after, so the migration is small. Worth doing in the same PR, or split? + +**Q6 — Multi-unit `KeyChainCache` simplification?** +v4 makes the cache mint-wide (covers all units). Today we maintain 3 separate TanStack queries (`mintInfo`, `allMintKeysets`, `mintKeys`) and a per-unit cache. The v3 todo doc already flagged this as a candidate cleanup. Do it in this PR or defer? + +**Q7 — `meltProofsIdempotent` over `prepareMelt`/`completeMelt`?** +The v3 todo doc already flagged switching to `prepareMelt()` + `completeMelt()` as a P2. v4 adds `createMeltChangeProofs` for async melt change. Both could obsolete our `matchBlindSignaturesToOutputData` workaround. Out of scope for the v4 PR itself but worth a follow-up issue. + +**Q8 — `CTSError` base class for PR #1090?** +v4 introduced `CTSError` as the common ancestor for cashu-ts errors. PR #1090 (#cashu-error-codes umbrella, send-hardening) is building a classifier. Discuss with Josip whether the classifier should `instanceof CTSError` rather than `instanceof MintOperationError | NetworkError | HttpResponseError`. + +**Q9 — `AmountWithUnit` vs `Money` long-term?** +v4.4 introduced `AmountWithUnit` for multi-unit apps. It's not `Money` — it's a unit-aware arithmetic wrapper on `Amount`, without currency formatting. Long-term, would we ever fold `Money` onto `AmountWithUnit` (with display layer on top)? Or stay split? Doesn't block the v4 PR; useful to name now. + +**Q10 — Do we want the `requireSigDleq` strict-DLEQ Wallet option?** +v4.0 ships this as opt-in. We already require NUT-12 (`mint-validation.ts` requires nut 12). Could harden by enforcing strict signature DLEQ — but this might reject proofs from older mints. Test mint compatibility check needed. + +--- + +## Section 5 — Recommended PR sequencing + +### Proposal: **3 PRs.** Single mega-PR is doable but pre-launch makes review risky. + +**PR 1 — Mechanical v4 upgrade (boundary strategy, no behavior change)** +Goal: get the codebase compiling on v4.5.x with minimum semantic drift. Choice (b) on Q1 (convert at boundary) and choice (a) on Q2 (keep DB as number). + +Includes: +- Bump `@cashu/cashu-ts` to `^4.5.0` in `package.json` + `bun.lock` +- All Bucket A sites: add `.toNumber()` at the library call boundary, leave internal types as `number`. `splitAmount(...).map(a => a.toNumber())`, `wallet.getFeesForProofs(...).toNumber()`, etc. +- `toProof()` in `features/accounts/cashu-account.ts`: wrap `amount: Amount.from(proof.amount)` so wallet APIs receive a valid `Proof`. +- Bucket B: delete `encodeToken` wrapper in `app/lib/cashu/token.ts`; update single import in `features/shared/cashu.ts`. Drop the wrapper's TODO comment. +- Bucket D — must-fix: + - `KeyChain.mintToCacheDTO(mintUrl, unitKeysets, [activeKeysForUnit])` — drop the `wallet.unit` arg in `features/shared/cashu.ts:344`. + - Update `classify-input.test.ts` — remove `{ version: N }` options; convert proof `amount: 1` → `Amount.from(1)`. + - Drop `amount` arg from `createBlindSignature` call in `blind-signature-matching.test.ts`. + - Null-guard `expiry` in `cashu-receive-quote-core.ts:274` and `cashu-send-quote-service.ts:240`. + - `blind-signature-matching.ts:50`: `keyset.keys[sig.amount.toNumber()]`. +- Spot-check: `tokenToMoney` in `features/shared/cashu.ts:68` — `sumProofs` now returns `Amount`; wrap with `.toNumber()`. +- Verify `bun run fix:all` passes (zero type errors). Smoke test send + receive on testnet mint. + +**Out of scope for PR 1:** any error-surface change, any `Money`/`AmountWithUnit` rework, `KeyChainCache` consolidation, `getTokenMetadata` adoption, `prepareMelt`/`completeMelt`, strict DLEQ. + +**PR 2 — `KeyChainCache` simplification + `wallet.decodeToken` adoption** +Goal: realize the cache simplification v4 enables. Single-unit cache → mint-wide cache. Q5 + Q6. + +- Collapse the 3 TanStack queries (`mintInfo`, `allMintKeysets`, `mintKeys`) into one `KeyChainCache`-backed query if it cleanly maps. +- Convert `decodeCashuToken` in `features/shared/cashu.ts:225` to `getTokenMetadata` → build wallet → `wallet.decodeToken(str)`. +- Audit `ensureKeysetKeys` call sites to see if mint-wide cache obsoletes them. + +**PR 3 — Money/Amount cleanup at the cashu boundary** +Goal: kill the `cent`/`usd` plumbing TODO at `utils.ts:133` and remove duplicated `getCashuUnit + .toNumber(cashuUnit)` patterns. Q1 follow-up. + +- Add `Money.toAmount(unit)` helper that returns `Amount.from(money.toNumber(unit))`. +- Optionally adopt `Amount` natively in `cashu-receive-quote-service.ts` + `cashu-receive-swap-service.ts` + `cashu-send-swap-service.ts` (the 3 hottest files for E). +- Consider `ExtendedCashuWallet` absorbing the unit conversion (per the existing TODO). + +### Follow-up issues (not PRs) + +- `RateLimitError` / `ResponseMeta` adoption (Q4). +- `prepareMelt` / `completeMelt` adoption + retire `matchBlindSignaturesToOutputData` (Q7, also in v3 todo). +- `CTSError` integration in PR #1090 classifier (Q8). +- Strict DLEQ enforcement (Q10). +- Long-term `AmountWithUnit` vs `Money` design (Q9). + +--- + +## Appendix — full grep result + +50 files matched `@cashu/cashu-ts`. Non-code (4): `package.json`, `bun.lock`, `CLAUDE.md`, `docs/migrations/*` (3). First-party code (24 listed in §2). Remaining = test/route files implicitly covered by service updates. From 664d8be1e650a7ab486057112bd2b212044f6b3c Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 22 May 2026 11:42:27 -0700 Subject: [PATCH 2/8] chore(cashu-ts): bump @cashu/cashu-ts to 4.5.0 --- bun.lock | 12 ++++++------ package.json | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index 662d6a66a..056daa60a 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,7 @@ "@agicash/breez-sdk-spark": "0.13.5-1", "@agicash/opensecret": "0.1.0", "@agicash/qr-scanner": "0.1.2", - "@cashu/cashu-ts": "3.6.1", + "@cashu/cashu-ts": "4.5.0", "@noble/ciphers": "1.3.0", "@noble/curves": "1.9.7", "@noble/hashes": "1.8.0", @@ -177,7 +177,7 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="], - "@cashu/cashu-ts": ["@cashu/cashu-ts@3.6.1", "", { "dependencies": { "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "@scure/base": "^2.0.0", "@scure/bip32": "^2.0.1" } }, "sha512-ynncvX5vv/m2Dzp28m1ApxNsmrsw1p6laOdOnvlHVOPK3x4Nz3K/ScV7UjnbuwubTVeIcKP1FXEdycedhhlJbQ=="], + "@cashu/cashu-ts": ["@cashu/cashu-ts@4.5.0", "", { "dependencies": { "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@scure/base": "^2.2.0", "@scure/bip32": "^2.2.0" } }, "sha512-NoUOitUMaxzmA3k4qLozZyFM1qonBdNB2ZM55U45X9SwFIQovRDttVDFL6Dn39q3H8v6pUTYxOHeUKdAmY/V/g=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], @@ -495,7 +495,7 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="], - "@scure/base": ["@scure/base@2.0.0", "", {}, "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="], + "@scure/base": ["@scure/base@2.2.0", "", {}, "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg=="], "@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="], @@ -1535,11 +1535,11 @@ "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@cashu/cashu-ts/@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="], + "@cashu/cashu-ts/@noble/curves": ["@noble/curves@2.2.0", "", { "dependencies": { "@noble/hashes": "2.2.0" } }, "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ=="], - "@cashu/cashu-ts/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + "@cashu/cashu-ts/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], - "@cashu/cashu-ts/@scure/bip32": ["@scure/bip32@2.0.1", "", { "dependencies": { "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA=="], + "@cashu/cashu-ts/@scure/bip32": ["@scure/bip32@2.2.0", "", { "dependencies": { "@noble/curves": "2.2.0", "@noble/hashes": "2.2.0", "@scure/base": "2.2.0" } }, "sha512-zFr7t2F+a9+5tB7QbarF2HQNYrgjCNaoLAupZdKkrFMYMozJf5zqH2WJCQibMzm1qQ0QogrxVGO3qXfQDYMaQg=="], "@fastify/otel/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA=="], diff --git a/package.json b/package.json index eb3f3c527..de013fd3b 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@agicash/breez-sdk-spark": "0.13.5-1", "@agicash/opensecret": "0.1.0", "@agicash/qr-scanner": "0.1.2", - "@cashu/cashu-ts": "3.6.1", + "@cashu/cashu-ts": "4.5.0", "@noble/ciphers": "1.3.0", "@noble/curves": "1.9.7", "@noble/hashes": "1.8.0", From 0c38a4e6f167003e6137e289a867236ae1f8331a Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 22 May 2026 11:48:53 -0700 Subject: [PATCH 3/8] refactor(cashu): inline getEncodedToken, drop encodeToken wrapper cashu-ts v4.0 (#536) fixed the proof.id mutation bug in getEncodedToken, so the deep-clone wrapper in app/lib/cashu/token.ts is no longer needed. Call getEncodedToken directly at all sites. --- app/features/receive/receive-cashu-token.tsx | 7 +++---- app/features/send/share-cashu-token.tsx | 5 ++--- app/features/shared/cashu.ts | 4 ++-- app/lib/cashu/token.ts | 18 ------------------ 4 files changed, 7 insertions(+), 27 deletions(-) diff --git a/app/features/receive/receive-cashu-token.tsx b/app/features/receive/receive-cashu-token.tsx index 87e0d8570..c383b67ba 100644 --- a/app/features/receive/receive-cashu-token.tsx +++ b/app/features/receive/receive-cashu-token.tsx @@ -1,4 +1,4 @@ -import type { Token } from '@cashu/cashu-ts'; +import { type Token, getEncodedToken } from '@cashu/cashu-ts'; import { useMutation } from '@tanstack/react-query'; import { AlertCircle } from 'lucide-react'; import { useState } from 'react'; @@ -21,7 +21,6 @@ import { Button } from '~/components/ui/button'; import { useFeatureFlag } from '~/features/shared/feature-flags'; import { useBuildLinkWithSearchParams } from '~/hooks/use-search-params-link'; import { useToast } from '~/hooks/use-toast'; -import { encodeToken } from '~/lib/cashu/token'; import type { Currency } from '~/lib/money'; import { LinkWithViewTransition, @@ -88,7 +87,7 @@ function TokenAmountDisplay({ className="z-10 transition-transform active:scale-95" onClick={() => { copyToClipboard( - encodeToken(claimableToken ?? token, { removeDleq: true }), + getEncodedToken(claimableToken ?? token, { removeDleq: true }), ); toast({ title: 'Token copied to clipboard', @@ -387,7 +386,7 @@ export function PublicReceiveCashuToken({ token }: { token: Token }) { const mintRequiresTerms = accountRequiresGiftCardTermsAcceptance(sourceAccount); - const encodedToken = encodeToken(claimableToken ?? token, { + const encodedToken = getEncodedToken(claimableToken ?? token, { removeDleq: true, }); diff --git a/app/features/send/share-cashu-token.tsx b/app/features/send/share-cashu-token.tsx index f02955929..3a3824487 100644 --- a/app/features/send/share-cashu-token.tsx +++ b/app/features/send/share-cashu-token.tsx @@ -1,4 +1,4 @@ -import type { Token } from '@cashu/cashu-ts'; +import { type Token, getEncodedToken } from '@cashu/cashu-ts'; import { Banknote, Link, Share } from 'lucide-react'; import { useState } from 'react'; import { useCopyToClipboard } from 'usehooks-ts'; @@ -22,7 +22,6 @@ import { import useLocationData from '~/hooks/use-location'; import { useRedirectTo } from '~/hooks/use-redirect-to'; import { useToast } from '~/hooks/use-toast'; -import { encodeToken } from '~/lib/cashu/token'; import { normalizeMintUrl } from '~/lib/cashu/utils'; import type { Money } from '~/lib/money'; import { canShare, shareContent } from '~/lib/share'; @@ -42,7 +41,7 @@ type ShareOption = { }; const deriveTokenStrings = (token: Token, origin: string) => { - const encodedToken = encodeToken(token, { removeDleq: true }); + const encodedToken = getEncodedToken(token, { removeDleq: true }); const mintParam = encodeURIComponent( normalizeMintUrl(token.mint).replace(/^https?:\/\//, ''), ); diff --git a/app/features/shared/cashu.ts b/app/features/shared/cashu.ts index 3e9548854..2731cc475 100644 --- a/app/features/shared/cashu.ts +++ b/app/features/shared/cashu.ts @@ -11,6 +11,7 @@ import { NetworkError, type Token, getDecodedToken, + getEncodedToken, } from '@cashu/cashu-ts'; import { HDKey } from '@scure/bip32'; import { mnemonicToSeedSync } from '@scure/bip39'; @@ -35,7 +36,6 @@ import { MintBlocklistSchema, buildMintValidator, } from '~/lib/cashu/mint-validation'; -import { encodeToken } from '~/lib/cashu/token'; import { type Currency, type CurrencyUnit, Money } from '~/lib/money'; import { measureOperation } from '~/lib/performance'; import { computeSHA256 } from '~/lib/sha256'; @@ -161,7 +161,7 @@ export function getTokenHash(token: Token | string): Promise { if (typeof token === 'string') { return computeSHA256(token); } - return computeSHA256(encodeToken(token)); + return computeSHA256(getEncodedToken(token)); } const mintBlocklist = MintBlocklistSchema.parse( diff --git a/app/lib/cashu/token.ts b/app/lib/cashu/token.ts index cb71a9711..01afe053d 100644 --- a/app/lib/cashu/token.ts +++ b/app/lib/cashu/token.ts @@ -4,7 +4,6 @@ import { type Token, type TokenMetadata, Wallet, - getEncodedToken, getTokenMetadata, } from '@cashu/cashu-ts'; import { proofToY } from './proof'; @@ -33,23 +32,6 @@ export const getUnspentProofsFromToken = async ( }); }; -/** - * Encode a token without mutating the input. - * - * cashu-ts's getEncodedToken() mutates proof.id in place, truncating v2 keyset - * IDs to their short form. This wrapper deep-clones proofs before encoding. - * - * TODO: remove after upgrading to cashu-ts v4 (fixed in cashu-ts#536) - */ -export function encodeToken( - ...[token, opts]: Parameters -): ReturnType { - return getEncodedToken( - { ...token, proofs: token.proofs.map((p) => ({ ...p })) }, - opts, - ); -} - const CASHU_TOKEN_REGEX = /cashu[AB][A-Za-z0-9_-]+={0,2}/; /** From 297040312f4d24aecd55e930fd05aa625eebcf68 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 22 May 2026 11:49:01 -0700 Subject: [PATCH 4/8] fix(cashu): drop unit arg from KeyChain.mintToCacheDTO v4 KeyChain cache is mint-wide rather than unit-scoped, so the first unit argument has been removed from the signature. --- app/features/shared/cashu.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/features/shared/cashu.ts b/app/features/shared/cashu.ts index 2731cc475..47cec89da 100644 --- a/app/features/shared/cashu.ts +++ b/app/features/shared/cashu.ts @@ -342,7 +342,6 @@ export async function getInitializedCashuWallet({ authProvider, }); const keyChainCache = KeyChain.mintToCacheDTO( - wallet.unit, mintUrl, unitKeysets, [activeKeysForUnit], From 858c01ecf4193a4074b5ab0099998800edb32664 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 22 May 2026 11:51:02 -0700 Subject: [PATCH 5/8] fix(cashu): use bolt11 invoice expiry when mint omits quote expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v4 types MintQuoteBolt11Response.expiry as `number | null` to match NUT-23 — null means the mint did not communicate one, not that the quote never expires. Per NUT-23 the field *is* the bolt11 invoice expiry, so fall back to the decoded invoice's expiry (which decodeBolt11 already returns) when the mint omits it, instead of substituting a sentinel. MeltQuoteBolt11Response.expiry remains non-nullable in v4 per its type declaration, so no change is needed on the send-quote side. --- app/features/receive/cashu-receive-quote-core.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/features/receive/cashu-receive-quote-core.ts b/app/features/receive/cashu-receive-quote-core.ts index e8478990e..db3034607 100644 --- a/app/features/receive/cashu-receive-quote-core.ts +++ b/app/features/receive/cashu-receive-quote-core.ts @@ -271,7 +271,17 @@ export async function getLightningQuote( description, ); - const expiresAt = new Date(mintQuoteResponse.expiry * 1000).toISOString(); + const { + decoded: { paymentHash, expiryUnixMs }, + } = decodeBolt11(mintQuoteResponse.request); + + // NUT-23 defines mint quote expiry as the bolt11 invoice expiry; when the + // mint omits it, use the decoded invoice directly. + const expiresAt = new Date( + mintQuoteResponse.expiry !== null + ? mintQuoteResponse.expiry * 1000 + : expiryUnixMs, + ).toISOString(); const mintingFee = mintQuoteResponse.fee ? new Money({ @@ -281,10 +291,6 @@ export async function getLightningQuote( }) : undefined; - const { - decoded: { paymentHash }, - } = decodeBolt11(mintQuoteResponse.request); - return { mintQuote: mintQuoteResponse, lockingPublicKey, From 67c2e77b23c7d756b1394ffaba8e7bd706f232ab Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 22 May 2026 11:52:46 -0700 Subject: [PATCH 6/8] test(cashu): adapt fixtures to v4 signatures - classify-input.test.ts: drop the removed `{ version }` option from getEncodedToken (v4 has a single encoding) and wrap proof.amount in Amount.from() to match the new Proof type. - blind-signature-matching.test.ts: drop the `amount` arg from createBlindSignature (v4 takes only B_, privateKey, id) and read the amount from the local parameter wrapped in Amount.from(), since the returned BlindSignature no longer carries an amount field. --- app/features/scan/classify-input.test.ts | 8 ++--- .../cashu/blind-signature-matching.test.ts | 34 +++++++++++++++---- app/lib/cashu/token.test.ts | 31 ++++++++++++----- 3 files changed, 54 insertions(+), 19 deletions(-) diff --git a/app/features/scan/classify-input.test.ts b/app/features/scan/classify-input.test.ts index b2621e840..f53d2a288 100644 --- a/app/features/scan/classify-input.test.ts +++ b/app/features/scan/classify-input.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { type Token, getEncodedToken } from '@cashu/cashu-ts'; +import { Amount, type Token, getEncodedToken } from '@cashu/cashu-ts'; import { classifyInput } from './classify-input'; // -- Test fixtures -- @@ -9,7 +9,7 @@ const CASHU_TOKEN: Token = { proofs: [ { id: '009a1f293253e41e', - amount: 1, + amount: Amount.from(1), secret: 'test-secret-1', C: '02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904', }, @@ -17,8 +17,8 @@ const CASHU_TOKEN: Token = { unit: 'sat', }; -const CASHU_A_TOKEN = getEncodedToken(CASHU_TOKEN, { version: 3 }); -const CASHU_B_TOKEN = getEncodedToken(CASHU_TOKEN, { version: 4 }); +const CASHU_A_TOKEN = getEncodedToken(CASHU_TOKEN); +const CASHU_B_TOKEN = getEncodedToken(CASHU_TOKEN); // Real BOLT11 test vector from bolt11.test.ts (250,000 sats, "1 cup coffee") const BOLT11_INVOICE = diff --git a/app/lib/cashu/blind-signature-matching.test.ts b/app/lib/cashu/blind-signature-matching.test.ts index 47f948406..979887ccf 100644 --- a/app/lib/cashu/blind-signature-matching.test.ts +++ b/app/lib/cashu/blind-signature-matching.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test'; import { + Amount, OutputData, type SerializedBlindedSignature, createBlindSignature, @@ -35,11 +36,11 @@ function mintSign( B_: ReturnType, amount: number, ): SerializedBlindedSignature { - const blindSig = createBlindSignature(B_, PRIVATE_KEY, amount, KEYSET_ID); + const blindSig = createBlindSignature(B_, PRIVATE_KEY, KEYSET_ID); const dleq = createDLEQProof(B_, PRIVATE_KEY); return { id: blindSig.id, - amount: blindSig.amount, + amount: Amount.from(amount), C_: blindSig.C_.toHex(), dleq: { s: bytesToHex(dleq.s), @@ -87,7 +88,7 @@ describe('matchBlindSignaturesToOutputData', () => { ); expect(proofs).toHaveLength(5); - expect(proofs.map((p) => p.amount)).toEqual(amounts); + expect(proofs.map((p) => p.amount.toNumber())).toEqual(amounts); }); test('matches reversed signatures', () => { @@ -104,7 +105,9 @@ describe('matchBlindSignaturesToOutputData', () => { expect(proofs).toHaveLength(5); // Proofs should match the reversed signature order (each proof matches its signature) - expect(proofs.map((p) => p.amount)).toEqual([...amounts].reverse()); + expect(proofs.map((p) => p.amount.toNumber())).toEqual( + [...amounts].reverse(), + ); // But each proof's secret should match the CORRECT OutputData, not the positional one for (const proof of proofs) { expect(proof.dleq).toBeDefined(); @@ -151,7 +154,7 @@ describe('matchBlindSignaturesToOutputData', () => { ); expect(proofs).toHaveLength(1); - expect(proofs[0].amount).toBe(1024); + expect(proofs[0].amount.toNumber()).toBe(1024); }); test('matches empty signatures', () => { @@ -181,7 +184,7 @@ describe('matchBlindSignaturesToOutputData', () => { ); expect(proofs).toHaveLength(3); - expect(proofs.map((p) => p.amount)).toEqual([2048, 1024, 512]); + expect(proofs.map((p) => p.amount.toNumber())).toEqual([2048, 1024, 512]); }); test('throws when signature has no DLEQ', () => { @@ -219,6 +222,25 @@ describe('matchBlindSignaturesToOutputData', () => { ).toThrow('No matching OutputData'); }); + test('throws when DLEQ proof is corrupted (toProof rejects internally)', () => { + const amounts = [1024]; + const outputData = createTestOutputData(amounts, TEST_SEED, 90); + const [validSig] = signOutputData(outputData, amounts); + + // Corrupt one byte of dleq.s so it stays valid-shaped (64 hex chars) but + // fails verification. toProof must reject this; nothing should match. + const corruptedByte = validSig.dleq?.s.startsWith('00') ? 'ff' : '00'; + const corruptedSig: SerializedBlindedSignature = { + ...validSig, + // biome-ignore lint/style/noNonNullAssertion: signOutputData always sets dleq + dleq: { ...validSig.dleq!, s: corruptedByte + validSig.dleq!.s.slice(2) }, + }; + + expect(() => + matchBlindSignaturesToOutputData([corruptedSig], outputData, TEST_KEYSET), + ).toThrow('No matching OutputData'); + }); + test('produces identical proofs regardless of signature order', () => { const amounts = [1024, 512, 8]; const outputData = createTestOutputData(amounts, TEST_SEED, 80); diff --git a/app/lib/cashu/token.test.ts b/app/lib/cashu/token.test.ts index 02f226247..af78dbebc 100644 --- a/app/lib/cashu/token.test.ts +++ b/app/lib/cashu/token.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from 'bun:test'; -import { type Token, getDecodedToken, getEncodedToken } from '@cashu/cashu-ts'; +import { + Amount, + type Token, + getDecodedToken, + getEncodedToken, +} from '@cashu/cashu-ts'; import { extractCashuToken } from './token'; // A real v1 cashuA token (v1 keyset ID starts with "00") @@ -8,7 +13,7 @@ const V1_TOKEN: Token = { proofs: [ { id: '009a1f293253e41e', - amount: 1, + amount: Amount.from(1), secret: 'test-secret-1', C: '02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904', }, @@ -16,8 +21,11 @@ const V1_TOKEN: Token = { unit: 'sat', }; -const V1_ENCODED_A = getEncodedToken(V1_TOKEN, { version: 3 }); -const V1_ENCODED_B = getEncodedToken(V1_TOKEN, { version: 4 }); +// cashu-ts only decodes the cashuA format, so this fixture is a hand-encoded +// cashuA serialization of V1_TOKEN. +const V1_ENCODED_A = + 'cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vbWludC5leGFtcGxlLmNvbSIsInByb29mcyI6W3siaWQiOiIwMDlhMWYyOTMyNTNlNDFlIiwiYW1vdW50IjoxLCJzZWNyZXQiOiJ0ZXN0LXNlY3JldC0xIiwiQyI6IjAyNjk4YzRlMmI1Zjk1MzRjZDA2ODdkODc1MTNjNzU5NzkwY2Y4MjlhYTU3MzkxODRhM2UzNzM1NDcxZmJkYTkwNCJ9XX1dLCJ1bml0Ijoic2F0In0'; +const V1_ENCODED_B = getEncodedToken(V1_TOKEN); describe('extractCashuToken', () => { test('extracts a valid cashuA token with metadata from content', () => { @@ -54,7 +62,7 @@ const V2_TOKEN: Token = { proofs: [ { id: V2_KEYSET_ID, - amount: 1, + amount: Amount.from(1), secret: 'test-secret-v2', C: '02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904', }, @@ -62,10 +70,13 @@ const V2_TOKEN: Token = { unit: 'sat', }; -// cashuA preserves full keyset IDs in the JSON -const V2_ENCODED_A = getEncodedToken(V2_TOKEN, { version: 3 }); +// cashuA preserves full keyset IDs in the JSON. cashu-ts only decodes the +// cashuA format, so this fixture is a hand-encoded cashuA serialization of +// V2_TOKEN. +const V2_ENCODED_A = + 'cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vdjJtaW50LmV4YW1wbGUuY29tIiwicHJvb2ZzIjpbeyJpZCI6IjAxYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYSIsImFtb3VudCI6MSwic2VjcmV0IjoidGVzdC1zZWNyZXQtdjIiLCJDIjoiMDI2OThjNGUyYjVmOTUzNGNkMDY4N2Q4NzUxM2M3NTk3OTBjZjgyOWFhNTczOTE4NGEzZTM3MzU0NzFmYmRhOTA0In1dfV0sInVuaXQiOiJzYXQifQ'; // cashuB truncates v2 keyset IDs to 16 chars (short ID) -const V2_ENCODED_B = getEncodedToken(V2_TOKEN, { version: 4 }); +const V2_ENCODED_B = getEncodedToken(V2_TOKEN); describe('getDecodedToken with v2 keyset IDs', () => { test('decodes a v2 cashuB token with keyset IDs', () => { @@ -93,6 +104,8 @@ describe('getDecodedToken with v2 keyset IDs', () => { const roundTripped = getDecodedToken(reEncoded, [V2_KEYSET_ID]); 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); + expect( + roundTripped.proofs[0].amount.equals(original.proofs[0].amount), + ).toBe(true); }); }); From b2495644c506472114820bab3d4d4fbfe76eea99 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 22 May 2026 11:53:33 -0700 Subject: [PATCH 7/8] fix(cashu): misc v4 adjustments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - blind-signature-matching: sig.amount is now an Amount value object, not a number, so it can't index keyset.keys directly — call toNumber(). - tokenToMoney: post-Bucket-A, sumProofs returns Amount; call toNumber() so Money still receives a primitive amount. --- app/features/shared/cashu.ts | 2 +- app/lib/cashu/blind-signature-matching.ts | 47 ++++++++--------------- 2 files changed, 17 insertions(+), 32 deletions(-) diff --git a/app/features/shared/cashu.ts b/app/features/shared/cashu.ts index 47cec89da..fc7310fd4 100644 --- a/app/features/shared/cashu.ts +++ b/app/features/shared/cashu.ts @@ -67,7 +67,7 @@ function getCurrencyAndUnitFromToken(token: Token): { export function tokenToMoney(token: Token): Money { const { currency, unit } = getCurrencyAndUnitFromToken(token); - const amount = sumProofs(token.proofs); + const amount = sumProofs(token.proofs).toNumber(); return new Money({ amount, currency, diff --git a/app/lib/cashu/blind-signature-matching.ts b/app/lib/cashu/blind-signature-matching.ts index c21475d57..19643feb7 100644 --- a/app/lib/cashu/blind-signature-matching.ts +++ b/app/lib/cashu/blind-signature-matching.ts @@ -1,12 +1,9 @@ -import { - type HasKeysetKeys, - type OutputData, - type Proof, - type SerializedBlindedSignature, - pointFromHex, - verifyDLEQProof_reblind, +import type { + HasKeysetKeys, + OutputData, + Proof, + SerializedBlindedSignature, } from '@cashu/cashu-ts'; -import { hexToBytes } from '@noble/hashes/utils'; /** * Match blind signatures to output data using DLEQ verification. @@ -41,33 +38,21 @@ export function matchBlindSignaturesToOutputData( for (const i of unmatched) { const od = outputData[i]; - const proof = od.toProof(sig, keyset); - if (!proof.dleq) { + let proof: Proof; + try { + proof = od.toProof(sig, keyset); + } catch { + // OutputData.toProof verifies DLEQ internally and throws on mismatch; + // treat that as a non-match and continue trying other OutputData. continue; } - const K = pointFromHex(keyset.keys[sig.amount]); - const C = pointFromHex(proof.C); - - const isValid = verifyDLEQProof_reblind( - new TextEncoder().encode(proof.secret), - { - s: hexToBytes(proof.dleq.s), - e: hexToBytes(proof.dleq.e), - r: od.blindingFactor, - }, - C, - K, - ); - - if (isValid) { - result.push(proof); - matchedIndices.push(i); - unmatched.delete(i); - matched = true; - break; - } + result.push(proof); + matchedIndices.push(i); + unmatched.delete(i); + matched = true; + break; } if (!matched) { From a7626a86c950575ac85d0de1b5c793b07842d7c6 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 22 May 2026 12:12:00 -0700 Subject: [PATCH 8/8] refactor(cashu): boundary-convert Amount to number --- app/features/accounts/cashu-account.ts | 4 +-- .../cashu-receive-quote-repository.server.ts | 10 ++++++- .../receive/cashu-receive-quote-repository.ts | 15 +++++++++-- .../receive/cashu-receive-quote-service.ts | 7 +++-- .../receive/cashu-receive-swap-repository.ts | 10 +++++-- .../receive/cashu-receive-swap-service.ts | 12 ++++----- .../receive-cashu-token-quote-service.ts | 8 +++--- .../spark-receive-quote-repository.server.ts | 10 ++++++- .../receive/spark-receive-quote-repository.ts | 10 ++++++- .../send/cashu-send-quote-repository.ts | 2 +- app/features/send/cashu-send-quote-service.ts | 21 ++++++++------- .../send/cashu-send-swap-repository.ts | 5 +++- app/features/send/cashu-send-swap-service.ts | 10 ++++--- app/features/shared/cashu.ts | 10 +++---- .../transaction-additional-details.tsx | 6 ++--- app/lib/cashu/melt-quote-subscription.ts | 3 ++- app/lib/cashu/proof.test.ts | 8 +++--- app/lib/cashu/proof.ts | 8 +++--- app/lib/cashu/utils.ts | 27 ++++++++++++++----- 19 files changed, 125 insertions(+), 61 deletions(-) diff --git a/app/features/accounts/cashu-account.ts b/app/features/accounts/cashu-account.ts index edf069b2d..4f9208f8d 100644 --- a/app/features/accounts/cashu-account.ts +++ b/app/features/accounts/cashu-account.ts @@ -1,4 +1,4 @@ -import type { Proof } from '@cashu/cashu-ts'; +import { Amount, type Proof } from '@cashu/cashu-ts'; import { z } from 'zod/mini'; import { ProofSchema } from '~/lib/cashu/types'; @@ -25,7 +25,7 @@ export type CashuProof = z.infer; export const toProof = (proof: CashuProof): Proof => { return { id: proof.keysetId, - amount: proof.amount, + amount: Amount.from(proof.amount), secret: proof.secret, C: proof.unblindedSignature, dleq: proof.dleq, diff --git a/app/features/receive/cashu-receive-quote-repository.server.ts b/app/features/receive/cashu-receive-quote-repository.server.ts index fe329884e..4e6253a93 100644 --- a/app/features/receive/cashu-receive-quote-repository.server.ts +++ b/app/features/receive/cashu-receive-quote-repository.server.ts @@ -73,7 +73,15 @@ export class CashuReceiveQuoteRepositoryServer { description, mintingFee, cashuTokenMeltData: - receiveType === 'CASHU_TOKEN' ? params.meltData : undefined, + receiveType === 'CASHU_TOKEN' + ? { + ...params.meltData, + tokenProofs: params.meltData.tokenProofs.map((p) => ({ + ...p, + amount: p.amount.toNumber(), + })), + } + : undefined, totalFee, } satisfies z.input); diff --git a/app/features/receive/cashu-receive-quote-repository.ts b/app/features/receive/cashu-receive-quote-repository.ts index 99e3aaf7e..47ea10541 100644 --- a/app/features/receive/cashu-receive-quote-repository.ts +++ b/app/features/receive/cashu-receive-quote-repository.ts @@ -64,7 +64,15 @@ export class CashuReceiveQuoteRepository { description, mintingFee, cashuTokenMeltData: - receiveType === 'CASHU_TOKEN' ? params.meltData : undefined, + receiveType === 'CASHU_TOKEN' + ? { + ...params.meltData, + tokenProofs: params.meltData.tokenProofs.map((p) => ({ + ...p, + amount: p.amount.toNumber(), + })), + } + : undefined, totalFee, } satisfies z.input); @@ -316,7 +324,10 @@ export class CashuReceiveQuoteRepository { */ addedProofs: string[]; }> { - const dataToEncrypt = proofs.flatMap((x) => [x.amount, x.secret]); + const dataToEncrypt = proofs.flatMap((x) => [ + x.amount.toNumber(), + x.secret, + ]); const encryptedData = await this.encryption.encryptBatch(dataToEncrypt); const encryptedProofs = proofs.map((x, index) => { const encryptedDataIndex = index * 2; diff --git a/app/features/receive/cashu-receive-quote-service.ts b/app/features/receive/cashu-receive-quote-service.ts index d883bdd50..2cf68ecae 100644 --- a/app/features/receive/cashu-receive-quote-service.ts +++ b/app/features/receive/cashu-receive-quote-service.ts @@ -1,4 +1,5 @@ import { + Amount, MintOperationError, MintQuoteState, OutputData, @@ -246,7 +247,9 @@ export class CashuReceiveQuoteService { const keyset = wallet.getKeyset(keysetId); const cashuUnit = getCashuUnit(quote.amount.currency); const amountInCashuUnit = quote.amount.toNumber(cashuUnit); - const outputAmounts = splitAmount(amountInCashuUnit, keyset.keys); + const outputAmounts = splitAmount(amountInCashuUnit, keyset.keys).map((a) => + a.toNumber(), + ); const result = await this.cashuReceiveQuoteRepository.processPayment({ quote, @@ -322,7 +325,7 @@ export class CashuReceiveQuoteService { state: MintQuoteState.PAID, expiry: Math.floor(new Date(quote.expiresAt).getTime() / 1000), pubkey: lockingPublicKey, - amount, + amount: Amount.from(amount), unit: wallet.unit, }) .keyset(quote.keysetId) diff --git a/app/features/receive/cashu-receive-swap-repository.ts b/app/features/receive/cashu-receive-swap-repository.ts index 8cadb93c1..114a2c4fd 100644 --- a/app/features/receive/cashu-receive-swap-repository.ts +++ b/app/features/receive/cashu-receive-swap-repository.ts @@ -101,7 +101,10 @@ export class CashuReceiveSwapRepository { const receiveData = CashuSwapReceiveDbDataSchema.parse({ tokenMintUrl: token.mint, tokenAmount: inputAmount, - tokenProofs: token.proofs, + tokenProofs: token.proofs.map((p) => ({ + ...p, + amount: p.amount.toNumber(), + })), tokenDescription: token.memo, amountReceived: receiveAmount, outputAmounts, @@ -174,7 +177,10 @@ export class CashuReceiveSwapRepository { account: CashuAccount; addedProofs: string[]; }> { - const dataToEncrypt = proofs.flatMap((x) => [x.amount, x.secret]); + const dataToEncrypt = proofs.flatMap((x) => [ + x.amount.toNumber(), + x.secret, + ]); const encryptedData = await this.encryption.encryptBatch(dataToEncrypt); const encryptedProofs = proofs.map((x, index) => { const encryptedDataIndex = index * 2; diff --git a/app/features/receive/cashu-receive-swap-service.ts b/app/features/receive/cashu-receive-swap-service.ts index 5fdd7d302..10dfa58f1 100644 --- a/app/features/receive/cashu-receive-swap-service.ts +++ b/app/features/receive/cashu-receive-swap-service.ts @@ -70,7 +70,7 @@ export class CashuReceiveSwapService { const wallet = account.wallet; const keyset = wallet.getKeyset(); - const fee = wallet.getFeesForProofs(token.proofs); + const fee = wallet.getFeesForProofs(token.proofs).toNumber(); const amountToReceive = sumProofs(token.proofs) - fee; if (amountToReceive <= 0) { @@ -89,7 +89,9 @@ export class CashuReceiveSwapService { unit: cashuUnit, }); - const outputAmounts = splitAmount(amountToReceive, keyset.keys); + const outputAmounts = splitAmount(amountToReceive, keyset.keys).map((a) => + a.toNumber(), + ); return await this.receiveSwapRepository.create({ token, @@ -205,11 +207,7 @@ export class CashuReceiveSwapService { ) { try { return await wallet.ops - .receive({ - mint: wallet.mint.mintUrl, - proofs: receiveSwap.tokenProofs, - unit: wallet.unit, - }) + .receive(receiveSwap.tokenProofs) .asCustom(outputData) .run(); } catch (error) { diff --git a/app/features/receive/receive-cashu-token-quote-service.ts b/app/features/receive/receive-cashu-token-quote-service.ts index 4c3f6fbfb..af32b022d 100644 --- a/app/features/receive/receive-cashu-token-quote-service.ts +++ b/app/features/receive/receive-cashu-token-quote-service.ts @@ -107,7 +107,9 @@ export class ReceiveCashuTokenQuoteService { throw new Error('Must melt token to a different account than source'); } - const feesForProofs = sourceAccount.wallet.getFeesForProofs(token.proofs); + const feesForProofs = sourceAccount.wallet + .getFeesForProofs(token.proofs) + .toNumber(); const cashuReceiveFee = new Money({ amount: feesForProofs, currency: tokenAmount.currency, @@ -133,7 +135,7 @@ export class ReceiveCashuTokenQuoteService { ).toISOString(); const lightningFeeReserve = new Money({ - amount: quotes.meltQuote.fee_reserve, + amount: quotes.meltQuote.fee_reserve.toNumber(), currency: tokenAmount.currency, unit: sourceCashuUnit, }); @@ -251,7 +253,7 @@ export class ReceiveCashuTokenQuoteService { await sourceAccount.wallet.createMeltQuoteBolt11(paymentRequest); const amountRequired = new Money({ - amount: meltQuote.amount + meltQuote.fee_reserve, + amount: meltQuote.amount.toNumber() + meltQuote.fee_reserve.toNumber(), currency: sourceCurrency, unit: getCashuUnit(sourceCurrency), }); diff --git a/app/features/receive/spark-receive-quote-repository.server.ts b/app/features/receive/spark-receive-quote-repository.server.ts index 685b32c95..6d4bb7804 100644 --- a/app/features/receive/spark-receive-quote-repository.server.ts +++ b/app/features/receive/spark-receive-quote-repository.server.ts @@ -69,7 +69,15 @@ export class SparkReceiveQuoteRepositoryServer { amountReceived: amount, description, cashuTokenMeltData: - receiveType === 'CASHU_TOKEN' ? params.meltData : undefined, + receiveType === 'CASHU_TOKEN' + ? { + ...params.meltData, + tokenProofs: params.meltData.tokenProofs.map((p) => ({ + ...p, + amount: p.amount.toNumber(), + })), + } + : undefined, totalFee, } satisfies z.input); diff --git a/app/features/receive/spark-receive-quote-repository.ts b/app/features/receive/spark-receive-quote-repository.ts index 7a738a15b..13803a356 100644 --- a/app/features/receive/spark-receive-quote-repository.ts +++ b/app/features/receive/spark-receive-quote-repository.ts @@ -52,7 +52,15 @@ export class SparkReceiveQuoteRepository { amountReceived: amount, description, cashuTokenMeltData: - receiveType === 'CASHU_TOKEN' ? params.meltData : undefined, + receiveType === 'CASHU_TOKEN' + ? { + ...params.meltData, + tokenProofs: params.meltData.tokenProofs.map((p) => ({ + ...p, + amount: p.amount.toNumber(), + })), + } + : undefined, totalFee, } satisfies z.input); diff --git a/app/features/send/cashu-send-quote-repository.ts b/app/features/send/cashu-send-quote-repository.ts index 53f65d24c..1a1671eb7 100644 --- a/app/features/send/cashu-send-quote-repository.ts +++ b/app/features/send/cashu-send-quote-repository.ts @@ -241,7 +241,7 @@ export class CashuSendQuoteRepository { } satisfies z.input); const proofDataToEncrypt = changeProofs.flatMap((x) => [ - x.amount, + x.amount.toNumber(), x.secret, ]); diff --git a/app/features/send/cashu-send-quote-service.ts b/app/features/send/cashu-send-quote-service.ts index 24f6881c6..af1b41923 100644 --- a/app/features/send/cashu-send-quote-service.ts +++ b/app/features/send/cashu-send-quote-service.ts @@ -148,8 +148,10 @@ export class CashuSendQuoteService { const wallet = account.wallet; const meltQuote = await wallet.createMeltQuoteBolt11(paymentRequest); + const meltAmount = meltQuote.amount.toNumber(); + const feeReserve = meltQuote.fee_reserve.toNumber(); - const amountWithLightningFee = meltQuote.amount + meltQuote.fee_reserve; + const amountWithLightningFee = meltAmount + feeReserve; const { proofs, fee: proofsFee } = this.selectProofs( account, @@ -157,12 +159,12 @@ export class CashuSendQuoteService { ); const amountToReceive = new Money({ - amount: meltQuote.amount, + amount: meltAmount, currency: account.currency, unit: cashuUnit, }); const lightningFeeReserve = new Money({ - amount: meltQuote.fee_reserve, + amount: feeReserve, currency: account.currency, unit: cashuUnit, }); @@ -249,7 +251,9 @@ export class CashuSendQuoteService { const keyset = wallet.getKeyset(); const keysetId = keyset.id; - const amountWithLightningFee = meltQuote.amount + meltQuote.fee_reserve; + const meltAmount = meltQuote.amount.toNumber(); + const feeReserve = meltQuote.fee_reserve.toNumber(); + const amountWithLightningFee = meltAmount + feeReserve; const { proofs, fee: proofsFee } = this.selectProofs( account, @@ -260,12 +264,12 @@ export class CashuSendQuoteService { const totalAmountToSend = amountWithLightningFee + proofsFee; const amountToReceive = new Money({ - amount: meltQuote.amount, + amount: meltAmount, currency: account.currency, unit: cashuUnit, }); const lightningFeeReserve = new Money({ - amount: meltQuote.fee_reserve, + amount: feeReserve, currency: account.currency, unit: cashuUnit, }); @@ -283,8 +287,7 @@ export class CashuSendQuoteService { ); } - const maxPotentialChangeAmount = - proofsToSendSum - meltQuote.amount - proofsFee; + const maxPotentialChangeAmount = proofsToSendSum - meltAmount - proofsFee; const numberOfChangeOutputs = maxPotentialChangeAmount === 0 ? 0 @@ -562,7 +565,7 @@ export class CashuSendQuoteService { return { proofs: selectedProofs, - fee: account.wallet.getFeesForProofs(send), + fee: account.wallet.getFeesForProofs(send).toNumber(), }; } } diff --git a/app/features/send/cashu-send-swap-repository.ts b/app/features/send/cashu-send-swap-repository.ts index 7f0c3f354..b3ee42284 100644 --- a/app/features/send/cashu-send-swap-repository.ts +++ b/app/features/send/cashu-send-swap-repository.ts @@ -195,7 +195,10 @@ export class CashuSendSwapRepository { changeProofs: Proof[]; }) { const allProofs = proofsToSend.concat(changeProofs); - const proofDataToEncrypt = allProofs.flatMap((x) => [x.amount, x.secret]); + const proofDataToEncrypt = allProofs.flatMap((x) => [ + x.amount.toNumber(), + x.secret, + ]); const encryptedProofData = await this.encryption.encryptBatch(proofDataToEncrypt); diff --git a/app/features/send/cashu-send-swap-service.ts b/app/features/send/cashu-send-swap-service.ts index e611f55aa..9404f7187 100644 --- a/app/features/send/cashu-send-swap-service.ts +++ b/app/features/send/cashu-send-swap-service.ts @@ -154,8 +154,10 @@ export class CashuSendSwapService { const amountToKeep = sumProofs(inputProofs) - totalAmountToSend - cashuSendFee; outputAmounts = { - send: splitAmount(totalAmountToSend, keyset.keys), - change: splitAmount(amountToKeep, keyset.keys), + send: splitAmount(totalAmountToSend, keyset.keys).map((a) => + a.toNumber(), + ), + change: splitAmount(amountToKeep, keyset.keys).map((a) => a.toNumber()), }; } @@ -333,7 +335,7 @@ export class CashuSendSwapService { requestedAmountNumber, includeFeesInSendAmount, ); - const feeToSwapSelectedProofs = wallet.getFeesForProofs(send); + const feeToSwapSelectedProofs = wallet.getFeesForProofs(send).toNumber(); let proofAmountSelected = sumProofs(send); const amountToSend = requestedAmountNumber + feeToSwapSelectedProofs; @@ -370,7 +372,7 @@ export class CashuSendSwapService { )); proofAmountSelected = sumProofs(send); - const cashuSendFee = wallet.getFeesForProofs(send); + const cashuSendFee = wallet.getFeesForProofs(send).toNumber(); const cashuReceiveFee = estimatedFeeToReceive; if ( diff --git a/app/features/shared/cashu.ts b/app/features/shared/cashu.ts index fc7310fd4..efd7c1183 100644 --- a/app/features/shared/cashu.ts +++ b/app/features/shared/cashu.ts @@ -67,7 +67,7 @@ function getCurrencyAndUnitFromToken(token: Token): { export function tokenToMoney(token: Token): Money { const { currency, unit } = getCurrencyAndUnitFromToken(token); - const amount = sumProofs(token.proofs).toNumber(); + const amount = sumProofs(token.proofs); return new Money({ amount, currency, @@ -341,11 +341,9 @@ export async function getInitializedCashuWallet({ bip39seed: bip39seed ?? undefined, authProvider, }); - const keyChainCache = KeyChain.mintToCacheDTO( - mintUrl, - unitKeysets, - [activeKeysForUnit], - ); + const keyChainCache = KeyChain.mintToCacheDTO(mintUrl, unitKeysets, [ + activeKeysForUnit, + ]); wallet.loadMintFromCache(mintInfo.cache, keyChainCache); return { wallet, isOnline: true }; diff --git a/app/features/transactions/transaction-additional-details.tsx b/app/features/transactions/transaction-additional-details.tsx index 7f079ddc4..ba9994a92 100644 --- a/app/features/transactions/transaction-additional-details.tsx +++ b/app/features/transactions/transaction-additional-details.tsx @@ -1,4 +1,4 @@ -import { CheckStateEnum, type Proof } from '@cashu/cashu-ts'; +import { CheckStateEnum, type ProofLike } from '@cashu/cashu-ts'; import { useSuspenseQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; import { @@ -20,7 +20,7 @@ import type { Transaction } from './transaction'; import { useTransaction } from './transaction-hooks'; const augmentProofsWithState = ( - proofs: CashuProof[] | Proof[], + proofs: CashuProof[] | ProofLike[], states: Map, ) => { return proofs.map((p) => ({ @@ -34,7 +34,7 @@ const augmentProofsWithState = ( const useProofStates = ( transactionId: string, account: CashuAccount, - proofs: CashuProof[] | Proof[], + proofs: CashuProof[] | ProofLike[], ) => { const { data: states } = useSuspenseQuery({ queryKey: ['transaction-proof-states', transactionId], diff --git a/app/lib/cashu/melt-quote-subscription.ts b/app/lib/cashu/melt-quote-subscription.ts index 10b29635e..d45b9b01f 100644 --- a/app/lib/cashu/melt-quote-subscription.ts +++ b/app/lib/cashu/melt-quote-subscription.ts @@ -68,7 +68,8 @@ export function useOnMeltQuoteStateChange({ } else if (meltQuote.state === MeltQuoteState.PAID) { // There is a bug in nutshell where the change is not included in the melt quote state updates, so we need to refetch the quote to get the change proofs. // see https://github.com/cashubtc/nutshell/pull/788 - const expectChange = quoteData.inputAmount > meltQuote.amount; + const expectChange = + quoteData.inputAmount > meltQuote.amount.toNumber(); if ( expectChange && !(meltQuote.change && meltQuote.change.length > 0) diff --git a/app/lib/cashu/proof.test.ts b/app/lib/cashu/proof.test.ts index aa9fe0e8d..14234ca8b 100644 --- a/app/lib/cashu/proof.test.ts +++ b/app/lib/cashu/proof.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from 'bun:test'; -import type { Proof } from '@cashu/cashu-ts'; +import { Amount, type Proof } from '@cashu/cashu-ts'; import { getClaimableProofs, sumProofs } from './proof'; const proofWithP2PKSecret: Proof = { - amount: 1, + amount: Amount.from(1), secret: '["P2PK",{"nonce":"0","data":"0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7","tags":[["sigflag","SIG_INPUTS"]]}]', C: '02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904', @@ -11,14 +11,14 @@ const proofWithP2PKSecret: Proof = { }; const proofWithPlainSecret: Proof = { - amount: 1, + amount: Amount.from(1), secret: '0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7', C: '02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904', id: '009a1f293253e41e', }; const unknownTypeProof: Proof = { - amount: 1, + amount: Amount.from(1), secret: '["UNKNOWN_TYPE",{"some":"data"}]', C: '02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904', id: '009a1f293253e41e', diff --git a/app/lib/cashu/proof.ts b/app/lib/cashu/proof.ts index 438c35585..569d5092e 100644 --- a/app/lib/cashu/proof.ts +++ b/app/lib/cashu/proof.ts @@ -1,10 +1,10 @@ -import { type Proof, hashToCurve } from '@cashu/cashu-ts'; +import { Amount, type Proof, hashToCurve } from '@cashu/cashu-ts'; import { parseSecret } from './secret'; /** Sum the amounts from a list of proofs. */ -export const sumProofs = (proofs: Pick[]): number => { +export const sumProofs = (proofs: { amount: number | Amount }[]): number => { return proofs.reduce((acc, proof) => { - return acc + proof.amount; + return acc + Amount.from(proof.amount).toNumber(); }, 0); }; @@ -13,7 +13,7 @@ export const sumProofs = (proofs: Pick[]): number => { * * @see https://github.com/cashubtc/nuts/blob/main/00.md#hash_to_curvex-bytes---curve-point-y */ -export const proofToY = (proof: Proof): string => { +export const proofToY = (proof: Pick): string => { const encoder = new TextEncoder(); return hashToCurve(encoder.encode(proof.secret)).toHex(true); }; diff --git a/app/lib/cashu/utils.ts b/app/lib/cashu/utils.ts index 8b6db6204..217e1e100 100644 --- a/app/lib/cashu/utils.ts +++ b/app/lib/cashu/utils.ts @@ -1,11 +1,12 @@ import { + Amount, type Keyset, type MeltQuoteBolt11Response, MeltQuoteState, type Mint, type MintKeyset, type MintQuoteBolt11Response, - type Proof, + type ProofLike, Wallet, splitAmount, } from '@cashu/cashu-ts'; @@ -103,6 +104,13 @@ export const getMintPurpose = ( return mintInfo?.agicash?.purpose ?? 'transactional'; }; +const isKeysetActive = (ks: MintKeyset | Keyset): boolean => + 'isActive' in ks ? ks.isActive : ks.active; + +const getKeysetFinalExpiry = ( + ks: MintKeyset | Keyset, +): number | null | undefined => ('expiry' in ks ? ks.expiry : ks.final_expiry); + /** * Finds the first active keyset for the given currency. */ @@ -111,15 +119,16 @@ export const findFirstActiveKeyset = ( currency: Currency, ): T | undefined => { const unit = getCashuProtocolUnit(currency); - return keysets.find((ks) => ks.unit === unit && ks.active); + return keysets.find((ks) => ks.unit === unit && isKeysetActive(ks)); }; /** * Returns the keyset's expiry as a Date, or null if it has no expiry. */ export const getKeysetExpiry = (keyset: MintKeyset | Keyset): Date | null => { - if (!keyset.final_expiry) return null; - return new Date(keyset.final_expiry * 1000); + const expiry = getKeysetFinalExpiry(keyset); + if (!expiry) return null; + return new Date(expiry * 1000); }; export const getWalletCurrency = (wallet: Wallet) => { @@ -237,16 +246,20 @@ export class ExtendedCashuWallet extends Wallet { * This handles the case where meltProofs is called twice for the same quote. */ async meltProofsIdempotent( - meltQuote: Pick, - proofs: Proof[], + meltQuote: { quote: string; amount: number | Amount }, + proofs: ProofLike[], config?: Parameters[2], outputType?: Parameters[3], ) { // cashu-ts accepts a full MeltQuoteBolt11Response but only reads .quote (the ID) // and .amount (for fee reserve calculation). Some callers only have these two fields // from stored data, so we accept a partial and cast it. + const normalizedMeltQuote = { + ...meltQuote, + amount: Amount.from(meltQuote.amount), + } as MeltQuoteBolt11Response; return this.meltProofsBolt11( - meltQuote as MeltQuoteBolt11Response, + normalizedMeltQuote, proofs, config, outputType,