feat(cli): amy cashu receive/send/maintenance/mint-rec tiers

Complete the Cashu command surface, every verb a thin wrapper over the
shared commons CashuWalletOps the Android wallet runs:

  cashu receive ln SATS [--mint]      start mint, return bolt11 + kind:7374
  cashu receive complete QUOTE_ID     poll mint; on settle, mint proofs
  cashu receive resume QUOTE_ID       alias of complete
  cashu receive token TOKEN           redeem a cashuB token
  cashu receive nutzap-sweep [--mint] redeem inbound NIP-61 nutzaps
  cashu send ln INVOICE [--mint]      melt to a bolt11 (scrubs first)
  cashu send token SATS [--mint --memo]  export a cashuB token (scrubs first)
  cashu send nutzap USER SATS [--zapped --message]  P2PK-locked nutzap
  cashu maintenance scrub [--mint]    NUT-07 + NIP-09 prune spent proofs
  cashu maintenance restore MINT_URL  NUT-09 restore from seed
  cashu maintenance migrate-keysets [--mint]  consolidate onto active keyset
  cashu mint-rec show [--author] / add URL [--dtag --review] / remove ID

- scrubStaleProofs extracted into CashuWalletOps so Android's
  CashuWalletState.scrubLocallyStaleProofs and amy share one impl.
- Context.cashuRestore mirrors CashuWalletState.restoreFromMint (seed +
  NUT-13 counter bump); Context.cashuSeed warms the per-run seed.
- receive complete recovers the mint amount by decoding the quote's
  bolt11 (kind:7374 stores only the quote id), so it works statelessly.

