@node-dlc/messaging
Version:
DLC Messaging Protocol
457 lines (385 loc) • 14.6 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 } from '../MessageType';
import { deserializeTlv } from '../serialize/deserializeTlv';
import { getTlv } from '../serialize/getTlv';
import { BatchFundingGroup, IBatchFundingGroupJSON } from './BatchFundingGroup';
import {
ContractInfo,
IContractInfoV0JSON,
IContractInfoV1JSON,
} from './ContractInfo';
import { IDlcMessage } from './DlcMessage';
import {
FundingInput,
FundingInputV0,
IFundingInputV0JSON,
} from './FundingInput';
import {
IOrderIrcInfoJSON,
OrderIrcInfo,
OrderIrcInfoV0,
} from './OrderIrcInfo';
import {
IOrderMetadataJSON,
OrderMetadata,
OrderMetadataV0,
} from './OrderMetadata';
import { IOrderPositionInfoJSON, OrderPositionInfo } from './OrderPositionInfo';
export const LOCKTIME_THRESHOLD = 500000000;
export abstract class DlcOffer {
public static deserialize(buf: Buffer): DlcOfferV0 {
const reader = new BufferReader(buf);
const type = Number(reader.readUInt16BE());
switch (type) {
case MessageType.DlcOfferV0:
return DlcOfferV0.deserialize(buf);
default:
throw new Error(`DLC Offer message type must be DlcOfferV0`); // This is a temporary measure while protocol is being developed
}
}
public abstract type: number;
public abstract getAddresses(network: BitcoinNetwork): IDlcOfferV0Addresses;
public abstract validate(): void;
public abstract toJSON(): IDlcOfferV0JSON;
public abstract serialize(): Buffer;
}
/**
* 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.
*/
export class DlcOfferV0 extends DlcOffer implements IDlcMessage {
public static type = MessageType.DlcOfferV0;
/**
* Deserializes an offer_dlc_v0 message
* @param buf
*/
public static deserialize(buf: Buffer): DlcOfferV0 {
const instance = new DlcOfferV0();
const reader = new BufferReader(buf);
reader.readUInt16BE(); // read type
instance.contractFlags = reader.readBytes(1);
instance.chainHash = reader.readBytes(32);
instance.contractInfo = ContractInfo.deserialize(getTlv(reader));
instance.fundingPubKey = reader.readBytes(33);
const payoutSPKLen = reader.readUInt16BE();
instance.payoutSPK = reader.readBytes(payoutSPKLen);
instance.payoutSerialId = reader.readUInt64BE();
instance.offerCollateralSatoshis = reader.readUInt64BE();
const fundingInputsLen = reader.readUInt16BE();
for (let i = 0; i < fundingInputsLen; i++) {
instance.fundingInputs.push(FundingInput.deserialize(getTlv(reader)));
}
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();
while (!reader.eof) {
const buf = getTlv(reader);
const tlvReader = new BufferReader(buf);
const { type } = deserializeTlv(tlvReader);
switch (Number(type)) {
case MessageType.OrderMetadataV0:
instance.metadata = OrderMetadataV0.deserialize(buf);
break;
case MessageType.OrderIrcInfoV0:
instance.ircInfo = OrderIrcInfoV0.deserialize(buf);
break;
case MessageType.OrderPositionInfoV0:
instance.positionInfo = OrderPositionInfo.deserialize(buf);
break;
case MessageType.BatchFundingGroup:
if (!instance.batchFundingGroups) {
instance.batchFundingGroups = [];
}
instance.batchFundingGroups.push(BatchFundingGroup.deserialize(buf));
break;
default:
break;
}
}
return instance;
}
/**
* The type for offer_dlc_v0 message. offer_dlc_v0 = 42778
*/
public type = DlcOfferV0.type;
public contractFlags: Buffer;
public chainHash: Buffer;
public contractInfo: ContractInfo;
public fundingPubKey: Buffer;
public payoutSPK: Buffer;
public payoutSerialId: bigint;
public offerCollateralSatoshis: bigint;
public fundingInputs: FundingInput[] = [];
public changeSPK: Buffer;
public changeSerialId: bigint;
public fundOutputSerialId: bigint;
public feeRatePerVb: bigint;
public cetLocktime: number;
public refundLocktime: number;
public metadata?: OrderMetadata;
public ircInfo?: OrderIrcInfo;
public positionInfo?: OrderPositionInfo;
public batchFundingGroups?: BatchFundingGroup[];
/**
* Get funding, change and payout address from DlcOffer
* @param network Bitcoin Network
* @returns {IDlcOfferV0Addresses}
*/
public getAddresses(network: BitcoinNetwork): IDlcOfferV0Addresses {
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 in DlcOffer
* https://github.com/discreetlogcontracts/dlcspecs/blob/master/Protocol.md#the-offer_dlc-message
* @throws Will throw an error if validation fails
*/
public validate(): void {
// 1. Type is set automatically in class
// 2. contract_flags field is ignored
// 3. chain_hash must be validated as input by end user
// 4. payout_spk and change_spk must be standard script pubkeys
try {
address.fromOutputScript(this.payoutSPK);
} catch (e) {
throw new Error('DlcOffer payoutSPK is invalid');
}
try {
address.fromOutputScript(this.changeSPK);
} catch (e) {
throw new Error('DlcOffer 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. offer_collateral_satoshis must be greater than or equal to 1000
if (this.offerCollateralSatoshis < 1000) {
throw new Error(
'offer_collateral_satoshis 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');
}
// 7. 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 < LOCKTIME_THRESHOLD &&
this.refundLocktime < LOCKTIME_THRESHOLD) ||
(this.cetLocktime >= LOCKTIME_THRESHOLD &&
this.refundLocktime >= LOCKTIME_THRESHOLD)
)
) {
throw new Error('cetLocktime and refundLocktime must be in same units');
}
// 8. cetLocktime must be less than refundLocktime
if (this.cetLocktime >= this.refundLocktime) {
throw new Error('cetLocktime must be less than refundLocktime');
}
// 9. inputSerialId must be unique for each input
const inputSerialIds = this.fundingInputs.map(
(input: FundingInputV0) => input.inputSerialId,
);
if (new Set(inputSerialIds).size !== inputSerialIds.length) {
throw new Error('inputSerialIds must be unique');
}
// 10. 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();
// totalCollaterial should be > offerCollaterial (logical validation)
if (this.contractInfo.totalCollateral <= this.offerCollateralSatoshis) {
throw new Error('totalCollateral should be greater than offerCollateral');
}
// validate funding amount
const fundingAmount = this.fundingInputs.reduce((acc, fundingInput) => {
const input = fundingInput as FundingInputV0;
return acc + input.prevTx.outputs[input.prevTxVout].value.sats;
}, BigInt(0));
if (this.offerCollateralSatoshis >= fundingAmount) {
throw new Error(
'fundingAmount must be greater than offerCollateralSatoshis',
);
}
}
/**
* Converts dlc_offer_v0 to JSON
*/
public toJSON(): IDlcOfferV0JSON {
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()),
);
return {
type: this.type,
contractFlags: this.contractFlags.toString('hex'),
chainHash: this.chainHash.toString('hex'),
contractInfo: this.contractInfo.toJSON(),
fundingPubKey: this.fundingPubKey.toString('hex'),
payoutSPK: this.payoutSPK.toString('hex'),
payoutSerialId: Number(this.payoutSerialId),
offerCollateralSatoshis: Number(this.offerCollateralSatoshis),
fundingInputs: this.fundingInputs.map((input) => input.toJSON()),
changeSPK: this.changeSPK.toString('hex'),
changeSerialId: Number(this.changeSerialId),
fundOutputSerialId: Number(this.fundOutputSerialId),
feeRatePerVb: Number(this.feeRatePerVb),
cetLocktime: this.cetLocktime,
refundLocktime: this.refundLocktime,
tlvs,
};
}
/**
* Serializes the offer_dlc_v0 message into a Buffer
*/
public serialize(): Buffer {
const writer = new BufferWriter();
writer.writeUInt16BE(this.type);
writer.writeBytes(this.contractFlags);
writer.writeBytes(this.chainHash);
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.offerCollateralSatoshis);
writer.writeUInt16BE(this.fundingInputs.length);
for (const fundingInput of this.fundingInputs) {
writer.writeBytes(fundingInput.serialize());
}
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);
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()),
);
return writer.toBuffer();
}
}
export interface IDlcOfferV0JSON {
type: number;
contractFlags: string;
chainHash: string;
contractInfo: IContractInfoV0JSON | IContractInfoV1JSON;
fundingPubKey: string;
payoutSPK: string;
payoutSerialId: number;
offerCollateralSatoshis: number;
fundingInputs: IFundingInputV0JSON[];
changeSPK: string;
changeSerialId: number;
fundOutputSerialId: number;
feeRatePerVb: number;
cetLocktime: number;
refundLocktime: number;
tlvs: (
| IOrderMetadataJSON
| IOrderIrcInfoJSON
| IOrderPositionInfoJSON
| IBatchFundingGroupJSON
)[];
}
export interface IDlcOfferV0Addresses {
fundingAddress: string;
changeAddress: string;
payoutAddress: string;
}
export class DlcOfferContainer {
private offers: DlcOffer[] = [];
/**
* Adds a DlcOffer to the container.
* @param offer The DlcOffer to add.
*/
public addOffer(offer: DlcOffer): void {
this.offers.push(offer);
}
/**
* Returns all DlcOffers in the container.
* @returns An array of DlcOffer instances.
*/
public getOffers(): DlcOffer[] {
return this.offers;
}
/**
* Serializes all DlcOffers in the container to a Buffer.
* @returns A Buffer containing the serialized DlcOffers.
*/
public serialize(): Buffer {
const writer = new 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.
*/
public static deserialize(buf: Buffer): DlcOfferContainer {
const reader = new 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); // This needs to be adjusted based on actual implementation.
container.addOffer(offer);
}
return container;
}
}