DEV Community

Cover image for How to Create a Zero Knowledge DApp: From Zero to Production, Case 2: zk P2P
Jet Halo
Jet Halo

Posted on

How to Create a Zero Knowledge DApp: From Zero to Production, Case 2: zk P2P

Can a P2P on-ramp still work without a platform acting as escrow?

In the world of crypto, the first thing a newcomer really has to do to enter the space is usually not trading and not doing DeFi, but completing an on-ramp first.

And the on-ramp flow most people are familiar with is usually an OTC exchange on an exchange: the user sends fiat first, and after the platform confirms the payment, it releases USDT or USDC.

The reason this works has never been just matching. The exchange holds the decision-making power in the middle. It custody-holds one side of the assets first, judges whether the off-chain payment is real, and then decides whether the crypto should be released. As long as those three things stay in the platform’s hands, users assume the trade can be completed.

The question is: what happens if you remove that layer of platform-backed escrow?

The buyer can send a bank transfer directly to the seller, and the seller can promise that they have enough stablecoin liquidity. But the hard parts show up immediately:

  • Who guarantees that the seller’s on-chain funds are actually ready
  • Who guarantees that the buyer’s off-chain payment is real, instead of a screenshot or a forged receipt
  • Who guarantees that this payment corresponds to the current order
  • Who prevents the same payment proof from being reused to exchange for crypto again

The reason the platform model is stable is that the platform keeps handling these four things for the user.

Without relying on an exchange to make the final decision, can a P2P fiat-to-USDC on-ramp still work, and can the final release step be turned into an on-chain release driven by verification results? This article tries to achieve that with zk.

If you want to run this case first, you can go directly to the installation steps in the zkVerify zk-p2p example docs. The demo site is at zkp2p-demo-web.vercel.app, and the GitHub repository is JetHalo/zkp2p-demo.

After reading this article, you will probably come away with three things:
First, if a P2P fiat-to-USDC on-ramp does not rely on exchange arbitration, how the on-chain funds should be locked first;
Second, how a Wise payment record goes through zkTLS / TLSNotary, the verifier, and wiseReceiptHash to enter the proving path;
Third, how a Noir proof generated locally in the browser eventually goes through Kurier, zkVerify, and the aggregation tuple before being consumed on-chain by releaseWithProof(...).

1. Why P2P on-ramps still rely on platform-backed escrow today

The flow on a traditional OTC platform looks smooth:

  1. The seller posts an order
  2. The buyer places an order
  3. The buyer pays off-chain
  4. The platform confirms receipt
  5. The platform releases the crypto

But this path works because the platform controls the state on both sides at the same time.

On one side, it controls the assets in an on-chain or custodial account. The platform can hold that portion of the crypto first so it cannot move before the trade is completed. On the other side, it controls confirmation of the off-chain payment. The platform gets to decide whether a receipt, a screenshot, or a bank transfer record counts as completed payment, together with confirmation from both parties. And once there is a dispute between buyer and seller, the platform can directly arbitrate.

So in the platform model, the core of the on-ramp is never “the buyer and seller agreed by themselves,” but “the platform holds the final authority to decide whether the crypto should be released.”

Once the platform is removed, the real problem is no longer matching efficiency, but how to rebuild these three layers of trust:

  • Liquidity has to be locked first
  • Payment has to be verifiable
  • After verification passes, the release action has to be completed by the chain itself

If any one of these three layers still depends on manual judgment, then the platform-backed escrow has not really been removed.

2. What zkp2p is and how it works

zkp2p can be understood in simple terms like this:

The buyer pays on Wise first, the system turns that payment into a piece of verifiable evidence, and then the chain releases the USDC that the seller locked earlier on-chain to the buyer.

Below, this path is unfolded step by step:

  1. The seller first deposits the USDC they want to sell into an on-chain pool
  2. The buyer creates an intent on the page, which locks part of that liquidity first
  3. The buyer then completes a real payment on Wise
  4. The browser extension and zkTLS/TLSNotary turn that payment into an attestation
  5. The backend verifier validates the attestation and compresses it into wiseReceiptHash
  6. The browser locally generates a proof with Noir + UltraHonk
  7. Your Web API submits the proof to Kurier and waits for zkVerify to complete verification and aggregation
  8. After the frontend gets the aggregation tuple, the buyer calls releaseWithProof(...) themselves
  9. Only after contract verification passes does the previously locked USDC get sent to the buyer

So the key to this path is not simply “prove that a payment happened,” but making three things true at the same time:

  • The funds have already been locked on-chain in advance
  • The payment has been compressed into verification input that the later system can continue to consume
  • The final release is not based on a platform judgment, but on-chain verification of the aggregation result

First, take a look at the overall architecture diagram.

Step flow diagram of the zkp2p order showing seller deposit, buyer payment, attestation capture, local proving, Kurier relay, zkVerify aggregation, and on-chain release

There are actually three different flows in this overall diagram.

The first is the fund flow. The seller first puts the assets into the Deposit Pool, and after the buyer completes the off-chain payment, the chain releases the corresponding portion of USDC from the pool.

The second is the proof flow. The payment data on the Wise page is first captured by the zkTLS/TLSNotary plugin, then compressed by the verifier into wiseReceiptHash, and then a proof is generated locally in the browser.

The third is the on-chain consumption flow. The proof itself is not consumed directly by the contract. It first goes through Kurier and zkVerify and is then assembled into an aggregation tuple. The frontend reads that tuple back from the Kurier API, and the place that actually consumes and verifies it is the gateway and DepositPool on the Horizen target chain.

