diff --git a/README.md b/README.md index aa1525d..89ee7e1 100644 --- a/README.md +++ b/README.md @@ -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:::: @@ -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 . +- 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): diff --git a/SPEC.md b/SPEC.md index 68821b9..d858ede 100644 --- a/SPEC.md +++ b/SPEC.md @@ -12,7 +12,8 @@ The key words MUST, MUST NOT, SHOULD and MAY are used as in RFC 2119. ## 1. Overview -ChartRunner commits to a frozen result set (one Trading Cup, or one UTC day of a leaderboard) by writing a single +ChartRunner commits to a frozen result set (one Trading Cup, one UTC day of a leaderboard, or the battles frozen in one +4-hour UTC window) by writing a single line of text to Solana with the SPL Memo program: ``` @@ -39,7 +40,9 @@ string, with no trimming or normalization of any kind: ``` and the first group MUST be one of the kinds in the registry (2.2). For a kind whose count rule is "one" (every user -kind), the count MUST be exactly `1`. +kind), the count MUST be exactly `1`. For `battle`, the id MUST be a battle window (2.2: `YYYY-MM-DDTHH` on a real +date, `HH` one of `00`, `04`, `08`, `12`, `16`, `20`) and the count MUST NOT exceed `10000`. No other kind has a rule +beyond the shared grammar, so these rules change the meaning of no memo of an earlier kind. | field | rule | | ------- | ---- | @@ -51,9 +54,12 @@ 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 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. +`1` for a user kind, an empty or over-long id, an id containing `:` or a space, a `battle` id that is not a battle +window (an hour outside the six, a date that does not exist, a missing or lowercase `T`) or a `battle` count over +`10000`, extra or missing fields, any version 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 `battle` memo is at most 106 bytes (a 13-character id and a count of at most 5 digits), so registering +`battle` leaves the maximum at 164. A battle window's characters (`0-9`, `-`, `T`) are all in the id alphabet. 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). @@ -79,6 +85,7 @@ Signer models: | `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 | +| `battle` | operator | `YYYY-MM-DDTHH` (UTC 4-hour window, `HH` in `00 04 08 12 16 20`) | battle leaf (7.5): one per final standing of every battle frozen in that window | any, at most 10000 | 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 | @@ -98,6 +105,16 @@ without a mode (the board from before the modes), `dailyrun` the Daily Run board 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`. +A battle window is the 4-hour UTC interval `[HH:00, HH+4:00)` of its date: `2026-10-19T04` is 04:00:00.000 up to, not +including, 08:00 UTC on 2026-10-19, and `2026-10-19T20` ends at 00:00 UTC on 2026-10-20. The id is valid only when the +date exists in the Gregorian calendar (year `0001` to `9999`, leap years included) and `HH` is one of the six window +starts; the separator is an uppercase `T`, and there are no minutes, seconds or zone suffix. A battle belongs to the +window in which its results were frozen, wherever it started. One anchor covers every battle frozen in that window, +with one leaf per final standing, and is written only after the window has ended. A window in which no battle was +frozen is not anchored (there is no anchor of zero rows, section 4). A `battle` anchor carries at most 10000 leaves. +How a window with more standings than that would be split is not defined in v1; such a window cannot be anchored +under `battle` until a later revision of this document defines it. + 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. @@ -272,7 +289,7 @@ a data export can be checked cheaply. It is not on chain and not part of the anc proof of their own row from a snapshot of `count` rows was left out, publicly. The memo grammar allows up to 999999999. The reference operator caps a snapshot at 10000 leaves; that cap is an -operating limit, not part of the format. +operating limit, not part of the format, except for `battle`, whose kind rule (2.1, 2.2) makes 10000 the maximum count. ## 6. Proofs and offline verification @@ -352,12 +369,54 @@ One leaf per player: their best run of that day (highest score; the earliest run | `pct` | return in percent, the results document's fixed-decimal string, verbatim | | `tr` | number of exits (integer) | | `dq` | the host's disqualification flag (boolean) | -| `fh` | `fillsHash` of the standing: the hash of the player's validated fills | +| `fh` | `fillsHash` of the standing: the hash of the player's validated fills, defined in 7.2.1 | One leaf per standing, built from the frozen results document only (never from live data). `cupLeavesFromStandings` reads exactly the fields `rank, userId, name, equity, pnlPct, trades, disqualified, fillsHash` from each standing and ignores every other field. +#### 7.2.1 The fills hash `fh` + +`fh` is the same function for a cup standing (7.2) and a battle standing (7.5): + +``` +fh = hex SHA-256( UTF-8( fills_text ) ) lowercase hex, plain SHA-256 (no 0x00 prefix) +fills_text = "[" + canonical text of fill 1 + "," + ... + "," + canonical text of fill n + "]" +``` + +The list holds one object per fill of the player, in the order the server accepted the fills (submission order), +followed by every liquidation the operator's final sweep applied when the results were frozen. Each object is +serialized with the canonical serialization of section 3 (keys sorted, no whitespace); the objects are joined by `,` +with no whitespace. A player with no fills has the list `[]`, so `fh` is +`4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945`. + +Fill number `i` (counting from 1) has exactly these keys and no others (no client ids, no internal flags): + +| key | value | +| ----------- | ----- | +| `seq` | `i` (integer): its position in the list, 1 to n with no gaps, not a stored id | +| `t` | the time of the fill, integer epoch milliseconds | +| `symbol` | the traded symbol (string) | +| `side` | `"long"` or `"short"` | +| `kind` | `"open"`, `"add"`, `"reduce"`, `"close"` or `"liquidation"` | +| `orderType` | cups: `"market"`, `"limit"`, `"stop"` or `"liquidation"`; battles: `"market"` or `"liquidation"` | +| `qty` | quantity, a fixed-decimal string with exactly 10 decimals | +| `price` | fill price, a fixed-decimal string with exactly 8 decimals | +| `fee` | fee, a fixed-decimal string with exactly 8 decimals | +| `realized` | realized profit or loss, a fixed-decimal string with exactly 8 decimals, or `null` | +| `net` | the fill's net result as the server stored it, a fixed-decimal string with exactly 8 decimals, or `null` | +| `leverage` | leverage (integer) | + +A fixed-decimal string (section 3) here is the value written with exactly the stated number of decimals, rounded half +away from zero as ECMAScript `Number.prototype.toFixed` does on the stored number: a leading `-` only when negative, +no `+`, no exponent, no leading zeros. A value that rounds to zero is written without a sign (`"0.00000000"`, never +`"-0.00000000"`). The vectors include an empty list, a three-fill list and a negative-zero case +(`fillsHash` in `test-vectors/v1.json`); the reference is `fillsHash` / `fillsCanonical` in `leaves.ts`. + +This is the hash the app has computed for every cup standing since the first cup anchor, over the stored fills plus the +final sweep's liquidations: writing it down clarifies 7.2 and changes no cup anchor, leaf or vector. `fh` lets a +published fill list be checked for consistency with a standing; it is not proof of fair execution (section 8). + ### 7.3 Daily Run leaf (`dailyrun`) ```json @@ -408,7 +467,37 @@ 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. -### 7.5 All leaf schemas +### 7.5 Battle leaf (`battle`) + +```json +{"b":"","eq":"10842.120000","fh":"","fin":false,"h":"","id":"2026-10-19T04","k":"battle","out":false,"pct":"8.4212","rank":1,"tr":8,"u":"","v":1} +``` + +The keys above are in canonical order (section 3); a battle leaf has exactly these thirteen keys. + +| key | value | why it is in the leaf | +| ------ | ----- | --------------------- | +| `v` | `1` | leaf schema version | +| `k` | `"battle"` | binds the leaf to this kind | +| `id` | the battle window, equal to the memo `id` (2.2) | binds the leaf to its window | +| `b` | the battle's id, a lowercase UUID (`8-4-4-4-12` lowercase hex digits with `-`) | one window holds many battles; binds the standing to its battle. The battle's join code is private and never appears in a leaf | +| `rank` | the standing's rank in the battle's frozen results (integer, 1 or more) | the position the battle's results show | +| `u` | user pseudonym of the account (7, as in 7.2) | lets a player find their own leaf without the raw id | +| `h` | handle pseudonym of the displayed name (7, as in 7.2) | lets a player look up a leaf by name | +| `eq` | final equity: a decimal string with exactly 6 decimals, no sign, no leading zeros (`0.000000` or more) | the final balance the results show | +| `pct` | return in percent: a decimal string with exactly 4 decimals, `-` when negative and no sign otherwise, no leading zeros, never `-0.0000` (written as in the cup vectors: `"-50.0000"`, `"1.0000"`) | the return the results show | +| `tr` | number of exits (integer, 0 or more) | as `tr` in 7.2 | +| `out` | `true` when the player was knocked out (Last Man Standing) (boolean) | knock-out decides a Last Man Standing battle | +| `fin` | `true` when the player reached the target (Profit Race) (boolean) | reaching the target decides a Profit Race battle | +| `fh` | `fillsHash` of the standing, 64 lowercase hex: the fills hash of 7.2.1, the same function as the cup leaf's `fh`: the player's fills in submission order followed by the final sweep's liquidations, battle order types `market` and `liquidation` only | commits the standing to its fills (section 8) | + +One leaf per final standing of each battle whose results were frozen in the window, built from the battle's frozen +results only (never from live data). A player who played two battles in one window has two leaves; they differ in `b`. +Numbers are integers and money and percentages are strings, because the format has no floats (section 3). A writer +MUST NOT anchor a leaf outside this table (the reference `battleLeaf` refuses one, `battleLeafProblem` names the first +violation); a verifier rebuilding a root needs no such check, because any other leaf yields a different root. + +### 7.6 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. @@ -430,13 +519,17 @@ It does not prove: - **Fair play or fair fills.** The anchor proves the published standings were not changed after anchoring. It does not prove the game server's rules, prices, fills or scoring were correct beyond what the server checked before it anchored. A self-consistent but wrong snapshot anchors and verifies like a correct one. -- **The fills themselves.** `fh` commits each cup standing to a hash of its fills, so a published fills list can be - checked for consistency with the standing. That is a consistency check, not proof of fair execution. +- **The fills themselves.** `fh` commits each cup standing (7.2) and each battle standing (7.5) to a hash of its fills + (7.2.1, final-sweep liquidations included), so a published fills list can be checked for consistency with the + standing. That is a consistency check, not proof of fair execution. - **The cup results document as a whole.** The app also publishes `results_hash`, the SHA-256 of a cup's canonical results text. That hash is not on chain. Fields of the results document that are not in the cup leaf (section 7.2) are not covered by the anchor. In particular, the `final` block of a final-stage cup (`qualifierId`, the `finalists` in seed order, `pickedByHost`) is covered by `results_hash` but **not** by the Merkle anchor, whose leaves are the standings only. +- **A battle's results as a whole.** A battle leaf covers only the fields in 7.5. Anything else about a battle (its + join code, its players' settings, its rules or target) is not covered by the anchor, and the battle's join code is + never published by it. - **Completeness by itself.** The root does not stop the operator from leaving a player out before anchoring. The on-chain count makes the number of entries public, and an excluded player can show they have no proof (section 5). - **Who the operator is.** The verifier checks the fee payer against an address you supply. Take it from section 2.6 @@ -446,7 +539,8 @@ It does not prove: - **Anything about a source you do not trust.** If the memo text, the signature or the RPC come from an untrusted source, so does the answer. Use your own RPC or an explorer you trust. - **Privacy.** Pseudonyms hide raw ids, but a handle is public, so anyone can test whether a known handle matches `h`. -- **Late data.** A run submitted after its day was anchored is not in that day's snapshot. +- **Late data.** A run submitted after its day was anchored is not in that day's snapshot. A battle is in the window + its results were frozen in, never in the window it started in. ## 9. Versioning policy @@ -471,6 +565,8 @@ It does not prove: | ---------- | ------ | | 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 | +| 2026-11-03 | `battle` added: operator kind, id the 4-hour UTC window `YYYY-MM-DDTHH` (`HH` in `00 04 08 12 16 20`, a real date), count the number of leaves, at most 10000, leaf 7.5 (the former 7.5 is now 7.6). Vectors appended (`battle`, three valid and eleven invalid memos). No existing vector, hash or kind changed; the longest valid memo stays 164 bytes | +| 2026-11-03 | the fills hash `fh` of cup and battle standings written down (7.2.1): SHA-256 of the canonical fill list in submission order plus the final sweep's liquidations. A clarification of what the app has computed since the first cup anchor; no existing anchor, leaf, schema or vector changed. Vectors appended (`fillsHash`: empty list, three fills, negative zero) | ## 10. Legacy: the `cr1:` memo format @@ -511,11 +607,11 @@ 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, Daily Run, Daily Race and cup leaf schemas, ranking | `leaves.ts` | +| pseudonyms, board, Daily Run, Daily Race, cup and battle 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` | | command-line verifier and exit codes | `cli.ts` | -| kinds registry, signer models, id grammar | `kinds.ts` | +| kinds registry, signer models, id grammar, per-kind memo rules (battle window) | `kinds.ts` | | equivocation scan (2.5) | `equivocation.ts` | | legacy `cr1:` parser (section 10) | `legacy.ts` | diff --git a/packages/verify/README.md b/packages/verify/README.md index 825da3a..77e6bbc 100644 --- a/packages/verify/README.md +++ b/packages/verify/README.md @@ -40,10 +40,10 @@ chartrunner:v1:::: | 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. @@ -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 @@ -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: @@ -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) | diff --git a/packages/verify/src/__tests__/cli.test.ts b/packages/verify/src/__tests__/cli.test.ts index 1d655a2..134efad 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, 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; @@ -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); diff --git a/packages/verify/src/__tests__/registry.test.ts b/packages/verify/src/__tests__/registry.test.ts index e6f2561..d8580a3 100644 --- a/packages/verify/src/__tests__/registry.test.ts +++ b/packages/verify/src/__tests__/registry.test.ts @@ -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); @@ -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(); }); diff --git a/packages/verify/src/__tests__/sdk.test.ts b/packages/verify/src/__tests__/sdk.test.ts index 8a21b8d..6394f94 100644 --- a/packages/verify/src/__tests__/sdk.test.ts +++ b/packages/verify/src/__tests__/sdk.test.ts @@ -3,13 +3,22 @@ import { describe, expect, it } from "vitest"; import * as legacy from "./legacy-merkle"; import * as legacyLeaves from "./legacy-leaves"; import { + battleLeaf, + battleLeafProblem, boardLeaf, cupLeaf, cupLeavesFromStandings, dailyRaceLeaf, dailyRunLeaf, + BATTLE_ORDER_TYPES, + fillsCanonical, + fillsCommitment, + fillsHash, formatMemo, + isBattleLeaf, + isBattleWindowId, leafHash, + MAX_BATTLE_LEAVES, memoText, merkleProof, merkleRoot, @@ -162,6 +171,147 @@ describe("memo format", () => { }); }); +describe("battle kind (SPEC 2.2, 7.5)", () => { + const W = "2026-10-19T04"; + const row = { battleId: "3f2b9c1e-8d4a-4b6f-9e21-7c5d0a1b2e3f", rank: 1, userId: "u", name: "Al", equity: "10842.120000", pnlPct: "8.4212", trades: 8, out: false, finished: false, fillsHash: "a".repeat(64) }; + + it("window ids: a real UTC date and HH in 00, 04, 08, 12, 16, 20, nothing else", () => { + for (const h of ["00", "04", "08", "12", "16", "20"]) expect(isBattleWindowId(`2026-10-19T${h}`)).toBe(true); + for (const id of ["2028-02-29T00", "2000-02-29T20", "2026-12-31T20", "0001-01-01T00", "9999-12-31T20"]) expect(isBattleWindowId(id), id).toBe(true); + const bad = [ + "2026-10-19T03", "2026-10-19T24", "2026-10-19T4", "2026-10-19T004", "2026-10-19t04", "2026-10-19 04", "2026-10-1904", + "2026-10-19", "2026-10-19T04:00", "2026-10-19T04Z", " 2026-10-19T04", "2026-10-19T04\n", "2026-02-30T00", "2026-02-29T00", + "1900-02-29T00", "2026-04-31T00", "2026-13-01T00", "2026-00-10T00", "2026-10-00T00", "0000-01-01T00", "26-10-19T04", + "2026-10-19T04+00", "\uff12\uff10\uff12\uff16-10-19T04", "", + ]; + for (const id of bad) expect(isBattleWindowId(id), JSON.stringify(id)).toBe(false); + expect(isBattleWindowId(undefined)).toBe(false); + expect(isBattleWindowId(2026)).toBe(false); + }); + it("the memo grammar applies the window rule and the 10000-leaf cap to battle only", () => { + const ok = formatMemo({ kind: "battle", id: W, root: ROOT_3, count: MAX_BATTLE_LEAVES }); + expect(parseMemo(ok)).toEqual({ kind: "battle", id: W, root: ROOT_3, count: 10000 }); + expect(parseMemoStrict(ok).ok).toBe(true); + expect(() => formatMemo({ kind: "battle", id: W, root: ROOT_3, count: 10001 })).toThrow(); + expect(() => formatMemo({ kind: "battle", id: "2026-10-19T03", root: ROOT_3, count: 1 })).toThrow(); + for (const text of [`chartrunner:v1:battle:2026-10-19T03:${ROOT_3}:1`, `chartrunner:v1:battle:${W}:${ROOT_3}:10001`, `chartrunner:v1:battle:cup-1:${ROOT_3}:1`]) { + expect(parseMemo(text)).toBeNull(); + expect(parseMemoStrict(text)).toMatchObject({ ok: false, reason: "malformed" }); + } + // Other kinds keep the shared id grammar and the 9-digit count: nothing registered earlier changes meaning. + expect(parseMemo(`chartrunner:v1:cup:2026-10-19T03:${ROOT_3}:10001`)).toEqual({ kind: "cup", id: "2026-10-19T03", root: ROOT_3, count: 10001 }); + // The longest valid memo is still 164 bytes (9-character kind, 64-character id, 9-digit count); a battle memo is at most 106 (count <= 10000). + expect(new TextEncoder().encode(formatMemo({ kind: "dailyrace", id: "a".repeat(64), root: ROOT_3, count: 999999999 })).length).toBe(164); + expect(new TextEncoder().encode(ok).length).toBe(106); + }); + it("builds the leaf in canonical key order", async () => { + const leaf = await battleLeaf(W, row); + expect(serializeRow(leaf)).toBe(`{"b":"${row.battleId}","eq":"10842.120000","fh":"${"a".repeat(64)}","fin":false,"h":"${leaf.h}","id":"${W}","k":"battle","out":false,"pct":"8.4212","rank":1,"tr":8,"u":"${leaf.u}","v":1}`); + // u / h are derived exactly as in a cup leaf. + const cup = await cupLeaf("c", { rank: 1, userId: "u", name: "Al", equity: "1.000000", pnlPct: "0.0000", trades: 0, disqualified: false, fillsHash: "a".repeat(64) }); + expect([leaf.u, leaf.h]).toEqual([cup.u, cup.h]); + expect(isBattleLeaf(leaf, W)).toBe(true); + expect(battleLeafProblem(leaf, "2026-10-19T08")).toMatch(/window/); + }); + it("refuses every input outside the schema", async () => { + const bad: [string, Partial | Record][] = [ + ["uppercase uuid", { battleId: row.battleId.toUpperCase() }], + ["battle code instead of uuid", { battleId: "K7QX2M" }], + ["uuid without dashes", { battleId: row.battleId.replaceAll("-", "") }], + ["rank 0", { rank: 0 }], + ["negative rank", { rank: -1 }], + ["float rank", { rank: 1.5 }], + ["string rank", { rank: "1" }], + ["negative trades", { trades: -1 }], + ["float trades", { trades: 0.5 }], + ["unsafe trades", { trades: 2 ** 53 }], + ["equity with 2 decimals", { equity: "10842.12" }], + ["equity with 7 decimals", { equity: "10842.1200000" }], + ["negative equity", { equity: "-1.000000" }], + ["equity with a leading zero", { equity: "010842.120000" }], + ["equity without integer part", { equity: ".120000" }], + ["equity as a number", { equity: 10842.12 }], + ["pct with 2 decimals", { pnlPct: "8.42" }], + ["pct with a plus sign", { pnlPct: "+8.4212" }], + ["negative zero pct", { pnlPct: "-0.0000" }], + ["pct with a leading zero", { pnlPct: "08.4212" }], + ["pct with exponent", { pnlPct: "8.4212e0" }], + ["out as a string", { out: "false" }], + ["fin as a number", { finished: 0 }], + ["short fills hash", { fillsHash: "a".repeat(63) }], + ["uppercase fills hash", { fillsHash: "A".repeat(64) }], + ["empty user id", { userId: "" }], + ["missing name", { name: undefined }], + ]; + for (const [name, patch] of bad) await expect(battleLeaf(W, { ...row, ...patch } as typeof row), name).rejects.toThrow(/invalid battle leaf/); + for (const w of ["2026-10-19T03", "2026-02-30T00", "2026-10-19"]) await expect(battleLeaf(w, row), w).rejects.toThrow(/invalid battle leaf/); + // Signed decimals are written like the cup vectors: "-" when negative, none when positive or zero. + for (const pnlPct of ["-2.6850", "-100.0000", "0.0000", "1250.5000"]) expect((await battleLeaf(W, { ...row, pnlPct })).pct).toBe(pnlPct); + }); + it("the validator rejects extra, missing or mistyped keys of a built leaf", async () => { + const leaf = await battleLeaf(W, row); + expect(battleLeafProblem(leaf)).toBeNull(); + expect(battleLeafProblem({ ...leaf, code: "K7QX2M" })).toMatch(/exactly the keys/); + const { fh: _fh, ...noFh } = leaf; + void _fh; + expect(battleLeafProblem(noFh)).toMatch(/exactly the keys/); + expect(battleLeafProblem({ ...leaf, v: 2 })).toMatch(/^v /); + expect(battleLeafProblem({ ...leaf, k: "cup" })).toMatch(/^k /); + expect(battleLeafProblem({ ...leaf, u: "x" })).toMatch(/^u /); + expect(battleLeafProblem({ ...leaf, h: null })).toMatch(/^h /); + expect(battleLeafProblem(null)).not.toBeNull(); + expect(battleLeafProblem([leaf])).not.toBeNull(); + }); +}); + +describe("fillsHash (SPEC 7.2.1)", () => { + const fill = { t: 1760846400000, symbol: "SOL", side: "long", kind: "open", orderType: "market", qty: 12.5, price: 187.4321, fee: 1.17145063, realized: null, net: null, leverage: 5 } as const; + + it("commits exactly the documented keys, seq is the 1-based position, extra fields are ignored", () => { + const c = fillsCommitment([{ ...fill, clientId: "x", internal: true } as typeof fill, { ...fill, kind: "close", realized: 1, net: 0.5 }]); + expect(Object.keys(c[0]!).sort()).toEqual(["fee", "kind", "leverage", "net", "orderType", "price", "qty", "realized", "seq", "side", "symbol", "t"]); + expect(c.map((f) => f.seq)).toEqual([1, 2]); + expect(c[1]).toMatchObject({ realized: "1.00000000", net: "0.50000000" }); + }); + it("formats numbers like toFixed, drops the sign of a negative zero, and accepts already fixed strings", async () => { + const c = fillsCommitment([{ ...fill, qty: 1e-11, price: "65000.00000000", fee: -0, realized: -1e-9, net: -12.345 }])[0]!; + expect(c).toMatchObject({ qty: "0.0000000000", price: "65000.00000000", fee: "0.00000000", realized: "0.00000000", net: "-12.34500000" }); + expect(c.qty).toBe((1e-11).toFixed(10)); + expect(await fillsHash([fill])).toBe(await fillsHash([{ ...fill, qty: "12.5000000000", price: "187.43210000", fee: "1.17145063" }])); + expect(await fillsHash([])).toBe("4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945"); + expect(fillsCanonical([])).toBe("[]"); + }); + it("order matters: the list is hashed in submission order", async () => { + const other = { ...fill, kind: "close" as const, t: fill.t + 1 }; + expect(await fillsHash([fill, other])).not.toBe(await fillsHash([other, fill])); + }); + it("refuses values outside the definition", () => { + const bad: [string, Record][] = [ + ["wrong seq", { seq: 2 }], + ["float t", { t: 1.5 }], + ["string t", { t: "1760846400000" }], + ["empty symbol", { symbol: "" }], + ["unknown side", { side: "buy" }], + ["unknown kind", { kind: "flip" }], + ["unknown order type", { orderType: "twap" }], + ["float leverage", { leverage: 2.5 }], + ["NaN price", { price: Number.NaN }], + ["infinite fee", { fee: Number.POSITIVE_INFINITY }], + ["exponent from toFixed", { price: 1e21 }], + ["qty string with 8 decimals", { qty: "12.50000000" }], + ["price string with a plus", { price: `+${"187.43210000"}` }], + ["negative zero string", { fee: "-0.00000000" }], + ["leading zero string", { fee: "01.00000000" }], + ["undefined realized", { realized: undefined }], + ["boolean net", { net: false }], + ]; + for (const [name, patch] of bad) expect(() => fillsCommitment([{ ...fill, ...patch } as typeof fill]), name).toThrow(/invalid fill 1/); + expect(() => fillsCommitment([{ ...fill, orderType: "limit" }], BATTLE_ORDER_TYPES)).toThrow(/orderType/); + expect(() => fillsCommitment([{ ...fill, kind: "liquidation", orderType: "liquidation" }], BATTLE_ORDER_TYPES)).not.toThrow(); + expect(() => fillsCommitment(null as never)).toThrow(); + }); +}); + describe("verifyAgainstMemo", () => { const rows = [A, B, C]; const memo = memoText({ kind: "cup", id: "c1", root: ROOT_3, count: 3 }); diff --git a/packages/verify/src/__tests__/vectors.test.ts b/packages/verify/src/__tests__/vectors.test.ts index 40373cd..cd98738 100644 --- a/packages/verify/src/__tests__/vectors.test.ts +++ b/packages/verify/src/__tests__/vectors.test.ts @@ -3,10 +3,14 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { ANCHOR_KINDS, + battleLeaf, + battleLeafProblem, boardLeaf, cupLeavesFromStandings, dailyRaceLeaf, dailyRunLeaf, + fillsCanonical, + fillsHash, formatMemo, handleHash, hashNode, @@ -34,7 +38,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 = "563ee67172cc80a2343961a12e41b4d0333960a2da6cf1854a7fd56c3e40e6af"; +const VECTORS_SHA256 = "7681b3ee07a8ea7b8ec7ffcf84e787cbe0041964fd2e5549302dfb35c53a99f6"; const raw = readFileSync(VECTORS_URL); const V = JSON.parse(raw.toString("utf8")) as Vectors; @@ -66,6 +70,18 @@ interface Vectors { legacyCr1: { valid: { text: string; fields: ReturnType }[]; invalid: { name: string; text: string }[] }; dailyRun: ModeVector[1], "createdAt">>; dailyRace: ModeVector[1], "createdAt">>; + battle: { + kind: "battle"; + window: string; + standings: Parameters[1][]; + leaves: ResultsRow[]; + root: string; + count: number; + memo: string; + sortedLeaves: { canonical: string; hash: string }[]; + proofs: { index: number; leaf: string; proof: ProofStep[] }[]; + }; + fillsHash: { name: string; fills: Parameters[0]; canonical: string; hash: string }[]; } interface ModeVector { kind: "dailyrun" | "dailyrace"; @@ -174,6 +190,40 @@ describe("Daily Run and Daily Race leaf schemas (SPEC 7.3, 7.4)", () => { }); }); +describe("battle leaf schema (SPEC 7.5)", () => { + it("builds the vector leaves, root and memo from the frozen standings", async () => { + const d = V.battle; + const leaves = await Promise.all(d.standings.map((s) => battleLeaf(d.window, s))); + expect(leaves).toEqual(d.leaves); + for (const l of leaves) expect(battleLeafProblem(l, d.window)).toBeNull(); + expect(await merkleRoot(leaves)).toEqual({ root: d.root, count: d.count }); + expect(formatMemo({ kind: "battle", id: d.window, 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: "battle", id: d.window } })).toMatchObject({ ok: true }); + // Pinned to another window or kind, the same anchor does not verify. + const p0 = d.proofs[0]!; + expect(await verifyAgainstMemo({ leaf: p0.leaf, proof: p0.proof, memoText: d.memo, expect: { kind: "battle", id: "2026-10-19T08" } })).toMatchObject({ ok: false }); + expect(await verifyAgainstMemo({ leaf: p0.leaf, proof: p0.proof, memoText: V.dailyRace.memo })).toMatchObject({ ok: false }); + }); + it("the leaves carry exactly the documented keys, in canonical order", () => { + expect(Object.keys(V.battle.leaves[0]!).sort()).toEqual(["b", "eq", "fh", "fin", "h", "id", "k", "out", "pct", "rank", "tr", "u", "v"]); + expect(V.battle.sortedLeaves[0]!.canonical).toMatch(/^\{"b":"[0-9a-f-]{36}","eq":"[0-9.]+","fh":"[0-9a-f]{64}","fin":(true|false),"h":"[0-9a-f]{64}","id":"2026-10-19T04","k":"battle","out":(true|false),"pct":"-?[0-9.]+","rank":[1-9][0-9]*,"tr":[0-9]+,"u":"[0-9a-f]{64}","v":1\}$/); + }); +}); + +describe("fills hash fh (SPEC 7.2.1)", () => { + for (const v of V.fillsHash) { + it(v.name, async () => { + expect(fillsCanonical(v.fills)).toBe(v.canonical); + expect(await fillsHash(v.fills)).toBe(v.hash); + expect(createHash("sha256").update(v.canonical, "utf8").digest("hex")).toBe(v.hash); + }); + } + it("the empty list is [] and hashes to the documented value", () => { + expect(V.fillsHash[0]).toMatchObject({ canonical: "[]", hash: "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945" }); + }); +}); + 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 8cbcb89..86d3106 100644 --- a/packages/verify/src/kinds.ts +++ b/packages/verify/src/kinds.ts @@ -4,14 +4,14 @@ // 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", "dailyrun", "dailyrace"] as const; +export const OPERATOR_KINDS = ["freeplay", "daily", "cup", "xp", "dailyrun", "dailyrace", "battle"] 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, 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 const ANCHOR_KINDS = ["freeplay", "daily", "cup", "xp", "map", "strategy", "bot", "backtest", "dailyrun", "dailyrace", "battle"] as const; export type OperatorKind = (typeof OPERATOR_KINDS)[number]; export type UserKind = (typeof USER_KINDS)[number]; @@ -44,6 +44,7 @@ export const KINDS: Readonly>> = Object.fr 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" }, + battle: { kind: "battle", signer: "operator", status: "active", id: "4-hour UTC window YYYY-MM-DDTHH (HH 00, 04, 08, 12, 16 or 20)", leaf: "battle leaf (SPEC 7.5)", 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" }, @@ -55,6 +56,42 @@ export const KINDS: Readonly>> = Object.fr /** The id grammar shared by every kind. */ export const ID_RE = /^[A-Za-z0-9._-]{1,64}$/; +/** Most leaves one `battle` anchor may carry (SPEC 7.5). Splitting a busier window is not defined in v1. */ +export const MAX_BATTLE_LEAVES = 10_000; + +const BATTLE_WINDOW_RE = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T(00|04|08|12|16|20)$/; + +/** + * A `battle` id: the 4-hour UTC window `YYYY-MM-DDTHH`, HH one of 00, 04, 08, 12, 16, 20, on a real Gregorian + * calendar date (year 0001 to 9999). Uppercase `T` only; nothing else is accepted. The window is [HH:00, HH+4:00) UTC. + */ +export function isBattleWindowId(id: unknown): id is string { + if (typeof id !== "string") return false; + const m = BATTLE_WINDOW_RE.exec(id); + if (!m) return false; + const year = Number(m[1]); + const month = Number(m[2]); + const day = Number(m[3]); + if (year < 1 || month < 1 || month > 12 || day < 1) return false; + const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]!; + return day <= days; +} + +/** + * The per-kind rules on top of the shared memo grammar (SPEC 2.1, 2.2): a user kind's count is 1; a `battle` id is a + * 4-hour UTC window and its count is at most MAX_BATTLE_LEAVES. Returns a reason, or null when the pair is allowed. + * Every kind registered before `battle` has no rule here beyond the count rule, so no earlier memo changes meaning. + */ +export function kindRuleViolation(kind: AnchorKind, id: string, count: number): string | null { + if (KINDS[kind].count === "one" && count !== 1) return `Kind "${kind}" anchors a single leaf, so its count must be 1.`; + if (kind === "battle") { + if (!isBattleWindowId(id)) return 'A "battle" id must be a 4-hour UTC window YYYY-MM-DDTHH with HH 00, 04, 08, 12, 16 or 20, on a real date.'; + if (count > MAX_BATTLE_LEAVES) return `A "battle" anchor carries at most ${MAX_BATTLE_LEAVES} leaves.`; + } + return null; +} + export const isKind = (k: unknown): k is AnchorKind => typeof k === "string" && (ANCHOR_KINDS as readonly string[]).includes(k); export const isOperatorKind = (k: unknown): k is OperatorKind => typeof k === "string" && (OPERATOR_KINDS as readonly string[]).includes(k); export const isUserKind = (k: unknown): k is UserKind => typeof k === "string" && (USER_KINDS as readonly string[]).includes(k); diff --git a/packages/verify/src/leaves.ts b/packages/verify/src/leaves.ts index e80771c..03ba787 100644 --- a/packages/verify/src/leaves.ts +++ b/packages/verify/src/leaves.ts @@ -1,4 +1,5 @@ -import { sha256Hex, type LeafEntry } from "./merkle.ts"; +import { isBattleWindowId } from "./kinds.ts"; +import { canonicalJson, isHash, sha256Hex, type LeafEntry } from "./merkle.ts"; // Pseudonymous identifiers used inside leaves. Isomorphic: /verify hashes a typed handle the same way the server did. @@ -160,3 +161,196 @@ export async function cupLeavesFromStandings(cupId: string, standings: readonly standings.map((s) => cupLeaf(cupId, { rank: s.rank, userId: s.userId, name: s.name, equity: s.equity, pnlPct: s.pnlPct, trades: s.trades, disqualified: s.disqualified, fillsHash: s.fillsHash })), ); } + +/** A battle id as it appears in a battle leaf: a lowercase UUID (8-4-4-4-12 hex). The battle's join code never does. */ +export const BATTLE_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +/** Battle equity: a non-negative decimal string with exactly 6 decimals, no leading zeros, no sign ("10842.120000"). */ +export const BATTLE_EQUITY_RE = /^(0|[1-9][0-9]*)\.[0-9]{6}$/; +/** Battle return in percent: a decimal string with exactly 4 decimals, "-" when negative, no "+", never "-0.0000". */ +export const BATTLE_PCT_RE = /^(?!-0\.0000$)-?(0|[1-9][0-9]*)\.[0-9]{4}$/; + +/** The part of one frozen battle standing a battle leaf commits to (money values are decimal strings, as in a cup). */ +export interface BattleLeafRow { + /** The battle's id, a lowercase UUID. Never the battle's join code. */ + battleId: string; + /** The standing's rank in the battle's frozen results, 1 or more. */ + rank: number; + userId: string; + /** The displayed name the standings show (hashed into `h` exactly like a cup standing's name). */ + name: string; + /** Final equity, exactly 6 decimals. */ + equity: string; + /** Return in percent, exactly 4 decimals, possibly negative. */ + pnlPct: string; + /** Number of exits, 0 or more. */ + trades: number; + /** Knocked out (Last Man Standing). */ + out: boolean; + /** Reached the target (Profit Race). */ + finished: boolean; + /** `fillsHash` of the standing, defined as for a cup standing (SPEC 7.2): 64 lowercase hex. */ + fillsHash: string; +} + +const BATTLE_LEAF_KEYS = ["b", "eq", "fh", "fin", "h", "id", "k", "out", "pct", "rank", "tr", "u", "v"] as const; +const isNonNegInt = (n: unknown): n is number => typeof n === "number" && Number.isSafeInteger(n) && n >= 0; + +/** + * Why a value is not a valid battle leaf (SPEC 7.5), or null when it is: exactly the keys + * `b, eq, fh, fin, h, id, k, out, pct, rank, tr, u, v`, each with its documented type and format. Pass `windowId` to + * also require the leaf's `id` to be that window (the memo id of the anchor it belongs to). + */ +export function battleLeafProblem(leaf: unknown, windowId?: string): string | null { + if (typeof leaf !== "object" || leaf === null || Array.isArray(leaf)) return "a battle leaf must be a flat object"; + const o = leaf as Record; + const keys = Object.keys(o).sort(); + if (keys.length !== BATTLE_LEAF_KEYS.length || keys.some((k, i) => k !== BATTLE_LEAF_KEYS[i])) return `a battle leaf has exactly the keys ${BATTLE_LEAF_KEYS.join(", ")}`; + if (o.v !== 1) return "v must be 1"; + if (o.k !== "battle") return 'k must be "battle"'; + if (!isBattleWindowId(o.id)) return "id must be a 4-hour UTC window YYYY-MM-DDTHH (HH 00, 04, 08, 12, 16 or 20) on a real date"; + if (windowId !== undefined && o.id !== windowId) return `id must be the anchor's window ${windowId}`; + if (typeof o.b !== "string" || !BATTLE_UUID_RE.test(o.b)) return "b must be a lowercase UUID"; + if (!isNonNegInt(o.rank) || o.rank < 1) return "rank must be an integer, 1 or more"; + if (!isHash(o.u)) return "u must be 64 lowercase hex"; + if (!isHash(o.h)) return "h must be 64 lowercase hex"; + if (typeof o.eq !== "string" || !BATTLE_EQUITY_RE.test(o.eq)) return "eq must be a non-negative decimal string with exactly 6 decimals"; + if (typeof o.pct !== "string" || !BATTLE_PCT_RE.test(o.pct)) return 'pct must be a decimal string with exactly 4 decimals ("-" when negative, never "-0.0000")'; + if (!isNonNegInt(o.tr)) return "tr must be an integer, 0 or more"; + if (typeof o.out !== "boolean") return "out must be a boolean"; + if (typeof o.fin !== "boolean") return "fin must be a boolean"; + if (!isHash(o.fh)) return "fh must be 64 lowercase hex"; + return null; +} + +/** True when `leaf` is a valid battle leaf (SPEC 7.5), optionally of the given window. */ +export const isBattleLeaf = (leaf: unknown, windowId?: string): leaf is LeafEntry => battleLeafProblem(leaf, windowId) === null; + +/** + * One battle leaf (format v1, kind "battle"): one frozen standing of one battle whose results were frozen in the 4-hour + * UTC window `windowId`. `u` / `h` are the pseudonyms of the account and the displayed name (as in a cup leaf), `b` the + * battle's UUID, `eq` / `pct` the final equity and return as fixed-decimal strings, `tr` the exits, `out` knocked out, + * `fin` reached the target, `fh` the hash of the fills. Throws when any input is outside SPEC 7.5. Keep in sync with + * SPEC.md section 7.5. + */ +export async function battleLeaf(windowId: string, row: BattleLeafRow): Promise { + if (typeof row?.userId !== "string" || row.userId === "") throw new Error("invalid battle leaf: userId must be a non-empty string"); + if (typeof row.name !== "string") throw new Error("invalid battle leaf: name must be a string"); + const leaf: LeafEntry = { + v: 1, + k: "battle", + id: windowId, + b: row.battleId, + rank: row.rank, + u: await userHash(row.userId), + h: await handleHash(row.name), + eq: row.equity, + pct: row.pnlPct, + tr: row.trades, + out: row.out, + fin: row.finished, + fh: row.fillsHash, + }; + const problem = battleLeafProblem(leaf); + if (problem) throw new Error(`invalid battle leaf: ${problem}`); + return leaf; +} + +// ---- fills hash (SPEC 7.2.1) ------------------------------------------------------------------------------------- + +/** A decimal fill value: a number (written with toFixed, negative zero without the sign) or an already fixed string. */ +export type FillDecimal = number | string; + +/** + * One fill as the fills hash reads it (SPEC 7.2.1), in the order the server accepted it; the final sweep's liquidations + * come last. Only these fields are committed; any other field of the input (a client id, an internal flag) is ignored. + * `seq` is the 1-based position and is assigned here; when the input carries one, it must equal that position. + */ +export interface FillInput { + seq?: number; + /** Epoch milliseconds (integer). */ + t: number; + symbol: string; + side: "long" | "short"; + kind: "open" | "add" | "reduce" | "close" | "liquidation"; + orderType: "market" | "limit" | "stop" | "liquidation"; + /** 10 decimals. */ + qty: FillDecimal; + /** 8 decimals. */ + price: FillDecimal; + /** 8 decimals. */ + fee: FillDecimal; + /** 8 decimals, or null. */ + realized: FillDecimal | null; + /** 8 decimals, or null. */ + net: FillDecimal | null; + leverage: number; +} + +const FILL_SIDES = ["long", "short"] as const; +const FILL_KINDS = ["open", "add", "reduce", "close", "liquidation"] as const; +/** Order types of cup fills; battle fills use only "market" and "liquidation". */ +export const CUP_ORDER_TYPES = ["market", "limit", "stop", "liquidation"] as const; +export const BATTLE_ORDER_TYPES = ["market", "liquidation"] as const; + +/** Fixed decimal with exactly `d` decimals: optional "-", no leading zeros, never a negative zero. */ +function fixedDecimal(v: unknown, d: number, field: string, i: number): string { + let out: string; + if (typeof v === "number") { + if (!Number.isFinite(v)) throw new Error(`invalid fill ${i}: ${field} must be finite`); + out = v.toFixed(d); + if (/^-0\.0+$/.test(out)) out = out.slice(1); + } else if (typeof v === "string") { + out = v; + } else { + throw new Error(`invalid fill ${i}: ${field} must be a number or a decimal string`); + } + const re = new RegExp(`^-?(0|[1-9][0-9]*)\\.[0-9]{${d}}$`); + if (!re.test(out) || /^-0\.0+$/.test(out)) throw new Error(`invalid fill ${i}: ${field} must be a decimal with exactly ${d} decimals (no exponent, no "+", no negative zero), got ${JSON.stringify(out)}`); + return out; +} + +/** + * The committed form of a fill list (SPEC 7.2.1): one object per fill with exactly the keys + * `fee, kind, leverage, net, orderType, price, qty, realized, seq, side, symbol, t`. Throws on any value outside the + * definition. `orderTypes` narrows the allowed order types (battles: BATTLE_ORDER_TYPES). + */ +export function fillsCommitment(fills: readonly FillInput[], orderTypes: readonly string[] = CUP_ORDER_TYPES): LeafEntry[] { + if (!Array.isArray(fills)) throw new Error("fills must be an array"); + return fills.map((f, idx) => { + const i = idx + 1; + if (typeof f !== "object" || f === null) throw new Error(`invalid fill ${i}: not an object`); + if (f.seq !== undefined && f.seq !== i) throw new Error(`invalid fill ${i}: seq must be its 1-based position ${i}`); + if (!Number.isSafeInteger(f.t)) throw new Error(`invalid fill ${i}: t must be integer epoch milliseconds`); + if (typeof f.symbol !== "string" || f.symbol === "") throw new Error(`invalid fill ${i}: symbol must be a non-empty string`); + if (!(FILL_SIDES as readonly string[]).includes(f.side)) throw new Error(`invalid fill ${i}: side must be long or short`); + if (!(FILL_KINDS as readonly string[]).includes(f.kind)) throw new Error(`invalid fill ${i}: kind must be one of ${FILL_KINDS.join(", ")}`); + if (!orderTypes.includes(f.orderType)) throw new Error(`invalid fill ${i}: orderType must be one of ${orderTypes.join(", ")}`); + if (!Number.isSafeInteger(f.leverage)) throw new Error(`invalid fill ${i}: leverage must be an integer`); + return { + seq: i, + t: f.t, + symbol: f.symbol, + side: f.side, + kind: f.kind, + orderType: f.orderType, + qty: fixedDecimal(f.qty, 10, "qty", i), + price: fixedDecimal(f.price, 8, "price", i), + fee: fixedDecimal(f.fee, 8, "fee", i), + realized: f.realized === null ? null : fixedDecimal(f.realized, 8, "realized", i), + net: f.net === null ? null : fixedDecimal(f.net, 8, "net", i), + leverage: f.leverage, + }; + }); +} + +/** Canonical text of a fill list (SPEC 7.2.1): `[` + each fill's canonical object (section 3) joined by `,` + `]`. */ +export const fillsCanonical = (fills: readonly FillInput[], orderTypes: readonly string[] = CUP_ORDER_TYPES): string => + `[${fillsCommitment(fills, orderTypes).map(canonicalJson).join(",")}]`; + +/** + * `fh` of a cup or battle standing (SPEC 7.2.1): lowercase hex SHA-256 of the UTF-8 canonical text of the fill list, + * fills in submission order followed by the final sweep's liquidations. An empty list hashes `[]` + * (4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945). A consistency check, not proof of fair execution. + */ +export const fillsHash = (fills: readonly FillInput[], orderTypes: readonly string[] = CUP_ORDER_TYPES): Promise => + sha256Hex(fillsCanonical(fills, orderTypes)); diff --git a/packages/verify/src/merkle.ts b/packages/verify/src/merkle.ts index 93e2240..b95c472 100644 --- a/packages/verify/src/merkle.ts +++ b/packages/verify/src/merkle.ts @@ -1,4 +1,4 @@ -import { ID_RE, isKind, KINDS, type AnchorKind } from "./kinds.ts"; +import { ID_RE, isKind, KINDS, kindRuleViolation, type AnchorKind } from "./kinds.ts"; // Deterministic Merkle commitments for ChartRunner results. Isomorphic (WebCrypto only), so the exact same code // runs on the server (building a snapshot) and in the browser (/verify recomputes the root itself). @@ -188,6 +188,8 @@ export function memoText(f: MemoFields): string { if (!isHash(f.root)) throw new Error("invalid root"); if (!Number.isSafeInteger(f.count) || f.count < 1 || f.count > MAX_MEMO_COUNT) throw new Error("invalid count"); if (KINDS[f.kind].count === "one" && f.count !== 1) throw new Error("invalid count: this kind anchors a single leaf"); + const rule = kindRuleViolation(f.kind, f.id, f.count); + if (rule) throw new Error(`invalid memo: ${rule}`); return `${MEMO_PREFIX}${f.kind}:${f.id}:${f.root}:${f.count}`; } @@ -195,6 +197,6 @@ export function parseMemo(text: string): MemoFields | null { const m = /^chartrunner:v1:([a-z]+):([A-Za-z0-9._-]{1,64}):([0-9a-f]{64}):([1-9][0-9]{0,8})$/.exec(text); if (!m || !isKind(m[1])) return null; const count = Number(m[4]); - if (KINDS[m[1]].count === "one" && count !== 1) return null; + if (kindRuleViolation(m[1], m[2]!, count) !== null) return null; return { kind: m[1], id: m[2]!, root: m[3]!, count }; } diff --git a/packages/verify/src/verify.ts b/packages/verify/src/verify.ts index da4fd72..45bdd09 100644 --- a/packages/verify/src/verify.ts +++ b/packages/verify/src/verify.ts @@ -12,7 +12,7 @@ import { type MemoFields, type ProofStep, } from "./merkle.ts"; -import { ANCHOR_KINDS, KINDS, isKind, type AnchorKind } from "./kinds.ts"; +import { ANCHOR_KINDS, isKind, kindRuleViolation, type AnchorKind } from "./kinds.ts"; // The SDK surface: friendly names over the format-v1 primitives in merkle.ts / leaves.ts (which are the same code the // ChartRunner app runs). Nothing here changes hashing or serialization. @@ -38,7 +38,9 @@ export function parseMemoStrict(text: string): MemoParseResult { if (memo) return { ok: true, memo }; const kind = text.split(":")[2]; if (kind !== undefined && /^[a-z]+$/.test(kind) && !(ANCHOR_KINDS as readonly string[]).includes(kind)) return { ok: false, reason: "unknown_kind", message: `Unknown anchor kind "${kind}".` }; - if (isKind(kind) && KINDS[kind].count === "one" && /^chartrunner:v1:[a-z]+:[A-Za-z0-9._-]{1,64}:[0-9a-f]{64}:[1-9][0-9]{0,8}$/.test(text)) return { ok: false, reason: "malformed", message: `Kind "${kind}" anchors a single leaf, so its count must be 1.` }; + const m = /^chartrunner:v1:([a-z]+):([A-Za-z0-9._-]{1,64}):[0-9a-f]{64}:([1-9][0-9]{0,8})$/.exec(text); + const rule = m && isKind(m[1]) ? kindRuleViolation(m[1], m[2]!, Number(m[3])) : null; + if (rule) return { ok: false, reason: "malformed", message: rule }; return { ok: false, reason: "malformed", message: "Memo does not match chartrunner:v1:::<64-hex root>:." }; } diff --git a/scripts/gen-vectors.mjs b/scripts/gen-vectors.mjs index 2040907..982f836 100644 --- a/scripts/gen-vectors.mjs +++ b/scripts/gen-vectors.mjs @@ -72,6 +72,7 @@ const KINDS = [ // 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" }, + { kind: "battle", signer: "operator", status: "active", count: "any" }, ]; // ---- spec section 5: leaf schemas ---- @@ -84,6 +85,8 @@ const boardLeaf = (kind, date, r) => ({ v: 1, k: kind, d: date, rank: r.rank, u: // 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) }); +// SPEC 7.5 +const battleLeaf = (windowId, s) => ({ v: 1, k: "battle", id: windowId, b: s.battleId, rank: s.rank, u: userHash(s.userId), h: handleHash(s.name), eq: s.equity, pct: s.pnlPct, tr: s.trades, out: s.out, fin: s.finished, fh: s.fillsHash }); // ---- inputs (all synthetic) ---- const canonicalCases = [ @@ -148,6 +151,57 @@ const dailyRaceRows = [ { rank: 3, userId: "user-0001", handle: "Alice", runId: "run-0203", finishTick: 7199, distance: 60, hardcore: false }, ]; +// Battles: two battles frozen in one 4-hour window (2026-10-19 04:00 to 08:00 UTC), one leaf per standing. A Last Man +// Standing battle (a knocked-out player) and a Profit Race battle (a player who reached the target), a negative +// return, zero exits, and the same account in both battles. +const battleWindow = "2026-10-19T04"; +const BATTLE_A = "3f2b9c1e-8d4a-4b6f-9e21-7c5d0a1b2e3f"; +const BATTLE_B = "a0c4e6f8-1b3d-4f5a-8c7e-9d0b2a4c6e81"; +const battleStandings = [ + { battleId: BATTLE_A, rank: 1, userId: "user-0001", name: "Alice", equity: "10842.120000", pnlPct: "8.4212", trades: 8, out: false, finished: false, fillsHash: "d".repeat(64) }, + { battleId: BATTLE_A, rank: 2, userId: "user-0002", name: "Bob", equity: "9731.500000", pnlPct: "-2.6850", trades: 5, out: false, finished: false, fillsHash: "e".repeat(64) }, + { battleId: BATTLE_A, rank: 3, userId: "user-0003", name: "Cy", equity: "0.000000", pnlPct: "-100.0000", trades: 12, out: true, finished: false, fillsHash: "f".repeat(64) }, + { battleId: BATTLE_B, rank: 1, userId: "user-0002", name: "Bob", equity: "11000.000000", pnlPct: "10.0000", trades: 3, out: false, finished: true, fillsHash: "1".repeat(64) }, + { battleId: BATTLE_B, rank: 2, userId: "user-0004", name: "Dee", equity: "10000.000000", pnlPct: "0.0000", trades: 0, out: false, finished: false, fillsHash: "2".repeat(64) }, +]; + +// SPEC 7.2.1: the fills hash `fh` of a cup or battle standing. Inputs are numbers as the server stores them; each value +// is written with toFixed (qty 10 decimals, price/fee/realized/net 8), negative zero without the sign. +const fixed = (v, d) => { + const s = v.toFixed(d); + return /^-0\.0+$/.test(s) ? s.slice(1) : s; +}; +const fillObject = (f, i) => ({ + seq: i + 1, + t: f.t, + symbol: f.symbol, + side: f.side, + kind: f.kind, + orderType: f.orderType, + qty: fixed(f.qty, 10), + price: fixed(f.price, 8), + fee: fixed(f.fee, 8), + realized: f.realized === null ? null : fixed(f.realized, 8), + net: f.net === null ? null : fixed(f.net, 8), + leverage: f.leverage, +}); +const fillsText = (fills) => `[${fills.map((f, i) => canonical(fillObject(f, i))).join(",")}]`; +const fillsCases = [ + ["no fills", []], + [ + "open, reduce with realized/net, final-sweep liquidation", + [ + { t: 1760846400000, symbol: "SOL", side: "long", kind: "open", orderType: "market", qty: 12.5, price: 187.4321, fee: 1.17145063, realized: null, net: null, leverage: 5 }, + { t: 1760847300250, symbol: "SOL", side: "long", kind: "reduce", orderType: "market", qty: 5, price: 191.2, fee: 0.478, realized: 18.8395, net: 18.3615, leverage: 5 }, + { t: 1760860799999, symbol: "SOL", side: "long", kind: "liquidation", orderType: "liquidation", qty: 7.5, price: 150.12345678, fee: 0, realized: null, net: -280.4627, leverage: 5 }, + ], + ], + [ + "negative zero is written without the sign", + [{ t: 1760846400000, symbol: "BTC", side: "short", kind: "close", orderType: "market", qty: -0.00000000001, price: 65000, fee: 0, realized: -0.000000001, net: -0.0000000004, leverage: 1 }], + ], +]; + const R = "0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92"; const validMemos = [ ["chartrunner:v1:cup:cup-2026.10:" + R + ":3", { kind: "cup", id: "cup-2026.10", root: R, count: 3 }], @@ -161,6 +215,9 @@ const validMemos = [ ["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 }], + ["chartrunner:v1:battle:2026-10-19T04:" + R + ":5", { kind: "battle", id: "2026-10-19T04", root: R, count: 5 }], + ["chartrunner:v1:battle:2026-10-19T20:" + R + ":10000", { kind: "battle", id: "2026-10-19T20", root: R, count: 10000 }], + ["chartrunner:v1:battle:2028-02-29T00:" + R + ":1", { kind: "battle", id: "2028-02-29T00", root: R, count: 1 }], ]; const invalidMemos = [ ["empty", ""], @@ -185,6 +242,17 @@ const invalidMemos = [ ["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`], + ["battle window hour not a multiple of 4", `chartrunner:v1:battle:2026-10-19T03:${R}:3`], + ["battle window hour 24", `chartrunner:v1:battle:2026-10-19T24:${R}:3`], + ["battle window on a date that does not exist", `chartrunner:v1:battle:2026-02-30T00:${R}:3`], + ["battle window on Feb 29 of a common year", `chartrunner:v1:battle:2026-02-29T00:${R}:3`], + ["battle window without the T", `chartrunner:v1:battle:2026-10-1904:${R}:3`], + ["battle window with only a date", `chartrunner:v1:battle:2026-10-19:${R}:3`], + ["battle window with a one-digit hour", `chartrunner:v1:battle:2026-10-19T4:${R}:3`], + ["battle window with a lowercase t", `chartrunner:v1:battle:2026-10-19t04:${R}:3`], + ["uppercase battle kind", `chartrunner:v1:BATTLE:2026-10-19T04:${R}:3`], + ["mixed-case battle kind", `chartrunner:v1:Battle:2026-10-19T04:${R}:3`], + ["battle anchor over 10000 leaves", `chartrunner:v1:battle:2026-10-19T04:${R}:10001`], ]; // ---- spec section 10: legacy cr1 (read-only) ---- @@ -232,10 +300,21 @@ function rejects(rows) { } } +// SPEC 2.2 / 7.5: a battle id is YYYY-MM-DDTHH, HH in 00 04 08 12 16 20, on a real date; at most 10000 leaves. +function battleWindowOk(id) { + const m = /^(\d{4})-(\d{2})-(\d{2})T(00|04|08|12|16|20)$/.exec(id); + if (!m) return false; + const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])]; + if (y < 1 || mo < 1 || mo > 12 || d < 1) return false; + const leap = (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0; + return d <= [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][mo - 1]; +} + function parseV1(text) { const m = /^chartrunner:v1:([a-z]+):([A-Za-z0-9._-]{1,64}):([0-9a-f]{64}):([1-9][0-9]{0,8})$/.exec(text); const k = m && KINDS.find((x) => x.kind === m[1]); if (!k || (k.count === "one" && m[4] !== "1")) return null; + if (k.kind === "battle" && (!battleWindowOk(m[2]) || Number(m[4]) > 10000)) return null; return { kind: m[1], id: m[2], root: m[3], count: Number(m[4]) }; } @@ -251,6 +330,7 @@ function build() { 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 battleLeaves = battleStandings.map((s) => battleLeaf(battleWindow, s)); const a = leafHash(canonical({ id: "a", s: 1 })); const b = leafHash(canonical({ id: "b", s: 2 })); @@ -285,6 +365,10 @@ function build() { // 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)) }, + // Appended for the battle kind (SPEC 7.5). + battle: { kind: "battle", window: battleWindow, standings: battleStandings, leaves: battleLeaves, ...withProofs(treeVector("battle leaves of one window", "battle", battleWindow, battleLeaves)) }, + // Appended with the written-down `fh` definition (SPEC 7.2.1). + fillsHash: fillsCases.map(([name, fills]) => ({ name, fills, canonical: fillsText(fills), hash: shaHex(fillsText(fills)) })), }; } diff --git a/test-vectors/v1.json b/test-vectors/v1.json index e711b9d..435b228 100644 --- a/test-vectors/v1.json +++ b/test-vectors/v1.json @@ -64,6 +64,12 @@ "signer": "operator", "status": "active", "count": "any" + }, + { + "kind": "battle", + "signer": "operator", + "status": "active", + "count": "any" } ], "pseudonyms": [ @@ -1095,6 +1101,33 @@ "root": "0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92", "count": 3 } + }, + { + "text": "chartrunner:v1:battle:2026-10-19T04:0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92:5", + "fields": { + "kind": "battle", + "id": "2026-10-19T04", + "root": "0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92", + "count": 5 + } + }, + { + "text": "chartrunner:v1:battle:2026-10-19T20:0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92:10000", + "fields": { + "kind": "battle", + "id": "2026-10-19T20", + "root": "0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92", + "count": 10000 + } + }, + { + "text": "chartrunner:v1:battle:2028-02-29T00:0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92:1", + "fields": { + "kind": "battle", + "id": "2028-02-29T00", + "root": "0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92", + "count": 1 + } } ], "invalid": [ @@ -1185,6 +1218,50 @@ { "name": "mixed-case new kind", "text": "chartrunner:v1:dailyRun:2026-10-12:0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92:3" + }, + { + "name": "battle window hour not a multiple of 4", + "text": "chartrunner:v1:battle:2026-10-19T03:0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92:3" + }, + { + "name": "battle window hour 24", + "text": "chartrunner:v1:battle:2026-10-19T24:0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92:3" + }, + { + "name": "battle window on a date that does not exist", + "text": "chartrunner:v1:battle:2026-02-30T00:0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92:3" + }, + { + "name": "battle window on Feb 29 of a common year", + "text": "chartrunner:v1:battle:2026-02-29T00:0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92:3" + }, + { + "name": "battle window without the T", + "text": "chartrunner:v1:battle:2026-10-1904:0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92:3" + }, + { + "name": "battle window with only a date", + "text": "chartrunner:v1:battle:2026-10-19:0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92:3" + }, + { + "name": "battle window with a one-digit hour", + "text": "chartrunner:v1:battle:2026-10-19T4:0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92:3" + }, + { + "name": "battle window with a lowercase t", + "text": "chartrunner:v1:battle:2026-10-19t04:0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92:3" + }, + { + "name": "uppercase battle kind", + "text": "chartrunner:v1:BATTLE:2026-10-19T04:0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92:3" + }, + { + "name": "mixed-case battle kind", + "text": "chartrunner:v1:Battle:2026-10-19T04:0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92:3" + }, + { + "name": "battle anchor over 10000 leaves", + "text": "chartrunner:v1:battle:2026-10-19T04:0e35f6c675e52d251f7e0b25c5fb5d8b728344eea7f519f70ffc950651d95f92:10001" } ] }, @@ -1542,5 +1619,331 @@ ] } ] - } + }, + "battle": { + "kind": "battle", + "window": "2026-10-19T04", + "standings": [ + { + "battleId": "3f2b9c1e-8d4a-4b6f-9e21-7c5d0a1b2e3f", + "rank": 1, + "userId": "user-0001", + "name": "Alice", + "equity": "10842.120000", + "pnlPct": "8.4212", + "trades": 8, + "out": false, + "finished": false, + "fillsHash": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + }, + { + "battleId": "3f2b9c1e-8d4a-4b6f-9e21-7c5d0a1b2e3f", + "rank": 2, + "userId": "user-0002", + "name": "Bob", + "equity": "9731.500000", + "pnlPct": "-2.6850", + "trades": 5, + "out": false, + "finished": false, + "fillsHash": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + { + "battleId": "3f2b9c1e-8d4a-4b6f-9e21-7c5d0a1b2e3f", + "rank": 3, + "userId": "user-0003", + "name": "Cy", + "equity": "0.000000", + "pnlPct": "-100.0000", + "trades": 12, + "out": true, + "finished": false, + "fillsHash": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "battleId": "a0c4e6f8-1b3d-4f5a-8c7e-9d0b2a4c6e81", + "rank": 1, + "userId": "user-0002", + "name": "Bob", + "equity": "11000.000000", + "pnlPct": "10.0000", + "trades": 3, + "out": false, + "finished": true, + "fillsHash": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "battleId": "a0c4e6f8-1b3d-4f5a-8c7e-9d0b2a4c6e81", + "rank": 2, + "userId": "user-0004", + "name": "Dee", + "equity": "10000.000000", + "pnlPct": "0.0000", + "trades": 0, + "out": false, + "finished": false, + "fillsHash": "2222222222222222222222222222222222222222222222222222222222222222" + } + ], + "leaves": [ + { + "v": 1, + "k": "battle", + "id": "2026-10-19T04", + "b": "3f2b9c1e-8d4a-4b6f-9e21-7c5d0a1b2e3f", + "rank": 1, + "u": "cd1b65904801cca9643aaf213b69368b1725517f018a75f2b2548a152584cf10", + "h": "f0a86417a98d7c30b21117edb4cf41047add8ac05215fe7ed7fe4708143b07ee", + "eq": "10842.120000", + "pct": "8.4212", + "tr": 8, + "out": false, + "fin": false, + "fh": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + }, + { + "v": 1, + "k": "battle", + "id": "2026-10-19T04", + "b": "3f2b9c1e-8d4a-4b6f-9e21-7c5d0a1b2e3f", + "rank": 2, + "u": "a6d5c991038c029b47c91c6eb02528bdb8cfbf7599d42050b45bf1dfe4fe8310", + "h": "9f867447463fd16b1713794175b80e4012bfd1e3b643495788a069b0a18a4a65", + "eq": "9731.500000", + "pct": "-2.6850", + "tr": 5, + "out": false, + "fin": false, + "fh": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + { + "v": 1, + "k": "battle", + "id": "2026-10-19T04", + "b": "3f2b9c1e-8d4a-4b6f-9e21-7c5d0a1b2e3f", + "rank": 3, + "u": "ebba193792b200e77597b72e0519249a826db487dee7f7e3a6c0ef10eaae81c6", + "h": "37e5c092ea11c0e8377ed7cb67655cbe9743db9622c32c5043ac103184e8564a", + "eq": "0.000000", + "pct": "-100.0000", + "tr": 12, + "out": true, + "fin": false, + "fh": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "v": 1, + "k": "battle", + "id": "2026-10-19T04", + "b": "a0c4e6f8-1b3d-4f5a-8c7e-9d0b2a4c6e81", + "rank": 1, + "u": "a6d5c991038c029b47c91c6eb02528bdb8cfbf7599d42050b45bf1dfe4fe8310", + "h": "9f867447463fd16b1713794175b80e4012bfd1e3b643495788a069b0a18a4a65", + "eq": "11000.000000", + "pct": "10.0000", + "tr": 3, + "out": false, + "fin": true, + "fh": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "v": 1, + "k": "battle", + "id": "2026-10-19T04", + "b": "a0c4e6f8-1b3d-4f5a-8c7e-9d0b2a4c6e81", + "rank": 2, + "u": "58a3f3097992ceead2bedeefe1187304e12a0ce7935b289677d85fa5d84132b4", + "h": "263a5c92fd278e16d654812a8a6ad1a81770891a20d624f2a135623bf20e4938", + "eq": "10000.000000", + "pct": "0.0000", + "tr": 0, + "out": false, + "fin": false, + "fh": "2222222222222222222222222222222222222222222222222222222222222222" + } + ], + "root": "a1bade0839035f3ca3e83d521c3b68da8467f3073822d774e08b92e89b0aabdc", + "count": 5, + "memo": "chartrunner:v1:battle:2026-10-19T04:a1bade0839035f3ca3e83d521c3b68da8467f3073822d774e08b92e89b0aabdc:5", + "sortedLeaves": [ + { + "canonical": "{\"b\":\"3f2b9c1e-8d4a-4b6f-9e21-7c5d0a1b2e3f\",\"eq\":\"9731.500000\",\"fh\":\"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\",\"fin\":false,\"h\":\"9f867447463fd16b1713794175b80e4012bfd1e3b643495788a069b0a18a4a65\",\"id\":\"2026-10-19T04\",\"k\":\"battle\",\"out\":false,\"pct\":\"-2.6850\",\"rank\":2,\"tr\":5,\"u\":\"a6d5c991038c029b47c91c6eb02528bdb8cfbf7599d42050b45bf1dfe4fe8310\",\"v\":1}", + "hash": "24c7b02e985996d83ce13ab044c03d6d6c080d95fdfd2254cf2c5421163d867d" + }, + { + "canonical": "{\"b\":\"3f2b9c1e-8d4a-4b6f-9e21-7c5d0a1b2e3f\",\"eq\":\"0.000000\",\"fh\":\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\",\"fin\":false,\"h\":\"37e5c092ea11c0e8377ed7cb67655cbe9743db9622c32c5043ac103184e8564a\",\"id\":\"2026-10-19T04\",\"k\":\"battle\",\"out\":true,\"pct\":\"-100.0000\",\"rank\":3,\"tr\":12,\"u\":\"ebba193792b200e77597b72e0519249a826db487dee7f7e3a6c0ef10eaae81c6\",\"v\":1}", + "hash": "495c091060ce26beeecf68153acf75b007a53644fc636f2142890af249775813" + }, + { + "canonical": "{\"b\":\"a0c4e6f8-1b3d-4f5a-8c7e-9d0b2a4c6e81\",\"eq\":\"10000.000000\",\"fh\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"fin\":false,\"h\":\"263a5c92fd278e16d654812a8a6ad1a81770891a20d624f2a135623bf20e4938\",\"id\":\"2026-10-19T04\",\"k\":\"battle\",\"out\":false,\"pct\":\"0.0000\",\"rank\":2,\"tr\":0,\"u\":\"58a3f3097992ceead2bedeefe1187304e12a0ce7935b289677d85fa5d84132b4\",\"v\":1}", + "hash": "4c421b0d0edeba1b073fcdf61d646c2b86a9d81164f3b43b9361e6b7ebf28762" + }, + { + "canonical": "{\"b\":\"3f2b9c1e-8d4a-4b6f-9e21-7c5d0a1b2e3f\",\"eq\":\"10842.120000\",\"fh\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"fin\":false,\"h\":\"f0a86417a98d7c30b21117edb4cf41047add8ac05215fe7ed7fe4708143b07ee\",\"id\":\"2026-10-19T04\",\"k\":\"battle\",\"out\":false,\"pct\":\"8.4212\",\"rank\":1,\"tr\":8,\"u\":\"cd1b65904801cca9643aaf213b69368b1725517f018a75f2b2548a152584cf10\",\"v\":1}", + "hash": "6cf37505ca7c2fdf8c8189b64964f5e7e379172a788a1b5da47f4e7793465ef3" + }, + { + "canonical": "{\"b\":\"a0c4e6f8-1b3d-4f5a-8c7e-9d0b2a4c6e81\",\"eq\":\"11000.000000\",\"fh\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"fin\":true,\"h\":\"9f867447463fd16b1713794175b80e4012bfd1e3b643495788a069b0a18a4a65\",\"id\":\"2026-10-19T04\",\"k\":\"battle\",\"out\":false,\"pct\":\"10.0000\",\"rank\":1,\"tr\":3,\"u\":\"a6d5c991038c029b47c91c6eb02528bdb8cfbf7599d42050b45bf1dfe4fe8310\",\"v\":1}", + "hash": "db7478cb6c6c565aab2fa4fa05e73e3f07962e5a89d5778efdeba7de5665b071" + } + ], + "proofs": [ + { + "index": 0, + "leaf": "{\"b\":\"3f2b9c1e-8d4a-4b6f-9e21-7c5d0a1b2e3f\",\"eq\":\"9731.500000\",\"fh\":\"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\",\"fin\":false,\"h\":\"9f867447463fd16b1713794175b80e4012bfd1e3b643495788a069b0a18a4a65\",\"id\":\"2026-10-19T04\",\"k\":\"battle\",\"out\":false,\"pct\":\"-2.6850\",\"rank\":2,\"tr\":5,\"u\":\"a6d5c991038c029b47c91c6eb02528bdb8cfbf7599d42050b45bf1dfe4fe8310\",\"v\":1}", + "proof": [ + { + "side": "R", + "hash": "495c091060ce26beeecf68153acf75b007a53644fc636f2142890af249775813" + }, + { + "side": "R", + "hash": "34b7a1dd614311a315badfb73030fa887360541e83e3204b4fd5bae9f9cf9ba2" + }, + { + "side": "R", + "hash": "db7478cb6c6c565aab2fa4fa05e73e3f07962e5a89d5778efdeba7de5665b071" + } + ] + }, + { + "index": 1, + "leaf": "{\"b\":\"3f2b9c1e-8d4a-4b6f-9e21-7c5d0a1b2e3f\",\"eq\":\"0.000000\",\"fh\":\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\",\"fin\":false,\"h\":\"37e5c092ea11c0e8377ed7cb67655cbe9743db9622c32c5043ac103184e8564a\",\"id\":\"2026-10-19T04\",\"k\":\"battle\",\"out\":true,\"pct\":\"-100.0000\",\"rank\":3,\"tr\":12,\"u\":\"ebba193792b200e77597b72e0519249a826db487dee7f7e3a6c0ef10eaae81c6\",\"v\":1}", + "proof": [ + { + "side": "L", + "hash": "24c7b02e985996d83ce13ab044c03d6d6c080d95fdfd2254cf2c5421163d867d" + }, + { + "side": "R", + "hash": "34b7a1dd614311a315badfb73030fa887360541e83e3204b4fd5bae9f9cf9ba2" + }, + { + "side": "R", + "hash": "db7478cb6c6c565aab2fa4fa05e73e3f07962e5a89d5778efdeba7de5665b071" + } + ] + }, + { + "index": 2, + "leaf": "{\"b\":\"a0c4e6f8-1b3d-4f5a-8c7e-9d0b2a4c6e81\",\"eq\":\"10000.000000\",\"fh\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"fin\":false,\"h\":\"263a5c92fd278e16d654812a8a6ad1a81770891a20d624f2a135623bf20e4938\",\"id\":\"2026-10-19T04\",\"k\":\"battle\",\"out\":false,\"pct\":\"0.0000\",\"rank\":2,\"tr\":0,\"u\":\"58a3f3097992ceead2bedeefe1187304e12a0ce7935b289677d85fa5d84132b4\",\"v\":1}", + "proof": [ + { + "side": "R", + "hash": "6cf37505ca7c2fdf8c8189b64964f5e7e379172a788a1b5da47f4e7793465ef3" + }, + { + "side": "L", + "hash": "9b12243293472d0caa0938243a36e5996bb8f1e4903bac3ad2db98093c64bd21" + }, + { + "side": "R", + "hash": "db7478cb6c6c565aab2fa4fa05e73e3f07962e5a89d5778efdeba7de5665b071" + } + ] + }, + { + "index": 3, + "leaf": "{\"b\":\"3f2b9c1e-8d4a-4b6f-9e21-7c5d0a1b2e3f\",\"eq\":\"10842.120000\",\"fh\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"fin\":false,\"h\":\"f0a86417a98d7c30b21117edb4cf41047add8ac05215fe7ed7fe4708143b07ee\",\"id\":\"2026-10-19T04\",\"k\":\"battle\",\"out\":false,\"pct\":\"8.4212\",\"rank\":1,\"tr\":8,\"u\":\"cd1b65904801cca9643aaf213b69368b1725517f018a75f2b2548a152584cf10\",\"v\":1}", + "proof": [ + { + "side": "L", + "hash": "4c421b0d0edeba1b073fcdf61d646c2b86a9d81164f3b43b9361e6b7ebf28762" + }, + { + "side": "L", + "hash": "9b12243293472d0caa0938243a36e5996bb8f1e4903bac3ad2db98093c64bd21" + }, + { + "side": "R", + "hash": "db7478cb6c6c565aab2fa4fa05e73e3f07962e5a89d5778efdeba7de5665b071" + } + ] + }, + { + "index": 4, + "leaf": "{\"b\":\"a0c4e6f8-1b3d-4f5a-8c7e-9d0b2a4c6e81\",\"eq\":\"11000.000000\",\"fh\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"fin\":true,\"h\":\"9f867447463fd16b1713794175b80e4012bfd1e3b643495788a069b0a18a4a65\",\"id\":\"2026-10-19T04\",\"k\":\"battle\",\"out\":false,\"pct\":\"10.0000\",\"rank\":1,\"tr\":3,\"u\":\"a6d5c991038c029b47c91c6eb02528bdb8cfbf7599d42050b45bf1dfe4fe8310\",\"v\":1}", + "proof": [ + { + "side": "L", + "hash": "8a6472fd10726cfa88506c573582fb579a7fa1cc4aa89147f2ef6e90b3fd87f8" + } + ] + } + ] + }, + "fillsHash": [ + { + "name": "no fills", + "fills": [], + "canonical": "[]", + "hash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945" + }, + { + "name": "open, reduce with realized/net, final-sweep liquidation", + "fills": [ + { + "t": 1760846400000, + "symbol": "SOL", + "side": "long", + "kind": "open", + "orderType": "market", + "qty": 12.5, + "price": 187.4321, + "fee": 1.17145063, + "realized": null, + "net": null, + "leverage": 5 + }, + { + "t": 1760847300250, + "symbol": "SOL", + "side": "long", + "kind": "reduce", + "orderType": "market", + "qty": 5, + "price": 191.2, + "fee": 0.478, + "realized": 18.8395, + "net": 18.3615, + "leverage": 5 + }, + { + "t": 1760860799999, + "symbol": "SOL", + "side": "long", + "kind": "liquidation", + "orderType": "liquidation", + "qty": 7.5, + "price": 150.12345678, + "fee": 0, + "realized": null, + "net": -280.4627, + "leverage": 5 + } + ], + "canonical": "[{\"fee\":\"1.17145063\",\"kind\":\"open\",\"leverage\":5,\"net\":null,\"orderType\":\"market\",\"price\":\"187.43210000\",\"qty\":\"12.5000000000\",\"realized\":null,\"seq\":1,\"side\":\"long\",\"symbol\":\"SOL\",\"t\":1760846400000},{\"fee\":\"0.47800000\",\"kind\":\"reduce\",\"leverage\":5,\"net\":\"18.36150000\",\"orderType\":\"market\",\"price\":\"191.20000000\",\"qty\":\"5.0000000000\",\"realized\":\"18.83950000\",\"seq\":2,\"side\":\"long\",\"symbol\":\"SOL\",\"t\":1760847300250},{\"fee\":\"0.00000000\",\"kind\":\"liquidation\",\"leverage\":5,\"net\":\"-280.46270000\",\"orderType\":\"liquidation\",\"price\":\"150.12345678\",\"qty\":\"7.5000000000\",\"realized\":null,\"seq\":3,\"side\":\"long\",\"symbol\":\"SOL\",\"t\":1760860799999}]", + "hash": "d2f13a7274cfbfed4ec5b15f9c5ddb4ca1fb361f82bc371d9c542454e0effad4" + }, + { + "name": "negative zero is written without the sign", + "fills": [ + { + "t": 1760846400000, + "symbol": "BTC", + "side": "short", + "kind": "close", + "orderType": "market", + "qty": -1e-11, + "price": 65000, + "fee": 0, + "realized": -1e-9, + "net": -4e-10, + "leverage": 1 + } + ], + "canonical": "[{\"fee\":\"0.00000000\",\"kind\":\"close\",\"leverage\":1,\"net\":\"0.00000000\",\"orderType\":\"market\",\"price\":\"65000.00000000\",\"qty\":\"0.0000000000\",\"realized\":\"0.00000000\",\"seq\":1,\"side\":\"short\",\"symbol\":\"BTC\",\"t\":1760846400000}]", + "hash": "62917553ecc089cd761fbe050241a0b62d84a01a5ca4b7e4929b9092cca3d02a" + } + ] }