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.
programs/src/ with the cited path. Nothing is paraphrased without source. Defensive findings are surfaced with severity tags.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
healthy? from the FHE evaluator, never the underlying amounts.Design Principles
| Principle | Concretely |
|---|---|
| Native, not synthetic, collateral | Ika dWallets remain the user's; Veil controls signing while the position is open. Source: programs/src/instructions/ika_register.rs. |
| Plaintext solvency, optional opacity | UserPosition is always authoritative. EncryptedPosition is a parallel mirror. Health checks never depend on encrypted state being decryptable. |
| Compute-bounded | Hot 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 be | Liquidation, oracle refresh, and all user state mutations are permissionless. Pool initialisation is curated through an off-chain allowlist. |
| Atomic state machines for risky primitives | Flash loans use a single-tx in-flight counter; missing repay reverts everything. |
| Source-grounded documentation | Every claim cites the on-chain Rust file. Drift between code and docs is a bug. |
System Overview
3.1Components
┌──────────────────────────────────────────────────────────────────────┐
│ 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
| Role | What they can do | Enforcement |
|---|---|---|
| User | Deposit, withdraw, borrow, repay, enable privacy | On-chain: signer == UserPosition.owner |
| Liquidator | Repay debt of unhealthy positions and seize collateral | Permissionless on-chain (only HF condition) |
| Pool Admin | Initialize a new pool; update params, pause/resume, collect fees on pools they authorise | On-chain: signer == pool.authority. Off-chain: must be in pool_admins to use the curated UI for Initialize. |
| Super-admin | Curate the off-chain allowlist (add/revoke pool admins) | Off-chain only: pool_admins.role == 'super_admin', signed nonce verified server-side. |
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.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.
| OFF | SZ | FIELD | TYPE / NOTES |
|---|---|---|---|
| 0x000 | 8 | discriminator | b"VEILPOOL" |
| 0x008 | 32 | authority | Address — admin set at Initialize |
| 0x028 | 32 | token_mint | Address |
| 0x048 | 32 | vault | SPL ATA owned by PoolAuthority PDA |
| 0x068 | 8 | total_deposits | u64; virtual; grows with depositor interest |
| 0x070 | 8 | total_borrows | u64; virtual; grows with borrower interest |
| 0x078 | 8 | accumulated_fees | u64; protocol reserves owed to authority |
| 0x080 | 8 | last_update_timestamp | i64; Unix seconds |
| 0x088 | 1 | authority_bump | u8 |
| 0x089 | 1 | pool_bump | u8 |
| 0x08A | 1 | vault_bump | u8 |
| 0x08B | 1 | paused | u8 — 0 active, 1 paused |
| 0x090 | 16 | borrow_index | u128 WAD; init = WAD |
| 0x0A0 | 16 | supply_index | u128 WAD; init = WAD |
| 0x0B0..0x140 | 16×9 | rate + risk params | base_rate, optimal_util, slope1/2, reserve_factor, ltv, liq_threshold, liq_bonus, protocol_liq_fee, close_factor |
| 0x150 | 8 | flash_loan_amount | u64; non-zero ⇔ flash in flight |
| 0x158 | 8 | flash_fee_bps | u64; default 9 bps |
| 0x160 | 32 | pyth_price_feed | Address; anchored on first update |
| 0x180..0x190 | 20 | oracle_price · oracle_conf · oracle_expo | Last 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.
| OFF | SZ | FIELD |
|---|---|---|
| 0x00 | 8 | discriminator = b"VEILPOS!" |
| 0x08 | 32 | owner |
| 0x28 | 32 | pool |
| 0x48 | 8 | deposit_shares |
| 0x50 | 8 | borrow_principal |
| 0x60 | 16 | deposit_index_snapshot |
| 0x70 | 16 | borrow_index_snapshot |
| 0x80 | 1 | bump |
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.
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
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)
⎧ R₀ + (U / U_opt) × S₁ if U ≤ U_opt
borrow_rate =⎨
⎩ R₀ + S₁ + ((U − U_opt) / (1 − U_opt)) × S₂ if U > U_optVerified by tests borrow_rate_at_kink, borrow_rate_above_kink_full, borrow_rate_monotonically_increasing (math.rs:374-417).
| UTIL | BORROW RATE (DEFAULT) |
|---|---|
| 0 % | 1 % |
| 40 % | 3 % |
| 80 % (kink) | 5 % |
| 90 % | 42.5 % |
| 100 % | 80 % |
5.3Supply rate
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:
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
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
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
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.
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).
| DISC | INSTRUCTION | SIGNER | ON-CHAIN AUTH CHECK |
|---|---|---|---|
| 0x00 | Initialize | payer + authority | none — caller becomes pool authority |
| 0x01 | Deposit | user | user == position.owner; pool.paused == 0 |
| 0x02 | Withdraw | user | user == position.owner; HF ≥ 1 if debt > 0 |
| 0x03 | Borrow | user | owner; LTV cap; HF ≥ 1; not paused |
| 0x04 | Repay | user | owner |
| 0x05 | Liquidate | liquidator | borrower HF < 1 |
| 0x06 | FlashBorrow | borrower | not paused; no active flash |
| 0x07 | FlashRepay | borrower | active flash; repay ≥ amount + fee |
| 0x08 | EnablePrivacy | user | user == position.owner |
| 0x09 | PrivateDeposit | user | as plaintext + binding |
| 0x0A | PrivateBorrow | user | as plaintext + binding |
| 0x0B | PrivateRepay | user | as plaintext + binding |
| 0x0C | PrivateWithdraw | user | as plaintext + binding |
| 0x0D | UpdatePool | authority | signer == pool.authority |
| 0x0E | PausePool | authority | ditto |
| 0x0F | ResumePool | authority | ditto |
| 0x10 | CollectFees | authority | ditto |
| 0x11 | IkaRegister | user | dwallet.authority == Veil CPI PDA |
| 0x12 | IkaRelease | user | signer == ika_position.owner |
| 0x13 | IkaSign | user | owner; status == Active |
| 0x14 | UpdateOraclePrice | — | feed match if anchored |
Veil splits authorization into two independent layers that must both be satisfied to administer a canonical pool through the curated UI.
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.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
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".Oracle Subsystem
Veil reads Pyth legacy push-oracle accounts directly without the Pyth SDK. The per-call validation pipeline:
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.
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.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.
| Hidden | Not hidden |
|---|---|
| Post-EnablePrivacy deposit / borrow / repay / withdraw amounts | That an EncryptedPosition PDA exists |
| Current encrypted balances | That 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.
execute_graph CPIs are stubbed pending the SDK update. The plaintext path is fully functional today.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.
| CURVE | VAL | USE |
|---|---|---|
| SECP256K1 | 0 | Bitcoin, Ethereum |
| SECP256R1 | 1 | WebAuthn |
| CURVE25519 | 2 | Solana, Ed25519 |
| RISTRETTO | 3 | Substrate / 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.
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.Off-Chain Infrastructure
11.1Web stack
| Route | Purpose |
|---|---|
| / | Marketing landing |
| /dapp | Markets — deposit / borrow / repay / withdraw / flash |
| /dapp/liquidate | Permissionless liquidation UI |
| /dapp/admin | Allowlisted admin panel (Manage / Initialize / Allowlist) |
| /workflow | End-to-end actor & instruction overview |
| /whitepaper | Visual whitepaper (marketing / docs) |
| /api/* | HTTP API — see §12 |
11.2Postgres tables (Neon)
| TABLE | PURPOSE |
|---|---|
| pool_admins | Off-chain allowlist (pool_admin, super_admin) |
| pools | Cached on-chain LendingPool state |
| positions | Cached UserPosition snapshots with derived health_factor_wad |
| tx_log | Append-only signature log keyed on Solana tx signature |
| audit_log | Admin actions (allowlist edits, pool inits, fee collections) |
| auth_nonces | Single-use ed25519 nonces, 5-min TTL |
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
| VERB | PATH | PURPOSE | AUTH |
|---|---|---|---|
| GET | /api/admin/me?pubkey=… | Role lookup for UI gating | none |
| POST | /api/auth/nonce | Issue single-use signed-message nonce (5 min TTL) | none |
| GET | /api/admin/allowlist | List active admins | none |
| POST | /api/admin/allowlist | Add admin | super-admin |
| DELETE | /api/admin/allowlist | Revoke admin (cannot revoke self) | super-admin |
| GET | /api/pools | Cached pool index | none |
| POST | /api/pools/init | Register a freshly initialised pool | allowlisted |
| POST | /api/pools/sync | Refresh a pool's cache from chain | none |
| GET | /api/positions/[user] | Cached positions for a wallet | none |
| GET | /api/transactions | Tx log (filter by wallet or pool, max 200) | none |
| POST | /api/transactions | Append confirmed/failed tx (idempotent on signature) | none |
12.2Authenticated request envelope
Authenticated endpoints take a common preamble plus an action-specific payload:
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
{
"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:
| HTTP | ERROR | WHERE |
|---|---|---|
| 400 | bad json | req.json() rejected |
| 400 | missing fields | required fields absent |
| 400 | pubkey and action required | /api/auth/nonce |
| 400 | invalid pubkey | length / charset |
| 400 | invalid role | role validation |
| 400 | bad pubkey: <reason> | new PublicKey() threw |
| 400 | cannot revoke yourself | self-lockout guard |
| 404 | pool account not found | getAccountInfo returned null |
| 400 | pool_address required | /api/pools/sync |
| 400 | signature, wallet, action required | /api/transactions |
| 401 | bad signature | TweetNaCl verify failed |
| 401 | nonce invalid or expired | auth_nonces row absent / stale |
| 401 | not authorized | actor not in pool_admins |
| 401 | super_admin required | role mismatch |
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
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.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.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.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.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.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.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.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.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.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.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.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.13.2On-chain · cross-program vectors
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.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.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
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.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.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./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.DELETE … WHERE EXISTS (SELECT 1 FROM pool_admins …)) — atomic both-or-neither.dangerouslySetInnerHTML in rendered admin lists./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.signature from logs; never log DATABASE_URL; forward errors to a typed sink (Sentry) rather than stdout.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
EnablePrivacy. The user cannot unilaterally inflate.nacl.randomBytes. Brute force infeasible.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
[ ] 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)
Comparison: Aave V3
| Property | Aave V3 | Veil v0.1 |
|---|---|---|
| Chain | EVM | Solana (Pinocchio) |
| Account model | One Pool contract w/ many reserves | One PDA per token |
| Discriminator | 4-byte function selector | 1-byte instruction tag |
| Interest model | Two-slope kink | Two-slope kink (identical math) |
| Index basis | RAY (1e27) | WAD (1e18) |
| Health factor | Σ collateral × LT / Σ debt | collateral × LT / debt, single-asset position |
| Close factor | 50 % default | 50 % default |
| Liquidation bonus | Per-asset config | Per-pool config (default 5 %) |
| Flash loans | flashLoan / Simple, 5 bps | Single primitive, 9 bps default |
| Cross-chain collateral | Wrapped tokens | Native via Ika dWallet |
| Privacy | None | Optional FHE (REFHE) |
| Admin | PoolAdmin / RiskAdmin / governance | pool.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.
Roadmap & Open Items
| ITEM | STATUS |
|---|---|
| Pinocchio core (21 ix) | DONE |
| TypeScript SDK | DONE |
| Off-chain allowlist + signed-nonce auth | DONE |
| Neon-backed pool/position cache | DONE |
| Liquidation UI | DONE |
| Encrypt SDK pinocchio 0.11 wiring | PEND |
| Pyth pull-oracle migration | ROAD |
| Ika dWallet mainnet integration | PEND |
| 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) positions | v1 |
| Audit | PRE-MAINNET |
| Mainnet deploy | PRE-MAINNET |
Error Codes
All 28 error variants. Source: programs/src/errors.rs:5-62.
| CODE | VARIANT | MEANING |
|---|---|---|
| 6000 | MissingSignature | Caller is not a signer when required |
| 6001 | AccountNotWritable | Account is not writable when required |
| 6002 | InvalidAccountOwner | Account owner is not this program |
| 6003 | InvalidDiscriminator | Account discriminator does not match |
| 6004 | InvalidPda | PDA derivation mismatch |
| 6005 | InvalidInstructionData | Instruction data is malformed |
| 6006 | ZeroAmount | Amount is zero |
| 6007 | InsufficientLiquidity | Pool has insufficient liquidity |
| 6008 | ExceedsCollateralFactor | Borrow would exceed LTV |
| 6009 | Undercollateralised | HF would drop below 1.0 |
| 6010 | PositionHealthy | HF ≥ 1.0; liquidation refused |
| 6011 | ExceedsCloseFactor | Liquidation repay > close-factor cap |
| 6012 | ExceedsDepositBalance | Withdraw > deposit_shares' balance |
| 6013 | ExceedsDebtBalance | Repay > current debt |
| 6014 | NoBorrow | No debt to act on |
| 6015 | MathOverflow | Arithmetic overflow |
| 6016 | TransferFailed | SPL transfer CPI failed |
| 6017 | InvalidTimestamp | Clock went backwards |
| 6018 | FlashLoanActive | Flash already in flight |
| 6019 | FlashLoanNotActive | FlashRepay without active flash |
| 6020 | FlashLoanRepayInsufficient | Repay < amount + fee |
| 6021 | Unauthorized | Signer ≠ pool.authority |
| 6022 | PoolPaused | Deposit/Borrow/FlashBorrow blocked |
| 6023 | NoFeesToCollect | accumulated_fees == 0 |
| 6024 | OracleInvalid | Pyth account malformed |
| 6025 | OraclePriceStale | Pyth status ≠ Trading |
| 6026 | OraclePriceFeedMismatch | Anchored feed mismatch |
| 6027 | OracleConfTooWide | Pyth conf > 2 % of price |
External Program IDs
| PROGRAM | ID | USED BY |
|---|---|---|
| Ika dWallet | 87W54kGYFQ1rgWqMeu4XTPHWXWmXSQCcjm8vCTfiq1oY | IkaRegister/Sign/Release |
| Encrypt | 4ebfzWdKnrnGseuQpezXdG8yCdHqwQ1SSBHD3bWArND8 | EnablePrivacy + Private* |
| SPL Token | TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA | All token transfers |
| Associated Token | ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJe1bsn | Vault ATA derivation |
| System | 11111111111111111111111111111111 | Account creation |
References
- [01]Aave V3 — Whitepaper. Two-slope kink interest model, close-factor and liquidation-bonus design.
- [02]Compound V2 — Earliest production deployment of the index-based share-accounting model.
- [03]Pinocchio — Solana zero-copy program framework. github.com/febo/pinocchio
- [04]Pyth Network — Push-oracle aggregation across publishers. pyth.network
- [05]Ika dWallet protocol — Programmable MPC signing for cross-chain assets. github.com/dwallet-labs/ika
- [06]Encrypt FHE / REFHE — Fully-homomorphic-encryption construction used for amount privacy. docs.encrypt.xyz
- [07]Oro / GRAIL — Physical-gold settlement layer. docs.grail.oro.finance
- [08]EIP-4361 (SIWE) — Sign-In with Ethereum — domain-bound canonical message format. Referenced as model for O-03.
- [09]Veil program source — programs/src/
- [10]Veil dApp + API — veil-landing/
- [11]Veil docs site — docs/content/