docs(cli): plan — Cashu (NIP-60/61/87) in amy as an Amethyst test harness

The framing: Amy gains every cashu action the Amethyst UI exposes, each
one reusing the exact same quartz + commons code the Android wallet
runs in production. The deliverable is a shell harness under
cli/tests/cashu/ that walks two Amy accounts through the full wallet
lifecycle against a production mint, so regressions in the cashu code
path fail on the JVM in CI without an emulator.

Plan covers:
- Three extractions before any verb lands: cashu token parsers to
  quartz, CashuWalletOps to commons, CashuWalletReader projection
  helpers to commons.
- One storage addition: ~/.amy/<account>/cashu.json for NUT-13 keyset
  counters (deterministic secrets need durable counter state across
  invocations).
- Command surface under amy cashu …: wallet, mint, balance, receive,
  send, mint-rec, maintenance — mirrors every action the Android
  wallet exposes.
- Stable --json shape per verb.
- 9-PR sequencing: extractions first, then verbs grouped by user
  intent, then the 10-scenario interop harness.
- Acceptance criteria: harness passes against mint.minibits.cash and
  the on-relay events are byte-equivalent to what Amethyst would
  produce.
This commit is contained in:
Claude
2026-05-27 21:04:32 +00:00
parent d28458553a
commit 02e611c986
+451
View File
@@ -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<Event>): WalletSnapshot
data class WalletSnapshot(
val walletEvent: CashuWalletEvent?,
val nutzapInfoEvent: NutzapInfoEvent?,
val mints: List<String>,
val tokenEntries: List<TokenEntry>,
val history: List<CashuSpendingHistoryEvent>,
val pendingQuotes: List<CashuMintQuoteEvent>,
val nutzapEvents: List<NutzapEvent>,
val recommendations: List<MintRecommendationEvent>,
)
}
```
`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/<account>/cashu.json`, atomic via
`SecureFileIO`.
---
## Storage additions in `~/.amy/`
| File | Contents | Writer |
|---|---|---|
| `~/.amy/<account>/cashu.json` | `{ "keyset_counters": { "<id>": <long> } }` | `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: <code>: <detail>` /
`{"error":"<code>","detail":"<detail>"}` pattern. Codes include
`bad_args`, `no_wallet`, `no_mint`, `insufficient_funds`,
`mint_unreachable`, `mint_http_<status>`, `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 <TOKEN>`
asserts a kind:7375 with 5 sat; `alice` `balance` shows the
5-sat change.
4. **`04-melt-to-ln.sh`** — `alice` `send ln <INVOICE>`,
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 <NOTE_ID>`. `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 <MINT>` 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 14 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/`