From 6d84618617c6e31d21da521b864e0bb1b03bc3c9 Mon Sep 17 00:00:00 2001 From: 0xNyk <0xnykcd@googlemail.com> Date: Tue, 3 Nov 2026 19:58:13 +0100 Subject: [PATCH 1/3] chore(verify): publish readiness, CLI exit codes, stricter rows and scans Package and CI - publishConfig (public, provenance); prepack/build use a cross-platform node script; prepublishOnly fails without LICENSE, NOTICE or a built dist/ - publish.yml: manual (release published or dispatch, dry run by default), pinned actions, id-token for provenance, NPM_TOKEN by name only - engines.node >=20 (global WebCrypto + fetch); the bin falls back to dist/ on a Node without type stripping; @types/node pinned exactly - CI: package job packs the tarball, installs it in an empty project and runs the bin and the offline example on Node 20 and 24 CLI - --verify-signature without Ed25519 exits 2 (signature_unverified) - --signer must be a base58 32-byte address (usage error, exit 2) - --max-signatures takes plain decimal digits only - CHARTRUNNER_RPC_URL when --rpc is absent; --rpc wins - bad_signature and tx_mismatch are mismatches (exit 1); table in README SDK - canonicalJson rejects arrays, null, primitives and class instances as rows - checkEquivocation: a signature entry without a reported memo ends the scan as scan_incomplete instead of reading every transaction serially Docs: live figures marked as of their date; SPEC 8 and READMEs say proof mode binds count only as an upper bound. No hash, serialization or vector changed. --- .github/workflows/ci.yml | 41 ++++++++- .github/workflows/publish.yml | 75 ++++++++++++++++ README.md | 22 +++-- SPEC.md | 5 +- packages/verify/README.md | 66 ++++++++++++-- packages/verify/bin/chartrunner-verify.mjs | 7 +- packages/verify/package.json | 13 ++- packages/verify/scripts/pack-files.mjs | 51 +++++++++++ packages/verify/src/__tests__/cli.test.ts | 71 +++++++++++++-- .../verify/src/__tests__/registry.test.ts | 31 ++++++- packages/verify/src/__tests__/rpc.test.ts | 42 +++++++++ packages/verify/src/__tests__/sdk.test.ts | 15 ++++ .../verify/src/__tests__/sigcheck.test.ts | 22 +++++ packages/verify/src/cli.ts | 87 ++++++++++++++++--- packages/verify/src/equivocation.ts | 12 ++- packages/verify/src/merkle.ts | 9 ++ pnpm-lock.yaml | 2 +- scripts/gen-vectors.mjs | 1 + scripts/pack-smoke.mjs | 60 +++++++++++++ 19 files changed, 577 insertions(+), 55 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 packages/verify/scripts/pack-files.mjs create mode 100644 scripts/pack-smoke.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28f221f..e563173 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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: 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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..73101e9 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,75 @@ +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 + 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 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 + + - 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 diff --git a/README.md b/README.md index 89ee7e1..c69d40e 100644 --- a/README.md +++ b/README.md @@ -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 . + 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 + . - 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. @@ -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 @@ -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 @@ -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 --rpc --signer ``` -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. @@ -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; diff --git a/SPEC.md b/SPEC.md index d858ede..911aeeb 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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. @@ -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). diff --git a/packages/verify/README.md b/packages/verify/README.md index 77e6bbc..f635a62 100644 --- a/packages/verify/README.md +++ b/packages/verify/README.md @@ -9,7 +9,8 @@ app uses for its own `/verify` page (); shar The format is specified in [SPEC.md](https://github.com/ChartRunner/chartrunner-onchain/blob/main/SPEC.md). This README is a usage guide; where the two differ, SPEC.md wins. -Pure TypeScript, ESM, zero runtime dependencies. Uses WebCrypto (`crypto.subtle`), so it runs in browsers and in Node 20+. +Pure TypeScript, ESM, zero runtime dependencies. Uses WebCrypto (`crypto.subtle`, Ed25519 for `verifySignature`) and +`fetch`, so it runs in current browsers and in Node 20 or later (`engines.node` is `>=20`). ## What this proves, and what it does not @@ -56,7 +57,14 @@ node = sha256(0x01 || left || right) ``` Leaves are sorted ascending by leaf hash, duplicates are rejected, and a lone node on a level is promoted unchanged. -The leaf count is not hashed into the root; the memo carries it next to the root. +The leaf count is not hashed into the root; the memo carries it next to the root. A full row set is checked against +`count`; one row with a proof is bound to `count` only as an upper bound on the proof's length, so proof mode shows the +row is in the committed set, not that the set is complete (SPEC section 8). + +A row must be a plain JSON object. `serializeRow`, `leafHash`, `merkleRoot` and `merkleProof` throw on an array, `null`, +a primitive or a class instance such as a `Date` or `Map`, and `verifyAgainstMemo` reports `rows_invalid` (or +`leaf_invalid` for a non-string `leaf`). Earlier builds serialized an array row as `{"0":...,"1":...}`; no anchor +depends on that, because the app only ever anchors objects. 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` @@ -86,6 +94,15 @@ node packages/verify/dist/cli.js --help Once published it will install as `npm install @chartrunner/verify` (the package has no runtime dependencies). +Publishing is manual and owner-only. The `Publish @chartrunner/verify` workflow +(`.github/workflows/publish.yml`) runs when the owner publishes a GitHub release tagged `v`, or when the owner +dispatches it by hand (a dry run by default). It builds, tests, checks the vectors and runs +`npm publish --provenance --access public` with the `NPM_TOKEN` repository secret, which only the owner sets. +`prepublishOnly` refuses to publish when `LICENSE`, `NOTICE` or the built `dist/` is missing from the package +directory (`node scripts/pack-files.mjs copy` puts the licence files in place; `prepack` does it too). CI packs the +package on every pull request, installs the tarball in an empty project and runs the CLI and the offline example on +Node 20 and 24. + ## Node ```ts @@ -140,20 +157,47 @@ chartrunner-verify --proof proof.json --memo "" # one entry + p `{"leaf": "", "proof": [{"side": "L"|"R", "hash": ""}]}`. `--signer` is required with `--sig`: anyone can post a memo for any id, so a matching root only means something when the transaction was paid by the operator address ChartRunner publishes. Add `--kind`/`--id` to pin the memo to a snapshot and `--json` for machine output. `--verify-signature` checks the transaction's signatures locally (above). +`--signer` must be a base58 address of 32 bytes; anything else is a usage error (exit `2`), so a typo is never reported +as a mismatch. In a runtime without WebCrypto Ed25519, `--verify-signature` exits `2` with `signature_unverified` +(the check was requested but not performed), never `0`. Instead of `--rpc` you can set `CHARTRUNNER_RPC_URL`, which +keeps a URL with an API key out of your shell history; `--rpc` overrides it, and offline (`--memo`) runs ignore it. `--check-equivocation` scans the operator's finalized history for other anchors of the same kind/id (SPEC 2.5) and applies "first finalized operator memo wins" to the transaction checked: if another anchor has a different root or count, the checked one passes (with a loud warning) only if it has the same root and count as the first finalized, and is a mismatch (exit `1`) otherwise; a duplicate is a warning; a scan that cannot complete, or that completes without -finding the checked transaction, is an error (exit `2`: the root matched, but uniqueness was not checked). `--max-signatures ` sets how much history to read (default 100000). - -Exit codes: `0` verified, `1` mismatch (the data does not match the anchor, or the memo is not a valid anchor), -`2` error (bad input, no RPC given, RPC outage, transaction not found, ...). An outage is never a pass. +finding the checked transaction, is an error (exit `2`: the root matched, but uniqueness was not checked). `--max-signatures ` sets how much history to read (default 100000); it takes plain decimal digits only, so `1e3`, `0x10` +or `10.0` are usage errors. + +### Exit codes + +`0` verified. `1` the data does not match: the rows, the proof or the transaction contradict the anchor (tampering or +a mismatch). `2` a usage or environment error, or a check that was not performed: nothing was compared, so there is no +verdict either way. An outage is never a pass. + +| code | reason | why this code | +| ---- | ------ | ------------- | +| `1` | `root_mismatch`, `count_mismatch`, `proof_invalid` | the data you hold does not rebuild the anchored root or count (a proof longer than the count allows cannot belong to that tree) | +| `1` | `memo_malformed`, `memo_subject_mismatch` | the memo is not a valid anchor, or is for another kind or id than you pinned | +| `1` | `no_memo`, `multiple_memos`, `tx_failed`, `signer_mismatch` | the transaction is not a valid anchor: no single memo, failed on chain, or not signed as its kind requires | +| `1` | `bad_signature`, `tx_mismatch` | with `--verify-signature`: the RPC served data that the signed transaction bytes contradict, a forged answer, so the data does not match | +| `1` | `equivocation` | with `--check-equivocation`: the operator anchored this kind/id with another root or count first, so this anchor is void | +| `2` | `rows_invalid`, `leaf_invalid`, `input_invalid` | your input cannot be committed or read (empty, duplicate rows, a float, an array as a row, ...): an input error, nothing was compared | +| `2` | usage errors (no reason): unknown flag, missing `--rpc`/`CHARTRUNNER_RPC_URL` or `--signer`, a `--signer` that is not a 32-byte base58 address, a bad `--max-signatures` | fix the command line | +| `2` | `rpc_url_invalid`, `signature_invalid` | the URL or transaction signature you gave is malformed | +| `2` | `rpc_unreachable`, `rpc_error`, `tx_not_found`, `meta_missing`, `signature_mismatch`, `tx_undecodable` | the RPC could not give a usable answer (outage, pruned history, a different transaction than asked for, bytes that do not decode); nothing proves the anchor right or wrong | +| `2` | `signature_unverified` | `--verify-signature` was requested, but this runtime has no Ed25519, so it was not performed | +| `2` | `equivocation_unchecked` | the root matched, but the equivocation scan could not complete or did not find this transaction: uniqueness was not checked | + +`signature_mismatch` stays `2` while `tx_mismatch` is `1`: the first is an RPC returning some other transaction, which +says nothing about this anchor; the second is the RPC's description of this transaction contradicted by its own signed +bytes. The offline `--memo` text is used exactly as given (no trimming). From a checkout: `node packages/verify/dist/cli.js ...` after `pnpm build`, or `node packages/verify/bin/chartrunner-verify.mjs ...`, -which runs the TypeScript source directly (Node 22.18+ strips the types on the fly; `engines.node` is `>=22.18`). The -built library itself only needs a runtime with WebCrypto and `fetch` (Node 20+ or a current browser). +which runs the TypeScript source directly on a Node that strips types (22.18 or later) and the built `dist/` otherwise. +The package needs only a runtime with global WebCrypto and `fetch`: Node 20 or later (`engines.node` is `>=20`; Node 18 +has no global `crypto`), or a current browser. Building and testing this repository needs Node 24 (`.node-version`). ## API @@ -184,7 +228,11 @@ Failure reasons from `verifyAgainstMemo`: `memo_malformed`, `memo_subject_mismat `proof_invalid`, `root_mismatch`, `count_mismatch`, `input_invalid`. From the RPC helpers: `rpc_url_invalid`, `signature_invalid`, `signature_mismatch`, `meta_missing`, `rpc_unreachable`, `rpc_error`, `tx_not_found`, `tx_failed`, `no_memo`, `multiple_memos`, `signer_mismatch`, and with `verifySignature`: `tx_undecodable`, `bad_signature`, `tx_mismatch`. From `checkEquivocation`: `input_invalid`, `rpc_url_invalid`, `rpc_unreachable`, `rpc_error`, -`scan_incomplete` (never answered with `none` or `unique` from a partial history). +`scan_incomplete` (never answered with `none` or `unique` from a partial history). `checkEquivocation` also returns +`scan_incomplete` when the RPC's `getSignaturesForAddress` entries leave out the `memo` field (or report something +other than a string or `null`): without it the scan cannot tell which transactions to read, and reading every one would +cost up to `maxSignatures` requests. Earlier builds read every such transaction one by one; use an RPC that reports +memos (standard Solana RPC nodes do). ## Development diff --git a/packages/verify/bin/chartrunner-verify.mjs b/packages/verify/bin/chartrunner-verify.mjs index c24a7f4..df54317 100755 --- a/packages/verify/bin/chartrunner-verify.mjs +++ b/packages/verify/bin/chartrunner-verify.mjs @@ -1,11 +1,12 @@ #!/usr/bin/env node -// Workspace checkout: runs the TypeScript source through Node's type stripping (Node >= 22.18 / 24). -// Published package (only dist/ ships): runs the compiled output built by `pnpm build`. +// Workspace checkout on a Node that strips TypeScript types (22.18+ / 24): runs the TypeScript source directly. +// Otherwise (the published package, which ships only dist/, or an older Node): runs the compiled output of `pnpm build`. import { existsSync } from "node:fs"; try { const src = new URL("../src/cli.ts", import.meta.url); - const { main } = await import(existsSync(src) ? src.href : new URL("../dist/cli.js", import.meta.url).href); + const useSrc = existsSync(src) && Boolean(process.features?.typescript); + const { main } = await import(useSrc ? src.href : new URL("../dist/cli.js", import.meta.url).href); process.exitCode = await main(process.argv.slice(2)); } catch (e) { console.error(`ERROR unexpected: ${e instanceof Error ? e.message : String(e)}`); diff --git a/packages/verify/package.json b/packages/verify/package.json index 82cbd9a..0438a46 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -20,7 +20,7 @@ "type": "module", "sideEffects": false, "engines": { - "node": ">=22.18" + "node": ">=20" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -41,14 +41,19 @@ "LICENSE", "NOTICE" ], + "publishConfig": { + "access": "public", + "provenance": true + }, "scripts": { - "build": "rm -rf dist && tsc -p tsconfig.build.json", + "build": "node scripts/pack-files.mjs clean && tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit", "test": "vitest run", - "prepack": "pnpm run build && cp ../../LICENSE ../../NOTICE ." + "prepack": "npm run build && node scripts/pack-files.mjs copy", + "prepublishOnly": "node scripts/pack-files.mjs check" }, "devDependencies": { - "@types/node": "^24", + "@types/node": "24.19.1", "typescript": "5.9.3", "vitest": "5.0.3" } diff --git a/packages/verify/scripts/pack-files.mjs b/packages/verify/scripts/pack-files.mjs new file mode 100644 index 0000000..2b351f2 --- /dev/null +++ b/packages/verify/scripts/pack-files.mjs @@ -0,0 +1,51 @@ +// Package-file housekeeping for @chartrunner/verify, cross-platform (no cp / rm in npm scripts). +// +// node scripts/pack-files.mjs clean remove dist/ (before a build) +// node scripts/pack-files.mjs copy copy LICENSE and NOTICE from the repository root into this package (prepack) +// node scripts/pack-files.mjs check fail unless LICENSE, NOTICE and a built dist/ are here (prepublishOnly) +// +// LICENSE and NOTICE are canonical at the repository root; the copies in this directory are gitignored. + +import { copyFileSync, existsSync, readFileSync, rmSync } from "node:fs"; + +const here = new URL("../", import.meta.url); +const root = new URL("../../", here); +const LEGAL = ["LICENSE", "NOTICE"]; +const DIST = ["dist/index.js", "dist/index.d.ts", "dist/cli.js"]; + +function clean() { + rmSync(new URL("dist", here), { recursive: true, force: true }); +} + +function copy() { + for (const name of LEGAL) { + const from = new URL(name, root); + if (!existsSync(from)) throw new Error(`${name} is missing at the repository root`); + copyFileSync(from, new URL(name, here)); + } +} + +function check() { + const problems = []; + for (const name of LEGAL) { + const file = new URL(name, here); + if (!existsSync(file)) { + problems.push(`${name} is missing from the package directory (run: node scripts/pack-files.mjs copy)`); + continue; + } + const canonical = new URL(name, root); + if (existsSync(canonical) && !readFileSync(file).equals(readFileSync(canonical))) problems.push(`${name} differs from the repository root copy`); + } + for (const name of DIST) if (!existsSync(new URL(name, here))) problems.push(`${name} is missing: build the package first (pnpm build)`); + if (problems.length > 0) throw new Error(`not ready to publish:\n - ${problems.join("\n - ")}`); +} + +const commands = { clean, copy, check }; +const cmd = process.argv[2] ?? ""; +try { + if (!Object.hasOwn(commands, cmd)) throw new Error("usage: node scripts/pack-files.mjs clean | copy | check"); + commands[cmd](); +} catch (e) { + console.error(`pack-files: ${e instanceof Error ? e.message : String(e)}`); + process.exitCode = 1; +} diff --git a/packages/verify/src/__tests__/cli.test.ts b/packages/verify/src/__tests__/cli.test.ts index 134efad..e20454a 100644 --- a/packages/verify/src/__tests__/cli.test.ts +++ b/packages/verify/src/__tests__/cli.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { battleLeaf, cupLeavesFromStandings, dailyRaceLeaf, dailyRunLeaf, formatMemo, merkleProof, merkleRoot } from "../index.ts"; -import { exitCodeFor } from "../cli.ts"; +import { exitCodeFor, main } from "../cli.ts"; const bin = new URL("../../bin/chartrunner-verify.mjs", import.meta.url).pathname; const dir = mkdtempSync(join(tmpdir(), "cr-verify-cli-")); @@ -13,7 +13,12 @@ const write = (name: string, v: unknown) => { writeFileSync(f, typeof v === "string" ? v : JSON.stringify(v)); return f; }; -const run = (...args: string[]) => spawnSync(process.execPath, [bin, ...args], { encoding: "utf8" }); +// The child never inherits a CHARTRUNNER_RPC_URL from the developer's shell. +const childEnv = { ...process.env }; +delete childEnv.CHARTRUNNER_RPC_URL; +const run = (...args: string[]) => spawnSync(process.execPath, [bin, ...args], { encoding: "utf8", env: childEnv }); +/** A syntactically valid address (32 bytes in base58); not anyone's operator. */ +const ADDR = "So11111111111111111111111111111111111111112"; const rows = [{ id: "a", s: 1 }, { id: "b", s: 2 }, { id: "c", s: 3 }]; @@ -89,7 +94,9 @@ describe("chartrunner-verify CLI", async () => { expect(run().status).toBe(2); expect(run("--rows", good).status).toBe(2); expect(run("--memo", memo).status).toBe(2); - expect(run("--rows", good, "--sig", "5".repeat(88), "--signer", "Op").status).toBe(2); // no default RPC + const noRpc = run("--rows", good, "--sig", "5".repeat(88), "--signer", ADDR); + expect(noRpc.status).toBe(2); // no default RPC + expect(noRpc.stderr).toContain("CHARTRUNNER_RPC_URL"); const noSigner = run("--rows", good, "--sig", "5".repeat(88), "--rpc", "http://127.0.0.1:1"); expect(noSigner.status).toBe(2); // --signer is required with --sig expect(noSigner.stderr).toContain("--signer"); @@ -99,7 +106,7 @@ describe("chartrunner-verify CLI", async () => { expect(run("--bogus").status).toBe(2); }); it("exits 2, never 0, when the RPC is unreachable", () => { - const r = run("--rows", good, "--sig", "5".repeat(88), "--rpc", "http://127.0.0.1:1", "--signer", "Op"); + const r = run("--rows", good, "--sig", "5".repeat(88), "--rpc", "http://127.0.0.1:1", "--signer", ADDR); expect(r.status).toBe(2); expect(r.stderr).toContain("rpc_unreachable"); }); @@ -113,10 +120,58 @@ describe("chartrunner-verify CLI", async () => { it("--help exits 0", () => { expect(run("--help").status).toBe(0); }); - it("maps reasons to exit codes", () => { + it("maps reasons to exit codes: 1 the data does not match, 2 usage/environment or a check not performed", () => { expect(exitCodeFor({ ok: true })).toBe(0); - expect(exitCodeFor({ ok: false, reason: "root_mismatch" })).toBe(1); - expect(exitCodeFor({ ok: false, reason: "rpc_unreachable" })).toBe(2); - expect(exitCodeFor({ ok: false, reason: "rows_invalid" })).toBe(2); + for (const reason of ["root_mismatch", "count_mismatch", "proof_invalid", "memo_subject_mismatch", "memo_malformed", "signer_mismatch", "tx_failed", "no_memo", "multiple_memos", "equivocation", "bad_signature", "tx_mismatch"]) { + expect(exitCodeFor({ ok: false, reason }), reason).toBe(1); + } + for (const reason of ["rows_invalid", "leaf_invalid", "input_invalid", "rpc_url_invalid", "signature_invalid", "rpc_unreachable", "rpc_error", "tx_not_found", "meta_missing", "signature_mismatch", "tx_undecodable", "signature_unverified", "equivocation_unchecked"]) { + expect(exitCodeFor({ ok: false, reason }), reason).toBe(2); + } + }); + it("--signer must be a base58 32-byte address: a typo is a usage error (2), never a mismatch", () => { + for (const bad of ["Op", ADDR.slice(0, -1) + "0", ADDR.slice(0, -1) + "l", "1".repeat(44), ADDR + "1"]) { + const r = run("--rows", good, "--sig", "5".repeat(88), "--rpc", "http://127.0.0.1:1", "--signer", bad); + expect(r.status, bad).toBe(2); + expect(r.stderr, bad).toMatch(/--signer must be a base58 Solana address/); + } + }); +}); + +describe("chartrunner-verify CLI: RPC URL from the environment", () => { + const SIG = "5".repeat(88); + const silent = { out: () => {}, err: () => {} }; + /** Runs main with a fetch that records the URL it was called with and answers like a node without the transaction. */ + async function urlUsed(argv: string[], env: Record) { + const urls: string[] = []; + const real = globalThis.fetch; + globalThis.fetch = (async (url: string) => { + urls.push(String(url)); + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: null })); + }) as typeof fetch; + try { + const code = await main(argv, silent, env); + return { code, urls }; + } finally { + globalThis.fetch = real; + } + } + const rowsFile = write("env-rows.json", rows); + + it("uses CHARTRUNNER_RPC_URL when --rpc is absent", async () => { + const r = await urlUsed(["--rows", rowsFile, "--sig", SIG, "--signer", ADDR], { CHARTRUNNER_RPC_URL: "https://env-rpc.example/key" }); + expect(r.urls).toEqual(["https://env-rpc.example/key"]); + expect(r.code).toBe(2); // tx_not_found: the URL was used, nothing verified + }); + it("--rpc overrides the environment", async () => { + const r = await urlUsed(["--rows", rowsFile, "--sig", SIG, "--signer", ADDR, "--rpc", "https://flag-rpc.example"], { CHARTRUNNER_RPC_URL: "https://env-rpc.example" }); + expect(r.urls).toEqual(["https://flag-rpc.example/"]); + }); + it("an empty variable is unset, and offline mode ignores it", async () => { + const empty = await urlUsed(["--rows", rowsFile, "--sig", SIG, "--signer", ADDR], { CHARTRUNNER_RPC_URL: "" }); + expect(empty).toEqual({ code: 2, urls: [] }); + const { root, count } = await merkleRoot(rows); + const offline = await urlUsed(["--rows", rowsFile, "--memo", formatMemo({ kind: "cup", id: "c1", root, count })], { CHARTRUNNER_RPC_URL: "https://env-rpc.example" }); + expect(offline).toEqual({ code: 0, urls: [] }); }); }); diff --git a/packages/verify/src/__tests__/registry.test.ts b/packages/verify/src/__tests__/registry.test.ts index d8580a3..aceaf12 100644 --- a/packages/verify/src/__tests__/registry.test.ts +++ b/packages/verify/src/__tests__/registry.test.ts @@ -251,10 +251,35 @@ describe("checkEquivocation", () => { ]); expect(await checkEquivocation({ ...base, fetch: f })).toMatchObject({ ok: true, status: "unique", winner: { signature: sig(3) } }); }); - it("reads every transaction when the RPC does not report memos", async () => { + it("an RPC that does not report memos is scan_incomplete: no answer, and no read of every transaction", async () => { const { f, calls } = fakeRpc([{ signature: sig(1), slot: 10, memo: m("cup-1") }, { signature: sig(2), slot: 11, memo: m("cup-2") }], { omitMemoField: true }); - expect(await checkEquivocation({ ...base, fetch: f })).toMatchObject({ ok: true, status: "unique" }); - expect(calls.filter((c) => c.method === "getTransaction")).toHaveLength(2); + const r = await checkEquivocation({ ...base, fetch: f }); + expect(r).toMatchObject({ ok: false, reason: "scan_incomplete" }); + expect(!r.ok && r.message).toMatch(/did not report the memo of .* uniqueness was not checked/); + expect(calls.filter((c) => c.method === "getTransaction")).toHaveLength(0); + }); + it("a memo field that is not a string or null is unknown too, even on one entry", async () => { + const txs = [{ signature: sig(1), slot: 10, memo: m("cup-1") }, { signature: sig(2), slot: 11, memo: null }]; + const { f: good } = fakeRpc(txs); + const odd = (async (url: string, init: RequestInit) => { + const res = await good(url, init); + const body = await res.json(); + if (JSON.parse(init.body as string).method === "getSignaturesForAddress" && body.result.length > 0) body.result[1].memo = 42; + return new Response(JSON.stringify(body)); + }) as typeof fetch; + expect(await checkEquivocation({ ...base, fetch: odd })).toMatchObject({ ok: false, reason: "scan_incomplete" }); + }); + it("a failed transaction without a memo field is still skipped (it anchors nothing)", async () => { + const { f } = fakeRpc([{ signature: sig(1), slot: 10, memo: m("cup-1") }], {}); + const failedNoMemo = (async (url: string, init: RequestInit) => { + const res = await f(url, init); + const body = await res.json(); + if (JSON.parse(init.body as string).method === "getSignaturesForAddress" && !JSON.parse(init.body as string).params[1].before) { + body.result.unshift({ signature: sig(5), slot: 3, err: { InstructionError: [0, "x"] }, blockTime: null }); + } + return new Response(JSON.stringify(body)); + }) as typeof fetch; + expect(await checkEquivocation({ ...base, fetch: failedNoMemo })).toMatchObject({ ok: true, status: "unique" }); }); it("pages through the whole history", async () => { const txs = Array.from({ length: 7 }, (_, i) => ({ signature: sig(i + 1), slot: 100 + i, memo: i === 0 || i === 6 ? m("cup-1") : m("cup-9") })); diff --git a/packages/verify/src/__tests__/rpc.test.ts b/packages/verify/src/__tests__/rpc.test.ts index d52f16b..2c2e77e 100644 --- a/packages/verify/src/__tests__/rpc.test.ts +++ b/packages/verify/src/__tests__/rpc.test.ts @@ -91,6 +91,48 @@ describe("fetchMemo", () => { }); }); +describe("fetchMemo: account-key shapes and the signer model (SPEC 2.4 rule 6)", () => { + const OPERATOR = "So11111111111111111111111111111111111111112"; + const WALLET = "Vote111111111111111111111111111111111111111"; + const OTHER = "Stake11111111111111111111111111111111111111"; + const LOADED = "SysvarRent111111111111111111111111111111111"; + const ROOT = "a".repeat(64); + const cupMemo = formatMemo({ kind: "cup", id: "c1", root: ROOT, count: 3 }); + const mapMemo = formatMemo({ kind: "map", id: "ent-1", root: ROOT, count: 1 }); + const key = (pubkey: string, signer: boolean, source = "transaction") => ({ pubkey, signer, writable: signer, source }); + const withKeys = (memo: string, accountKeys: unknown[], extra: Record = {}) => ({ + slot: 90, + version: 0, + meta: { err: null, loadedAddresses: { writable: [], readonly: [LOADED] } }, + transaction: { signatures: [SIG], message: { accountKeys, instructions: [{ programId: MEMO_PROGRAM_ID, parsed: memo }], ...extra } }, + }); + + it("a v0 transaction with lookup-table addresses: loaded keys never count as signers", async () => { + const keys = [key(OPERATOR, true), key(MEMO_PROGRAM_ID, false), key(LOADED, false, "lookupTable")]; + const v0 = withKeys(cupMemo, keys, { addressTableLookups: [{ accountKey: OTHER, writableIndexes: [], readonlyIndexes: [0] }] }); + expect(await fetchMemo(SIG, { ...opts(reply(v0)), expectSigner: OPERATOR })).toEqual({ ok: true, memo: cupMemo, feePayer: OPERATOR, signers: [OPERATOR], slot: 90, signatureCheck: "not_requested" }); + // A user kind cannot be "signed" by an address that only arrived through a lookup table. + expect(await fetchMemo(SIG, { ...opts(reply(withKeys(mapMemo, keys))), expectSigner: LOADED })).toMatchObject({ ok: false, reason: "signer_mismatch" }); + }); + it("operator kind: the operator co-signing while someone else pays is a signer_mismatch", async () => { + const cosigned = [key(OTHER, true), key(OPERATOR, true)]; + const r = await fetchMemo(SIG, { ...opts(reply(withKeys(cupMemo, cosigned))), expectSigner: OPERATOR }); + expect(r).toMatchObject({ ok: false, reason: "signer_mismatch" }); + expect(!r.ok && r.message).toContain(OTHER); + }); + it("user kind: the expected wallet among the signers passes, whoever pays", async () => { + const sponsored = [key(OTHER, true), key(WALLET, true), key(MEMO_PROGRAM_ID, false)]; + expect(await fetchMemo(SIG, { ...opts(reply(withKeys(mapMemo, sponsored))), expectSigner: WALLET })).toMatchObject({ ok: true, feePayer: OTHER, signers: [OTHER, WALLET] }); + }); + it("plain-string account keys carry no signer flags: only the fee payer is known to have signed", async () => { + const plain = [OPERATOR, WALLET, MEMO_PROGRAM_ID]; + expect(await fetchMemo(SIG, opts(reply(withKeys(cupMemo, plain))))).toMatchObject({ ok: true, feePayer: OPERATOR, signers: [OPERATOR] }); + expect(await fetchMemo(SIG, { ...opts(reply(withKeys(cupMemo, plain))), expectSigner: OPERATOR })).toMatchObject({ ok: true }); + expect(await fetchMemo(SIG, { ...opts(reply(withKeys(mapMemo, plain))), expectSigner: WALLET })).toMatchObject({ ok: false, reason: "signer_mismatch" }); + expect(await fetchMemo(SIG, { ...opts(reply(withKeys(mapMemo, plain))), expectSigner: OPERATOR })).toMatchObject({ ok: true, signers: [OPERATOR] }); + }); +}); + describe("verifyAgainstSignature", () => { it("verifies rows against the on-chain memo", async () => { const { root, count } = await merkleRoot(rows); diff --git a/packages/verify/src/__tests__/sdk.test.ts b/packages/verify/src/__tests__/sdk.test.ts index 6394f94..2dc02e5 100644 --- a/packages/verify/src/__tests__/sdk.test.ts +++ b/packages/verify/src/__tests__/sdk.test.ts @@ -359,6 +359,21 @@ describe("verifyAgainstMemo", () => { expect(await verifyAgainstMemo({ rows: [A, A], memoText: memo })).toMatchObject({ ok: false, reason: "rows_invalid" }); expect(await verifyAgainstMemo({ memoText: memo } as never)).toMatchObject({ ok: false, reason: "input_invalid" }); }); + it("a row must be a plain object: an array, null or a class instance is rows_invalid / leaf_invalid, never hashed", async () => { + // Before this check an array row serialized as {"0":"a","1":1}, a Date as {}. + const notRows: unknown[] = [["a", 1], [], null, "row", 7, new Date(0), new Map([["a", 1]])]; + for (const row of notRows) { + expect(() => serializeRow(row as never), String(row)).toThrow(/plain JSON object/); + await expect(merkleRoot([row as never])).rejects.toThrow(); + expect(await verifyAgainstMemo({ rows: [row as never], memoText: memo })).toMatchObject({ ok: false, reason: "rows_invalid" }); + expect(await verifyAgainstMemo({ leaf: row as never, proof: [], memoText: memo })).toMatchObject({ ok: false }); + } + expect(await verifyAgainstMemo({ leaf: ["a", 1] as never, proof: [], memoText: memo })).toMatchObject({ ok: false, reason: "leaf_invalid" }); + expect(await verifyAgainstMemo({ rows: [A, ["a", 1] as never], memoText: memo })).toMatchObject({ ok: false, reason: "rows_invalid" }); + // A null-prototype object is still a plain row and serializes like a literal. + const bare = Object.assign(Object.create(null), { s: 1, id: "a" }); + expect(serializeRow(bare)).toBe('{"id":"a","s":1}'); + }); it("can pin the memo to an expected kind and id", async () => { expect(await verifyAgainstMemo({ rows, memoText: memo, expect: { kind: "cup", id: "c1" } })).toMatchObject({ ok: true }); expect(await verifyAgainstMemo({ rows, memoText: memo, expect: { id: "c2" } })).toMatchObject({ ok: false, reason: "memo_subject_mismatch" }); diff --git a/packages/verify/src/__tests__/sigcheck.test.ts b/packages/verify/src/__tests__/sigcheck.test.ts index 2a22367..23a1951 100644 --- a/packages/verify/src/__tests__/sigcheck.test.ts +++ b/packages/verify/src/__tests__/sigcheck.test.ts @@ -191,6 +191,28 @@ describe("CLI --verify-signature and --check-equivocation", async () => { expect(await s.run("--check-equivocation", "--max-signatures", "0")).toBe(2); expect(await s.run("--max-signatures", "10")).toBe(2); }); + it("--max-signatures accepts plain decimal digits only (not 1e3, 0x10, 10.0, +10, a space or a leading zero)", async () => { + const memo = formatMemo({ kind: "cup", id: "c1", root, count }); + const s = await setup([{ memo, slot: 50, self: true }]); + for (const bad of ["1e3", "0x10", "10.0", "+10", " 10", "010", "-1", "9007199254740993"]) { + expect(await s.run("--check-equivocation", "--max-signatures", bad), bad).toBe(2); + } + expect(s.err.join("\n")).toMatch(/--max-signatures must be a positive decimal integer/); + expect(await s.run("--check-equivocation", "--max-signatures", "1000")).toBe(0); + }); + it("--verify-signature without Ed25519 in this runtime: exit 2 (check not performed), never 0", async () => { + const memo = formatMemo({ kind: "cup", id: "c1", root, count }); + const s = await setup([{ memo, slot: 50, self: true }]); + const real = globalThis.crypto; + // SHA-256 still works (the Merkle root needs it); only Ed25519 is missing. + const noEd = { digest: (alg: AlgorithmIdentifier, data: BufferSource) => real.subtle.digest(alg, data), generateKey: () => Promise.reject(new DOMException("nope", "NotSupportedError")) }; + vi.stubGlobal("crypto", { subtle: noEd, getRandomValues: real.getRandomValues.bind(real) }); + expect(await s.run("--verify-signature")).toBe(2); + expect(s.err.join("\n")).toMatch(/ERROR {2}signature_unverified: .*signatures were NOT checked/); + expect(s.out).toEqual([]); + vi.stubGlobal("crypto", real); + expect(await s.run()).toBe(0); // without --verify-signature the RPC's answer is used, as documented + }); it("a same-root duplicate of the first anchor passes even when a later anchor equivocates", async () => { const memo = formatMemo({ kind: "cup", id: "c1", root, count }); const s = await setup([{ memo, slot: 50, self: true }, { memo, slot: 40 }, { memo: formatMemo({ kind: "cup", id: "c1", root: "f".repeat(64), count }), slot: 60 }]); diff --git a/packages/verify/src/cli.ts b/packages/verify/src/cli.ts index 9d1fbf0..58ec508 100644 --- a/packages/verify/src/cli.ts +++ b/packages/verify/src/cli.ts @@ -6,39 +6,69 @@ import { fetchMemo, type SignatureCheck } from "./rpc.ts"; import { verifyAgainstMemo, type VerifyResult } from "./verify.ts"; import type { ProofStep } from "./merkle.ts"; import { ANCHOR_KINDS, isKind, isOperatorKind, type AnchorKind } from "./kinds.ts"; +import { base58Decode } from "./sigcheck.ts"; -// chartrunner-verify: exit 0 verified, 1 the data does not match the anchor, 2 error (bad input, RPC outage, ...). +// chartrunner-verify: exit 0 verified, 1 the data does not match the anchor, 2 usage or environment error, or a check +// that was not performed (bad input, RPC outage, no Ed25519 for --verify-signature, ...). + +/** Environment variable read for the RPC URL when --rpc is not given (keeps a keyed URL out of shell history). */ +export const RPC_URL_ENV = "CHARTRUNNER_RPC_URL"; export const USAGE = `chartrunner-verify: check a ChartRunner result against its on-chain anchor chartrunner-verify --rows results.json --sig --rpc --signer
+ CHARTRUNNER_RPC_URL= chartrunner-verify --rows results.json --sig --signer
chartrunner-verify --rows results.json --memo "" (offline) chartrunner-verify --proof proof.json --memo "" (one entry + Merkle proof, offline) chartrunner-verify --proof proof.json --sig --rpc --signer
--rows JSON: an array of rows, {"rows":[...]}, or a cup results document ({"cupId","results":{"standings":[...]}}) --proof JSON: {"leaf":"","proof":[{"side":"L|R","hash":""}]} (the app's /api/anchors/proof response works) - --sig anchor transaction signature; requires --rpc and --signer - --rpc Solana JSON-RPC endpoint you trust (no default) + --sig anchor transaction signature; requires --rpc (or CHARTRUNNER_RPC_URL) and --signer + --rpc Solana JSON-RPC endpoint you trust (no default). Without --rpc, the CHARTRUNNER_RPC_URL + environment variable is used, which keeps a URL with an API key out of shell history; + --rpc overrides it --memo the memo text as shown on a Solana explorer (offline) --signer required with --sig (anyone can post a memo): the published operator address - (fee payer) for operator kinds, or the wallet that must have signed for user kinds + (fee payer) for operator kinds, or the wallet that must have signed for user kinds; + must be a base58 32-byte address (a typo is a usage error, exit 2, not a mismatch) --kind / --id also require the memo to be for this kind / id --verify-signature with --sig: also fetch the wire transaction and verify its Ed25519 signatures locally - (then memo, fee payer and signers come from the signed bytes, not the RPC's word) + (then memo, fee payer and signers come from the signed bytes, not the RPC's word). In a + runtime without WebCrypto Ed25519 the check cannot run: exit 2, never a pass --check-equivocation with --sig, operator kinds: scan the operator's finalized history (SPEC 2.5). The first finalized operator anchor for the kind/id wins: if another anchor has a different root or count, the checked one passes only if it has the first finalized anchor's root and count (with a loud warning) and fails (exit 1) otherwise; a duplicate (same root and count) is a warning; a scan that cannot complete, or does not find the checked transaction, is an error (exit 2), never a pass - --max-signatures with --check-equivocation: how many operator signatures to read at most (default ${DEFAULT_MAX_SIGNATURES}) + --max-signatures with --check-equivocation: how many operator signatures to read at most, a plain decimal + integer >= 1 (default ${DEFAULT_MAX_SIGNATURES}) --json print the result as JSON -h, --help -exit codes: 0 verified, 1 mismatch, 2 error`; +exit codes: 0 verified; 1 the data does not match (tampering or mismatch); 2 usage or environment error, +or a check that was not performed`; -const MISMATCH = new Set(["root_mismatch", "count_mismatch", "proof_invalid", "memo_subject_mismatch", "signer_mismatch", "memo_malformed", "tx_failed", "no_memo", "multiple_memos", "equivocation"]); +/** + * Reasons that mean "the data does not match the anchor": exit 1. Everything else (bad input, RPC outage, a check that + * could not run) is exit 2. rows_invalid / leaf_invalid are input errors (2): nothing was compared. bad_signature / + * tx_mismatch are mismatches (1): the RPC served data that the signed bytes contradict. + */ +const MISMATCH = new Set([ + "root_mismatch", + "count_mismatch", + "proof_invalid", + "memo_subject_mismatch", + "signer_mismatch", + "memo_malformed", + "tx_failed", + "no_memo", + "multiple_memos", + "equivocation", + "bad_signature", + "tx_mismatch", +]); /** Exit code for a result: 0 verified, 1 the data does not match the anchor, 2 anything that prevented a verdict. */ export const exitCodeFor = (r: { ok: boolean; reason?: string }): 0 | 1 | 2 => (r.ok ? 0 : MISMATCH.has(r.reason ?? "") ? 1 : 2); @@ -98,13 +128,28 @@ function loadProof(file: string): Loaded { return { leaf: data.leaf, proof: data.proof }; } -/** Run the CLI. Returns the exit code; writes to stdout/stderr via the given sinks (defaults: console). */ -export async function main(argv: string[], io: { out: (s: string) => void; err: (s: string) => void } = { out: (s) => console.log(s), err: (s) => console.error(s) }): Promise<0 | 1 | 2> { +/** True for a base58 string that decodes to exactly 32 bytes (a Solana address). */ +function isAddress(s: string): boolean { + if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(s)) return false; + return base58Decode(s)?.length === 32; +} + +/** + * Run the CLI. Returns the exit code; writes to stdout/stderr via the given sinks (defaults: console). `env` supplies + * CHARTRUNNER_RPC_URL (default: process.env). + */ +export async function main( + argv: string[], + io: { out: (s: string) => void; err: (s: string) => void } = { out: (s) => console.log(s), err: (s) => console.error(s) }, + env: Record = process.env, +): Promise<0 | 1 | 2> { const args = parseArgs(argv); if (typeof args === "string") { io.err(`error: ${args}\n\n${USAGE}`); return 2; } + // --rpc overrides the environment; an empty variable counts as unset. + if (args.rpc === undefined && args.sig !== undefined && env[RPC_URL_ENV]) args.rpc = env[RPC_URL_ENV]; if (args.help) { io.out(USAGE); return 0; @@ -118,16 +163,22 @@ export async function main(argv: string[], io: { out: (s: string) => void; err: return 2; } if (args.sig !== undefined && args.rpc === undefined) { - io.err("error: --sig needs --rpc (there is no default RPC)"); + io.err(`error: --sig needs --rpc or ${RPC_URL_ENV} (there is no default RPC)`); return 2; } if (args.sig !== undefined && args.signer === undefined) { io.err("error: --sig needs --signer . Anyone can post a memo for any id, so a root match alone does not show ChartRunner anchored it."); return 2; } - const maxSignatures = args["max-signatures"] === undefined ? DEFAULT_MAX_SIGNATURES : Number(args["max-signatures"]); + if (args.signer !== undefined && !isAddress(args.signer)) { + io.err("error: --signer must be a base58 Solana address (32 bytes). Check it for a typo: this is a usage error, not a mismatch."); + return 2; + } + const rawMax = args["max-signatures"]; + // Plain decimal only: Number() would also accept "1e3", "0x10", " 10" or "10.0". + const maxSignatures = rawMax === undefined ? DEFAULT_MAX_SIGNATURES : /^[1-9][0-9]*$/.test(rawMax) ? Number(rawMax) : NaN; if (!Number.isSafeInteger(maxSignatures) || maxSignatures < 1) { - io.err("error: --max-signatures must be a positive integer"); + io.err("error: --max-signatures must be a positive decimal integer (digits only)"); return 2; } if (args["max-signatures"] !== undefined && !args.checkEquivocation) { @@ -156,6 +207,15 @@ export async function main(argv: string[], io: { out: (s: string) => void; err: if (args.sig !== undefined) { const got = await fetchMemo(args.sig, { rpcUrl: args.rpc!, expectSigner: args.signer, verifySignature: args.verifySignature }); if (!got.ok) return report(io, args.json, { ok: false, reason: got.reason, message: got.message }, undefined); + if (args.verifySignature && got.signatureCheck !== "verified") { + // Requested but not performed (no WebCrypto Ed25519 here): never report that as verified. + return report( + io, + args.json, + { ok: false, reason: "signature_unverified", message: "--verify-signature was requested, but this runtime has no WebCrypto Ed25519, so the signatures were NOT checked. Use a runtime with Ed25519, or drop --verify-signature to rely on the RPC's answer." }, + undefined, + ); + } memoText = got.memo; chain = { feePayer: got.feePayer, slot: got.slot, signatureCheck: got.signatureCheck, warnings: [] }; } @@ -220,7 +280,6 @@ function report(io: { out: (s: string) => void; err: (s: string) => void }, json if (chain) { io.out(` paid by ${chain.feePayer ?? "unknown"}${chain.slot !== null ? `, slot ${chain.slot}` : ""}`); if (chain.signatureCheck === "verified") io.out(" signatures verified locally over the transaction bytes"); - if (chain.signatureCheck === "unsupported") io.out(" signatures NOT verified locally (no Ed25519 in this runtime); the RPC's answer was trusted"); if (chain.standing === "sole" && chain.equivocation?.ok) io.out(` only operator anchor for this kind/id (${chain.equivocation.scanned} signatures scanned)`); } } else io.err(`${code === 1 ? "MISMATCH" : "ERROR"} ${r.reason}: ${r.message}`); diff --git a/packages/verify/src/equivocation.ts b/packages/verify/src/equivocation.ts index 37d0472..69a7f85 100644 --- a/packages/verify/src/equivocation.ts +++ b/packages/verify/src/equivocation.ts @@ -103,9 +103,17 @@ export async function checkEquivocation(opts: EquivocationOptions): Promise= maxSignatures) { diff --git a/packages/verify/src/merkle.ts b/packages/verify/src/merkle.ts index b95c472..cf78cf4 100644 --- a/packages/verify/src/merkle.ts +++ b/packages/verify/src/merkle.ts @@ -62,6 +62,9 @@ export async function sha256Hex(text: string): Promise { /** Canonical JSON: keys sorted by UTF-16 code unit, no whitespace, integers/strings/booleans/null only. */ export function canonicalJson(entry: LeafEntry): string { + // A row is a flat JSON object (SPEC section 3). An array would otherwise serialize as {"0":...,"1":...}, and a class + // instance (Date, Map, ...) as its own enumerable keys; neither is a row. + if (!isPlainObject(entry)) throw new Error("a row must be a plain JSON object (not an array, null or a class instance)"); const keys = Object.keys(entry).sort(); const parts = keys.map((k) => { const v = entry[k]; @@ -72,6 +75,12 @@ export function canonicalJson(entry: LeafEntry): string { return `{${parts.join(",")}}`; } +function isPlainObject(v: unknown): v is Record { + if (v === null || typeof v !== "object" || Array.isArray(v)) return false; + const proto = Object.getPrototypeOf(v); + return proto === Object.prototype || proto === null; +} + const LEAF_PREFIX = new Uint8Array([0x00]); const NODE_PREFIX = new Uint8Array([0x01]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 963c775..c1f5a9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,7 +27,7 @@ importers: packages/verify: devDependencies: '@types/node': - specifier: ^24 + specifier: 24.19.1 version: 24.19.1 typescript: specifier: 5.9.3 diff --git a/scripts/gen-vectors.mjs b/scripts/gen-vectors.mjs index 982f836..d551a73 100644 --- a/scripts/gen-vectors.mjs +++ b/scripts/gen-vectors.mjs @@ -18,6 +18,7 @@ const shaHex = (text) => sha(text).toString("hex"); // ---- spec section 3: canonical row text ---- function canonical(row) { + if (row === null || typeof row !== "object" || Array.isArray(row)) throw new Error("a row must be a JSON object"); const keys = Object.keys(row).sort(); // UTF-16 code unit order return `{${keys .map((k) => { diff --git a/scripts/pack-smoke.mjs b/scripts/pack-smoke.mjs new file mode 100644 index 0000000..416c336 --- /dev/null +++ b/scripts/pack-smoke.mjs @@ -0,0 +1,60 @@ +// Install a packed @chartrunner/verify tarball into a throwaway project and use it the way a user would: the +// `chartrunner-verify` bin and the offline example, resolved from node_modules (not from this workspace). +// +// cd packages/verify && npm pack --pack-destination +// node scripts/pack-smoke.mjs /chartrunner-verify-.tgz +// +// CI runs this on the minimum Node version in engines and on Node 24. Exit 0 only when every check passes. + +import { execFileSync, spawnSync } from "node:child_process"; +import { copyFileSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const tarball = process.argv[2]; +if (!tarball) { + console.error("usage: node scripts/pack-smoke.mjs "); + process.exit(2); +} + +const repo = new URL("../", import.meta.url); +const dir = mkdtempSync(join(tmpdir(), "cr-verify-pack-")); +writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "pack-smoke", private: true, type: "module" })); +// The package has no dependencies and no install scripts; nothing else is fetched. +execFileSync("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", resolve(tarball)], { cwd: dir, stdio: "inherit" }); + +const pkg = JSON.parse(readFileSync(join(dir, "node_modules/@chartrunner/verify/package.json"), "utf8")); +const bin = join(dir, "node_modules/.bin/chartrunner-verify"); +let failures = 0; +const check = (label, ok, detail = "") => { + if (!ok) failures++; + console.log(`${ok ? "ok " : "FAIL"} ${label}${ok || !detail ? "" : `\n${detail}`}`); +}; + +console.log(`node ${process.version}, @chartrunner/verify ${pkg.version}, engines ${pkg.engines?.node}`); +for (const f of ["LICENSE", "NOTICE", "dist/index.js", "dist/cli.js"]) { + let present = true; + try { + readFileSync(join(dir, "node_modules/@chartrunner/verify", f)); + } catch { + present = false; + } + check(`tarball contains ${f}`, present); +} + +const help = spawnSync(bin, ["--help"], { encoding: "utf8" }); +check("chartrunner-verify --help exits 0", help.status === 0 && help.stdout.includes("chartrunner-verify"), help.stderr); + +// The offline example, run from the temp project so `@chartrunner/verify` resolves to the installed tarball. +copyFileSync(new URL("examples/verify-offline.mjs", repo), join(dir, "verify-offline.mjs")); +copyFileSync(new URL("examples/results.json", repo), join(dir, "results.json")); +const example = spawnSync(process.execPath, [join(dir, "verify-offline.mjs")], { cwd: dir, encoding: "utf8" }); +check("examples/verify-offline.mjs passes against the installed package", example.status === 0, example.stdout + example.stderr); + +const memo = /const MEMO = "([^"]+)"/.exec(readFileSync(new URL("examples/verify-offline.mjs", repo), "utf8"))?.[1] ?? ""; +const ok = spawnSync(bin, ["--rows", join(dir, "results.json"), "--memo", memo], { encoding: "utf8" }); +check("CLI verifies the example results (exit 0)", ok.status === 0, ok.stdout + ok.stderr); +const wrong = spawnSync(bin, ["--rows", join(dir, "results.json"), "--memo", memo.replace(/:3$/, ":4")], { encoding: "utf8" }); +check("CLI reports a mismatch (exit 1)", wrong.status === 1, wrong.stdout + wrong.stderr); + +process.exitCode = failures === 0 ? 0 : 1; From 510863d9f4fca0b5411caa9649c349d8c9e45e1f Mon Sep 17 00:00:00 2001 From: 0xNyk <0xnykcd@googlemail.com> Date: Tue, 3 Nov 2026 20:01:21 +0100 Subject: [PATCH 2/3] Harden publish: real publishes only from main, protected npm environment; plain rows from another realm publish.yml: a real publish (release or a dispatch with dry_run off) refuses unless the release targets main or the dispatch runs on main; the job runs in the 'npm' environment so the owner can require a reviewer. canonicalJson accepts an object literal from another realm (vm, iframe), whose Object.prototype differs; class instances are still refused. Review follow-ups on #6. --- .github/workflows/publish.yml | 15 +++++++++++++++ packages/verify/src/__tests__/sdk.test.ts | 5 +++++ packages/verify/src/merkle.ts | 3 ++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 73101e9..352d9fc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -26,6 +26,8 @@ jobs: 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: @@ -53,6 +55,19 @@ jobs: 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 diff --git a/packages/verify/src/__tests__/sdk.test.ts b/packages/verify/src/__tests__/sdk.test.ts index 2dc02e5..3b4c5eb 100644 --- a/packages/verify/src/__tests__/sdk.test.ts +++ b/packages/verify/src/__tests__/sdk.test.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { runInNewContext } from "node:vm"; import { describe, expect, it } from "vitest"; import * as legacy from "./legacy-merkle"; import * as legacyLeaves from "./legacy-leaves"; @@ -373,6 +374,10 @@ describe("verifyAgainstMemo", () => { // A null-prototype object is still a plain row and serializes like a literal. const bare = Object.assign(Object.create(null), { s: 1, id: "a" }); expect(serializeRow(bare)).toBe('{"id":"a","s":1}'); + // So is an object literal from another realm (vm context, iframe), whose Object.prototype is a different object. + const foreign = runInNewContext('({ s: 1, id: "a" })') as typeof bare; + expect(Object.getPrototypeOf(foreign)).not.toBe(Object.prototype); + expect(serializeRow(foreign)).toBe('{"id":"a","s":1}'); }); it("can pin the memo to an expected kind and id", async () => { expect(await verifyAgainstMemo({ rows, memoText: memo, expect: { kind: "cup", id: "c1" } })).toMatchObject({ ok: true }); diff --git a/packages/verify/src/merkle.ts b/packages/verify/src/merkle.ts index cf78cf4..b89f831 100644 --- a/packages/verify/src/merkle.ts +++ b/packages/verify/src/merkle.ts @@ -78,7 +78,8 @@ export function canonicalJson(entry: LeafEntry): string { function isPlainObject(v: unknown): v is Record { if (v === null || typeof v !== "object" || Array.isArray(v)) return false; const proto = Object.getPrototypeOf(v); - return proto === Object.prototype || proto === null; + // Object.prototype of this realm or of another one (vm, iframe): its own prototype is null. A class instance's is not. + return proto === null || Object.getPrototypeOf(proto) === null; } const LEAF_PREFIX = new Uint8Array([0x00]); From 1e430d2e7c6db855f62c57f97fd274cf9523ad1a Mon Sep 17 00:00:00 2001 From: 0xNyk <0xnykcd@googlemail.com> Date: Tue, 3 Nov 2026 20:09:06 +0100 Subject: [PATCH 3/3] ci: create the pack destination before npm pack --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e563173..faca1bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,7 +73,7 @@ jobs: # prepack builds dist/ and copies LICENSE and NOTICE in. - name: npm pack working-directory: packages/verify - run: npm pack --pack-destination "$RUNNER_TEMP/pack" + run: mkdir -p "$RUNNER_TEMP/pack" && npm pack --pack-destination "$RUNNER_TEMP/pack" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: