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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

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 leaderboard day (free play, and the
Daily Run, Daily Race and pre-modes boards of the daily chart):
ChartRunner writes one line to Solana for every frozen Trading Cup, every closed leaderboard day (free play, and the
Daily Run, Daily Race and pre-modes boards of the daily chart) and, for battles, every 4-hour UTC window in which at
least one battle was frozen:

```
chartrunner:v1:<kind>:<id>:<merkle root>:<row count>
Expand Down Expand Up @@ -41,6 +42,9 @@ What is live on mainnet today:
address `68tWAPfHGkfVzNK1D7PxzVrBrDbtiyqmW2wddwqEmVhy` ([SPEC.md section 2.6](SPEC.md#26-operator-addresses)).
35 anchors (27 cups, 6 free-play boards, 1 daily board, 1 Daily Race board) were posted between 2026-10-06 and
2026-10-15. The full list is at <https://app.chartrunner.xyz/api/anchors?limit=100>.
- Battles have their own kind, `battle` (id the 4-hour UTC window `YYYY-MM-DDTHH`, one leaf per final standing of every
battle frozen in it), specified in [SPEC.md section 7.5](SPEC.md#75-battle-leaf-battle). Its first anchor follows the
app release that writes it.
- The app's `/verify` page runs the library in this repository in the browser.

Check a real cup yourself in about a minute (Node 24, pnpm 11, any mainnet RPC):
Expand Down
122 changes: 109 additions & 13 deletions SPEC.md

Large diffs are not rendered by default.

20 changes: 14 additions & 6 deletions packages/verify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ chartrunner:v1:<kind>:<id>:<root>:<count>

| field | rule |
| ------- | ----------------------------------------------------------------- |
| `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, ...) |
| `kind` | operator kinds `freeplay`, `daily`, `cup`, `xp`, `dailyrun`, `dailyrace`, `battle`; 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, ...); for `battle`, the 4-hour UTC window `YYYY-MM-DDTHH` on a real date, `HH` one of `00 04 08 12 16 20` |
| `root` | 64 lowercase hex characters |
| `count` | number of rows, decimal, 1 to 999999999, no leading zeros |
| `count` | number of rows, decimal, 1 to 999999999, no leading zeros; at most 10000 for `battle` |

Parsing is strict: whitespace, other case, extra fields, other versions and out-of-range counts are all rejected.

Expand All @@ -60,7 +60,7 @@ The leaf count is not hashed into the root; the memo carries it next to the root

A cup row (`cupLeaf`) is `{v:1, k:"cup", id, rank, u, h, eq, pct, tr, dq, fh}`: `u`/`h` are sha256 pseudonyms of the
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.
the disqualified flag and `fh` the hash of the fills (`fillsHash`, SPEC 7.2.1).

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
Expand All @@ -69,6 +69,12 @@ hc, fin, r}` with `hc` the HARDCORE flag and `fin` whether the run survived the
(`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.

A battle row (`battleLeaf`, kind `battle`) is `{v:1, k:"battle", id, b, rank, u, h, eq, pct, tr, out, fin, fh}`: `id` the
4-hour UTC window the battle's results were frozen in, `b` the battle's lowercase UUID (never its join code), `u`/`h`,
`eq`, `pct`, `tr` and `fh` as in a cup row (`eq` with exactly 6 decimals, `pct` with exactly 4, `-` when negative),
`out` knocked out (Last Man Standing) and `fin` reached the target (Profit Race). `battleLeaf` refuses any input outside
the schema; `battleLeafProblem` / `isBattleLeaf` check a built leaf. See SPEC.md section 7.5.

## Install

