diff --git a/.env.example b/.env.example index c2d9baae9..b116d7e3c 100644 --- a/.env.example +++ b/.env.example @@ -144,6 +144,16 @@ NUXT_PUBLIC_CONFIG_ENABLE_INCENTRA="true" NUXT_PUBLIC_CONFIG_ENABLE_FUUL="true" NUXT_PUBLIC_CONFIG_ENABLE_TURTLE="true" +# One-time announcement modal (optional). Populate any content field to show it +# after onboarding. Short CONFIG_ANNOUNCEMENT_* names are also accepted at +# runtime for Doppler-injected deployments. +NUXT_PUBLIC_CONFIG_ANNOUNCEMENT_TITLE= +NUXT_PUBLIC_CONFIG_ANNOUNCEMENT_BODY= +# Newline-delimited text or JSON array string, e.g. '["First","Second"]'. +NUXT_PUBLIC_CONFIG_ANNOUNCEMENT_ITEMS= +# Must be https://, http://, or a safe root-relative path such as /portfolio. +NUXT_PUBLIC_CONFIG_ANNOUNCEMENT_URL= + # External token lists for swap token selector (optional) NUXT_PUBLIC_CONFIG_UNISWAP_TOKEN_LIST_URL="https://tokens.uniswap.org" NUXT_PUBLIC_CONFIG_DEFILLAMA_TOKEN_LIST_URL="https://d3g10bzo9rdluh.cloudfront.net" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..eb454796a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,192 @@ +# AGENTS.md + +Euler Lite is a single-service **Nuxt 4 / Vue 3 / TypeScript** frontend for the Euler DeFi +lending protocol (lend, borrow, earn, portfolio, rewards, swaps). There is no separate backend: +the Nitro server layer only serves the SPA shell and a set of `/api/internal/*` proxy routes. +All Ethereum interaction uses **viem** + the **`@eulerxyz/euler-v2-sdk`**; wallet connection uses +**wagmi + Reown AppKit**. + +Deep-dive docs live in `docs/` (see "Reference docs" below) and setup/scripts are in `README.md` +and `package.json`. This file captures the non-obvious, durable context that isn't already +obvious from those sources. + +## Cursor Cloud specific instructions + +Euler Lite is the only service. Standard commands live in `README.md` ("Available Scripts") and +`package.json`. + +### Node version +- The app requires **Node 24** (24.14.1 is installed via `nvm` and set as the default). The VM's + system `node` at `/exec-daemon/node` is Node 22, so `~/.bashrc` prepends the Node 24 bin to + `PATH`. New login shells already resolve `node -v` to 24.x — no action needed. If you spawn a + non-login/non-interactive shell and get Node 22, run `nvm use default` or prepend + `$HOME/.nvm/versions/node/v24.14.1/bin` to `PATH`. CI also pins Node 24 (`.github/workflows/ci.yaml`). + +### Environment config (`.env`) +- The app reads runtime config from `.env` (gitignored). A working dev `.env` is created during + setup by copying `.env.example` and setting `NUXT_PUBLIC_APP_URL=http://localhost:3000` plus a + public RPC: `RPC_URL_1=https://ethereum-rpc.publicnode.com`. If `.env` is missing, recreate it + the same way — **at least one `RPC_URL_` is required** or the chain selector shows no + chains. Subgraph URLs for many chains are already present in `.env.example`. +- `NUXT_PUBLIC_APP_KIT_PROJECT_ID` (Reown/WalletConnect) is left empty; browsing/read flows work + without it, but live wallet connection needs a real project ID. +- Enabled chains are derived from `RPC_URL_` vars at server startup, so **restart + `npm run dev` after editing chain env vars** (they are not hot-reloaded). + +### Running / testing +- Dev server: `npm run dev` → http://localhost:3000 (use a tmux session so it persists). +- Vault/market data (Explore page, vault details) loads from the upstream V3 API + the configured + RPC; some individual price/vault upstream calls may log `502`/timeout warnings without breaking + the page. +- Lint: `npm run lint` (a few pre-existing `no-explicit-any` warnings, 0 errors). +- Typecheck: `npm run typecheck`. +- Tests: `npm run test:run` (single pass; `npm run test` is watch mode). Test runs print lots of + `pino` warn/error JSON lines from exercised code paths — this is expected; check the final + vitest summary. See "Testing" below for the golden/parity suites and the CI build-before-test + gotcha. +- A `pre-commit` hook (simple-git-hooks + lint-staged) runs `eslint --fix` on staged files. + +## Architecture at a glance + +- **SPA, not SSR.** `nuxt.config.ts` sets `ssr: false`. Nitro still runs to serve the HTML shell + (with injected config scripts) and the `/api/internal/*` proxies. Details in `docs/architecture.md`. +- **No Pinia.** State lives in module-scoped `ref`s inside composables (e.g. `useVaults`, + `useEulerAccount`, `useTxBatch`, `useWallets`). `app.vue` eagerly instantiates the key + composables at the root so their watchers survive navigation. TanStack Vue Query + (`plugins/01.query.ts`) is used for some async caching, not as global app state. +- **Config flows env → server plugin → `window` global → composable:** + - `RPC_URL_*`, `DEPRECATED_CHAINS`, `ONCHAIN_SDK_CHAINS`, `EVAULT_FETCH_CHUNK_CHAINS` + → `server/plugins/chain-config.ts` → `window.__CHAIN_CONFIG__` → `useChainConfig()`. + Note: `__CHAIN_CONFIG__` carries only chain IDs + validation metadata (`enabledChainIds`, + `deprecatedChainIds`, `onchainSdkChainIds`, `eVaultFetchChunkChainIds`, `unsupportedChainIds`, + `chainEnvIssues`) — the `RPC_URL_*` values themselves stay server-side. + - AppKit / V3 / Swap / Pyth URLs + branding → `server/plugins/app-config.ts` → + `window.__APP_CONFIG__` → `useEnvConfig()` + - `NUXT_PUBLIC_CONFIG_*` feature flags/page toggles → Nuxt `runtimeConfig.public` → `useDeployConfig()` +- **Configured upstream secrets stay server-side.** `RPC_URL_*`, subgraph URLs (`SUBGRAPH_URL_*`), + and the V3 URL are never shipped to the browser; the app's own reads go through same-origin + proxies (`/api/internal/rpc/{chainId}`, `/api/internal/proxy/subgraph/{chainId}`, + `/api/internal/v3`), and `pythHermesUrl` is exposed to the client only as the string `'proxy'`. + This is **not** a claim that the browser makes zero third-party requests: per-chain viem + transports use the network's public RPC (`network.rpcUrls.default.http`) as a fallback behind the + proxy (`plugins/00.wagmi.ts`), and Reown AppKit + the connected wallet provider make their own + outbound calls (AppKit Blockchain API, WalletConnect relay, etc.). + +## Repository layout + +| Directory | Contents | +|-----------|----------| +| `pages/` | File-based routes. Key sections: `/explore`, `/lend`, `/borrow`, `/earn`, `/portfolio`, `/position/[number]/*` (supply/withdraw/repay/multiply/migrate), `/batch`. `/ui` is a dev-only component playground stripped from production builds. | +| `components/` | `base/`, `layout/`, `entities/` (feature components — scanned by Tailwind), `ui/` (design-system kit — SCSS, **excluded** from Tailwind scanning), plus root `Batch*.vue`. Auto-imported flat (`pathPrefix: false`), so use `LogoBrand`, not `BaseLogoBrand`. | +| `composables/` | Business logic (~97 files). Subdirs `borrow/`, `repay/`, `cowswap/`, `guards/`, `position/`, `useKeyring/`, `useRpcClient/`. **Auto-import nuance:** only top-level `composables/*/index.ts` are auto-imported; nested files must be imported explicitly (e.g. `~/composables/repay/useRepaySwapCore`). | +| `utils/` | Pure helpers. `utils/vault/` is the largest (classification, APY, LTV, liquidation). | +| `entities/` | App-specific domain types/constants (`account.ts`, `chainRegistry.ts`, `constants.ts`, `menu.ts`, `oracle.ts`, migration/cowswap helpers). On-chain types come from the SDK, not here. | +| `server/` | Nitro layer: `api/`, `middleware/`, `plugins/`, `utils/`. | +| `plugins/` | Client/Nuxt plugins (numbered for load order): `00.wagmi.ts`, `00.chartjs.client.ts`, `01.query.ts`, `theme.client.ts`, `node.ts` (Buffer polyfill). | +| `middleware/` | Route middleware (numbered): `01.network.global.ts` (normalizes `?network=`, applies legacy path rewrites), `02.spy-param.global.ts`, `ensure-vault.global.ts`. | +| `services/` | 3 thin fetch wrappers over internal API routes (`country.ts`, `trm.ts`, `vpn.ts`). | +| `abis/` | viem ABIs (`vault.ts`, `evc.ts`, `erc20.ts`, `pyth.ts`, `keyring.ts`, `merkl.ts`, ...). | +| `types/` | Shared TS types + global `Window` augmentations (`types/index.ts`). | +| `assets/` | `styles/` (SCSS + theme vars in `variables.scss`), `tokens/` (icon overrides), `chains/`, `sprite/`. | +| `tests/` | Vitest suites + `golden/`, `parity/`, `execution/` (see Testing). | +| `scripts/` | Node tooling for parity/execution runs, Doppler sync, preview-SDK install. | +| `docs/` | Developer docs (see below). | + +## Server / proxy layer (`server/`) + +- **Internal proxies** (`server/api/internal/`) keep configured upstreams/secrets server-side and + are the app's main outbound data path. Representative routes: `v3/[...path]` (allowlisted V3 + proxy), `rpc/[chainId]` (JSON-RPC, allowlisted methods), `token-list`, `vaults` (pre-computed + snapshot), `euler-chains` (deployment metadata used as the SDK `deploymentsUrl`), + `proxy/subgraph/[chainId]`, `proxy/merkl|fuul|incentra|turtle/*`, `proxy/aave` + `proxy/morpho` + (external-migration discovery), `proxy/intrinsic-apy-overrides`, `pyth/updates`, `screen-address`, + `tenderly/*`, `oracle-*`, `labels/*`, `tos`. This list is **not** exhaustive and drifts between + branches (e.g. the client-error log endpoint has been removed on `development` to keep browser + diagnostics local) — treat the `server/api/internal/` directory + `docs/architecture.md` as the + authoritative inventory, especially when auditing CSP / `connect-src`. +- **Public routes** (`server/api/public/`): `is-known`, `metadata` (documented in `docs/public-api.md`). +- **Server middleware:** `geo-gate.ts` (451 for sanctioned countries via Cloudflare `CF-IPCountry`; + set `DEV_GEO_COUNTRY` locally since there's no CF header), `cors.ts`, `security-headers.ts`, + `body-limit.ts`, `ensure-vault.ts`. +- **Server plugins (load order matters):** `app-config.ts` / `chain-config.ts` inject the `window` + config; `csp.ts` (nonce-based CSP) must run after them; `warm-cache.ts` warms labels/token-list/ + vault caches in the background. Caching internals are in `docs/server-side-caching.md`. +- **Logging:** server uses `pino` JSON (`server/utils/logger.ts`); client/shared uses a console + shim (`utils/logger.ts`). viem errors are sanitized via `utils/viem-errors.ts`. + +## Testing + +Three distinct test layers — most day-to-day work only touches the first. + +1. **Unit / integration (Vitest, `vitest.config.ts`).** 125+ files under `tests/` mirroring source + dirs. Run with `npm run test:run`. Nuxt/Nitro globals and Vue reactivity are stubbed in + `tests/setup.ts`; `#app` is aliased to `tests/stubs/nuxt-app.ts`. + - **Gotcha:** `tests/utils/logger-bundle.test.ts` inspects the production client bundle at + `.output/public/_nuxt` and **auto-skips when no build exists** (this is the "1 skipped" test + in a plain run). CI runs `npm run build` before `npm run test:run` so it executes. Build + first if you need to exercise it locally. +2. **Golden canary (`tests/golden/`).** A version canary (not a correctness suite) that pins the + byte-for-byte calldata a user would sign for one representative operation per SDK encoder family + (plain vault op, borrow, repay, migration, swap, leverage). Each SDK `executionService.plan*` + output is normalized to a canonical `[{to, data, value, evcBatch}]` tx list (`normalize.ts`) and + asserted against a committed fixture under `tests/golden/plans/*.json`; its purpose is to catch an + SDK version bump that silently changes calldata. It uses **committed fixtures — no worktree and no + separate vitest config** — and **runs as part of `npm run test:run`**. Scripts: `npm run test:golden` + (golden only), `npm run test:golden:update` (`UPDATE_GOLDEN=1`; regenerate `plans/*.json` after a + deliberate builder change, then review the diff), and `npm run test:golden:fetch-fixtures` + (re-fetch the swap-quote fixtures in `tests/golden/fixtures/*.json` via the SDK `SwapService`; + override the endpoint with `GOLDEN_SWAP_API_URL`). Outside update mode a missing fixture is a hard + failure, so the suite can't mint its own baseline. Details in `tests/golden/README.md`. +3. **Parity / execution (Playwright, `scripts/*.mjs` + `tests/parity/`, `tests/execution/`).** + Browser-driven diff and fork-based (Anvil) transaction recording. Driven by the `parity:*` and + `execution:*` npm scripts using JSON scenario files. The UI exposes `data-id` / `data-field` / + `data-value` attributes specifically for these scrapers — don't remove them casually. + +**CI (`.github/workflows/ci.yaml`)** runs three blocking jobs on every PR: `lint`, `typecheck`, +and `test` (which builds first, then `test:run`). Match these locally before pushing. + +## Reference docs (`docs/`) + +Start with `docs/README.md` (index) and `docs/architecture.md`. Notable topics: +`development-guide.md`, `sdk-integration.md`, `transaction-building.md`, `server-side-caching.md`, +`pricing-system.md`, `pyth-oracle-handling.md`, `portfolio-logic.md`, +`vault-labels-and-verification.md`, `token-list.md`, `geo-blocking.md`, `tos-signing.md`, +`keyring-hooks.md`, `public-api.md`, `intrinsic-apy.md`. + +## Euler protocol safety invariants + +When touching transaction-building or position flows, verify against the Euler v2 protocol +invariants — do **not** reinvent them inline. The authoritative sources are `docs/transaction-building.md` +(EVC batching, sub-accounts, Permit2), `docs/pricing-system.md` / `docs/pyth-oracle-handling.md` +(oracle pricing), and the `.claude/skills/review-business/SKILL.md` skill, which links the +upstream `euler-xyz/agent-skills` (`euler-vaults`, `euler-earn`, `euler-advanced`, `euler-irm-oracles`) +plus https://docs.euler.finance. Key invariants those cover (checklist, not a substitute for the docs): + +- Vault state-changing calls go through the **EVC** (`abis/evc.ts`), not directly to the vault. +- At most **one controller (borrow) vault enabled per account**; `enableController()` must precede + the borrow, and `enableCollateral()` must be set before collateral is counted. +- Prices resolve through the **EulerRouter / cross-adapter** oracle config — never a raw price feed. +- Vaults/adapters are **factory-deployed**; treat non-factory addresses as untrusted. +- Flash loans must be **repaid within the same batch**; `pullDebt` moves debt *to* the caller. +- **EulerEarn** respects target allocations, cap timelocks, and ascending PublicAllocator withdrawal + order (`docs`/`euler-earn` skill). + +## Conventions & gotchas + +- **Vue style:** `` -// would break out of the inline script context. Escaping `<` (and the U+2028 / -// U+2029 line separators, which are invalid in JS string literals) as unicode -// escapes keeps the payload inside the script tag while preserving identical -// JSON/JS semantics — `<` parses back to `<` inside string values. -export function escapeScriptJson(json: string): string { - return json - .replace(/ { const onchainSdkChainIds = parseChainIds(process.env.ONCHAIN_SDK_CHAINS, enabledSet) const eVaultFetchChunkChainIds = parseEVaultFetchChunkChainIds(process.env, enabledSet) - const scriptTag = `` + const payload = escapeScriptJson(JSON.stringify({ + enabledChainIds, + deprecatedChainIds, + onchainSdkChainIds, + eVaultFetchChunkChainIds, + unsupportedChainIds: unknownChainIds, + chainEnvIssues, + })) + const scriptTag = `` nitroApp.hooks.hook('render:html', (html) => { html.head.push(scriptTag) diff --git a/server/utils/escape-script-json.ts b/server/utils/escape-script-json.ts new file mode 100644 index 000000000..d8afe67cd --- /dev/null +++ b/server/utils/escape-script-json.ts @@ -0,0 +1,11 @@ +/** + * Escape serialized JSON before embedding it in an inline script element. + * JSON.stringify does not escape `<`, so an env-derived value could otherwise + * terminate the script element. U+2028/U+2029 are escaped for JS compatibility. + */ +export function escapeScriptJson(json: string): string { + return json + .replace(/ pathname.replace(/\/+$/, '') +const ENCODED_PATH_BYTE_RE = /%[0-9a-f]{2}/i const normalizeV3ProxyPath = (pathname: string) => pathname.startsWith('/v3/') ? pathname : `/v3${pathname.startsWith('/') ? pathname : `/${pathname}`}` @@ -68,6 +69,10 @@ export function getV3ProxyPath(requestUrl: URL): string { } export function isV3ProxyPathAllowed(pathname: string): boolean { + // None of the allowlisted path segments require percent-encoding. Reject it + // so an upstream cannot decode `%2F`, `%5C`, `%2E`, or `%00` differently + // after this proxy has already approved the apparent path shape. + if (ENCODED_PATH_BYTE_RE.test(pathname)) return false return ( GET_ONLY_PATHS.has(pathname) || POST_ONLY_PATHS.has(pathname) @@ -85,6 +90,10 @@ export function validateV3ProxyUrl(method: string, requestUrl: URL): V3ProxyVali const normalizedMethod = method.toUpperCase() const pathname = getV3ProxyPath(requestUrl) + if (ENCODED_PATH_BYTE_RE.test(pathname)) { + return invalid(400, 'Encoded V3 path bytes are not allowed') + } + if (!isV3ProxyPathAllowed(pathname)) { return invalid(404, 'V3 path not allowed') } diff --git a/tests/components/reulUnlockReview.test.ts b/tests/components/reulUnlockReview.test.ts new file mode 100644 index 000000000..3cd4075da --- /dev/null +++ b/tests/components/reulUnlockReview.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest' +import type { REULLock } from '~/entities/reul' +import type { TransactionPlan } from '@eulerxyz/euler-v2-sdk' +import { + prepareREULUnlockPlan, + runWithFreshREULLockReview, +} from '~/components/entities/reward/reulUnlockReview' + +const reviewedLock: REULLock = { + timestamp: 1n, + amount: 100n, + unlockableAmount: 80n, + amountToBeBurned: 20n, +} + +describe('prepareREULUnlockPlan', () => { + const plan = [] as TransactionPlan + + it('reports a build failure without attempting simulation', async () => { + const buildError = new Error('build failed') + const simulatePlan = vi.fn(async () => true) + + await expect(prepareREULUnlockPlan( + reviewedLock, + async () => { throw buildError }, + simulatePlan, + )).resolves.toEqual({ status: 'build-failed', error: buildError }) + expect(simulatePlan).not.toHaveBeenCalled() + }) + + it('reports a failed simulation instead of returning a reviewable plan', async () => { + const simulatePlan = vi.fn(async () => false) + + await expect(prepareREULUnlockPlan( + reviewedLock, + async () => plan, + simulatePlan, + )).resolves.toEqual({ status: 'simulation-failed' }) + expect(simulatePlan).toHaveBeenCalledWith(plan) + }) +}) + +describe('runWithFreshREULLockReview', () => { + it('does not execute when a deferred refresh returns a different burn quote', async () => { + let resolveRefresh!: (locks: REULLock[]) => void + const pendingRefresh = new Promise((resolve) => { + resolveRefresh = resolve + }) + const execute = vi.fn(async () => true) + + const resultPromise = runWithFreshREULLockReview( + reviewedLock, + () => pendingRefresh, + execute, + ) + + expect(execute).not.toHaveBeenCalled() + resolveRefresh([{ + ...reviewedLock, + amountToBeBurned: 30n, + }]) + + await expect(resultPromise).resolves.toEqual({ status: 'changed' }) + expect(execute).not.toHaveBeenCalled() + }) + + it('executes only after the refreshed lock matches the reviewed amounts', async () => { + const currentLock = { ...reviewedLock } + const execute = vi.fn(async () => true) + + await expect(runWithFreshREULLockReview( + reviewedLock, + async () => [currentLock], + execute, + )).resolves.toEqual({ status: 'executed' }) + expect(execute).toHaveBeenCalledWith(currentLock) + }) + + it('accepts the naturally improved unlock quote at confirmation', async () => { + const improvedLock = { + ...reviewedLock, + unlockableAmount: 81n, + amountToBeBurned: 19n, + } + const execute = vi.fn(async () => true) + + await expect(runWithFreshREULLockReview( + reviewedLock, + async () => [improvedLock], + execute, + )).resolves.toEqual({ status: 'executed' }) + expect(execute).toHaveBeenCalledWith(improvedLock) + }) +}) diff --git a/tests/composables/useREULLocks.test.ts b/tests/composables/useREULLocks.test.ts index b89eb7870..7f2f81e9d 100644 --- a/tests/composables/useREULLocks.test.ts +++ b/tests/composables/useREULLocks.test.ts @@ -12,6 +12,8 @@ const importUseREULLocks = async (wallet: { } = {}) => { vi.resetModules() + const unmountCallbacks: Array<() => void> = [] + const lock = { timestamp: 1n, amount: 5_920_093_000_000_000_000n, @@ -36,7 +38,9 @@ const importUseREULLocks = async (wallet: { vi.stubGlobal('until', () => ({ toBeTruthy: vi.fn(async () => true), })) - vi.stubGlobal('onUnmounted', vi.fn()) + vi.stubGlobal('onUnmounted', (callback: () => void) => { + unmountCallbacks.push(callback) + }) vi.stubGlobal('useWagmi', () => ({ isConnected: ref(wallet.connected ?? false), address: ref(wallet.address), @@ -67,6 +71,7 @@ const importUseREULLocks = async (wallet: { buildUnlockPlan, unlockPlan, lock, + unmountCallbacks, } } @@ -75,6 +80,7 @@ describe('useREULLocks', () => { afterEach(() => { scope?.stop() + vi.useRealTimers() vi.unstubAllGlobals() vi.resetModules() }) @@ -118,7 +124,111 @@ describe('useREULLocks', () => { chainId: 1, account: owner, lockTimestamp: 123n, + allowRemainderLoss: true, rEulAddress: reulAddress, }) }) + + it('removes stale rows while a required post-transaction refresh is pending', async () => { + const { useREULLocks, fetchLocks, lock } = await importUseREULLocks() + + let locks: ReturnType | undefined + scope = effectScope() + scope.run(() => { + locks = useREULLocks() + }) + + if (!locks) throw new Error('useREULLocks did not initialize') + await vi.waitFor(() => expect(locks?.locks.value).toEqual([lock])) + + const refreshedLock = { + ...lock, + unlockableAmount: lock.unlockableAmount + 1n, + amountToBeBurned: 1n, + } + let resolveRefresh!: (value: typeof lock[]) => void + const pendingRefresh = new Promise((resolve) => { + resolveRefresh = resolve + }) + fetchLocks.mockImplementationOnce(() => pendingRefresh) + + const refreshPromise = locks.refreshLocks(true) + + expect(locks.isLocksLoading.value).toBe(true) + expect(locks.locks.value).toEqual([]) + + resolveRefresh([refreshedLock]) + await expect(refreshPromise).resolves.toEqual([refreshedLock]) + expect(locks.isLocksLoading.value).toBe(false) + expect(locks.locks.value).toEqual([refreshedLock]) + }) + + it('clears shared state and invalidates in-flight loads after the final consumer unmounts', async () => { + const { useREULLocks, fetchLocks, lock, unmountCallbacks } = await importUseREULLocks() + + let locks: ReturnType | undefined + scope = effectScope() + scope.run(() => { + locks = useREULLocks() + }) + + if (!locks) throw new Error('useREULLocks did not initialize') + await vi.waitFor(() => expect(locks?.locks.value).toEqual([lock])) + + const staleLock = { + ...lock, + unlockableAmount: lock.unlockableAmount + 1n, + } + let resolveRefresh!: (value: typeof lock[]) => void + const pendingRefresh = new Promise((resolve) => { + resolveRefresh = resolve + }) + fetchLocks.mockImplementationOnce(() => pendingRefresh) + + const refreshPromise = locks.refreshLocks() + await vi.waitFor(() => expect(fetchLocks).toHaveBeenCalledTimes(2)) + + scope.stop() + scope = undefined + unmountCallbacks[0]?.() + expect(locks.locks.value).toEqual([]) + expect(locks.isLocksLoading.value).toBe(false) + + resolveRefresh([staleLock]) + await expect(refreshPromise).resolves.toBeNull() + expect(locks.locks.value).toEqual([]) + + scope = effectScope() + scope.run(() => { + locks = useREULLocks() + }) + await vi.waitFor(() => expect(fetchLocks).toHaveBeenCalledTimes(3)) + expect(locks.locks.value).toEqual([lock]) + }) + + it('keeps the shared poller alive until the last sibling consumer unmounts', async () => { + vi.useFakeTimers() + const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval') + const { useREULLocks, fetchLocks, unmountCallbacks } = await importUseREULLocks() + + scope = effectScope() + scope.run(() => { + useREULLocks() + useREULLocks() + }) + + await vi.waitFor(() => expect(fetchLocks).toHaveBeenCalledTimes(1)) + expect(unmountCallbacks).toHaveLength(2) + unmountCallbacks[0]?.() + expect(clearIntervalSpy).not.toHaveBeenCalled() + const callsBeforePoll = fetchLocks.mock.calls.length + await vi.advanceTimersByTimeAsync(60_000) + expect(fetchLocks).toHaveBeenCalledTimes(callsBeforePoll + 1) + + unmountCallbacks[1]?.() + expect(clearIntervalSpy).toHaveBeenCalledTimes(1) + const callsAfterUnmount = fetchLocks.mock.calls.length + await vi.advanceTimersByTimeAsync(60_000) + expect(fetchLocks).toHaveBeenCalledTimes(callsAfterUnmount) + }) }) diff --git a/tests/entities/reward-campaign.test.ts b/tests/entities/reward-campaign.test.ts index cd253006f..abb48ec14 100644 --- a/tests/entities/reward-campaign.test.ts +++ b/tests/entities/reward-campaign.test.ts @@ -73,6 +73,23 @@ describe('rewardCampaignDisplay', () => { }).sourceUrl).toBe('https://app.merkl.xyz/opportunities/monad/EULER/example') }) + it.each([ + 'javascript:alert(document.domain)', + 'data:text/html,', + 'vbscript:msgbox(1)', + '//evil.example/drainer', + ])('drops an unsafe provider sourceUrl (%s)', (sourceUrl) => { + expect(rewardCampaignDisplay({ ...baseCampaign, sourceUrl }).sourceUrl).toBeUndefined() + }) + + it('does not disguise a rejected URL with a provider fallback', () => { + expect(rewardCampaignDisplay({ + ...baseCampaign, + source: 'turtle', + sourceUrl: 'javascript:alert(1)', + }).sourceUrl).toBeUndefined() + }) + it('links Turtle campaigns to the Turtle stream dashboard', () => { expect(rewardCampaignDisplay({ ...baseCampaign, diff --git a/tests/server/security.test.ts b/tests/server/security.test.ts index 46aafa7f9..03ae002c7 100644 --- a/tests/server/security.test.ts +++ b/tests/server/security.test.ts @@ -12,12 +12,19 @@ * docs/architecture.md (Clickjacking & Framing Defenses) first. */ import { describe, it, expect } from 'vitest' +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' import type { H3Event } from 'h3' import { buildCsp } from '~/server/plugins/csp' import { applySecurityHeaders } from '~/server/middleware/security-headers' import { ANTI_CLICKJACK_SCRIPT } from '~/server/plugins/00-anti-clickjack' import { escapeScriptJson } from '~/server/plugins/app-config' +const collectTsFiles = (dir: string): string[] => readdirSync(dir).flatMap((entry) => { + const path = join(dir, entry) + return statSync(path).isDirectory() ? collectTsFiles(path) : path.endsWith('.ts') ? [path] : [] +}) + describe('buildCsp', () => { const csp = buildCsp('test-nonce', [], { connect: [] }, []) @@ -179,3 +186,23 @@ describe('escapeScriptJson (inline __APP_CONFIG__ payload)', () => { expect(JSON.parse(escaped)).toEqual(original) }) }) + +describe('server logging and inline-config invariants', () => { + it('does not bypass the redacting server logger with console calls', () => { + const offenders = collectTsFiles(join(process.cwd(), 'server')).flatMap(file => + readFileSync(file, 'utf8').split('\n').flatMap((line, index) => + /(? { + for (const file of ['server/plugins/app-config.ts', 'server/plugins/chain-config.ts']) { + const source = readFileSync(join(process.cwd(), file), 'utf8') + expect(source).toContain('escapeScriptJson(') + } + }) +}) diff --git a/tests/server/v3-proxy.test.ts b/tests/server/v3-proxy.test.ts index f0f6f6d88..ee525f5e3 100644 --- a/tests/server/v3-proxy.test.ts +++ b/tests/server/v3-proxy.test.ts @@ -66,6 +66,18 @@ describe('v3 proxy utilities', () => { expect(isV3ProxyPathAllowed(`/v3/activity/vaults/1/not-an-address/events`)).toBe(false) }) + it('rejects percent-encoded path bytes before attaching the upstream API key', () => { + for (const path of [ + `/api/internal/v3/accounts/${ACCOUNT}%2Fadmin/positions`, + `/api/internal/v3/accounts/${ACCOUNT}%5Cadmin/positions`, + `/api/internal/v3/accounts/${ACCOUNT}%2e%2e/positions`, + `/api/internal/v3/accounts/${ACCOUNT}%00/positions`, + ]) { + expect(validateV3ProxyUrl('GET', new URL(`https://app.example${path}`))) + .toMatchObject({ ok: false, statusCode: 400 }) + } + }) + it('allows query strings on SDK-owned endpoints for V3 to validate', () => { expect(validateV3ProxyUrl( 'GET', diff --git a/tests/utils/activity-display.test.ts b/tests/utils/activity-display.test.ts index c9f873740..7a8e19e79 100644 --- a/tests/utils/activity-display.test.ts +++ b/tests/utils/activity-display.test.ts @@ -73,6 +73,10 @@ describe('activity display helpers', () => { 'repay', 'set_caps', 'set_ltv', + 'set_oracle_config', + 'set_fallback_oracle', + 'set_resolved_vault', + 'set_oracle_governor', 'liquidation', ])) @@ -192,9 +196,15 @@ describe('activity display helpers', () => { it('uses normalized labels and titleizes fallback event types', () => { expect(formatActivityEventLabel({ label: 'Borrowed USDC', type: 'borrow' })).toBe('Borrowed USDC') - expect(formatActivityEventLabel({ type: 'set_supply_cap' })).toBe('Set supply cap') - expect(formatActivityEventLabel({ type: 'set_ltv' })).toBe('Set LTV') - expect(formatActivityEventLabel({ type: 'set_interest_rate_model' })).toBe('Set interest rate model') + expect(formatActivityEventLabel({ type: 'set_supply_cap' })).toBe('Supply cap updated') + expect(formatActivityEventLabel({ type: 'set_ltv' })).toBe('LTV updated') + expect(formatActivityEventLabel({ type: 'set_interest_rate_model' })).toBe('Interest rate model updated') + expect(formatActivityEventLabel({ type: 'set_liquidation_cool_off_time' })).toBe('Liquidation cool-off time updated') + expect(formatActivityEventLabel({ type: 'set_is_allocator' })).toBe('Allocator status updated') + expect(formatActivityEventLabel({ type: 'set_oracle_config' })).toBe('Oracle route updated') + expect(formatActivityEventLabel({ type: 'set_fallback_oracle' })).toBe('Fallback oracle updated') + expect(formatActivityEventLabel({ type: 'set_resolved_vault' })).toBe('Resolved vault updated') + expect(formatActivityEventLabel({ type: 'set_oracle_governor' })).toBe('Oracle governor updated') }) it('labels and styles vault share transfers relative to the event position', () => { @@ -703,6 +713,119 @@ describe('activity display helpers', () => { }, getVaultMetadata)).toEqual([ { field: 'new_supply_cap', label: 'New supply cap', value: '155M USDC' }, ]) + + expect(getActivityChangeEntries({ + type: 'set_oracle_config', + vault: VAULT, + vaultType: 'evk', + change: { + fields: { + router: OTHER_VAULT, + oracle: VAULT, + asset1: SHARES, + asset0: ASSET, + }, + }, + }, getVaultMetadata, address => address === ASSET + ? 'AUSD' + : address === SHARES + ? 'PT-AUSD' + : undefined)).toEqual([ + { + field: 'asset_pair', + label: 'Asset pair', + summary: 'AUSD / PT-AUSD', + addresses: [ + { address: ASSET, label: 'AUSD', linkKind: 'explorer' }, + { address: SHARES, label: 'PT-AUSD', linkKind: 'explorer' }, + ], + }, + { + field: 'oracle', + label: 'Oracle', + addresses: [{ address: VAULT, linkKind: 'explorer' }], + }, + { + field: 'router', + label: 'Router', + addresses: [{ address: OTHER_VAULT, linkKind: 'explorer' }], + }, + ]) + + // The resolved vault leads (the collapsed row shows only the first + // entry), and the asset address decodes into its token symbol. + expect(getActivityChangeEntries({ + type: 'set_resolved_vault', + vault: VAULT, + vaultType: 'evk', + change: { + fields: { + router: ASSET, + resolved_vault: OTHER_VAULT, + asset: SHARES, + }, + }, + }, getVaultMetadata, address => address === SHARES ? 'PT-AUSD' : undefined)).toEqual([ + { + field: 'resolved_vault', + label: 'Resolved vault', + addresses: [{ + address: OTHER_VAULT, + label: 'Collateral vault', + linkKind: 'vault', + vaultType: 'evk', + }], + }, + { + field: 'asset', + label: 'Asset', + addresses: [{ address: SHARES, label: 'PT-AUSD', linkKind: 'explorer' }], + }, + { + field: 'router', + label: 'Router', + addresses: [{ address: ASSET, linkKind: 'explorer' }], + }, + ]) + + // A resolved vault the registry cannot resolve (e.g. a non-Euler + // ERC-4626) falls back to its token symbol and an explorer link instead + // of a dead internal vault page. + expect(getActivityChangeEntries({ + type: 'set_resolved_vault', + vault: VAULT, + vaultType: 'evk', + change: { fields: { resolved_vault: SHARES, asset: ASSET } }, + }, getVaultMetadata, address => address === SHARES + ? 'sUSDS' + : address === ASSET + ? 'USDC' + : undefined)).toEqual([ + { + field: 'resolved_vault', + label: 'Resolved vault', + addresses: [{ address: SHARES, label: 'sUSDS', linkKind: 'explorer' }], + }, + { + field: 'asset', + label: 'Asset', + addresses: [{ address: ASSET, label: 'USDC', linkKind: 'explorer' }], + }, + ]) + + // Without a symbol source the asset falls back to its plain address link. + expect(getActivityChangeEntries({ + type: 'set_resolved_vault', + vault: VAULT, + vaultType: 'evk', + change: { fields: { asset: SHARES } }, + }, getVaultMetadata)).toEqual([ + { + field: 'asset', + label: 'Asset', + addresses: [{ address: SHARES, linkKind: 'explorer' }], + }, + ]) }) it('orders LTV change fields and trims ramp fields on immediate changes', () => { diff --git a/tests/utils/external-url.test.ts b/tests/utils/external-url.test.ts new file mode 100644 index 000000000..724f7ec66 --- /dev/null +++ b/tests/utils/external-url.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { safeExternalHttpUrl } from '~/utils/external-url' + +describe('safeExternalHttpUrl', () => { + it('passes through absolute HTTP(S) URLs', () => { + expect(safeExternalHttpUrl('https://example.com/path')).toBe('https://example.com/path') + expect(safeExternalHttpUrl('http://example.com')).toBe('http://example.com') + }) + + it.each([ + 'javascript:alert(1)', + 'JavaScript:alert(1)', + 'data:text/html,', + 'vbscript:msgbox(1)', + 'file:///etc/passwd', + 'blob:https://example.com/id', + '/relative', + '//example.com/path', + 'not a url', + ])('rejects a non-HTTP(S) external URL (%s)', (value) => { + expect(safeExternalHttpUrl(value)).toBeUndefined() + }) + + it('rejects empty and non-string values', () => { + expect(safeExternalHttpUrl('')).toBeUndefined() + expect(safeExternalHttpUrl(undefined)).toBeUndefined() + expect(safeExternalHttpUrl(null)).toBeUndefined() + expect(safeExternalHttpUrl(42)).toBeUndefined() + }) +}) diff --git a/tests/utils/race-guard.test.ts b/tests/utils/race-guard.test.ts index 1231b1fc8..3722070ba 100644 --- a/tests/utils/race-guard.test.ts +++ b/tests/utils/race-guard.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { createRaceGuard } from '~/utils/race-guard' +import { createRaceGuard, runGuarded } from '~/utils/race-guard' describe('createRaceGuard', () => { it('starts at generation 0', () => { @@ -30,3 +30,47 @@ describe('createRaceGuard', () => { expect(guard.isStale(gen)).toBe(false) }) }) + +describe('runGuarded', () => { + const settleAfter = (value: T, ms: number) => + new Promise(resolve => setTimeout(() => resolve(value), ms)) + + it('commits the result when no newer run started', async () => { + const guard = createRaceGuard() + let committed = '-' + + await runGuarded(guard, () => settleAfter('fresh', 1), (value) => { + committed = value + }) + + expect(committed).toBe('fresh') + }) + + // Regression: a slow early run must not overwrite a fresher result that + // already landed — the failure mode when a second read retriggers formatting. + it('drops an out-of-order result from a superseded run', async () => { + const guard = createRaceGuard() + let committed = '-' + const commit = (value: string) => { + committed = value + } + + const slowFirst = runGuarded(guard, () => settleAfter('stale', 30), commit) + const fastSecond = runGuarded(guard, () => settleAfter('fresh', 5), commit) + + await Promise.all([slowFirst, fastSecond]) + + expect(committed).toBe('fresh') + }) + + it('does not commit when the task rejects', async () => { + const guard = createRaceGuard() + let committed = '-' + + await expect( + runGuarded(guard, () => Promise.reject(new Error('boom')), (value: string) => { committed = value }), + ).rejects.toThrow('boom') + + expect(committed).toBe('-') + }) +}) diff --git a/tests/utils/sdk-prices.test.ts b/tests/utils/sdk-prices.test.ts index 241179f4d..a004e70f7 100644 --- a/tests/utils/sdk-prices.test.ts +++ b/tests/utils/sdk-prices.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { ONE_18, conservativePriceRatio, + formatAssetValue, getAssetUsdPrice, getAssetUsdValue, getAssetUsdValueForEstimate, @@ -30,6 +31,21 @@ describe('sdk-prices', () => { await expect(getAssetUsdValue(1_500_000n, vault, 'off-chain')).resolves.toBe(3) }) + it('preserves the normalized uncovered-loss amount when formatting it for display', async () => { + const vault = { + address: addressA, + asset: { decimals: 6, symbol: 'USDT' }, + marketPriceUsd: ONE_18, + } + + await expect(formatAssetValue(6_361_518_648_400n, vault, 'off-chain')).resolves.toMatchObject({ + assetAmount: 6_361_518.6484, + usdValue: 6_361_518.6484, + hasPrice: true, + assetSymbol: 'USDT', + }) + }) + it('distinguishes an empty estimate leg from a positive unpriced amount', async () => { const vault = { address: addressA, diff --git a/tests/utils/stepDecoding.test.ts b/tests/utils/stepDecoding.test.ts index 3aa43e464..9b3cfcf50 100644 --- a/tests/utils/stepDecoding.test.ts +++ b/tests/utils/stepDecoding.test.ts @@ -73,6 +73,9 @@ const aaveAuthAbi = parseAbi([ 'function delegationWithSig(address delegator,address delegatee,uint256 value,uint256 deadline,uint8 v,bytes32 r,bytes32 s)', 'function permit(address owner,address spender,uint256 value,uint256 deadline,uint8 v,bytes32 r,bytes32 s)', ]) +const reulAbi = parseAbi([ + 'function withdrawToByLockTimestamp(address account,uint256 lockTimestamp,bool allowRemainderLoss)', +]) const ctx: StepDecodingContext = { type: 'swap', @@ -534,6 +537,41 @@ describe('buildTransactionPlanDisplaySteps generic-handler redeem outside migrat }) }) +describe('buildTransactionPlanDisplaySteps rEUL unlock rows', () => { + it('labels the SDK unlock batch item and shows the reviewed EUL amount', () => { + const steps = buildTransactionPlanDisplaySteps( + [{ + type: 'evcBatch', + items: [{ + type: 'operation', + name: 'Unlock rEUL', + items: [batchItem(encodeFunctionData({ + abi: reulAbi, + functionName: 'withdrawToByLockTimestamp', + args: [account, 123n, true], + }))], + }], + }] satisfies TransactionPlan, + { + type: 'reul-unlock', + asset: { symbol: 'EUL', address: usdcAsset, decimals: 18 }, + amount: '1.2345', + }, + getVault, + getLogoUrl, + ) + + expect(steps).toMatchObject([{ + label: 'Unlock', + assetInfo: { + symbol: 'EUL', + address: usdcAsset, + amount: '1.2345', + }, + }]) + }) +}) + describe('buildTransactionPlanDisplaySteps migration rows', () => { const collateralAmount = 1_771_920_000_000_000n const debtBorrowAmount = 1_010_849n diff --git a/utils/activity-display.ts b/utils/activity-display.ts index dcfc9eb87..1df1af2b6 100644 --- a/utils/activity-display.ts +++ b/utils/activity-display.ts @@ -110,6 +110,10 @@ const VAULT_ACTIVITY_EVENT_TYPES = { 'set_interest_rate_model', 'set_liquidation_cool_off_time', 'set_max_liquidation_discount', + 'set_oracle_config', + 'set_fallback_oracle', + 'set_resolved_vault', + 'set_oracle_governor', ], earn: [ 'deposit', @@ -445,6 +449,19 @@ export const formatActivityEventLabel = ( ): string => { const sourceLabel = event.label?.trim() if (sourceLabel) return sourceLabel + const normalizedLabel = { + set_oracle_config: 'Oracle route updated', + set_fallback_oracle: 'Fallback oracle updated', + set_resolved_vault: 'Resolved vault updated', + set_oracle_governor: 'Oracle governor updated', + set_liquidation_cool_off_time: 'Liquidation cool-off time updated', + set_is_allocator: 'Allocator status updated', + }[event.type] + if (normalizedLabel) return normalizedLabel + if (event.type.startsWith('set_')) { + const setting = titleizeActivityType(event.type.slice('set_'.length)) + return `${applyActivityAcronyms(setting)} updated` + } if (event.type === 'transfer') { const direction = getActivityTransferDirection(event) if (direction === 'sent') return 'Vault shares sent' @@ -813,6 +830,7 @@ export interface ActivityChangeEntry { field: string label: string value?: string + summary?: string addresses?: ActivityChangeAddress[] } @@ -829,12 +847,18 @@ const VAULT_ADDRESS_FIELDS_BY_EVENT: Partial> = { + set_resolved_vault: ['asset'], +} + const parseActivityInteger = (value: ActivityChangeValue): bigint | null => { if (typeof value !== 'string' && typeof value !== 'number') return null try { @@ -951,6 +975,7 @@ const isZeroAddressValue = (value: ActivityChangeValue): boolean => { /** Display order for change fields whose upstream order is unhelpful. */ const CHANGE_FIELD_PRIORITY: Partial> = { + set_fallback_oracle: ['fallback_oracle', 'router'], set_ltv: [ 'collateral', 'borrow_ltv', @@ -959,6 +984,9 @@ const CHANGE_FIELD_PRIORITY: Partial { const values = Array.isArray(value) ? value : [value] if (!values.length || !values.every(item => typeof item === 'string' && isAddress(item))) return null const isVaultAddress = VAULT_ADDRESS_FIELDS_BY_EVENT[event.type]?.includes(field) ?? false + const isTokenAddress = TOKEN_ADDRESS_FIELDS_BY_EVENT[event.type]?.includes(field) ?? false + const tokenLabel = (address: Address) => + getTokenSymbol?.(address) ?? getSpecialAddressLabel(address) return values.map((item) => { const address = item as Address - if (!isVaultAddress) return { address, linkKind: 'explorer' as const } - const display = getVaultMetadata - ? resolveActivityVaultDisplay(address, getVaultMetadata) - : null + if (!isVaultAddress) { + const label = isTokenAddress ? tokenLabel(address) : undefined + return { address, linkKind: 'explorer' as const, ...(label ? { label } : {}) } + } + const metadata = getVaultMetadata?.(address) + // A vault the registry cannot resolve (e.g. a non-Euler ERC-4626 resolved + // vault) still has a token symbol — show that and link to the explorer + // instead of a dead internal vault page. + if (!metadata) { + const label = tokenLabel(address) + return { address, linkKind: 'explorer' as const, ...(label ? { label } : {}) } + } + const display = resolveActivityVaultDisplay(address, getVaultMetadata!) return { address, linkKind: 'vault' as const, label: display?.name ?? display?.addressLabel, - vaultType: getVaultMetadata?.(address)?.vaultType ?? event.vaultType, + vaultType: metadata.vaultType ?? event.vaultType, } }) } +const resolveOracleAssetPair = ( + event: ActivityChangeEventSource, + getTokenSymbol: ActivityAddressLabelLookup | undefined, +): ActivityChangeEntry | null => { + if (event.type !== 'set_oracle_config') return null + const asset0 = event.change?.fields.asset0 + const asset1 = event.change?.fields.asset1 + if ( + typeof asset0 !== 'string' + || typeof asset1 !== 'string' + || !isAddress(asset0) + || !isAddress(asset1) + ) return null + + const addresses = [asset0, asset1].map((value) => { + const address = value as Address + return { + address, + label: getTokenSymbol?.(address) ?? getSpecialAddressLabel(address) ?? shortenAddress(address), + linkKind: 'explorer' as const, + } + }) + return { + field: 'asset_pair', + label: 'Asset pair', + summary: addresses.map(address => address.label).join(' / '), + addresses, + } +} + export const getActivityChangeEntries = ( event: ActivityChangeEventSource, getVaultMetadata?: ActivityVaultMetadataLookup, -): ActivityChangeEntry[] => orderedActivityChangeFields(event).map(([field, value]) => { + getTokenSymbol?: ActivityAddressLabelLookup, +): ActivityChangeEntry[] => { + const assetPair = resolveOracleAssetPair(event, getTokenSymbol) + const fields = orderedActivityChangeFields(event) + .filter(([field]) => !assetPair || (field !== 'asset0' && field !== 'asset1')) + + const entries = fields.map(([field, value]): ActivityChangeEntry => { // The zero address reads better as an explicit "None" than as a linked, // copyable 0x0000…0000 (e.g. a renounced governor or cleared receiver). - if (isZeroAddressValue(value)) { - return { field, label: formatActivityChangeLabel(field), value: 'None' } - } - const addresses = resolveChangeAddresses(event, field, value, getVaultMetadata) - if (addresses) return { field, label: formatActivityChangeLabel(field), addresses } + if (isZeroAddressValue(value)) { + return { field, label: formatActivityChangeLabel(field), value: 'None' } + } + const addresses = resolveChangeAddresses(event, field, value, getVaultMetadata, getTokenSymbol) + if (addresses) return { field, label: formatActivityChangeLabel(field), addresses } - let formatted: string | null = null - const vaultMetadata = event.vault ? getVaultMetadata?.(event.vault) : undefined - if (event.type === 'set_caps' && (field === 'supply_cap' || field === 'borrow_cap')) { - formatted = formatActivityCap(value, event.vault, getVaultMetadata) - } - else if ( - (event.type === 'set_cap' || event.type === 'submit_cap') - && field === 'cap' - ) { - const cap = parseActivityInteger(value) - formatted = event.vaultType === 'earn' && cap !== null && cap >= UINT136_MAX - ? 'Unlimited' - : formatActivityTokenAmount(value, vaultMetadata?.asset, true) - } - else if ( - event.type === 'set_supply_cap' - && (field === 'cap' || field === 'supply_cap' || field === 'new_supply_cap') - ) { - formatted = formatActivityTokenAmount(value, vaultMetadata?.asset, true) - } - else if ( - (event.type === 'reallocate_supply' || event.type === 'reallocate_withdraw') - && (field === 'supplied_assets' || field === 'withdrawn_assets') - ) { - formatted = formatActivityTokenAmount(value, vaultMetadata?.asset, true) - } - else if (event.type === 'set_ltv' && field.endsWith('_ltv')) { - formatted = formatActivityBps(value) - } - else if ( - event.type === 'set_config_flags' - && (field === 'config_flags' || field === 'new_config_flags') - ) { - formatted = formatActivityConfigFlags(value) - } - // EVK ConfigAmounts are scaled over 1e4 — 500 reads as 5%, 350 as 3.5%. - else if ( - event.type === 'set_interest_fee' - && ['fee', 'new_fee', 'interest_fee', 'new_interest_fee'].includes(field) - ) { - formatted = formatActivityBps(value) - } - else if ( - event.type === 'set_max_liquidation_discount' - && ['discount', 'new_discount', 'max_liquidation_discount', 'new_max_liquidation_discount'].includes(field) - ) { - formatted = formatActivityBps(value) - } - else if ( - event.type === 'set_hook_config' - && (field === 'hooked_ops' || field === 'new_hooked_ops') - ) { - formatted = formatActivityHookedOperations(value) - } - else if (field === 'target_timestamp') { - formatted = formatActivityUnixTimestamp(value) - } - else if (field === 'ramp_duration' || field.endsWith('_timelock') || field.endsWith('_cool_off_time')) { - formatted = formatActivityDuration(value) - } + let formatted: string | null = null + const vaultMetadata = event.vault ? getVaultMetadata?.(event.vault) : undefined + if (event.type === 'set_caps' && (field === 'supply_cap' || field === 'borrow_cap')) { + formatted = formatActivityCap(value, event.vault, getVaultMetadata) + } + else if ( + (event.type === 'set_cap' || event.type === 'submit_cap') + && field === 'cap' + ) { + const cap = parseActivityInteger(value) + formatted = event.vaultType === 'earn' && cap !== null && cap >= UINT136_MAX + ? 'Unlimited' + : formatActivityTokenAmount(value, vaultMetadata?.asset, true) + } + else if ( + event.type === 'set_supply_cap' + && (field === 'cap' || field === 'supply_cap' || field === 'new_supply_cap') + ) { + formatted = formatActivityTokenAmount(value, vaultMetadata?.asset, true) + } + else if ( + (event.type === 'reallocate_supply' || event.type === 'reallocate_withdraw') + && (field === 'supplied_assets' || field === 'withdrawn_assets') + ) { + formatted = formatActivityTokenAmount(value, vaultMetadata?.asset, true) + } + else if (event.type === 'set_ltv' && field.endsWith('_ltv')) { + formatted = formatActivityBps(value) + } + else if ( + event.type === 'set_config_flags' + && (field === 'config_flags' || field === 'new_config_flags') + ) { + formatted = formatActivityConfigFlags(value) + } + // EVK ConfigAmounts are scaled over 1e4 — 500 reads as 5%, 350 as 3.5%. + else if ( + event.type === 'set_interest_fee' + && ['fee', 'new_fee', 'interest_fee', 'new_interest_fee'].includes(field) + ) { + formatted = formatActivityBps(value) + } + else if ( + event.type === 'set_max_liquidation_discount' + && ['discount', 'new_discount', 'max_liquidation_discount', 'new_max_liquidation_discount'].includes(field) + ) { + formatted = formatActivityBps(value) + } + else if ( + event.type === 'set_hook_config' + && (field === 'hooked_ops' || field === 'new_hooked_ops') + ) { + formatted = formatActivityHookedOperations(value) + } + else if (field === 'target_timestamp') { + formatted = formatActivityUnixTimestamp(value) + } + else if (field === 'ramp_duration' || field.endsWith('_timelock') || field.endsWith('_cool_off_time')) { + formatted = formatActivityDuration(value) + } - return { - field, - label: formatActivityChangeLabel(field), - value: formatted ?? formatActivityChangeValue(value), - } -}) + return { + field, + label: formatActivityChangeLabel(field), + value: formatted ?? formatActivityChangeValue(value), + } + }) + return assetPair ? [assetPair, ...entries] : entries +} interface ActivityParticipantSource { account?: Address diff --git a/utils/external-url.ts b/utils/external-url.ts new file mode 100644 index 000000000..88aa5e256 --- /dev/null +++ b/utils/external-url.ts @@ -0,0 +1,11 @@ +/** Return an absolute HTTP(S) URL, or undefined for unsafe/relative values. */ +export function safeExternalHttpUrl(value: unknown): string | undefined { + if (typeof value !== 'string' || !value) return undefined + try { + const url = new URL(value) + return url.protocol === 'http:' || url.protocol === 'https:' ? value : undefined + } + catch { + return undefined + } +} diff --git a/utils/race-guard.ts b/utils/race-guard.ts index c85de115b..0d128ea49 100644 --- a/utils/race-guard.ts +++ b/utils/race-guard.ts @@ -12,3 +12,21 @@ export function createRaceGuard(): RaceGuard { isStale: (captured: number) => captured !== generation, } } + +/** + * Awaits `task` and commits its result only when no newer run started meanwhile. + * + * Guards the common effect shape where a display value is recomputed on every + * dependency change: without this, a slow early call can resolve last and + * overwrite a fresher result that already landed. + */ +export async function runGuarded( + guard: RaceGuard, + task: () => Promise, + commit: (value: T) => void, +): Promise { + const generation = guard.next() + const result = await task() + if (guard.isStale(generation)) return + commit(result) +} diff --git a/utils/stepDecoding.ts b/utils/stepDecoding.ts index 0ae700482..42a207e4f 100644 --- a/utils/stepDecoding.ts +++ b/utils/stepDecoding.ts @@ -115,6 +115,7 @@ const AAVE_PERMIT_SELECTOR = toFunctionSelector('function permit(address,address const MERKL_CLAIM_SELECTOR = toFunctionSelector('function claim(address[],address[],uint256[],bytes32[][])') const BREVIS_CLAIM_SELECTOR = toFunctionSelector('function claim(address,uint256[],uint64,bytes32[])') const FUUL_CLAIM_SELECTOR = toFunctionSelector('function claim((address,address,address,uint8,uint256,uint8,uint256,uint256,bytes32,bytes[])[])') +const REUL_UNLOCK_SELECTOR = toFunctionSelector('function withdrawToByLockTimestamp(address,uint256,bool)') const MORPHO_AUTHORIZATION_SELECTOR = toFunctionSelector('function setAuthorizationWithSig((address,address,bool,uint256,uint256),(uint8,bytes32,bytes32))') const MORPHO_BORROW_FOR_SENDER_SELECTOR = toFunctionSelector('function morphoBorrowForSender(address,(address,address,address,address,uint256),uint256,address)') const MORPHO_WITHDRAW_COLLATERAL_FOR_SENDER_SELECTOR = toFunctionSelector('function morphoWithdrawCollateralForSender(address,(address,address,address,address,uint256),uint256,address)') @@ -208,6 +209,7 @@ const SELECTOR_LABELS: Record = { [MERKL_CLAIM_SELECTOR]: 'Claim', [BREVIS_CLAIM_SELECTOR]: 'Claim', [FUUL_CLAIM_SELECTOR]: 'Claim', + [REUL_UNLOCK_SELECTOR]: 'Unlock', } const MAX_UINT256 = 2n ** 256n - 1n @@ -1091,7 +1093,7 @@ const resolveBatchItemAssetInfo = ( return { symbol: ctx.asset.symbol, address: ctx.asset.address } } - if (label === 'Claim') { + if (label === 'Claim' || label === 'Unlock') { return { symbol: ctx.asset.symbol, address: ctx.asset.address, amount: ctx.amount } }