npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

vk-guard

v0.4.3

Published

Verification-key and constraint-count regression guard for o1js zkApps. Fails CI when a circuit's verification key or constraint counts drift from a committed baseline.

Readme

vk-guard

CI npm license: MIT

Verification-key and constraint-count regression guard for o1js zkApps.

Changing a circuit changes its verification key. If an account is already deployed holding the previous key, proofs generated for the changed circuit will not verify against it, and applying the change may require an authorized verification-key update or a redeployment. The o1js CHANGELOG documents circuit-level changes of this kind repeatedly — group operation changes, a VK-hash fix, and a Provable.if() rewrite.

o1Labs runs verification-key regression tests for o1js itself (tests/vk-regression/). vk-guard brings verification-key regression checks into application CI.

vk-guard snapshots your contracts' verification key hashes and per-method constraint counts into a committed file, and fails CI when they drift.

See also: o1js-scan (o1js / Noir soundness linting), gnark-safety (gnark circuits).

$ vk-guard check

1 of 1 compared verification key changed.
o1js version unchanged (3.1.0).
Method circuit digests also changed.
This comparison does not determine the cause.

  Counter   vk 1760987873…1876 -> 1340037853…9289  (SmartContract)

If an account still holds a previous key, proofs from the changed circuit will
not verify against it. Applying the change may require an authorized
verification-key update or a redeployment, depending on account permissions.
vk-guard reads no chain state and cannot confirm what is deployed. Accepting a
new baseline with `vk-guard update` records the new key locally; it does not
change any key on-chain. See README, "What a verification key change means".

Circuit changed (method digest differs) in:
  Counter.increment()
This is the constraint-level evidence behind the key differences above.

Gate types that moved:
  Counter.increment()
    Generic          15 -> 16     (+1)

Constraint count changed:
  Counter.increment()   615 -> 616 rows (+1)

1 contract, 2 methods, o1js 3.1.0

Install

Requires Node.js 20 or newer and an o1js project.

npm install --save-dev vk-guard

o1js is a peer dependency. vk-guard always measures the o1js your project builds against, never a copy of its own.

Usage

npx vk-guard update      # record the current state as the baseline, then commit .vk-guard.json
npx vk-guard check       # compare against the baseline (exit 1 on drift)

| Command / flag | Meaning | | --- | --- | | vk-guard check | Compare against the snapshot. The default command. | | vk-guard update | Accept the current state as the new baseline. | | --rows-only | Skip compile(). Compares constraint rows and circuit digests only. Seconds instead of minutes. | | --json | Machine-readable output on stdout. | | --entry <glob> | Override discovery. Repeatable. Default src/**/*.ts. | | --cache-dir <path> | o1js compile cache location. | | --snapshot <path> | Snapshot file. Default .vk-guard.json. | | --tsconfig <path> | tsconfig to build with. Default: nearest tsconfig.json. | | --root <path> | Project root. Default: cwd. |

Exit code 0 means no drift. Exit code 1 means drift, no contracts found, or a contract that could not be measured.

--json

Every --json result is a single JSON object on stdout, carrying ok whether the run succeeded or not — progress goes to stderr, so stdout stays parseable. A run that ends before there is a comparison to report is the case a CI integration most needs to read, so those are JSON too:

{
  "ok": false,
  "reason": "no-snapshot",
  "error": "no snapshot at /repo/.vk-guard.json\n\nRun `vk-guard update` to record …",
  "summary": "1 contract, 2 methods, o1js 3.1.0",
  "snapshot": "/repo/.vk-guard.json",
  "typeErrorCount": 0
}

reason is one of no-contracts, no-snapshot, rows-only-snapshot (a full check against a baseline recorded with --rows-only), no-gate-data (explain only), or bad-arguments — a malformed or unknown option, which is the failure a misconfigured workflow hits first and so is reported on stdout like any other. An error raised during the run — a contract that failed to compile, for instance — comes back as {"ok": false, "error": "…"} without a reason.

