← DOCSVEIL · SPECRFC v0.1
WHITEPAPER·v0.1·2026-04-25·27 MIN READ·SOURCE-GROUNDED
PROTOCOL SPECVEIL · v0.1

A privacy-first cross-chain
lending protocol on Solana.

Native Bitcoin, Ethereum, and physical-gold collateral via MPC dWallets and Oro/GRAIL settlement. Optional per-position fully-homomorphic privacy. Two-slope kink interest-rate model and Aave-style liquidation engine, implemented in Pinocchio 0.11.1 for low compute-unit overhead. This document is the canonical engineering specification.
AUTHOR
Veil Labs
STATUS
DEVNET · PRE-AUDIT
LICENSE
BSL 1.1
REV
0x0001
SHA
GROUND-TRUTHED
§ 00ABSTRACT00.0

Abstract

Veil is an over-collateralised lending protocol on Solana. It accepts SPL tokens as collateral natively and is designed to extend, without bridging, to native Bitcoin and Ethereum via Ika MPC dWallets, and to physical gold via Oro's GRAIL settlement layer. Each position can opt into amount-private accounting backed by Encrypt's REFHE construction; plaintext health checks remain the authoritative solvency rule.

The on-chain program is implemented in Pinocchio 0.11.1. A two-slope kink interest-rate model, index-based share accounting, and an Aave-style liquidation engine (50 % close factor, 5 % liquidation bonus, 10 % protocol fee) provide the economic backbone. An off-chain allowlist on Neon Postgres curates which wallets are permitted to act as pool administrators for new markets; on-chain administrative authority is independently bound to LendingPool.authority.

[NOTE]
This document is the canonical source-grounded specification. Every formula, parameter, and PDA seed appears in programs/src/ with the cited path. Nothing is paraphrased without source. Defensive findings are surfaced with severity tags.
§ 01MOTIVATION01.0

Introduction

1.1Two structural blockers in DeFi lending

Two forces keep institutional and high-net-worth capital out of on-chain credit markets. Liquidity fragmentation across chains: the largest pools of value sit in native Bitcoin, native Ethereum, and physical gold. Bringing any of these into a Solana lending market today requires bridging or wrapping — introducing custody risk, smart-contract risk, and trusted multi-sigs. Position transparency: Aave and Compound disclose every user's collateral, debt, liquidation price, and strategy on a public ledger. For market makers, treasuries, and funds, this leaks inventory and invites front-running.

1.2Veil's response

01
Native collateral via MPC dWallets
Ika dwallet accounts can be placed under the joint signing authority of a Solana program PDA and the Ika MPC committee. Veil is the Solana side of that pairing — a borrow on Solana is simultaneously authorised against native Bitcoin or Ethereum collateral. No bridge contract holds the underlying asset.
02
Per-position privacy via FHE
When a user enables privacy, balances become ciphertext handles and arithmetic happens homomorphically. The program receives a plaintext boolean healthy? from the FHE evaluator, never the underlying amounts.
03
Pinocchio for predictable compute
Borrow checks, liquidation math, and oracle reads run in a few thousand compute units. Pinocchio's zero-copy account access avoids Anchor's deserialisation overhead.
04
Curated, not gated
Liquidation, oracle refresh, and all user state mutations are permissionless. Pool initialisation is curated through an off-chain allowlist (§7); on-chain authority is independently bound at Initialize time.
§ 02PRINCIPLES02.0

Design Principles

PrincipleConcretely
Native, not synthetic, collateralIka dWallets remain the user's; Veil controls signing while the position is open. Source: programs/src/instructions/ika_register.rs.
Plaintext solvency, optional opacityUserPosition is always authoritative. EncryptedPosition is a parallel mirror. Health checks never depend on encrypted state being decryptable.
Compute-boundedHot paths accrue interest, do at most one HF check, transfer tokens, and update u128 fields — no per-position storage walks.
Permissionless where it can be, gated where it must beLiquidation, oracle refresh, and all user state mutations are permissionless. Pool initialisation is curated through an off-chain allowlist.
Atomic state machines for risky primitivesFlash loans use a single-tx in-flight counter; missing repay reverts everything.
Source-grounded documentationEvery claim cites the on-chain Rust file. Drift between code and docs is a bug.
§ 03SCHEMATIC03.0

System Overview

3.1Components

FIG. 1 · COMPONENT TOPOLOGY
┌──────────────────────────────────────────────────────────────────────┐
│                            Veil Program                              │
│                       (Pinocchio 0.11.1, no_std)                     │
│                                                                      │
│   LendingPool ←─→ UserPosition         EncryptedPosition (optional)  │
│   (1 / mint)      (1 / user / pool)    (1 / user / pool)             │
│         │                                                            │
│         │  oracle_price, oracle_conf cached on every UpdateOracle    │
│         ▼                                                            │
│   Pyth legacy push-oracle account (per pool, address-anchored)       │
│                                                                      │
│   Cross-chain collateral                                             │
│   IkaDwalletPosition ───CPI──→ Ika dWallet program                   │
└──────────────────────────────────────────────────────────────────────┘
              ▲                                  ▲
              │ tx submission (web3.js)          │ permissionless reads
              │                                  │
   Next.js dApp + admin panel ────────────→  Neon Postgres
   (/dapp, /dapp/admin, /dapp/liquidate)    (allowlist · tx_log · cache
                                             positions · auth_nonces)

