Audited all 143 plan files across the 10 plans/ folders. Each plan now carries a Status header (shipped | in-progress | queued | abandoned) backed by codebase evidence, and every folder has a README.md index grouping plans by status. Shipped plans were moved into a per-folder plans/archive/ (via git mv, history preserved) so each plans/ folder surfaces only live work: shipped (archived): 122 in-progress: 8 queued: 7 abandoned: 4 docs/plans/ is the frozen legacy folder; its plans were stamped and indexed in place (48 of 52 archived) but it remains closed to new plans. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016hpUivtmq4pgzqRbY6MYrA
21 KiB
Cashu (NIP-60 / NIP-61 / NIP-87) in amy
Status: in-progress — the full command surface ships under
cli/.../commands/cashu/(wallet/mint/balance/receive/send/maintenance/mint-rec), but the PR 9 interop harness (cli/tests/cashu/) does not exist yet. Audited 2026-06-30.
Status: in progress · Date: 2026-05-28 · Roadmap row: added to
the parity matrix in cli/ROADMAP.md.
Landed so far: Extraction B (CashuWalletOps → commons/jvmAndroid),
Extraction C (CashuWalletReader → commons/jvmAndroid), Extraction D (the
CashuKeysetCounterStore contract in commons + FileCashuKeysetCounterStore
in cli), and the offline command tier: cashu wallet {create, show, export-key, destroy}, cashu mint {ping, info}, cashu balance. Extraction A
(token parsers) turned out to be unnecessary — V4Encoder,
CashuTokenB64Parser, and friends already live in quartz. Note:
CashuWalletOps/CashuWalletReader land in the jvmAndroid source set, not
commonMain as originally sketched, because they compose quartz's jvmAndroid
CashuMintOperations/MintHttpClient. The full command surface now ships —
receive {ln, complete, resume, token, nutzap-sweep}, send {ln, token, nutzap}, maintenance {scrub, restore, migrate-keysets}, mint-rec {show, add, remove} — each a thin wrapper over a shared CashuWalletOps method.
scrubStaleProofs was extracted into CashuWalletOps so Android and amy prune
identically; Context.cashuRestore mirrors CashuWalletState.restoreFromMint
(seed + NUT-13 counter bump). Still pending: the interop harness (PR 9) —
the happy-path mint/melt/send completions need a payable bolt11 to exercise
end-to-end.
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:
- Amy gains every cashu action the Amethyst UI exposes, each one
reusing the exact same
quartz+commonscode the Android wallet runs in production. - 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. - 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
-
Reuse over re-implement. Every protocol primitive (
Bdhke,P2PK,MintHttpClient,CashuMintOperations, wallet event types) is already inquartz. The orchestration layer (CashuWalletOps) is inamethyst/model/nip60Cashu/today but has no Android-only dependencies once two helpers are moved — it gets pulled intocommons/in PR 2 and both Android and Amy call into the same object. -
Thin assembly only. Anything longer than ~30 lines in a
cli/commands/cashu/*.ktfile is a code smell — push it intocommons/cashu/. The command code parses args, opens a Context, calls one or twocommons/methods, emits a result. -
Stable JSON contract. Every verb defines a
--jsonshape with snake_case keys, documented in this plan and pinned incli/DEVELOPMENT.mdonce shipped. Renaming a key is a breaking change that ships with a commit-message callout. -
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 usesNostrSignerInternal)publish: suspend (Event) -> Unit— passed in (Amy usesContext.publishwrapped to drop the per-relay result map)okHttpClient: (String) -> OkHttpClient— passed in (Amy reusesContext.okhttp)secretFactory: SecretFactory— passed in, NUT-13 or randomseedWarmer: 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:
- The reactive plumbing (StateFlow updates, LocalCache subscription, AccountSettings backups) — stays in amethyst.
- 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.cashuKeysetCountersmap + persisted save. Lives inamethyst/.../nip60Cashu/AndroidCashuKeysetCounterStore.kt. - Amy impl:
cli/.../stores/FileCashuKeysetCounterStore.ktbacked by~/.amy/<account>/cashu.json, atomic viaSecureFileIO.
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.
01-wallet-bootstrap.sh—alicecreates a wallet with the test mint; asserts kind:17375 + kind:10019 published withmintscontaining the test URL and a derivable P2PK pubkey.02-mint-from-ln.sh—alicecallsreceive 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 QUOTEasserts a kind:7375 landed with proofs summing to 10 sat and a kind:7376 IN row referencing it.03-send-receive-token.sh—alicesend token 5, captures cashuB string.bobreceive token <TOKEN>asserts a kind:7375 with 5 sat;alicebalanceshows the 5-sat change.04-melt-to-ln.sh—alicesend ln <INVOICE>, asserts the melt succeeded, the change is in a new kind:7375, and a kind:7376 OUT row was written.05-self-zap.sh—alicepublishes a kind:10019, thensend nutzap alice 3to herself. Asserts a kind:9321 with proofs P2PK-locked to her wallet pubkey, thenreceive nutzap-sweepredeems it cleanly (no double-redeem, no "proofs already spent").06-cross-nutzap.sh—bobpublishes a kind:10019.alicesend nutzap bob 3 --zapped <NOTE_ID>.bobreceive nutzap-sweepredeems it; both wallets'balancematch expectations including the input fee.07-keyset-rotation.sh— set the mint to one with a rotated keyset (or fake via env override);maintenance migrate-keysetsconsolidates onto the active id.08-stale-proof-heal.sh— externally spendalice's proofs (via a second Amy instance or mint admin API);maintenance scrubremoves the stale kind:7375 entries via NIP-09, balance reflects the loss.09-restore-from-seed.sh— fresh Amydavidimportsalice's privkey,wallet createwith same mints,maintenance restore <MINT>recovers Alice's unspent proofs from the seed.10-mint-recommendations.sh—alicerecommends the test mint,bobreads it back viamint-rec show --author alice,aliceretracts it; assertions on kind:38000 publish + NIP-09 delete.
Shared scaffolding
cli/tests/cashu/lib.sh— sourcescli/tests/lib.sh, addscashu_balance_eq,cashu_history_has,proofs_spent_at_minthelpers.cli/tests/cashu/headless/— wrapsamy cashuinvocations with--jsonparsing into shell vars.cli/tests/cashu/run.sh— runs all0?-*.shfiles 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.
MintHttpClientandCashuMintOperationsaccept a(String) -> OkHttpClient; reuseContext.okhttp(the same instance the WebSocket layer uses, so DNS / TLS sessions pool). - NUT-13 counter durability. Deterministic secret reuse
causes
outputs_already_signedfrom the mint. Everyreserve(...)writescashu.jsonatomically (tmpfile + rename) before the swap fires. The Android impl already does this viaAccountSettings.save(). - One-shot vs reactive. Amy never observes auto-redeem
races because each command is a fresh process. The Android
app keeps its
sessionRedeemedNutzaps/sessionUnredeemableNutzapsmutex; Amy doesn't need them. - NIP-46 / NIP-55 signers. Out of scope — Amy uses
NostrSignerInternalonly. Documenting this inDEVELOPMENT.mdis part of PR 4. - NUT-17 WebSocket subscriptions. Out of scope for v1.
receive completepolls. 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:
- PRs 1–4 land;
CashuWalletOps, the parsers, and the reader live in shared modules; Android still builds and the wallet UI still works. - Every roadmap row added by PR 1 reaches ✅.
- All ten harness scripts in
cli/tests/cashu/run.shpass againstmint.minibits.cashon a fresh~/.amy/. - 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.
cli/DEVELOPMENT.mddocuments thecashu.jsonschema 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/