@node-dlc/messaging
Version:
DLC Messaging Protocol
590 lines (508 loc) • 21.6 kB
text/typescript
import { Value } from '@node-dlc/bitcoin';
import { BitcoinNetworks } from 'bitcoin-networks';
import { expect } from 'chai';
import {
BatchFundingGroup,
OrderIrcInfoV0,
OrderMetadataV0,
OrderPositionInfo,
} from '../../lib';
import { EnumeratedDescriptor } from '../../lib/messages/ContractDescriptor';
import { SingleContractInfo } from '../../lib/messages/ContractInfo';
import {
DlcOffer,
DlcOfferContainer,
LOCKTIME_THRESHOLD,
} from '../../lib/messages/DlcOffer';
import { EnumEventDescriptorV0 } from '../../lib/messages/EventDescriptor';
import { FundingInput } from '../../lib/messages/FundingInput';
import { OracleAnnouncement } from '../../lib/messages/OracleAnnouncement';
import { OracleEvent } from '../../lib/messages/OracleEvent';
import { SingleOracleInfo } from '../../lib/messages/OracleInfo';
import { MessageType, PROTOCOL_VERSION } from '../../lib/MessageType';
describe('DlcOffer', () => {
const bitcoinNetwork = BitcoinNetworks.bitcoin_regtest;
let instance: DlcOffer;
const contractFlags = Buffer.from('00', 'hex');
const chainHash = Buffer.from(
'06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f',
'hex',
);
const temporaryContractId = Buffer.from(
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', // 32 bytes
'hex',
);
// Create ContractInfo programmatically for new dlcspecs PR #163 format
function createTestContractInfo(): SingleContractInfo {
const contractInfo = new SingleContractInfo();
contractInfo.totalCollateral = BigInt(200000000);
// Create enumerated contract descriptor
const contractDescriptor = new EnumeratedDescriptor();
contractDescriptor.outcomes = [
{ outcome: 'win', localPayout: BigInt(0) },
{ outcome: 'draw', localPayout: BigInt(153666723) },
{ outcome: 'lose', localPayout: BigInt(200000000) },
];
// Create oracle info (simplified)
const oracleInfo = new SingleOracleInfo();
const announcement = new OracleAnnouncement();
announcement.announcementSig = Buffer.from(
'fab22628f6e2602e1671c286a2f63a9246794008627a1749639217f4214cb4a9494c93d1a852221080f44f697adb4355df59eb339f6ba0f9b01ba661a8b108d4',
'hex',
);
announcement.oraclePublicKey = Buffer.from(
'da078bbb1d34e7729e38e2ae34236e776da121af442626fa31e31ae55a279a0b',
'hex',
);
const oracleEvent = new OracleEvent();
oracleEvent.oracleNonces = [
Buffer.from(
'3cfba011378411b20a5ab773cb95daab93e9bcd1e4cce44986a7dda84e01841b',
'hex',
),
];
oracleEvent.eventMaturityEpoch = 0;
// Use proper EnumEventDescriptorV0 for new dlcspecs PR #163 format
const eventDescriptor = new EnumEventDescriptorV0();
eventDescriptor.outcomes = ['dummy1', 'dummy2'];
oracleEvent.eventDescriptor = eventDescriptor;
oracleEvent.eventId = 'dummy';
announcement.oracleEvent = oracleEvent;
oracleInfo.announcement = announcement;
contractInfo.contractDescriptor = contractDescriptor;
contractInfo.oracleInfo = oracleInfo;
return contractInfo;
}
const fundingPubkey = Buffer.from(
'0327efea09ff4dfb13230e887cbab8821d5cc249c7ff28668c6633ff9f4b4c08e3',
'hex',
);
const payoutSpk = Buffer.from(
'00142bbdec425007dc360523b0294d2c64d2213af498',
'hex',
);
const fundingInput = Buffer.from(
'fda714' +
'3f' + // length
'000000000000fa51' + // input_serial_id
'0029' + // prevtx_len
'02000000000100c2eb0b00000000160014369d63a82ed846f4d47ad55045e594ab95539d6000000000' + // prevtx
'00000000' + // prevtx_vout
'ffffffff' + // sequence
'006b' + // max_witness_len
'0000', // redeemscript_len
'hex',
);
const changeSpk = Buffer.from(
'0014afa16f949f3055f38bd3a73312bed00b61558884',
'hex',
);
beforeEach(() => {
instance = new DlcOffer();
instance.protocolVersion = PROTOCOL_VERSION; // New field
instance.contractFlags = contractFlags;
instance.chainHash = chainHash;
instance.temporaryContractId = temporaryContractId; // New field
instance.contractInfo = createTestContractInfo();
instance.fundingPubkey = fundingPubkey;
instance.payoutSpk = payoutSpk;
instance.payoutSerialId = BigInt(11555292);
instance.offerCollateral = BigInt(99999999);
instance.fundingInputs = [FundingInput.deserialize(fundingInput)];
instance.changeSpk = changeSpk;
instance.changeSerialId = BigInt(2008045);
instance.fundOutputSerialId = BigInt(5411962);
instance.feeRatePerVb = BigInt(1);
instance.cetLocktime = 100;
instance.refundLocktime = 200;
});
describe('deserialize', () => {
it('should throw if incorrect type', () => {
// Create buffer with incorrect type (0x123 instead of 42778)
const incorrectTypeBuffer = Buffer.concat([
Buffer.from([0x01, 0x23]), // incorrect type
instance.serialize().slice(2), // rest of the data
]);
expect(function () {
DlcOffer.deserialize(incorrectTypeBuffer);
}).to.throw(Error);
});
it('has correct type', () => {
expect(DlcOffer.deserialize(instance.serialize()).type).to.equal(
instance.type,
);
});
});
describe('DlcOffer', () => {
describe('serialize', () => {
it('serializes', () => {
// Test that it serializes without errors (new dlcspecs PR #163 format)
const serialized = instance.serialize();
expect(serialized).to.be.instanceof(Buffer);
expect(serialized.length).to.be.greaterThan(0);
});
it('serializes with positioninfo', () => {
const positionInfo = new OrderPositionInfo();
positionInfo.shiftForFees = 'acceptor';
positionInfo.fees = BigInt(1000);
instance.positionInfo = positionInfo;
// Test that it serializes without errors (new dlcspecs PR #163 format)
const serialized = instance.serialize();
expect(serialized).to.be.instanceof(Buffer);
expect(serialized.length).to.be.greaterThan(0);
});
});
describe('deserialize', () => {
it('deserializes', () => {
// Use round-trip testing approach for consistency
const serialized = instance.serialize();
const deserialized = DlcOffer.deserialize(serialized);
expect(deserialized.protocolVersion).to.equal(PROTOCOL_VERSION);
expect(deserialized.contractFlags).to.deep.equal(contractFlags);
expect(deserialized.chainHash).to.deep.equal(chainHash);
expect(deserialized.temporaryContractId).to.deep.equal(
temporaryContractId,
);
expect(deserialized.fundingPubkey).to.deep.equal(fundingPubkey);
expect(deserialized.payoutSpk).to.deep.equal(payoutSpk);
expect(Number(deserialized.payoutSerialId)).to.equal(11555292);
expect(Number(deserialized.offerCollateral)).to.equal(99999999);
expect(deserialized.changeSpk).to.deep.equal(changeSpk);
expect(Number(deserialized.changeSerialId)).to.equal(2008045);
expect(Number(deserialized.fundOutputSerialId)).to.equal(5411962);
expect(Number(deserialized.feeRatePerVb)).to.equal(1);
expect(deserialized.cetLocktime).to.equal(100);
expect(deserialized.refundLocktime).to.equal(200);
});
it('has correct type', () => {
const serialized = instance.serialize();
expect(DlcOffer.deserialize(serialized).type).to.equal(
MessageType.DlcOffer,
);
});
it('deserializes with positioninfo', () => {
const positionInfo = new OrderPositionInfo();
positionInfo.shiftForFees = 'acceptor';
positionInfo.fees = BigInt(1000);
instance.positionInfo = positionInfo;
const serialized = instance.serialize();
const deserialized = DlcOffer.deserialize(serialized);
expect(deserialized.positionInfo).to.be.instanceof(OrderPositionInfo);
expect(deserialized.positionInfo?.serialize()).to.deep.equal(
positionInfo.serialize(),
);
});
});
describe('toJSON', () => {
it('converts to JSON', async () => {
const json = instance.toJSON();
// Basic structure validation - detailed field testing is done in cross-language tests
expect(json).to.be.an('object');
expect(json.protocolVersion).to.be.a('number');
expect(json.temporaryContractId).to.be.a('string');
expect(json.contractFlags).to.be.a('number');
expect(json.chainHash).to.be.a('string');
expect(json.contractInfo).to.be.an('object');
});
});
describe('getAddresses', () => {
it('should get addresses', () => {
const expectedFundingAddress =
'bcrt1qayylp95g2tzq2a60x2l7f8mclnx5y2jxm0yt09';
const expectedChangeAddress =
'bcrt1q47skl9ylxp2l8z7n5ue390kspds4tzyy5jdxs8';
const expectedPayoutAddress =
'bcrt1q9w77csjsqlwrvpfrkq556try6gsn4ayc2kn0kl';
// Use round-trip approach for consistency
const serialized = instance.serialize();
const deserialized = DlcOffer.deserialize(serialized);
const {
fundingAddress,
changeAddress,
payoutAddress,
} = deserialized.getAddresses(bitcoinNetwork);
expect(fundingAddress).to.equal(expectedFundingAddress);
expect(changeAddress).to.equal(expectedChangeAddress);
expect(payoutAddress).to.equal(expectedPayoutAddress);
});
});
describe('validate', () => {
it('should throw if protocol version is invalid', () => {
instance.protocolVersion = 999;
expect(function () {
instance.validate();
}).to.throw('Unsupported protocol version: 999, expected: 1');
});
it('should throw if temporaryContractId is invalid', () => {
instance.temporaryContractId = Buffer.from('invalid', 'hex');
expect(function () {
instance.validate();
}).to.throw('temporaryContractId must be 32 bytes');
});
it('should throw if payout_spk is invalid', () => {
instance.payoutSpk = Buffer.from('fff', 'hex');
expect(function () {
instance.validate();
}).to.throw(Error);
});
it('should throw if change_spk is invalid', () => {
instance.changeSpk = Buffer.from('fff', 'hex');
expect(function () {
instance.validate();
}).to.throw(Error);
});
it('should throw if fundingpubkey is not a valid pubkey', () => {
instance.fundingPubkey = Buffer.from(
'00f003aa11f2a97b6be755a86b9fd798a7451c670196a5245b7bae971306b7c87e',
'hex',
);
expect(function () {
instance.validate();
}).to.throw(Error);
});
it('should throw if fundingpubkey is not in compressed secp256k1 format', () => {
instance.fundingPubkey = Buffer.from(
'045162991c7299223973cabc99ef5087d7bab2dafe61f78e5388b2f9492f7978123f51fd05ef0693790c0b2d4f30848363a3f3fbcf2bd53a05ba0fd5bb708c3184',
'hex',
);
expect(function () {
instance.validate();
}).to.throw(Error);
});
it('should throw if offerCollateral is less than 1000', () => {
instance.offerCollateral = BigInt(999);
expect(function () {
instance.validate();
}).to.throw(Error);
// boundary check
instance.offerCollateral = BigInt(1000);
expect(function () {
instance.validate();
}).to.not.throw(Error);
});
it('should throw if cet_locktime is less than 0', () => {
instance.cetLocktime = -1;
expect(() => {
instance.validate();
}).to.throw('cet_locktime must be greater than or equal to 0');
});
it('should throw if refund_locktime is less than 0', () => {
instance.refundLocktime = -1;
expect(() => {
instance.validate();
}).to.throw('refund_locktime must be greater than or equal to 0');
});
it('should throw if cet_locktime and refund_locktime are not in same units', () => {
instance.cetLocktime = 100;
instance.refundLocktime = LOCKTIME_THRESHOLD + 200;
expect(function () {
instance.validate();
}).to.throw(Error);
});
it('should not throw if cet_locktime and refund_locktime are in same units', () => {
instance.cetLocktime = 100;
instance.refundLocktime = 200;
expect(function () {
instance.validate();
}).to.not.throw(Error);
instance.cetLocktime = LOCKTIME_THRESHOLD + 100;
instance.refundLocktime = LOCKTIME_THRESHOLD + 200;
expect(function () {
instance.validate();
}).to.not.throw(Error);
});
it('should throw if cet_locktime >= refund_locktime', () => {
instance.cetLocktime = 200;
instance.refundLocktime = 100;
expect(function () {
instance.validate();
}).to.throw(Error);
instance.cetLocktime = 100;
instance.refundLocktime = 100;
expect(function () {
instance.validate();
}).to.throw(Error);
});
it('should throw if inputSerialIds arent unique', () => {
instance.fundingInputs = [
FundingInput.deserialize(fundingInput),
FundingInput.deserialize(fundingInput),
];
expect(function () {
instance.validate();
}).to.throw(Error);
});
it('should throw if changeSerialId == fundOutputSerialId', () => {
instance.changeSerialId = instance.fundOutputSerialId;
expect(function () {
instance.validate();
}).to.throw(Error);
});
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(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.not.throw(Error);
});
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(
'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;
});
});
});
describe('DlcOfferContainer', () => {
it('should serialize and deserialize', () => {
// Create test offers using round-trip approach
const dlcOffer = createTestDlcOffer();
const dlcOffer2 = createTestDlcOffer();
// swap payout and change spk to differentiate between dlcoffers
dlcOffer2.payoutSpk = dlcOffer.changeSpk;
dlcOffer2.changeSpk = dlcOffer.payoutSpk;
const container = new DlcOfferContainer();
container.addOffer(dlcOffer);
container.addOffer(dlcOffer2);
const serialized = container.serialize();
const deserialized = DlcOfferContainer.deserialize(serialized);
expect(deserialized.serialize()).to.deep.equal(container.serialize());
});
function createTestDlcOffer(): DlcOffer {
const testOffer = new DlcOffer();
testOffer.protocolVersion = PROTOCOL_VERSION;
testOffer.contractFlags = contractFlags;
testOffer.chainHash = chainHash;
testOffer.temporaryContractId = temporaryContractId;
testOffer.contractInfo = createTestContractInfo();
testOffer.fundingPubkey = fundingPubkey;
testOffer.payoutSpk = payoutSpk;
testOffer.payoutSerialId = BigInt(11555292);
testOffer.offerCollateral = BigInt(99999999);
testOffer.fundingInputs = [FundingInput.deserialize(fundingInput)];
testOffer.changeSpk = changeSpk;
testOffer.changeSerialId = BigInt(2008045);
testOffer.fundOutputSerialId = BigInt(5411962);
testOffer.feeRatePerVb = BigInt(1);
testOffer.cetLocktime = 100;
testOffer.refundLocktime = 200;
return testOffer;
}
});
describe('TLVs', () => {
it('should deserialize with all TLV types present', () => {
const dlcOffer = new DlcOffer();
dlcOffer.protocolVersion = PROTOCOL_VERSION;
dlcOffer.contractFlags = contractFlags;
dlcOffer.chainHash = chainHash;
dlcOffer.temporaryContractId = temporaryContractId;
dlcOffer.contractInfo = createTestContractInfo();
dlcOffer.fundingPubkey = fundingPubkey;
dlcOffer.payoutSpk = payoutSpk;
dlcOffer.payoutSerialId = BigInt(29829);
dlcOffer.offerCollateral = BigInt(16649967);
dlcOffer.fundingInputs = [FundingInput.deserialize(fundingInput)];
dlcOffer.changeSpk = changeSpk;
dlcOffer.changeSerialId = BigInt(94880);
dlcOffer.fundOutputSerialId = BigInt(44394);
dlcOffer.feeRatePerVb = BigInt(45);
dlcOffer.cetLocktime = 1712689645;
dlcOffer.refundLocktime = 1719255498;
// Create TLV components programmatically for new dlcspecs PR #163 format
const metadata = new OrderMetadataV0();
metadata.offerId = 'test-offer-id'; // Required property
metadata.createdAt = 1640995200; // Optional but set for consistency
metadata.goodTill = 1640995260; // Optional but set for consistency
const ircInfo = new OrderIrcInfoV0();
ircInfo.nick = 'test-nick'; // Required property
ircInfo.pubKey = Buffer.alloc(33); // Required property
const positionInfo = new OrderPositionInfo();
positionInfo.shiftForFees = 'acceptor';
positionInfo.fees = BigInt(6930);
const batchFundingGroup = new BatchFundingGroup();
batchFundingGroup.eventIds = ['test-event']; // Required property
batchFundingGroup.allocatedCollateral = Value.fromSats(BigInt(1000)); // Required property
dlcOffer.metadata = metadata;
dlcOffer.ircInfo = ircInfo;
dlcOffer.positionInfo = positionInfo;
dlcOffer.batchFundingGroups = [batchFundingGroup];
// Test round-trip consistency with TLVs
const serialized = dlcOffer.serialize();
const deserialized = DlcOffer.deserialize(serialized);
expect(deserialized.toJSON()).to.deep.equal(dlcOffer.toJSON());
});
});
});