-
Notifications
You must be signed in to change notification settings - Fork 31
feat(Epoch): transform Epoch
into a class and add utility methods
#314
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@ckb-ccc/core": minor | ||
--- | ||
|
||
feat(Epoch): transform `Epoch` into a class and add utilities |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,350 @@ | ||
import type { ClientBlockHeader } from "../client/clientTypes.js"; | ||
import { Zero } from "../fixedPoint/index.js"; | ||
import { type Hex, type HexLike } from "../hex/index.js"; | ||
import { Codec } from "../molecule/codec.js"; | ||
import { mol } from "../molecule/index.js"; | ||
import { numFrom, NumLike, type Num } from "../num/index.js"; | ||
import { gcd } from "../utils/index.js"; | ||
|
||
/** | ||
* @deprecated use `Epoch.from` instead | ||
* Convert an Epoch-like value into an Epoch instance. | ||
* | ||
* @param epochLike - An EpochLike value (object or tuple). | ||
* @returns An Epoch instance built from `epochLike`. | ||
*/ | ||
export function epochFrom(epochLike: EpochLike): Epoch { | ||
return Epoch.from(epochLike); | ||
} | ||
|
||
/** | ||
* @deprecated use `Epoch.decode` instead | ||
* Decode an epoch from a hex-like representation. | ||
* | ||
* @param hex - A hex-like value representing an encoded epoch. | ||
* @returns An Epoch instance decoded from `hex`. | ||
*/ | ||
export function epochFromHex(hex: HexLike): Epoch { | ||
return Epoch.decode(hex); | ||
} | ||
|
||
/** | ||
* @deprecated use `Epoch.from(epochLike).toHex` instead | ||
* Convert an Epoch-like value to its hex representation. | ||
* | ||
* @param epochLike - An EpochLike value (object, tuple, or Epoch). | ||
* @returns Hex string representing the epoch. | ||
*/ | ||
export function epochToHex(epochLike: EpochLike): Hex { | ||
return Epoch.from(epochLike).toHex(); | ||
} | ||
|
||
export type EpochLike = | ||
| { | ||
integer: NumLike; | ||
numerator: NumLike; | ||
denominator: NumLike; | ||
} | ||
| [NumLike, NumLike, NumLike]; | ||
|
||
@mol.codec( | ||
mol | ||
.struct({ | ||
padding: Codec.from({ | ||
byteLength: 1, | ||
encode: (_) => new Uint8Array(1), | ||
decode: (_) => "0x00", | ||
}), | ||
value: mol.struct({ | ||
denominator: mol.Uint16LE, | ||
numerator: mol.Uint16LE, | ||
integer: mol.uint(3, true), | ||
}), | ||
}) | ||
.mapIn((encodable: EpochLike) => { | ||
const value = Epoch.from(encodable); | ||
return { | ||
padding: "0x00", | ||
value, | ||
}; | ||
}) | ||
.mapOut((v) => v.value as Epoch), | ||
) | ||
/** | ||
* Epoch | ||
* | ||
* Represents a timestamp-like epoch as a mixed whole integer and fractional part: | ||
* - integer: whole units | ||
* - numerator: numerator of the fractional part | ||
* - denominator: denominator of the fractional part (must be > 0) | ||
* | ||
* The fractional portion is numerator/denominator. Instances normalize fractions where | ||
* appropriate (e.g., reduce by GCD, carry whole units). | ||
*/ | ||
export class Epoch extends mol.Entity.Base<EpochLike, Epoch>() { | ||
/** | ||
* Construct a new Epoch. | ||
* | ||
* The constructor enforces a positive `denominator`. If `denominator` | ||
* is non-positive an Error is thrown. | ||
* | ||
* @param integer - Whole number portion of the epoch. | ||
* @param numerator - Fractional numerator. | ||
* @param denominator - Fractional denominator (must be > 0). | ||
*/ | ||
public constructor( | ||
public readonly integer: Num, | ||
public readonly numerator: Num, | ||
public readonly denominator: Num, | ||
) { | ||
// Ensure the epoch has a positive denominator. | ||
if (denominator <= Zero) { | ||
throw new Error("Non positive Epoch denominator"); | ||
} | ||
super(); | ||
} | ||
|
||
/** | ||
* @deprecated use `integer` instead | ||
* Backwards-compatible array-style index 0 referencing the whole epoch integer. | ||
*/ | ||
get 0(): Num { | ||
return this.integer; | ||
} | ||
|
||
/** | ||
* @deprecated use `numerator` instead | ||
* Backwards-compatible array-style index 1 referencing the epoch fractional numerator. | ||
*/ | ||
get 1(): Num { | ||
return this.numerator; | ||
} | ||
|
||
/** | ||
* @deprecated use `denominator` instead | ||
* Backwards-compatible array-style index 2 referencing the epoch fractional denominator. | ||
*/ | ||
get 2(): Num { | ||
return this.denominator; | ||
} | ||
|
||
/** | ||
* Create an Epoch from an EpochLike value. | ||
* | ||
* Accepts: | ||
* - an Epoch instance (returned as-is) | ||
* - an object { integer, numerator, denominator } where each field is NumLike | ||
* - a tuple [integer, numerator, denominator] where each element is NumLike | ||
* | ||
* All returned fields are converted to `Num` using `numFrom`. | ||
* | ||
* @param epochLike - Value to convert into an Epoch. | ||
* @returns A new or existing Epoch instance. | ||
*/ | ||
static from(epochLike: EpochLike): Epoch { | ||
if (epochLike instanceof Epoch) { | ||
return epochLike; | ||
} | ||
|
||
let integer: NumLike, numerator: NumLike, denominator: NumLike; | ||
if (Array.isArray(epochLike)) { | ||
[integer, numerator, denominator] = epochLike; | ||
} else { | ||
({ integer, numerator, denominator } = epochLike); | ||
} | ||
|
||
return new Epoch( | ||
numFrom(integer), | ||
numFrom(numerator), | ||
numFrom(denominator), | ||
); | ||
} | ||
Comment on lines
+144
to
+161
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To support the corrected static from(epochLike: EpochLike | NumLike): Epoch {
if (epochLike instanceof Epoch) {
return epochLike;
}
if (Array.isArray(epochLike)) {
const [integer, numerator, denominator] = epochLike;
return new Epoch(
numFrom(integer),
numFrom(numerator),
numFrom(denominator),
);
}
if (typeof epochLike === "object" && epochLike !== null) {
const { integer, numerator, denominator } = epochLike as {
integer: NumLike;
numerator: NumLike;
denominator: NumLike;
};
return new Epoch(
numFrom(integer),
numFrom(numerator),
numFrom(denominator),
);
}
// Handle NumLike for raw value
const raw = numFrom(epochLike);
const integer = raw & 0xffffffn;
const numerator = (raw >> 24n) & 0xffffn;
const denominator = (raw >> 40n) & 0xffffn;
return new Epoch(integer, numerator, denominator);
} There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @Hanssen0 was of a similar idea too:
@gemini-code-assist I'd say that it would fit with the original spec description, but which additional benefits would it bring to a dev workflow? Which real additional use-case does it solve? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's a great question, @phroi! The primary driver for this change is to directly support the updated Here are the additional benefits and use-cases this brings to the developer workflow:
In essence, it makes the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @gemini-code-assist please, reply in a concise way. Let's analyze your points one by one:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You are absolutely right, @phroi, my apologies for the imprecision in my previous statement.
This change makes There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @gemini-code-assist please, reply in a concise way. Let's analyze your points one by one:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You are absolutely correct, @phroi. My apologies for the continued imprecision. You are right that the deprecated My point was that This means that if a developer already has the raw There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Not a thing, that's the issue. The only way a dev has an Given that Hanssen was of a similar idea too:
@Hanssen0 would you like to add something? |
||
|
||
/** | ||
* Return an epoch representing zero (0 + 0/1). | ||
*/ | ||
static zero(): Epoch { | ||
return new Epoch(0n, 0n, numFrom(1)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
} | ||
|
||
/** | ||
* Return an epoch representing one cycle (180 + 0/1). | ||
* | ||
* This is a NervosDAO convenience constant. | ||
*/ | ||
static oneNervosDaoCycle(): Epoch { | ||
return new Epoch(numFrom(180), 0n, numFrom(1)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
} | ||
|
||
/** | ||
* Compare this epoch to another EpochLike. | ||
* | ||
* Comparison is performed by converting both epochs to a common integer | ||
* representation: (integer * denominator + numerator) scaled by the other's denominator. | ||
* | ||
* @param other - EpochLike value to compare against. | ||
* @returns 1 if this > other, 0 if equal, -1 if this < other. | ||
*/ | ||
compare(other: EpochLike): 1 | 0 | -1 { | ||
if (this === other) { | ||
return 0; | ||
} | ||
|
||
const o = Epoch.from(other); | ||
const a = | ||
(this.integer * this.denominator + this.numerator) * o.denominator; | ||
const b = (o.integer * o.denominator + o.numerator) * this.denominator; | ||
|
||
return a > b ? 1 : a < b ? -1 : 0; | ||
} | ||
|
||
/** | ||
* Check whether this epoch is less than another EpochLike. | ||
* | ||
* @param other - EpochLike to compare against. | ||
* @returns true if this epoch is strictly less than the other. | ||
*/ | ||
lt(other: EpochLike): boolean { | ||
return this.compare(other) < 0; | ||
} | ||
|
||
/** | ||
* Check whether this epoch is less than or equal to another EpochLike. | ||
* | ||
* @param other - EpochLike to compare against. | ||
* @returns true if this epoch is less than or equal to the other. | ||
*/ | ||
le(other: EpochLike): boolean { | ||
return this.compare(other) <= 0; | ||
} | ||
|
||
/** | ||
* Check whether this epoch is equal to another EpochLike. | ||
* | ||
* @param other - EpochLike to compare against. | ||
* @returns true if both epochs represent the same value. | ||
*/ | ||
eq(other: EpochLike): boolean { | ||
phroi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return this.compare(other) === 0; | ||
} | ||
|
||
/** | ||
* Check whether this epoch is greater than or equal to another EpochLike. | ||
* | ||
* @param other - EpochLike to compare against. | ||
* @returns true if this epoch is greater than or equal to the other. | ||
*/ | ||
ge(other: EpochLike): boolean { | ||
return this.compare(other) >= 0; | ||
} | ||
|
||
/** | ||
* Check whether this epoch is greater than another EpochLike. | ||
* | ||
* @param other - EpochLike to compare against. | ||
* @returns true if this epoch is strictly greater than the other. | ||
*/ | ||
gt(other: EpochLike): boolean { | ||
return this.compare(other) > 0; | ||
} | ||
|
||
/** | ||
* Return a normalized epoch: | ||
* - Ensures numerator is non-negative by borrowing from `integer` if needed. | ||
* - Reduces the fraction (numerator/denominator) by their GCD. | ||
* - Carries any whole units from the fraction into `integer`. | ||
* | ||
* @returns A new, normalized Epoch instance. | ||
*/ | ||
normalized(): Epoch { | ||
let { integer, numerator, denominator } = this; | ||
|
||
// Normalize negative numerator values by borrowing from the whole integer. | ||
if (numerator < Zero) { | ||
// Calculate how many whole units to borrow. | ||
const n = (-numerator + denominator - 1n) / denominator; | ||
integer -= n; | ||
numerator += denominator * n; | ||
} | ||
|
||
// Reduce the fraction (numerator / denominator) to its simplest form using the greatest common divisor. | ||
const g = gcd(numerator, denominator); | ||
numerator /= g; | ||
denominator /= g; | ||
|
||
// Add any whole integer overflow from the fraction. | ||
integer += numerator / denominator; | ||
|
||
// Calculate the leftover numerator after accounting for the whole integer part from the fraction. | ||
numerator %= denominator; | ||
|
||
return new Epoch(integer, numerator, denominator); | ||
} | ||
|
||
/** | ||
* Add another epoch to this one. | ||
* | ||
* If denominators differ, the method aligns to a common denominator before | ||
* adding the fractional numerators, then returns a normalized Epoch. | ||
* | ||
* @param other - EpochLike to add. | ||
* @returns New Epoch equal to this + other. | ||
*/ | ||
add(other: EpochLike): Epoch { | ||
const o = Epoch.from(other); | ||
|
||
// Sum the whole integer parts. | ||
const integer = this.integer + o.integer; | ||
let numerator: Num; | ||
let denominator: Num; | ||
|
||
// If the epochs have different denominators, align them to a common denominator. | ||
if (this.denominator !== o.denominator) { | ||
numerator = | ||
o.numerator * this.denominator + this.numerator * o.denominator; | ||
denominator = this.denominator * o.denominator; | ||
} else { | ||
phroi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
// If denominators are equal, simply add the numerators. | ||
numerator = this.numerator + o.numerator; | ||
denominator = this.denominator; | ||
} | ||
|
||
return new Epoch(integer, numerator, denominator).normalized(); | ||
} | ||
|
||
/** | ||
* Subtract an epoch from this epoch. | ||
* | ||
* @param other - EpochLike to subtract. | ||
* @returns New Epoch equal to this - other. | ||
*/ | ||
sub(other: EpochLike): Epoch { | ||
const { integer, numerator, denominator } = Epoch.from(other); | ||
return this.add(new Epoch(-integer, -numerator, denominator)); | ||
} | ||
|
||
/** | ||
* Convert this epoch to an estimated Unix timestamp in milliseconds using as reference the block header. | ||
* | ||
* @param reference - ClientBlockHeader providing a reference epoch and timestamp. | ||
* @returns Unix timestamp in milliseconds as bigint. | ||
*/ | ||
toUnix(reference: ClientBlockHeader): bigint { | ||
// Calculate the difference between the provided epoch and the reference epoch. | ||
const { integer, numerator, denominator } = this.sub(reference.epoch); | ||
|
||
return ( | ||
reference.timestamp + | ||
EPOCH_IN_MILLISECONDS * integer + | ||
(EPOCH_IN_MILLISECONDS * numerator) / denominator | ||
); | ||
} | ||
} | ||
|
||
/** | ||
* A constant representing the epoch duration in milliseconds. | ||
* | ||
* Calculated as 4 hours in milliseconds: | ||
* 4 hours * 60 minutes per hour * 60 seconds per minute * 1000 milliseconds per second. | ||
*/ | ||
const EPOCH_IN_MILLISECONDS = numFrom(4 * 60 * 60 * 1000); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
mol.codec
forEpoch
has an incorrect field order for serialization, which will lead to an incorrect byte representation of epoch values. The CKB epoch format is a 64-bit little-endian integer where the fields are packed asinteger
(24 bits),numerator
(16 bits), anddenominator
(16 bits), followed by padding.The current struct definition serializes
padding
first, thendenominator
,numerator
, andinteger
. This results in an incorrect memory layout:[padding: 1] [denominator: 2] [numerator: 2] [integer: 3]
.To fix this, the order of fields in the structs should be changed to match the CKB epoch format, which should be
[integer: 3] [numerator: 2] [denominator: 2] [padding: 1]
.