UNPKG

@node-dlc/core

Version:
206 lines 10.8 kB
"use strict"; 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