Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add batch dlc tx builder and fix tx finalizer #208

Merged
merged 2 commits into from
Mar 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
matthewjablack marked this conversation as resolved.
Show resolved Hide resolved
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
Loading