Skip to content

Commit

Permalink
feat: add batch dlc tx builder and fix tx finalizer
Browse files Browse the repository at this point in the history
Add BatchDlcTxBuilder class, and fix order of operations bug with
DualFundingTxFinalizer future fees
  • Loading branch information
matthewjablack committed Mar 15, 2024
1 parent 896e77e commit 795f23b
Show file tree
Hide file tree
Showing 4 changed files with 227 additions and 48 deletions.
177 changes: 135 additions & 42 deletions packages/core/lib/dlc/TxBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
TxBuilder,
Value,
} from '@node-lightning/bitcoin';
import Decimal from 'decimal.js';

import { DualFundingTxFinalizer } from './TxFinalizer';

Expand All @@ -21,56 +22,72 @@ export class DlcTxBuilder {
readonly dlcAccept: DlcAcceptWithoutSigs,
) {}

public buildFundingTransaction(): Tx {
const txBuilder = new BatchDlcTxBuilder([this.dlcOffer], [this.dlcAccept]);
return txBuilder.buildFundingTransaction();
}
}

export class BatchDlcTxBuilder {
constructor(
readonly dlcOffers: DlcOfferV0[],
readonly dlcAccepts: DlcAcceptWithoutSigs[],
) {}

public buildFundingTransaction(): Tx {
const tx = new TxBuilder();
tx.version = 2;
tx.locktime = LockTime.zero();

const multisigScript =
Buffer.compare(
this.dlcOffer.fundingPubKey,
this.dlcAccept.fundingPubKey,
) === -1
? Script.p2msLock(
2,
this.dlcOffer.fundingPubKey,
this.dlcAccept.fundingPubKey,
)
: Script.p2msLock(
2,
this.dlcAccept.fundingPubKey,
this.dlcOffer.fundingPubKey,
);
const witScript = Script.p2wshLock(multisigScript);

const offerInput = this.dlcOffer.offerCollateralSatoshis;
const acceptInput = this.dlcAccept.acceptCollateralSatoshis;

const totalInput = offerInput + acceptInput;
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: Script[] = [];
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
? Script.p2msLock(2, offer.fundingPubKey, accept.fundingPubKey)
: Script.p2msLock(2, accept.fundingPubKey, offer.fundingPubKey),
);
}

const witScripts = multisigScripts.map((multisigScript) =>
Script.p2wshLock(multisigScript),
);

