Skip to content

Commit 3dfce54

Browse files
committed
feat(Epoch): transform Epoch into a class and add utility methods
1 parent e2ce0a2 commit 3dfce54

File tree

6 files changed

+351
-71
lines changed

6 files changed

+351
-71
lines changed

.changeset/crazy-hairs-greet.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ckb-ccc/core": patch
3+
---
4+
5+
feat(Epoch): transform `Epoch` into a class and add utility methods

packages/core/src/ckb/epoch.ts

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
import type { ClientBlockHeader } from "../client/clientTypes.js";
2+
import { Zero } from "../fixedPoint/index.js";
3+
import { type Hex, type HexLike } from "../hex/index.js";
4+
import { mol } from "../molecule/index.js";
5+
import { numFrom, NumLike, type Num } from "../num/index.js";
6+
7+
/**
8+
* @deprecated use `Epoch.from` instead
9+
* Convert an Epoch-like value into an Epoch instance.
10+
*
11+
* @param epochLike - An EpochLike value (object or tuple).
12+
* @returns An Epoch instance built from `epochLike`.
13+
*/
14+
export function epochFrom(epochLike: EpochLike): Epoch {
15+
return Epoch.from(epochLike);
16+
}
17+
18+
/**
19+
* @deprecated use `Epoch.decode` instead
20+
* Decode an epoch from a hex-like representation.
21+
*
22+
* @param hex - A hex-like value representing an encoded epoch.
23+
* @returns An Epoch instance decoded from `hex`.
24+
*/
25+
export function epochFromHex(hex: HexLike): Epoch {
26+
return Epoch.decode(hex);
27+
}
28+
29+
/**
30+
* @deprecated use `Epoch.from(epochLike).toHex` instead
31+
* Convert an Epoch-like value to its hex representation.
32+
*
33+
* @param epochLike - An EpochLike value (object, tuple, or Epoch).
34+
* @returns Hex string representing the epoch.
35+
*/
36+
export function epochToHex(epochLike: EpochLike): Hex {
37+
return Epoch.from(epochLike).toHex();
38+
}
39+
40+
export type EpochLike =
41+
| {
42+
number: NumLike;
43+
index: NumLike;
44+
length: NumLike;
45+
}
46+
| [NumLike, NumLike, NumLike];
47+
48+
@mol.codec(
49+
mol
50+
.struct({
51+
length: mol.uint(2, true),
52+
index: mol.uint(2, true),
53+
number: mol.uint(3, true),
54+
})
55+
.mapIn((encodable: EpochLike) => Epoch.from(encodable)),
56+
)
57+
/**
58+
* Epoch
59+
*
60+
* Represents a timestamp-like epoch as a mixed whole number and fractional part:
61+
* - number: whole units
62+
* - index: numerator of the fractional part
63+
* - length: denominator of the fractional part (must be > 0)
64+
*
65+
* The fractional portion is index/length. Instances normalize fractions where
66+
* appropriate (e.g., reduce by GCD, carry whole units).
67+
*/
68+
export class Epoch extends mol.Entity.Base<EpochLike, Epoch>() {
69+
/**
70+
* @deprecated use `number` instead
71+
* Backwards-compatible array-style index 0 referencing the whole number.
72+
*/
73+
public readonly [0]: Num;
74+
/**
75+
* @deprecated use `index` instead
76+
* Backwards-compatible array-style index 1 referencing the fractional numerator.
77+
*/
78+
public readonly [1]: Num;
79+
/**
80+
* @deprecated use `length` instead
81+
* Backwards-compatible array-style index 2 referencing the fractional denominator.
82+
*/
83+
public readonly [2]: Num;
84+
85+
/**
86+
* Construct a new Epoch.
87+
*
88+
* The constructor enforces a positive `length` (denominator). If `length`
89+
* is non-positive an Error is thrown.
90+
*
91+
* @param number - Whole number portion of the epoch.
92+
* @param index - Fractional numerator.
93+
* @param length - Fractional denominator (must be > 0).
94+
*/
95+
public constructor(
96+
public readonly number: Num,
97+
public readonly index: Num,
98+
public readonly length: Num,
99+
) {
100+
// Ensure the epoch has a positive denominator.
101+
if (length <= Zero) {
102+
throw new Error("Non positive Epoch length");
103+
}
104+
super();
105+
this[0] = number;
106+
this[1] = index;
107+
this[2] = length;
108+
}
109+
110+
/**
111+
* Create an Epoch from an EpochLike value.
112+
*
113+
* Accepts:
114+
* - an Epoch instance (returned as-is)
115+
* - an object { number, index, length } where each field is NumLike
116+
* - a tuple [number, index, length] where each element is NumLike
117+
*
118+
* All returned fields are converted to `Num` using `numFrom`.
119+
*
120+
* @param epochLike - Value to convert into an Epoch.
121+
* @returns A new or existing Epoch instance.
122+
*/
123+
static from(epochLike: EpochLike): Epoch {
124+
if (epochLike instanceof Epoch) {
125+
return epochLike;
126+
}
127+
128+
let number: NumLike, index: NumLike, length: NumLike;
129+
if (epochLike instanceof Array) {
130+
[number, index, length] = epochLike;
131+
} else {
132+
({ number, index, length } = epochLike);
133+
}
134+
135+
return new Epoch(numFrom(number), numFrom(index), numFrom(length));
136+
}
137+
138+
/**
139+
* Return an epoch representing zero (0 + 0/1).
140+
*/
141+
static zero(): Epoch {
142+
return new Epoch(0n, 0n, numFrom(1));
143+
}
144+
145+
/**
146+
* Return an epoch representing one (1 + 0/1).
147+
*/
148+
static one(): Epoch {
149+
return new Epoch(numFrom(1), 0n, numFrom(1));
150+
}
151+
152+
/**
153+
* Return an epoch representing one cycle (180 + 0/1).
154+
*
155+
* This is a NervosDAO convenience constant.
156+
*/
157+
static oneCycle(): Epoch {
158+
return new Epoch(numFrom(180), 0n, numFrom(1));
159+
}
160+
161+
/**
162+
* Compare this epoch to another EpochLike.
163+
*
164+
* Comparison is performed by converting both epochs to a common integer
165+
* representation: (number * length + index) scaled by the other's length.
166+
*
167+
* @param other - EpochLike value to compare against.
168+
* @returns positive if this > other, 0 if equal, negative if this < other.
169+
*/
170+
compare(other: EpochLike): number {
171+
if (this === other) {
172+
return 0;
173+
}
174+
175+
const other_ = Epoch.from(other);
176+
const a = (this.number * this.length + this.index) * other_.length;
177+
const b = (other_.number * other_.length + other_.index) * this.length;
178+
179+
return Number(a - b);
180+
}
181+
182+
/**
183+
* Check equality with another EpochLike.
184+
*
185+
* @param other - EpochLike to test equality against.
186+
* @returns true if both epochs represent the same value.
187+
*/
188+
eq(other: EpochLike): boolean {
189+
return this.compare(other) === 0;
190+
}
191+
192+
/**
193+
* Return a normalized epoch:
194+
* - Ensures index is non-negative by borrowing from `number` if needed.
195+
* - Reduces the fraction (index/length) by their GCD.
196+
* - Carries any whole units from the fraction into `number`.
197+
*
198+
* @returns A new, normalized Epoch instance.
199+
*/
200+
normalized(): Epoch {
201+
let { number, index, length } = this;
202+
203+
// Normalize negative index values by borrowing from the whole number.
204+
if (index < Zero) {
205+
// Calculate how many whole units to borrow.
206+
const n = (-index + length - 1n) / length;
207+
number -= n;
208+
index += length * n;
209+
}
210+
211+
// Reduce the fraction (index / length) to its simplest form using the greatest common divisor.
212+
const g = gcd(index, length);
213+
index /= g;
214+
length /= g;
215+
216+
// Add any whole number overflow from the fraction.
217+
number += index / length;
218+
219+
// Calculate the leftover index after accounting for the whole number part from the fraction.
220+
index %= length;
221+
222+
return new Epoch(number, index, length);
223+
}
224+
225+
/**
226+
* Add another epoch to this one.
227+
*
228+
* If denominators differ, the method aligns to a common denominator before
229+
* adding the fractional numerators, then returns a normalized Epoch.
230+
*
231+
* @param other - EpochLike to add.
232+
* @returns New Epoch equal to this + other.
233+
*/
234+
add(other: EpochLike): Epoch {
235+
const other_ = Epoch.from(other);
236+
237+
// Sum the whole number parts.
238+
const number = this.number + other_.number;
239+
let index: Num;
240+
let length: Num;
241+
242+
// If the epochs have different denominators (lengths), align them to a common denominator.
243+
if (this.length !== other_.length) {
244+
index = other_.index * this.length + this.index * other_.length;
245+
length = this.length * other_.length;
246+
} else {
247+
// If denominators are equal, simply add the indices.
248+
index = this.index + other_.index;
249+
length = this.length;
250+
}
251+
252+
return new Epoch(number, index, length).normalized();
253+
}
254+
255+
/**
256+
* Subtract an epoch from this epoch.
257+
*
258+
* @param other - EpochLike to subtract.
259+
* @returns New Epoch equal to this - other.
260+
*/
261+
sub(other: EpochLike): Epoch {
262+
const { number, index, length } = Epoch.from(other);
263+
return this.add(new Epoch(-number, -index, length));
264+
}
265+
266+
/**
267+
* Convert this epoch to an estimated Unix timestamp in milliseconds using as reference the block header.
268+
*
269+
* @param reference - ClientBlockHeader providing a reference epoch and timestamp.
270+
* @returns Unix timestamp in milliseconds as bigint.
271+
*/
272+
toUnix(reference: ClientBlockHeader): bigint {
273+
// Calculate the difference between the provided epoch and the reference epoch.
274+
const { number, index, length } = this.sub(reference.epoch);
275+
276+
return (
277+
reference.timestamp +
278+
epochInMilliseconds * number +
279+
(epochInMilliseconds * index) / length
280+
);
281+
}
282+
}
283+
284+
/**
285+
* A constant representing the epoch duration in milliseconds.
286+
*
287+
* Calculated as 4 hours in milliseconds:
288+
* 4 hours * 60 minutes per hour * 60 seconds per minute * 1000 milliseconds per second.
289+
*/
290+
const epochInMilliseconds = numFrom(14400000); // (Number.isSafeInteger(14400000) === true)
291+
292+
/**
293+
* Calculate the greatest common divisor (GCD) of two Num values using the Euclidean algorithm.
294+
*
295+
* @param a - First operand.
296+
* @param b - Second operand.
297+
* @returns GCD(a, b) as a Num.
298+
*/
299+
function gcd(a: Num, b: Num): Num {
300+
while (b !== Zero) {
301+
[a, b] = [b, a % b];
302+
}
303+
return a;
304+
}

packages/core/src/ckb/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from "./epoch.js";
12
export * from "./hash.js";
23
export * from "./script.js";
34
export * from "./transaction.js";

0 commit comments

Comments
 (0)