Skip to content
Open
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
65 changes: 65 additions & 0 deletions packages/core/__tests__/dlc/CoinSelect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,5 +233,70 @@ describe('CoinSelect', () => {
expect(taprootSelection.inputs.length).to.equal(1);
expect(p2wpkhSelection.inputs.length).to.equal(0);
});

it('should charge the sole funder full base weights when single funded', () => {
const feeRate = BigInt(10);
expect(dualFees(feeRate, 1, 1, true)).to.equal(BigInt(3000));
expect(dualFees(feeRate, 1, 1) < dualFees(feeRate, 1, 1, true)).to.equal(
true,
);
});

it('should select enough inputs for a single-funded contract', () => {
// ddk-dlc requires 8000 sats of inputs for 5000 sat collateral at
// 10 sat/vB with one p2wpkh input, so a lone 7950 sat UTXO must not be
// accepted (the selector adds a dust cushion on top of that).
const feeRate = BigInt(10);
const utxos = [7950, 3000].map((value, vout) => ({
...getUtxos(BigInt(value))[0],
vout,
}));

const { fee, inputs } = dualFundingCoinSelect(
utxos,
[BigInt(5000)],
feeRate,
true,
);

expect(inputs.length).to.equal(2);
expect(fee).to.equal(dualFees(feeRate, 2, 1, true));
});

it('should pin the single-funded threshold to collateral + dust + full fees', () => {
const feeRate = BigInt(10);
const collateral = BigInt(5000);
const required =
collateral + dustThreshold(feeRate) + dualFees(feeRate, 1, 1, true);
const exact = { ...getUtxos(required)[0], vout: 0 };
const oneBelow = { ...getUtxos(required - BigInt(1))[0], vout: 1 };

expect(
dualFundingCoinSelect([exact], [collateral], feeRate, true).inputs
.length,
).to.equal(1);
expect(
dualFundingCoinSelect([oneBelow], [collateral], feeRate, true).inputs
.length,
).to.equal(0);
});

it('should return the single-funded fee estimate when nothing is selectable', () => {
const feeRate = BigInt(10);
const expected = dualFees(feeRate, 1, 1, true);

expect(
dualFundingCoinSelect([], [BigInt(5000)], feeRate, true).fee,
).to.equal(expected);
// 100 sat UTXO costs 680 sats to spend at 10 sat/vB -> detrimental
expect(
dualFundingCoinSelect(
getUtxos(BigInt(100)),
[BigInt(5000)],
feeRate,
true,
).fee,
).to.equal(expected);
});
});
});
36 changes: 36 additions & 0 deletions packages/core/__tests__/dlc/TxBuilder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,20 @@ describe('TxBuilder', () => {
expect(Number(maxCollateral)).to.be.lessThan(1000000);
});

it('should deduct full sole-funder fund tx and CET fees', () => {
const fundingInputs = [createTestFundingInput(BigInt(1000000))];
const feeRate = BigInt(10);

const maxCollateral = BatchDlcTxBuilder.calculateMaxCollateral(
fundingInputs,
feeRate,
1,
);

// 1530 fund fee + 1470 CET fee: full base weights, not the half split
expect(maxCollateral).to.equal(BigInt(1000000) - BigInt(3000));
});