3.2Roles

RoleWhat they can doEnforcement
UserDeposit, withdraw, borrow, repay, enable privacyOn-chain: signer == UserPosition.owner
LiquidatorRepay debt of unhealthy positions and seize collateralPermissionless on-chain (only HF condition)
Pool AdminInitialize a new pool; update params, pause/resume, collect fees on pools they authoriseOn-chain: signer == pool.authority. Off-chain: must be in pool_admins to use the curated UI for Initialize.
Super-adminCurate the off-chain allowlist (add/revoke pool admins)Off-chain only: pool_admins.role == 'super_admin', signed nonce verified server-side.
[ATTN]
An attacker who bypasses the curated UI can still call Initialize directly with their own wallet as authority. The pool that results is not registered in Veil's index and has no relationship to canonical pools. The off-chain allowlist gates which pools Veil considers canonical — not the on-chain instruction itself. See §13.5 for first-mover squatting risk.
§ 04STATE04.0

On-Chain State

All accounts are #[repr(C)]zero-copy structs. They are read by direct raw pointer cast (Pinocchio idiom). The first eight bytes are an ASCII discriminator; subsequent fields are little-endian on Solana's BPF target.

4.1LendingPool — 416 bytes

One per token market. PDA seeds: [b"pool", token_mint]. Source: programs/src/state/lending_pool.rs; SIZE = 416 at line 114.

OFFSZFIELDTYPE / NOTES
0x0008discriminatorb"VEILPOOL"
0x00832authorityAddress — admin set at Initialize
0x02832token_mintAddress
0x04832vaultSPL ATA owned by PoolAuthority PDA
0x0688total_depositsu64; virtual; grows with depositor interest
0x0708total_borrowsu64; virtual; grows with borrower interest
0x0788accumulated_feesu64; protocol reserves owed to authority
0x0808last_update_timestampi64; Unix seconds
0x0881authority_bumpu8
0x0891pool_bumpu8
0x08A1vault_bumpu8
0x08B1pausedu8 — 0 active, 1 paused
0x09016borrow_indexu128 WAD; init = WAD
0x0A016supply_indexu128 WAD; init = WAD
0x0B0..0x14016×9rate + risk paramsbase_rate, optimal_util, slope1/2, reserve_factor, ltv, liq_threshold, liq_bonus, protocol_liq_fee, close_factor
0x1508flash_loan_amountu64; non-zero ⇔ flash in flight
0x1588flash_fee_bpsu64; default 9 bps
0x16032pyth_price_feedAddress; anchored on first update
0x180..0x19020oracle_price · oracle_conf · oracle_expoLast validated price snapshot

4.2UserPosition — 144 bytes

One per (user, pool). PDA seeds: [b"position", pool, user]. Source: programs/src/instructions/deposit.rs:82,94.

OFFSZFIELD
0x008discriminator = b"VEILPOS!"
0x0832owner
0x2832pool
0x488deposit_shares
0x508borrow_principal
0x6016deposit_index_snapshot
0x7016borrow_index_snapshot
0x801bump

4.3EncryptedPosition — 144 bytes

Optional, created by EnablePrivacy. Seeds: [b"enc_pos", owner, pool]. Holds two ciphertext-account pubkeys (enc_deposit, enc_debt) on the Encrypt program.

4.4IkaDwalletPosition — 128 bytes

Tracks a registered Ika dWallet pledged as collateral. Seeds: [b"ika_pos", pool, user]. Status field tracks ACTIVE | RELEASED | LIQUIDATED.

§ 05FORMAL05.0

Mathematical Specification

All rates and indices live in WAD space, where WAD = 10¹⁸ = 1.0. Token amounts are u64 in their native units; they are widened to u128 only inside arithmetic and narrowed back at the boundary. All formulas appear verbatim in programs/src/math.rs.

5.1Constants

programs/src/math.rs
WAD               = 1_000_000_000_000_000_000      // line 16
SECONDS_PER_YEAR  = 31_536_000                     // line 19

BASE_RATE         = WAD / 100         ≈  1 % apr   // line 23
OPTIMAL_UTIL      = WAD * 80 / 100    =  80 %      // line 24
SLOPE1            = WAD *  4 / 100    =   4 % apr  // line 25
SLOPE2            = WAD * 75 / 100    =  75 % apr  // line 26
RESERVE_FACTOR    = WAD / 10          =  10 %      // line 27
LTV               = WAD * 75 / 100    =  75 %      // line 28
LIQ_THRESHOLD     = WAD * 80 / 100    =  80 %      // line 29
LIQ_BONUS         = WAD *  5 / 100    =   5 %      // line 30
PROTOCOL_LIQ_FEE  = WAD / 10          =  10 % of bonus
CLOSE_FACTOR      = WAD / 2           =  50 %
FLASH_FEE_BPS     = 9                 ≈ 0.09 %     // line 35
FLASH_PROTOCOL_SHARE_BPS = 10                     // line 37
FLASH_LP_SHARE_BPS       = 90                     // line 39

5.2Borrow rate (two-slope kink)

programs/src/math.rs:76-102
             ⎧ R₀ + (U / U_opt) × S₁                          if U ≤ U_opt
borrow_rate =⎨
             ⎩ R₀ + S₁ + ((U − U_opt) / (1 − U_opt)) × S₂      if U > U_opt

