1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
|
import config from "$config";
import { db, gf, s } from "$lib/db";
import { l } from "$lib/logging";
import { CHILD_VSIZE, P2A_VALUE, getP2AAddress, isP2AOutput, calculateBumpReserve } from "$lib/p2a";
import { fail, sats } from "$lib/utils";
import rpc from "@coinos/rpc";
const bc = rpc(config.bitcoin);
export function estimateBumpCost(
parentFee: number,
parentVsize: number,
targetFeeRate: number,
): number {
const packageVsize = parentVsize + CHILD_VSIZE;
const requiredPackageFee = Math.ceil(targetFeeRate * packageVsize);
return Math.max(0, requiredPackageFee - parentFee);
}
export async function createCpfpChild(payment: any, targetFeeRate: number) {
const { hash: parentTxid, p2aVout, parentVsize, fee: parentFee, bumpReserve } = payment;
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");
// Get a confirmed wallet UTXO to fund the child
const utxos = await bc.listUnspent(1);
if (!utxos.length) fail("no confirmed wallet UTXOs available for bump");
const walletUtxo = utxos[0];
const walletUtxoValue = sats(walletUtxo.amount);
const changeValue = walletUtxoValue + P2A_VALUE - childFee - preservedTotal;
if (changeValue < 0) fail("wallet UTXO too small for bump");
const changeAddress = await bc.getRawChangeAddress();
const btc = (v: number) => parseFloat((v / 1e8).toFixed(8));
const inputs = [
{ txid: parentTxid, vout: p2aVout },
{ txid: walletUtxo.txid, vout: walletUtxo.vout },
];
const outputs: Record<string, number>[] = preservedOutputs.map((o) => ({
[o.address]: btc(o.amount),
}));
if (changeValue >= 547) {
outputs.push({ [changeAddress]: btc(changeValue) });
}
let raw = await bc.createRawTransaction(inputs, outputs);
raw = "03000000" + raw.substring(8);
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("child transaction exceeds TRUC 1kvB limit");
// Try mempool accept, fall back to package submission
let childTxid: string;
try {
const r = await bc.testMempoolAccept([hex]);
if (r[0].allowed) {
childTxid = await bc.sendRawTransaction(hex);
} else {
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[];
childTxid = results[results.length - 1]?.txid || decoded.txid;
} else {
fail("package submission failed");
}
}
} catch (e) {
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[];
childTxid = results[results.length - 1]?.txid || decoded.txid;
} else {
fail("package submission failed");
}
} catch (e2) {
fail(`bump failed: ${e2.message}`);
}
}
payment.childTxid = childTxid;
payment.bumpedFee = childFee;
await s(`payment:${payment.id}`, payment);
l("CPFP bump", parentTxid, "->", childTxid, "fee:", childFee,
"preserved outputs:", preservedOutputs.length);
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 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 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 + 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 * 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");
// 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
const childChange = changeValue + P2A_VALUE - totalPaymentAmount - 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: 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) });
}
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
parentPayment.childTxid = decoded.txid;
parentPayment.bumpedFee = cpfpSubsidy;
await s(`payment:${parentPayment.id}`, parentPayment);
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.max(Math.ceil(fees.fastestFee * 1.5), 4);
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,
};
}
|