Skip to Content
SecurityOverview

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:

  1. Internal Deep Review — 2026-04-27, on-chain math, oracle, frontend, API
  2. Adversarial Audit — 2026-04-28, attacker-first review of every instruction
  3. Frontend Security — 2026-04-28, veil-landing API and auth surface
  4. 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 all
  • atype == 3 — confirms it is a PriceAccount (not a MappingAccount or ProductAccount)

Passing a wrong Pyth account type returns OracleInvalid.

Summary

AttackMitigation
Feed substitutionAddress anchored on first call; mismatch → error
Flash-loan price manipulationConfidence interval guard rejects > 2% uncertainty
Stale price (halted feed)status != 1OraclePriceStale
Negative / zero priceprice <= 0OracleInvalid
Crafted non-Pyth accountMagic + type + length checks → OracleInvalid
Account too short for fieldsMin 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 EnablePrivacy is 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 EncryptedPosition PDA 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

  • IkaSign verifies ika_position.status == Active before approving any message
  • A liquidated position (status == Liquidated) can never approve new signatures — the collateral is permanently locked for recovery
  • Only ika_position.owner can call IkaRelease or IkaSign

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 IkaSign logic 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:

  1. The caller is the position owner
  2. 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 via entrypoint.rs
  • Description: The MockFees instruction allows anyone to directly set accumulated_fees on any pool to an arbitrary value. No authority check is enforced — any signer can call it.
  • Attack: An attacker calls MockFees to inflate accumulated_fees to the pool’s entire vault balance, then calls CollectFees (as the authority, or exploits this in combination with other bugs) to drain the vault.
  • Recommendation: Remove MockFees from 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 == Trading and confidence bounds but does not check agg.publish_time or timestamp. 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 calls AccrueInterest for 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 u128 math 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 = true enforced 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_amount is a u64 that doubles as both a guard flag and the amount to repay. If any code path sets it to a non-zero value without going through FlashBorrow, 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 set flash_loan_amount.

M-2: UpdatePool Has No Parameter Bounds

  • Location: programs/src/instructions/update_pool.rs
  • Description: UpdatePool accepts 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 to u128::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 UpdateOraclePrice on 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 (ProposeNewAuthorityAcceptAuthority).

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 usePools hook 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

LimitationDescription
No timelock on adminUpdatePool and PausePool take effect immediately; use a multisig authority
Single oracle per poolEach pool uses one Pyth feed; there is no fallback oracle
No timestamp staleness checkRelies on keeper liveness and Pyth status field rather than publishTime
Flash oracle gapBetween UpdateOraclePrice and the instruction that uses the cached price, the on-chain price can change; keepers should atomically refresh and act
FHE key custodyUser is solely responsible for their FHE decryption key
dWallet content blindnessVeil approves signing but does not inspect the transaction being signed
Last updated on