diff --git a/README.md b/README.md index 6b130ec..1f512eb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ The specification and reference verifier for ChartRunner's on-chain result anchors. -ChartRunner writes one line to Solana for every frozen Trading Cup and every closed free-play or daily-chart leaderboard day: +ChartRunner writes one line to Solana for every frozen Trading Cup and every closed leaderboard day (free play, and the +Daily Run, Daily Race and pre-modes boards of the daily chart): ``` chartrunner:v1:::: @@ -34,7 +35,9 @@ anyone can use to check them without trusting us. What is live on mainnet today: - Every finished Trading Cup and every closed free-play and daily-chart leaderboard day is anchored as one SPL Memo - (the newer Daily Run and Daily Race boards are not anchored yet), signed by the published operator + (the Daily Run and Daily Race boards have their own kinds, `dailyrun` and `dailyrace`, specified in + [SPEC.md sections 7.3 and 7.4](SPEC.md#73-daily-run-leaf-dailyrun); their first anchors follow the app release that + writes them), signed by the published operator address `68tWAPfHGkfVzNK1D7PxzVrBrDbtiyqmW2wddwqEmVhy` ([SPEC.md section 2.6](SPEC.md#26-operator-addresses)). 33 anchors (27 cups, 5 free-play boards, 1 daily board) were posted between 2026-10-06 and 2026-10-12. The full list is at . diff --git a/SPEC.md b/SPEC.md index 6a2ca38..68821b9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -52,7 +52,8 @@ kind), the count MUST be exactly `1`. A parser MUST reject anything else, including: leading or trailing whitespace or newline, an uppercase kind or root, a shorter or longer root, a count of `0`, a count with a leading zero, a count of ten or more digits, a count other than `1` for a user kind, an empty or over-long id, an id containing `:` or a space, extra or missing fields, any version -other than `v1`, and any other prefix. The longest valid memo is 163 bytes. +other than `v1`, and any other prefix. The longest valid memo is 164 bytes (a 9-character kind such as `dailyrace`, a +64-character id and a 9-digit count); before `dailyrace` was registered it was 163. A writer MUST only emit text that this parser accepts and that formats back to the same text (the reference operator code parses the memo, re-formats it, and refuses to sign unless the two are identical). @@ -74,8 +75,10 @@ Signer models: | kind | signer | `id` | leaves | count | status | | ---------- | -------- | ------------------- | ------------------------------------------------------------------- | ----- | ------ | | `freeplay` | operator | `YYYY-MM-DD` (UTC) | board leaf (7.1): best free-play run per player on that UTC day | any | active | -| `daily` | operator | `YYYY-MM-DD` (UTC) | board leaf (7.1): best run per player on that day's shared chart | any | active | +| `daily` | operator | `YYYY-MM-DD` (UTC) | board leaf (7.1): best run per player on that day's shared chart, runs without a mode | any | active | | `cup` | operator | the cup id | cup leaf (7.2): one per final standing | any | active | +| `dailyrun` | operator | `YYYY-MM-DD` (UTC) | Daily Run leaf (7.3): best Daily Run per player on that day's chart | any | active | +| `dailyrace`| operator | `YYYY-MM-DD` (UTC) | Daily Race leaf (7.4): best arrived Daily Race per player that day | any | active | | `xp` | operator | not defined yet | XP ledger daily totals, schema not defined yet | any | reserved | | `map` | user | opaque entity id | one entity leaf, schema not defined yet | 1 | planned | | `strategy` | user | opaque entity id | one entity leaf, schema not defined yet | 1 | planned | @@ -89,6 +92,12 @@ a leaf-plus-proof that the caller already holds. A day board is anchored only after its UTC day has ended. A cup is anchored after its results are frozen. +The daily chart has three boards per date. Since the Daily Run and Daily Race modes were introduced, a run on the daily +chart is played in one mode, `run` or `race`, or, for runs from before the modes, in none. `daily` covers the runs +without a mode (the board from before the modes), `dailyrun` the Daily Run board and `dailyrace` the Daily Race +board. The three never share a run, and the same date can be anchored under all three. Kind names follow the memo +grammar (`[a-z]+`): no separator, so `dailyrun`, not `daily-run` or `daily_run`. + The pair (`kind`, `id`) names the snapshot. Verifiers SHOULD pin both (the reference verifier's `expect` option and the CLI's `--kind` / `--id`) so that a valid memo for one snapshot cannot be presented as the anchor of another. @@ -349,8 +358,60 @@ One leaf per standing, built from the frozen results document only (never from l reads exactly the fields `rank, userId, name, equity, pnlPct, trades, disqualified, fillsHash` from each standing and ignores every other field. -Because a leaf includes its kind and id (`k` and `d`, or `k` and `id`), a leaf from one snapshot does not hash the same -as a leaf from another. +### 7.3 Daily Run leaf (`dailyrun`) + +```json +{"d":"2026-10-12","dist":41,"fin":true,"h":"","hc":true,"k":"dailyrun","r":"","rank":1,"s":120,"u":"","v":1} +``` + +The board leaf of 7.1 with two booleans added. Every key that 7.1 also has means exactly what it means there. + +| key | value | why it is in the leaf | +| ------ | ----- | --------------------- | +| `v` | `1` | leaf schema version, as in every leaf | +| `k` | `"dailyrun"` | binds the leaf to this board, so a Daily Race or old daily leaf of the same date never hashes the same | +| `d` | the UTC date of the daily chart, equal to the memo `id` | binds the leaf to its day | +| `rank` | standard competition rank by score (ties share a rank: 1, 2, 2, 4) | the position the board shows | +| `u` | user pseudonym | one leaf per account; lets a player find their own leaf without the raw id | +| `h` | handle pseudonym (the handle the board shows, `guest-` + the first 4 characters of the account id for a player without one) | lets a player look up their leaf by handle, as on the other boards | +| `s` | score (integer) | what the board ranks by | +| `dist` | distance (integer) | shown on the board, as in 7.1 | +| `hc` | `true` when the run was played in HARDCORE (boolean) | HARDCORE runs share the board and are marked; the mark is part of the result | +| `fin` | `true` when that run survived the 2:00 timer (boolean; the run's `finished`) | the board marks a survived run; it decides the Daily Run reward | +| `r` | run pseudonym of the best run | ties the entry to one stored, server-replayed run | + +One leaf per player: their best Daily Run of that date (highest score; the earliest run wins a tie, then the lowest run +id). Every Daily Run counts for the score, whether it survived or not; `fin` describes the run that is the best. All runs +of a board share one rules version (the version the modes run under); like 7.1, the leaf does not carry it. + +### 7.4 Daily Race leaf (`dailyrace`) + +```json +{"d":"2026-10-12","dist":60,"ft":5400,"h":"","hc":false,"k":"dailyrace","r":"","rank":1,"u":"","v":1} +``` + +| key | value | why it is in the leaf | +| ------ | ----- | --------------------- | +| `v` | `1` | leaf schema version | +| `k` | `"dailyrace"` | binds the leaf to this board | +| `d` | the UTC date of the daily chart, equal to the memo `id` | binds the leaf to its day | +| `rank` | standard competition rank by finish tick, lowest first (ties share a rank) | the position the board shows | +| `u` | user pseudonym | as in 7.3 | +| `h` | handle pseudonym (same fallback as 7.3) | as in 7.3 | +| `ft` | finish tick (integer, 1 or more): the simulation tick (60 per second) at which the run crossed the line | what the board ranks by; the race time is `ft / 60` seconds. An integer, because the format has no floats | +| `dist` | distance (integer) | shown on the board, as in 7.1 | +| `hc` | `true` when the run was played in HARDCORE (boolean) | as in 7.3 | +| `r` | run pseudonym of the best run | as in 7.3 | + +One leaf per player: their best ARRIVED race of that date (lowest finish tick; the earliest run wins a tie, then the +lowest run id). A race that timed out or died is stored but never ranked, so it has no leaf. There is no score key: a +race collects no coins, so its score carries no information, and every leaf on this board is an arrived race, so there +is no `fin` key either. + +### 7.5 All leaf schemas + +Because every leaf schema in this section includes its kind and id (`k` and `d`, or `k` and `id`), a leaf from one +snapshot does not hash the same as a leaf from another. ## 8. What an anchor proves, and what it does not @@ -400,9 +461,17 @@ It does not prove: get their leaf schemas the same way, frozen with their first mainnet anchor. - Any change to hashing, serialization or the memo grammar requires a new version prefix (`chartrunner:v2:`) with its own specification and vectors. Verifiers keep accepting `v1` unchanged. -- Existing entries in `test-vectors/v1.json` are never edited, only appended to. The ChartRunner app checks the same +- Existing entries in `test-vectors/v1.json` are never edited, only appended to. New kinds go at the end of its + `kinds` list (and of `ANCHOR_KINDS`), whatever their signer model, so the existing entries keep their positions. The ChartRunner app checks the same file, pinned by its SHA-256 (see the repository README), so the public SDK and the app cannot drift apart silently. +### 9.1 Registry changes + +| date | change | +| ---------- | ------ | +| 2026-10-06 | first mainnet anchors; format v1 frozen with `freeplay`, `daily`, `cup` (active), `xp` (reserved), `map`, `strategy`, `bot`, `backtest` (planned) | +| 2026-10-13 | `dailyrun` and `dailyrace` added: operator kinds, id the UTC date, count the number of leaves, leaves 7.3 and 7.4. Vectors appended (`dailyRun`, `dailyRace`, two valid and three invalid memos). No existing vector, hash or kind changed | + ## 10. Legacy: the `cr1:` memo format `cr1:` is the memo format of the earlier ChartRunner app. It is **frozen, legacy and read-only**: existing `cr1:` memos @@ -442,7 +511,7 @@ memo is never a `chartrunner:v1` anchor: `parseMemo` rejects it, and `verifyAgai | concern | `packages/verify/src` | | ------- | --------------------- | | canonical JSON, leaf/node hashing, tree, proofs, memo grammar | `merkle.ts` | -| pseudonyms, board and cup leaf schemas | `leaves.ts` | +| pseudonyms, board, Daily Run, Daily Race and cup leaf schemas, ranking | `leaves.ts` | | offline verification and failure reasons | `verify.ts` | | reading an anchor transaction over JSON-RPC, the rules in 2.4 | `rpc.ts` | | wire-transaction decoding and local Ed25519 signature check (2.4 rule 10) | `sigcheck.ts` | diff --git a/packages/verify/README.md b/packages/verify/README.md index c328008..825da3a 100644 --- a/packages/verify/README.md +++ b/packages/verify/README.md @@ -40,7 +40,7 @@ chartrunner:v1:::: | field | rule | | ------- | ----------------------------------------------------------------- | -| `kind` | operator kinds `freeplay`, `daily`, `cup`, `xp`; planned user kinds `map`, `strategy`, `bot`, `backtest` (count must be 1) | +| `kind` | operator kinds `freeplay`, `daily`, `cup`, `xp`, `dailyrun`, `dailyrace`; planned user kinds `map`, `strategy`, `bot`, `backtest` (count must be 1) | | `id` | 1-64 characters from `A-Z a-z 0-9 . _ -` (cup id, date, ...) | | `root` | 64 lowercase hex characters | | `count` | number of rows, decimal, 1 to 999999999, no leading zeros | @@ -62,6 +62,13 @@ A cup row (`cupLeaf`) is `{v:1, k:"cup", id, rank, u, h, eq, pct, tr, dq, fh}`: account and displayed name, `eq` the final equity and `pct` the return as fixed-decimal strings, `tr` the exits, `dq` the disqualified flag and `fh` the hash of the fills. +A leaderboard-day row (`boardLeaf`, kinds `freeplay` and `daily`) is `{v:1, k, d, rank, u, h, s, dist, r}`: the date, +the rank by score, the pseudonyms of the account, the handle and the run, the score and the distance. The daily +chart's mode boards add to it: a Daily Run row (`dailyRunLeaf`, kind `dailyrun`) is `{v:1, k, d, rank, u, h, s, dist, +hc, fin, r}` with `hc` the HARDCORE flag and `fin` whether the run survived the 2:00 timer; a Daily Race row +(`dailyRaceLeaf`, kind `dailyrace`) is `{v:1, k, d, rank, u, h, ft, dist, hc, r}`, ranked by `ft`, the finish tick +(60 per second, lowest first). See SPEC.md sections 7.1, 7.3 and 7.4. + ## Install Not published to npm yet. Build it from a checkout of this repository: @@ -157,7 +164,8 @@ built library itself only needs a runtime with WebCrypto and `fetch` (Node 20+ o | `fetchMemo(signature, { rpcUrl, verifySignature? })` | read the memo of a transaction via `getTransaction`; optionally verify its signatures locally | | `checkTransactionSignatures(base64, memoProgramId)` | decode a wire transaction and verify every Ed25519 signature (low level) | | `verifyAgainstSignature({ signature, rpcUrl, rows \| leaf+proof })` | fetch + verify | -| `cupLeaf`, `boardLeaf`, `cupLeavesFromStandings`, `userHash`, `handleHash`, `runHash` | build the exact leaves the app anchors | +| `cupLeaf`, `boardLeaf`, `dailyRunLeaf`, `dailyRaceLeaf`, `cupLeavesFromStandings`, `userHash`, `handleHash`, `runHash` | build the exact leaves the app anchors | +| `rankRows`, `rankRowsBy` | standard competition ranking (ties share a rank) over rows sorted best first: by score, or by any key such as a race's finish tick | | `KINDS`, `ANCHOR_KINDS`, `OPERATOR_KINDS`, `USER_KINDS`, `kindInfo`, `isOperatorKind`, `isUserKind` | the kinds registry: signer model, status, id and count rule of each kind | | `signerModelOf(memo)` | which signer rule `expectSigner` applies to a memo | | `checkEquivocation({ rpcUrl, operator, kind, id, maxSignatures? })` | scan the operator's history: `none`, `unique`, `duplicate` or `equivocation`, plus the first finalized anchor | diff --git a/packages/verify/src/__tests__/cli.test.ts b/packages/verify/src/__tests__/cli.test.ts index eb05fae..1d655a2 100644 --- a/packages/verify/src/__tests__/cli.test.ts +++ b/packages/verify/src/__tests__/cli.test.ts @@ -3,7 +3,7 @@ import { copyFileSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { cupLeavesFromStandings, formatMemo, merkleProof, merkleRoot } from "../index.ts"; +import { cupLeavesFromStandings, dailyRaceLeaf, dailyRunLeaf, formatMemo, merkleProof, merkleRoot } from "../index.ts"; import { exitCodeFor } from "../cli.ts"; const bin = new URL("../../bin/chartrunner-verify.mjs", import.meta.url).pathname; @@ -39,6 +39,18 @@ describe("chartrunner-verify CLI", async () => { const f = write("cup.json", { cupId: "cup-9", results: { standings } }); expect(run("--rows", f, "--memo", formatMemo({ kind: "cup", id: "cup-9", root: m.root, count: m.count })).status).toBe(0); }); + it("verifies Daily Run and Daily Race board leaves, pinned with --kind / --id", async () => { + const date = "2026-10-12"; + const run1 = await dailyRunLeaf(date, { rank: 1, userId: "u1", handle: "Al", runId: "r1", score: 50, distance: 9, hardcore: true, finished: true, createdAt: "" }); + const race1 = await dailyRaceLeaf(date, { rank: 1, userId: "u1", handle: "Al", runId: "r2", finishTick: 4000, distance: 60, hardcore: false, createdAt: "" }); + for (const [kind, leaf] of [["dailyrun", run1], ["dailyrace", race1]] as const) { + const m = await merkleRoot([leaf]); + const text = formatMemo({ kind, id: date, root: m.root, count: m.count }); + const f = write(`${kind}.json`, [leaf]); + expect(run("--rows", f, "--memo", text, "--kind", kind, "--id", date).status).toBe(0); + expect(run("--rows", f, "--memo", text, "--kind", kind === "dailyrun" ? "dailyrace" : "dailyrun").status).toBe(1); + } + }); it("exits 1 on a tampered row, a wrong root and a malformed memo", () => { const tampered = write("t.json", [rows[0], rows[1], { id: "c", s: 4 }]); const t = run("--rows", tampered, "--memo", memo); diff --git a/packages/verify/src/__tests__/registry.test.ts b/packages/verify/src/__tests__/registry.test.ts index 10e9f75..e6f2561 100644 --- a/packages/verify/src/__tests__/registry.test.ts +++ b/packages/verify/src/__tests__/registry.test.ts @@ -29,13 +29,17 @@ const OTHER = "SomeoneE1seAddressxxxxxxxxxxxxxxxxxxxxxxxxx"; describe("kinds registry", () => { it("lists every kind once, with a signer model", () => { - expect([...ANCHOR_KINDS]).toEqual([...OPERATOR_KINDS, ...USER_KINDS]); + // Same members; ANCHOR_KINDS keeps registration order (append-only), so new operator kinds come after the user kinds. + expect([...ANCHOR_KINDS].sort()).toEqual([...OPERATOR_KINDS, ...USER_KINDS].sort()); + expect(ANCHOR_KINDS.slice(0, 8)).toEqual(["freeplay", "daily", "cup", "xp", "map", "strategy", "bot", "backtest"]); expect(new Set(ANCHOR_KINDS).size).toBe(ANCHOR_KINDS.length); for (const k of ANCHOR_KINDS) expect(KINDS[k].kind).toBe(k); for (const k of OPERATOR_KINDS) expect(KINDS[k]).toMatchObject({ signer: "operator", count: "any" }); for (const k of USER_KINDS) expect(KINDS[k]).toMatchObject({ signer: "user", status: "planned", count: "one" }); expect(KINDS.xp.status).toBe("reserved"); - expect(["freeplay", "daily", "cup"].map((k) => kindInfo(k)?.status)).toEqual(["active", "active", "active"]); + expect(["freeplay", "daily", "cup", "dailyrun", "dailyrace"].map((k) => kindInfo(k)?.status)).toEqual(["active", "active", "active", "active", "active"]); + expect(kindInfo("dailyrun")).toMatchObject({ signer: "operator", count: "any", id: "UTC date YYYY-MM-DD" }); + expect(kindInfo("dailyrace")).toMatchObject({ signer: "operator", count: "any", id: "UTC date YYYY-MM-DD" }); }); it("is frozen", () => { expect(Object.isFrozen(KINDS)).toBe(true); @@ -48,6 +52,9 @@ describe("kinds registry", () => { expect(isOperatorKind("map")).toBe(false); expect(isUserKind("bot")).toBe(true); expect(isUserKind("daily")).toBe(false); + expect(isOperatorKind("dailyrun")).toBe(true); + expect(isOperatorKind("dailyrace")).toBe(true); + expect(kindInfo("daily-run")).toBeNull(); expect(kindInfo("nope")).toBeNull(); expect(kindInfo("__proto__")).toBeNull(); }); diff --git a/packages/verify/src/__tests__/sdk.test.ts b/packages/verify/src/__tests__/sdk.test.ts index 8de83ff..8a21b8d 100644 --- a/packages/verify/src/__tests__/sdk.test.ts +++ b/packages/verify/src/__tests__/sdk.test.ts @@ -6,6 +6,8 @@ import { boardLeaf, cupLeaf, cupLeavesFromStandings, + dailyRaceLeaf, + dailyRunLeaf, formatMemo, leafHash, memoText, @@ -13,6 +15,8 @@ import { merkleRoot, parseMemo, parseMemoStrict, + rankRows, + rankRowsBy, serializeRow, verifyAgainstMemo, verifyProof, @@ -66,6 +70,26 @@ describe("cross-check against the app's previous implementation (verbatim copy o const b = { userId: "u", handle: "Al", runId: "r", score: 5, distance: 9, createdAt: "x", rank: 2 }; expect(await boardLeaf("daily", "2026-10-02", b)).toEqual(await legacyLeaves.boardLeaf("daily", "2026-10-02", b)); }); + it("Daily Run / Daily Race leaves extend the board leaf: same pseudonyms, same keys where they overlap", async () => { + const b = { userId: "u", handle: "Al", runId: "r", score: 5, distance: 9, createdAt: "x", rank: 2 }; + const board = await boardLeaf("daily", "2026-10-12", b); + const run = await dailyRunLeaf("2026-10-12", { ...b, hardcore: false, finished: true }); + const race = await dailyRaceLeaf("2026-10-12", { ...b, finishTick: 4321, hardcore: true }); + for (const k of ["v", "d", "rank", "u", "h", "dist", "r"]) { + expect(run[k], k).toBe(board[k]); + expect(race[k], k).toBe(board[k]); + } + expect(run).toMatchObject({ k: "dailyrun", s: 5, hc: false, fin: true }); + expect(race).toMatchObject({ k: "dailyrace", ft: 4321, hc: true }); + expect("s" in race).toBe(false); + }); + it("rankRowsBy: standard competition ranking over sorted rows", () => { + expect(rankRowsBy([], (r: { x: number }) => r.x)).toEqual([]); + expect(rankRowsBy([{ x: 9 }, { x: 7 }, { x: 7 }, { x: 1 }], (r) => r.x).map((r) => r.rank)).toEqual([1, 2, 2, 4]); + expect(rankRowsBy([{ x: 3 }, { x: 3 }, { x: 3 }], (r) => r.x).map((r) => r.rank)).toEqual([1, 1, 1]); + const scored = [{ score: 9 }, { score: 7 }, { score: 7 }, { score: 1 }]; + expect(rankRowsBy(scored, (r) => r.score)).toEqual(rankRows(scored)); + }); it("roots, proofs and memos are identical for many sizes", async () => { for (const n of [1, 2, 3, 5, 8, 13, 50]) { const rows: ResultsRow[] = Array.from({ length: n }, (_, i) => ({ i, name: `p${i}`, ok: i % 2 === 0, none: null })); diff --git a/packages/verify/src/__tests__/vectors.test.ts b/packages/verify/src/__tests__/vectors.test.ts index 0bb5aa4..40373cd 100644 --- a/packages/verify/src/__tests__/vectors.test.ts +++ b/packages/verify/src/__tests__/vectors.test.ts @@ -5,6 +5,8 @@ import { ANCHOR_KINDS, boardLeaf, cupLeavesFromStandings, + dailyRaceLeaf, + dailyRunLeaf, formatMemo, handleHash, hashNode, @@ -16,6 +18,7 @@ import { parseMemo, parseLegacyCr1, parseMemoStrict, + rankRowsBy, runHash, serializeRow, userHash, @@ -31,7 +34,7 @@ import { const VECTORS_URL = new URL("../../../../test-vectors/v1.json", import.meta.url); /** sha256 of test-vectors/v1.json. The app pins the same value: update both together, and only to APPEND vectors. */ -const VECTORS_SHA256 = "e75afcbec9e2f30b96ac89dc631f9ce1540b189b7b28ea000a5f026947b7901c"; +const VECTORS_SHA256 = "563ee67172cc80a2343961a12e41b4d0333960a2da6cf1854a7fd56c3e40e6af"; const raw = readFileSync(VECTORS_URL); const V = JSON.parse(raw.toString("utf8")) as Vectors; @@ -61,6 +64,19 @@ interface Vectors { board: { kind: "daily" | "freeplay"; date: string; rows: Parameters[2][]; leaves: ResultsRow[]; root: string; count: number; memo: string }; memos: { valid: { text: string; fields: ReturnType }[]; invalid: { name: string; text: string }[] }; legacyCr1: { valid: { text: string; fields: ReturnType }[]; invalid: { name: string; text: string }[] }; + dailyRun: ModeVector[1], "createdAt">>; + dailyRace: ModeVector[1], "createdAt">>; +} +interface ModeVector { + kind: "dailyrun" | "dailyrace"; + date: string; + rows: Row[]; + leaves: ResultsRow[]; + root: string; + count: number; + memo: string; + sortedLeaves: { canonical: string; hash: string }[]; + proofs: { index: number; leaf: string; proof: ProofStep[] }[]; } describe("test-vectors/v1.json", () => { @@ -129,6 +145,35 @@ describe("leaf schemas", () => { }); }); +describe("Daily Run and Daily Race leaf schemas (SPEC 7.3, 7.4)", () => { + const at = "2026-10-12T00:00:00.000Z"; + it("dailyrun: ranks by score and builds the vector leaves, root and memo", async () => { + const d = V.dailyRun; + expect(rankRowsBy(d.rows, (r) => r.score).map((r) => r.rank)).toEqual(d.rows.map((r) => r.rank)); + const leaves = await Promise.all(d.rows.map((r) => dailyRunLeaf(d.date, { ...r, createdAt: at }))); + expect(leaves).toEqual(d.leaves); + expect(await merkleRoot(leaves)).toEqual({ root: d.root, count: d.count }); + expect(formatMemo({ kind: "dailyrun", id: d.date, root: d.root, count: d.count })).toBe(d.memo); + expect(d.sortedLeaves.map((l) => l.canonical).sort()).toEqual(leaves.map((l) => serializeRow(l)).sort()); + for (const p of d.proofs) expect(await verifyAgainstMemo({ leaf: p.leaf, proof: p.proof, memoText: d.memo, expect: { kind: "dailyrun", id: d.date } })).toMatchObject({ ok: true }); + }); + it("dailyrace: ranks by finish tick (lowest first) and builds the vector leaves, root and memo", async () => { + const d = V.dailyRace; + expect(rankRowsBy(d.rows, (r) => r.finishTick).map((r) => r.rank)).toEqual(d.rows.map((r) => r.rank)); + const leaves = await Promise.all(d.rows.map((r) => dailyRaceLeaf(d.date, { ...r, createdAt: at }))); + expect(leaves).toEqual(d.leaves); + expect(await merkleRoot(leaves)).toEqual({ root: d.root, count: d.count }); + expect(formatMemo({ kind: "dailyrace", id: d.date, root: d.root, count: d.count })).toBe(d.memo); + for (const p of d.proofs) expect(await verifyAgainstMemo({ leaf: p.leaf, proof: p.proof, memoText: d.memo, expect: { kind: "dailyrace", id: d.date } })).toMatchObject({ ok: true }); + // A leaf of one board never verifies against the other board's anchor (k is in the leaf). + expect(await verifyAgainstMemo({ leaf: d.proofs[0]!.leaf, proof: d.proofs[0]!.proof, memoText: V.dailyRun.memo })).toMatchObject({ ok: false }); + }); + it("the leaves carry exactly the documented keys", () => { + expect(Object.keys(V.dailyRun.leaves[0]!).sort()).toEqual(["d", "dist", "fin", "h", "hc", "k", "r", "rank", "s", "u", "v"]); + expect(Object.keys(V.dailyRace.leaves[0]!).sort()).toEqual(["d", "dist", "ft", "h", "hc", "k", "r", "rank", "u", "v"]); + }); +}); + describe("memo parsing", () => { for (const m of V.memos.valid) { it(`accepts ${m.text.slice(0, 40)}...`, () => { diff --git a/packages/verify/src/kinds.ts b/packages/verify/src/kinds.ts index 71b5a4b..8cbcb89 100644 --- a/packages/verify/src/kinds.ts +++ b/packages/verify/src/kinds.ts @@ -4,16 +4,24 @@ // Adding a kind is a deliberate, versioned change (SPEC.md section 9). Never remove or redefine a kind. /** Kinds written by the ChartRunner operator key: the fee payer is the published operator address. */ -export const OPERATOR_KINDS = ["freeplay", "daily", "cup", "xp"] as const; +export const OPERATOR_KINDS = ["freeplay", "daily", "cup", "xp", "dailyrun", "dailyrace"] as const; /** Kinds signed by the user's own wallet (the wallet is among the transaction's signers). Planned, not produced yet. */ export const USER_KINDS = ["map", "strategy", "bot", "backtest"] as const; -/** Every kind the memo grammar accepts. */ -export const ANCHOR_KINDS = [...OPERATOR_KINDS, ...USER_KINDS] as const; +/** + * Every kind the memo grammar accepts, in the order the kinds were registered. Append-only, like the `kinds` list of + * test-vectors/v1.json: a new kind goes at the end, whatever its signer model. + */ +export const ANCHOR_KINDS = ["freeplay", "daily", "cup", "xp", "map", "strategy", "bot", "backtest", "dailyrun", "dailyrace"] as const; export type OperatorKind = (typeof OPERATOR_KINDS)[number]; export type UserKind = (typeof USER_KINDS)[number]; export type AnchorKind = (typeof ANCHOR_KINDS)[number]; +// Compile-time guard: ANCHOR_KINDS is exactly the operator kinds plus the user kinds, nothing more and nothing less. +type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +const _registryComplete: Same = true; +void _registryComplete; + /** Who must have signed an anchor of this kind. */ export type SignerModel = "operator" | "user"; /** active: produced now. reserved: accepted, not produced, leaf schema open. planned: accepted, not produced yet. */ @@ -34,6 +42,8 @@ export interface KindInfo { export const KINDS: Readonly>> = Object.freeze({ freeplay: { kind: "freeplay", signer: "operator", status: "active", id: "UTC date YYYY-MM-DD", leaf: "board leaf (SPEC 7.1)", count: "any" }, daily: { kind: "daily", signer: "operator", status: "active", id: "UTC date YYYY-MM-DD", leaf: "board leaf (SPEC 7.1)", count: "any" }, + dailyrun: { kind: "dailyrun", signer: "operator", status: "active", id: "UTC date YYYY-MM-DD", leaf: "Daily Run leaf (SPEC 7.3)", count: "any" }, + dailyrace: { kind: "dailyrace", signer: "operator", status: "active", id: "UTC date YYYY-MM-DD", leaf: "Daily Race leaf (SPEC 7.4)", count: "any" }, cup: { kind: "cup", signer: "operator", status: "active", id: "cup id", leaf: "cup leaf (SPEC 7.2)", count: "any" }, xp: { kind: "xp", signer: "operator", status: "reserved", id: "not defined yet", leaf: "not defined yet", count: "any" }, map: { kind: "map", signer: "user", status: "planned", id: "opaque entity id", leaf: "single entity leaf, schema not defined yet", count: "one" }, diff --git a/packages/verify/src/leaves.ts b/packages/verify/src/leaves.ts index 5fe8a6a..e80771c 100644 --- a/packages/verify/src/leaves.ts +++ b/packages/verify/src/leaves.ts @@ -37,6 +37,86 @@ export async function boardLeaf(kind: "freeplay" | "daily", date: string, row: B }; } +/** + * Standard competition ranking over rows already sorted best first by `key`: equal keys share a rank (1, 2, 2, 4). + * The Daily Run board ranks by score (higher first), the Daily Race board by finish tick (lower first); the caller + * sorts, this only numbers. + */ +export function rankRowsBy(rows: readonly T[], key: (row: T) => number): (T & { rank: number })[] { + const out: (T & { rank: number })[] = []; + rows.forEach((r, i) => { + const prev = out[i - 1]; + out.push({ ...r, rank: prev !== undefined && key(rows[i - 1]!) === key(r) ? prev.rank : i + 1 }); + }); + return out; +} + +/** One row of a Daily Run board (best score per player on one UTC date's shared chart, mode "run"). */ +export interface DailyRunRow { + userId: string; + handle: string; + runId: string; + score: number; + distance: number; + /** The run was played in HARDCORE (same board, marked). */ + hardcore: boolean; + /** The best run survived the 2:00 timer (the run's `finished`). */ + finished: boolean; + createdAt: string; +} + +/** One row of a Daily Race board (lowest finish tick per player on one UTC date's shared chart, arrived races only). */ +export interface DailyRaceRow { + userId: string; + handle: string; + runId: string; + /** The race time in simulation ticks (60 per second): the tick the run crossed the line. */ + finishTick: number; + distance: number; + /** The run was played in HARDCORE (same board, marked). */ + hardcore: boolean; + createdAt: string; +} + +/** + * One Daily Run leaf (format v1, kind "dailyrun"): the board leaf of SPEC 7.1 plus `hc` (HARDCORE) and `fin` + * (survived the timer). Rank is by score, ties share a rank. Keep in sync with SPEC.md section 7.3. + */ +export async function dailyRunLeaf(date: string, row: DailyRunRow & { rank: number }): Promise { + return { + v: 1, + k: "dailyrun", + d: date, + rank: row.rank, + u: await userHash(row.userId), + h: await handleHash(row.handle), + s: row.score, + dist: row.distance, + hc: row.hardcore, + fin: row.finished, + r: await runHash(row.runId), + }; +} + +/** + * One Daily Race leaf (format v1, kind "dailyrace"): `ft` (finish tick) in place of the score, plus `hc` (HARDCORE). + * Rank is by finish tick, lowest first, ties share a rank. Keep in sync with SPEC.md section 7.4. + */ +export async function dailyRaceLeaf(date: string, row: DailyRaceRow & { rank: number }): Promise { + return { + v: 1, + k: "dailyrace", + d: date, + rank: row.rank, + u: await userHash(row.userId), + h: await handleHash(row.handle), + ft: row.finishTick, + dist: row.distance, + hc: row.hardcore, + r: await runHash(row.runId), + }; +} + /** The part of a frozen cup standing a leaf commits to (money values are the results document's decimal strings). */ export interface CupLeafRow { rank: number; diff --git a/scripts/gen-vectors.mjs b/scripts/gen-vectors.mjs index e7148b9..2040907 100644 --- a/scripts/gen-vectors.mjs +++ b/scripts/gen-vectors.mjs @@ -69,6 +69,9 @@ const KINDS = [ { kind: "strategy", signer: "user", status: "planned", count: "one" }, { kind: "bot", signer: "user", status: "planned", count: "one" }, { kind: "backtest", signer: "user", status: "planned", count: "one" }, + // Appended (SPEC 9: the registry only grows, vectors are only appended). + { kind: "dailyrun", signer: "operator", status: "active", count: "any" }, + { kind: "dailyrace", signer: "operator", status: "active", count: "any" }, ]; // ---- spec section 5: leaf schemas ---- @@ -78,6 +81,9 @@ const runHash = (id) => shaHex(`chartrunner:run:${id}`); const cupLeaf = (cupId, s) => ({ v: 1, k: "cup", id: cupId, rank: s.rank, u: userHash(s.userId), h: handleHash(s.name), eq: s.equity, pct: s.pnlPct, tr: s.trades, dq: s.disqualified, fh: s.fillsHash }); const boardLeaf = (kind, date, r) => ({ v: 1, k: kind, d: date, rank: r.rank, u: userHash(r.userId), h: handleHash(r.handle), s: r.score, dist: r.distance, r: runHash(r.runId) }); +// SPEC 7.3 / 7.4 +const dailyRunLeaf = (date, r) => ({ v: 1, k: "dailyrun", d: date, rank: r.rank, u: userHash(r.userId), h: handleHash(r.handle), s: r.score, dist: r.distance, hc: r.hardcore, fin: r.finished, r: runHash(r.runId) }); +const dailyRaceLeaf = (date, r) => ({ v: 1, k: "dailyrace", d: date, rank: r.rank, u: userHash(r.userId), h: handleHash(r.handle), ft: r.finishTick, dist: r.distance, hc: r.hardcore, r: runHash(r.runId) }); // ---- inputs (all synthetic) ---- const canonicalCases = [ @@ -127,6 +133,21 @@ const boardRows = [ { rank: 2, userId: "user-0003", handle: "Cy", runId: "run-0003", score: 75, distance: 30 }, ]; +// Daily Run: sorted by score desc (ties share a rank); a HARDCORE row and a best run that did not survive. +const dailyModesDate = "2026-10-12"; +const dailyRunRows = [ + { rank: 1, userId: "user-0001", handle: "Alice", runId: "run-0101", score: 120, distance: 41, hardcore: true, finished: true }, + { rank: 2, userId: "user-0002", handle: "bob", runId: "run-0102", score: 88, distance: 35, hardcore: false, finished: true }, + { rank: 2, userId: "user-0003", handle: "Cy", runId: "run-0103", score: 88, distance: 22, hardcore: false, finished: false }, + { rank: 4, userId: "user-0004", handle: "Dee", runId: "run-0104", score: 0, distance: 3, hardcore: true, finished: false }, +]; +// Daily Race: arrived races only, sorted by finish tick asc (ties share a rank). +const dailyRaceRows = [ + { rank: 1, userId: "user-0002", handle: "bob", runId: "run-0201", finishTick: 5400, distance: 60, hardcore: false }, + { rank: 1, userId: "user-0003", handle: "Cy", runId: "run-0202", finishTick: 5400, distance: 60, hardcore: true }, + { rank: 3, userId: "user-0001", handle: "Alice", runId: "run-0203", finishTick: 7199, distance: 60, hardcore: false }, +]; + const R = "0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92"; const validMemos = [ ["chartrunner:v1:cup:cup-2026.10:" + R + ":3", { kind: "cup", id: "cup-2026.10", root: R, count: 3 }], @@ -138,6 +159,8 @@ const validMemos = [ ["chartrunner:v1:strategy:s-1:" + R + ":1", { kind: "strategy", id: "s-1", root: R, count: 1 }], ["chartrunner:v1:bot:b-1:" + R + ":1", { kind: "bot", id: "b-1", root: R, count: 1 }], ["chartrunner:v1:backtest:bt-1:" + R + ":1", { kind: "backtest", id: "bt-1", root: R, count: 1 }], + ["chartrunner:v1:dailyrun:2026-10-12:" + R + ":4", { kind: "dailyrun", id: "2026-10-12", root: R, count: 4 }], + ["chartrunner:v1:dailyrace:2026-10-12:" + R + ":3", { kind: "dailyrace", id: "2026-10-12", root: R, count: 3 }], ]; const invalidMemos = [ ["empty", ""], @@ -159,6 +182,9 @@ const invalidMemos = [ ["legacy cr1 memo", `cr1:map:Example:${"ab".repeat(32)}`], ["user kind with count 2", `chartrunner:v1:map:e1:${R}:2`], ["user kind with a free-text name as id", `chartrunner:v1:map:My Map:${R}:1`], + ["hyphenated kind", `chartrunner:v1:daily-run:2026-10-12:${R}:3`], + ["kind with an underscore", `chartrunner:v1:daily_race:2026-10-12:${R}:3`], + ["mixed-case new kind", `chartrunner:v1:dailyRun:2026-10-12:${R}:3`], ]; // ---- spec section 10: legacy cr1 (read-only) ---- @@ -223,6 +249,8 @@ function build() { const cupLeaves = standings.map((s) => cupLeaf(cupId, s)); const boardLeaves = boardRows.map((r) => boardLeaf("daily", boardDate, r)); + const runLeaves = dailyRunRows.map((r) => dailyRunLeaf(dailyModesDate, r)); + const raceLeaves = dailyRaceRows.map((r) => dailyRaceLeaf(dailyModesDate, r)); const a = leafHash(canonical({ id: "a", s: 1 })); const b = leafHash(canonical({ id: "b", s: 2 })); @@ -254,10 +282,14 @@ function build() { valid: validCr1.map(([text, fields]) => ({ text, fields })), invalid: invalidCr1.map(([name, text]) => ({ name, text })), }, + // Appended for the dailyrun / dailyrace kinds (SPEC 7.3, 7.4). + dailyRun: { kind: "dailyrun", date: dailyModesDate, rows: dailyRunRows, leaves: runLeaves, ...withProofs(treeVector("dailyrun board leaves", "dailyrun", dailyModesDate, runLeaves)) }, + dailyRace: { kind: "dailyrace", date: dailyModesDate, rows: dailyRaceRows, leaves: raceLeaves, ...withProofs(treeVector("dailyrace board leaves", "dailyrace", dailyModesDate, raceLeaves)) }, }; } const pick = (t) => ({ root: t.root, count: t.count, memo: t.memo }); +const withProofs = (t) => ({ ...pick(t), sortedLeaves: t.sortedLeaves, proofs: t.proofs }); const text = `${JSON.stringify(build(), null, 2)}\n`; const mode = process.argv[2]; diff --git a/test-vectors/v1.json b/test-vectors/v1.json index 700b09f..e711b9d 100644 --- a/test-vectors/v1.json +++ b/test-vectors/v1.json @@ -52,6 +52,18 @@ "signer": "user", "status": "planned", "count": "one" + }, + { + "kind": "dailyrun", + "signer": "operator", + "status": "active", + "count": "any" + }, + { + "kind": "dailyrace", + "signer": "operator", + "status": "active", + "count": "any" } ], "pseudonyms": [ @@ -1065,6 +1077,24 @@ "root": "0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92", "count": 1 } + }, + { + "text": "chartrunner:v1:dailyrun:2026-10-12:0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92:4", + "fields": { + "kind": "dailyrun", + "id": "2026-10-12", + "root": "0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92", + "count": 4 + } + }, + { + "text": "chartrunner:v1:dailyrace:2026-10-12:0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92:3", + "fields": { + "kind": "dailyrace", + "id": "2026-10-12", + "root": "0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92", + "count": 3 + } } ], "invalid": [ @@ -1143,6 +1173,18 @@ { "name": "user kind with a free-text name as id", "text": "chartrunner:v1:map:My Map:0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92:1" + }, + { + "name": "hyphenated kind", + "text": "chartrunner:v1:daily-run:2026-10-12:0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92:3" + }, + { + "name": "kind with an underscore", + "text": "chartrunner:v1:daily_race:2026-10-12:0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92:3" + }, + { + "name": "mixed-case new kind", + "text": "chartrunner:v1:dailyRun:2026-10-12:0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92:3" } ] }, @@ -1193,5 +1235,312 @@ "text": "cr2:map:Name:abababababababababababababababababababababababababababababababab" } ] + }, + "dailyRun": { + "kind": "dailyrun", + "date": "2026-10-12", + "rows": [ + { + "rank": 1, + "userId": "user-0001", + "handle": "Alice", + "runId": "run-0101", + "score": 120, + "distance": 41, + "hardcore": true, + "finished": true + }, + { + "rank": 2, + "userId": "user-0002", + "handle": "bob", + "runId": "run-0102", + "score": 88, + "distance": 35, + "hardcore": false, + "finished": true + }, + { + "rank": 2, + "userId": "user-0003", + "handle": "Cy", + "runId": "run-0103", + "score": 88, + "distance": 22, + "hardcore": false, + "finished": false + }, + { + "rank": 4, + "userId": "user-0004", + "handle": "Dee", + "runId": "run-0104", + "score": 0, + "distance": 3, + "hardcore": true, + "finished": false + } + ], + "leaves": [ + { + "v": 1, + "k": "dailyrun", + "d": "2026-10-12", + "rank": 1, + "u": "cd1b65904801cca9643aaf213b69368b1725517f018a75f2b2548a152584cf10", + "h": "f0a86417a98d7c30b21117edb4cf41047add8ac05215fe7ed7fe4708143b07ee", + "s": 120, + "dist": 41, + "hc": true, + "fin": true, + "r": "7dc8f6001af6f32f7af48784f83e0dc904364df93f92beb23bd8f0a4cc2d6ad4" + }, + { + "v": 1, + "k": "dailyrun", + "d": "2026-10-12", + "rank": 2, + "u": "a6d5c991038c029b47c91c6eb02528bdb8cfbf7599d42050b45bf1dfe4fe8310", + "h": "9f867447463fd16b1713794175b80e4012bfd1e3b643495788a069b0a18a4a65", + "s": 88, + "dist": 35, + "hc": false, + "fin": true, + "r": "332174895e3107f7493cd37116ee36afb22969471fbb796607e6e7885bf1074b" + }, + { + "v": 1, + "k": "dailyrun", + "d": "2026-10-12", + "rank": 2, + "u": "ebba193792b200e77597b72e0519249a826db487dee7f7e3a6c0ef10eaae81c6", + "h": "37e5c092ea11c0e8377ed7cb67655cbe9743db9622c32c5043ac103184e8564a", + "s": 88, + "dist": 22, + "hc": false, + "fin": false, + "r": "615281b48859ceb793b4a70af5247767c397e52ff08da31f03b008f0220d0d28" + }, + { + "v": 1, + "k": "dailyrun", + "d": "2026-10-12", + "rank": 4, + "u": "58a3f3097992ceead2bedeefe1187304e12a0ce7935b289677d85fa5d84132b4", + "h": "263a5c92fd278e16d654812a8a6ad1a81770891a20d624f2a135623bf20e4938", + "s": 0, + "dist": 3, + "hc": true, + "fin": false, + "r": "51e6e5b7d1fa49623cd979bc037742a5d183cb54356dbed13a3bf97415489aec" + } + ], + "root": "65d46a4da638bb6610987e62e96afd8641aa171130b58fe78544162753c5be41", + "count": 4, + "memo": "chartrunner:v1:dailyrun:2026-10-12:65d46a4da638bb6610987e62e96afd8641aa171130b58fe78544162753c5be41:4", + "sortedLeaves": [ + { + "canonical": "{\"d\":\"2026-10-12\",\"dist\":22,\"fin\":false,\"h\":\"37e5c092ea11c0e8377ed7cb67655cbe9743db9622c32c5043ac103184e8564a\",\"hc\":false,\"k\":\"dailyrun\",\"r\":\"615281b48859ceb793b4a70af5247767c397e52ff08da31f03b008f0220d0d28\",\"rank\":2,\"s\":88,\"u\":\"ebba193792b200e77597b72e0519249a826db487dee7f7e3a6c0ef10eaae81c6\",\"v\":1}", + "hash": "15789cd5852b86acc1439f1b636d2ce0d290612a5fea282a84039bb7f8f51a87" + }, + { + "canonical": "{\"d\":\"2026-10-12\",\"dist\":35,\"fin\":true,\"h\":\"9f867447463fd16b1713794175b80e4012bfd1e3b643495788a069b0a18a4a65\",\"hc\":false,\"k\":\"dailyrun\",\"r\":\"332174895e3107f7493cd37116ee36afb22969471fbb796607e6e7885bf1074b\",\"rank\":2,\"s\":88,\"u\":\"a6d5c991038c029b47c91c6eb02528bdb8cfbf7599d42050b45bf1dfe4fe8310\",\"v\":1}", + "hash": "3b4c2fc138bb52f1c685dbf27ad6bbde142273d16e4e3bbab73ab245db920bc4" + }, + { + "canonical": "{\"d\":\"2026-10-12\",\"dist\":41,\"fin\":true,\"h\":\"f0a86417a98d7c30b21117edb4cf41047add8ac05215fe7ed7fe4708143b07ee\",\"hc\":true,\"k\":\"dailyrun\",\"r\":\"7dc8f6001af6f32f7af48784f83e0dc904364df93f92beb23bd8f0a4cc2d6ad4\",\"rank\":1,\"s\":120,\"u\":\"cd1b65904801cca9643aaf213b69368b1725517f018a75f2b2548a152584cf10\",\"v\":1}", + "hash": "5488de3dc4001baf1fb6ffb6c6f625b4614efab314c482c95abe2c3d0868459a" + }, + { + "canonical": "{\"d\":\"2026-10-12\",\"dist\":3,\"fin\":false,\"h\":\"263a5c92fd278e16d654812a8a6ad1a81770891a20d624f2a135623bf20e4938\",\"hc\":true,\"k\":\"dailyrun\",\"r\":\"51e6e5b7d1fa49623cd979bc037742a5d183cb54356dbed13a3bf97415489aec\",\"rank\":4,\"s\":0,\"u\":\"58a3f3097992ceead2bedeefe1187304e12a0ce7935b289677d85fa5d84132b4\",\"v\":1}", + "hash": "683b83149a38d40e824f2cdef5817fa78f8a1a5dc0f3e32e41cb7aabd754174a" + } + ], + "proofs": [ + { + "index": 0, + "leaf": "{\"d\":\"2026-10-12\",\"dist\":22,\"fin\":false,\"h\":\"37e5c092ea11c0e8377ed7cb67655cbe9743db9622c32c5043ac103184e8564a\",\"hc\":false,\"k\":\"dailyrun\",\"r\":\"615281b48859ceb793b4a70af5247767c397e52ff08da31f03b008f0220d0d28\",\"rank\":2,\"s\":88,\"u\":\"ebba193792b200e77597b72e0519249a826db487dee7f7e3a6c0ef10eaae81c6\",\"v\":1}", + "proof": [ + { + "side": "R", + "hash": "3b4c2fc138bb52f1c685dbf27ad6bbde142273d16e4e3bbab73ab245db920bc4" + }, + { + "side": "R", + "hash": "963008e7d5d40ba0ed3c1ac851973bee83f8ef1aaf85001061f2fa36b0a122c0" + } + ] + }, + { + "index": 1, + "leaf": "{\"d\":\"2026-10-12\",\"dist\":35,\"fin\":true,\"h\":\"9f867447463fd16b1713794175b80e4012bfd1e3b643495788a069b0a18a4a65\",\"hc\":false,\"k\":\"dailyrun\",\"r\":\"332174895e3107f7493cd37116ee36afb22969471fbb796607e6e7885bf1074b\",\"rank\":2,\"s\":88,\"u\":\"a6d5c991038c029b47c91c6eb02528bdb8cfbf7599d42050b45bf1dfe4fe8310\",\"v\":1}", + "proof": [ + { + "side": "L", + "hash": "15789cd5852b86acc1439f1b636d2ce0d290612a5fea282a84039bb7f8f51a87" + }, + { + "side": "R", + "hash": "963008e7d5d40ba0ed3c1ac851973bee83f8ef1aaf85001061f2fa36b0a122c0" + } + ] + }, + { + "index": 2, + "leaf": "{\"d\":\"2026-10-12\",\"dist\":41,\"fin\":true,\"h\":\"f0a86417a98d7c30b21117edb4cf41047add8ac05215fe7ed7fe4708143b07ee\",\"hc\":true,\"k\":\"dailyrun\",\"r\":\"7dc8f6001af6f32f7af48784f83e0dc904364df93f92beb23bd8f0a4cc2d6ad4\",\"rank\":1,\"s\":120,\"u\":\"cd1b65904801cca9643aaf213b69368b1725517f018a75f2b2548a152584cf10\",\"v\":1}", + "proof": [ + { + "side": "R", + "hash": "683b83149a38d40e824f2cdef5817fa78f8a1a5dc0f3e32e41cb7aabd754174a" + }, + { + "side": "L", + "hash": "25b8510f6cdf3cf46ccb34d0ee9b06f607a6a549f6ca5cb2a35196774d75a444" + } + ] + }, + { + "index": 3, + "leaf": "{\"d\":\"2026-10-12\",\"dist\":3,\"fin\":false,\"h\":\"263a5c92fd278e16d654812a8a6ad1a81770891a20d624f2a135623bf20e4938\",\"hc\":true,\"k\":\"dailyrun\",\"r\":\"51e6e5b7d1fa49623cd979bc037742a5d183cb54356dbed13a3bf97415489aec\",\"rank\":4,\"s\":0,\"u\":\"58a3f3097992ceead2bedeefe1187304e12a0ce7935b289677d85fa5d84132b4\",\"v\":1}", + "proof": [ + { + "side": "L", + "hash": "5488de3dc4001baf1fb6ffb6c6f625b4614efab314c482c95abe2c3d0868459a" + }, + { + "side": "L", + "hash": "25b8510f6cdf3cf46ccb34d0ee9b06f607a6a549f6ca5cb2a35196774d75a444" + } + ] + } + ] + }, + "dailyRace": { + "kind": "dailyrace", + "date": "2026-10-12", + "rows": [ + { + "rank": 1, + "userId": "user-0002", + "handle": "bob", + "runId": "run-0201", + "finishTick": 5400, + "distance": 60, + "hardcore": false + }, + { + "rank": 1, + "userId": "user-0003", + "handle": "Cy", + "runId": "run-0202", + "finishTick": 5400, + "distance": 60, + "hardcore": true + }, + { + "rank": 3, + "userId": "user-0001", + "handle": "Alice", + "runId": "run-0203", + "finishTick": 7199, + "distance": 60, + "hardcore": false + } + ], + "leaves": [ + { + "v": 1, + "k": "dailyrace", + "d": "2026-10-12", + "rank": 1, + "u": "a6d5c991038c029b47c91c6eb02528bdb8cfbf7599d42050b45bf1dfe4fe8310", + "h": "9f867447463fd16b1713794175b80e4012bfd1e3b643495788a069b0a18a4a65", + "ft": 5400, + "dist": 60, + "hc": false, + "r": "ef6d18ed5e5e81483b1765b08763241ef6a410fe2dee8736755da5d13eba91b7" + }, + { + "v": 1, + "k": "dailyrace", + "d": "2026-10-12", + "rank": 1, + "u": "ebba193792b200e77597b72e0519249a826db487dee7f7e3a6c0ef10eaae81c6", + "h": "37e5c092ea11c0e8377ed7cb67655cbe9743db9622c32c5043ac103184e8564a", + "ft": 5400, + "dist": 60, + "hc": true, + "r": "6d2ce8630d8467200a3f5f3a57a3f3d1482180517b40a064d34c2b66cfc87309" + }, + { + "v": 1, + "k": "dailyrace", + "d": "2026-10-12", + "rank": 3, + "u": "cd1b65904801cca9643aaf213b69368b1725517f018a75f2b2548a152584cf10", + "h": "f0a86417a98d7c30b21117edb4cf41047add8ac05215fe7ed7fe4708143b07ee", + "ft": 7199, + "dist": 60, + "hc": false, + "r": "026735ff98df8d77908fb2e3e9371df8ef76cbc7fc501cf4ec7c26b04f2afccf" + } + ], + "root": "63e576a0c4e0cbac4966e346aa6fda37fa11b4830526155e83c4333c99c9b15b", + "count": 3, + "memo": "chartrunner:v1:dailyrace:2026-10-12:63e576a0c4e0cbac4966e346aa6fda37fa11b4830526155e83c4333c99c9b15b:3", + "sortedLeaves": [ + { + "canonical": "{\"d\":\"2026-10-12\",\"dist\":60,\"ft\":7199,\"h\":\"f0a86417a98d7c30b21117edb4cf41047add8ac05215fe7ed7fe4708143b07ee\",\"hc\":false,\"k\":\"dailyrace\",\"r\":\"026735ff98df8d77908fb2e3e9371df8ef76cbc7fc501cf4ec7c26b04f2afccf\",\"rank\":3,\"u\":\"cd1b65904801cca9643aaf213b69368b1725517f018a75f2b2548a152584cf10\",\"v\":1}", + "hash": "35948f86d615b3bfc26cb53a8c6daef050d82c5b53984abc3fd44c03ae090009" + }, + { + "canonical": "{\"d\":\"2026-10-12\",\"dist\":60,\"ft\":5400,\"h\":\"9f867447463fd16b1713794175b80e4012bfd1e3b643495788a069b0a18a4a65\",\"hc\":false,\"k\":\"dailyrace\",\"r\":\"ef6d18ed5e5e81483b1765b08763241ef6a410fe2dee8736755da5d13eba91b7\",\"rank\":1,\"u\":\"a6d5c991038c029b47c91c6eb02528bdb8cfbf7599d42050b45bf1dfe4fe8310\",\"v\":1}", + "hash": "6a79555c917d0ae4acd3d97cad3d47025e50112e55ba5e4f7615eb3c63094289" + }, + { + "canonical": "{\"d\":\"2026-10-12\",\"dist\":60,\"ft\":5400,\"h\":\"37e5c092ea11c0e8377ed7cb67655cbe9743db9622c32c5043ac103184e8564a\",\"hc\":true,\"k\":\"dailyrace\",\"r\":\"6d2ce8630d8467200a3f5f3a57a3f3d1482180517b40a064d34c2b66cfc87309\",\"rank\":1,\"u\":\"ebba193792b200e77597b72e0519249a826db487dee7f7e3a6c0ef10eaae81c6\",\"v\":1}", + "hash": "8bf057b0680958a74bd9c2481a5a839b535a4059510a2d6fc97cd86045c44595" + } + ], + "proofs": [ + { + "index": 0, + "leaf": "{\"d\":\"2026-10-12\",\"dist\":60,\"ft\":7199,\"h\":\"f0a86417a98d7c30b21117edb4cf41047add8ac05215fe7ed7fe4708143b07ee\",\"hc\":false,\"k\":\"dailyrace\",\"r\":\"026735ff98df8d77908fb2e3e9371df8ef76cbc7fc501cf4ec7c26b04f2afccf\",\"rank\":3,\"u\":\"cd1b65904801cca9643aaf213b69368b1725517f018a75f2b2548a152584cf10\",\"v\":1}", + "proof": [ + { + "side": "R", + "hash": "6a79555c917d0ae4acd3d97cad3d47025e50112e55ba5e4f7615eb3c63094289" + }, + { + "side": "R", + "hash": "8bf057b0680958a74bd9c2481a5a839b535a4059510a2d6fc97cd86045c44595" + } + ] + }, + { + "index": 1, + "leaf": "{\"d\":\"2026-10-12\",\"dist\":60,\"ft\":5400,\"h\":\"9f867447463fd16b1713794175b80e4012bfd1e3b643495788a069b0a18a4a65\",\"hc\":false,\"k\":\"dailyrace\",\"r\":\"ef6d18ed5e5e81483b1765b08763241ef6a410fe2dee8736755da5d13eba91b7\",\"rank\":1,\"u\":\"a6d5c991038c029b47c91c6eb02528bdb8cfbf7599d42050b45bf1dfe4fe8310\",\"v\":1}", + "proof": [ + { + "side": "L", + "hash": "35948f86d615b3bfc26cb53a8c6daef050d82c5b53984abc3fd44c03ae090009" + }, + { + "side": "R", + "hash": "8bf057b0680958a74bd9c2481a5a839b535a4059510a2d6fc97cd86045c44595" + } + ] + }, + { + "index": 2, + "leaf": "{\"d\":\"2026-10-12\",\"dist\":60,\"ft\":5400,\"h\":\"37e5c092ea11c0e8377ed7cb67655cbe9743db9622c32c5043ac103184e8564a\",\"hc\":true,\"k\":\"dailyrace\",\"r\":\"6d2ce8630d8467200a3f5f3a57a3f3d1482180517b40a064d34c2b66cfc87309\",\"rank\":1,\"u\":\"ebba193792b200e77597b72e0519249a826db487dee7f7e3a6c0ef10eaae81c6\",\"v\":1}", + "proof": [ + { + "side": "L", + "hash": "16cee3d104a3cb93ab7f19a02649d6ee18c4e0d78177dae68527cf740b9564c3" + } + ] + } + ] } }