UNPKG

@node-dlc/messaging

Version:
465 lines 22.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.DlcOfferContainer = exports.DlcOffer = exports.LOCKTIME_THRESHOLD = void 0; const bitcoin_1 = require("@node-dlc/bitcoin"); const bufio_1 = require("@node-dlc/bufio"); const crypto_1 = require("@node-dlc/crypto"); const bitcoinjs_lib_1 = require("bitcoinjs-lib"); const secp256k1_1 = __importDefault(require("secp256k1")); const MessageType_1 = require("../MessageType"); const deserializeTlv_1 = require("../serialize/deserializeTlv"); const getTlv_1 = require("../serialize/getTlv"); const util_1 = require("../util"); const BatchFundingGroup_1 = require("./BatchFundingGroup"); const ContractInfo_1 = require("./ContractInfo"); const FundingInput_1 = require("./FundingInput"); const OrderIrcInfo_1 = require("./OrderIrcInfo"); const OrderMetadata_1 = require("./OrderMetadata"); const OrderPositionInfo_1 = require("./OrderPositionInfo"); exports.LOCKTIME_THRESHOLD = 500000000; /** * DlcOffer message contains information about a node and indicates its * desire to enter into a new contract. This is the first step toward * creating the funding transaction and CETs. * Updated to support dlcspecs PR #163 format. */ class DlcOffer { constructor() { /** * The type for offer_dlc message. offer_dlc = 42778 */ this.type = DlcOffer.type; // New fields as per dlcspecs PR #163 this.protocolVersion = MessageType_1.PROTOCOL_VERSION; // Default to current protocol version this.fundingInputs = []; /** * Flag to indicate if this is a single funded DLC * In single funded DLCs, totalCollateral equals offerCollateral */ this.singleFunded = false; } /** * Creates a DlcOffer 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 offer */ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any static fromJSON(json) { const instance = new DlcOffer(); // Basic fields with field name variations instance.protocolVersion = json.protocolVersion || json.protocol_version || MessageType_1.PROTOCOL_VERSION; instance.contractFlags = Buffer.from(json.contractFlags || json.contract_flags || '00', 'hex'); instance.chainHash = Buffer.from(json.chainHash || json.chain_hash, 'hex'); instance.temporaryContractId = Buffer.from(json.temporaryContractId || json.temporary_contract_id, 'hex'); instance.fundingPubkey = Buffer.from(json.fundingPubkey || json.fundingPubKey || json.funding_pubkey, 'hex'); instance.payoutSpk = Buffer.from(json.payoutSpk || json.payoutSPK || json.payout_spk, 'hex'); // Use toBigInt helper to handle BigInt values from json-bigint instance.payoutSerialId = (0, util_1.toBigInt)(json.payoutSerialId || json.payout_serial_id); instance.offerCollateral = (0, util_1.toBigInt)(json.offerCollateral || json.offerCollateralSatoshis || json.offer_collateral); instance.changeSpk = Buffer.from(json.changeSpk || json.changeSPK || json.change_spk, 'hex'); instance.changeSerialId = (0, util_1.toBigInt)(json.changeSerialId || json.change_serial_id); instance.fundOutputSerialId = (0, util_1.toBigInt)(json.fundOutputSerialId || json.fund_output_serial_id); instance.feeRatePerVb = (0, util_1.toBigInt)(json.feeRatePerVb || json.fee_rate_per_vb); instance.cetLocktime = json.cetLocktime || json.cet_locktime || 0; instance.refundLocktime = json.refundLocktime || json.refund_locktime || 0; // Use ContractInfo.fromJSON() - proper delegation instance.contractInfo = ContractInfo_1.ContractInfo.fromJSON(json.contractInfo || json.contract_info); // Use FundingInput.fromJSON() for each funding input - proper delegation instance.fundingInputs = (json.fundingInputs || json.funding_inputs || []) // eslint-disable-next-line @typescript-eslint/no-explicit-any .map((inputJson) => FundingInput_1.FundingInput.fromJSON(inputJson)); return instance; } /** * Deserializes an offer_dlc message with backward compatibility * Detects old format (without protocol_version) vs new format (with protocol_version) * @param buf */ static deserialize(buf) { const instance = new DlcOffer(); const reader = new bufio_1.BufferReader(buf); const type = reader.readUInt16BE(); // read type // Validate type matches expected DlcOffer type if (type !== MessageType_1.MessageType.DlcOffer) { throw new Error(`Invalid message type. Expected ${MessageType_1.MessageType.DlcOffer}, got ${type}`); } // BACKWARD COMPATIBILITY: Detect old vs new format // New format: [type][protocol_version: 4 bytes][contract_flags: 1 byte][chain_hash: 32 bytes] // Old format: [type][contract_flags: 1 byte][chain_hash: 32 bytes] const nextBytes = reader.buffer.subarray(reader.position, reader.position + 5); const nextBytesBuffer = Buffer.from(nextBytes); const nextBytesReader = new bufio_1.BufferReader(nextBytesBuffer); const possibleProtocolVersion = nextBytesReader.readUInt32BE(); const possibleContractFlags = nextBytesReader.readUInt8(); // Heuristic: protocol_version should be 1, contract_flags should be 0 // If first 4 bytes are reasonable protocol version (1-10) and next byte is 0, assume new format const isNewFormat = possibleProtocolVersion >= 1 && possibleProtocolVersion <= 10 && possibleContractFlags === 0; if (isNewFormat) { // New format with protocol_version instance.protocolVersion = reader.readUInt32BE(); instance.contractFlags = reader.readBytes(1); } else { // Old format without protocol_version instance.protocolVersion = 1; // Default to version 1 instance.contractFlags = reader.readBytes(1); } instance.chainHash = reader.readBytes(32); instance.temporaryContractId = reader.readBytes(32); // ContractInfo is serialized as sibling type in dlcspecs PR #163 format instance.contractInfo = ContractInfo_1.ContractInfo.deserialize(Buffer.from(reader.buffer.subarray(reader.position))); // Skip past the ContractInfo we just read const contractInfoLength = instance.contractInfo.serialize().length; reader.position += contractInfoLength; instance.fundingPubkey = reader.readBytes(33); const payoutSpkLen = reader.readUInt16BE(); instance.payoutSpk = reader.readBytes(payoutSpkLen); instance.payoutSerialId = reader.readUInt64BE(); instance.offerCollateral = 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_1.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(); instance.fundOutputSerialId = reader.readUInt64BE(); instance.feeRatePerVb = reader.readUInt64BE(); instance.cetLocktime = reader.readUInt32BE(); instance.refundLocktime = reader.readUInt32BE(); // Parse TLV stream as per dlcspecs PR #163 while (!reader.eof) { const buf = (0, getTlv_1.getTlv)(reader); const tlvReader = new bufio_1.BufferReader(buf); const { type } = (0, deserializeTlv_1.deserializeTlv)(tlvReader); switch (Number(type)) { case MessageType_1.MessageType.OrderMetadataV0: instance.metadata = OrderMetadata_1.OrderMetadataV0.deserialize(buf); break; case MessageType_1.MessageType.OrderIrcInfoV0: instance.ircInfo = OrderIrcInfo_1.OrderIrcInfoV0.deserialize(buf); break; case MessageType_1.MessageType.OrderPositionInfo: instance.positionInfo = OrderPositionInfo_1.OrderPositionInfo.deserialize(buf); break; case MessageType_1.MessageType.BatchFundingGroup: if (!instance.batchFundingGroups) { instance.batchFundingGroups = []; } instance.batchFundingGroups.push(BatchFundingGroup_1.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; } } // Auto-detect single funded DLCs if (instance.contractInfo.getTotalCollateral() === instance.offerCollateral) { instance.singleFunded = true; } return instance; } /** * 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 */ markAsSingleFunded() { 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 */ isSingleFunded() { return (this.singleFunded || this.contractInfo.getTotalCollateral() === this.offerCollateral); } /** * Get funding, change and payout address from DlcOffer * @param network Bitcoin Network * @returns {IDlcOfferAddresses} */ getAddresses(network) { const fundingSPK = bitcoin_1.Script.p2wpkhLock((0, crypto_1.hash160)(this.fundingPubkey)) .serialize() .slice(1); const fundingAddress = bitcoinjs_lib_1.address.fromOutputScript(fundingSPK, network); const changeAddress = bitcoinjs_lib_1.address.fromOutputScript(this.changeSpk, network); const payoutAddress = bitcoinjs_lib_1.address.fromOutputScript(this.payoutSpk, network); return { fundingAddress, changeAddress, payoutAddress, }; } /** * Validates correctness of all fields in DlcOffer * Updated validation rules as per dlcspecs PR #163 * @throws Will throw an error if validation fails */ validate() { // 1. Type is set automatically in class // 2. protocol_version validation if (this.protocolVersion !== MessageType_1.PROTOCOL_VERSION) { throw new Error(`Unsupported protocol version: ${this.protocolVersion}, expected: ${MessageType_1.PROTOCOL_VERSION}`); } // 3. temporary_contract_id validation if (!this.temporaryContractId || this.temporaryContractId.length !== 32) { throw new Error('temporaryContractId must be 32 bytes'); } // 4. contract_flags field is ignored // 5. chain_hash must be validated as input by end user // 6. payout_spk and change_spk must be standard script pubkeys try { bitcoinjs_lib_1.address.fromOutputScript(this.payoutSpk); } catch (e) { throw new Error('DlcOffer payoutSpk is invalid'); } try { bitcoinjs_lib_1.address.fromOutputScript(this.changeSpk); } catch (e) { throw new Error('DlcOffer changeSpk is invalid'); } // 7. 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_1.default.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'); } // 8. offer_collateral must be greater than or equal to 1000 if (this.offerCollateral < 1000) { throw new Error('offer_collateral must be greater than or equal to 1000'); } if (this.cetLocktime < 0) { throw new Error('cet_locktime must be greater than or equal to 0'); } if (this.refundLocktime < 0) { throw new Error('refund_locktime must be greater than or equal to 0'); } // 9. cet_locktime and refund_locktime must either both be unix timestamps, or both be block heights. // https://en.bitcoin.it/wiki/NLockTime // https://github.com/bitcoin/bips/blob/master/bip-0065.mediawiki#detailed-specification // https://github.com/bitcoin/bitcoin/blob/master/src/script/script.h#L39 if (!((this.cetLocktime < exports.LOCKTIME_THRESHOLD && this.refundLocktime < exports.LOCKTIME_THRESHOLD) || (this.cetLocktime >= exports.LOCKTIME_THRESHOLD && this.refundLocktime >= exports.LOCKTIME_THRESHOLD))) { throw new Error('cetLocktime and refundLocktime must be in same units'); } // 10. cetLocktime must be less than refundLocktime if (this.cetLocktime >= this.refundLocktime) { throw new Error('cetLocktime must be less than refundLocktime'); } // 11. inputSerialId must be unique for each input const inputSerialIds = this.fundingInputs.map((input) => input.inputSerialId); if (new Set(inputSerialIds).size !== inputSerialIds.length) { throw new Error('inputSerialIds must be unique'); } // 12. changeSerialId and fundOutputSerialID must be different if (this.changeSerialId === this.fundOutputSerialId) { throw new Error('changeSerialId and fundOutputSerialId must be different'); } // validate contractInfo this.contractInfo.validate(); // totalCollateral should be > offerCollateral (logical validation) // 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 const fundingAmount = this.fundingInputs.reduce((acc, fundingInput) => { const input = fundingInput; return acc + input.prevTx.outputs[input.prevTxVout].value.sats; }, BigInt(0)); 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'); } } } /** * Converts dlc_offer to JSON (canonical rust-dlc format) */ toJSON() { const tlvs = []; if (this.metadata) tlvs.push(this.metadata.toJSON()); if (this.ircInfo) tlvs.push(this.ircInfo.toJSON()); if (this.positionInfo) tlvs.push(this.positionInfo.toJSON()); if (this.batchFundingGroups) this.batchFundingGroups.forEach((fundingInfo) => tlvs.push(fundingInfo.toJSON())); // Include unknown TLVs for debugging if (this.unknownTlvs) { this.unknownTlvs.forEach((tlv) => tlvs.push({ type: tlv.type, data: tlv.data.toString('hex') })); } // Return canonical rust-dlc format only return { protocolVersion: this.protocolVersion, temporaryContractId: this.temporaryContractId.toString('hex'), contractFlags: Number(this.contractFlags[0]), chainHash: this.chainHash.toString('hex'), contractInfo: this.contractInfo.toJSON(), fundingPubkey: this.fundingPubkey.toString('hex'), payoutSpk: this.payoutSpk.toString('hex'), payoutSerialId: (0, util_1.bigIntToNumber)(this.payoutSerialId), offerCollateral: (0, util_1.bigIntToNumber)(this.offerCollateral), fundingInputs: this.fundingInputs.map((input) => input.toJSON()), changeSpk: this.changeSpk.toString('hex'), changeSerialId: (0, util_1.bigIntToNumber)(this.changeSerialId), fundOutputSerialId: (0, util_1.bigIntToNumber)(this.fundOutputSerialId), feeRatePerVb: (0, util_1.bigIntToNumber)(this.feeRatePerVb), cetLocktime: this.cetLocktime, refundLocktime: this.refundLocktime, }; // Allow different field names from interface } /** * Serializes the offer_dlc message into a Buffer * Updated serialization format as per dlcspecs PR #163 */ serialize() { const writer = new bufio_1.BufferWriter(); writer.writeUInt16BE(this.type); // New fields as per dlcspecs PR #163 writer.writeUInt32BE(this.protocolVersion); writer.writeBytes(this.contractFlags); writer.writeBytes(this.chainHash); writer.writeBytes(this.temporaryContractId); // New field writer.writeBytes(this.contractInfo.serialize()); writer.writeBytes(this.fundingPubkey); writer.writeUInt16BE(this.payoutSpk.length); writer.writeBytes(this.payoutSpk); writer.writeUInt64BE(this.payoutSerialId); writer.writeUInt64BE(this.offerCollateral); // 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.writeUInt64BE(this.fundOutputSerialId); writer.writeUInt64BE(this.feeRatePerVb); writer.writeUInt32BE(this.cetLocktime); writer.writeUInt32BE(this.refundLocktime); // TLV stream as per dlcspecs PR #163 if (this.metadata) writer.writeBytes(this.metadata.serialize()); if (this.ircInfo) writer.writeBytes(this.ircInfo.serialize()); if (this.positionInfo) writer.writeBytes(this.positionInfo.serialize()); 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(); } } exports.DlcOffer = DlcOffer; DlcOffer.type = MessageType_1.MessageType.DlcOffer; class DlcOfferContainer { constructor() { this.offers = []; } /** * Adds a DlcOffer to the container. * @param offer The DlcOffer to add. */ addOffer(offer) { this.offers.push(offer); } /** * Returns all DlcOffers in the container. * @returns An array of DlcOffer instances. */ getOffers() { return this.offers; } /** * Serializes all DlcOffers in the container to a Buffer. * @returns A Buffer containing the serialized DlcOffers. */ serialize() { const writer = new bufio_1.BufferWriter(); // Write the number of offers in the container first. writer.writeBigSize(this.offers.length); // Serialize each offer and write it. this.offers.forEach((offer) => { const serializedOffer = offer.serialize(); // Optionally, write the length of the serialized offer for easier deserialization. writer.writeBigSize(serializedOffer.length); writer.writeBytes(serializedOffer); }); return writer.toBuffer(); } /** * Deserializes a Buffer into a DlcOfferContainer with DlcOffers. * @param buf The Buffer to deserialize. * @returns A DlcOfferContainer instance. */ static deserialize(buf) { const reader = new bufio_1.BufferReader(buf); const container = new DlcOfferContainer(); const offersCount = reader.readBigSize(); for (let i = 0; i < offersCount; i++) { // Optionally, read the length of the serialized offer if it was written during serialization. const offerLength = reader.readBigSize(); const offerBuf = reader.readBytes(Number(offerLength)); const offer = DlcOffer.deserialize(offerBuf); container.addOffer(offer); } return container; } } exports.DlcOfferContainer = DlcOfferContainer; //# sourceMappingURL=DlcOffer.js.map