Verified by tests borrow_rate_at_kink, borrow_rate_above_kink_full, borrow_rate_monotonically_increasing (math.rs:374-417).

UTILBORROW RATE (DEFAULT)
0 %1 %
40 %3 %
80 % (kink)5 %
90 %42.5 %
100 %80 %

5.3Supply rate

programs/src/math.rs:104-115
supply_rate = borrow_rate × U × (1 − reserve_factor)

5.4Index accrual

Simple interest within a single accrual call; calls compose into compound interest across blocks. Per call, with elapsed Δt seconds:

programs/src/math.rs:124-159
borrow_index_new = borrow_index × (1 + borrow_rate × Δt / SECONDS_PER_YEAR)
supply_index_new = supply_index × (1 + supply_rate × Δt / SECONDS_PER_YEAR)

5.5Health factor

programs/src/math.rs:201-211
HF = (deposit_balance × liquidation_threshold) / debt_balance     (WAD)
HF = u128::MAX                if debt_balance == 0

A position is liquidatable iff HF < WAD. The boundary is exact: HF == WAD is not liquidatable.

5.6Liquidation

programs/src/instructions/liquidate.rs
repay_amount      = current_debt × close_factor                ≤ 50 %
seized_collateral = repay_amount × (1 + liquidation_bonus)     +5 % bonus
protocol_fee      = seized_collateral × protocol_liq_fee       10 % cut
liquidator_gets   = seized_collateral − protocol_fee

5.7Flash-loan economics

programs/src/math.rs:223-238
fee = amount × flash_fee_bps / 10_000
(lp_portion, protocol_portion) = (fee − fee/10,  fee/10)

The 10 % protocol cut is integer-divided first; the LP portion takes the remainder. total_deposits grows by lp_portion; accumulated_fees grows by protocol_portion.

§ 06OPCODES06.0

Instruction Specification

The dispatcher is a single-byte switch on data[0]: programs/src/entrypoint.rs:28-50. There is no Anchor 8-byte hash. All u64 fields are little-endian; all u128 fields are little-endian (low 8 bytes followed by high 8 bytes).

DISCINSTRUCTIONSIGNERON-CHAIN AUTH CHECK
0x00Initializepayer + authoritynone — caller becomes pool authority
0x01Deposituseruser == position.owner; pool.paused == 0
0x02Withdrawuseruser == position.owner; HF ≥ 1 if debt > 0
0x03Borrowuserowner; LTV cap; HF ≥ 1; not paused
0x04Repayuserowner
0x05Liquidateliquidatorborrower HF < 1
0x06FlashBorrowborrowernot paused; no active flash
0x07FlashRepayborroweractive flash; repay ≥ amount + fee
0x08EnablePrivacyuseruser == position.owner
0x09PrivateDeposituseras plaintext + binding
0x0APrivateBorrowuseras plaintext + binding
0x0BPrivateRepayuseras plaintext + binding
0x0CPrivateWithdrawuseras plaintext + binding
0x0DUpdatePoolauthoritysigner == pool.authority
0x0EPausePoolauthorityditto
0x0FResumePoolauthorityditto
0x10CollectFeesauthorityditto
0x11IkaRegisteruserdwallet.authority == Veil CPI PDA
0x12IkaReleaseusersigner == ika_position.owner
0x13IkaSignuserowner; status == Active
0x14UpdateOraclePricefeed match if anchored
§ 07AUTH07.0

Authorization Model

Veil splits authorization into two independent layers that must both be satisfied to administer a canonical pool through the curated UI.

01
On-chain authority
Encoded in LendingPool.authority at offset 0x008. Set once by Initialize to the second signer. Update / Pause / Resume / CollectFees enforce signer == pool.authority on every call, returning Unauthorized (6021) otherwise. Citations: programs/src/instructions/update_pool.rs:102-105, pause_pool.rs:31, resume_pool.rs:30, collect_fees.rs:44.
02
Off-chain pool-creation allowlist
Stored in Neon Postgres in pool_admins. Gates which wallets the canonical UI permits to start the on-chain Initialize flow, and which wallets can manage the allowlist itself.

7.1Signed-nonce handshake

lib/auth/admin.ts:21-66
1.  UI POSTs /api/auth/nonce {pubkey, action}
    Server returns a 16-byte hex nonce + canonical message:
       "Veil admin auth\nAction: <action>\nNonce: <nonce>"
    TTL: 5 minutes. Stored in auth_nonces.
2.  Wallet signs the exact bytes (ed25519 detached signature).
3.  UI POSTs the protected endpoint with {actor, nonce, signature, …}.
4.  Server, in this order:
    a. verifies ed25519 signature over canonical message     (TweetNaCl)
    b. atomically DELETE … RETURNING the nonce row          (single use)
    c. checks pool_admins membership and revoked_at         (registry)
    d. if requireRole == 'super_admin', enforces role       (privilege)
5.  On any failure, returns 401 with redacted reason. Nonce
    consumption is idempotent; replay produces "nonce invalid".
[SAFE]
Each property below is required for the handshake to succeed: (a) signature over the exactcanonical message bytes — prevents reuse from other contexts; (b) nonce single-use via atomic DELETE — prevents replay; (c) per-wallet nonce — A's nonce cannot be used by B; (d) action encoded into the message — sig for add_admin:X:role cannot be replayed as revoke_admin:X; (e) allowlist re-check on every request — revocation is immediate.
§ 08ORACLE08.0

