aboutsummaryrefslogtreecommitdiffstats
path: root/src
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
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')
-rw-r--r--src/app.js10
-rw-r--r--src/i18n.js4
-rw-r--r--src/wallet.js32
3 files changed, 41 insertions, 5 deletions
diff --git a/src/app.js b/src/app.js
index 5146414..ed4ca8c 100644
--- a/src/app.js
+++ b/src/app.js
@@ -2372,6 +2372,7 @@ function giftHistoryItem(g) {
function txHistoryItem(tx) {
const incoming = tx.net >= 0;
+ const stuck = !tx.confirmed && wallet.isStuck(tx);
return h(
'div',
{ class: 'item', style: 'cursor:pointer', onClick: () => { ui.txDetail = tx.txid; render(); } },
@@ -2379,9 +2380,11 @@ function txHistoryItem(tx) {
h('div', { class: 'grow' },
h('div', { class: 'row gap6' },
incoming ? t('received') : t('sent'),
- tx.confirmed ? null : h('span', { class: 'tag pending' }, t('pendingTag'))
+ tx.confirmed ? null
+ : stuck ? h('span', { class: 'tag', style: 'background:var(--red-soft);color:var(--red)' }, t('stuckTag'))
+ : h('span', { class: 'tag pending' }, t('pendingTag'))
),
- h('div', { class: 'small faint' }, tx.confirmed ? timeAgo(tx.blockTime) : t('awaitingConfirmation'))
+ h('div', { class: 'small faint' }, tx.confirmed ? timeAgo(tx.blockTime) : stuck ? t('stuckNote') : t('awaitingConfirmation'))
),
h('div', { style: 'text-align:right' },
h('div', { class: incoming ? 'amount-pos' : 'amount-neg' }, (incoming ? '+' : '') + fmtAmount(tx.net)),
@@ -2491,6 +2494,9 @@ function txDetailView(tx) {
tx.confirmed && tx.blockTime ? line(t('date'), new Date(tx.blockTime * 1000).toLocaleString()) : null,
!incoming && tx.fee ? line(t('networkFee'), fmtAmount(tx.fee) + ' ' + unitLabel()) : null
),
+ !tx.confirmed && wallet.isStuck(tx)
+ ? h('div', { class: 'warn-box' }, incoming ? t('stuckIncomingNote') : t('stuckOutgoingNote'))
+ : null,
h('div', { class: 'col gap6' },
h('span', { class: 'lab' }, t('transactionId')),
h('div', { class: 'addr-box', style: 'font-size:13px' }, tx.txid)
diff --git a/src/i18n.js b/src/i18n.js
index d948917..ce0f94d 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -174,6 +174,10 @@ const STR = {
received: 'Received',
sent: 'Sent',
pendingTag: '⏳ pending',
+ stuckTag: '⚠ stuck',
+ stuckNote: 'Fee too low — may not confirm',
+ stuckIncomingNote: '⚠ This payment’s fee is below the current network floor, so it may not confirm. Only the sender can speed it up (bump or re-send).',
+ stuckOutgoingNote: '⚠ This transaction’s fee is below the current network floor, so it may not confirm on its own. Bump the fee below to rebroadcast it at a higher rate.',
liveTag: 'Live',
pollingTag: 'Polling',
awaitingConfirmation: 'awaiting confirmation',
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();