diff options
| author | Adam Soltys <[email protected]> | 2026-07-04 23:07:51 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-04 23:07:51 -0700 |
| commit | b6875f7bcefe10c4c23868d7952c4584d557aa60 (patch) | |
| tree | 625ac1980d55f36ce96733f1427e0994af5e5bc8 /src | |
| parent | 086bcf33d532f61505a0bc19e8f2d1f08392acce (diff) | |
| download | coinosv3-b6875f7bcefe10c4c23868d7952c4584d557aa60.tar.gz coinosv3-b6875f7bcefe10c4c23868d7952c4584d557aa60.zip | |
Prune RBF-replaced pending txs from history
A replaced/evicted unconfirmed tx lingered forever as a phantom pending
receive (signetfaucet fee-bumps its batch payout every few minutes, so
History grew a new copy per bump). The poll now status-checks pending
txs — via /tx/:txid, since /tx/:txid/status answers 200 confirmed:false
for txids the node no longer knows — drops the vanished ones, and
re-fetches their addresses' coins and history, which _reconcileHeld
skips when the replacement pays identical amounts.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/api.js | 13 | ||||
| -rw-r--r-- | src/wallet.js | 44 |
2 files changed, 56 insertions, 1 deletions
@@ -486,7 +486,7 @@ export class Api { return null; } - async #get(path, asText = false) { + async #get(path, asText = false, { notFoundNull = false } = {}) { if (this.offline) throw new Error('offline'); let lastErr; for (let attempt = 0; attempt < 6; attempt++) { @@ -499,6 +499,7 @@ export class Api { } try { const res = await this.#run(host, path); + if (notFoundNull && res.status === 404) return null; // definitive: not an error if (!res.ok) throw new Error(`${res.status} ${res.statusText}: ${path}`); return asText ? await res.text() : await res.json(); } catch (e) { @@ -533,6 +534,16 @@ export class Api { return this.#get(`/tx/${txid}`); } + // Confirmation status of one tx, or null when the node no longer knows the + // txid at all — i.e. it was RBF-replaced or evicted from the mempool. The + // 404 is the answer we're after, so it doesn't retry or fail over. NOTE: + // /tx/:txid/status is NOT usable for this — electrs and mempool.space both + // answer 200 {confirmed:false} for unknown txids; only /tx/:txid 404s. + async txStatus(txid) { + const tx = await this.#get(`/tx/${txid}`, false, { notFoundNull: true }); + return tx ? tx.status || { confirmed: false } : null; + } + async feeRates() { for (const host of this._hosts) { if (host.cooldownUntil > Date.now()) continue; diff --git a/src/wallet.js b/src/wallet.js index a5d7b6e..93f5c39 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -476,6 +476,9 @@ export class Wallet { // would otherwise never reconcile without a full rescan). if (await this._reconcileHeld()) changed = true; + // Drop pending txs the mempool no longer knows (RBF-replaced/evicted). + if (await this._pruneReplaced()) changed = true; + if (changed) { this.utxos.sort((x, y) => y.value - x.value); this._sortTxs(); @@ -774,6 +777,47 @@ export class Wallet { return s; } + // Unconfirmed txs the mempool no longer knows were RBF-replaced or evicted — + // e.g. a faucet fee-bumping its batched payout mints a new txid each round. + // The utxo reconcile follows the replacement (address utxos are re-fetched), + // but without this every replaced version lingers in History as a phantom + // pending receive. Only a definitive 404 prunes; transient errors keep the + // tx for the next poll, and a fresh tx gets a grace period in case the + // polled explorer hasn't indexed our own broadcast yet. + async _pruneReplaced() { + const stale = this.txs.filter((t) => !t.confirmed && Date.now() - (t.firstSeen || 0) > 60_000); + let changed = false; + for (const t of stale) { + let status; + try { status = await this.api.txStatus(t.txid); } catch { continue; } + if (status !== null) continue; + this.txs = this.txs.filter((x) => x.txid !== t.txid); + changed = true; + // A replacement often pays the same address the same amount, which + // _reconcileHeld skips as "unchanged" — so the dead outpoints (and the + // missing replacement tx) must be re-fetched here explicitly. + const addrs = [...new Set(this.utxos.filter((u) => u.txid === t.txid).map((u) => u.address))]; + for (const address of addrs) { + const p = this.addrMap.get(address); + if (!p) continue; + try { + const us = await this.api.addressUtxos(address); + 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 }); + } + const list = await this.api.addressTxs(address); + for (const tx of list) { + const summary = this._txSummary(tx); + const at = this.txs.findIndex((x) => x.txid === tx.txid); + if (at >= 0) this.txs[at] = summary; else this.txs.push(summary); + } + } catch {} + } + } + return changed; + } + _sortTxs() { this.txs.sort((a, b) => { if (a.confirmed !== b.confirmed) return a.confirmed ? 1 : -1; // pending first |