Oracle Subsystem

Veil reads Pyth legacy push-oracle accounts directly without the Pyth SDK. The per-call validation pipeline:

programs/src/pyth/mod.rs
1. data.len() ≥ 228         → else OracleInvalid       (6024)
2. magic == 0xa1b2c3d4      → else OracleInvalid
3. atype == 3 (Price)       → else OracleInvalid
4. agg.price > 0            → else OracleInvalid
5. agg.status == 1          → else OraclePriceStale   (6025)
6. agg.conf ≤ price / 50    → else OracleConfTooWide  (6027)

After the first successful update, pool.pyth_price_feed records the feed account address; subsequent calls with a different feed return OraclePriceFeedMismatch (6026). The 2 % confidence cap is the load-bearing defence against flash-loan-driven oracle manipulation: during such an attack Pyth's aggregation widens the confidence interval before the aggregate price is fully deflected.

[OPEN]
Open issue · O-01 · feed first-call hijack. The address of the Pyth feed is anchored on the first successful UpdateOraclePrice call. On a freshly-initialised pool, an attacker who is first to call this instruction can anchor the pool to a feed account they crafted (any account whose first 228 bytes pass magic/atype/conf). Mitigation: pool deployment scripts must atomically initialize and anchor in a single tx; or accept the canonical Pyth feed address as part of Initialize data.
§ 09FHE09.0

Privacy Subsystem (FHE)

Privacy is opt-in per (user, pool). EnablePrivacy creates an EncryptedPosition PDA and two ciphertext accounts on the Encrypt program, seeded with the user's current plaintext deposit and debt. The four Private* instructions (0x09-0x0C) replicate the plaintext flow and emit Encrypt CPIs that update ciphertexts homomorphically.

HiddenNot hidden
Post-EnablePrivacy deposit / borrow / repay / withdraw amountsThat an EncryptedPosition PDA exists
Current encrypted balancesThat a private instruction was called
The pool, the wallet address, the timing

Solvency under FHE. Health checks run homomorphically. The Encrypt evaluator returns a plaintext boolean (healthy?) to Veil, which decides whether to allow the borrow or withdraw. Underlying balances are never decrypted on-chain.

[ATTN]
Implementation status (v0.1): all five private instructions compile and route correctly. The Encrypt SDK currently targets Pinocchio 0.10.x while Veil targets 0.11.x — execute_graph CPIs are stubbed pending the SDK update. The plaintext path is fully functional today.
§ 10DWALLET10.0

Cross-Chain Collateral (Ika dWallet)

An Ika dWallet is a programmable signing primitive: an MPC-managed key governed by a programmable authority address. Veil registers a dWallet by verifying that its on-chain authority field equals Veil's CPI authority PDA ([b"__ika_cpi_authority"] on Veil's program ID, programs/src/ika/mod.rs:67). While registered, the dWallet can only sign when Veil approves the message via IkaSign.

CURVEVALUSE
SECP256K10Bitcoin, Ethereum
SECP256R11WebAuthn
CURVE255192Solana, Ed25519
RISTRETTO3Substrate / sr25519

IkaRelease returns the dWallet to the user iff the position is ACTIVE (not LIQUIDATED). A liquidated dWallet remains under Veil's control for recovery.

[OPEN]
Open issue · X-01 · liquidation settlement path. A position with status LIQUIDATED is bricked by IkaRelease (status check rejects it) and by IkaSign (status must be Active). The intended recovery — liquidator claims the dWallet's native chain assets — is not yet wired in v0.1. A dedicated IkaLiquidate instruction or an ownership-transfer pathway is required.
§ 11STACK11.0

Off-Chain Infrastructure

11.1Web stack

RoutePurpose
/Marketing landing
/dappMarkets — deposit / borrow / repay / withdraw / flash
/dapp/liquidatePermissionless liquidation UI
/dapp/adminAllowlisted admin panel (Manage / Initialize / Allowlist)
/workflowEnd-to-end actor & instruction overview
/whitepaperVisual whitepaper (marketing / docs)
/api/*HTTP API — see §12

11.2Postgres tables (Neon)

TABLEPURPOSE
pool_adminsOff-chain allowlist (pool_admin, super_admin)
poolsCached on-chain LendingPool state
positionsCached UserPosition snapshots with derived health_factor_wad
tx_logAppend-only signature log keyed on Solana tx signature
audit_logAdmin actions (allowlist edits, pool inits, fee collections)
auth_noncesSingle-use ed25519 nonces, 5-min TTL
§ 12HTTP12.0

HTTP API

Eleven endpoints, all in veil-landing/app/api/**/route.ts, running on the Node runtime. The Neon HTTP driver is used for DB access; no per-request WebSocket pool. Numeric on-chain quantities are returned as strings to preserve u64/u128 fidelity.

12.1Endpoint surface

VERBPATHPURPOSEAUTH
GET/api/admin/me?pubkey=…Role lookup for UI gatingnone
POST/api/auth/nonceIssue single-use signed-message nonce (5 min TTL)none
GET/api/admin/allowlistList active adminsnone
POST/api/admin/allowlistAdd adminsuper-admin
DELETE/api/admin/allowlistRevoke admin (cannot revoke self)super-admin
GET/api/poolsCached pool indexnone
POST/api/pools/initRegister a freshly initialised poolallowlisted
POST/api/pools/syncRefresh a pool's cache from chainnone
GET/api/positions/[user]Cached positions for a walletnone
GET/api/transactionsTx log (filter by wallet or pool, max 200)none
POST/api/transactionsAppend confirmed/failed tx (idempotent on signature)none

