diff --git a/cli/plans/2026-05-28-cashu-cli.md b/cli/plans/2026-05-28-cashu-cli.md new file mode 100644 index 0000000000..9afe7bee8d --- /dev/null +++ b/cli/plans/2026-05-28-cashu-cli.md @@ -0,0 +1,451 @@ +# Cashu (NIP-60 / NIP-61 / NIP-87) in `amy` + +**Status:** plan · **Date:** 2026-05-28 · **Roadmap row:** new (no +row today). To be added at the end of the parity matrix in +`cli/ROADMAP.md` once PR 1 lands. + +## Why + +The Amethyst Cashu wallet is the most stateful, network-heavy +feature in the app: multi-mint balances, NUT-13 deterministic +secrets that require durable counter state, NIP-61 nutzaps with +P2PK-locked proofs, NUT-12 DLEQ checks, NUT-09 restore, mint +keyset rotations. Every bug in it has so far come from either a +race in the reactive plumbing or a failure mode no manual click- +test would have caught (proofs going stale at the mint, partial +publish on signer cancel, keyset rotation mid-flow). + +The goal of this work is **to make the Cashu wallet testable +without running Android**: + +1. Amy gains every cashu action the Amethyst UI exposes, each one + reusing the exact same `quartz` + `commons` code the Android + wallet runs in production. +2. A shell harness under `cli/tests/cashu/` walks two Amy accounts + through a sequence of real flows against **production mints** + (minibits, nutshell, cdk-mintd if reachable), validating the + on-relay event shape and the mint-side proof state on every + step. +3. Regressions in the wallet — wrong DLEQ encoding, double-redeem, + counter reuse, broken self-zap — fail the harness in CI, on the + JVM, in seconds, without an emulator. + +The non-goal: Amy is *not* a second wallet. The cashu code path it +exercises is the same path the Android app exercises. If a test +finds a bug, both binaries get the fix. + +--- + +## Guiding principles + +1. **Reuse over re-implement.** Every protocol primitive + (`Bdhke`, `P2PK`, `MintHttpClient`, `CashuMintOperations`, + wallet event types) is already in `quartz`. The orchestration + layer (`CashuWalletOps`) is in `amethyst/model/nip60Cashu/` + today but has no Android-only dependencies once two helpers + are moved — it gets pulled into `commons/` in PR 2 and both + Android and Amy call into the same object. + +2. **Thin assembly only.** Anything longer than ~30 lines in a + `cli/commands/cashu/*.kt` file is a code smell — push it into + `commons/cashu/`. The command code parses args, opens a + Context, calls one or two `commons/` methods, emits a result. + +3. **Stable JSON contract.** Every verb defines a `--json` shape + with snake_case keys, documented in this plan and pinned in + `cli/DEVELOPMENT.md` once shipped. Renaming a key is a + breaking change that ships with a commit-message callout. + +4. **Production-mint interop is the deliverable.** The harness in + step 9 is the acceptance test for the whole effort. + +--- + +## API surface we reuse + +| Concern | Class | File | +|---|---|---| +| BDHKE blind/unblind/sign/verify, hashToCurve, NUT-12 DLEQ | `Bdhke` | `quartz/.../nip60Cashu/bdhke/Bdhke.kt` | +| NUT-11 P2PK secret parse + witness sign | `P2PK` | `quartz/.../nip60Cashu/p2pk/P2PK.kt` | +| Cashu v1 mint HTTP (info, keys, swap, mint, melt, checkstate, restore) | `MintHttpClient` | `quartz/.../nip60Cashu/mintApi/MintHttpClient.kt` | +| High-level swap / mint / melt / restore / checkStates / DLEQ | `CashuMintOperations` | `quartz/.../nip60Cashu/mintApi/CashuMintOperations.kt` | +| NUT-13 deterministic-secret derivation | `CashuDeterministic`, `DeterministicSecretFactory` | `quartz/.../nip60Cashu/seed/`, `mintApi/SecretFactory.kt` | +| NUT-20 signed mint-quote (P2PK lock on /v1/mint) | `MintQuoteSignature` | `quartz/.../nip60Cashu/mintApi/MintQuoteSignature.kt` | +| Token v3 / v4 parse + encode | `V3Parser`, `V4Parser`, `V4Encoder`, `CashuParser` | `amethyst/service/cashu/` ⟶ moves to `quartz` in PR 1 | +| Wallet / token / quote / history events | `CashuWalletEvent`, `CashuTokenEvent`, `CashuMintQuoteEvent`, `CashuSpendingHistoryEvent` | `quartz/.../nip60Cashu/` | +| Nutzap + nutzap-info events | `NutzapEvent`, `NutzapInfoEvent` | `quartz/.../nip61Nutzaps/` | +| Mint announcement + recommendation | `CashuMintEvent`, `MintRecommendationEvent` | `quartz/.../nip87Ecash/` | +| Wallet ops (publish create, mint, melt, send token, send nutzap, redeem, scrub, restore, migrate, recommend) | `CashuWalletOps` | `amethyst/.../nip60Cashu/CashuWalletOps.kt` ⟶ moves to `commons` in PR 2 | +| Cashu filter assembler | `CashuWalletFilterAssembler` | already in `commons/.../assemblers/` | +| Publish-and-confirm | `Context.publish` | `cli/.../Context.kt` | +| Drain a subscription | `Context.drain` | `cli/.../Context.kt` | +| Identifier resolve | `Context.requireUserHex` | `cli/.../Context.kt` | +| In-memory signer (NIP-44 ops included) | `NostrSignerInternal` | `Context.signer` | + +Everything above is already on the JVM classpath of the `cli` +module after PRs 1 and 2 land. + +--- + +## Extraction work (Rule 5) + +Three pieces of Amethyst-side code need to move before any `amy +cashu` command can be written. Each is a standalone PR so the +move is reviewable on its own. + +### Extraction A — token serialization to `quartz` + +`amethyst/service/cashu/{V3Parser,V4Parser,V4Encoder,CashuParser, +CachedCashuParser}.kt` are pure protocol code that decode/encode +the `cashuA` (CBOR/JSON v3) and `cashuB` (CBOR v4) token strings. +No Android dependencies — `CachedCashuParser` uses only an +`LruCache`-equivalent which has a `commons` analogue. + +Target: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/serialization/`. + +Callers updated: `CashuWalletOps`, the cashu paste-redeem UI in +`amethyst/`, any place in `commons/` that wants to render a token. + +### Extraction B — `CashuWalletOps` to `commons` + +`CashuWalletOps` orchestrates the imperative API: publish wallet +events, start/check/cancel/complete mint, request/confirm melt, +send-as-token, send-nutzap, redeem-token, redeem-nutzap, restore, +scrub-stale-proofs, migrate-keysets, mint-recommendations. It is +already abstract w.r.t. its host: + +- `signer: NostrSigner` — passed in (Amy uses + `NostrSignerInternal`) +- `publish: suspend (Event) -> Unit` — passed in (Amy uses + `Context.publish` wrapped to drop the per-relay result map) +- `okHttpClient: (String) -> OkHttpClient` — passed in (Amy + reuses `Context.okhttp`) +- `secretFactory: SecretFactory` — passed in, NUT-13 or random +- `seedWarmer: suspend () -> Unit` — passed in, ensures the seed + cache is populated before any blind op + +The only Android coupling is the V4Encoder import (gone after +Extraction A) and the `Log` import (already JVM-portable via +`quartz/.../utils/Log.kt`). + +Target: `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt`. + +The result types (`SendTokenCompleted`, `MeltCompleted`, +`RedeemCompleted`, `MintQuoteStarted`, `RestoreOutcome`, +`MigrationResult`, `TokenEntry`, `CreatedWallet`, `NutzapSent`) +move with it. + +Callers updated: `CashuWalletState` (one import change), +`CashuWalletViewModel` (one import change), and any test code. + +### Extraction C — `CashuWalletReader` projection helpers + +`CashuWalletState` mixes two things: +1. The reactive plumbing (StateFlow updates, LocalCache + subscription, AccountSettings backups) — *stays in + amethyst*. +2. Pure projection over a stream of events into + `{ mints, tokenEntries, history, pendingQuotes, + nutzapEvents }` — *moves to commons*. + +Target: `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuWalletReader.kt`. + +Shape: + +```kotlin +class CashuWalletReader( + private val signer: NostrSigner, + private val pubKey: HexKey, +) { + suspend fun project(events: Iterable): WalletSnapshot + + data class WalletSnapshot( + val walletEvent: CashuWalletEvent?, + val nutzapInfoEvent: NutzapInfoEvent?, + val mints: List, + val tokenEntries: List, + val history: List, + val pendingQuotes: List, + val nutzapEvents: List, + val recommendations: List, + ) +} +``` + +`project` does what `applyEvents` + `recomputeUnspent` + +`recomputePending` do today, minus the StateFlow side effects. +Amethyst rewrites `applyEvents` as a delta over a snapshot; +Amy calls `project(store.allOfKinds(WALLET_KINDS))` once per +command. + +### Extraction D — NUT-13 counter store + +NUT-13 counter persistence cannot live in `AccountSettings` +because Amy doesn't have one. Move to an interface: + +```kotlin +interface CashuKeysetCounterStore { + fun peek(keysetId: String): Long + /** Atomically reserve [count] counters; returns the first reserved. */ + fun reserve(keysetId: String, count: Int): Long +} +``` + +Target: +`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuKeysetCounterStore.kt`. + +- Android impl: thin wrapper over the existing + `AccountSettings.cashuKeysetCounters` map + persisted save. + Lives in `amethyst/.../nip60Cashu/AndroidCashuKeysetCounterStore.kt`. +- Amy impl: `cli/.../stores/FileCashuKeysetCounterStore.kt` + backed by `~/.amy//cashu.json`, atomic via + `SecureFileIO`. + +--- + +## Storage additions in `~/.amy/` + +| File | Contents | Writer | +|---|---|---| +| `~/.amy//cashu.json` | `{ "keyset_counters": { "": } }` | `FileCashuKeysetCounterStore.reserve` | + +That's the entire delta. The file event store already holds +kind:17375, kind:10019, kind:7375, kind:7376, kind:7374, +kind:9321, kind:38000 — there is no separate wallet backup +needed in Amy. + +--- + +## Command surface + +All under `amy cashu …`. Dispatcher mirrors `marmot` — +`Main.kt` recognises `cashu` and forwards to a `CashuCommands` +dispatcher that splits on the subgroup (`wallet`, `mint`, +`balance`, `receive`, `send`, `mint-rec`, `maintenance`). + +``` +amy cashu wallet create [--mint URL]... [--privkey HEX] [--relay URL]... +amy cashu wallet edit [--add-mint URL]... [--remove-mint URL]... [--relay URL]... +amy cashu wallet show +amy cashu wallet export-key +amy cashu wallet destroy + +amy cashu mint ping URL +amy cashu mint info URL + +amy cashu balance [--mint URL] + +amy cashu receive ln SATS [--mint URL] [--description S] +amy cashu receive complete QUOTE_ID +amy cashu receive resume QUOTE_ID +amy cashu receive token TOKEN +amy cashu receive nutzap-sweep [--mint URL] + +amy cashu send ln INVOICE [--mint URL] +amy cashu send token SATS [--mint URL] [--memo S] +amy cashu send nutzap USER SATS [--zapped EVENT_ID] [--message S] + +amy cashu mint-rec show [--author NPUB] +amy cashu mint-rec add URL [--dtag X] [--review TEXT] +amy cashu mint-rec remove EVENT_ID + +amy cashu maintenance scrub [--mint URL] +amy cashu maintenance restore MINT_URL +amy cashu maintenance migrate-keysets [--mint URL] +``` + +### `--json` shape (stable contract) + +Common keys: `event_id`, `mint_url`, `amount_sats` (Long), +`proofs_count` (Int), `created_at` (Unix seconds), +`pubkey_hex`, `pubkey_npub`, `token_event_id`, +`history_event_id`, `quote_id`. + +Per verb: + +| Verb | Stable keys | +|---|---| +| `wallet create` | `wallet_event_id`, `nutzap_info_event_id`, `p2pk_pubkey`, `mints[]` | +| `wallet show` | `p2pk_pubkey`, `mints[]`, `balance_sats`, `balances_by_mint{}`, `proofs_count`, `history[…]`, `pending_quotes[…]` | +| `wallet export-key` | `privkey_hex` | +| `wallet destroy` | `deletion_event_id` | +| `mint ping URL` | `name`, `pubkey`, `version`, `supported_nuts[]` | +| `mint info URL` | full DTO passthrough (documented as `mint_info` blob — *not* part of the stability contract; mint controls it) | +| `balance` | `balance_sats`, `balances_by_mint{}`, `proofs_count` | +| `receive ln` | `quote_id`, `invoice`, `mint_url`, `amount_sats`, `expires_at`, `kind_7374_event_id` | +| `receive complete` | `status` (`paid`/`pending`/`expired`/`gone`), `amount_sats`, `token_event_id`, `history_event_id` | +| `receive resume` | same as `complete` | +| `receive token` | `amount_sats`, `mint_url`, `token_event_id`, `history_event_id` | +| `receive nutzap-sweep` | `redeemed[{nutzap_id,amount_sats,token_event_id,history_event_id}]`, `skipped[{nutzap_id,reason}]` | +| `send ln` | `amount_sats`, `fee_paid_sats`, `preimage`, `history_event_id` | +| `send token` | `token` (cashuB), `amount_sats`, `mint_url`, `history_event_id` | +| `send nutzap` | `nutzap_event_id`, `recipient_pubkey`, `mint_url`, `amount_sats`, `history_event_id` | +| `mint-rec show` | `recommendations[{event_id,mint_url,dtag,review,pubkey_hex,created_at}]` | +| `mint-rec add` | `event_id`, `mint_url` | +| `mint-rec remove` | `deletion_event_id` | +| `maintenance scrub` | `scrubbed[{event_id,amount_sats,mint_url}]`, `kept_count` | +| `maintenance restore` | `mint_url`, `sats_recovered`, `proofs_recovered`, `token_event_id`, `history_event_id` | +| `maintenance migrate-keysets` | `migrated[{mint_url,old_event_ids[],new_event_id,amount_sats}]` | + +Errors follow the standard `error: : ` / +`{"error":"","detail":""}` pattern. Codes include +`bad_args`, `no_wallet`, `no_mint`, `insufficient_funds`, +`mint_unreachable`, `mint_http_`, `mint_proofs_spent`, +`mint_quote_unpaid`, `mint_quote_gone`, `dleq_failed`, +`nutzap_locked_to_wrong_key`, `signer_error`, `network_timeout`. + +--- + +## PR sequencing + +Each step is one PR. Each step except the test-suite step +extracts code from `amethyst/` into shared modules; if a PR +ships without an extraction, re-audit. + +| PR | Touches | Extraction | New `amy` verbs | +|---|---|---|---| +| **1** | `amethyst/service/cashu/`, `quartz` | A — parsers/encoder to quartz | none | +| **2** | `amethyst/model/nip60Cashu/CashuWalletOps.kt`, `commons` | B — `CashuWalletOps` to commons | none | +| **3** | `amethyst/model/nip60Cashu/CashuWalletState.kt`, `commons` | C — `CashuWalletReader` to commons | none | +| **4** | `commons`, `amethyst`, `cli` | D — `CashuKeysetCounterStore` + Android/Amy impls | `wallet {create, show, export-key, destroy}`, `mint ping`, `balance` | +| **5** | `cli` | — | `receive {ln, complete, resume, token}` | +| **6** | `cli` | — | `send {ln, token, nutzap}` | +| **7** | `cli` | — | `receive nutzap-sweep`, `maintenance {scrub, restore, migrate-keysets}` | +| **8** | `cli` | — | `mint-rec {show, add, remove}` | +| **9** | `cli/tests/cashu/` | — | full interop harness against production mints | + +After PR 9, every roadmap row in the Cashu area moves from 🆕 / +📦 to ✅. + +--- + +## Interop harness (PR 9 — the acceptance test) + +Lives under `cli/tests/cashu/`, structured like +`cli/tests/marmot/` and `cli/tests/dm/`. Two Amy accounts +(`alice`, `bob`) plus a public-mint URL in an env var +(`AMY_TEST_MINT_URL`, default `https://mint.minibits.cash`). + +### Scenarios + +Each scenario is one shell file under `cli/tests/cashu/`. Each +exits non-zero on any deviation from expectation. + +1. **`01-wallet-bootstrap.sh`** — `alice` creates a wallet with + the test mint; asserts kind:17375 + kind:10019 published with + `mints` containing the test URL and a derivable P2PK pubkey. +2. **`02-mint-from-ln.sh`** — `alice` calls `receive ln 10`, + gets a bolt11. *Manual step:* pay externally (or, if running + against a regtest mint, the harness pays via the mint's test + endpoint). `receive complete QUOTE` asserts a kind:7375 + landed with proofs summing to 10 sat and a kind:7376 IN row + referencing it. +3. **`03-send-receive-token.sh`** — `alice` `send token 5`, + captures cashuB string. `bob` `receive token ` + asserts a kind:7375 with 5 sat; `alice` `balance` shows the + 5-sat change. +4. **`04-melt-to-ln.sh`** — `alice` `send ln `, + asserts the melt succeeded, the change is in a new kind:7375, + and a kind:7376 OUT row was written. +5. **`05-self-zap.sh`** — `alice` publishes a kind:10019, then + `send nutzap alice 3` to herself. Asserts a kind:9321 with + proofs P2PK-locked to her wallet pubkey, then + `receive nutzap-sweep` redeems it cleanly (no double-redeem, + no "proofs already spent"). +6. **`06-cross-nutzap.sh`** — `bob` publishes a kind:10019. + `alice` `send nutzap bob 3 --zapped `. `bob` + `receive nutzap-sweep` redeems it; both wallets' + `balance` match expectations including the input fee. +7. **`07-keyset-rotation.sh`** — set the mint to one with a + rotated keyset (or fake via env override); `maintenance + migrate-keysets` consolidates onto the active id. +8. **`08-stale-proof-heal.sh`** — externally spend `alice`'s + proofs (via a second Amy instance or mint admin API); + `maintenance scrub` removes the stale kind:7375 entries via + NIP-09, balance reflects the loss. +9. **`09-restore-from-seed.sh`** — fresh Amy `david` imports + `alice`'s privkey, `wallet create` with same mints, + `maintenance restore ` recovers Alice's unspent + proofs from the seed. +10. **`10-mint-recommendations.sh`** — `alice` recommends the + test mint, `bob` reads it back via `mint-rec show + --author alice`, `alice` retracts it; assertions on + kind:38000 publish + NIP-09 delete. + +### Shared scaffolding + +- `cli/tests/cashu/lib.sh` — sources `cli/tests/lib.sh`, adds + `cashu_balance_eq`, `cashu_history_has`, `proofs_spent_at_mint` + helpers. +- `cli/tests/cashu/headless/` — wraps `amy cashu` invocations + with `--json` parsing into shell vars. +- `cli/tests/cashu/run.sh` — runs all `0?-*.sh` files in order, + fails fast. + +### CI footprint + +The harness needs outbound HTTP to a production mint plus a +public relay. The DM harness already burns this budget; the +cashu suite reuses the same network policy. + +For the mint side, the default is `https://mint.minibits.cash` +(small free mint, no per-IP limits for the tiny amounts the +suite mints). Override via `AMY_TEST_MINT_URL` for fork-CI or +local nutshell. + +--- + +## Risks and decisions + +- **HTTP to mints.** Amy hasn't yet talked to non-relay HTTP. + `MintHttpClient` and `CashuMintOperations` accept a + `(String) -> OkHttpClient`; reuse `Context.okhttp` (the same + instance the WebSocket layer uses, so DNS / TLS sessions + pool). +- **NUT-13 counter durability.** Deterministic secret reuse + causes `outputs_already_signed` from the mint. Every + `reserve(...)` writes `cashu.json` atomically (tmpfile + + rename) *before* the swap fires. The Android impl already + does this via `AccountSettings.save()`. +- **One-shot vs reactive.** Amy never observes auto-redeem + races because each command is a fresh process. The Android + app keeps its `sessionRedeemedNutzaps` / + `sessionUnredeemableNutzaps` mutex; Amy doesn't need them. +- **NIP-46 / NIP-55 signers.** Out of scope — Amy uses + `NostrSignerInternal` only. Documenting this in + `DEVELOPMENT.md` is part of PR 4. +- **NUT-17 WebSocket subscriptions.** Out of scope for v1. + `receive complete` polls. NUT-17 layer in quartz is unused + by Amy. +- **Lightning regtest.** The harness expects an externally + payable bolt11 OR a regtest mint that auto-pays its own + quotes. Document both options in `cli/tests/cashu/README.md`. + +--- + +## Acceptance criteria + +Done when all of the following hold: + +1. PRs 1–4 land; `CashuWalletOps`, the parsers, and the reader + live in shared modules; Android still builds and the + wallet UI still works. +2. Every roadmap row added by PR 1 reaches ✅. +3. All ten harness scripts in `cli/tests/cashu/run.sh` pass + against `mint.minibits.cash` on a fresh `~/.amy/`. +4. The Android wallet survives the same scenarios manually + (same accounts, same mint) — verifying that Amy's + on-relay output is byte-equivalent to what Amethyst + would have produced. +5. `cli/DEVELOPMENT.md` documents the `cashu.json` schema and + every JSON output key from the table above. + +--- + +## References + +- Wallet code today: `amethyst/.../nip60Cashu/CashuWalletState.kt`, `CashuWalletOps.kt` +- Mint HTTP: `quartz/.../nip60Cashu/mintApi/` +- BDHKE / DLEQ: `quartz/.../nip60Cashu/bdhke/Bdhke.kt` +- Parity matrix: `cli/ROADMAP.md` +- Plan format: this file + the four sibling plans in `cli/plans/`