aboutsummaryrefslogtreecommitdiffstats
path: root/src/app.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-24 09:15:45 -0700
committerAdam Soltys <[email protected]>2026-06-24 09:15:45 -0700
commit85eca26f9141d339c2e58a1022d49e4007eb638f (patch)
tree755e852934528e0c8fb114545f6b39a803ce6c90 /src/app.js
parent17acf5ec80a0c0b0acc44a6a8373e154c68d4748 (diff)
downloadcoinosv3-85eca26f9141d339c2e58a1022d49e4007eb638f.tar.gz
coinosv3-85eca26f9141d339c2e58a1022d49e4007eb638f.zip
Add RBF fee bumping for unconfirmed sends
- 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) <[email protected]>
Diffstat (limited to 'src/app.js')
-rw-r--r--src/app.js35
1 files changed, 33 insertions, 2 deletions
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'))
);
}