@node-dlc/messaging
Version:
DLC Messaging Protocol
640 lines (553 loc) • 21.9 kB
text/typescript
import { Script } from '@node-dlc/bitcoin';
import { BufferReader, BufferWriter } from '@node-dlc/bufio';
import { hash160 } from '@node-dlc/crypto';
import { BitcoinNetwork } from 'bitcoin-networks';
import { address } from 'bitcoinjs-lib';
import secp256k1 from 'secp256k1';
import { MessageType, PROTOCOL_VERSION } from '../MessageType';
import { deserializeTlv } from '../serialize/deserializeTlv';
import { getTlv } from '../serialize/getTlv';
import { bigIntToNumber, toBigInt } from '../util';
import { BatchFundingGroup } from './BatchFundingGroup';
import {
CetAdaptorSignatures,
ICetAdaptorSignaturesJSON,
} from './CetAdaptorSignatures';
import { IDlcMessage } from './DlcMessage';
import { FundingInput, IFundingInputJSON } from './FundingInput';
import {
INegotiationFieldsV0JSON,
INegotiationFieldsV1JSON,
INegotiationFieldsV2JSON,
NegotiationFields,
} from './NegotiationFields';
/**
* DlcAccept contains information about a node and indicates its
* acceptance of the new DLC, as well as its CET and refund
* transaction signatures. This is the second step toward creating
* the funding transaction and closing transactions.
* Updated to support dlcspecs PR #163 format.
*/
export class DlcAccept implements IDlcMessage {
public static type = MessageType.DlcAccept;
/**
* Creates a DlcAccept from JSON data (e.g., from test vectors)
* Handles both our internal format and external test vector formats
* @param json JSON object representing a DLC accept
*/
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
public static fromJSON(json: any): DlcAccept {
const instance = new DlcAccept();
// Helper function to parse DER-encoded signature and extract raw r,s values (64 bytes total)
const parseDerSignature = (hexSig: string): Buffer => {
const sigBuffer = Buffer.from(hexSig, 'hex');
// If it's already 64 bytes, assume it's raw
if (sigBuffer.length === 64) {
return sigBuffer;
}
// Use secp256k1.signatureImport to parse DER signature
try {
const rawSig = secp256k1.signatureImport(sigBuffer);
return Buffer.from(rawSig);
} catch (ex) {
throw new Error(`Invalid DER signature: ${ex.message}`);
}
};
// Handle both internal and external field naming conventions
instance.protocolVersion =
json.protocolVersion || json.protocol_version || PROTOCOL_VERSION;
instance.temporaryContractId = Buffer.from(
json.temporaryContractId ||
json.tempContractId ||
json.temporary_contract_id,
'hex',
);
instance.acceptCollateral = toBigInt(
json.acceptCollateral ||
json.acceptCollateralSatoshis ||
json.accept_collateral,
);
// Handle field name variations between formats
instance.fundingPubkey = Buffer.from(
json.fundingPubkey || json.fundingPubKey || json.funding_pubkey,
'hex',
);
instance.payoutSpk = Buffer.from(
json.payoutSpk || json.payoutSPK || json.payout_spk,
'hex',
);
instance.payoutSerialId = toBigInt(
json.payoutSerialId || json.payout_serial_id,
);
instance.changeSpk = Buffer.from(
json.changeSpk || json.changeSPK || json.change_spk,
'hex',
);
instance.changeSerialId = toBigInt(
json.changeSerialId || json.change_serial_id,
);
// Parse FundingInputs
instance.fundingInputs = DlcAccept.parseFundingInputsFromJSON(
json.fundingInputs || json.funding_inputs || [],
);
// Parse CET adaptor signatures
instance.cetAdaptorSignatures = DlcAccept.parseCetAdaptorSignaturesFromJSON(
json.cetAdaptorSignatures || json.cet_adaptor_signatures,
);
// Parse refund signature - handle DER encoding
const refundSigHex = json.refundSignature || json.refund_signature;
instance.refundSignature = parseDerSignature(refundSigHex);
// Parse optional negotiation fields
if (json.negotiationFields || json.negotiation_fields) {
instance.negotiationFields = DlcAccept.parseNegotiationFieldsFromJSON(
json.negotiationFields || json.negotiation_fields,
);
}
return instance;
}
/**
* Parses FundingInputs from JSON
* @param fundingInputsJson Array of JSON objects representing funding inputs
*/
private static parseFundingInputsFromJSON(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fundingInputsJson: any[],
): FundingInput[] {
return fundingInputsJson.map((inputJson) => {
// Use the existing FundingInput.fromJSON method which handles all the field mapping correctly
return FundingInput.fromJSON(inputJson) as FundingInput;
});
}
/**
* Parses CetAdaptorSignatures from JSON
* @param cetSigsJson JSON object representing CET adaptor signatures
*/
private static parseCetAdaptorSignaturesFromJSON(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cetSigsJson: any,
): CetAdaptorSignatures {
const instance = new CetAdaptorSignatures();
if (
cetSigsJson.ecdsaAdaptorSignatures ||
cetSigsJson.ecdsa_adaptor_signatures
) {
const ecdsaSigs =
cetSigsJson.ecdsaAdaptorSignatures ||
cetSigsJson.ecdsa_adaptor_signatures;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
instance.sigs = ecdsaSigs.map((sig: any) => {
// The test vectors use 'signature' field, but our internal format uses encryptedSig/dleqProof
// Adaptor signatures have different format than regular ECDSA (65 bytes + 97 bytes)
const sigBuffer = Buffer.from(sig.signature, 'hex');
return {
encryptedSig: sigBuffer.slice(0, 65), // First 65 bytes
dleqProof:
sigBuffer.length > 65 ? sigBuffer.slice(65, 162) : Buffer.alloc(97), // Next 97 bytes or empty
};
});
}
return instance;
}
/**
* Parses NegotiationFields from JSON
* @param negotiationJson JSON object representing negotiation fields
*/
private static parseNegotiationFieldsFromJSON(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
negotiationJson: any,
): NegotiationFields {
return NegotiationFields.fromJSON(negotiationJson);
}
/**
* Deserializes an accept_dlc message
* @param buf
*/
public static deserialize(buf: Buffer, parseCets = true): DlcAccept {
const instance = new DlcAccept();
const reader = new BufferReader(buf);
reader.readUInt16BE(); // read type
// New fields as per dlcspecs PR #163
instance.protocolVersion = reader.readUInt32BE();
instance.temporaryContractId = reader.readBytes(32);
instance.acceptCollateral = reader.readUInt64BE();
instance.fundingPubkey = reader.readBytes(33);
const payoutSpkLen = reader.readUInt16BE();
instance.payoutSpk = reader.readBytes(payoutSpkLen);
instance.payoutSerialId = reader.readUInt64BE();
// Changed from u16 to bigsize as per dlcspecs PR #163
const fundingInputsLen = Number(reader.readBigSize());
for (let i = 0; i < fundingInputsLen; i++) {
// FundingInput body is serialized directly without TLV wrapper in rust-dlc format
const fundingInput = FundingInput.deserializeBody(
Buffer.from(reader.buffer.subarray(reader.position)),
);
instance.fundingInputs.push(fundingInput);
// Skip past the FundingInput we just read
const fundingInputLength = fundingInput.serializeBody().length;
reader.position += fundingInputLength;
}
const changeSpkLen = reader.readUInt16BE();
instance.changeSpk = reader.readBytes(changeSpkLen);
instance.changeSerialId = reader.readUInt64BE();
if (parseCets) {
// Read CET adaptor signatures directly to match serialize format (no TLV wrapping)
instance.cetAdaptorSignatures = CetAdaptorSignatures.deserialize(
Buffer.from(reader.buffer.subarray(reader.position)),
);
// Skip past the CET adaptor signatures we just read
const cetLength = instance.cetAdaptorSignatures.serialize().length;
reader.position += cetLength;
} else {
instance.cetAdaptorSignatures = new CetAdaptorSignatures();
}
instance.refundSignature = reader.readBytes(64);
// negotiation_fields is now optional as per dlcspecs PR #163
// Check if there's enough data left for the new format fields (backward compatibility)
// The old format ends exactly after refundSignature. If there's more data, it should be new format.
const remainingBytes = reader.buffer.length - reader.position;
if (remainingBytes > 0) {
// Only try to parse new fields if we have a reasonable amount of extra data
// A single stray byte is likely not valid new format data
if (remainingBytes >= 1) {
try {
const hasNegotiationFields = reader.readUInt8();
if (hasNegotiationFields === 0x01) {
instance.negotiationFields = NegotiationFields.deserialize(
getTlv(reader),
);
}
// Parse TLV stream as per dlcspecs PR #163
// Only continue if there's still data left after the hasNegotiationFields flag
while (reader.position < reader.buffer.length) {
const buf = getTlv(reader);
const tlvReader = new BufferReader(buf);
const { type } = deserializeTlv(tlvReader);
switch (Number(type)) {
case MessageType.BatchFundingGroup:
if (!instance.batchFundingGroups) {
instance.batchFundingGroups = [];
}
instance.batchFundingGroups.push(
BatchFundingGroup.deserialize(buf),
);
break;
default:
// Store unknown TLVs for future compatibility
if (!instance.unknownTlvs) {
instance.unknownTlvs = [];
}
instance.unknownTlvs.push({ type: Number(type), data: buf });
break;
}
}
} catch (error) {
// If parsing new format fails, assume it's old format and ignore the extra bytes
// This provides backward compatibility for malformed or old format data
// Silently ignore parsing errors for backward compatibility
}
}
}
// 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;
}
/**
* The type for accept_dlc message. accept_dlc = 42780
*/
public type = DlcAccept.type;
// New fields as per dlcspecs PR #163
public protocolVersion: number = PROTOCOL_VERSION; // Default to current protocol version
// Existing fields
public temporaryContractId: Buffer;
public acceptCollateral: bigint;
public fundingPubkey: Buffer;
public payoutSpk: Buffer;
public payoutSerialId: bigint;
public fundingInputs: FundingInput[] = [];
public changeSpk: Buffer;
public changeSerialId: bigint;
public cetAdaptorSignatures: CetAdaptorSignatures;
public refundSignature: Buffer;
// negotiation_fields is now optional
public negotiationFields?: NegotiationFields;
public batchFundingGroups?: BatchFundingGroup[];
// 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
* @returns {IDlcAcceptAddresses}
*/
public getAddresses(network: BitcoinNetwork): IDlcAcceptAddresses {
const fundingSPK = Script.p2wpkhLock(hash160(this.fundingPubkey))
.serialize()
.slice(1);
const fundingAddress = address.fromOutputScript(fundingSPK, network);
const changeAddress = address.fromOutputScript(this.changeSpk, network);
const payoutAddress = address.fromOutputScript(this.payoutSpk, network);
return {
fundingAddress,
changeAddress,
payoutAddress,
};
}
/**
* Validates correctness of all fields
* Updated validation rules as per dlcspecs PR #163
* https://github.com/discreetlogcontracts/dlcspecs/blob/master/Protocol.md#the-accept_dlc-message
* @throws Will throw an error if validation fails
*/
public validate(): void {
// 1. Type is set automatically in class
// 2. protocol_version validation
if (this.protocolVersion !== PROTOCOL_VERSION) {
throw new Error(
`Unsupported protocol version: ${this.protocolVersion}, expected: ${PROTOCOL_VERSION}`,
);
}
// 3. temporary_contract_id must match the one from offer_dlc
if (!this.temporaryContractId || this.temporaryContractId.length !== 32) {
throw new Error('temporaryContractId must be 32 bytes');
}
// 4. payout_spk and change_spk must be standard script pubkeys
try {
address.fromOutputScript(this.payoutSpk);
} catch (e) {
throw new Error('payoutSpk is invalid');
}
try {
address.fromOutputScript(this.changeSpk);
} catch (e) {
throw new Error('changeSpk is invalid');
}
// 5. funding_pubkey must be a valid secp256k1 pubkey in compressed format
// https://github.com/bitcoin/bips/blob/master/bip-0137.mediawiki#background-on-ecdsa-signatures
if (secp256k1.publicKeyVerify(Buffer.from(this.fundingPubkey))) {
if (this.fundingPubkey[0] != 0x02 && this.fundingPubkey[0] != 0x03) {
throw new Error('fundingPubkey must be in compressed format');
}
} else {
throw new Error('fundingPubkey is not a valid secp256k1 key');
}
// 6. inputSerialId must be unique for each input
const inputSerialIds = this.fundingInputs.map(
(input: FundingInput) => input.inputSerialId,
);
if (new Set(inputSerialIds).size !== inputSerialIds.length) {
throw new Error('inputSerialIds must be unique');
}
// 7. Ensure funding inputs are segwit
this.fundingInputs.forEach((input: FundingInput) => input.validate());
// 8. validate funding amount
const fundingAmount = this.fundingInputs.reduce((acc, fundingInput) => {
const input = fundingInput as FundingInput;
return acc + input.prevTx.outputs[input.prevTxVout].value.sats;
}, BigInt(0));
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');
}
}
}
/**
* Converts accept_dlc to JSON (canonical rust-dlc format)
*/
public toJSON(): IDlcAcceptJSON {
// Convert raw signature back to DER format for canonical rust-dlc JSON
const derRefundSignature = secp256k1.signatureExport(this.refundSignature);
return {
protocolVersion: this.protocolVersion,
temporaryContractId: this.temporaryContractId.toString('hex'),
acceptCollateral: bigIntToNumber(this.acceptCollateral),
fundingPubkey: this.fundingPubkey.toString('hex'), // lowercase 'k'
payoutSpk: this.payoutSpk.toString('hex'), // lowercase
payoutSerialId: bigIntToNumber(this.payoutSerialId),
fundingInputs: this.fundingInputs.map((input) => input.toJSON()),
changeSpk: this.changeSpk.toString('hex'), // lowercase
changeSerialId: bigIntToNumber(this.changeSerialId),
cetAdaptorSignatures: this.cetAdaptorSignatures.toJSON(),
refundSignature: Buffer.from(derRefundSignature).toString('hex'),
negotiationFields: this.negotiationFields?.toJSON(),
};
}
/**
* Serializes the accept_dlc message into a Buffer
* Updated serialization format as per dlcspecs PR #163
*/
public serialize(): Buffer {
const writer = new BufferWriter();
writer.writeUInt16BE(this.type);
// New fields as per dlcspecs PR #163
writer.writeUInt32BE(this.protocolVersion);
writer.writeBytes(this.temporaryContractId);
writer.writeUInt64BE(this.acceptCollateral);
writer.writeBytes(this.fundingPubkey);
writer.writeUInt16BE(this.payoutSpk.length);
writer.writeBytes(this.payoutSpk);
writer.writeUInt64BE(this.payoutSerialId);
// Changed from u16 to bigsize as per dlcspecs PR #163
writer.writeBigSize(this.fundingInputs.length);
for (const fundingInput of this.fundingInputs) {
// Use serializeBody() to match rust-dlc behavior - funding inputs in vec are serialized without TLV wrapper
writer.writeBytes(fundingInput.serializeBody());
}
writer.writeUInt16BE(this.changeSpk.length);
writer.writeBytes(this.changeSpk);
writer.writeUInt64BE(this.changeSerialId);
writer.writeBytes(this.cetAdaptorSignatures.serialize());
writer.writeBytes(this.refundSignature);
// negotiation_fields is now optional as per dlcspecs PR #163
if (this.negotiationFields) {
writer.writeUInt8(0x01); // present
writer.writeBytes(this.negotiationFields.serialize());
} else {
writer.writeUInt8(0x00); // absent
}
// TLV stream as per dlcspecs PR #163
if (this.batchFundingGroups)
this.batchFundingGroups.forEach((fundingInfo) =>
writer.writeBytes(fundingInfo.serialize()),
);
// Write unknown TLVs for forward compatibility
if (this.unknownTlvs) {
this.unknownTlvs.forEach((tlv) => {
writer.writeBytes(tlv.data);
});
}
return writer.toBuffer();
}
public withoutSigs(): DlcAcceptWithoutSigs {
return new DlcAcceptWithoutSigs(
this.protocolVersion,
this.temporaryContractId,
this.acceptCollateral,
this.fundingPubkey,
this.payoutSpk,
this.payoutSerialId,
this.fundingInputs,
this.changeSpk,
this.changeSerialId,
this.negotiationFields,
this.batchFundingGroups,
);
}
}
export class DlcAcceptWithoutSigs {
constructor(
readonly protocolVersion: number,
readonly temporaryContractId: Buffer,
readonly acceptCollateral: bigint,
readonly fundingPubkey: Buffer,
readonly payoutSpk: Buffer,
readonly payoutSerialId: bigint,
readonly fundingInputs: FundingInput[],
readonly changeSpk: Buffer,
readonly changeSerialId: bigint,
readonly negotiationFields?: NegotiationFields,
readonly batchFundingGroups?: BatchFundingGroup[],
) {}
}
export interface IDlcAcceptJSON {
protocolVersion: number;
temporaryContractId: string;
acceptCollateral: number;
fundingPubkey: string;
payoutSpk: string;
payoutSerialId: number;
fundingInputs: IFundingInputJSON[];
changeSpk: string;
changeSerialId: number;
cetAdaptorSignatures: ICetAdaptorSignaturesJSON;
refundSignature: string;
negotiationFields?: // Now optional
| INegotiationFieldsV0JSON
| INegotiationFieldsV1JSON
| INegotiationFieldsV2JSON;
}
export interface IDlcAcceptAddresses {
fundingAddress: string;
changeAddress: string;
payoutAddress: string;
}
export class DlcAcceptContainer {
private accepts: DlcAccept[] = [];
/**
* Adds a DlcAccept to the container.
* @param accept The DlcAccept to add.
*/
public addAccept(accept: DlcAccept): void {
this.accepts.push(accept);
}
/**
* Returns all DlcAccepts in the container.
* @returns An array of DlcAccept instances.
*/
public getAccepts(): DlcAccept[] {
return this.accepts;
}
/**
* Serializes all DlcAccepts in the container to a Buffer.
* @returns A Buffer containing the serialized DlcAccepts.
*/
public serialize(): Buffer {
const writer = new BufferWriter();
// Write the number of accepts in the container first.
writer.writeBigSize(this.accepts.length);
// Serialize each accept and write it.
this.accepts.forEach((accept) => {
const serializedAccept = accept.serialize();
// Optionally, write the length of the serialized accept for easier deserialization.
writer.writeBigSize(serializedAccept.length);
writer.writeBytes(serializedAccept);
});
return writer.toBuffer();
}
/**
* Deserializes a Buffer into a DlcAcceptContainer with DlcAccepts.
* @param buf The Buffer to deserialize.
* @returns A DlcAcceptContainer instance.
*/
public static deserialize(buf: Buffer, parseCets = true): DlcAcceptContainer {
const reader = new BufferReader(buf);
const container = new DlcAcceptContainer();
const acceptsCount = reader.readBigSize();
for (let i = 0; i < acceptsCount; i++) {
const acceptLength = reader.readBigSize();
const acceptBuf = reader.readBytes(Number(acceptLength));
const accept = DlcAccept.deserialize(acceptBuf, parseCets);
container.addAccept(accept);
}
return container;
}
}