mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
The NIP-60 balance is a pure function of the kind:7375 events the client
holds, and nothing ever checked that it held all of them.
The live wallet subscription sends one REQ per outbox relay with no
`limit`, asking for six kinds at once. Relays answer an unbounded REQ
with their own cap applied to the newest matching events, and kind:7376
history outnumbers the proofs by an order of magnitude on any wallet with
a few hundred transactions — so the proofs that lose that race are the
ones at mints the user hasn't touched recently, which is exactly the
balance they'd forgotten they had. Nothing recovers afterwards: a capped
page and a complete page both just EOSE, and PerUserEoseManager records
that EOSE as the `since` for every later REQ to the relay, so the events
below the cap are never asked for again. The subset is stable across cold
starts and differs per device, which is how one account reads three
different balances on three phones with none of them right.
Page the proof set instead of taking one REQ's word for it: a one-shot
fetchAllPagesFromPool walk over kind:7375 on the outbox relays, run at
startup once the relay list is known and again (forced) when the user
opens the wallet. Only kind:7375 — history and quotes are display-only,
and paging them would multiply the download without moving a balance.
Because a relay that ignores NIP-09 will hand back proofs the mint
already burned, a walk that recovers anything new finishes with the
NUT-07 scrub so the mint, not the relay, decides what is still unspent;
a walk that finds nothing new skips it and costs no mint traffic.
The seed-based recovery that should have been the fallback was blind in
the same direction. NUT-13 derives a counter chain per keyset, and
scanRecoverableProofs only ever scanned the mint's *active* keyset, so
proofs minted before the mint's last rotation sat on a derivation path
nothing walked — the restore reported an empty wallet rather than an
incomplete scan, since a scan that never asks looks like one that found
nothing. It now walks every keyset the mint lists for the unit, active
first, skipping (and logging) any that errors. fetchKeysetById resolved
ids through /v1/keys, which lists active keysets only, so an inactive
keyset was unresolvable even when asked for by id; it now tries NUT-01's
/v1/keys/{id} first and keeps the active-list lookup as fallback.
Counter bookkeeping still tracks the active keyset alone — an inactive
keyset can never receive another mint, so advancing its counter would
protect nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaZ8RprmKC3sidsq6W8dKY