Direct Mode Quick Start
This tutorial follows the repository's direct submission flow.
If you want to read the docs side by side with the code, it is easiest to keep JetHalo/zk-Escrow's direct branch open as you go.
If you want to see the live app first, open the zkEscrow direct-mode demo. It follows the same direct path described in this tutorial.
The browser generates a Groth16 proof locally, the server submits proof, publicSignals, and vk directly to Kurier, and once Kurier reaches finalized, the frontend requests an authorization signature before calling on-chain finalize.
This tutorial is for Base Sepolia testnet, not Base mainnet. Wallet setup, RPC endpoints, contract addresses, test ETH, and on-chain interactions in this doc all assume Base Sepolia.
The repository already includes the
wasm,.zkey, andvkey.jsonartifacts needed for browser-side proving. If your goal is only to get the direct flow running, you can use those artifacts as-is. If you want to regenerate them from circuit source, the full process is included below.
Prerequisiteâ
Before you start, prepare the following:
- Node.js 22.x or newer
- npm
- Foundry
- A Base Sepolia wallet
- Test ETH in that wallet
- A working Kurier API key
- A deployed
ZKEscrowReleasecontract address - The deployment block for that contract
- The private key that matches the contract's
finalizeAuthority
What you'll learnâ
By the end of this tutorial, you will have:
- A local environment that runs the direct mode flow
- A minimal working
apps/web/.env.local - A full debugging path from
deposittoprovetofinalize - An optional path for rebuilding circuit artifacts and indexing with The Graph
1. Understand The Two Modesâ
First, make the difference between direct and aggregation explicit.
Direct modeâ
The core idea of direct is "submit the proof directly, consume the result directly."
- Submit the full proof, public inputs, and verification key together to Kurier.
- The frontend only polls the proof status.
- Once the status reaches
finalized, the flow moves straight into authorization and on-chainfinalize. - This repository's main path uses exactly this route, and
/api/submit-proofis fixed tosubmissionMode: direct.
Aggregation modeâ
aggregation adds one more batching layer on top of direct.
- After you submit the proof, you do not consume it immediately as a single result.
- You must wait until the proof is included in an aggregation batch.
- In addition to proof status, you also need the aggregation tuple, such as
domainId,aggregationId,leafCount,index, andmerklePath. - The repository keeps
/api/proof-aggregationfor this route.
If your goal is to get the flow working end to end first, start with direct. That is also the repository's default route today.
2. Install The Projectâ
Install the web app dependenciesâ
Install the frontend dependencies first:
cd apps/webnpm install
Optional: install the subgraph dependenciesâ
If you plan to use The Graph, install the subgraph dependencies as well:
cd indexer/subgraphnpm install
Optional: run the contract testsâ
If you want to confirm the contract logic before anything else, run:
cd contractsforge test
3. Generate The Proving Artifactsâ
If you do not want to use the proving artifacts already committed to the repository and instead want to regenerate wasm, .zkey, and vkey.json from circuit source, follow this section.
This is a manual flow. The repository does not currently wrap circuit build and trusted setup into a script.
Install the circuit dependenciesâ
circuits/escrow/circom/escrowRelease.circom depends on circomlib, so first add the local dependencies in circuits/escrow:
cd circuits/escrownpm install circomlib snarkjs
You also need the circom binary installed on your machine.
Compile escrowRelease.circomâ
Compile the circuit into r1cs, wasm, and sym:
cd circuits/escrowmkdir -p buildcircom circom/escrowRelease.circom --r1cs --wasm --sym -o build
After the command completes, the key outputs should be here:
build/escrowRelease.r1csbuild/escrowRelease.symbuild/escrowRelease_js/escrowRelease.wasm
Run the local trusted setupâ
This circuit uses groth16. For local development, you can run a local ceremony directly.
This flow is only suitable for local development and tutorial reproduction. Do not treat it as a production setup.
cd circuits/escrowsnarkjs powersoftau new bn128 16 build/pot16_0000.ptausnarkjs powersoftau contribute build/pot16_0000.ptau build/pot16_0001.ptau --name="zkescrow dev ptau" -e="replace-with-random-text"snarkjs powersoftau prepare phase2 build/pot16_0001.ptau build/pot16_final.ptausnarkjs groth16 setup build/escrowRelease.r1cs build/pot16_final.ptau build/escrowRelease_0000.zkeysnarkjs zkey contribute build/escrowRelease_0000.zkey build/escrowRelease_final.zkey --name="zkescrow dev zkey" -e="replace-with-random-text"snarkjs zkey export verificationkey build/escrowRelease_final.zkey build/vkey.json
At this point, you should have:
build/escrowRelease_final.zkeybuild/vkey.json
Copy the artifacts into the web appâ
The frontend prover reads from apps/web/public/zk/escrow, so copy the generated artifacts there:
cp circuits/escrow/build/escrowRelease_js/escrowRelease.wasm apps/web/public/zk/escrow/escrowRelease.wasmcp circuits/escrow/build/escrowRelease_final.zkey apps/web/public/zk/escrow/escrowRelease_final.zkeycp circuits/escrow/build/vkey.json apps/web/public/zk/escrow/vkey.json
If you regenerated the artifacts, restart the apps/web development server so it does not keep serving stale cached files.
4. Configure The Direct Mode Environmentâ
Create apps/web/.env.localâ
Create apps/web/.env.local and fill in the values below:
NEXT_PUBLIC_ESCROW_ADDRESS=0xYourEscrowAddressNEXT_PUBLIC_DEPLOY_BLOCK=12345678NEXT_PUBLIC_DOMAIN=111NEXT_PUBLIC_APP_ID=222NEXT_PUBLIC_BASE_SEPOLIA_RPC_URL=https://your-base-sepolia-rpcINDEXER_RPC_URL=https://your-base-sepolia-rpcINDEXER_STRATEGY=sqliteKURIER_API_KEY=your-kurier-api-keyKURIER_API_URL=https://api-testnet.kurier.xyz/api/v1FINALIZE_AUTH_PRIVATE_KEY=0xyour_finalize_authority_private_keyNEXT_PUBLIC_KURIER_POLL_ATTEMPTS=150NEXT_PUBLIC_KURIER_POLL_INTERVAL_MS=4000NEXT_PUBLIC_INDEXER_POLL_INTERVAL_MS=10000
Understand the required valuesâ
NEXT_PUBLIC_ESCROW_ADDRESSmust be the deployedZKEscrowReleasecontract address.NEXT_PUBLIC_DEPLOY_BLOCKis the contract deployment block. The indexer starts scanning from there.NEXT_PUBLIC_DOMAINandNEXT_PUBLIC_APP_IDmust match the values used when the contract was deployed.FINALIZE_AUTH_PRIVATE_KEYmust match the contract'sfinalizeAuthority, or the signature produced by/api/authorize-finalizewill fail.- For the first run, keep
INDEXER_STRATEGY=sqlite. That lets the frontend scan the chain over RPC and cache results in local SQLite without requiring a subgraph first.
5. Start The Web Appâ
Run the development serverâ
Start the development server from apps/web:
cd apps/webnpm run dev
Open the escrow pageâ
Then open:
http://localhost:3000
The app should redirect to /escrow automatically.
If the page loads correctly, you should see:
DepositandWithdrawtabs- A Base Sepolia network switcher
- A proof status panel
6. Run The Direct Flowâ
Step 1: create a depositâ
- Connect your wallet.
- Switch to Base Sepolia.
- Enter the deposit amount and recipient address.
- Click
Deposit & Lock.
After the transaction succeeds, the page will generate a credential. Save it, because it will be used later to build the withdrawal proof.
Step 2: generate and submit the proofâ
- Paste the saved
credentialinto theWithdrawtab. - Click
Unlock. - The browser generates the proof locally.
- The server submits the proof to Kurier in
directmode. - The frontend starts polling
/api/proof-status. - Once the status reaches
finalized, the server generates an authorization signature through/api/authorize-finalize. - The wallet sends the on-chain
finalizetransaction. - The recipient receives the funds.
This path does not call /api/proof-aggregation. If you see errors related to aggregation tuples, your environment does not match the direct-flow assumptions of this tutorial.
7. Build And Deploy The Graph Subgraphâ
If you do not want to rely on local SQLite chain scans, you can switch to The Graph.
Install the subgraph dependenciesâ
Enter indexer/subgraph first:
cd indexer/subgraphnpm install
Provide the subgraph inputsâ
This subgraph reads Deposited and Finalized events, so at minimum prepare:
NEXT_PUBLIC_ESCROW_ADDRESSorSUBGRAPH_ESCROW_ADDRESSNEXT_PUBLIC_DEPLOY_BLOCKorSUBGRAPH_START_BLOCK
Optional:
SUBGRAPH_NETWORK=base-sepolia
If you already completed Step 4, you usually do not need to duplicate those core values. npm run render reads the existing environment variables directly.
Generate subgraph.yamlâ
indexer/subgraph/subgraph.template.yaml is a template, not a deployable manifest. Render it into a real subgraph.yaml first:
cd indexer/subgraphnpm run render
Generate types and build the subgraphâ
cd indexer/subgraphnpm run codegennpm run build
At that point, local subgraph generation and build are complete.
Deploy to The Graph Studioâ
Prepare your Graph Studio deploy key and slug:
export GRAPH_DEPLOY_KEY=<your_studio_deploy_key>export SUBGRAPH_SLUG=<your_studio_subgraph_slug>
Then deploy:
cd indexer/subgraphnpm run authnpm run deploy
If you do not want to use the npm scripts, you can run the commands directly:
graph auth "$GRAPH_DEPLOY_KEY"graph deploy "$SUBGRAPH_SLUG" subgraph.yaml \--node https://api.studio.thegraph.com/deploy/ \--deploy-key "$GRAPH_DEPLOY_KEY"
Connect the query URL to the web appâ
After deployment succeeds, take the query URL from Graph Studio and write it back into apps/web/.env.local:
INDEXER_STRATEGY=thegraphTHEGRAPH_SUBGRAPH_URL=https://your-subgraph-query-url
If you want The Graph first and local scanning only as fallback, you can also use:
INDEXER_STRATEGY=hybrid
Troubleshootingâ
Missing NEXT_PUBLIC_ESCROW_ADDRESSâ
This means apps/web/.env.local is still missing the contract address, or you changed the env file without restarting the development server.
Local scan missing depositsâ
Usually this means NEXT_PUBLIC_DEPLOY_BLOCK is set too late, so local indexing started from the wrong block. Change it to the actual deployment block, then click Rescan in the UI.
Proof not finalized yetâ
This means Kurier has not pushed this direct submission to finalized yet. Check KURIER_API_KEY, RPC configuration, and proof parameters first, then inspect the raw Kurier status.
bad authorizationâ
Confirm that the address derived from FINALIZE_AUTH_PRIVATE_KEY is the same address configured as the contract's finalizeAuthority.