--help and --version are for people and still print text.

What a verification key change means

vk-guard compares a newly compiled verification key against the one committed in your snapshot. That is a local measurement. It is not a statement about what is deployed.

What the tool establishes:

  • The newly compiled verification key differs from the committed baseline.

What follows conditionally, depending on state vk-guard cannot see:

  • If an account is still using the previous key, proofs generated for the changed circuit will not verify against that key.
  • Applying the new circuit may require an authorized verification-key update or a redeployment, depending on the account's permissions.
  • For a ZkProgram, there is no deployed account of its own. Its key matters to whatever verifies its proofs: a verifier pinning a previous key will not accept proofs from the changed program.

What is not true:

  • A local rebuild does not modify any deployed account.
  • Accepting a new baseline with vk-guard update records the new key in your snapshot. It does not update any key on-chain.
  • vk-guard performs no on-chain reads. It cannot confirm whether anything is deployed, which key an account holds, or whether a key update is permitted.

The tool does not determine cause

When keys differ, vk-guard reports what it observed — how many compared keys changed, how many were unchanged, how many could not be compared, whether the o1js version changed, and whether method circuit digests changed — and states plainly that the comparison does not determine the cause.

It deliberately does not infer that an o1js upgrade "explains" the drift, nor that an unchanged o1js version means your source must have changed. Comparing two snapshots cannot separate application edits from dependency, SDK or build configuration changes, so it does not pretend to.

In --rows-only mode no compilation happens, so no verification key is measured at all. Circuit digest changes are reported as observations, with a recommendation to run a full check to measure whether the keys differ.

A check never passes because nothing ran

A check that succeeds because it found nothing is worse than no check — it manufactures false confidence. So:

  • Zero contracts discovered exits 1, with no contracts found; check --entry. Never 0.
  • A contract that fails to compile exits 1 with the error. It is never skipped.
  • A snapshot entry with no matching contract is reported explicitly — removed, renamed, or discovery is broken. vk-guard cannot tell these apart, so it says so and you decide.
  • Every run prints what it actually checked: 4 contracts, 17 methods, o1js 2.4.0. Silence is always attributable.

Snapshot format

.vk-guard.json is human-readable, stably ordered, and meant to be committed:

{
  "vkGuardVersion": "0.4.3",
  "o1jsVersion": "3.1.0",
  "contracts": {
    "Counter": {
      "file": "src/Counter.ts",
      "kind": "SmartContract",
      "verificationKeyHash": "17609878734510172312999390341520149576174096919885406581548138984158042861876",
      "digest": "28b21ef82c11eac43caef828304800537ba7ebde5fa2f699de224531db17e008",
      "methods": {
        "increment": {
          "rows": 615,
          "digest": "b36e671c87cf5043980baa22bb2daaec",
          "gateTypes": {
            "Poseidon": 550,
            "Zero": 50,
            "Generic": 15
          }
        },
        "reset": {
          "rows": 626,
          "digest": "94b89320f7403f9d8df1c0f8165e76c4",
          "gateTypes": {
            "Poseidon": 561,
            "Zero": 51,
            "Generic": 14
          }
        }
      }
    }
  }
}

verificationKey.data is deliberately not stored. It is a multi-kilobyte base64 blob; committing it would make the file unreadable and produce enormous, unreviewable diffs. The hash is a binding commitment to the key, so it is sufficient to answer the only question this tool asks: did it change?

Method digest is stored in addition to rows, because a row count is a weak fingerprint — a refactor can swap one constraint for another and leave the count identical. The digest changes whenever the circuit changes, and it comes from analyzeMethods() without a full compile(). That is what makes --rows-only a real check rather than a rubber stamp.

Row-count thresholds

Exact row matching is too noisy for some teams. Add a config block to .vk-guard.json (it is preserved across vk-guard update):

{
  "config": {
    "rowTolerance": { "default": 0, "MyContract.withdraw": 100 }
  }
}

