mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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:
@@ -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. |
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
+57
@@ -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) },
|
||||
),
|
||||
)
|
||||
}
|
||||
+82
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
@@ -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
|
||||
}
|
||||
}
|
||||
+70
@@ -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
|
||||
}
|
||||
}
|
||||
+50
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user