From abe0728bde95c838840d700e0ff0fb0f5b3ff0f1 Mon Sep 17 00:00:00 2001 From: Matthew Black Date: Mon, 14 Jul 2025 13:10:29 -0700 Subject: [PATCH] feat: add single funded DLC support to messaging - add singleFunded flag and methods to DlcOffer and DlcAccept - implement auto-detection logic for single funded DLCs - update validation rules for single funded DLC scenarios - add comprehensive test coverage for new functionality In single funded DLCs: - DlcOffer: totalCollateral equals offerCollateral - DlcAccept: acceptCollateral is typically 0 or minimal --- .../__tests__/messages/DlcAccept.spec.ts | 72 +++++++++++++ .../__tests__/messages/DlcOffer.spec.ts | 102 ++++++++++++++++-- packages/messaging/lib/messages/DlcAccept.ts | 44 +++++++- packages/messaging/lib/messages/DlcOffer.ts | 68 +++++++++++- 4 files changed, 272 insertions(+), 14 deletions(-) diff --git a/packages/messaging/__tests__/messages/DlcAccept.spec.ts b/packages/messaging/__tests__/messages/DlcAccept.spec.ts index 21e292be..5b67e653 100644 --- a/packages/messaging/__tests__/messages/DlcAccept.spec.ts +++ b/packages/messaging/__tests__/messages/DlcAccept.spec.ts @@ -216,6 +216,78 @@ describe('DlcAccept', () => { instance.validate(); }).to.throw(Error); }); + + it('should allow single funded DLCs with zero acceptCollateral', () => { + // Set up single funded DLC with zero collateral + instance.acceptCollateral = BigInt(0); + instance.fundingInputs = []; + instance.markAsSingleFunded(); + + expect(function () { + instance.validate(); + }).to.not.throw(Error); + }); + + it('should throw if single funded DLC funding amount is insufficient', () => { + // Set up single funded DLC with insufficient funding + instance.acceptCollateral = BigInt(300000000); + instance.fundingInputs = []; + instance.markAsSingleFunded(); + + expect(function () { + instance.validate(); + }).to.throw( + 'For single funded DLCs, fundingAmount must be at least acceptCollateral', + ); + }); + }); + + describe('Single Funded DLC Methods', () => { + it('should correctly identify single funded DLCs', () => { + // Initially not single funded + expect(instance.isSingleFunded()).to.be.false; + + // Mark as single funded + instance.markAsSingleFunded(); + + expect(instance.isSingleFunded()).to.be.true; + }); + + it('should not identify regular DLCs as single funded', () => { + // Default test setup has non-zero acceptCollateral + expect(instance.isSingleFunded()).to.be.false; + }); + + it('should mark DLC as single funded', () => { + expect(function () { + instance.markAsSingleFunded(); + }).to.not.throw(Error); + + expect(instance.singleFunded).to.be.true; + }); + + it('should auto-detect single funded DLCs during deserialization', () => { + // Set up single funded DLC with zero collateral + instance.acceptCollateral = BigInt(0); + + // Serialize and deserialize + const serialized = instance.serialize(); + const deserialized = DlcAccept.deserialize(serialized); + + // Should auto-detect as single funded + expect(deserialized.singleFunded).to.be.true; + expect(deserialized.isSingleFunded()).to.be.true; + }); + + it('should not auto-detect regular DLCs as single funded', () => { + // Default test setup has non-zero acceptCollateral + const serialized = instance.serialize(); + const deserialized = DlcAccept.deserialize(serialized); + + // Should not be detected as single funded + expect(deserialized.singleFunded).to.be.false; + expect(deserialized.isSingleFunded()).to.be.false; + }); }); }); diff --git a/packages/messaging/__tests__/messages/DlcOffer.spec.ts b/packages/messaging/__tests__/messages/DlcOffer.spec.ts index 45d0eaf2..f557259b 100644 --- a/packages/messaging/__tests__/messages/DlcOffer.spec.ts +++ b/packages/messaging/__tests__/messages/DlcOffer.spec.ts @@ -385,25 +385,111 @@ describe('DlcOffer', () => { }).to.throw(Error); }); - it('should throw if totalCollateral <= offerCollateral', () => { - instance.contractInfo.totalCollateral = BigInt(200000000); - instance.offerCollateral = BigInt(200000000); + it('should throw if funding amount less than offer collateral', () => { + instance.offerCollateral = BigInt(3e8); expect(function () { instance.validate(); }).to.throw(Error); + }); + + it('should allow single funded DLCs when totalCollateral equals offerCollateral', () => { + // Set up single funded DLC + instance.contractInfo.totalCollateral = BigInt(99999999); + instance.offerCollateral = BigInt(99999999); + instance.markAsSingleFunded(); + + expect(function () { + instance.validate(); + }).to.not.throw(Error); + }); + it('should throw if marked as single funded but collateral amounts do not match', () => { instance.contractInfo.totalCollateral = BigInt(200000000); - instance.offerCollateral = BigInt(200000001); + instance.offerCollateral = BigInt(99999999); + + expect(function () { + instance.markAsSingleFunded(); + }).to.throw( + 'Cannot mark as single funded: totalCollateral (200000000) must equal offerCollateral (99999999)', + ); + }); + + it('should validate single funded DLC funding amount correctly', () => { + // Set up single funded DLC + instance.contractInfo.totalCollateral = BigInt(99999999); + instance.offerCollateral = BigInt(99999999); + instance.markAsSingleFunded(); + + // Funding amount should be at least totalCollateral for single funded DLCs expect(function () { instance.validate(); - }).to.throw(Error); + }).to.not.throw(Error); }); - it('should throw if funding amount less than offer collateral', () => { - instance.offerCollateral = BigInt(3e8); + it('should throw if single funded DLC funding amount is insufficient', () => { + // Set up single funded DLC with insufficient funding + instance.contractInfo.totalCollateral = BigInt(300000000); + instance.offerCollateral = BigInt(300000000); + instance.markAsSingleFunded(); + expect(function () { instance.validate(); - }).to.throw(Error); + }).to.throw( + 'For single funded DLCs, fundingAmount must be at least totalCollateral', + ); + }); + }); + + describe('Single Funded DLC Methods', () => { + it('should correctly identify single funded DLCs', () => { + // Initially not single funded + expect(instance.isSingleFunded()).to.be.false; + + // Mark as single funded + instance.contractInfo.totalCollateral = BigInt(99999999); + instance.offerCollateral = BigInt(99999999); + instance.markAsSingleFunded(); + + expect(instance.isSingleFunded()).to.be.true; + }); + + it('should auto-detect single funded DLCs based on collateral amounts', () => { + // Set equal collateral amounts + instance.contractInfo.totalCollateral = BigInt(99999999); + instance.offerCollateral = BigInt(99999999); + + // Should be detected as single funded even without explicit flag + expect(instance.isSingleFunded()).to.be.true; + }); + + it('should not identify regular DLCs as single funded', () => { + // Default test setup has different collateral amounts + expect(instance.isSingleFunded()).to.be.false; + }); + + it('should mark DLC as single funded when collateral amounts are equal', () => { + instance.contractInfo.totalCollateral = BigInt(99999999); + instance.offerCollateral = BigInt(99999999); + + expect(function () { + instance.markAsSingleFunded(); + }).to.not.throw(Error); + + expect(instance.singleFunded).to.be.true; + }); + + it('should auto-detect single funded DLCs during deserialization', () => { + // Set up single funded DLC + instance.contractInfo.totalCollateral = BigInt(99999999); + instance.offerCollateral = BigInt(99999999); + + // Serialize and deserialize + const serialized = instance.serialize(); + const deserialized = DlcOffer.deserialize(serialized); + + // Should auto-detect as single funded + expect(deserialized.singleFunded).to.be.true; + expect(deserialized.isSingleFunded()).to.be.true; }); }); }); diff --git a/packages/messaging/lib/messages/DlcAccept.ts b/packages/messaging/lib/messages/DlcAccept.ts index 76ce8250..b1fcec4b 100644 --- a/packages/messaging/lib/messages/DlcAccept.ts +++ b/packages/messaging/lib/messages/DlcAccept.ts @@ -281,6 +281,12 @@ export class DlcAccept implements IDlcMessage { } } + // Auto-detect single funded DLCs based on minimal acceptCollateral + // In single funded DLCs, acceptCollateral is typically 0 or very small + if (instance.acceptCollateral === BigInt(0)) { + instance.singleFunded = true; + } + return instance; } @@ -321,6 +327,28 @@ export class DlcAccept implements IDlcMessage { // Store unknown TLVs for forward compatibility public unknownTlvs?: Array<{ type: number; data: Buffer }>; + /** + * Flag to indicate if this is a single funded DLC + * In single funded DLCs, the acceptor provides minimal or no collateral + */ + public singleFunded = false; + + /** + * Marks this DLC accept as single funded + * For single funded DLCs, acceptCollateral is typically 0 or minimal + */ + public markAsSingleFunded(): void { + this.singleFunded = true; + } + + /** + * Checks if this DLC accept is for a single funded DLC + * @returns True if this is a single funded DLC + */ + public isSingleFunded(): boolean { + return this.singleFunded; + } + /** * Get funding, change and payout address from DlcAccept * @param network Bitcoin Network @@ -402,8 +430,20 @@ export class DlcAccept implements IDlcMessage { const input = fundingInput as FundingInput; return acc + input.prevTx.outputs[input.prevTxVout].value.sats; }, BigInt(0)); - if (this.acceptCollateral >= fundingAmount) { - throw new Error('fundingAmount must be greater than acceptCollateral'); + + if (this.isSingleFunded()) { + // For single funded DLCs, acceptor may provide minimal or no collateral + // fundingAmount should be >= acceptCollateral (allowing for 0 collateral case) + if (this.acceptCollateral > 0 && fundingAmount < this.acceptCollateral) { + throw new Error( + 'For single funded DLCs, fundingAmount must be at least acceptCollateral', + ); + } + } else { + // For regular DLCs, funding amount must be greater than accept collateral + if (this.acceptCollateral >= fundingAmount) { + throw new Error('fundingAmount must be greater than acceptCollateral'); + } } } diff --git a/packages/messaging/lib/messages/DlcOffer.ts b/packages/messaging/lib/messages/DlcOffer.ts index 8020d2d9..50f851b3 100644 --- a/packages/messaging/lib/messages/DlcOffer.ts +++ b/packages/messaging/lib/messages/DlcOffer.ts @@ -226,6 +226,13 @@ export class DlcOffer implements IDlcMessage { } } + // Auto-detect single funded DLCs + if ( + instance.contractInfo.getTotalCollateral() === instance.offerCollateral + ) { + instance.singleFunded = true; + } + return instance; } @@ -279,6 +286,37 @@ export class DlcOffer implements IDlcMessage { // Store unknown TLVs for forward compatibility public unknownTlvs?: Array<{ type: number; data: Buffer }>; + /** + * Flag to indicate if this is a single funded DLC + * In single funded DLCs, totalCollateral equals offerCollateral + */ + public singleFunded = false; + + /** + * Marks this DLC offer as single funded and validates that collateral amounts are correct + * @throws Will throw an error if totalCollateral doesn't equal offerCollateral + */ + public markAsSingleFunded(): void { + const totalCollateral = this.contractInfo.getTotalCollateral(); + if (totalCollateral !== this.offerCollateral) { + throw new Error( + `Cannot mark as single funded: totalCollateral (${totalCollateral}) must equal offerCollateral (${this.offerCollateral})`, + ); + } + this.singleFunded = true; + } + + /** + * Checks if this DLC offer is single funded (totalCollateral == offerCollateral) + * @returns True if this is a single funded DLC + */ + public isSingleFunded(): boolean { + return ( + this.singleFunded || + this.contractInfo.getTotalCollateral() === this.offerCollateral + ); + } + /** * Get funding, change and payout address from DlcOffer * @param network Bitcoin Network @@ -400,8 +438,19 @@ export class DlcOffer implements IDlcMessage { this.contractInfo.validate(); // totalCollateral should be > offerCollateral (logical validation) - if (this.contractInfo.getTotalCollateral() <= this.offerCollateral) { - throw new Error('totalCollateral should be greater than offerCollateral'); + // Exception: for single funded DLCs, totalCollateral == offerCollateral is allowed + if (this.isSingleFunded()) { + if (this.contractInfo.getTotalCollateral() !== this.offerCollateral) { + throw new Error( + 'For single funded DLCs, totalCollateral must equal offerCollateral', + ); + } + } else { + if (this.contractInfo.getTotalCollateral() <= this.offerCollateral) { + throw new Error( + 'totalCollateral should be greater than offerCollateral', + ); + } } // validate funding amount @@ -409,8 +458,19 @@ export class DlcOffer implements IDlcMessage { const input = fundingInput as FundingInput; return acc + input.prevTx.outputs[input.prevTxVout].value.sats; }, BigInt(0)); - if (this.offerCollateral >= fundingAmount) { - throw new Error('fundingAmount must be greater than offerCollateral'); + + if (this.isSingleFunded()) { + // For single funded DLCs, funding amount must cover the full total collateral plus fees + if (fundingAmount < this.contractInfo.getTotalCollateral()) { + throw new Error( + 'For single funded DLCs, fundingAmount must be at least totalCollateral', + ); + } + } else { + // For regular DLCs, funding amount must be greater than offer collateral + if (this.offerCollateral >= fundingAmount) { + throw new Error('fundingAmount must be greater than offerCollateral'); + } } }