⚠️ Research / educational. Testnet only. Unaudited. Do not deploy to mainnet or use with real funds. This document is an honest account of what Obsidian protects, what it does not, and the assumptions it relies on.
A fixed-denomination ERC-20 shielded vault. A deposit posts commitment = Poseidon(nullifier, secret)
as a leaf in an on-chain Merkle tree. A withdrawal proves, in zero knowledge, that the commitment is
some leaf under a known recent root — revealing only the root and nullifierHash = Poseidon(nullifier),
never which leaf. This breaks the on-chain link between depositor and recipient.
Hidden
- The link between a specific deposit and a specific withdrawal.
- Which leaf / commitment a withdrawal corresponds to.
- The
secretandnullifier(never leave the client).
Anonymity set = the number of deposits of the same denomination that have not yet been withdrawn. It is only as large as the pool is used; a withdrawal from a near-empty pool is weakly private.
Leaked / out of scope
- Fixed denomination and the fact that a deposit/withdrawal occurred (and when) — timing analysis remains possible.
- The recipient address (public on withdrawal).
- If a user self-withdraws and pays gas from a funded address, that address may link them. Use a
relayer (Phase 2) to avoid this; the circuit/contract already bind
relayer+fee. - Network-level metadata (IP, RPC provider) — not addressed on-chain.
- Trusted setup (the big one). Groth16 needs a per-circuit trusted setup. The current key comes
from a single-contribution dev ceremony (
packages/circuits/run.zsh). If that contributor retained the toxic waste, they could forge proofs and drain the pool. A real multi-party ceremony (security holds if ≥1 contributor is honest) is Phase 3, to be run once the circuit is frozen. Acceptable for testnet; not for production. - Cryptography. Groth16 soundness on BN254, and Poseidon's collision/preimage resistance.
- On-chain hasher. The Poseidon hasher is deployed from circomlib-generated bytecode and is
immutable. A test (Poseidon equivalence) asserts it matches the circuit's Poseidon bit-for-bit; a mismatch would break root computation, not create a forgery vector.
- Double-spend:
nullifierHashesmapping; a nullifier can be spent once. - Proof binding:
recipient,relayer, andfeeare constrained inside the circuit (squared), so a relayer cannot rewrite them after proving (front-running / malleability resistance). - Root freshness: withdrawals must reference one of the last
ROOT_HISTORY_SIZE(30) roots, so honest withdrawals survive concurrent deposits without accepting arbitrary roots. - Field validation: commitments and tree inputs are range-checked against the BN254 field.
- Reentrancy:
ReentrancyGuardondeposit/withdraw, checks-effects-interactions ordering (state updated before transfers), andSafeERC20. Covered by a malicious-token reentrancy test. - Custom errors throughout for clear, gas-efficient reverts.
slither . runs clean (0 findings) with two detectors excluded in slither.config.json, both
reviewed and accepted:
calls-loop— the per-levelhasher.poseidon(...)is a deliberate external call to a trusted, immutable hasher; it is the standard Merkle-tree construction and cannot be inlined without a Solidity Poseidon implementation.naming-convention— leading-underscore parameters and theZERO_VALUEconstant are intentional, idiomatic style.
- Single-contribution dev trusted setup (→ Phase 3 multi-party ceremony).
- No deployed relayer yet (→ Phase 2; protocol support is in place).
- Mock
USDCtoken for local/testnet; not a real asset. - Unaudited. No formal verification.
This is a personal research project. Open an issue for anything you find.