Iframe widget
Embed the swap form inside your own modal. User stays on your UI, sees your brand around ours.
<iframe
src={`https://app.zksdk.io/embed/pools/${POOL_ID}?` + new URLSearchParams({
chain: "evm",
tokenIn: userSelectedTokenIn,
tokenOut: userSelectedTokenOut,
amount: userAmountInUnits,
})}
width="420"
height="640"
style={{ border: 0 }}
/>;
useEffect(() => {
const handler = (e: MessageEvent) => {
if (e.data?.type === "PRIVATE_SWAP_COMPLETE") {
onSwapLanded(e.data.txHash);
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, [onSwapLanded]);
On success the iframe emits:
{
"type": "PRIVATE_SWAP_COMPLETE",
"txHash": "0x…",
"changeNoteCommitment": "0x…"
}
The widget emits PRIVATE_SWAP_COMPLETE on success — that's the only message you need to wire. For user-cancel (they close the modal) or error (stalled flow), handle it like any third-party iframe: give users a close button on your modal, and set a timeout on the listener (we suggest 5 minutes, which covers Groth16 proof generation on slower devices).
Validate the origin. In production, reject message events that don't come from the iframe's own origin — otherwise any page-level iframe or extension can forge a PRIVATE_SWAP_COMPLETE:
if (e.origin !== "https://app.zksdk.io") return;
What happens inside the iframe
- Loads pool metadata from our REST API (
/api/pools/:id) and the user's shielded notes from their browser (never sent to us). Reads the pool's allowlist + token metadata directly on-chain. - If the user has no shielded notes → inline Shield to Start deposit, then auto-flips to swap.
- Pulls the merkle tree leaves from
/api/pools/:id/zk/commitments(public data cached from on-chain events); the browser builds the witness and generates a Groth16 proof locally using the user's note secrets — the secrets never leave the device. - User signs one EIP-712
BroadcasterRequest. No EOA gas. - Broadcaster relays through
RelayAdapt→ShieldedPool. Pool verifies the proof, nullifies input notes, emits the change-note commitment. - Iframe
postMessagesPRIVATE_SWAP_COMPLETEonce confirmed.
Where data lives
Useful mental model.
Our REST API only holds public on-chain data (cached from the pool's own events, for speed). Your users' private data never touches our servers:
| Piece | Who holds it | Private or public? |
|---|---|---|
| Pool config (verifier, allowlist, fees) | Our REST API — cache of chain state | Public |
| Commitment list + nullifiers (the merkle tree's leaves) | Our REST API — cache of ShieldedPool events; the chain is the source of truth | Public. A commitment is a hash — knowing it reveals nothing about the note behind it. |
| Token metadata, on-chain reads | Directly on-chain via viem | Public |
| Your users' private data (their balances, amounts, spend keys, note secrets) | Their browser only — not our servers, not yours either | Private |
| Proof generation | User's browser | Private witness → public proof |
| Tx submission + gas | Our broadcaster (meta-tx, any-token fee) | Public |
Why this is private. A commitment is a hash of (amount, token, owner, randomness) — it reveals nothing about the note behind it. We and any blockchain indexer can serve the commitment list; holding it tells you nothing about users' balances or activity. Privacy is enforced because only your user holds the preimage. In short: our API mirrors public chain events — not your users' balance data.
Ships in v0.95.