diff --git a/apps/sampler/.npmignore b/apps/sampler/.npmignore new file mode 100644 index 0000000..6396b25 --- /dev/null +++ b/apps/sampler/.npmignore @@ -0,0 +1 @@ +**/*{_,.}{test,spec}.* \ No newline at end of file diff --git a/apps/sampler/README.md b/apps/sampler/README.md new file mode 100644 index 0000000..d38c95b --- /dev/null +++ b/apps/sampler/README.md @@ -0,0 +1,39 @@ +# iCKB Sampler + +An utility to help sampling iCKB rate across time. + +## Run the sampler on mainnet + +1. Download this repo in a folder of your choice: + +```bash +git clone https://github.com/ickb/stack.git +``` + +2. Enter into the repo folder: + +```bash +cd stack/apps/sampler +``` + +3. Install dependencies: + +```bash +pnpm install +``` + +4. Build project: + +```bash +pnpm build +``` + +5. Start the sampler utility: + +```bash +pnpm start +``` + +## Licensing + +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). diff --git a/apps/sampler/package.json b/apps/sampler/package.json new file mode 100644 index 0000000..bdb6dc2 --- /dev/null +++ b/apps/sampler/package.json @@ -0,0 +1,56 @@ +{ + "name": "@ickb/sampler", + "version": "1001.0.0", + "description": "iCKB sampler built on top of CCC", + "keywords": [ + "ickb", + "ccc", + "ckb", + "blockchain" + ], + "author": "phroi", + "license": "MIT", + "homepage": "https://ickb.org", + "repository": { + "type": "git", + "url": "https://github.com/ickb/stack" + }, + "bugs": { + "url": "https://github.com/ickb/stack/issues" + }, + "sideEffects": false, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "test": "vitest", + "test:ci": "vitest run", + "build": "tsc", + "lint": "eslint ./src", + "clean": "rm -fr dist", + "clean:deep": "rm -fr dist node_modules pnpm-lock.yaml", + "start": "node dist/index.js | tee rate.csv" + }, + "files": [ + "dist", + "src" + ], + "publishConfig": { + "access": "public", + "provenance": true + }, + "devDependencies": { + "@types/node": "^24.7.0" + }, + "dependencies": { + "@ckb-ccc/core": "catalog:", + "@ickb/core": "workspace:*", + "@ickb/utils": "workspace:*" + } +} \ No newline at end of file diff --git a/apps/sampler/rate.csv b/apps/sampler/rate.csv new file mode 100644 index 0000000..f5c1643 --- /dev/null +++ b/apps/sampler/rate.csv @@ -0,0 +1,27 @@ +BlockNumber, Date, Value, Note +0, 2019-11-15T21:09:50.812Z, 1.00082, Genesis +413943, 2020-01-01T00:00:36.936Z, 1.00553737, +1311192, 2020-04-01T12:00:02.732Z, 1.01529582, +2225181, 2020-07-02T00:00:01.791Z, 1.02475181, +2887376, 2020-10-01T12:00:03.001Z, 1.03388539, +3583555, 2021-01-01T00:00:01.008Z, 1.04279524, +4048565, 2021-04-02T06:00:46.073Z, 1.05146502, +4697782, 2021-07-02T12:00:00.086Z, 1.05990675, +5503834, 2021-10-01T18:00:03.408Z, 1.06816083, +6194506, 2022-01-01T00:00:01.554Z, 1.07621462, +6822996, 2022-04-02T06:00:46.874Z, 1.08407983, +7548564, 2022-07-02T12:00:01.029Z, 1.09176325, +8188955, 2022-10-01T18:00:08.715Z, 1.09928404, +8877418, 2023-01-01T00:00:00.729Z, 1.10664516, +9562759, 2023-04-02T06:00:11.059Z, 1.11387639, +10360745, 2023-07-02T12:00:27.581Z, 1.12095011, +11084807, 2023-10-01T18:00:58.702Z, 1.12788772, +11850353, 2024-01-01T00:00:04.666Z, 1.13469412, +12600876, 2024-04-01T12:00:04.633Z, 1.1414592, +13377494, 2024-07-02T00:00:18.459Z, 1.14815852, +14010067, 2024-09-12T15:13:19.574Z, 1.15343076, iCKB Launch +14160825, 2024-10-01T12:00:24.531Z, 1.15479538, +15001370, 2025-01-01T00:00:12.070Z, 1.1613766, +15799566, 2025-04-02T06:00:09.602Z, 1.16787482, +16605969, 2025-07-02T12:00:00.609Z, 1.17431822, +17426528, 2025-10-01T18:00:20.609Z, 1.18071088, diff --git a/apps/sampler/src/index.ts b/apps/sampler/src/index.ts new file mode 100644 index 0000000..664cbfd --- /dev/null +++ b/apps/sampler/src/index.ts @@ -0,0 +1,192 @@ +/** + * @packageDocumentation + * + * Entry-point script that samples block headers from a CKB mainnet public client + * and prints a CSV report (BlockNumber, Date, Value, Note). + * + * Summary of behavior: + * - Constructs a `ccc.ClientPublicMainnet` client and queries the genesis and tip headers. + * - Builds a set of Date samples between genesis and tip (including a small set of + * named dates such as "Genesis", "iCKB Launch", and the "Tip"). + * - For each sample date, performs a binary search over block numbers to find + * the first block whose timestamp is greater than or equal to the sample date. + * - Logs CSV lines with block number, ISO timestamp, converted value, and an optional note. + * + * Remarks: + * - The sampling functions accept timestamps as bigint millisecond values. + * - This file runs in Node.js (uses top-level await) and exits on completion or error. + * - Failures in fetching blocks will throw. + * + * Example output (CSV): + * BlockNumber, Date, Value, Note + * 0, 2019-11-15T21:09:50.812Z, 1.00082, Genesis + * + * @public + */ + +import { ccc } from "@ckb-ccc/core"; +import { convert } from "@ickb/core"; +import { asyncBinarySearch } from "@ickb/utils"; + +/** + * Main program that orchestrates sampling and logging. + * + * - Constructs a public mainnet client. + * - Fetches genesis and tip headers (throws if missing). + * - Computes an upper bound `n` for the block-number binary search using the + * bit-length of tip.number (a simple power-of-two bound). + * - Generates date samples (per-year, `n` samples per year) and inserts a + * named "iCKB Launch" sample. + * - For each date sample, finds the earliest block whose timestamp >= sample + * date via `asyncBinarySearch` and logs a CSV row for that header. + * + * Notes on error handling: + * - Missing blocks will cause this function to throw. + * + * @returns Promise that resolves when sampling and logging complete. + * + * @public + */ +export async function main(): Promise { + // Create a public mainnet client (network I/O happens on method calls). + const client = new ccc.ClientPublicMainnet(); + + // Fetch genesis header (block 0). If absent, abort early. + const genesis = await client.getHeaderByNumber(0); + if (!genesis) { + throw new Error("Genesis block not found"); + } + + // Fetch tip header to bound our searches. + const tip = await client.getTipHeader(); + + // Compute an upper bound `n` for the binary search using the bit-length + // of the tip number. This yields a power-of-two >= tip.number. + const n = 1 << tip.number.toString(2).length; + + // Generate date samples between genesis and tip (timestamps are bigints in ms). + // The samples(...) helper returns Date instances; attach optional notes here. + const dates = samples(genesis.timestamp, tip.timestamp, 4).map( + (d) => [d, ""] as [Date, string], + ); + // Insert a named event sample (kept as an example of adding special dates). + dates.push([new Date("2024-09-12T15:13:19.574Z"), "iCKB Launch"]); + // Ensure chronological order across all samples (safety). + dates.sort((a, b) => a[0].getTime() - b[0].getTime()); + + // Emit CSV header and the genesis row. + console.log(["BlockNumber", "Date", "Value", "Note"].join(", ")); + logRow(genesis, "Genesis"); + + // For each sample date, find the earliest block whose timestamp is >= date. + for (const [date, note] of dates) { + // asyncBinarySearch expects a predicate that returns true when the index i + // is at or past the desired condition. We provide a predicate that fetches + // the header and compares timestamps. + const blockNumber = await asyncBinarySearch( + n, + async (i: number): Promise => { + const header = await client.getHeaderByNumber(i); + if (!header) { + // If there's no header at i, signal "true" so the search moves left. + return true; + } + // header.timestamp is numeric-like; convert to Number and compare to Date. + return date <= new Date(Number(header.timestamp)); + }, + ); + + // Fetch header for the found block number and log it. + const header = await client.getHeaderByNumber(blockNumber); + if (!header) { + throw Error("Header not found"); + } + + logRow(header, note); + } +} + +/** + * Log a CSV row for a header. + * + * Behavior: + * - Converts the header value via `convert(false, ccc.One, header)`, + * formats it with `ccc.fixedPointToString`, and writes a CSV line. + * - This helper is intentionally lightweight and will throw only on programmer errors + * (e.g. unexpected undefined header when called). + * + * @param header - Block header to log. + * @param note - Optional short note to include in the CSV row (e.g. "Genesis"...). + * + * @internal + */ +function logRow(header: ccc.ClientBlockHeader, note: string): void { + // Compute ISO timestamp from header timestamp (milliseconds). + const date = new Date(Number(header.timestamp)); + // Convert the header's monetary value to a fixed-point representation. + const val = convert(false, ccc.One, header); + // Emit CSV row: blockNumber, ISO date, formatted value, note. + console.log( + [ + String(header.number), + date.toISOString(), + ccc.fixedPointToString(val), + note, + ].join(", "), + ); +} + +/** + * Generate a set of sample Dates between two millisecond-based bigints. + * + * The function: + * - Splits the overall [startMs, endMs] span by UTC calendar years. + * - Emits `n` evenly-spaced samples within each year span [Y0, Y1). + * - Uses integer-rounded millisecond timestamps and returns Date objects. + * + * @param startMs - Inclusive start of the sampling range as a bigint (ms since epoch). + * @param endMs - Inclusive end of the sampling range as a bigint (ms since epoch). + * @param n - Number of evenly-spaced samples to generate per year span. Must be >= 1. + * + * @returns An array of Date objects. Samples are generated year-by-year; calling + * code may sort again for global ordering (the caller does so). + * + * @throws Error if endMs < startMs or if n < 1. + * + * @public + */ +export function samples(startMs: bigint, endMs: bigint, n: number): Date[] { + if (endMs < startMs) throw Error("endMs must be bigger than startMs"); + if (n < 1) throw Error("n must be a positive number"); + + // Convert bigints (ms) to Dates for year extraction. + const start = new Date(Number(startMs)); + const end = new Date(Number(endMs)); + const startYear = start.getUTCFullYear(); + const endYear = end.getUTCFullYear(); + const out: Date[] = []; + + // For each UTC year in the covered range, generate n samples inside that year. + for (let year = startYear; year <= endYear; year++) { + // Y0 is start of `year` in ms (UTC), Y1 is start of next year. + const Y0 = Date.UTC(year, 0, 1); + const Y1 = Date.UTC(year + 1, 0, 1); + const span = Y1 - Y0; + + for (let i = 0; i < n; i++) { + // Evenly space n samples in [Y0, Y1). Round to nearest millisecond. + const t = Y0 + Math.round((span * i) / n); + const sample = new Date(t); + // Only include samples that fall within the inclusive overall range. + if (sample >= start && sample <= end) { + out.push(sample); + } + } + } + + return out; +} + +await main(); + +process.exit(0); diff --git a/apps/sampler/tsconfig.json b/apps/sampler/tsconfig.json new file mode 100644 index 0000000..68226cd --- /dev/null +++ b/apps/sampler/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "sourceRoot": "../src" + }, + "include": ["src"], +} diff --git a/apps/sampler/typedoc.json b/apps/sampler/typedoc.json new file mode 100644 index 0000000..28e3fc5 --- /dev/null +++ b/apps/sampler/typedoc.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": ["./src/index.ts"], + "extends": ["../../typedoc.base.json"], +} diff --git a/apps/sampler/vitest.config.mts b/apps/sampler/vitest.config.mts new file mode 100644 index 0000000..dc6a587 --- /dev/null +++ b/apps/sampler/vitest.config.mts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + coverage: { + include: ["src/**/*.ts"], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22ae26b..42d671b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -204,6 +204,22 @@ importers: specifier: ^6.3.6 version: 6.3.6(@types/node@22.18.8)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1) + apps/sampler: + dependencies: + '@ckb-ccc/core': + specifier: 'catalog:' + version: 1.12.2(typescript@5.9.3)(zod@3.25.76) + '@ickb/core': + specifier: workspace:* + version: link:../../packages/core + '@ickb/utils': + specifier: workspace:* + version: link:../../packages/utils + devDependencies: + '@types/node': + specifier: ^24.7.0 + version: 24.7.0 + apps/tester: dependencies: '@ckb-lumos/base': @@ -1406,8 +1422,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.8.13: - resolution: {integrity: sha512-7s16KR8io8nIBWQyCYhmFhd+ebIzb9VKTzki+wOJXHTxTnV6+mFGH3+Jwn1zoKaY9/H9T/0BcKCZnzXljPnpSQ==} + baseline-browser-mapping@2.8.14: + resolution: {integrity: sha512-GM9c0cWWR8Ga7//Ves/9KRgTS8nLausCkP3CGiFLrnwA2CDUluXgaQqvrULoR2Ujrd/mz/lkX87F5BHFsNr5sQ==} hasBin: true bech32@2.0.0: @@ -1471,8 +1487,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001748: - resolution: {integrity: sha512-5P5UgAr0+aBmNiplks08JLw+AW/XG/SurlgZLgB1dDLfAw7EfRGxIwzPHxdSCGY/BTKDqIVyJL87cCN6s0ZR0w==} + caniuse-lite@1.0.30001749: + resolution: {integrity: sha512-0rw2fJOmLfnzCRbkm8EyHL8SvI2Apu5UbnQuTsJ0ClgrH8hcwFooJ1s5R0EP8o8aVrFu8++ae29Kt9/gZAZp/Q==} chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} @@ -1576,8 +1592,8 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - electron-to-chromium@1.5.232: - resolution: {integrity: sha512-ENirSe7wf8WzyPCibqKUG1Cg43cPaxH4wRR7AJsX7MCABCHBIOFqvaYODSLKUuZdraxUTHRE/0A2Aq8BYKEHOg==} + electron-to-chromium@1.5.233: + resolution: {integrity: sha512-iUdTQSf7EFXsDdQsp8MwJz5SVk4APEFqXU/S47OtQ0YLqacSwPXdZ5vRlMX3neb07Cy2vgioNuRnWUXFwuslkg==} elliptic@6.6.1: resolution: {integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==} @@ -4261,7 +4277,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.8.13: {} + baseline-browser-mapping@2.8.14: {} bech32@2.0.0: {} @@ -4304,9 +4320,9 @@ snapshots: browserslist@4.26.3: dependencies: - baseline-browser-mapping: 2.8.13 - caniuse-lite: 1.0.30001748 - electron-to-chromium: 1.5.232 + baseline-browser-mapping: 2.8.14 + caniuse-lite: 1.0.30001749 + electron-to-chromium: 1.5.233 node-releases: 2.0.23 update-browserslist-db: 1.1.3(browserslist@4.26.3) @@ -4337,7 +4353,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001748: {} + caniuse-lite@1.0.30001749: {} chai@5.3.3: dependencies: @@ -4426,7 +4442,7 @@ snapshots: eastasianwidth@0.2.0: {} - electron-to-chromium@1.5.232: {} + electron-to-chromium@1.5.233: {} elliptic@6.6.1: dependencies: