Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/fair-items-shout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@ckb-ccc/core": minor
---

feat(core): `mol.padding` for padding codec

241 changes: 241 additions & 0 deletions packages/core/src/ckb/epoch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import { describe, expect, it } from "vitest";
import type { ClientBlockHeader } from "../client/index.js";
import { Epoch, epochFrom, epochFromHex, epochToHex } from "./epoch";

describe("Epoch", () => {
it("constructs from tuple and object via from()", () => {
const a = Epoch.from([1n, 2n, 3n]);
expect(a.integer).toBe(1n);
expect(a.numerator).toBe(2n);
expect(a.denominator).toBe(3n);

const b = Epoch.from({ integer: 4n, numerator: 5n, denominator: 6n });
expect(b.integer).toBe(4n);
expect(b.numerator).toBe(5n);
expect(b.denominator).toBe(6n);

const c = new Epoch(7n, 8n, 9n);
expect(Epoch.from(c)).toBe(c);
});

it("packs and unpacks numeric layout (toNum/fromNum) and hex conversion", () => {
const e = new Epoch(0x010203n, 0x0405n, 0x0607n); // use values within bit widths
const packed1 = e.toNum();
// integer in lower 24 bits, numerator next 16, denominator next 16
expect(packed1 & 0xffffffn).toBe(0x010203n);
expect((packed1 >> 24n) & 0xffffn).toBe(0x0405n);
expect((packed1 >> 40n) & 0xffffn).toBe(0x0607n);

const hex = e.toPackedHex();
expect(typeof hex).toBe("string");
expect(hex.startsWith("0x")).toBe(true);

// round-trip
const decoded = Epoch.fromNum(packed1);
expect(decoded.integer).toBe(e.integer);
expect(decoded.numerator).toBe(e.numerator);
expect(decoded.denominator).toBe(e.denominator);
});

it("throws when packing negative components with toNum", () => {
const e = new Epoch(-1n, 0n, 1n);
expect(() => e.toNum()).toThrow();

const e2 = new Epoch(0n, -1n, 1n);
expect(() => e2.toNum()).toThrow();

const e3 = new Epoch(0n, 0n, -1n);
expect(() => e3.toNum()).toThrow();
});

it("throws when packing components too big with toNum", () => {
const e = new Epoch(1n << 24n, 1n, 1n); // integer = 16777215 (24-bit limit + 1)
expect(() => e.toNum()).toThrow();

const e2 = new Epoch(1n, 1n << 16n, 1n); // numerator = 65536 (16-bit limit + 1)
expect(() => e2.toNum()).toThrow();

const e3 = new Epoch(1n, 1n, 1n << 16n); // denominator = 65536 (16-bit limit + 1)
expect(() => e3.toNum()).toThrow();
});

it("normalizeBase fixes zero or negative denominators", () => {
const a = new Epoch(1n, 2n, 0n).normalizeBase();
expect(a.denominator).toBe(1n);
expect(a.numerator).toBe(0n);

const b = new Epoch(1n, 2n, -3n).normalizeBase();
expect(b.denominator).toBe(3n);
expect(b.numerator).toBe(-2n);
});

it("normalizeCanonical reduces fractions and carries/borrows correctly", () => {
// reduction by gcd: 2/4 -> 1/2
const a = new Epoch(1n, 2n, 4n).normalizeCanonical();
expect(a.integer).toBe(1n);
expect(a.numerator).toBe(1n);
expect(a.denominator).toBe(2n);

// carry: 5/2 -> +2 integer, remainder 1/2
const b = new Epoch(0n, 5n, 2n).normalizeCanonical();
expect(b.integer).toBe(2n);
expect(b.numerator).toBe(1n);
expect(b.denominator).toBe(2n);

// borrow when numerator negative
const c = new Epoch(5n, -1n, 2n).normalizeCanonical();
// -1/2 borrowed: integer 4, numerator becomes 1/2
expect(c.integer).toBe(4n);
expect(c.numerator).toBe(1n);
expect(c.denominator).toBe(2n);
});

it("clone returns a deep copy", () => {
const e = new Epoch(1n, 1n, 1n);
const c = e.clone();
expect(c).not.toBe(e);
expect(c.integer).toBe(e.integer);
expect(c.numerator).toBe(e.numerator);
expect(c.denominator).toBe(e.denominator);
});

it("Genesis and OneNervosDaoCycle helpers", () => {
const g = Epoch.Genesis;
expect(g.integer).toBe(0n);
expect(g.numerator).toBe(0n);
expect(g.denominator).toBe(0n);

const o = Epoch.OneNervosDaoCycle;
expect(o.integer).toBe(180n);
expect(o.numerator).toBe(0n);
expect(o.denominator).toBe(1n);
});

it("comparison operations and compare()", () => {
const a = new Epoch(1n, 0n, 1n);
const b = new Epoch(1n, 1n, 2n);
const c = new Epoch(2n, 0n, 1n);

expect(a.compare(b)).toBe(-1);
expect(b.compare(a)).toBe(1);
expect(a.compare(a)).toBe(0);

expect(a.lt(b)).toBe(true);
expect(b.le(b)).toBe(true);
expect(b.eq(new Epoch(1n, 2n, 4n))).toBe(true); // 1 + 1/2 == 1 + 2/4
expect(c.gt(b)).toBe(true);
expect(c.ge(b)).toBe(true);
});

it("add and sub arithmetic with differing denominators", () => {
const a = new Epoch(1n, 1n, 2n); // 1.5
const b = new Epoch(2n, 1n, 3n); // 2 + 1/3
const s = a.add(b);
// compute expected: whole = 3, fractional = 1/2 + 1/3 = 5/6 -> 3 + 5/6
expect(s.integer).toBe(3n);
expect(s.numerator).toBe(5n);
expect(s.denominator).toBe(6n);

const sub = s.sub(new Epoch(1n, 5n, 6n));
expect(sub.integer).toBe(2n);
expect(sub.numerator).toBe(0n);
expect(sub.denominator).toBe(1n);
});

it("toUnix estimates timestamp using a reference header", () => {
const refEpoch = new Epoch(1n, 0n, 1n);
// Provide a minimal shaped header for toUnix without using `any`.
const refHeader = {
epoch: refEpoch,
timestamp: 1000n,
};

// target epoch is 2 + 1/2
const target = new Epoch(2n, 1n, 2n);
const delta = target.sub(refEpoch); // should be 1 + 1/2

// Test default behavior (4 hours)
const expectedDefault =
refHeader.timestamp +
DEFAULT_EPOCH_IN_MILLISECONDS * delta.integer +
(DEFAULT_EPOCH_IN_MILLISECONDS * delta.numerator) / delta.denominator;

expect(target.toUnix(refHeader)).toBe(expectedDefault);

// Test custom epoch duration (10 minutes)
const customEpochMs = 10n * 60n * 1000n;
const expectedCustom =
refHeader.timestamp +
customEpochMs * delta.integer +
(customEpochMs * delta.numerator) / delta.denominator;

expect(target.toUnix(refHeader, customEpochMs)).toBe(expectedCustom);
});

it("toUnix accepts full ClientBlockHeader", () => {
const refEpoch = new Epoch(1n, 0n, 1n);
// Simulate a full ClientBlockHeader object
const fullHeader: ClientBlockHeader = {
epoch: refEpoch,
timestamp: 1000n,
compactTarget: 0n,
hash: "0x1234567890abcdef",
number: 100n,
parentHash: "0xabcdef1234567890",
version: 0n,
nonce: 0n,
dao: { c: 0n, ar: 0n, s: 0n, u: 0n },
extraHash: "0x0000000000000000",
proposalsHash: "0x0000000000000000",
transactionsRoot: "0x0000000000000000",
};

const target = new Epoch(2n, 1n, 2n);
const delta = target.sub(fullHeader.epoch);
const expected =
fullHeader.timestamp +
DEFAULT_EPOCH_IN_MILLISECONDS * delta.integer +
(DEFAULT_EPOCH_IN_MILLISECONDS * delta.numerator) / delta.denominator;

// Full ClientBlockHeader should work due to structural typing
expect(target.toUnix(fullHeader)).toBe(expected);
});

it("toUnix accepts object literal with exact required properties", () => {
const target = new Epoch(2n, 1n, 2n);
const minimalRef = {
epoch: new Epoch(1n, 0n, 1n),
timestamp: 1000n,
};

const delta = target.sub(minimalRef.epoch);
const expected =
minimalRef.timestamp +
DEFAULT_EPOCH_IN_MILLISECONDS * delta.integer +
(DEFAULT_EPOCH_IN_MILLISECONDS * delta.numerator) / delta.denominator;

expect(target.toUnix(minimalRef)).toBe(expected);
});

it("deprecated helpers epochFrom / epochFromHex / epochToHex", () => {
const e = new Epoch(3n, 4n, 5n);
expect(epochFrom(e)).toBe(e);

const hex = epochToHex(e);
expect(typeof hex).toBe("string");
expect(hex.startsWith("0x")).toBe(true);

const decoded = epochFromHex(hex);
expect(decoded.integer).toBe(e.integer);
expect(decoded.numerator).toBe(e.numerator);
expect(decoded.denominator).toBe(e.denominator);
});
});

/**
* DEFAULT_EPOCH_IN_MILLISECONDS
*
* Constant duration of a single standard ideal epoch expressed in milliseconds.
* Defined as 4 hours = 4 * 60 * 60 * 1000 ms.
*/
const DEFAULT_EPOCH_IN_MILLISECONDS = 4n * 60n * 60n * 1000n;
Loading