aboutsummaryrefslogtreecommitdiffstats
path: root/src
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
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')
-rw-r--r--src/api.js5
-rw-r--r--src/app.js35
-rw-r--r--src/i18n.js3
-rw-r--r--src/locales/ar.json5
-rw-r--r--src/locales/bn.json5
-rw-r--r--src/locales/de.json5
-rw-r--r--src/locales/es.json5
-rw-r--r--src/locales/fr.json5
-rw-r--r--src/locales/hi.json5
-rw-r--r--src/locales/id.json5
-rw-r--r--src/locales/ja.json5
-rw-r--r--src/locales/ko.json5
-rw-r--r--src/locales/mr.json5
-rw-r--r--src/locales/pt.json5
-rw-r--r--src/locales/ru.json5
-rw-r--r--src/locales/sw.json5
-rw-r--r--src/locales/ta.json5
-rw-r--r--src/locales/te.json5
-rw-r--r--src/locales/tr.json5
-rw-r--r--src/locales/ur.json5
-rw-r--r--src/locales/vi.json5
-rw-r--r--src/locales/zh.json5
-rw-r--r--src/wallet.js77
23 files changed, 194 insertions, 21 deletions
diff --git a/src/api.js b/src/api.js
index 23a1c2d..08f03b2 100644
--- a/src/api.js
+++ b/src/api.js
@@ -200,6 +200,11 @@ export class Api {
return this.#get(`/address/${address}/txs`);
}
+ // Full transaction (vin with prevouts, vout, status) — used for fee bumping.
+ getTx(txid) {
+ return this.#get(`/tx/${txid}`);
+ }
+
async feeRates() {
for (const host of this._hosts) {
if (host.cooldownUntil > Date.now()) continue;
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'))
);
}
diff --git a/src/i18n.js b/src/i18n.js
index 91234d7..396c0f7 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -110,6 +110,9 @@ const STR = {
scanUnrecognized: 'Couldn’t read a Bitcoin address from that QR.',
broadcastScanned: 'Broadcast transaction?',
broadcastNow: 'Broadcast',
+ bumpFee: 'Bump fee',
+ bumpConfirm: 'Replace with a higher fee?',
+ replaceTx: 'Replace',
scanOffline: 'You’re offline — reconnect to broadcast this transaction.',
broadcastFailed: 'Broadcast failed: {msg}',
signingFailed: 'Signing failed: {msg}',
diff --git a/src/locales/ar.json b/src/locales/ar.json
index aecf68e..11cfd61 100644
--- a/src/locales/ar.json
+++ b/src/locales/ar.json
@@ -141,5 +141,8 @@
"copy": "نسخ",
"switchUnit": "تبديل BTC / sats",
"footerMadeBy": "صُنع بواسطة",
- "footerSourceOn": "الكود المصدري على"
+ "footerSourceOn": "الكود المصدري على",
+ "bumpFee": "رفع الرسوم",
+ "bumpConfirm": "استبدال برسوم أعلى؟",
+ "replaceTx": "استبدال"
} \ No newline at end of file
diff --git a/src/locales/bn.json b/src/locales/bn.json
index 55c3a3a..43785aa 100644
--- a/src/locales/bn.json
+++ b/src/locales/bn.json
@@ -141,5 +141,8 @@
"copy": "কপি",
"switchUnit": "BTC / sats পাল্টান",
"footerMadeBy": "তৈরি করেছেন",
- "footerSourceOn": "সোর্স কোড"
+ "footerSourceOn": "সোর্স কোড",
+ "bumpFee": "ফি বাড়ান",
+ "bumpConfirm": "বেশি ফি দিয়ে প্রতিস্থাপন করবেন?",
+ "replaceTx": "প্রতিস্থাপন"
} \ No newline at end of file
diff --git a/src/locales/de.json b/src/locales/de.json
index 9e44612..3a59f78 100644
--- a/src/locales/de.json
+++ b/src/locales/de.json
@@ -141,5 +141,8 @@
"copy": "Kopieren",
"switchUnit": "BTC / sats wechseln",
"footerMadeBy": "gemacht von",
- "footerSourceOn": "Quellcode auf"
+ "footerSourceOn": "Quellcode auf",
+ "bumpFee": "Gebühr erhöhen",
+ "bumpConfirm": "Mit höherer Gebühr ersetzen?",
+ "replaceTx": "Ersetzen"
} \ No newline at end of file
diff --git a/src/locales/es.json b/src/locales/es.json
index fbdf21d..b9bddab 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -141,5 +141,8 @@
"copy": "Copiar",
"switchUnit": "Cambiar BTC / sats",
"footerMadeBy": "hecho por",
- "footerSourceOn": "código disponible en"
+ "footerSourceOn": "código disponible en",
+ "bumpFee": "Aumentar comisión",
+ "bumpConfirm": "¿Reemplazar con una comisión más alta?",
+ "replaceTx": "Reemplazar"
} \ No newline at end of file
diff --git a/src/locales/fr.json b/src/locales/fr.json
index a629174..c09408d 100644
--- a/src/locales/fr.json
+++ b/src/locales/fr.json
@@ -141,5 +141,8 @@
"copy": "Copier",
"switchUnit": "Basculer BTC / sats",
"footerMadeBy": "fait par",
- "footerSourceOn": "code source sur"
+ "footerSourceOn": "code source sur",
+ "bumpFee": "Augmenter les frais",
+ "bumpConfirm": "Remplacer avec des frais plus élevés ?",
+ "replaceTx": "Remplacer"
} \ No newline at end of file
diff --git a/src/locales/hi.json b/src/locales/hi.json
index b40b0ca..fd0d997 100644
--- a/src/locales/hi.json
+++ b/src/locales/hi.json
@@ -141,5 +141,8 @@
"copy": "कॉपी",
"switchUnit": "BTC / sats बदलें",
"footerMadeBy": "द्वारा बनाया",
- "footerSourceOn": "सोर्स यहाँ"
+ "footerSourceOn": "सोर्स यहाँ",
+ "bumpFee": "फ़ीस बढ़ाएँ",
+ "bumpConfirm": "अधिक फ़ीस के साथ बदलें?",
+ "replaceTx": "बदलें"
} \ No newline at end of file
diff --git a/src/locales/id.json b/src/locales/id.json
index 57e87ee..4edc234 100644
--- a/src/locales/id.json
+++ b/src/locales/id.json
@@ -141,5 +141,8 @@
"copy": "Salin",
"switchUnit": "Ganti BTC / sats",
"footerMadeBy": "dibuat oleh",
- "footerSourceOn": "sumber tersedia di"
+ "footerSourceOn": "sumber tersedia di",
+ "bumpFee": "Naikkan biaya",
+ "bumpConfirm": "Ganti dengan biaya lebih tinggi?",
+ "replaceTx": "Ganti"
} \ No newline at end of file
diff --git a/src/locales/ja.json b/src/locales/ja.json
index e107ab0..6bc532b 100644
--- a/src/locales/ja.json
+++ b/src/locales/ja.json
@@ -141,5 +141,8 @@
"copy": "コピー",
"switchUnit": "BTC / sats を切替",
"footerMadeBy": "作成者:",
- "footerSourceOn": "ソースは"
+ "footerSourceOn": "ソースは",
+ "bumpFee": "手数料を上げる",
+ "bumpConfirm": "より高い手数料で置き換えますか?",
+ "replaceTx": "置き換える"
} \ No newline at end of file
diff --git a/src/locales/ko.json b/src/locales/ko.json
index 765838a..e46b7c7 100644
--- a/src/locales/ko.json
+++ b/src/locales/ko.json
@@ -141,5 +141,8 @@
"copy": "복사",
"switchUnit": "BTC / sats 전환",
"footerMadeBy": "만든 사람:",
- "footerSourceOn": "소스 공개:"
+ "footerSourceOn": "소스 공개:",
+ "bumpFee": "수수료 올리기",
+ "bumpConfirm": "더 높은 수수료로 교체할까요?",
+ "replaceTx": "교체"
} \ No newline at end of file
diff --git a/src/locales/mr.json b/src/locales/mr.json
index 7b369a0..36dfd56 100644
--- a/src/locales/mr.json
+++ b/src/locales/mr.json
@@ -141,5 +141,8 @@
"copy": "कॉपी",
"switchUnit": "BTC / sats बदला",
"footerMadeBy": "द्वारे बनवले",
- "footerSourceOn": "सोर्स येथे"
+ "footerSourceOn": "सोर्स येथे",
+ "bumpFee": "फी वाढवा",
+ "bumpConfirm": "जास्त फीसह बदला?",
+ "replaceTx": "बदला"
} \ No newline at end of file
diff --git a/src/locales/pt.json b/src/locales/pt.json
index 277622d..01e07eb 100644
--- a/src/locales/pt.json
+++ b/src/locales/pt.json
@@ -141,5 +141,8 @@
"copy": "Copiar",
"switchUnit": "Alternar BTC / sats",
"footerMadeBy": "feito por",
- "footerSourceOn": "código disponível no"
+ "footerSourceOn": "código disponível no",
+ "bumpFee": "Aumentar taxa",
+ "bumpConfirm": "Substituir com uma taxa maior?",
+ "replaceTx": "Substituir"
} \ No newline at end of file
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 7c700b8..dad4ba4 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -141,5 +141,8 @@
"copy": "Копировать",
"switchUnit": "Переключить BTC / sats",
"footerMadeBy": "сделано —",
- "footerSourceOn": "исходный код на"
+ "footerSourceOn": "исходный код на",
+ "bumpFee": "Поднять комиссию",
+ "bumpConfirm": "Заменить с более высокой комиссией?",
+ "replaceTx": "Заменить"
} \ No newline at end of file
diff --git a/src/locales/sw.json b/src/locales/sw.json
index 4921e66..6e5c0bc 100644
--- a/src/locales/sw.json
+++ b/src/locales/sw.json
@@ -141,5 +141,8 @@
"copy": "Nakili",
"switchUnit": "Badilisha BTC / sats",
"footerMadeBy": "imetengenezwa na",
- "footerSourceOn": "msimbo unapatikana"
+ "footerSourceOn": "msimbo unapatikana",
+ "bumpFee": "Ongeza ada",
+ "bumpConfirm": "Badilisha kwa ada ya juu zaidi?",
+ "replaceTx": "Badilisha"
} \ No newline at end of file
diff --git a/src/locales/ta.json b/src/locales/ta.json
index b30ebb5..dadd598 100644
--- a/src/locales/ta.json
+++ b/src/locales/ta.json
@@ -141,5 +141,8 @@
"copy": "நகலெடு",
"switchUnit": "BTC / sats மாற்று",
"footerMadeBy": "உருவாக்கியவர்",
- "footerSourceOn": "மூலம்"
+ "footerSourceOn": "மூலம்",
+ "bumpFee": "கட்டணத்தை உயர்த்து",
+ "bumpConfirm": "அதிக கட்டணத்துடன் மாற்றவா?",
+ "replaceTx": "மாற்று"
} \ No newline at end of file
diff --git a/src/locales/te.json b/src/locales/te.json
index fc2b2d0..1380942 100644
--- a/src/locales/te.json
+++ b/src/locales/te.json
@@ -141,5 +141,8 @@
"copy": "కాపీ",
"switchUnit": "BTC / sats మార్చు",
"footerMadeBy": "తయారు చేసినవారు",
- "footerSourceOn": "సోర్స్ ఇక్కడ"
+ "footerSourceOn": "సోర్స్ ఇక్కడ",
+ "bumpFee": "ఫీజు పెంచు",
+ "bumpConfirm": "ఎక్కువ ఫీజుతో భర్తీ చేయాలా?",
+ "replaceTx": "భర్తీ చేయి"
} \ No newline at end of file
diff --git a/src/locales/tr.json b/src/locales/tr.json
index 80c352f..adb8d36 100644
--- a/src/locales/tr.json
+++ b/src/locales/tr.json
@@ -141,5 +141,8 @@
"copy": "Kopyala",
"switchUnit": "BTC / sats değiştir",
"footerMadeBy": "yapan:",
- "footerSourceOn": "kaynak kodu"
+ "footerSourceOn": "kaynak kodu",
+ "bumpFee": "Ücreti artır",
+ "bumpConfirm": "Daha yüksek ücretle değiştirilsin mi?",
+ "replaceTx": "Değiştir"
} \ No newline at end of file
diff --git a/src/locales/ur.json b/src/locales/ur.json
index dc48572..bb5c028 100644
--- a/src/locales/ur.json
+++ b/src/locales/ur.json
@@ -141,5 +141,8 @@
"copy": "کاپی",
"switchUnit": "BTC / sats بدلیں",
"footerMadeBy": "بنایا از",
- "footerSourceOn": "سورس کوڈ یہاں"
+ "footerSourceOn": "سورس کوڈ یہاں",
+ "bumpFee": "فیس بڑھائیں",
+ "bumpConfirm": "زیادہ فیس کے ساتھ بدلیں؟",
+ "replaceTx": "بدلیں"
} \ No newline at end of file
diff --git a/src/locales/vi.json b/src/locales/vi.json
index 0a4d413..2c17f7d 100644
--- a/src/locales/vi.json
+++ b/src/locales/vi.json
@@ -141,5 +141,8 @@
"copy": "Sao chép",
"switchUnit": "Đổi BTC / sats",
"footerMadeBy": "làm bởi",
- "footerSourceOn": "mã nguồn tại"
+ "footerSourceOn": "mã nguồn tại",
+ "bumpFee": "Tăng phí",
+ "bumpConfirm": "Thay thế với phí cao hơn?",
+ "replaceTx": "Thay thế"
} \ No newline at end of file
diff --git a/src/locales/zh.json b/src/locales/zh.json
index c26db60..0ac43c2 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -141,5 +141,8 @@
"copy": "复制",
"switchUnit": "切换 BTC / sats",
"footerMadeBy": "制作者",
- "footerSourceOn": "源代码见"
+ "footerSourceOn": "源代码见",
+ "bumpFee": "提高手续费",
+ "bumpConfirm": "用更高手续费替换?",
+ "replaceTx": "替换"
} \ No newline at end of file
diff --git a/src/wallet.js b/src/wallet.js
index 0f71454..a107fdc 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -587,6 +587,7 @@ export class Wallet {
...pay,
txid: u.txid,
index: u.vout,
+ sequence: 0xfffffffd, // signal opt-in RBF (BIP125) so sends are bumpable
witnessUtxo: { script: pay.script, amount: BigInt(u.value) },
};
});
@@ -667,6 +668,82 @@ export class Wallet {
return { txid: tx.id, total, outputs };
}
+ // Build + sign an RBF replacement of an unconfirmed outgoing tx at a higher
+ // fee rate. Reuses the original inputs and recipient outputs, taking the extra
+ // fee from the change output (pulling in a confirmed coin if change can't
+ // cover it). Returns a signed draft { hex, txid, fee, oldFee, feeRate, outputs }.
+ async bumpTx(origTxid, feeRate) {
+ const orig = await this.api.getTx(origTxid);
+ if (orig.status && orig.status.confirmed) throw new Error('Transaction already confirmed.');
+
+ // Every input of our own send is ours; reconstruct them with values + paths.
+ const ins = [];
+ let totalIn = 0;
+ for (const vin of orig.vin || []) {
+ const a = vin.prevout && vin.prevout.scriptpubkey_address;
+ const p = a && this.addrMap.get(a);
+ if (!p) throw new Error('Can only bump your own transactions.');
+ totalIn += vin.prevout.value;
+ ins.push({ txid: vin.txid, vout: vin.vout, value: vin.prevout.value, chain: p.chain, index: p.index });
+ }
+
+ // Split outputs: our first change-chain output is the change we'll shrink;
+ // everything else is a recipient and stays fixed.
+ const recipients = [];
+ let outTotal = 0;
+ let changeSeen = false;
+ for (const o of orig.vout || []) {
+ outTotal += o.value;
+ const p = o.scriptpubkey_address && this.addrMap.get(o.scriptpubkey_address);
+ if (p && p.chain === 1 && !changeSeen) { changeSeen = true; continue; }
+ recipients.push({ address: o.scriptpubkey_address, value: o.value });
+ }
+ const recipTotal = recipients.reduce((s, r) => s + r.value, 0);
+ const oldFee = totalIn - outTotal;
+
+ const rate = Math.max(1, Math.round(feeRate));
+ const DUST = 294; // p2wpkh dust
+ const vsizeOf = (nIn, nOut) => 11 + 68 * nIn + 31 * nOut;
+ const usedIds = new Set(ins.map((i) => `${i.txid}:${i.vout}`));
+ const spare = this.utxos.filter((u) => u.confirmed && !usedIds.has(utxoId(u)));
+ const extra = [];
+
+ const plan = () => {
+ const nIn = ins.length + extra.length;
+ const inAmt = totalIn + extra.reduce((s, u) => s + u.value, 0);
+ let nOut = recipients.length + 1;
+ let fee = Math.ceil(vsizeOf(nIn, nOut) * rate);
+ let change = inAmt - recipTotal - fee;
+ if (change < DUST) { nOut = recipients.length; fee = inAmt - recipTotal; change = 0; }
+ // BIP125: beat old fee by at least the replacement's vsize (incremental relay).
+ const minFee = oldFee + vsizeOf(nIn, nOut);
+ if (change > 0 && fee < minFee) {
+ fee = minFee; change = inAmt - recipTotal - fee;
+ if (change < DUST) { fee = inAmt - recipTotal; change = 0; }
+ }
+ return { inAmt, fee, change, ok: inAmt - recipTotal >= fee && fee > oldFee };
+ };
+ let pl = plan();
+ while (!pl.ok && spare.length) { extra.push(spare.shift()); pl = plan(); }
+ if (!pl.ok) throw new Error('Not enough funds to bump at this rate. Try a lower rate or CPFP.');
+
+ const allIns = [...ins, ...extra.map((u) => ({ txid: u.txid, vout: u.vout, value: u.value, chain: u.chain, index: u.index }))];
+ const t = new btc.Transaction();
+ for (const i of allIns) {
+ const pay = p2wpkh(this.derive(i.chain, i.index).pubkey, this.netCfg.net);
+ t.addInput({ ...pay, txid: i.txid, index: i.vout, sequence: 0xfffffffd, witnessUtxo: { script: pay.script, amount: BigInt(i.value) } });
+ }
+ for (const r of recipients) t.addOutputAddress(r.address, BigInt(r.value), this.netCfg.net);
+ const changeAddr = this.freshChange().address;
+ if (pl.change > 0) t.addOutputAddress(changeAddr, BigInt(pl.change), this.netCfg.net);
+ for (let k = 0; k < allIns.length; k++) t.signIdx(this.node(allIns[k].chain, allIns[k].index).privateKey, k);
+ t.finalize();
+
+ const outputs = recipients.map((r) => ({ address: r.address, value: r.value }));
+ if (pl.change > 0) outputs.push({ address: changeAddr, value: pl.change, change: true });
+ return { hex: t.hex, txid: t.id, fee: pl.fee, oldFee, feeRate: rate, outputs, replaces: origTxid };
+ }
+
// --- realtime (mempool.space WebSocket) ---------------------------------
// Pushes us new mempool/confirmed transactions for our addresses so history
// and balances update with no polling.