HTTP API Reference
All routes live under veil-landing/app/api/**/route.ts and run on the
Node runtime. The Neon HTTP driver (@neondatabase/serverless) is used for
DB access; no WebSocket pool is opened per request.
Conventions
- Request bodies are JSON. Bad JSON returns
400 {"error":"bad json"}. - All authenticated endpoints accept
{actor, nonce, signature, ...payload}. See Authorization Model for the handshake. - Responses are JSON. Errors are
{"error": "<short reason>"}. - Numeric on-chain quantities are returned as strings to preserve u64/u128 fidelity.
Auth
POST /api/auth/nonce
Issue a single-use signed-message nonce. TTL: 5 minutes.
Request
{
"pubkey": "<base58 wallet>",
"action": "<scoped action, e.g. \"add_admin:<pubkey>:<role>\">"
}Response
{
"nonce": "<32-hex-char nonce>",
"message": "Veil admin auth\nAction: <action>\nNonce: <nonce>",
"expiresAt": "2026-04-25T08:39:31.950Z"
}The wallet signs the exact message bytes (UTF-8) with ed25519 detached
signature. The base58-encoded signature goes into the protected endpoint’s
body as signature.
Source: app/api/auth/nonce/route.ts.
Admin
GET /api/admin/me?pubkey=<wallet>
Read-only role lookup for UI gating. Not authoritative — the same role is re-checked server-side on every authenticated endpoint.
Response
{ "role": "super_admin" | "pool_admin" | null }GET /api/admin/allowlist
Public list of currently active admins (revoked admins excluded).
Response
{
"admins": [
{
"pubkey": "...",
"role": "super_admin",
"label": "bootstrap",
"added_by": "system",
"created_at": "2026-04-25T08:26:29.249Z",
"revoked_at": null
}
]
}POST /api/admin/allowlist
Add a wallet to the allowlist. Requires super_admin signed nonce.
The signed action is add_admin:<pubkey>:<role>.
Request
{
"actor": "<super_admin pubkey>",
"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>"
}Response on success
{ "ok": true }Failure cases
| Status | error | Cause |
|---|---|---|
| 400 | missing fields | One of actor/nonce/signature/pubkey absent |
| 400 | invalid role | role ∉ {pool_admin, super_admin} |
| 401 | bad signature | ed25519 verify failed |
| 401 | nonce invalid or expired | Nonce already consumed or > 5 min old |
| 401 | not authorized | Actor not in pool_admins (or revoked) |
| 401 | super_admin required | Actor is pool_admin, not super_admin |
GET /api/admin/audit?actor=<pubkey>&action=<action>&limit=50
Read the admin audit log. Public on devnet/localnet; on mainnet the response
is gated behind a signed nonce (same actor / nonce / signature headers
as the allowlist endpoints) and otherwise returns 403 forbidden.
Filters: actor (admin pubkey), action (e.g. init_pool, update_pool,
pause, collect_fees, add_admin, revoke_admin). limit defaults 50,
max 200.
Response
{
"entries": [
{
"id": 42,
"actor": "...",
"action": "update_pool",
"target": "...pool...",
"details": { "field": "value" },
"created_at": "..."
}
]
}DELETE /api/admin/allowlist
Soft-revoke a wallet (sets revoked_at = now()). Requires super_admin
signed nonce. Signed action: revoke_admin:<pubkey>.
Request
{
"actor": "<super_admin pubkey>",
"nonce": "...",
"signature": "...",
"pubkey": "<wallet to revoke>"
}The endpoint rejects actor == pubkey to prevent self-lockout.
Pools
GET /api/pools
Cached pool index (refreshed via /api/pools/sync).
Response
{
"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": "..."
}
]
}POST /api/pools/init
Register an on-chain pool that the actor just initialised. Requires the
actor to be on the allowlist with role pool_admin or super_admin.
Signed action: init_pool:<token_mint>.
Request
{
"actor": "...",
"nonce": "...",
"signature": "...",
"pool_address": "<derived PDA>",
"token_mint": "...",
"symbol": "USDC" | null,
"authority": "<actor>",
"vault": "<derived ATA>",
"pool_bump": 254,
"authority_bump": 255,
"vault_bump": 0,
"init_signature": "<solana tx sig>"
}The endpoint is idempotent on pool_address (ON CONFLICT DO NOTHING).
A row in audit_log is written regardless of conflict outcome.
POST /api/pools/sync
Refresh a pool’s cached row from chain. Public.
Request
{ "pool_address": "<PDA>", "symbol": "USDC" }The server calls getAccountInfo, decodes the bytes via the same TS
decoder used by the dApp (lib/veil/state.ts::decodeLendingPool), and
upserts the result into pools.
Response
{ "ok": true, "pool_address": "..." }Errors: 400 bad pubkey, 404 pool account not found.
Positions
GET /api/positions/[user]
Cached positions for a wallet, sorted by last_synced_at DESC.
Response
{
"positions": [
{
"position_address": "...",
"pool_address": "...",
"owner": "<user>",
"deposit_shares": "0",
"borrow_principal": "0",
"deposit_idx_snap": "1000000000000000000",
"borrow_idx_snap": "1000000000000000000",
"health_factor_wad": "...",
"last_synced_at": "..."
}
]
}Position rows are populated by indexers / liquidator bots, not by the dApp flow today. The schema and endpoints exist so an indexer can write here and the liquidator UI can scan unhealthy positions efficiently.
GET /api/positions/[user]/detail
Enriched per-position view: joins positions ⨯ pools ⨯ recent tx_log,
and computes deposit/debt token values, principal+interest split, supply/borrow
APYs, and the account-level health factor across cross-collateralized pools.
Response (per position)
{
"positions": [
{
"pool_address": "...",
"symbol": "USDC",
"deposit_shares": "0",
"borrow_principal": "1500000000",
"deposit_tokens": "0",
"borrow_debt": "1500004153",
"original_deposit": "0",
"principal": "1500000000",
"interest": "4153",
"supply_apy": 2.8,
"borrow_apy": 5.5,
"borrow_txs": [{ "signature": "...", "amount": "...", "created_at": "..." }],
"health_factor_wad": "625004153360933801"
}
]
}The health_factor_wad is the account-level HF (Σ collateral × liq_threshold ÷ Σ debt) replicated on every row, not per-pool. This is the same value used by /api/positions/unhealthy.
POST /api/positions/sync
Refresh a single (user, pool) position from on-chain into the DB. Reads the
on-chain UserPosition and LendingPool accounts via the server-side RPC
(serverRpcUrl() from lib/network.ts — never user-supplied), recomputes the
HF, and upserts. The dApp calls this after every successful action so the
indexed views stay current.
Request
{ "pool_address": "...", "user": "..." }Response
{ "ok": true, "exists": true, "position_address": "..." }If the position no longer exists on-chain, the row is removed from the DB and
exists returns false. Both accounts must be owned by the Veil program;
otherwise the call returns 400 invalid.
GET /api/positions/unhealthy?pool=<pool>&limit=50
Return all positions belonging to wallets whose account-level HF is below 1.0 WAD (i.e. liquidatable). Sorted by HF ascending so the most underwater positions come first. Both deposit and borrow rows for an unhealthy owner are returned so a liquidator UI can show the cross-collateral picture.
limit defaults to 50 and is clamped to 200. Filtering by pool keeps only
debt rows in the named pool.
Response
{
"positions": [
{
"position_address": "...",
"pool_address": "...",
"owner": "...",
"deposit_shares": "5000000",
"borrow_principal": "0",
"health_factor_wad": "625004153360933801",
"account_health_factor_wad": "625004153360933801"
}
]
}The HF math uses pools.oracle_price, oracle_expo, liquidation_threshold_wad,
and decimals columns. decimals must be populated correctly per pool —
the setup scripts pass it on INSERT; legacy rows that defaulted to 9 will
miscompute USD amounts by 10× (BTC) or 1000× (USDC/USDT) and leave underwater
positions invisible to this endpoint.
Transactions
GET /api/transactions?wallet=<pubkey>&pool=<pool>&limit=50
Append-only tx log. Filters: wallet, pool, limit (max 200, default 50).
Response
{
"transactions": [
{
"id": 42,
"signature": "...",
"pool_address": "...",
"wallet": "...",
"action": "deposit" | "withdraw" | "borrow" | "repay" |
"liquidate" | "flash" | "init" | "update_pool" |
"pause" | "resume" | "collect_fees" | "update_oracle",
"amount": "1000000",
"status": "pending" | "confirmed" | "failed",
"error_msg": null,
"created_at": "..."
}
]
}POST /api/transactions
Append a confirmed (or failed) transaction. Idempotent on signature
(ON CONFLICT DO UPDATE with the new status). Called by the dApp after
the wallet returns a tx signature.
Request
{
"signature": "...",
"wallet": "...",
"action": "deposit",
"pool_address": "...",
"amount": "1000000",
"status": "confirmed",
"error_msg": null
}Health
GET /api/health
System liveness probe. Returns DB latency, the current RPC slot, and counts
of pools / tx_log rows under the active cluster. Used by uptime checks
and as the first-debug step when something looks wrong end-to-end.
Response
{
"status": "ok",
"timestamp": "2026-05-02T17:00:00.000Z",
"checks": {
"database": { "ok": true, "latencyMs": 38 },
"rpc": { "ok": true, "slot": 452800123 },
"counts": { "pools": 5, "tx_log": 142 }
}
}If any sub-check fails, status: "degraded" is returned with 200 OK so a
caller can distinguish “infra reachable but unhealthy” from “veil API itself down”.
Error code mapping
API errors map to short, redacted strings to avoid leaking implementation detail. The set is closed:
Returned error | HTTP | Where it comes from |
|---|---|---|
bad json | 400 | req.json() rejected |
missing fields | 400 | required fields absent |
pubkey and action required | 400 | nonce endpoint |
invalid pubkey | 400 | length/charset check |
invalid role | 400 | role validation |
bad pubkey: <reason> | 400 | new PublicKey() threw |
cannot revoke yourself | 400 | self-lockout guard |
pool account not found | 404 | getAccountInfo returned null |
pool_address required | 400 | sync endpoint |
signature, wallet, action required | 400 | tx log endpoint |
bad signature | 401 | TweetNaCl verify failed |
nonce invalid or expired | 401 | auth_nonces row absent or stale |
not authorized | 401 | actor not in pool_admins |
super_admin required | 401 | role mismatch |
Source: app/api/**/route.ts, lib/auth/admin.ts.