aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-03-11 22:20:46 -0700
committerAdam Soltys <[email protected]>2026-03-11 22:20:46 -0700
commite6938fb6c4cf6e6f3b3c7aa5c7b1d8f28c745031 (patch)
tree64c1d7e5e46b061d87e85b79f617005d3cd88ec9
parent4007842de3cacd93db5df6222ddaa47ec6e557ce (diff)
downloadcoinos-server-e6938fb6c4cf6e6f3b3c7aa5c7b1d8f28c745031.tar.gz
coinos-server-e6938fb6c4cf6e6f3b3c7aa5c7b1d8f28c745031.zip
cpfp improvements
-rw-r--r--lib/cpfp.ts161
-rw-r--r--lib/payments.ts71
2 files changed, 230 insertions, 2 deletions
diff --git a/lib/cpfp.ts b/lib/cpfp.ts
index 6f0eb948..14d8b098 100644
--- a/lib/cpfp.ts
+++ b/lib/cpfp.ts
@@ -1,7 +1,7 @@
import config from "$config";
-import { s } from "$lib/db";
+import { db, gf, s } from "$lib/db";
import { l } from "$lib/logging";
-import { CHILD_VSIZE } from "$lib/p2a";
+import { CHILD_VSIZE, P2A_VALUE, getP2AAddress, isP2AOutput, calculateBumpReserve } from "$lib/p2a";
import { fail, sats } from "$lib/utils";
import rpc from "@coinos/rpc";
@@ -105,3 +105,160 @@ export async function createCpfpChild(payment: any, targetFeeRate: number) {
return { txid: childTxid, childFee };
}
+
+async function findStuckParent() {
+ const paymentIds = await db.sMembers("outgoing:unconfirmed");
+
+ let best: any = null;
+ let bestChangeValue = 0;
+
+ for (const pid of paymentIds) {
+ const p = await gf(`payment:${pid}`);
+ if (!p || p.confirmed || p.p2aVout < 0 || !p.bumpReserve || p.bumpReserve <= 0) continue;
+
+ // Decode the parent to find its change output
+ let parentHex: string;
+ try {
+ parentHex = await bc.getRawTransaction(p.hash);
+ } catch {
+ continue;
+ }
+
+ const decoded = await bc.decodeRawTransaction(parentHex);
+ let changeVout = -1;
+ let changeValue = 0;
+
+ for (let i = 0; i < decoded.vout.length; i++) {
+ const out = decoded.vout[i];
+ if (isP2AOutput(out.scriptPubKey.hex)) continue;
+ try {
+ const info = await bc.getAddressInfo(out.scriptPubKey.address);
+ if (info.ismine) {
+ changeVout = i;
+ changeValue = sats(out.value);
+ break;
+ }
+ } catch {
+ continue;
+ }
+ }
+
+ if (changeVout < 0 || changeValue === 0) continue;
+
+ // Pick the parent with the largest change (most sats available)
+ if (changeValue > bestChangeValue) {
+ best = { payment: p, parentHex, changeVout, changeValue };
+ bestChangeValue = changeValue;
+ }
+ }
+
+ return best;
+}
+
+export async function buildCpfpSend({
+ address,
+ amount,
+ feeRate,
+ fees,
+}: {
+ address: string;
+ amount: number;
+ feeRate: number;
+ fees: any;
+}) {
+ const parent = await findStuckParent();
+ if (!parent) fail("no confirmed UTXOs and no bumpable parent found");
+
+ const { payment: parentPayment, parentHex, changeVout, changeValue } = parent;
+ const { hash: parentTxid, p2aVout, parentVsize, fee: parentFee, bumpReserve } = parentPayment;
+
+ const p2aAddress = getP2AAddress();
+
+ // Estimate child vsize: 2-in (P2A + change), 3-out (payment + change + P2A)
+ const COMBINED_CHILD_VSIZE = 260;
+
+ // The package fee rate must meet feeRate for the whole package
+ const packageVsize = parentVsize + COMBINED_CHILD_VSIZE;
+ const requiredPackageFee = Math.ceil(feeRate * packageVsize);
+ const totalChildFee = Math.max(0, requiredPackageFee - parentFee);
+
+ // User B pays for the child's own weight at their fee rate
+ const userBFee = Math.ceil(feeRate * COMBINED_CHILD_VSIZE);
+
+ // The CPFP subsidy is the extra cost to drag the parent up to feeRate
+ const cpfpSubsidy = Math.max(0, totalChildFee - userBFee);
+
+ if (cpfpSubsidy > bumpReserve) fail("parent bump reserve insufficient for CPFP subsidy");
+
+ // If the parent already has a child (sibling eviction), the new child must pay more
+ let evictedChildFee = 0;
+ if (parentPayment.childTxid) {
+ evictedChildFee = parentPayment.bumpedFee || 0;
+ if (totalChildFee <= evictedChildFee)
+ fail("cannot evict existing child — new fee too low");
+ }
+
+ // Calculate change for the child tx
+ // Inputs: changeValue + P2A_VALUE
+ // Outputs: amount + childChange + P2A_VALUE
+ const childChange = changeValue + P2A_VALUE - amount - totalChildFee - P2A_VALUE;
+ if (childChange < 0) fail("insufficient funds in parent change for this send");
+
+ // Build inputs
+ const inputs = [
+ { txid: parentTxid, vout: p2aVout },
+ { txid: parentTxid, vout: changeVout },
+ ];
+
+ // Build outputs
+ const btc = (v: number) => parseFloat((v / 1e8).toFixed(8));
+ const outputs: Record<string, number>[] = [{ [address]: btc(amount) }];
+ if (childChange >= 547) {
+ const changeAddr = await bc.getRawChangeAddress();
+ outputs.push({ [changeAddr]: btc(childChange) });
+ }
+ outputs.push({ [p2aAddress]: btc(P2A_VALUE) });
+
+ // Create v3 raw transaction
+ let raw = await bc.createRawTransaction(inputs, outputs);
+ raw = "03000000" + raw.substring(8);
+
+ // Sign (only the change input needs signing; P2A is keyless)
+ if ((config.bitcoin as any).walletpass) await bc.walletPassphrase((config.bitcoin as any).walletpass, 300);
+ const { hex } = await bc.signRawTransactionWithWallet(raw);
+
+ const decoded = await bc.decodeRawTransaction(hex);
+ if (decoded.vsize > 1000) fail("combined child exceeds TRUC 1kvB limit");
+
+ // Find the P2A vout in the new child
+ let newP2aVout = -1;
+ for (let i = 0; i < decoded.vout.length; i++) {
+ if (isP2AOutput(decoded.vout[i].scriptPubKey.hex)) {
+ newP2aVout = i;
+ break;
+ }
+ }
+
+ // Update the parent payment — charge the CPFP subsidy to the low-baller's reserve
+ parentPayment.childTxid = decoded.txid;
+ parentPayment.bumpedFee = cpfpSubsidy;
+ await s(`payment:${parentPayment.id}`, parentPayment);
+
+ l("CPFP+send via parent", parentTxid, "child:", decoded.txid, "subsidy:", cpfpSubsidy);
+
+ // Calculate User B's bump reserve for this child
+ const fastestFee = Math.ceil(fees.fastestFee * 1.5);
+ const childBumpReserve = calculateBumpReserve(feeRate, fastestFee, decoded.vsize);
+
+ return {
+ hex,
+ fee: userBFee,
+ parentHex,
+ txid: decoded.txid,
+ p2aVout: newP2aVout,
+ parentVsize: decoded.vsize,
+ bumpReserve: childBumpReserve,
+ cpfpParentId: parentPayment.id,
+ cpfpSubsidy,
+ };
+}
diff --git a/lib/payments.ts b/lib/payments.ts
index 0dacf035..cc5551ce 100644
--- a/lib/payments.ts
+++ b/lib/payments.ts
@@ -56,6 +56,7 @@ import { v4 } from "uuid";
import { PaymentType } from "$lib/types";
import { P2A_VALUE, getP2AAddress, isP2AOutput, calculateBumpReserve } from "$lib/p2a";
+import { buildCpfpSend } from "$lib/cpfp";
const bc = rpc(config.bitcoin);
const lq = rpc(config.liquid);
@@ -723,6 +724,61 @@ export const sendOnchain = async (params) => {
hex = buildResult.hex;
}
+ // CPFP+send path: already signed, needs package submission
+ if (buildResult?.signed) {
+ const { txid, parentHex, fee, bumpReserve, p2aVout, parentVsize, ourfee, cpfpParentId, cpfpSubsidy } = buildResult;
+
+ try {
+ if (inflight[txid]) fail("payment in flight");
+ inflight[txid] = true;
+
+ // Submit as package (parent + child) since parent may not be in mempool
+ const r = await bc.testMempoolAccept([hex]);
+ if (r[0].allowed) {
+ await bc.sendRawTransaction(hex);
+ } else {
+ const pkg = await bc.submitPackage([parentHex, hex]);
+ if (!pkg["tx-results"]) fail("package submission failed");
+ }
+
+ // Decode to get the send amount
+ const decoded = await bc.decodeRawTransaction(hex);
+ let amount = 0;
+ for (const out of decoded.vout) {
+ if (isP2AOutput(out.scriptPubKey.hex)) continue;
+ const info = await bc.getAddressInfo(out.scriptPubKey.address);
+ if (!info.ismine) amount += sats(out.value);
+ }
+
+ const p = await debit({
+ aid,
+ hash: txid,
+ amount,
+ fee: fee + bumpReserve,
+ rate,
+ user,
+ type: PaymentType.bitcoin,
+ });
+
+ p.fee = fee;
+ p.hex = hex;
+ (p as any).bumpReserve = bumpReserve;
+ (p as any).p2aVout = p2aVout;
+ (p as any).parentVsize = parentVsize;
+ (p as any).cpfpParentId = cpfpParentId;
+ p.confirmed = false;
+
+ await s(`payment:${p.id}`, p);
+ await db.sAdd("outgoing:unconfirmed", p.id);
+
+ delete inflight[txid];
+ return p;
+ } catch (e) {
+ delete inflight[txid];
+ throw e;
+ }
+ }
+
const { tx, type } = await decode(hex);
const node = rpc(config[type]);
const isBitcoin = type === PaymentType.bitcoin;
@@ -1163,6 +1219,21 @@ export const build = async ({ aid, amount, address, feeRate, subtract, user }) =
let bumpReserve = 0;
let parentVsize = 0;
+ // Check if confirmed UTXOs can cover this send — if not, fall back to CPFP+send
+ if (isBitcoin) {
+ const confirmedUtxos = await node.listUnspent(1);
+ const confirmedTotal = confirmedUtxos.reduce((sum, u) => sum + sats(u.amount), 0);
+ if (confirmedTotal < amount + P2A_VALUE) {
+ const balance = await getBalance(aid);
+ let ourfee = Math.round(amount * config.fee[type]);
+ const creditBal = await getCredit(aid, type);
+ const covered = Math.min(creditBal, ourfee);
+ ourfee -= covered;
+ const cpfp = await buildCpfpSend({ address, amount: amount - ourfee, feeRate, fees });
+ return { ...cpfp, feeRate, ourfee, fees, inputs: [], subtract, signed: true };
+ }
+ }
+
try {
tx = await node.fundRawTransaction(raw, {
fee_rate: feeRate,