From 9ab7f898a02ecbafd7da511107cddf2fd5fe104d Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 15 Jun 2026 09:52:18 -0700 Subject: Fix Max button, slash scan requests (429s), cache history locally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Max button: fill the amount field with the computed maximum (n inputs, 1 output, current fee rate) so it gives visible feedback instead of just blanking/disabling the field. Rate limits: eliminate look-ahead (gap limit 1 — stop at the first unused address, safe because we only ever expose one unused address at a time), query one address at a time (API concurrency 1, 400ms spacing across two backends), scan chains sequentially, and pause 500ms after each used address. An empty scan is now ~2-3 requests instead of ~10+. History cache: persist scanned state (addresses, utxos, txs, indices) in localStorage keyed by sha256(seed) — the seed itself is never stored. Re- importing the same seed in this browser shows the last-known balance/history instantly while a fresh scan runs. Cleared by nothing automatically (public chain data); different seeds get separate keys. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api.js | 7 ++--- src/app.js | 21 ++++++++++++++- src/wallet.js | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 98 insertions(+), 16 deletions(-) (limited to 'src') diff --git a/src/api.js b/src/api.js index 43d2bae..3e10351 100644 --- a/src/api.js +++ b/src/api.js @@ -28,9 +28,10 @@ export class Api { })); this._rr = 0; - // Global throttle. - this._maxConcurrent = 2; - this._minGapMs = 200; + // Global throttle: one request at a time, well spaced. Combined with + // round-robin across two backends this keeps each host's rate very low. + this._maxConcurrent = 1; + this._minGapMs = 400; this._active = 0; this._nextStart = 0; this._queue = []; diff --git a/src/app.js b/src/app.js index e3a023c..c986323 100644 --- a/src/app.js +++ b/src/app.js @@ -347,6 +347,7 @@ async function openWallet(mnemonic) { async function enterWallet(mnemonic, passphrase) { wallet.load({ mnemonic, passphrase, netName: 'mainnet', offline: false }); persistSession(mnemonic, passphrase); + wallet.restoreCache(); // show last-known balance/history instantly, if cached ui.screen = 'wallet'; ui.tab = 'receive'; ui.send = blankSend(); @@ -626,7 +627,10 @@ function sendForm() { { class: 'input-group' }, h('input', { type: 'number', step: 'any', min: '0', placeholder: '0.00000000', - disabled: s.max, value: s.max ? '' : s.amount, + disabled: s.max, + value: s.max + ? (s.unit === 'sats' ? String(estimatedMaxSats()) : fmtBtc(estimatedMaxSats())) + : s.amount, onInput: (e) => (s.amount = e.target.value), }), h( @@ -714,6 +718,21 @@ function coinControl() { ); } +// Coins that a send would draw from (all, or the manually-selected subset). +function spendableCoins() { + const s = ui.send; + return s.manual ? wallet.utxos.filter((u) => s.coins.has(utxoId(u))) : wallet.utxos; +} + +// Estimated max sendable = selected total − fee for (n inputs, 1 output). +function estimatedMaxSats() { + const coins = spendableCoins(); + const total = coins.reduce((a, u) => a + u.value, 0); + const vbytes = 11 + 68 * coins.length + 31; + const fee = Math.ceil(vbytes * currentFeeRate()); + return Math.max(0, total - fee); +} + function currentFeeRate() { const s = ui.send; if (s.feeChoice === 'custom') return Math.max(1, Math.round(Number(s.customFee) || 1)); diff --git a/src/wallet.js b/src/wallet.js index 3627275..4f6cf0d 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -12,18 +12,23 @@ import { HDKey } from '@scure/bip32'; import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english'; import { hex } from '@scure/base'; +import { sha256 } from '@noble/hashes/sha256'; import * as btc from '@scure/btc-signer'; import { p2wpkh } from '@scure/btc-signer/payment'; import { Api, pool } from './api.js'; -// The BIP44 standard gap limit is 20, but that means ~40 address lookups per -// scan. We can safely use a much smaller limit because this wallet only ever -// exposes ONE unused receive address at a time (freshReceive = first unused; -// there is no "generate another address" button). That keeps used addresses -// contiguous, so the gap between them is never more than 1 — a limit of 5 is a -// comfortable safety margin while cutting scan requests ~4x. -const GAP_LIMIT = 5; +// No look-ahead: stop scanning a chain at the first unused address. This wallet +// only ever exposes ONE unused address at a time (freshReceive = first unused; +// there is no "generate another address" button), so used addresses stay +// contiguous and there is never a gap to look past. Keeps scans tiny. +const GAP_LIMIT = 1; + +// Extra pause after finding a used address, before querying the next index — +// keeps us gentle on the explorers' rate limits when a wallet has activity. +const USED_HIT_DELAY_MS = 500; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const NETS = { mainnet: { net: btc.NETWORK, coin: 0 }, @@ -170,6 +175,7 @@ export class Wallet { } // --- online scan -------------------------------------------------------- + // Query one address at a time; stop at the first unused one (no look-ahead). async scanChain(chain) { const found = []; let gap = 0; @@ -181,7 +187,12 @@ export class Wallet { info.chain_stats.tx_count > 0 || info.mempool_stats.tx_count > 0; found.push({ chain, index: i, address, used }); this.addrMap.set(address, { chain, index: i }); - gap = used ? 0 : gap + 1; + if (used) { + gap = 0; + await sleep(USED_HIT_DELAY_MS); // slow down before fetching the next one + } else { + gap += 1; + } i++; } return found; @@ -211,10 +222,8 @@ export class Wallet { } try { this.addrMap = new Map(); - [this.receive, this.change] = await Promise.all([ - this.scanChain(0), - this.scanChain(1), - ]); + this.receive = await this.scanChain(0); + this.change = await this.scanChain(1); this.nextReceiveIndex = firstUnused(this.receive); this.nextChangeIndex = firstUnused(this.change); @@ -226,6 +235,7 @@ export class Wallet { await this.refreshUtxos(); await this.refreshHistory(); this.loaded = true; + this.saveCache(); } finally { this._refreshing = false; if (!silent) { @@ -510,6 +520,58 @@ export class Wallet { } } + // --- local history cache ------------------------------------------------ + // Cache the scanned state in localStorage, keyed by a hash of the seed (the + // seed itself is never stored). Re-importing the same seed in this browser + // then shows the last-known balance/history instantly while a fresh scan + // runs in the background. + _cacheKey() { + const bytes = new TextEncoder().encode(`${this.mnemonic}\n${this.passphrase}`); + return 'btc-wallet-cache:' + hex.encode(sha256(bytes)).slice(0, 32); + } + + saveCache() { + try { + localStorage.setItem( + this._cacheKey(), + JSON.stringify({ + v: 1, + receive: this.receive, + change: this.change, + utxos: this.utxos, + txs: this.txs, + nextReceiveIndex: this.nextReceiveIndex, + nextChangeIndex: this.nextChangeIndex, + feeRates: this.feeRates, + }) + ); + } catch {} + } + + restoreCache() { + try { + const raw = localStorage.getItem(this._cacheKey()); + if (!raw) return false; + const d = JSON.parse(raw); + this.receive = d.receive || []; + this.change = d.change || []; + this.utxos = d.utxos || []; + this.txs = d.txs || []; + this.nextReceiveIndex = d.nextReceiveIndex || 0; + this.nextChangeIndex = d.nextChangeIndex || 0; + this.feeRates = d.feeRates || this.feeRates; + this.addrMap = new Map(); + for (const a of [...this.receive, ...this.change]) { + this.addrMap.set(a.address, { chain: a.chain, index: a.index }); + } + this.loaded = true; + this.emit(); + return true; + } catch { + return false; + } + } + // --- offline snapshot --------------------------------------------------- // Exported on the ONLINE device. Contains no secrets — just what an offline // signer needs: coins, fee rates, and which addresses are next/fresh. -- cgit v1.2.3