Most specific wins: "Contract.method", then "Contract", then "default". Default is 0 (exact). A row change within tolerance passes but is still printed — a tolerance hides a failure, not a fact.

A tolerance never silences a verification key change. Those are always exact, and so are circuit digests. Because any circuit change moves the digest too, a tolerance will not make a changed circuit pass; it suppresses the row-count finding only. That matters most during an o1js upgrade, where you have already accepted that keys moved but still want large proving-time regressions surfaced.

Caching and correctness

A stale cache that produced a matching verification key would be a false pass — the worst failure mode this tool could have. We investigated rather than assumed.

o1js's filesystem cache chooses an entry using persistentId and checks the requested uniqueId against the entry's .header before reading it. Current upstream header construction uses a Pickles identifying hash; it should not be assumed to be merely the method digest. The following are historical observations against o1js 3.0.0:

| Run | Source | Cache | Verification key hash | increment rows | | --- | --- | --- | --- | --- | | 1 | original | cold | 39730952…9048 | 615 | | 2 | one extra constraint | same warm cache | 22280710…3583 (differs) | 616 | | 3 | original restored | same warm cache | 39730952…9048 (matches run 1) | 615 |

For this fixture and configuration, the warm cache did not produce a stale-key mismatch, and the round trip was stable. The 5s warm and 27s cold measurements are historical.

vk-guard conservatively namespaces its cache directory by o1js version (.vk-guard-cache/o1js-<version>/). Lack of an explicit npm version in an identifier does not demonstrate unsafe cross-version reuse, and version separation is not proof of complete isolation. Cross-version and cross-backend guarantees remain outside this experiment.

Identical source is also insufficient when environment-dependent circuit construction or compile options differ. The strengthened experiment records those inputs and directly compares warm and independent cold compilation of the same changed circuit.

Determinism was verified before anything was built on it, and both claims above are reproducible rather than asserted:

npm run experiments

Each script exits non-zero if its property does not hold, so they double as a platform check. See experiments/ for the method and full results.

Requirements

vk-guard compiles your TypeScript with the real TypeScript compiler, using your project's tsconfig.json.

This is not swappable for a faster esbuild-based loader (tsx, ts-node in transpile mode). o1js's @method decorator reads parameter types at runtime via design:paramtypes, which only emitDecoratorMetadata emits — and esbuild does not implement it. Loading contracts through esbuild fails inside sortMethodArguments with a confusing Cannot read properties of undefined (reading 'map').

Your tsconfig.json needs what o1js already requires:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "useDefineForClassFields": false
  }
}

vk-guard defaults these on if your config is silent about them.

o1js versions

Verified against o1js 3.0.0 and 3.1.0. The peer range is >=3.0.0 <4.0.0. Major o1js releases can change the compiler API, so they are enabled only after explicit compatibility testing rather than being assumed compatible.

Note that verificationKey.hash is an o1js Field, not a string — vk-guard stores its decimal toString() form, which is what you see in the snapshot.

GitHub Action

name: vk-guard
on: pull_request

jobs:
  vk-guard:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write   # only needed for comment-on-pr
    steps:
      - uses: actions/checkout@v7
      - uses: auditinfra-io/vk-guard@v0

@v0 is the major-version alias, moved by the release workflow on every release. It stays @v0 while the tool is pre-1.0 and becomes @v1 after that. Pin an exact release (@v0.1.0) if you would rather approve each upgrade yourself.

Inputs: working-directory, entry, rows-only, cache-dir, snapshot, node-version, version, source, install, install-command, cache, comment-on-pr, fail-on-drift. Outputs: drift, summary, json-file.

source selects where the tool itself comes from. The default, npm, installs the release named by version. Setting source: action instead builds vk-guard from the action's own checkout, which is how you run an unreleased commit — pin the action to a git SHA and it uses exactly that code. (Installing the checkout directory directly does not work: npm links it without running prepack, so dist/ is never built and no binary is created. The action packs a tarball first.)

The Action reads its default npm package version from the package.json in the pinned Action checkout, so a tagged workflow cannot silently begin executing a newer package and a release bump has only one source of truth. Set version explicitly when evaluating another release.

The Action caches o1js compile artifacts across runs, keyed on the o1js version and the lockfile hash, and posts (and updates) a single pull request comment summarizing drift. cache-dir is empty by default, which leaves the location to vk-guard and keeps the per-o1js-version namespacing described above; set it only if you want the artifacts somewhere specific.

Compiling real circuits takes minutes. For a fast signal on every push, use rows-only: true and run the full check on a schedule or before release:

      - uses: auditinfra-io/vk-guard@v0
        with:
          rows-only: true

--rows-only still catches any circuit change via the method digests. What it cannot do is tell you the new verification key hash.

Where did my rows go?

A row count tells you a circuit is expensive. It does not tell you why. vk-guard explain reports what the constraint system is actually made of:

npx vk-guard explain
Counter.increment()   615 rows
  Poseidon        550   89.4%  █████████████████████···
  Zero             50    8.1%  ██······················
  Generic          15    2.4%  █·······················

  structure:
    Poseidon     50 x 11 rows       550 rows  89.4%

  wire locality: 99.5% of wires stay within 16 rows

Counter.increment() is one field addition, yet it compiles to 615 rows — and 89% of them are Poseidon gates from the framework's state commitment, not from your arithmetic. The structure line makes that concrete: one Poseidon hash costs 11 rows, so this method performs fifty of them before your code does anything.

That reframes the optimisation question. Shaving your own logic cannot touch the 550 rows; reducing the number of state fields you commit to can.

Like --rows-only, explain uses analyzeMethods() and never calls compile(), so it runs in seconds.

What it does not do

o1js attaches no source location to gates — a gate carries its type, its wire permutation and its coefficients, and nothing else. So vk-guard cannot tell you which line of TypeScript produced which gate, and it does not guess. It reports which gate types occupy which row ranges, which is the part the data supports.

Witness values are likewise not exposed per gate, and are secret by nature, so there is no witness inspection here either.

vk-guard guards itself

examples/counter is a small but real zkApp — a SmartContract, a ZkProgram, and a pair of contracts where one calls the other's @method — with its verification keys and per-method row counts committed alongside it in examples/counter/.vk-guard.json. CI runs vk-guard check against it on every pull request, so the tool is exercised end to end against real compiled circuits, and an o1js upgrade that changes any of those circuit shapes announces itself here first.

The nested call was added after o1js 3.1.0. That release changed how a caller witnesses the callee's account update, which changes the caller's verification key — and the example as it stood, with no nested call, reported no drift. Against the 3.0.0 baseline the new Caller.callAdd() goes from 1399 to 1548 rows; Callee does not move. An example only guards the circuit shapes it contains.

npm run example:check

Design notes

docs/DESIGN.md records why the tool is built this way and what had to be discovered about o1js to build it — gates carrying no source location, the emitDecoratorMetadata requirement that rules out esbuild-based loaders, the dual CJS/ESM entry points that break instanceof, and the content-addressed cache. Most of it is not in o1js's documentation.

Scope

vk-guard reports that a verification key changed. It does not try to explain why — attributing a key change to a specific line is not something it can do honestly.

It does no security analysis, no proving, no deployment, and no on-chain reads.

For soundness analysis of o1js circuits — under-constrained witnesses and similar bugs — see o1js-scan, a static analyzer that runs in milliseconds with no dependencies. The two are complements: o1js-scan asks whether your circuit is correct; vk-guard asks whether it changed.

Project status and support

vk-guard is an independent community project and is not affiliated with or endorsed by o1Labs. Treat a baseline update like any other security-relevant code change: review it and keep it version-controlled.

  • See CONTRIBUTING.md to develop and test changes.
  • Report bugs and compatibility issues through GitHub Issues.
  • Report suspected vulnerabilities privately using SECURITY.md.
  • User-visible changes are recorded in CHANGELOG.md.

License

MIT