Skip to Content
SecurityAudit Reports05 — Session audit (cleanup pass)

Session audit — 2026-05-02

A full-surface security review run by 6 parallel auditors over the working tree right before the second devnet deployment. Each finding lists severity, the file/line where it lives, why it matters, and the action taken. “Fixed in this pass” entries point to the commit that closes the gap; “Documented” entries explain why the fix is non-trivial and what the path forward looks like.

Severity scale: HIGH (block before mainnet), MED (fix before mainnet), LOW (clean up), INFO (FYI).

Surface tally

SurfaceHIGHMEDLOWINFOTotal
Core lending342110
Encrypt (FHE)22217
IKA23117
APIs03216
Frontend02136
Docs11002
Total8156738

Core lending program

HIGH

L-1 · cross_borrow set_id collisionprograms/src/instructions/cross_borrow.rs:287 The synthetic set_id is slot * 1_000_003 + timestamp + amount. Two cross-borrows in the same slot with the same amount produce identical IDs, allowing a malicious caller to rebind a position into a second cross-set and double-pledge the same collateral. Fix in this pass: mix the caller’s pubkey + a hash of the account list into the set_id, removing the collision class entirely.

L-2 · oracle MAX_ORACLE_AGE is too tightprograms/src/instructions/update_oracle_price.rs:118 30s rejects perfectly-fine Pyth updates during devnet hiccups; pools freeze until somebody manually re-anchors. Operationally this means liquidations can’t fire when the network is under load, exactly when they’re most needed. Fix in this pass: raise to 180s and add a comment explaining the budget.

L-3 · flash-repay fee split is non-atomicprograms/src/instructions/flash_repay.rs:77-83 LP fee (90%) and protocol fee (10%) are written via two separate saturating_add calls. One can saturate while the other doesn’t, leaving the pool’s accounting inconsistent forever. Fix in this pass: combine into a single checked_add that returns MathOverflow on failure; either both writes succeed or the tx aborts.

MED

L-4 · liquidation paths use cached oracle price without per-tx freshnessliquidate.rs, cross_liquidate.rs Liquidate reads pool.oracle_price written by UpdateOraclePrice and trusts it. If the oracle hasn’t been re-anchored in the last MAX_ORACLE_AGE, liquidators can seize collateral at stale prices. Fix in this pass: add a clock.unix_timestamp - pool.oracle_updated_at <= MAX_ORACLE_AGE check at the top of both liquidation paths.

L-5 · cross_borrow oracle anchoring check is too latecross_borrow.rs:203-221 pool_token_to_usd returns OracleNotAnchored mid-flight after fee math has already run. Wastes CU and gives a confusing error. Fix in this pass: validate every collateral pool’s oracle is anchored at the top of the instruction, before any state mutation.

L-6 · repay rejects over-repay; cross_repay silently capsrepay.rs:89 vs cross_repay.rs:52 Inconsistent UX: a user calling Repay { amount: u64::MAX } gets an error, but the same call routed through CrossRepay succeeds. Fix in this pass: silent cap in both — let amount = amount.min(total_debt).

L-7 · cross_liquidate count check is conditionalcross_liquidate.rs:250-259 debt_count match is gated on debt_cross != 0. A debt position never marked cross-collateral can be omitted from the call to manufacture a passing HF. Fix in this pass: match unconditionally for any position with a non-zero borrow_principal.

LOW

L-8 · init_position silently no-ops on re-initinit_position.rs:64 A typo’d address re-initializes someone else’s PDA without complaint. Fix in this pass: if the account is already a valid UserPosition, return InvalidAccountData.

L-9 · update_pool missing PoolNotEmpty enforcementupdate_pool.rs The spec says it should reject when total_deposits + total_borrows > 0; not visible in code. Fix in this pass: add the check; surface PoolNotEmpty (6034).

INFO

L-10 · set_ika_cap has no upper boundset_ika_cap.rs:55 u64::MAX cents is technically valid; trusts admin input. Fix in this pass: cap at $100M / position (10_000_000_000 cents) with a clear error.

Encrypt (FHE) module

HIGH

E-1 · ciphertext account ownership not validatedenable_privacy.rs:119-121, private_*.rs After create_plaintext_u64, Veil never verifies the ciphertext account is owned by the Encrypt program. A SystemProgram-owned account passed in slot 3/4 can pollute the EncryptedPosition’s stored addresses. Fix in this pass: if accounts[3].owner() != &ENCRYPT_PROGRAM_ID { ... } post-CPI, in every private_* path.

E-2 · cpi_authority_bump from instruction data, never validatedenable_privacy.rs:114, private_borrow.rs:195 A wrong bump → different PDA → potential auth bypass on the Encrypt side. Fix in this pass: re-derive the PDA with the supplied bump and confirm it matches the address passed in the account list.

MED

E-3 · EncryptedPosition PDA derivation never re-checkedenable_privacy.rs:73-76 The code accepts whatever account was passed in slot 2 as the EncryptedPosition. Defense-in-depth: derive ["enc_pos", owner, pool] and compare. Fix in this pass: re-derive and compare.

E-4 · same PDA gap in private_*.rsprivate_deposit.rs:90, private_borrow.rs:101, private_repay.rs:87, private_withdraw.rs:94 Same as E-3. Fix in this pass: add PDA re-derivation alongside verify_binding.

LOW

