Skip to content

Security: MirageSystems/EclipseCash

Security

SECURITY.md

Eclipse: Security Notes & Threat Model

Status: hackathon research project. Not audited. Do not use with real funds. The Groth16 trusted setup is a local (single-party) ceremony, not an MPC ceremony, so the proving key must be treated as compromised for any real deployment.

What the cryptography guarantees

  • Withdrawals require a valid Groth16 proof. The proof attests, in zero knowledge, knowledge of (nullifier, secret) whose commitment = Poseidon(nullifier, secret) is a leaf in the on-chain Merkle tree, without revealing which leaf. Verified on-chain with Stellar's native BN254 pairing host functions against a baked-in verifying key.
  • On-chain tree == circuit tree. The contract hashes the Merkle tree with Stellar's native Poseidon host function using circomlib's exact t=3 parameters. This is proven byte-identical to withdraw.circom (poseidon(1,2) test vector, and the on-chain root after a real deposit matches the root the proof was generated against, verified on devnet).
  • Recipient / relayer / fee are bound into the proof (squaring constraint). A relayer that submits someone else's proof cannot redirect funds or change amounts; the contract recomputes the bound field elements from its own arguments and the proof fails otherwise (test: wrong_recipient_rejected).
  • Double-spend prevention. Each withdrawal reveals a one-time nullifierHash; the contract records it and rejects reuse (test: double_spend_rejected).

Threats considered

Threat Mitigation Test
Reuse of a spent note global nullifier-hash set double_spend_rejected
Proof against a stale/forged root recent-root ring buffer (30); unknown roots rejected unknown_root_rejected
Relayer redirecting funds recipient/relayer/fee bound in proof wrong_recipient_rejected
Relayer over-charging fee <= denomination enforced fee_above_denomination_rejected
Duplicate commitment per-commitment dedup before insert duplicate_commitment_rejected
Forged / tampered proof on-chain Groth16 pairing check groth16_rejects_tampered_public_input
Unauthorized deposit from.require_auth() deposit_requires_real_auth
Unauthorized freeze/admin admin.require_auth(); admin handover is two-step (transfer_admin proposes, accept_admin confirms) so a typo cannot brick admin two_step_admin_handover, accept_admin_without_pending_rejected
Front-run of init init runs as __constructor, atomic with deploy (no separate initialize to race) constructor_rejects_zero_denomination
Self-grief via note bloat per-depositor note-backup vector capped at MAX_NOTES notes_cap_enforced
Reentrancy effects-before-interactions: nullifier marked spent before payout; SAC token has no attacker callback n/a
Integer overflow overflow-checks = true; payout = denomination - fee with fee <= denomination n/a
State expiry breaking the tree / enabling double-spend Merkle tree state (zeros, filled-subtrees, root ring) kept in instance storage (bumped together); nullifier/commitment/notes/frozen persistent entries TTL-bumped on write n/a
Public input ≥ field modulus address fields are sha256 with the top byte zeroed (< 2^248 < r); amounts are small n/a
Nullifier malleability (nh + r replay) the host reduces scalars mod r, so the contract requires nullifier_hash to be the canonical representative (< r) before the spent/frozen lookups, making the field element and the storage key 1:1 non_canonical_nullifier_rejected

Compliance control (honest framing)

Compliance is a contract-layer freeze list keyed by nullifierHash, the only deposit identifier revealed at withdrawal time. The admin can freeze a flagged note (set_frozen); a frozen note cannot withdraw (frozen_note_rejected).

A commitment blocklist is deliberately not used: it would be unenforceable, since withdrawals never reveal the commitment. A circuit-level Association Set (Privacy Pools / Buterin et al.) is the stronger, decentralizable design and is tracked as future work, not in v1.

Residual risks / known limitations

  1. Local trusted setup. Single-party powers-of-tau + phase2. A real deployment needs a multi-party ceremony; otherwise forged proofs are possible.
  2. Centralized admin. A single admin key controls the freeze list. Handover is now two-step (propose/accept) to prevent bricking, but there is no timelock or multisig yet.
  3. Root history depth = 30. If more than 30 deposits land between proof generation and withdrawal, the proof's root ages out and the user must re-prove against a current root. Mitigated by client retry; a larger buffer trades storage for resilience.
  4. Nullifier persistence. Spent nullifiers live in persistent storage with a generous TTL bump. For a long-lived pool they must never be evicted (eviction could allow replay); production needs a TTL-maintenance/restore job.
  5. Fixed denomination, single asset. By design for a strong anonymity set.
  6. Wallet-derived backup key depends on stable message signing. The on-chain note backup is encrypted with a key derived from the wallet's signature over a fixed message. If the wallet changes how it signs/encodes that message (a version-to-version risk), the derived key changes and old backups become undecryptable. The user's shown/exported note string is the primary backup; the on-chain copy is a convenience. A KDF version tag + a non-wallet fallback are future hardening.

Deployment status

Built against soroban-sdk 27.0.0-rc.1 (wasm targets protocol 27) and deployed + fully exercised on Stellar testnet (protocol 27, stellar-core 27.0.0):

  • Contract CBRICYFV2KXEN2Q224NUULGNKRUWVXBP2SEVHIEXXPHQVH7327TBUCTS, fixed denomination 1 USDC (10_000_000 stroops at 7 decimals).
  • __constructor builds the empty tree (20 on-chain Poseidon hashes) atomically at deploy. deposit (on-chain Poseidon Merkle insert; root matches the circuit exactly: 09e718aa…) and withdraw settled live: the Groth16 proof verified on-chain via BN254 pairing and paid 9999000 (0.9999 USDC) to the recipient and 1000 (0.0001 USDC) fee to the relayer, 1 USDC total; the nullifier is marked spent and a replay is rejected (AlreadySpent).

Host-version note (resolved)

An earlier local quickstart:future image ran mismatched env-host versions (soroban-rpc preflight on env-host 26.0.1, stellar-core apply on 26.1.0). The 26.1.0 apply host spuriously rejected valid BN254 points that both the 26.0.1 preflight host and the 27.0.0 test/testnet host accept, a regression in the preview BN254 path (CAP-0074), not a contract defect. Running on a consistent protocol-27 host (testnet / quickstart:testing) eliminates the skew: the proof both verifies and settles.

There aren't any published security advisories