aboutsummaryrefslogtreecommitdiffstats
path: root/src/wallet.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-24 23:48:53 -0700
committerAdam Soltys <[email protected]>2026-06-24 23:48:53 -0700
commitd4db7d545d51cde2f5a98426c1839e0fe0745efa (patch)
tree455ddf9c4d1ffff97d184ab3d19315d2f2728d6c /src/wallet.js
parentfaceb2eef8abe14302b057d835f0449bcd06bc59 (diff)
downloadcoinosv3-d4db7d545d51cde2f5a98426c1839e0fe0745efa.tar.gz
coinosv3-d4db7d545d51cde2f5a98426c1839e0fe0745efa.zip
Detect stuck low-fee txs and stop actively monitoring them
A pending tx is judged 'stuck' once it has waited ~10 blocks (≈100 min) and its fee rate (fee/vsize) is below the mempool's current floor (minimumFee) — i.e. it's been purged and won't confirm without a bump. _txSummary now records vsize and a firstSeen timestamp (persisted in cache) to support this. Stuck txs are dropped from active monitoring: excluded from the watched-address set and from the fast-poll trigger, so they don't hold a socket slot or keep us in the 6s lane. The slow ~25s reconcile still catches one if the floor later drops and it confirms. History flags them (⚠ stuck) and the detail view explains it — pointing outgoing ones at the existing fee bump. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src/wallet.js')
-rw-r--r--src/wallet.js32
1 files changed, 29 insertions, 3 deletions
diff --git a/src/wallet.js b/src/wallet.js
index 54e427a..6b8be9f 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -584,16 +584,38 @@ export class Wallet {
for (const vout of tx.vout || []) {
if (vout.scriptpubkey_address && mine.has(vout.scriptpubkey_address)) received += vout.value;
}
+ const confirmed = !!(tx.status && tx.status.confirmed);
+ // Preserve when we first saw it pending, so we can later judge a stuck tx.
+ const prior = this.txs.find((t) => t.txid === tx.txid);
return {
txid: tx.txid,
net: received - sent, // >0 incoming, <0 outgoing
fee: tx.fee || 0,
- confirmed: !!(tx.status && tx.status.confirmed),
+ vsize: tx.weight ? Math.ceil(tx.weight / 4) : (prior && prior.vsize) || 0,
+ confirmed,
blockTime: (tx.status && tx.status.block_time) || 0,
blockHeight: (tx.status && tx.status.block_height) || 0,
+ firstSeen: confirmed ? 0 : (prior && prior.firstSeen) || Date.now(),
};
}
+ // A pending tx is "stuck" once it's waited ~10 blocks (≈100 min) and its fee
+ // rate is below the mempool's current floor (minimumFee) — i.e. it's been
+ // purged and won't confirm without a bump (RBF/CPFP). We stop actively
+ // monitoring these (no socket slot, no fast poll); the slow reconcile still
+ // picks it up if the floor later drops and it confirms after all.
+ isStuck(tx) {
+ if (!tx || tx.confirmed || !tx.firstSeen || !tx.vsize) return false;
+ if (Date.now() - tx.firstSeen < 100 * 60000) return false; // give it ~10 blocks
+ const floor = (this.feeRates && this.feeRates.minimumFee) || 1;
+ return tx.fee / tx.vsize < floor;
+ }
+ _stuckTxids() {
+ const s = new Set();
+ for (const tx of this.txs) if (this.isStuck(tx)) s.add(tx.txid);
+ return s;
+ }
+
_sortTxs() {
this.txs.sort((a, b) => {
if (a.confirmed !== b.confirmed) return a.confirmed ? 1 : -1; // pending first
@@ -1116,9 +1138,10 @@ export class Wallet {
this.derive(0, this.nextReceiveIndex).address,
this.derive(1, this.nextChangeIndex).address,
]);
+ const stuck = this._stuckTxids();
for (const u of this.utxos) {
if (set.size >= 10) break;
- if (!u.confirmed) set.add(u.address);
+ if (!u.confirmed && !stuck.has(u.txid)) set.add(u.address);
}
return [...set];
}
@@ -1133,7 +1156,10 @@ export class Wallet {
// 3s tick is a cheap time check — it issues no request unless one is due.
this._pollTimer = setInterval(() => {
if (this.offline) return;
- const pending = this.utxos.some((u) => !u.confirmed);
+ // Active pending = an unconfirmed coin we still expect to confirm; stuck
+ // (purged, too-low-fee) txs don't keep us in the fast lane.
+ const stuck = this._stuckTxids();
+ const pending = this.utxos.some((u) => !u.confirmed && !stuck.has(u.txid));
const due = this.live && !pending ? 25000 : 6000;
if (Date.now() - (this._lastPoll || 0) < due) return;
this._lastPoll = Date.now();