diff options
| author | Adam Soltys <[email protected]> | 2026-03-11 22:20:46 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-03-11 22:20:46 -0700 |
| commit | e6938fb6c4cf6e6f3b3c7aa5c7b1d8f28c745031 (patch) | |
| tree | 64c1d7e5e46b061d87e85b79f617005d3cd88ec9 | |
| parent | 4007842de3cacd93db5df6222ddaa47ec6e557ce (diff) | |
| download | coinos-server-e6938fb6c4cf6e6f3b3c7aa5c7b1d8f28c745031.tar.gz coinos-server-e6938fb6c4cf6e6f3b3c7aa5c7b1d8f28c745031.zip | |
cpfp improvements
| -rw-r--r-- | lib/cpfp.ts | 161 | ||||
| -rw-r--r-- | lib/payments.ts | 71 |
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, |
