aboutsummaryrefslogtreecommitdiffstats
path: root/src/wallet.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/wallet.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/wallet.js')
-rw-r--r--src/wallet.js77
1 files changed, 77 insertions, 0 deletions
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.