aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-15 09:52:18 -0700
committerAdam Soltys <[email protected]>2026-06-15 09:52:18 -0700
commit9ab7f898a02ecbafd7da511107cddf2fd5fe104d (patch)
tree922502921bfebe3f4d24df97cf47d64952c80aca
parent92b27e7d6de51ae178f068b079d3eec017689d85 (diff)
downloadcoinosv3-9ab7f898a02ecbafd7da511107cddf2fd5fe104d.tar.gz
coinosv3-9ab7f898a02ecbafd7da511107cddf2fd5fe104d.zip
Fix Max button, slash scan requests (429s), cache history locally
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) <[email protected]>
-rw-r--r--src/api.js7
-rw-r--r--src/app.js21
-rw-r--r--src/wallet.js86
3 files changed, 98 insertions, 16 deletions
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.