From 85eca26f9141d339c2e58a1022d49e4007eb638f Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Wed, 24 Jun 2026 09:15:45 -0700 Subject: Add RBF fee bumping for unconfirmed sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Signal opt-in RBF (BIP125, sequence 0xfffffffd) on all new sends so they're replaceable. - "Bump fee" on an unconfirmed sent tx (its detail view): rebuilds the same inputs + recipient outputs at the Priority rate, taking the extra fee from the change output (pulling in a confirmed coin if change can't cover it), re-signs, and routes to a "Replace with a higher fee?" confirmation showing old → new fee before broadcasting. - New keys translated into all 19 locales. Honest scope: P2A/TRUC/package-relay are L2/pre-signed-tx + node-relay features that don't apply to a single-sig wallet broadcasting single txs via public explorers, so they're intentionally out. CPFP (for receives / unbumpable sends) is a possible follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) (limited to 'src/app.js') diff --git a/src/app.js b/src/app.js index c43ec6f..5f64e90 100644 --- a/src/app.js +++ b/src/app.js @@ -1008,7 +1008,7 @@ function broadcastConfirmView() { return h( 'div', { class: 'card col' }, - h('h3', {}, t('broadcastScanned')), + h('h3', {}, b.bump ? t('bumpConfirm') : t('broadcastScanned')), h( 'div', { class: 'summary col', style: 'gap:0' }, @@ -1018,6 +1018,11 @@ function broadcastConfirmView() { h('span', { class: 'v' }, fmtAmount(o.value), ' ', unitTag()) ) ), + b.bump + ? h('div', { class: 'line' }, + h('span', { class: 'k' }, t('networkFee')), + h('span', { class: 'v' }, fmtAmount(b.bump.oldFee) + ' → ' + fmtAmount(b.bump.newFee) + ' ' + unitLabel())) + : null, h('div', { class: 'line' }, h('span', { class: 'k' }, t('transactionId')), h('span', { class: 'v mono break' }, shortTxid(b.txid)) @@ -1028,11 +1033,31 @@ function broadcastConfirmView() { h('button', { class: 'btn-ghost', onClick: () => { ui.broadcastTx = null; ui.sendError = ''; render(); } }, t('back')), ui.busy ? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner' })) - : h('button', { class: 'btn-primary grow', onClick: broadcastScanned }, t('broadcastNow')) + : h('button', { class: 'btn-primary grow', onClick: broadcastScanned }, b.bump ? t('replaceTx') : t('broadcastNow')) ) ); } +// Build a higher-fee RBF replacement of an unconfirmed send and route it to the +// broadcast confirmation. Bumps to the Priority rate. +async function bumpFee(txid) { + if (wallet.offline) { toast(t('scanOffline')); return; } + const rate = (wallet.feeRates && wallet.feeRates.fastestFee) || 10; + ui.busy = true; + render(); + try { + const d = await wallet.bumpTx(txid, rate); + ui.broadcastTx = { hex: d.hex, txid: d.txid, outputs: d.outputs, bump: { oldFee: d.oldFee, newFee: d.fee } }; + ui.txDetail = null; + ui.tab = 'send'; + ui.sendError = ''; + } catch (e) { + toast(e.message); + } + ui.busy = false; + render(); +} + async function broadcastScanned() { if (wallet.offline) { ui.sendError = t('scanOffline'); render(); return; } ui.busy = true; @@ -1480,6 +1505,12 @@ function txDetailView(tx) { copyBtn(tx.txid, t('copyId')), h('a', { class: 'btn btn-sm', href: wallet.api.explorerTx(tx.txid), target: '_blank', rel: 'noopener' }, t('viewOnMempool')) ), + // RBF: an unconfirmed send can be rebroadcast at a higher fee. + !tx.confirmed && !incoming && !wallet.offline + ? (ui.busy + ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) + : h('button', { class: 'btn-primary btn-block', onClick: () => bumpFee(tx.txid) }, t('bumpFee'))) + : null, h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.txDetail = null; render(); } }, t('backToHistory')) ); } -- cgit v1.2.3