Local zkp2p Setup
If you want to start from the exact codebase used in this guide, use the zkp2p-demo repository directly. If you want to see the product first, open the zkp2p demo. This is the live entry point for the demo described in this guide.
What this project doesâ
You can think of zkp2p as an OTC tool. The buyer pays the seller with a real bank transfer first, then the frontend and browser plugin turn "this payment really happened" into a verifiable proof. Once the proof passes, the contract releases the previously locked collateral on-chain.
In this demo, the real payment record comes from Wise. The browser extension handles capture and proving, the server verifies the attestation and forwards the proof, and the contract releases funds based on the aggregation result.
Main parts of the projectâ
At the repository level, a local run mainly touches these pieces:
apps/web: the OTC web app itself, plus/api/submit-proof,/api/proof-status,/api/proof-aggregation, and/api/verify-wise-attestationapps/proof-plugin: the browser extension that starts capture, triggers proving, submits proofs, and polls statusapps/tlsn-verifier: the server-side verifier that confirms the Wise attestation is real and produceswiseReceiptHashapps/tlsn-wise-pluginandapps/tlsn-wasm-host: the Wise TLSN wasm plugin and the static host that serves it
On top of that, you still need a deployed Zkp2pDepositPool contract and a corresponding subgraph to handle on-chain release and read chain state.
Prerequisitesâ
Before you start, prepare the following:
- Node.js 20 or newer
- npm
- Chrome or Edge with
Developer modeavailable - A working Kurier configuration set:
KURIER_API_URL,KURIER_API_KEY,KURIER_VK_HASH, andKURIER_AGGREGATION_DOMAIN_ID - A deployed
Zkp2pDepositPoolcontract address - A working Goldsky or The Graph subgraph URL for seller liquidity and commitments queries
Optional:
- Foundry, if you want to run contract tests locally or redeploy contracts
- Noir /
nargo, if you want to recompile the circuit - Docker, if you want to host the TLSN wasm through a container
Install the repositoryâ
1. Clone the repositoryâ
Clone the code first:
git clone https://github.com/JetHalo/zkp2p-demo.gitcd zkp2p-demo
2. Install workspace dependenciesâ
This repository uses npm workspaces, so one install from the root is enough:
npm install
That installs the dependencies needed by apps/web, apps/proof-plugin, and apps/tlsn-verifier. The extension itself is static files, so it does not need a separate build.
Start the supporting servicesâ
1. Start tlsn-verifierâ
Start the verifier first:
cd apps/tlsn-verifierPORT=8080 \CORS_ALLOW_ORIGIN=http://localhost:3011 \npm run dev
Expected output:
[tlsn-verifier] listening on :8080
Two details are worth calling out:
- The repository includes
apps/tlsn-verifier/.env.example, but the current service code does not automatically load.envfiles. That file is better treated as a field reference or something you can feed into your own process manager. - The verifier is responsible for validation, not proving. It returns normalized transfer fields and
wiseReceiptHash, but it does not generate proofs for the browser.
You can verify the service is up with:
curl -sS http://localhost:8080/health
Expected response:
{"ok":true,"service":"tlsn-verifier"}
2. Expose the Wise TLSN wasmâ
The repository already includes a usable wise_plugin.tlsn.wasm, so locally you can expose it as a static file first:
cd apps/tlsn-wasm-hostpython3 -m http.server 8090
Once it is running, this URL should be reachable:
http://localhost:8090/wise_plugin.tlsn.wasm
The wasm is hosted separately because proof-plugin handles orchestration, while the Wise-page interaction and TLS attestation logic live inside the TLSN plugin wasm. Keeping it separately hosted makes capture-rule updates more stable and easier to swap out later.
3. Optional: rebuild the Wise TLSN artifactâ
If your only goal right now is to get the project running, you can skip this section and use the wasm already in apps/tlsn-wasm-host.
If you want to modify the Wise-side capture rules yourself, work from apps/tlsn-wise-plugin:
cd apps/tlsn-wise-pluginbash ./scripts/bootstrap-boilerplate.sh
That command pulls in the TLSNotary boilerplate and applies the Wise-specific project scaffold on top. After that, you need to produce your own wise.plugin.wasm, host it at a reachable URL, and write that URL back into NEXT_PUBLIC_TLSN_WISE_PLUGIN_URL.
Configure the web appâ
1. Create apps/web/.env.localâ
This repository does not currently include apps/web/.env.local.example, so create apps/web/.env.local manually:
NEXT_PUBLIC_CHAIN_ID=<your_horizen_chain_id>NEXT_PUBLIC_CONTRACT_ADDRESS=0x<your_deposit_pool_address>NEXT_PUBLIC_BUSINESS_DOMAIN=zkp2p-horizenNEXT_PUBLIC_TLSN_WISE_PLUGIN_URL=http://localhost:8090/wise_plugin.tlsn.wasmNEXT_PUBLIC_INTENT_TTL_SECONDS=1800KURIER_API_URL=https://<your-kurier-api-base>KURIER_API_KEY=<your_kurier_api_key>KURIER_API_ID=zkp2pKURIER_AGGREGATION_DOMAIN_ID=175KURIER_VK_HASH=0x<vk_hash_from_register_vk>KURIER_PROOF_VARIANT=PlainTHEGRAPH_SUBGRAPH_URL=https://api.goldsky.com/api/public/<project_id>/subgraphs/<name>/<version>/gnTLSN_VERIFIER_URL=http://localhost:8080/verify-wise-attestationTLSN_ALLOWED_HOST_SUFFIXES=wise.com,transferwise.comRPC_URL=https://horizen-testnet.rpc.caldera.xyz/http
The four groups of variables that most often get mixed up are:
NEXT_PUBLIC_TLSN_WISE_PLUGIN_URLis browser-facing. It tells the extension where to fetch the Wise TLSN wasm.TLSN_VERIFIER_URLis for the web server./api/verify-wise-attestationforwards requests there. In most cases, this proxy path is preferable to letting the browser talk to the verifier directly.KURIER_API_KEYandKURIER_VK_HASHshould stay server-side only. Do not put them into the extension, and do not expose them as public frontend config.THEGRAPH_SUBGRAPH_URLis used by seller and commitments-related APIs./api/commitmentscan fall back to sqlite if it is missing, but/api/sellersfails directly, so if you want the full seller and buyer flow working, it is better to configure it from the start.
If you do not have a subgraph URL yet, sort out your Goldsky subgraph deployment flow first. If you also want a cleaner configuration split, it helps to keep separate notes for env boundaries and the env schema so browser, server, and plugin settings do not get mixed together.
2. Register the verification key and capture vkHashâ
You can leave KURIER_VK_HASH as a placeholder in .env.local for now, because this step generates it.
The circuit artifacts are already committed to the repository, so VK registration does not need an extra build script. The current project uses:
- proof system:
ultrahonk - number of public inputs:
10 - VK file:
circuits/zkp2p-horizen-release/noir/target/vk
Convert the local VK file to base64 first so you do not push raw binary into JSON:
VK_BASE64="$(base64 < circuits/zkp2p-horizen-release/noir/target/vk | tr -d '\n')"
Then call Kurier's VK registration endpoint and store the response temporarily:
curl -sS -X POST "$KURIER_API_URL/register-vk/$KURIER_API_KEY" \-H 'content-type: application/json' \--data "{\"proofType\": \"ultrahonk\",\"vk\": \"${VK_BASE64}\",\"proofOptions\": {\"numberOfPublicInputs\": 10}}" | tee /tmp/zkp2p-vk.json
If registration succeeds, the response should include vkHash or meta.vkHash. Extract it with:
jq -r '.vkHash // .meta.vkHash' /tmp/zkp2p-vk.json
Then write that value back into apps/web/.env.local as KURIER_VK_HASH.
Two things need to stay aligned here:
numberOfPublicInputsmust remain10, matching the current circuit's public inputs:business_domain,app_id,user_addr,chain_id,timestamp,intent_id,amount,wise_receipt_hash,nullifier, andstatement.KURIER_PROOF_VARIANTmust match the proof style used later during proof generation and submission. If your Kurier environment requiresZK, switch registration, proof generation, and submission together rather than changing only one stage.
If this step fails with proofOptions Required, INVALID_SUBMISSION_MODE_ERROR, or VK-related errors, check:
- whether
KURIER_API_URLreally points to the Kurier environment you intend to use - whether
proofTypestill matches this case'sultrahonk - whether
numberOfPublicInputsstill matches the circuit
3. Start the web appâ
Return to the repository root and start Next.js:
npm run dev:web
By default, it runs at http://localhost:3011.
When the page opens for the first time, the dApp also installs window.__ZKP2P_NOIR_PROVER__ into the runtime. When the extension starts proving, it asks the current dApp page to load the Noir artifact served by apps/web/pages/api/circuit-artifact.ts, and then generates proof and publicInputs in the browser.
You can confirm the artifact is wired correctly with:
curl -sS "http://localhost:3011/api/circuit-artifact?name=zkp2p_horizen_release"
If that endpoint returns JSON, the browser prover already has the circuit artifact it needs. Because the repository already includes circuits/zkp2p-horizen-release/noir/target/zkp2p_horizen_release.json, the first local run does not require nargo build.
Load the proof pluginâ
1. Load the unpacked extensionâ
Open chrome://extensions or edge://extensions, then:
- Turn on
Developer mode - Click
Load unpacked - Select
apps/proof-plugin - Go back to
http://localhost:3011and refresh the page
This extension does not need a separate build. manifest.json, background.js, popup.js, and inpage-bridge.js in that directory are already in a loadable development form.
2. Verify the extension can talk to the dAppâ
After you refresh, the bridge between the dApp and the extension should already be established. The flow is:
inpage-bridge.jsexposes the extension capability aswindow.zkp2pProofPlugin- once the dApp calls
startProof(...), the extension maintains the proof session in the background - when it reaches the proving step, the extension returns to the current dApp page and calls the already-installed
__ZKP2P_NOIR_PROVER__
So:
- proving still happens in the browser, and the witness does not need to be sent to the server
- the extension does not need to bundle a full Noir runtime of its own, because it reuses the prover environment the dApp already prepared
Optional: prepare contracts and on-chain toolingâ
If you only need the web app, plugin, and verifier running right now, you can skip this section.
1. Prepare deployment variablesâ
If you want to test or deploy contracts, start with a contracts/.env file as your variable inventory:
RPC_URL=https://horizen-testnet.rpc.caldera.xyz/httpPRIVATE_KEY=0x<your_private_key>USDC_ADDRESS=0x<existing_usdc_or_leave_blank_for_usdch>GATEWAY_ADDRESS=0x<zkverify_aggregation_gateway_proxy>DEPOSIT_POOL_ADDRESS=
The repository does not currently include a ready-made forge script deployment script, so this guide uses forge create directly. GATEWAY_ADDRESS is the zkVerify aggregation gateway or proxy that already exists on the target chain. This repository does not deploy it for you.
If you store the values in contracts/.env, export them into the current shell first:
cd contractsset -asource .envset +a
Then derive the deployer address from the private key. You will use it later if you deploy USDCH:
export DEPLOYER_ADDRESS="$(cast wallet address "$PRIVATE_KEY")"echo "$DEPLOYER_ADDRESS"
2. Run contract tests firstâ
forge test
Running tests first is recommended. The core on-chain constraints for this repository live in contracts/test/Zkp2pDepositPool.t.sol, including buyer binding, nullifier replay protection, deadline handling, and gateway validation. It is better to expose obvious issues before deployment.
3. Deploy USDCH only if you need the demo tokenâ
If the target chain already has the 6-decimal USDC you want, fill USDC_ADDRESS with that address and skip this step.
If you want to use the repository's demo token, deploy USDCH:
forge create src/USDCH.sol:USDCH \--rpc-url "$RPC_URL" \--private-key "$PRIVATE_KEY" \--broadcast \--constructor-args "$DEPLOYER_ADDRESS"
After deployment succeeds, record the deployed address and export it:
export USDC_ADDRESS=0x<deployed_usdch_address>
4. Deploy Zkp2pDepositPoolâ
Zkp2pDepositPool has only two constructor arguments:
token_: the USDC or USDCH address you prepared in the previous stepgateway_: the zkVerify aggregation gateway or proxy address on the target chain
Deploy it with:
forge create src/Zkp2pDepositPool.sol:Zkp2pDepositPool \--rpc-url "$RPC_URL" \--private-key "$PRIVATE_KEY" \--broadcast \--constructor-args "$USDC_ADDRESS" "$GATEWAY_ADDRESS"
After deployment succeeds, export the pool address because the web app and subgraph will both need it:
export DEPOSIT_POOL_ADDRESS=0x<deployed_pool_address>
5. Verify the deployment before wiring the frontendâ
Once deployment completes, use cast call for a basic read check before you push addresses into the frontend:
cast call "$DEPOSIT_POOL_ADDRESS" "token()(address)" --rpc-url "$RPC_URL"cast call "$DEPOSIT_POOL_ADDRESS" "gateway()(address)" --rpc-url "$RPC_URL"cast call "$DEPOSIT_POOL_ADDRESS" "totalDeposited()(uint256)" --rpc-url "$RPC_URL"
If the first two calls return your USDC_ADDRESS and GATEWAY_ADDRESS, your constructor arguments are correct.
If you deployed the repository's USDCH, you can also do a minimal approve + deposit smoke test:
cast send "$USDC_ADDRESS" "approve(address,uint256)" "$DEPOSIT_POOL_ADDRESS" 100000000 \--rpc-url "$RPC_URL" \--private-key "$PRIVATE_KEY"cast send "$DEPOSIT_POOL_ADDRESS" "deposit(uint256)" 100000000 \--rpc-url "$RPC_URL" \--private-key "$PRIVATE_KEY"
Here 100000000 means 100 tokens with 6 decimals. After both approve and deposit succeed, write these values back into the app layer:
NEXT_PUBLIC_CONTRACT_ADDRESSinapps/web/.env.localDEPOSIT_POOL_ADDRESSincontracts/.envDEPOSIT_POOL_ADDRESSin the subgraph deployment command
That write-back step matters. The frontend does not read contracts/.env for real deposit and release flows. It only reads the NEXT_PUBLIC_* values in apps/web/.env.local. If you update only the contract-side config, the page will still point to the old address or no address at all.
If you plan to deploy the subgraph next, you can continue with the repository's existing command:
cd ../scripts/zkp2p-horizen-release/thegraphDEPOSIT_POOL_ADDRESS="$DEPOSIT_POOL_ADDRESS" \DEPOSIT_POOL_START_BLOCK=<deploy_block_number> \SUBGRAPH_NETWORK=horizen-testnet \npm run prepare:manifest
Verify the installationâ
At this point, run one round of basic checks:
1. Service checksâ
Confirm that all three are true:
curl -sS http://localhost:8080/healthreturns{"ok":true,"service":"tlsn-verifier"}http://localhost:8090/wise_plugin.tlsn.wasmis reachablehttp://localhost:3011/api/circuit-artifact?name=zkp2p_horizen_releasereturns JSON
2. App checksâ
Open http://localhost:3011, then confirm:
- the page loads instead of stopping on a missing env-var error
- the extension appears in the browser extensions list
- after you start the plugin, page logs show the plugin state moving forward; if it immediately complains about
NEXT_PUBLIC_TLSN_WISE_PLUGIN_URL, the wasm URL is still missing or wrong
If Kurier, contract addresses, and the subgraph are all wired correctly, the full flow usually moves through statuses in this order:
wise_opened -> capture_ready -> proving -> proof_ready -> submitted -> verified -> aggregated
Troubleshooting orderâ
If the flow is not working, debug in this order instead of starting with wallet or contract issues:
- Check whether
NEXT_PUBLIC_TLSN_WISE_PLUGIN_URLandTLSN_VERIFIER_URLare correct. - Then check whether
KURIER_API_URL,KURIER_API_KEY,KURIER_VK_HASH, andKURIER_AGGREGATION_DOMAIN_IDall belong to the same Kurier setup. - Then confirm that
THEGRAPH_SUBGRAPH_URLandNEXT_PUBLIC_CONTRACT_ADDRESSpoint to the same deployment on the same chain. - If the proof is already submitted but aggregation never appears, inspect statement, tuple, and gateway precheck next.
- Only after that should you move on to wallet signing, gas, nonce, and other chain-specific issues.