From 42a82c7e8b00f17af5e0b33c2c8eee9a14cde9b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 00:10:40 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- .../model/nip60Cashu/CashuWalletState.kt | 38 +--- cli/ROADMAP.md | 2 +- cli/plans/2026-05-28-cashu-cli.md | 11 +- .../com/vitorpamplona/amethyst/cli/Context.kt | 27 +++ .../cli/commands/cashu/CashuCommands.kt | 6 +- .../cashu/CashuMaintenanceCommands.kt | 134 ++++++++++++ .../commands/cashu/CashuMintRecCommands.kt | 117 ++++++++++ .../commands/cashu/CashuReceiveCommands.kt | 198 +++++++++++++++++ .../cli/commands/cashu/CashuSendCommands.kt | 202 ++++++++++++++++++ .../commons/cashu/ops/CashuWalletOps.kt | 27 +++ 10 files changed, 730 insertions(+), 32 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMaintenanceCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintRecCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuSendCommands.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index fdb2580d04..3018ddb4f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -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()) } } diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index ebcebc1a53..24e37a2ff2 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -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. | diff --git a/cli/plans/2026-05-28-cashu-cli.md b/cli/plans/2026-05-28-cashu-cli.md index c86c17b41a..12886f8244 100644 --- a/cli/plans/2026-05-28-cashu-cli.md +++ b/cli/plans/2026-05-28-cashu-cli.md @@ -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 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index 99771c6420..f135e6b407 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -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 /** diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt index c59c1489f5..877e99c222 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt @@ -39,12 +39,16 @@ object CashuCommands { route( name = "cashu", tail = tail, - usage = "cashu ", + usage = "cashu ", 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) }, ), ) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMaintenanceCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMaintenanceCommands.kt new file mode 100644 index 0000000000..274e83d60a --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMaintenanceCommands.kt @@ -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 ` โ€” wallet hygiene, + * all on the shared commons CashuWalletOps. + */ +object CashuMaintenanceCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + name = "cashu maintenance", + tail = tail, + usage = "cashu maintenance ", + 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, + ): 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>() + 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, + ): 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, + ): 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>() + 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 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintRecCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintRecCommands.kt new file mode 100644 index 0000000000..3f7a7bb6bc --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintRecCommands.kt @@ -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 ` โ€” NIP-87 mint recommendations + * (kind:38000), on the shared commons CashuWalletOps. + */ +object CashuMintRecCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + name = "cashu mint-rec", + tail = tail, + usage = "cashu mint-rec ", + 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, + ): 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(filter).isEmpty()) ctx.drain(ctx.bootstrapRelays().associateWith { listOf(filter) }) + ctx.store + .query(filter) + .filterIsInstance() + .sortedByDescending { it.createdAt } + } + Output.emit(mapOf("recommendations" to recs.map { render(it) })) + } + return 0 + } + + private suspend fun add( + dataDir: DataDir, + rest: Array, + ): 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, + ): 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(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 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt new file mode 100644 index 0000000000..f2c52589d2 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt @@ -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 ` โ€” inbound flows, + * all on the shared commons CashuWalletOps the Android wallet runs. + */ +object CashuReceiveCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + name = "cashu receive", + tail = tail, + usage = "cashu receive ", + 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? = flag?.trimEnd('/') ?: mints.firstOrNull() + + private suspend fun ln( + dataDir: DataDir, + rest: Array, + ): 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, + ): 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, + ): 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, + ): 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>() + val skipped = mutableListOf>() + 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" +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuSendCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuSendCommands.kt new file mode 100644 index 0000000000..87efd169d4 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuSendCommands.kt @@ -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 ` โ€” 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, + ): Int = + route( + name = "cashu send", + tail = tail, + usage = "cashu send ", + 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? = flag?.trimEnd('/') ?: mints.firstOrNull() + + private suspend fun ln( + dataDir: DataDir, + rest: Array, + ): 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, + ): 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, + ): 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(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(filter).firstOrNull() as? NutzapInfoEvent)?.let { return it } + ctx.drain(ctx.bootstrapRelays().associateWith { listOf(filter) }) + return ctx.store.query(filter).firstOrNull() as? NutzapInfoEvent + } + + private fun describe(e: Throwable): String = e.message ?: e::class.simpleName ?: "error" +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt index 11aa8484ee..cea2a3f9c2 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt @@ -945,6 +945,33 @@ class CashuWalletOps( proofs: List, ): Map = 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, + ): List { + 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