diff options
| author | Adam Soltys <[email protected]> | 2026-06-24 23:48:53 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-24 23:48:53 -0700 |
| commit | d4db7d545d51cde2f5a98426c1839e0fe0745efa (patch) | |
| tree | 455ddf9c4d1ffff97d184ab3d19315d2f2728d6c /src | |
| parent | faceb2eef8abe14302b057d835f0449bcd06bc59 (diff) | |
| download | coinosv3-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.js | 10 | ||||
| -rw-r--r-- | src/i18n.js | 4 | ||||
| -rw-r--r-- | src/wallet.js | 32 |
3 files changed, 41 insertions, 5 deletions
@@ -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(); |