it('should calculate maximum collateral for multiple inputs', () => {
const fundingInputs = [
createTestFundingInput(BigInt(500000)),
Expand Down Expand Up @@ -301,6 +315,28 @@ describe('TxBuilder', () => {
);
});

it('should size single-funded funding output and change with full sole-funder fees', () => {
const offerInput = createTestFundingInput(BigInt(1000000));
const offer = createTestDlcOffer(
BigInt(500000),
[offerInput],
BigInt(10),
);
const accept = createTestDlcAccept(BigInt(0), []);

const tx = new BatchDlcTxBuilder(
[offer],
[accept],
).buildFundingTransaction();
const values = tx.outputs.map((output) => output.value.sats);

expect(values.length).to.equal(2);
// funding = collateral + full CET fee (1470)
expect(values).to.include(BigInt(501470));
// change = inputs - collateral - (1530 fund + 1470 CET)
expect(values).to.include(BigInt(497000));
});

it('should preserve offer Taproot change scriptPubKey', () => {
const offerInput = createTestFundingInput(BigInt(1050000));
const offer = createTestDlcOffer(BigInt(1000000), [offerInput]);
Expand Down
60 changes: 60 additions & 0 deletions packages/core/__tests__/dlc/TxFinalizer.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { FundingInput } from '@node-dlc/messaging';
import { expect } from 'chai';

import {
DEFAULT_MAX_WITNESS_LEN,
DualFundingTxFinalizer,
fundingInputWithWitnessLen,
getFinalizerByCount,
getMaxWitnessLen,
} from '../../lib';
Expand Down Expand Up @@ -50,4 +53,61 @@ describe('TxFinalizer', () => {
).to.throw('Expected 2 witness lengths, got 1');
});
});

describe('single-funded', () => {
const feeRate = BigInt(10);

it('should charge the sole funder full fund tx and CET base weights (matches ddk-dlc)', () => {
const finalizer = getFinalizerByCount(feeRate, 1, 0, 1);
// fund: 214 base + 272 p2wpkh input + 88 change spk + 36 = 610 wu -> 153 vB
expect(finalizer.offerFundingFee).to.equal(BigInt(1530));
// cet: 500 base + 88 payout spk = 588 wu -> 147 vB
expect(finalizer.offerFutureFee).to.equal(BigInt(1470));
expect(finalizer.offerFees).to.equal(BigInt(3000));
expect(finalizer.acceptFees).to.equal(BigInt(0));
});

it('should be symmetric when the accepter is the sole funder', () => {
const finalizer = getFinalizerByCount(feeRate, 0, 1, 1);
expect(finalizer.acceptFees).to.equal(BigInt(3000));
expect(finalizer.offerFees).to.equal(BigInt(0));
});

it('should charge the sole funder full batch base weights for multiple contracts', () => {
const finalizer = getFinalizerByCount(feeRate, 1, 0, 3);
// fund: 42 + 43*3*4 = 558 base + 272 + 88 + 36 = 954 wu -> 239 vB
expect(finalizer.offerFundingFee).to.equal(BigInt(2390));
// cet: (500 + 88) * 3 = 1764 wu -> 441 vB
expect(finalizer.offerFutureFee).to.equal(BigInt(4410));
expect(finalizer.acceptFees).to.equal(BigInt(0));
});

it('should keep the half split when accept inputs are unknown (null)', () => {
const spk = Buffer.from(
'0014663117d27e78eb432505180654e603acb30e8a4a',
'hex',
);
const finalizer = new DualFundingTxFinalizer(
[fundingInputWithWitnessLen(DEFAULT_MAX_WITNESS_LEN)],
spk,
spk,
null as unknown as FundingInput[],
spk,
spk,
feeRate,
);
expect(finalizer.offerFees).to.equal(
getFinalizerByCount(feeRate, 1, 1, 1).offerFees,
);
expect(finalizer.acceptFees).to.equal(BigInt(0));
});

it('should keep the half split for dual-funded contracts', () => {
const finalizer = getFinalizerByCount(feeRate, 1, 1, 1);
// fund: 107 half base + 272 + 88 + 36 = 503 wu -> 126 vB
expect(finalizer.offerFundingFee).to.equal(BigInt(1260));
// cet: 249 half base + 88 = 337 wu -> 85 vB
expect(finalizer.offerFutureFee).to.equal(BigInt(850));
});
});
});
51 changes: 19 additions & 32 deletions packages/core/lib/dlc/CoinSelect.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import {
DEFAULT_MAX_WITNESS_LEN,
DualFundingTxFinalizer,
FundingInputScriptType,
fundingInputVBytes,
fundingInputWithWitnessLen,
getFinalizerByCount,
getMaxWitnessLen,
} from './TxFinalizer';

Expand Down Expand Up @@ -62,40 +60,26 @@ const utxoScore = (x: UTXO, feeRate: bigint): bigint => {
return BigInt(x.value) - feeRate * inputBytes(x);
};

const witnessLensFromInputCount = (
numInputsOrWitnessLens: number | number[],
): number[] =>
Array.isArray(numInputsOrWitnessLens)
? numInputsOrWitnessLens
: Array.from(
{ length: numInputsOrWitnessLens },
() => DEFAULT_MAX_WITNESS_LEN,
);

