Skip to content

Commit c0b65aa

Browse files
committed
feat(apps/sampler): iCKB sampling tool, config, README, and initial data
1 parent e8a0ae1 commit c0b65aa

File tree

9 files changed

+367
-12
lines changed

9 files changed

+367
-12
lines changed

apps/sampler/.npmignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
**/*{_,.}{test,spec}.*

apps/sampler/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# iCKB Sampler
2+
3+
An utility to help sampling iCKB rate across time.
4+
5+
## Run the sampler on mainnet
6+
7+
1. Download this repo in a folder of your choice:
8+
9+
```bash
10+
git clone https://github.com/ickb/stack.git
11+
```
12+
13+
2. Enter into the repo folder:
14+
15+
```bash
16+
cd stack/apps/sampler
17+
```
18+
19+
3. Install dependencies:
20+
21+
```bash
22+
pnpm install
23+
```
24+
25+
4. Build project:
26+
27+
```bash
28+
pnpm build
29+
```
30+
31+
5. Start the sampler utility:
32+
33+
```bash
34+
pnpm start
35+
```
36+
37+
## Licensing
38+
39+
This source code, crafted with care by [Phroi](https://phroi.com/), is freely available on [GitHub](https://github.com/ickb/stack) and it is released under the [MIT License](../../LICENSE).

apps/sampler/package.json

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
{
2+
"name": "@ickb/sampler",
3+
"version": "1001.0.0",
4+
"description": "iCKB sampler built on top of CCC",
5+
"keywords": [
6+
"ickb",
7+
"ccc",
8+
"ckb",
9+
"blockchain"
10+
],
11+
"author": "phroi",
12+
"license": "MIT",
13+
"homepage": "https://ickb.org",
14+
"repository": {
15+
"type": "git",
16+
"url": "https://github.com/ickb/stack"
17+
},
18+
"bugs": {
19+
"url": "https://github.com/ickb/stack/issues"
20+
},
21+
"sideEffects": false,
22+
"type": "module",
23+
"main": "dist/index.js",
24+
"types": "dist/index.d.ts",
25+
"exports": {
26+
".": {
27+
"import": "./dist/index.js",
28+
"types": "./dist/index.d.ts"
29+
}
30+
},
31+
"scripts": {
32+
"test": "vitest",
33+
"test:ci": "vitest run",
34+
"build": "tsc",
35+
"lint": "eslint ./src",
36+
"clean": "rm -fr dist",
37+
"clean:deep": "rm -fr dist node_modules pnpm-lock.yaml",
38+
"start": "node dist/index.js | tee rate.csv"
39+
},
40+
"files": [
41+
"dist",
42+
"src"
43+
],
44+
"publishConfig": {
45+
"access": "public",
46+
"provenance": true
47+
},
48+
"devDependencies": {
49+
"@types/node": "^24.7.0"
50+
},
51+
"dependencies": {
52+
"@ckb-ccc/core": "catalog:",
53+
"@ickb/core": "workspace:*",
54+
"@ickb/utils": "workspace:*"
55+
}
56+
}

apps/sampler/rate.csv

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
BlockNumber, Date, Value, Note
2+
0, 2019-11-15T21:09:50.812Z, 1.00082, Genesis
3+
413943, 2020-01-01T00:00:36.936Z, 1.00553737,
4+
1311192, 2020-04-01T12:00:02.732Z, 1.01529582,
5+
2225181, 2020-07-02T00:00:01.791Z, 1.02475181,
6+
2887376, 2020-10-01T12:00:03.001Z, 1.03388539,
7+
3583555, 2021-01-01T00:00:01.008Z, 1.04279524,
8+
4048565, 2021-04-02T06:00:46.073Z, 1.05146502,
9+
4697782, 2021-07-02T12:00:00.086Z, 1.05990675,
10+
5503834, 2021-10-01T18:00:03.408Z, 1.06816083,
11+
6194506, 2022-01-01T00:00:01.554Z, 1.07621462,
12+
6822996, 2022-04-02T06:00:46.874Z, 1.08407983,
13+
7548564, 2022-07-02T12:00:01.029Z, 1.09176325,
14+
8188955, 2022-10-01T18:00:08.715Z, 1.09928404,
15+
8877418, 2023-01-01T00:00:00.729Z, 1.10664516,
16+
9562759, 2023-04-02T06:00:11.059Z, 1.11387639,
17+
10360745, 2023-07-02T12:00:27.581Z, 1.12095011,
18+
11084807, 2023-10-01T18:00:58.702Z, 1.12788772,
19+
11850353, 2024-01-01T00:00:04.666Z, 1.13469412,
20+
12600876, 2024-04-01T12:00:04.633Z, 1.1414592,
21+
13377494, 2024-07-02T00:00:18.459Z, 1.14815852,
22+
14010067, 2024-09-12T15:13:19.574Z, 1.15343076, iCKB Launch
23+
14160825, 2024-10-01T12:00:24.531Z, 1.15479538,
24+
15001370, 2025-01-01T00:00:12.070Z, 1.1613766,
25+
15799566, 2025-04-02T06:00:09.602Z, 1.16787482,
26+
16605969, 2025-07-02T12:00:00.609Z, 1.17431822,
27+
17426528, 2025-10-01T18:00:20.609Z, 1.18071088,

apps/sampler/src/index.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/**
2+
* @packageDocumentation
3+
*
4+
* Entry-point script that samples block headers from a CKB mainnet public client
5+
* and prints a CSV report (BlockNumber, Date, Value, Note).
6+
*
7+
* Summary of behavior:
8+
* - Constructs a `ccc.ClientPublicMainnet` client and queries the genesis and tip headers.
9+
* - Builds a set of Date samples between genesis and tip (including a small set of
10+
* named dates such as "Genesis", "iCKB Launch", and the "Tip").
11+
* - For each sample date, performs a binary search over block numbers to find
12+
* the first block whose timestamp is greater than or equal to the sample date.
13+
* - Logs CSV lines with block number, ISO timestamp, converted value, and an optional note.
14+
*
15+
* Remarks:
16+
* - The sampling functions accept timestamps as bigint millisecond values.
17+
* - This file runs in Node.js (uses top-level await) and exits on completion or error.
18+
* - Failures in fetching blocks will throw.
19+
*
20+
* Example output (CSV):
21+
* BlockNumber, Date, Value, Note
22+
* 0, 2019-11-15T21:09:50.812Z, 1.00082, Genesis
23+
*
24+
* @public
25+
*/
26+
27+
import { ccc } from "@ckb-ccc/core";
28+
import { convert } from "@ickb/core";
29+
import { asyncBinarySearch } from "@ickb/utils";
30+
31+
/**
32+
* Main program that orchestrates sampling and logging.
33+
*
34+
* - Constructs a public mainnet client.
35+
* - Fetches genesis and tip headers (throws if missing).
36+
* - Computes an upper bound `n` for the block-number binary search using the
37+
* bit-length of tip.number (a simple power-of-two bound).
38+
* - Generates date samples (per-year, `n` samples per year) and inserts a
39+
* named "iCKB Launch" sample.
40+
* - For each date sample, finds the earliest block whose timestamp >= sample
41+
* date via `asyncBinarySearch` and logs a CSV row for that header.
42+
*
43+
* Notes on error handling:
44+
* - Missing blocks will cause this function to throw.
45+
*
46+
* @returns Promise<void> that resolves when sampling and logging complete.
47+
*
48+
* @public
49+
*/
50+
export async function main(): Promise<void> {
51+
// Create a public mainnet client (network I/O happens on method calls).
52+
const client = new ccc.ClientPublicMainnet();
53+
54+
// Fetch genesis header (block 0). If absent, abort early.
55+
const genesis = await client.getHeaderByNumber(0);
56+
if (!genesis) {
57+
throw new Error("Genesis block not found");
58+
}
59+
60+
// Fetch tip header to bound our searches.
61+
const tip = await client.getTipHeader();
62+
63+
// Compute an upper bound `n` for the binary search using the bit-length
64+
// of the tip number. This yields a power-of-two >= tip.number.
65+
const n = 1 << tip.number.toString(2).length;
66+
67+
// Generate date samples between genesis and tip (timestamps are bigints in ms).
68+
// The samples(...) helper returns Date instances; attach optional notes here.
69+
const dates = samples(genesis.timestamp, tip.timestamp, 4).map(
70+
(d) => [d, ""] as [Date, string],
71+
);
72+
// Insert a named event sample (kept as an example of adding special dates).
73+
dates.push([new Date("2024-09-12T15:13:19.574Z"), "iCKB Launch"]);
74+
// Ensure chronological order across all samples (safety).
75+
dates.sort((a, b) => a[0].getTime() - b[0].getTime());
76+
77+
// Emit CSV header and the genesis row.
78+
console.log(["BlockNumber", "Date", "Value", "Note"].join(", "));
79+
logRow(genesis, "Genesis");
80+
81+
// For each sample date, find the earliest block whose timestamp is >= date.
82+
for (const [date, note] of dates) {
83+
// asyncBinarySearch expects a predicate that returns true when the index i
84+
// is at or past the desired condition. We provide a predicate that fetches
85+
// the header and compares timestamps.
86+
const blockNumber = await asyncBinarySearch(
87+
n,
88+
async (i: number): Promise<boolean> => {
89+
const header = await client.getHeaderByNumber(i);
90+
if (!header) {
91+
// If there's no header at i, signal "true" so the search moves left.
92+
return true;
93+
}
94+
// header.timestamp is numeric-like; convert to Number and compare to Date.
95+
return date <= new Date(Number(header.timestamp));
96+
},
97+
);
98+
99+
// Fetch header for the found block number and log it.
100+
const header = await client.getHeaderByNumber(blockNumber);
101+
if (!header) {
102+
throw Error("Header not found");
103+
}
104+
105+
logRow(header, note);
106+
}
107+
}
108+
109+
/**
110+
* Log a CSV row for a header.
111+
*
112+
* Behavior:
113+
* - Converts the header value via `convert(false, ccc.One, header)`,
114+
* formats it with `ccc.fixedPointToString`, and writes a CSV line.
115+
* - This helper is intentionally lightweight and will throw only on programmer errors
116+
* (e.g. unexpected undefined header when called).
117+
*
118+
* @param header - Block header to log.
119+
* @param note - Optional short note to include in the CSV row (e.g. "Genesis"...).
120+
*
121+
* @internal
122+
*/
123+
function logRow(header: ccc.ClientBlockHeader, note: string): void {
124+
// Compute ISO timestamp from header timestamp (milliseconds).
125+
const date = new Date(Number(header.timestamp));
126+
// Convert the header's monetary value to a fixed-point representation.
127+
const val = convert(false, ccc.One, header);
128+
// Emit CSV row: blockNumber, ISO date, formatted value, note.
129+
console.log(
130+
[
131+
String(header.number),
132+
date.toISOString(),
133+
ccc.fixedPointToString(val),
134+
note,
135+
].join(", "),
136+
);
137+
}
138+
139+
/**
140+
* Generate a set of sample Dates between two millisecond-based bigints.
141+
*
142+
* The function:
143+
* - Splits the overall [startMs, endMs] span by UTC calendar years.
144+
* - Emits `n` evenly-spaced samples within each year span [Y0, Y1).
145+
* - Uses integer-rounded millisecond timestamps and returns Date objects.
146+
*
147+
* @param startMs - Inclusive start of the sampling range as a bigint (ms since epoch).
148+
* @param endMs - Inclusive end of the sampling range as a bigint (ms since epoch).
149+
* @param n - Number of evenly-spaced samples to generate per year span. Must be >= 1.
150+
*
151+
* @returns An array of Date objects. Samples are generated year-by-year; calling
152+
* code may sort again for global ordering (the caller does so).
153+
*
154+
* @throws Error if endMs < startMs or if n < 1.
155+
*
156+
* @public
157+
*/
158+
export function samples(startMs: bigint, endMs: bigint, n: number): Date[] {
159+
if (endMs < startMs) throw Error("endMs must be bigger than startMs");
160+
if (n < 1) throw Error("n must be a positive number");
161+
162+
// Convert bigints (ms) to Dates for year extraction.
163+
const start = new Date(Number(startMs));
164+
const end = new Date(Number(endMs));
165+
const startYear = start.getUTCFullYear();
166+
const endYear = end.getUTCFullYear();
167+
const out: Date[] = [];
168+
169+
// For each UTC year in the covered range, generate n samples inside that year.
170+
for (let year = startYear; year <= endYear; year++) {
171+
// Y0 is start of `year` in ms (UTC), Y1 is start of next year.
172+
const Y0 = Date.UTC(year, 0, 1);
173+
const Y1 = Date.UTC(year + 1, 0, 1);
174+
const span = Y1 - Y0;
175+
176+
for (let i = 0; i < n; i++) {
177+
// Evenly space n samples in [Y0, Y1). Round to nearest millisecond.
178+
const t = Y0 + Math.round((span * i) / n);
179+
const sample = new Date(t);
180+
// Only include samples that fall within the inclusive overall range.
181+
if (sample >= start && sample <= end) {
182+
out.push(sample);
183+
}
184+
}
185+
}
186+
187+
return out;
188+
}
189+
190+
await main();
191+
192+
process.exit(0);

apps/sampler/tsconfig.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"rootDir": "src",
5+
"outDir": "dist",
6+
"sourceRoot": "../src"
7+
},
8+
"include": ["src"],
9+
}

apps/sampler/typedoc.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"$schema": "https://typedoc.org/schema.json",
3+
"entryPoints": ["./src/index.ts"],
4+
"extends": ["../../typedoc.base.json"],
5+
}

apps/sampler/vitest.config.mts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { defineConfig } from "vitest/config";
2+
3+
export default defineConfig({
4+
test: {
5+
include: ["src/**/*.test.ts"],
6+
coverage: {
7+
include: ["src/**/*.ts"],
8+
},
9+
},
10+
});

0 commit comments

Comments
 (0)