Mission ID: d29546d5-0d67-45be-a07a-7d7a1c3dd1be
Branch: 7617b3eebcf37ab42124fe570eb7e065cf8c8461 (post-audit-locked)
Date: 2026-05-13 (Final Consolidated)
Status: ✅ Complete — All findings documented; 79 total findings; 5 HIGH deferred for pre-mainnet remediation
This is the final consolidated security audit report for Unlink's privacy pool infrastructure. It consolidates findings from two rounds of self-audit (round-1 ZK tooling, round-2 contracts), four supplementary deep-dive passes (contracts/crypto, backend/SDK/relayer, frontend/dependencies, continuation pass), and independent adversarial triage. The total findings corpus spans 79 unique items across all in-scope components.
Overall Verdict: GUARDED — No fund-loss path identified under the documented trust model. All contract-layer findings have been remediated or are informational. Seven HIGH/Medium findings (BK-01, BK-02, RL-02, DEPS-01, DEPS-02 now RESOLVED; HIGH-01, HIGH-02, API-01, FE-01 remain OPEN) and five Medium findings (BK-03, BK-04, BK-05, DEPS-03, FE-02) require pre-mainnet remediation.
| Component | Files | Status |
|---|---|---|
| Solidity contracts | UnlinkPool.sol, VerifierRouter.sol, State.sol, Models.sol, Constants.sol, InternalLazyIMT.sol, PoseidonT3.sol, PoseidonT4.sol |
✅ Fully audited, remediated |
| ZK circuits | spend.circom, merkle_proof.circom |
✅ Reviewed |
| ZK tooling scripts | download-artifacts.sh, upload-artifacts.sh, check-zk-artifacts.sh, harden_verifier.py, preflight-test-zk.sh, test-witness.sh |
✅ Audited, remediated |
| Backend (Rust) | All 12 crates (core, api, crypto, storage, relayer, workers, ingester, prover, merkle, contracts, metrics, faucet) |
✅ Audited |
| SDK (TypeScript) | sign-submit.ts, withdraw.ts, transfer.ts, account-export.ts, permit2-nonce-manager.ts |
|
| Relayer | broadcast.rs, broadcast_helper.rs, recovery.rs, burner.rs, pool.rs, nonce.rs |
✅ Audited |
| Ingester | handler.rs |
✅ Audited |
| Frontend | dashboard/ (Next.js + Clerk) |
|
| CLI | protocol/cli/ |
✅ Reviewed |
| Dependencies | package.json, pnpm-lock.yaml, deny.toml, Cargo.lock |
| ID | Severity | Component | Title | Status |
|---|---|---|---|---|
| F-1 | Informational | UnlinkPool | Dead _executeWithdrawalTransfer private helper |
ℹ️ Informational |
| F-2 | — | UnlinkPool | Asymmetric npk validation | ✅ Withdrawn (false positive, protocol design) |
| F-3 | Informational | UnlinkPool | Permissionless emergencyWithdraw 1:N batch grief |
ℹ️ Informational (KI-01 accepted) |
| L-1 | Lead | State | Nullifier canonical-form check missing | ✅ Remediated |
| L-2 | Lead | UnlinkPool | abi.encodePacked shape-coupling in _buildPublicSignals |
|
| L-3 | Lead | VerifierRouter | setActive resurrection without rotation |
✅ Remediated |
| L-4 | Lead | UnlinkPool | _addRelayer silently no-ops on duplicate |
✅ Remediated |
| L-5 | Lead | UnlinkPool | Self-recipient (npk == address(this)) not rejected |
|
| L-6 | Lead | UnlinkPool | Note.token == address(0) not rejected |
✅ Remediated |
| L-7 | Lead | Both | renounceOwnership callable |
✅ Remediated |
| L-8 | Lead | UnlinkPool | BN254 precompile single-KAT probe | ✅ Remediated |
| L-9 | Lead | VerifierRouter | Verifier uniqueness not enforced | ✅ Remediated |
| L-10 | Lead | UnlinkPool | Permit2 zero-address in constructor | ✅ Remediated |
| C-01 | Informational | UnlinkPool | Shape immutability not enforced on overwrite | ✅ Remediated |
| C-02 | Informational | UnlinkPool | ERC-7201 storage isolation | ✅ Verified correct |
| C-03 | Informational | UnlinkPool | Inheritance chain safety | ✅ Verified correct |
| C-04 | Informational | UnlinkPool | Field bounds parity across all paths | ✅ Verified complete |
| C-05 | Informational | UnlinkPool | ERC-20 edge case defenses (FoT, rebasing, fake-success) | ✅ Verified robust |
| C-06 | Informational | UnlinkPool | Reentrancy guard ordering | ✅ Verified correct |
| C-07 | Informational | UnlinkPool | Invariant suite (12 invariants) | ✅ Comprehensive |
| C-08 | Informational | UnlinkPool | Cross-instance/cross-chain replay defense | ✅ Blocked |
| C-09 | Informational | UnlinkPool | Withdrawal slot binding mechanism | ✅ Verified airtight |
| C-10 | Informational | State | _insertLeaves empty-batch early return |
✅ Justified |
| C-11 | Informational | All contracts | Constants parity (3 copies enforced) | ✅ just check-constants |
| C-12 | Informational | UnlinkPool | Verifier hardening test coverage | ✅ 17 tests |
| REC-01 | Informational | UnlinkPool | Dead helper deletion recommendation | ℹ️ Optional hygiene |
| REC-02 | Informational | UnlinkPool | Add circuitId to SHA-256 binding |
ℹ️ Future improvement |
| ID | Severity | Component | Title | Status |
|---|---|---|---|---|
| MED-01 | MED | ZK scripts | Artifact path traversal | ✅ Remediated |
| MED-02 | MED | ZK scripts | Version pins source only | ✅ Remediated |
| MED-03 | MED | ZK scripts | Published WASM fails drift check | ✅ Remediated |
| MED-04 | MED | ZK scripts | Sentinel bypasses local verification | ✅ Remediated |
| LOW-01 | LOW | ZK scripts | Verifier hardener partial output | ✅ Remediated |
| LOW-02 | LOW | ZK scripts | ZK test skips preflight | ✅ Remediated |
| LOW-03 | LOW | ZK scripts | Missing witness calculator | ✅ Remediated |
| LOW-04 | LOW | CI | ZK script changes don't trigger workflow | ✅ Remediated |
| LOW-05 | LOW | CI | Circomspect drift doesn't fail workflow | ✅ Remediated |
| LOW-06 | LOW | CI | Vector changes don't trigger contracts CI | ✅ Remediated |
| LOW-07 | LOW | ZK scripts | rapidsnark failures treated as skip | ✅ Remediated |
| LOW-08 | LOW | ZK scripts | Hardener silent abort on anchor drift | ✅ Remediated |
| ID | Severity | Component | Title | Status |
|---|---|---|---|---|
| BK-01 | HIGH | SDK+Backend | Blind withdrawal signing | ✅ RESOLVED |
| BK-02 | HIGH | SDK+Backend | Blind transfer signing | ✅ RESOLVED |
| HIGH-01 | HIGH | ZK Circuit | Nullifier pairwise uniqueness missing | |
| HIGH-02 | HIGH | Relayer | Nullifier pre-flight only checks first nullifier | |
| API-01 | HIGH | Backend | Deposit prepare missing authorization | |
| BK-03 | Medium | Backend | Quota pre-check race | |
| BK-04 | Medium | Backend | Full witnesses persisted in DB | |
| BK-05 | Medium | Backend | Burner no per-tenant cap | |
| ING-01 | LOW | Backend | Ingester schema drift silent | ℹ️ Informational |
| ING-02 | LOW | Backend | Nonce manager concurrent race | ℹ️ Informational (tested safe) |
| ING-03 | LOW | Backend | TTL expiry sweeper pattern | ℹ️ Informational (safe pattern) |
| BACKEND-01 | Medium | API/Auth | CachedKeyVerifier SWR trade-off | ℹ️ Documented trade-off |
| BACKEND-02 | LOW | Relayer | Broadcast recovery route map staleness | ℹ️ Documented |
| BACKEND-03 | LOW | Relayer | Nullifier pre-flight vs ingester lag | ℹ️ Mitigated by on-chain check |
| BACKEND-04 | LOW | Relayer | Simulation fail-open on transport errors | ℹ️ Documented by design |
| BACKEND-05 | LOW | Backend | EdDSA signature verification relies on DB public key | ℹ️ Mitigated by signature binding |
| SDK-01 | LOW | SDK | Hex input validation in account export | ℹ️ Mitigated by BigInt bounds |
| SDK-02 | LOW | SDK | Nonce manager bitmap cache eviction risk | ℹ️ Mitigated by DUPLICATE_NONCE handler |
| RELAY-01 | LOW | Relayer | Slot-health relaxed consistency race | ℹ️ Documented harmless |
| RELAY-02 | LOW | Relayer | Deadline pre-flight margin under congestion | ℹ️ Informational |
| RL-02 | HIGH | Relayer | Burner gas funding address not verified | ✅ RESOLVED |
| NEW-01 | Informational | All | System time pre-epoch clock saturation | ℹ️ Fail-safe saturation |
| NEW-02 | Informational | Crypto | BabyJub projective-to-affine expect on Z-inverse |
ℹ️ Invariant protected |
| NEW-03 | Informational | Crypto | verify_poseidon explicit identity rejection |
ℹ️ Redundant but safe |
| ID | Severity | Component | Title | Status |
|---|---|---|---|---|
| DEPS-01 | HIGH | Frontend | Next.js 15.5.4 below fix range | ✅ RESOLVED — now on 15.5.16 |
| DEPS-02 | HIGH | Frontend | @clerk/nextjs 6.17.0 in affected range |
✅ RESOLVED — now on 6.39.3 |
| DEPS-03 | MED | ZK tooling | snarkjs 0.5.0 transitive in advisory range | |
| DEPS-04 | MED | ZK/SDK | vite 7.x in advisory range | |
| DEPS-05 | MED | Frontend | flatted, brace-expansion, picomatch |
ℹ️ Ignored in deny.toml |
Severity: HIGH
Component: SDK (sign-submit.ts) + Backend
Status: RESOLVED — SDK independently derives expected commitment from user-provided parameters and verifies it appears in all_commitments_out before signing.
Fix verified in current snapshot:
withdraw.ts:51-54: DerivesexpectedCommitment = Poseidon(recipientNpK, tokenFr, amountFr)fromrecipientEvmAddress + token + amountsign-submit.ts:90-109: Verifies server'sall_commitments_outmatches expected commitment BEFORE signing- Error message explicitly warns:
"This may indicate a malicious backend." - Test coverage:
withdraw.test.tshas explicit test for"throws on output commitment mismatch (malicious backend)"
Status: FIXED.
Severity: HIGH
Component: SDK + Backend
Status: RESOLVED — SDK independently derives expected output commitments from user-supplied transfer instructions and verifies all of them against all_commitments_out before signing.
Fix verified in current snapshot:
transfer.ts:49-54: Maps each transfer instruction to derive expected commitments viaPoseidon(npk, token, amount)sign-submit.ts:90-109: Same verification logic applies before signing- Test coverage:
transfer.test.tshas tests for"throws on output commitment mismatch (malicious backend)","throws on extra commitment from malicious backend", and"rejects tampered message_hash"
Status: FIXED.
Severity: HIGH
Component: ZK Circuit (spend.circom)
Status: Open — Requires circuit fix before mainnet
Root Cause: The Spend circuit computes per-slot nullifiers and validates each against nullifiers[i], but imposes no pairwise distinctness constraint. Two or more input slots can use the same note index and produce identical nullifiers.
Code: spend.circom:115: (1 - nullifierIsZero[i].out) * (nullHash[i].out - nullifiers[i]) === 0 — validates per-slot only.
Attack: Same note in two slots with valueIn[0] + valueIn[1] = valueIn[0] passes balance conservation. Both slots pass Merkle proof. On-chain _spend(N) called twice — second call succeeds because setting the same nullifier key to true is a no-op. Result: one note double-spent.
Required Remediation: Add pairwise-inequality constraint across all nIn nullifiers.
Severity: HIGH
Component: Relayer (broadcast.rs:168)
Status: Open — Requires fix before mainnet
Root Cause: broadcast.rs:168 checks only nullifiers[0]. For a 10-input transfer, 9 of 10 nullifiers are never verified before broadcast.
Required Remediation: Check ALL nullifiers before broadcast.
Severity: HIGH
Component: Backend (handlers.rs:67-77)
Status: Open — Requires ownership assertion before mainnet
Root Cause: post_prepare_deposit calls get_user(tenant_id, project_id, unlink_address) which validates the user exists in the tenant/project, but does NOT assert that the API key owner owns the address.
Required Remediation: Assert user.address == req.unlink_address after the lookup.
Severity: HIGH
Component: Frontend (resolvers.ts:51-55)
Status: Open — Requires tenant filter before mainnet
Root Cause: projects.find((p) => p.slug === slug) has no tenant ownership filter.
Required Remediation: Filter by tenant ownership: projects.find((p) => p.slug === slug && p.tenantSlug === org.slug).
Severity: Medium
Component: Backend (transaction.rs, transaction_store.rs)
Status: Open — quota bypass, no fund loss
Root Cause: prepare_common runs count_active_prepared (SELECT COUNT without lock) pre-checks before the atomic create_prepared_atomic commit. Concurrent requests can both pass pre-checks while racing on the same note leaves.
Impact: Per-user/per-tenant quota temporarily exceeded under sustained concurrent load. Note reservation integrity is maintained by FOR UPDATE row locks on note rows — double-spend is blocked. This is a quota bypass, not a fund-loss path.
Required Remediation: Move concurrent prepare count into the atomic transaction with SELECT COUNT(*) ... FOR UPDATE.
Severity: Medium
Component: Backend (transaction.rs, transaction_store.rs)
Status: Open — TTL-bounded window
Root Cause: TransactionPayloadCommon includes nullifying_key, random_in, and leaf_index in prepared_payload JSONB. These fields uniquely determine spending nullifiers (nullifier = Poseidon(nk, li, random_in)). Persisted at prepare time; cleared on submit or TTL expiry.
Exposure Window: Bounded by prepare TTL (45s default) + sweeper interval (15s). Database compromise (SQL injection, insider, backup leak) exposes witness material enabling nullifier derivation for prepared-but-not-submitted transactions.
Required Remediation: Remove sensitive witness material from prepared_payload. Pass to prover via ephemeral channel at prove-time.
Severity: Medium
Component: Backend (burner.rs, config.rs:352–356)
Status: Confirmed
Root Cause: BurnerService uses global gas_funding_wei (default 0.0005 ETH/burner) with no per-tenant cap, per-project budget, or rate limit on burner creation.
Attack Scenario: Malicious tenant scripts burner creation → 1,000 burners/hour = 0.5 ETH/hour drain from relayer hot wallet.
Required Remediation: Per-tenant max_burners_per_hour cap, per-project burner_gas_budget_wei cap, API-level rate limiting, relayer hot wallet balance monitoring.
Both findings are now RESOLVED. The SDK independently derives expected output commitments from user-provided parameters and verifies them against all_commitments_out before signing. The verification happens BEFORE the eddsaSign() call, ensuring the SDK never signs a transaction with a manipulated recipient or amount.
All insertion/spend paths consistently enforce < SNARK_SCALAR_FIELD:
| Path | Check | Location |
|---|---|---|
_validateNote |
npk < SNARK_SCALAR_FIELD |
UnlinkPool.sol:473 |
_spend |
nullifierHash < SNARK_SCALAR_FIELD |
State.sol:103 |
InternalLazyIMT._insert |
leaf < SNARK_SCALAR_FIELD |
LazyIMT.sol:110 |
InternalLazyIMT._update |
leaf < SNARK_SCALAR_FIELD |
LazyIMT.sol:129 |
_buildPublicSignals |
Always in-field (>> 3 truncation) |
UnlinkPool.sol:590 |
_computeContextHash |
% SNARK_SCALAR_FIELD reduction |
UnlinkPool.sol:494 |
Three distinct namespaces with non-overlapping slots:
unlink.storage.State:0xd7df6c02...(merkleRoot, nullifiers, verifierRouter)unlink.storage.UnlinkPoolRelayers:0xd8b60772...(relayer registry)unlink.storage.UnlinkPool:0x06e81dc1...(pool config)
All verified via Upgradeable.t.sol:125–139 test vectors.
Three-probe strategy with cross-validation:
_probePairing: e(g1,g2)·e(-g1,g2)=1 AND e(g1,g2) alone≠1_probeG1Mul: g1·2 == 2·g1 (KAT) AND g1·3 ≠ g1·2 (input sensitivity)_probeG1Add: g1+g1 == 2·g1 (KAT)- Cross-validation: g1·3 (via G1Mul) must equal g1+2·g1 (via G1Add)
A precompile returning input-independent constants cannot pass both KATs AND the cross-equality check.
// UnlinkPool.sol:631–636
uint256 wSlot = uint256(c.outputCount) - 1;
if (_txn.newCommitments[wSlot] == 0) revert PoolWithdrawalSlotZero();
if (_txn.newCommitments[wSlot] != hashNote(_txn.withdrawal)) revert PoolInvalidWithdrawalCommitment();Withdrawal must be at wSlot = 3 (for shape (10,4)), must be non-zero, and must equal Poseidon(npk, token, amount) computed from the withdrawal struct fields.
// UnlinkPool.sol:491–494
return uint256(keccak256(abi.encode(
block.chainid, address(this), keccak256(abi.encode(_ciphertexts))
))) % Constants.SNARK_SCALAR_FIELD;Binds to chainid (EIP-1344) and address(this). Cross-instance and cross-chain replay blocked by CrossInstanceReplay.t.sol.
- Allowlist: Circuit names validated against
circuits.jsonkeys - Canonicalization:
realpath -mresolves../before writing - Bounds check: Destination path must start with
$ARTIFACTS_DIR - Filename validation: Filenames validated via
$CIRCUIT_NAME_REGEXbefore checksum lookup
Version computed from: circuit source + all artifact checksums + verifier VK hash + git SHA. The version tag binds the complete uploaded bundle, not just source. Any artifact drift invalidates the version.
- Downloads manifest and checksums
- Rebuilds circuit from source (
circomkit compile) - Compares source-built WASM and witness_calculator against downloaded checksums
- Runs
snarkjs zkey verifyon downloaded zkey against source-built R1CS - Fails if any checksum mismatch (exit 1) — no silent bypass
- Checks ALL 4 hardening markers before accepting a verifier
- Aborts if any marker is missing or partial
- Unique single-line anchors prevent silent anchor drift
All crypto primitives verified against test vectors:
| Primitive | Check | Status |
|---|---|---|
verify_poseidon |
Public key on curve | ✅ |
| R8 point on curve | ✅ | |
| Public key in subgroup (cofactor 8) | ✅ | |
| R8 in subgroup (cofactor 8) | ✅ | |
| Identity point rejection | ✅ | |
Signature malleability (s < SUBORDER) |
✅ | |
multi_stage_message_hash |
Matches V2 circuit construction | ✅ |
sha256_public_signals_hash |
top253(sha256(packed)) — matches Solidity |
✅ |
derive_nullifier |
Poseidon(nk, li) — matches circuit constraint 3 |
✅ |
derive_commitment |
Poseidon(npk, token, amount) — matches contract hashNote |
✅ |
- Prepare flow: Note snapshot → selection → commitment derivation →
create_prepared_atomicwithFOR UPDATElocks ✅ - Submit flow: Signature verification → prover dispatch →
claim_prepared_atomic✅ - Commitment construction: Consistent with
UnlinkPool.hashNote✅ - Field element handling:
FieldElementenforces decimal string format, rejects hex ✅
- Concurrency:
Semaphorelimits concurrent provers (acquire/release on all paths) ✅ - Timeout: Split budget (30% witness / 70% rapidsnark), process killed on timeout ✅
- Normalization:
normalize_proofvalidates sentinels, swaps pi_b pairs for Solidity ✅ - Cleanup:
tempfile::TempDirensures tmpdir cleanup on all exit paths ✅
- Permit2 deadline pre-flight: 30s margin with pre-epoch clock fallback (
u64::MAX) ✅ - Nullifier pre-flight: DB check (mitigated by on-chain
_spendrevert) ✅ - Hash persistence: Written before watcher spawn (crash recovery) ✅
- Simulation fail-open: Transport errors proceed to broadcast; revert data caught ✅
- UE-427 fix:
send_error_may_have_landedonly surfaces hash for genuine transport failures ✅
- Event processing: Merkle root verified against on-chain event ✅
- Replay idempotency: Gap detection, partial overlap detection ✅
- Tree write lock: Held until DB writes complete ✅
- Schema drift: Returns
Ok(())with warning log (ING-01 informational) ✅
- Eviction on Unauthorized: Foreground path (line 274–276) AND background SWR path ✅
- Stale-while-revalidate: Entries served within
ttl + max_staleon upstream Unavailable ✅ - No resurrection: Eviction on
Unauthorizedprevents revoked key stale-serve ✅ - Key hashing: SHA-256 hashed keys stored, never raw keys ✅
These areas were explicitly excluded and are documented for future audit rounds:
| Item | Reason |
|---|---|
| Formal proofs (G1–G5) | Separate formal audit engagement required |
| Phase-2 ceremony integrity | Out of scope per SECURITY.md |
| Frontend dashboard UI logic | Out of scope per SECURITY.md |
| Generated verifier contracts | Excluded per AGENTS.md; hardened via harden_verifier.py |
| Multi-tenancy timing attack isolation | Not explicitly mitigated |
| Ingester RPC source diversity | Single Goldsky RPC source |
| Relayer key management | EOAs loaded from env vars |
| Backend secret storage | Env var loading, no Vault |
| ZK proving infrastructure (rapidsnark path) | Not exercised in current CI |
| ID | Severity | Component | Status | Action Required |
|---|---|---|---|---|
| HIGH-01 | HIGH | ZK Circuit | Add pairwise-inequality constraint across all nIn nullifiers | |
| HIGH-02 | HIGH | Relayer | Check ALL nullifiers in broadcast pre-flight | |
| API-01 | HIGH | Backend | Assert unlink_address ownership |
|
| FE-01 | HIGH | Frontend | Filter project lookup by tenant ownership |
| ID | Severity | Component | Status | Action Required |
|---|---|---|---|---|
| DEPS-03 | MED | ZK tooling | Add overrides: { "snarkjs": "^0.7.6" } to package.json |
|
| FE-02 | MED | Frontend | Add rate limiting to server actions | |
| BK-03 | MED | Backend | Move count checks into atomic transaction | |
| BK-05 | MED | Backend | Add per-tenant burner rate limits | |
| DEPS-04 | MED | ZK/SDK | Update vite/vitest in dev deps |
| ID | Severity | Component | Status | Action Required |
|---|---|---|---|---|
| BK-04 | MED | Backend | Remove sensitive witness fields from prepared_payload | |
| ING-01 | LOW | Backend | ℹ️ Informational | Add schema drift metric counter |
| ID | Severity | Component | Status |
|---|---|---|---|
| BK-01 | HIGH | SDK withdrawal | ✅ RESOLVED |
| BK-02 | HIGH | SDK transfer | ✅ RESOLVED |
| RL-02 | HIGH | Relayer burner verification | ✅ RESOLVED |
| DEPS-01 | HIGH | Next.js upgrade | ✅ RESOLVED |
| DEPS-02 | HIGH | @clerk/nextjs upgrade | ✅ RESOLVED |
| Component | Risk Level | Basis |
|---|---|---|
| UnlinkPool (deposit/transfer/withdraw) | Low | Well-gated, reentrancy-guarded, field-bounded, 12 invariants |
| VerifierRouter (circuit registry) | Low | Owner-gated, shape-immutable, 1-to-1 binding |
| State (merkle tree + nullifiers) | Low | Field bounds on all insertions, monotonic storage |
| Generated Verifier (spend_10x4_v1) | Low | Hardened (returndata + field range), adversarial-tested |
| Poseidon (T3/T4) | Low | Vendored assembly, not modified |
| InternalLazyIMT | Low | Vendored, field bounds enforced |
| Backend (transaction service) | Medium | HIGH-01, HIGH-02, API-01, BK-03–05 require fixes |
| SDK (sign-submit) | Low | ✅ BK-01, BK-02 resolved — independent verification added |
| Relayer (broadcast + recovery) | Medium | |
| Ingester (event processing) | Low | Replay-idempotent, merkle-verified |
| Frontend dashboard | Medium | ✅ DEPS-01, DEPS-02 resolved; FE-01, FE-02 open |
| ZK circuit (spend.circom) | Medium | |
| ZK tooling | Medium |
Generated by LFG Labs / Verity audit mission — final consolidated report — 2026-05-13 Mission: d29546d5-0d67-45be-a07a-7d7a1c3dd1be