We build Quetzal (quetzaldex.xyz), a dark-pool DEX on Aztec. Orders stay sealed on-chain: side, size, and limit price are private. An aggregator matches them off-chain into uniform-price batches, and a Noir circuit proves on-chain that the clearing came from exactly the orders that were committed.
The full lifecycle — place → reveal → clear → claim — is running end-to-end on testnet, with the clearing transition verified on-chain.
We recently finished the 4.3.1 → 5.0.0 migration. Most of it was uneventful. Four things were not. We are writing them down in case they save someone else the same days.
Everything below is what we observed in our own setup. Where we have not isolated a cause, we say so.
⸻
1. A stale committed artifact, and an error that does not point at it
Symptom: verification key has wrong size: expected 5216, got 4576, thrown during PXE contract registration.
In our migration, the private-function verification keys produced by the v5 toolchain were 5216 bytes, while the artifacts in our repository still contained 4576-byte keys.
The mechanism is worth describing precisely, because our first explanation was wrong.
The artifacts were already tracked in Git. They had been force-added long before, so the target/ rule in .gitignore no longer prevented Git from tracking changes to them.
During the migration, we recompiled the contracts locally, which updated the tracked artifact files. Those changes were never committed.
As a result, the developer working tree contained the correct artifacts, while every build from a clean checkout used the stale committed versions.
Two of five contracts were affected. The other three had been committed correctly, which made the failure look like a per-contract logic problem rather than a build-hygiene problem.
Our deployed testnet stack was unavailable for 13 days because of this. During that window, it was being served from a developer machine over an SSH tunnel: the one working tree that still contained the correct artifacts.
The error message is accurate, but gives you almost nothing to act on. It names neither the contract nor the function, and does not suggest that the artifact, rather than the contract source, is what is wrong.
We spent the time auditing our Noir code instead of our build outputs.
How to check quickly: base64-decode functions[].verification_key in each artifact JSON and compare the byte lengths with the committed version using git show HEAD:<path>.
A private-function key whose size differs from what the current toolchain produces is a strong sign that the committed artifact was built with a different toolchain.
Lesson: treat compiled artifacts as pinned build inputs. We now fail CI if the committed artifacts’ private-function verification keys do not match the size the pinned toolchain produces, or if some artifacts disagree with others — the case we actually hit. Filed upstream as #25107 (name the contract and function in the error) and #25108 (catch it before PXE registration). Recompiling in CI would pull in the whole Aztec toolchain, but reading what is already committed costs nothing and catches this specific failure.
⸻
2. The FeeJuice protocol contract address changed
In 4.3, the FeeJuice contract was at 0x…05. In v5, it is at 0x…03.
Our balance lookup was written against the old address. Rather than surfacing an obvious configuration error, it returned zero.
That is worse than an exception, because a FeeJuice balance of zero looks exactly like “this wallet is empty.” We went and topped up a wallet that had never been low.
Lesson: resolve the address from getNodeInfo().protocolContractAddresses.feeJuice rather than hardcoding it, so the client follows the protocol configuration instead of silently querying an obsolete address.
⸻
3. We reimplemented something stdlib already shipped
L2→L1 withdrawals failed for us with:
the L2ToL1Message you are trying to prove inclusion of does not exist
Deposits, meanwhile, were working.
That split is confusing because a one-directional failure looks like a bug in your own bridge code.
The cause was that TxEffect.l2ToL1Msgs carries a scoped message hash, which binds the emitting L2 contract and the target L1 portal, rather than the raw portal content.
If you search the outbox for the raw content, you will never find it, and the error tells you only that the message does not exist.
We worked this out and wrote our own leaf-computation helper, validated against a golden vector read back from testnet. It works.
It was also unnecessary.
@aztec/stdlib already exports computeL2ToL1MembershipWitness(), getL2ToL1MessageLeafId(), and L2ToL1Message.scope().
Those helpers were already available in the versions we were using. We simply did not find them and took on the maintenance burden of a bespoke implementation of something already maintained upstream.
That one is on us, not on Aztec. We are now replacing our implementation with the upstream helper.
Lesson, mostly to ourselves: inspect @aztec/stdlib/src/messaging/ before hand-rolling anything protocol-shaped.
⸻
4. Two build environments, two vk_hash values — cause not yet isolated
We observed different vk_hash values for what were intended to be identical builds across two environments:
- arm64 Linux inside Docker on an Apple Silicon host
- amd64 Linux and native macOS
We have not isolated whether the architecture itself is responsible or whether the difference came from the surrounding toolchain configuration.
We did not control bb binary provenance, CRS, compilation flags, and backend configuration rigorously enough to make a causal claim. We are therefore reporting the observation, not a diagnosis.
Either way, the operational consequence is sharp.
If the deployed contract is bound to one VK hash and the prover ships another, clearing can prove successfully in the local environment and then fail during on-chain recursive verification with:
Failed to verify the generated proof!
That is about the least informative point at which to discover a build mismatch.
Lesson: pin the exact binary that writes the VK, and check the resulting vk_hash against what is deployed before shipping a prover. We pin the binary today; wiring that comparison into CI is on our list.
If we isolate the variable properly, we will open a separate issue with a reproducible test case.
⸻
What we would ask for
The clearest actionable DX gap we found is small, and we have filed it as #25107:
Include the contract and function name in the VK-size error.
Two magic byte counts are a puzzle. LiquidityPool::deposit is an answer, because it immediately points toward a stale artifact instead of suggesting that something is wrong with the contract logic. A hint about a possible toolchain mismatch would be a bonus.
#25108 is the companion: catching an incompatible artifact before it ever reaches PXE, which is where we think the real win is.
The rest was either documented behaviour we missed, our own build hygiene, or something we have not isolated well enough to call a bug.
We are happy to go deeper on any of it, particularly the clearing circuit, where we perform recursive on-chain verification of a batch-clearing proof and had to think carefully about VK pinning across the aggregator and contract boundary.