返回 Expert 笔记
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 bar30s 等待用户体验差,必须明示
Mobile警告 "可能慢"mobile WASM 性能差,建议桌面

4.2 隐私泄露风险与提示

UI 必须显示这些警告:

  1. 不要从 deposit wallet 直接 withdraw —— 这会关联两个地址
  2. 不要在 deposit 后立即 withdraw —— 时间相关性强
  3. 使用 VPN 或 Tor —— 否则 IP 与 wallet 关联
  4. 小额重复 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 是简历"杀手锏"

引用