@node-dlc/core
Version:
731 lines (662 loc) • 21.7 kB
text/typescript
import { Value } from '@node-dlc/bitcoin';
import {
DigitDecompositionEventDescriptorV0,
MessageType,
NumericalDescriptor,
OracleAnnouncement,
OrderOffer,
OrderPositionInfo,
PayoutFunction,
RoundingIntervals,
SingleContractInfo,
SingleOracleInfo,
} from '@node-dlc/messaging';
import {
BitcoinNetwork,
BitcoinNetworks,
chainHashFromNetwork,
} from 'bitcoin-networks';
import Decimal from 'decimal.js';
import { dustThreshold } from '../CoinSelect';
import { getFinalizerByCount } from '../TxFinalizer';
import { CoveredCall } from './CoveredCall';
import { LinearPayout } from './LinearPayout';
import { LongCall } from './LongCall';
import { LongPut } from './LongPut';
import { ShortPut } from './ShortPut';
export const UNIT_MULTIPLIER = {
bits: BigInt(1e2),
sats: BigInt(1),
};
/**
* Round a number to the nearest multiple of a given multiplier.
*
* @param num - The number to be rounded.
* @param multiplier - The multiplier to round to.
* @returns The number rounded to the nearest multiple of the multiplier.
*
* @example
* ```typescript
* // Example: rounding to nearest 100
* const number = BigInt(354);
* const multiplier = BigInt(100);
* const roundedNumber = roundToNearestMultiplier(number, multiplier);
* console.log(roundedNumber); // Output: 300
* ```
*/
export const roundToNearestMultiplier = (
num: bigint,
multiplier: bigint,
): bigint => (num / multiplier) * multiplier;
/**
* Round a number up to the nearest multiple of a given multiplier.
*
* @param num - The number to be rounded.
* @param multiplier - The multiplier to round to.
* @returns The number rounded up to the nearest multiple of the multiplier.
*
* @example
* ```typescript
* // Example: rounding up to nearest 100
* const number = BigInt(354);
* const multiplier = BigInt(100);
* const roundedNumber = roundToNearestMultiplier(number, multiplier);
* console.log(roundedNumber); // Output: 400
* ```
*/
export const roundUpToNearestMultiplier = (
num: bigint,
multiplier: bigint,
): bigint => ((num + multiplier - BigInt(1)) / multiplier) * multiplier;
export const roundDownToNearestMultiplier = (
num: bigint,
multiplier: bigint,
): bigint => num - (num % multiplier);
export type DlcParty = 'offeror' | 'acceptor' | 'neither';
/**
* Compute rounding intervals for a linear or hyperbola payout curve
*
* @param {number | bigint | Value} rounding rounding interval in sats
* @param {number | bigint | Value} contractSize contract size in sats
* @returns rounding mod for contract size
*/
export const computeRoundingModulus = (
rounding: number | bigint | Value,
contractSize: number | bigint | Value,
): bigint => {
const roundingInSats =
rounding instanceof Value
? rounding.sats
: typeof rounding === 'number'
? BigInt(Math.round(rounding * 1e8)) // Convert bitcoin amount to satoshis
: rounding;
const contractSizeInSats =
contractSize instanceof Value
? contractSize.sats
: typeof contractSize === 'number'
? BigInt(Math.round(contractSize * 1e8)) // Convert bitcoin amount to satoshis
: contractSize;
return (roundingInSats * contractSizeInSats) / BigInt(1e8);
};
/**
* Get digit decomposition event descriptor from oracle announcement
*
* @param {OracleAnnouncement} announcement oracle announcement
* @returns {DigitDecompositionEventDescriptorV0} event descriptor
*/
export const getDigitDecompositionEventDescriptor = (
announcement: OracleAnnouncement,
): DigitDecompositionEventDescriptorV0 => {
if (
announcement.oracleEvent.eventDescriptor.type !==
MessageType.DigitDecompositionEventDescriptorV0
)
throw Error('Only DigitDecompositionEventDescriptorV0 currently supported');
const eventDescriptor = announcement.oracleEvent
.eventDescriptor as DigitDecompositionEventDescriptorV0;
return eventDescriptor;
};
/**
* Build an orderoffer for ContractDescriptorV1
*
* @param {OracleAnnouncement} announcement oracle announcement
* @param {bigint} totalCollateral total collateral in satoshis
* @param {bigint} offerCollateral offer collateral in satoshis
* @param {PayoutFunction} payoutFunction
* @param {RoundingIntervals} roundingIntervals
* @param {bigint} feePerByte sats/vbyte
* @param {NetworkName} network
* @returns {OrderOffer} Returns order offer
*/
export const buildOrderOffer = (
announcement: OracleAnnouncement,
totalCollateral: bigint,
offerCollateral: bigint,
payoutFunction: PayoutFunction,
roundingIntervals: RoundingIntervals,
feePerByte: bigint,
network: string,
): OrderOffer => {
const eventDescriptor = getDigitDecompositionEventDescriptor(announcement);
const contractDescriptor = new NumericalDescriptor();
contractDescriptor.numDigits = eventDescriptor.nbDigits;
contractDescriptor.payoutFunction = payoutFunction;
contractDescriptor.roundingIntervals = roundingIntervals;
const oracleInfo = new SingleOracleInfo();
oracleInfo.announcement = announcement;
const contractInfo = new SingleContractInfo();
contractInfo.totalCollateral = totalCollateral;
contractInfo.contractDescriptor = contractDescriptor;
contractInfo.oracleInfo = oracleInfo;
const orderOffer = new OrderOffer();
// Set required fields for OrderOffer
orderOffer.contractFlags = Buffer.from('00', 'hex'); // Default contract flags
// Generate a random 32-byte temporary contract ID
orderOffer.temporaryContractId = Buffer.from(
Array.from({ length: 32 }, () => Math.floor(Math.random() * 256)),
);
orderOffer.chainHash = chainHashFromNetwork(BitcoinNetworks[network]);
orderOffer.contractInfo = contractInfo;
orderOffer.offerCollateral = offerCollateral; // Use correct property name
orderOffer.feeRatePerVb = feePerByte;
orderOffer.cetLocktime = Math.floor(new Date().getTime() / 1000); // set to current time
orderOffer.refundLocktime =
announcement.oracleEvent.eventMaturityEpoch + 2419200; // 4 weeks after maturity
return orderOffer;
};
/**
* Builds an order offer for a covered call or short put
*
* @param {OracleAnnouncement} announcement oracle announcement
* @param {number} contractSize contract size in satoshis
* @param {number} strikePrice strike price of contract
* @param {number} premium premium of contract in satoshis
* @param {number | bigint} feePerByte sats/vbyte
* @param {number} rounding rounding interval
* @param {string} network bitcoin network type
* @param {string} type call or put
* @param {number} _totalCollateral total collateral in satoshis (applicable only for short put)
* @returns {OrderOffer} Returns order offer
*/
export const buildOptionOrderOffer = (
announcement: OracleAnnouncement,
contractSize: Value,
strikePrice: number,
premium: Value,
feePerByte: number | bigint,
rounding: number,
network: string,
type: 'call' | 'put',
direction: 'long' | 'short',
_totalCollateral?: Value,
): OrderOffer => {
const eventDescriptor = getDigitDecompositionEventDescriptor(announcement);
let totalCollateral: bigint;
let payoutFunctionInfo: {
payoutFunction: PayoutFunction;
totalCollateral?: bigint;
};
const roundingIntervals = new RoundingIntervals();
const roundingMod = computeRoundingModulus(rounding, contractSize);
if (direction === 'short') {
if (type === 'call') {
payoutFunctionInfo = CoveredCall.buildPayoutFunction(
BigInt(strikePrice),
contractSize.sats,
eventDescriptor.base,
eventDescriptor.nbDigits,
);
totalCollateral = payoutFunctionInfo.totalCollateral;
roundingIntervals.intervals = [
{
beginInterval: BigInt(0),
roundingMod: BigInt(1),
},
{
beginInterval: BigInt(strikePrice),
roundingMod,
},
];
} else {
payoutFunctionInfo = ShortPut.buildPayoutFunction(
BigInt(strikePrice),
contractSize.sats,
_totalCollateral.sats,
eventDescriptor.base,
eventDescriptor.nbDigits,
);
totalCollateral = _totalCollateral.sats;
roundingIntervals.intervals = [
{
beginInterval: BigInt(0),
roundingMod,
},
{
beginInterval: BigInt(strikePrice),
roundingMod: BigInt(1),
},
];
}
} else {
totalCollateral = _totalCollateral.sats;
if (type === 'call') {
payoutFunctionInfo = LongCall.buildPayoutFunction(
BigInt(strikePrice),
contractSize.sats,
totalCollateral,
eventDescriptor.base,
eventDescriptor.nbDigits,
);
roundingIntervals.intervals = [
{
beginInterval: BigInt(0),
roundingMod: BigInt(1),
},
{
beginInterval: BigInt(strikePrice),
roundingMod,
},
];
} else {
payoutFunctionInfo = LongPut.buildPayoutFunction(
BigInt(strikePrice),
contractSize.sats,
totalCollateral,
eventDescriptor.base,
eventDescriptor.nbDigits,
);
roundingIntervals.intervals = [
{
beginInterval: BigInt(0),
roundingMod,
},
{
beginInterval: BigInt(strikePrice),
roundingMod: BigInt(1),
},
];
}
}
const payoutFunction = payoutFunctionInfo.payoutFunction;
const offerCollateral =
direction === 'short' ? totalCollateral - premium.sats : premium.sats;
return buildOrderOffer(
announcement,
totalCollateral,
offerCollateral,
payoutFunction,
roundingIntervals,
BigInt(feePerByte),
network,
);
};
/**
* Builds an order offer for a covered call
*
* @param {OracleAnnouncement} announcement oracle announcement
* @param {number} contractSize contract size in satoshis
* @param {number} strikePrice strike price of contract
* @param {number} premium premium of contract in satoshis
* @param {number} feePerByte sats/vbyte
* @param {number} rounding rounding interval
* @param {string} network bitcoin network type
* @returns {OrderOffer} Returns order offer
*/
export const buildCoveredCallOrderOffer = (
announcement: OracleAnnouncement,
contractSize: Value,
strikePrice: number,
premium: Value,
feePerByte: number,
rounding: number,
network: string,
): OrderOffer => {
return buildOptionOrderOffer(
announcement,
contractSize,
strikePrice,
premium,
feePerByte,
rounding,
network,
'call',
'short',
);
};
/**
* Builds an order offer for a short put
*
* @param {OracleAnnouncement} announcement oracle announcement
* @param {number} contractSize contract size in satoshis
* @param {number} strikePrice strike price of contract
* @param {number} totalCollateral total collateral in satoshis
* @param {number} premium premium of contract in satoshis
* @param {number} feePerByte sats/vbyte
* @param {number} rounding rounding interval
* @param {string} network bitcoin network type
* @returns {OrderOffer} Returns order offer
*/
export const buildShortPutOrderOffer = (
announcement: OracleAnnouncement,
contractSize: Value,
strikePrice: number,
totalCollateral: Value,
premium: Value,
feePerByte: number,
rounding: number,
network: string,
): OrderOffer => {
return buildOptionOrderOffer(
announcement,
contractSize,
strikePrice,
premium,
feePerByte,
rounding,
network,
'put',
'short',
totalCollateral,
);
};
/**
* Builds an order offer for a long call
*
* @param {OracleAnnouncement} announcement oracle announcement
* @param {number} contractSize contract size in satoshis
* @param {number} strikePrice strike price of contract
* @param {number} maxGain maximum amount that can be gained (totalCollateral)
* @param {number} premium premium of contract in satoshis
* @param {number} feePerByte sats/vbyte
* @param {number} rounding rounding interval
* @param {string} network bitcoin network type
* @returns {OrderOffer} Returns order offer
*/
export const buildLongCallOrderOffer = (
announcement: OracleAnnouncement,
contractSize: Value,
strikePrice: number,
maxGain: Value,
premium: Value,
feePerByte: number,
rounding: number,
network: string,
): OrderOffer => {
return buildOptionOrderOffer(
announcement,
contractSize,
strikePrice,
premium,
feePerByte,
rounding,
network,
'call',
'long',
maxGain,
);
};
/**
* Builds an order offer for a long put
*
* @param {OracleAnnouncement} announcement oracle announcement
* @param {number} contractSize contract size in satoshis
* @param {number} strikePrice strike price of contract
* @param {number} maxGain maximum amount that can be gained (totalCollateral)
* @param {number} premium premium of contract in satoshis
* @param {number} feePerByte sats/vbyte
* @param {number} rounding rounding interval
* @param {string} network bitcoin network type
* @returns {OrderOffer} Returns order offer
*/
export const buildLongPutOrderOffer = (
announcement: OracleAnnouncement,
contractSize: Value,
strikePrice: number,
maxGain: Value,
premium: Value,
feePerByte: number,
rounding: number,
network: string,
): OrderOffer => {
return buildOptionOrderOffer(
announcement,
contractSize,
strikePrice,
premium,
feePerByte,
rounding,
network,
'put',
'long',
maxGain,
);
};
/**
* Builds an order offer for a linear curve
*
* @param {OracleAnnouncement} announcement oracle announcement
* @param {Value} offerCollateral offer collateral amount
* @param {Value} minPayout minimum payout
* @param {Value} maxPayout maximum payout (also total collateral)
* @param {bigint} startOutcome oracle outcome (in bits or sats)
* @param {bigint} endOutcome oracle outcome (in bits or sats)
* @param {bigint} feePerByte sats/vbyte
* @param {Value} rounding rounding mod for RoundingInterval
* @param {BitcoinNetwork} network bitcoin, bitcoin_testnet or bitcoin_regtest
* @param {DlcParty} [shiftForFees] shift for offerer, acceptor or neither (who should pay fees)
* @param {Value} [fees] fees to shift
* @returns {OrderOffer}
*/
export const buildLinearOrderOffer = (
announcement: OracleAnnouncement,
offerCollateral: Value,
minPayout: Value,
maxPayout: Value,
startOutcome: bigint,
endOutcome: bigint,
feePerByte: bigint,
roundingIntervals: RoundingIntervals,
network: BitcoinNetwork,
shiftForFees: DlcParty = 'neither',
fees: Value = Value.fromSats(0),
): OrderOffer => {
if (maxPayout.lt(minPayout))
throw Error('maxPayout must be greater than minPayout');
if (endOutcome < startOutcome)
throw Error('endOutcome must be greater than startOutcome');
const eventDescriptor = getDigitDecompositionEventDescriptor(announcement);
const totalCollateral = maxPayout.sats;
const { payoutFunction } = LinearPayout.buildPayoutFunction(
minPayout.sats,
maxPayout.sats,
startOutcome,
endOutcome,
eventDescriptor.base,
eventDescriptor.nbDigits,
);
const orderOffer = buildOrderOffer(
announcement,
totalCollateral,
offerCollateral.sats,
payoutFunction,
roundingIntervals,
feePerByte,
network.name,
);
const positionInfo = new OrderPositionInfo();
positionInfo.shiftForFees = shiftForFees;
positionInfo.fees = shiftForFees === 'neither' ? BigInt(0) : fees.sats;
orderOffer.positionInfo = positionInfo;
return orderOffer;
};
/**
* Builds a custom strategy order offer
*
* Calculates offer fees
* calculates the minimum max gain
* maxLoss and maxGain are normalized to 1 BTC contracts
*
* shiftForFees 'offeror' means 'offeror' pays network fees
* shiftForFees 'acceptor' means 'acceptor' pays network fees
*
* numContracts refers to the number of DLCs in the funding transaction
* if it's not a batch dlc funding transaction, then this is not relevant
*
* @param {OracleAnnouncement} announcement oracle announcement
* @param {Value} contractSize contract size
* @param {Value} normalizedMaxLoss maximum amount that can be lost based on 1 BTC contract
* @param {Value} normalizedMaxGain maximum amount that can be gained based on 1 BTC contract
* @param {bigint} feePerByte sats/vbyte
* @param {Value} roundingIntervals rounding mod for RoundingInterval
* @param {BitcoinNetwork} network bitcoin, bitcoin_testnet or bitcoin_regtest
* @param {DlcParty} [shiftForFees] shift for offerer, acceptor or neither
* @param {Value} [fees_] fees to shift
* @param {Value} [collateral] collateral to use
* @param {number} [numOfferInputs] number of inputs to use
* @param {number} [numContracts] number of DLCs in the funding transaction
*
* @returns {OrderOffer}
*/
export const buildCustomStrategyOrderOffer = (
announcement: OracleAnnouncement,
contractSize: Value,
normalizedMaxLoss: Value,
normalizedMaxGain: Value,
feePerByte: bigint,
roundingIntervals: RoundingIntervals,
network: BitcoinNetwork,
shiftForFees: DlcParty = 'neither',
fees_: Value = Value.fromSats(0), // NOTE: fees should be divided before doing batch transaction
collateral: Value = Value.fromSats(0),
numOfferInputs = 1,
numContracts = 1,
skipValidation = false,
): OrderOffer => {
if (contractSize.eq(Value.zero())) {
throw Error('contractSize must be greater than 0');
}
if (collateral.eq(Value.zero())) {
collateral = contractSize.clone();
}
const eventDescriptor = getDigitDecompositionEventDescriptor(announcement);
const unit = eventDescriptor.unit;
const fees = Value.fromSats(
Number(new Decimal(Number(fees_.sats)).dividedBy(numContracts).toFixed(0)),
);
const finalizer = getFinalizerByCount(
feePerByte,
numOfferInputs,
1,
numContracts,
);
const offerFees = Value.fromSats(finalizer.offerFees);
// Use offerFees + dustThreshold for min max gain, to ensure in the case of
// 0 PnL the acceptor payout is not dust
const minMaxGainForContractSize_ = offerFees.addn(
Value.fromSats(dustThreshold(BigInt(feePerByte))),
);
const minMaxGainForContractSize = Value.fromSats(
roundUpToNearestMultiplier(
minMaxGainForContractSize_.sats,
BigInt(UNIT_MULTIPLIER[unit.toLowerCase()]),
),
);
const maxLossForContractSize = Value.fromSats(
roundUpToNearestMultiplier(
(normalizedMaxLoss.sats * contractSize.sats) / BigInt(1e8),
BigInt(UNIT_MULTIPLIER[unit.toLowerCase()]),
),
);
const tempMaxGainForContractSize = Value.fromSats(
roundUpToNearestMultiplier(
(normalizedMaxGain.sats * contractSize.sats) / BigInt(1e8),
BigInt(UNIT_MULTIPLIER[unit.toLowerCase()]),
),
);
const maxGainForContractSize = Value.zero();
if (tempMaxGainForContractSize.lt(minMaxGainForContractSize)) {
maxGainForContractSize.add(minMaxGainForContractSize);
} else {
maxGainForContractSize.add(tempMaxGainForContractSize);
}
const maxGainAdjusted = Value.fromSats(
roundToNearestMultiplier(
(maxGainForContractSize.sats * BigInt(1e8)) / contractSize.sats,
BigInt(UNIT_MULTIPLIER[unit.toLowerCase()]),
),
);
const feesAdjusted = Value.fromSats(
roundToNearestMultiplier(
(fees.sats * BigInt(1e8)) / contractSize.sats,
BigInt(UNIT_MULTIPLIER[unit.toLowerCase()]),
),
);
const minPayout = collateral.clone();
if (minPayout.lt(maxLossForContractSize.addn(maxGainForContractSize))) {
throw new Error(
'Subtraction would result in a negative value for minPayout.',
);
}
minPayout.sub(maxLossForContractSize);
minPayout.sub(maxGainForContractSize);
const maxPayout = collateral.clone();
const startOutcomeValue = Value.fromBitcoin(1);
startOutcomeValue.sub(normalizedMaxLoss);
const endOutcomeValue = Value.fromBitcoin(1);
endOutcomeValue.add(maxGainAdjusted);
if (shiftForFees === 'offeror') {
startOutcomeValue.add(feesAdjusted);
endOutcomeValue.add(feesAdjusted);
} else if (shiftForFees === 'acceptor') {
startOutcomeValue.sub(feesAdjusted);
endOutcomeValue.sub(feesAdjusted);
}
const startOutcome =
startOutcomeValue.sats / BigInt(UNIT_MULTIPLIER[unit.toLowerCase()]);
const endOutcome =
endOutcomeValue.sats / BigInt(UNIT_MULTIPLIER[unit.toLowerCase()]);
const offerCollateral = collateral.clone();
if (offerCollateral.lt(maxGainForContractSize)) {
throw new Error(
'Subtraction would result in a negative value for offerCollateral.',
);
}
offerCollateral.sub(maxGainForContractSize);
const orderOffer = buildLinearOrderOffer(
announcement,
offerCollateral,
minPayout,
maxPayout,
startOutcome,
endOutcome,
feePerByte,
roundingIntervals,
network,
shiftForFees,
fees,
);
(orderOffer.positionInfo as OrderPositionInfo).contractSize =
contractSize.sats;
(orderOffer.positionInfo as OrderPositionInfo).instrumentName = ((orderOffer.contractInfo as SingleContractInfo)
.oracleInfo as SingleOracleInfo).announcement.oracleEvent.eventId;
if (!skipValidation) orderOffer.validate();
return orderOffer;
};
interface IInterval {
beginInterval: bigint;
rounding: number | bigint | Value;
}
export const buildRoundingIntervalsFromIntervals = (
contractSize: Value,
intervals: IInterval[],
): RoundingIntervals => {
const roundingIntervals = new RoundingIntervals();
roundingIntervals.intervals = intervals.map((interval) => {
const roundingMod = computeRoundingModulus(interval.rounding, contractSize);
return {
beginInterval: interval.beginInterval,
roundingMod,
};
});
return roundingIntervals;
};