Skip to content

Commit

Permalink
feat: add batch dlc tx builder and fix tx finalizer (#208)
Browse files Browse the repository at this point in the history
* feat: add batch dlc tx builder and fix tx finalizer

Add BatchDlcTxBuilder class, and fix order of operations bug with
DualFundingTxFinalizer future fees

* fix: high leverage collateral csoinfo tests

These tests were affected by the DualFundingTxFinalizer future fees
fix.
  • Loading branch information
matthewjablack authored Mar 16, 2024
1 parent 896e77e commit 78b7ce7
Show file tree
Hide file tree
Showing 7 changed files with 281 additions and 71 deletions.
60 changes: 44 additions & 16 deletions packages/core/__tests__/dlc/finance/CsoInfo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
dustThreshold,
getFinalizerByCount,
LinearPayout,
roundDownToNearestMultiplier,
roundUpToNearestMultiplier,
} from '../../../lib';
import {
Expand Down Expand Up @@ -631,10 +632,19 @@ describe('CsoInfo', () => {
} = getCsoInfoFromOffer(csoOrderOffer, 'v1');

// Fees are very high, so use dust threshold for max gain
const expectedMaxGainForContractSize = Value.fromSats(
const offerFees = Value.fromSats(
getFinalizerByCount(feePerByte, numOfferInputs, 3, numContracts)
.offerFees,
).addn(Value.fromSats(dustThreshold(feePerByte)));
);
const expectedMaxGainForContractSize_ = offerFees.addn(
Value.fromSats(dustThreshold(BigInt(feePerByte))),
);
const expectedMaxGainForContractSize = Value.fromSats(
roundUpToNearestMultiplier(
expectedMaxGainForContractSize_.sats,
BigInt(100),
),
);

const expectedMaxLossForContractSize = Value.fromSats(
roundUpToNearestMultiplier(
Expand All @@ -644,24 +654,24 @@ describe('CsoInfo', () => {
);

const expectedNormalizedMaxGain = Value.fromSats(
roundUpToNearestMultiplier(
roundDownToNearestMultiplier(
(expectedMaxGainForContractSize.sats * BigInt(1e8)) /
contractSize.sats,
BigInt(100),
),
);

expect(actualNormalizedMaxGain.sats).to.equal(
expectedNormalizedMaxGain.sats,
);
expect(actualNormalizedMaxLoss.sats).to.equal(normalizedMaxLoss.sats);
expect(actualMaxGainForContractSize.sats).to.equal(
expectedMaxGainForContractSize.sats,
);
expect(actualMaxLossForContractSize.sats).to.equal(
expectedMaxLossForContractSize.sats,
);
expect(minPayout).to.equal(BigInt(119900));
expect(actualNormalizedMaxLoss.sats).to.equal(normalizedMaxLoss.sats);
expect(actualNormalizedMaxGain.sats).to.equal(
expectedNormalizedMaxGain.sats,
); // TODO: Fix issue with this line
expect(minPayout).to.equal(BigInt(121200));
expect(maxPayout).to.equal(collateral.sats);
expect(actualContractSize.sats).to.equal(contractSize.sats);
expect(actualOfferCollateral.sats).to.equal(
Expand Down Expand Up @@ -706,10 +716,19 @@ describe('CsoInfo', () => {
} = getCsoInfoFromOffer(csoOrderOffer, 'v1');

// Fees are very high, so use dust threshold for max gain
const expectedMaxGainForContractSize = Value.fromSats(
const offerFees = Value.fromSats(
getFinalizerByCount(feePerByte, numOfferInputs, 3, numContracts)
.offerFees,
).addn(Value.fromSats(dustThreshold(feePerByte)));
);
const expectedMaxGainForContractSize_ = offerFees.addn(
Value.fromSats(dustThreshold(BigInt(feePerByte))),
);
const expectedMaxGainForContractSize = Value.fromSats(
roundUpToNearestMultiplier(
expectedMaxGainForContractSize_.sats,
BigInt(100),
),
);

const expectedMaxLossForContractSize = Value.fromSats(
roundUpToNearestMultiplier(
Expand All @@ -719,7 +738,7 @@ describe('CsoInfo', () => {
);

const expectedNormalizedMaxGain = Value.fromSats(
roundUpToNearestMultiplier(
roundDownToNearestMultiplier(
(expectedMaxGainForContractSize.sats * BigInt(1e8)) /
contractSize.sats,
BigInt(100),
Expand All @@ -736,7 +755,7 @@ describe('CsoInfo', () => {
expect(actualMaxLossForContractSize.sats).to.equal(
expectedMaxLossForContractSize.sats,
);
expect(minPayout).to.equal(BigInt(119900));
expect(minPayout).to.equal(BigInt(121200));
expect(maxPayout).to.equal(collateral.sats);
expect(actualContractSize.sats).to.equal(contractSize.sats);
expect(actualOfferCollateral.sats).to.equal(
Expand Down Expand Up @@ -781,10 +800,19 @@ describe('CsoInfo', () => {
} = getCsoInfoFromOffer(csoOrderOffer, 'v1');

// Fees are very high, so use dust threshold for max gain
const expectedMaxGainForContractSize = Value.fromSats(
const offerFees = Value.fromSats(
getFinalizerByCount(feePerByte, numOfferInputs, 3, numContracts)
.offerFees,
).addn(Value.fromSats(dustThreshold(feePerByte)));
);
const expectedMaxGainForContractSize_ = offerFees.addn(
Value.fromSats(dustThreshold(BigInt(feePerByte))),
);
const expectedMaxGainForContractSize = Value.fromSats(
roundUpToNearestMultiplier(
expectedMaxGainForContractSize_.sats,
BigInt(100),
),
);

const expectedMaxLossForContractSize = Value.fromSats(
roundUpToNearestMultiplier(
Expand All @@ -794,7 +822,7 @@ describe('CsoInfo', () => {
);

const expectedNormalizedMaxGain = Value.fromSats(
roundUpToNearestMultiplier(
roundDownToNearestMultiplier(
(expectedMaxGainForContractSize.sats * BigInt(1e8)) /
contractSize.sats,
BigInt(100),
Expand All @@ -811,7 +839,7 @@ describe('CsoInfo', () => {
expect(actualMaxLossForContractSize.sats).to.equal(
expectedMaxLossForContractSize.sats,
);
expect(minPayout).to.equal(BigInt(119900));
expect(minPayout).to.equal(BigInt(121200));
expect(maxPayout).to.equal(collateral.sats);
expect(actualContractSize.sats).to.equal(contractSize.sats);
expect(actualOfferCollateral.sats).to.equal(
Expand Down
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
Loading

0 comments on commit 78b7ce7

Please sign in to comment.