From c7300714c57652e68fcadcdbcfcce3df8dd7d37b Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 2 Mar 2026 22:50:58 -0800 Subject: fee bumping --- index.ts | 3 +- lib/cpfp.ts | 107 +++++++++++++++++++++++++++++++++++++++++++ lib/p2a.ts | 54 ++++++++++++++++++++++ lib/payments.ts | 130 ++++++++++++++++++++++++++++++++++++++++++++++------- lib/zmq.ts | 3 +- routes/payments.ts | 66 ++++++++++++++------------- 6 files changed, 314 insertions(+), 49 deletions(-) create mode 100644 lib/cpfp.ts create mode 100644 lib/p2a.ts diff --git a/index.ts b/index.ts index 9e41e240..2acf685b 100644 --- a/index.ts +++ b/index.ts @@ -111,7 +111,8 @@ app.post("/print", auth, payments.print); app.post("/send/:lnaddress/:amount", auth, payments.lnaddress); app.post("/send", auth, payments.internal); app.post("/gateway", payments.gateway); -app.post("/replace", auth, payments.replace); +app.post("/bump", auth, payments.bump); +app.post("/bump/estimate", auth, payments.bumpEstimate); app.get("/decode/:bolt11", payments.decode); app.post("/fetchinvoice", payments.fetchinvoice); app.get("/ark/address", payments.arkAddress); diff --git a/lib/cpfp.ts b/lib/cpfp.ts new file mode 100644 index 00000000..6f0eb948 --- /dev/null +++ b/lib/cpfp.ts @@ -0,0 +1,107 @@ +import config from "$config"; +import { s } from "$lib/db"; +import { l } from "$lib/logging"; +import { CHILD_VSIZE } from "$lib/p2a"; +import { fail, sats } from "$lib/utils"; +import rpc from "@coinos/rpc"; + +const bc = rpc(config.bitcoin); + +export function estimateBumpCost( + parentFee: number, + parentVsize: number, + targetFeeRate: number, +): number { + const packageVsize = parentVsize + CHILD_VSIZE; + const requiredPackageFee = Math.ceil(targetFeeRate * packageVsize); + return Math.max(0, requiredPackageFee - parentFee); +} + +export async function createCpfpChild(payment: any, targetFeeRate: number) { + const { hash: parentTxid, p2aVout, parentVsize, fee: parentFee, bumpReserve } = payment; + + if (p2aVout < 0) fail("no P2A output on this transaction"); + + const childFee = estimateBumpCost(parentFee, parentVsize, targetFeeRate); + if (childFee <= 0) fail("transaction already meets target fee rate"); + if (childFee > bumpReserve) fail("bump cost exceeds reserve"); + + // Get a confirmed wallet UTXO to fund the child + const utxos = await bc.listUnspent(1); + if (!utxos.length) fail("no confirmed wallet UTXOs available for bump"); + + const walletUtxo = utxos[0]; + const walletUtxoValue = sats(walletUtxo.amount); + const p2aValue = 240; + + const changeValue = walletUtxoValue + p2aValue - childFee; + if (changeValue < 0) fail("wallet UTXO too small for bump"); + + // Get a change address from the wallet + const changeAddress = await bc.getRawChangeAddress(); + + // Build child tx inputs + const inputs = [ + { txid: parentTxid, vout: p2aVout }, + { txid: walletUtxo.txid, vout: walletUtxo.vout }, + ]; + + // Build child tx outputs + const outputs = [{ [changeAddress]: parseFloat((changeValue / 1e8).toFixed(8)) }]; + + // Create raw transaction with nVersion=3 (TRUC child must also be v3) + let raw = await bc.createRawTransaction(inputs, outputs); + raw = "03000000" + raw.substring(8); + + // Sign — wallet signs the wallet input, P2A input needs no signature (keyless anchor) + if ((config.bitcoin as any).walletpass) await bc.walletPassphrase((config.bitcoin as any).walletpass, 300); + const { hex } = await bc.signRawTransactionWithWallet(raw); + + // Verify child vsize ≤ 1000 (TRUC child limit) + const decoded = await bc.decodeRawTransaction(hex); + if (decoded.vsize > 1000) fail("child transaction exceeds TRUC 1kvB limit"); + + // Try mempool accept, fall back to package submission + let childTxid: string; + try { + const r = await bc.testMempoolAccept([hex]); + if (r[0].allowed) { + childTxid = await bc.sendRawTransaction(hex); + } else { + // Parent might not be in mempool — try package submission + const parentHex = await bc.getRawTransaction(parentTxid); + const pkg = await bc.submitPackage([parentHex, hex]); + if (pkg["tx-results"]) { + const results = Object.values(pkg["tx-results"]) as any[]; + const childResult = results[results.length - 1]; + childTxid = childResult?.txid || decoded.txid; + } else { + fail("package submission failed"); + } + } + } catch (e) { + // Try package submission as fallback + try { + const parentHex = await bc.getRawTransaction(parentTxid); + const pkg = await bc.submitPackage([parentHex, hex]); + if (pkg["tx-results"]) { + const results = Object.values(pkg["tx-results"]) as any[]; + const childResult = results[results.length - 1]; + childTxid = childResult?.txid || decoded.txid; + } else { + fail("package submission failed"); + } + } catch (e2) { + fail(`bump failed: ${e2.message}`); + } + } + + // Update payment + payment.childTxid = childTxid; + payment.bumpedFee = childFee; + await s(`payment:${payment.id}`, payment); + + l("CPFP bump", parentTxid, "->", childTxid, "fee:", childFee); + + return { txid: childTxid, childFee }; +} diff --git a/lib/p2a.ts b/lib/p2a.ts new file mode 100644 index 00000000..6e256f20 --- /dev/null +++ b/lib/p2a.ts @@ -0,0 +1,54 @@ +import config from "$config"; +import { bech32m } from "@scure/base"; + +export const P2A_VALUE = 240; +export const P2A_SCRIPT = "51024e73"; // OP_1 OP_PUSHBYTES_2 0x4e73 +export const CHILD_VSIZE = 152; + +const P2A_WITNESS_PROGRAM = new Uint8Array([0x4e, 0x73]); +const P2A_WITNESS_VERSION = 1; + +const P2A_ADDRESSES: Record = { + mainnet: "bc1pfeessrawgf", + testnet: "tb1pfeessrawgf", + signet: "tb1pfeessrawgf", + regtest: "bcrt1pfeessrawgf", +}; + +const BECH32M_PREFIXES: Record = { + mainnet: "bc", + testnet: "tb", + signet: "tb", + regtest: "bcrt", +}; + +function verifyP2AAddresses() { + for (const [network, expected] of Object.entries(P2A_ADDRESSES)) { + const prefix = BECH32M_PREFIXES[network]; + const words = [P2A_WITNESS_VERSION, ...bech32m.toWords(P2A_WITNESS_PROGRAM)]; + const encoded = bech32m.encode(prefix, words); + if (encoded !== expected) { + throw new Error(`P2A address mismatch for ${network}: got ${encoded}, expected ${expected}`); + } + } +} + +verifyP2AAddresses(); + +export function getP2AAddress(): string { + const network = config.bitcoin.network || "mainnet"; + return P2A_ADDRESSES[network]; +} + +export function isP2AOutput(scriptPubKeyHex: string): boolean { + return scriptPubKeyHex === P2A_SCRIPT; +} + +export function calculateBumpReserve( + userRate: number, + fastestRate: number, + parentVsize: number, +): number { + if (userRate >= fastestRate) return 0; + return Math.ceil((fastestRate - userRate) * parentVsize + fastestRate * CHILD_VSIZE); +} diff --git a/lib/payments.ts b/lib/payments.ts index dbebfa39..363fb8ff 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -55,6 +55,7 @@ import got from "got"; import { v4 } from "uuid"; import { PaymentType } from "$lib/types"; +import { P2A_VALUE, getP2AAddress, isP2AOutput, calculateBumpReserve } from "$lib/p2a"; const bc = rpc(config.bitcoin); const lq = rpc(config.liquid); @@ -682,10 +683,15 @@ export const sendOnchain = async (params) => { return sendNonCustodial(params); } - if (!hex) ({ hex } = await build(params)); + let buildResult; + if (!hex) { + buildResult = await build(params); + hex = buildResult.hex; + } const { tx, type } = await decode(hex); const node = rpc(config[type]); + const isBitcoin = type === PaymentType.bitcoin; let { txid } = tx; try { @@ -708,6 +714,7 @@ export const sendOnchain = async (params) => { let total = 0; let fee = 0; let change = 0; + let p2aVout = -1; if (type === PaymentType.liquid) { for (const { @@ -735,15 +742,19 @@ export const sendOnchain = async (params) => { totalIn += sats(tx.vout[vout].value); } - for (const { - scriptPubKey: { address }, - value, - } of tx.vout) { + for (let i = 0; i < tx.vout.length; i++) { + const { scriptPubKey, value } = tx.vout[i]; + + if (isP2AOutput(scriptPubKey.hex)) { + p2aVout = i; + continue; + } + total += sats(value); - const invoice = await getInvoice(address); + const invoice = await getInvoice(scriptPubKey.address); if (invoice?.aid === aid) fail("Cannot send to internal address"); - if ((await node.getAddressInfo(address)).ismine) { + if ((await node.getAddressInfo(scriptPubKey.address)).ismine) { change += sats(value); } } @@ -752,22 +763,37 @@ export const sendOnchain = async (params) => { } const amount = total - change; + const bumpReserve = buildResult?.bumpReserve || 0; + const parentVsize = buildResult?.parentVsize || 0; const p = await debit({ aid, hash: txid, amount, - fee, + fee: fee + bumpReserve, rate, user, type, }); + p.fee = fee; p.hex = hex; + + if (isBitcoin && bumpReserve > 0) { + (p as any).bumpReserve = bumpReserve; + (p as any).p2aVout = p2aVout; + (p as any).parentVsize = parentVsize; + p.confirmed = false; + } + await s(`payment:${p.id}`, p); await node.sendRawTransaction(hex); + if (isBitcoin && bumpReserve > 0) { + await db.sAdd("outgoing:unconfirmed", p.id); + } + delete inflight[txid]; return p; } catch (e) { @@ -1052,6 +1078,8 @@ export const build = async ({ aid, amount, address, feeRate, subtract, user }) = } const node = rpc(config[type]); + const isBitcoin = type === PaymentType.bitcoin; + const p2aAddress = isBitcoin ? getP2AAddress() : null; amount = Number.parseInt(amount); if (amount < 0) fail("invalid amount"); @@ -1061,7 +1089,7 @@ export const build = async ({ aid, amount, address, feeRate, subtract, user }) = ? { fastestFee: 0.1, halfHourFee: 0.1, hourFee: 0.1 } : await fetch(`${api[type]}/fees/recommended`).then((r) => r.json()); - if (type === PaymentType.bitcoin) { + if (isBitcoin) { fees.hourFee = fees.halfHourFee; fees.halfHourFee = fees.fastestFee; fees.fastestFee = Math.ceil(fees.fastestFee * 1.5); @@ -1071,18 +1099,26 @@ export const build = async ({ aid, amount, address, feeRate, subtract, user }) = feeRate = fees.halfHourFee; } - if (feeRate < fees.hourFee) fail("fee rate too low"); + if (isBitcoin) { + if (feeRate < 0.1) fail("fee rate too low"); + } else { + if (feeRate < fees.hourFee) fail("fee rate too low"); + } const replaceable = false; - let outs = [{ [address]: btc(amount) }]; + let outs: any[] = [{ [address]: btc(amount) }]; + if (p2aAddress) outs.push({ [p2aAddress]: btc(P2A_VALUE) }); if (type === PaymentType.liquid) outs = outs.map((o) => ({ ...o, asset: config.liquid.btc })); let raw = await node.createRawTransaction([], outs, 0, replaceable); + if (isBitcoin) raw = "03000000" + raw.substring(8); let fee = 0; let tx; + let bumpReserve = 0; + let parentVsize = 0; try { tx = await node.fundRawTransaction(raw, { @@ -1092,6 +1128,12 @@ export const build = async ({ aid, amount, address, feeRate, subtract, user }) = }); fee = sats(tx.fee); + + if (isBitcoin) { + const decoded = await node.decodeRawTransaction(tx.hex); + parentVsize = decoded.vsize; + bumpReserve = calculateBumpReserve(feeRate, fees.fastestFee, parentVsize); + } } catch (e) { if (e.message.startsWith("Insufficient")) subtract = true; else throw e; @@ -1103,14 +1145,23 @@ export const build = async ({ aid, amount, address, feeRate, subtract, user }) = const covered = Math.min(creditBal, ourfee); ourfee -= covered; - if (subtract || amount + fee + ourfee > balance) { + if (subtract || amount + fee + ourfee + bumpReserve > balance) { subtract = true; - if (amount <= fee + ourfee + dust) { - fail(`insufficient funds ⚡️${balance} of ⚡️${amount + fee + ourfee + dust}`); + + if (isBitcoin && parentVsize === 0) { + parentVsize = 200; + bumpReserve = calculateBumpReserve(feeRate, fees.fastestFee, parentVsize); + } + + if (amount <= fee + ourfee + bumpReserve + dust) { + fail(`insufficient funds ⚡️${balance} of ⚡️${amount + fee + ourfee + bumpReserve + dust}`); } - outs = [{ [address]: btc(amount - ourfee) }]; + outs = [{ [address]: btc(amount - ourfee - bumpReserve) }]; + if (p2aAddress) outs.push({ [p2aAddress]: btc(P2A_VALUE) }); + raw = await node.createRawTransaction([], outs, 0, replaceable); + if (isBitcoin) raw = "03000000" + raw.substring(8); tx = await node.fundRawTransaction(raw, { fee_rate: feeRate, @@ -1119,6 +1170,11 @@ export const build = async ({ aid, amount, address, feeRate, subtract, user }) = }); fee = sats(tx.fee); + + if (isBitcoin) { + const decoded = await node.decodeRawTransaction(tx.hex); + parentVsize = decoded.vsize; + } } const inputs = []; @@ -1137,7 +1193,46 @@ export const build = async ({ aid, amount, address, feeRate, subtract, user }) = inputs.push({ witnessUtxo, path }); } - return { feeRate, ourfee, fee, fees, hex: tx.hex, inputs, subtract }; + return { feeRate, ourfee, fee, fees, hex: tx.hex, inputs, subtract, bumpReserve, parentVsize }; +}; + +export const checkOutgoingConfirmations = async () => { + try { + const paymentIds = await db.sMembers("outgoing:unconfirmed"); + for (const pid of paymentIds) { + try { + const p = await gf(`payment:${pid}`); + if (!p) { + await db.sRem("outgoing:unconfirmed", pid); + continue; + } + + if (p.confirmed) { + await db.sRem("outgoing:unconfirmed", pid); + continue; + } + + const txInfo = await bc.getRawTransaction(p.hash, true).catch(() => null); + if (!txInfo || !txInfo.confirmations || txInfo.confirmations < 1) continue; + + const refundAmount = (p.bumpReserve || 0) - (p.bumpedFee || 0); + if (refundAmount > 0) { + await tbRefund(p.uid, refundAmount); + } + + p.confirmed = true; + p.bumpReserve = 0; + await s(`payment:${p.id}`, p); + await db.sRem("outgoing:unconfirmed", pid); + emit(p.uid, "payment", p); + l("outgoing confirmed", p.id, "refunded", refundAmount); + } catch (e) { + err("problem checking outgoing confirmation", pid, e.message); + } + } + } catch (e) { + err("checkOutgoingConfirmations failed", e.message); + } }; export const catchUp = async () => { @@ -1155,6 +1250,9 @@ export const catchUp = async () => { } } + // Check custodial outgoing bitcoin tx confirmations + await checkOutgoingConfirmations(); + // Non-custodial accounts: check pending outgoing payments const inflightAccounts = await db.keys("inflight:*"); for (const key of inflightAccounts) { diff --git a/lib/zmq.ts b/lib/zmq.ts index 342d72ae..c584a41d 100644 --- a/lib/zmq.ts +++ b/lib/zmq.ts @@ -3,7 +3,7 @@ import config from "$config"; import { btcNetwork } from "$lib/esplora"; import { refreshArkWallet } from "$lib/ark"; import { err, l, warn } from "$lib/logging"; -import { processWatchedTx } from "$lib/payments"; +import { checkOutgoingConfirmations, processWatchedTx } from "$lib/payments"; import { Transaction } from "@scure/btc-signer"; import { concatBytes, sha256x2 } from "@scure/btc-signer/utils.js"; import { bytesToHex } from "@noble/hashes/utils.js"; @@ -115,6 +115,7 @@ const handleRawBlock = async (raw: Uint8Array) => { } } + checkOutgoingConfirmations().catch((e) => warn("block confirmation check failed", e.message)); refreshArkWallet().catch(() => {}); }; diff --git a/routes/payments.ts b/routes/payments.ts index 91115fc2..043d1c47 100644 --- a/routes/payments.ts +++ b/routes/payments.ts @@ -9,6 +9,7 @@ import { replay } from "$lib/lightning"; import ln from "$lib/ln"; import { err, l, warn } from "$lib/logging"; import mqtt from "$lib/mqtt"; +import { createCpfpChild, estimateBumpCost } from "$lib/cpfp"; import { acquireArkLock, build, @@ -16,7 +17,6 @@ import { createArkPayment, credit, debit, - decode, getUserRate, processWatchedTx, reverse, @@ -665,49 +665,53 @@ export default { return c.json({ ok: true }); }, - async replace(c) { + async bump(c) { const body = await c.req.json(); - const { id } = body; + const { id, targetFeeRate } = body; const user = c.get("user"); try { const p = await gf(`payment:${id}`); if (!p) fail("Payment not found"); if (p.uid !== user.id) fail("unauthorized"); + if (p.confirmed) fail("transaction already confirmed"); + if (p.type !== PaymentType.bitcoin) fail("only bitcoin transactions can be bumped"); + if (!p.bumpReserve || p.bumpReserve <= 0) fail("no bump reserve available"); + if (p.childTxid) fail("transaction already bumped"); - const { tx, type } = await decode(p.hex); - const node = rpc(config[type]); - - const fees: any = await fetch(`${api[type]}/fees/recommended`).then((r) => r.json()); - - const outputs = []; - for (const { - scriptPubKey: { address }, - value, - } of tx.vout) { - if (address && !(await node.getAddressInfo(address)).ismine) - outputs.push({ [address]: value }); - } - - const raw = await node.createRawTransaction(tx.vin, outputs); + const { txid, childFee } = await createCpfpChild(p, targetFeeRate); - const newTx = await node.fundRawTransaction(raw, { - fee_rate: fees.fastestFee + 50, - replaceable: true, - subtractFeeFromOutputs: [], + return c.json({ + txid, + childFee, + remaining: p.bumpReserve - childFee, }); + } catch (e) { + err("failed to bump payment", id, e.message); + return bail(c, e.message); + } + }, - const diff = sats(newTx.fee) - p.fee; - if (diff < 0) fail("fee must increase"); + async bumpEstimate(c) { + const body = await c.req.json(); + const { id, targetFeeRate } = body; + const user = c.get("user"); + try { + const p = await gf(`payment:${id}`); + if (!p) fail("Payment not found"); + if (p.uid !== user.id) fail("unauthorized"); - if (config[type].walletpass) await node.walletPassphrase(config[type].walletpass, 300); - p.hex = (await node.signRawTransactionWithWallet(newTx.hex)).hex; - const r = await node.testMempoolAccept([p.hex]); - if (!r[0].allowed) fail(`transaction rejected ${p.hex}`); - warn("bump", user.username, p.hex); + const cost = estimateBumpCost(p.fee, p.parentVsize, targetFeeRate); - return c.json({ ok: true }); + const fees: any = await fetch(`${api[PaymentType.bitcoin]}/fees/recommended`).then((r) => + r.json(), + ); + + return c.json({ + cost, + fees, + bumpReserve: p.bumpReserve || 0, + }); } catch (e) { - err("failed to bump payment", id, e.message); return bail(c, e.message); } }, -- cgit v1.2.3