Files
amethyst/cli/plans/2026-05-28-cashu-cli.md
T
Claude 02e611c986 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.
2026-05-27 21:04:32 +00:00

452 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/`