aboutsummaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-03-11 23:01:00 -0700
committerAdam Soltys <[email protected]>2026-03-11 23:01:00 -0700
commit79252576d1bd24bc74fe1b7fc9cc3d4d68a1c8a0 (patch)
treeb25510681bb1b935be477c59585fbc8c45deeb86 /lib
parent05119b32f331ee97130a5300feb5c02f791aac80 (diff)
downloadcoinos-server-79252576d1bd24bc74fe1b7fc9cc3d4d68a1c8a0.tar.gz
coinos-server-79252576d1bd24bc74fe1b7fc9cc3d4d68a1c8a0.zip
try to cpfp piggyback on in flight withdrawals
Diffstat (limited to 'lib')
-rw-r--r--lib/cpfp.ts120
-rw-r--r--lib/payments.ts11
2 files changed, 87 insertions, 44 deletions
diff --git a/lib/cpfp.ts b/lib/cpfp.ts
index 14d8b098..20171acc 100644
--- a/lib/cpfp.ts
+++ b/lib/cpfp.ts
@@ -22,6 +22,27 @@ export async function createCpfpChild(payment: any, targetFeeRate: number) {
if (p2aVout < 0) fail("no P2A output on this transaction");
+ // Re-include payment outputs from existing child being evicted
+ const preservedOutputs: { address: string; amount: number }[] = [];
+ if (payment.childTxid) {
+ try {
+ const oldChildHex = await bc.getRawTransaction(payment.childTxid);
+ const oldChild = await bc.decodeRawTransaction(oldChildHex);
+ for (const out of oldChild.vout) {
+ if (isP2AOutput(out.scriptPubKey.hex)) continue;
+ const info = await bc.getAddressInfo(out.scriptPubKey.address);
+ if (!info.ismine) {
+ preservedOutputs.push({
+ address: out.scriptPubKey.address,
+ amount: sats(out.value),
+ });
+ }
+ }
+ } catch {}
+ }
+
+ const preservedTotal = preservedOutputs.reduce((sum, o) => sum + o.amount, 0);
+
const childFee = estimateBumpCost(parentFee, parentVsize, targetFeeRate);
if (childFee <= 0) fail("transaction already meets target fee rate");
if (childFee > bumpReserve) fail("bump cost exceeds reserve");
@@ -32,32 +53,31 @@ export async function createCpfpChild(payment: any, targetFeeRate: number) {
const walletUtxo = utxos[0];
const walletUtxoValue = sats(walletUtxo.amount);
- const p2aValue = 240;
- const changeValue = walletUtxoValue + p2aValue - childFee;
+ const changeValue = walletUtxoValue + P2A_VALUE - childFee - preservedTotal;
if (changeValue < 0) fail("wallet UTXO too small for bump");
- // Get a change address from the wallet
const changeAddress = await bc.getRawChangeAddress();
+ const btc = (v: number) => parseFloat((v / 1e8).toFixed(8));
- // 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)) }];
+ const outputs: Record<string, number>[] = preservedOutputs.map((o) => ({
+ [o.address]: btc(o.amount),
+ }));
+ if (changeValue >= 547) {
+ outputs.push({ [changeAddress]: btc(changeValue) });
+ }
- // 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");
@@ -68,26 +88,22 @@ export async function createCpfpChild(payment: any, targetFeeRate: number) {
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;
+ childTxid = results[results.length - 1]?.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;
+ childTxid = results[results.length - 1]?.txid || decoded.txid;
} else {
fail("package submission failed");
}
@@ -96,12 +112,12 @@ export async function createCpfpChild(payment: any, targetFeeRate: number) {
}
}
- // Update payment
payment.childTxid = childTxid;
payment.bumpedFee = childFee;
await s(`payment:${payment.id}`, payment);
- l("CPFP bump", parentTxid, "->", childTxid, "fee:", childFee);
+ l("CPFP bump", parentTxid, "->", childTxid, "fee:", childFee,
+ "preserved outputs:", preservedOutputs.length);
return { txid: childTxid, childFee };
}
@@ -167,41 +183,69 @@ export async function buildCpfpSend({
fees: any;
}) {
const parent = await findStuckParent();
- if (!parent) fail("no confirmed UTXOs and no bumpable parent found");
+ if (!parent) fail("no bumpable parent found");
const { payment: parentPayment, parentHex, changeVout, changeValue } = parent;
const { hash: parentTxid, p2aVout, parentVsize, fee: parentFee, bumpReserve } = parentPayment;
const p2aAddress = getP2AAddress();
+ const btc = (v: number) => parseFloat((v / 1e8).toFixed(8));
+
+ // Collect payment outputs: new payment + any from an existing child being evicted
+ const paymentOutputs: { address: string; amount: number }[] = [{ address, amount }];
+ let evictedChildFee = 0;
+
+ if (parentPayment.childTxid) {
+ try {
+ const oldChildHex = await bc.getRawTransaction(parentPayment.childTxid);
+ const oldChild = await bc.decodeRawTransaction(oldChildHex);
+ evictedChildFee = parentPayment.bumpedFee || 0;
+
+ // Re-include non-change, non-P2A outputs from the evicted child
+ for (const out of oldChild.vout) {
+ if (isP2AOutput(out.scriptPubKey.hex)) continue;
+ const info = await bc.getAddressInfo(out.scriptPubKey.address);
+ if (!info.ismine) {
+ paymentOutputs.push({
+ address: out.scriptPubKey.address,
+ amount: sats(out.value),
+ });
+ }
+ }
+ } catch {
+ // Evicted child not found — maybe already confirmed, skip
+ }
+ }
+
+ const totalPaymentAmount = paymentOutputs.reduce((sum, p) => sum + p.amount, 0);
- // Estimate child vsize: 2-in (P2A + change), 3-out (payment + change + P2A)
- const COMBINED_CHILD_VSIZE = 260;
+ // Estimate child vsize based on output count
+ // Base: ~180 vB (2 inputs: P2A + change, overhead)
+ // Each output: ~31 vB (p2wpkh) + P2A output ~4 vB + change output ~31 vB
+ const numOutputs = paymentOutputs.length + 2; // payments + change + P2A
+ const estimatedChildVsize = 180 + numOutputs * 31;
+
+ if (estimatedChildVsize > 1000) fail("too many outputs for TRUC child");
// The package fee rate must meet feeRate for the whole package
- const packageVsize = parentVsize + COMBINED_CHILD_VSIZE;
+ const packageVsize = parentVsize + estimatedChildVsize;
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);
+ const userBFee = Math.ceil(feeRate * estimatedChildVsize);
// 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");
- }
+ // Sibling eviction requires the new child to pay a higher fee
+ if (evictedChildFee > 0 && 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;
+ const childChange = changeValue + P2A_VALUE - totalPaymentAmount - totalChildFee - P2A_VALUE;
if (childChange < 0) fail("insufficient funds in parent change for this send");
// Build inputs
@@ -210,9 +254,10 @@ export async function buildCpfpSend({
{ txid: parentTxid, vout: changeVout },
];
- // Build outputs
- const btc = (v: number) => parseFloat((v / 1e8).toFixed(8));
- const outputs: Record<string, number>[] = [{ [address]: btc(amount) }];
+ // Build outputs: all payments + change + P2A
+ const outputs: Record<string, number>[] = paymentOutputs.map((p) => ({
+ [p.address]: btc(p.amount),
+ }));
if (childChange >= 547) {
const changeAddr = await bc.getRawChangeAddress();
outputs.push({ [changeAddr]: btc(childChange) });
@@ -239,12 +284,13 @@ export async function buildCpfpSend({
}
}
- // Update the parent payment — charge the CPFP subsidy to the low-baller's reserve
+ // Update the parent payment — charge the CPFP subsidy
parentPayment.childTxid = decoded.txid;
parentPayment.bumpedFee = cpfpSubsidy;
await s(`payment:${parentPayment.id}`, parentPayment);
- l("CPFP+send via parent", parentTxid, "child:", decoded.txid, "subsidy:", cpfpSubsidy);
+ l("CPFP+send via parent", parentTxid, "child:", decoded.txid, "subsidy:", cpfpSubsidy,
+ "payments:", paymentOutputs.length);
// Calculate User B's bump reserve for this child
const fastestFee = Math.ceil(fees.fastestFee * 1.5);
diff --git a/lib/payments.ts b/lib/payments.ts
index cc5551ce..d936314b 100644
--- a/lib/payments.ts
+++ b/lib/payments.ts
@@ -849,7 +849,7 @@ export const sendOnchain = async (params) => {
}
}
- fee = totalIn - total;
+ fee = totalIn - total - (p2aVout >= 0 ? P2A_VALUE : 0);
}
const amount = total - change;
@@ -1219,19 +1219,16 @@ 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
+ // Piggyback on an unconfirmed parent via CPFP if one exists
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);
+ try {
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 };
- }
+ } catch {}
}
try {