If these three flows are mixed together, it becomes easy to assume that “once the browser generates a proof, the money is released.” In reality, one of the most important design points here is this: there is still an independent verification and aggregation path between the proof and the payout.

3. What components make up the system

Before talking about sequence, first make the object boundaries clear.

Because the easiest thing to mix up in this project is to treat the browser plugin, the TLSN-side artifact, your verifier service, your Web API, and Kurier / zkVerify as one big blob called “the backend.”

But their responsibilities inside the system are completely different.

Component map of zkp2p showing the seller, buyer, browser app, proof plugin, local prover, verifier services, Kurier, zkVerify, and the target chain

After looking at this diagram, we can understand it from five directions.

First, Seller and Buyer are not directly making an on-chain transfer between each other.

The seller provides liquidity by first putting funds into the on-chain Deposit Pool. The buyer uses the dApp and plugin in the browser, and in the end it is also the buyer who signs the releaseWithProof() transaction. The seller does not manually send crypto directly to the buyer anywhere in this path.

Second, the browser itself is not a single frontend block either.

The dApp is responsible for placing the order, creating the intent, showing status, and finally providing the release entrypoint.
The Proof Plugin is responsible for capture, letting the user select the target transfer from recent payments, triggering proving, submitting the proof, and polling status.
The Local Prover is the Noir runtime and proving backend that actually runs in the browser.

Third, zkTLS/TLSNotary.

apps/tlsn-wise-plugin is responsible for producing the Wise-specific TLSN wasm artifact, while apps/tlsn-wasm-host only hosts that wasm. Their job is to “generate the attestation,” not to “verify the attestation,” and even less to “submit the proof to zkVerify.”

Fourth, the services are split into two layers.

  • tlsn-verifier: dedicated to attestation verification, normalizing transfer fields, and producing wiseReceiptHash
  • apps/web API: responsible for /api/verify-wise-attestation, /api/submit-proof, /api/proof-status, and /api/proof-aggregation

Fifth, Kurier + zkVerify are the proof relay and the verification network.

4. How the seller’s money gets locked first

First, fix the numbers for this order:

  • seller deposit 1000 USDC
  • buyer reserve 100 USDC

The first step is deposit().

function deposit(uint256 amount) external {
    if (amount == 0) revert InvalidAmount();

    bool ok = token.transferFrom(msg.sender, address(this), amount);
    if (!ok) revert TransferFailed();

    sellerDeposits[msg.sender] += amount;
    totalDeposited += amount;
    availableBalance += amount;

    emit Deposited(msg.sender, amount);
}
Enter fullscreen mode Exit fullscreen mode

Code location: contracts/src/Zkp2pDepositPool.sol

After this step, there are three new pieces of on-chain state:

  • sellerDeposits[msg.sender]
  • totalDeposited
  • availableBalance

At this point there is still no order. The contract has only placed the seller’s liquidity into the pool first.

The second step is createIntent().

function _createIntent(
    bytes32 intentId,
    address seller,
    uint256 amount,
    uint256 deadline,
    bytes32 nullifierHash,
    bytes32 intentHash,
    bytes32[] memory cleanupIntentIds
)
    private
{
    if (seller == address(0)) revert InvalidSeller();
    if (amount == 0) revert InvalidAmount();
    if (deadline <= block.timestamp) revert InvalidDeadline();

    if (availableBalance < amount) revert InsufficientAvailableBalance();
    if (sellerDeposits[seller] < sellerReserved[seller] + amount) revert SellerDepositTooLow();

    intents[intentId] = Intent({
        seller: seller,
        buyer: msg.sender,
        amount: amount,
        deadline: deadline,
        reserved: true,
        released: false,
        cancelled: false,
        nullifierHash: nullifierHash,
        intentHash: intentHash
    });

    availableBalance -= amount;
    reservedBalance += amount;
    sellerReserved[seller] += amount;
}
Enter fullscreen mode Exit fullscreen mode

Code location: contracts/src/Zkp2pDepositPool.sol

This step is where the order is actually written on-chain. What the contract records is:

  • Which seller this order belongs to
  • Who the buyer address is
  • How much the amount is
  • When it expires
  • The nullifierHash that must match later during release
  • The intentHash that must match later during release

At the same time, availableBalance goes down, while reservedBalance and sellerReserved[seller] go up. That is how the 100 USDC is split out and locked from the seller’s total liquidity.

Once this contract section is done, there is already a locked intent on-chain. The later payment proof, proof, and tuple all continue downward around this intent.

The contract also prepares a recovery path when the order expires:

function cancelExpiredIntent(bytes32 intentId) external {
    Intent storage intent = intents[intentId];
    if (!intent.reserved) revert IntentNotReserved();
    if (intent.released) revert IntentAlreadyReleased();
    if (intent.cancelled) revert IntentAlreadyCancelled();
    if (block.timestamp <= intent.deadline) revert IntentNotExpired();

    intent.reserved = false;
    intent.cancelled = true;

    reservedBalance -= intent.amount;
    availableBalance += intent.amount;
    sellerReserved[intent.seller] -= intent.amount;
}
Enter fullscreen mode Exit fullscreen mode

Code location: contracts/src/Zkp2pDepositPool.sol

This part returns the seller’s amount back to the available state after timeout, so later orders can continue to use it.

