Security
This page documents Veil’s threat model, the mitigations in place, and the assumptions that must hold for the protocol to remain safe.
Full audit reports are published under Audit Reports and numbered in the order they were performed:
- Internal Deep Review — 2026-04-27, on-chain math, oracle, frontend, API
- Adversarial Audit — 2026-04-28, attacker-first review of every instruction
- Frontend Security — 2026-04-28,
veil-landingAPI and auth surface - Red-Team Report — 2026-04-28, hostile end-to-end review
Oracle Security
Price oracle manipulation is one of the most common attack vectors in DeFi. Veil defends against it at multiple layers.
Feed Address Anchoring
The first call to UpdateOraclePrice on a pool locks the Pyth feed address permanently into pool.pyth_price_feed. Every subsequent call verifies the feed address matches. Any attempt to substitute a different feed — even one owned by the same publisher — returns OraclePriceFeedMismatch (error 6026).
This eliminates the feed substitution attack, where an attacker calls UpdateOraclePrice with a different Pyth feed they control or can more easily manipulate.
Confidence Interval Guard
Pyth publishes a confidence interval alongside every aggregate price. When the market is volatile or the Pyth aggregation has high uncertainty, this interval widens. Veil rejects prices where:
conf > price / 50 (i.e., confidence > 2% of price)This is the primary defence against flash-loan-driven oracle manipulation. A flash loan attack compresses on-chain prices by moving DEX pools. Pyth observes these price discrepancies across sources and — before the aggregate price itself is fully deflected — the confidence interval widens. Rejecting wide-confidence prices means the protocol will not act on manipulated data during the attack window.
If Pyth confidence is persistently wide (e.g. during a genuine market crash), UpdateOraclePrice will fail. Keepers should monitor for this condition. The pool will operate on its last cached price until confidence narrows.
Status Check
Pyth sets agg.status to values other than 1 (Trading) when:
- A market is closed (equities outside trading hours)
- There is insufficient publisher data
- The feed is intentionally halted
Veil returns OraclePriceStale (error 6025) for any non-Trading status, preventing stale prices from being used for collateral valuation or liquidation.
Negative Price Guard
Pyth can return negative agg.price values for certain derivative assets. Veil returns OracleInvalid (error 6024) for any price ≤ 0. This prevents nonsensical collateral valuations.
Minimum Data Length
The program verifies the Pyth account’s data is at least 228 bytes before reading any fields. A short account cannot be a valid Pyth PriceAccount. This prevents out-of-bounds reads via crafted accounts.
Magic and Type Bytes
Veil validates:
magic == 0xa1b2c3d4— confirms it is a Pyth account at allatype == 3— confirms it is aPriceAccount(not aMappingAccountorProductAccount)
Passing a wrong Pyth account type returns OracleInvalid.
Summary
| Attack | Mitigation |
|---|---|
| Feed substitution | Address anchored on first call; mismatch → error |
| Flash-loan price manipulation | Confidence interval guard rejects > 2% uncertainty |
| Stale price (halted feed) | status != 1 → OraclePriceStale |
| Negative / zero price | price <= 0 → OracleInvalid |
| Crafted non-Pyth account | Magic + type + length checks → OracleInvalid |
| Account too short for fields | Min 228-byte length check → OracleInvalid |
FHE Privacy Model
The Encrypted Instructions (EnablePrivacy, PrivateDeposit, PrivateBorrow, PrivateRepay, PrivateWithdraw) store encrypted ciphertexts on-chain. The plaintext amounts are hidden from validators and other users.
What is hidden
- Deposit amounts after
EnablePrivacyis called - Borrow amounts on a private position
- Repay amounts
- Current position balances
The EncryptedPosition account stores two ciphertext account public keys: enc_deposit and enc_debt. These are references to ciphertext accounts, not the ciphertexts themselves. The ciphertexts live in the FHE program’s ciphertext accounts.
What is NOT hidden
- That a position exists (the
EncryptedPositionPDA is public) - That the user called a private instruction (transaction is public on-chain)
- The pool the user is interacting with
- The user’s wallet address
Veil’s FHE privacy model is amount privacy, not address privacy.
Threat model for FHE
- Validators cannot decrypt amounts; they process ciphertexts as opaque data
- Other users cannot decrypt amounts
- The position owner can decrypt via their FHE private key (held off-chain, never sent on-chain)
- Veil program can perform homomorphic operations on ciphertexts without decrypting
The FHE private key is the user’s responsibility. Losing it means losing the ability to decrypt your own position’s balances. The position can still be repaid or closed using the on-chain ciphertext operations; only the readable balance view is lost.
Health factor under FHE
When a position is encrypted, health factor checks run homomorphically over encrypted values. The comparison result (healthy / unhealthy) is revealed to the program as a plaintext boolean, but the underlying deposit and debt amounts remain encrypted.
Ika dWallet Custody Model
When a dWallet is registered as collateral, its signing authority is transferred to Veil’s CPI PDA. This means only Veil can approve Bitcoin or Ethereum transactions for that dWallet until it is released.
What Veil enforces
IkaSignverifiesika_position.status == Activebefore approving any message- A liquidated position (
status == Liquidated) can never approve new signatures — the collateral is permanently locked for recovery - Only
ika_position.ownercan callIkaReleaseorIkaSign
What Veil does NOT enforce
- The content of the Bitcoin/Ethereum transaction being approved. Veil authorises signing — it does not parse the Bitcoin script or Ethereum calldata
- That the signed transaction is actually broadcast. The user could approve a signature and never broadcast it
Custody risk
While a dWallet is registered:
- Veil’s program logic is the exclusive gatekeeper for all signing operations
- A bug in Veil’s
IkaSignlogic could, in principle, approve signatures the user did not intend - Veil has no mechanism to unilaterally move funds — it can only approve signing; the user must construct and broadcast transactions
Release conditions
IkaRelease returns dWallet authority to the user only if:
- The caller is the position owner
- The position status is
Active(not Liquidated)
A liquidated position cannot be released. The dWallet remains under Veil’s control for recovery by the liquidator.
Flash Loan Safety
Flash loans in Veil are atomic within a single transaction. The repayment check happens at the end of the instruction — if the borrower fails to return amount + fee in the same transaction, the entire transaction reverts.
Re-entrancy
Veil uses an amount-based flash-loan guard (pool.flash_loan_amount, source: programs/src/state/lending_pool.rs:88). FlashBorrow sets it to the borrowed amount; FlashRepay requires it non-zero, then zeroes it. A second FlashBorrow on the same pool while a loan is in flight returns FlashLoanActive (error 6018); a FlashRepay without an active loan returns FlashLoanNotActive (6019); insufficient repay returns FlashLoanRepayInsufficient (6020).
Flash loan fee
The fee is configurable via UpdatePool (flash_fee_bps). The minimum enforceable fee is 0 (permissioned pools may set this to 0). The fee is paid to the pool’s reserve and compounds into the supply index for depositors.
Admin Controls
Pool pausing
PausePool blocks Deposit, Borrow, and FlashBorrow. Withdraw, Repay, and Liquidate remain available so users and liquidators can always exit. Only the pool authority (set at Initialize) can pause or resume. Returns PoolPaused (error 6022) when blocked.
Pool parameters
UpdatePool can change interest rate parameters, LTV, liquidation threshold, liquidation bonus, and protocol fees. Changes take effect immediately on the next accrual.
There is no timelock on UpdatePool. Protocol deployments should use a multisig or governance program as the pool authority to prevent unilateral parameter changes.
Fee collection
CollectFees moves accrued protocol reserves from the vault to a designated treasury address. Only the pool authority can call this.
AI Security Audit — April 27, 2026
Automated security review of the Veil on-chain program (programs/src/), API layer, and frontend. Findings are organized by severity.
Critical
C-1: MockFees Backdoor Instruction
- Location:
programs/src/instructions/mock_fees.rs, dispatched viaentrypoint.rs - Description: The
MockFeesinstruction allows anyone to directly setaccumulated_feeson any pool to an arbitrary value. No authority check is enforced — any signer can call it. - Attack: An attacker calls
MockFeesto inflateaccumulated_feesto the pool’s entire vault balance, then callsCollectFees(as the authority, or exploits this in combination with other bugs) to drain the vault. - Recommendation: Remove
MockFeesfrom the production build entirely. If needed for testing, gate it behind a#[cfg(feature = "testing")]flag so it is excluded from release builds.
C-2: Missing Pyth Account Owner Verification
- Location:
programs/src/instructions/update_oracle_price.rs - Description: The oracle update instruction checks Pyth magic bytes, account type, data length, status, confidence, and feed address — but does not verify that the account is owned by the Pyth program (
FsJ3A3u2vn5cTVofAjvy6y5kwABJAqYWpe4975bi2epH). - Attack: An attacker creates a fake account with the correct magic bytes, type field, and data layout but containing a manipulated price. Since ownership is not checked, the program accepts this spoofed oracle and caches the attacker’s price. The attacker then liquidates healthy positions or borrows at inflated collateral valuations.
- Recommendation: Add an owner check:
if oracle_account.owner() != &PYTH_PROGRAM_ID { return Err(OracleInvalid); }before reading any fields.
C-3: Missing Account Owner Checks on PDAs
- Location: All instruction handlers that read pool, position, or vault accounts
- Description: Instructions verify PDA derivation (seeds + bump) but do not verify that the accounts are actually owned by the Veil program. On Solana, PDA derivation alone is not sufficient — an attacker can create an account at the same address via a different program if the account doesn’t already exist, or pass accounts from other programs that happen to have valid-looking data.
- Attack: An attacker passes a crafted account from a program they control. The data layout matches what Veil expects, but the values are manipulated (e.g., inflated deposit amounts). Veil reads the fake data and allows unauthorized withdrawals.
- Recommendation: Add
if account.owner() != program_id { return Err(InvalidAccountOwner); }for every account the program reads state from (pools, positions, encrypted positions, ika positions).
High
H-1: No Timestamp Staleness Check on Oracle Prices
- Location:
programs/src/instructions/update_oracle_price.rs - Description: The instruction checks Pyth
agg.status == Tradingand confidence bounds but does not checkagg.publish_timeortimestamp. A price from hours or days ago could be accepted as current. - Attack: During network congestion or Pyth downtime, the cached price goes stale. An attacker waits for a significant real-world price movement, then uses the stale on-chain price to borrow at an outdated (favorable) collateral valuation or avoid liquidation.
- Recommendation: Add a staleness window:
if clock.unix_timestamp - agg.timestamp > MAX_ORACLE_AGE { return Err(OraclePriceStale); }. A typical threshold is 60–120 seconds.
H-2: Interest Accrual Unbounded Time Jump
- Location:
programs/src/instructions/accrue_interest.rs - Description: Interest accrual uses
(current_slot - last_accrual_slot)as the time delta. If no one callsAccrueInterestfor an extended period, the jump can be large, potentially causing index overflow or extreme interest accumulation. - Attack: On a low-activity pool, an attacker deliberately avoids triggering accrual for days, then triggers it. The large time delta causes a massive index jump, potentially overflowing
u128math or creating an exploitable discrepancy between supply and borrow indices. - Recommendation: Cap the maximum time delta per accrual (e.g., 1 hour worth of slots). If the gap exceeds the cap, accrue the cap and require multiple calls.
H-3: Liquidation Bonus Can Exceed Collateral
- Location:
programs/src/instructions/liquidate.rs - Description: The liquidation bonus is applied as a percentage of the repaid amount. If
close_factor * debt * (1 + liquidation_bonus)exceeds the borrower’s collateral, the instruction may attempt to transfer more tokens than the position holds. - Attack: With aggressive parameters (high close factor + high liquidation bonus), a liquidator could extract more value than the position’s collateral, creating bad debt in the pool.
- Recommendation: Clamp the liquidation transfer to
min(bonus_amount, position.deposited)and add a bad-debt accounting path when collateral is insufficient.
H-4: No Signer Verification on Position Owner for Borrow
- Location:
programs/src/instructions/borrow.rs - Description: The borrow instruction checks that the position PDA matches the borrower’s public key but may not verify that the borrower actually signed the transaction, depending on how the account is passed.
- Recommendation: Ensure the borrower account has
is_signer = trueenforced in the instruction’s account validation, not just in CPI contexts.
Medium
M-1: Flash Loan Guard Uses Amount Instead of Boolean
- Location:
programs/src/state/lending_pool.rs:88 - Description:
flash_loan_amountis au64that doubles as both a guard flag and the amount to repay. If any code path sets it to a non-zero value without going throughFlashBorrow, the pool enters a stuck state where flash loans are permanently blocked. - Recommendation: Consider separating the boolean guard (
flash_loan_active: bool) from the amount, or ensure all code paths that modify pool state do not accidentally setflash_loan_amount.
M-2: UpdatePool Has No Parameter Bounds
- Location:
programs/src/instructions/update_pool.rs - Description:
UpdatePoolaccepts arbitrary values for LTV, liquidation threshold, interest rate slopes, and fees. A misconfigured or malicious authority could set LTV to 100% (instant insolvency risk), liquidation bonus to 100% (drain on liquidation), or slope2 tou128::MAX(overflow in interest math). - Recommendation: Add sanity bounds:
ltv < liquidation_threshold < 100%,liquidation_bonus < 50%,reserve_factor < 100%,slopes < reasonable_max.
M-3: No Rate Limiting on Oracle Updates
- Location:
programs/src/instructions/update_oracle_price.rs - Description: Anyone can call
UpdateOraclePriceon any pool with no rate limit. While not directly exploitable, this creates a griefing vector (filling blocks with oracle update transactions) and could be combined with other timing attacks. - Recommendation: Consider a minimum slot interval between updates (e.g., once per 2 slots).
Low / Informational
L-1: Hardcoded Confidence Threshold
- Location:
programs/src/instructions/update_oracle_price.rs - Description: The 2% confidence threshold (
conf > price / 50) is hardcoded. Different assets have different normal confidence ranges — gold (XAU) typically has tighter confidence than crypto assets. - Recommendation: Make the confidence threshold configurable per pool via
UpdatePool.
L-2: No Event Emission for Critical State Changes
- Location: All instruction handlers
- Description: The program does not emit Solana program logs or events for critical state changes (liquidations, oracle updates, parameter changes). This makes off-chain monitoring and incident response difficult.
- Recommendation: Add
msg!()or structured event logs for liquidations, oracle updates, pause/resume, and parameter changes.
L-3: Pool Authority Is Immutable After Initialize
- Location:
programs/src/instructions/initialize.rs - Description: The pool authority is set at initialization and cannot be changed. If the authority key is compromised or the team wants to migrate to a multisig, a new pool must be created and all liquidity migrated.
- Recommendation: Add a two-step authority transfer instruction (
ProposeNewAuthority→AcceptAuthority).
L-4: No Maximum Pool Count
- Description: There is no limit on the number of pools that can be initialized. An attacker could spam pool creation to bloat on-chain state, though they pay rent.
- Recommendation: Low priority — rent cost is the natural rate limit. Consider a governance gate for pool creation in production.
L-5: Frontend Does Not Validate API Response Schema
- Location:
veil-landing/lib/veil/usePools.ts - Description: The
usePoolshook trusts the API response shape without runtime validation. A compromised or buggy API could return malformed data that causes runtime errors or displays incorrect values. - Recommendation: Add runtime schema validation (e.g., Zod) for API responses, especially for financial data displayed to users.
Known Limitations
| Limitation | Description |
|---|---|
| No timelock on admin | UpdatePool and PausePool take effect immediately; use a multisig authority |
| Single oracle per pool | Each pool uses one Pyth feed; there is no fallback oracle |
| No timestamp staleness check | Relies on keeper liveness and Pyth status field rather than publishTime |
| Flash oracle gap | Between UpdateOraclePrice and the instruction that uses the cached price, the on-chain price can change; keepers should atomically refresh and act |
| FHE key custody | User is solely responsible for their FHE decryption key |
| dWallet content blindness | Veil approves signing but does not inspect the transaction being signed |