Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<kind>:<id>:<merkle root>:<row count>
Expand Down Expand Up @@ -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 <https://app.chartrunner.xyz/api/anchors?limit=100>.
Expand Down
81 changes: 75 additions & 6 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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 |
Expand All @@ -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.

Expand Down Expand Up @@ -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":"<hex>","hc":true,"k":"dailyrun","r":"<hex>","rank":1,"s":120,"u":"<hex>","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":"<hex>","hc":false,"k":"dailyrace","r":"<hex>","rank":1,"u":"<hex>","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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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` |
Expand Down
12 changes: 10 additions & 2 deletions packages/verify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ chartrunner:v1:<kind>:<id>:<root>:<count>

| 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 |
Expand All @@ -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:
Expand Down Expand Up @@ -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 |
Expand Down
14 changes: 13 additions & 1 deletion packages/verify/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 9 additions & 2 deletions packages/verify/src/__tests__/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
});
Expand Down
24 changes: 24 additions & 0 deletions packages/verify/src/__tests__/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ import {
boardLeaf,
cupLeaf,
cupLeavesFromStandings,
dailyRaceLeaf,
dailyRunLeaf,
formatMemo,
leafHash,
memoText,
merkleProof,
merkleRoot,
parseMemo,
parseMemoStrict,
rankRows,
rankRowsBy,
serializeRow,
verifyAgainstMemo,
verifyProof,
Expand Down Expand Up @@ -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 }));
Expand Down
Loading
Loading