diff --git a/cli/README.md b/cli/README.md index 76ec8f2844..7266b7df65 100644 --- a/cli/README.md +++ b/cli/README.md @@ -423,10 +423,11 @@ amy's on-relay events match the app's. NUT-13 counters persist in | Command | What it does | |---|---| | `amy cashu wallet create [--mint URL] [--mints a,b] [--privkey HEX] [--relay r1,r2]` | Publish a kind:17375 wallet + kind:10019 nutzap info. Advertises your outbox relays for nutzaps unless `--relay` overrides. | -| `amy cashu wallet show` | P2PK pubkey, mints, balance, per-mint balances, proof/history/pending counts. | +| `amy cashu wallet show [--sync]` | P2PK pubkey, mints, balance, per-mint balances, proof/history/pending counts. `--sync` pulls from the relays first. | | `amy cashu wallet export-key` | Decrypt and print the wallet's P2PK private key. | | `amy cashu wallet destroy` | Withdraw the nutzap advertisement and NIP-09 delete the wallet (leaves token events โ€” the ecash still lives at the mint). | -| `amy cashu balance [--mint URL]` | Spendable balance from the local store (optionally one mint). | +| `amy cashu sync` | Page every NIP-60/61 event off the relays into the local store, then report the balance and the proof/history counts. Every other `cashu` read projects the store and never touches the network, so this is what fills it โ€” run it first on a machine that didn't create the wallet. | +| `amy cashu balance [--mint URL] [--sync]` | Spendable balance from the local store (optionally one mint). `--sync` pages from the relays first. | | `amy cashu mint ping URL` / `info URL` | Stateless `/v1/info` probe (name/pubkey/version) / full DTO. | | `amy cashu receive ln SATS [--mint URL]` | Request a mint quote; prints the bolt11 + kind:7374 quote. | | `amy cashu receive complete QUOTE_ID` | Poll the quote; once the invoice is settled, mint proofs (kind:7375 + kind:7376). (`resume` is a deprecated alias.) | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 590964f6d4..d0fab242bd 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -77,7 +77,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 blobs (NIP-B7) | โœ… | `BlossomCommands` โ€” upload/download/list/delete/check/mirror on shared `commons` `BlossomClient`; live-server harness at `cli/tests/blossom/`. | -| 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-60 / 61 Cashu wallet + nutzaps | โœ… | Full surface: `cashu wallet {create,show,export-key,destroy}`, `mint {ping,info}`, `sync`, `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). Reads project the local store; `cashu sync` (or `--sync`) is what fills it, paging every relay to exhaustion so a cap can't truncate the proof set. 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 | โœ… | `BunkerCommand` + `NostrConnect` + `LoginCommand` โ€” host (`amy bunker[ connect]`) and client (`amy login bunker://` / `--nostrconnect`) sides, `--perms`/`--interactive` gating, `auth_url` challenges. | | Profile view (`amy profile show NPUB`) + edit | โœ… | `ProfileCommands`. Cache-first; `--refresh` forces a relay drain. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CashuContext.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CashuContext.kt index 28cf1d9a63..0ac9e65b23 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CashuContext.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CashuContext.kt @@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.cli.stores.FileCashuKeysetCounterStore 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.relayClient.assemblers.cashuInboundNutzapBackfillFilters +import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuOwnEventBackfillFilters import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -92,11 +94,60 @@ class CashuContext( reserveCashuCounters = { keysetId, count -> counters.reserve(keysetId, count) }, ) + /** + * Page this account's whole NIP-60/61/87 event set off the relays into the + * local store, so the next [snapshot] projects a complete wallet rather than + * whatever happened to be synced. Returns the number of events delivered + * (duplicates across relays already deduped by [Context.drainAllPages]). + * + * ### Why paging, and why this is opt-in + * + * [snapshot] reads the store and nothing else โ€” that is amy's contract, and + * it is why `cashu balance` is instant and offline-capable. The cost is that + * the balance is only ever as complete as whatever last filled the store, + * and nothing in amy fetched the NIP-60 kinds at all: a wallet created on + * the phone read zero here. So this is a verb (`amy cashu sync`) and a flag + * (`--sync`), never an implicit round-trip inside a read. + * + * It pages rather than issuing one REQ because a relay answers an unbounded + * REQ with its own cap applied to the newest matching events, and kind:7376 + * history outnumbers the kind:7375 proofs by an order of magnitude on a + * wallet with any history โ€” so the events that fall off the bottom are the + * proofs at mints the user hasn't touched lately, and the balance reads low + * with nothing to indicate it. `drainAllPages` walks each relay on its own + * `until` cursor to exhaustion, which is the only way to be sure. + * + * Split across relay sets exactly like the Android subscription: own events + * from the outbox (where they were published), inbound nutzaps from the + * inbox (where senders deliver them). + */ + suspend fun sync(): Int { + val pk = ctx.identity.pubKeyHex + val outbox = ctx.outboxRelays() + val inbox = ctx.inboxRelays() + + val own = + if (outbox.isEmpty()) { + emptyList() + } else { + ctx.drainAllPages(outbox.associateWith { cashuOwnEventBackfillFilters(pk) }) + } + val nutzaps = + if (inbox.isEmpty()) { + emptyList() + } else { + ctx.drainAllPages(inbox.associateWith { cashuInboundNutzapBackfillFilters(pk) }) + } + + return own.size + nutzaps.size + } + /** * 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 [Context.drain] first. + * cache only; commands that need fresh state should [sync] (or + * [Context.drain]) first. */ suspend fun snapshot(): CashuWalletReader.WalletSnapshot { val pk = ctx.identity.pubKeyHex 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 index 1d5b1ee728..7db9036e58 100644 --- 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 @@ -26,8 +26,13 @@ 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. + * `amy cashu balance [--mint URL] [--sync]` โ€” spendable balance from the local + * store, via the shared CashuWalletReader projection. Optionally filtered to one + * mint. + * + * `--sync` pages the wallet off the relays first (see [CashuContext.sync]). + * Without it this is a pure local read, and reports only what the store already + * holds โ€” which for a wallet created elsewhere may be nothing at all. */ object CashuBalanceCommand { suspend fun run( @@ -36,8 +41,10 @@ object CashuBalanceCommand { ): Int { val args = Args(rest) val mintFilter = args.flag("mint")?.trimEnd('/') + val sync = args.bool("sync") args.rejectUnknown() Context.open(dataDir).use { ctx -> + if (sync) ctx.cashu.sync() val snap = ctx.cashuSnapshot() val byMint = snap.balancesByMint.let { all -> diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt index 9114c438d1..4d8375daec 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt @@ -37,11 +37,13 @@ object CashuCommands { |Cashu wallet (NIP-60 / NIP-61): | cashu wallet create [--mint URL] [--mints a,b] publish a kind:17375 wallet + kind:10019 | [--privkey HEX] [--relay r1,r2] nutzap info - | cashu wallet show P2PK pubkey, mints, balances, counts + | cashu wallet show [--sync] P2PK pubkey, mints, balances, counts | cashu wallet export-key decrypt + print the wallet's P2PK key | cashu wallet destroy withdraw nutzap ad + NIP-09 delete wallet | cashu mint ping URL / info URL stateless /v1/info probe (no account) - | cashu balance [--mint URL] spendable balance from the local store + | cashu sync page every NIP-60/61 event off the relays + | into the local store, then report balance + | cashu balance [--mint URL] [--sync] spendable balance from the local store | cashu receive ln SATS [--mint URL] request a mint quote (bolt11 + kind:7374) | cashu receive complete QUOTE_ID poll the quote; mint proofs once settled | cashu receive token TOKEN redeem a cashuBโ€ฆ token into the wallet @@ -65,12 +67,13 @@ object CashuCommands { route( name = "cashu", tail = tail, - usage = "cashu ", + usage = "cashu ", help = USAGE, routes = mapOf( "wallet" to { rest -> CashuWalletCommands.dispatch(dataDir, rest) }, "mint" to { rest -> CashuMintCommands.dispatch(rest) }, + "sync" to { rest -> CashuSyncCommand.run(dataDir, rest) }, "balance" to { rest -> CashuBalanceCommand.run(dataDir, rest) }, "receive" to { rest -> CashuReceiveCommands.dispatch(dataDir, rest) }, "send" to { rest -> CashuSendCommands.dispatch(dataDir, rest) }, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuSyncCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuSyncCommand.kt new file mode 100644 index 0000000000..e1680a11a6 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuSyncCommand.kt @@ -0,0 +1,63 @@ +/* + * 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 sync` โ€” page the whole NIP-60/61 event set off the relays into the + * local store, then report the resulting balance. + * + * Every other `cashu` read command projects the local store and never touches + * the network, which is what makes them instant and offline-capable โ€” but it + * also means they only ever saw whatever else had filled the store, and nothing + * in amy fetched the NIP-60 kinds at all. This is the verb that fills it. + * + * Reports both the balance and the proof/history counts so a caller can tell a + * genuinely empty wallet from an unsynced one. + */ +object CashuSyncCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + args.rejectUnknown() + Context.open(dataDir).use { ctx -> + val downloaded = ctx.cashu.sync() + val snap = ctx.cashuSnapshot() + Output.emit( + mapOf( + "events_downloaded" to downloaded, + "balance_sats" to snap.balanceSats, + "balances_by_mint" to snap.balancesByMint, + "proofs_count" to snap.tokenEntries.sumOf { it.content.proofs.size }, + "token_events" to snap.tokenEntries.size, + "history_events" to snap.history.size, + ), + ) + } + return 0 + } +} 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 index b6427b2b34..5671d05d9a 100644 --- 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 @@ -115,9 +115,22 @@ object CashuWalletCommands { dataDir: DataDir, rest: Array, ): Int { + val args = Args(rest) + val sync = args.bool("sync") + args.rejectUnknown() Context.open(dataDir).use { ctx -> + if (sync) ctx.cashu.sync() 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`") + // "Not in the store" is not the same as "does not exist": a wallet created on another + // client is on the relays and simply hasn't been pulled down here yet, and telling the + // user to `create` one in that state would publish a fresh kind:17375 over a replaceable + // slot that already holds theirs. Point at `sync` first. + if (snap.walletEvent == null) { + return Output.error( + "no_wallet", + "no kind:17375 wallet in the local store โ€” run `cashu sync` to pull an existing one, or `cashu wallet create`", + ) + } Output.emit( mapOf( "p2pk_pubkey" to snap.nutzapInfoEvent?.p2pkPubkey(), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt index 9d56877cf9..e54b67c629 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt @@ -159,12 +159,55 @@ fun cashuWalletFilters( * Scoped to kind:7375 alone. Those are the events that carry money; history, * quotes and recommendations are display-only, and paging them too would * multiply the download for a wallet with a long history without changing a - * single balance. + * single balance. A caller that wants the rest โ€” a headless client with no + * scrolling list to page for it โ€” asks for [cashuOwnEventBackfillFilters]. */ -fun cashuProofBackfillFilters(pubkey: HexKey): List = +fun cashuProofBackfillFilters(pubkey: HexKey): List = cashuOwnEventBackfillFilters(pubkey, listOf(CashuTokenEvent.KIND)) + +/** + * Every NIP-60/87 kind this account authors, for a paged walk over the relays it + * publishes to. The read-side twin of the `authors=` half of [cashuWalletFilters], + * minus the live subscription's cap exposure. + */ +val OWN_CASHU_KINDS = + listOf( + CashuWalletEvent.KIND, + CashuTokenEvent.KIND, + CashuSpendingHistoryEvent.KIND, + CashuMintQuoteEvent.KIND, + NutzapInfoEvent.KIND, + MintRecommendationEvent.KIND, + ) + +/** + * A paged backfill of the account's **own** NIP-60/87 events, over the relays it + * publishes to. Defaults to every kind it authors; pass a narrower [kinds] to + * page only part of it (see [cashuProofBackfillFilters]). + * + * Hand this to `fetchAllPages` / `fetchAllPagesFromPool`, never to a plain REQ: + * the whole point is walking `until` cursors past the relay's cap, which is what + * silently truncates the single uncapped REQ [cashuWalletFilters] opens. + */ +fun cashuOwnEventBackfillFilters( + pubkey: HexKey, + kinds: List = OWN_CASHU_KINDS, +): List = listOf( Filter( - kinds = listOf(CashuTokenEvent.KIND), + kinds = kinds, authors = listOf(pubkey), ), ) + +/** + * A paged backfill of inbound NIP-61 nutzaps (kind:9321) addressed to this + * account, matched by the recipient `#p` tag because someone else authored them. + * Read from the account's inbox set, mirroring the split in [cashuWalletFilters]. + */ +fun cashuInboundNutzapBackfillFilters(pubkey: HexKey): List = + listOf( + Filter( + kinds = listOf(NutzapEvent.KIND), + tags = mapOf("p" to listOf(pubkey)), + ), + ) diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuBalanceTruncationTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuBalanceTruncationTest.kt index f0596040de..48053dd60f 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuBalanceTruncationTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuBalanceTruncationTest.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.commons.cashu import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletQueryState +import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuInboundNutzapBackfillFilters +import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuOwnEventBackfillFilters import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuProofBackfillFilters import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuWalletFilters import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -179,6 +181,37 @@ class CashuBalanceTruncationTest { assertNull(filter.until) } + @Test + fun `own-event backfill covers every kind the live subscription authors`() { + val relay = RelayUrlNormalizer.normalize("wss://relay.example.com") + val liveOwnKinds = + cashuWalletFilters( + CashuWalletQueryState(owner, setOf(relay), emptySet()), + since = null, + ).single { it.filter.authors == listOf(owner) } + .filter.kinds + .orEmpty() + + val backfillKinds = cashuOwnEventBackfillFilters(owner).single().kinds.orEmpty() + + // A headless client has no scrolling list to page for it, so its one-shot walk has to reach + // everything the capped live query would have asked for โ€” otherwise the gap just moves. + liveOwnKinds.forEach { + assertTrue("backfill must cover live-authored kind $it", it in backfillKinds) + } + } + + @Test + fun `inbound nutzap backfill matches by recipient tag, not author`() { + val f = cashuInboundNutzapBackfillFilters(owner).single() + + // Someone else signs a nutzap addressed to me, so it can only be found by the #p tag โ€” + // authors=[me] would return nothing and look like an empty inbox. + assertEquals(listOf(owner), f.tags?.get("p")) + assertNull(f.authors) + assertNull("paged walks must not carry a limit", f.limit) + } + @Test fun `the live subscription mixes proofs with history โ€” which is what starves them`() { val relay = RelayUrlNormalizer.normalize("wss://relay.example.com")