# 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**:

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 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/`
