rate-limit every passphrase check, and stop address issuance outrunning the scan

two holes found auditing the wallet sidecar after the frozen-utxo fix.

the brute-force backoff lived inside UnlockSession.unlock alone, so /unlock capped
at five guesses a minute while export-seed — the one endpoint that returns the words
in the clear — took unlimited ones. every passphrase check now goes through the same
guard. verifyPassphrase rethrows LOCKED_OUT rather than folding it into `false`, so a
caller can tell "wrong" from "stop".

nextUnused advanced its mark on every issuance, paid or not, so a run of unpaid
addresses walked it past the end of the window the next scan covers; a payment there
would never be found again, and esplora has no rescan to go looking. sources now
declare how far past a scan's last index they can still see, and issuance clamps to
it — re-offering a virgin address rather than handing out one that could lose money.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 20:41:46 +00:00
co-authored by Claude Opus 5
parent bc06fbb2a5
commit e0f6a469aa
8 changed files with 133 additions and 28 deletions
@@ -96,6 +96,21 @@ export interface WalletChainSource {
/** Human-readable, for logs and the sync-state surface. e.g. `esplora(mempool.space)`. */
readonly label: string;
/**
* How many indices past the highest one `scan` reported this source will still find a payment on.
*
* Issuance must not outrun the search that finds the money again. `nextUnused` advances a mark every
* time an address is handed out, so a run of addresses issued and never paid walks the mark forward
* with nothing marking those indices used — and a later payment to one of them lands beyond the
* window the next scan covers. On a source that cannot rescan, that is an invisible, unrecoverable
* balance, from nothing worse than clicking "new address" too many times.
*
* Esplora is 0: its walk already extends a full gap limit past the last used address, so every index
* `scan` returned is covered and none beyond it is. NBXplorer reports only up to its own unused mark
* but watches a gap beyond it, so a modest overrun is still seen.
*/
readonly issueAhead: number;
/** Current best block height. The cheapest liveness probe the wallet has. */
getTipHeight(): Promise<number>;