aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/api.js13
-rw-r--r--src/wallet.js44
2 files changed, 56 insertions, 1 deletions
diff --git a/src/api.js b/src/api.js
index e011e9c..76852d4 100644
--- a/src/api.js
+++ b/src/api.js
@@ -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