Skip to content

Test coverage and a total order for verificationKeyUpdates - #1

Merged
mellowcroc merged 2 commits into
mellowcroc:feat/verification-key-updatesfrom
o1-labs:review/pr-225-followup
Sep 2, 2026
Merged

Test coverage and a total order for verificationKeyUpdates#1
mellowcroc merged 2 commits into
mellowcroc:feat/verification-key-updatesfrom
o1-labs:review/pr-225-followup

Conversation

@dkijania

@dkijania dkijania commented Sep 1, 2026

Copy link
Copy Markdown

For o1-labs#225. Merging this into your branch updates that PR.

This is the part of the review we said was ours rather than yours — it needs a
fixture and a mainnet-scale dump, and you should not have to guess at either.
The query you wrote is correct; I verified the positive path by hand against a
681,000-block devnet archive before writing any of this. Nothing here changes
what the query means.

1. A fixture that can fail

archive_db.sql has 227 blocks_zkapp_commands rows and every one has status
failed. It holds one verification-key hash and one account update that sets a
verification key. No input can make the query return a row against it, which is
why the only assertion available to you was the empty list.

That is measured, not argued. Replacing the query body with one that returns
nothing at all (AND 1=0) left all 63 integration tests green.

fixtures/verification_key_updates.sql adds blocks 26…32 on top of the base
canonical tip, with eleven accounts that each make one distinction observable:

account what it is expected
alpha sets the target key returned
beta sets a different key filtered by hash
gamma sets the target key, on a custom token proves the token join is real
delta / epsilon sequence_no 0 and 1 at height 28 command order
zeta sets the target key, command failed excluded
eta target hash as a precondition only excluded
theta sets the target key in an orphaned block excluded
iota sets the target key in a pending block pending only
kappa carried by both competing tips at height 32 two occurrences
lambda carried by fork B only the tips differ

eta is the one worth keeping. It is the wrong-column trap your PR already
avoids, now pinned down by a test: the target hash sits in
zkapp_account_update_body.verification_key_hash_id (the key the update
requires) while its zkapp_updates row has verification_key_id IS NULL. A
query that reads that column answers "who called this contract" instead of "who
deployed it".

The suite has power now. Against the query as it stands on your branch:

mutation tests failing
returns nothing (AND 1=0) 10 of 15
reads the precondition column instead 11 of 15
failed commands not excluded 14 of 15

It runs on its own database, like action-state-ordering.test.ts does: the
fixture changes the maximum height and the pending chain, and the other
integration tests assert on both.

The placeholder test in integration.test.ts is removed — its one assertion,
that a failed deployment is not discoverable, is now zeta.

The fixture is generated, not hand-written:
node tests/integration/fixtures/generate-verification-key-fixture.mjs. The
tests read the expected values from the JSON the same script emits, so they
cannot drift from the SQL.

2. A total order

ORDER BY was height, sequence_no, account-update position, then
zkapp_account_update.id. None of those separates two competing tips at the
same height, and pending_chain seeds from every block at the maximum pending
height, so both tips are in the answer.

When both tips carry the same command all four keys tie. That is the normal case
during a reorg, not a corner: on the devnet archive one zkApp command appears in
8 blocks at a single height. On the new fixture the two rows came back fork B
first, then fork A.

b.state_hash is now the second key. The order is total and groups by block.

3. Cost

The hash was matched last, after the join tree had expanded every account update
in the range. The planner cannot drive from the hash instead, because
zkapp_updates.verification_key_id and zkapp_account_update_body.update_id
are both unindexed, so it hashed all of zkapp_updates (173k rows) and
zkapp_account_update_body (365k rows) on every call and spilled to disk.

The hash is now resolved to its small set of zkapp_updates rows in a
MATERIALIZED CTE before any block is touched, then applied as a semi-join.

Measured on the 681k-block devnet archive, over the 10,000-block maximum a
client may request:

time plan
before 405 ms sequential scans, hash join spilling to 8 batches
after 199 ms

This is a shape improvement, not a cure. The remaining cost is inherent
while those two columns are unindexed, and those indexes belong to the archive
schema in the mina repository, not to this one. Their effect still has to be
measured on a dump we can index, and mainnet is much larger than this one. We
will follow that up separately; it should not hold your PR.

