ZK Escrow Hands-on Tutorial: Common Pitfalls and How to Avoid Them
Target audience: developers building ZK Escrow for the first time. This document is based on real troubleshooting records and organized as: symptom -> root cause -> shortest diagnostic command -> fix action.
1. Mixed routes (aggregation / direct / local verifier)â
Symptomâ
proofOptions RequiredINVALID_SUBMISSION_MODE_ERRORAggregation proof fields missing from Kurier response
Root causeâ
Different submission modes were mixed in the same branch, causing payload and state-gate conflicts.
Shortest diagnostic commandâ
rg -n "submissionMode|API_VERSION|proofOptions|vkRegistered" \apps/web/src/pages/api/submit-proof.ts
Fix actionâ
- Keep only one mode per branch.
- In this repository, route is fixed to aggregation; do not keep direct logic in
submit-proof.
2. Confusing business domain with aggregation domainIdâ
Symptomâ
Aggregation domainId mismatch- Proof has
domain=1, but aggregation verification still fails.
Root causeâ
DOMAIN (business) and KURIER_ZKVERIFY_DOMAIN_ID (aggregation) have different semantics but were treated like the same variable.
Shortest diagnostic commandâ
cat apps/web/.env.local | rg "DOMAIN|domainId|KURIER_ZKVERIFY_DOMAIN_ID"
Fix actionâ
- Keep:
NEXT_PUBLIC_DOMAIN=1 - Keep:
KURIER_ZKVERIFY_DOMAIN_ID=2(Base Sepolia aggregation domain) - Always use distinct field names in code.
3. zkverify invalid (most common)â
Symptomâ
The contract function "finalize" reverted with the following reason: zkverify invalid
Root cause (ordered by likelihood)â
- Statement algorithm or byte order mismatch
- Tuple parameter mismatch (
domainId/aggregationId/leafCount/index/merklePath) - Contract binding parameter mismatch (
vkHash/domain/appId/chainId)
Shortest diagnostic commandâ
# 1) Contract binding parameterscast call $ESCROW "vkHash()(bytes32)" --rpc-url "$RPC_URL"cast call $ESCROW "expectedDomain()(uint256)" --rpc-url "$RPC_URL"cast call $ESCROW "expectedAppId()(uint256)" --rpc-url "$RPC_URL"cast call $ESCROW "expectedChainId()(uint256)" --rpc-url "$RPC_URL"# 2) tuplecurl -s -X POST "http://localhost:3000/api/proof-aggregation" \-H "Content-Type: application/json" \--data "{\"proofId\":\"$JOB_ID\"}"
Fix actionâ
- First ensure
statement == leaf. - Then ensure
verifyProofAggregation(...) == true. - Only then send
finalize.
4. root not known / root not allowedâ
Symptomâ
root not knownroot not allowedLocal tree root mismatch
Root causeâ
- Wrong scan start height (missed deposit)
- Hasher address or tree parameters mismatch
- Stale local cache
Shortest diagnostic commandâ
cast call $ESCROW "nextIndex()(uint32)" --rpc-url "$RPC_URL"cast call $ESCROW "getLastRoot()(bytes32)" --rpc-url "$RPC_URL"cast call $ESCROW "hasher()(address)" --rpc-url "$RPC_URL"curl -s "http://localhost:3000/api/commitments?statusOnly=1"
Fix actionâ
- Set
NEXT_PUBLIC_DEPLOY_BLOCKto the deployment block of the current escrow contract. - Confirm
HASHER_ADDRESSmatches the actual on-chain value. - Trigger one rescan (reset) if needed.
5. Kurier is Aggregated, but frontend does not continueâ
Symptomâ
- Stuck at pending/aggregated
- No follow-up verification or wallet popup
Root causeâ
NEXT_PUBLIC_KURIER_REQUIRE_FINALIZED=trueblocks flow at finalized gate.
Shortest diagnostic commandâ
cat apps/web/.env.local | rg "NEXT_PUBLIC_KURIER_REQUIRE_FINALIZED"
Fix actionâ
- For aggregation route, set it to
false. - Use on-chain
verifyProofAggregationprecheck as the actual gate.
6. forge create still dry-runs even with --broadcastâ
Symptomâ
Warning: Dry run enabled, not broadcasting transaction
Root causeâ
Environment contamination, argument ordering, or terminal env not fully sourced.
Shortest diagnostic commandâ
env | grep -i FOUNDRYforge create --help | rg -n "dry|broadcast"
Fix actionâ
cd contractsset -a; source .env; set +aforge create --broadcast --rpc-url "$RPC_URL" --private-key "$PRIVATE_KEY" ...
7. eth_getLogs returns 400/503/range-limit errorsâ
Symptomâ
- Free tier limit: max 10 blocks per request
- Backend unhealthy / 429
Root causeâ
Full-range block scans + high-frequency polling trigger RPC rate limits.
Shortest diagnostic commandâ
curl -s -X POST "$RPC_URL" \-H "Content-Type: application/json" \--data '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}'
Fix actionâ
- Use incremental indexing (
deployBlock + lastScannedBlock) - Use The Graph as primary read source
- Avoid large-range log scans directly in browser
8. CORS + 429 (frontend calling RPC directly)â
Symptomâ
- Browser console shows
blocked by CORS policy 429 Too Many Requests
Root causeâ
Frontend directly requests restricted RPC endpoints.
Shortest diagnostic commandâ
# Validate RPC availability in server environmentcurl -s -X POST "$RPC_URL" \-H "Content-Type: application/json" \--data '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}'
Fix actionâ
- Move on-chain reads to API layer (server side) as much as possible.
- Frontend should only call your own
/api/*routes.
9. Subgraph build failure (AssemblyScript types)â
Symptomâ
Property 'toBigInt' does not exist on type ArrayBufferView
Root causeâ
Mapping treats bytes32 as BigInt, causing type mismatch.
Shortest diagnostic commandâ
cd indexer/subgraphnpm run build
Fix actionâ
- Persist
bytes32fields asBytesor hex string. - Do not call
toBigInt()onbytes32directly in mapping.
13. Recommended troubleshooting orderâ
Following this order usually avoids repeated rework where fixing one issue breaks three others.