From a5d9d61d146aca270cd0d5ee76d6ca8d634432bb Mon Sep 17 00:00:00 2001 From: 0xNyk <0xnykcd@googlemail.com> Date: Tue, 13 Oct 2026 14:07:49 +0200 Subject: [PATCH 1/2] feat(spec): register dailyrun and dailyrace operator kinds The Daily Run and Daily Race boards of the daily chart get their own anchor kinds (SPEC 2.2, 7.3, 7.4, 9.1): operator signer, id = UTC date, count = rows. - dailyrun leaf: {v,k,d,rank,u,h,s,dist,hc,fin,r}, ranked by score - dailyrace leaf: {v,k,d,rank,u,h,ft,dist,hc,r}, ranked by finish tick (lowest first) - SDK: dailyRunLeaf, dailyRaceLeaf, rankRowsBy; ANCHOR_KINDS keeps registration order - vectors appended (kinds, dailyRun, dailyRace, 2 valid + 3 invalid memos); every existing vector value is unchanged (git diff: 0 removed lines) - longest valid memo is now 164 bytes (9-character kind) --- README.md | 7 +- SPEC.md | 73 +++- packages/verify/README.md | 12 +- packages/verify/src/__tests__/cli.test.ts | 14 +- .../verify/src/__tests__/registry.test.ts | 11 +- packages/verify/src/__tests__/sdk.test.ts | 24 ++ packages/verify/src/__tests__/vectors.test.ts | 47 ++- packages/verify/src/kinds.ts | 16 +- packages/verify/src/leaves.ts | 80 ++++ scripts/gen-vectors.mjs | 32 ++ test-vectors/v1.json | 349 ++++++++++++++++++ 11 files changed, 651 insertions(+), 14 deletions(-) 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..62a3c5b 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). @@ -76,6 +77,8 @@ Signer models: | `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 | | `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,6 +358,56 @@ 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. +### 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. + 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. @@ -400,9 +459,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 +509,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" + } + ] + } + ] } } From c4039a1a3191791e9e891ed42963f60ead5d8f5a Mon Sep 17 00:00:00 2001 From: 0xNyk <0xnykcd@googlemail.com> Date: Tue, 13 Oct 2026 14:27:50 +0200 Subject: [PATCH 2/2] docs(spec): review nits: shared leaf note as 7.5, daily row says runs without a mode --- SPEC.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/SPEC.md b/SPEC.md index 62a3c5b..68821b9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -75,7 +75,7 @@ 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 | @@ -408,8 +408,10 @@ lowest run id). A race that timed out or died is stored but never ranked, so it 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. -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.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