5. The browser layer: the page prepares the order first, then hands the proving work to the plugin

Next, go back to the page. The page first computes all the fields needed for the current order, then hands this group of fields to the plugin.

The entrypoint on the page is reserveIntentOnChain().

const intentId = randomHex32();
const intentHash = intentId;
const proverSecret = randomFieldSecret();
const nullifierHash = buildNullifier({ secret: proverSecret, intentId });
const businessDomain = nonEmptyString(process.env.NEXT_PUBLIC_BUSINESS_DOMAIN, "zkp2p-horizen");
const appId = nonEmptyString(KURIER_API_ID, "zkp2p");
const statement = buildStatement({
  intentId,
  buyerAddress: buyerAddress as `0x${string}`,
  amount,
  chainId: network.chainId,
  timestamp: nowSec,
  businessDomain,
  appId
});

tx = await contract["createIntent(bytes32,address,uint256,uint256,bytes32,bytes32)"](
  intentId,
  order.quote.sellerAddress,
  amount,
  deadline,
  nullifierHash,
  intentHash
);
Enter fullscreen mode Exit fullscreen mode

Code location: apps/web/pages/zkp2p-horizen-release.tsx

Here the page prepares all the core fields needed later in one shot:

  • intentId
  • intentHash
  • proverSecret
  • nullifierHash
  • statement

In the current implementation, intentHash = intentId. This convention is carried forward later in the plugin and in the circuit as well.

After the page creates the on-chain intent, it hands this order context to the plugin. The code is inside startPluginProofWithAmount().

const payload = {
  proofId,
  intentId,
  intentHash,
  buyerAddress: walletAddress ?? defaultBuyerAddress,
  amount: String(Math.round(amountUsdc * 1_000_000)),
  businessDomain: nonEmptyString(reservation?.businessDomain, fallbackBusinessDomain),
  aggregationDomainId: KURIER_AGGREGATION_DOMAIN_ID,
  appId: nonEmptyString(reservation?.appId, fallbackAppId),
  chainId: reservationChainId ?? fallbackChainId,
  timestamp: reservationTimestamp ?? fallbackTimestamp,
  nullifier,
  proverSecret,
  verificationMode: "aggregation-kurier",
  proofSystem: "ultrahonk",
  submitEndpoint: `${location.origin}/api/submit-proof`,
  statusEndpoint: `${location.origin}/api/proof-status`,
  aggregationEndpoint: `${location.origin}/api/proof-aggregation`,
  wiseAttestationEndpoint:
    process.env.NEXT_PUBLIC_TLSN_VERIFIER_URL ?? `${location.origin}/api/verify-wise-attestation`,
  tlsnPluginUrl: process.env.NEXT_PUBLIC_TLSN_WISE_PLUGIN_URL ?? "",
  statement: reservation?.statement ?? null,
  deadline: reservation?.deadline ?? null
};

const result = await plugin.startProof(payload);
Enter fullscreen mode Exit fullscreen mode

Code location: apps/web/pages/zkp2p-horizen-release.tsx

This puts three groups of information into the same session in one go:

  • The on-chain intent context
  • The fields used by browser proving
  • The three later backend entrypoints for submit/status/aggregation

From here, the plugin has the full proof session. The later capture, verify, prove, submit, and poll steps all proceed around this session.

Mapped onto the sequence diagram, it is this section below:

Architecture diagram of the zkp2p P2P fiat-to-USDC on-ramp flow across the deposit pool, browser, verifier, Kurier, zkVerify, and the target chain

6. zkTLS / TLSNotary: first turn the Wise page into an attestation

Next, the page hands the work to the plugin. The plugin first enters the capture stage.

At this point, two concepts need to be explained clearly first: TLSNotary and wasm.

TLSNotary can be understood first as a kind of “webpage forensics” tool. When we open the Wise page, the payment record on the page is just a string of text shown in the browser. A screenshot can capture that text too, but a screenshot cannot prove that this content really came from Wise, and it cannot prove that the content was obtained inside a real TLS session. What TLSNotary is trying to do is turn “I saw this payment in the browser” into an attestation that can continue to be verified later.

wasm does not need to be made too complicated here. It is just a binary program that can run in the browser. The wise.plugin.wasm in this project is not a normal frontend resource and not contract bytecode. It is more like a small program written specifically for the browser plugin to execute. After the plugin loads it, this wasm interacts with the Wise page according to prewritten rules, gathers the page content and TLS session material that is needed, and finally generates the attestation.

So the relationship at this layer can be remembered simply like this:

  • TLSNotary is responsible for turning webpage content into a verifiable attestation
  • wise.plugin.wasm is the program in the browser that executes this job
  • proof-plugin is responsible for invoking this program inside the current order and then passing the result forward into the later verifier and proving flow

Once these two concepts are in place, the code below will not feel abrupt.

In apps/proof-plugin/background.js, the core capture logic is this section:

const bridgeCandidates = [window.tlsn, window.__tlsn, window.tlsnExtension];
const tlsnBridge = bridgeCandidates.find(
  (candidate) => candidate && typeof candidate.connect === "function"
);
if (!tlsnBridge) {
  throw new Error("TLSNotary runtime missing");
}

const provider = await tlsnBridge.connect();
if (!provider || typeof provider.runPlugin !== "function") {
  throw new Error("tlsn provider missing runPlugin");
}
tlsn.attestation = await provider.runPlugin(tlsnPluginUrl, {});
tlsn.ok = true;
Enter fullscreen mode Exit fullscreen mode

