aboutsummaryrefslogtreecommitdiffstats
path: root/src/wallet.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-23 23:40:09 -0700
committerAdam Soltys <[email protected]>2026-06-23 23:40:09 -0700
commit6190f25ca44ed12a0e136004096aef042c472b77 (patch)
treecca5b22cdd0b8626624d567cfd5e59928d97f65b /src/wallet.js
parentecdc460769fc6daad66df23604ceee6456b29061 (diff)
downloadcoinosv3-6190f25ca44ed12a0e136004096aef042c472b77.tar.gz
coinosv3-6190f25ca44ed12a0e136004096aef042c472b77.zip
Reconcile spends of held coins on refresh
refreshLive only walked the frontier forward, so a coin spent from an already-scanned address never reconciled — the balance stayed stale until a full rescan, even after the spend confirmed (e.g. a tx broadcast from another device). Add _reconcileHeld: re-verify every address that currently holds a UTXO and rebuild its balance/UTXOs from the explorer, catching spends whether pending or confirmed. Runs in the refreshLive path (load, poll, WS), so a refresh now self-corrects. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src/wallet.js')
-rw-r--r--src/wallet.js58
1 files changed, 58 insertions, 0 deletions
diff --git a/src/wallet.js b/src/wallet.js
index 2547b45..79c6353 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -309,6 +309,12 @@ export class Wallet {
}
}
+ // Re-verify the addresses that currently hold coins, to catch spends from
+ // an already-scanned address (the frontier walk above only looks forward,
+ // so a coin spent here — including by a tx broadcast on another device —
+ // would otherwise never reconcile without a full rescan).
+ if (await this._reconcileHeld()) changed = true;
+
if (changed) {
this.utxos.sort((x, y) => y.value - x.value);
this._sortTxs();
@@ -324,6 +330,58 @@ export class Wallet {
}
}
+ // Re-check every address that currently holds a UTXO: refresh its balance from
+ // chain/mempool stats and rebuild its UTXOs. This is what reconciles spends
+ // (a held coin that's now gone) — confirmed or still in the mempool, regardless
+ // of which device broadcast the spending transaction. Returns true if anything
+ // changed. Only touches addresses we already know, so it stays cheap.
+ async _reconcileHeld() {
+ const addrs = [...new Set(this.utxos.map((u) => u.address))];
+ let changed = false;
+ for (const address of addrs) {
+ const p = this.addrMap.get(address);
+ if (!p) continue;
+ let info;
+ try {
+ info = await this.api.addressInfo(address);
+ } catch {
+ continue;
+ }
+ const cs = info.chain_stats || {};
+ const ms = info.mempool_stats || {};
+ const confirmed = (cs.funded_txo_sum || 0) - (cs.spent_txo_sum || 0);
+ const pending = (ms.funded_txo_sum || 0) - (ms.spent_txo_sum || 0);
+ const arr = p.chain === 0 ? this.receive : this.change;
+ const entry = arr.find((e) => e.index === p.index);
+ if (entry && entry.confirmed === confirmed && entry.pending === pending) continue;
+
+ let us;
+ try {
+ us = await this.api.addressUtxos(address);
+ } catch {
+ continue;
+ }
+ if (entry) {
+ entry.confirmed = confirmed;
+ entry.pending = pending;
+ }
+ this.utxos = this.utxos.filter((u) => u.address !== address);
+ for (const u of us) {
+ this.utxos.push({
+ txid: u.txid,
+ vout: u.vout,
+ value: u.value,
+ address,
+ chain: p.chain,
+ index: p.index,
+ confirmed: !!u.status.confirmed,
+ });
+ }
+ changed = true;
+ }
+ return changed;
+ }
+
// silent=false: foreground load (shows loading state, always re-renders).
// silent=true: background refresh (poll / WS) — no spinner, and only emits
// if the visible state changed, so it never disrupts typing.