@ickb/order
Version:
UDT Limit Order utilities built on top of CCC
770 lines (703 loc) • 21.3 kB
text/typescript
import { ccc, mol } from "@ckb-ccc/core";
import {
CheckedInt32LE,
union,
type ExchangeRatio,
gcd,
max,
} from "@ickb/utils";
/**
* Represents a ratio of two scales, CKB and UDT, with validation and comparison methods.
*
* @class Ratio
* @extends {mol.Entity.Base<ExchangeRatio, Ratio>}
* @codec {mol.struct({ ckbScale: mol.Uint64, udtScale: mol.Uint64 })}
*/
.codec(
mol.struct({
ckbScale: mol.Uint64,
udtScale: mol.Uint64,
}),
)
export class Ratio extends mol.Entity.Base<ExchangeRatio, Ratio>() {
/**
* Creates an instance of Ratio.
*
* @param {ccc.Num} ckbScale - The scale of CKB.
* @param {ccc.Num} udtScale - The scale of UDT.
*/
constructor(
public ckbScale: ccc.Num,
public udtScale: ccc.Num,
) {
super();
}
/**
* Creates a Ratio instance from a ExchangeRatio object.
*
* @static
* @param {ExchangeRatio} ratio - The exchange ratio object to convert.
* @returns {Ratio} The created Ratio instance.
*/
static override from(ratio: ExchangeRatio): Ratio {
if (ratio instanceof Ratio) {
return ratio;
}
const { ckbScale, udtScale } = ratio;
return new Ratio(ckbScale, udtScale);
}
/**
* Validates the Ratio instance.
*
* @throws {Error} If the Ratio is not empty and not populated.
*/
validate(): void {
if (!this.isEmpty() && !this.isPopulated()) {
throw Error("Ratio invalid: not empty, not populated");
}
}
/**
* Checks if the Ratio instance is valid.
*
* @returns {boolean} True if valid, otherwise false.
*/
isValid(): boolean {
try {
this.validate();
return true;
} catch {
return false;
}
}
/**
* Checks if the Ratio instance is empty.
*
* @returns {boolean} True if both scales are zero, otherwise false.
*/
isEmpty(): boolean {
return this.ckbScale === 0n && this.udtScale === 0n;
}
/**
* Checks if the Ratio instance is populated.
*
* @returns {boolean} True if both scales are greater than zero, otherwise false.
*/
isPopulated(): boolean {
return this.ckbScale > 0n && this.udtScale > 0n;
}
/**
* Creates an empty Ratio instance.
*
* @static
* @returns {Ratio} An empty Ratio instance.
*/
static empty(): Ratio {
return new Ratio(0n, 0n);
}
/**
* Compares this Ratio instance with another Ratio instance,
* compare directly on ckbScale and inversely to udtScale.
*
* @param {Ratio} other - The other Ratio instance to compare against.
* @returns {number} A negative number if this is less than other,
* a positive number if this is greater than other,
* and zero if they are equal.
*/
compare(other: Ratio): number {
if (this.udtScale == other.udtScale) {
return Number(this.ckbScale - other.ckbScale);
}
if (this.ckbScale == other.ckbScale) {
return Number(other.udtScale - this.udtScale);
}
// Idea: o0.Ckb2Udt - o1.Ckb2Udt
// ~ o0.ckbScale / o0.udtScale - o1.ckbScale / o1.udtScale
// order equivalent to:
// ~ o0.ckbScale * o1.udtScale - o1.ckbScale * o0.udtScale
return Number(
this.ckbScale * other.udtScale - other.ckbScale * this.udtScale,
);
}
/**
* Applies a fee to the current conversion ratio.
*
* This method adjusts the ratio by applying a fee relative to a base value.
* It modifies the scaling factors accordingly and reduces them by their greatest common divisor (GCD).
* If the resulting scaling factors exceed 64 bits, they are shifted to prevent potential overflow.
*
* @param isCkb2Udt - Indicates the conversion direction.
* - If true, the conversion is from CKB to UDT.
* - Otherwise, for UDT to CKB conversion, the scaling factors are swapped.
* @param fee - The fee to apply during conversion as a `ccc.Num`.
* Must be less than the provided feeBase.
* @param feeBase - The base reference for the fee calculation as a `ccc.Num`.
* Used to adjust the scaling factors and prevent oversized values.
* @returns A new Ratio instance with the adjusted scaling factors after applying the fee.
*
* @throws Error if fee is greater than or equal to feeBase.
*/
applyFee(isCkb2Udt: boolean, fee: ccc.Num, feeBase: ccc.Num): Ratio {
if (fee >= feeBase) {
throw Error("Fee too big respectfully to feeBase");
}
if (fee === 0n) {
return this;
}
// Extract scaling factors from the current Ratio.
let { ckbScale: aScale, udtScale: bScale } = this;
// For UDT to CKB conversion, swap the scaling factors.
if (!isCkb2Udt) {
[aScale, bScale] = [bScale, aScale];
}
// Adjust scales by applying the fee.
aScale *= feeBase - fee;
bScale *= feeBase;
// Reduce the ratio by dividing by the greatest common divisor.
const g = gcd(aScale, bScale);
aScale /= g;
bScale /= g;
// Prevent potential overflow by ensuring the bit length stays within 64 bits.
const maxBitLen = max(aScale.toString(2).length, bScale.toString(2).length);
if (maxBitLen > 64) {
const shift = BigInt(maxBitLen - 64);
aScale >>= shift;
bScale >>= shift;
}
// Rebuild and return the adjusted ratio based on the conversion direction.
return Ratio.from({
ckbScale: isCkb2Udt ? aScale : bScale,
udtScale: isCkb2Udt ? bScale : aScale,
});
}
/**
* Converts an amount between CKB and UDT based on the specified conversion direction and scaling factors.
*
* @param isCkb2Udt - If true, converts from CKB to UDT; if false, converts from UDT to CKB.
* @param amount - The amount to convert, represented as a `ccc.FixedPoint`.
* @param mustCeil - When true, applies a ceiling adjustment during conversion for rounding up;
* if false, applies a floor adjustment for rounding down.
* @returns The converted amount as a `ccc.FixedPoint` in the target unit.
*
* @throws Error if the ExchangeRatio instance is not properly populated.
*
* @remarks
* The conversion is achieved using the internal scaling factors:
* - `ckbScale` is used when converting from CKB.
* - `udtScale` is used when converting from UDT.
*
* If the conversion direction is from UDT to CKB, the scales are swapped.
* The adjustment is determined by the `mustCeil` flag:
* - If `mustCeil` is true, an adjustment of `(udtScale - 1n)` is applied to round up.
* - Otherwise, no adjustment (i.e., `0n`) is applied for rounding down.
*/
convert(
isCkb2Udt: boolean,
amount: ccc.FixedPoint,
mustCeil: boolean,
): ccc.FixedPoint {
if (!this.isPopulated()) {
throw Error("Invalid midpoint ExchangeRatio");
}
if (amount === 0n) {
return 0n;
}
let { ckbScale: aScale, udtScale: bScale } = this;
if (!isCkb2Udt) {
// For UDT to CKB conversion, swap the scaling factors.
[aScale, bScale] = [bScale, aScale];
}
// Apply ceiling adjustment when necessary; otherwise, use floor adjustment.
return (amount * aScale + (mustCeil ? bScale - 1n : 0n)) / bScale;
}
}
/**
* Represents a structure containing conversion ratios and a minimum match log value.
*
* @interface InfoLike
*/
export interface InfoLike {
/**
* The ratio for converting CKB to UDT.
*
* @type {ExchangeRatio}
*/
ckbToUdt: ExchangeRatio;
/**
* The ratio for converting UDT to CKB.
*
* @type {ExchangeRatio}
*/
udtToCkb: ExchangeRatio;
/**
* The minimum match log value for CKB.
*
* @type {ccc.FixedPointLike}
*/
ckbMinMatchLog: ccc.FixedPointLike;
}
/**
* Represents conversion information between CKB and UDT, including validation and comparison methods.
*
* @class Info
* @extends {mol.Entity.Base<InfoLike, Info>}
* @codec {mol.struct({ ckbToUdt: Ratio, udtToCkb: Ratio, ckbMinMatchLog: mol.Uint8 })}
*/
.codec(
mol.struct({
ckbToUdt: Ratio,
udtToCkb: Ratio,
ckbMinMatchLog: mol.Uint8,
}),
)
export class Info extends mol.Entity.Base<InfoLike, Info>() {
/**
* Creates an instance of Info.
*
* @param {Ratio} ckbToUdt - The ratio for converting CKB to UDT.
* @param {Ratio} udtToCkb - The ratio for converting UDT to CKB.
* @param {number} ckbMinMatchLog - The minimum match log value for CKB.
*/
constructor(
public ckbToUdt: Ratio,
public udtToCkb: Ratio,
public ckbMinMatchLog: number,
) {
super();
}
/**
* Creates an Info instance from an InfoLike object.
*
* @static
* @param {InfoLike} info - The info-like object to convert.
* @returns {Info} The created Info instance.
*/
static override from(info: InfoLike): Info {
if (info instanceof Info) {
return info;
}
const { ckbToUdt, udtToCkb, ckbMinMatchLog } = info;
return new Info(
Ratio.from(ckbToUdt),
Ratio.from(udtToCkb),
Number(ckbMinMatchLog),
);
}
/**
* Creates a new Info instance based on the provided parameters.
*
* @static
* @param {boolean} isCkb2Udt - Indicates if the conversion is from CKB to UDT.
* @param {ExchangeRatio} ratioLike - The ratio to use for conversion.
* @param {number} [ckbMinMatchLog] - The minimum match log value for CKB (Default: 33, about 86 CKB)
* @returns {Info} The created Info instance.
*/
static create(
isCkb2Udt: boolean,
ratioLike: ExchangeRatio,
ckbMinMatchLog = Info.ckbMinMatchLogDefault(),
): Info {
return Info.from({
ckbToUdt: isCkb2Udt ? ratioLike : Ratio.empty(),
udtToCkb: isCkb2Udt ? Ratio.empty() : ratioLike,
ckbMinMatchLog,
});
}
/**
* Validates the Info instance.
*
* @throws {Error} If the Info instance is invalid based on its properties.
*/
validate(): void {
if (this.ckbMinMatchLog < 0 || this.ckbMinMatchLog > 64) {
throw Error("ckbMinMatchLog invalid");
}
if (this.ckbToUdt.isEmpty()) {
if (this.udtToCkb.isPopulated()) {
return;
} else {
throw Error("ckbToUdt is Empty, but udtToCkb is not Populated");
}
}
if (this.udtToCkb.isEmpty()) {
if (this.ckbToUdt.isPopulated()) {
return;
} else {
throw Error("udtToCkb is Empty, but ckbToUdt is not Populated");
}
}
if (!this.ckbToUdt.isPopulated() || !this.udtToCkb.isPopulated()) {
throw Error("One ratio is invalid, so not Empty and not Populated");
}
// Check that if we convert from ckb to udt and then back from udt to ckb, it doesn't lose value.
if (
this.ckbToUdt.ckbScale * this.udtToCkb.udtScale <
this.ckbToUdt.udtScale * this.udtToCkb.ckbScale
) {
throw Error("udtToCkb and ckbToUdt allow order value to be extracted");
}
}
/**
* Checks if the Info instance is valid.
*
* @returns {boolean} True if valid, otherwise false.
*/
isValid(): boolean {
try {
this.validate();
return true;
} catch {
return false;
}
}
/**
* Gets the minimum match value for CKB as a fixed point.
*
* @returns {ccc.FixedPoint} The minimum match value for CKB.
*/
getCkbMinMatch(): ccc.FixedPoint {
return 1n << BigInt(this.ckbMinMatchLog);
}
/**
* Checks if the Info instance represents a CKB to UDT conversion or dual ratio.
*
* @returns {boolean} True if it is a CKB to UDT conversion, otherwise false.
*/
isCkb2Udt(): boolean {
return this.ckbToUdt.isPopulated();
}
/**
* Checks if the Info instance represents a UDT to CKB conversion or dual ratio.
*
* @returns {boolean} True if it is a UDT to CKB conversion, otherwise false.
*/
isUdt2Ckb(): boolean {
return this.udtToCkb.isPopulated();
}
/**
* Checks if the Info instance represents a dual ratio (both conversions).
*
* @returns {boolean} True if both conversions are populated, otherwise false.
*/
isDualRatio(): boolean {
return this.isCkb2Udt() && this.isUdt2Ckb();
}
/**
* Compares the CKB to UDT ratio of this Info instance with another Info instance.
*
* @param {Info} other - The other Info instance to compare against.
* @returns {number} A negative number if this is less than other,
* a positive number if this is greater than other,
* and zero if they are equal.
*/
ckb2UdtCompare(other: Info): number {
return this.ckbToUdt.compare(other.ckbToUdt);
}
/**
* Compares the UDT to CKB ratio of this Info instance with another Info instance.
*
* @param {Info} other - The other Info instance to compare against.
* @returns {number} A negative number if this is less than other,
* a positive number if this is greater than other,
* and zero if they are equal.
*/
udt2CkbCompare(other: Info): number {
return other.udtToCkb.compare(this.udtToCkb);
}
/**
* Provides the default minimum match log value for CKB, which is 86 CKB.
*
* @static
* @returns {number} The default minimum match log value.
*/
static ckbMinMatchLogDefault(): number {
return 33; // ~ 86 CKB
}
}
/**
* Represents a structure containing padding and distance values.
*
* @interface RelativeLike
*/
export interface RelativeLike {
/**
* The padding value, represented as bytes.
*
* @type {ccc.BytesLike}
*/
padding: ccc.BytesLike;
/**
* The distance value, represented as a number.
*
* @type {ccc.NumLike}
*/
distance: ccc.NumLike;
}
/**
* Represents a relative structure with padding and distance, including validation methods.
*
* @class Relative
* @extends {mol.Entity.Base<RelativeLike, Relative>}
* @codec {mol.struct({ padding: mol.Byte32, distance: CheckedInt32LE })}
*/
.codec(
mol.struct({
padding: mol.Byte32,
distance: CheckedInt32LE,
}),
)
export class Relative extends mol.Entity.Base<RelativeLike, Relative>() {
/**
* Creates an instance of Relative.
*
* @param {ccc.Bytes} padding - The padding value.
* @param {ccc.Num} distance - The distance value.
*/
constructor(
public padding: ccc.Bytes,
public distance: ccc.Num,
) {
super();
}
/**
* Creates a Relative instance from a RelativeLike object.
*
* @static
* @param {RelativeLike} relative - The relative-like object to convert.
* @returns {Relative} The created Relative instance.
*/
static override from(relative: RelativeLike): Relative {
if (relative instanceof Relative) {
return relative;
}
const { padding, distance } = relative;
return new Relative(ccc.bytesFrom(padding), ccc.numFrom(distance));
}
/**
* Creates a new Relative instance with default padding and the specified distance.
*
* @static
* @param {ccc.Num} distance - The distance value.
* @returns {Relative} The created Relative instance.
*/
static create(distance: ccc.Num): Relative {
return new Relative(Relative.padding(), distance);
}
/**
* Provides the default padding value as a byte array of length 32.
*
* @static
* @returns {ccc.Bytes} The default padding value.
*/
static padding(): ccc.Bytes {
return new Uint8Array(32);
}
/**
* Validates the Relative instance.
*
* @throws {Error} If the padding is not of length 32 or contains non-zero values.
*/
validate(): void {
if (this.padding.length != 32 || this.padding.some((x) => x !== 0)) {
throw Error("Relative master invalid, non standard padding");
}
}
/**
* Checks if the Relative instance is valid.
*
* @returns {boolean} True if valid, otherwise false.
*/
isValid(): boolean {
try {
this.validate();
return true;
} catch {
return false;
}
}
}
/**
* A union codec that can encode and decode either a Relative or an Absolute OutPoint.
*
* @constant MasterCodec
* @type {mol.UnionCodec<{ relative: Relative; absolute: ccc.OutPoint; }>}
*/
export const MasterCodec = union({
relative: Relative,
absolute: ccc.OutPoint,
});
/**
* Represents a type that can be encoded using the MasterCodec.
*
* @type {MasterLike}
*/
export type MasterLike = mol.EncodableType<typeof MasterCodec>;
/**
* Represents a type that has been decoded using the MasterCodec.
*
* @type {Master}
*/
export type Master = mol.DecodedType<typeof MasterCodec>;
/**
* Converts a MasterLike object to a Master object.
*
* @param {MasterLike} master - The master-like object to convert.
* @returns {Master} The converted Master object.
* @throws {Error} If the type is not "relative" or "absolute".
*/
function masterFrom(master: MasterLike): Master {
const { type, value } = master;
if (type === "relative") {
return { type, value: Relative.from(value) };
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if (type === "absolute") {
return { type, value: ccc.OutPoint.from(value) };
} else {
throw Error(`Invalid type ${String(type)}, not relative, not absolute`);
}
}
/**
* Validates a Master object, ensuring that it conforms to the expected structure
* based on its type (either "relative" or "absolute").
*
* @param {Master} master - The Master object to validate.
* @throws {Error} If the Master object is of an invalid type or if the validation
* checks for the specific type fail.
*/
function masterValidate(master: Master): void {
const { type, value } = master;
if (type === "relative") {
value.validate();
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if (type === "absolute") {
if (!/^0x[0-9a-f]{64}$/i.test(value.txHash) || value.index < 0) {
throw Error("OutPoint invalid");
}
} else {
throw Error(`Invalid type ${String(type)}, not relative, not absolute`);
}
}
/**
* Represents a structure containing UDT amount, master information, and additional info.
*
* @interface OrderDataLike
*/
export interface OrderDataLike {
/**
* The amount of UDT (User Defined Token).
*
* @type {ccc.FixedPointLike}
*/
udtValue: ccc.FixedPointLike;
/**
* The master information, which can be either relative or absolute.
*
* @type {MasterLike}
*/
master: MasterLike;
/**
* Additional information related to the data.
*
* @type {InfoLike}
*/
info: InfoLike;
}
/**
* Represents a data structure that includes UDT amount, master, and info,
* with validation and utility methods.
*
* @class Data
* @extends {mol.Entity.Base<OrderDataLike, OrderData>}
* @codec {mol.struct({ udtValue: mol.Uint128, master: MasterCodec, info: Info })}
*/
.codec(
mol.struct({
udtValue: mol.Uint128,
master: MasterCodec,
info: Info,
}),
)
export class OrderData extends mol.Entity.Base<OrderDataLike, OrderData>() {
/**
* Creates an instance of OrderData.
*
* @param {ccc.FixedPoint} udtValue - The amount of UDT.
* @param {Master} master - The master information.
* @param {Info} info - The additional information.
*/
constructor(
public udtValue: ccc.FixedPoint,
public master: Master,
public info: Info,
) {
super();
}
/**
* Creates a OrderData instance from a OrderDataLike object.
*
* @static
* @param {OrderDataLike} data - The data-like object to convert.
* @returns {OrderData} The created Data instance.
*/
static override from(data: OrderDataLike): OrderData {
if (data instanceof OrderData) {
return data;
}
const { udtValue, master, info } = data;
return new OrderData(
ccc.numFrom(udtValue),
masterFrom(master),
Info.from(info),
);
}
/**
* Validates the Data instance.
*
* @throws {Error} If the UDT amount is negative or if the master or info are invalid.
*/
validate(): void {
if (this.udtValue < 0) {
throw Error("udtValue invalid, negative");
}
masterValidate(this.master);
this.info.validate();
}
/**
* Checks if the Data instance is valid.
*
* @returns {boolean} True if valid, otherwise false.
*/
isValid(): boolean {
try {
this.validate();
return true;
} catch {
return false;
}
}
/**
* Checks if the Data instance represents a mint operation.
*
* @returns {boolean} True if the master type is "relative", otherwise false.
*/
isMint(): boolean {
return this.master.type === "relative";
}
/**
* Gets the master OutPoint based on the current OutPoint.
*
* @param {ccc.OutPoint} current - The current OutPoint to use for calculation.
* @returns {ccc.OutPoint} The calculated master OutPoint.
* @throws {Error} If the master type is invalid.
*/
getMaster(current: ccc.OutPoint): ccc.OutPoint {
const { type, value } = this.master;
if (type === "relative") {
return new ccc.OutPoint(current.txHash, current.index + value.distance);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if (type === "absolute") {
return value;
} else {
throw Error(`Invalid type ${String(type)}, not relative, not absolute`);
}
}
}