12.2Authenticated request envelope

Authenticated endpoints take a common preamble plus an action-specific payload:

REQ · authenticated
POST /api/admin/allowlist
Content-Type: application/json

{
  "actor":     "<base58 super-admin pubkey>",
  "nonce":     "<32-hex-char nonce from /api/auth/nonce>",
  "signature": "<base58 ed25519 sig over the canonical message>",
  "pubkey":    "<wallet to add>",
  "role":      "pool_admin" | "super_admin",
  "label":     "<optional human label>"
}

The canonical message the wallet signs:

Veil admin auth
Action: add_admin:<pubkey>:<role>
Nonce: <nonce>

Wire format: UTF-8 bytes, two LF separators. The signature must be valid ed25519 detached over those exact bytes.

12.3Response shape · /api/pools

RES · GET /api/pools
{
  "pools": [
    {
      "pool_address": "...",
      "token_mint":   "...",
      "symbol":       "USDC" | null,
      "authority":    "...",
      "vault":        "...",
      "pool_bump":    254,
      "authority_bump": 255,
      "vault_bump":   0,
      "paused":       false,
      "total_deposits":   "0",
      "total_borrows":    "0",
      "accumulated_fees": "0",
      "ltv_wad":                    "750000000000000000",
      "liquidation_threshold_wad":  "800000000000000000",
      "liquidation_bonus_wad":      "50000000000000000",
      "protocol_liq_fee_wad":       "100000000000000000",
      "reserve_factor_wad":         "100000000000000000",
      "close_factor_wad":           "500000000000000000",
      "base_rate_wad":              "10000000000000000",
      "optimal_util_wad":           "800000000000000000",
      "slope1_wad":                 "40000000000000000",
      "slope2_wad":                 "750000000000000000",
      "flash_fee_bps":              9,
      "oracle_price":               null,
      "oracle_conf":                null,
      "oracle_expo":                null,
      "pyth_price_feed":            null,
      "created_by":     "...",
      "init_signature": "...",
      "last_synced_at": "...",
      "created_at":     "..."
    }
  ]
}

12.4Error code mapping

API errors map to short, redacted strings to avoid leaking implementation detail. The set is closed:

HTTPERRORWHERE
400bad jsonreq.json() rejected
400missing fieldsrequired fields absent
400pubkey and action required/api/auth/nonce
400invalid pubkeylength / charset
400invalid rolerole validation
400bad pubkey: <reason>new PublicKey() threw
400cannot revoke yourselfself-lockout guard
404pool account not foundgetAccountInfo returned null
400pool_address required/api/pools/sync
400signature, wallet, action required/api/transactions
401bad signatureTweetNaCl verify failed
401nonce invalid or expiredauth_nonces row absent / stale
401not authorizedactor not in pool_admins
401super_admin requiredrole mismatch
§ 13ULTRATHINK13.0

Threat Model

A defence-in-depth survey of the protocol's attack surface. Each finding is tagged with a status: SAFE (mitigated by current code), DEFENSE (recommended hardening before mainnet), or OPEN (a known gap that must be addressed). Findings cite source where applicable.

13.1On-chain · economic vectors

