aboutsummaryrefslogtreecommitdiffstats
path: root/lib/cpfp.ts
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 /lib/cpfp.ts
parent4007842de3cacd93db5df6222ddaa47ec6e557ce (diff)
downloadcoinos-server-e6938fb6c4cf6e6f3b3c7aa5c7b1d8f28c745031.tar.gz
coinos-server-e6938fb6c4cf6e6f3b3c7aa5c7b1d8f28c745031.zip
cpfp improvements
Diffstat (limited to 'lib/cpfp.ts')
-rw-r--r--lib/cpfp.ts161
1 files changed, 159 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,
+ };
+}