Not published to npm yet. Build it from a checkout of this repository:
Expand Down Expand Up @@ -164,9 +170,11 @@ 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`, `dailyRunLeaf`, `dailyRaceLeaf`, `cupLeavesFromStandings`, `userHash`, `handleHash`, `runHash` | build the exact leaves the app anchors |
| `cupLeaf`, `boardLeaf`, `dailyRunLeaf`, `dailyRaceLeaf`, `battleLeaf`, `cupLeavesFromStandings`, `userHash`, `handleHash`, `runHash` | build the exact leaves the app anchors |
| `battleLeafProblem`, `isBattleLeaf`, `isBattleWindowId`, `MAX_BATTLE_LEAVES` | check a battle leaf and a battle window id against SPEC 7.5 and 2.2 |
| `fillsHash`, `fillsCanonical`, `fillsCommitment`, `CUP_ORDER_TYPES`, `BATTLE_ORDER_TYPES` | the `fh` of a cup or battle standing from its fills (SPEC 7.2.1): canonical fill list in submission order plus final-sweep liquidations, SHA-256 |
| `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 |
| `KINDS`, `ANCHOR_KINDS`, `OPERATOR_KINDS`, `USER_KINDS`, `kindInfo`, `isOperatorKind`, `isUserKind`, `kindRuleViolation` | 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 |
| `anchorStanding(result, checked)` | find the anchor being verified in a completed scan and apply the first-finalized-wins rule: `not_in_scan` (uniqueness not checked), `sole`, `duplicate`, `equivocation_first` (same root and count as the first finalized: pass, warn) or `not_first` (fail) |
Expand Down
17 changes: 16 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, dailyRaceLeaf, dailyRunLeaf, formatMemo, merkleProof, merkleRoot } from "../index.ts";
import { battleLeaf, 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 @@ -51,6 +51,21 @@ describe("chartrunner-verify CLI", async () => {
expect(run("--rows", f, "--memo", text, "--kind", kind === "dailyrun" ? "dailyrace" : "dailyrun").status).toBe(1);
}
});
it("verifies battle leaves of one window, pinned with --kind / --id", async () => {
const w = "2026-10-19T04";
const base = { userId: "u1", name: "Al", equity: "10842.120000", pnlPct: "-8.4212", trades: 8, out: true, finished: false, fillsHash: "d".repeat(64) };
const leaves = [
await battleLeaf(w, { ...base, battleId: "3f2b9c1e-8d4a-4b6f-9e21-7c5d0a1b2e3f", rank: 1 }),
await battleLeaf(w, { ...base, battleId: "a0c4e6f8-1b3d-4f5a-8c7e-9d0b2a4c6e81", rank: 2 }),
];
const m = await merkleRoot(leaves);
const text = formatMemo({ kind: "battle", id: w, root: m.root, count: m.count });
const f = write("battle.json", leaves);
expect(run("--rows", f, "--memo", text, "--kind", "battle", "--id", w).status).toBe(0);
expect(run("--rows", f, "--memo", text, "--kind", "battle", "--id", "2026-10-19T08").status).toBe(1);
expect(run("--rows", f, "--memo", text, "--kind", "cup").status).toBe(1);
expect(run("--rows", f, "--memo", text.replace("T04", "T03")).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
10 changes: 9 additions & 1 deletion packages/verify/src/__tests__/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,13 @@ describe("kinds registry", () => {
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", "dailyrun", "dailyrace"].map((k) => kindInfo(k)?.status)).toEqual(["active", "active", "active", "active", "active"]);
expect(ANCHOR_KINDS.slice(0, 10)).toEqual(["freeplay", "daily", "cup", "xp", "map", "strategy", "bot", "backtest", "dailyrun", "dailyrace"]);
expect(ANCHOR_KINDS.at(-1)).toBe("battle");
expect(OPERATOR_KINDS.at(-1)).toBe("battle");
expect(["freeplay", "daily", "cup", "dailyrun", "dailyrace", "battle"].map((k) => kindInfo(k)?.status)).toEqual(["active", "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" });
expect(kindInfo("battle")).toMatchObject({ signer: "operator", status: "active", count: "any", leaf: "battle leaf (SPEC 7.5)" });
});
it("is frozen", () => {
expect(Object.isFrozen(KINDS)).toBe(true);
Expand All @@ -55,6 +59,10 @@ describe("kinds registry", () => {
expect(isOperatorKind("dailyrun")).toBe(true);
expect(isOperatorKind("dailyrace")).toBe(true);
expect(kindInfo("daily-run")).toBeNull();
expect(isOperatorKind("battle")).toBe(true);
expect(isUserKind("battle")).toBe(false);
expect(kindInfo("Battle")).toBeNull();
expect(kindInfo("battles")).toBeNull();
expect(kindInfo("nope")).toBeNull();
expect(kindInfo("__proto__")).toBeNull();
});
Expand Down
Loading
Loading