SAFEA-01
Flash-loan reentrancy
pool.flash_loan_amount at offset 0x150 is set non-zero by FlashBorrow and zeroed by FlashRepay. A second FlashBorrow on the same pool while a loan is in flight returns FlashLoanActive (6018); a FlashRepay without an active loan returns FlashLoanNotActive (6019); insufficient repay returns FlashLoanRepayInsufficient (6020). Solana transaction atomicity guarantees that a missed FlashRepay reverts the entire transaction including the FlashBorrow that incremented the counter — so the flag is always consistent with the realised state at tx end.
SAFEA-02
Oracle confidence-interval guard
Pyth aggregation widens the agg.conf interval before the published price moves materially during a flash-loan-driven manipulation. The 2 % cap (conf ≤ price / 50) rejects prices during the attack window. Source: programs/src/pyth/mod.rs.
SAFEA-03
Oracle feed substitution after anchor
The pyth_price_feed address is recorded on the first successful update; subsequent calls require an exact match or return OraclePriceFeedMismatch (6026). Once anchored, the feed cannot be silently swapped.
OPENA-04
Oracle feed first-call hijack
Before the first UpdateOraclePrice call, no feed is anchored. Any caller can anchor the pool to any account whose first 228 bytes pass the magic / atype / conf / status / price-positive checks. An attacker who races the canonical anchor can pin the pool to a feed they crafted. Mitigation: deployment scripts must atomically initialize and anchor in a single tx, OR Initialize data should accept the expected feed address and the program should reject any other on first UpdateOraclePrice.
SAFEA-05
LTV / health-factor enforcement at borrow time
Borrow enforces both the LTV cap (debt + amount ≤ deposit_balance × LTV) and the post-borrow health factor (HF ≥ WAD). Withdraw enforces a post-withdraw HF check identical to the borrow case when the user has open debt. Both checks consult the cached oracle price; for sensitive operations, callers should atomically refresh the oracle in the same transaction.
SAFEA-06
Liquidation grief / front-run
Liquidations are explicitly permissionless. The 50 % close factor prevents a single liquidator from sweeping an entire underwater position; multiple liquidators compete for the residual debt. The 5 % bonus is a public auction parameter; competition tightens spreads and is healthy.
DEFENSEA-07
Oracle-price staleness between refreshes
UpdateOraclePrice is permissionless but explicit. Between calls, the cached price is used — even if hours old. The program does not check a Pyth publishTime. Defence: keepers must atomically refresh the oracle in the same transaction as Borrow, Withdraw, and Liquidate. Document this requirement clearly to integrators; consider a minimum-staleness check before mainnet.
DEFENSEA-08
Liquidation-parameter race (no timelock)
UpdatePool takes effect on the next interest accrual. A malicious authority could collapse liquidation_threshold just before the next block and make every borrower instantly liquidatable. Defence: production deployments must place a Squads multisig + timelocked governance program in front of pool.authority. The program should additionally cap per-block parameter deltas in v1.
OPENA-09
Initialize squatting
Initialize is permissionless on chain (the off-chain allowlist gates only the canonical UI). PDAs are deterministic per [b"pool", token_mint] — there is exactly one possible pool per mint. An attacker who initializes first claims pool.authority for that mint, locking out the canonical operator. Mitigation: deployment script atomically initializes all expected pools first; OR add a SetExpectedAuthority upgradeable governor; OR require an initialization signature from a known root key in Initialize data.
SAFEA-10
Math overflow
Every multiplication uses checked_mul and returns MathOverflow on overflow. Indices grow at a bounded rate; even at 80 % apr, borrow_index reaches u128::MAX only on geological timescales. Tests wad_mul_overflow_returns_err, accrue_full_utilization_maximum_rate, and the index-progression suite (math.rs:286-466) validate boundary behaviour.
SAFEA-11
PDA collision / owner spoofing
Every state-mutating instruction either (a) derives the PDA and compares to the supplied address, or (b) calls verify_binding(owner, pool) after discriminator + size validation. Discriminator + PDA-derivation together imply program ownership — a non-Veil-owned account at the derived PDA is impossible because PDA accounts can only be created with the program as signer.
DEFENSEA-12
Token-program hardening (defense in depth)
Veil's SPL transfers go through pinocchio_token::Transfer, which uses a hardcoded SPL Token program ID for the CPI target. The runtime then requires that program ID to be present in the transaction's account list. This means a malicious caller cannot redirect transfers — a fake account at index [token_program] would simply not be the SPL Token program, so the CPI would fail when the runtime cannot find SPL Token in the tx. Defence: add an explicit accounts[token_program].address() == TOKEN_PROGRAM_ID check up front so failures are surfaced as InvalidAccountOwner instead of an opaque CPI error, and guard against future pinocchio_token API changes.
DEFENSEA-13
Account aliasing on identical positions
If a user supplies the same token account for both user_token and vault, an SPL self-transfer occurs (a no-op for normal token programs). The program would then mutate total_deposits as if a real deposit had happened. Defence: assert accounts[user_token].address() != accounts[vault].address() in deposit/withdraw/borrow/repay/liquidate. SPL token program currently rejects mismatched authority on self-transfers, but defence in depth is cheap.
DEFENSEA-14
Decimal-mismatch in cross-asset HF
Single-asset positions (current design) sidestep this entirely — deposits and debts are denominated in the same token. If v1 introduces multi-asset positions, the HF formula needs decimal normalisation against the oracle-USD value, not raw token amounts. Defence: explicit assertion now that all instructions enforce single-asset denomination via the pool binding check; document the cross-asset extension before implementing it.

13.2On-chain · cross-program vectors

OPENX-01
dWallet liquidation settlement
A position with status LIQUIDATED is bricked: IkaRelease (status check rejects) and IkaSign (status must be Active). The intended recovery — liquidator claims the dWallet's native chain assets — is not yet wired in v0.1. Required for cross-chain mainnet: a dedicated IkaLiquidate instruction or an ownership-transfer pathway.
SAFEX-02
dWallet authority binding
IkaRegister verifies dwallet.discriminator == 2, dwallet.state == 1 (DKG complete), and dwallet.authority == cpi_authority (Veil controls signing). The CPI authority PDA is at [b"__ika_cpi_authority"]. Only Veil can therefore approve signing while the position is open.
DEFENSEX-03
Veil approves signing, not transaction content
IkaSign CPIs approve_message on the Ika program. Veil does not parse the underlying Bitcoin script or Ethereum calldata. The user is expected to construct and broadcast the transaction themselves. A malicious owner can obtain a signature for any valid message digest. Defence: this is the documented model — the protocol authorises signing, not a specific transaction. Front-ends should display the message digest so users review what they sign.

13.3Off-chain · API and database vectors

