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

19 KiB
Raw Blame History

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:

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:

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 marmotMain.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.shalice 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.shalice 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.shalice 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.shalice 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.shalice 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.shbob 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.shalice 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/