feat(cli): amy cashu wallet/mint/balance on shared NIP-60/61 code

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/<account>/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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
This commit is contained in:
Claude
2026-06-21 22:30:59 +00:00
parent d111c2589d
commit 8bb537438f
11 changed files with 566 additions and 3 deletions
+1
View File
@@ -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. |
+14 -3
View File
@@ -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
@@ -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")
@@ -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 `<data-dir>/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<Event>(
Filter(
authors = listOf(pk),
kinds =
listOf(
CashuWalletEvent.KIND,
NutzapInfoEvent.KIND,
CashuTokenEvent.KIND,
CashuSpendingHistoryEvent.KIND,
CashuMintQuoteEvent.KIND,
NutzapEvent.KIND,
MintRecommendationEvent.KIND,
),
),
)
val inboundNutzaps =
store.query<Event>(
Filter(kinds = listOf(NutzapEvent.KIND), tags = mapOf("p" to listOf(pk))),
)
return CashuWalletReader(signer).project(authored + inboundNutzaps)
}
private var prepared = false
/**
@@ -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<String>): 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<String>): 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 -> {
@@ -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<String>,
): 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
}
}
@@ -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<String>,
): Int =
route(
name = "cashu",
tail = tail,
usage = "cashu <wallet|mint|balance>",
routes =
mapOf(
"wallet" to { rest -> CashuWalletCommands.dispatch(dataDir, rest) },
"mint" to { rest -> CashuMintCommands.dispatch(rest) },
"balance" to { rest -> CashuBalanceCommand.run(dataDir, rest) },
),
)
}
@@ -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 <ping|info> 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<String>): Int =
route(
name = "cashu mint",
tail = tail,
usage = "cashu mint <ping|info> 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<String>): 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<String>): 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)
}
}
}
@@ -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|show|export-key|destroy>`.
*
* create [--mint URL] [--mints a,b] [--privkey HEX] [--relay r1,r2]
* show
* export-key
* destroy
*/
object CashuWalletCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
name = "cashu wallet",
tail = tail,
usage = "cashu wallet <create|show|export-key|destroy>",
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<String> =
flag(key)
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
private suspend fun create(
dataDir: DataDir,
rest: Array<String>,
): 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<String>,
): 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<String>,
): 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<String>,
): 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
}
}
@@ -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/<account>/cashu.json` as `{ "keyset_counters": { "<id>": <long> } }`.
*
* [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<String, Long> = mutableMapOf(),
)
private fun load(): Persisted =
if (file.exists()) {
runCatching { mapper.readValue<Persisted>(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
}
}
@@ -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/<account>/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
}