diff options
| author | Adam Soltys <[email protected]> | 2026-03-11 23:01:00 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-03-11 23:01:00 -0700 |
| commit | 79252576d1bd24bc74fe1b7fc9cc3d4d68a1c8a0 (patch) | |
| tree | b25510681bb1b935be477c59585fbc8c45deeb86 /lib | |
| parent | 05119b32f331ee97130a5300feb5c02f791aac80 (diff) | |
| download | coinos-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.ts | 120 | ||||
| -rw-r--r-- | lib/payments.ts | 11 |
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 { |
