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
@@ -719,6 +719,17 @@ export class OnchainBackend extends BaseBackend {
// Anything past the scanned window is unused by definition — the gap limit is what ended the scan.
while (index <= highest && used.has(index)) index++;
// ...but "unused" is not the same as "findable". The mark advances on every issuance, paid or not,
// so twenty unpaid addresses walk it past the end of the window the next scan covers, and a payment
// to the twenty-first would never be seen again. Rather than hand out an address the wallet could
// lose money on, stop and re-offer the last safe one: every index in that run is still virgin, so
// repeating one is not address reuse in the sense that matters — nothing was ever received on it.
// The floor of 0 covers a wallet the chain has never seen: Esplora short-circuits an account with no
// history and returns no addresses at all, leaving `highest` at -1, and index 0 is then the only one
// it will look at. Handing that out until it is paid is exactly right.
const ceiling = Math.max(0, highest + this.chain.issueAhead);
if (index > ceiling) index = ceiling;
if (!peek) this.issued.set(key, index + 1);
return this.derive(type, chain, index);
}