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
41 changes: 39 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ jobs:
node-version-file: .node-version
cache: pnpm

# Lifecycle scripts stay off (pnpm-workspace.yaml allowBuilds).
- run: pnpm install --frozen-lockfile
# Lifecycle scripts stay off (pnpm-workspace.yaml allowBuilds, and --ignore-scripts).
- run: pnpm install --frozen-lockfile --ignore-scripts

- run: pnpm typecheck
- run: pnpm lint
Expand All @@ -44,3 +44,40 @@ jobs:
# The built package works: CLI entry point and the offline example.
- run: node packages/verify/dist/cli.js --help
- run: pnpm examples

# The npm tarball works for a user: packed exactly as `npm publish` would pack it, installed into an empty project,
# then the bin and the offline example run against it. Packed on the repository's Node; run on the minimum Node in
# packages/verify engines and on 24. (The test suite itself needs Node 22.12+ for vitest, so it runs in `check`.)
package:
name: package (node ${{ matrix.node }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
node: ["20", "24"]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .node-version
cache: pnpm

- run: pnpm install --frozen-lockfile --ignore-scripts

# prepack builds dist/ and copies LICENSE and NOTICE in.
- name: npm pack
working-directory: packages/verify
run: mkdir -p "$RUNNER_TEMP/pack" && npm pack --pack-destination "$RUNNER_TEMP/pack"

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node }}

- name: install the tarball and run the bin and an example
run: node scripts/pack-smoke.mjs "$RUNNER_TEMP"/pack/chartrunner-verify-*.tgz
90 changes: 90 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
name: Publish @chartrunner/verify

# Publishing is manual and owner-only: it runs when the owner publishes a GitHub release, or dispatches this workflow by
# hand (a dry run unless told otherwise). It needs the NPM_TOKEN repository secret, which only the owner sets.

on:
release:
types: [published]
workflow_dispatch:
inputs:
dry_run:
description: "Pack and run every check, but do not publish (npm publish --dry-run)"
type: boolean
default: true

permissions:
contents: read
id-token: write # npm provenance (Sigstore attestation of this workflow run)

concurrency:
group: publish
cancel-in-progress: false

jobs:
publish:
name: publish
runs-on: ubuntu-latest
timeout-minutes: 20
# A protected environment: the owner can require a reviewer for every publish (Settings > Environments > npm).
environment: npm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

# Version comes from package.json "packageManager".
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .node-version
registry-url: https://registry.npmjs.org

- run: pnpm install --frozen-lockfile --ignore-scripts

# A release must be tagged v<version> of packages/verify/package.json, so the tag and the tarball agree.
- name: release tag matches the package version
if: github.event_name == 'release'
env:
TAG: ${{ github.event.release.tag_name }}
run: |
version="$(node -p "require('./packages/verify/package.json').version")"
if [ "$TAG" != "v$version" ]; then
echo "release tag $TAG does not match packages/verify version v$version" >&2
exit 1
fi

# A real publish only ever comes from main: a dispatch must run on main, a release must target main.
- name: real publishes come from main
if: github.event_name == 'release' || inputs.dry_run == false
env:
REF: ${{ github.ref }}
TARGET: ${{ github.event.release.target_commitish }}
run: |
if [ "${{ github.event_name }}" = "release" ]; then
[ "$TARGET" = "main" ] || { echo "release targets $TARGET, not main" >&2; exit 1; }
else
[ "$REF" = "refs/heads/main" ] || { echo "dispatch on $REF: a real publish runs on main only" >&2; exit 1; }
fi

- run: pnpm build
- run: pnpm test
- run: pnpm vectors:check

# prepublishOnly fails unless LICENSE, NOTICE and dist/ are in the package directory; prepack rebuilds and recopies.
- name: copy LICENSE and NOTICE into the package
working-directory: packages/verify
run: node scripts/pack-files.mjs copy