Code location: apps/proof-plugin/background.js

The tlsnPluginUrl here points to wise_plugin.tlsn.wasm. This wasm comes from the cooperation of two directories:

  • apps/tlsn-wise-plugin: produces the Wise-specific TLSN plugin artifact
  • apps/tlsn-wasm-host: exposes this wasm as a URL that the browser can access

This layer then proceeds in the following four steps:

  1. apps/tlsn-wise-plugin produces wise.plugin.wasm
  2. apps/tlsn-wasm-host hosts it
  3. The plugin executes it on the Wise page through runPlugin()
  4. The browser gets the attestation

After capture ends, the plugin pushes the result together with several recent payments into the proof session:

return {
  amountText,
  recipientText,
  transferTimeText,
  pageTitle: document.title,
  pageUrl: location.href,
  recentTransfers,
  tlsn,
  capturedAt: new Date().toISOString()
};
Enter fullscreen mode Exit fullscreen mode

Code location: apps/proof-plugin/background.js

After capture ends, the plugin holds two kinds of things:

  • tlsn.attestation
  • recentTransfers

The first is used by the verifier later, and the second is used in the popup so the user can select the payment corresponding to the current order.

7. tlsn-verifier: verify the attestation first, then compress it into wiseReceiptHash

The next step enters apps/tlsn-verifier.

This service first performs local TLSN verification. The core code is in src/lib.js:

const presentationHex = extractPresentationHex(attestation);
if (!presentationHex) {
  throw new Error("unable to extract presentation hex from attestation payload");
}

const rawResult = await Promise.resolve(verifyPresentation(presentationHex, notaryPublicKeyPem));
const sent = typeof rawResult?.sent === "string" ? rawResult.sent : "";
const recv = typeof rawResult?.recv === "string" ? rawResult.recv : "";
const serverName = pickString(asRecord(rawResult), ["server_name", "serverName", "sourceHost", "host"]);
Enter fullscreen mode Exit fullscreen mode

Code location: apps/tlsn-verifier/src/lib.js

After local verification completes, the service has already obtained:

  • presentationHex
  • sent / recv from the TLS session
  • The connected server host
  • The session timestamp

Then in src/server.js, the service matches the transfer selected by the user against recent transfers and then normalizes the fields.

localVerification = await verifyPresentationLocally({
  attestation: payload.attestation,
  notaryPublicKeyPem,
  verifyPresentation
});

const recentTransfers = extractRecentTransfers(attestationRaw, localVerification.recv, recentCount);
const selectedTransfer = findMatchingRecentTransfer(recentTransfers, payload.selectedTransfer);

const normalized = normalizedCheck.normalized;
const wiseReceiptHash =
  typeof raw.wiseReceiptHash === "string" && raw.wiseReceiptHash.startsWith("0x")
    ? raw.wiseReceiptHash
    : buildWiseReceiptHash(normalized, payload.attestation);
Enter fullscreen mode Exit fullscreen mode

Code location: apps/tlsn-verifier/src/server.js

What the service returns to the frontend is a group of stable fields:

  • amount
  • timestamp
  • payerRef
  • transferId
  • sourceHost
  • wiseReceiptHash

wiseReceiptHash then continues into the later circuit inputs, Web API anti-replay checks, and proof submit.

8. apps/web API: what these four API routes each do

This apps/web layer corresponds to four API routes.

/api/verify-wise-attestation

This route forwards the attestation from the browser to the verifier, and then aligns it once more with the order context.

if (
  payload.expected?.amount &&
  isUnsignedIntegerText(payload.expected.amount) &&
  isUnsignedIntegerText(normalized.amount as string) &&
  payload.expected.amount !== normalized.amount
) {
  return res.status(400).json({
    error: "amount mismatch with expected order amount"
  });
}

if (normalizedTransferId !== expectedTransferId) {
  return res.status(400).json({
    error: "transferId mismatch with selected payment"
  });
}

const attestationDigest = sha256Hex(JSON.stringify(payload.attestation));
const wiseReceiptHash = sha256Hex(
  [
    "wise",
    normalized.sourceHost,
    normalizedTransferId,
    normalized.payerRef,
    normalized.amount,
    String(Math.trunc(normalized.timestamp as number)),
    attestationDigest
  ].join("|")
);
Enter fullscreen mode Exit fullscreen mode

Code location: apps/web/pages/api/verify-wise-attestation.ts

This puts three things together and checks them:

  • The amount in the current order
  • The transferId selected by the current user
  • The normalized payment fields returned by the verifier

After the check passes, it returns wiseReceiptHash to the plugin.

/api/submit-proof

After the plugin generates the proof locally, it sends the proof and public inputs to this route.

This route first performs one anti-replay layer:

const nullifierReserved = reserveNullifier(payload.nullifier);
if (!nullifierReserved) {
  return res.status(409).json({ error: "anti-replay violation: nullifier already seen" });
}

const wiseReceiptReserved = reserveWiseReceiptHash(payload.wiseReceiptHash);
if (!wiseReceiptReserved) {
  releaseNullifier(payload.nullifier);
  return res.status(409).json({ error: "anti-replay violation: wise receipt hash already seen" });
}
Enter fullscreen mode Exit fullscreen mode

Code location: apps/web/pages/api/submit-proof.ts

The in-memory store used here is in proof-store.ts:

const nullifierSet = new Set<string>();
const wiseReceiptHashSet = new Set<string>();
const statusByProofId = new Map<string, ProofStatusResponse>();
const tupleByProofId = new Map<string, ProofAggregationTuple>();
Enter fullscreen mode Exit fullscreen mode

Code location: apps/web/src/zk/zkp2p-horizen-release/store/proof-store.ts

Then this route converts the payload into a body that Kurier accepts and tries several compatible endpoints:

for (const body of buildKurierSubmitBodies(payload, env)) {
  for (const target of buildSubmitTargets(env)) {
    upstream = await postSubmitCandidate(target, env.apiKey, body);
    if (upstream.ok) break;
  }
  if (upstream?.ok) break;
}
Enter fullscreen mode Exit fullscreen mode

Code location: apps/web/pages/api/submit-proof.ts

/api/proof-status

After the proof enters Kurier, this route maps the upstream job status into the few statuses needed by the page:

export function mapProofStatus(rawStatus: string): "pending" | "verified" | "aggregated" | "failed" {
  const s = rawStatus.toLowerCase();
  if (s.includes("fail") || s.includes("error") || s.includes("reject") || s.includes("invalid")) {
    return "failed";
  }
  if (s.includes("aggregated") || s.includes("aggregationpublished") || s.includes("published")) {
    return "aggregated";
  }
  if (
    s.includes("verified") ||
    s.includes("included") ||
    s.includes("finalized") ||
    s.includes("aggregation pending") ||
    s.includes("aggregationpending")
  ) {
    return "verified";
  }
  return "pending";
}
Enter fullscreen mode Exit fullscreen mode

Code location: apps/web/src/zk/zkp2p-horizen-release/api/kurier.ts

/api/proof-aggregation

After the proof enters the aggregation stage, this route extracts the tuple that will be consumed on-chain from the job status:

