From bebd34664ff0e957128b394ac98117f982eeb35b Mon Sep 17 00:00:00 2001 From: Matt Black Date: Wed, 23 Sep 2026 17:47:07 -0500 Subject: [PATCH 1/2] fix(core): charge sole funder full base weights Match ddk-dlc: when the counterparty has no inputs, the funding party pays the full fund tx and CET base weights instead of half Add singleFunded option to dualFees and dualFundingCoinSelect --- .../core/__tests__/dlc/CoinSelect.spec.ts | 28 +++++++++++++++++++ .../core/__tests__/dlc/TxFinalizer.spec.ts | 28 +++++++++++++++++++ packages/core/lib/dlc/CoinSelect.ts | 14 ++++++++-- packages/core/lib/dlc/TxFinalizer.ts | 17 +++++++++-- 4 files changed, 81 insertions(+), 6 deletions(-) diff --git a/packages/core/__tests__/dlc/CoinSelect.spec.ts b/packages/core/__tests__/dlc/CoinSelect.spec.ts index d88cd85f..8b1310a9 100644 --- a/packages/core/__tests__/dlc/CoinSelect.spec.ts +++ b/packages/core/__tests__/dlc/CoinSelect.spec.ts @@ -233,5 +233,33 @@ 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', () => { + // 5000 sat collateral at 10 sat/vB needs 8000 sats of inputs with one + // p2wpkh input, so a lone 7950 sat UTXO must not be accepted. + 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)); + }); }); }); diff --git a/packages/core/__tests__/dlc/TxFinalizer.spec.ts b/packages/core/__tests__/dlc/TxFinalizer.spec.ts index 3cb3a9b8..6ff4ca41 100644 --- a/packages/core/__tests__/dlc/TxFinalizer.spec.ts +++ b/packages/core/__tests__/dlc/TxFinalizer.spec.ts @@ -50,4 +50,32 @@ 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 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)); + }); + }); }); diff --git a/packages/core/lib/dlc/CoinSelect.ts b/packages/core/lib/dlc/CoinSelect.ts index a8753255..3a56e883 100644 --- a/packages/core/lib/dlc/CoinSelect.ts +++ b/packages/core/lib/dlc/CoinSelect.ts @@ -76,6 +76,7 @@ export const dualFees = ( feeRate: bigint, numInputsOrWitnessLens: number | number[], numContracts: number, + singleFunded = false, ): bigint => { const fakeSPK = Buffer.from( '0014663117d27e78eb432505180654e603acb30e8a4a', @@ -85,7 +86,11 @@ export const dualFees = ( const offerInputs = witnessLensFromInputCount(numInputsOrWitnessLens).map( fundingInputWithWitnessLen, ); - const acceptInputs = [fundingInputWithWitnessLen(DEFAULT_MAX_WITNESS_LEN)]; + // With no accept inputs the offerer is the sole funder and pays the full + // fund tx and CET base weights (matches ddk-dlc). + const acceptInputs = singleFunded + ? [] + : [fundingInputWithWitnessLen(DEFAULT_MAX_WITNESS_LEN)]; return new DualFundingTxFinalizer( offerInputs, @@ -104,6 +109,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) @@ -113,6 +119,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)), @@ -134,7 +141,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; @@ -147,6 +154,7 @@ export const dualFundingCoinSelect = ( feeRate, inputs.map((input) => getMaxWitnessLen(scriptTypeFromUtxo(input))), collaterals.length, + singleFunded, ); // go again? @@ -156,7 +164,7 @@ export const dualFundingCoinSelect = ( } return { - fee: dualFees(feeRate, 1, collaterals.length), + fee: dualFees(feeRate, 1, collaterals.length, singleFunded), inputs: [], }; }; diff --git a/packages/core/lib/dlc/TxFinalizer.ts b/packages/core/lib/dlc/TxFinalizer.ts index ba48483a..39d45c09 100644 --- a/packages/core/lib/dlc/TxFinalizer.ts +++ b/packages/core/lib/dlc/TxFinalizer.ts @@ -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'; @@ -75,6 +79,7 @@ export class DualFundingTxFinalizer { payoutSPK: Buffer, changeSPK: Buffer, numContracts: number, + soleFunder: boolean, ): IFees { // If no inputs, return zero fees (matches C++ layer behavior for single-funded DLCs) if (_inputs.length === 0) { @@ -91,7 +96,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 = soleFunder ? CET_BASE_WEIGHT : HALF_CET_BASE_WEIGHT; + const futureFeeWeight = cetBaseWeight + 4 * payoutSPK.length; const futureFeeVBytes = new Decimal(futureFeeWeight) .times(numContracts) .div(4) @@ -103,8 +111,9 @@ 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 = soleFunder ? fundTxBaseWeight : fundTxBaseWeight / 2; const outputWeight = 36 + 4 * changeSPK.length + contractWeight; const weight = outputWeight + inputWeight; const vbytes = new Decimal(weight).div(4).ceil().toNumber(); @@ -119,6 +128,7 @@ export class DualFundingTxFinalizer { this.offerPayoutSPK, this.offerChangeSPK, this.numContracts, + this.acceptInputs.length === 0, ); } @@ -128,6 +138,7 @@ export class DualFundingTxFinalizer { this.acceptPayoutSPK, this.acceptChangeSPK, this.numContracts, + this.offerInputs.length === 0, ); } From 97f47b44ba3bbbb64daefd51183647f48b0b1528 Mon Sep 17 00:00:00 2001 From: Matt Black Date: Fri, 25 Sep 2026 08:59:13 -0500 Subject: [PATCH 2/2] fix(core): treat null accept inputs as dual-funded Only an explicit empty array marks the counterparty as non-funding; bitcoin-abstraction-layer passes null at offer time and must keep the half split instead of throwing Delegate dualFees to getFinalizerByCount and pin exact single-funded fee, threshold and funding tx values in tests --- .../core/__tests__/dlc/CoinSelect.spec.ts | 41 +++++++++++++++++- packages/core/__tests__/dlc/TxBuilder.spec.ts | 36 ++++++++++++++++ .../core/__tests__/dlc/TxFinalizer.spec.ts | 32 ++++++++++++++ packages/core/lib/dlc/CoinSelect.ts | 43 +++++-------------- packages/core/lib/dlc/TxFinalizer.ts | 20 ++++++--- 5 files changed, 132 insertions(+), 40 deletions(-) diff --git a/packages/core/__tests__/dlc/CoinSelect.spec.ts b/packages/core/__tests__/dlc/CoinSelect.spec.ts index 8b1310a9..af09c872 100644 --- a/packages/core/__tests__/dlc/CoinSelect.spec.ts +++ b/packages/core/__tests__/dlc/CoinSelect.spec.ts @@ -243,8 +243,9 @@ describe('CoinSelect', () => { }); it('should select enough inputs for a single-funded contract', () => { - // 5000 sat collateral at 10 sat/vB needs 8000 sats of inputs with one - // p2wpkh input, so a lone 7950 sat UTXO must not be accepted. + // 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], @@ -261,5 +262,41 @@ describe('CoinSelect', () => { 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); + }); }); }); diff --git a/packages/core/__tests__/dlc/TxBuilder.spec.ts b/packages/core/__tests__/dlc/TxBuilder.spec.ts index 48bb99ba..e7cb9b14 100644 --- a/packages/core/__tests__/dlc/TxBuilder.spec.ts +++ b/packages/core/__tests__/dlc/TxBuilder.spec.ts @@ -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)), @@ -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]); diff --git a/packages/core/__tests__/dlc/TxFinalizer.spec.ts b/packages/core/__tests__/dlc/TxFinalizer.spec.ts index 6ff4ca41..19f1e0c7 100644 --- a/packages/core/__tests__/dlc/TxFinalizer.spec.ts +++ b/packages/core/__tests__/dlc/TxFinalizer.spec.ts @@ -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'; @@ -70,6 +73,35 @@ describe('TxFinalizer', () => { 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 diff --git a/packages/core/lib/dlc/CoinSelect.ts b/packages/core/lib/dlc/CoinSelect.ts index 3a56e883..6aeeb27d 100644 --- a/packages/core/lib/dlc/CoinSelect.ts +++ b/packages/core/lib/dlc/CoinSelect.ts @@ -1,9 +1,7 @@ import { - DEFAULT_MAX_WITNESS_LEN, - DualFundingTxFinalizer, FundingInputScriptType, fundingInputVBytes, - fundingInputWithWitnessLen, + getFinalizerByCount, getMaxWitnessLen, } from './TxFinalizer'; @@ -62,45 +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 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). - const acceptInputs = singleFunded - ? [] - : [fundingInputWithWitnessLen(DEFAULT_MAX_WITNESS_LEN)]; - - return new DualFundingTxFinalizer( - offerInputs, - fakeSPK, - fakeSPK, - acceptInputs, - fakeSPK, - fakeSPK, + return getFinalizerByCount( feeRate, + numInputs, + singleFunded ? 0 : 1, numContracts, + witnessLens, ).offerFees; }; diff --git a/packages/core/lib/dlc/TxFinalizer.ts b/packages/core/lib/dlc/TxFinalizer.ts index 39d45c09..b74654eb 100644 --- a/packages/core/lib/dlc/TxFinalizer.ts +++ b/packages/core/lib/dlc/TxFinalizer.ts @@ -62,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[], @@ -79,10 +85,10 @@ export class DualFundingTxFinalizer { payoutSPK: Buffer, changeSPK: Buffer, numContracts: number, - soleFunder: boolean, + 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) }; } @@ -98,7 +104,7 @@ export class DualFundingTxFinalizer { // https://github.com/discreetlogcontracts/dlcspecs/blob/8ee4bbe816c9881c832b1ce320b9f14c72e3506f/Transactions.md#expected-weight-of-the-contract-execution-or-refund-transaction // A sole funder pays the full base weight (matches ddk-dlc), otherwise the // base weight is split between the two funding parties. - const cetBaseWeight = soleFunder ? CET_BASE_WEIGHT : HALF_CET_BASE_WEIGHT; + const cetBaseWeight = singleFunded ? CET_BASE_WEIGHT : HALF_CET_BASE_WEIGHT; const futureFeeWeight = cetBaseWeight + 4 * payoutSPK.length; const futureFeeVBytes = new Decimal(futureFeeWeight) .times(numContracts) @@ -113,7 +119,9 @@ export class DualFundingTxFinalizer { }, 0); const fundTxBaseWeight = BATCH_FUND_TX_BASE_WEIGHT + FUNDING_OUTPUT_SIZE * numContracts * 4; - const contractWeight = soleFunder ? fundTxBaseWeight : fundTxBaseWeight / 2; + 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(); @@ -128,7 +136,7 @@ export class DualFundingTxFinalizer { this.offerPayoutSPK, this.offerChangeSPK, this.numContracts, - this.acceptInputs.length === 0, + isSingleFunded(this.acceptInputs), ); } @@ -138,7 +146,7 @@ export class DualFundingTxFinalizer { this.acceptPayoutSPK, this.acceptChangeSPK, this.numContracts, - this.offerInputs.length === 0, + isSingleFunded(this.offerInputs), ); }