Still yours, and deliberately left alone

  • the tagged-template conversion — I kept client.unsafe so your unit test
    keeps passing unchanged, and left the change to you
  • public_keys and tokens in USED_TABLES
  • an example in docs/getting-started.md

Checks

npm run build, npm run lint, test:unit (all pass, yours unchanged) and
test:integration (77 tests, was 63).

🤖 Generated with Claude Code

https://claude.ai/code/session_01MmkAwbAi2ogrSg4XU9tYAz

dkijania and others added 2 commits September 1, 2026 22:31
The base archive_db.sql fixture has 227 blocks_zkapp_commands rows and every
one has status 'failed'. It holds one verification-key hash and one account
update that sets a verification key. No input can make
getVerificationKeyUpdatesQuery return a row against it, so the only assertion
available was the empty list.

That gap was measured, not assumed: replacing the query body with one that
returns nothing at all (AND 1=0) left all 63 integration tests green.

verification_key_updates.sql adds blocks 26..32 on top of the base canonical
tip, with eleven accounts that each make one distinction observable:

  alpha    sets the target key                        -> returned
  beta     sets a different key                       -> filtered by hash
  gamma    sets the target key, on a CUSTOM token     -> proves the token join
  delta    sequence_no 0 at height 28                 -> command order
  epsilon  sequence_no 1 at height 28                 -> command order
  zeta     sets the target key, command FAILED        -> excluded
  eta      target hash as a PRECONDITION only         -> excluded
  theta    sets the target key in an ORPHANED block   -> excluded
  iota     sets the target key in a pending block     -> pending only
  kappa    carried by BOTH competing tips at height 32
  lambda   carried by fork B only

eta is the important one. The archive records the key an account update SETS in
zkapp_updates.verification_key_id, reached through
zkapp_account_update_body.update_id, and the key it merely REQUIRES in
zkapp_account_update_body.verification_key_hash_id. A query that reads the
second column answers "who called this contract" instead of "who deployed it".

The two tips at height 32 carry the same command, which is what a real fork
looks like: on the devnet archive one zkApp command was measured in 8 blocks at
a single height. Their rows agree on height, sequence_no, account-update
position and zkapp_account_update.id alike.

The suite now has power. Against the query as it stands:

  returns nothing (AND 1=0)                    10 of 15 tests fail
  reads the precondition column instead        11 of 15 tests fail
  failed commands not excluded                 14 of 15 tests fail

Like action-state-ordering.test.ts, this runs on its own database: the fixture
changes the maximum height and the pending chain, which the other integration
tests assert on.

The placeholder test in integration.test.ts is removed. Its one assertion, that
a failed deployment is not discoverable, is now zeta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmkAwbAi2ogrSg4XU9tYAz
…ates

Ordering
--------
ORDER BY was height, sequence_no, account-update position, then
zkapp_account_update.id. None of those separates two competing tips at the same
height, and pending_chain seeds from every block at the maximum pending height,
so both tips are in the answer. When both carry the same command — the normal
case during a reorg, and one command was measured in 8 blocks at a single height
on the devnet archive — all four keys tie and the order was whatever the plan
produced. Observed on the new fixture: fork B came back before fork A.

state_hash is now the second key, so the order is total and groups by block.

Cost
----
The verification-key hash was matched last, after the join tree had already
expanded every account update in the range. The planner cannot drive from the
hash instead, because zkapp_updates.verification_key_id and
zkapp_account_update_body.update_id are both unindexed, so it hashed all of
zkapp_updates (173k rows) and zkapp_account_update_body (365k rows) on every
call and spilled to disk.

The hash is now resolved to its (small) set of zkapp_updates rows in a
MATERIALIZED CTE before any block is touched, and applied as a semi-join.

Measured on a 681k-block devnet archive, over the 10 000-block maximum a client
may request:

  before  405 ms, sequential scans, hash join spilling to 8 batches
  after   199 ms

That is a shape improvement, not a cure. The remaining cost is inherent while
those two columns are unindexed, and the indexes belong to the archive schema in
the mina repository, not here. Their effect is still to be measured on a dump we
can index, and mainnet is much larger than this one.

Behaviour is unchanged: same parameters in the same positions, and the
precondition column is still never read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmkAwbAi2ogrSg4XU9tYAz
@mellowcroc
mellowcroc merged commit b2eab26 into mellowcroc:feat/verification-key-updates Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants