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.
Maintainers
Readme
vk-guard
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.0Install
Requires Node.js 20 or newer and an o1js project.
npm install --save-dev vk-guardo1js 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 updaterecords 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 experimentsEach 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 explainCounter.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 rowsCounter.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:checkDesign 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
