diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/api.js | 5 | ||||
| -rw-r--r-- | src/app.js | 35 | ||||
| -rw-r--r-- | src/i18n.js | 3 | ||||
| -rw-r--r-- | src/locales/ar.json | 5 | ||||
| -rw-r--r-- | src/locales/bn.json | 5 | ||||
| -rw-r--r-- | src/locales/de.json | 5 | ||||
| -rw-r--r-- | src/locales/es.json | 5 | ||||
| -rw-r--r-- | src/locales/fr.json | 5 | ||||
| -rw-r--r-- | src/locales/hi.json | 5 | ||||
| -rw-r--r-- | src/locales/id.json | 5 | ||||
| -rw-r--r-- | src/locales/ja.json | 5 | ||||
| -rw-r--r-- | src/locales/ko.json | 5 | ||||
| -rw-r--r-- | src/locales/mr.json | 5 | ||||
| -rw-r--r-- | src/locales/pt.json | 5 | ||||
| -rw-r--r-- | src/locales/ru.json | 5 | ||||
| -rw-r--r-- | src/locales/sw.json | 5 | ||||
| -rw-r--r-- | src/locales/ta.json | 5 | ||||
| -rw-r--r-- | src/locales/te.json | 5 | ||||
| -rw-r--r-- | src/locales/tr.json | 5 | ||||
| -rw-r--r-- | src/locales/ur.json | 5 | ||||
| -rw-r--r-- | src/locales/vi.json | 5 | ||||
| -rw-r--r-- | src/locales/zh.json | 5 | ||||
| -rw-r--r-- | src/wallet.js | 77 |
23 files changed, 194 insertions, 21 deletions
@@ -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; @@ -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. |