const finalizer = new DualFundingTxFinalizer(
this.dlcOffer.fundingInputs,
this.dlcOffer.payoutSPK,
this.dlcOffer.changeSPK,
this.dlcAccept.fundingInputs,
this.dlcAccept.payoutSPK,
this.dlcAccept.changeSPK,
this.dlcOffer.feeRatePerVb,
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.dlcOffer.fundingInputs.forEach((input) => {
this.dlcOffers[0].fundingInputs.forEach((input) => {
if (input.type !== MessageType.FundingInputV0)
throw Error('FundingInput must be V0');
});
const offerFundingInputs: FundingInputV0[] = this.dlcOffer.fundingInputs.map(
const offerFundingInputs: FundingInputV0[] = this.dlcOffers[0].fundingInputs.map(
(input) => input as FundingInputV0,
);

const offerTotalFunding = offerFundingInputs.reduce((total, input) => {
return total + input.prevTx.outputs[input.prevTxVout].value.sats;
}, BigInt(0));

const acceptTotalFunding = this.dlcAccept.fundingInputs.reduce(
const acceptTotalFunding = this.dlcAccepts[0].fundingInputs.reduce(
(total, input) => {
return total + input.prevTx.outputs[input.prevTxVout].value.sats;
},
Expand All @@ -79,7 +96,7 @@ export class DlcTxBuilder {

const fundingInputs: FundingInputV0[] = [
...offerFundingInputs,
...this.dlcAccept.fundingInputs,
...this.dlcAccepts[0].fundingInputs,
];

fundingInputs.sort(
Expand All @@ -94,28 +111,64 @@ export class DlcTxBuilder {
);
});

const fundingValue =
totalInput + finalizer.offerFutureFee + finalizer.acceptFutureFee;
const offerInput = this.dlcOffers.reduce(
(total, offer) => total + offer.offerCollateralSatoshis,
BigInt(0),
);
const acceptInput = this.dlcAccepts.reduce(
(total, accept) => total + accept.acceptCollateralSatoshis,
BigInt(0),
);

const totalInputs = this.dlcOffers.map((offer, i) => {
const offerInput = offer.offerCollateralSatoshis;
const acceptInput = this.dlcAccepts[i].acceptCollateralSatoshis;
return offerInput + acceptInput;
});

const fundingValues = totalInputs.map((totalInput) => {
const offerFutureFeePerOffer = new Decimal(
finalizer.offerFutureFee.toString(),
)
.div(this.dlcOffers.length)
.ceil()
.toNumber();
const acceptFutureFeePerAccept = new Decimal(
finalizer.acceptFutureFee.toString(),
)
.div(this.dlcAccepts.length)
.ceil()
.toNumber();

return (
totalInput +
Value.fromSats(offerFutureFeePerOffer).sats +
Value.fromSats(acceptFutureFeePerAccept).sats
);
});

const offerChangeValue =
offerTotalFunding - offerInput - finalizer.offerFees;
const acceptChangeValue =
acceptTotalFunding - acceptInput - finalizer.acceptFees;

const outputs: Output[] = [];
outputs.push({
value: Value.fromSats(Number(fundingValue)),
script: witScript,
serialId: this.dlcOffer.fundOutputSerialId,
witScripts.forEach((witScript, i) => {
outputs.push({
value: Value.fromSats(Number(fundingValues[i])),
script: witScript,
serialId: this.dlcOffers[i].fundOutputSerialId,
});
});
outputs.push({
value: Value.fromSats(Number(offerChangeValue)),
script: Script.p2wpkhLock(this.dlcOffer.changeSPK.slice(2)),
serialId: this.dlcOffer.changeSerialId,
script: Script.p2wpkhLock(this.dlcOffers[0].changeSPK.slice(2)),
serialId: this.dlcOffers[0].changeSerialId,
});
outputs.push({
value: Value.fromSats(Number(acceptChangeValue)),
script: Script.p2wpkhLock(this.dlcAccept.changeSPK.slice(2)),
serialId: this.dlcAccept.changeSerialId,
script: Script.p2wpkhLock(this.dlcAccepts[0].changeSPK.slice(2)),
serialId: this.dlcAccepts[0].changeSerialId,
});

outputs.sort((a, b) => Number(a.serialId) - Number(b.serialId));
Expand All @@ -126,6 +179,46 @@ export class DlcTxBuilder {

return tx.toTx();
}

private ensureSameFundingInputs(): void {
// 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.`,
);
}
}
}

private arraysEqual(arr1: string[], arr2: string[]): boolean {
if (arr1.length !== arr2.length) return false;
for (let i = 0; i < arr1.length; i++) {
if (arr1[i] !== arr2[i]) return false;
}
return true;
}
}

interface Output {
Expand Down
14 changes: 9 additions & 5 deletions packages/core/lib/dlc/TxFinalizer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { FundingInput, FundingInputV0, MessageType } from '@node-dlc/messaging';
import { Decimal } from 'decimal.js';

const BATCH_FUND_TX_BASE_WEIGHT = 42;
const FUNDING_OUTPUT_SIZE = 43;
Expand Down Expand Up @@ -30,9 +31,12 @@ export class DualFundingTxFinalizer {
);
// https://github.com/discreetlogcontracts/dlcspecs/blob/8ee4bbe816c9881c832b1ce320b9f14c72e3506f/Transactions.md#expected-weight-of-the-contract-execution-or-refund-transaction
const futureFeeWeight = 249 + 4 * payoutSPK.length;
const futureFeeVBytes = Math.ceil(futureFeeWeight / 4);
const futureFee =
this.feeRate * BigInt(futureFeeVBytes) * BigInt(numContracts);
const futureFeeVBytes = new Decimal(futureFeeWeight)
.times(numContracts)
.div(4)
.ceil()
.toNumber();
const futureFee = this.feeRate * BigInt(futureFeeVBytes);

// https://github.com/discreetlogcontracts/dlcspecs/blob/8ee4bbe816c9881c832b1ce320b9f14c72e3506f/Transactions.md#expected-weight-of-the-funding-transaction
const inputWeight = inputs.reduce((total, input) => {
Expand All @@ -42,7 +46,7 @@ export class DualFundingTxFinalizer {
(BATCH_FUND_TX_BASE_WEIGHT + FUNDING_OUTPUT_SIZE * numContracts * 4) / 2;
const outputWeight = 36 + 4 * changeSPK.length + contractWeight;
const weight = outputWeight + inputWeight;
const vbytes = Math.ceil(weight / 4);
const vbytes = new Decimal(weight).div(4).ceil().toNumber();
const fundingFee = this.feeRate * BigInt(vbytes);

return { futureFee, fundingFee };
Expand Down Expand Up @@ -115,7 +119,7 @@ export class DualClosingTxFinalizer {
}, 0);
const outputWeight = 36 + 4 * payoutSPK.length;
const weight = 213 + outputWeight + inputWeight;
const vbytes = Math.ceil(weight / 4);
const vbytes = new Decimal(weight).div(4).ceil().toNumber();
const fee = this.feeRate * BigInt(vbytes);

return fee;
Expand Down
Loading

0 comments on commit 795f23b

Please sign in to comment.