From 1fc45e4bf6237b63c36f3cc0157f22eef78d9159 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:14:15 +0000 Subject: [PATCH] =?UTF-8?q?feat(cli):=20amy=20cashu=20sync=20=E2=80=94=20p?= =?UTF-8?q?age=20the=20wallet=20off=20the=20relays=20into=20the=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit amy's cashu reads project the local event store and never touch the network: that is the contract, and it is why `cashu balance` is instant and works offline. The gap was that nothing in amy ever put the NIP-60 kinds INTO the store. CashuContext.snapshot() queries kinds 17375/7375/ 7376/7374/10019/38000 out of ctx.store, and a grep for CashuTokenEvent.KIND across cli/ returns exactly that one call site — a store query. So a wallet created on the phone read as an empty wallet here, and `cashu wallet show` answered "no kind:17375 wallet — run `cashu wallet create`", which for a replaceable kind is advice that would have overwritten the user's real wallet. Adds `amy cashu sync`, plus `--sync` on `cashu balance` and `cashu wallet show` for the common case. Kept opt-in rather than folded into the reads: an implicit network round-trip inside a command documented as a local projection is a contract change, and the offline read is worth keeping. It pages rather than issuing one REQ, for the same reason the Android backfill does: 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 what falls off the bottom is the proofs at mints the user hasn't touched lately — the balance reads low with nothing to indicate it. drainAllPages walks each relay on its own until cursor to exhaustion. Relay sets are split exactly as the Android subscription splits them: own events from the outbox, inbound nutzaps from the inbox. The filter builders move to commons per the thin-assembly rule, and generalize what the Android backfill already had: cashuProofBackfillFilters is now the kind:7375 narrowing of cashuOwnEventBackfillFilters, which defaults to every kind the account authors. cashuInboundNutzapBackfillFilters covers the #p half. A test pins that the own-event backfill covers every kind the live subscription authors, so the gap can't reopen by someone adding a kind to one and not the other. Verified against a real binary, not just the compiler: `cashu sync` emits its six JSON keys and exits 0 with no relays configured, `--sync` parses on both readers, an unknown flag still exits 2, and the reworded no_wallet error goes to stderr with exit 1. New --json keys (additive): events_downloaded, token_events, history_events on `cashu sync`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HaZ8RprmKC3sidsq6W8dKY --- cli/README.md | 5 +- cli/ROADMAP.md | 2 +- .../amethyst/cli/CashuContext.kt | 53 +++++++++++++++- .../cli/commands/cashu/CashuBalanceCommand.kt | 11 +++- .../cli/commands/cashu/CashuCommands.kt | 9 ++- .../cli/commands/cashu/CashuSyncCommand.kt | 63 +++++++++++++++++++ .../cli/commands/cashu/CashuWalletCommands.kt | 15 ++++- .../assemblers/CashuWalletFilterAssembler.kt | 49 ++++++++++++++- .../cashu/CashuBalanceTruncationTest.kt | 33 ++++++++++ 9 files changed, 227 insertions(+), 13 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuSyncCommand.kt 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")