@node-dlc/core
Version:
206 lines • 10.8 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.BatchDlcTxBuilder = exports.DlcTxBuilder = exports.DUST_LIMIT = void 0;
const bitcoin_1 = require("@node-dlc/bitcoin");
const messaging_1 = require("@node-dlc/messaging");
const decimal_js_1 = __importDefault(require("decimal.js"));
const TxFinalizer_1 = require("./TxFinalizer");
// Dust limit matching C++ implementation (1000 satoshis)
exports.DUST_LIMIT = BigInt(1000);
class DlcTxBuilder {
constructor(dlcOffer, dlcAccept) {
this.dlcOffer = dlcOffer;
this.dlcAccept = dlcAccept;
}
buildFundingTransaction() {
const txBuilder = new BatchDlcTxBuilder([this.dlcOffer], [this.dlcAccept]);
return txBuilder.buildFundingTransaction();
}
}
exports.DlcTxBuilder = DlcTxBuilder;
class BatchDlcTxBuilder {
constructor(dlcOffers, dlcAccepts) {
this.dlcOffers = dlcOffers;
this.dlcAccepts = dlcAccepts;
}
/**
* Calculates the maximum collateral that can be used given a set of funding inputs
* for exact-amount DLC scenarios (no change outputs).
*
* @param fundingInputs The inputs to be used for funding
* @param feeRatePerVb Fee rate in satoshis per virtual byte
* @param numContracts Number of DLC contracts being created (default: 1)
* @returns Maximum collateral amount in satoshis
*
* @example
* ```typescript
* // Calculate max collateral for DLC splicing scenario
* const dlcFundingInput = getDlcFundingInput(); // 970,332 sats
* const additionalInput = getAdditionalInput(); // 100,000 sats
* const inputs = [dlcFundingInput, additionalInput];
*
* const maxCollateral = BatchDlcTxBuilder.calculateMaxCollateral(
* inputs,
* BigInt(1), // 1 sat/vB fee rate
* 1 // Single DLC contract
* );
*
* // Use maxCollateral in DLC offer to ensure exact amount with no change
* const dlcOffer = createDlcOffer(contractInfo, maxCollateral, ...);
* ```
*/
static calculateMaxCollateral(fundingInputs, feeRatePerVb, numContracts = 1) {
// Calculate total input value
const totalInputValue = fundingInputs.reduce((total, input) => {
return total + input.prevTx.outputs[input.prevTxVout].value.sats;
}, BigInt(0));
// Create a temporary finalizer to calculate fees
const fakeSPK = Buffer.from('0014663117d27e78eb432505180654e603acb30e8a4a', 'hex');
const finalizer = new TxFinalizer_1.DualFundingTxFinalizer(fundingInputs, fakeSPK, fakeSPK, [], // No accepter inputs for single-funded scenario
fakeSPK, fakeSPK, feeRatePerVb, numContracts);
// For exact-amount scenarios, we need to account for:
// 1. Future fees (for CET/refund transactions)
// 2. Funding transaction fees
const futureFee = finalizer.offerFutureFee;
const fundingFee = finalizer.offerFundingFee;
// Maximum collateral is input value minus all fees
const maxCollateral = totalInputValue - futureFee - fundingFee;
// Ensure we don't return negative values
return maxCollateral > BigInt(0) ? maxCollateral : BigInt(0);
}
buildFundingTransaction() {
const tx = new bitcoin_1.TxBuilder();
tx.version = 2;
tx.locktime = bitcoin_1.LockTime.zero();
if (this.dlcOffers.length !== this.dlcAccepts.length)
throw Error('DlcOffers and DlcAccepts must be the same length');
if (this.dlcOffers.length === 0)
throw Error('DlcOffers must not be empty');
if (this.dlcAccepts.length === 0)
throw Error('DlcAccepts must not be empty');
// Ensure all DLC offers and accepts have the same funding inputs
this.ensureSameFundingInputs();
const multisigScripts = [];
for (let i = 0; i < this.dlcOffers.length; i++) {
const offer = this.dlcOffers[i];
const accept = this.dlcAccepts[i];
multisigScripts.push(Buffer.compare(offer.fundingPubkey, accept.fundingPubkey) === -1
? bitcoin_1.Script.p2msLock(2, offer.fundingPubkey, accept.fundingPubkey)
: bitcoin_1.Script.p2msLock(2, accept.fundingPubkey, offer.fundingPubkey));
}
const witScripts = multisigScripts.map((multisigScript) => bitcoin_1.Script.p2wshLock(multisigScript));
const finalizer = new TxFinalizer_1.DualFundingTxFinalizer(this.dlcOffers[0].fundingInputs, this.dlcOffers[0].payoutSpk, this.dlcOffers[0].changeSpk, this.dlcAccepts[0].fundingInputs, this.dlcAccepts[0].payoutSpk, this.dlcAccepts[0].changeSpk, this.dlcOffers[0].feeRatePerVb, this.dlcOffers.length);
this.dlcOffers[0].fundingInputs.forEach((input) => {
if (input.type !== messaging_1.MessageType.FundingInput)
throw new Error('Input is not a funding input');
});
const offerFundingInputs = this.dlcOffers[0].fundingInputs.map((input) => input);
const offerTotalFunding = offerFundingInputs.reduce((total, input) => {
return total + input.prevTx.outputs[input.prevTxVout].value.sats;
}, BigInt(0));
const acceptTotalFunding = this.dlcAccepts[0].fundingInputs.reduce((total, input) => {
return total + input.prevTx.outputs[input.prevTxVout].value.sats;
}, BigInt(0));
const fundingInputs = [
...offerFundingInputs,
...this.dlcAccepts[0].fundingInputs,
];
fundingInputs.sort((a, b) => Number(a.inputSerialId) - Number(b.inputSerialId));
fundingInputs.forEach((input) => {
tx.addInput(bitcoin_1.OutPoint.fromString(`${input.prevTx.txId.toString()}:${input.prevTxVout}`));
});
const offerInput = this.dlcOffers.reduce((total, offer) => total + offer.offerCollateral, BigInt(0));
const acceptInput = this.dlcAccepts.reduce((total, accept) => total + accept.acceptCollateral, BigInt(0));
const totalInputs = this.dlcOffers.map((offer, i) => {
const offerInput = offer.offerCollateral;
const acceptInput = this.dlcAccepts[i].acceptCollateral;
return offerInput + acceptInput;
});
const fundingValues = totalInputs.map((totalInput) => {
const offerFutureFeePerOffer = new decimal_js_1.default(finalizer.offerFutureFee.toString())
.div(this.dlcOffers.length)
.ceil()
.toNumber();
const acceptFutureFeePerAccept = new decimal_js_1.default(finalizer.acceptFutureFee.toString())
.div(this.dlcAccepts.length)
.ceil()
.toNumber();
return (totalInput +
bitcoin_1.Value.fromSats(offerFutureFeePerOffer).sats +
bitcoin_1.Value.fromSats(acceptFutureFeePerAccept).sats);
});
const offerChangeValue = offerTotalFunding - offerInput - finalizer.offerFees;
const acceptChangeValue = acceptTotalFunding - acceptInput - finalizer.acceptFees;
// Validate that we have sufficient funds
if (offerChangeValue < BigInt(0)) {
throw new Error(`Insufficient funds for offerer: need ${offerInput + finalizer.offerFees} sats, have ${offerTotalFunding} sats`);
}
// In single-funded DLCs, if accepter has no inputs, they don't pay fees
// This matches the C++ layer behavior where parties with no inputs have zero fees
if (acceptChangeValue < BigInt(0) && acceptTotalFunding > BigInt(0)) {
throw new Error(`Insufficient funds for accepter: need ${acceptInput + finalizer.acceptFees} sats, have ${acceptTotalFunding} sats`);
}
const outputs = [];
witScripts.forEach((witScript, i) => {
outputs.push({
value: bitcoin_1.Value.fromSats(Number(fundingValues[i])),
script: witScript,
serialId: this.dlcOffers[i].fundOutputSerialId,
});
});
// Dust filtering: Only create change outputs if they're above dust threshold
// This matches the C++ implementation and enables "exact amount" DLC scenarios
// where all input value goes into the DLC funding output with no change
if (offerChangeValue >= exports.DUST_LIMIT) {
outputs.push({
value: bitcoin_1.Value.fromSats(Number(offerChangeValue)),
script: bitcoin_1.Script.p2wpkhLock(this.dlcOffers[0].changeSpk.slice(2)),
serialId: this.dlcOffers[0].changeSerialId,
});
}
if (acceptChangeValue >= exports.DUST_LIMIT) {
outputs.push({
value: bitcoin_1.Value.fromSats(Number(acceptChangeValue)),
script: bitcoin_1.Script.p2wpkhLock(this.dlcAccepts[0].changeSpk.slice(2)),
serialId: this.dlcAccepts[0].changeSerialId,
});
}
outputs.sort((a, b) => Number(a.serialId) - Number(b.serialId));
outputs.forEach((output) => {
tx.addOutput(output.value, output.script);
});
return tx.toTx();
}
ensureSameFundingInputs() {
// Check for offers
const referenceOfferInputs = this.dlcOffers[0].fundingInputs.map((input) => input.serialize().toString('hex'));
for (let i = 1; i < this.dlcOffers.length; i++) {
const currentInputs = this.dlcOffers[i].fundingInputs.map((input) => input.serialize().toString('hex'));
if (!this.arraysEqual(referenceOfferInputs, currentInputs)) {
throw new Error(`Funding inputs for offer ${i} do not match the first offer's funding inputs.`);
}
}
// Check for accepts
const referenceAcceptInputs = this.dlcAccepts[0].fundingInputs.map((input) => input.serialize().toString('hex'));
for (let i = 1; i < this.dlcAccepts.length; i++) {
const currentInputs = this.dlcAccepts[i].fundingInputs.map((input) => input.serialize().toString('hex'));
if (!this.arraysEqual(referenceAcceptInputs, currentInputs)) {
throw new Error(`Funding inputs for accept ${i} do not match the first accept's funding inputs.`);
}
}
}
arraysEqual(arr1, arr2) {
if (arr1.length !== arr2.length)
return false;
for (let i = 0; i < arr1.length; i++) {
if (arr1[i] !== arr2[i])
return false;
}
return true;
}
}
exports.BatchDlcTxBuilder = BatchDlcTxBuilder;
//# sourceMappingURL=TxBuilder.js.map