export const dualFees = (
feeRate: bigint,
numInputsOrWitnessLens: number | number[],
numContracts: number,
singleFunded = false,
): bigint => {
const fakeSPK = Buffer.from(
'0014663117d27e78eb432505180654e603acb30e8a4a',
'hex',
);

const offerInputs = witnessLensFromInputCount(numInputsOrWitnessLens).map(
fundingInputWithWitnessLen,
);
const acceptInputs = [fundingInputWithWitnessLen(DEFAULT_MAX_WITNESS_LEN)];

return new DualFundingTxFinalizer(
offerInputs,
fakeSPK,
fakeSPK,
acceptInputs,
fakeSPK,
fakeSPK,
const witnessLens = Array.isArray(numInputsOrWitnessLens)
? numInputsOrWitnessLens
: undefined;
const numInputs = Array.isArray(numInputsOrWitnessLens)
? numInputsOrWitnessLens.length
: numInputsOrWitnessLens;
// With no accept inputs the offerer is the sole funder and pays the full
// fund tx and CET base weights (matches ddk-dlc).
return getFinalizerByCount(
feeRate,
numInputs,
singleFunded ? 0 : 1,
numContracts,
witnessLens,
).offerFees;
};

Expand All @@ -104,6 +88,7 @@ export const dualFees = (
* @param utxos - UTXOs to select from
* @param collaterals - Collaterals to fund (just one for non-batch tx)
* @param feeRate - Fee rate in satoshis per byte
* @param singleFunded - Offerer funds the whole contract (accepter has no inputs)
* @returns Inputs and fee
* @description
* Add inputs until we reach or surpass the target value (or deplete)
Expand All @@ -113,6 +98,7 @@ export const dualFundingCoinSelect = (
utxos: UTXO[],
collaterals: bigint[], // in satoshis
feeRate: bigint,
singleFunded = false,
): { inputs: UTXO[]; fee: bigint } => {
utxos = [...utxos].sort((a, b) =>
Number(utxoScore(b, feeRate) - utxoScore(a, feeRate)),
Expand All @@ -134,7 +120,7 @@ export const dualFundingCoinSelect = (
if (utxoFee > utxo.value) {
if (i === utxos.length - 1)
return {
fee: dualFees(feeRate, 1, collaterals.length),
fee: dualFees(feeRate, 1, collaterals.length, singleFunded),
inputs: [],
};
continue;
Expand All @@ -147,6 +133,7 @@ export const dualFundingCoinSelect = (
feeRate,
inputs.map((input) => getMaxWitnessLen(scriptTypeFromUtxo(input))),
collaterals.length,
singleFunded,
);

// go again?
Expand All @@ -156,7 +143,7 @@ export const dualFundingCoinSelect = (
}

return {
fee: dualFees(feeRate, 1, collaterals.length),
fee: dualFees(feeRate, 1, collaterals.length, singleFunded),
inputs: [],
};
};
27 changes: 23 additions & 4 deletions packages/core/lib/dlc/TxFinalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { Decimal } from 'decimal.js';

const BATCH_FUND_TX_BASE_WEIGHT = 42;
const FUNDING_OUTPUT_SIZE = 43;
// Full CET/refund base weight (rust-dlc CET_BASE_WEIGHT). Dual-funded parties
// each pay the spec's 249-weight half, a sole funder pays all 500.
const CET_BASE_WEIGHT = 500;
const HALF_CET_BASE_WEIGHT = 249;

export type FundingInputScriptType = 'p2wpkh' | 'p2wsh' | 'p2tr';

Expand Down Expand Up @@ -58,6 +62,12 @@ const fundingInputsWithWitnessLens = (
return witnessLens.map(fundingInputWithWitnessLen);
};

// Only an explicit empty array means the counterparty funds nothing. Callers
// (e.g. bitcoin-abstraction-layer offer checks) pass null before the accept
// is known, which keeps the dual-funded split.
const isSingleFunded = (counterpartyInputs?: FundingInput[] | null): boolean =>
Array.isArray(counterpartyInputs) && counterpartyInputs.length === 0;

export class DualFundingTxFinalizer {
constructor(
readonly offerInputs: FundingInput[],
Expand All @@ -75,9 +85,10 @@ export class DualFundingTxFinalizer {
payoutSPK: Buffer,
changeSPK: Buffer,
numContracts: number,
singleFunded: boolean,
): IFees {
// If no inputs, return zero fees (matches C++ layer behavior for single-funded DLCs)
if (_inputs.length === 0) {
if (!_inputs?.length) {
return { futureFee: BigInt(0), fundingFee: BigInt(0) };
}

Expand All @@ -91,7 +102,10 @@ export class DualFundingTxFinalizer {
(input) => input as FundingInput,
);
// https://github.com/discreetlogcontracts/dlcspecs/blob/8ee4bbe816c9881c832b1ce320b9f14c72e3506f/Transactions.md#expected-weight-of-the-contract-execution-or-refund-transaction
const futureFeeWeight = 249 + 4 * payoutSPK.length;
// A sole funder pays the full base weight (matches ddk-dlc), otherwise the
// base weight is split between the two funding parties.
const cetBaseWeight = singleFunded ? CET_BASE_WEIGHT : HALF_CET_BASE_WEIGHT;
const futureFeeWeight = cetBaseWeight + 4 * payoutSPK.length;
const futureFeeVBytes = new Decimal(futureFeeWeight)
.times(numContracts)
.div(4)
Expand All @@ -103,8 +117,11 @@ export class DualFundingTxFinalizer {
const inputWeight = inputs.reduce((total, input) => {
return total + 164 + input.maxWitnessLen + input.scriptSigLength();
}, 0);
const contractWeight =
(BATCH_FUND_TX_BASE_WEIGHT + FUNDING_OUTPUT_SIZE * numContracts * 4) / 2;
const fundTxBaseWeight =
BATCH_FUND_TX_BASE_WEIGHT + FUNDING_OUTPUT_SIZE * numContracts * 4;
const contractWeight = singleFunded
? fundTxBaseWeight
: fundTxBaseWeight / 2;
const outputWeight = 36 + 4 * changeSPK.length + contractWeight;
const weight = outputWeight + inputWeight;
const vbytes = new Decimal(weight).div(4).ceil().toNumber();
Expand All @@ -119,6 +136,7 @@ export class DualFundingTxFinalizer {
this.offerPayoutSPK,
this.offerChangeSPK,
this.numContracts,
isSingleFunded(this.acceptInputs),
);
}

Expand All @@ -128,6 +146,7 @@ export class DualFundingTxFinalizer {
this.acceptPayoutSPK,
this.acceptChangeSPK,
this.numContracts,
isSingleFunded(this.offerInputs),
);
}

Expand Down
Loading