SAFEO-01
SQL injection
All queries use parameterised tagged-template literals via Neon's driver. No string concatenation builds SQL.
SAFEO-02
Replay / cross-action / cross-pubkey signature reuse
Nonces are 16 random bytes (128-bit), single-use via atomic DELETE … RETURNING, scoped per (pubkey, action), and TTL-expired after 5 minutes. The signed canonical message bakes in both the nonce and the action, so a sig produced for add_admin:X:role cannot be replayed as revoke_admin:X.
DEFENSEO-03
Phishing / domain binding (SIWE-style)
The current canonical message does not include a domain or origin. A phishing site could ask the wallet to sign Veil admin auth ... indistinguishable from the real one. Defence: add an Origin: https://veil.xyz line to the canonical message (EIP-4361 / SIWE pattern). Reject signatures whose canonical message omits or mismatches the expected origin server-side.
DEFENSEO-04
Database compromise leverage
If DATABASE_URL leaks: an attacker can INSERT themselves into pool_admins and bypass the signed-nonce flow entirely. The on-chain pool.authority is independent — existing pools remain governed by their on-chain authorities — but the attacker can register new pools into the canonical index. Defence: rotate DATABASE_URL on team membership changes; treat as Stripe-key-grade secret. Stronger: separate Postgres roles — read-only user for read endpoints, writer that cannot touch pool_admins for writes, dedicated super-admin role for allowlist ops, with column-level ACLs. Strongest: require 2-of-N signed nonces from distinct super-admins for allowlist mutations.
OPENO-05
Rate limiting · DoS surface
No rate limiting on /api/auth/nonce. An attacker can pump the auth_nonces table arbitrarily. The opportunistic GC (DELETE WHERE expires_at < now() on every nonce issuance) caps growth, but the attack still consumes DB resources. Required for mainnet: per-IP and per-pubkey rate limits at the edge (Vercel Edge Config / Cloudflare WAF). Enforce a cap on rows per pubkey in auth_nonces.
DEFENSEO-06
Time-of-check / time-of-use on role
Within a single auth handler the sequence is: (1) verify signature, (2) consume nonce, (3) read role. An admin revoked between steps 2 and 3 is still admitted for that one request. The window is microseconds in practice but exists. Defence: combine the nonce consume and role check into a single SQL statement (DELETE … WHERE EXISTS (SELECT 1 FROM pool_admins …)) — atomic both-or-neither.
SAFEO-07
React XSS surface
The dApp renders pubkeys, labels, and tx signatures from the API. React escapes by default. No usage of dangerouslySetInnerHTML in rendered admin lists.
SAFEO-08
CORS / cross-origin misuse
All /api/*routes are same-origin in the Next.js deployment. No cross-origin CORS headers are set. A malicious site cannot trigger a privileged write because (a) the wallet's signMessage requires user approval, (b) the canonical message is unique per call, (c) the nonce is single-use. Domain binding (O-03) closes the remaining vector.
DEFENSEO-09
Logging / observability hygiene
Server logs may contain pubkeys, nonces, and signatures. None are private secrets, but logging signatures is bad hygiene. Defence: redact signature from logs; never log DATABASE_URL; forward errors to a typed sink (Sentry) rather than stdout.
DEFENSEO-10
Secret exposure in client bundle
DATABASE_URL is server-only; it must never be prefixed with NEXT_PUBLIC_. Defence: a CI lint rule that fails the build if any NEXT_PUBLIC_* var contains the substring postgres or password.

13.4Cryptographic / privacy vectors

SAFEP-01
FHE solvency soundness
The user holds the FHE private key off-chain and could encrypt arbitrary values, but the program only acts on encrypted balances that it has updated homomorphically — starting from the user's own (publicly recorded) plaintext seed at EnablePrivacy. The user cannot unilaterally inflate.
DEFENSEP-02
FHE timing side-channels
Private operations produce transactions with sizes proportional to FHE ciphertext sizes, which leak operation type. Network observers can fingerprint PrivateBorrow versus PrivateRepay by tx size and account count. Defence: pad ciphertext payloads to a uniform size; document this is amount privacy, not behaviour privacy.
SAFEP-03
Nonce entropy
16 random bytes from nacl.randomBytes. Brute force infeasible.
SAFEP-04
ed25519 signature verification
TweetNaCl sign.detached.verify over the canonical message bytes. The verifier rejects 32-byte pubkeys and 64-byte signatures of incorrect length before calling into the curve library.

13.5Hardening checklist before mainnet

MAINNET CHECKLIST
[ ]  pool.authority is a Squads multisig vault PDA
[ ]  Squads is fronted by a governance program with timelock
[ ]  UpdatePool deltas capped per block at the program level
[ ]  Initialize takes expected-pyth-feed in data (closes A-04, A-09)
[ ]  IkaLiquidate / dWallet ownership-transfer instruction shipped (X-01)
[ ]  Per-IP and per-pubkey rate limit on /api/auth/nonce (O-05)
[ ]  Origin baked into canonical signed message (O-03)
[ ]  Postgres least-privilege roles for read / write / super-admin (O-04)
[ ]  Signature redaction in server logs (O-09)
[ ]  CI guard rejecting NEXT_PUBLIC_*postgres* variables (O-10)
[ ]  Atomic role check + nonce consume in one SQL statement (O-06)
[ ]  Token-program-id explicit assertion in every transfer instruction (A-12)
[ ]  user_token != vault assertion in deposit/withdraw/borrow/repay (A-13)
[ ]  Oracle keeper: refresh atomically with sensitive ix (A-07)
[ ]  Audit (third-party, scope: full program + API)
§ 14DELTA14.0

Comparison: Aave V3

PropertyAave V3Veil v0.1
ChainEVMSolana (Pinocchio)
Account modelOne Pool contract w/ many reservesOne PDA per token
Discriminator4-byte function selector1-byte instruction tag
Interest modelTwo-slope kinkTwo-slope kink (identical math)
Index basisRAY (1e27)WAD (1e18)
Health factorΣ collateral × LT / Σ debtcollateral × LT / debt, single-asset position
Close factor50 % default50 % default
Liquidation bonusPer-asset configPer-pool config (default 5 %)
Flash loansflashLoan / Simple, 5 bpsSingle primitive, 9 bps default
Cross-chain collateralWrapped tokensNative via Ika dWallet
PrivacyNoneOptional FHE (REFHE)
AdminPoolAdmin / RiskAdmin / governancepool.authority + optional off-chain allowlist

Two structural differences are worth highlighting. Single-asset positions: a Veil UserPosition is bound to one pool — you can deposit USDC and borrow USDC against it, but not deposit BTC and borrow USDC. Cross-asset positions are a v1 design item. Native vs wrapped cross-chain: Aave-on-Solana via wrapped BTC requires a bridge custody assumption. Veil-via-Ika requires an MPC committee honesty assumption with the user as a co-signer. The trust profile is different in kind, not just degree.

§ 15ROADMAP15.0

Roadmap & Open Items

ITEMSTATUS
Pinocchio core (21 ix)DONE
TypeScript SDKDONE
Off-chain allowlist + signed-nonce authDONE
Neon-backed pool/position cacheDONE
Liquidation UIDONE
Encrypt SDK pinocchio 0.11 wiringPEND
Pyth pull-oracle migrationROAD
Ika dWallet mainnet integrationPEND
IkaLiquidate / dWallet liq settlement (X-01)OPEN
Initialize squat / first-call hijack mitigation (A-04, A-09)OPEN
Origin-bound canonical signing (O-03)v1
Rate limiting · /api/auth/nonce (O-05)PRE-MAINNET
Cross-asset (multi-collateral) positionsv1
AuditPRE-MAINNET
Mainnet deployPRE-MAINNET
§ 16APPENDIX A16.0

Error Codes

All 28 error variants. Source: programs/src/errors.rs:5-62.

CODEVARIANTMEANING
6000MissingSignatureCaller is not a signer when required
6001AccountNotWritableAccount is not writable when required
6002InvalidAccountOwnerAccount owner is not this program
6003InvalidDiscriminatorAccount discriminator does not match
6004InvalidPdaPDA derivation mismatch
6005InvalidInstructionDataInstruction data is malformed
6006ZeroAmountAmount is zero
6007InsufficientLiquidityPool has insufficient liquidity
6008ExceedsCollateralFactorBorrow would exceed LTV
6009UndercollateralisedHF would drop below 1.0
6010PositionHealthyHF ≥ 1.0; liquidation refused
6011ExceedsCloseFactorLiquidation repay > close-factor cap
6012ExceedsDepositBalanceWithdraw > deposit_shares' balance
6013ExceedsDebtBalanceRepay > current debt
6014NoBorrowNo debt to act on
6015MathOverflowArithmetic overflow
6016TransferFailedSPL transfer CPI failed
6017InvalidTimestampClock went backwards
6018FlashLoanActiveFlash already in flight
6019FlashLoanNotActiveFlashRepay without active flash
6020FlashLoanRepayInsufficientRepay < amount + fee
6021UnauthorizedSigner ≠ pool.authority
6022PoolPausedDeposit/Borrow/FlashBorrow blocked
6023NoFeesToCollectaccumulated_fees == 0
6024OracleInvalidPyth account malformed
6025OraclePriceStalePyth status ≠ Trading
6026OraclePriceFeedMismatchAnchored feed mismatch
6027OracleConfTooWidePyth conf > 2 % of price
§ 17APPENDIX B17.0

External Program IDs

PROGRAMIDUSED BY
Ika dWallet87W54kGYFQ1rgWqMeu4XTPHWXWmXSQCcjm8vCTfiq1oYIkaRegister/Sign/Release
Encrypt4ebfzWdKnrnGseuQpezXdG8yCdHqwQ1SSBHD3bWArND8EnablePrivacy + Private*
SPL TokenTokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DAAll token transfers
Associated TokenATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJe1bsnVault ATA derivation
System11111111111111111111111111111111Account creation
§ 18REFS18.0

References

  1. [01]Aave V3Whitepaper. Two-slope kink interest model, close-factor and liquidation-bonus design.
  2. [02]Compound V2Earliest production deployment of the index-based share-accounting model.
  3. [03]PinocchioSolana zero-copy program framework. github.com/febo/pinocchio
  4. [04]Pyth NetworkPush-oracle aggregation across publishers. pyth.network
  5. [05]Ika dWallet protocolProgrammable MPC signing for cross-chain assets. github.com/dwallet-labs/ika
  6. [06]Encrypt FHE / REFHEFully-homomorphic-encryption construction used for amount privacy. docs.encrypt.xyz
  7. [07]Oro / GRAILPhysical-gold settlement layer. docs.grail.oro.finance
  8. [08]EIP-4361 (SIWE)Sign-In with Ethereum — domain-bound canonical message format. Referenced as model for O-03.
  9. [09]Veil program sourceprograms/src/
  10. [10]Veil dApp + APIveil-landing/
  11. [11]Veil docs sitedocs/content/
END OF DOCUMENT

From spec to chain.

PROGRAM REF →INTEGRATION GUIDESECURITYDOCS HOME