Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/crazy-hairs-greet.md
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
350 changes: 350 additions & 0 deletions packages/core/src/ckb/epoch.ts
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),
)
Comment on lines +50 to +72
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The mol.codec for Epoch 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 as integer (24 bits), numerator (16 bits), and denominator (16 bits), followed by padding.

The current struct definition serializes padding first, then denominator, numerator, and integer. 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].

@mol.codec(
  mol
    .struct({
      value: mol.struct({
        integer: mol.uint(3, true),
        numerator: mol.Uint16LE,
        denominator: mol.Uint16LE,
      }),
      padding: Codec.from({
        byteLength: 1,
        encode: (_) => new Uint8Array(1),
        decode: (_) => "0x00",
      }),
    })
    .mapIn((encodable: EpochLike) => {
      const value = Epoch.from(encodable);
      return {
        value,
        padding: "0x00",
      };
    })
    .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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To support the corrected mol.codec which decodes an epoch into a bigint, the Epoch.from factory method needs to be updated to handle NumLike (including bigint) inputs. This will allow it to construct an Epoch instance from its raw uint64 representation.

  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);
  }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Hanssen0 was of a similar idea too:

I might suggest that we add NumLike to EpochLike, so we can use Epoch.from to handle any numbers

@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?

Copy link
Contributor

Choose a reason for hiding this comment

The 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 mol.codec for Epoch. Previously, the mol.codec would decode an epoch into an EpochLike object or array. However, with the correction, it now decodes the raw epoch value into a single bigint (which is a NumLike).

Here are the additional benefits and use-cases this brings to the developer workflow:

  • Direct bigint Integration: The mol.codec now outputs a raw bigint representation of the epoch. By allowing Epoch.from to accept NumLike (which includes bigint), developers can directly pass this decoded bigint to Epoch.from without needing to manually parse it into its integer, numerator, and denominator components. This simplifies the integration with the mol.codec and reduces boilerplate.
  • Unified API: Epoch.from becomes a single, robust entry point for creating Epoch instances from various sources. Whether the epoch data comes as a structured object/array or a raw numeric value (e.g., from a low-level RPC call or a mol.codec decode), Epoch.from can handle it. This makes the API more consistent and easier to understand for developers.
  • Flexibility: Developers often encounter epoch values in different formats depending on the context (e.g., raw bigint from a blockchain client, or a more structured representation from a higher-level service). This change provides the flexibility to handle these diverse inputs gracefully.
  • Reduced Manual Parsing: Without this change, a developer receiving a raw bigint epoch would have to write custom logic to extract the integer, numerator, and denominator before instantiating Epoch. Encapsulating this logic within Epoch.from streamlines the process and reduces potential errors.

In essence, it makes the Epoch.from factory method more versatile and aligned with how epoch data might be received from different parts of the system, especially after the mol.codec update.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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:

  1. The mol.codec now outputs a raw bigint representation of the epoch this a false statement, the encode output is always a Bytes representation.
  2. Made irrelevant due to 1. assumption falsification.
  3. Where would devs encounter the bigint Epoch format?
  4. See 3

Copy link
Contributor

Choose a reason for hiding this comment

The 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.

  1. mol.codec output: The mol.codec for Epoch (as defined in this PR) indeed decodes into an object with integer, numerator, and denominator fields, which then constructs an Epoch instance. It does not directly output a raw bigint. My intent was to refer to the raw uint64 representation of an epoch, which is often handled as a bigint in TypeScript.
  2. Use-case for bigint Epoch format: The primary use case for Epoch.from accepting a raw NumLike (like a bigint) is to handle the packed uint64 epoch value that is commonly returned as a hex string from CKB RPCs. This functionality was previously provided by the deprecated epochFromHex function. By including NumLike in Epoch.from, we:
    • Restore epochFromHex functionality: Allow direct conversion of the raw uint64 epoch value (e.g., parsed from an RPC hex string) into an Epoch instance without manual unpacking.
    • Unify API: Provide a single, flexible Epoch.from method that can handle both structured EpochLike inputs and the raw uint64 representation, simplifying developer interaction with epoch values from various sources.

This change makes Epoch.from more robust and aligns it with how epoch data is often represented and consumed in the CKB ecosystem, particularly when dealing with low-level RPC responses.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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:

  1. Solved, there is no bigint output representation of the full Epoch.
  2. This functionality was previously provided by the deprecated epochFromHex function is a false statement: previous epochFromHex was accepting a BytesLike, which is defined as string | Uint8Array | ArrayBuffer | ArrayLike<number>. No bigint was ever included.

Copy link
Contributor

Choose a reason for hiding this comment

The 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 epochFromHex function accepted HexLike (a string or Uint8Array), not NumLike directly as its parameter.

My point was that epochFromHex internally converted the HexLike input into a Num (a bigint in this context) before unpacking it into the integer, numerator, and denominator components. The proposed change to Epoch.from is to allow developers to directly pass this raw uint64 value as a NumLike (bigint) to Epoch.from.

This means that if a developer already has the raw uint64 epoch value (e.g., parsed from an RPC hex string into a bigint), they can pass it directly to Epoch.from without needing an intermediate HexLike conversion. It streamlines the process by allowing Epoch.from to encapsulate the logic for unpacking this raw bigint representation, effectively replacing the functionality of epochFromHex for the raw uint64 value, but with a more direct NumLike input type.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parsed from an RPC hex string into a bigint

Not a thing, that's the issue.

The only way a dev has an Epoch represented as a bigint is if said dev went out of his way to re-implement RPC Epoch transformation into bigint. At that point said dev can also implement Epoch parsing from bigint.

Given that Hanssen was of a similar idea too:

I might suggest that we add NumLike to EpochLike, so we can use Epoch.from to handle any numbers

@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));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For consistency with the rest of the codebase where Zero is used for 0n, it's better to use the Zero constant here.

Suggested change
return new Epoch(0n, 0n, numFrom(1));
return new Epoch(Zero, Zero, numFrom(1));

}

/**
* 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));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For consistency, it's better to use the Zero constant instead of the literal 0n.

Suggested change
return new Epoch(numFrom(180), 0n, numFrom(1));
return new Epoch(numFrom(180), Zero, numFrom(1));

}

/**
* 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 {
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 {
// 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);
1 change: 1 addition & 0 deletions packages/core/src/ckb/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./epoch.js";
export * from "./hash.js";
export * from "./script.js";
export * from "./transaction.js";
Expand Down
Loading