Verified against mint.minibits.cash + live relays: receive ln returns a
real invoice, complete/resume poll a pending quote, mint-rec round-trips,
and every error path (insufficient_funds, no_mint, mint_quote_gone) is
clean. Happy-path mint/melt completions need a payable bolt11 (interop
harness, PR 9).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
This commit is contained in:
Claude
2026-06-22 00:10:40 +00:00
parent 72a7f59d14
commit 42a82c7e8b
10 changed files with 730 additions and 32 deletions
@@ -43,7 +43,6 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
import com.vitorpamplona.quartz.nip60Cashu.mintApi.DeterministicSecretFactory
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MeltQuoteBolt11ResponseDto
import com.vitorpamplona.quartz.nip60Cashu.mintApi.ProofState
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
import com.vitorpamplona.quartz.nip60Cashu.seed.CashuDeterministic
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
@@ -1118,38 +1117,21 @@ class CashuWalletState(
.groupBy { it.content.mint }
.filterKeys { mintUrlFilter == null || it == mintUrlFilter }
for ((mintUrl, entries) in byMint) {
val allProofs = entries.flatMap { it.content.proofs }
if (allProofs.isEmpty()) continue
val states =
runCatching { ops.checkProofStates(mintUrl, allProofs) }
// Shared NUT-07 check + NIP-09 delete with amy's `cashu maintenance
// scrub`. Returns the stale token events it published a deletion for.
val staleEvents =
runCatching { ops.scrubStaleProofs(mintUrl, entries) }
.onFailure {
Log.w("CashuWallet", "checkProofStates($mintUrl) failed; skipping sweep", it)
Log.w("CashuWallet", "scrubStaleProofs($mintUrl) failed; skipping sweep", it)
}.getOrNull()
?: continue
// Any entry with at least one SPENT proof gets purged. Keeping
// mixed-state entries around would let the next send pick them
// and trip the same HTTP 400 we're trying to prevent.
val staleEntries =
entries.filter { entry ->
entry.content.proofs.any { states[it.secret] == ProofState.SPENT }
}
if (staleEntries.isEmpty()) continue
if (staleEvents.isEmpty()) continue
Log.i("CashuWallet") {
"Scrubbing ${staleEntries.size} stale kind:7375 event(s) at $mintUrl"
"Scrubbing ${staleEvents.size} stale kind:7375 event(s) at $mintUrl"
}
val staleIds = staleEntries.map { it.event.id }.toSet()
runCatching {
val template = DeletionEvent.build(staleEntries.map { it.event })
val signed = signer.sign(template)
publishEvent(signed)
}.onFailure {
Log.w("CashuWallet", "Failed to NIP-09 delete stale entries for $mintUrl", it)
}
// Drop from internal indexes regardless of publish success — even
// if the kind:5 didn't go out, we know these proofs are unusable
// and shouldn't be selected for the next swap.
removeEvents(staleIds)
// Drop from internal indexes — even if the kind:5 didn't reach a
// relay, these proofs are unusable and must not be re-selected.
removeEvents(staleEvents.map { it.id }.toSet())
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
| Long-form (NIP-23) publish / read | 🆕 | |
| Live activities / chess (NIP-53 / NIP-64) | 🆕 | |
| Blossom uploads (NIP-B7) | 🆕 | |
| NIP-60 / 61 Cashu wallet + nutzaps | ✅ in part | `amy cashu wallet {create,show,export-key,destroy}`, `mint {ping,info}`, `balance` shipped — all on shared `commons` `CashuWalletOps` + `CashuWalletReader`. `receive`/`send`/`maintenance`/`mint-rec` (live-mint) pending. Plan: [`cli/plans/2026-05-28-cashu-cli.md`](./plans/2026-05-28-cashu-cli.md). |
| NIP-60 / 61 Cashu wallet + nutzaps | ✅ | Full surface: `cashu wallet {create,show,export-key,destroy}`, `mint {ping,info}`, `balance`, `receive {ln,complete,resume,token,nutzap-sweep}`, `send {ln,token,nutzap}`, `maintenance {scrub,restore,migrate-keysets}`, `mint-rec {show,add,remove}` — all on shared `commons` `CashuWalletOps` + `CashuWalletReader` (the exact path the Android wallet runs). Interop harness pending. Plan: [`cli/plans/2026-05-28-cashu-cli.md`](./plans/2026-05-28-cashu-cli.md). |
| NIP-47 Wallet Connect | 🆕 | |
| NIP-46 bunker signer | 🆕 | Needs a signers abstraction in Amy. |
| Profile view (`amy profile show NPUB`) + edit | ✅ | `ProfileCommands`. Cache-first; `--refresh` forces a relay drain. |
+9 -2
View File
@@ -12,8 +12,15 @@ export-key, destroy}`, `cashu mint {ping, info}`, `cashu balance`. Extraction A
`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`. **Still pending:** `receive`, `send`,
`receive nutzap-sweep`, `maintenance`, `mint-rec`, and the interop harness.
`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
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.cli.stores.FileMarmotMessageStore
import com.vitorpamplona.amethyst.cli.stores.FileMlsGroupStateStore
import com.vitorpamplona.amethyst.commons.cashu.CashuWalletReader
import com.vitorpamplona.amethyst.commons.cashu.ops.CashuWalletOps
import com.vitorpamplona.amethyst.commons.cashu.ops.RestoreOutcome
import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
@@ -257,6 +258,32 @@ class Context(
return CashuWalletReader(signer).project(authored + inboundNutzaps)
}
/** Warm and return the wallet's NUT-13 seed, or null if no wallet. */
suspend fun cashuSeed(): ByteArray? {
warmCashuSeed()
return cachedCashuSeed
}
/**
* NUT-09 restore for one mint, mirroring Android's
* `CashuWalletState.restoreFromMint`: re-derive proofs from the seed
* (skipping secrets we already hold), then bump the persisted NUT-13
* counter past every slot the scan confirmed in use so later mints can't
* reuse one. Returns null when the wallet has no seed yet.
*/
suspend fun cashuRestore(mintUrl: String): RestoreOutcome? {
val seed = cashuSeed() ?: return null
val existingSecrets =
cashuSnapshot()
.tokenEntries
.flatMap { it.content.proofs }
.mapTo(HashSet()) { it.secret }
val outcome = cashuOps().restoreFromMint(mintUrl = mintUrl, seed = seed, startCounter = 0L, existingSecrets = existingSecrets)
val delta = (outcome.nextCounterAfterScan - cashuCounters.peek(outcome.keysetId)).coerceAtLeast(0L)
if (delta > 0) cashuCounters.reserve(outcome.keysetId, delta.toInt())
return outcome
}
private var prepared = false
/**
@@ -39,12 +39,16 @@ object CashuCommands {
route(
name = "cashu",
tail = tail,
usage = "cashu <wallet|mint|balance>",
usage = "cashu <wallet|mint|balance|receive|send|maintenance|mint-rec>",
routes =
mapOf(
"wallet" to { rest -> CashuWalletCommands.dispatch(dataDir, rest) },
"mint" to { rest -> CashuMintCommands.dispatch(rest) },
"balance" to { rest -> CashuBalanceCommand.run(dataDir, rest) },
"receive" to { rest -> CashuReceiveCommands.dispatch(dataDir, rest) },
"send" to { rest -> CashuSendCommands.dispatch(dataDir, rest) },
"maintenance" to { rest -> CashuMaintenanceCommands.dispatch(dataDir, rest) },
"mint-rec" to { rest -> CashuMintRecCommands.dispatch(dataDir, rest) },
),
)
}
@@ -0,0 +1,134 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands.cashu
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.cli.commands.route
/**
* `amy cashu maintenance <scrub|restore|migrate-keysets>` — wallet hygiene,
* all on the shared commons CashuWalletOps.
*/
object CashuMaintenanceCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
name = "cashu maintenance",
tail = tail,
usage = "cashu maintenance <scrub|restore|migrate-keysets>",
routes =
mapOf(
"scrub" to { rest -> scrub(dataDir, rest) },
"restore" to { rest -> restore(dataDir, rest) },
"migrate-keysets" to { rest -> migrate(dataDir, rest) },
),
)
private suspend fun scrub(
dataDir: DataDir,
rest: Array<String>,
): Int {
val mintFilter = Args(rest).flag("mint")?.trimEnd('/')
Context.open(dataDir).use { ctx ->
ctx.prepare()
val byMint =
ctx
.cashuSnapshot()
.tokenEntries
.groupBy { it.content.mint }
.filterKeys { mintFilter == null || it.trimEnd('/') == mintFilter }
val scrubbed = mutableListOf<Map<String, Any?>>()
var keptCount = 0
for ((mint, entries) in byMint) {
val staleEvents = runCatching { ctx.cashuOps().scrubStaleProofs(mint, entries) }.getOrDefault(emptyList())
val staleIds = staleEvents.map { it.id }.toSet()
entries.filter { it.event.id in staleIds }.forEach {
scrubbed.add(mapOf("event_id" to it.event.id, "amount_sats" to it.content.totalAmount(), "mint_url" to mint))
}
keptCount += entries.count { it.event.id !in staleIds }
}
Output.emit(mapOf("scrubbed" to scrubbed, "kept_count" to keptCount))
}
return 0
}
private suspend fun restore(
dataDir: DataDir,
rest: Array<String>,
): Int {
val mint = Args(rest).positional(0, "mint-url").trimEnd('/')
Context.open(dataDir).use { ctx ->
ctx.prepare()
return try {
val outcome = ctx.cashuRestore(mint) ?: return Output.error("no_wallet", "no wallet seed to restore from")
Output.emit(
mapOf(
"mint_url" to mint,
"sats_recovered" to outcome.amountRecoveredSats,
"proofs_recovered" to outcome.proofsRecovered,
"token_event_id" to outcome.tokenEvent?.id,
),
)
0
} catch (e: Exception) {
Output.error("mint_unreachable", e.message ?: "restore failed")
}
}
}
private suspend fun migrate(
dataDir: DataDir,
rest: Array<String>,
): Int {
val mintFilter = Args(rest).flag("mint")?.trimEnd('/')
Context.open(dataDir).use { ctx ->
ctx.prepare()
val byMint =
ctx
.cashuSnapshot()
.tokenEntries
.groupBy { it.content.mint }
.filterKeys { mintFilter == null || it.trimEnd('/') == mintFilter }
val migrated = mutableListOf<Map<String, Any?>>()
for ((mint, entries) in byMint) {
val activeId = runCatching { ctx.cashuOps().fetchActiveKeysetId(mint) }.getOrNull() ?: continue
val stale = entries.filter { entry -> entry.content.proofs.any { it.id != activeId } }
if (stale.isEmpty()) continue
val result = runCatching { ctx.cashuOps().migrateToActiveKeyset(mint, stale, activeId) }.getOrNull() ?: continue
migrated.add(
mapOf(
"mint_url" to mint,
"old_event_ids" to stale.map { it.event.id },
"amount_sats" to result.amountMigrated,
"proofs_migrated" to result.proofsMigrated,
),
)
}
Output.emit(mapOf("migrated" to migrated))
}
return 0
}
}
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands.cashu
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.cli.commands.route
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent
/**
* `amy cashu mint-rec <show|add|remove>` — NIP-87 mint recommendations
* (kind:38000), on the shared commons CashuWalletOps.
*/
object CashuMintRecCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
name = "cashu mint-rec",
tail = tail,
usage = "cashu mint-rec <show|add|remove>",
routes =
mapOf(
"show" to { rest -> show(dataDir, rest) },
"add" to { rest -> add(dataDir, rest) },
"remove" to { rest -> remove(dataDir, rest) },
),
)
private fun render(e: MintRecommendationEvent) =
mapOf(
"event_id" to e.id,
"mint_url" to e.mintUrls().firstOrNull(),
"dtag" to e.dTag(),
"review" to e.content,
"pubkey_hex" to e.pubKey,
"created_at" to e.createdAt,
)
private suspend fun show(
dataDir: DataDir,
rest: Array<String>,
): Int {
val author = Args(rest).flag("author")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val recs =
if (author == null) {
ctx.cashuSnapshot().recommendations
} else {
val pk = ctx.requireUserHex(author)
val filter = Filter(authors = listOf(pk), kinds = listOf(MintRecommendationEvent.KIND))
if (ctx.store.query<Event>(filter).isEmpty()) ctx.drain(ctx.bootstrapRelays().associateWith { listOf(filter) })
ctx.store
.query<Event>(filter)
.filterIsInstance<MintRecommendationEvent>()
.sortedByDescending { it.createdAt }
}
Output.emit(mapOf("recommendations" to recs.map { render(it) }))
}
return 0
}
private suspend fun add(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val url = args.positional(0, "mint-url").trimEnd('/')
Context.open(dataDir).use { ctx ->
ctx.prepare()
val event = ctx.cashuOps().recommendMint(mintUrl = url, mintAnnouncementDTag = args.flag("dtag"), review = args.flag("review") ?: "")
Output.emit(mapOf("event_id" to event.id, "mint_url" to url))
}
return 0
}
private suspend fun remove(
dataDir: DataDir,
rest: Array<String>,
): Int {
val id = Args(rest).positional(0, "event-id")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val event =
ctx.cashuSnapshot().recommendations.firstOrNull { it.id == id }
?: (ctx.store.query<Event>(Filter(ids = listOf(id), limit = 1)).firstOrNull() as? MintRecommendationEvent)
?: return Output.error("bad_args", "no recommendation event $id")
ctx.cashuOps().deleteRecommendation(event)
Output.emit(mapOf("deletion_event_id" to id, "deleted" to true))
}
return 0
}
}
@@ -0,0 +1,198 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands.cashu
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.cli.commands.route
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenB64Parser
/**
* `amy cashu receive <ln|complete|resume|token|nutzap-sweep>` — inbound flows,
* all on the shared commons CashuWalletOps the Android wallet runs.
*/
object CashuReceiveCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
name = "cashu receive",
tail = tail,
usage = "cashu receive <ln|complete|resume|token|nutzap-sweep>",
routes =
mapOf(
"ln" to { rest -> ln(dataDir, rest) },
"complete" to { rest -> complete(dataDir, rest) },
"resume" to { rest -> complete(dataDir, rest) },
"token" to { rest -> token(dataDir, rest) },
"nutzap-sweep" to { rest -> nutzapSweep(dataDir, rest) },
),
)
/** Resolve the mint to use: --mint, else the wallet's first configured mint. */
private fun pickMint(
flag: String?,
mints: List<String>,
): String? = flag?.trimEnd('/') ?: mints.firstOrNull()
private suspend fun ln(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val sats = args.positional(0, "sats").toLongOrNull() ?: return Output.error("bad_args", "sats must be a positive integer")
if (sats <= 0) return Output.error("bad_args", "sats must be positive")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val mint = pickMint(args.flag("mint"), ctx.cashuSnapshot().mints) ?: return Output.error("no_mint", "no mint configured; pass --mint URL")
return try {
val started = ctx.cashuOps().startMintFromLightning(mint, sats)
Output.emit(
mapOf(
"quote_id" to started.mintQuote.quote,
"invoice" to started.invoice,
"mint_url" to mint,
"amount_sats" to sats,
"kind_7374_event_id" to started.quoteEvent.id,
),
)
0
} catch (e: Exception) {
Output.error("mint_unreachable", describe(e))
}
}
}
private suspend fun complete(
dataDir: DataDir,
rest: Array<String>,
): Int {
val quoteId = Args(rest).positional(0, "quote-id")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val snap = ctx.cashuSnapshot()
val quoteEvent =
snap.pendingQuotes.firstOrNull { runCatching { it.quoteId(ctx.signer) }.getOrNull() == quoteId }
?: return Output.error("mint_quote_gone", "no pending kind:7374 quote with id $quoteId")
val mint = quoteEvent.mint() ?: snap.mints.firstOrNull() ?: return Output.error("no_mint", "quote has no mint")
return try {
// Poll the mint: a quote that isn't settled yet can't be minted.
val status = ctx.cashuOps().checkMintQuote(mint, quoteId)
if (!status.isSettled()) {
Output.emit(mapOf("status" to "pending", "quote_id" to quoteId, "mint_url" to mint))
return 0
}
// The kind:7374 stores only the quote id; recover the mint amount
// from the quote's bolt11 invoice (what the mint settled).
val amount = LnInvoiceUtil.getAmountInSats(status.request).toLong()
val done = ctx.cashuOps().completeMintFromLightning(mint, quoteEvent, amount)
Output.emit(
mapOf(
"status" to "paid",
"amount_sats" to done.mintedAmount,
"token_event_id" to done.tokenEvent.id,
"history_event_id" to done.historyEvent.id,
),
)
0
} catch (e: Exception) {
Output.error("mint_unreachable", describe(e))
}
}
}
private suspend fun token(
dataDir: DataDir,
rest: Array<String>,
): Int {
val raw = Args(rest).positional(0, "token").trim()
val parsed = CashuTokenB64Parser.parse(raw) ?: return Output.error("bad_args", "could not parse cashu token")
if (parsed.isEmpty()) return Output.error("bad_args", "token has no proofs")
Context.open(dataDir).use { ctx ->
ctx.prepare()
return try {
var total = 0L
var lastTokenEventId: String? = null
var lastHistoryEventId: String? = null
var mint = ""
for (t in parsed) {
val redeemed = ctx.cashuOps().redeemToken(raw, t.proofs, t.mint)
total += redeemed.amount
lastTokenEventId = redeemed.tokenEvent.id
lastHistoryEventId = redeemed.historyEvent.id
mint = t.mint
}
Output.emit(
mapOf(
"amount_sats" to total,
"mint_url" to mint,
"token_event_id" to lastTokenEventId,
"history_event_id" to lastHistoryEventId,
),
)
0
} catch (e: Exception) {
Output.error("mint_proofs_spent", describe(e))
}
}
}
private suspend fun nutzapSweep(
dataDir: DataDir,
rest: Array<String>,
): Int {
val mintFilter = Args(rest).flag("mint")?.trimEnd('/')
Context.open(dataDir).use { ctx ->
ctx.prepare()
val snap = ctx.cashuSnapshot()
val wallet = snap.walletEvent ?: return Output.error("no_wallet", "no wallet to redeem into")
val privkey = runCatching { wallet.privkey(ctx.signer) }.getOrNull() ?: return Output.error("signer_error", "could not decrypt wallet key")
val p2pkPubkey = snap.nutzapInfoEvent?.p2pkPubkey() ?: return Output.error("no_wallet", "no kind:10019 nutzap pubkey")
val redeemed = mutableListOf<Map<String, Any?>>()
val skipped = mutableListOf<Map<String, Any?>>()
for (nutzap in snap.nutzapEvents) {
if (mintFilter != null && nutzap.mintUrl()?.trimEnd('/') != mintFilter) continue
try {
val r = ctx.cashuOps().redeemNutzap(nutzap, privkey, p2pkPubkey)
redeemed.add(
mapOf(
"nutzap_id" to nutzap.id,
"amount_sats" to r.amount,
"token_event_id" to r.tokenEvent.id,
"history_event_id" to r.historyEvent.id,
),
)
} catch (e: Exception) {
skipped.add(mapOf("nutzap_id" to nutzap.id, "reason" to describe(e)))
}
}
Output.emit(mapOf("redeemed" to redeemed, "skipped" to skipped))
}
return 0
}
private fun describe(e: Throwable): String = e.message ?: e::class.simpleName ?: "error"
}
@@ -0,0 +1,202 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands.cashu
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.cli.commands.route
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
/**
* `amy cashu send <ln|token|nutzap>` — outbound flows on the shared
* commons CashuWalletOps. All spends scrub the source mint first (NUT-07 +
* NIP-09), exactly like the Android wallet, so a stale proof can't trip
* "proofs already spent".
*/
object CashuSendCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
name = "cashu send",
tail = tail,
usage = "cashu send <ln|token|nutzap>",
routes =
mapOf(
"ln" to { rest -> ln(dataDir, rest) },
"token" to { rest -> token(dataDir, rest) },
"nutzap" to { rest -> nutzap(dataDir, rest) },
),
)
private fun pickMint(
flag: String?,
mints: List<String>,
): String? = flag?.trimEnd('/') ?: mints.firstOrNull()
private suspend fun ln(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val invoice = args.positional(0, "invoice").trim()
Context.open(dataDir).use { ctx ->
ctx.prepare()
val snap = ctx.cashuSnapshot()
val mint = pickMint(args.flag("mint"), snap.mints) ?: return Output.error("no_mint", "no mint configured; pass --mint URL")
return try {
val quote = ctx.cashuOps().requestMeltQuote(mint, invoice)
// Scrub stale proofs at this mint before melting (matches Amethyst).
val scrubbed =
ctx
.cashuOps()
.scrubStaleProofs(mint, snap.tokenEntries.filter { it.content.mint == mint })
.map { it.id }
.toSet()
val available = snap.tokenEntries.filter { it.content.mint == mint && it.event.id !in scrubbed }
if (available.isEmpty()) return Output.error("insufficient_funds", "no proofs available at $mint")
val done = ctx.cashuOps().meltToLightning(mint, quote, available)
Output.emit(
mapOf(
"amount_sats" to done.paidAmount,
"fee_paid_sats" to done.fees,
"preimage" to done.preimage,
"history_event_id" to done.historyEvent.id,
),
)
0
} catch (e: Exception) {
Output.error("mint_unreachable", describe(e))
}
}
}
private suspend fun token(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val sats = args.positional(0, "sats").toLongOrNull() ?: return Output.error("bad_args", "sats must be a positive integer")
if (sats <= 0) return Output.error("bad_args", "sats must be positive")
val memo = args.flag("memo")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val snap = ctx.cashuSnapshot()
val mint = pickMint(args.flag("mint"), snap.mints) ?: return Output.error("no_mint", "no mint configured; pass --mint URL")
return try {
val scrubbed =
ctx
.cashuOps()
.scrubStaleProofs(mint, snap.tokenEntries.filter { it.content.mint == mint })
.map { it.id }
.toSet()
val available = snap.tokenEntries.filter { it.content.mint == mint && it.event.id !in scrubbed }
val balance = available.sumOf { it.content.totalAmount() }
if (balance < sats) return Output.error("insufficient_funds", "mint $mint has only $balance sat")
val done = ctx.cashuOps().sendAsToken(mint, sats, available, memo)
Output.emit(
mapOf(
"token" to done.cashuToken,
"amount_sats" to done.amount,
"mint_url" to mint,
"history_event_id" to done.historyEvent.id,
),
)
0
} catch (e: Exception) {
Output.error("mint_unreachable", describe(e))
}
}
}
private suspend fun nutzap(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val userArg = args.positional(0, "user")
val sats = args.positional(1, "sats").toLongOrNull() ?: return Output.error("bad_args", "sats must be a positive integer")
if (sats <= 0) return Output.error("bad_args", "sats must be positive")
val message = args.flag("message") ?: ""
val zappedId = args.flag("zapped")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val recipient = ctx.requireUserHex(userArg)
val snap = ctx.cashuSnapshot()
// Resolve the recipient's kind:10019 (cache then relay).
val info = resolveNutzapInfo(ctx, recipient) ?: return Output.error("no_mint", "recipient has no kind:10019 nutzap info")
val recipientP2pk = info.p2pkPubkey() ?: return Output.error("nutzap_locked_to_wrong_key", "recipient kind:10019 has no P2PK pubkey")
val recipientMints = info.mints().map { it.mintUrl.trimEnd('/') }.toSet()
// Pick a mint we both share AND hold a balance at.
val mint =
args.flag("mint")?.trimEnd('/')?.takeIf { it in recipientMints }
?: snap.balancesByMint.keys.firstOrNull { it.trimEnd('/') in recipientMints }
?: return Output.error("no_mint", "no shared mint with a balance; recipient mints=$recipientMints")
val available = snap.tokenEntries.filter { it.content.mint.trimEnd('/') == mint.trimEnd('/') }
if (available.sumOf { it.content.totalAmount() } < sats) return Output.error("insufficient_funds", "mint $mint has insufficient balance")
val zapped =
zappedId?.let { id ->
val ev = ctx.store.query<Event>(Filter(ids = listOf(id), limit = 1)).firstOrNull()
ev?.let { EventHintBundle(it) }
}
return try {
val sent = ctx.cashuOps().sendNutzap(mint, sats, recipient, recipientP2pk, zapped, message, available)
Output.emit(
mapOf(
"nutzap_event_id" to sent.nutzapEvent.id,
"recipient_pubkey" to recipient,
"mint_url" to mint,
"amount_sats" to sent.amount,
"history_event_id" to sent.historyEvent.id,
),
)
0
} catch (e: Exception) {
Output.error("mint_unreachable", describe(e))
}
}
}
/** Recipient kind:10019 from the local store, falling back to a relay drain. */
private suspend fun resolveNutzapInfo(
ctx: Context,
pubkey: String,
): NutzapInfoEvent? {
val filter = Filter(authors = listOf(pubkey), kinds = listOf(NutzapInfoEvent.KIND), limit = 1)
(ctx.store.query<Event>(filter).firstOrNull() as? NutzapInfoEvent)?.let { return it }
ctx.drain(ctx.bootstrapRelays().associateWith { listOf(filter) })
return ctx.store.query<Event>(filter).firstOrNull() as? NutzapInfoEvent
}
private fun describe(e: Throwable): String = e.message ?: e::class.simpleName ?: "error"
}
@@ -945,6 +945,33 @@ class CashuWalletOps(
proofs: List<CashuProof>,
): Map<String, ProofState> = ops(mintUrl).checkStates(proofs)
/**
* NUT-07 scrub for a single mint: check every proof in [entries] against
* the mint's `/checkstate`, then NIP-09 delete every kind:7375 event that
* holds at least one SPENT proof (a mixed-state entry is unusable — the
* next swap would pick it and trip HTTP 400). Returns the deleted token
* events so the caller can drop them from its own indexes.
*
* Shared by Android's `CashuWalletState.scrubLocallyStaleProofs` and amy's
* `cashu maintenance scrub`, so both prune identically.
*/
suspend fun scrubStaleProofs(
mintUrl: String,
entries: List<TokenEntry>,
): List<CashuTokenEvent> {
val allProofs = entries.flatMap { it.content.proofs }
if (allProofs.isEmpty()) return emptyList()
val states = ops(mintUrl).checkStates(allProofs)
val stale =
entries.filter { entry ->
entry.content.proofs.any { states[it.secret] == ProofState.SPENT }
}
if (stale.isEmpty()) return emptyList()
val delTemplate = DeletionEvent.build(stale.map { it.event })
publish(signer.sign(delTemplate))
return stale.map { it.event }
}
/**
* Swap every proof inside [entries] (which the caller has already
* filtered to "contains at least one stale-keyset proof") onto the