Smart Contract Design
Contract Overview
Main Responsibilities
- Accept deposits (ETH and ERC-20)
- Store the current valid Merkle Root
- Process withdrawals via valid Merkle Proofs and Admin Signatures
- Track nullifiers to prevent replay attacks
Supported Actions
deposit: Publicly callablewithdraw: Executed via proofsupdateRoot: Called by the shielded sequencer
Key Concepts
- Encrypted commitments: Data structures representing user balances
- Merkle roots: The 32-byte hash representing the state of all commitments
- Nullifiers / spent markers: Unique hashes generated when a commitment is spent, stored on-chain to prevent double-spending
How Nullifiers Work
Purpose
Nullifiers are used to prevent replay attacks during withdrawals. Once a withdrawal is executed, its corresponding nullifier is marked as spent on-chain.
Nullifier Generation
The nullifier is deterministically generated based on the current Merkle root and the specific leaf details:
keccak256(abi.encode(root, user, token, balance))Preventing Double-Use
The smart contract stores spent nullifiers. Any attempt to submit a proof with an already spent nullifier will revert the transaction.
Private Deposit Flow
- User action: User deposits 100 USDC via the frontend
- Commitment creation: Frontend generates an encrypted commitment for 100 USDC
- Encryption step: Data is secured
- Contract update: The user sends a transaction to
VeilNet.soldepositing the funds - Merkle tree insertion: The state sequencer detects the deposit and adds the new commitment to the Merkle tree
- User-side note/receipt: The user's dashboard reflects the new shielded balance
Private Transfer Flow
- Sender input: User initiates a transfer to a recipient's shielded address
- Recipient shielded address: The frontend prepares the secure payload for the recipient
- Balance update: The sequencer validates the sender's balance and processes the transfer
- Nullifier/spend protection: The sender's old commitment is marked as spent internally
- Merkle root update: The sequencer generates new commitments for both sender (change) and recipient, updating the Merkle tree
- Output commitment creation: The new state is finalized
Private Withdrawal Flow
- User claim: User requests to withdraw 50 USDC to a specific public address
- Verification step: The proving layer verifies the user's balance and generates a Merkle Proof
- Spend/nullifier check: The proving network ensures the funds haven't been spent
- Output release: An admin transaction is submitted to
VeilNet.solwith the Proof and an EIP-712WithdrawAuthsignature. The contract verifies the proof against the current Root, checks the nullifier, and releases the funds - Privacy considerations: While the withdrawal amount and destination are public, the source of the funds (the user's internal Veilnet balance) remains hidden
Failure Cases and Protections
- Invalid signatures: Rejected by the sequencer or contract
- Reused commitments: Rejected; old commitments cannot be spent twice
- Double-spend attempts: Prevented by the on-chain and stateless nullifier checks
- Wrong Merkle root: On-chain transactions will revert if the provided proof doesn't match the stored Root
- Incorrect encrypted payload: Cannot be decrypted or verified; transaction fails safely
- Replay protection: Enforced via EIP-712 nonces and nullifiers