- name: npm publish
working-directory: packages/verify
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
run: |
if [ "$DRY_RUN" = "true" ]; then
npm publish --provenance --access public --dry-run
else
npm publish --provenance --access public
fi
22 changes: 14 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ What is live on mainnet today:
[SPEC.md sections 7.3 and 7.4](SPEC.md#73-daily-run-leaf-dailyrun); the first `dailyrace` anchor is
2026-10-14; a board day with no finished entries is not anchored), signed by the published operator
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>.
As of 2026-10-15, 35 anchors (27 cups, 6 free-play boards, 1 daily board, 1 Daily Race board) had been posted
since 2026-10-06. That is a snapshot, not a guarantee; the count keeps growing. The current 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.
Expand All @@ -59,7 +60,8 @@ node packages/verify/dist/cli.js --rows results.json --sig "$SIG" --rpc https://
--signer 68tWAPfHGkfVzNK1D7PxzVrBrDbtiyqmW2wddwqEmVhy --kind cup --id "$CUP" --verify-signature --check-equivocation
```

Expected output (exit code `0`):
Example output, as of 2026-10-12 (exit code `0`). The root and slot are fixed by that cup's anchor, but the number of
signatures scanned grows as the operator address writes more transactions, so yours will be higher:

```
VERIFIED cup 3277e988-3f27-4de1-9954-4f5681c34fbd: 2 rows rebuild root c08eae042e433c371dbae5ae3bc1f1cbcc1e781814015e2c7e55cb7f5fc45c91
Expand All @@ -83,8 +85,8 @@ choose. The ChartRunner app's own `/verify` page runs this same library, so what

## Quickstart

Requirements: Node 24 (see `.node-version`) and pnpm 11 (the version is pinned in `package.json`). The package is not on
npm yet, so build it from this checkout:
Requirements to build and test: Node 24 (see `.node-version`) and pnpm 11 (the version is pinned in `package.json`).
The built package itself runs on Node 20 or later. It is not on npm yet, so build it from this checkout:

```sh
git clone https://github.com/ChartRunner/chartrunner-onchain.git
Expand All @@ -107,8 +109,11 @@ node packages/verify/dist/cli.js --proof proof.json --memo "chartrunner:v1:cup:<
node packages/verify/dist/cli.js --rows results.json --sig <signature> --rpc <your rpc url> --signer <operator address>
```

Exit codes: `0` verified, `1` the data does not match the anchor, `2` anything that prevented a verdict (bad input,
RPC outage, transaction not found). An outage never exits `0`.
Exit codes: `0` verified, `1` the data does not match the anchor, `2` a usage or environment error, or a check that was
not performed (bad input, RPC outage, transaction not found, `--verify-signature` without Ed25519). An outage never
exits `0`. The [package README](packages/verify/README.md#exit-codes) lists every reason and its code. Instead of
`--rpc`, the RPC URL can come from the `CHARTRUNNER_RPC_URL` environment variable, which keeps a URL with an API key
out of your shell history; `--rpc` wins when both are set.

`results.json` may be an array of rows, `{"rows": [...]}`, or a cup results document
(`{"cupId": "...", "results": {"standings": [...]}}`), which is turned into leaves exactly as the app does.
Expand Down Expand Up @@ -142,7 +147,8 @@ existed by that slot. It does not prove:
whether the host overrode the default picks) is covered by the cup's `results_hash`, which is not on chain, and not by
the Merkle anchor;
- that nobody was left out before anchoring (the row count is on chain, so an excluded player can show they have no
proof, but the root alone does not prevent it);
proof, but the root alone does not prevent it). A single row checked with a proof is bound to `count` only as an
upper bound on the proof's length: it shows that row is in the set, not that the set is complete;
- that the operator address you compared against is really ChartRunner's. Take it from
[SPEC.md section 2.6](SPEC.md#26-operator-addresses) or the [documentation](https://docs.chartrunner.xyz), not from
the same source as the data;
Expand Down
5 changes: 4 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ pages through the operator address's finalized signature history (`getSignatures
transactions and those whose reported memo does not name the (kind, id), re-reads every remaining candidate with the
rules in 2.4, and returns `none`, `unique`, `duplicate` or `equivocation` together with every anchor found and the
winner. A history it could not read completely (outage, pruned transaction, more signatures than its limit, a cursor that
does not advance) is a failure with a reason, never `none` or `unique`. `anchorStanding(result, checked)` applies the
does not advance, an entry whose memo the RPC did not report) is a failure with a reason, never `none` or `unique`. `anchorStanding(result, checked)` applies the
table above to a checked anchor (`not_in_scan`, `sole`, `duplicate`, `equivocation_first`, `not_first`).

The rule covers operator kinds. For user kinds it is not defined yet; it will be specified with their leaf schemas.
Expand Down Expand Up @@ -532,6 +532,9 @@ It does not prove:
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).
Proof mode (one row and its proof, section 6) binds `count` only as an upper bound on the proof's length: it shows
that the row is in the committed set, not that the set you were shown is complete. Only a full row-set check compares
the number of rows with `count`.
- **Who the operator is.** The verifier checks the fee payer against an address you supply. Take it from section 2.6
or ChartRunner's published documentation, not from the same place as the data you are checking.
- **That this is the only anchor.** One transaction cannot show that no other operator memo exists for its (kind, id).
Expand Down
Loading
Loading