This document provides an overview of the Euler Lite system architecture, including the high-level design, component structure, and key architectural decisions.
┌─────────────────────────────────────────────────────────────────┐
│ User Interface Layer │
├─────────────────────────────────────────────────────────────────┤
│ Application Logic Layer │
├─────────────────────────────────────────────────────────────────┤
│ Integration Layer │
├─────────────────────────────────────────────────────────────────┤
│ External Services Layer │
└─────────────────────────────────────────────────────────────────┘
- User Interface Layer: Vue components, pages, and UI elements
- Application Logic Layer: Composables, business logic, and state management
- Integration Layer: API clients, blockchain interactions, and external service adapters
- External Services Layer: Euler Finance, EVM chains, and third-party APIs
The application follows Vue 3's Composition API pattern, organizing code into logical, reusable composables:
┌─────────────────────────────────────────────────────────────────┐
│ Pages │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Index │ │ Borrow │ │ Portfolio │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ Components │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Layout │ │ Vault │ │ UI │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ Composables │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ useVaults │ │useEulerAcct │ │ useWallets │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ Entities │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Vault │ │ Account │ │ Rewards │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────┘
- Separation of Concerns: Clear separation between UI, business logic, and data
- Composability: Reusable composables for shared functionality
- Type Safety: Full TypeScript integration for better development experience
- Reactive State: Vue 3 reactivity system for state management
- Modular Design: Well-defined boundaries between different system parts
- Directory-based Modules: Large composables and UI workflow helpers are split into focused modules within directories (e.g.,
utils/vault/,composables/repay/). Protocol transaction planning flows throughuseEulerTx.ts, and SDK entities are imported directly from@eulerxyz/euler-v2-sdk - Structured Logging: a single
loggerAPI from~/utils/logger(console-backed shim, used by shared code) and~/server/utils/logger(pino, JSON to stdout for BetterStack on Fargate). Errors passed inerrare summarised by~/utils/viem-errorsso viem'sabi/metaMessages/ hex request bodies never leak.~/utils/errorHandlingexposes a smalllogWarnhelper for the long tail of client-side call sites; it routes through the same shared logger
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ External │───▶│ Composables │───▶ │ Components │
│ Services │ │ │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
▲ │ │
│ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Entities │◀───│ State │◀───│ UI State │
│ │ │ Management │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
- External Data Fetching: Composables fetch data from external services
- State Transformation: Raw data is transformed into application entities
- Reactive Updates: UI automatically updates when state changes
- Caching Strategy: Smart caching to minimize API calls
- Error Handling: Graceful error handling with user feedback
┌─────────────────────────────────────────────────────────────────┐
│ Nuxt.js 4 │
├─────────────────────────────────────────────────────────────────┤
│ Vue 3 + Composition API │
├─────────────────────────────────────────────────────────────────┤
│ TypeScript + SCSS │
├─────────────────────────────────────────────────────────────────┤
│ ESLint (flat config) + Vitest (unit/integration) │
├─────────────────────────────────────────────────────────────────┤
│ simple-git-hooks + lint-staged (pre-commit) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Wagmi / Reown │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ useWagmi │ │ Reown │ │ Wallet │ │
│ │ │ │ AppKit │ │ Connect │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ EVM (Viem + Multicall) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ EVC Batch │ │ Pyth Oracle │ │ Simulation │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ Euler Finance │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Vault Lens │ │ Account │ │ Interest │ │
│ │ │ │ Lens │ │ Rate Model │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Why Nuxt.js 4?
- SSR Disabled: Client-side only SPA, SSR is not needed
- Auto-imports: Reduces boilerplate and improves developer experience
- File-based Routing: Simple and intuitive routing system
- Built-in Optimizations: Automatic code splitting and optimization
Why Composition API?
- Better Logic Reuse: Composables can be shared across components
- TypeScript Support: Better type inference and type safety
- Tree-shaking: Unused code can be eliminated during build
Why Composables over Pinia?
- Lightweight: No additional state management library needed
- Vue Native: Built into Vue 3, no external dependencies
- Flexibility: Can implement custom state management patterns
- Performance: Direct Vue reactivity, no additional overhead
Why TypeScript?
- Type Safety: Catches errors at compile time
- Better IDE Support: Enhanced autocomplete and refactoring
- Documentation: Types serve as living documentation
- Team Collaboration: Easier for multiple developers to work together
┌─────────────────────────────────────────────────────────────────┐
│ Application │
├─────────────────────────────────────────────────────────────────┤
│ Integration Layer │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Wagmi/Viem │ │ Rewards API │ │ Composables │ │
│ │ Client │ │ Client │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ Server-Side Proxy Layer (Nuxt server/) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ /api/internal│ │ /api/internal/pyth │ │ /api/internal/public │ │
│ │ /token-list │ │ /updates │ │ -labels, /rpc/[chain]│ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ External Services │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Euler V3 / │ │ EVM RPC │ │ Merkl / │ │
│ │ Pyth Hermes │ │ (multi- │ │ Brevis / │ │
│ │ / DefiLlama │ │ chain) │ │ Fuul │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Server-Side Proxy Layer: External data sources (token lists, Pyth Hermes, Public Labels, Data V3 oracle assessments, RPC) are reached through Nuxt server endpoints rather than called directly from the browser. This provides caching, rate limiting, CORS avoidance, and keeps credentials server-side. Vault reads use the Euler SDK from the client layer. See Development Guide - Server-Side Data Proxies for the full endpoint reference.
Proxy cache strategy:
| Endpoint | TTL | Notes |
|---|---|---|
/api/internal/public-labels |
5 min | One chain/version-scoped Public Labels V3 bundle; in-flight dedup and bounded stale fallback |
/api/internal/liquidation-coverage |
5 min | One chain's default liquidation bot coverage (LIQUIDATION_BOT_API_URL); last answer kept 30 min, then no-bot. Badge is borrow-side only; unknown coverage renders nothing. See Server-Side Caching |
/api/internal/token-list |
5 min | Checks the current configured AppKit/RPC chain set before cache or upstream work; four sources merged via Promise.allSettled (Euler SDK, DefiLlama, Uniswap, Merkl), with per-source cache and stale fallback. Browser registry visibility rules do not narrow server admission; see Token List |
/api/internal/v3/oracles/adapter-assessments/{address}?chainId={chainId} |
request-scoped | Lazy per-address Data V3 assessment fetch through the exact SDK browser endpoint allowlist |
/api/internal/euler-chains |
5 min | Static chain-agnostic config from euler-interfaces repo. 7-day stale window so a running instance outlives upstream outages |
/api/internal/abis/{contract} |
5 min | Runtime ABI documents from euler-interfaces (AccountLens/VaultLens/UtilsLens allowlist; SDK setQueryABI target). Same 7-day stale window as euler-chains |
/api/internal/vaults |
2 min (V3) / 5 min (no V3) | Pre-computed chain vault snapshot. Handler is read-only — no request-triggered refresh; warm-cache rewrites at the same cadence as the TTL |
/api/internal/proxy/{merkl,fuul,incentra}/{path} |
60 s | Rewards-provider proxies built with createProviderProxy; allowlists, credentials and browser hints in Server-Side Caching |
/api/internal/proxy/turtle/{path} |
none (no-store) |
Same factory; per-wallet proofs bypass the cache and the server-only Turtle key is required (503 without it) |
/api/internal/proxy/subgraph/{chainId} |
none (no-store) |
Same-origin proxy to the chain's explicit subgraph URL or selected catalog endpoint; POST only; in-flight coalescing only; 8 MiB decoded response cap and 10 s total fetch/body deadline; budget failures return 502 |
/api/internal/proxy/aave |
none (no-store) |
Aave migration markets; POST only; exact document and single-chain variables; in-flight coalescing only; 8 MiB / 10 s total response budget |
/api/internal/proxy/morpho |
none (no-store) |
Morpho migration markets and wallet positions; POST only; exact documents and bounded variables; in-flight coalescing only; 8 MiB / 10 s total response budget |
/api/internal/proxy/intrinsic-apy-overrides |
5 min | Lite overlay aggregator for HyperEVM (999) / Monad (143); cache key is chainId only |
/api/internal/v3/{...path} |
request-scoped | Rate-limited proxy for the exact SDK browser V3 endpoint allowlist; no TTL — V3 manages its own caching |
Every cacheable proxy above uses a TTL cache for fresh hits and in-flight request deduplication so concurrent cache-miss callers (e.g. warm-cache racing real traffic) collapse onto a single upstream fetch per cache key. The in-flight dedup pattern itself is a shared util — createInFlightDedup / scheduleBackgroundRefresh in server/utils/in-flight.ts. The rewards-provider proxies are one createProviderProxy pipeline (server/utils/provider-proxy.ts) over the shared forwarder server/utils/external-proxy.ts; the rules every provider proxy follows are documented once in Server-Side Caching. Most cacheable proxies also return stale data on upstream failure. The intrinsic-APY overlay is a separate aggregator with chain-keyed TTL and in-flight coalescing; it does not use that forwarder and does not stale-fallback a previous successful payload. See Intrinsic APY.
server/plugins/warm-cache.ts pre-populates the Public Labels bundle, its temporary effective-policy overlay, the token list and (when LIQUIDATION_BOT_API_URL is set) liquidation bot coverage for every enabled chain, plus Euler Chains and runtime ABI manifests once globally, on a 5-minute cycle. The vault snapshot runs on its own faster timer (1 minute when V3 is configured, 5 minutes otherwise). Warm calls refresh the shared utilities directly; concurrent requests deduplicate against the same in-flight work, and stale fallback is bounded by createTtlCache.
For the full setup — per-host proxies, vault snapshot pipeline, two-pass client hydration, V3-conditional cadence, and the bigint wire codec — see Server-Side Caching.
/api/internal/vaults?chainId=X serves a pre-computed snapshot of the public vault set for a chain: every EVault, Earn vault, Securitize vault, and referenced escrow vault, with all on-chain state (caps, rates, LTV matrices, oracle prices) already resolved. Per-user data (balances, positions, collateral flags) is not in this snapshot — the client fetches it separately after wallet connect via useEulerAccount.
The client composable useVaults.loadVaults() runs in two phases:
- Hydrate (
~100 ms):$fetch('/api/internal/vaults?chainId=X'), deserialise via the bigint codec, instantiate each vault into its SDK class (new EVault(args)etc.), then runpopulateCollaterals/populateStrategyVaultsagainst a registry-backedIVaultMetaServicestub (utils/sdk-vault-meta-stub.ts) so cross-references resolve to the same EVault instances already in the registry (no RPC). FlipisReady=true; UI renders a fully populatedborrowListimmediately. - Fresh RPC pass (
~3-6 s): the existing batched lens pipeline (fetchVaults/fetchEarnVaults/fetchSecuritizeVault/fetchEscrowVault) runs against the client's RPC with Pyth simulation, overwriting registry entries with live prices and rates in silent mode (loading flags stay false).
The public interface of useVaults() is unchanged — the 15 exports (isReady, borrowList, getVault, etc.) keep their names, types, and semantics. Vault entities are SDK-owned (EVault, EulerEarn, SecuritizeCollateralVault), while Lite keeps UI-only categorization, LTV, APY, collateral discovery, and presentation helpers under utils/vault/.
The wire payload uses the bigint codec at utils/snapshot-codec.ts: bigints serialise as { __bi: "<decimal>" } (object-wrapper tag, unforgeable by adversary-controlled ERC-20 metadata). The server-side SDK builder at server/utils/sdk-server.ts instantiates one EulerSDK per chain (lazy, cached at module scope) with the default 'fallback' adapter chain — V3 primary, onchain secondary when V3 is configured; pure onchain otherwise. That builder talks to RPC_URL_<chainId> through the SDK provider, using registry viemName resolution with a smaller Multicall aggregate configured for Sonic (146); every other chain keeps the SDK transport defaults. See Server-Side Caching → Server provider overrides.
Reward APRs and account claimable rewards are SDK-owned data. Lite reads reward campaigns through SDK-populated vault entities and reads claimable user rewards from portfolio.account.userRewards. Claim plans are built through sdk.rewardsService.buildClaimPlan (composables/useSdkRewards.ts) and submitted as reviewed-execution reward-claim intents. useEulerTx() is planning and simulation only — see Reviewed Execution Inventory and Transaction Building.
The SDK's default rewards adapter reads V3 reward APIs and uses direct provider reads where V3 does not expose claim-specific helper data. Lite's provider feature flags are passed into SDK config as rewardsEnableMerkl, rewardsEnableBrevis, rewardsEnableFuul, and rewardsEnableTurtle only when a provider is disabled. Display formatting, provider labels, sorting, the Settings Rewards toggle (enableRewardsApy), and transaction review remain in Lite. Display-toggle and spy-mode viewer rules are in Projected Yield; claim snapshots (including Brevis epoch identity) are in SDK Integration. rEUL vesting unlock is a separate reviewed-execution path: rEUL Unlocks.
The Explore page (pages/explore/index.vue) provides a market discovery interface that groups vaults into logical markets.
The useMarketGroups composable (composables/useMarketGroups.ts) implements a hybrid grouping algorithm:
- Product-label groups — Vaults are first assigned to groups using versioned Public Labels product metadata from V3. Each product defines its managing entity, optional display-only co-brands, name, and assigned vaults. Curator ownership and governance verification use only the managing entity.
- Collateral graph augmentation — For each group, external collateral vaults (referenced by member vaults but not in the group) are resolved and attached.
- Orphan clustering — Vaults not assigned to any product are clustered using a BFS connected-component algorithm over their collateral relationships. This produces "Ungrouped" markets.
- Async TVL resolution — Group metrics (TVL, available liquidity, borrowed) are resolved asynchronously using USD pricing.
A grouped product is not automatically listed. groupHasExplorableMarket drops collateral-only products (no lend-open member and no borrowable, borrow-open member). Explore then applies isExploreListedMarket once market data resolves: the card stays only while a member is a Lend-table vault or in a Borrow-table pair, except a fully deprecated market which stays listed last. ?showAll keeps every grouped market and sets showsAllMembers so graphs keep hidden loan relationships. Listing predicates, Unknown-collateral exclusion, and the graph drop exception are in Vault Labels → Discovery Page Filtering. Curator names on cards link to /curators/{entityId} when a single managing entity resolves; see Curator profile pages.
Leveraged return metrics are only shown when the collateral and debt assets are treated as price-correlated. The source of truth is token-list metadata: useTokenList().getTokenCategoryTags(address) returns normalized tags, and utils/token-categories.ts only considers tags present in CORRELATED_CATEGORY_LABELS (usd, eth, btc, mon, avax, hype, bnb).
Correlation rules:
- Pair-level:
areTokenAddressesCorrelatedByTags()returns true when both assets share an allowlisted category tag, or when they are the same asset address. - Portfolio-level:
areRoeCollateralVaultsCorrelatedWithBorrow()requires every resolved collateral asset and the borrow asset to share one allowlisted category. - Math:
utils/leverage.tsdelegates max multiplier and max ROE formulas to the SDK, then adds looping rewards flat because those rewards are paid per unit of equity rather than scaled by leverage.
The correlation decision is shared across discovery, borrow, and portfolio surfaces:
| Surface | Correlated assets | Uncorrelated assets |
|---|---|---|
Explore market card (useBestMaxROE) |
Best Max ROE across eligible LTV pairs | Best visible Net APY fallback |
Explore matrix (DiscoveryMarketMatrix.vue) |
Numeric cells in roe and multiplier views |
- cell with unavailable-metric tooltip and accordion notice |
Borrow pair card (VaultBorrowItem.vue) |
Max ROE headline, correlated-category badge, Max multiplier column, Multiply tab link | Net APY headline; multiplier column and Multiply tab shortcut are hidden |
Portfolio borrow card (PortfolioBorrowItem.vue) |
Position ROE when all collateral vaults resolve and share the category | Net APY path |
Borrow-page collateral and debt filters reuse the same category metadata. pages/borrow/index.vue builds quick-filter values such as category:usd from tags present in the active pair list, and matching accepts either an explicit token address or a category match via tokenAddressMatchesCategoryFilter().
MarketGroup— Core group with vaults, external collateral, metrics, and curator infoMarketGroupMetrics— TVL, best APYs, utilization, vault counts, asset symbolsCuratorGroup— GroupsMarketGroups by curator entity for aggregated views
The listing pages (Lend, Borrow, Earn, Explore) support user-defined metric filters via the useCustomFilters composable:
UiCustomFilterModal— Modal for creating filters with metric, operator (gt/lt), and valueUiCustomFilterChips— Displays active filters as removable chips- Filters are applied client-side using
matchesCustomFilters(item) - Custom filters are not written to the URL. Shareable links only carry the
useUrlQuerySynckeys below.
Listing pages persist search, sort, and chip filters in the query string through useUrlQuerySync (composables/useUrlQuerySync.ts) and resolveUrlQueryValue (utils/url-query-alias.ts).
Rules:
- Each filter has one current key (
queryKey). That is the only name written. - Former names (
legacyKeys) are still read from saved links. After the first paint the URL is rewritten without them. - If both the current and a legacy name are present, the current value wins. The legacy key is still stripped.
- Default values are omitted from the URL (empty chip arrays, default sort).
- Keys that this helper does not own (notably
network) are preserved.
| Page | Current keys | Legacy aliases |
|---|---|---|
| Explore | search, sort, dir, market, asset, curator |
riskManager → curator |
| Lend | search, sort, dir, vault, market, curator |
riskManager → curator |
| Borrow | search, sort, dir, collateral, debt, market, curator |
riskManager → curator |
| Earn | search, sort, dir, vault, curator |
allocator → curator |
Example: /explore?network=1&curator=Gauntlet is the canonical curator filter. /explore?riskManager=Gauntlet still applies that filter, then replaces the URL with ?curator=Gauntlet. Do not mint new riskManager or Earn allocator links.
Chain selection is separate: middleware/01.network.global.ts normalizes ?network= (or legacy ?chainId=) to a numeric chain id and rewrites pre-lite paths (/vault → /lend, /positions → /borrow, /account → /position, /market → /explore and drops tab).
The app uses Vue's <KeepAlive> to preserve component state across navigation for listing pages:
<NuxtPage
:keepalive="{ include: ['ExplorePage', 'EarnPage', 'LendPage', 'BorrowPage', 'PortfolioPage'] }"
/>This prevents re-fetching and re-rendering when users navigate between listing pages and detail views (e.g., clicking a vault then pressing back). app.vue keeps route rendering outside Suspense to avoid conflicts with keepalive cache invalidation.
- Code Splitting: Automatic route-based code splitting
- Lazy Loading: Components and composables loaded on demand
- Caching: Smart caching of blockchain data and API responses. Asset prices are cached with TTL to avoid redundant RPC calls
- RPC Deduplication: Concurrent RPC calls for the same data are deduplicated so only one request is made
- Batch Operations: Batch API calls to reduce network overhead
- Debouncing: User input and position refresh debouncing to prevent excessive API calls
- KeepAlive: Listing pages cached in memory to avoid redundant data loads
- Lazy Chart Loading: Chart.js is lazy-loaded only when chart components are mounted. Chart colors are read from CSS custom properties via the
useThemeColorscomposable (readsdocument.bodycomputed styles, reactive touseTheme()), so charts automatically follow the theme - Interval Cleanup: All
setIntervaltimers are properly cleaned up to prevent memory leaks - shallowRef: Collection data (arrays, maps) uses
shallowRefinstead ofrefto avoid deep reactivity overhead
The useSlippage composable (composables/useSlippage.ts) manages swap slippage tolerance with two safety features:
- 24-hour expiry: Custom slippage values above 0.3% automatically expire after 24 hours, reverting to the default. This prevents users from forgetting high slippage settings across sessions. Values at or below 0.3% never expire.
- Stablecoin defaults: Swaps between two stablecoins (detected by "USD" in the token symbol) default to 0.05% slippage instead of the general 0.3% default, reducing unnecessary losses on stable-to-stable pairs.
The composable accepts optional fromSymbol/toSymbol getters to detect stablecoin pairs reactively. Slippage state is persisted to localStorage with a timestamp for expiry tracking.
- Input Validation: Strict input validation and sanitization
- Type Safety: TypeScript prevents many runtime errors
- Secure Communication: HTTPS for all external communications
- Wallet Security: Secure wallet connection and transaction signing
- Error Handling: Secure error messages that don't leak sensitive information
The Nuxt server layer (server/api/) proxies requests to external services (RPC nodes, Tenderly, the compliance screening API) to keep operator API keys out of client bundles. Several layers protect these endpoints:
| Layer | Purpose |
|---|---|
CORS (server/middleware/cors.ts) |
Restricts API access to configured origins |
Body size limits (server/middleware/body-limit.ts) |
Caps request payloads (1 MB RPC, 2 MB Tenderly) |
Geo-blocking (server/middleware/geo-gate.ts) |
Blocks sanctioned countries via the edge-provided country (getEdgeContext); fails closed (HTTP 451) if a geo-capable edge leaves the country undetermined outside dev |
RPC method allowlist (server/api/internal/rpc/[chainId].ts) |
JSON-RPC 2.0 POST proxy; read/simulation methods only (see below) |
Rate limiting (server/utils/rate-limit.ts) |
Per-IP cost-based budgets (see below); fails closed (HTTP 403) if the edge provides no trusted client identity in prod |
Swap quote contract validation (@eulerxyz/euler-v2-sdk swapService) |
Validates each fetched quote's swapper and verifier addresses against the chain's canonical deployment allowlist |
API guards and sensitive-cache checks classify requests with getRouterPathname(event)
(server/utils/router-path.ts), using h3's decoded event.path without the query string.
This keeps encoded API prefixes under the same checks as canonical routes. Proxy forwarding
validation still inspects the raw URL, including V3's rejection of encoded path bytes.
POST /api/internal/rpc/{chainId} forwards JSON-RPC 2.0 to the matching RPC_URL_<chainId>. Browser SDK and wagmi transports use it; getServerSdk talks to RPC_URL_* directly and does not go through this proxy.
ALLOWED_METHODS in server/api/internal/rpc/[chainId].ts:
eth_call, eth_estimateGas, eth_createAccessList, eth_getTransactionReceipt, eth_getTransactionByHash, eth_blockNumber, eth_getBlockByNumber, eth_getBalance, eth_getCode, eth_getLogs, eth_chainId, eth_gasPrice, eth_maxPriorityFeePerGas, eth_feeHistory, eth_getTransactionCount, net_version.
eth_createAccessList is required for SDK state-override simulation: fetchErc20SlotHints / primeSlotHintsFor discover ERC-20 allowance and balance slots so later simulate/estimate calls can skip that probe. See Transaction Building → Simulation Performance Tuning. Wallet writes never use this proxy; they go through the connected wallet provider.
| Constraint | Behavior |
|---|---|
| HTTP | POST only (405 otherwise). |
| Chain | Integer chainId with a configured RPC_URL_<chainId>; otherwise 400 / 404. |
| Envelope | JSON-RPC 2.0 objects with an id. Notifications (no id) are rejected. |
| Batch | Max 100 items; empty batches are 400. |
| Method | Not in the allowlist → 403 Method not allowed. |
| Upstream | 30 s timeout → 504; fetch failure → 502. Response body is the upstream text. |
| Rate limit | 10,000 units / 60 s per client IP, shared across chains. Each JSON-RPC call costs 1 unit, so a 100-item batch costs 100. An over-budget batch gets 429 before any calls are forwarded and consumes no budget. |
Public-RPC fallback can mask a 403. plugins/00.wagmi.ts builds a viem fallback of [/api/internal/rpc/{chainId}, ...network.rpcUrls.default.http] with retryCount: 0. If the proxy rejects a method, the public URL may still succeed, so a missing allowlist entry can look fine in the browser while the proxy log shows method-not-allowed. Add the method to ALLOWED_METHODS (only if it is a read/simulation call) rather than relying on the fallback. Both legs use { batchSize: 100, wait: 100 }.
The app includes a built-in per-IP rate limiter as a defense-in-depth measure. Default budgets per 60-second window:
- RPC proxy: 10,000 units per client IP, shared across chains (one unit per JSON-RPC call; a 100-item batch costs 100)
- All other proxies: 1,000 requests (token list, Pyth updates, labels, euler chains, intrinsic APY, TOS, V3)
- Tenderly simulate: 10 requests
- Address screening: 10 requests
RPC budget rejections emit structured JSON warning logs with ctx: "rpc", msg: "rate limited", and ip (server/utils/rate-limit.ts). Monitor their frequency and affected IPs after deployment, especially shared corporate, VPN, or carrier NAT exits. Only tune the RPC limiter's max after comparing observed rejections with legitimate traffic volume; retain per-call charging and whole-batch rejection. These warnings report rejected attempts, not remaining capacity or successful-call volume.
Wallet screening fail-closed: connect-time only. POST /api/internal/screen-address proxies to data-v3 (ADDRESS_SCREENING_URI + ADDRESS_SCREENING_API_KEY). Both unset is an opt-out except in DOPPLER_ENVIRONMENT=prd, where missing config fails closed. Partial config, non-TLS URIs, upstream errors, and incomplete verdicts all return addressIsSuspicious: true. VPN usage is audit metadata and does not gate access. First-party *.euler.finance SPAs share this one path via a scoped CORS exception — keep the contract backward-compatible. Full runbook: Address Screening.
Important: This is a best-effort safeguard, not a security boundary. It catches accidental abuse (e.g. a client stuck in a retry loop) but will not stop a determined attacker. Known limitations:
- In-memory state is per-process — if Nitro spawns multiple workers, each gets its own budget, effectively multiplying the limit.
The server never reads vendor edge headers directly. getEdgeContext(event) (server/utils/edge.ts) normalizes whatever the fronting infrastructure provides into a single shape — trusted client IP, country, VPN evidence, origin-auth status — and every consumer (geo-gate, rate limiter, CORS country hint, screening audit) reads that. The vendor-specific header mapping lives exclusively in utils/edge-presets.ts, selected by the EDGE_PROVIDER env var:
| Preset | Trusted client IP | Country | VPN evidence |
|---|---|---|---|
cloudflare |
cf-connecting-ip |
cf-ipcountry |
x-is-vpn / x-is-proxy-or-vpn |
google |
x-forwarded-for second-to-last entry (LB-appended) |
x-client-geo (LB custom request header, see below) |
— |
cloudfront |
cloudfront-viewer-address (port stripped) |
cloudfront-viewer-country |
— |
none (default) |
rightmost x-forwarded-for entry, else socket |
— | — |
Production deployments must set EDGE_PROVIDER explicitly — the server refuses to boot in prd without it (server/plugins/edge-guard.ts), because the none default runs with geo-blocking off. none is intended for forks and previews that have no fronting edge. It is permitted in production only as an explicit opt-out (edge-guard logs a warning at boot): under none there is no trusted identity at all — the rate limiter keys on the rightmost x-forwarded-for entry, which a direct client can rotate unless the hosting platform's proxy rewrites it — so none must not be read as rate-limit protection.
google preset prerequisite: Google's external load balancer does not set a country header on its own. The backend service must be configured with the custom request header x-client-geo: {client_region}, which the LB then stamps on every forwarded request (replacing any client-supplied value). Without it the header arrives from the client untouched and the country is forgeable — origin auth proves the request traversed the LB, not that the LB wrote this header. The preset also assumes exactly one LB hop for the x-forwarded-for identity.
Origin auth (EDGE_ORIGIN_SECRET): when set, every request must carry a matching x-edge-origin-auth header, stamped by the edge (e.g. a request-header transform rule). Requests without it are treated as having bypassed the edge: their trusted inputs are voided and the fail-closed paths below apply. The secret is optional for the cloudflare and none presets — until it is set, the edge headers are trusted on the historical assumption that the origin is only reachable through the edge. It is required for google and cloudfront (the server refuses to boot without it): those edges forward client headers untouched, so without origin auth their trusted inputs would be forgeable by anyone who can reach the origin. Configuring the secret is what closes direct-to-origin spoofing in every preset.
Internal fetches (server/utils/internal-headers.ts): server-internal $fetch calls (warm-cache, vaults-cache, labels) authenticate with an x-edge-internal marker whose value is the origin-auth secret or, when none is configured, a random per-process value — unforgeable under every preset with zero configuration. The container healthcheck does not use it: it probes /healthz, which lives outside /api/ and is deliberately independent of edge configuration.
Fail-closed behaviour per environment (geo-capable presets):
| Environment | Geo-gate | Rate limiter |
|---|---|---|
prd |
Country required; fail-closed (HTTP 451) if undetermined. DEV_GEO_COUNTRY is rejected at boot (assertEdgeConfig), so it cannot mask a missing country. |
Trusted identity required; fail-closed (HTTP 403) if absent. |
stg |
Country required; fail-closed (HTTP 451) if undetermined. DEV_GEO_COUNTRY bypasses fail-closed if set. |
Trusted identity not required; falls back to X-Forwarded-For. |
dev |
Country not required; falls back to DEV_GEO_COUNTRY, then allows through if unset. |
Trusted identity not required; falls back to X-Forwarded-For. |
Under the none preset the geo-gate does not fail closed (there is no geo evidence by design) and the rate limiter keys budgets on the rightmost x-forwarded-for entry, best-effort (see the production caveat above).
The app is a wallet-bearing DeFi interface. Loading it inside an attacker-controlled <iframe> could let the attacker overlay invisible elements on top of transaction approval buttons (UI-redressing / clickjacking). Four independent layers prevent this, following a defense-in-depth model:
| Layer | Mechanism | Implementation |
|---|---|---|
| HTTP header — legacy | X-Frame-Options: DENY |
server/middleware/security-headers.ts |
| HTTP header — modern | Content-Security-Policy: frame-ancestors 'none' |
server/plugins/csp.ts |
| HTTP header — opener isolation | Cross-Origin-Opener-Policy: same-origin-allow-popups |
server/middleware/security-headers.ts |
| Inline script — last resort | JS frame-busting: hides the page and navigates window.top |
server/plugins/00-anti-clickjack.ts |
Why all four?
X-Frame-Optionsis the universally-supported legacy header; some older user agents or edge workers only check this one.frame-ancestors 'none'is the modern CSP equivalent and takes precedence in browsers that support CSP Level 2+. It is set alongsideX-Frame-Optionsfor belt-and-suspenders coverage.Cross-Origin-Opener-Policy: same-origin-allow-popupsprevents a cross-origin page from retaining awindow.openerreference to ours after the user navigates away.same-origin-allow-popups(rather thansame-origin) is the strictest value that still allows Reown AppKit and Coinbase Wallet SDK to open wallet connection popups.- The inline script is the last line of defense in case a CDN, edge worker, or proxy strips the headers. It detects
window.self !== window.top, hides the document root immediately, and attemptswindow.top.locationnavigation. Becausecsp.tsruns after00-anti-clickjack.tsin alphabetical Nitro plugin order, every<script>inhtml.head(including this one) already has a per-request CSP nonce injected before the response is sent.
Wallet popup compatibility
COOP: same-origin would fully isolate the browsing context but breaks popup-based wallet connection flows (Reown AppKit, Coinbase Wallet SDK). same-origin-allow-popups is the correct trade-off: it isolates the opener reference from cross-origin navigations while preserving same-origin popup handles.
Regression coverage
tests/server/security.test.ts locks in all four layers with pure-function unit tests (no Nitro boot required). The tests assert that frame-ancestors 'none', X-Frame-Options: DENY, Cross-Origin-Opener-Policy, and the frame-busting script content cannot silently regress. Do not weaken these assertions without reviewing the threat model above.
- Mobile-First: Design starts with mobile and scales up
- Touch-Friendly: Optimized for touch interactions
- Performance: Optimized for mobile network conditions
┌─────────────────────────────────────────────────────────────────┐
│ Global State │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ User State │ │ App State │ │ Config │ │
│ │ (Wallet) │ │ (UI) │ │ State │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ Feature State │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Vaults │ │ Portfolio │ │ Rewards │ │
│ │ State │ │ State │ │ State │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────┘
- Reactive References:
ref()andshallowRef()for state (shallowReffor collections to avoid deep reactivity overhead) - Computed Properties:
computed()for derived state - Watchers:
watch()andwatchEffect()for side effects - State Persistence: Local storage for user preferences
- Race Guards:
createRaceGuard()utility prevents stale async operations from overwriting fresh data. Used extensively inwatchEffectpatterns where rapid chain/account switching can cause race conditions - Reactive Maps:
useReactiveMap()composable encapsulates the race-guarded async watchEffect pattern for keyed async data loading
Next: Learn about the Project Structure to understand how this architecture is implemented in the codebase.