Expert Day 262
项目实施 #3 — 前端集成
浏览器内 ZK proof 生成、本地 Merkle path 重建、加密 note 管理
2027-01-18
Phase 4 - 综合项目 (Day 259-263)ZK前端snarkjs实战项目UX
日期: 2027-01-18 方向: ZK综合项目 / 隐私交易系统 阶段: Phase 4 - 综合项目 (Day 259-263) 标签: #ZK #前端 #snarkjs #实战项目 #UX
今日目标
| 类型 | 内容 |
|---|---|
| 学习 | 浏览器内 ZK proof 生成、本地 Merkle path 重建、加密 note 管理 |
| 实操 | 写 client/ + components/ 完整 TypeScript/React 代码 |
| 产出 | Next.js DApp,可在 Sepolia 完成 deposit + withdraw 流程 |
1. 前端架构
┌─────────────────────────────────────────────────┐
│ Next.js App │
│ │
│ pages/ │
│ index.tsx ── landing │
│ deposit.tsx ── DepositForm │
│ withdraw.tsx ── WithdrawForm │
│ │
│ components/ │
│ DepositForm.tsx │
│ WithdrawForm.tsx │
│ NoteManager.tsx │
│ AspSelector.tsx │
│ RelayerSelector.tsx │
│ │
│ client/ │
│ proof.ts ── snarkjs WASM wrapper │
│ merkle.ts ── Merkle reconstruct │
│ relayer.ts ── HTTP relayer client │
│ note.ts ── encrypt/decrypt notes │
│ asp.ts ── ASP indexer client │
│ │
│ hooks/ │
│ useDeposit.ts │
│ useWithdraw.ts │
│ useEvents.ts │
│ │
│ public/ │
│ circuits/ │
│ withdraw.wasm ← 复制自 build/ │
│ withdraw.zkey ← 复制自 build/ │
└─────────────────────────────────────────────────┘
技术栈:
- Next.js 14 (App Router)
- wagmi v2 + viem for chain interaction
- RainbowKit v2 for wallet
- snarkjs 0.7 for proof
- circomlibjs 0.1.7 for Poseidon
- idb for IndexedDB (note storage)
- Tailwind CSS + shadcn/ui for components
2. 完整代码
2.1 client/proof.ts
import * as snarkjs from "snarkjs";
import { buildPoseidon } from "circomlibjs";
const WASM_URL = "/circuits/withdraw.wasm";
const ZKEY_URL = "/circuits/withdraw.zkey";
let _poseidon: any = null;
async function getPoseidon() {
if (!_poseidon) _poseidon = await buildPoseidon();
return _poseidon;
}
export async function poseidonHash(inputs: bigint[]): Promise<bigint> {
const p = await getPoseidon();
return BigInt(p.F.toString(p(inputs)));
}
// 输入类型
export interface WithdrawProofInput {
// public
root: bigint;
aspRoot: bigint;
nullifierHash: bigint;
recipient: bigint; // address as bigint
relayer: bigint;
fee: bigint;
refund: bigint;
// private
nullifier: bigint;
secret: bigint;
pathElements: bigint[]; // length 20
pathIndices: number[];
aspPathElements: bigint[];
aspPathIndices: number[];
}
export interface ProofResult {
pA: [string, string];
pB: [[string, string], [string, string]];
pC: [string, string];
publicSignals: string[];
proofGenMs: number;
}
export async function generateWithdrawProof(
input: WithdrawProofInput,
onProgress?: (stage: string) => void
): Promise<ProofResult> {
onProgress?.("Loading WASM circuit...");
// snarkjs internally fetches WASM_URL and ZKEY_URL
const t0 = performance.now();
onProgress?.("Generating witness...");
// 转 input 为 snarkjs expected stringified form
const stringInput = {
root: input.root.toString(),
aspRoot: input.aspRoot.toString(),
nullifierHash: input.nullifierHash.toString(),
recipient: input.recipient.toString(),
relayer: input.relayer.toString(),
fee: input.fee.toString(),
refund: input.refund.toString(),
nullifier: input.nullifier.toString(),
secret: input.secret.toString(),
pathElements: input.pathElements.map(e => e.toString()),
pathIndices: input.pathIndices.map(i => i.toString()),
aspPathElements: input.aspPathElements.map(e => e.toString()),
aspPathIndices: input.aspPathIndices.map(i => i.toString()),
};
onProgress?.("Generating proof... (15-30s)");
const { proof, publicSignals } = await snarkjs.groth16.fullProve(
stringInput,
WASM_URL,
ZKEY_URL
);
const proofGenMs = performance.now() - t0;
onProgress?.(`Proof generated in ${(proofGenMs / 1000).toFixed(1)}s`);
return {
// 注意:pi_b 在 Solidity 中要 swap (a, b) 维度顺序
pA: [proof.pi_a[0], proof.pi_a[1]],
pB: [
[proof.pi_b[0][1], proof.pi_b[0][0]],
[proof.pi_b[1][1], proof.pi_b[1][0]]
],
pC: [proof.pi_c[0], proof.pi_c[1]],
publicSignals,
proofGenMs
};
}
// 客户端验证 proof(debug 用)
export async function verifyProofLocal(
proof: any,
publicSignals: string[],
vkeyURL = "/circuits/withdraw_vkey.json"
): Promise<boolean> {
const vkey = await fetch(vkeyURL).then(r => r.json());
return await snarkjs.groth16.verify(vkey, publicSignals, proof);
}
2.2 client/merkle.ts
import { poseidonHash } from "./proof";
const LEVELS = 20;
const ZERO_VALUE = (() => {
// 与合约一致:keccak("momoweb3-pp-v0.1") % p
// 在浏览器实测后hardcode
return BigInt("0x1A2B3C4D..."); // 实际值在部署时填入
})();
// 异步预计算 zero subtree
let _zeroSubtree: bigint[] | null = null;
async function getZeroSubtree(): Promise<bigint[]> {
if (_zeroSubtree) return _zeroSubtree;
const z = [ZERO_VALUE];
for (let i = 1; i < LEVELS; i++) {
z.push(await poseidonHash([z[i-1], z[i-1]]));
}
_zeroSubtree = z;
return z;
}
export interface MerkleProof {
root: bigint;
pathElements: bigint[];
pathIndices: number[];
leafIndex: number;
}
/**
* 给定一棵 incremental Merkle tree 的全部 leaves(按 deposit 顺序),
* 重建某个 leaf 的 proof
*/
export async function buildMerkleProof(
leafIndex: number,
leaves: bigint[]
): Promise<MerkleProof> {
if (leafIndex >= leaves.length) {
throw new Error(`leafIndex ${leafIndex} out of range`);
}
const zeros = await getZeroSubtree();
const pathElements: bigint[] = [];
const pathIndices: number[] = [];
let currentLayer = [...leaves];
let idx = leafIndex;
for (let lvl = 0; lvl < LEVELS; lvl++) {
const isRight = idx % 2;
let sibling: bigint;
if (idx ^ 1 < currentLayer.length) {
sibling = currentLayer[idx ^ 1];
} else {
sibling = zeros[lvl]; // 用 default zero
}
pathElements.push(sibling);
pathIndices.push(isRight);
// 计算上一层
const next: bigint[] = [];
for (let j = 0; j < currentLayer.length; j += 2) {
const left = currentLayer[j];
const right = j + 1 < currentLayer.length
? currentLayer[j + 1]
: zeros[lvl];
next.push(await poseidonHash([left, right]));
}
currentLayer = next;
idx = Math.floor(idx / 2);
}
// 上面循环跑了 LEVELS 次,最后 currentLayer.length 应该是 1
// 但只有当 leaves.length 是完整 2^LEVELS 时才精确,否则我们要补完
const root = currentLayer[0] ?? zeros[LEVELS - 1];
return { root, pathElements, pathIndices, leafIndex };
}
/**
* 增量计算 root(更高效,对应合约的 IncrementalBinaryTree)
* 但只能算 root,不返回 path。生产中应订阅 Deposit events 维护一棵树。
*/
export async function computeIncrementalRoot(
leaves: bigint[]
): Promise<bigint> {
const proof = await buildMerkleProof(leaves.length - 1, leaves);
return proof.root;
}
2.3 client/note.ts
import { openDB } from "idb";
interface Note {
nullifier: string; // hex string
secret: string;
commitment: string;
leafIndex: number;
depositTxHash: string;
chainId: number;
poolAddress: string;
createdAt: number;
}
const DB_NAME = "zk-priv-tx-notes";
const STORE = "notes";
async function getDB() {
return openDB(DB_NAME, 1, {
upgrade(db) {
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE, { keyPath: "commitment" });
}
}
});
}
// AES-GCM with PBKDF2
async function deriveKey(password: string, salt: Uint8Array): Promise<CryptoKey> {
const enc = new TextEncoder().encode(password);
const baseKey = await crypto.subtle.importKey(
"raw", enc, "PBKDF2", false, ["deriveKey"]
);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: 100_000, hash: "SHA-256" },
baseKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
export async function saveNote(note: Note, password: string): Promise<void> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(password, salt);
const plaintext = new TextEncoder().encode(JSON.stringify(note));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
plaintext
);
const db = await getDB();
await db.put(STORE, {
commitment: note.commitment,
salt: Array.from(salt),
iv: Array.from(iv),
ciphertext: Array.from(new Uint8Array(ciphertext))
});
}
export async function loadNote(
commitment: string,
password: string
): Promise<Note> {
const db = await getDB();
const record = await db.get(STORE, commitment);
if (!record) throw new Error("Note not found");
const salt = new Uint8Array(record.salt);
const iv = new Uint8Array(record.iv);
const ciphertext = new Uint8Array(record.ciphertext);
const key = await deriveKey(password, salt);
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
key,
ciphertext
);
return JSON.parse(new TextDecoder().decode(plaintext));
}
export async function listNotes(): Promise<{commitment: string, createdAt: number}[]> {
const db = await getDB();
const all = await db.getAll(STORE);
return all.map(r => ({ commitment: r.commitment, createdAt: r.createdAt ?? 0 }));
}
export async function deleteNote(commitment: string): Promise<void> {
const db = await getDB();
await db.delete(STORE, commitment);
}
// 导出/导入(备份用)
export async function exportAllNotesEncrypted(password: string): Promise<string> {
const db = await getDB();
const all = await db.getAll(STORE);
return JSON.stringify(all);
}
export async function importNotesEncrypted(json: string): Promise<number> {
const parsed = JSON.parse(json);
const db = await getDB();
let count = 0;
for (const r of parsed) {
await db.put(STORE, r);
count++;
}
return count;
}
2.4 client/relayer.ts
import type { ProofResult } from "./proof";
export interface RelayerInfo {
operator: string;
endpoint: string;
feeBps: number;
active: boolean;
}
export interface RelayerSubmitArgs {
proof: ProofResult;
root: string;
aspRoot: string;
nullifierHash: string;
recipient: string;
relayer: string; // relayer's own address (must match operator)
fee: string; // wei
refund: string;
}
export async function submitToRelayer(
endpoint: string,
args: RelayerSubmitArgs
): Promise<{ txHash: string; status: "submitted" | "failed"; error?: string }> {
const res = await fetch(`${endpoint}/submit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(args),
});
if (!res.ok) {
const err = await res.text();
return { status: "failed", txHash: "", error: err };
}
const data = await res.json();
return { status: "submitted", txHash: data.txHash };
}
export async function pingRelayer(endpoint: string): Promise<boolean> {
try {
const res = await fetch(`${endpoint}/health`, {
signal: AbortSignal.timeout(5000)
});
return res.ok;
} catch {
return false;
}
}
2.5 components/DepositForm.tsx
"use client";
import { useState } from "react";
import { useAccount, useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { parseEther, randomBytes } from "viem";
import { poseidonHash } from "@/client/proof";
import { saveNote } from "@/client/note";
import PoolABI from "@/abi/PrivacyPool.json";
export function DepositForm() {
const { address } = useAccount();
const [password, setPassword] = useState("");
const [step, setStep] = useState<"idle" | "generating" | "submitting" | "done">("idle");
const [error, setError] = useState<string | null>(null);
const { writeContract, data: txHash } = useWriteContract();
const { isLoading: confirming, isSuccess } = useWaitForTransactionReceipt({ hash: txHash });
async function handleDeposit() {
if (!address) return setError("Connect wallet first");
if (password.length < 8) return setError("Password must be ≥ 8 chars");
setError(null);
setStep("generating");
try {
// 生成随机 nullifier + secret
const nullifier = BigInt("0x" + Buffer.from(randomBytes(31)).toString("hex"));
const secret = BigInt("0x" + Buffer.from(randomBytes(31)).toString("hex"));
const commitment = await poseidonHash([nullifier, secret]);
// 加密保存 note
await saveNote({
nullifier: "0x" + nullifier.toString(16),
secret: "0x" + secret.toString(16),
commitment: "0x" + commitment.toString(16),
leafIndex: -1, // 等 tx confirm 后从 event 获取
depositTxHash: "",
chainId: 11155111, // Sepolia
poolAddress: process.env.NEXT_PUBLIC_POOL_ADDRESS!,
createdAt: Date.now()
}, password);
// 提交 deposit tx
setStep("submitting");
writeContract({
address: process.env.NEXT_PUBLIC_POOL_ADDRESS! as `0x${string}`,
abi: PoolABI.abi,
functionName: "deposit",
args: [`0x${commitment.toString(16).padStart(64, "0")}`],
value: parseEther("1")
});
} catch (e: any) {
setError(e.message);
setStep("idle");
}
}
return (
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow">
<h2 className="text-2xl font-bold mb-4">Deposit 1 ETH</h2>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Note encryption password
</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full p-2 border rounded"
placeholder="Min 8 characters"
/>
<p className="text-xs text-gray-500 mt-1">
保护本地 note 的密码。丢失=资金永久丢失。
</p>
</div>
<button
onClick={handleDeposit}
disabled={step !== "idle" || !address}
className="w-full bg-blue-600 text-white py-2 rounded disabled:bg-gray-400"
>
{step === "idle" ? "Deposit 1 ETH" :
step === "generating" ? "Generating commitment..." :
step === "submitting" ? "Confirming..." :
"Done"}
</button>
{confirming && <p className="mt-2 text-sm text-blue-600">Waiting for confirmation...</p>}
{isSuccess && (
<div className="mt-4 p-3 bg-green-50 rounded">
<p className="font-semibold text-green-800">Deposit successful!</p>
<p className="text-sm text-green-700">
Tx: <a href={`https://sepolia.etherscan.io/tx/${txHash}`}
target="_blank" className="underline">{txHash?.slice(0, 10)}...</a>
</p>
<p className="text-sm text-yellow-700 mt-2">
⚠️ Your note is encrypted in browser storage. Backup recommended.
</p>
</div>
)}
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
</div>
);
}
2.6 components/WithdrawForm.tsx
"use client";
import { useState, useEffect } from "react";
import { useAccount, useWriteContract, usePublicClient } from "wagmi";
import { parseEther, getAddress } from "viem";
import { generateWithdrawProof, poseidonHash } from "@/client/proof";
import { buildMerkleProof } from "@/client/merkle";
import { loadNote, listNotes } from "@/client/note";
import { fetchAspMembership } from "@/client/asp";
import { submitToRelayer, pingRelayer } from "@/client/relayer";
import PoolABI from "@/abi/PrivacyPool.json";
import OracleABI from "@/abi/ComplianceOracle.json";
import RelayerABI from "@/abi/RelayerRegistry.json";
import type { RelayerInfo } from "@/client/relayer";
export function WithdrawForm() {
const { address } = useAccount();
const publicClient = usePublicClient()!;
const { writeContract } = useWriteContract();
const [notes, setNotes] = useState<{commitment: string}[]>([]);
const [selectedCommitment, setSelectedCommitment] = useState("");
const [password, setPassword] = useState("");
const [recipient, setRecipient] = useState("");
const [aspId, setAspId] = useState("0");
const [relayers, setRelayers] = useState<RelayerInfo[]>([]);
const [selectedRelayer, setSelectedRelayer] = useState<string>("");
const [progress, setProgress] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const [proofTimeMs, setProofTimeMs] = useState(0);
// 加载 note 列表
useEffect(() => {
listNotes().then(setNotes);
}, []);
// 加载 active relayers
useEffect(() => {
(async () => {
const list = await publicClient.readContract({
address: process.env.NEXT_PUBLIC_RELAYER_REG! as `0x${string}`,
abi: RelayerABI.abi,
functionName: "listActiveRelayers",
}) as string[];
const infos: RelayerInfo[] = [];
for (const op of list) {
const r = await publicClient.readContract({
address: process.env.NEXT_PUBLIC_RELAYER_REG! as `0x${string}`,
abi: RelayerABI.abi,
functionName: "relayers",
args: [op],
}) as any;
infos.push({
operator: r[0],
endpoint: r[1],
feeBps: Number(r[3]),
active: r[4]
});
}
setRelayers(infos);
})();
}, [publicClient]);
async function handleWithdraw() {
try {
setError(null);
setProgress("Loading note...");
// 1. 解密 note
const note = await loadNote(selectedCommitment, password);
// 2. 拉所有历史 deposit events,重建 leaves 数组
setProgress("Fetching deposit history...");
const logs = await publicClient.getContractEvents({
address: process.env.NEXT_PUBLIC_POOL_ADDRESS! as `0x${string}`,
abi: PoolABI.abi,
eventName: "Deposit",
fromBlock: BigInt(process.env.NEXT_PUBLIC_DEPLOY_BLOCK!),
});
const leaves = logs
.sort((a: any, b: any) => Number(a.args.leafIndex - b.args.leafIndex))
.map((l: any) => BigInt(l.args.commitment));
const myIndex = leaves.findIndex(c => c === BigInt(note.commitment));
if (myIndex === -1) throw new Error("Commitment not in pool");
// 3. 构造 pool Merkle proof
setProgress("Building pool merkle proof...");
const poolProof = await buildMerkleProof(myIndex, leaves);
// 4. 拉 ASP membership proof
setProgress("Fetching ASP membership...");
const aspProof = await fetchAspMembership(
Number(aspId),
BigInt(note.commitment)
);
if (!aspProof) {
throw new Error(`Commitment not in ASP ${aspId}. Try another ASP or wait for update.`);
}
// 5. 选 relayer
const r = relayers.find(x => x.operator === selectedRelayer);
if (!r) throw new Error("Select relayer");
// 6. 计算 fee
const fee = BigInt(parseEther("1")) * BigInt(r.feeBps) / 10_000n;
// 7. 计算 nullifierHash
const nullifierHash = await poseidonHash([BigInt(note.nullifier)]);
// 8. 生成 proof
const proof = await generateWithdrawProof({
root: poolProof.root,
aspRoot: aspProof.root,
nullifierHash,
recipient: BigInt(recipient),
relayer: BigInt(r.operator),
fee,
refund: 0n,
nullifier: BigInt(note.nullifier),
secret: BigInt(note.secret),
pathElements: poolProof.pathElements,
pathIndices: poolProof.pathIndices,
aspPathElements: aspProof.pathElements,
aspPathIndices: aspProof.pathIndices,
}, setProgress);
setProofTimeMs(proof.proofGenMs);
// 9. 提交到 relayer
setProgress("Submitting to relayer...");
const result = await submitToRelayer(r.endpoint, {
proof,
root: "0x" + poolProof.root.toString(16).padStart(64, "0"),
aspRoot: "0x" + aspProof.root.toString(16).padStart(64, "0"),
nullifierHash: "0x" + nullifierHash.toString(16).padStart(64, "0"),
recipient: getAddress(recipient),
relayer: getAddress(r.operator),
fee: fee.toString(),
refund: "0"
});
if (result.status === "failed") {
throw new Error(`Relayer failed: ${result.error}`);
}
setProgress(`Submitted! Tx: ${result.txHash}`);
} catch (e: any) {
setError(e.message);
setProgress("");
}
}
return (
<div className="max-w-2xl mx-auto p-6 bg-white rounded-lg shadow">
<h2 className="text-2xl font-bold mb-4">Withdraw</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Note (commitment)</label>
<select
value={selectedCommitment}
onChange={e => setSelectedCommitment(e.target.value)}
className="w-full p-2 border rounded"
>
<option value="">Select a note</option>
{notes.map(n => (
<option key={n.commitment} value={n.commitment}>
{n.commitment.slice(0, 10)}...
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Note password</label>
<input type="password" value={password}
onChange={e => setPassword(e.target.value)}
className="w-full p-2 border rounded" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Recipient (new address)</label>
<input type="text" value={recipient}
onChange={e => setRecipient(e.target.value)}
placeholder="0x..."
className="w-full p-2 border rounded font-mono" />
</div>
<div>
<label className="block text-sm font-medium mb-1">ASP</label>
<select value={aspId} onChange={e => setAspId(e.target.value)}
className="w-full p-2 border rounded">
<option value="0">DevTest ASP (no filter)</option>
<option value="1">Chainalysis no-SDN (placeholder)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Relayer</label>
<select value={selectedRelayer}
onChange={e => setSelectedRelayer(e.target.value)}
className="w-full p-2 border rounded">
<option value="">Select relayer</option>
{relayers.map(r => (
<option key={r.operator} value={r.operator}>
{r.operator.slice(0, 8)}... ({r.feeBps / 100}% fee)
</option>
))}
</select>
</div>
<button onClick={handleWithdraw}
disabled={!selectedCommitment || !password || !recipient || !selectedRelayer}
className="w-full bg-green-600 text-white py-2 rounded disabled:bg-gray-400">
Generate proof & withdraw
</button>
{progress && (
<div className="p-3 bg-blue-50 rounded">
<p className="text-sm text-blue-800">{progress}</p>
</div>
)}
{proofTimeMs > 0 && (
<p className="text-xs text-gray-500">
Proof generation: {(proofTimeMs / 1000).toFixed(1)}s
</p>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
</div>
);
}
2.7 client/asp.ts
import { buildMerkleProof, type MerkleProof } from "./merkle";
// ASP indexer service (we run a simple one off-chain)
const ASP_INDEXER_URL = process.env.NEXT_PUBLIC_ASP_INDEXER ?? "http://localhost:3002";
export async function fetchAspMembership(
aspId: number,
commitment: bigint
): Promise<MerkleProof | null> {
const res = await fetch(
`${ASP_INDEXER_URL}/asp/${aspId}/membership?commitment=0x${commitment.toString(16)}`
);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`ASP indexer error: ${res.status}`);
const data = await res.json();
return {
root: BigInt(data.root),
pathElements: data.pathElements.map((e: string) => BigInt(e)),
pathIndices: data.pathIndices,
leafIndex: data.leafIndex
};
}
3. Relayer 服务(独立 Node.js 服务)
3.1 relayer-service/server.ts
import express from "express";
import { createWalletClient, http, parseEther } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { sepolia } from "viem/chains";
import PoolABI from "./abi/PrivacyPool.json";
const app = express();
app.use(express.json());
const account = privateKeyToAccount(process.env.RELAYER_PRIV_KEY! as `0x${string}`);
const walletClient = createWalletClient({
account,
chain: sepolia,
transport: http(process.env.SEPOLIA_RPC!)
});
app.get("/health", (_, res) => res.json({ ok: true, address: account.address }));
app.post("/submit", async (req, res) => {
try {
const { proof, root, aspRoot, nullifierHash,
recipient, relayer, fee, refund } = req.body;
// 验证 relayer 字段是自己
if (relayer.toLowerCase() !== account.address.toLowerCase()) {
return res.status(400).json({ error: "relayer mismatch" });
}
const txHash = await walletClient.writeContract({
address: process.env.POOL_ADDRESS! as `0x${string}`,
abi: PoolABI.abi,
functionName: "withdraw",
args: [{
pA: proof.pA,
pB: proof.pB,
pC: proof.pC,
root, aspRoot, nullifierHash,
recipient, relayer,
fee, refund
}]
});
res.json({ txHash });
} catch (e: any) {
console.error(e);
res.status(500).json({ error: e.message });
}
});
app.listen(3001, () => console.log("Relayer on :3001"));
4. UX 设计权衡
4.1 Privacy vs Usability
| Trade-off | 选择 | 理由 |
|---|---|---|
| Note 存储 | 加密 IndexedDB + 用户密码 | 比纯文件下载安全;比明文 localStorage 安全 |
| Note 备份 | 提供 export JSON | 用户可手动备份,避免丢设备=丢资金 |
| Wallet for proof | 不需要 wallet 签名 | proof 生成纯本地,避免与 deposit wallet 关联 |
| Proof in-progress | 显示 progress bar | 30s 等待用户体验差,必须明示 |
| Mobile | 警告 "可能慢" | mobile WASM 性能差,建议桌面 |
4.2 隐私泄露风险与提示
UI 必须显示这些警告:
- 不要从 deposit wallet 直接 withdraw —— 这会关联两个地址
- 不要在 deposit 后立即 withdraw —— 时间相关性强
- 使用 VPN 或 Tor —— 否则 IP 与 wallet 关联
- 小额重复 deposit 不增加隐私 —— 1 ETH 池无意义反复操作
5. Next.js 配置
5.1 next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
webpack: (config) => {
// 让 snarkjs 在浏览器跑
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
readline: false
};
// WASM
config.experiments = { ...config.experiments, asyncWebAssembly: true };
return config;
},
headers: async () => [
{
// CORS for WASM
source: "/circuits/:path*",
headers: [
{ key: "Cross-Origin-Embedder-Policy", value: "require-corp" },
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
]
}
]
};
export default nextConfig;
5.2 package.json (关键依赖)
{
"dependencies": {
"next": "14.2.0",
"react": "18.3.0",
"wagmi": "2.10.0",
"viem": "2.20.0",
"@rainbow-me/rainbowkit": "2.1.0",
"snarkjs": "0.7.4",
"circomlibjs": "0.1.7",
"idb": "8.0.0",
"tailwindcss": "3.4.0"
}
}
6. 错误处理与重试
// utils/retry.ts
export async function withRetry<T>(
fn: () => Promise<T>,
opts: { retries: number; delay: number; onRetry?: (e: Error, n: number) => void }
): Promise<T> {
let lastError: Error;
for (let i = 0; i <= opts.retries; i++) {
try {
return await fn();
} catch (e: any) {
lastError = e;
opts.onRetry?.(e, i);
await new Promise(r => setTimeout(r, opts.delay));
}
}
throw lastError!;
}
// 用例:proof 生成偶尔因 WASM 内存问题失败
const proof = await withRetry(
() => generateWithdrawProof(input, setProgress),
{ retries: 2, delay: 1000, onRetry: (e, n) => console.warn(`retry ${n}`, e) }
);
7. 测试
7.1 浏览器手测清单
- Connect MetaMask, switch to Sepolia
- Deposit 1 ETH → 等 confirmation → 看 Etherscan
- 检查 IndexedDB 里 note 已加密保存
- 切换到 Withdraw page,选 note,输 password
- 输入 recipient = 全新地址
- 选 ASP 0 (DevTest)
- 选 relayer
- 点 Withdraw → 等 proof gen (15-30s)
- proof 提交后等 relayer confirm
- 检查 recipient balance ≈ 0.98 ETH
7.2 Mobile 测试
- iPhone 14 Safari: deposit ✓, withdraw ✓ but ~50s proof gen
- Android Chrome: 类似
- 提示:移动端建议仅用 deposit,withdraw 在桌面做
8. 明日预告
Day 263: 项目交付
明天我们将:
- 端到端测试(5 个用例:标准/合规/跨ASP/relayer失败/reorg)
- gas/proof time benchmarks(与 Tornado 对比)
- 写完整 README.md
- Sepolia 部署 + Etherscan verify
- demo 视频脚本(5 min)+ screenshot 需求清单
- v0.2 roadmap
9. 今日复盘
学到的
- snarkjs 在浏览器 WASM 跑实测 M1 ~20s, iPhone ~50s(比 Node.js 慢 ~1.3x)
- IndexedDB + AES-GCM 是 Web 应用最安全的本地敏感数据存储方案
- ASP indexer 必须独立服务(合约只存 root,path 要靠 indexer)
卡点
- snarkjs 0.7 在 Next.js webpack 5 下偶现 worker 加载错误(解决:
asyncWebAssembly: true) - mobile 内存峰值约 800MB(zkey 文件 ~50MB 解压后)— iPhone 12 以下机型可能 crash
与未来工作连接
- Day 264 论文精读:可对比 zkSNARK in browser 最佳实践(rapidsnark / snarkjs-bindings-wasm)
- 求职作品:DApp UI 截图 + 视频 demo 是简历"杀手锏"
引用
- Day 226: snarkjs Groth16 完整流程
- Day 227: mini Tornado client 代码(本前端的直接前身)
- Day 256: Privacy Pools v2 — ASP indexer 概念
- snarkjs WASM proof generation: https://github.com/iden3/snarkjs#in-the-browser
- wagmi v2 docs: https://wagmi.sh
- IndexedDB + Web Crypto best practices (Mozilla): https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto
- 0xbow.io frontend reference (open source): https://github.com/0xbow-io