function tupleFromJobStatusRaw(
  raw: Record<string, unknown>,
  proofId: string,
  aggregationDomainId: string,
  intentHash?: string,
  nullifier?: string
): ProofAggregationTuple | null {
  const details = raw.aggregationDetails;
  const row = details as Record<string, unknown>;

  const aggregationId = String(raw.aggregationId ?? row.aggregationId ?? "").trim();
  const leaf = String(row.leaf ?? raw.statement ?? "").trim();
  const leafCount = String(row.numberOfLeaves ?? row.leafCount ?? "").trim();
  const index = String(row.leafIndex ?? row.index ?? "").trim();
  const merklePath = Array.isArray(row.merkleProof)
    ? row.merkleProof.map((x) => String(x))
    : Array.isArray(row.merklePath)
      ? row.merklePath.map((x) => String(x))
      : [];
Enter fullscreen mode Exit fullscreen mode

Code location: apps/web/pages/api/proof-aggregation.ts

What the browser is waiting for at this stage is exactly this tuple.

9. The Noir circuit: the browser loads the artifact and generates the proof locally with UltraHonk

The core code for local proving is in browser-prover.ts.

async function installBrowserProver() {
  await initNoirWasmRuntime();
  const circuit = await fetchCircuitArtifact();

  const noir = new Noir(circuit as never);
  const backend = new UltraHonkBackend(circuit.bytecode);
  const ultraHonkOptions = { keccak: true as const };

  window.__ZKP2P_NOIR_PROVER__ = {
    async prove(payload: WitnessPayload): Promise<ProofResult> {
      const { witness } = await noir.execute(payload.circuitInputs);
      const generated = await backend.generateProof(witness, ultraHonkOptions);
      const locallyVerified = await backend.verifyProof(generated, ultraHonkOptions);
      if (!locallyVerified) {
        throw new Error("Local UltraHonk verification failed (oracle hash / VK mismatch)");
      }

      return {
        proof: toHex(generated.proof),
        publicInputs: Array.isArray(generated.publicInputs)
          ? generated.publicInputs.map(normalizePublicInputToHex)
          : []
      };
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

Code location: apps/web/src/zk/zkp2p-horizen-release/browser-prover.ts

The actual runtime assets used here are:

  • Circuit source file: circuits/zkp2p-horizen-release/noir/src/main.nr
  • Artifact loaded by the frontend: /api/circuit-artifact?name=zkp2p_horizen_release
  • Proving backend: UltraHonkBackend
  • Verification key: circuits/zkp2p-horizen-release/noir/target/vk

This runtime model is different from the usual circom wasm + zkey frontend mental model. What the page gets here is a Noir artifact JSON; what is initialized locally is Noir + ACVM + bb.js; and what comes out in the end is proof + publicInputs.

When the plugin actually triggers proving, it is calling exactly this browser runtime:

if (!session.wiseReceiptHash) throw new Error("wise receipt hash missing, run capture first");

await patchSession(proofId, { status: SESSION_STATUS.PROVING });
const proving = await runBrowserProving({ session, capture: session.capture });

const patched = await patchSession(proofId, {
  proof: proving.proof,
  publicInputs: proving.publicInputs,
  status: SESSION_STATUS.PROOF_READY
});
Enter fullscreen mode Exit fullscreen mode

Code location: apps/proof-plugin/background.js

At this point, the proof has been fully generated inside the browser.

10. What exactly the circuit binds: statement, nullifier, wiseReceiptHash

Next, unfold the circuit itself.

First, unfold the inputs and constraints of main():

fn main(
    business_domain: pub Field,
    app_id: pub Field,
    user_addr: pub Field,
    chain_id: pub Field,
    timestamp: pub Field,
    intent_id: pub Field,
    amount: pub Field,
    wise_receipt_hash: pub Field,
    nullifier: pub Field,
    statement: pub Field,
    secret: Field,
    wise_witness_hash: Field,
) {
    let expected_statement = compute_statement(
        business_domain,
        app_id,
        user_addr,
        chain_id,
        timestamp,
        intent_id,
        amount,
    );
    assert(statement == expected_statement);

    let expected_nullifier = compute_nullifier(secret, intent_id);
    assert(nullifier == expected_nullifier);

    let expected_wise_witness_hash =
        compute_wise_witness_hash(amount, timestamp, user_addr, wise_receipt_hash, secret);
    assert(wise_witness_hash == expected_wise_witness_hash);
}
Enter fullscreen mode Exit fullscreen mode

Code location: circuits/zkp2p-horizen-release/noir/src/main.nr

This code makes the relationship in the current circuit very clear:

  • The public inputs carry the order context
  • The private inputs carry secret
  • statement must be recomputable from the order context
  • nullifier must be recomputable from secret + intent_id
  • wise_witness_hash must be recomputable from the payment fields and secret

First look at statement. The TS side and the circuit keep the same order.

export function buildStatementField(input: StatementInput): bigint {
  const businessDomain = stringToField(input.businessDomain);
  const appId = stringToField(input.appId);
  const userAddr = hexToField(input.buyerAddress);
  const intentId = hexToField(input.intentId);
  const amount = mod(input.amount);
  const chainId = mod(input.chainId);
  const timestamp = mod(input.timestamp);

  let acc = mimc7Hash2(intentId, userAddr);
  acc = mimc7Hash2(acc, amount);
  acc = mimc7Hash2(acc, chainId);
  acc = mimc7Hash2(acc, timestamp);
  acc = mimc7Hash2(acc, businessDomain);
  return mimc7Hash2(acc, appId);
}
Enter fullscreen mode Exit fullscreen mode

Code location: apps/web/src/zk/zkp2p-horizen-release/statement.ts

Once this order is fixed, statement folds the following into one value:

  • intentId
  • buyerAddress
  • amount
  • chainId
  • timestamp
  • businessDomain
  • appId

Next, look at how the plugin assembles the witness. deriveCircuitInputs() arranges the fields in the session into circuit inputs, and it also preserves one current implementation detail here:

const intentIdHex = normalizeHex32(session.intentId, "intentId");
const intentHashHex = normalizeHex32(session.intentHash || session.intentId, "intentHash");

if (intentHashHex !== intentIdHex) {
  throw new Error(
    `intentHash mismatch: intentHash=${intentHashHex} intentId=${intentIdHex}. ` +
      "Current circuit binds intentHash to intentId."
  );
}
Enter fullscreen mode Exit fullscreen mode

Code location: apps/proof-plugin/lib/circuit-inputs.js

This shows that in the current version, the actual binding of intentHash is still equal to intentId. The page, the plugin, and the contract all keep using this assumption.

Finally, look at wiseWitnessHash. The plugin folds the payment fields together with wiseReceiptHash one more time and then places the result into the private inputs:

const wiseWitnessHashField = computeWiseWitnessHash({
  amountField,
  timestampField,
  userAddrField,
  wiseReceiptHashField,
  secretField
});

const privateInputsByName = {
  secret: secretField.toString(),
  wise_witness_hash: wiseWitnessHashField.toString()
};
Enter fullscreen mode Exit fullscreen mode

Code location: apps/proof-plugin/lib/circuit-inputs.js

By the end of this circuit section, the order context, the payment digest, and the one-time consumption right have all been placed into the same witness relation.

11. After the proof leaves the browser, how the frontend waits for results from Kurier and zkVerify

After the browser gets proof + publicInputs, the plugin immediately submits them to /api/submit-proof.

const body = {
  proofId: session.proofId,
  verificationMode: session.verificationMode,
  proofSystem: session.proofSystem,
  proof: session.proof,
  publicInputs: session.publicInputs,
  appId: session.appId,
  businessDomain: session.businessDomain,
  aggregationDomainId: session.aggregationDomainId,
  userAddr: session.buyerAddress,
  chainId: session.chainId,
  timestamp: session.timestamp,
  intentId: session.intentId,
  intentHash: session.intentHash || session.intentId,
  amount: session.amount,
  wiseReceiptHash: session.wiseReceiptHash,
  nullifier: session.nullifier
};

const submit = await postJson(session.submitEndpoint, body);
Enter fullscreen mode Exit fullscreen mode

Code location: apps/proof-plugin/background.js

This section deserves to be discussed in more detail, because from this point on, the proof has already left the browser, and the later flow enters the full Kurier -> zkVerify -> aggregation tuple path.

First, separate these two roles:

  • Kurier is the relay and status outlet. It processes the proof payload forwarded by your backend, returns providerJobId, exposes job status and the aggregation tuple, and synchronizes zkVerify’s results into the target-chain verification path.
  • zkVerify is the verification and aggregation network. After the proof enters this layer, it goes through verification and aggregation, eventually corresponding to an aggregation result that the target-chain gateway can verify.

Two things should be remembered here first:

  • What the frontend polls later is actually the upstream job corresponding to providerJobId
  • Even though the page reads the tuple back from the Kurier API, the place that actually executes verifyProofAggregation(...) and releaseWithProof() is the Horizen target chain

/api/submit-proof first performs one round of business-field validation, and then submits this proof upward together with the current order context.

if (payload.appId !== env.appId) {
  return res.status(400).json({ error: "appId mismatch with server env" });
}

if (payload.aggregationDomainId !== env.aggregationDomainId) {
  return res.status(400).json({ error: "aggregationDomainId mismatch with server env" });
}

const nullifierReserved = reserveNullifier(payload.nullifier);
if (!nullifierReserved) {
  return res.status(409).json({ error: "anti-replay violation: nullifier already seen" });
}

const wiseReceiptReserved = reserveWiseReceiptHash(payload.wiseReceiptHash);
if (!wiseReceiptReserved) {
  releaseNullifier(payload.nullifier);
  return res.status(409).json({ error: "anti-replay violation: wise receipt hash already seen" });
}
Enter fullscreen mode Exit fullscreen mode

Code location: apps/web/pages/api/submit-proof.ts

This step makes two layers of constraints explicit:

  • The appId and aggregationDomainId inside the proof must match the current backend environment
  • The same nullifier and wiseReceiptHash cannot be submitted twice

After the validation passes, the backend packages the proof into a body accepted by Kurier. It explicitly carries vk, proof, and publicSignals, while also preserving the order-related fields:

const modern = {
  proofType: payload.proofSystem,
  proofOptions,
  vkRegistered: true,
  proofData: {
    vk: env.vkHash,
    proof: normalizedProof,
    publicSignals: normalizedPublicSignals
  },
  mode: payload.verificationMode,
  appId: payload.appId,
  businessDomain: payload.businessDomain,
  aggregationDomainId: payload.aggregationDomainId,
  userAddr: payload.userAddr,
  chainId: payload.chainId,
  timestamp: payload.timestamp,
  intentId: payload.intentId,
  intentHash: payload.intentHash,
  amount: payload.amount,
  nullifier: payload.nullifier
};
Enter fullscreen mode Exit fullscreen mode

Code location: apps/web/pages/api/submit-proof.ts

The meaning here is very clear: what gets submitted to Kurier is a proof payload that has already been bound to the current order. The proof system, vk, public signals, intent context, and nullifier all enter upstream together at this layer.

After the backend submits successfully, it pulls providerJobId out of the upstream response and binds the local proofId to that upstream job.

const providerJobId = extractProviderJobId(raw);
if (!providerJobId) {
  releaseNullifier(payload.nullifier);
  releaseWiseReceiptHash(payload.wiseReceiptHash);
  return res.status(502).json({
    error: "kurier submit response missing jobId"
  });
}

const status: ProofStatusResponse = {
  proofId: payload.proofId,
  status: failed ? "failed" : "pending",
  rawStatus,
  updatedAt: new Date().toISOString(),
  source: "kurier-keyed",
  availableKeys: Object.keys(raw),
  providerJobId,
  intentHash: payload.intentHash,
  nullifier: payload.nullifier
};
Enter fullscreen mode Exit fullscreen mode

Code location: apps/web/pages/api/submit-proof.ts

After this step, the page holds two sets of IDs:

  • The local proofId of this proof session
  • The upstream Kurier / zkVerify job providerJobId

It is the second one that is actually used later to query status.

After successful submission, the plugin saves submitResponse and starts polling status:

const statusUrl = new URL(session.statusEndpoint);
statusUrl.searchParams.set("proofId", String(proofId));
if (typeof providerJobId === "string" && providerJobId.trim()) {
  statusUrl.searchParams.set("providerJobId", providerJobId.trim());
}

const statusResp = await getJson(statusUrl.toString());
Enter fullscreen mode Exit fullscreen mode

Code location: apps/proof-plugin/background.js

/api/proof-status first uses providerJobId to query Kurier job status, and then maps the upstream status into a few more stable statuses for the page:

export function mapProofStatus(rawStatus: string): "pending" | "verified" | "aggregated" | "failed" {
  const s = rawStatus.toLowerCase();
  if (s.includes("fail") || s.includes("error") || s.includes("reject") || s.includes("invalid")) {
    return "failed";
  }
  if (s.includes("aggregated") || s.includes("aggregationpublished") || s.includes("published")) {
    return "aggregated";
  }
  if (
    s.includes("verified") ||
    s.includes("included") ||
    s.includes("finalized") ||
    s.includes("aggregation pending") ||
    s.includes("aggregationpending")
  ) {
    return "verified";
  }
  return "pending";
}
Enter fullscreen mode Exit fullscreen mode

Code location: apps/web/src/zk/zkp2p-horizen-release/api/kurier.ts

The status here can be understood as having two layers:

  • rawStatus preserves the raw upstream wording from Kurier / zkVerify, which is useful for debugging
  • pending / verified / aggregated / failed is the page’s own stable state machine

The page itself also polls along with it:

const resp = await plugin.queryStatus(activeProofId).catch(() => null);
const rawStatus =
  statusPayload.statusResponse?.rawStatus ??
  statusPayload.statusResponse?.status ??
  statusPayload.status ??
  "pending";
applyProofStatus(activeProofId, rawStatus);
Enter fullscreen mode Exit fullscreen mode

Code location: apps/web/pages/zkp2p-horizen-release.tsx

After the status reaches aggregated, the plugin then fetches the aggregation tuple:

const tupleUrl = new URL(session.aggregationEndpoint);
tupleUrl.searchParams.set("proofId", String(proofId));
if (typeof providerJobId === "string" && providerJobId.trim()) {
  tupleUrl.searchParams.set("providerJobId", providerJobId.trim());
}

const tupleResp = await getJson(tupleUrl.toString());
await patchSession(proofId, { tuple: tupleResp.json });
Enter fullscreen mode Exit fullscreen mode

Code location: apps/proof-plugin/background.js

/api/proof-aggregation extracts the aggregation result produced by zkVerify from the job status:

function tupleFromJobStatusRaw(
  raw: Record<string, unknown>,
  proofId: string,
  aggregationDomainId: string,
  intentHash?: string,
  nullifier?: string
): ProofAggregationTuple | null {
  const details = raw.aggregationDetails;
  if (!details || typeof details !== "object") return null;
  const row = details as Record<string, unknown>;

  const aggregationId = String(raw.aggregationId ?? row.aggregationId ?? "").trim();
  const leaf = String(row.leaf ?? raw.statement ?? "").trim();
  const leafCount = String(row.numberOfLeaves ?? row.leafCount ?? "").trim();
  const index = String(row.leafIndex ?? row.index ?? "").trim();
  const merklePath = Array.isArray(row.merkleProof)
    ? row.merkleProof.map((x) => String(x))
    : Array.isArray(row.merklePath)
      ? row.merklePath.map((x) => String(x))
      : [];
Enter fullscreen mode Exit fullscreen mode

Code location: apps/web/pages/api/proof-aggregation.ts

This layer is where zkVerify’s aggregation result is finally organized into the format consumed by the target chain. All the statuses, logs, and job information returned earlier by Kurier and zkVerify are eventually reduced to this set of fields:

  • aggregationDomainId
  • aggregationId
  • leaf
  • merklePath
  • leafCount
  • index

These are the inputs that the target-chain gateway verifyProofAggregation(...) can actually understand.

What the page really uses later from the tuple is:

  • aggregationDomainId
  • aggregationId
  • leaf
  • merklePath
  • leafCount
  • index

For Chapter 11, the relationship to remember first is this:

  • The browser is responsible for generating the proof locally
  • The Web API is responsible for binding the proof to the business fields and then submitting it to Kurier
  • Kurier sends this proof into zkVerify’s verification / aggregation flow, and exposes job status / tuple to the frontend
  • After the frontend gets the tuple, the actual verification happens on the gateway / deposit pool of the Horizen target chain
  • What the page is waiting for in the end is this tuple, not a piece of status text

Only after this proof relay section is complete does the page truly have the parameters needed to call releaseWithProof() on-chain.

12. How the final releaseWithProof() happens

Finally, go back to the page and the contract.

Before the buyer signs, the page has already arranged the tuple and the intent-related fields. The place where the transaction is actually sent is in apps/web/pages/zkp2p-horizen-release.tsx:

appendPluginLog("releaseWithProof 提交中...");
const tx = await contract.releaseWithProof(
  intentId,
  nullifier,
  proofIntentHash,
  domainId,
  aggregationId,
  leaf,
  merklePath,
  leafCount,
  index
);
appendPluginLog(`release tx: ${tx.hash}`);
const receipt = await tx.wait();
Enter fullscreen mode Exit fullscreen mode

Code location: apps/web/pages/zkp2p-horizen-release.tsx

After the contract receives this set of parameters, it checks them in order:

Intent storage intent = intents[intentId];
if (!intent.reserved) revert IntentNotReserved();
if (intent.released) revert IntentAlreadyReleased();
if (intent.cancelled) revert IntentAlreadyCancelled();
if (block.timestamp > intent.deadline) revert IntentExpired();
if (msg.sender != intent.buyer) revert OnlyIntentBuyer();
if (nullifierUsed[nullifierHash]) revert NullifierAlreadyUsed();
if (intent.nullifierHash != nullifierHash) revert NullifierMismatch();
if (intent.intentHash != proofIntentHash) revert IntentHashMismatch();

bool verified = gateway.verifyProofAggregation(domainId, aggregationId, leaf, merklePath, leafCount, index);
if (!verified) revert VerificationFailed();

nullifierUsed[nullifierHash] = true;
intent.reserved = false;
intent.released = true;

bool transferred = token.transfer(intent.buyer, intent.amount);
if (!transferred) revert TransferFailed();
Enter fullscreen mode Exit fullscreen mode

Code location: contracts/src/Zkp2pDepositPool.sol

After the transaction is confirmed, both the on-chain state and the funds land at the same time:

  • nullifierHash is marked as used
  • The current intent changes from reserved to released
  • The seller’s locked amount is reduced
  • The USDC corresponding to intent.amount is transferred to the buyer

At this point, the entire path is closed.

Each earlier layer does one thing of its own:

  • The contract locks the seller’s liquidity out first
  • The page prepares the order context
  • The plugin obtains the attestation from the Wise page
  • tlsn-verifier verifies the attestation and compresses it into wiseReceiptHash
  • The browser locally generates the proof with Noir + UltraHonk
  • The Web API sends the proof into Kurier and zkVerify
  • After the page gets the aggregation tuple, the buyer initiates releaseWithProof() themselves

That way, this full P2P fiat-to-USDC on-ramp path lands end to end. The seller locks liquidity first, the buyer then completes the Wise payment, the browser pushes the payment evidence into proving, Kurier synchronizes zkVerify’s aggregation result outward, and after the frontend gets the tuple, validation and release are finally completed by releaseWithProof() on the Horizen target chain.

Top comments (0)