From 8bb537438fc9f9035f8366ce7eef9c9d6004a32f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 22:30:59 +0000 Subject: [PATCH] feat(cli): amy cashu wallet/mint/balance on shared NIP-60/61 code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the offline Cashu command tier to amy, all driven by the shared commons wallet code so amy exercises the same path as the Android app: amy cashu wallet create [--mint URL] [--mints a,b] [--privkey HEX] [--relay r1,r2] amy cashu wallet show amy cashu wallet export-key amy cashu wallet destroy amy cashu mint ping URL (stateless) amy cashu mint info URL (stateless) amy cashu balance [--mint URL] - create/destroy reuse commons CashuWalletOps.publishWalletEvents / deleteWallet; show/balance reuse the CashuWalletReader projection over the local event store; mint ping/info hit quartz's MintHttpClient. - Context gains cashuOps() (wired to the file NUT-13 counter store + a per-run seed cache) and cashuSnapshot(); DataDir gains cashu.json. - Extraction D: CashuKeysetCounterStore contract in commons + FileCashuKeysetCounterStore (atomic ~/.amy//cashu.json). PRs 4 of cli/plans/2026-05-28-cashu-cli.md (extractions Aโ€“D + offline tier). receive/send/maintenance/mint-rec + interop harness still pending. Verified end-to-end against mint.minibits.cash and live relays. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/ROADMAP.md | 1 + cli/plans/2026-05-28-cashu-cli.md | 17 ++- .../com/vitorpamplona/amethyst/cli/Config.kt | 1 + .../com/vitorpamplona/amethyst/cli/Context.kt | 89 +++++++++++ .../com/vitorpamplona/amethyst/cli/Main.kt | 9 ++ .../cli/commands/cashu/CashuBalanceCommand.kt | 57 +++++++ .../cli/commands/cashu/CashuCommands.kt | 50 ++++++ .../cli/commands/cashu/CashuMintCommands.kt | 82 ++++++++++ .../cli/commands/cashu/CashuWalletCommands.kt | 143 ++++++++++++++++++ .../cli/stores/FileCashuKeysetCounterStore.kt | 70 +++++++++ .../commons/cashu/CashuKeysetCounterStore.kt | 50 ++++++ 11 files changed, 566 insertions(+), 3 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuBalanceCommand.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuWalletCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileCashuKeysetCounterStore.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuKeysetCounterStore.kt diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 32ca77110e..ebcebc1a53 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -63,6 +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-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 9afe7bee8d..c86c17b41a 100644 --- a/cli/plans/2026-05-28-cashu-cli.md +++ b/cli/plans/2026-05-28-cashu-cli.md @@ -1,8 +1,19 @@ # Cashu (NIP-60 / NIP-61 / NIP-87) in `amy` -**Status:** plan ยท **Date:** 2026-05-28 ยท **Roadmap row:** new (no -row today). To be added at the end of the parity matrix in -`cli/ROADMAP.md` once PR 1 lands. +**Status:** in progress ยท **Date:** 2026-05-28 ยท **Roadmap row:** added to +the parity matrix in `cli/ROADMAP.md`. + +**Landed so far:** Extraction B (`CashuWalletOps` โ†’ `commons/jvmAndroid`), +Extraction C (`CashuWalletReader` โ†’ `commons/jvmAndroid`), Extraction D (the +`CashuKeysetCounterStore` contract in `commons` + `FileCashuKeysetCounterStore` +in `cli`), and the offline command tier: `cashu wallet {create, show, +export-key, destroy}`, `cashu mint {ping, info}`, `cashu balance`. Extraction A +(token parsers) turned out to be unnecessary โ€” `V4Encoder`, +`CashuTokenB64Parser`, and friends already live in `quartz`. **Note:** +`CashuWalletOps`/`CashuWalletReader` land in the `jvmAndroid` source set, not +`commonMain` as originally sketched, because they compose quartz's jvmAndroid +`CashuMintOperations`/`MintHttpClient`. **Still pending:** `receive`, `send`, +`receive nutzap-sweep`, `maintenance`, `mint-rec`, and the interop harness. ## Why diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index c3bc468c76..dd7c0fa984 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -208,6 +208,7 @@ class DataDir( val identityFile = File(root, "identity.json") val stateFile = File(root, "state.json") val aliasesFile = File(root, "aliases.json") + val cashuFile = File(root, "cashu.json") val marmotDir = File(root, "marmot") val groupsDir = File(marmotDir, "groups") val keyPackageBundleFile = File(marmotDir, "keypackages.bundle") 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 e734f17af0..889c363698 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -20,9 +20,12 @@ */ package com.vitorpamplona.amethyst.cli +import com.vitorpamplona.amethyst.cli.stores.FileCashuKeysetCounterStore import com.vitorpamplona.amethyst.cli.stores.FileKeyPackageBundleStore 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.defaults.DefaultDMRelayList import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet import com.vitorpamplona.amethyst.commons.marmot.MarmotManager @@ -33,6 +36,7 @@ import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -52,7 +56,16 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent +import com.vitorpamplona.quartz.nip60Cashu.mintApi.DeterministicSecretFactory +import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent +import com.vitorpamplona.quartz.nip60Cashu.seed.CashuDeterministic +import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent +import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent +import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED @@ -162,6 +175,82 @@ class Context( /** Fully-wired manager. Call [prepare] once before use to load persisted state. */ val marmot: MarmotManager = MarmotManager(signer, mlsStore, messageStore, keyPackageStore) + // ------------------------------------------------------------------ + // Cashu (NIP-60 / NIP-61) โ€” shared wallet code from commons + // ------------------------------------------------------------------ + + /** Durable NUT-13 counter store at `/cashu.json`. */ + private val cashuCounters by lazy { FileCashuKeysetCounterStore(dataDir.cashuFile) } + + @Volatile private var cachedCashuSeed: ByteArray? = null + + /** + * Decrypt the wallet's NUT-13 seed once per run and cache it. The + * [DeterministicSecretFactory] thunk reads this synchronously, so any + * mint/swap op must warm it first (CashuWalletOps' seedWarmer does). + */ + private suspend fun warmCashuSeed() { + if (cachedCashuSeed != null) return + val priv = cashuSnapshot().walletEvent?.let { runCatching { it.privkey(signer) }.getOrNull() } ?: return + cachedCashuSeed = CashuDeterministic.deriveWalletSeed(priv.hexToByteArray()) + } + + /** + * Wallet operations driven by the exact same `commons` [CashuWalletOps] + * the Android app uses. Wired to publish on the account's outbox relays, + * the shared OkHttp instance for mint HTTP, and the file-backed NUT-13 + * counter store. + */ + fun cashuOps(): CashuWalletOps = + CashuWalletOps( + signer = signer, + publish = { event -> publish(event, outboxRelays()) }, + okHttpClient = { okhttp }, + secretFactory = + DeterministicSecretFactory( + seedProvider = { cachedCashuSeed }, + reserveCounters = { keysetId, count -> cashuCounters.reserve(keysetId, count) }, + ), + seedWarmer = { warmCashuSeed() }, + seedForRestore = { + warmCashuSeed() + cachedCashuSeed + }, + peekCashuCounter = { keysetId -> cashuCounters.peek(keysetId) }, + reserveCashuCounters = { keysetId, count -> cashuCounters.reserve(keysetId, count) }, + ) + + /** + * Project this account's locally-stored NIP-60/61/87 events into a wallet + * snapshot via the shared [CashuWalletReader] โ€” the same decrypt + + * del-rollover + pending-quote logic the Android holder runs. Reads the + * cache only; commands that need fresh state should [drain] first. + */ + suspend fun cashuSnapshot(): CashuWalletReader.WalletSnapshot { + val pk = identity.pubKeyHex + val authored = + store.query( + Filter( + authors = listOf(pk), + kinds = + listOf( + CashuWalletEvent.KIND, + NutzapInfoEvent.KIND, + CashuTokenEvent.KIND, + CashuSpendingHistoryEvent.KIND, + CashuMintQuoteEvent.KIND, + NutzapEvent.KIND, + MintRecommendationEvent.KIND, + ), + ), + ) + val inboundNutzaps = + store.query( + Filter(kinds = listOf(NutzapEvent.KIND), tags = mapOf("p" to listOf(pk))), + ) + return CashuWalletReader(signer).project(authored + inboundNutzaps) + } + private var prepared = false /** diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 5cda741f43..eeaa80a898 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -62,6 +62,8 @@ import com.vitorpamplona.amethyst.cli.commands.SyncCommand import com.vitorpamplona.amethyst.cli.commands.UseCommand import com.vitorpamplona.amethyst.cli.commands.VerifyCommand import com.vitorpamplona.amethyst.cli.commands.ZapCommand +import com.vitorpamplona.amethyst.cli.commands.cashu.CashuCommands +import com.vitorpamplona.amethyst.cli.commands.cashu.CashuMintCommands import com.vitorpamplona.amethyst.cli.commands.route import com.vitorpamplona.amethyst.cli.secrets.SecretStore import kotlinx.coroutines.runBlocking @@ -183,6 +185,12 @@ private suspend fun dispatch(argv: Array): Int { return RelayCommands.info(tail.drop(1).toTypedArray()) } + // `cashu mint ping|info URL` is a stateless NIP-60 /v1/info probe โ€” no + // account, no relays. The rest of `cashu โ€ฆ` operates on the account. + if (head == "cashu" && tail.firstOrNull() == "mint") { + return CashuMintCommands.dispatch(tail.drop(1).toTypedArray()) + } + val secrets = SecretStore.from(backendFlag = secretBackendFlag, passphraseFile = passphraseFileFlag) val dataDir = DataDir.resolve(accountFlag = accountFlag, secrets = secrets) @@ -217,6 +225,7 @@ private suspend fun dispatch(argv: Array): Int { "blossom" -> BlossomCommands.dispatch(dataDir, tail) "sync" -> SyncCommand.run(dataDir, tail) "git" -> GitCommands.dispatch(dataDir, tail) + "cashu" -> CashuCommands.dispatch(dataDir, tail) "podcast" -> PodcastCommands.dispatch(dataDir, tail) "bunker" -> BunkerCommand.run(dataDir, tail) else -> { diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuBalanceCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuBalanceCommand.kt new file mode 100644 index 0000000000..881574d7ca --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuBalanceCommand.kt @@ -0,0 +1,57 @@ +/* + * 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 + +/** + * `amy cashu balance [--mint URL]` โ€” spendable balance from the local store, + * via the shared CashuWalletReader projection. Optionally filtered to one mint. + */ +object CashuBalanceCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val mintFilter = Args(rest).flag("mint")?.trimEnd('/') + Context.open(dataDir).use { ctx -> + val snap = ctx.cashuSnapshot() + val byMint = + snap.balancesByMint.let { all -> + if (mintFilter == null) all else all.filterKeys { it.trimEnd('/') == mintFilter } + } + Output.emit( + mapOf( + "balance_sats" to byMint.values.sum(), + "balances_by_mint" to byMint, + "proofs_count" to + snap.tokenEntries + .filter { mintFilter == null || it.content.mint.trimEnd('/') == mintFilter } + .sumOf { it.content.proofs.size }, + ), + ) + } + return 0 + } +} 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 new file mode 100644 index 0000000000..c59c1489f5 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt @@ -0,0 +1,50 @@ +/* + * 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.DataDir +import com.vitorpamplona.amethyst.cli.commands.route + +/** + * `amy cashu โ€ฆ` โ€” NIP-60 Cashu wallet + NIP-61 nutzaps, driven entirely + * through the shared `commons` wallet code (CashuWalletOps + CashuWalletReader) + * so amy exercises the exact path the Android app runs. + * + * See `cli/plans/2026-05-28-cashu-cli.md` for the full command surface and the + * stable `--json` contract. + */ +object CashuCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + name = "cashu", + tail = tail, + usage = "cashu ", + routes = + mapOf( + "wallet" to { rest -> CashuWalletCommands.dispatch(dataDir, rest) }, + "mint" to { rest -> CashuMintCommands.dispatch(rest) }, + "balance" to { rest -> CashuBalanceCommand.run(dataDir, rest) }, + ), + ) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintCommands.kt new file mode 100644 index 0000000000..687e1fee50 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintCommands.kt @@ -0,0 +1,82 @@ +/* + * 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.Output +import com.vitorpamplona.amethyst.cli.commands.route +import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpClient +import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpException +import okhttp3.OkHttpClient + +/** + * `amy cashu mint URL` โ€” stateless NIP-60 mint /v1/info probes. + * No account or relays; talks straight to the mint over HTTP. + */ +object CashuMintCommands { + suspend fun dispatch(tail: Array): Int = + route( + name = "cashu mint", + tail = tail, + usage = "cashu mint URL", + routes = + mapOf( + "ping" to { rest -> ping(rest) }, + "info" to { rest -> info(rest) }, + ), + ) + + private val okhttp = OkHttpClient.Builder().build() + + private suspend fun ping(rest: Array): Int { + val url = Args(rest).positional(0, "mint-url") + return try { + val dto = MintHttpClient(url) { okhttp }.info() + Output.emit( + mapOf( + "mint_url" to url, + "name" to dto.name, + "pubkey" to dto.pubkey, + "version" to dto.version, + "description" to dto.description, + ), + ) + 0 + } catch (e: MintHttpException) { + Output.error("mint_http_${e.code}", e.message) + } catch (e: Exception) { + Output.error("mint_unreachable", e.message) + } + } + + private suspend fun info(rest: Array): Int { + val url = Args(rest).positional(0, "mint-url") + return try { + val dto = MintHttpClient(url) { okhttp }.info(force = true) + Output.emit(mapOf("mint_url" to url, "mint_info" to dto)) + 0 + } catch (e: MintHttpException) { + Output.error("mint_http_${e.code}", e.message) + } catch (e: Exception) { + Output.error("mint_unreachable", e.message) + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuWalletCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuWalletCommands.kt new file mode 100644 index 0000000000..19efab438f --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuWalletCommands.kt @@ -0,0 +1,143 @@ +/* + * 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.relay.normalizer.RelayUrlNormalizer + +/** + * `amy cashu wallet `. + * + * create [--mint URL] [--mints a,b] [--privkey HEX] [--relay r1,r2] + * show + * export-key + * destroy + */ +object CashuWalletCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + name = "cashu wallet", + tail = tail, + usage = "cashu wallet ", + routes = + mapOf( + "create" to { rest -> create(dataDir, rest) }, + "show" to { rest -> show(dataDir, rest) }, + "export-key" to { rest -> exportKey(dataDir, rest) }, + "destroy" to { rest -> destroy(dataDir, rest) }, + ), + ) + + private fun Args.csv(key: String): List = + flag(key) + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + + private suspend fun create( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val mints = (args.csv("mint") + args.csv("mints") + args.positional.toList()).distinct() + if (mints.isEmpty()) return Output.error("bad_args", "at least one --mint URL is required") + val privkey = args.flag("privkey") + val nutzapRelays = args.csv("relay").mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val created = + try { + ctx.cashuOps().publishWalletEvents(mints, privkey, nutzapRelays) + } catch (e: IllegalArgumentException) { + return Output.error("bad_args", e.message) + } + Output.emit( + mapOf( + "wallet_event_id" to created.walletEvent.id, + "nutzap_info_event_id" to created.nutzapInfo.id, + "p2pk_pubkey" to created.p2pkPubkeyHex, + "mints" to mints, + ), + ) + } + return 0 + } + + private suspend fun show( + dataDir: DataDir, + rest: Array, + ): Int { + Context.open(dataDir).use { ctx -> + val snap = ctx.cashuSnapshot() + if (snap.walletEvent == null) return Output.error("no_wallet", "no kind:17375 wallet in the local store โ€” run `cashu wallet create`") + Output.emit( + mapOf( + "p2pk_pubkey" to snap.nutzapInfoEvent?.p2pkPubkey(), + "mints" to snap.mints, + "balance_sats" to snap.balanceSats, + "balances_by_mint" to snap.balancesByMint, + "proofs_count" to snap.tokenEntries.sumOf { it.content.proofs.size }, + "history_count" to snap.history.size, + "pending_quotes" to snap.pendingQuotes.size, + ), + ) + } + return 0 + } + + private suspend fun exportKey( + dataDir: DataDir, + rest: Array, + ): Int { + Context.open(dataDir).use { ctx -> + val wallet = ctx.cashuSnapshot().walletEvent ?: return Output.error("no_wallet", "no wallet to export a key from") + val priv = + runCatching { wallet.privkey(ctx.signer) }.getOrNull() + ?: return Output.error("signer_error", "could not decrypt the wallet P2PK key") + Output.emit(mapOf("privkey_hex" to priv)) + } + return 0 + } + + private suspend fun destroy( + dataDir: DataDir, + rest: Array, + ): Int { + Context.open(dataDir).use { ctx -> + ctx.prepare() + val wallet = ctx.cashuSnapshot().walletEvent ?: return Output.error("no_wallet", "no wallet to destroy") + // Withdraws the nutzap advertisement and NIP-09 deletes the + // kind:17375; leaves token events (the ecash still lives at the mint). + ctx.cashuOps().deleteWallet(wallet) + Output.emit(mapOf("destroyed_wallet_event_id" to wallet.id)) + } + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileCashuKeysetCounterStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileCashuKeysetCounterStore.kt new file mode 100644 index 0000000000..c561cf2b10 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileCashuKeysetCounterStore.kt @@ -0,0 +1,70 @@ +/* + * 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.stores + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.cli.SecureFileIO +import com.vitorpamplona.amethyst.commons.cashu.CashuKeysetCounterStore +import java.io.File + +/** + * File-backed NUT-13 counter store for `amy`, persisted at + * `~/.amy//cashu.json` as `{ "keyset_counters": { "": } }`. + * + * [reserve] is the durability-critical path: it bumps the counter and + * rewrites the file **atomically** (tempfile + rename via [SecureFileIO]) + * before returning, so a crash after a swap can never replay a counter and + * trip the mint's `outputs already signed`. Access is serialized on the + * instance โ€” amy is single-process, but a `synchronized` block keeps two + * concurrent mint ops within one run safe. + */ +class FileCashuKeysetCounterStore( + private val file: File, +) : CashuKeysetCounterStore { + private val mapper = jacksonObjectMapper() + private val lock = Any() + + private data class Persisted( + val keyset_counters: MutableMap = mutableMapOf(), + ) + + private fun load(): Persisted = + if (file.exists()) { + runCatching { mapper.readValue(file.readText()) }.getOrDefault(Persisted()) + } else { + Persisted() + } + + override fun peek(keysetId: String): Long = synchronized(lock) { load().keyset_counters[keysetId] ?: 0L } + + override fun reserve( + keysetId: String, + count: Int, + ): Long = + synchronized(lock) { + val state = load() + val first = state.keyset_counters[keysetId] ?: 0L + state.keyset_counters[keysetId] = first + count.coerceAtLeast(0) + SecureFileIO.writeTextAtomic(file, mapper.writeValueAsString(state)) + first + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuKeysetCounterStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuKeysetCounterStore.kt new file mode 100644 index 0000000000..1da6f2855e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuKeysetCounterStore.kt @@ -0,0 +1,50 @@ +/* + * 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.commons.cashu + +/** + * Durable NUT-13 deterministic-secret counter store, keyed by mint keyset id. + * + * Cashu NUT-13 derives blinded secrets from the wallet seed plus a per-keyset + * monotonically increasing counter. Reusing a counter makes the mint reply + * `outputs already signed`, so every reservation MUST be persisted **before** + * the blinded outputs hit the mint. Implementations therefore make + * [reserve] atomic and durable. + * + * - Android backs this with `AccountSettings` / `CashuPreferences`. + * - `amy` backs this with `~/.amy//cashu.json`. + * + * `CashuWalletOps` consumes the two operations as plain function references + * ([peek] / [reserve]); this interface gives both hosts one named contract. + */ +interface CashuKeysetCounterStore { + /** The next counter for [keysetId] without advancing it (0 if unseen). */ + fun peek(keysetId: String): Long + + /** + * Atomically reserve [count] consecutive counters for [keysetId] and + * return the first reserved index. Persists before returning. + */ + fun reserve( + keysetId: String, + count: Int, + ): Long +}