Skip to content

Latest commit

 

History

History
517 lines (378 loc) · 48.9 KB

architecture.md

File metadata and controls

517 lines (378 loc) · 48.9 KB

Architecture Overview

This document provides an overview of the Euler Lite system architecture, including the high-level design, component structure, and key architectural decisions.

🏗️ High-Level System Architecture

┌─────────────────────────────────────────────────────────────────┐
│                     User Interface Layer                        │
├─────────────────────────────────────────────────────────────────┤
│                    Application Logic Layer                      │
├─────────────────────────────────────────────────────────────────┤
│                      Integration Layer                          │
├─────────────────────────────────────────────────────────────────┤
│                    External Services Layer                      │
└─────────────────────────────────────────────────────────────────┘

System Layers

  1. User Interface Layer: Vue components, pages, and UI elements
  2. Application Logic Layer: Composables, business logic, and state management
  3. Integration Layer: API clients, blockchain interactions, and external service adapters
  4. External Services Layer: Euler Finance, EVM chains, and third-party APIs

🧩 Component Architecture

Vue 3 Composition API Pattern

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    │                │
│  └─────────────┘ └─────────────┘ └─────────────┘                │
└─────────────────────────────────────────────────────────────────┘

Key Architectural Principles

  1. Separation of Concerns: Clear separation between UI, business logic, and data
  2. Composability: Reusable composables for shared functionality
  3. Type Safety: Full TypeScript integration for better development experience
  4. Reactive State: Vue 3 reactivity system for state management
  5. Modular Design: Well-defined boundaries between different system parts
  6. 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 through useEulerTx.ts, and SDK entities are imported directly from @eulerxyz/euler-v2-sdk
  7. Structured Logging: a single logger API from ~/utils/logger (console-backed shim, used by shared code) and ~/server/utils/logger (pino, JSON to stdout for BetterStack on Fargate). Errors passed in err are summarised by ~/utils/viem-errors so viem's abi / metaMessages / hex request bodies never leak. ~/utils/errorHandling exposes a small logWarn helper for the long tail of client-side call sites; it routes through the same shared logger

🔄 Data Flow Architecture

Unidirectional Data Flow

┌─────────────┐    ┌─────────────┐     ┌─────────────┐
│   External  │───▶│ Composables │───▶ │ Components  │
│   Services  │    │             │     │             │
└─────────────┘    └─────────────┘     └─────────────┘
       ▲                   │                   │
       │                   ▼                   ▼
┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│   Entities  │◀───│   State     │◀───│   UI State  │
│             │    │ Management  │    │             │
└─────────────┘    └─────────────┘    └─────────────┘

Data Flow Patterns

  1. External Data Fetching: Composables fetch data from external services
  2. State Transformation: Raw data is transformed into application entities
  3. Reactive Updates: UI automatically updates when state changes
  4. Caching Strategy: Smart caching to minimize API calls
  5. Error Handling: Graceful error handling with user feedback

🏛️ Technology Architecture

Frontend Framework Stack

┌─────────────────────────────────────────────────────────────────┐
│                        Nuxt.js 4                                │
├─────────────────────────────────────────────────────────────────┤
│                    Vue 3 + Composition API                      │
├─────────────────────────────────────────────────────────────────┤
│                    TypeScript + SCSS                            │
├─────────────────────────────────────────────────────────────────┤
│         ESLint (flat config) + Vitest (unit/integration)        │
├─────────────────────────────────────────────────────────────────┤
│          simple-git-hooks + lint-staged (pre-commit)            │
└─────────────────────────────────────────────────────────────────┘

Blockchain Integration Architecture

┌─────────────────────────────────────────────────────────────────┐
│                    Wagmi / Reown                                │
│  ┌─────────────┐ ┌─────────────┐ ┌─────────────┐                │
│  │ useWagmi    │ │ Reown       │ │ Wallet      │                │
│  │             │ │ AppKit      │ │ Connect     │                │
│  └─────────────┘ └─────────────┘ └─────────────┘                │
├─────────────────────────────────────────────────────────────────┤
│                    EVM (Viem + Multicall)                       │
│  ┌─────────────┐ ┌─────────────┐ ┌─────────────┐                │
│  │ EVC Batch   │ │ Pyth Oracle │ │ Simulation  │                │
│  └─────────────┘ └─────────────┘ └─────────────┘                │
├─────────────────────────────────────────────────────────────────┤
│                    Euler Finance                                │
│  ┌─────────────┐ ┌─────────────┐ ┌─────────────┐                │
│  │ Vault Lens  │ │ Account     │ │ Interest    │                │
│  │             │ │ Lens        │ │ Rate Model  │                │
│  └─────────────┘ └─────────────┘ └─────────────┘                │
└─────────────────────────────────────────────────────────────────┘

🎯 Key Architectural Decisions

1. Nuxt.js 4 Framework

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

2. Composition API Pattern

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

3. Composable-based State Management

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

4. TypeScript Integration

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

🔌 Integration Architecture

External Service Integration Pattern

┌─────────────────────────────────────────────────────────────────┐
│                        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.

Vault snapshot pipeline

