Các bằng chứng không có thông tin (ZKPs) đã được chuyển từ các bài báo học thuật sang các ứng dụng web sản xuất. Dù bạn đang xây dựng một hệ thống bầu cử riêng tư, chuyển giao mã thông tin bí mật hoặc một quy trình KYC chứng minh tuổi tác mà không tiết lộ ngày sinh, khả năng chạy các trình kiểm chứng và kiểm tra trong JavaScript mở ra các loại sản phẩm hoàn toàn mới. Hướng dẫn này sẽ hướng dẫn toàn bộ quá trình: từ viết các mạch trong circom đến triển khai các điểm kết thúc kiểm tra trong Express.js, với các mẫu thực tế cho cả việc chứng minh trên trình duyệt và phía máy chủ.
Từ khóa chính: zk trong nodejs
Tại sao ZKP trong JavaScript quan trọng
Hệ sinh thái Zero-Knowledge trong lịch sử đã được thống trị bởi Rust (RISC Zero, SP1), Go (gnark) và C++ (libsnark). Tuy nhiên, JavaScript có một vị trí độc đáo: nó chạy ở mọi nơi. Một prover được biên dịch thành WebAssembly sẽ được chạy trong Chrome trên điện thoại, trong Node.js trên máy chủ và trong Electron trên máy tính để bàn – tất cả từ cùng một đối tượng.
Độ cầm tay này thay đổi mô hình mối đe dọa. Khi người chứng minh chạy trong trình duyệt của người dùng, người chứng kiến (cấu trúc dữ liệu riêng) sẽ không bao giờ rời khỏi thiết bị. Máy chủ chỉ nhận được bằng chứng và tín hiệu công khai. So sánh với việc chứng minh trên máy chủ, nơi người dùng phải tin tưởng máy chủ với dữ liệu riêng của họ.
Hệ thống xử lý snarkjs hoàn chỉnh
snarkjs is the de facto standard for ZK-SNARKs in JavaScript. It implements Groth16 and PLONK, handles the trusted setup ceremony, and provides both CLI and programmatic APIs. Here's the end-to-end flow:
1. Viết Circuit (circom)
Các circuit mô tả các giới hạn toán học xác định "điều đang được chứng minh". circom biên dịch thành R1CS (Rank-1 Constraint System), biểu diễn trung gian mà snarkjs sử dụng.
`circom // circuits/balance_check.circom pragma circom 2.0.0;
template BalanceCheck() { signal private input balance; signal private input minimum; signal output result;
// Constraint: balance - minimum >= 0 // In R1CS: (balance - minimum) 1 = result (balance - minimum) // Where result = 1 if valid, 0 if invalid signal diff; diff <== balance - minimum; // IsZero template returns 1 if input == 0, else 0 component isZero = IsZero(); isZero.in <== diff; // result = 1 - isZero(diff) → 1 if diff > 0, 0 if diff == 0 // Note: This simplified version; production needs range checks result <== 1 - isZero.out; }
component main = BalanceCheck(); `
Các mẫu circom quan trọng cho các ứng dụng web:
2. Chuyển sang R1CS + WASM
`bash circom circuits/balance_check.circom --r1cs --wasm --sym -o build/
`
Tệp WASM là công cụ tính toán chứng cứ. Được cung cấp các đầu vào riêng, nó tính toán tất cả các tín hiệu trung gian và tạo ra witness.wtns - một định nghĩa đầy đủ đáp ứng R1CS. WASM này được chạy giống nhau trong Node.js và trình duyệt.
3. Cài đặt đáng tin cậy (Công năng của Tau)
Groth16 yêu cầu một cấu hình tin cậy cho từng vòng. snarkjs sử dụng nghi thức Công thức của Tau:
`bash snarkjs powersoftau new bn128 12 pot12_0000.ptau -v snarkjs powersoftau contribute pot12_0000.ptau pot12_0001.ptau --name="Contribution 1" -v snarkjs powersoftau beacon pot12_0001.ptau pot12_final.ptau 0102030405060708090a0b0c0d0e0f 10 -n="Final Beacon"
`
Nguy hiểm: Tệp .zkey chứa chất thải độc hại. Ai có bản ghi toàn bộ cấu hình đều có thể tạo ra bằng chứng giả. Trong môi trường sản xuất, hãy sử dụng nghi thức đa bên (MPC) với ít nhất 3 người tham gia độc lập. PLONK tránh được điều này bằng cách sử dụng cấu hình chung – một nghi thức phục vụ tất cả các chuỗi cho đến kích thước giới hạn.
Tính toán chứng nhân và tạo ra bằng chứng
Tính toán chứng kiến
`javascript // Node.js: using the WASM witness calculator const { calculateWitness } = require('snarkjs'); const fs = require('fs');
// Inputs (private!) const input = { balance: "1000000000000000000000000", // 1M tokens (18 decimals) minimum: "100000000000000000000000" // 100K tokens };
// Calculate witness const witness = await calculateWitness( './build/balance_check.wasm', input, './build/witness.wtns' );
// witness is a BigInt array: [1, balance, minimum, diff, isZero.out, result] `
Tạo chứng minh
`javascript const { groth16 } = require('snarkjs');
const { proof, publicSignals } = await groth16.prove( './build/balance_check_final.zkey', './build/witness.wtns' );
// proof = { pi_a: [...], pi_b: [...], pi_c: [...], protocol: "groth16", curve: "bn128" } // publicSignals = ["1"] (result = 1, meaning balance >= minimum) `
Verification
`javascript const vKey = JSON.parse(fs.readFileSync('./build/verification_key.json'));
const isValid = await groth16.verify(vKey, publicSignals, proof); console.log(isValid); // true `
The verifier only needs: verification key + public signals + proof. No private inputs. No toxic waste. This is what makes ZKP suitable for smart contracts and API endpoints.
Browser vs Server Proving: The Fundamental Trade-off
This is the architectural decision that shapes your entire product.
| Dimension | Browser Prover (WASM) | Server Prover |
|---|---|---|
| Bảo mật | ✅ Người chứng kiến không bao giờ rời khỏi thiết bị | ❌ Server sees private inputs |
| Trust Model | ✅ Trustless (user controls prover) | ⚠️ Requires server trust |
| Performance | ❌ 5-30 giây (máy di động: 60s+) | ✅ 100-500ms |
| Memory | ❌ < 4GB WASM limit | ✅ 64GB+ available |
| Battery | ❌ Significant drain | ✅ N/A |
| Availability | ✅ Hoạt động khi không có kết nối mạng | ❌ Yêu cầu kết nối |
| Scalability | ✅ Client-side scales for free | ❌ Server cost grows with users |
When to Choose Browser Proving
- Ứng dụng bảo mật cao: hồ sơ y tế, danh tính, lịch sử tài chính
When to Choose Server Proving
- High-throughput apps: DEX swaps, gaming, frequent interactions
- Complex circuits: > 100k constraints (browser times out)
- User experience critical: Sub-second proving expected
The Hybrid Approach: MPC-Assisted Proving
Emerging pattern: Split the witness between client and server using Multi-Party Computation (MPC). The client holds private inputs, the server holds the proving key, and they jointly compute the proof without either learning the other's secret. Projects like Nova and HyperPlonk are making this practical.
Integrating ZKP into Express.js: Production Patterns
A production ZKP API needs to handle: large file uploads (.zkey up to 100MB), long-running proof generation (blocking the event loop), and concurrent requests.
Pattern 1: Worker Threads for Non-Blocking Proving
Node.js is single-threaded. Proof generation blocks the event loop. Always use worker threads.
`javascript // workers/prover.worker.js const { parentPort, workerData } = require('worker_threads'); const { groth16 } = require('snarkjs'); const fs = require('fs');
parentPort.on('message', async ({ zkeyPath, witnessPath, jobId }) => { try { const { proof, publicSignals } = await groth16.prove(zkeyPath, witnessPath); parentPort.postMessage({ jobId, proof, publicSignals, error: null }); } catch (error) { parentPort.postMessage({ jobId, proof: null, publicSignals: null, error: error.message }); } }); `
`javascript // services/zkpService.js const { Worker } = require('worker_threads'); const path = require('path'); const { calculateWitness } = require('snarkjs'); const fs = require('fs').promises; const os = require('os');
class ZKPService { constructor() { this.workers = new Map(); this.maxWorkers = os.cpus().length; this.jobQueue = []; }
async prove(circuitName, inputs) { const zkeyPath = ./build/${circuitName}_final.zkey; const wasmPath = ./build/${circuitName}.wasm; // 1. Calculate witness (fast, can stay on main thread) const witnessPath = /tmp/witness_${Date.now()}.wtns; await calculateWitness(wasmPath, inputs, witnessPath); // 2. Queue proof generation to worker return new Promise((resolve, reject) => { const jobId = Date.now().toString(36) + Math.random().toString(36).substr(2); const worker = this.getWorker(); worker.once('message', async (result) => { this.releaseWorker(worker); await fs.unlink(witnessPath).catch(() => {}); if (result.error) reject(new Error(result.error)); else resolve({ proof: result.proof, publicSignals: result.publicSignals }); }); worker.postMessage({ zkeyPath, witnessPath, jobId }); }); }
getWorker() { if (this.workers.size < this.maxWorkers) { const worker = new Worker(path.join(__dirname, '../workers/prover.worker.js')); this.workers.set(worker, true); return worker; } // Queue if all workers busy return new Promise((resolve) => { this.jobQueue.push({ resolve, zkeyPath, witnessPath, jobId }); }); }
releaseWorker(worker) { this.workers.delete(worker); if (this.jobQueue.length > 0) { const next = this.jobQueue.shift(); // ... assign to worker } } }
module.exports = new ZKPService(); `
Mẫu 2: Phần mềm trung gian cho xác minh
Verification is fast (< 10ms). No worker needed.
`javascript // middleware/zkpVerify.js const { groth16 } = require('snarkjs'); const fs = require('fs');
const vKeys = new Map();
async function loadVKey(circuitName) { if (!vKeys.has(circuitName)) { const vKey = JSON.parse(fs.readFileSync(./build/${circuitName}_vkey.json)); vKeys.set(circuitName, vKey); } return vKeys.get(circuitName); }
function zkpVerify(circuitName) { return async (req, res, next) => { try { const { proof, publicSignals } = req.body; if (!proof || !publicSignals) { return res.status(400).json({ error: 'Missing proof or publicSignals' }); } const vKey = await loadVKey(circuitName); const isValid = await groth16.verify(vKey, publicSignals, proof); req.zkpResult = { valid: isValid, circuit: circuitName }; next(); } catch (error) { res.status(500).json({ error: 'Verification failed', detail: error.message }); } }; }
module.exports = { zkpVerify }; `
Pattern 3: Streaming Large Files
.zkey files can exceed 100MB. Load them once at startup, not per-request.
`javascript // services/zkpLoader.js const fs = require('fs').promises; const { groth16 } = require('snarkjs');
class ZKPLoader { constructor() { this.zkeys = new Map(); this.vkeys = new Map(); }
async preload(circuitNames) { for (const name of circuitNames) { // Stream load to avoid memory spikes const zkeyBuffer = await fs.readFile(./build/${name}_final.zkey); this.zkeys.set(name, zkeyBuffer); const vkey = JSON.parse(await fs.readFile(./build/${name}_vkey.json)); this.vkeys.set(name, vkey); } }
getZKey(circuitName) { return this.zkeys.get(circuitName); }
getVKey(circuitName) { return this.vkeys.get(circuitName); } } `
Circuit Example: Private Balance Check
A practical circuit that proves balance >= minimum without revealing balance. This pattern underpins private payments, credit scoring, and compliance checks.
Complete Circuit
`circom // circuits/private_balance.circom pragma circom 2.0.0;
include "circomlib/circuits/comparators.circom"; include "circomlib/circuits/poseidon.circom";
template PrivateBalanceCheck() { signal private input balance; signal private input minimum; signal private input salt; // Blinding factor signal output commitment; // Public commitment to balance signal output result; // 1 if balance >= minimum, else 0 signal output nullifier; // Prevents double-spend
// Commitment: poseidon(balance, salt) component hasher = Poseidon(2); hasher.inputs[0] <== balance; hasher.inputs[1] <== salt; commitment <== hasher.out;
// Range check: balance < 2^64 (prevents overflow) component rangeCheck = Num2Bits(64); rangeCheck.in <== balance;
// Comparison: balance >= minimum // LessThan returns 1 if in[0] < in[1], else 0 component cmp = LessThan(64); cmp.in[0] <== minimum; cmp.in[1] <== balance; // cmp.out = 1 if minimum < balance (i.e., balance >= minimum) result <== cmp.out;
// Nullifier: hash(commitment, secret) for nullifier set component nullifierHasher = Poseidon(2); nullifierHasher.inputs[0] <== commitment; nullifierHasher.inputs[1] <== salt; nullifier <== nullifierHasher.out; }
component main = PrivateBalanceCheck(); `
Deploying the Verifier to a Smart Contract
snarkjs exports a Solidity verifier:
`bash snarkjs zkey export solidityverifier balance_check_final.zkey Verifier.sol `
`solidity // Verifier.sol (auto-generated) // function verifyProof( // uint[2] memory a, // uint[2][2] memory b, // uint[2] memory c, // uint[] memory input // ) public view returns (bool) `
Deploy to Ethereum, Polygon, Arbitrum, or any EVM chain. The on-chain verifier costs ~200-400k gas for Groth16.
Danh sách kiểm tra tối ưu hóa hiệu suất
| Tối ưu hóa | Impact | Implementation |
|---|---|---|
| Reuse WASM module | 2-3x faster witness calc | Khóa lưu trữ đối tượng Module trong suốt các yêu cầu |
| Tính toán bằng chứng song song | Linear scaling | Worker pool for calculateWitness |
| Pre-load .zkey | Eliminates 100MB read/req | Tải khi khởi động, giữ trong bộ nhớ |
| Use PLONK for universal setup | Removes per-circuit ceremony | snarkjs plonk setup instead of groth16 |
| Batch verification | Amortize pairing costs | snarkjs doesn't support yet; use gnark/solidity batch |
| Constraint optimization | Smaller circuit = faster prove | Minimize constraints; use hints for non-linear ops |
Constraint Count Guidelines
| Phức tạp của circuit | Khá hạn | Browser Proving Time | Server Proving Time |
|---|---|---|---|
| Simple (hash, equality) | < 1,000 | 200-500ms | 10-20ms |
| Cao (bảo vệ phạm vi, merkle) | 1.000 - 10.000 | 2-5s | 50-200ms |
| Complex (private DEX, zkRollup) | 10,000 - 100,000 | 15-60 tuổi | 500ms - 3s |
| Very Complex | > 100,000 | Timeout likely | 3-10s |
Rule of thumb: If your circuit exceeds 20k constraints, do not use browser proving for primary UX. Use server proving with MPC or ZK-ML for the heavy parts.
Những lưu ý về bảo mật
1. Bảo mật cài đặt đáng tin cậy
- Never use a single-party setup in production
- Document the ceremony participants and transcripts
- Verify the beacon hash matches the published value
- Consider PLONK for universal setup (no per-circuit ceremony)
2. Input Validation
`javascript
// Always validate public inputs before verification
function validatePublicSignals(circuitName, publicSignals) {
const expectedLength = CIRCUIT_PUBLIC_INPUT_COUNTS[circuitName];
if (publicSignals.length !== expectedLength) {
throw new Error(Invalid public signal count: expected ${expectedLength}, got ${publicSignals.length});
}
// Check each signal is valid field element (bn128: < 2^254)
for (const signal of publicSignals) {
if (BigInt(signal) >= BigInt("21888242871839275222246405745257275088548364400416034343698204186575808495617")) {
throw new Error('Public signal exceeds field modulus');
}
}
}
`
3. Replay Protection
Include a nonce or nullifier in public signals. Track used nullifiers in a database or on-chain.`javascript // In circuit: nullifier = poseidon(commitment, secret) // In verifier: check nullifier not in used set const usedNullifiers = new Set(); // Or Redis, or on-chain mapping
async function verifyWithNullifierCheck(vKey, publicSignals, proof) { const nullifier = publicSignals[publicSignals.length - 1]; // Last signal if (usedNullifiers.has(nullifier)) { throw new Error('Nullifier already used - replay attack'); } const valid = await groth16.verify(vKey, publicSignals, proof); if (valid) usedNullifiers.add(nullifier); return valid; } `
4. Side-Channel Resistance
- Các cuộc tấn công về thời gian: Thời gian tạo ra chứng minh tương quan với các giá trị của người chứng kiến. Thêm các sự chậm trễ ngẫu nhiên trong quá trình sản xuất.
Bối cảnh Việt Nam: Ứng dụng ZKP trong lĩnh vực Fintech
Khung cảnh fintech của Việt Nam có vị trí độc đáo để áp dụng ZKP:
- MoMo, ZaloPay, VNPay: 50M+ users each. ZKP can enable private transaction history — prove spending patterns for credit scoring without revealing individual transactions.
- E-KYC regulations: Circular 23/2021/TT-NHNN requires identity verification. ZKP allows "selective disclosure" — prove "age ≥ 18" without revealing birthdate, name, or address.
- Cross-border payments: ZKP-based private bridges can prove reserves without revealing balances (crucial for stablecoin issuers operating in VN).
- DeFi on VN-regulated chains: Local regulators (SBV) piloting sandbox for blockchain. ZKP provides auditable privacy — regulators get compliance proofs, users get privacy.
FAQ
Q: Can I run snarkjs in React Native? A: Yes, via react-native-webview with WASM or Hermes bytecode compilation. Performance is ~2x slower than browser.
Q: What's the difference between Groth16 and PLONK in snarkjs? A: Groth16: per-circuit trusted setup, smaller proofs (~200 bytes), faster verification. PLONK: universal setup, larger proofs (~1KB), slightly slower verification. For web apps with many circuits, PLONK reduces operational burden.
Q: How do I update a circuit without re-running the trusted setup? A: You can't with Groth16. Any circuit change requires new setup. With PLONK, you only need a new setup if the constraint count exceeds the universal setup bound.
Q: Can I verify proofs in a smart contract without Solidity? A: Yes. Any language with bn128 pairing support: Rust (arkworks), Go (gnark), Python (py_ecc). The verification algorithm is public.
Câu hỏi: ZKP đã sẵn sàng cho các ứng dụng web sản xuất hiện nay chưa? A: Có, đối với các trường hợp sử dụng cụ thể. Đánh giá riêng, thông tin xác thực bí mật, mã thông báo bảo mật và bằng chứng tuân thủ đã sẵn sàng cho sản xuất. ZK-VM có mục đích chung (RISC Zero, SP1) vẫn đang phát triển cho việc triển khai trên web.
Tóm tắt
Running ZKP in Node.js and browsers is no longer experimental. The snarkjs + circom + WASM stack provides:
- Complete pipeline: Circuit → Compile → Setup → Prove → Verify
- Flexible deployment: Browser (privacy) or Server (performance) or Hybrid (MPC)
- Production patterns: Worker threads, streaming loads, Express middleware
- Smart contract integration: Auto-generated Solidity verifiers
- Vietnamese fintech fit: Selective disclosure for e-KYC, private transaction proofs for credit scoring
Start with a simple circuit (balance check, set membership). Measure proving time on target devices. Then scale complexity. The privacy guarantees are worth the engineering investment.
Further Reading
- [snarkjs GitHub](https://github.com/iden3/snarkjs) - Official library
- [circom Documentation](https://docs.circom.io/) - Circuit language
- [circomlib](https://github.com/iden3/circomlib) - Reusable circuit templates
- [ZKP Whitepaper Series](https://zkproof.org/) - Standards body
- [Vitalik's ZK-SNARK Explainer](https://vitalik.ca/general/2021/01/26/snarks.html) - Deep technical dive
