Every technology choice in ShieldLend V2A was made deliberately. This document explains the reasoning behind each decision and what was evaluated and rejected.
What it is: An EVM Layer 2 on Ethereum. Full Solidity compatibility, low gas fees, and the same toolchain as Ethereum mainnet.
Why Base for V2A:
- EVM-compatible: all Solidity contracts, Foundry tests, and wagmi frontend work unchanged
- Low gas fees — epoch flush with dummy insertion and shuffling would be impractical on Ethereum L1
- Fast finality (~2s block time) — allows the 50-block epoch wait (~100s) to be reasonable UX
- Base Sepolia testnet has reliable public RPC endpoints and a well-stocked faucet
- BaseScan block explorer works out of the box for debugging
What was evaluated and rejected:
- Ethereum L1: proof verification gas (~500k) makes withdrawals cost $30+ per tx even with zkVerify
- Polygon zkEVM/Scroll: not purpose-built for privacy DeFi; more complex bridge setup for zkVerify
- Aztec: would require rewriting all circuits in Noir and deploying on Aztec infrastructure
What it is: A modular, chain-agnostic proof verification layer. Accepts Groth16 proofs via SDK and emits on-chain attestations that any EVM contract can verify.
Why zkVerify:
Cost: On-chain Groth16 verification costs 250-500k gas on Ethereum L1 ($5-30 per proof at moderate gas). zkVerify moves verification off-chain — reducing per-user verification cost by ~91%. On Base L2 the direct gas cost is lower, but zkVerify adds proof aggregation infrastructure that scales to multi-proof batching without changing the on-chain interface.
Decoupled verification: Proof verification is handled by a dedicated verifier network, not bundled into the application contract. This means circuit upgrades (new ptau, new vkey) don't require redeploying ShieldedPool — only the vkey registered with zkVerify changes.
Modularity: The same proof submission pipeline works on any EVM chain. No chain-specific changes needed to the withdrawal logic.
Single-leaf aggregation: V2A uses a single-leaf aggregation pattern — each proof is its own 1-leaf aggregation tree. This simplifies integration while remaining compatible with multi-proof batching in production.
What was evaluated:
- On-chain Groth16 Solidity verifier (generated by snarkjs): works but ties verification to a specific chain; acceptable on L2 but chains the verifier to a deployment
- PLONK with universal setup: no per-circuit ceremony, but larger proofs (~700 bytes vs 192 bytes) and slower verification
What it is: Circom is a DSL for ZK-SNARK arithmetic circuits. circomlib is the standard template library.
Why Circom:
- Most mature ZK toolchain for the EVM ecosystem — largest community, most production deployments
- snarkjs compiles Circom circuits to WebAssembly — enables browser-side proof generation with no server trust assumption
- circomlib provides: Poseidon hash, MerkleTreeChecker, GreaterEqThan comparators, MultiMux1 — all used in V2A circuits
- Tornado Cash (the architectural inspiration) was written in Circom — circuits are a direct reference
What was evaluated and rejected:
- Noir (Aztec): different language, requires Aztec infrastructure
- Halo2 (zcash): Rust-based, no snarkjs/WASM compilation, no browser proving
- Leo (Aleo): Aleo-specific, not EVM-compatible
What it is: Non-interactive ZK proof system producing 3 elliptic curve points (192 bytes).
Why Groth16:
- Smallest proof size of any practical SNARK (192 bytes) — minimizes calldata and zkVerify submission cost
- Fastest verification — 3 pairing operations regardless of circuit size
- snarkjs has first-class Groth16 support and compiles to WASM
- zkVerify supports Groth16 natively
Trade-off: Groth16 requires a per-circuit trusted setup (Powers of Tau + circuit-specific contribution). PLONK uses a universal setup but produces larger proofs. For V2A, Groth16 proof size and verification speed outweigh the setup overhead.
This is the core V2A innovation over V1.
V1 used standard Merkle membership proofs (same as Tornado Cash). Problem: with a sparse pool, the anonymity set is trivially small — timing correlation attacks work even with valid ZK proofs.
V2A ring approach:
- Prover selects K=16 commitments from the last 30 epoch flushes
- These include real deposits AND protocol-inserted dummy commitments
- The proof shows the prover's note is one of the 16, without revealing which
- With 10 dummies/epoch × 30 epochs = 300 minimum anonymity set at launch
- ring_index (which position is the prover's) is a private circuit input
Result: anonymity guarantees hold even at protocol launch with zero real users.
V1 stored notes as plaintext in localStorage. V2A encrypts every note before storage.
Key derivation:
- User signs a fixed message with MetaMask: "ShieldLend: Sign to derive vault key"
- The 65-byte ECDSA signature is the key material
- HKDF(keccak256("ShieldLend note key") as salt, signature as IKM) -> 32-byte AES key
Why this approach:
- Key is deterministic — user can recover all notes on any device by signing the same message
- Key is wallet-bound — only the wallet owner can decrypt
- Key never touches a server — derived entirely client-side
- AES-256-GCM provides authenticated encryption — prevents silent tampering
This is the temporal privacy layer — it breaks timing correlation between deposit and withdrawal.
How it works:
- All deposits go into pendingCommitments[] queue (not immediately in the Merkle tree)
- Every 50 blocks (~100s on Base Sepolia), flushEpoch() is called
- The batch is shuffled using block.prevrandao (unmanipulable by miners post-merge)
- Adaptive dummy commitments are inserted alongside real ones (2/5/10 dummies based on batch size)
- All commitments (real + dummy) are inserted into the Merkle tree together
Why this matters: without batching, a deposit at block N followed by a withdrawal at block N+50 is easily identifiable even with valid ZK proofs — there are only a handful of deposits in that range. Batching creates an indistinguishable group.
Why Foundry over Hardhat:
- Significantly faster test execution (Rust vs Node.js)
- forge test --match-test for targeted runs during development
- forge script for reproducible deployments with full type safety
- 60+ tests passing in contracts/test/, including SecurityAudit.t.sol regression suite
Vault-strategy separation: ShieldedPool is the sole ETH custodian. LendingPool is purely accounting. This limits the blast radius of any LendingPool bug to the loan accounting state — no direct ETH drain path.
Why Next.js:
- API routes for server-side zkVerify SDK calls (requires DEPLOYER_PRIVATE_KEY — never exposed to browser)
- snarkjs WASM loads cleanly with next/dynamic and ssr:false
- Static export possible for IPFS deployment
Why wagmi + viem:
- Type-safe contract interactions (parseAbi, readContract, writeContractAsync)
- usePublicClient for RPC access (getLogs, getBlockNumber, waitForTransactionReceipt)
- Wallet connection + chain switching built-in
Why browser-side proof generation: The alternative (server-side) would require sending secret + nullifier to a server. If that server is compromised, all user funds can be stolen. WASM keeps private inputs on the user's device. The ~25s proving time is the cost of this security guarantee.
| Decision | Choice | Key Reason |
|---|---|---|
| Chain | Base Sepolia | EVM-native, low gas for epoch flush + dummy insertion |
| Proof verification | zkVerify Volta | 91% cheaper; modular; single-leaf aggregation pattern |
| Circuit language | Circom 2.1.6 | Most mature EVM ZK toolchain; WASM compilation; circomlib |
| Proof system | Groth16 | Smallest proof (192 bytes); fastest verification; snarkjs |
| Circuit design | Ring proofs K=16 | User-base-independent anonymity (300+ set at launch) |
| Temporal privacy | Epoch batching 50 blocks | Breaks timing correlation; prevrandao shuffle |
| Note storage | AES-256-GCM + HKDF | Encrypted, wallet-bound, cross-device recoverable |
| Contracts | Solidity + Foundry | EVM standard; fastest test runner; vault-strategy separation |
| Frontend | Next.js + wagmi + viem | SSR; API routes for server secrets; type-safe contracts |
| Proof generation | Browser WASM | Private inputs never leave user's device |