/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:

  1. 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 run populateCollaterals / populateStrategyVaults against a registry-backed IVaultMetaService stub (utils/sdk-vault-meta-stub.ts) so cross-references resolve to the same EVault instances already in the registry (no RPC). Flip isReady=true; UI renders a fully populated borrowList immediately.
  2. 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 campaign and claim pipeline

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.

🔍 Explore Page & Market Discovery

The Explore page (pages/explore/index.vue) provides a market discovery interface that groups vaults into logical markets.

Market Grouping Algorithm

The useMarketGroups composable (composables/useMarketGroups.ts) implements a hybrid grouping algorithm:

  1. 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.
  2. Collateral graph augmentation — For each group, external collateral vaults (referenced by member vaults but not in the group) are resolved and attached.
  3. Orphan clustering — Vaults not assigned to any product are clustered using a BFS connected-component algorithm over their collateral relationships. This produces "Ungrouped" markets.
  4. 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.

Correlated Pairs, Max ROE, and Max Multiplier

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.ts delegates 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().

Key Types

  • MarketGroup — Core group with vaults, external collateral, metrics, and curator info
  • MarketGroupMetrics — TVL, best APYs, utilization, vault counts, asset symbols
  • CuratorGroup — Groups MarketGroups by curator entity for aggregated views

Custom Filters

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 value
  • UiCustomFilterChips — 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 useUrlQuerySync keys below.

Shareable list query parameters

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:

  1. Each filter has one current key (queryKey). That is the only name written.
  2. Former names (legacyKeys) are still read from saved links. After the first paint the URL is rewritten without them.
  3. If both the current and a legacy name are present, the current value wins. The legacy key is still stripped.
  4. Default values are omitted from the URL (empty chip arrays, default sort).
  5. 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).

🚀 Performance Architecture

Page KeepAlive Strategy

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.

Optimization Strategies

  1. Code Splitting: Automatic route-based code splitting
  2. Lazy Loading: Components and composables loaded on demand
  3. Caching: Smart caching of blockchain data and API responses. Asset prices are cached with TTL to avoid redundant RPC calls
  4. RPC Deduplication: Concurrent RPC calls for the same data are deduplicated so only one request is made
  5. Batch Operations: Batch API calls to reduce network overhead
  6. Debouncing: User input and position refresh debouncing to prevent excessive API calls
  7. KeepAlive: Listing pages cached in memory to avoid redundant data loads
  8. Lazy Chart Loading: Chart.js is lazy-loaded only when chart components are mounted. Chart colors are read from CSS custom properties via the useThemeColors composable (reads document.body computed styles, reactive to useTheme()), so charts automatically follow the theme
  9. Interval Cleanup: All setInterval timers are properly cleaned up to prevent memory leaks
  10. shallowRef: Collection data (arrays, maps) uses shallowRef instead of ref to avoid deep reactivity overhead

🔄 Swap & Slippage Behavior

Slippage Settings

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.

🔒 Security Architecture

Security Measures

  1. Input Validation: Strict input validation and sanitization
  2. Type Safety: TypeScript prevents many runtime errors
  3. Secure Communication: HTTPS for all external communications
  4. Wallet Security: Secure wallet connection and transaction signing
  5. Error Handling: Secure error messages that don't leak sensitive information

Server-Side API Protection

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.

JSON-RPC proxy

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 }.

Rate Limiting

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.

Edge Provider

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).

Clickjacking & Framing Defenses

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-Options is 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 alongside X-Frame-Options for belt-and-suspenders coverage.
  • Cross-Origin-Opener-Policy: same-origin-allow-popups prevents a cross-origin page from retaining a window.opener reference to ours after the user navigates away. same-origin-allow-popups (rather than same-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 attempts window.top.location navigation. Because csp.ts runs after 00-anti-clickjack.ts in alphabetical Nitro plugin order, every <script> in html.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 Architecture

Responsive Design Principles

  1. Mobile-First: Design starts with mobile and scales up
  2. Touch-Friendly: Optimized for touch interactions
  3. Performance: Optimized for mobile network conditions

🔄 State Management Architecture

State Organization

┌─────────────────────────────────────────────────────────────────┐
│                        Global State                             │
│  ┌─────────────┐ ┌─────────────┐ ┌─────────────┐                │
│  │ User State  │ │ App State   │ │ Config      │                │
│  │ (Wallet)    │ │ (UI)        │ │ State       │                │
│  └─────────────┘ └─────────────┘ └─────────────┘                │
├─────────────────────────────────────────────────────────────────┤
│                      Feature State                              │
│  ┌─────────────┐ ┌─────────────┐ ┌─────────────┐                │
│  │ Vaults      │ │ Portfolio   │ │ Rewards     │                │
│  │ State       │ │ State       │ │ State       │                │
│  └─────────────┘ └─────────────┘ └─────────────┘                │
└─────────────────────────────────────────────────────────────────┘

State Management Patterns

  1. Reactive References: ref() and shallowRef() for state (shallowRef for collections to avoid deep reactivity overhead)
  2. Computed Properties: computed() for derived state
  3. Watchers: watch() and watchEffect() for side effects
  4. State Persistence: Local storage for user preferences
  5. Race Guards: createRaceGuard() utility prevents stale async operations from overwriting fresh data. Used extensively in watchEffect patterns where rapid chain/account switching can cause race conditions
  6. 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.