Smart Contractsintermediatearticle35 min read

ZKP trong Node.js: Chạy Prover và Verifier trong Web App | VCC

Hướng dẫn triển khai Zero-Knowledge Proof trong Node.js và trình duyệt: từ viết circuit circom, setup tin cậy, sinh chứng minh, đến tích hợp Express.js. Bao gồm so sánh prover trình duyệt vs server, pattern production, và ứng dụng fintech Việt Nam.

Vietcardano teamTeam

Đội ngũ biên tập VietCardanoCommunity — các chuyên gia blockchain và Cardano, cung cấp nội dung giáo dục và nghiên cứu chuyên sâu cho cộng đồng.

Published 31 tháng 8, 2026Updated 30 tháng 8, 2026

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ọ.

Radial Map - Zero-Knowledge Proof Flow: Circuit → Compile → Setup → Prove → Verify

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"

snarkjs groth16 setup balance_check.r1cs pot12_final.ptau balance_check_0000.zkey snarkjs zkey contribute balance_check_0000.zkey balance_check_0001.zkey --name="Circuit Setup" -v snarkjs zkey beacon balance_check_0001.zkey balance_check_final.zkey 0102030405060708090a0b0c0d0e0f 10 -n="Final Beacon" snarkjs zkey export verificationkey balance_check_final.zkey verification_key.json `
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.
Flowchart - snarkjs Pipeline: circom → R1CS → Powers of Tau → Witness → Proof → Verify

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.

Comparison - Browser Prover vs Server Prover: Privacy vs Performance Trade-offs
DimensionBrowser 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.

Sequence - Express.js ZKP Integration: Client → API → Worker → snarkjs → Response

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.

Circuit Diagram - Private Balance Check: Proves balance ≥ minimum without revealing balance

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óaImpactImplementation
Reuse WASM module2-3x faster witness calcKhó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 songLinear scalingWorker pool for calculateWitness
Pre-load .zkeyEliminates 100MB read/reqTải khi khởi động, giữ trong bộ nhớ
Use PLONK for universal setupRemoves per-circuit ceremonysnarkjs plonk setup instead of groth16
Batch verificationAmortize pairing costssnarkjs doesn't support yet; use gnark/solidity batch
Constraint optimizationSmaller circuit = faster proveMinimize constraints; use hints for non-linear ops

Constraint Count Guidelines

Phức tạp của circuitKhá hạnBrowser Proving TimeServer Proving Time
Simple (hash, equality)< 1,000200-500ms10-20ms
Cao (bảo vệ phạm vi, merkle)1.000 - 10.0002-5s50-200ms
Complex (private DEX, zkRollup)10,000 - 100,00015-60 tuổi500ms - 3s
Very Complex> 100,000Timeout likely3-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:

  1. Complete pipeline: Circuit → Compile → Setup → Prove → Verify
  2. Flexible deployment: Browser (privacy) or Server (performance) or Hybrid (MPC)
  3. Production patterns: Worker threads, streaming loads, Express middleware
  4. Smart contract integration: Auto-generated Solidity verifiers
  5. 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

Tags:

zk trong nodejssnarkjs verifier webzk javascript