mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
+10
-28
@@ -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
@@ -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. |
|
||||
|
||||
@@ -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) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+134
@@ -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
|
||||
}
|
||||
}
|
||||
+117
@@ -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
|
||||
}
|
||||
}
|
||||
+198
@@ -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"
|
||||
}
|
||||
+202
@@ -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"
|
||||
}
|
||||
+27
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user