aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-03-02 22:50:58 -0800
committerAdam Soltys <[email protected]>2026-03-02 22:51:02 -0800
commitc7300714c57652e68fcadcdbcfcce3df8dd7d37b (patch)
tree84bee888a8f3173257c6d2aa9670d41498f50c63
parent58e7f32f48c2fb094196f44f4c59a8975e5f93a9 (diff)
downloadcoinos-server-c7300714c57652e68fcadcdbcfcce3df8dd7d37b.tar.gz
coinos-server-c7300714c57652e68fcadcdbcfcce3df8dd7d37b.zip
fee bumping
-rw-r--r--index.ts3
-rw-r--r--lib/cpfp.ts107
-rw-r--r--lib/p2a.ts54
-rw-r--r--lib/payments.ts130
-rw-r--r--lib/zmq.ts3
-rw-r--r--routes/payments.ts66
6 files changed, 314 insertions, 49 deletions
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<string, string> = {
+ mainnet: "bc1pfeessrawgf",
+ testnet: "tb1pfeessrawgf",
+ signet: "tb1pfeessrawgf",
+ regtest: "bcrt1pfeessrawgf",
+};
+
+const BECH32M_PREFIXES: Record<string, string> = {
+ 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);
}
},