aboutsummaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/cpfp.ts107
-rw-r--r--lib/p2a.ts54
-rw-r--r--lib/payments.ts130
-rw-r--r--lib/zmq.ts3
4 files changed, 277 insertions, 17 deletions
diff --git a/lib/cpfp.ts b/lib/cpfp.ts
new file mode 100644
index 00000000..6f0eb948
--- /dev/null
+++ b/lib/cpfp.ts
@@ -0,0 +1,107 @@
+import config from "$config";
+import { s } from "$lib/db";
+import { l } from "$lib/logging";
+import { CHILD_VSIZE } 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");
+
+ 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 p2aValue = 240;
+
+ const changeValue = walletUtxoValue + p2aValue - childFee;
+ if (changeValue < 0) fail("wallet UTXO too small for bump");
+
+ // Get a change address from the wallet
+ const changeAddress = await bc.getRawChangeAddress();
+
+ // 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)) }];
+
+ // 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");
+
+ // 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 {
+ // 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;
+ } 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;
+ } else {
+ fail("package submission failed");
+ }
+ } catch (e2) {
+ fail(`bump failed: ${e2.message}`);
+ }
+ }
+
+ // Update payment
+ payment.childTxid = childTxid;
+ payment.bumpedFee = childFee;
+ await s(`payment:${payment.id}`, payment);
+
+ l("CPFP bump", parentTxid, "->", childTxid, "fee:", childFee);
+
+ return { txid: childTxid, childFee };
+}
diff --git a/lib/p2a.ts b/lib/p2a.ts
new file mode 100644
index 00000000..6e256f20
--- /dev/null
+++ b/lib/p2a.ts
@@ -0,0 +1,54 @@
+import config from "$config";
+import { bech32m } from "@scure/base";
+
+export const P2A_VALUE = 240;
+export const P2A_SCRIPT = "51024e73"; // OP_1 OP_PUSHBYTES_2 0x4e73
+export const CHILD_VSIZE = 152;
+
+const P2A_WITNESS_PROGRAM = new Uint8Array([0x4e, 0x73]);
+const P2A_WITNESS_VERSION = 1;
+
+const P2A_ADDRESSES: Record<string, string> = {
+ mainnet: "bc1pfeessrawgf",
+ testnet: "tb1pfeessrawgf",
+ signet: "tb1pfeessrawgf",
+ regtest: "bcrt1pfeessrawgf",
+};
+
+const BECH32M_PREFIXES: Record<string, string> = {
+ mainnet: "bc",
+ testnet: "tb",
+ signet: "tb",
+ regtest: "bcrt",
+};
+
+function verifyP2AAddresses() {
+ for (const [network, expected] of Object.entries(P2A_ADDRESSES)) {
+ const prefix = BECH32M_PREFIXES[network];
+ const words = [P2A_WITNESS_VERSION, ...bech32m.toWords(P2A_WITNESS_PROGRAM)];
+ const encoded = bech32m.encode(prefix, words);
+ if (encoded !== expected) {
+ throw new Error(`P2A address mismatch for ${network}: got ${encoded}, expected ${expected}`);
+ }
+ }
+}
+
+verifyP2AAddresses();
+
+export function getP2AAddress(): string {
+ const network = config.bitcoin.network || "mainnet";
+ return P2A_ADDRESSES[network];
+}
+
+export function isP2AOutput(scriptPubKeyHex: string): boolean {
+ return scriptPubKeyHex === P2A_SCRIPT;
+}
+
+export function calculateBumpReserve(
+ userRate: number,
+ fastestRate: number,
+ parentVsize: number,
+): number {
+ if (userRate >= fastestRate) return 0;
+ return Math.ceil((fastestRate - userRate) * parentVsize + fastestRate * CHILD_VSIZE);
+}
diff --git a/lib/payments.ts b/lib/payments.ts
index dbebfa39..363fb8ff 100644
--- a/lib/payments.ts
+++ b/lib/payments.ts
@@ -55,6 +55,7 @@ import got from "got";
import { v4 } from "uuid";
import { PaymentType } from "$lib/types";
+import { P2A_VALUE, getP2AAddress, isP2AOutput, calculateBumpReserve } from "$lib/p2a";
const bc = rpc(config.bitcoin);
const lq = rpc(config.liquid);
@@ -682,10 +683,15 @@ export const sendOnchain = async (params) => {
return sendNonCustodial(params);
}
- if (!hex) ({ hex } = await build(params));
+ let buildResult;
+ if (!hex) {
+ buildResult = await build(params);
+ hex = buildResult.hex;
+ }
const { tx, type } = await decode(hex);
const node = rpc(config[type]);
+ const isBitcoin = type === PaymentType.bitcoin;
let { txid } = tx;
try {
@@ -708,6 +714,7 @@ export const sendOnchain = async (params) => {
let total = 0;
let fee = 0;
let change = 0;
+ let p2aVout = -1;
if (type === PaymentType.liquid) {
for (const {
@@ -735,15 +742,19 @@ export const sendOnchain = async (params) => {
totalIn += sats(tx.vout[vout].value);
}
- for (const {
- scriptPubKey: { address },
- value,
- } of tx.vout) {
+ for (let i = 0; i < tx.vout.length; i++) {
+ const { scriptPubKey, value } = tx.vout[i];
+
+ if (isP2AOutput(scriptPubKey.hex)) {
+ p2aVout = i;
+ continue;
+ }
+
total += sats(value);
- const invoice = await getInvoice(address);
+ const invoice = await getInvoice(scriptPubKey.address);
if (invoice?.aid === aid) fail("Cannot send to internal address");
- if ((await node.getAddressInfo(address)).ismine) {
+ if ((await node.getAddressInfo(scriptPubKey.address)).ismine) {
change += sats(value);
}
}
@@ -752,22 +763,37 @@ export const sendOnchain = async (params) => {
}
const amount = total - change;
+ const bumpReserve = buildResult?.bumpReserve || 0;
+ const parentVsize = buildResult?.parentVsize || 0;
const p = await debit({
aid,
hash: txid,
amount,
- fee,
+ fee: fee + bumpReserve,
rate,
user,
type,
});
+ p.fee = fee;
p.hex = hex;
+
+ if (isBitcoin && bumpReserve > 0) {
+ (p as any).bumpReserve = bumpReserve;
+ (p as any).p2aVout = p2aVout;
+ (p as any).parentVsize = parentVsize;
+ p.confirmed = false;
+ }
+
await s(`payment:${p.id}`, p);
await node.sendRawTransaction(hex);
+ if (isBitcoin && bumpReserve > 0) {
+ await db.sAdd("outgoing:unconfirmed", p.id);
+ }
+
delete inflight[txid];
return p;
} catch (e) {
@@ -1052,6 +1078,8 @@ export const build = async ({ aid, amount, address, feeRate, subtract, user }) =
}
const node = rpc(config[type]);
+ const isBitcoin = type === PaymentType.bitcoin;
+ const p2aAddress = isBitcoin ? getP2AAddress() : null;
amount = Number.parseInt(amount);
if (amount < 0) fail("invalid amount");
@@ -1061,7 +1089,7 @@ export const build = async ({ aid, amount, address, feeRate, subtract, user }) =
? { fastestFee: 0.1, halfHourFee: 0.1, hourFee: 0.1 }
: await fetch(`${api[type]}/fees/recommended`).then((r) => r.json());
- if (type === PaymentType.bitcoin) {
+ if (isBitcoin) {
fees.hourFee = fees.halfHourFee;
fees.halfHourFee = fees.fastestFee;
fees.fastestFee = Math.ceil(fees.fastestFee * 1.5);
@@ -1071,18 +1099,26 @@ export const build = async ({ aid, amount, address, feeRate, subtract, user }) =
feeRate = fees.halfHourFee;
}
- if (feeRate < fees.hourFee) fail("fee rate too low");
+ if (isBitcoin) {
+ if (feeRate < 0.1) fail("fee rate too low");
+ } else {
+ if (feeRate < fees.hourFee) fail("fee rate too low");
+ }
const replaceable = false;
- let outs = [{ [address]: btc(amount) }];
+ let outs: any[] = [{ [address]: btc(amount) }];
+ if (p2aAddress) outs.push({ [p2aAddress]: btc(P2A_VALUE) });
if (type === PaymentType.liquid) outs = outs.map((o) => ({ ...o, asset: config.liquid.btc }));
let raw = await node.createRawTransaction([], outs, 0, replaceable);
+ if (isBitcoin) raw = "03000000" + raw.substring(8);
let fee = 0;
let tx;
+ let bumpReserve = 0;
+ let parentVsize = 0;
try {
tx = await node.fundRawTransaction(raw, {
@@ -1092,6 +1128,12 @@ export const build = async ({ aid, amount, address, feeRate, subtract, user }) =
});
fee = sats(tx.fee);
+
+ if (isBitcoin) {
+ const decoded = await node.decodeRawTransaction(tx.hex);
+ parentVsize = decoded.vsize;
+ bumpReserve = calculateBumpReserve(feeRate, fees.fastestFee, parentVsize);
+ }
} catch (e) {
if (e.message.startsWith("Insufficient")) subtract = true;
else throw e;
@@ -1103,14 +1145,23 @@ export const build = async ({ aid, amount, address, feeRate, subtract, user }) =
const covered = Math.min(creditBal, ourfee);
ourfee -= covered;
- if (subtract || amount + fee + ourfee > balance) {
+ if (subtract || amount + fee + ourfee + bumpReserve > balance) {
subtract = true;
- if (amount <= fee + ourfee + dust) {
- fail(`insufficient funds ⚡️${balance} of ⚡️${amount + fee + ourfee + dust}`);
+
+ if (isBitcoin && parentVsize === 0) {
+ parentVsize = 200;
+ bumpReserve = calculateBumpReserve(feeRate, fees.fastestFee, parentVsize);
+ }
+
+ if (amount <= fee + ourfee + bumpReserve + dust) {
+ fail(`insufficient funds ⚡️${balance} of ⚡️${amount + fee + ourfee + bumpReserve + dust}`);
}
- outs = [{ [address]: btc(amount - ourfee) }];
+ outs = [{ [address]: btc(amount - ourfee - bumpReserve) }];
+ if (p2aAddress) outs.push({ [p2aAddress]: btc(P2A_VALUE) });
+
raw = await node.createRawTransaction([], outs, 0, replaceable);
+ if (isBitcoin) raw = "03000000" + raw.substring(8);
tx = await node.fundRawTransaction(raw, {
fee_rate: feeRate,
@@ -1119,6 +1170,11 @@ export const build = async ({ aid, amount, address, feeRate, subtract, user }) =
});
fee = sats(tx.fee);
+
+ if (isBitcoin) {
+ const decoded = await node.decodeRawTransaction(tx.hex);
+ parentVsize = decoded.vsize;
+ }
}
const inputs = [];
@@ -1137,7 +1193,46 @@ export const build = async ({ aid, amount, address, feeRate, subtract, user }) =
inputs.push({ witnessUtxo, path });
}
- return { feeRate, ourfee, fee, fees, hex: tx.hex, inputs, subtract };
+ return { feeRate, ourfee, fee, fees, hex: tx.hex, inputs, subtract, bumpReserve, parentVsize };
+};
+
+export const checkOutgoingConfirmations = async () => {
+ try {
+ const paymentIds = await db.sMembers("outgoing:unconfirmed");
+ for (const pid of paymentIds) {
+ try {
+ const p = await gf(`payment:${pid}`);
+ if (!p) {
+ await db.sRem("outgoing:unconfirmed", pid);
+ continue;
+ }
+
+ if (p.confirmed) {
+ await db.sRem("outgoing:unconfirmed", pid);
+ continue;
+ }
+
+ const txInfo = await bc.getRawTransaction(p.hash, true).catch(() => null);
+ if (!txInfo || !txInfo.confirmations || txInfo.confirmations < 1) continue;
+
+ const refundAmount = (p.bumpReserve || 0) - (p.bumpedFee || 0);
+ if (refundAmount > 0) {
+ await tbRefund(p.uid, refundAmount);
+ }
+
+ p.confirmed = true;
+ p.bumpReserve = 0;
+ await s(`payment:${p.id}`, p);
+ await db.sRem("outgoing:unconfirmed", pid);
+ emit(p.uid, "payment", p);
+ l("outgoing confirmed", p.id, "refunded", refundAmount);
+ } catch (e) {
+ err("problem checking outgoing confirmation", pid, e.message);
+ }
+ }
+ } catch (e) {
+ err("checkOutgoingConfirmations failed", e.message);
+ }
};
export const catchUp = async () => {
@@ -1155,6 +1250,9 @@ export const catchUp = async () => {
}
}
+ // Check custodial outgoing bitcoin tx confirmations
+ await checkOutgoingConfirmations();
+
// Non-custodial accounts: check pending outgoing payments
const inflightAccounts = await db.keys("inflight:*");
for (const key of inflightAccounts) {
diff --git a/lib/zmq.ts b/lib/zmq.ts
index 342d72ae..c584a41d 100644
--- a/lib/zmq.ts
+++ b/lib/zmq.ts
@@ -3,7 +3,7 @@ import config from "$config";
import { btcNetwork } from "$lib/esplora";
import { refreshArkWallet } from "$lib/ark";
import { err, l, warn } from "$lib/logging";
-import { processWatchedTx } from "$lib/payments";
+import { checkOutgoingConfirmations, processWatchedTx } from "$lib/payments";
import { Transaction } from "@scure/btc-signer";
import { concatBytes, sha256x2 } from "@scure/btc-signer/utils.js";
import { bytesToHex } from "@noble/hashes/utils.js";
@@ -115,6 +115,7 @@ const handleRawBlock = async (raw: Uint8Array) => {
}
}
+ checkOutgoingConfirmations().catch((e) => warn("block confirmation check failed", e.message));
refreshArkWallet().catch(() => {});
};