E-5 · UI claims encryption, on-chain writes plaintext — dapp gap useVeilActions doesn’t route encPos to private_*. UX promise vs reality. Documented: noted in frontend-e2e-tests/README.md and the modal already shows the “FHE” copy clearly. Wiring is a frontend feature, not a security fix — tracked in repo.

E-6 · stale comment “stub never dereferences past .address()lib/veil/instructions.ts:703, e2e-cross-encrypt.ts:284 Fix in this pass: delete or replace with current behaviour.

INFO

E-7 · no request_decryption path implemented — n/a Future work. Flagged in docs/content/program-reference/privacy.mdx.

IKA dWallet integration

HIGH

I-1 · ika_sign relies on CPI authority signature, no user-side proofika_sign.rs:82-103 Authorization chain is user → position-binding → CPI authority → IKA. A Veil program compromise grants attacker control over every registered dWallet. Documented: real fix requires storing the user’s pubkey at registration and verifying an ed25519 signature on the message hash inside ika_sign. That’s an architectural change (new ed25519 syscall integration + TS-side signing flow). Tracked as design-level work in docs/internal/ika-integration-roadmap.md.

I-2 · IKA_PROGRAM_ID hardcoded for devnet pre-alphaprograms/src/ika/mod.rs:69 No runtime guard against a mainnet build using the devnet ID. Fix in this pass: make IKA_PROGRAM_ID build-time configurable via IKA_PROGRAM_ID_BYTES env var (matches the MOCK_ADMIN pattern); refuse to build for mainnet without an explicit override.

MED

I-3 · dWallet usd_value frozen at registrationika_register.rs:92-97 Stale collateral pricing is a core DeFi risk. Documented: real fix requires storing the dWallet’s oracle feed reference on IkaDwalletPosition and re-validating freshness in ika_sign. Tracked in the IKA roadmap.

I-4 · ika_release only checks current poolika_release.rs:94-108 Same dWallet could back debt in another Veil pool. Documented: requires a global dWallet-usage registry. Tracked in roadmap.

I-5 · CPI authority owner not verified after PDA address matchika_register.rs:126-133, ika_sign.rs:76-83 Fix in this pass: add cpi_authority.owner() == &VEIL_PROGRAM_ID.

LOW

I-6 · ika_release verify_binding return value relied onika_release.rs:99-102 Function does validate; minor code-clarity nit. Closed, no fix needed.

INFO

I-7 · mainnet safety hinges on env var alignment — n/a Closed by I-2 (build-time configuration).

APIs

MED

A-1 · exception messages echoed to clientpools/sync/route.ts:27, transactions/route.ts:248, health/route.ts:16 Leaks parser internals. Fix in this pass: replace with closed-set generic strings; full error to logSafe.

A-2 · no rate limit on POST /api/pools/initpools/init/route.ts:15 Auth-gated but a compromised admin key can spam. Fix in this pass: add rateLimit(req, { key: "pools.init", max: 20, windowSec: 60 }).

A-3 · no rate limit on GET /api/admin/meadmin/me/route.ts:8 Polling abuse vector; no auth. Fix in this pass: add rateLimit(req, { key: "admin.me", max: 120, windowSec: 60 }).

LOW

A-4 · IDOR on positions readpositions/[user]/route.ts Intentional public state mirror. Documented as a design choice in docs/content/integration/api.mdx.

A-5 · cross-revoke between super_admins not blockedadmin/allowlist/route.ts:100-102 Operational risk only. Documented — admins are mutually-trusting.

INFO

A-6 · everything else — SQL parameterization, CSP, nonce TTL+single-use, origin binding, cluster scoping, sig re-verification. ✓

Frontend

MED

F-1 · CSP includes 'unsafe-eval'middleware.ts:19 No eval()/Function() in codebase. Fix in this pass: drop the token.

F-2 · TestWalletAdapter trusts window.__VEIL_TEST_WALLET_SECRET__app/dapp/lib/TestWalletAdapter.ts:50,67 Any same-origin script can hijack on devnet. Fix in this pass: require an explicit Playwright-provided injection marker (window.__VEIL_TEST_WALLET_INJECT_TOKEN__) plus a build-time-only toggle. Mainnet gate at SolanaProvider.tsx:46 already blocks production.

LOW

F-3 · formatHF lacks WAD === 0n guardapp/dapp/lib/format.ts:96 Catastrophic state would panic. Fix in this pass: trivial guard.

INFO

F-4 · CSP frame-src implicit (inherits ‘self’)middleware.ts Fix in this pass: add explicit frame-src 'self'.

Docs

HIGH (cross-file)

D-1 · hardcoded /Users/eshan/... keypair pathfrontend-e2e-tests/specs/*, frontend-e2e-tests/setup/*, .env.local Leaks dev identity in any committed copy. Fix in this pass: read all three test-wallet paths from TEST_ADMIN_KEYPAIR, TEST_USER_KEYPAIR, TEST_VICTIM_KEYPAIR env vars in .env.local; documented in frontend-e2e-tests/README.md and .env.example.

MED

D-2 · deployment.mdx lacks mainnet warning bannerdocs/content/integration/deployment.mdx:37-42 Fix in this pass: add explicit --url devnet callouts and a do-not-deploy-to-mainnet banner.

Apply log

Every “Fix in this pass” item above lands in this commit. The “Documented” items become tracked tickets in docs/internal/ika-integration-roadmap.md and the dapp privacy gap.

Last updated on