diff --git a/.env.example b/.env.example index a457d5432..2fb19507f 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,22 @@ VITE_RPC_URI_FOR_8453= VITE_RPC_URI_FOR_42161= VITE_RPC_URI_FOR_747474= +# Tenderly Virtual TestNet mode: opt-in execution-chain override layer. +# Keep the app canonical on chain IDs like 1/10/8453 and map execution only through these vars. +# Repeat the *_FOR_ pattern for each canonical chain you enable. +VITE_TENDERLY_MODE= +VITE_TENDERLY_CHAIN_ID_FOR_1= +VITE_TENDERLY_RPC_URI_FOR_1= +# Server-only Admin RPC for local scripts and cheatcodes. Keep this private and out of client-exposed VITE_ vars. +TENDERLY_ADMIN_RPC_URI_FOR_1= +VITE_TENDERLY_EXPLORER_URI_FOR_1= + +PERSONAL_TENDERLY_API_KEY= +PERSONAL_ACCOUNT_SLUG= +PERSONAL_PROJECT_SLUG= +PERSONAL_TENDERLY_RPC_NAME= +TENDERLY_TEST_TX_FROM_ADDRESS= + VITE_ALCHEMY_KEY= VITE_INFURA_PROJECT_ID= VITE_PARTNER_ID_ADDRESS= diff --git a/.gitignore b/.gitignore index 7d7433988..abbe96a3e 100755 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,4 @@ yearn.fi-worktree-* # codex settings .codex +.codex/* diff --git a/CLAUDE.md b/CLAUDE.md index 3e0c77ffb..590a7c2e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,18 @@ IMPORTANT: After making code changes, always verify: Husky runs `lint-staged` + `bun run tslint` on every commit. +## Testing + +All tests live in `src/test/` — never mirror the source tree 1:1. Group related tests into a single file per domain. + +**Priority: math and calculations.** APY/APR, price impact, share/asset conversions, bigint math, formatting, duration math. A wrong decimal silently loses user funds. Expected values must be human-verified independently of the code — if an AI would just read the implementation and write the expected value to match, the test is worthless. + +**Allowed: safety-critical logic.** Transaction state machines (skipping steps or double-submitting costs funds), URL validation (XSS vectors, misdirected pool links). These are kept because the consequences of a bug are severe and not immediately visible. + +**Deprioritised: UI/component tests.** Do not use `render`, `screen`, or `@testing-library/react`. When AI writes both the implementation and the tests, it validates its own assumptions against itself — when the implementation changes, the tests get rewritten to match, so they never actually catch anything. + +**Do not test:** config assertions, boolean flag logic, string/label mapping, route resolution, schema validation, data selection/preference, viewport/layout (Tailwind standardizes breakpoints), ABI construction, balance partitioning. These are all patterns where the expected value has no independent source of truth. + ## Code Style Formatting is enforced by Biome (biome.jsonc) — do not worry about indentation, quotes, or commas. @@ -39,6 +51,24 @@ Naming: - Utilities: camelCase (`format.ts`) - Types: T-prefixed (`TSortDirection`, `TVaultType`) +### useEffect — prefer alternatives + +Avoid `useEffect` when a better primitive exists. Most `useEffect` usage hides derived state, duplicates event handling, or re-implements what TanStack Query already provides. + +**Prefer these instead:** +- **Derived state** — compute inline or with `useMemo` instead of `useEffect(() => setX(f(y)), [y])` +- **Event handlers** — do work directly in `onClick`/`onChange` instead of setting a flag for an effect to pick up +- **TanStack Query** — use `useQuery`/`useMutation` for data fetching, never `useEffect` + `fetch` + `setState` +- **`key` prop for reset** — use `` to remount instead of `useEffect` that resets state when an ID changes +- **Conditional rendering** — render children only when preconditions are met (e.g., `{!isLoading && }`) instead of guarding inside an effect + +**When `useEffect` is acceptable:** +- One-time DOM/browser API setup on mount (IntersectionObserver, event listeners, focus) +- Third-party library lifecycle (init/destroy) +- Cases where no declarative alternative exists + +When writing a new `useEffect`, add a brief comment explaining why an alternative does not apply. + ## Architecture **Tech stack:** React 19, Vite, React Router (lazy-loaded), Tailwind CSS 4, TanStack Query, Wagmi/Viem/RainbowKit diff --git a/api/server.ts b/api/server.ts index edd9f459a..0c622ba20 100644 --- a/api/server.ts +++ b/api/server.ts @@ -1,10 +1,40 @@ import { serve } from 'bun' +import type { + TTenderlyFundRequest, + TTenderlyIncreaseTimeRequest, + TTenderlyRevertRequest, + TTenderlySnapshotRequest +} from '../src/components/shared/types/tenderly' +import { + buildTenderlyPanelStatus, + buildTenderlyRevertResponse, + buildTenderlySnapshotRecord, + requireTenderlyServerChain, + resolveTenderlyFundRpcRequest +} from './tenderly.helpers' +import { buildTenderlyAdminAccessDeniedResponse } from './tenderlyAccess' const ENSO_API_BASE = 'https://api.enso.finance' const YVUSD_APR_SERVICE_API = ( process.env.YVUSD_APR_SERVICE_API || 'https://yearn-yvusd-apr-service.vercel.app/api/aprs' ).replace(/\/$/, '') +type TTenderlyJsonRpcSuccess = { + id: string | number | null + jsonrpc: '2.0' + result: unknown +} + +type TTenderlyJsonRpcError = { + id: string | number | null + jsonrpc: '2.0' + error: { + code: number + message: string + data?: unknown + } +} + async function handleYvUsdAprs(req: Request): Promise { if (req.method !== 'GET') { return Response.json({ error: 'Method not allowed' }, { status: 405 }) @@ -43,6 +73,154 @@ async function handleYvUsdAprs(req: Request): Promise { } } +async function parseJsonBody(req: Request): Promise { + try { + return (await req.json()) as T + } catch (_error) { + throw new Error('Invalid JSON body') + } +} + +async function callTenderlyAdminRpc(canonicalChainId: number, method: string, params: unknown[]): Promise { + const configuredChain = requireTenderlyServerChain(process.env, canonicalChainId) + const response = await fetch(configuredChain.adminRpcUri as string, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + id: 1, + jsonrpc: '2.0', + method, + params + }) + }) + + if (!response.ok) { + const details = await response.text() + throw new Error(`Tenderly RPC request failed with status ${response.status}: ${details}`) + } + + const payload = (await response.json()) as TTenderlyJsonRpcSuccess | TTenderlyJsonRpcError + if ('error' in payload) { + throw new Error(`${payload.error.message} (code ${payload.error.code})`) + } + + return payload.result +} + +function handleTenderlyStatus(req: Request): Response { + if (req.method !== 'GET') { + return Response.json({ error: 'Method not allowed' }, { status: 405 }) + } + + try { + return Response.json(buildTenderlyPanelStatus(process.env)) + } catch (error) { + console.error('Error building Tenderly status:', error) + return Response.json( + { error: error instanceof Error ? error.message : 'Failed to build Tenderly status' }, + { status: 500 } + ) + } +} + +async function handleTenderlySnapshot(req: Request): Promise { + if (req.method !== 'POST') { + return Response.json({ error: 'Method not allowed' }, { status: 405 }) + } + + try { + const body = await parseJsonBody(req) + const configuredChain = requireTenderlyServerChain(process.env, body.canonicalChainId) + const snapshotId = await callTenderlyAdminRpc(body.canonicalChainId, 'evm_snapshot', []) + const snapshotRecord = buildTenderlySnapshotRecord({ + canonicalChainId: body.canonicalChainId, + executionChainId: configuredChain.executionChainId, + snapshotId: String(snapshotId), + label: body.label, + isBaseline: body.isBaseline + }) + + return Response.json(snapshotRecord) + } catch (error) { + console.error('Error creating Tenderly snapshot:', error) + return Response.json( + { error: error instanceof Error ? error.message : 'Failed to create Tenderly snapshot' }, + { status: 400 } + ) + } +} + +async function handleTenderlyRevert(req: Request): Promise { + if (req.method !== 'POST') { + return Response.json({ error: 'Method not allowed' }, { status: 405 }) + } + + try { + const body = await parseJsonBody(req) + const result = await callTenderlyAdminRpc(body.canonicalChainId, 'evm_revert', [body.snapshotId]) + + return Response.json(buildTenderlyRevertResponse(result, body.snapshotId)) + } catch (error) { + console.error('Error reverting Tenderly snapshot:', error) + return Response.json( + { error: error instanceof Error ? error.message : 'Failed to revert Tenderly snapshot' }, + { status: 400 } + ) + } +} + +async function handleTenderlyIncreaseTime(req: Request): Promise { + if (req.method !== 'POST') { + return Response.json({ error: 'Method not allowed' }, { status: 405 }) + } + + try { + const body = await parseJsonBody(req) + if (!Number.isInteger(body.seconds) || body.seconds <= 0) { + throw new Error('seconds must be a positive integer') + } + + const timeResult = await callTenderlyAdminRpc(body.canonicalChainId, 'evm_increaseTime', [ + `0x${BigInt(body.seconds).toString(16)}` + ]) + const mineResult = body.mineBlock ? await callTenderlyAdminRpc(body.canonicalChainId, 'evm_mine', []) : undefined + + return Response.json({ + timeResult, + mineResult + }) + } catch (error) { + console.error('Error increasing Tenderly time:', error) + return Response.json( + { error: error instanceof Error ? error.message : 'Failed to increase Tenderly time' }, + { status: 400 } + ) + } +} + +async function handleTenderlyFund(req: Request): Promise { + if (req.method !== 'POST') { + return Response.json({ error: 'Method not allowed' }, { status: 405 }) + } + + try { + const body = await parseJsonBody(req) + const { method, params } = resolveTenderlyFundRpcRequest(body) + const result = await callTenderlyAdminRpc(body.canonicalChainId, method, params) + + return Response.json({ + method, + result + }) + } catch (error) { + console.error('Error funding Tenderly wallet:', error) + return Response.json( + { error: error instanceof Error ? error.message : 'Failed to fund wallet on Tenderly' }, + { status: 400 } + ) + } +} + function handleEnsoStatus(): Response { const apiKey = process.env.ENSO_API_KEY return Response.json({ configured: !!apiKey }) @@ -159,7 +337,7 @@ async function handleEnsoBalances(req: Request): Promise { } serve({ - async fetch(req) { + async fetch(req, server) { const url = new URL(req.url) if (url.pathname === '/api/enso/status') { @@ -178,6 +356,42 @@ serve({ return handleYvUsdAprs(req) } + if (url.pathname === '/api/tenderly/status') { + return handleTenderlyStatus(req) + } + + if (url.pathname === '/api/tenderly/snapshot') { + const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address) + if (accessDeniedResponse) { + return accessDeniedResponse + } + return handleTenderlySnapshot(req) + } + + if (url.pathname === '/api/tenderly/revert') { + const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address) + if (accessDeniedResponse) { + return accessDeniedResponse + } + return handleTenderlyRevert(req) + } + + if (url.pathname === '/api/tenderly/increase-time') { + const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address) + if (accessDeniedResponse) { + return accessDeniedResponse + } + return handleTenderlyIncreaseTime(req) + } + + if (url.pathname === '/api/tenderly/fund') { + const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address) + if (accessDeniedResponse) { + return accessDeniedResponse + } + return handleTenderlyFund(req) + } + return new Response('Not found', { status: 404 }) }, port: 3001 diff --git a/api/tenderly.helpers.test.ts b/api/tenderly.helpers.test.ts new file mode 100644 index 000000000..1c180b6a6 --- /dev/null +++ b/api/tenderly.helpers.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import type { TTenderlyFundRequest } from '../src/components/shared/types/tenderly' +import { + buildTenderlyPanelStatus, + buildTenderlyRevertResponse, + buildTenderlySnapshotRecord, + parseTenderlyServerChains, + resolveTenderlyFundRpcRequest +} from './tenderly.helpers' + +describe('parseTenderlyServerChains', () => { + it('parses configured Tenderly chains from environment variables', () => { + expect( + parseTenderlyServerChains({ + VITE_TENDERLY_MODE: 'true', + VITE_TENDERLY_CHAIN_ID_FOR_1: '694201', + VITE_TENDERLY_RPC_URI_FOR_1: 'https://public.rpc', + TENDERLY_ADMIN_RPC_URI_FOR_1: 'https://admin.rpc' + }) + ).toEqual([ + { + canonicalChainId: 1, + canonicalChainName: 'Ethereum', + executionChainId: 694201, + rpcUri: 'https://public.rpc', + adminRpcUri: 'https://admin.rpc' + } + ]) + }) +}) + +describe('buildTenderlyPanelStatus', () => { + it('reports admin availability per configured chain', () => { + expect( + buildTenderlyPanelStatus({ + VITE_TENDERLY_MODE: 'true', + VITE_TENDERLY_CHAIN_ID_FOR_1: '694201', + VITE_TENDERLY_RPC_URI_FOR_1: 'https://public.rpc' + }) + ).toEqual({ + isTenderlyModeEnabled: true, + configuredChains: [ + { + canonicalChainId: 1, + canonicalChainName: 'Ethereum', + executionChainId: 694201, + hasAdminRpc: false + } + ] + }) + }) +}) + +describe('buildTenderlySnapshotRecord', () => { + it('creates a normalized baseline snapshot record', () => { + const record = buildTenderlySnapshotRecord({ + canonicalChainId: 1, + executionChainId: 694201, + snapshotId: '0x1', + isBaseline: true + }) + + expect(record.kind).toBe('baseline') + expect(record.lastKnownStatus).toBe('valid') + expect(record.label.startsWith('Baseline ')).toBe(true) + }) +}) + +describe('buildTenderlyRevertResponse', () => { + it('returns a success payload when Tenderly restores the snapshot', () => { + expect(buildTenderlyRevertResponse(true, '0x1')).toEqual({ + success: true, + revertedSnapshotId: '0x1' + }) + }) + + it('throws when Tenderly rejects the revert', () => { + expect(() => buildTenderlyRevertResponse(false, '0xdead')).toThrow('Tenderly rejected revert for snapshot 0xdead') + }) +}) + +describe('resolveTenderlyFundRpcRequest', () => { + it('builds native balance funding calls', () => { + const request: TTenderlyFundRequest = { + canonicalChainId: 1, + walletAddress: '0x1111111111111111111111111111111111111111', + assetKind: 'native', + symbol: 'ETH', + decimals: 18, + amount: '1.5', + mode: 'add' + } + + expect(resolveTenderlyFundRpcRequest(request)).toEqual({ + method: 'tenderly_addBalance', + params: [['0x1111111111111111111111111111111111111111'], '0x14d1120d7b160000'] + }) + }) + + it('builds ERC-20 funding calls', () => { + const request: TTenderlyFundRequest = { + canonicalChainId: 1, + walletAddress: '0x1111111111111111111111111111111111111111', + assetKind: 'erc20', + tokenAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + decimals: 6, + amount: '50000' + } + + expect(resolveTenderlyFundRpcRequest(request)).toEqual({ + method: 'tenderly_setErc20Balance', + params: [ + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + '0x1111111111111111111111111111111111111111', + '0xba43b7400' + ] + }) + }) +}) diff --git a/api/tenderly.helpers.ts b/api/tenderly.helpers.ts new file mode 100644 index 000000000..5b22c0077 --- /dev/null +++ b/api/tenderly.helpers.ts @@ -0,0 +1,191 @@ +import { getAddress } from 'viem' +import type { + TTenderlyFundRequest, + TTenderlyPanelStatus, + TTenderlyRevertResponse, + TTenderlySnapshotRecord +} from '../src/components/shared/types/tenderly' +import { canonicalChains } from '../src/config/chainDefinitions' + +type TTenderlyServerEnv = Record + +export type TTenderlyServerChainConfig = { + canonicalChainId: number + canonicalChainName: string + executionChainId: number + rpcUri: string + adminRpcUri?: string +} + +function readEnvString(value: string | undefined): string { + return typeof value === 'string' ? value.trim() : '' +} + +function isTruthyEnvValue(value: string | undefined): boolean { + return ['1', 'on', 'true', 'yes'].includes(readEnvString(value).toLowerCase()) +} + +export function parseTenderlyServerChains(env: TTenderlyServerEnv): TTenderlyServerChainConfig[] { + if (!isTruthyEnvValue(env.VITE_TENDERLY_MODE)) { + return [] + } + + return canonicalChains.reduce((accumulator, chain) => { + const rawExecutionChainId = readEnvString(env[`VITE_TENDERLY_CHAIN_ID_FOR_${chain.id}`]) + const rawRpcUri = readEnvString(env[`VITE_TENDERLY_RPC_URI_FOR_${chain.id}`]) + const rawAdminRpcUri = readEnvString(env[`TENDERLY_ADMIN_RPC_URI_FOR_${chain.id}`]) + const hasAnyConfig = Boolean(rawExecutionChainId || rawRpcUri || rawAdminRpcUri) + + if (!hasAnyConfig) { + return accumulator + } + + if (!rawExecutionChainId || !rawRpcUri) { + throw new Error( + `Tenderly chain ${chain.id} requires both VITE_TENDERLY_CHAIN_ID_FOR_${chain.id} and VITE_TENDERLY_RPC_URI_FOR_${chain.id}` + ) + } + + const executionChainId = Number(rawExecutionChainId) + if (!Number.isInteger(executionChainId) || executionChainId <= 0) { + throw new Error(`Invalid Tenderly execution chain ID for canonical chain ${chain.id}: ${rawExecutionChainId}`) + } + + accumulator.push({ + canonicalChainId: chain.id, + canonicalChainName: chain.name, + executionChainId, + rpcUri: rawRpcUri, + adminRpcUri: rawAdminRpcUri || undefined + }) + + return accumulator + }, []) +} + +export function buildTenderlyPanelStatus(env: TTenderlyServerEnv): TTenderlyPanelStatus { + return { + isTenderlyModeEnabled: isTruthyEnvValue(env.VITE_TENDERLY_MODE), + configuredChains: parseTenderlyServerChains(env).map((chain) => ({ + canonicalChainId: chain.canonicalChainId, + canonicalChainName: chain.canonicalChainName, + executionChainId: chain.executionChainId, + hasAdminRpc: Boolean(chain.adminRpcUri) + })) + } +} + +export function requireTenderlyServerChain( + env: TTenderlyServerEnv, + canonicalChainId: number +): TTenderlyServerChainConfig { + const configuredChain = parseTenderlyServerChains(env).find((chain) => chain.canonicalChainId === canonicalChainId) + + if (!configuredChain) { + throw new Error(`Tenderly chain ${canonicalChainId} is not configured`) + } + + if (!configuredChain.adminRpcUri) { + throw new Error(`Missing TENDERLY_ADMIN_RPC_URI_FOR_${canonicalChainId}`) + } + + return configuredChain +} + +export function parseDecimalAmount(value: string, decimals: number): bigint { + if (!Number.isInteger(decimals) || decimals < 0) { + throw new Error(`Invalid decimals value: ${decimals}`) + } + + const normalizedValue = value.trim() + if (normalizedValue.length === 0) { + throw new Error('Amount is required') + } + + if (normalizedValue.startsWith('-')) { + throw new Error('Negative amounts are not supported') + } + + if (!/^\d+(\.\d+)?$/.test(normalizedValue)) { + throw new Error(`Invalid decimal amount: ${value}`) + } + + const [wholePartRaw, fractionalPartRaw = ''] = normalizedValue.split('.') + if (fractionalPartRaw.length > decimals) { + throw new Error(`Amount ${value} exceeds ${decimals} decimals`) + } + + return BigInt(wholePartRaw) * 10n ** BigInt(decimals) + BigInt(fractionalPartRaw.padEnd(decimals, '0') || '0') +} + +export function toHexQuantity(value: bigint): `0x${string}` { + if (value < 0n) { + throw new Error('Negative quantities are not supported') + } + + return `0x${value.toString(16)}` as const +} + +export function buildDefaultSnapshotLabel(kind: TTenderlySnapshotRecord['kind']): string { + const prefix = kind === 'baseline' ? 'Baseline' : 'Snapshot' + return `${prefix} ${new Date() + .toISOString() + .replace('T', ' ') + .replace(/\.\d{3}Z$/, ' UTC')}` +} + +export function buildTenderlySnapshotRecord(params: { + canonicalChainId: number + executionChainId: number + snapshotId: string + label?: string + isBaseline?: boolean +}): TTenderlySnapshotRecord { + const kind = params.isBaseline ? 'baseline' : 'snapshot' + + return { + snapshotId: params.snapshotId, + canonicalChainId: params.canonicalChainId, + executionChainId: params.executionChainId, + label: params.label?.trim() || buildDefaultSnapshotLabel(kind), + createdAt: new Date().toISOString(), + kind, + lastKnownStatus: 'valid' + } +} + +export function buildTenderlyRevertResponse(result: unknown, snapshotId: string): TTenderlyRevertResponse { + if (result !== true) { + throw new Error(`Tenderly rejected revert for snapshot ${snapshotId}`) + } + + return { + success: true, + revertedSnapshotId: snapshotId + } +} + +export function resolveTenderlyFundRpcRequest(body: TTenderlyFundRequest): { + method: string + params: unknown[] +} { + const walletAddress = getAddress(body.walletAddress) + const amount = parseDecimalAmount(body.amount, body.decimals) + + if (body.assetKind === 'native') { + const mode = body.mode === 'set' ? 'set' : 'add' + return { + method: mode === 'set' ? 'tenderly_setBalance' : 'tenderly_addBalance', + params: [[walletAddress], toHexQuantity(amount)] + } + } + + if (!body.tokenAddress) { + throw new Error('tokenAddress is required for ERC-20 funding') + } + + return { + method: 'tenderly_setErc20Balance', + params: [getAddress(body.tokenAddress), walletAddress, toHexQuantity(amount)] + } +} diff --git a/api/tenderlyAccess.test.ts b/api/tenderlyAccess.test.ts new file mode 100644 index 000000000..2151e5996 --- /dev/null +++ b/api/tenderlyAccess.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { + buildTenderlyAdminAccessDeniedResponse, + isLoopbackAddress, + isTenderlyAdminRequestAllowed +} from './tenderlyAccess' + +describe('isLoopbackAddress', () => { + it('accepts loopback addresses', () => { + expect(isLoopbackAddress('localhost')).toBe(true) + expect(isLoopbackAddress('127.0.0.1')).toBe(true) + expect(isLoopbackAddress('127.0.0.2')).toBe(true) + expect(isLoopbackAddress('::1')).toBe(true) + expect(isLoopbackAddress('::ffff:127.0.0.1')).toBe(true) + }) + + it('rejects non-loopback addresses', () => { + expect(isLoopbackAddress('10.0.0.8')).toBe(false) + expect(isLoopbackAddress(undefined)).toBe(false) + }) +}) + +describe('isTenderlyAdminRequestAllowed', () => { + it('allows loopback client addresses', () => { + expect(isTenderlyAdminRequestAllowed('127.0.0.1')).toBe(true) + expect(isTenderlyAdminRequestAllowed('::1')).toBe(true) + expect(isTenderlyAdminRequestAllowed('::ffff:127.0.0.1')).toBe(true) + }) + + it('rejects remote client addresses', async () => { + expect(isTenderlyAdminRequestAllowed('10.0.0.8')).toBe(false) + + const response = buildTenderlyAdminAccessDeniedResponse('10.0.0.8') + expect(response?.status).toBe(403) + await expect(response?.json()).resolves.toEqual({ + error: 'Tenderly admin routes are only available from localhost' + }) + }) +}) diff --git a/api/tenderlyAccess.ts b/api/tenderlyAccess.ts new file mode 100644 index 000000000..949848e10 --- /dev/null +++ b/api/tenderlyAccess.ts @@ -0,0 +1,50 @@ +const LOOPBACK_ADDRESSES = new Set(['localhost', '::1']) + +function isLoopbackIpv4(address: string): boolean { + const octets = address.split('.') + if (octets.length !== 4) { + return false + } + + return ( + octets[0] === '127' && + octets.every((octet) => { + if (!/^\d+$/.test(octet)) { + return false + } + + const value = Number(octet) + return value >= 0 && value <= 255 + }) + ) +} + +export function isLoopbackAddress(address: string | null | undefined): boolean { + if (typeof address !== 'string') { + return false + } + + if (LOOPBACK_ADDRESSES.has(address) || isLoopbackIpv4(address)) { + return true + } + + if (address.startsWith('::ffff:')) { + return isLoopbackIpv4(address.slice('::ffff:'.length)) + } + + return false +} + +export function isTenderlyAdminRequestAllowed(requestIpAddress: string | null | undefined): boolean { + return isLoopbackAddress(requestIpAddress) +} + +export function buildTenderlyAdminAccessDeniedResponse( + requestIpAddress: string | null | undefined +): Response | undefined { + if (isTenderlyAdminRequestAllowed(requestIpAddress)) { + return undefined + } + + return Response.json({ error: 'Tenderly admin routes are only available from localhost' }, { status: 403 }) +} diff --git a/bun.lock b/bun.lock index 889b7c9ea..b605725eb 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "yearnfi", @@ -33,7 +32,7 @@ "@tailwindcss/postcss": "4.1.18", "@testing-library/react": "16.3.2", "@types/minimatch": "6.0.0", - "@types/node": "20.19.10", + "@types/node": "25.3.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@vercel/node": "5.6.3", @@ -46,7 +45,6 @@ "tailwindcss": "4.1.18", "typescript": "5.9.3", "vite": "7.3.1", - "vite-plugin-webfont-dl": "3.12.0", "vitest": "1.6.1", }, }, @@ -118,10 +116,6 @@ "@bytecodealliance/preview2-shim": ["@bytecodealliance/preview2-shim@0.17.6", "", {}, "sha512-n3cM88gTen5980UOBAD6xDcNNL3ocTK8keab21bpx1ONdA+ARj7uD1qoFxOWCyKlkpSi195FH+GeAut7Oc6zZw=="], - "@cacheable/memory": ["@cacheable/memory@2.0.7", "", { "dependencies": { "@cacheable/utils": "^2.3.3", "@keyv/bigmap": "^1.3.0", "hookified": "^1.14.0", "keyv": "^5.5.5" } }, "sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A=="], - - "@cacheable/utils": ["@cacheable/utils@2.3.4", "", { "dependencies": { "hashery": "^1.3.0", "keyv": "^5.6.0" } }, "sha512-knwKUJEYgIfwShABS1BX6JyJJTglAFcEU7EXqzTdiGCXur4voqkiJkdgZIQtWNFhynzDWERcTYv/sETMu3uJWA=="], - "@coinbase/wallet-sdk": ["@coinbase/wallet-sdk@4.3.6", "", { "dependencies": { "@noble/hashes": "1.4.0", "clsx": "1.2.1", "eventemitter3": "5.0.1", "idb-keyval": "6.2.1", "ox": "0.6.9", "preact": "10.24.2", "viem": "^2.27.2", "zustand": "5.0.3" } }, "sha512-4q8BNG1ViL4mSAAvPAtpwlOs1gpC+67eQtgIwNvT3xyeyFFd+guwkc8bcX5rTmQhXpqnhzC4f0obACbP9CqMSA=="], "@ecies/ciphers": ["@ecies/ciphers@0.2.5", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="], @@ -292,10 +286,6 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@keyv/bigmap": ["@keyv/bigmap@1.3.1", "", { "dependencies": { "hashery": "^1.4.0", "hookified": "^1.15.0" }, "peerDependencies": { "keyv": "^5.6.0" } }, "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ=="], - - "@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="], - "@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.5.1", "", {}, "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA=="], "@lit/reactive-element": ["@lit/reactive-element@2.1.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } }, "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A=="], @@ -582,7 +572,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@20.19.10", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-iAFpG6DokED3roLSP0K+ybeDdIX6Bc0Vd3mLW5uDqThPWtNos3E+EqOM11mPQHKzfWHqEBuLjIlsBQQ8CsISmQ=="], + "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -712,14 +702,10 @@ "async-sema": ["async-sema@3.1.1", "", {}, "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg=="], - "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], - "axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="], - "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -758,8 +744,6 @@ "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], - "cacheable": ["cacheable@2.3.2", "", { "dependencies": { "@cacheable/memory": "^2.0.7", "@cacheable/utils": "^2.3.3", "hookified": "^1.15.0", "keyv": "^5.5.5", "qified": "^0.6.0" } }, "sha512-w+ZuRNmex9c1TR9RcsxbfTKCjSL0rh1WA5SABbrWprIHeNBdmyQLSYonlDy9gpD+63XT8DgZ/wNh1Smvc9WnJA=="], - "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -794,8 +778,6 @@ "cjs-module-lexer": ["cjs-module-lexer@1.2.3", "", {}, "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ=="], - "clean-css": ["clean-css@5.3.3", "", { "dependencies": { "source-map": "~0.6.0" } }, "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg=="], - "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="], @@ -812,8 +794,6 @@ "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], - "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], - "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], @@ -900,8 +880,6 @@ "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], - "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], - "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "derive-valtio": ["derive-valtio@0.1.0", "", { "peerDependencies": { "valtio": "*" } }, "sha512-OCg2UsLbXK7GmmpzMXhYkdO64vhJ1ROUUGaTFyHjVwEdMEcTTRj7W1TxLbSBxdY8QLBPCcp66MTyaSy0RpO17A=="], @@ -958,8 +936,6 @@ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - "es-toolkit": ["es-toolkit@1.33.0", "", {}, "sha512-X13Q/ZSc+vsO1q600bvNK4bxgXMkHcf//RxCmYDaRY5DAcT+eoXjY5hoAPGMdRnWQjvyLEcyauG3b6hz76LNqg=="], "esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], @@ -1018,16 +994,8 @@ "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], - "flat-cache": ["flat-cache@6.1.20", "", { "dependencies": { "cacheable": "^2.3.2", "flatted": "^3.3.3", "hookified": "^1.15.0" } }, "sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ=="], - - "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], - - "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], - "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], - "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], - "framer-motion": ["framer-motion@12.34.0", "", { "dependencies": { "motion-dom": "^12.34.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg=="], "fs-extra": ["fs-extra@11.1.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ=="], @@ -1080,8 +1048,6 @@ "hash.js": ["hash.js@1.1.7", "", { "dependencies": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA=="], - "hashery": ["hashery@1.5.0", "", { "dependencies": { "hookified": "^1.14.0" } }, "sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q=="], - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], @@ -1092,8 +1058,6 @@ "hono": ["hono@4.12.3", "", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="], - "hookified": ["hookified@1.15.1", "", {}, "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg=="], - "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -1174,8 +1138,6 @@ "keccak": ["keccak@3.0.4", "", { "dependencies": { "node-addon-api": "^2.0.0", "node-gyp-build": "^4.2.0", "readable-stream": "^3.6.0" } }, "sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q=="], - "keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], - "keyvaluestorage-interface": ["keyvaluestorage-interface@1.0.0", "", {}, "sha512-8t6Q3TclQ4uZynJY9IGr2+SsIGwK9JHcO6ootkHCGA0CrQCRy+VkouYNO2xicET6b9al7QKzpebNow+gkpCL8g=="], "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], @@ -1328,9 +1290,9 @@ "modern-ahocorasick": ["modern-ahocorasick@1.1.0", "", {}, "sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ=="], - "motion-dom": ["motion-dom@12.34.3", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ=="], + "motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="], - "motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="], + "motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="], "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], @@ -1450,14 +1412,10 @@ "proxy-compare": ["proxy-compare@2.6.0", "", {}, "sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw=="], - "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], - "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "qified": ["qified@0.6.0", "", { "dependencies": { "hookified": "^1.14.0" } }, "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA=="], - "qr": ["qr@0.5.4", "", {}, "sha512-gjVMHOt7CX+BQd7JLQ9fnS4kJK4Lj4u+Conq52tcCbW7YH3mATTtBbTMA+7cQ1rKOkDo61olFHJReawe+XFxIA=="], "qrcode": ["qrcode@1.5.3", "", { "dependencies": { "dijkstrajs": "^1.0.1", "encode-utf8": "^1.0.3", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg=="], @@ -1572,8 +1530,6 @@ "sonic-boom": ["sonic-boom@2.8.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-kuonw1YOYYNOve5iHdSahXPOK49GqwA+LZhI6Wz/l0rP57iKyXXIHaRagOBHAPmGwJC6od2Z9zgvZ5loSgMlVg=="], - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], @@ -1670,7 +1626,7 @@ "undici": ["undici@5.28.4", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g=="], - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], @@ -1724,8 +1680,6 @@ "vite-node": ["vite-node@1.6.1", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.4", "pathe": "^1.1.1", "picocolors": "^1.0.0", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA=="], - "vite-plugin-webfont-dl": ["vite-plugin-webfont-dl@3.12.0", "", { "dependencies": { "axios": "^1", "clean-css": "^5", "flat-cache": "^6", "picocolors": "^1" }, "peerDependencies": { "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8" } }, "sha512-0jxsr8ycuoK/uV5Y3ytttTRhgvfZo8v3O4JZBlVc4C7QWIws/vCLVR4B3ag+TGVkLNQya6hXfY3UnZge3M8vmA=="], - "vitest": ["vitest@1.6.1", "", { "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", "@vitest/snapshot": "1.6.1", "@vitest/spy": "1.6.1", "@vitest/utils": "1.6.1", "acorn-walk": "^8.3.2", "chai": "^4.3.10", "debug": "^4.3.4", "execa": "^8.0.1", "local-pkg": "^0.5.0", "magic-string": "^0.30.5", "pathe": "^1.1.1", "picocolors": "^1.0.0", "std-env": "^3.5.0", "strip-literal": "^2.0.0", "tinybench": "^2.5.1", "tinypool": "^0.8.3", "vite": "^5.0.0", "vite-node": "1.6.1", "why-is-node-running": "^2.2.2" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "1.6.1", "@vitest/ui": "1.6.1", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag=="], "wagmi": ["wagmi@2.18.2", "", { "dependencies": { "@wagmi/connectors": "6.1.0", "@wagmi/core": "2.22.1", "use-sync-external-store": "1.4.0" }, "peerDependencies": { "@tanstack/react-query": ">=5.0.0", "react": ">=18", "typescript": ">=5.0.4", "viem": "2.x" }, "optionalPeers": ["typescript"] }, "sha512-9jFip+0ZfjMBxT72m02MZD2+VmQQ/UmqZhHl+98N9HEqXLn765fIu45QPV85DAnQqIHD81gvY3vTvfWt16A5yQ=="], diff --git a/package.json b/package.json index f84b37ca6..82cbd4fb2 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev:client": "vite", "dev:server": "bun --watch api/server.ts", "dev:ts": "tsc --watch", + "tenderly": "bun scripts/tenderly.ts", "build": "tsc && vite build", "preview": "concurrently \"bun run preview:server\" \"bun run preview:client\"", "preview:client": "vite preview", @@ -47,7 +48,7 @@ "@tailwindcss/postcss": "4.1.18", "@testing-library/react": "16.3.2", "@types/minimatch": "6.0.0", - "@types/node": "20.19.10", + "@types/node": "25.3.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@vercel/node": "5.6.3", @@ -60,7 +61,6 @@ "tailwindcss": "4.1.18", "typescript": "5.9.3", "vite": "7.3.1", - "vite-plugin-webfont-dl": "3.12.0", "vitest": "1.6.1" } } diff --git a/public/fonts/fonts.css b/public/fonts/fonts.css index 4deba7937..7e379dcf8 100755 --- a/public/fonts/fonts.css +++ b/public/fonts/fonts.css @@ -1,7 +1,10 @@ /* Aeonik Font Family */ @font-face { font-family: "Aeonik"; - src: url("./Aeonik-Light.ttf") format("truetype"); + src: + local("Aeonik Light"), + local("Aeonik-Light"), + url("./Aeonik-Light.ttf") format("truetype"); font-weight: 300; font-style: normal; font-display: swap; @@ -10,6 +13,9 @@ @font-face { font-family: "Aeonik"; src: + local("Aeonik"), + local("Aeonik Regular"), + local("Aeonik-Regular"), url("./Aeonik-Regular.woff2") format("woff2"), url("./Aeonik-Regular.woff") format("woff"), url("./Aeonik-Regular.ttf") format("truetype"); @@ -21,6 +27,8 @@ @font-face { font-family: "Aeonik"; src: + local("Aeonik Bold"), + local("Aeonik-Bold"), url("./Aeonik-Bold.woff2") format("woff2"), url("./Aeonik-Bold.woff") format("woff"), url("./Aeonik-Bold.ttf") format("truetype"); @@ -31,12 +39,24 @@ @font-face { font-family: "Aeonik"; - src: url("./Aeonik-Black.ttf") format("truetype"); + src: + local("Aeonik Black"), + local("Aeonik-Black"), + url("./Aeonik-Black.ttf") format("truetype"); font-weight: 900; font-style: normal; font-display: swap; } +/* Explicit sans fallback used when Aeonik cannot be resolved */ +@font-face { + font-family: "Aeonik Fallback"; + src: local("SF Pro Text"), local("SF Pro Display"), local("Arial"), local("Segoe UI"), local("Roboto"); + font-weight: 300 900; + font-style: normal; + font-display: swap; +} + /* Aeonik Fono Font Family */ @font-face { font-family: "Aeonik Fono"; @@ -58,6 +78,9 @@ @font-face { font-family: "Aeonik Mono"; src: + local("Aeonik Mono"), + local("AeonikMono Regular"), + local("AeonikMono-Regular"), url("./AeonikMono-Regular.woff2") format("woff2"), url("./AeonikMono-Regular.woff") format("woff"), url("./AeonikMono-Regular.ttf") format("truetype"); @@ -69,6 +92,9 @@ @font-face { font-family: "Aeonik Mono"; src: + local("Aeonik Mono Bold"), + local("AeonikMono Bold"), + local("AeonikMono-Bold"), url("./AeonikMono-Bold.woff2") format("woff2"), url("./AeonikMono-Bold.woff") format("woff"), url("./AeonikMono-Bold.ttf") format("truetype"); @@ -79,8 +105,6 @@ /* CSS Variables for Fonts */ :root { - --font-aeonik: "Aeonik", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - --font-aeonik-fono: - "Aeonik Fono", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - --scp-font: "Source Code Pro", "Aeonik Mono", Consolas, "Courier New", monospace; + --font-aeonik: "Aeonik", Helvetica, Arial, system-ui, sans-serif; + --font-aeonik-fono: "Aeonik Fono", Helvetica, Arial, system-ui, sans-serif; } diff --git a/scripts/tenderly-vnet-status.test.ts b/scripts/tenderly-vnet-status.test.ts new file mode 100644 index 000000000..f0b225b36 --- /dev/null +++ b/scripts/tenderly-vnet-status.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest' +import { + buildTenderlyStatusJson, + buildTenderlyStatusMarkdown, + classifyPublicRpcMode, + normalizeTenderlyTransactionListResponse, + normalizeTenderlyVnetListResponse, + resolveStableNamedPublicRpcName, + resolveTenderlyStatusIdentity, + selectMatchingTenderlyVnet +} from './tenderly-vnet-status' + +const TEST_TX_FROM_ADDRESS = process.env.TENDERLY_TEST_TX_FROM_ADDRESS ?? '0x2222222222222222222222222222222222222222' + +describe('tenderly-vnet-status', () => { + it('defaults to the webops profile', () => { + expect( + resolveTenderlyStatusIdentity( + {}, + { + WEBOPS_ACCOUNT_SLUG: 'yearn', + WEBOPS_PROJECT_SLUG: 'frontend' + } + ) + ).toMatchObject({ + profile: 'webops', + accountSlug: 'yearn', + projectSlug: 'frontend' + }) + }) + + it('supports explicit personal profile overrides', () => { + expect( + resolveTenderlyStatusIdentity( + { profile: 'personal' }, + { + PERSONAL_ACCOUNT_SLUG: 'me', + PERSONAL_PROJECT_SLUG: 'sandbox' + } + ) + ).toMatchObject({ + profile: 'personal', + accountSlug: 'me', + projectSlug: 'sandbox' + }) + }) + + it('normalizes array and wrapped vnet list responses', () => { + expect(normalizeTenderlyVnetListResponse([{ slug: 'vnet-a' }])).toHaveLength(1) + expect(normalizeTenderlyVnetListResponse({ vnets: [{ slug: 'vnet-b' }] })).toHaveLength(1) + expect(normalizeTenderlyVnetListResponse({ virtual_networks: [{ slug: 'vnet-c' }] })).toHaveLength(1) + }) + + it('normalizes array and wrapped transaction list responses', () => { + expect(normalizeTenderlyTransactionListResponse([{ tx_hash: '0xabc' }])).toHaveLength(1) + expect(normalizeTenderlyTransactionListResponse({ transactions: [{ tx_hash: '0xdef' }] })).toHaveLength(1) + expect(normalizeTenderlyTransactionListResponse({ data: [{ tx_hash: '0xghi' }] })).toHaveLength(1) + }) + + it('matches the active vnet by admin rpc before any weaker fallback', () => { + expect( + selectMatchingTenderlyVnet({ + vnets: [ + { + slug: 'first', + rpcs: [{ name: 'Admin RPC', url: 'https://admin-a' }] + }, + { + slug: 'second', + rpcs: [{ name: 'Admin RPC', url: 'https://admin-b' }] + } + ], + adminRpcUri: 'https://admin-b', + publicRpcUri: 'https://public-miss', + executionChainId: 694201 + }) + ).toMatchObject({ + reason: 'admin-rpc', + record: { slug: 'second' } + }) + }) + + it('classifies stable named public rpcs correctly without relying on a single profile rpc name', () => { + expect( + classifyPublicRpcMode({ + accountSlug: 'yearn', + projectSlug: 'frontend', + publicRpcUri: 'https://virtual.rpc.tenderly.co/yearn/frontend/public/yearn-fi-webops-vnet-42161' + }) + ).toBe('stable named endpoint') + + expect( + resolveStableNamedPublicRpcName({ + accountSlug: 'yearn', + projectSlug: 'frontend', + publicRpcUri: 'https://virtual.rpc.tenderly.co/yearn/frontend/public/yearn-fi-webops-vnet-42161' + }) + ).toBe('yearn-fi-webops-vnet-42161') + + expect( + classifyPublicRpcMode({ + accountSlug: 'yearn', + projectSlug: 'frontend', + rpcName: 'yearn-fi-webops-vnet', + publicRpcUri: 'https://virtual.mainnet.eu.rpc.tenderly.co/ephemeral-id' + }) + ).toBe('dynamic endpoint') + }) + + it('builds sanitized markdown and json reports without admin rpc leakage', () => { + const report = { + profile: 'webops' as const, + accountSlug: 'yearn', + projectSlug: 'frontend', + restMetadataAvailable: true, + recentTransactionCount: 5, + chainReports: [ + { + canonicalChainId: 1, + canonicalChainName: 'Ethereum', + configuredExecutionChainId: 694201, + liveExecutionChainId: 694201, + currentBlockNumber: 24_735_515, + latestBlockTimestampSeconds: 1_742_000_000, + latestBlockTimestampLabel: '2025-03-25 17:46:40 UTC', + latestBlockAgeLabel: '8s', + publicRpcUri: 'https://virtual.rpc.tenderly.co/yearn/frontend/public/yearn-fi-webops-vnet-1', + publicRpcMode: 'stable named endpoint' as const, + publicRpcName: 'yearn-fi-webops-vnet-1', + hasAdminRpc: true, + explorerEnabled: false, + totalTransactionsAvailable: true, + totalTransactionsCount: 12, + recentTransactionsAvailable: true, + recentTransactions: [ + { + status: 'success', + functionName: 'deposit', + createdAtAgeLabel: '8s', + blockNumber: 24_735_515, + txHash: '0xb00dc057e50c8926896495cb31717bfa7ab47608673789b4b7b478ec114080cb', + from: 'TEST_TX_FROM_ADDRESS', + to: '0xc56413869c6cdf96496f2b1ef801fedbdfa7ddb0' + } + ], + matchedVnet: { + slug: 'vnet-123', + displayName: 'Webops VNet 123', + forkNetworkId: 1, + forkBlockNumber: 24_735_515 + }, + matchReason: 'admin-rpc' as const + } + ] + } + + const markdown = buildTenderlyStatusMarkdown(report) + const json = JSON.stringify(buildTenderlyStatusJson(report)) + + expect(markdown).toContain('# Tenderly VNet Status') + expect(markdown).toContain('Profile: webops') + expect(markdown).toContain('## Ethereum (1)') + expect(markdown).toContain('ID: vnet-123') + expect(markdown).toContain('Chain ID: 694201') + expect(markdown).toContain( + 'Public RPC: https://virtual.rpc.tenderly.co/yearn/frontend/public/yearn-fi-webops-vnet-1' + ) + expect(markdown).toContain('Public RPC Mode: stable named endpoint (yearn-fi-webops-vnet-1)') + expect(markdown).toContain('Total Transactions: 12') + expect(markdown).toContain('Admin RPC: configured') + expect(markdown).toContain('Most Recent Transactions (1 shown):') + expect(markdown).toContain('| Age | Status | Block | Method | From | To | Tx Hash |') + expect(markdown).toContain('deposit') + expect(markdown).not.toContain('https://admin') + expect(json).toContain('"slug":"vnet-123"') + expect(json).toContain('"recentTransactions"') + expect(json).toContain( + '"publicRpcUri":"https://virtual.rpc.tenderly.co/yearn/frontend/public/yearn-fi-webops-vnet-1"' + ) + expect(json).toContain('"totalTransactionsCount":12') + expect(json).toContain('"publicRpcName":"yearn-fi-webops-vnet-1"') + expect(json).not.toContain('adminRpc') + }) +}) diff --git a/scripts/tenderly-vnet-status.ts b/scripts/tenderly-vnet-status.ts new file mode 100644 index 000000000..332480921 --- /dev/null +++ b/scripts/tenderly-vnet-status.ts @@ -0,0 +1,930 @@ +/// + +import { resolve as resolvePath } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parseTenderlyServerChains, type TTenderlyServerChainConfig } from '../api/tenderly.helpers' +import { buildPredictablePublicRpcUrl, readEnvFile, sanitizeConsoleText } from './tenderly-vnet' + +type TParsedCliArgs = { + flags: Record + positionals: string[] +} + +type TTenderlyProfile = 'webops' | 'personal' + +type TTenderlyStatusIdentity = { + profile: TTenderlyProfile + accountSlug: string + projectSlug: string + apiKey?: string + rpcName?: string +} + +type TTenderlyVnetRpc = { + name?: string + url?: string +} + +type TTenderlyVnetRecord = { + id?: string + slug?: string + display_name?: string + status?: string + fork_config?: { + network_id?: number | string + block_number?: number | string + } + virtual_network_config?: { + chain_config?: { + chain_id?: number | string + } + } + explorer_page_config?: { + enabled?: boolean + } + rpcs?: TTenderlyVnetRpc[] +} + +type TTenderlyStatusMatchReason = 'admin-rpc' | 'public-rpc' | 'execution-chain-id' | 'single-vnet-fallback' + +type TTenderlyMatchedVnet = { + record: TTenderlyVnetRecord + reason: TTenderlyStatusMatchReason +} + +type TTenderlyChainLiveState = { + liveExecutionChainId: number + currentBlockNumber: number + latestBlockTimestampSeconds: number +} + +type TTenderlyVnetTransactionRecord = { + id?: string + vnet_id?: string + origin?: string + category?: string + kind?: string + status?: string + rpc_method?: string + created_at?: string + block_number?: number | string + block_hash?: string + tx_hash?: string + tx_index?: number | string + from?: string + to?: string + function_name?: string + dashboard_url?: string +} + +type TTenderlyRecentTransaction = { + id?: string + status: string + category?: string + kind?: string + rpcMethod?: string + functionName?: string + createdAt?: string + createdAtAgeLabel?: string + blockNumber?: number + txHash?: string + from?: string + to?: string +} + +type TTenderlyStatusChainReport = { + canonicalChainId: number + canonicalChainName: string + configuredExecutionChainId: number + liveExecutionChainId: number + currentBlockNumber: number + latestBlockTimestampSeconds: number + latestBlockTimestampLabel: string + latestBlockAgeLabel: string + publicRpcUri: string + publicRpcMode: 'stable named endpoint' | 'dynamic endpoint' + publicRpcName?: string + hasAdminRpc: boolean + explorerEnabled: boolean + explorerUri?: string + totalTransactionsAvailable: boolean + totalTransactionsCount?: number + totalTransactionsNote?: string + recentTransactionsAvailable: boolean + recentTransactionsNote?: string + recentTransactions: TTenderlyRecentTransaction[] + matchedVnet?: { + id?: string + slug?: string + displayName?: string + status?: string + forkNetworkId?: number + forkBlockNumber?: number + } + matchReason?: TTenderlyStatusMatchReason +} + +type TTenderlyStatusReport = { + profile: TTenderlyProfile + accountSlug: string + projectSlug: string + restMetadataAvailable: boolean + restMetadataNote?: string + recentTransactionCount: number + chainReports: TTenderlyStatusChainReport[] +} + +const DEFAULT_ACCOUNT_SLUG = 'me' +const DEFAULT_RECENT_TRANSACTION_COUNT = 5 +const TENDERLY_TRANSACTION_PAGE_SIZE = 100 +const TENDERLY_TRANSACTION_MAX_PAGES = 100 +const TENDERLY_API_URL = 'https://api.tenderly.co/api/v1/account' +const PROFILE_DEFAULTS = { + personal: { + apiKeyEnv: 'PERSONAL_TENDERLY_API_KEY', + accountEnvKeys: ['PERSONAL_ACCOUNT_SLUG', 'ACCOUNT_SLUG'], + projectEnvKeys: ['PERSONAL_PROJECT_SLUG', 'PROJECT_SLUG'], + rpcNameEnv: 'PERSONAL_TENDERLY_RPC_NAME' + }, + webops: { + apiKeyEnv: 'WEBOPS_TENDERLY_API_KEY', + accountEnvKeys: ['WEBOPS_ACCOUNT_SLUG', 'TENDERLY_ACCOUNT_SLUG'], + projectEnvKeys: ['WEBOPS_PROJECT_SLUG', 'TENDERLY_PROJECT_SLUG'], + rpcNameEnv: 'WEBOPS_TENDERLY_RPC_NAME' + } +} as const + +const HELP_TEXT = `Tenderly Virtual TestNet status + +Usage: + bun scripts/tenderly-vnet-status.ts [options] + +Options: + --profile Credential profile: webops or personal (default: webops) + --chain Canonical chain id to inspect (default: all configured Tenderly chains) + --account Tenderly account slug override + --project Tenderly project slug override + --api-key Tenderly API key override for metadata lookup + --api-key-env Env var name to read the API key from + --account-env Env var name to read the account slug from + --project-env Env var name to read the project slug from + --rpc-name Stable public RPC name override + --recent-tx-count Number of recent transactions to include per chain (default: 5) + --json Print a sanitized JSON report instead of Markdown + --help Show this help text + +Notes: + - Reads the active Tenderly mapping from repo .env and process.env. + - Uses Admin RPC for live chain state. + - Uses the Tenderly REST API for VNet metadata when credentials are available. + - Never prints API keys or admin RPC URLs. +` + +function parseCliArgs(argv: readonly string[]): TParsedCliArgs { + const recurse = (index: number, accumulator: TParsedCliArgs): TParsedCliArgs => { + if (index >= argv.length) { + return accumulator + } + + const token = argv[index] + if (!token.startsWith('--')) { + return recurse(index + 1, { + flags: accumulator.flags, + positionals: [...accumulator.positionals, token] + }) + } + + const key = token.slice(2) + const nextToken = argv[index + 1] + const value = nextToken && !nextToken.startsWith('--') ? nextToken : 'true' + const nextIndex = value === 'true' ? index + 1 : index + 2 + + return recurse(nextIndex, { + positionals: accumulator.positionals, + flags: { + ...accumulator.flags, + [key]: value + } + }) + } + + return recurse(0, { flags: {}, positionals: [] }) +} + +function getArg(flags: Record, ...keys: string[]): string | undefined { + return keys.map((key) => flags[key]?.trim()).find(Boolean) +} + +function getEnvValue(env: Record, key?: string): string | undefined { + if (!key) { + return undefined + } + return env[key]?.trim() +} + +function getFirstEnvValue(env: Record, keys: string[]): string | undefined { + return keys.map((key) => getEnvValue(env, key)).find(Boolean) +} + +function parseProfile(value: string | undefined): TTenderlyProfile { + const normalizedValue = value?.trim().toLowerCase() + + if (!normalizedValue) { + return 'webops' + } + + if (normalizedValue === 'webops' || normalizedValue === 'personal') { + return normalizedValue + } + + throw new Error(`Unsupported profile: ${value}. Expected "webops" or "personal".`) +} + +function parseOptionalInteger(value: string | undefined, label: string): number | undefined { + if (!value) { + return undefined + } + + const parsedValue = Number(value) + if (!Number.isInteger(parsedValue) || parsedValue <= 0) { + throw new Error(`Invalid ${label}: ${value}`) + } + + return parsedValue +} + +function parseRecentTransactionCount(flags: Record): number { + const parsedValue = + parseOptionalInteger(getArg(flags, 'recent-tx-count', 'recent-transactions'), 'recent transaction count') || + DEFAULT_RECENT_TRANSACTION_COUNT + + return Math.min(Math.max(parsedValue, 1), 20) +} + +export function resolveTenderlyStatusIdentity( + flags: Record, + env: Record +): TTenderlyStatusIdentity { + const profile = parseProfile(getArg(flags, 'profile')) + const defaults = PROFILE_DEFAULTS[profile] + const accountEnv = getArg(flags, 'account-env') + const projectEnv = getArg(flags, 'project-env') + const apiKeyEnv = getArg(flags, 'api-key-env') + + return { + profile, + accountSlug: + getArg(flags, 'account') || + getEnvValue(env, accountEnv) || + getFirstEnvValue(env, [...defaults.accountEnvKeys]) || + DEFAULT_ACCOUNT_SLUG, + projectSlug: + getArg(flags, 'project') || + getEnvValue(env, projectEnv) || + getFirstEnvValue(env, [...defaults.projectEnvKeys]) || + '', + apiKey: + getArg(flags, 'api-key') || + getEnvValue(env, apiKeyEnv) || + getEnvValue(env, defaults.apiKeyEnv) || + env.TENDERLY_ACCESS_KEY?.trim() || + env.TENDERLY_API_KEY?.trim(), + rpcName: getArg(flags, 'rpc-name') || getEnvValue(env, defaults.rpcNameEnv) || env.TENDERLY_RPC_NAME?.trim() + } +} + +export function normalizeTenderlyVnetListResponse(payload: unknown): TTenderlyVnetRecord[] { + if (Array.isArray(payload)) { + return payload as TTenderlyVnetRecord[] + } + + if (!payload || typeof payload !== 'object') { + return [] + } + + const record = payload as Record + return ( + [record.virtual_networks, record.vnets] + .find(Array.isArray) + ?.filter(Boolean) + .map((item) => item as TTenderlyVnetRecord) || [] + ) +} + +export function normalizeTenderlyTransactionListResponse(payload: unknown): TTenderlyVnetTransactionRecord[] { + if (Array.isArray(payload)) { + return payload as TTenderlyVnetTransactionRecord[] + } + + if (!payload || typeof payload !== 'object') { + return [] + } + + const record = payload as Record + return ( + [record.transactions, record.results, record.data] + .find(Array.isArray) + ?.filter(Boolean) + .map((item) => item as TTenderlyVnetTransactionRecord) || [] + ) +} + +function resolveVnetRpcUrl(vnet: TTenderlyVnetRecord, rpcName: string): string | undefined { + return vnet.rpcs?.find((rpc) => rpc.name === rpcName)?.url +} + +function parseNumericString(value: number | string | undefined): number | undefined { + if (value === undefined) { + return undefined + } + + if (typeof value === 'number') { + return Number.isFinite(value) ? value : undefined + } + + const normalizedValue = value.trim() + if (!normalizedValue) { + return undefined + } + + return normalizedValue.startsWith('0x') ? Number(BigInt(normalizedValue)) : Number(normalizedValue) +} + +export function selectMatchingTenderlyVnet(params: { + vnets: TTenderlyVnetRecord[] + adminRpcUri?: string + publicRpcUri?: string + executionChainId: number +}): TTenderlyMatchedVnet | undefined { + const adminRpcMatch = params.adminRpcUri + ? params.vnets.find((vnet) => resolveVnetRpcUrl(vnet, 'Admin RPC') === params.adminRpcUri) + : undefined + + if (adminRpcMatch) { + return { record: adminRpcMatch, reason: 'admin-rpc' } + } + + const publicRpcMatch = params.publicRpcUri + ? params.vnets.find((vnet) => resolveVnetRpcUrl(vnet, 'Public RPC') === params.publicRpcUri) + : undefined + + if (publicRpcMatch) { + return { record: publicRpcMatch, reason: 'public-rpc' } + } + + const executionChainMatch = params.vnets.find( + (vnet) => parseNumericString(vnet.virtual_network_config?.chain_config?.chain_id) === params.executionChainId + ) + + if (executionChainMatch) { + return { record: executionChainMatch, reason: 'execution-chain-id' } + } + + return params.vnets.length === 1 ? { record: params.vnets[0], reason: 'single-vnet-fallback' } : undefined +} + +export function classifyPublicRpcMode(params: { + accountSlug: string + projectSlug: string + rpcName?: string + publicRpcUri: string +}): 'stable named endpoint' | 'dynamic endpoint' { + if (resolveStableNamedPublicRpcName(params)) { + return 'stable named endpoint' + } + + if (!params.rpcName) { + return 'dynamic endpoint' + } + + const predictablePublicRpc = buildPredictablePublicRpcUrl(params.accountSlug, params.projectSlug, params.rpcName) + return predictablePublicRpc === params.publicRpcUri ? 'stable named endpoint' : 'dynamic endpoint' +} + +export function resolveStableNamedPublicRpcName(params: { + accountSlug: string + projectSlug: string + rpcName?: string + publicRpcUri: string +}): string | undefined { + try { + const parsedUrl = new URL(params.publicRpcUri) + if (parsedUrl.hostname !== 'virtual.rpc.tenderly.co') { + return undefined + } + + const pathSegments = parsedUrl.pathname.split('/').filter(Boolean) + if (pathSegments.length !== 4) { + return undefined + } + + const [accountSlug, projectSlug, visibility, rpcName] = pathSegments + if ( + accountSlug !== params.accountSlug || + projectSlug !== params.projectSlug || + visibility !== 'public' || + !rpcName + ) { + return undefined + } + + return rpcName + } catch { + return undefined + } +} + +function formatTimestampUtc(timestampSeconds: number): string { + return new Date(timestampSeconds * 1000) + .toISOString() + .replace('T', ' ') + .replace(/\.\d{3}Z$/, ' UTC') +} + +function formatRelativeAgeLabel(timestampSeconds: number): string { + const elapsedSeconds = Math.max(0, Math.round(Date.now() / 1000 - timestampSeconds)) + + if (elapsedSeconds < 60) { + return `${elapsedSeconds}s` + } + + if (elapsedSeconds < 3_600) { + return `${Math.round(elapsedSeconds / 60)}m` + } + + if (elapsedSeconds < 86_400) { + return `${Math.round(elapsedSeconds / 3_600)}h` + } + + return `${Math.round(elapsedSeconds / 86_400)}d` +} + +function formatIsoAgeLabel(isoTimestamp: string | undefined): string | undefined { + if (!isoTimestamp) { + return undefined + } + + const timestampMs = Date.parse(isoTimestamp) + if (!Number.isFinite(timestampMs)) { + return undefined + } + + return formatRelativeAgeLabel(Math.round(timestampMs / 1000)) +} + +function shortenHex(value: string | undefined, prefixLength: number, suffixLength: number): string { + if (!value) { + return 'n/a' + } + + const normalizedValue = value.trim() + if (normalizedValue.length <= prefixLength + suffixLength + 1) { + return normalizedValue + } + + return `${normalizedValue.slice(0, prefixLength)}…${normalizedValue.slice(-suffixLength)}` +} + +function formatTransactionLabel(transaction: TTenderlyRecentTransaction): string { + return transaction.functionName || transaction.rpcMethod || transaction.kind || transaction.category || 'unknown' +} + +function formatRecentTransactionRow(transaction: TTenderlyRecentTransaction): string { + return `| ${transaction.createdAtAgeLabel || 'n/a'} | ${transaction.status} | ${ + transaction.blockNumber?.toLocaleString('en-US') || 'n/a' + } | ${formatTransactionLabel(transaction)} | ${shortenHex(transaction.from, 8, 4)} | ${shortenHex( + transaction.to, + 8, + 4 + )} | ${shortenHex(transaction.txHash, 10, 6)} |` +} + +async function fetchJsonRpcResult(adminRpcUri: string, method: string, params: unknown[]): Promise { + const response = await fetch(adminRpcUri, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + id: 1, + jsonrpc: '2.0', + method, + params + }) + }) + + if (!response.ok) { + throw new Error(`Tenderly Admin RPC request failed (${response.status})`) + } + + const payload = (await response.json()) as { result?: T; error?: { code: number; message: string } } | undefined + + if (payload?.error) { + throw new Error(sanitizeConsoleText(`${payload.error.message} (code ${payload.error.code})`)) + } + + return payload?.result as T +} + +async function fetchTenderlyVnets(identity: TTenderlyStatusIdentity): Promise { + if (!identity.apiKey || !identity.projectSlug) { + return [] + } + + const response = await fetch( + `${TENDERLY_API_URL}/${encodeURIComponent(identity.accountSlug)}/project/${encodeURIComponent(identity.projectSlug)}/vnets`, + { + headers: { + Accept: 'application/json', + 'X-Access-Key': identity.apiKey + } + } + ) + + if (!response.ok) { + throw new Error(`Tenderly REST API request failed (${response.status})`) + } + + return normalizeTenderlyVnetListResponse(await response.json()) +} + +async function fetchTenderlyRecentTransactions(params: { + identity: TTenderlyStatusIdentity + vnetId?: string + recentCount: number +}): Promise<{ + recentTransactions: TTenderlyRecentTransaction[] + totalTransactionsCount: number + totalTransactionsNote?: string +}> { + if (!params.identity.apiKey || !params.identity.projectSlug || !params.vnetId) { + return { + recentTransactions: [], + totalTransactionsCount: 0 + } + } + + const recentTransactions: TTenderlyRecentTransaction[] = [] + let totalTransactionsCount = 0 + + for (let page = 1; page <= TENDERLY_TRANSACTION_MAX_PAGES; page += 1) { + const response = await fetch( + `${TENDERLY_API_URL}/${encodeURIComponent(params.identity.accountSlug)}/project/${encodeURIComponent( + params.identity.projectSlug + )}/vnets/${encodeURIComponent(params.vnetId)}/transactions?page=${page}&per_page=${TENDERLY_TRANSACTION_PAGE_SIZE}`, + { + headers: { + Accept: 'application/json', + 'X-Access-Key': params.identity.apiKey + } + } + ) + + if (!response.ok) { + throw new Error(`Tenderly recent transactions request failed (${response.status})`) + } + + const pageTransactions = normalizeTenderlyTransactionListResponse(await response.json()).map((transaction) => ({ + id: transaction.id, + status: transaction.status || 'unknown', + category: transaction.category, + kind: transaction.kind, + rpcMethod: transaction.rpc_method, + functionName: transaction.function_name, + createdAt: transaction.created_at, + createdAtAgeLabel: formatIsoAgeLabel(transaction.created_at), + blockNumber: parseNumericString(transaction.block_number), + txHash: transaction.tx_hash, + from: transaction.from, + to: transaction.to + })) + + totalTransactionsCount += pageTransactions.length + + if (page === 1) { + recentTransactions.push(...pageTransactions.slice(0, params.recentCount)) + } + + if (pageTransactions.length < TENDERLY_TRANSACTION_PAGE_SIZE) { + return { + recentTransactions, + totalTransactionsCount + } + } + } + + const totalTransactionsNote = `count truncated after ${( + TENDERLY_TRANSACTION_MAX_PAGES * TENDERLY_TRANSACTION_PAGE_SIZE + ).toLocaleString('en-US')} transactions` + + return { + recentTransactions, + totalTransactionsCount, + totalTransactionsNote + } +} + +async function fetchTenderlyChainLiveState(chain: TTenderlyServerChainConfig): Promise { + const [chainIdHex, blockNumberHex, latestBlock] = await Promise.all([ + fetchJsonRpcResult(chain.adminRpcUri || '', 'eth_chainId', []), + fetchJsonRpcResult(chain.adminRpcUri || '', 'eth_blockNumber', []), + fetchJsonRpcResult<{ timestamp?: string }>(chain.adminRpcUri || '', 'eth_getBlockByNumber', ['latest', false]) + ]) + + const liveExecutionChainId = Number(BigInt(chainIdHex)) + const currentBlockNumber = Number(BigInt(blockNumberHex)) + const latestBlockTimestampSeconds = Number(BigInt(latestBlock.timestamp || '0x0')) + + return { + liveExecutionChainId, + currentBlockNumber, + latestBlockTimestampSeconds + } +} + +function buildChainReport(params: { + identity: TTenderlyStatusIdentity + chain: TTenderlyServerChainConfig + liveState: TTenderlyChainLiveState + matchedVnet?: TTenderlyMatchedVnet + recentTransactionsResult: + | { + recentTransactionsAvailable: true + totalTransactionsCount: number + totalTransactionsNote?: string + recentTransactions: TTenderlyRecentTransaction[] + recentTransactionsNote?: string + } + | { + recentTransactionsAvailable: false + totalTransactionsCount?: number + totalTransactionsNote?: string + recentTransactions: TTenderlyRecentTransaction[] + recentTransactionsNote?: string + } + env: Record +}): TTenderlyStatusChainReport { + const explorerUri = params.env[`VITE_TENDERLY_EXPLORER_URI_FOR_${params.chain.canonicalChainId}`]?.trim() || undefined + const forkNetworkId = parseNumericString(params.matchedVnet?.record.fork_config?.network_id) + const forkBlockNumber = parseNumericString(params.matchedVnet?.record.fork_config?.block_number) + + return { + canonicalChainId: params.chain.canonicalChainId, + canonicalChainName: params.chain.canonicalChainName, + configuredExecutionChainId: params.chain.executionChainId, + liveExecutionChainId: params.liveState.liveExecutionChainId, + currentBlockNumber: params.liveState.currentBlockNumber, + latestBlockTimestampSeconds: params.liveState.latestBlockTimestampSeconds, + latestBlockTimestampLabel: formatTimestampUtc(params.liveState.latestBlockTimestampSeconds), + latestBlockAgeLabel: formatRelativeAgeLabel(params.liveState.latestBlockTimestampSeconds), + publicRpcUri: params.chain.rpcUri, + publicRpcName: resolveStableNamedPublicRpcName({ + accountSlug: params.identity.accountSlug, + projectSlug: params.identity.projectSlug, + rpcName: params.identity.rpcName, + publicRpcUri: params.chain.rpcUri + }), + publicRpcMode: classifyPublicRpcMode({ + accountSlug: params.identity.accountSlug, + projectSlug: params.identity.projectSlug, + rpcName: params.identity.rpcName, + publicRpcUri: params.chain.rpcUri + }), + hasAdminRpc: Boolean(params.chain.adminRpcUri), + explorerEnabled: Boolean(params.matchedVnet?.record.explorer_page_config?.enabled || explorerUri), + explorerUri, + totalTransactionsAvailable: params.recentTransactionsResult.recentTransactionsAvailable, + totalTransactionsCount: params.recentTransactionsResult.totalTransactionsCount, + totalTransactionsNote: params.recentTransactionsResult.totalTransactionsNote, + recentTransactionsAvailable: params.recentTransactionsResult.recentTransactionsAvailable, + recentTransactionsNote: params.recentTransactionsResult.recentTransactionsNote, + recentTransactions: params.recentTransactionsResult.recentTransactions, + matchedVnet: params.matchedVnet + ? { + id: params.matchedVnet.record.id, + slug: params.matchedVnet.record.slug, + displayName: params.matchedVnet.record.display_name, + status: params.matchedVnet.record.status, + forkNetworkId, + forkBlockNumber + } + : undefined, + matchReason: params.matchedVnet?.reason + } +} + +export function buildTenderlyStatusMarkdown(report: TTenderlyStatusReport): string { + const headerLines = [ + '# Tenderly VNet Status', + '', + `Profile: ${report.profile}`, + `Account / Project: ${report.accountSlug} / ${report.projectSlug || '[unset]'}`, + `Metadata: ${report.restMetadataAvailable ? 'available' : report.restMetadataNote || 'unavailable'}` + ] + + const chainSections = report.chainReports.flatMap((chainReport) => { + const transactionSection = + chainReport.recentTransactions.length > 0 + ? [ + '', + `Most Recent Transactions (${Math.min(chainReport.recentTransactions.length, report.recentTransactionCount)} shown):`, + '', + '| Age | Status | Block | Method | From | To | Tx Hash |', + '| --- | --- | ---: | --- | --- | --- | --- |', + ...chainReport.recentTransactions.map(formatRecentTransactionRow) + ] + : [ + '', + `Most Recent Transactions: ${ + chainReport.recentTransactionsAvailable + ? chainReport.recentTransactionsNote || 'none' + : chainReport.recentTransactionsNote || 'unavailable' + }` + ] + + return [ + '', + `## ${chainReport.canonicalChainName} (${chainReport.canonicalChainId})`, + `ID: ${chainReport.matchedVnet?.slug || 'metadata unavailable'}`, + `UUID: ${chainReport.matchedVnet?.id || 'metadata unavailable'}`, + `Display Name: ${chainReport.matchedVnet?.displayName || 'metadata unavailable'}`, + `Chain ID: ${chainReport.liveExecutionChainId}`, + `Configured Chain ID: ${chainReport.configuredExecutionChainId}`, + `Public RPC: ${chainReport.publicRpcUri}`, + `Public RPC Mode: ${ + chainReport.publicRpcName + ? `${chainReport.publicRpcMode} (${chainReport.publicRpcName})` + : chainReport.publicRpcMode + }`, + `Current Block: ${chainReport.currentBlockNumber.toLocaleString('en-US')}`, + `Total Transactions: ${ + chainReport.totalTransactionsAvailable + ? `${chainReport.totalTransactionsCount?.toLocaleString('en-US') || 0}${ + chainReport.totalTransactionsNote ? ` (${chainReport.totalTransactionsNote})` : '' + }` + : chainReport.totalTransactionsNote || 'unavailable' + }`, + `Latest Block Time: ${chainReport.latestBlockTimestampLabel}`, + `Block Age: ~${chainReport.latestBlockAgeLabel}`, + `Fork: ${ + chainReport.matchedVnet?.forkNetworkId && chainReport.matchedVnet?.forkBlockNumber + ? `${chainReport.matchedVnet.forkNetworkId} @ ${chainReport.matchedVnet.forkBlockNumber.toLocaleString('en-US')}` + : 'metadata unavailable' + }`, + `Admin RPC: ${chainReport.hasAdminRpc ? 'configured' : 'missing'}`, + `Explorer: ${chainReport.explorerEnabled ? chainReport.explorerUri || 'enabled' : 'disabled'}`, + `Repo Mapping: ${chainReport.matchReason ? `matched via ${chainReport.matchReason}` : 'configured'}`, + ...transactionSection + ] + }) + + return [...headerLines, ...chainSections].join('\n') +} + +export function buildTenderlyStatusJson(report: TTenderlyStatusReport): Record { + return { + profile: report.profile, + accountSlug: report.accountSlug, + projectSlug: report.projectSlug, + restMetadataAvailable: report.restMetadataAvailable, + restMetadataNote: report.restMetadataNote || null, + recentTransactionCount: report.recentTransactionCount, + chains: report.chainReports.map((chainReport) => ({ + canonicalChainId: chainReport.canonicalChainId, + canonicalChainName: chainReport.canonicalChainName, + configuredExecutionChainId: chainReport.configuredExecutionChainId, + liveExecutionChainId: chainReport.liveExecutionChainId, + currentBlockNumber: chainReport.currentBlockNumber, + latestBlockTimestampSeconds: chainReport.latestBlockTimestampSeconds, + latestBlockTimestampLabel: chainReport.latestBlockTimestampLabel, + latestBlockAgeLabel: chainReport.latestBlockAgeLabel, + publicRpcUri: chainReport.publicRpcUri, + publicRpcMode: chainReport.publicRpcMode, + publicRpcName: chainReport.publicRpcName || null, + hasAdminRpc: chainReport.hasAdminRpc, + explorerEnabled: chainReport.explorerEnabled, + explorerUri: chainReport.explorerUri || null, + totalTransactionsAvailable: chainReport.totalTransactionsAvailable, + totalTransactionsCount: chainReport.totalTransactionsCount ?? null, + totalTransactionsNote: chainReport.totalTransactionsNote || null, + recentTransactionsAvailable: chainReport.recentTransactionsAvailable, + recentTransactionsNote: chainReport.recentTransactionsNote || null, + recentTransactions: chainReport.recentTransactions, + matchedVnet: chainReport.matchedVnet || null, + matchReason: chainReport.matchReason || null + })) + } +} + +async function buildTenderlyStatusReport( + flags: Record, + env: Record +): Promise { + const identity = resolveTenderlyStatusIdentity(flags, env) + const recentTransactionCount = parseRecentTransactionCount(flags) + if (!identity.projectSlug) { + throw new Error(`Missing required value: ${identity.profile} project slug`) + } + + const requestedChainId = parseOptionalInteger(getArg(flags, 'chain'), 'chain') + const configuredChains = parseTenderlyServerChains(env).filter( + (chain) => requestedChainId === undefined || chain.canonicalChainId === requestedChainId + ) + + if (configuredChains.length === 0) { + throw new Error( + requestedChainId === undefined + ? 'No Tenderly chains are configured in the current environment' + : `Tenderly chain ${requestedChainId} is not configured` + ) + } + + const vnetResult = await fetchTenderlyVnets(identity) + .then((vnets) => ({ + vnets, + restMetadataAvailable: vnets.length > 0, + restMetadataNote: vnets.length > 0 ? undefined : 'available but empty' + })) + .catch((error) => ({ + vnets: [] as TTenderlyVnetRecord[], + restMetadataAvailable: false, + restMetadataNote: sanitizeConsoleText(error instanceof Error ? error.message : String(error)) + })) + + const chainReports = await Promise.all( + configuredChains.map(async (chain) => { + const matchedVnet = selectMatchingTenderlyVnet({ + vnets: vnetResult.vnets, + adminRpcUri: chain.adminRpcUri, + publicRpcUri: chain.rpcUri, + executionChainId: chain.executionChainId + }) + const [liveState, recentTransactionsResult] = await Promise.all([ + fetchTenderlyChainLiveState(chain), + fetchTenderlyRecentTransactions({ + identity, + vnetId: matchedVnet?.record.id, + recentCount: recentTransactionCount + }) + .then(({ recentTransactions, totalTransactionsCount, totalTransactionsNote }) => ({ + recentTransactionsAvailable: true as const, + totalTransactionsCount, + totalTransactionsNote, + recentTransactions, + recentTransactionsNote: recentTransactions.length > 0 ? undefined : 'none' + })) + .catch((error) => ({ + recentTransactionsAvailable: false as const, + totalTransactionsCount: undefined, + totalTransactionsNote: sanitizeConsoleText(error instanceof Error ? error.message : String(error)), + recentTransactions: [] as TTenderlyRecentTransaction[], + recentTransactionsNote: sanitizeConsoleText(error instanceof Error ? error.message : String(error)) + })) + ]) + + return buildChainReport({ + identity, + chain, + liveState, + matchedVnet, + recentTransactionsResult, + env + }) + }) + ) + + return { + profile: identity.profile, + accountSlug: identity.accountSlug, + projectSlug: identity.projectSlug, + restMetadataAvailable: vnetResult.restMetadataAvailable, + restMetadataNote: vnetResult.restMetadataNote, + recentTransactionCount, + chainReports + } +} + +async function main(): Promise { + const parsedArgs = parseCliArgs(process.argv.slice(2)) + const { flags } = parsedArgs + + if ('help' in flags || 'h' in flags) { + console.log(HELP_TEXT) + return + } + + const scriptDir = resolvePath(fileURLToPath(import.meta.url), '..') + const envFromFile = readEnvFile(resolvePath(scriptDir, '../.env')) + const env = { ...envFromFile, ...process.env } + const report = await buildTenderlyStatusReport(flags, env) + + if ('json' in flags) { + console.log(JSON.stringify(buildTenderlyStatusJson(report), null, 2)) + return + } + + console.log(buildTenderlyStatusMarkdown(report)) +} + +main().catch((error) => { + console.error(error instanceof Error ? sanitizeConsoleText(error.message) : sanitizeConsoleText(String(error))) + process.exitCode = 1 +}) diff --git a/scripts/tenderly-vnet.test.ts b/scripts/tenderly-vnet.test.ts new file mode 100644 index 000000000..faad488f3 --- /dev/null +++ b/scripts/tenderly-vnet.test.ts @@ -0,0 +1,165 @@ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + buildSanitizedVnetJson, + buildTenderlyApiErrorMessage, + buildTenderlyEnvFragment, + buildVnetConsoleSummary, + resolveExplorerUriFromResponse, + sanitizeConsoleText, + validateWritableOutputPath, + writeOutputFile +} from './tenderly-vnet' + +const connectionDetails = { + adminRpc: 'https://admin.rpc/secret-path', + publicRpc: 'https://dynamic.public.rpc/ephemeral-path', + predictablePublicRpc: 'https://predictable.public.rpc/stable-path', + explorerUri: 'https://explorer.tenderly.public/virtual-mainnet' +} + +const response = { + slug: 'vnet-123', + display_name: 'Personal VNet 123', + id: 'vnet-id-123', + rpcs: [ + { name: 'Admin RPC', url: connectionDetails.adminRpc }, + { name: 'Public RPC', url: connectionDetails.publicRpc } + ] +} + +describe('tenderly-vnet console safety', () => { + it('redacts URLs in console text', () => { + expect(sanitizeConsoleText('failed at https://admin.rpc/secret-path')).toBe('failed at [redacted-url]') + }) + + it('builds an env fragment with the preferred public RPC without printing it elsewhere', () => { + const envFragment = buildTenderlyEnvFragment({ + canonicalChainId: 1, + executionChainId: 694201, + details: connectionDetails + }) + + expect(envFragment).toContain('VITE_TENDERLY_MODE=true') + expect(envFragment).toContain('VITE_TENDERLY_CHAIN_ID_FOR_1=694201') + expect(envFragment).toContain('VITE_TENDERLY_RPC_URI_FOR_1=https://predictable.public.rpc/stable-path') + expect(envFragment).toContain('TENDERLY_ADMIN_RPC_URI_FOR_1=https://admin.rpc/secret-path') + expect(envFragment).toContain('VITE_TENDERLY_EXPLORER_URI_FOR_1=https://explorer.tenderly.public/virtual-mainnet') + }) + + it('extracts explorer urls from explorer-specific response fields without mistaking public rpc urls for explorers', () => { + expect( + resolveExplorerUriFromResponse({ + public_rpc_url: 'https://rpc.tenderly.example/ignored', + explorer_page: { + public_url: 'https://explorer.tenderly.public/virtual-mainnet' + } + }) + ).toBe('https://explorer.tenderly.public/virtual-mainnet') + }) + + it('omits sensitive RPC values from the default console summary', () => { + const summary = buildVnetConsoleSummary({ + profile: 'personal', + requestedSlug: 'vnet-123', + displayName: 'Personal VNet 123', + chainId: 694201, + networkId: 1, + response, + details: connectionDetails + }).join('\n') + + expect(summary).toContain('Created Tenderly Virtual TestNet') + expect(summary).toContain('Sensitive RPC values were returned but not printed.') + expect(summary).not.toContain(connectionDetails.adminRpc) + expect(summary).not.toContain(connectionDetails.publicRpc) + expect(summary).not.toContain(connectionDetails.predictablePublicRpc) + }) + + it('includes only file paths when sensitive artifacts are written to disk', () => { + const summary = buildVnetConsoleSummary({ + profile: 'webops', + requestedSlug: 'vnet-123', + displayName: 'Webops VNet 123', + chainId: 694201, + networkId: 1, + response, + details: connectionDetails, + envFilePath: '/tmp/tenderly-vnet.env', + responseFilePath: '/tmp/tenderly-vnet.json' + }).join('\n') + + expect(summary).toContain('/tmp/tenderly-vnet.env') + expect(summary).toContain('/tmp/tenderly-vnet.json') + expect(summary).not.toContain('Sensitive RPC values were returned but not printed.') + expect(summary).not.toContain(connectionDetails.adminRpc) + }) + + it('prints a sanitized JSON summary without RPC URLs', () => { + const sanitized = buildSanitizedVnetJson({ + profile: 'personal', + requestedSlug: 'vnet-123', + displayName: 'Personal VNet 123', + chainId: 694201, + networkId: 1, + response, + details: connectionDetails + }) + + const serialized = JSON.stringify(sanitized) + + expect(serialized).toContain('"has_admin_rpc":true') + expect(serialized).toContain('"has_public_rpc":true') + expect(serialized).not.toContain('"rpcs"') + expect(serialized).not.toContain(connectionDetails.adminRpc) + expect(serialized).not.toContain(connectionDetails.publicRpc) + expect(serialized).not.toContain(connectionDetails.predictablePublicRpc) + }) + + it('sanitizes API error messages instead of echoing raw response data', () => { + const message = buildTenderlyApiErrorMessage({ + status: 404, + parsedBody: { + message: 'Request failed for https://admin.rpc/secret-path', + details: 'hidden' + } + }) + + expect(message).toContain('Tenderly API request failed (404)') + expect(message).toContain('[redacted-url]') + expect(message).toContain('Hint: check --account and --project slugs.') + expect(message).not.toContain(connectionDetails.adminRpc) + expect(message).not.toContain('Response:') + }) + + it('fails local output validation before writing to an unwritable location', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'tenderly-vnet-')) + const lockedDir = join(tempDir, 'locked') + mkdirSync(lockedDir, { recursive: true }) + chmodSync(lockedDir, 0o500) + + try { + expect(() => validateWritableOutputPath(join(lockedDir, 'vnet.env'))).toThrow('Cannot write output file') + } finally { + chmodSync(lockedDir, 0o700) + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('writes secret output files with 0600 permissions', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'tenderly-vnet-')) + const outputFile = join(tempDir, 'vnet.env') + + try { + validateWritableOutputPath(outputFile) + writeOutputFile(outputFile, 'secret=value\n') + + expect(readFileSync(outputFile, 'utf8')).toBe('secret=value\n') + expect(statSync(outputFile).mode & 0o777).toBe(0o600) + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } + }) +}) diff --git a/scripts/tenderly-vnet.ts b/scripts/tenderly-vnet.ts new file mode 100644 index 000000000..cb1b2132c --- /dev/null +++ b/scripts/tenderly-vnet.ts @@ -0,0 +1,638 @@ +/// + +import { accessSync, chmodSync, constants, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +type TParsedCliArgs = { + flags: Record + positionals: string[] +} + +type TTenderlyProfile = 'webops' | 'personal' + +type TResolvedTenderlyCredentials = { + apiKey: string + accountSlug: string + projectSlug: string + profile: TTenderlyProfile +} + +type TTenderlyVnetResponse = { + slug?: string + display_name?: string + chain_id?: number + network_id?: number + id?: string + rpcs?: Array<{ + name: string + url: string + }> + [key: string]: unknown +} + +type TTenderlyVnetConnectionDetails = { + adminRpc?: string + publicRpc?: string + predictablePublicRpc?: string + explorerUri?: string +} + +type TCreateVnetPayload = { + slug: string + display_name: string + fork_config: { + network_id: number + block_number: string + } + virtual_network_config: { + chain_config: { + chain_id: number + } + } + sync_state_config: { + enabled: boolean + commitment_level: 'latest' + } + explorer_page_config: { + enabled: boolean + verification_visibility: 'bytecode' + } + rpc_config?: { + rpc_name: string + } +} + +const DEFAULT_ACCOUNT_SLUG = 'me' +const DEFAULT_NETWORK_ID = 1 +const DEFAULT_CHAIN_ID_PREFIX = '69420' +const TENDERLY_API_URL = 'https://api.tenderly.co/api/v1/account' +const REDACTED_URL = '[redacted-url]' + +const HELP_TEXT = `Tenderly Virtual TestNet bootstrap + +Usage: + bun run scripts/tenderly-vnet.ts [options] + +Options: + --profile Credential profile: webops or personal (default: webops) + --account Tenderly account slug (defaults to TENDERLY_ACCOUNT_SLUG, then me) + --project Tenderly project slug (defaults from selected profile env vars) + --api-key Tenderly API key override + --api-key-env Env var name to read the API key from + --account-env Env var name to read the account slug from + --project-env Env var name to read the project slug from + --slug VNet slug (default: vnet-) + --display-name VNet display name (default: Webops VNet) + --network-id Parent network id (default: 1) + --block-number Fork block number or latest (default: latest) + --chain-id Execution chain id (default: 69420) + --rpc-name Stable public RPC name for predictable endpoint reuse + --enable-sync Enable state sync (default: false) + --enable-explorer Enable public explorer (default: false) + --json Print a sanitized JSON summary + --write-env Write the generated Tenderly env fragment to a local file + --write-response Write the raw Tenderly API response JSON to a local file + --help Show this help text + +Profiles: + webops -> WEBOPS_TENDERLY_API_KEY, TENDERLY_ACCOUNT_SLUG, TENDERLY_PROJECT_SLUG, WEBOPS_TENDERLY_RPC_NAME + personal -> PERSONAL_TENDERLY_API_KEY, PERSONAL_ACCOUNT_SLUG, PERSONAL_PROJECT_SLUG, PERSONAL_TENDERLY_RPC_NAME + +Sensitive RPC values are never printed to stdout. Use --write-env or --write-response when you need them locally. +Explicit flags always win over profile defaults. +` + +function parseProfile(value: string | undefined): TTenderlyProfile { + if (!value || value.trim().length === 0) { + return 'webops' + } + + const normalized = value.trim().toLowerCase() + if (normalized === 'webops' || normalized === 'personal') { + return normalized + } + + throw new Error(`Unsupported profile: ${value}. Expected "webops" or "personal".`) +} + +function parseCliArgs(argv: readonly string[]): TParsedCliArgs { + const recurse = (index: number, acc: TParsedCliArgs): TParsedCliArgs => { + if (index >= argv.length) return acc + + const token = argv[index] + if (!token.startsWith('--')) { + return recurse(index + 1, { ...acc, positionals: [...acc.positionals, token] }) + } + + const key = token.slice(2) + const nextValue = argv[index + 1] + const maybeValue = nextValue && !nextValue.startsWith('--') ? nextValue : 'true' + const nextIndex = maybeValue === 'true' ? index + 1 : index + 2 + + return recurse(nextIndex, { + ...acc, + flags: { + ...acc.flags, + [key]: maybeValue + } + }) + } + + return recurse(0, { flags: {}, positionals: [] }) +} + +export function readEnvFile(path = '.env'): Record { + if (!existsSync(path)) return {} + + const envLines = readFileSync(path, 'utf8').split('\n') + + return envLines + .map((rawLine) => rawLine.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + .map((line) => { + const separatorIndex = line.indexOf('=') + if (separatorIndex < 0) return null + + const key = line.slice(0, separatorIndex).trim() + if (!key) return null + + const rawValue = line.slice(separatorIndex + 1).trim() + if (!rawValue) return [key, ''] + + const value = rawValue.replace(/^['"]|['"]$/g, '') + return [key, value] + }) + .filter((entry): entry is [string, string] => Boolean(entry)) + .reduce( + (acc, [key, value]) => { + acc[key] = value + return acc + }, + {} as Record + ) +} + +function requireString(value: string | undefined, label: string): string { + if (!value || value.trim().length === 0) { + throw new Error(`Missing required value: ${label}`) + } + return value +} + +function parseOptionalInteger(value: string | undefined, label: string): number | undefined { + if (!value) return undefined + + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`Invalid ${label}: ${value}`) + } + return parsed +} + +function parseBooleanFlag(flags: Record, key: string, defaultValue = false): boolean { + if (!(key in flags)) return defaultValue + return String(flags[key]).toLowerCase() !== 'false' +} + +function getArg(flags: Record, ...keys: string[]): string | undefined { + return keys.map((key) => flags[key]?.trim()).find((value) => Boolean(value)) +} + +function resolveDefaultChainId(networkId: number): number { + const derivedChainId = Number(`${DEFAULT_CHAIN_ID_PREFIX}${networkId}`) + if (!Number.isSafeInteger(derivedChainId) || derivedChainId <= 0) { + throw new Error(`Unable to derive default chain id for network ${networkId}`) + } + return derivedChainId +} + +function getEnvValue(env: Record, key?: string): string | undefined { + if (!key) return undefined + return env[key]?.trim() +} + +export function sanitizeConsoleText(value: string): string { + return value.replace(/https?:\/\/\S+/gi, REDACTED_URL) +} + +function resolveTenderlyCredentials( + flags: Record, + env: Record +): TResolvedTenderlyCredentials { + const profile = parseProfile(getArg(flags, 'profile')) + const profileDefaults = + profile === 'personal' + ? { + apiKeyEnv: 'PERSONAL_TENDERLY_API_KEY', + accountEnv: 'PERSONAL_ACCOUNT_SLUG', + projectEnv: 'PERSONAL_PROJECT_SLUG' + } + : { + apiKeyEnv: 'WEBOPS_TENDERLY_API_KEY', + accountEnv: 'TENDERLY_ACCOUNT_SLUG', + projectEnv: 'TENDERLY_PROJECT_SLUG' + } + + const apiKeyEnv = getArg(flags, 'api-key-env') || profileDefaults.apiKeyEnv + const accountEnv = getArg(flags, 'account-env') || profileDefaults.accountEnv + const projectEnv = getArg(flags, 'project-env') || profileDefaults.projectEnv + + const apiKey = + getArg(flags, 'api-key') || + getEnvValue(env, apiKeyEnv) || + env.TENDERLY_ACCESS_KEY?.trim() || + env.TENDERLY_API_KEY?.trim() + + if (!apiKey) { + throw new Error('Missing Tenderly API key. Provide --api-key or configure a supported Tenderly API key env var.') + } + + const accountSlug = + getArg(flags, 'account') || + getEnvValue(env, accountEnv) || + (profile === 'personal' ? env.ACCOUNT_SLUG?.trim() : undefined) || + DEFAULT_ACCOUNT_SLUG + const projectSlug = requireString( + getArg(flags, 'project') || + getEnvValue(env, projectEnv) || + (profile === 'personal' ? env.PROJECT_SLUG?.trim() : undefined), + '--project or a configured Tenderly project env var' + ) + + return { + apiKey, + accountSlug, + projectSlug, + profile + } +} + +function resolveRpcName( + flags: Record, + env: Record, + profile: TTenderlyProfile +): string | undefined { + const profileDefaultEnvKey = profile === 'personal' ? 'PERSONAL_TENDERLY_RPC_NAME' : 'WEBOPS_TENDERLY_RPC_NAME' + return getArg(flags, 'rpc-name') || getEnvValue(env, profileDefaultEnvKey) || env.TENDERLY_RPC_NAME?.trim() +} + +export function buildPredictablePublicRpcUrl(accountSlug: string, projectSlug: string, rpcName: string): string { + return `https://virtual.rpc.tenderly.co/${encodeURIComponent(accountSlug)}/${encodeURIComponent(projectSlug)}/public/${encodeURIComponent(rpcName)}` +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isHttpUrl(value: unknown): value is string { + return typeof value === 'string' && /^https?:\/\//i.test(value) +} + +export function resolveExplorerUriFromResponse(response: TTenderlyVnetResponse): string | undefined { + const queue: Array<{ value: unknown; path: string[] }> = [{ value: response, path: [] }] + const visited = new Set() + + while (queue.length > 0) { + const current = queue.shift() + if (!current || !isRecord(current.value) || visited.has(current.value)) { + continue + } + visited.add(current.value) + + for (const [key, value] of Object.entries(current.value)) { + const normalizedKey = key.toLowerCase() + const normalizedPath = [...current.path, normalizedKey] + const isExplorerPath = normalizedPath.some((part) => part.includes('explorer')) + + if (isExplorerPath && isHttpUrl(value)) { + return value + } + + if (Array.isArray(value)) { + queue.push(...value.map((entry) => ({ value: entry, path: normalizedPath }))) + continue + } + + if (isRecord(value)) { + queue.push({ value, path: normalizedPath }) + } + } + } + + return undefined +} + +function getConnectionDetails( + response: TTenderlyVnetResponse, + accountSlug: string, + projectSlug: string, + rpcName: string | undefined +): TTenderlyVnetConnectionDetails { + return { + adminRpc: response.rpcs?.find((rpc) => rpc.name === 'Admin RPC')?.url, + publicRpc: response.rpcs?.find((rpc) => rpc.name === 'Public RPC')?.url, + predictablePublicRpc: rpcName ? buildPredictablePublicRpcUrl(accountSlug, projectSlug, rpcName) : undefined, + explorerUri: resolveExplorerUriFromResponse(response) + } +} + +function selectPublicRpc(details: TTenderlyVnetConnectionDetails): string | undefined { + return details.predictablePublicRpc || details.publicRpc +} + +function resolveOutputPath(value: string | undefined, flagName: string): string { + const trimmedValue = value?.trim() + if (!trimmedValue || trimmedValue === 'true') { + throw new Error(`--${flagName} requires a file path`) + } + + return resolve(process.cwd(), trimmedValue) +} + +export function validateWritableOutputPath(path: string): string { + const outputDir = dirname(path) + + try { + mkdirSync(outputDir, { recursive: true, mode: 0o700 }) + + if (existsSync(path)) { + if (statSync(path).isDirectory()) { + throw new Error(`Output path is a directory: ${path}`) + } + accessSync(path, constants.W_OK) + return path + } + + accessSync(outputDir, constants.W_OK) + return path + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Cannot write output file ${path}: ${message}`) + } +} + +export function writeOutputFile(path: string, contents: string): string { + writeFileSync(path, contents, { encoding: 'utf8', mode: 0o600 }) + chmodSync(path, 0o600) + return path +} + +function resolveRequestedOutputPaths(flags: Record): { + envFilePath?: string + responseFilePath?: string +} { + const envFilePath = + 'write-env' in flags + ? validateWritableOutputPath(resolveOutputPath(getArg(flags, 'write-env'), 'write-env')) + : undefined + const responseFilePath = + 'write-response' in flags + ? validateWritableOutputPath(resolveOutputPath(getArg(flags, 'write-response'), 'write-response')) + : undefined + + return { + envFilePath, + responseFilePath + } +} + +export function buildTenderlyEnvFragment(params: { + canonicalChainId: number + executionChainId: number + details: TTenderlyVnetConnectionDetails +}): string { + const publicRpc = selectPublicRpc(params.details) || '' + + return [ + '# Generated by scripts/tenderly-vnet.ts', + 'VITE_TENDERLY_MODE=true', + `VITE_TENDERLY_CHAIN_ID_FOR_${params.canonicalChainId}=${params.executionChainId}`, + `VITE_TENDERLY_RPC_URI_FOR_${params.canonicalChainId}=${publicRpc}`, + `TENDERLY_ADMIN_RPC_URI_FOR_${params.canonicalChainId}=${params.details.adminRpc || ''}`, + `VITE_TENDERLY_EXPLORER_URI_FOR_${params.canonicalChainId}=${params.details.explorerUri || ''}` + ].join('\n') +} + +export function buildSanitizedVnetJson(params: { + profile: TTenderlyProfile + requestedSlug: string + displayName: string + chainId: number + networkId: number + response: TTenderlyVnetResponse + details: TTenderlyVnetConnectionDetails + envFilePath?: string + responseFilePath?: string +}): Record { + const sanitizedResponse = Object.fromEntries( + Object.entries(params.response).filter(([key]) => key !== 'rpcs') + ) as Record + const hasSensitiveValues = Boolean( + params.details.adminRpc || params.details.publicRpc || params.details.predictablePublicRpc + ) + + return { + ...sanitizedResponse, + profile: params.profile, + slug: params.response.slug || params.requestedSlug, + display_name: params.response.display_name || params.displayName, + chain_id: params.chainId, + network_id: params.networkId, + has_admin_rpc: Boolean(params.details.adminRpc), + has_public_rpc: Boolean(selectPublicRpc(params.details)), + env_file_path: params.envFilePath || null, + response_file_path: params.responseFilePath || null, + note: + hasSensitiveValues && !params.envFilePath && !params.responseFilePath + ? 'Sensitive RPC values were returned but were not printed. Use --write-env or --write-response to persist them locally.' + : undefined + } +} + +export function buildVnetConsoleSummary(params: { + profile: TTenderlyProfile + requestedSlug: string + displayName: string + chainId: number + networkId: number + response: TTenderlyVnetResponse + details: TTenderlyVnetConnectionDetails + envFilePath?: string + responseFilePath?: string +}): string[] { + const hasSensitiveValues = Boolean( + params.details.adminRpc || params.details.publicRpc || params.details.predictablePublicRpc + ) + + return [ + 'Created Tenderly Virtual TestNet', + `profile: ${params.profile}`, + `slug: ${params.response.slug || params.requestedSlug}`, + `display name: ${params.response.display_name || params.displayName}`, + params.envFilePath ? `wrote env fragment: ${params.envFilePath}` : undefined, + params.responseFilePath ? `wrote raw response json: ${params.responseFilePath}` : undefined, + hasSensitiveValues && !params.envFilePath && !params.responseFilePath + ? 'Sensitive RPC values were returned but not printed. Use --write-env or --write-response to persist them locally.' + : undefined, + `chain-id: ${params.chainId}`, + `network-id: ${params.networkId}` + ].filter((line): line is string => Boolean(line)) +} + +function parseResponseBody(responseBody: string): TTenderlyVnetResponse { + try { + return JSON.parse(responseBody) as TTenderlyVnetResponse + } catch { + return {} + } +} + +export function buildTenderlyApiErrorMessage(params: { status: number; parsedBody: TTenderlyVnetResponse }): string { + const record = params.parsedBody as Record + const rawApiMessage = typeof record.message === 'string' ? record.message : undefined + const apiMessage = rawApiMessage ? `: ${sanitizeConsoleText(rawApiMessage)}` : '' + const accountHint = + params.status === 404 + ? '\nHint: check --account and --project slugs. Use your team/org slug in --account for 404 cases, not a project id.' + : '' + + return `Tenderly API request failed (${params.status})${apiMessage}${accountHint}` +} + +async function createVirtualTestNet( + apiKey: string, + accountSlug: string, + projectSlug: string, + payload: TCreateVnetPayload +): Promise { + const response = await fetch( + `${TENDERLY_API_URL}/${encodeURIComponent(accountSlug)}/project/${encodeURIComponent(projectSlug)}/vnets`, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'content-type': 'application/json', + 'X-Access-Key': apiKey + }, + body: JSON.stringify(payload) + } + ) + + const responseBody = (await response.text()) || '{}' + const parsed = parseResponseBody(responseBody) + + if (!response.ok) { + throw new Error(buildTenderlyApiErrorMessage({ status: response.status, parsedBody: parsed })) + } + + return parsed +} + +async function main(): Promise { + const parsedArgs = parseCliArgs(process.argv.slice(2)) + const { flags } = parsedArgs + + if ('help' in flags || 'h' in flags) { + console.log(HELP_TEXT) + return + } + + const scriptDir = resolve(fileURLToPath(import.meta.url), '..') + const envFromFile = readEnvFile(resolve(scriptDir, '../.env')) + const env = { ...envFromFile, ...process.env } + const { apiKey, accountSlug, projectSlug, profile } = resolveTenderlyCredentials(flags, env) + const rpcName = resolveRpcName(flags, env, profile) + const timestamp = Date.now().toString() + const requestedSlug = getArg(flags, 'slug') || `vnet-${timestamp}` + const defaultDisplayNamePrefix = profile === 'personal' ? 'Personal VNet' : 'Webops VNet' + const displayName = getArg(flags, 'display-name') || `${defaultDisplayNamePrefix} ${timestamp}` + const networkId = parseOptionalInteger(getArg(flags, 'network-id'), 'network-id') || DEFAULT_NETWORK_ID + const chainId = parseOptionalInteger(getArg(flags, 'chain-id'), 'chain-id') || resolveDefaultChainId(networkId) + const blockNumber = getArg(flags, 'block-number') || 'latest' + const enableSync = parseBooleanFlag(flags, 'enable-sync', false) + const enableExplorer = parseBooleanFlag(flags, 'enable-explorer', false) + const { envFilePath, responseFilePath } = resolveRequestedOutputPaths(flags) + + const payload: TCreateVnetPayload = { + slug: requestedSlug, + display_name: displayName, + fork_config: { + network_id: networkId, + block_number: blockNumber + }, + virtual_network_config: { + chain_config: { + chain_id: chainId + } + }, + sync_state_config: { + enabled: enableSync, + commitment_level: 'latest' + }, + explorer_page_config: { + enabled: enableExplorer, + verification_visibility: 'bytecode' + }, + ...(rpcName ? { rpc_config: { rpc_name: rpcName } } : {}) + } + + const response = await createVirtualTestNet(apiKey, accountSlug, projectSlug, payload) + const details = getConnectionDetails(response, accountSlug, projectSlug, rpcName) + if (envFilePath) { + writeOutputFile( + envFilePath, + `${buildTenderlyEnvFragment({ + canonicalChainId: networkId, + executionChainId: chainId, + details + })}\n` + ) + } + if (responseFilePath) { + writeOutputFile(responseFilePath, `${JSON.stringify(response, null, 2)}\n`) + } + + if ('json' in flags) { + console.log( + JSON.stringify( + buildSanitizedVnetJson({ + profile, + requestedSlug, + displayName, + chainId, + networkId, + response, + details, + envFilePath, + responseFilePath + }), + null, + 2 + ) + ) + return + } + + buildVnetConsoleSummary({ + profile, + requestedSlug, + displayName, + chainId, + networkId, + response, + details, + envFilePath, + responseFilePath + }).forEach((line) => { + console.log(line) + }) +} + +if (fileURLToPath(import.meta.url) === process.argv[1]) { + void main().catch((error: unknown) => { + const message = error instanceof Error ? sanitizeConsoleText(error.message) : 'Tenderly VNet bootstrap failed' + console.error(message) + process.exitCode = 1 + }) +} diff --git a/scripts/tenderly.test.ts b/scripts/tenderly.test.ts new file mode 100644 index 000000000..908d5b0d9 --- /dev/null +++ b/scripts/tenderly.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { parseCliArgs, parseDecimalAmount, toHexQuantity } from './tenderly' + +describe('tenderly script helpers', () => { + it('parses command-line flags and positionals', () => { + expect( + parseCliArgs([ + 'fund-native', + '--wallet', + '0x1111111111111111111111111111111111111111', + '--amount', + '25', + '--mode', + 'add', + 'extra' + ]) + ).toEqual({ + command: 'fund-native', + flags: { + wallet: '0x1111111111111111111111111111111111111111', + amount: '25', + mode: 'add' + }, + positionals: ['extra'] + }) + }) + + it('parses boolean-style flags without values', () => { + expect(parseCliArgs(['increase-time', '--seconds', '86400', '--mine-block'])).toEqual({ + command: 'increase-time', + flags: { + seconds: '86400', + 'mine-block': 'true' + }, + positionals: [] + }) + }) + + it('converts decimal values to bigint token amounts', () => { + expect(parseDecimalAmount('1.5', 18)).toBe(1500000000000000000n) + expect(parseDecimalAmount('50000', 6)).toBe(50000000000n) + }) + + it('converts bigint values to JSON-RPC hex quantities', () => { + expect(toHexQuantity(0n)).toBe('0x0') + expect(toHexQuantity(255n)).toBe('0xff') + }) +}) diff --git a/scripts/tenderly.ts b/scripts/tenderly.ts new file mode 100644 index 000000000..750c8c50e --- /dev/null +++ b/scripts/tenderly.ts @@ -0,0 +1,352 @@ +/// + +import { fileURLToPath } from 'node:url' +import { getAddress } from 'viem' + +type TParsedCliArgs = { + command?: string + flags: Record + positionals: string[] +} + +type TTenderlyJsonRpcSuccess = { + id: string | number | null + jsonrpc: '2.0' + result: unknown +} + +type TTenderlyJsonRpcError = { + id: string | number | null + jsonrpc: '2.0' + error: { + code: number + message: string + data?: unknown + } +} + +const DEFAULT_CANONICAL_CHAIN_ID = 1 + +const HELP_TEXT = `Tenderly Admin RPC helper + +Usage: + bun run tenderly [options] + +Commands: + help + chain-id [--chain 1] + fund-native --wallet
--amount [--unit eth|wei] [--mode add|set] [--chain 1] + fund-erc20 --wallet
--token
--amount [--decimals 18] [--unit token|raw] [--chain 1] + snapshot [--chain 1] + revert --id [--chain 1] + increase-time --seconds [--mine-block] [--chain 1] + set-next-timestamp --timestamp [--chain 1] + increase-blocks --count [--chain 1] + raw --method [--params ''] [--chain 1] + +Examples: + bun run tenderly chain-id + bun run tenderly fund-native --wallet 0xabc... --amount 25 --mode add + bun run tenderly fund-erc20 --wallet 0xabc... --token 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --amount 50000 --decimals 6 + bun run tenderly snapshot + bun run tenderly revert --id 0x1 + bun run tenderly increase-time --seconds 86400 --mine-block + bun run tenderly raw --method tenderly_setBalance --params '[["0xabc..."], "0x3635C9ADC5DEA00000"]' +` + +export function parseCliArgs(argv: readonly string[]): TParsedCliArgs { + const [command, ...rest] = argv + const flags: Record = {} + const positionals: string[] = [] + + for (let index = 0; index < rest.length; index += 1) { + const token = rest[index] + if (!token.startsWith('--')) { + positionals.push(token) + continue + } + + const key = token.slice(2) + const nextToken = rest[index + 1] + + if (!nextToken || nextToken.startsWith('--')) { + flags[key] = 'true' + continue + } + + flags[key] = nextToken + index += 1 + } + + return { command, flags, positionals } +} + +export function parseDecimalAmount(value: string, decimals: number): bigint { + if (!Number.isInteger(decimals) || decimals < 0) { + throw new Error(`Invalid decimals value: ${decimals}`) + } + + const normalizedValue = value.trim() + if (normalizedValue.length === 0) { + throw new Error('Amount is required') + } + + if (normalizedValue.startsWith('-')) { + throw new Error('Negative amounts are not supported') + } + + if (!/^\d+(\.\d+)?$/.test(normalizedValue)) { + throw new Error(`Invalid decimal amount: ${value}`) + } + + const [wholePartRaw, fractionalPartRaw = ''] = normalizedValue.split('.') + if (fractionalPartRaw.length > decimals) { + throw new Error(`Amount ${value} exceeds ${decimals} decimals`) + } + + const wholePart = BigInt(wholePartRaw) + const fractionalPart = fractionalPartRaw.length ? BigInt(fractionalPartRaw.padEnd(decimals, '0')) : 0n + + return wholePart * 10n ** BigInt(decimals) + fractionalPart +} + +export function toHexQuantity(value: bigint): `0x${string}` { + if (value < 0n) { + throw new Error('Negative quantities are not supported') + } + + return `0x${value.toString(16)}` as const +} + +function parsePositiveInteger(value: string, label: string): number { + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${label} must be a positive integer`) + } + return parsed +} + +function requireFlag(flags: Record, ...keys: string[]): string { + for (const key of keys) { + const value = flags[key]?.trim() + if (value) { + return value + } + } + + throw new Error(`Missing required flag: --${keys[0]}`) +} + +function optionalFlag(flags: Record, key: string): string | undefined { + const value = flags[key]?.trim() + return value ? value : undefined +} + +function hasFlag(flags: Record, key: string): boolean { + const value = optionalFlag(flags, key) + return value === 'true' || value === '1' +} + +function getCanonicalChainId(flags: Record): number { + const chainValue = optionalFlag(flags, 'chain') + return chainValue ? parsePositiveInteger(chainValue, 'chain') : DEFAULT_CANONICAL_CHAIN_ID +} + +function getAdminRpcUri(canonicalChainId: number): string { + const envName = `TENDERLY_ADMIN_RPC_URI_FOR_${canonicalChainId}` + const value = process.env[envName]?.trim() + + if (!value) { + throw new Error(`Missing ${envName} in the environment`) + } + + return value +} + +async function callAdminRpc(canonicalChainId: number, method: string, params: unknown[]): Promise { + const rpcUri = getAdminRpcUri(canonicalChainId) + const response = await fetch(rpcUri, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + id: 1, + jsonrpc: '2.0', + method, + params + }) + }) + + if (!response.ok) { + throw new Error(`Tenderly RPC request failed with status ${response.status}`) + } + + const payload = (await response.json()) as TTenderlyJsonRpcSuccess | TTenderlyJsonRpcError + + if ('error' in payload) { + throw new Error(`${payload.error.message} (code ${payload.error.code})`) + } + + return payload.result +} + +function parseWalletAddress(flags: Record): `0x${string}` { + return getAddress(requireFlag(flags, 'wallet', 'address')) +} + +function parseTokenAddress(flags: Record): `0x${string}` { + return getAddress(requireFlag(flags, 'token')) +} + +function parseNativeAmount(flags: Record): bigint { + const amount = requireFlag(flags, 'amount') + const unit = optionalFlag(flags, 'unit') || 'eth' + + if (unit === 'wei' || unit === 'raw') { + return amount.startsWith('0x') ? BigInt(amount) : BigInt(amount) + } + + if (unit !== 'eth') { + throw new Error(`Unsupported native unit: ${unit}`) + } + + return parseDecimalAmount(amount, 18) +} + +function parseTokenAmount(flags: Record): bigint { + const amount = requireFlag(flags, 'amount') + const unit = optionalFlag(flags, 'unit') || 'token' + + if (unit === 'raw' || unit === 'wei') { + return amount.startsWith('0x') ? BigInt(amount) : BigInt(amount) + } + + const decimals = parsePositiveInteger(optionalFlag(flags, 'decimals') || '18', 'decimals') + return parseDecimalAmount(amount, decimals) +} + +function formatResult(result: unknown): string { + if (typeof result === 'string') { + return result + } + + return JSON.stringify(result, null, 2) +} + +async function runCommand(parsedArgs: TParsedCliArgs): Promise { + const { command, flags } = parsedArgs + const canonicalChainId = getCanonicalChainId(flags) + + switch (command) { + case undefined: + case 'help': { + console.log(HELP_TEXT) + return + } + case 'chain-id': { + const result = await callAdminRpc(canonicalChainId, 'eth_chainId', []) + const chainIdHex = String(result) + const chainIdDecimal = Number(BigInt(chainIdHex)) + console.log(`canonical chain: ${canonicalChainId}`) + console.log(`execution chain: ${chainIdDecimal} (${chainIdHex})`) + return + } + case 'fund-native': { + const wallet = parseWalletAddress(flags) + const amount = parseNativeAmount(flags) + const mode = optionalFlag(flags, 'mode') || 'add' + const method = mode === 'set' ? 'tenderly_setBalance' : mode === 'add' ? 'tenderly_addBalance' : undefined + + if (!method) { + throw new Error(`Unsupported fund-native mode: ${mode}`) + } + + const result = await callAdminRpc(canonicalChainId, method, [[wallet], toHexQuantity(amount)]) + console.log( + `${mode === 'set' ? 'Set' : 'Added'} native balance for ${wallet} on canonical chain ${canonicalChainId}` + ) + console.log(formatResult(result)) + return + } + case 'fund-erc20': { + const wallet = parseWalletAddress(flags) + const token = parseTokenAddress(flags) + const amount = parseTokenAmount(flags) + const result = await callAdminRpc(canonicalChainId, 'tenderly_setErc20Balance', [ + token, + wallet, + toHexQuantity(amount) + ]) + console.log(`Set ERC-20 balance for ${wallet} on token ${token} (canonical chain ${canonicalChainId})`) + console.log(formatResult(result)) + return + } + case 'snapshot': { + const result = await callAdminRpc(canonicalChainId, 'evm_snapshot', []) + console.log(`Snapshot created on canonical chain ${canonicalChainId}: ${formatResult(result)}`) + return + } + case 'revert': { + const snapshotId = requireFlag(flags, 'id') + const result = await callAdminRpc(canonicalChainId, 'evm_revert', [snapshotId]) + console.log(`Revert result on canonical chain ${canonicalChainId}: ${formatResult(result)}`) + return + } + case 'increase-time': { + const seconds = parsePositiveInteger(requireFlag(flags, 'seconds'), 'seconds') + const shouldMineBlock = hasFlag(flags, 'mine-block') + const result = await callAdminRpc(canonicalChainId, 'evm_increaseTime', [toHexQuantity(BigInt(seconds))]) + console.log(`Advanced time by ${seconds} seconds on canonical chain ${canonicalChainId}`) + console.log(formatResult(result)) + if (shouldMineBlock) { + const minedBlockResult = await callAdminRpc(canonicalChainId, 'evm_mine', []) + console.log(`Mined 1 block on canonical chain ${canonicalChainId}`) + console.log(formatResult(minedBlockResult)) + } + return + } + case 'set-next-timestamp': { + const timestamp = parsePositiveInteger(requireFlag(flags, 'timestamp'), 'timestamp') + const result = await callAdminRpc(canonicalChainId, 'evm_setNextBlockTimestamp', [ + toHexQuantity(BigInt(timestamp)) + ]) + console.log(`Set next block timestamp to ${timestamp} on canonical chain ${canonicalChainId}`) + console.log(formatResult(result)) + return + } + case 'increase-blocks': { + const count = parsePositiveInteger(requireFlag(flags, 'count'), 'count') + const result = await callAdminRpc(canonicalChainId, 'evm_increaseBlocks', [toHexQuantity(BigInt(count))]) + console.log(`Advanced ${count} blocks on canonical chain ${canonicalChainId}`) + console.log(formatResult(result)) + return + } + case 'raw': { + const method = requireFlag(flags, 'method') + const rawParams = optionalFlag(flags, 'params') || '[]' + const params = JSON.parse(rawParams) as unknown + if (!Array.isArray(params)) { + throw new Error('--params must be a JSON array') + } + + const result = await callAdminRpc(canonicalChainId, method, params) + console.log(formatResult(result)) + return + } + default: { + throw new Error(`Unknown Tenderly command: ${command}`) + } + } +} + +async function main(): Promise { + const parsedArgs = parseCliArgs(process.argv.slice(2)) + await runCommand(parsedArgs) +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + void main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + process.exitCode = 1 + }) +} diff --git a/src/App.tsx b/src/App.tsx index 3291c7361..e69bb56b3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { ChartStyleContextApp } from '@shared/contexts/useChartStyle' import { IndexedDB } from '@shared/contexts/useIndexedDB' import { WithNotifications } from '@shared/contexts/useNotifications' import { WithNotificationsActions } from '@shared/contexts/useNotificationsActions' +import { TenderlyPanelProvider } from '@shared/contexts/useTenderlyPanel' import { WalletContextApp } from '@shared/contexts/useWallet' import { Web3ContextApp } from '@shared/contexts/useWeb3' import { YearnContextApp } from '@shared/contexts/useYearn' @@ -26,6 +27,7 @@ import { useLayoutEffect } from 'react' import { Toaster } from 'react-hot-toast' import { useLocation } from 'react-router' import { WagmiProvider } from 'wagmi' +import { TenderlyControlPanel } from '@/components/shared/components/TenderlyControlPanel' import { wagmiConfig } from '@/config/wagmi' import { ChainsProvider } from '@/context/ChainsProvider' import '@hooks/usePlausible' @@ -102,7 +104,10 @@ function App(): ReactElement { - + + + + diff --git a/src/components/Image.tsx b/src/components/Image.tsx index 7276a8109..bfab6794c 100644 --- a/src/components/Image.tsx +++ b/src/components/Image.tsx @@ -42,6 +42,7 @@ function Image(props: CustomImageProps): ReactElement { } = props const [imageSrc, setImageSrc] = useState(src) + const [prevSrc, setPrevSrc] = useState(src) const [isVisible, setIsVisible] = useState(loading !== 'lazy' || priority === true) const [isLoading, setIsLoading] = useState(true) const [hasError, setHasError] = useState(false) @@ -49,6 +50,14 @@ function Image(props: CustomImageProps): ReactElement { const imgRef = useRef(null) const observerRef = useRef(null) + // Render-time state adjustment: reset when src prop changes + if (src !== prevSrc) { + setPrevSrc(src) + setImageSrc(src) + setHasError(false) + setIsLoading(true) + } + // Set up IntersectionObserver for lazy loading useEffect(() => { if (loading !== 'lazy' || priority || !imageRef.current) return @@ -75,13 +84,6 @@ function Image(props: CustomImageProps): ReactElement { } }, [loading, priority]) - // Reset states when src changes - useEffect(() => { - setImageSrc(src) - setHasError(false) - setIsLoading(true) - }, [src]) - // Handle already-cached images where onLoad might not fire useEffect(() => { if (!isVisible) return diff --git a/src/components/Link.test.tsx b/src/components/Link.test.tsx deleted file mode 100644 index 997cb332b..000000000 --- a/src/components/Link.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import { MemoryRouter } from 'react-router' -import { describe, expect, it } from 'vitest' -import Link from './Link' - -describe('Link', () => { - it('renders external links as with safe defaults', () => { - const originalWindow = globalThis.window - ;(globalThis as unknown as { window: unknown }).window = { - location: { href: 'https://yearn.fi/', hostname: 'yearn.fi' } - } - - try { - const html = renderToStaticMarkup(Example) - - expect(html).toContain('href="https://example.com"') - expect(html).toContain('target="_blank"') - expect(html).toContain('rel="noopener noreferrer"') - } finally { - ;(globalThis as unknown as { window: unknown }).window = originalWindow - } - }) - - it('renders internal links through react-router', () => { - const originalWindow = globalThis.window - ;(globalThis as unknown as { window: unknown }).window = { - location: { href: 'https://yearn.fi/', hostname: 'yearn.fi' } - } - - try { - const html = renderToStaticMarkup( - - Vaults - - ) - - expect(html).toContain('href="/vaults"') - expect(html).not.toContain('target="_blank"') - } finally { - ;(globalThis as unknown as { window: unknown }).window = originalWindow - } - }) -}) diff --git a/src/components/pages/font-fallbacks/index.tsx b/src/components/pages/font-fallbacks/index.tsx new file mode 100644 index 000000000..78f366a0c --- /dev/null +++ b/src/components/pages/font-fallbacks/index.tsx @@ -0,0 +1,529 @@ +import { Meta } from '@shared/components/Meta' +import type { ReactElement, ReactNode } from 'react' +import { useEffect, useState } from 'react' + +type TFontCandidate = { + family: string + kind: 'named' | 'system' | 'generic' + label: string + note: string +} + +type TFontIdentityCandidate = Pick +type TFontProbe = { + fontSizePx: number + fontStyle: 'normal' | 'italic' + fontWeight: number + label: string + sampleText: string +} + +type TRenderedFontMatch = { + aliases: string[] + label: string + primaryLabel: string | null +} + +const SANS_STACK: TFontCandidate[] = [ + { family: '"Aeonik"', kind: 'named', label: 'Aeonik', note: 'Primary self-hosted UI sans.' }, + { family: 'Helvetica', kind: 'named', label: 'Helvetica', note: 'Primary Apple-compatible sans fallback.' }, + { family: 'Arial', kind: 'named', label: 'Arial', note: 'Cross-platform safety-net sans.' }, + { + family: 'system-ui', + kind: 'system', + label: 'system-ui', + note: 'Late cross-platform system UI fallback keyword.' + }, + { family: 'sans-serif', kind: 'generic', label: 'sans-serif', note: 'Browser generic sans fallback.' } +] + +const MONO_STACK: TFontCandidate[] = [ + { family: '"Aeonik Mono"', kind: 'named', label: 'Aeonik Mono', note: 'Primary mono for numeric UI samples.' }, + { family: 'Menlo', kind: 'named', label: 'Menlo', note: 'Preferred macOS mono fallback.' }, + { family: 'Consolas', kind: 'named', label: 'Consolas', note: 'Preferred Windows mono fallback.' }, + { family: '"Liberation Mono"', kind: 'named', label: 'Liberation Mono', note: 'Common Linux mono fallback.' }, + { family: 'monospace', kind: 'generic', label: 'monospace', note: 'Browser generic mono fallback.' } +] + +const SANS_RENDER_CANDIDATES: TFontIdentityCandidate[] = [ + { family: '"Aeonik"', kind: 'named', label: 'Aeonik' }, + { family: '"SF Pro Text"', kind: 'named', label: 'SF Pro Text' }, + { family: '"SF Pro Display"', kind: 'named', label: 'SF Pro Display' }, + { family: 'Helvetica', kind: 'named', label: 'Helvetica' }, + { family: 'Arial', kind: 'named', label: 'Arial' }, + { family: '"Segoe UI"', kind: 'named', label: 'Segoe UI' }, + { family: 'Roboto', kind: 'named', label: 'Roboto' }, + { family: 'system-ui', kind: 'system', label: 'system-ui' }, + { family: 'sans-serif', kind: 'generic', label: 'sans-serif' } +] + +const MONO_RENDER_CANDIDATES: TFontIdentityCandidate[] = [ + { family: '"Aeonik Mono"', kind: 'named', label: 'Aeonik Mono' }, + { family: '"SFMono-Regular"', kind: 'named', label: 'SFMono-Regular' }, + { family: 'Menlo', kind: 'named', label: 'Menlo' }, + { family: 'Consolas', kind: 'named', label: 'Consolas' }, + { family: '"Liberation Mono"', kind: 'named', label: 'Liberation Mono' }, + { family: 'monospace', kind: 'generic', label: 'monospace' } +] + +const SANS_RENDER_PROBES: TFontProbe[] = [ + { label: 'Regular', fontSizePx: 14, fontStyle: 'normal', fontWeight: 400, sampleText: 'Yearn BOLD Stability Pool' }, + { label: 'Bold', fontSizePx: 18, fontStyle: 'normal', fontWeight: 900, sampleText: 'Yearn BOLD 2.74%' }, + { label: 'Italic', fontSizePx: 14, fontStyle: 'italic', fontWeight: 400, sampleText: 'Italic stress sample' } +] + +const MONO_RENDER_PROBES: TFontProbe[] = [ + { label: 'Regular', fontSizePx: 16, fontStyle: 'normal', fontWeight: 400, sampleText: '2.74% 3.00% $4.54M' }, + { label: 'Bold', fontSizePx: 14, fontStyle: 'normal', fontWeight: 700, sampleText: '9,430 BOLD ($9,480)' }, + { + label: 'Italic', + fontSizePx: 12, + fontStyle: 'italic', + fontWeight: 400, + sampleText: 'Projected settlement 03/26/26 14:08' + } +] + +const BODY_STACK = SANS_STACK.map((entry) => entry.family).join(', ') +const NUMBER_STACK = MONO_STACK.map((entry) => entry.family).join(', ') + +const DETAIL_STATS = [ + { label: 'Est. APY', value: '2.74%' }, + { label: 'TVL', value: '$4.54M' }, + { label: 'Your deposits', value: '$0.00' } +] + +const NUMBER_ROWS = [ + { label: 'Vault share value', value: '9,430 BOLD ($9,480)' }, + { label: 'Expected out', value: '10.0K BOLD' }, + { label: 'Price impact', value: '-5.66%' } +] + +function buildFallbackStack(candidates: TFontCandidate[], startIndex: number): string { + return candidates + .slice(startIndex) + .map((entry) => entry.family) + .join(', ') +} + +function buildCanvasFont(probe: TFontProbe, fontFamily: string): string { + return `${probe.fontStyle} ${probe.fontWeight} ${probe.fontSizePx}px ${fontFamily}` +} + +function buildFontFingerprint(fontFamily: string, probe: TFontProbe): string | null { + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d', { willReadFrequently: true }) + + if (!context) { + return null + } + + context.font = buildCanvasFont(probe, fontFamily) + context.textBaseline = 'top' + + const width = Math.max(1, Math.ceil(context.measureText(probe.sampleText).width + 32)) + const height = Math.max(1, Math.ceil(probe.fontSizePx * 3)) + + canvas.width = width + canvas.height = height + + const renderContext = canvas.getContext('2d', { willReadFrequently: true }) + + if (!renderContext) { + return null + } + + renderContext.fillStyle = '#ffffff' + renderContext.fillRect(0, 0, width, height) + renderContext.fillStyle = '#000000' + renderContext.font = buildCanvasFont(probe, fontFamily) + renderContext.textBaseline = 'top' + renderContext.fontKerning = 'normal' + renderContext.fillText(probe.sampleText, 16, 12) + + const imageData = renderContext.getImageData(0, 0, width, height).data + const hash = imageData.reduce((currentHash, channel) => Math.imul(currentHash ^ channel, 16777619), 2166136261) + + return `${width}:${height}:${hash >>> 0}` +} + +function rankRenderedMatches(matches: TFontIdentityCandidate[]): TFontIdentityCandidate[] { + const priority = { + named: 0, + system: 1, + generic: 2 + } + + return [...matches].sort((candidateA, candidateB) => priority[candidateA.kind] - priority[candidateB.kind]) +} + +function inferRenderedFontMatch( + stack: string, + candidates: TFontIdentityCandidate[], + probe: TFontProbe +): TRenderedFontMatch { + const stackFingerprint = buildFontFingerprint(stack, probe) + + if (!stackFingerprint) { + return { label: probe.label, primaryLabel: null, aliases: [] } + } + + const matches = candidates.filter((candidate) => buildFontFingerprint(candidate.family, probe) === stackFingerprint) + + if (!matches.length) { + return { label: probe.label, primaryLabel: null, aliases: [] } + } + + const rankedMatches = rankRenderedMatches(matches) + + return { + label: probe.label, + primaryLabel: rankedMatches[0]?.label || null, + aliases: rankedMatches.slice(1).map((candidate) => candidate.label) + } +} + +function inferRenderedFontMatches( + stack: string, + candidates: TFontIdentityCandidate[], + probes: TFontProbe[] +): TRenderedFontMatch[] { + return probes.map((probe) => inferRenderedFontMatch(stack, candidates, probe)) +} + +function formatRenderedFontMatches(matches: TRenderedFontMatch[]): string { + const primaryLabels = [ + ...new Set(matches.map((match) => match.primaryLabel).filter((label): label is string => Boolean(label))) + ] + + if (!primaryLabels.length) { + return 'No exact match in known candidates' + } + + if (primaryLabels.length === 1) { + return primaryLabels[0] + } + + return matches.map((match) => `${match.label} ${match.primaryLabel || 'Unknown'}`).join(' · ') +} + +function RenderedFontHeader({ + isolatedStack, + fallbackStack, + candidates, + probes +}: { + candidates: TFontIdentityCandidate[] + fallbackStack: string + isolatedStack: string + probes: TFontProbe[] +}): ReactElement { + const [isolatedMatches, setIsolatedMatches] = useState([]) + const [fallbackMatches, setFallbackMatches] = useState([]) + + useEffect(() => { + let isActive = true + + const syncMatches = (): void => { + if (!isActive) { + return + } + + setIsolatedMatches(inferRenderedFontMatches(isolatedStack, candidates, probes)) + setFallbackMatches(inferRenderedFontMatches(fallbackStack, candidates, probes)) + } + + syncMatches() + + const fontSet = document.fonts + + void fontSet.ready.then(() => { + syncMatches() + }) + + fontSet.addEventListener('loadingdone', syncMatches) + fontSet.addEventListener('loadingerror', syncMatches) + + return () => { + isActive = false + fontSet.removeEventListener('loadingdone', syncMatches) + fontSet.removeEventListener('loadingerror', syncMatches) + } + }, [candidates, fallbackStack, isolatedStack, probes]) + + return ( +
+

{'Likely Rendered'}

+
+

+ {'Isolated: '} + {formatRenderedFontMatches(isolatedMatches)} +

+

+ {'Fallback: '} + {formatRenderedFontMatches(fallbackMatches)} +

+
+
+ ) +} + +function PreviewShell({ children, fontFamily }: { children: ReactNode; fontFamily: string }): ReactElement { + return ( +
+ {children} +
+ ) +} + +function PreviewPanel({ label, stack, preview }: { label: string; preview: ReactNode; stack: string }): ReactElement { + return ( +
+
+

{label}

+ {stack} +
+ {preview} +
+ ) +} + +function VaultSansPreview({ fontFamily }: { fontFamily: string }): ReactElement { + return ( + +
+

{'Vault Header'}

+

{'Yearn BOLD'}

+

{'Ethereum · Stablecoin · Single Asset'}

+
+ +
+ {DETAIL_STATS.map((item) => ( +
+

{item.label}

+

{item.value}

+
+ ))} +
+ +
+

+ { + "yBOLD is Yearn's BOLD tokenized Stability Pool product. It is designed to tokenize the benefits of a BOLD-in-stability-pool position." + } +

+

+ { + 'Italic stress sample: explanatory copy and warning notes should still read cleanly if Aeonik is unavailable.' + } +

+
+ +
+ {'Approve & Deposit'} +
+
+ ) +} + +function VaultMonoPreview({ fontFamily }: { fontFamily: string }): ReactElement { + return ( + +
+

{'Metric Samples'}

+

{'2.74% 3.00% $4.54M'}

+
+ +
+
+ {NUMBER_ROWS.map((item) => ( +
+ {item.label} + {item.value} +
+ ))} +
+
+ +
+

+ {'0x9f4330700a36b29952869fac9b33f45eedd8a3d8'} +

+

{'Projected settlement 03/26/26 14:08'}

+
+
+ ) +} + +function FontCard({ + candidate, + fallbackStack, + isolatedPreview, + fallbackPreview, + probeCandidates, + probes, + order +}: { + candidate: TFontCandidate + fallbackPreview: ReactNode + fallbackStack: string + isolatedPreview: ReactNode + order: number + probeCandidates: TFontIdentityCandidate[] + probes: TFontProbe[] +}): ReactElement { + return ( +
+
+
+

{`${order}. ${candidate.label}`}

+

{candidate.note}

+
+ +
+ + +
+ ) +} + +function StackPreview({ + title, + description, + stack, + preview +}: { + title: string + description: string + stack: string + preview: ReactNode +}): ReactElement { + return ( +
+
+

{title}

+

{description}

+
+ {stack} + {preview} +
+ ) +} + +function Index(): ReactElement { + const [platform, setPlatform] = useState('Unknown platform') + + useEffect(() => { + const navigatorWithUserAgentData = navigator as Navigator & { + userAgentData?: { platform?: string } + } + + setPlatform(navigatorWithUserAgentData.userAgentData?.platform || navigator.platform || 'Unknown platform') + }, []) + + return ( +
+ +
+
+

Font Fallback Debug

+
+
+

+ Compare the exact sans and mono fallback order on this machine. +

+

+ Use this page to compare the live body stack and number stack against each individual fallback + candidate. Each card shows the isolated family first, then the actual remaining fallback chain from that + point in the stack. The bold weights mirror the vault detail route, and the italic lines are included as + a deliberate stress test. The rendered-face readout is inferred by matching canvas output against known + families on this machine. +

+
+
+ {`Platform: ${platform}`} + {`Route: /font-fallbacks`} +
+
+
+ +
+ } + /> + } + /> +
+ +
+
+

Sans Candidates

+

Body fallback order, one family at a time.

+
+
+ {SANS_STACK.map((candidate, index) => ( + } + fallbackStack={buildFallbackStack(SANS_STACK, index)} + isolatedPreview={} + order={index + 1} + probeCandidates={SANS_RENDER_CANDIDATES} + probes={SANS_RENDER_PROBES} + /> + ))} +
+
+ +
+
+

Mono Candidates

+

+ Numeric and code-style fallback order, one family at a time. +

+
+
+ {MONO_STACK.map((candidate, index) => ( + } + fallbackStack={buildFallbackStack(MONO_STACK, index)} + isolatedPreview={} + order={index + 1} + probeCandidates={MONO_RENDER_CANDIDATES} + probes={MONO_RENDER_PROBES} + /> + ))} +
+
+
+
+ ) +} + +export default Index diff --git a/src/components/pages/portfolio/hooks/buildVaultSuggestions.test.ts b/src/components/pages/portfolio/hooks/buildVaultSuggestions.test.ts deleted file mode 100644 index 43e738642..000000000 --- a/src/components/pages/portfolio/hooks/buildVaultSuggestions.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { TExternalToken } from '@pages/portfolio/constants/externalTokens' -import { buildVaultSuggestions } from '@pages/portfolio/hooks/buildVaultSuggestions' -import type { TKongVault } from '@pages/vaults/domain/kongVaultSelectors' -import { describe, expect, it } from 'vitest' - -const buildVault = ({ - address, - assetSymbol, - tvl, - apr -}: { - address: string - assetSymbol: string - tvl: number - apr: number -}): TKongVault => - ({ - chainId: 1, - address, - name: `${assetSymbol} Vault`, - symbol: `yv${assetSymbol}`, - apiVersion: '3.0.0', - decimals: 18, - asset: { - address: - assetSymbol === 'USDC' - ? '0x0000000000000000000000000000000000000010' - : assetSymbol === 'DAI' - ? '0x0000000000000000000000000000000000000011' - : '0x0000000000000000000000000000000000000012', - name: assetSymbol, - symbol: assetSymbol, - decimals: 18 - }, - tvl, - performance: { - oracle: { apr, apy: apr }, - estimated: { - apr, - apy: apr, - type: 'estimated', - components: {} - }, - historical: { - net: apr, - weeklyNet: apr, - monthlyNet: apr, - inceptionNet: apr - } - }, - fees: { - managementFee: 0.0025, - performanceFee: 0.1 - }, - category: 'Stablecoin', - type: 'Standard', - kind: 'Multi Strategy', - v3: true, - yearn: true, - isRetired: false, - isHidden: false, - isBoosted: false, - isHighlighted: true, - strategiesCount: 1, - riskLevel: 1, - staking: { - address: null, - available: false - } - }) as unknown as TKongVault - -describe('buildVaultSuggestions', () => { - it('dedupes repeated vault matches before applying the two-item cap', () => { - const usdcVault = buildVault({ - address: '0x0000000000000000000000000000000000000001', - assetSymbol: 'USDC', - tvl: 2_000_000, - apr: 0.06 - }) - const daiVault = buildVault({ - address: '0x0000000000000000000000000000000000000002', - assetSymbol: 'DAI', - tvl: 1_500_000, - apr: 0.05 - }) - const wethVault = buildVault({ - address: '0x0000000000000000000000000000000000000003', - assetSymbol: 'WETH', - tvl: 1_250_000, - apr: 0.05 - }) - - const detectedTokens: TExternalToken[] = [ - { - address: '0x0000000000000000000000000000000000000101', - chainId: 1, - protocol: 'Aave V3', - underlyingSymbol: 'USDC', - underlyingAddress: '0x0000000000000000000000000000000000000010' - }, - { - address: '0x0000000000000000000000000000000000000102', - chainId: 1, - protocol: 'Compound V3', - underlyingSymbol: 'USDC', - underlyingAddress: '0x0000000000000000000000000000000000000010' - }, - { - address: '0x0000000000000000000000000000000000000103', - chainId: 1, - protocol: 'Spark', - underlyingSymbol: 'DAI', - underlyingAddress: '0x0000000000000000000000000000000000000011' - }, - { - address: '0x0000000000000000000000000000000000000104', - chainId: 1, - protocol: 'Morpho', - underlyingSymbol: 'WETH', - underlyingAddress: '0x0000000000000000000000000000000000000012' - } - ] - - const suggestions = buildVaultSuggestions( - detectedTokens, - { - [usdcVault.address]: usdcVault, - [daiVault.address]: daiVault, - [wethVault.address]: wethVault - }, - new Set() - ) - - expect(suggestions).toHaveLength(2) - expect(suggestions[0]?.vault).toBe(usdcVault) - expect(suggestions[1]?.vault).toBe(daiVault) - }) -}) diff --git a/src/components/pages/portfolio/hooks/getEligibleVaults.test.ts b/src/components/pages/portfolio/hooks/getEligibleVaults.test.ts deleted file mode 100644 index b4f2f5a35..000000000 --- a/src/components/pages/portfolio/hooks/getEligibleVaults.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { TKongVault } from '@pages/vaults/domain/kongVaultSelectors' -import { describe, expect, it } from 'vitest' -import { selectPreferredVault } from './getEligibleVaults' - -const buildVault = ({ - address, - assetSymbol, - tvl, - apr, - version = '3.0.0' -}: { - address: string - assetSymbol: string - tvl: number - apr: number - version?: string -}): TKongVault => - ({ - chainId: 1, - address, - name: `${assetSymbol} Vault`, - symbol: `yv${assetSymbol}`, - apiVersion: version, - decimals: 18, - asset: { - address: '0x0000000000000000000000000000000000000010', - name: assetSymbol, - symbol: assetSymbol, - decimals: 18 - }, - tvl, - performance: { - oracle: { apr, apy: apr }, - estimated: { - apr, - apy: apr, - type: 'estimated', - components: {} - }, - historical: { - net: apr, - weeklyNet: apr, - monthlyNet: apr, - inceptionNet: apr - } - }, - fees: { - managementFee: 0.0025, - performanceFee: 0.1 - }, - category: 'Stablecoin', - type: 'Standard', - kind: 'Single Strategy', - v3: true, - yearn: true, - isRetired: false, - isHidden: false, - isBoosted: false, - isHighlighted: true, - strategiesCount: 1, - riskLevel: 1, - staking: { - address: null, - available: false - } - }) as unknown as TKongVault - -describe('selectPreferredVault', () => { - it('prefers the highest-TVL qualifying vault', () => { - const smaller = buildVault({ - address: '0x0000000000000000000000000000000000000001', - assetSymbol: 'USDC', - tvl: 900_000, - apr: 0.06 - }) - const larger = buildVault({ - address: '0x0000000000000000000000000000000000000002', - assetSymbol: 'USDC', - tvl: 2_500_000, - apr: 0.05 - }) - - expect(selectPreferredVault([smaller, larger])).toBe(larger) - }) -}) diff --git a/src/components/pages/portfolio/hooks/portfolioVisibility.test.ts b/src/components/pages/portfolio/hooks/portfolioVisibility.test.ts deleted file mode 100644 index cfeb80708..000000000 --- a/src/components/pages/portfolio/hooks/portfolioVisibility.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { filterVisiblePortfolioHoldings } from './portfolioVisibility' - -function makeVault(address: string, isHidden: boolean) { - return { - chainID: 1, - address, - name: `Vault ${address.slice(-4)}`, - symbol: 'yvTEST', - version: '3.0.0', - type: 'Standard', - kind: 'Single Strategy', - decimals: 18, - token: { - address, - name: 'Vault Token', - symbol: 'yvTEST', - description: '', - decimals: 18 - }, - tvl: { - totalAssets: 0n, - tvl: 0, - price: 0 - }, - apr: { - type: 'oracle', - netAPR: 0, - fees: { - performance: 0, - withdrawal: 0, - management: 0 - }, - extra: { - stakingRewardsAPR: 0, - gammaRewardAPR: 0 - }, - points: { - weekAgo: 0, - monthAgo: 0, - inception: 0 - }, - pricePerShare: { - today: 1, - weekAgo: 1, - monthAgo: 1 - }, - forwardAPR: { - type: 'oracle', - netAPR: 0, - composite: { - boost: 0, - poolAPY: 0, - boostedAPR: 0, - baseAPR: 0, - cvxAPR: 0, - rewardsAPR: 0, - v3OracleCurrentAPR: 0, - v3OracleStratRatioAPR: 0, - keepCRV: 0, - keepVELO: 0, - cvxKeepCRV: 0 - } - } - }, - featuringScore: 0, - strategies: [], - staking: { - address: null, - available: false, - source: '', - rewards: [] - }, - migration: { - available: false, - address: '0x0000000000000000000000000000000000000000', - contract: '0x0000000000000000000000000000000000000000' - }, - info: { - sourceURL: '', - riskLevel: 1, - riskScore: [], - riskScoreComment: '', - uiNotice: '', - isRetired: false, - isBoosted: false, - isHighlighted: false, - isHidden - } - } as any -} - -describe('filterVisiblePortfolioHoldings', () => { - it('hides hidden vaults when the persisted hidden-vault filter is off', () => { - const visible = makeVault('0x1111111111111111111111111111111111111111', false) - const hidden = makeVault('0x2222222222222222222222222222222222222222', true) - - expect(filterVisiblePortfolioHoldings([visible, hidden], false)).toEqual([visible]) - }) - - it('keeps hidden vaults when the persisted hidden-vault filter is on', () => { - const visible = makeVault('0x1111111111111111111111111111111111111111', false) - const hidden = makeVault('0x2222222222222222222222222222222222222222', true) - - expect(filterVisiblePortfolioHoldings([visible, hidden], true)).toEqual([visible, hidden]) - }) -}) diff --git a/src/components/pages/portfolio/index.tsx b/src/components/pages/portfolio/index.tsx index 1c74f4f3f..bf5a797af 100644 --- a/src/components/pages/portfolio/index.tsx +++ b/src/components/pages/portfolio/index.tsx @@ -26,6 +26,7 @@ import { Tooltip } from '@shared/components/Tooltip' import { useNotifications } from '@shared/contexts/useNotifications' import { useWeb3 } from '@shared/contexts/useWeb3' import { useYearn } from '@shared/contexts/useYearn' +import { useChainId, useSwitchChain } from '@shared/hooks/useAppWagmi' import { getVaultKey } from '@shared/hooks/useVaultFilterUtils' import { IconSpinner } from '@shared/icons/IconSpinner' import type { TSortDirection } from '@shared/types' @@ -35,7 +36,6 @@ import { PLAUSIBLE_EVENTS } from '@shared/utils/plausible' import type { CSSProperties, ReactElement } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useSearchParams } from 'react-router' -import { useChainId, useSwitchChain } from 'wagmi' const currencyFormatter = new Intl.NumberFormat('en-US', { style: 'currency', diff --git a/src/components/pages/vaults/[chainID]/[address].tsx b/src/components/pages/vaults/[chainID]/[address].tsx index 2575cb10e..910d11f9f 100644 --- a/src/components/pages/vaults/[chainID]/[address].tsx +++ b/src/components/pages/vaults/[chainID]/[address].tsx @@ -563,15 +563,13 @@ function Index(): ReactElement | null { } | null>(null) const tourSectionsRef = useRef | null>(null) - useEffect(() => { - setWidgetMode((previous) => (widgetActions.includes(previous) ? previous : widgetActions[0])) - }, [widgetActions]) - - useEffect(() => { - if (!widgetActions.includes(mobileDrawerAction)) { - setMobileDrawerAction(widgetActions[0]) - } - }, [mobileDrawerAction, widgetActions]) + // Render-time state adjustment: keep mode valid when available actions change + if (!widgetActions.includes(widgetMode)) { + setWidgetMode(widgetActions[0]) + } + if (!widgetActions.includes(mobileDrawerAction)) { + setMobileDrawerAction(widgetActions[0]) + } useEffect(() => { if (typeof window === 'undefined') return @@ -833,11 +831,10 @@ function Index(): ReactElement | null { enabled: renderableSections.length > 0 && !isProgrammaticScroll }) - useEffect(() => { - if (!renderableSections.some((section) => section.key === activeSection) && renderableSections[0]) { - setActiveSection(renderableSections[0].key) - } - }, [renderableSections, activeSection]) + // Render-time state adjustment: ensure active section is valid + if (!renderableSections.some((section) => section.key === activeSection) && renderableSections[0]) { + setActiveSection(renderableSections[0].key) + } useEffect(() => { if (!pendingSectionKey || !isHeaderCompressed) return diff --git a/src/components/pages/vaults/components/SuggestedVaultCard.test.tsx b/src/components/pages/vaults/components/SuggestedVaultCard.test.tsx deleted file mode 100644 index 41875ae6e..000000000 --- a/src/components/pages/vaults/components/SuggestedVaultCard.test.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import type { TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' -import { useVaultApyData } from '@pages/vaults/hooks/useVaultApyData' -import { renderToStaticMarkup } from 'react-dom/server' -import { MemoryRouter } from 'react-router' -import { describe, expect, it, vi } from 'vitest' - -import { SuggestedVaultCard } from './SuggestedVaultCard' - -vi.mock('@vaults/hooks/useVaultApyData', () => ({ - useVaultApyData: vi.fn() -})) - -const baseVault = { - chainID: 1, - address: '0x0000000000000000000000000000000000000001', - name: 'A Very Long Vault Name That Should Truncate In The Card Header', - category: 'Some Category', - token: { - address: '0x0000000000000000000000000000000000000002', - symbol: 'TKN' - }, - staking: { - source: 'None' - }, - tvl: { - tvl: 1234567 - } -} as unknown as TKongVaultInput - -function renderCard(vault: TKongVaultInput): string { - const originalWindow = globalThis.window - ;(globalThis as unknown as { window: unknown }).window = { - location: { href: 'https://yearn.fi/', hostname: 'yearn.fi' } - } - - try { - return renderToStaticMarkup( - - - - ) - } finally { - ;(globalThis as unknown as { window: unknown }).window = originalWindow - } -} - -describe('SuggestedVaultCard', () => { - it('uses 30D APY and no HIST prefix when est APY is unavailable', () => { - vi.mocked(useVaultApyData).mockReturnValue({ - mode: 'historical', - baseForwardApr: 0, - netApr: 0.1234, - rewardsAprSum: 0, - isBoosted: false, - hasPendleArbRewards: false, - hasKelp: false, - hasKelpNEngenlayer: false, - katanaExtras: undefined, - katanaThirtyDayApr: undefined, - katanaEstApr: undefined - }) - - const html = renderCard(baseVault) - expect(html).toContain('30D APY') - expect(html).not.toContain('Hist.') - }) - - it('uses Est. APY when est APY is available', () => { - vi.mocked(useVaultApyData).mockReturnValue({ - mode: 'spot', - baseForwardApr: 0.1234, - netApr: 0, - rewardsAprSum: 0, - isBoosted: false, - hasPendleArbRewards: false, - hasKelp: false, - hasKelpNEngenlayer: false, - katanaExtras: undefined, - katanaThirtyDayApr: undefined, - katanaEstApr: undefined - }) - - const html = renderCard(baseVault) - expect(html).toContain('Est. APY') - }) - - it('truncates the vault title to a single line', () => { - vi.mocked(useVaultApyData).mockReturnValue({ - mode: 'spot', - baseForwardApr: 0.1, - netApr: 0, - rewardsAprSum: 0, - isBoosted: false, - hasPendleArbRewards: false, - hasKelp: false, - hasKelpNEngenlayer: false, - katanaExtras: undefined, - katanaThirtyDayApr: undefined, - katanaEstApr: undefined - }) - - const html = renderCard(baseVault) - expect(html).toContain('class="truncate') - }) -}) diff --git a/src/components/pages/vaults/components/compare/SwipeableCompareCarousel.tsx b/src/components/pages/vaults/components/compare/SwipeableCompareCarousel.tsx index 352a7b8f0..c9a8a55fe 100644 --- a/src/components/pages/vaults/components/compare/SwipeableCompareCarousel.tsx +++ b/src/components/pages/vaults/components/compare/SwipeableCompareCarousel.tsx @@ -4,7 +4,7 @@ import { getVaultKey } from '@shared/hooks/useVaultFilterUtils' import { IconChevron } from '@shared/icons/IconChevron' import { cl } from '@shared/utils' import { motion, type PanInfo, useAnimation } from 'framer-motion' -import { type ReactElement, useCallback, useEffect, useState } from 'react' +import { type ReactElement, useCallback, useState } from 'react' type TSwipeableCompareCarouselProps = { vaults: TKongVaultInput[] @@ -20,11 +20,10 @@ export function SwipeableCompareCarousel({ vaults, onRemove }: TSwipeableCompare const maxIndex = vaults.length - 1 - useEffect(() => { - if (currentIndex > maxIndex) { - setCurrentIndex(Math.max(0, maxIndex)) - } - }, [currentIndex, maxIndex]) + // Render-time state adjustment: clamp index when vaults are removed + if (currentIndex > maxIndex) { + setCurrentIndex(Math.max(0, maxIndex)) + } const goToIndex = useCallback( (index: number) => { diff --git a/src/components/pages/vaults/components/detail/QuickStatsGrid.test.tsx b/src/components/pages/vaults/components/detail/QuickStatsGrid.test.tsx deleted file mode 100644 index 48e4920a1..000000000 --- a/src/components/pages/vaults/components/detail/QuickStatsGrid.test.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import type { ReactNode } from 'react' -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' -import { MobileKeyMetrics, YvUsdApyStatBox } from './QuickStatsGrid' - -vi.mock('@shared/contexts/useWeb3', () => ({ - useWeb3: () => ({ - address: undefined, - isActive: false - }) -})) - -vi.mock('@pages/vaults/components/table/VaultForwardAPY', () => ({ - VaultForwardAPY: () =>
{'Default APY'}
-})) - -vi.mock('@pages/vaults/components/table/APYDetailsModal', () => ({ - APYDetailsModal: ({ isOpen, title, children }: { isOpen: boolean; title: string; children: ReactNode }) => - isOpen ? ( -
-

{title}

- {children} -
- ) : null -})) - -const TEST_VAULT = { - version: '3.0.0', - chainID: 1, - address: '0x0000000000000000000000000000000000000001', - name: 'Test Vault', - token: { - address: '0x0000000000000000000000000000000000000002', - symbol: 'TKN', - decimals: 6 - }, - tvl: { - tvl: 1234 - } -} - -describe('MobileKeyMetrics', () => { - it('renders a custom APY box override when provided', () => { - const html = renderToStaticMarkup( - {'Custom APY'}} - /> - ) - - expect(html).toContain('Custom APY') - expect(html).not.toContain('Default APY') - }) -}) - -describe('YvUsdApyStatBox', () => { - it('renders the locked variant by default with the unlocked toggle affordance', () => { - const html = renderToStaticMarkup() - - expect(html).toContain('Locked') - expect(html).toContain('9.00%') - expect(html).toContain('Switch to unlocked APY display') - expect(html).not.toContain('data-testid="apy-modal"') - }) - - it('formats large APY values with the shared significant-digit rules', () => { - const html = renderToStaticMarkup() - - expect(html).toContain('118%') - expect(html).not.toContain('117.77%') - }) - - it('renders the controlled variant when provided', () => { - const html = renderToStaticMarkup( - - ) - - expect(html).toContain('Unlocked') - expect(html).toContain('5.00%') - expect(html).toContain('Switch to locked APY display') - }) - - it('shows the Infinifi points icon whenever yvUSD has points', () => { - const lockedHtml = renderToStaticMarkup( - - ) - const unlockedHtml = renderToStaticMarkup( - - ) - - expect(lockedHtml).toContain('aria-label="Infinifi points"') - expect(unlockedHtml).toContain('aria-label="Infinifi points"') - }) -}) diff --git a/src/components/pages/vaults/components/detail/VaultInfoSection.test.ts b/src/components/pages/vaults/components/detail/VaultInfoSection.test.ts deleted file mode 100644 index ef2e9d200..000000000 --- a/src/components/pages/vaults/components/detail/VaultInfoSection.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { extractCurvePools, resolveCurveDepositUrl } from './VaultInfoSection' - -const TOKEN_ADDRESS = '0x1111111111111111111111111111111111111111' -const POOL_ADDRESS = '0x2222222222222222222222222222222222222222' - -describe('extractCurvePools', () => { - it('extracts pools from canonical data.poolData shape', () => { - const pools = extractCurvePools({ - data: { - poolData: [{ lpTokenAddress: TOKEN_ADDRESS, poolUrls: { deposit: ['https://curve.fi/deposit'] } }] - } - }) - - expect(pools).toHaveLength(1) - }) - - it('returns empty list for root-level array payloads', () => { - const pools = extractCurvePools([ - { lpTokenAddress: TOKEN_ADDRESS, poolUrls: { deposit: ['https://curve.fi/deposit'] } } - ]) - - expect(pools).toEqual([]) - }) -}) - -describe('resolveCurveDepositUrl', () => { - it('normalizes the deposit URL when lpTokenAddress matches', () => { - const pools = extractCurvePools({ - data: { - poolData: [{ lpTokenAddress: TOKEN_ADDRESS, poolUrls: { deposit: ['https://curve.fi/lp-match'] } }] - } - }) - - expect(resolveCurveDepositUrl(pools, TOKEN_ADDRESS)).toBe('https://www.curve.finance/lp-match') - }) - - it('returns deposit URL when pool address matches', () => { - const pools = extractCurvePools({ - data: { - poolData: [{ address: POOL_ADDRESS, poolUrls: { deposit: ['https://www.curve.finance/address-match'] } }] - } - }) - - expect(resolveCurveDepositUrl(pools, POOL_ADDRESS)).toBe('https://www.curve.finance/address-match') - }) - - it('ignores legacy key variants and returns empty string', () => { - const pools = extractCurvePools({ - data: { - poolData: [{ lp_token_address: TOKEN_ADDRESS, poolURLs: { deposit: ['https://curve.fi/legacy'] } }] - } - }) - - expect(resolveCurveDepositUrl(pools, TOKEN_ADDRESS)).toBe('') - }) -}) diff --git a/src/components/pages/vaults/components/detail/VaultInfoSection.tsx b/src/components/pages/vaults/components/detail/VaultInfoSection.tsx index a91e7a8dc..4c2f9cef8 100644 --- a/src/components/pages/vaults/components/detail/VaultInfoSection.tsx +++ b/src/components/pages/vaults/components/detail/VaultInfoSection.tsx @@ -6,9 +6,11 @@ import { getVaultInfo, getVaultStaking, getVaultToken, + getVaultVersion, isAutomatedVault, type TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' +import { YBOLD_VAULT_ADDRESS } from '@pages/vaults/domain/normalizeVault' import { useYvUsdVaults } from '@pages/vaults/hooks/useYvUsdVaults' import { KONG_REST_BASE } from '@pages/vaults/utils/kongRest' import { isYvUsdAddress, YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' @@ -35,8 +37,72 @@ type TCurvePoolsApiResponse = { const CURVE_POOLS_CACHE_TTL_MS = 30 * 60 * 1000 const CURVE_POOLS_CACHE_GC_MS = 60 * 60 * 1000 const CURVE_POOLS_ENDPOINT = 'https://api.curve.finance/v1/getPools/all' +const YVUSD_API_URL = 'https://yvusd-api.yearn.fi/api/aprs' +const USER_DOCS_V2_URL = 'https://docs.yearn.fi/getting-started/products/yvaults/v2' +const USER_DOCS_V3_URL = 'https://docs.yearn.fi/getting-started/products/yvaults/v3' +const DEVELOPER_DOCS_V2_URL = 'https://docs.yearn.fi/developers/building-on-yearn' +const DEVELOPER_DOCS_V3_URL = 'https://docs.yearn.fi/developers/v3/overview' +const USER_DOCS_YVUSD_URL = 'https://docs.yearn.fi/getting-started/products/yvaults/yvusd' +const DEVELOPER_DOCS_YVUSD_URL = 'https://docs.yearn.fi/developers/yvusd/' +const USER_DOCS_YBOLD_URL = 'https://docs.yearn.fi/getting-started/products/yvaults/yBold' +const DEVELOPER_DOCS_YBOLD_URL = DEVELOPER_DOCS_V3_URL +const USER_DOCS_YCRV_URL = 'https://docs.yearn.fi/getting-started/products/ylockers/ycrv/overview' +const DEVELOPER_DOCS_YCRV_URL = DEVELOPER_DOCS_V2_URL +const USER_DOCS_YYB_URL = 'https://docs.yearn.fi/getting-started/products/ylockers/yyb/overview' +const DEVELOPER_DOCS_YYB_URL = DEVELOPER_DOCS_V2_URL const INFO_LABEL_CLASS = 'w-full text-sm text-text-secondary md:w-auto md:pr-4' +export function getVaultDocsLinks( + vaultAddress: `0x${string}`, + tokenSymbol: string, + version: string +): { + userDocumentationUrl: string + developerDocumentationUrl: string +} { + const normalizedSymbol = tokenSymbol.toLowerCase() + + if (isYvUsdAddress(vaultAddress)) { + return { + developerDocumentationUrl: DEVELOPER_DOCS_YVUSD_URL, + userDocumentationUrl: USER_DOCS_YVUSD_URL + } + } + + if (vaultAddress === YBOLD_VAULT_ADDRESS) { + return { + developerDocumentationUrl: DEVELOPER_DOCS_YBOLD_URL, + userDocumentationUrl: USER_DOCS_YBOLD_URL + } + } + + if (normalizedSymbol === 'ycrv') { + return { + developerDocumentationUrl: DEVELOPER_DOCS_YCRV_URL, + userDocumentationUrl: USER_DOCS_YCRV_URL + } + } + + if (normalizedSymbol === 'yyb') { + return { + developerDocumentationUrl: DEVELOPER_DOCS_YYB_URL, + userDocumentationUrl: USER_DOCS_YYB_URL + } + } + + if (version.startsWith('3') || version.startsWith('~3')) { + return { + developerDocumentationUrl: DEVELOPER_DOCS_V3_URL, + userDocumentationUrl: USER_DOCS_V3_URL + } + } + + return { + developerDocumentationUrl: DEVELOPER_DOCS_V2_URL, + userDocumentationUrl: USER_DOCS_V2_URL + } +} + export function extractCurvePools(payload: unknown): TCurvePoolEntry[] { const poolData = (payload as TCurvePoolsApiResponse | null)?.data?.poolData return Array.isArray(poolData) ? (poolData as TCurvePoolEntry[]) : [] @@ -203,6 +269,12 @@ export function VaultInfoSection({ const liquidityUrl = getLiquidityUrl({ isVelodrome, isAerodrome, tokenAddress: token.address }) const powergloveUrl = `https://powerglove.yearn.fi/vaults/${chainID}/${vaultAddress}` const deployedLabel = getDeployedLabel(inceptTime) + const vaultVersion = getVaultVersion(currentVault) + const { developerDocumentationUrl, userDocumentationUrl } = getVaultDocsLinks( + vaultAddress, + token.symbol, + vaultVersion + ) return (
@@ -308,6 +380,42 @@ export function VaultInfoSection({
) : null} +
+ +
+

{'Developer Documentation'}

+ +
+

{'Powerglove Analytics Page'}

@@ -326,6 +434,26 @@ export function VaultInfoSection({
+ {isYvUsd ? ( +
+

{'yvUSD API'}

+ +
+ ) : null} +

{'Vault Snapshot Data'}

diff --git a/src/components/pages/vaults/components/detail/VaultsListHead.test.ts b/src/components/pages/vaults/components/detail/VaultsListHead.test.ts deleted file mode 100644 index 9ee31f604..000000000 --- a/src/components/pages/vaults/components/detail/VaultsListHead.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { STRATEGY_PANEL_HEAD_DESKTOP_LAYOUT } from './strategiesLayout' - -describe('VaultsListHead desktop layout', () => { - it('uses tightened strategy panel desktop column classes', () => { - expect(STRATEGY_PANEL_HEAD_DESKTOP_LAYOUT.nameColumnSpanClass).toBe('col-span-11') - expect(STRATEGY_PANEL_HEAD_DESKTOP_LAYOUT.valuesColumnSpanClass).toBe('col-span-12') - expect(STRATEGY_PANEL_HEAD_DESKTOP_LAYOUT.valuesGridClass).toBe('md:grid-cols-12 md:gap-2') - expect(STRATEGY_PANEL_HEAD_DESKTOP_LAYOUT.valueColumnSpanClass).toBe('md:col-span-4') - }) -}) diff --git a/src/components/pages/vaults/components/detail/VaultsListStrategy.test.ts b/src/components/pages/vaults/components/detail/VaultsListStrategy.test.ts deleted file mode 100644 index fc9a6c07f..000000000 --- a/src/components/pages/vaults/components/detail/VaultsListStrategy.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { STRATEGY_PANEL_ROW_DESKTOP_LAYOUT } from './strategiesLayout' - -describe('VaultsListStrategy desktop layout', () => { - it('uses tightened strategy row desktop column classes with balanced desktop title wrapping', () => { - expect(STRATEGY_PANEL_ROW_DESKTOP_LAYOUT.nameColumnSpanClass).toBe('md:col-span-11') - expect(STRATEGY_PANEL_ROW_DESKTOP_LAYOUT.valuesColumnSpanClass).toBe('md:col-span-12') - expect(STRATEGY_PANEL_ROW_DESKTOP_LAYOUT.valuesGridClass).toBe('md:grid-cols-12 md:gap-2') - expect(STRATEGY_PANEL_ROW_DESKTOP_LAYOUT.valueColumnSpanClass).toBe('md:col-span-4') - expect(STRATEGY_PANEL_ROW_DESKTOP_LAYOUT.nameLabelDesktopWrapClass).toBe( - 'md:[display:-webkit-box] md:[-webkit-box-orient:vertical] md:[-webkit-line-clamp:2] md:[text-wrap:balance] md:whitespace-normal' - ) - }) -}) diff --git a/src/components/pages/vaults/components/detail/desktopWidgetSizing.test.ts b/src/components/pages/vaults/components/detail/desktopWidgetSizing.test.ts deleted file mode 100644 index 7d86483cc..000000000 --- a/src/components/pages/vaults/components/detail/desktopWidgetSizing.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - DESKTOP_WIDGET_OFFSET_CSS_VAR, - getDesktopWidgetHeightClassNames, - resolveDesktopWidgetHeaderOffset -} from './desktopWidgetSizing' - -describe('resolveDesktopWidgetHeaderOffset', () => { - it('rounds the combined base offset, measured header height, and widget padding', () => { - expect(resolveDesktopWidgetHeaderOffset({ baseOffset: 72.2, headerHeight: 180.6 })).toBe(269) - }) - - it('returns null until a positive header height is available', () => { - expect(resolveDesktopWidgetHeaderOffset({ baseOffset: 72, headerHeight: 0 })).toBeNull() - }) -}) - -describe('getDesktopWidgetHeightClassNames', () => { - it('uses the compressed header offset by default for both container and stack sizing', () => { - expect(getDesktopWidgetHeightClassNames()).toEqual({ - container: `md:h-[calc(100vh-var(${DESKTOP_WIDGET_OFFSET_CSS_VAR})-16px)] max-h-[calc(100vh-var(${DESKTOP_WIDGET_OFFSET_CSS_VAR})-16px)]`, - stack: `max-h-[calc(100vh-16px-var(${DESKTOP_WIDGET_OFFSET_CSS_VAR}))]` - }) - }) - - it('supports an alternate offset variable when the layout needs a different measurement source', () => { - expect(getDesktopWidgetHeightClassNames('--vault-header-expanded-offset')).toEqual({ - container: - 'md:h-[calc(100vh-var(--vault-header-expanded-offset)-16px)] max-h-[calc(100vh-var(--vault-header-expanded-offset)-16px)]', - stack: 'max-h-[calc(100vh-16px-var(--vault-header-expanded-offset))]' - }) - }) -}) diff --git a/src/components/pages/vaults/components/filters/VaultVersionToggle.test.tsx b/src/components/pages/vaults/components/filters/VaultVersionToggle.test.tsx deleted file mode 100644 index ca3068ed5..000000000 --- a/src/components/pages/vaults/components/filters/VaultVersionToggle.test.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import { MemoryRouter } from 'react-router' -import { describe, expect, it } from 'vitest' - -import { VaultVersionToggle } from './VaultVersionToggle' - -function renderToggle(entry: string): string { - return renderToStaticMarkup( - - - - ) -} - -describe('VaultVersionToggle', () => { - it('marks all vaults active when no type param', () => { - const html = renderToggle('/vaults') - expect(html).toMatch(/data-active="true".*All Vaults/) - expect(html).toMatch(/data-active="false".*Single Asset/) - expect(html).toMatch(/data-active="false".*LP Token/) - expect(html).not.toMatch(/v3 Strategies/) - }) - - it('marks lp token vaults active when type=lp', () => { - const html = renderToggle('/vaults?type=lp') - expect(html).toMatch(/data-active="true".*LP Token/) - expect(html).toMatch(/data-active="false".*Single Asset/) - expect(html).toMatch(/data-active="false".*All Vaults/) - }) - - it('marks single asset vaults active when type=single', () => { - const html = renderToggle('/vaults?type=single') - expect(html).toMatch(/data-active="false".*All Vaults/) - expect(html).toMatch(/data-active="true".*Single Asset/) - expect(html).toMatch(/data-active="false".*LP Token/) - }) -}) diff --git a/src/components/pages/vaults/components/list/VaultsListEmpty.test.tsx b/src/components/pages/vaults/components/list/VaultsListEmpty.test.tsx deleted file mode 100644 index 2c72effc4..000000000 --- a/src/components/pages/vaults/components/list/VaultsListEmpty.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type { ComponentProps } from 'react' -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' - -import { VaultsListEmpty } from './VaultsListEmpty' - -function renderEmptyState(overrides: Partial> = {}): string { - return renderToStaticMarkup() -} - -describe('VaultsListEmpty', () => { - it('renders blocking filter toggles and a search action when results are recoverable', () => { - const html = renderEmptyState({ - currentSearch: 'USDC', - hiddenByFiltersCount: 3, - blockingFilterActions: [ - { key: 'showLegacyVaults', label: 'Show legacy vaults', additionalResults: 2, onApply: vi.fn() }, - { key: 'showStrategies', label: 'Show single asset strategies', additionalResults: 1, onApply: vi.fn() } - ] - }) - - expect(html).toContain('No results for "USDC" with current filters.') - expect(html).toContain('3 vaults found that are hidden by filters. Enable them below.') - expect(html).toContain('Show legacy vaults (+2)') - expect(html).toContain('Show single asset strategies (+1)') - expect(html).toContain('Search') - expect(html).not.toContain('Show all results') - }) - - it('renders vault-not-found copy when no recoverable results exist', () => { - const html = renderEmptyState({ - currentSearch: 'NOT_A_VAULT', - blockingFilterActions: [] - }) - - expect(html).toContain('The vault "NOT_A_VAULT" does not exist') - expect(html).not.toContain('Show all results') - }) - - it('renders combination filter actions without zero-result suffixes', () => { - const html = renderEmptyState({ - currentSearch: 'ETH', - hiddenByFiltersCount: 2, - blockingFilterActions: [ - { key: 'showAllChains', label: 'Show all chains', additionalResults: 0, onApply: vi.fn() }, - { key: 'showAllCategories', label: 'Show all categories', additionalResults: 0, onApply: vi.fn() } - ] - }) - - expect(html).toContain('Show all chains') - expect(html).toContain('Show all categories') - expect(html).not.toContain('Show all chains (+0)') - expect(html).not.toContain('Show all categories (+0)') - }) -}) diff --git a/src/components/pages/vaults/components/list/VaultsListEmpty.tsx b/src/components/pages/vaults/components/list/VaultsListEmpty.tsx index 6ef915873..6724b449e 100644 --- a/src/components/pages/vaults/components/list/VaultsListEmpty.tsx +++ b/src/components/pages/vaults/components/list/VaultsListEmpty.tsx @@ -3,7 +3,7 @@ import type { TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' import { Button } from '@shared/components/Button' import { EmptyState } from '@shared/components/EmptyState' import { cl } from '@shared/utils' -import { type ReactElement, useCallback, useEffect, useMemo, useState } from 'react' +import { type ReactElement, useCallback, useMemo, useState } from 'react' type TVaultsBlockingFilterAction = { key: string @@ -46,9 +46,10 @@ export function VaultsListEmpty({ ) const selectedBlockingFilterKeys = useMemo(() => new Set(selectedBlockingFilters), [selectedBlockingFilters]) - useEffect(() => { + // Render-time state adjustment: prune selections when available options change + if (selectedBlockingFilters.some((key) => !availableBlockingFilterKeys.has(key))) { setSelectedBlockingFilters((prev) => prev.filter((key) => availableBlockingFilterKeys.has(key))) - }, [availableBlockingFilterKeys]) + } const toggleBlockingFilter = useCallback((key: string): void => { setSelectedBlockingFilters((prev) => (prev.includes(key) ? prev.filter((entry) => entry !== key) : [...prev, key])) diff --git a/src/components/pages/vaults/components/list/VaultsListRow.test.tsx b/src/components/pages/vaults/components/list/VaultsListRow.test.tsx deleted file mode 100644 index f215458d8..000000000 --- a/src/components/pages/vaults/components/list/VaultsListRow.test.tsx +++ /dev/null @@ -1,385 +0,0 @@ -import type { TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' -import { YVUSD_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { renderToStaticMarkup } from 'react-dom/server' -import { MemoryRouter } from 'react-router' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { VaultsListRow } from './VaultsListRow' - -const { mockUseMediaQuery, mockUseYvUsdVaults } = vi.hoisted(() => ({ - mockUseMediaQuery: vi.fn(() => false), - mockUseYvUsdVaults: vi.fn((): any => ({ - metrics: undefined, - unlockedVault: undefined, - lockedVault: undefined - })) -})) - -vi.mock('@react-hookz/web', () => ({ - useMediaQuery: mockUseMediaQuery -})) - -vi.mock('@shared/contexts/useWallet', () => ({ - useWallet: () => ({ - getBalance: () => ({ raw: 0n, normalized: 0 }), - getToken: () => ({ value: 0 }) - }) -})) - -vi.mock('@shared/contexts/useWeb3', () => ({ - useWeb3: () => ({ - address: undefined - }) -})) - -vi.mock('@hooks/usePlausible', () => ({ - usePlausible: () => vi.fn() -})) - -vi.mock('@pages/vaults/hooks/useYvUsdVaults', () => ({ - useYvUsdVaults: mockUseYvUsdVaults -})) - -vi.mock('@pages/vaults/components/table/VaultForwardAPY', () => ({ - VaultForwardAPY: () =>
{'APY'}
, - VaultForwardAPYInlineDetails: () =>
{'APY details'}
-})) - -vi.mock('@pages/vaults/components/table/VaultHistoricalAPY', () => ({ - VaultHistoricalAPY: () =>
{'Historical APY'}
-})) - -vi.mock('@pages/vaults/components/table/VaultHoldingsAmount', () => ({ - VaultHoldingsAmount: () =>
{'Holdings'}
-})) - -vi.mock('@pages/vaults/components/table/VaultRiskScoreTag', () => ({ - VaultRiskScoreTag: () =>
{'Risk'}
, - RiskScoreInlineDetails: () =>
{'Risk details'}
-})) - -function renderRowHtml(vault: TKongVaultInput): string { - const queryClient = new QueryClient() - - return renderToStaticMarkup( - - - - - - ) -} - -describe('VaultsListRow', () => { - beforeEach(() => { - mockUseMediaQuery.mockReturnValue(false) - mockUseYvUsdVaults.mockReturnValue({ - metrics: undefined, - unlockedVault: undefined, - lockedVault: undefined - }) - }) - - it('renders the desktop TVL tooltip trigger for standard vault rows', () => { - const vault = { - version: '3.0.0', - chainID: 1, - address: '0x0000000000000000000000000000000000000001', - name: 'Test Vault', - category: 'Test Category', - kind: 'Multi Strategy', - token: { - address: '0x0000000000000000000000000000000000000002', - symbol: 'TKN', - decimals: 6 - }, - tvl: { - tvl: 1234, - totalAssets: 1234567 - }, - info: { - riskLevel: 3 - } - } as unknown as TKongVaultInput - - const html = renderRowHtml(vault) - - expect(html).toContain('tvl-subline-tooltip') - }) - - it('stacks the yvUSD mobile up-to label above the APY value', () => { - mockUseMediaQuery.mockReturnValue(true) - mockUseYvUsdVaults.mockReturnValue({ - metrics: { - unlocked: { apy: 0.05, tvl: 100, hasInfinifiPoints: false }, - locked: { apy: 0.09, tvl: 250, hasInfinifiPoints: false } - }, - unlockedVault: undefined, - lockedVault: undefined - }) - - const vault = { - version: '3.0.0', - chainID: 1, - address: YVUSD_UNLOCKED_ADDRESS, - name: 'yvUSD', - symbol: 'yvUSD', - category: 'Stablecoin', - kind: 'Multi Strategy', - token: { - address: '0x0000000000000000000000000000000000000002', - symbol: 'USDC', - decimals: 6 - }, - apr: { - forwardAPR: { - netAPR: 0.05 - }, - netAPR: 0.05 - }, - tvl: { - tvl: 350, - totalAssets: 350_000_000 - }, - info: { - riskLevel: 2 - }, - staking: { - address: '0x0000000000000000000000000000000000000000' - } - } as unknown as TKongVaultInput - - const html = renderRowHtml(vault) - - expect(html).toContain('Up to') - expect(html).toContain('9.00%') - expect(html).toContain('inline-flex flex-col items-start') - }) - - it('formats yvUSD locked APY with shared significant-digit rounding in the list row', () => { - mockUseMediaQuery.mockReturnValue(true) - mockUseYvUsdVaults.mockReturnValue({ - metrics: { - unlocked: { apy: 0.05, tvl: 100, hasInfinifiPoints: false }, - locked: { apy: 1.1777, tvl: 250, hasInfinifiPoints: false } - }, - unlockedVault: undefined, - lockedVault: undefined - }) - - const vault = { - version: '3.0.0', - chainID: 1, - address: YVUSD_UNLOCKED_ADDRESS, - name: 'yvUSD', - symbol: 'yvUSD', - category: 'Stablecoin', - kind: 'Multi Strategy', - token: { - address: '0x0000000000000000000000000000000000000002', - symbol: 'USDC', - decimals: 6 - }, - apr: { - forwardAPR: { - netAPR: 0.05 - }, - netAPR: 0.05 - }, - tvl: { - tvl: 350, - totalAssets: 350_000_000 - }, - info: { - riskLevel: 2 - }, - staking: { - address: '0x0000000000000000000000000000000000000000' - } - } as unknown as TKongVaultInput - - const html = renderRowHtml(vault) - - expect(html).toContain('118%') - expect(html).not.toContain('117.77%') - expect(html).toContain('flex items-center justify-center gap-2 whitespace-nowrap') - }) - - it('positions the desktop yvUSD up-to label above the APY value without changing row flow', () => { - mockUseMediaQuery.mockReturnValue(false) - mockUseYvUsdVaults.mockReturnValue({ - metrics: { - unlocked: { apy: 0.05, tvl: 100, hasInfinifiPoints: false }, - locked: { apy: 0.09, tvl: 250, hasInfinifiPoints: false } - }, - unlockedVault: undefined, - lockedVault: undefined - }) - - const vault = { - version: '3.0.0', - chainID: 1, - address: YVUSD_UNLOCKED_ADDRESS, - name: 'yvUSD', - symbol: 'yvUSD', - category: 'Stablecoin', - kind: 'Multi Strategy', - token: { - address: '0x0000000000000000000000000000000000000002', - symbol: 'USDC', - decimals: 6 - }, - apr: { - forwardAPR: { - netAPR: 0.05 - }, - netAPR: 0.05 - }, - tvl: { - tvl: 350, - totalAssets: 350_000_000 - }, - info: { - riskLevel: 2 - }, - staking: { - address: '0x0000000000000000000000000000000000000000' - } - } as unknown as TKongVaultInput - - const html = renderRowHtml(vault) - - expect(html).toContain('inline-flex items-center gap-2 text-right') - expect(html).toContain('relative inline-flex') - expect(html).toContain('absolute bottom-full left-0 mb-0.5') - }) - - it('shows the Infinifi points icon for yvUSD when either variant has points', () => { - mockUseYvUsdVaults.mockReturnValue({ - metrics: { - unlocked: { apy: 0.05, tvl: 100, hasInfinifiPoints: false }, - locked: { apy: 0.09, tvl: 250, hasInfinifiPoints: true } - }, - unlockedVault: undefined, - lockedVault: undefined - }) - - const vault = { - version: '3.0.0', - chainID: 1, - address: YVUSD_UNLOCKED_ADDRESS, - name: 'yvUSD', - symbol: 'yvUSD', - category: 'Stablecoin', - kind: 'Multi Strategy', - token: { - address: '0x0000000000000000000000000000000000000002', - symbol: 'USDC', - decimals: 6 - }, - apr: { - forwardAPR: { - netAPR: 0.05 - }, - netAPR: 0.05 - }, - tvl: { - tvl: 350, - totalAssets: 350_000_000 - }, - info: { - riskLevel: 2 - }, - staking: { - address: '0x0000000000000000000000000000000000000000' - } - } as unknown as TKongVaultInput - - const html = renderRowHtml(vault) - - expect(html).toContain('aria-label="Infinifi points"') - }) - - it('does not show the Infinifi points icon for yvUSD without points', () => { - mockUseYvUsdVaults.mockReturnValue({ - metrics: { - unlocked: { apy: 0.05, tvl: 100, hasInfinifiPoints: false }, - locked: { apy: 0.09, tvl: 250, hasInfinifiPoints: false } - }, - unlockedVault: undefined, - lockedVault: undefined - }) - - const vault = { - version: '3.0.0', - chainID: 1, - address: YVUSD_UNLOCKED_ADDRESS, - name: 'yvUSD', - symbol: 'yvUSD', - category: 'Stablecoin', - kind: 'Multi Strategy', - token: { - address: '0x0000000000000000000000000000000000000002', - symbol: 'USDC', - decimals: 6 - }, - apr: { - forwardAPR: { - netAPR: 0.05 - }, - netAPR: 0.05 - }, - tvl: { - tvl: 350, - totalAssets: 350_000_000 - }, - info: { - riskLevel: 2 - }, - staking: { - address: '0x0000000000000000000000000000000000000000' - } - } as unknown as TKongVaultInput - - const html = renderRowHtml(vault) - - expect(html).not.toContain('aria-label="Infinifi points"') - }) - - it('does not show the Infinifi points icon for non-yvUSD rows', () => { - mockUseYvUsdVaults.mockReturnValue({ - metrics: { - unlocked: { apy: 0.05, tvl: 100, hasInfinifiPoints: true }, - locked: { apy: 0.09, tvl: 250, hasInfinifiPoints: true } - }, - unlockedVault: undefined, - lockedVault: undefined - }) - - const vault = { - version: '3.0.0', - chainID: 1, - address: '0x0000000000000000000000000000000000000001', - name: 'Test Vault', - category: 'Test Category', - kind: 'Multi Strategy', - token: { - address: '0x0000000000000000000000000000000000000002', - symbol: 'TKN', - decimals: 6 - }, - tvl: { - tvl: 1234, - totalAssets: 1234567 - }, - info: { - riskLevel: 3 - } - } as unknown as TKongVaultInput - - const html = renderRowHtml(vault) - - expect(html).not.toContain('aria-label="Infinifi points"') - }) -}) diff --git a/src/components/pages/vaults/components/notifications/Notification.tsx b/src/components/pages/vaults/components/notifications/Notification.tsx index 0d59d2cc0..efdc4968b 100644 --- a/src/components/pages/vaults/components/notifications/Notification.tsx +++ b/src/components/pages/vaults/components/notifications/Notification.tsx @@ -8,6 +8,7 @@ import { IconCross } from '@shared/icons/IconCross' import { IconLoader } from '@shared/icons/IconLoader' import type { TNotification, TNotificationStatus } from '@shared/types/notifications' import { cl, SUPPORTED_NETWORKS, truncateHex } from '@shared/utils' +import { getNetwork } from '@shared/utils/wagmi' import type { ReactElement } from 'react' import { memo, useCallback, useMemo, useState } from 'react' import Link from '/src/components/Link' @@ -549,10 +550,9 @@ export const Notification = memo(function Notification({ return null } - const chain = SUPPORTED_NETWORKS.find((network) => network.id === notification.chainId) - const explorerBaseURI = chain?.blockExplorers?.default?.url || 'https://etherscan.io' - return `${explorerBaseURI}/tx/${notification.txHash}` - }, [notification.chainId, notification.txHash]) + const explorerBaseURI = getNetwork(notification.executionChainId ?? notification.chainId).defaultBlockExplorer + return explorerBaseURI ? `${explorerBaseURI}/tx/${notification.txHash}` : null + }, [notification.chainId, notification.executionChainId, notification.txHash]) const notificationTitle = useMemo(() => { switch (notification.type) { diff --git a/src/components/pages/vaults/components/table/APYSubline.test.ts b/src/components/pages/vaults/components/table/APYSubline.test.ts deleted file mode 100644 index cab527cd5..000000000 --- a/src/components/pages/vaults/components/table/APYSubline.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getApySublineLines } from './APYSubline' - -describe('getApySublineLines', () => { - it('returns Kelp + EigenLayer lines when both rewards apply', () => { - expect( - getApySublineLines({ - hasPendleArbRewards: false, - hasKelpNEngenlayer: true, - hasKelp: false, - isEligibleForSteer: false, - steerPointsPerDollar: 0, - isEligibleForSpectraBoost: false - }) - ).toEqual(['+1x Kelp Miles', '+1x EigenLayer Points']) - }) - - it('returns single Kelp line', () => { - expect( - getApySublineLines({ - hasPendleArbRewards: false, - hasKelpNEngenlayer: false, - hasKelp: true, - isEligibleForSteer: false, - steerPointsPerDollar: 0, - isEligibleForSpectraBoost: false - }) - ).toEqual(['+ 1x Kelp Miles']) - }) - - it('returns ARB line', () => { - expect( - getApySublineLines({ - hasPendleArbRewards: true, - hasKelpNEngenlayer: false, - hasKelp: false, - isEligibleForSteer: false, - steerPointsPerDollar: 0, - isEligibleForSpectraBoost: false - }) - ).toEqual(['+ 2500 ARB/week']) - }) - - it('returns eligible for extra rewards when Spectra/Steer applies', () => { - expect( - getApySublineLines({ - hasPendleArbRewards: false, - hasKelpNEngenlayer: false, - hasKelp: false, - isEligibleForSteer: true, - steerPointsPerDollar: 2, - isEligibleForSpectraBoost: false - }) - ).toEqual(['Eligible for Extra Rewards']) - }) - - it('returns empty list when no subline data applies', () => { - expect( - getApySublineLines({ - hasPendleArbRewards: false, - hasKelpNEngenlayer: false, - hasKelp: false, - isEligibleForSteer: false, - steerPointsPerDollar: 0, - isEligibleForSpectraBoost: false - }) - ).toEqual([]) - }) -}) diff --git a/src/components/pages/vaults/components/table/VaultForwardAPY.test.tsx b/src/components/pages/vaults/components/table/VaultForwardAPY.test.tsx deleted file mode 100644 index 251483457..000000000 --- a/src/components/pages/vaults/components/table/VaultForwardAPY.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -// @vitest-environment jsdom - -import type { TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' -import { useVaultApyData } from '@pages/vaults/hooks/useVaultApyData' -import { fireEvent, render } from '@testing-library/react' -import { act } from 'react' -import { describe, expect, it, vi } from 'vitest' - -import { VaultForwardAPY } from './VaultForwardAPY' - -vi.mock('@vaults/hooks/useVaultApyData', () => ({ - useVaultApyData: vi.fn() -})) - -const vault = { - chainID: 1, - address: '0x0000000000000000000000000000000000000001', - apr: { forwardAPR: { type: '' }, type: '' }, - staking: { source: 'None' } -} as unknown as TKongVaultInput - -describe('VaultForwardAPY', () => { - it('shows subline tooltip when hovering the value', () => { - vi.useFakeTimers() - vi.mocked(useVaultApyData).mockReturnValue({ - mode: 'spot', - baseForwardApr: 0.1, - netApr: 0, - rewardsAprSum: 0, - isBoosted: false, - hasPendleArbRewards: true, - hasKelp: false, - hasKelpNEngenlayer: false, - isEligibleForSteer: false, - steerPointsPerDollar: 0, - katanaExtras: undefined, - katanaThirtyDayApr: undefined, - katanaEstApr: undefined - }) - - const { container, queryByText } = render( - - ) - const trigger = container.querySelector('.apy-subline-tooltip') - - expect(trigger).not.toBeNull() - expect(queryByText('+ 2500 ARB/week')).toBeNull() - - fireEvent.mouseEnter(trigger as Element) - act(() => { - vi.advanceTimersByTime(150) - }) - - expect(queryByText('+ 2500 ARB/week')).not.toBeNull() - vi.useRealTimers() - }) -}) diff --git a/src/components/pages/vaults/components/table/apyDisplayConfig.test.tsx b/src/components/pages/vaults/components/table/apyDisplayConfig.test.tsx deleted file mode 100644 index d9488ab10..000000000 --- a/src/components/pages/vaults/components/table/apyDisplayConfig.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { KATANA_CHAIN_ID, SPECTRA_MARKET_VAULT_ADDRESSES } from '@pages/vaults/constants/addresses' -import type { TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' -import type { TVaultApyData } from '@pages/vaults/hooks/useVaultApyData' -import { isValidElement } from 'react' -import { describe, expect, it } from 'vitest' -import { resolveHistoricalApyDisplayConfig } from './apyDisplayConfig' - -const KATANA_SPECTRA_VAULT = { - version: '3.0.0', - chainID: KATANA_CHAIN_ID, - address: SPECTRA_MARKET_VAULT_ADDRESSES[0], - token: { - address: '0x0000000000000000000000000000000000000001' - }, - apr: { - type: '', - points: { - monthAgo: 0.1, - weekAgo: 0.1 - } - } -} as unknown as TKongVaultInput - -const KATANA_APY_DATA = { - mode: 'katana', - baseForwardApr: 0, - netApr: 0, - rewardsAprSum: 0, - isBoosted: false, - hasPendleArbRewards: false, - hasKelp: false, - hasKelpNEngenlayer: false, - katanaExtras: { - fixedRateKatanaRewards: 4, - katanaAppRewardsAPR: 3, - steerPointsPerDollar: 0 - }, - katanaThirtyDayApr: 7.1, - katanaEstApr: 7 -} as TVaultApyData - -describe('resolveHistoricalApyDisplayConfig', () => { - it('includes Spectra boost details in Katana 30 Day APY modal content', () => { - const { modalConfig } = resolveHistoricalApyDisplayConfig({ - currentVault: KATANA_SPECTRA_VAULT, - data: KATANA_APY_DATA, - showSublineTooltip: true - }) - - expect(modalConfig?.canOpen).toBe(true) - expect(isValidElement(modalConfig?.content)).toBe(true) - if (isValidElement<{ isEligibleForSpectraBoost?: boolean }>(modalConfig?.content)) { - expect(modalConfig.content.props.isEligibleForSpectraBoost).toBe(true) - } - }) -}) diff --git a/src/components/pages/vaults/components/widget/InputTokenAmount.test.tsx b/src/components/pages/vaults/components/widget/InputTokenAmount.test.tsx deleted file mode 100644 index f96ff2e04..000000000 --- a/src/components/pages/vaults/components/widget/InputTokenAmount.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' - -async function loadInputTokenAmount() { - return (await import('./InputTokenAmount')).InputTokenAmount -} - -vi.mock('wagmi', () => ({ - useAccount: () => ({ - address: '0x0000000000000000000000000000000000000001' - }) -})) - -vi.mock('@hooks/usePlausible', () => ({ - usePlausible: () => vi.fn() -})) - -vi.mock('@shared/contexts/useWeb3', () => ({ - useWeb3: () => ({ - openLoginModal: vi.fn() - }) -})) - -vi.mock('@shared/utils', () => ({ - cl: (...classes: Array) => classes.filter(Boolean).join(' '), - formatTAmount: ({ value, decimals }: { value: bigint; decimals: number }) => { - const divisor = 10n ** BigInt(decimals) - const whole = value / divisor - const remainder = value % divisor - if (remainder === 0n) { - return whole.toString() - } - - return `${whole.toString()}.${remainder.toString().padStart(decimals, '0').replace(/0+$/, '')}` - }, - simpleToExact: vi.fn(() => 0n) -})) - -vi.mock('@shared/components/TokenLogo', () => ({ - TokenLogo: (props: { src?: string; altSrc?: string }) => -})) - -describe('InputTokenAmount', () => { - it('uses the explicit token logo URI for the selected input token', async () => { - const InputTokenAmount = await loadInputTokenAmount() - const html = renderToStaticMarkup( - - ) - - expect(html).toContain('https://example.com/input-logo.png') - }) - - it('renders the display balance separately from the max-action balance', async () => { - const InputTokenAmount = await loadInputTokenAmount() - const html = renderToStaticMarkup( - - ) - - expect(html).toContain('Balance: 4 yvUSD') - }) -}) diff --git a/src/components/pages/vaults/components/widget/TokenSelector.test.tsx b/src/components/pages/vaults/components/widget/TokenSelector.test.tsx deleted file mode 100644 index 00512f49c..000000000 --- a/src/components/pages/vaults/components/widget/TokenSelector.test.tsx +++ /dev/null @@ -1,649 +0,0 @@ -import type { TToken } from '@shared/types' -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' -import { TokenSelector } from './TokenSelector' - -const BASE_TOKEN_ADDRESS = '0x0000000000000000000000000000000000000001' as const -const VAULT_TOKEN_ADDRESS = '0x0000000000000000000000000000000000000002' as const -const STAKING_TOKEN_ADDRESS = '0x0000000000000000000000000000000000000003' as const -const LEGACY_USDAF_ADDRESS = '0x85E30b8b263bC64d94b827ed450F2EdFEE8579dA' as const - -const { mockUseWallet, mockUseYearn } = vi.hoisted(() => ({ - mockUseWallet: vi.fn(), - mockUseYearn: vi.fn() -})) - -vi.mock('@shared/contexts/useWallet', () => ({ - useWallet: mockUseWallet -})) - -vi.mock('@shared/contexts/WithTokenList', () => ({ - useTokenList: () => ({ - tokenLists: {} - }) -})) - -vi.mock('@shared/contexts/useYearn', () => ({ - useYearn: mockUseYearn -})) - -function buildToken(overrides: Partial = {}): TToken { - return { - address: BASE_TOKEN_ADDRESS, - name: 'Base Token', - symbol: 'BASE', - decimals: 18, - chainID: 1, - value: 0, - balance: { - raw: 5n, - normalized: 5, - display: '5', - decimals: 18 - }, - ...overrides - } -} - -describe('TokenSelector', () => { - mockUseYearn.mockReturnValue({ - allVaults: {}, - getPrice: () => ({ normalized: 0 }) - }) - - it('lets extra tokens override selector metadata and logo sources for matching addresses', () => { - mockUseWallet.mockReturnValue({ - isLoading: false, - balances: { - 1: { - [BASE_TOKEN_ADDRESS]: buildToken() - } - }, - getToken: () => buildToken() - }) - - const html = renderToStaticMarkup( - undefined} - chainId={1} - extraTokens={[ - buildToken({ - name: 'Override Token', - symbol: 'OVR', - logoURI: 'https://example.com/override.png' - }) - ]} - /> - ) - - expect(html).toContain('Override Token') - expect(html).toContain('OVR') - expect(html).toContain('https://example.com/override.png') - expect(html).not.toContain('Base Token') - }) - - it('preserves wallet balance and value when an extra token only overrides metadata', () => { - mockUseYearn.mockReturnValue({ - allVaults: {}, - getPrice: () => ({ normalized: 0 }) - }) - mockUseWallet.mockReturnValue({ - isLoading: false, - balances: { - 1: { - [BASE_TOKEN_ADDRESS]: buildToken({ - name: 'Wallet Token', - symbol: 'WLT', - decimals: 0, - value: 100, - balance: { - raw: 1234n, - normalized: 1234, - display: '1234', - decimals: 0 - } - }), - [VAULT_TOKEN_ADDRESS]: buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Lower Value Token', - symbol: 'LVT', - decimals: 0, - value: 50, - balance: { - raw: 1n, - normalized: 1, - display: '1', - decimals: 0 - } - }) - } - }, - getToken: ({ address }: { address: string }) => { - if (address === VAULT_TOKEN_ADDRESS) { - return buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Lower Value Token', - symbol: 'LVT', - decimals: 0, - value: 50, - balance: { - raw: 1n, - normalized: 1, - display: '1', - decimals: 0 - } - }) - } - - return buildToken({ - name: 'Wallet Token', - symbol: 'WLT', - decimals: 0, - value: 100, - balance: { - raw: 1234n, - normalized: 1234, - display: '1234', - decimals: 0 - } - }) - } - }) - - const html = renderToStaticMarkup( - undefined} - chainId={1} - mode="deposit" - extraTokens={[ - buildToken({ - name: 'Override Token', - symbol: 'OVR', - decimals: 0, - value: 0, - balance: { - raw: 0n, - normalized: 0, - display: '0', - decimals: 0 - } - }) - ]} - /> - ) - - expect(html).toContain('Override Token') - expect(html).toContain('1,234') - expect(html.indexOf('Override Token')).toBeLessThan(html.indexOf('Lower Value Token')) - }) - - it('uses the asset logo for vault and staking entries', () => { - mockUseWallet.mockReturnValue({ - isLoading: false, - balances: { - 1: { - [BASE_TOKEN_ADDRESS]: buildToken({ - address: BASE_TOKEN_ADDRESS, - name: 'Base Token', - symbol: 'BASE', - logoURI: 'https://example.com/base.png' - }), - [VAULT_TOKEN_ADDRESS]: buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Vault Token', - symbol: 'vBASE', - logoURI: 'https://example.com/vault.png' - }), - [STAKING_TOKEN_ADDRESS]: buildToken({ - address: STAKING_TOKEN_ADDRESS, - name: 'Staking Token', - symbol: 'stBASE', - logoURI: 'https://example.com/staking.png' - }) - } - }, - getToken: ({ address }: { address: string }) => { - if (address === BASE_TOKEN_ADDRESS) { - return buildToken({ - address: BASE_TOKEN_ADDRESS, - name: 'Base Token', - symbol: 'BASE', - logoURI: 'https://example.com/base.png' - }) - } - if (address === VAULT_TOKEN_ADDRESS) { - return buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Vault Token', - symbol: 'vBASE', - logoURI: 'https://example.com/vault.png' - }) - } - return buildToken({ - address: STAKING_TOKEN_ADDRESS, - name: 'Staking Token', - symbol: 'stBASE', - logoURI: 'https://example.com/staking.png' - }) - } - }) - - const html = renderToStaticMarkup( - undefined} - chainId={1} - assetAddress={BASE_TOKEN_ADDRESS} - vaultAddress={VAULT_TOKEN_ADDRESS} - stakingAddress={STAKING_TOKEN_ADDRESS} - /> - ) - - expect(html).toContain('https://example.com/base.png') - expect(html).not.toContain('https://example.com/vault.png') - expect(html).not.toContain('https://example.com/staking.png') - }) - - it('never shows hidden vault share or staking tokens', () => { - mockUseWallet.mockReturnValue({ - isLoading: false, - balances: { - 1: { - [BASE_TOKEN_ADDRESS]: buildToken(), - [VAULT_TOKEN_ADDRESS]: buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Hidden Vault Token', - symbol: 'kpdWETH' - }), - [STAKING_TOKEN_ADDRESS]: buildToken({ - address: STAKING_TOKEN_ADDRESS, - name: 'Hidden Staking Token', - symbol: 'stkWETH' - }) - } - }, - getToken: ({ address }: { address: string }) => { - if (address === VAULT_TOKEN_ADDRESS) { - return buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Hidden Vault Token', - symbol: 'kpdWETH' - }) - } - if (address === STAKING_TOKEN_ADDRESS) { - return buildToken({ - address: STAKING_TOKEN_ADDRESS, - name: 'Hidden Staking Token', - symbol: 'stkWETH' - }) - } - return buildToken() - } - }) - mockUseYearn.mockReturnValue({ - allVaults: { - [VAULT_TOKEN_ADDRESS]: { - chainID: 1, - version: '3.0.0', - address: VAULT_TOKEN_ADDRESS, - token: { - address: BASE_TOKEN_ADDRESS, - symbol: 'WETH', - name: 'Wrapped Ether', - description: '', - decimals: 18 - }, - staking: { - address: STAKING_TOKEN_ADDRESS, - available: true, - source: '', - rewards: null - }, - info: { - sourceURL: '', - riskLevel: 0, - riskScore: [], - riskScoreComment: '', - uiNotice: '', - isRetired: false, - isBoosted: false, - isHighlighted: false, - isHidden: true - } - } - }, - getPrice: () => ({ normalized: 0 }) - }) - - const html = renderToStaticMarkup( - undefined} - chainId={1} - assetAddress={BASE_TOKEN_ADDRESS} - /> - ) - - expect(html).toContain('Base Token') - expect(html).not.toContain('kpdWETH') - expect(html).not.toContain('stkWETH') - }) - - it('keeps the hidden vault share token available only for explicit unstake selection', () => { - mockUseWallet.mockReturnValue({ - isLoading: false, - balances: { - 1: { - [BASE_TOKEN_ADDRESS]: buildToken(), - [VAULT_TOKEN_ADDRESS]: buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Hidden Vault Token', - symbol: 'kpdWETH' - }), - [STAKING_TOKEN_ADDRESS]: buildToken({ - address: STAKING_TOKEN_ADDRESS, - name: 'Hidden Staking Token', - symbol: 'stkWETH' - }) - } - }, - getToken: ({ address }: { address: string }) => { - if (address === VAULT_TOKEN_ADDRESS) { - return buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Hidden Vault Token', - symbol: 'kpdWETH' - }) - } - if (address === STAKING_TOKEN_ADDRESS) { - return buildToken({ - address: STAKING_TOKEN_ADDRESS, - name: 'Hidden Staking Token', - symbol: 'stkWETH' - }) - } - return buildToken() - } - }) - mockUseYearn.mockReturnValue({ - allVaults: { - [VAULT_TOKEN_ADDRESS]: { - chainID: 1, - version: '3.0.0', - address: VAULT_TOKEN_ADDRESS, - token: { - address: BASE_TOKEN_ADDRESS, - symbol: 'WETH', - name: 'Wrapped Ether', - description: '', - decimals: 18 - }, - staking: { - address: STAKING_TOKEN_ADDRESS, - available: true, - source: '', - rewards: null - }, - info: { - sourceURL: '', - riskLevel: 0, - riskScore: [], - riskScoreComment: '', - uiNotice: '', - isRetired: false, - isBoosted: false, - isHighlighted: false, - isHidden: true - } - } - }, - getPrice: () => ({ normalized: 0 }) - }) - - const html = renderToStaticMarkup( - undefined} - chainId={1} - mode="withdraw" - assetAddress={BASE_TOKEN_ADDRESS} - vaultAddress={VAULT_TOKEN_ADDRESS} - stakingAddress={STAKING_TOKEN_ADDRESS} - allowHiddenVaultTokenSelection - /> - ) - - expect(html).toContain('kpdWETH') - expect(html).not.toContain('stkWETH') - }) - - it('keeps hidden vault share tokens excluded for withdraw selectors without explicit unstake allowance', () => { - mockUseWallet.mockReturnValue({ - isLoading: false, - balances: { - 1: { - [BASE_TOKEN_ADDRESS]: buildToken(), - [VAULT_TOKEN_ADDRESS]: buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Hidden Vault Token', - symbol: 'kpdWETH' - }), - [STAKING_TOKEN_ADDRESS]: buildToken({ - address: STAKING_TOKEN_ADDRESS, - name: 'Hidden Staking Token', - symbol: 'stkWETH' - }) - } - }, - getToken: ({ address }: { address: string }) => { - if (address === VAULT_TOKEN_ADDRESS) { - return buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Hidden Vault Token', - symbol: 'kpdWETH' - }) - } - if (address === STAKING_TOKEN_ADDRESS) { - return buildToken({ - address: STAKING_TOKEN_ADDRESS, - name: 'Hidden Staking Token', - symbol: 'stkWETH' - }) - } - return buildToken() - } - }) - mockUseYearn.mockReturnValue({ - allVaults: { - [VAULT_TOKEN_ADDRESS]: { - chainID: 1, - version: '3.0.0', - address: VAULT_TOKEN_ADDRESS, - token: { - address: BASE_TOKEN_ADDRESS, - symbol: 'WETH', - name: 'Wrapped Ether', - description: '', - decimals: 18 - }, - staking: { - address: STAKING_TOKEN_ADDRESS, - available: true, - source: '', - rewards: null - }, - info: { - sourceURL: '', - riskLevel: 0, - riskScore: [], - riskScoreComment: '', - uiNotice: '', - isRetired: false, - isBoosted: false, - isHighlighted: false, - isHidden: true - } - } - }, - getPrice: () => ({ normalized: 0 }) - }) - - const html = renderToStaticMarkup( - undefined} - chainId={1} - mode="withdraw" - assetAddress={BASE_TOKEN_ADDRESS} - vaultAddress={VAULT_TOKEN_ADDRESS} - stakingAddress={STAKING_TOKEN_ADDRESS} - /> - ) - - expect(html).not.toContain('kpdWETH') - expect(html).not.toContain('stkWETH') - }) - - it('never shows locally deprecated legacy tokens from the token registry', () => { - mockUseWallet.mockReturnValue({ - isLoading: false, - balances: { - 1: { - [BASE_TOKEN_ADDRESS]: buildToken(), - [LEGACY_USDAF_ADDRESS]: buildToken({ - address: LEGACY_USDAF_ADDRESS, - name: 'USDaf Stablecoin', - symbol: 'USDaf' - }) - } - }, - getToken: ({ address }: { address: string }) => { - if (address === LEGACY_USDAF_ADDRESS) { - return buildToken({ - address: LEGACY_USDAF_ADDRESS, - name: 'USDaf Stablecoin', - symbol: 'USDaf' - }) - } - return buildToken() - } - }) - - const html = renderToStaticMarkup( - undefined} - chainId={1} - assetAddress={BASE_TOKEN_ADDRESS} - /> - ) - - expect(html).toContain('Base Token') - expect(html).not.toContain('USDaf Stablecoin') - }) - - it('keeps the base deposit asset visible while filtering other low-value pinned options', () => { - mockUseWallet.mockReturnValue({ - isLoading: false, - balances: { - 1: { - [BASE_TOKEN_ADDRESS]: buildToken({ - address: BASE_TOKEN_ADDRESS, - name: 'Visible Token', - symbol: 'VIS', - balance: { - raw: 1n, - normalized: 1, - display: '1', - decimals: 18 - } - }), - [VAULT_TOKEN_ADDRESS]: buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Dust Asset', - symbol: 'DST', - balance: { - raw: 5_000_000_000_000_000n, - normalized: 0.005, - display: '0.005', - decimals: 18 - } - }), - [STAKING_TOKEN_ADDRESS]: buildToken({ - address: STAKING_TOKEN_ADDRESS, - name: 'Pinned Dust Token', - symbol: 'PDT', - balance: { - raw: 4_000_000_000_000_000n, - normalized: 0.004, - display: '0.004', - decimals: 18 - } - }) - } - }, - getToken: ({ address }: { address: string }) => { - if (address === VAULT_TOKEN_ADDRESS) { - return buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Dust Asset', - symbol: 'DST', - balance: { - raw: 5_000_000_000_000_000n, - normalized: 0.005, - display: '0.005', - decimals: 18 - } - }) - } - if (address === STAKING_TOKEN_ADDRESS) { - return buildToken({ - address: STAKING_TOKEN_ADDRESS, - name: 'Pinned Dust Token', - symbol: 'PDT', - balance: { - raw: 4_000_000_000_000_000n, - normalized: 0.004, - display: '0.004', - decimals: 18 - } - }) - } - - return buildToken({ - address: BASE_TOKEN_ADDRESS, - name: 'Visible Token', - symbol: 'VIS', - balance: { - raw: 1n, - normalized: 1, - display: '1', - decimals: 18 - } - }) - } - }) - mockUseYearn.mockReturnValue({ - allVaults: {}, - getPrice: () => ({ normalized: 1 }) - }) - - const html = renderToStaticMarkup( - undefined} - chainId={1} - mode="deposit" - assetAddress={VAULT_TOKEN_ADDRESS} - priorityTokens={{ 1: [VAULT_TOKEN_ADDRESS, STAKING_TOKEN_ADDRESS] }} - topTokens={{ 1: [VAULT_TOKEN_ADDRESS, STAKING_TOKEN_ADDRESS] }} - /> - ) - - expect(html).toContain('Visible Token') - expect(html).toContain('Dust Asset') - expect(html).not.toContain('Pinned Dust Token') - }) -}) diff --git a/src/components/pages/vaults/components/widget/WalletPanel.tsx b/src/components/pages/vaults/components/widget/WalletPanel.tsx index 762cdec98..53235ea2f 100644 --- a/src/components/pages/vaults/components/widget/WalletPanel.tsx +++ b/src/components/pages/vaults/components/widget/WalletPanel.tsx @@ -17,6 +17,7 @@ import { useNotifications } from '@shared/contexts/useNotifications' import { useWeb3 } from '@shared/contexts/useWeb3' import { useYearn } from '@shared/contexts/useYearn' import { yvUsdLockedVaultAbi } from '@shared/contracts/abi/yvUsdLockedVault.abi' +import { useReadContract } from '@shared/hooks/useAppWagmi' import { useChainTimestamp } from '@shared/hooks/useChainTimestamp' import { IconCheck } from '@shared/icons/IconCheck' import { IconCross } from '@shared/icons/IconCross' @@ -35,7 +36,6 @@ import { import { getNetwork } from '@shared/utils/wagmi/utils' import { type FC, type ReactElement, useCallback, useMemo, useState } from 'react' import { useNavigate } from 'react-router' -import { useReadContract } from 'wagmi' import { formatDuration, parseCooldownStatus, resolveCooldownWindowState } from './yvUSD/cooldownUtils' type WalletPanelProps = { diff --git a/src/components/pages/vaults/components/widget/deposit/ApprovalOverlay.tsx b/src/components/pages/vaults/components/widget/deposit/ApprovalOverlay.tsx index 2805dac8e..4fbdfffe5 100644 --- a/src/components/pages/vaults/components/widget/deposit/ApprovalOverlay.tsx +++ b/src/components/pages/vaults/components/widget/deposit/ApprovalOverlay.tsx @@ -1,8 +1,10 @@ import { Button } from '@shared/components/Button' +import { useSwitchChain, useWaitForTransactionReceipt } from '@shared/hooks/useAppWagmi' import { getApproveAbi } from '@shared/utils/approve' import { type FC, useCallback, useEffect, useState } from 'react' import { maxUint256 } from 'viem' -import { useAccount, useChainId, useSwitchChain, useWaitForTransactionReceipt, useWriteContract } from 'wagmi' +import { useAccount, useWriteContract } from 'wagmi' +import { isConnectedToExecutionChain, resolveExecutionChainId } from '@/config/tenderly' import { InfoOverlay } from '../shared/InfoOverlay' import { AnimatedCheckmark, ErrorIcon, Spinner } from '../shared/TransactionStateIndicators' @@ -33,11 +35,10 @@ export const ApprovalOverlay: FC = ({ const [txState, setTxState] = useState('idle') const [errorMessage, setErrorMessage] = useState('') - const { address: account } = useAccount() - const currentChainId = useChainId() + const { address: account, chain } = useAccount() const { switchChainAsync } = useSwitchChain() const { writeContractAsync, data: txHash, reset } = useWriteContract() - const receipt = useWaitForTransactionReceipt({ hash: txHash }) + const receipt = useWaitForTransactionReceipt({ hash: txHash, chainId }) // Reset state when overlay closes useEffect(() => { @@ -67,11 +68,18 @@ export const ApprovalOverlay: FC = ({ const handleApprove = useCallback( async (amount: bigint) => { + const executionChainId = resolveExecutionChainId(chainId) + if (executionChainId === undefined) { + setTxState('error') + setErrorMessage('This network is not enabled for transactions') + return + } + setTxState('confirming') setErrorMessage('') // Handle chain switch if needed - if (currentChainId !== chainId) { + if (!isConnectedToExecutionChain(chain?.id, chainId)) { try { await switchChainAsync({ chainId }) } catch { @@ -87,7 +95,7 @@ export const ApprovalOverlay: FC = ({ abi: getApproveAbi(tokenAddress), functionName: 'approve', args: [spenderAddress, amount], - chainId + chainId: executionChainId }) setTxState('pending') } catch (error: any) { @@ -104,7 +112,7 @@ export const ApprovalOverlay: FC = ({ } } }, - [currentChainId, chainId, tokenAddress, spenderAddress, writeContractAsync, switchChainAsync] + [chain?.id, chainId, tokenAddress, spenderAddress, writeContractAsync, switchChainAsync] ) const handleRevoke = useCallback(() => handleApprove(0n), [handleApprove]) diff --git a/src/components/pages/vaults/components/widget/deposit/approvalSpender.test.ts b/src/components/pages/vaults/components/widget/deposit/approvalSpender.test.ts deleted file mode 100644 index 87299233e..000000000 --- a/src/components/pages/vaults/components/widget/deposit/approvalSpender.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { getAddress } from 'viem' -import { describe, expect, it } from 'vitest' -import { getDepositApprovalSpender } from './approvalSpender' - -describe('getDepositApprovalSpender', () => { - it('uses the zap router for direct deposit routes when a router address is present', () => { - expect( - getDepositApprovalSpender({ - routeType: 'DIRECT_DEPOSIT', - destinationToken: '0x00000000000000000000000000000000000000aa', - routerAddress: '0x00000000000000000000000000000000000000bb', - vaultSymbol: 'yvUSD' - }) - ).toEqual({ - spenderAddress: getAddress('0x00000000000000000000000000000000000000bb'), - spenderName: 'Yearn Zap' - }) - }) - - it('falls back to the vault for plain direct deposits', () => { - expect( - getDepositApprovalSpender({ - routeType: 'DIRECT_DEPOSIT', - destinationToken: '0x00000000000000000000000000000000000000aa', - vaultSymbol: 'yvUSD' - }) - ).toEqual({ - spenderAddress: getAddress('0x00000000000000000000000000000000000000aa'), - spenderName: 'yvUSD' - }) - }) -}) diff --git a/src/components/pages/vaults/components/widget/deposit/index.tsx b/src/components/pages/vaults/components/widget/deposit/index.tsx index ebffed4a7..9e5dd643e 100644 --- a/src/components/pages/vaults/components/widget/deposit/index.tsx +++ b/src/components/pages/vaults/components/widget/deposit/index.tsx @@ -1,11 +1,7 @@ -import { usePlausible } from '@hooks/usePlausible' import { InputTokenAmount } from '@pages/vaults/components/widget/InputTokenAmount' import { useDebouncedInput } from '@pages/vaults/hooks/useDebouncedInput' -import { useEnsoEnabled } from '@pages/vaults/hooks/useEnsoEnabled' import type { VaultUserData } from '@pages/vaults/hooks/useVaultUserData' import { Button } from '@shared/components/Button' -import { useWallet } from '@shared/contexts/useWallet' -import { useWeb3 } from '@shared/contexts/useWeb3' import { useYearn } from '@shared/contexts/useYearn' import { IconChevron } from '@shared/icons/IconChevron' import { IconCross } from '@shared/icons/IconCross' @@ -17,13 +13,16 @@ import { PLAUSIBLE_EVENTS } from '@shared/utils/plausible' import type { ReactElement, ReactNode } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { formatUnits } from 'viem' -import { useAccount } from 'wagmi' import { SettingsPanel } from '../SettingsPanel' +import { PriceImpactWarning } from '../shared/PriceImpactWarning' import { TokenSelectorOverlay } from '../shared/TokenSelectorOverlay' import { TransactionOverlay, type TransactionStep } from '../shared/TransactionOverlay' +import { usePriceImpactAcceptance } from '../shared/usePriceImpactAcceptance' import { useResetEnsoSelection } from '../shared/useResetEnsoSelection' +import { useWidgetContext } from '../shared/useWidgetContext' import { formatWidgetAllowance, formatWidgetValue } from '../shared/valueDisplay' import { WidgetHeader } from '../shared/WidgetHeader' +import { WidgetLoadingSkeleton } from '../shared/WidgetLoadingSkeleton' import { DEPOSIT_COMMON_TOKENS_BY_CHAIN } from '../withdraw/constants' import { AnnualReturnOverlay } from './AnnualReturnOverlay' import { ApprovalOverlay } from './ApprovalOverlay' @@ -149,27 +148,25 @@ export function WidgetDeposit({ deferSuccessEffectsUntilClose = false, deferSuccessEffectsUntilConfettiEnd = true }: Props): ReactElement { - const { address: account } = useAccount() - const { openLoginModal } = useWeb3() - const { onRefresh: refreshWalletBalances, getToken } = useWallet() - const { zapSlippage, isAutoStakingEnabled, getPrice, allVaults } = useYearn() - const trackEvent = usePlausible() - const ensoEnabled = useEnsoEnabled({ chainId, vaultAddress }) + const { + account, + openLoginModal, + refreshWalletBalances, + getToken, + zapSlippage, + isAutoStakingEnabled, + getPrice, + trackEvent, + ensoEnabled + } = useWidgetContext({ chainId, vaultAddress }) + const { allVaults } = useYearn() - const [selectedToken, setSelectedToken] = useState<`0x${string}` | undefined>(prefill?.address ?? assetAddress) - const [selectedChainId, setSelectedChainId] = useState(prefill?.chainId) const [showVaultSharesModal, setShowVaultSharesModal] = useState(false) const [showVaultShareValueModal, setShowVaultShareValueModal] = useState(false) const [showAnnualReturnModal, setShowAnnualReturnModal] = useState(false) const [showApprovalOverlay, setShowApprovalOverlay] = useState(false) - const [showTokenSelector, setShowTokenSelector] = useState(false) const [showTransactionOverlay, setShowTransactionOverlay] = useState(false) const [isDetailsPanelOpen, setIsDetailsPanelOpen] = useState(false) - const [priceImpactAcceptance, setPriceImpactAcceptance] = useState<{ key: string; isAccepted: boolean }>({ - key: '', - isAccepted: false - }) - const appliedPrefillRef = useRef(null) const { assetToken, @@ -179,6 +176,15 @@ export function WidgetDeposit({ isLoading: isLoadingVaultData, refetch: refetchVaultUserData } = vaultUserData + + // ============================================================================ + // Token Selection & Input + // ============================================================================ + const [selectedToken, setSelectedToken] = useState<`0x${string}` | undefined>(prefill?.address ?? assetAddress) + const [selectedChainId, setSelectedChainId] = useState(prefill?.chainId) + const [showTokenSelector, setShowTokenSelector] = useState(false) + const appliedPrefillRef = useRef(null) + // Derived token values const depositToken = selectedToken || assetAddress const sourceChainId = selectedChainId || chainId @@ -229,9 +235,6 @@ export function WidgetDeposit({ return vaultAddress }, [isAutoStakingEnabled, stakingAddress, vaultAddress]) - // ============================================================================ - // Input Handling - // ============================================================================ const depositInput = useDebouncedInput(inputToken?.decimals ?? 18) const [depositAmount, , setDepositInput] = depositInput const shouldCollapseDetails = Boolean(collapseDetails && !hideDetails && !hideActionButton) @@ -273,11 +276,10 @@ export function WidgetDeposit({ setShowTokenSelector }) - useEffect(() => { - if (!shouldCollapseDetails && isDetailsPanelOpen) { - setIsDetailsPanelOpen(false) - } - }, [isDetailsPanelOpen, shouldCollapseDetails]) + // Render-time state adjustment: close panel when collapse is disabled + if (!shouldCollapseDetails && isDetailsPanelOpen) { + setIsDetailsPanelOpen(false) + } const { routeType, activeFlow } = useDepositFlow({ depositToken, @@ -396,27 +398,15 @@ export function WidgetDeposit({ } }, [depositValueInfo.priceImpactPercentage, depositValueInfo.isHighPriceImpact]) - const priceImpactAcceptanceKey = useMemo(() => { - return [ - depositAmount.bn.toString(), - routeType, - sourceChainId, - depositToken, - destinationToken, - activeFlow.periphery.routerAddress ?? '', - activeFlow.periphery.expectedOut.toString() - ].join(':') - }, [ + const { hasAcceptedPriceImpact, priceImpactAcceptanceKey, setAcceptedPriceImpactKey } = usePriceImpactAcceptance([ depositAmount.bn, routeType, sourceChainId, depositToken, destinationToken, - activeFlow.periphery.routerAddress, + activeFlow.periphery.routerAddress ?? '', activeFlow.periphery.expectedOut ]) - const hasAcceptedPriceImpact = - priceImpactAcceptance.key === priceImpactAcceptanceKey && priceImpactAcceptance.isAccepted const formattedDepositAmount = formatTAmount({ value: depositAmount.bn, decimals: inputToken?.decimals ?? 18 }) const needsApproval = !isNativeToken && !activeFlow.periphery.isAllowanceSufficient @@ -546,14 +536,7 @@ export function WidgetDeposit({ ) if (isLoadingVaultData) { - return ( -
- -
-
-
-
- ) + return } // ============================================================================ @@ -614,34 +597,23 @@ export function WidgetDeposit({ /> ) - const priceImpactWarning = priceImpactInfo.isHigh && - !isLoadingQuote && - !depositAmount.isDebouncing && - depositAmount.bn === depositAmount.debouncedBn && - depositAmount.bn > 0n && ( -
-

- Price impact is high ({priceImpactInfo.percentage.toFixed(2)}%). Consider depositing less or waiting for - better liquidity conditions. -

- -
- ) + const priceImpactWarning = ( + 0n} + hasAcceptedPriceImpact={hasAcceptedPriceImpact} + priceImpactAcceptanceKey={priceImpactAcceptanceKey} + setAcceptedPriceImpactKey={setAcceptedPriceImpactKey} + actionVerb="depositing" + /> + ) const showActionRow = !hideActionButton || !!onOpenSettings + const showSettingsButton = !!account && !!onOpenSettings const depositButtonLabel = getDepositButtonLabel(isLoadingQuote, needsApproval, routeType) const isDepositButtonDisabled = !!depositError || @@ -651,7 +623,6 @@ export function WidgetDeposit({ (!activeFlow.periphery.isAllowanceSufficient && !activeFlow.periphery.prepareApproveEnabled) || (activeFlow.periphery.isAllowanceSufficient && !activeFlow.periphery.prepareDepositEnabled) || (priceImpactInfo.isHigh && !hasAcceptedPriceImpact) - const showSettingsButton = !!account && !!onOpenSettings const actionRow = showActionRow ? (
diff --git a/src/components/pages/vaults/components/widget/deposit/tokenSelectorFiltering.test.ts b/src/components/pages/vaults/components/widget/deposit/tokenSelectorFiltering.test.ts deleted file mode 100644 index 82001dc9a..000000000 --- a/src/components/pages/vaults/components/widget/deposit/tokenSelectorFiltering.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' -import type { Address } from 'viem' -import { describe, expect, it } from 'vitest' -import { getStructurallyExcludedDepositTokenAddresses } from './tokenSelectorFiltering' - -const DESTINATION_VAULT = '0x0000000000000000000000000000000000000001' as Address -const CANDIDATE_VAULT = '0x0000000000000000000000000000000000000002' as Address -const IRRELEVANT_VAULT = '0x0000000000000000000000000000000000000003' as Address -const OTHER_ASSET = '0x0000000000000000000000000000000000000004' as Address -const HIDDEN_STAKING = '0x0000000000000000000000000000000000000005' as Address - -describe('getStructurallyExcludedDepositTokenAddresses', () => { - it('excludes vault share tokens whose underlying asset is the destination vault', () => { - const excluded = getStructurallyExcludedDepositTokenAddresses({ - allVaults: { - [CANDIDATE_VAULT]: { - chainID: 1, - version: '3.0.0', - address: CANDIDATE_VAULT, - token: { - address: DESTINATION_VAULT, - symbol: 'DEST', - name: 'Destination Vault', - description: '', - decimals: 18 - } - } as never, - [IRRELEVANT_VAULT]: { - chainID: 1, - version: '3.0.0', - address: IRRELEVANT_VAULT, - token: { - address: OTHER_ASSET, - symbol: 'OTHER', - name: 'Other Asset', - description: '', - decimals: 18 - } - } as never - }, - destinationVaultAddress: DESTINATION_VAULT - }) - - expect(excluded).toContain(CANDIDATE_VAULT) - expect(excluded).not.toContain(IRRELEVANT_VAULT) - }) - - it('excludes locked yvUSD for deposits even when the destination vault is unrelated', () => { - const excluded = getStructurallyExcludedDepositTokenAddresses({ - allVaults: {}, - destinationVaultAddress: OTHER_ASSET - }) - - expect(excluded).toContain(YVUSD_LOCKED_ADDRESS) - }) - - it('excludes hidden vault share and staking tokens from deposit selectors', () => { - const excluded = getStructurallyExcludedDepositTokenAddresses({ - allVaults: { - [IRRELEVANT_VAULT]: { - chainID: 1, - version: '3.0.0', - address: IRRELEVANT_VAULT, - token: { - address: OTHER_ASSET, - symbol: 'OTHER', - name: 'Other Asset', - description: '', - decimals: 18 - }, - staking: { - address: HIDDEN_STAKING, - available: true, - source: '', - rewards: null - }, - info: { - sourceURL: '', - riskLevel: 0, - riskScore: [], - riskScoreComment: '', - uiNotice: '', - isRetired: false, - isBoosted: false, - isHighlighted: false, - isHidden: true - } - } as never - }, - destinationVaultAddress: DESTINATION_VAULT - }) - - expect(excluded).toContain(IRRELEVANT_VAULT) - expect(excluded).toContain(HIDDEN_STAKING) - }) - - it('does not exclude unlocked yvUSD for locked yvUSD deposits', () => { - const excluded = getStructurallyExcludedDepositTokenAddresses({ - allVaults: {}, - destinationVaultAddress: YVUSD_LOCKED_ADDRESS - }) - - expect(excluded).not.toContain(YVUSD_UNLOCKED_ADDRESS) - }) -}) diff --git a/src/components/pages/vaults/components/widget/deposit/useDepositRoute.test.ts b/src/components/pages/vaults/components/widget/deposit/useDepositRoute.test.ts deleted file mode 100644 index a782a2c7b..000000000 --- a/src/components/pages/vaults/components/widget/deposit/useDepositRoute.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { Address } from 'viem' -import { describe, expect, it } from 'vitest' -import { resolveDepositRouteType } from './useDepositRoute' - -const ASSET = '0x0000000000000000000000000000000000000001' as Address -const VAULT = '0x0000000000000000000000000000000000000002' as Address -const STAKING = '0x0000000000000000000000000000000000000003' as Address -const OTHER = '0x0000000000000000000000000000000000000004' as Address - -describe('resolveDepositRouteType', () => { - it('returns DIRECT_DEPOSIT for asset-to-vault deposits', () => { - const route = resolveDepositRouteType({ - depositToken: ASSET, - assetAddress: ASSET, - destinationToken: VAULT, - vaultAddress: VAULT, - stakingAddress: STAKING, - ensoEnabled: false - }) - - expect(route).toBe('DIRECT_DEPOSIT') - }) - - it('returns DIRECT_STAKE for vault-to-staking deposits', () => { - const route = resolveDepositRouteType({ - depositToken: VAULT, - assetAddress: ASSET, - destinationToken: STAKING, - vaultAddress: VAULT, - stakingAddress: STAKING, - ensoEnabled: false - }) - - expect(route).toBe('DIRECT_STAKE') - }) - - it('returns ENSO for non-direct routes when Enso is enabled', () => { - const route = resolveDepositRouteType({ - depositToken: OTHER, - assetAddress: ASSET, - destinationToken: VAULT, - vaultAddress: VAULT, - stakingAddress: STAKING, - ensoEnabled: true - }) - - expect(route).toBe('ENSO') - }) - - it('returns NO_ROUTE for non-direct routes when Enso is disabled', () => { - const route = resolveDepositRouteType({ - depositToken: OTHER, - assetAddress: ASSET, - destinationToken: VAULT, - vaultAddress: VAULT, - stakingAddress: STAKING, - ensoEnabled: false - }) - - expect(route).toBe('NO_ROUTE') - }) -}) diff --git a/src/components/pages/vaults/components/widget/index.tsx b/src/components/pages/vaults/components/widget/index.tsx index 85f940577..31c04939e 100644 --- a/src/components/pages/vaults/components/widget/index.tsx +++ b/src/components/pages/vaults/components/widget/index.tsx @@ -12,15 +12,7 @@ import type { VaultUserData } from '@pages/vaults/hooks/useVaultUserData' import { WidgetActionType as ActionType } from '@pages/vaults/types' import type { TAddress } from '@shared/types' import { cl, isZeroAddress, toAddress } from '@shared/utils' -import { - type ForwardedRef, - forwardRef, - type ReactElement, - type ReactNode, - useEffect, - useImperativeHandle, - useState -} from 'react' +import { type ForwardedRef, forwardRef, type ReactElement, type ReactNode, useImperativeHandle, useState } from 'react' import { WidgetDeposit } from './deposit' import { WidgetMigrate } from './migrate' import { WidgetWithdraw } from './withdraw' @@ -108,11 +100,10 @@ export const Widget = forwardRef(function Widget( } })) - useEffect(() => { - if (mode === undefined) { - setInternalMode(actions[0]) - } - }, [actions, mode]) + // Render-time state adjustment: keep internal mode valid when actions change + if (mode === undefined && !actions.includes(internalMode)) { + setInternalMode(actions[0]) + } function renderSelectedComponent(): ReactElement { switch (currentMode) { diff --git a/src/components/pages/vaults/components/widget/migrate/index.tsx b/src/components/pages/vaults/components/widget/migrate/index.tsx index a8f0d3cb9..f401c9f00 100644 --- a/src/components/pages/vaults/components/widget/migrate/index.tsx +++ b/src/components/pages/vaults/components/widget/migrate/index.tsx @@ -4,13 +4,14 @@ import { Button } from '@shared/components/Button' import { TokenLogo } from '@shared/components/TokenLogo' import { useWallet } from '@shared/contexts/useWallet' import { useWeb3 } from '@shared/contexts/useWeb3' +import { usePublicClient } from '@shared/hooks/useAppWagmi' import { PERMIT_ABI, type TPermitSignature } from '@shared/hooks/usePermit' import { IconLinkOut } from '@shared/icons/IconLinkOut' import { formatTAmount, isZeroAddress, toAddress, toNormalizedBN } from '@shared/utils' import { PLAUSIBLE_EVENTS } from '@shared/utils/plausible' import { type FC, useCallback, useEffect, useMemo, useState } from 'react' import { hexToNumber, slice } from 'viem' -import { useAccount, usePublicClient } from 'wagmi' +import { useAccount } from 'wagmi' import { useYearn } from '@/components/shared/contexts/useYearn' import { TransactionOverlay, type TransactionStep } from '../shared/TransactionOverlay' import { formatWidgetValue } from '../shared/valueDisplay' diff --git a/src/components/pages/vaults/components/widget/migrate/useMigrateFlow.ts b/src/components/pages/vaults/components/widget/migrate/useMigrateFlow.ts index d9139752f..e38d688f6 100644 --- a/src/components/pages/vaults/components/widget/migrate/useMigrateFlow.ts +++ b/src/components/pages/vaults/components/widget/migrate/useMigrateFlow.ts @@ -1,11 +1,11 @@ import { useTokenAllowance } from '@pages/vaults/hooks/useTokenAllowance' import type { MigrateRouteType, UseMigrateFlowReturn } from '@pages/vaults/types' import { ERC_4626_ROUTER_ABI } from '@shared/contracts/abi/erc4626Router.abi' +import { type AppUseSimulateContractReturnType, usePublicClient, useSimulateContract } from '@shared/hooks/useAppWagmi' import { detectPermitType, type PermitType, type TPermitSignature } from '@shared/hooks/usePermit' import { getMigratorConfig, type MigratorConfig } from '@shared/utils/migratorRegistry' import { useEffect, useMemo, useState } from 'react' import { type Address, encodeFunctionData, erc20Abi } from 'viem' -import { type UseSimulateContractReturnType, usePublicClient, useSimulateContract } from 'wagmi' import { YEARN_4626_ROUTER } from '@/components/shared/utils' // Default permit deadline: 20 minutes from now @@ -105,7 +105,7 @@ export const useMigrateFlow = ({ // Prepare approve (when using approve flow and allowance insufficient) const prepareApproveEnabled = routeType === 'approve' && !isAllowanceSufficient && hasBalance && !!account && enabled - const prepareApprove: UseSimulateContractReturnType = useSimulateContract({ + const prepareApprove: AppUseSimulateContractReturnType = useSimulateContract({ abi: erc20Abi, functionName: 'approve', address: vaultFrom, @@ -115,7 +115,7 @@ export const useMigrateFlow = ({ }) const prepareMigrateEnabled = routeType === 'approve' && hasBalance && !!account && enabled && isAllowanceSufficient - const prepareMigrate: UseSimulateContractReturnType = useSimulateContract({ + const prepareMigrate: AppUseSimulateContractReturnType = useSimulateContract({ abi: migratorConfig.abi, functionName: migratorConfig.functionName, address: effectiveRouter, @@ -160,7 +160,7 @@ export const useMigrateFlow = ({ migratorConfig.functionName ]) - const prepareMulticall: UseSimulateContractReturnType = useSimulateContract({ + const prepareMulticall: AppUseSimulateContractReturnType = useSimulateContract({ abi: ERC_4626_ROUTER_ABI, functionName: 'multicall', address: effectiveRouter, diff --git a/src/components/pages/vaults/components/widget/rewards/MerkleRewardRow.tsx b/src/components/pages/vaults/components/widget/rewards/MerkleRewardRow.tsx index 93c5c262e..6ec086afd 100644 --- a/src/components/pages/vaults/components/widget/rewards/MerkleRewardRow.tsx +++ b/src/components/pages/vaults/components/widget/rewards/MerkleRewardRow.tsx @@ -1,9 +1,9 @@ import { useClaimMerkleRewards } from '@pages/vaults/hooks/rewards/useClaimMerkleRewards' +import { useChainId } from '@shared/hooks/useAppWagmi' import { toNormalizedValue } from '@shared/utils' import type { ReactElement } from 'react' import { useCallback, useMemo } from 'react' -import type { UseSimulateContractReturnType } from 'wagmi' -import { useChainId, useWriteContract } from 'wagmi' +import { useWriteContract } from 'wagmi' import type { TransactionStep } from '../shared/TransactionOverlay' import { RewardRow } from './RewardRow' import type { TGroupedMerkleReward } from './types' @@ -38,7 +38,7 @@ export function MerkleRewardRow(props: TMerkleRewardRowProps): ReactElement { return undefined } return { - prepare: prepare as unknown as UseSimulateContractReturnType, + prepare, label: 'Claim', confirmMessage: `Claim ${formattedAmount} ${groupedReward.token.symbol}`, successTitle: 'Rewards Claimed', diff --git a/src/components/pages/vaults/components/widget/rewards/StakingRewardRow.tsx b/src/components/pages/vaults/components/widget/rewards/StakingRewardRow.tsx index 4dcb46aad..9e02ba9c5 100644 --- a/src/components/pages/vaults/components/widget/rewards/StakingRewardRow.tsx +++ b/src/components/pages/vaults/components/widget/rewards/StakingRewardRow.tsx @@ -1,9 +1,9 @@ import { useClaimStakingRewards } from '@pages/vaults/hooks/rewards/useClaimStakingRewards' +import { useChainId } from '@shared/hooks/useAppWagmi' import { toNormalizedValue } from '@shared/utils' import type { ReactElement } from 'react' import { useCallback, useMemo } from 'react' -import type { UseSimulateContractReturnType } from 'wagmi' -import { useChainId, useWriteContract } from 'wagmi' +import { useWriteContract } from 'wagmi' import type { TransactionStep } from '../shared/TransactionOverlay' import { RewardRow } from './RewardRow' import type { TStakingReward } from './types' @@ -41,7 +41,7 @@ export function StakingRewardRow(props: TStakingRewardRowProps): ReactElement { return undefined } return { - prepare: prepare as unknown as UseSimulateContractReturnType, + prepare, label: 'Claim', confirmMessage: `Claim ${formattedAmount} ${reward.symbol}`, successTitle: 'Rewards Claimed', diff --git a/src/components/pages/vaults/components/widget/rewards/index.tsx b/src/components/pages/vaults/components/widget/rewards/index.tsx index b6cc1fdcc..49cecb63c 100644 --- a/src/components/pages/vaults/components/widget/rewards/index.tsx +++ b/src/components/pages/vaults/components/widget/rewards/index.tsx @@ -4,13 +4,13 @@ import { type TRewardToken, useStakingRewards } from '@pages/vaults/hooks/reward import { Button } from '@shared/components/Button' import { SwitchChainPrompt } from '@shared/components/SwitchChainPrompt' import { useWeb3 } from '@shared/contexts/useWeb3' +import { useChainId, useSwitchChain } from '@shared/hooks/useAppWagmi' import { IconCross } from '@shared/icons/IconCross' import { cl, toAddress } from '@shared/utils' import { formatUSDWithThreshold } from '@shared/utils/format' import { PLAUSIBLE_EVENTS } from '@shared/utils/plausible' import type { ReactElement } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' -import { useChainId, useSwitchChain } from 'wagmi' import { TransactionOverlay, type TransactionStep } from '../shared/TransactionOverlay' import { MerkleRewardRow } from './MerkleRewardRow' import { StakingRewardRow } from './StakingRewardRow' diff --git a/src/components/pages/vaults/components/widget/shared/PriceImpactWarning.tsx b/src/components/pages/vaults/components/widget/shared/PriceImpactWarning.tsx new file mode 100644 index 000000000..7ea6aa326 --- /dev/null +++ b/src/components/pages/vaults/components/widget/shared/PriceImpactWarning.tsx @@ -0,0 +1,49 @@ +import type { ReactElement } from 'react' + +type TPriceImpactWarningProps = { + percentage: number + isHigh: boolean + isLoading: boolean + isDebouncing: boolean + isAmountSynced: boolean + hasAmount: boolean + hasAcceptedPriceImpact: boolean + priceImpactAcceptanceKey: string + setAcceptedPriceImpactKey: (key: string | null) => void + actionVerb?: string +} + +export function PriceImpactWarning({ + percentage, + isHigh, + isLoading, + isDebouncing, + isAmountSynced, + hasAmount, + hasAcceptedPriceImpact, + priceImpactAcceptanceKey, + setAcceptedPriceImpactKey, + actionVerb = 'depositing' +}: TPriceImpactWarningProps): ReactElement | null { + if (!isHigh || isLoading || isDebouncing || !isAmountSynced || !hasAmount) { + return null + } + + return ( +
+

+ Price impact is high ({percentage.toFixed(2)}%). Consider {actionVerb} less or waiting for better liquidity + conditions. +

+ +
+ ) +} diff --git a/src/components/pages/vaults/components/widget/shared/TransactionOverlay.tsx b/src/components/pages/vaults/components/widget/shared/TransactionOverlay.tsx index 7c912f66a..78d8c9e01 100644 --- a/src/components/pages/vaults/components/widget/shared/TransactionOverlay.tsx +++ b/src/components/pages/vaults/components/widget/shared/TransactionOverlay.tsx @@ -1,21 +1,20 @@ import { Button } from '@shared/components/Button' import { useNotificationsActions } from '@shared/contexts/useNotificationsActions' +import { + type AppUseSimulateContractReturnType, + useChainId, + useSwitchChain, + useWaitForTransactionReceipt +} from '@shared/hooks/useAppWagmi' import type { TCreateNotificationParams } from '@shared/types/notifications' import { cl } from '@shared/utils' -import { getNetwork } from '@shared/utils/wagmi' +import { getNetwork, retrieveConfig } from '@shared/utils/wagmi' +import { getPublicClient } from '@wagmi/core' import { type FC, useCallback, useEffect, useId, useRef, useState } from 'react' import { useReward } from 'react-rewards' import type { TypedData, TypedDataDomain } from 'viem' -import { - type UseSimulateContractReturnType, - useAccount, - useChainId, - usePublicClient, - useSignTypedData, - useSwitchChain, - useWaitForTransactionReceipt, - useWriteContract -} from 'wagmi' +import { useAccount, useSignTypedData, useWriteContract } from 'wagmi' +import { isConnectedToExecutionChain } from '@/config/tenderly' import { AnimatedCheckmark, ErrorIcon, Spinner } from './TransactionStateIndicators' import { type OverlayState, @@ -37,7 +36,7 @@ export type PermitDataAsync = { export type PermitData = PermitDataDirect | PermitDataAsync export type TransactionStep = { - prepare: UseSimulateContractReturnType + prepare: AppUseSimulateContractReturnType label: string confirmMessage: string successTitle: string @@ -56,7 +55,7 @@ type TPrepareDebugInfo = { isError: boolean isLoading: boolean isFetching: boolean - status: UseSimulateContractReturnType['status'] + status: AppUseSimulateContractReturnType['status'] error?: string request?: { chainId?: number @@ -65,7 +64,7 @@ type TPrepareDebugInfo = { } } -function getPrepareDebugInfo(prepare?: UseSimulateContractReturnType): TPrepareDebugInfo | undefined { +function getPrepareDebugInfo(prepare?: AppUseSimulateContractReturnType): TPrepareDebugInfo | undefined { if (!prepare) return undefined const request = prepare.data?.request as any @@ -170,8 +169,7 @@ export const TransactionOverlay: FC = ({ const currentChainId = useChainId() const { switchChainAsync } = useSwitchChain() const [txHash, setTxHash] = useState<`0x${string}` | undefined>() - const client = usePublicClient() - const { address: account } = useAccount() + const { address: account, chain } = useAccount() // Notification system integration const { createNotification, updateNotification } = useNotificationsActions() @@ -183,10 +181,10 @@ export const TransactionOverlay: FC = ({ // Track the step that was just executed (for showing success messages) const executedStepRef = useRef(null) - const receipt = useWaitForTransactionReceipt({ hash: txHash, confirmations }) const explorerChainId = - ((executedStepRef.current?.prepare.data?.request as any)?.chainId as number | undefined) ?? currentChainId - const blockExplorer = explorerChainId ? getNetwork(explorerChainId).defaultBlockExplorer : '' + ((executedStepRef.current?.prepare.data?.request as any)?.chainId as number | undefined) ?? undefined + const receipt = useWaitForTransactionReceipt({ hash: txHash, chainId: explorerChainId, confirmations }) + const blockExplorer = getNetwork(explorerChainId ?? currentChainId).defaultBlockExplorer const explorerTxUrl = txHash && blockExplorer ? `${blockExplorer}/tx/${txHash}` : '' // Track if the executed step was the last step (captured at execution time) @@ -305,12 +303,16 @@ export const TransactionOverlay: FC = ({ async ( txHash: `0x${string}`, notification?: TCreateNotificationParams, + executionChainId?: number, status: 'pending' | 'submitted' = 'pending' ): Promise => { if (!notification || !account) return undefined try { - const id = await createNotification(notification) + const id = await createNotification({ + ...notification, + executionChainId: executionChainId ?? notification.executionChainId + }) setNotificationId(id) await updateNotification({ id, txHash, status }) return id @@ -403,7 +405,7 @@ export const TransactionOverlay: FC = ({ const request = currentStep.prepare.data.request as any const txChainId = request.chainId - const wrongNetwork = txChainId && currentChainId !== txChainId + const wrongNetwork = txChainId && !isConnectedToExecutionChain(chain?.id, txChainId) if (wrongNetwork && txChainId) { try { @@ -427,7 +429,7 @@ export const TransactionOverlay: FC = ({ } if (isCrossChain) { - await handleCreateNotification(result.hash, currentStep.notification, 'submitted') + await handleCreateNotification(result.hash, currentStep.notification, txChainId, 'submitted') if (currentStep.showConfetti) { setTimeout(() => reward(), 100) } @@ -439,17 +441,18 @@ export const TransactionOverlay: FC = ({ setTxHash(result.hash) setOverlayState('pending') - await handleCreateNotification(result.hash, currentStep.notification) + await handleCreateNotification(result.hash, currentStep.notification, txChainId) return } - const gasOverrides: { gas?: bigint } = client - ? await client + const gasEstimateClient = txChainId ? getPublicClient(retrieveConfig(), { chainId: txChainId }) : undefined + const gasOverrides: { gas?: bigint } = gasEstimateClient + ? await gasEstimateClient .estimateContractGas(request) - .then((gasEstimate) => ({ + .then((gasEstimate: bigint) => ({ gas: (gasEstimate * BigInt(110)) / BigInt(100) })) - .catch((error) => { + .catch((error: unknown) => { console.warn('[TransactionOverlay] Gas estimation failed', { step: currentStep.label, error: (error as Error)?.message || error @@ -464,7 +467,7 @@ export const TransactionOverlay: FC = ({ }) setTxHash(hash) setOverlayState('pending') - await handleCreateNotification(hash, currentStep.notification) + await handleCreateNotification(hash, currentStep.notification, txChainId) } catch (error: any) { if (isUserRejectionError(error)) { onClose() @@ -476,8 +479,7 @@ export const TransactionOverlay: FC = ({ } }, [ - client, - currentChainId, + chain?.id, finalizeSuccessState, handleCreateNotification, isLastStep, @@ -517,41 +519,41 @@ export const TransactionOverlay: FC = ({ executeStep() }, [resetTxState, executeStep]) - const waitForAutoContinueBlock = useCallback( - async (executedStepLabel?: string) => { - // Most flows can continue immediately once the next simulation is ready. - // Unstake -> withdraw can race state propagation, so wait one block there. - if (executedStepLabel !== 'Unstake') return + const waitForAutoContinueBlock = useCallback(async (executedStepLabel?: string) => { + // Most flows can continue immediately once the next simulation is ready. + // Unstake -> withdraw can race state propagation, so wait one block there. + if (executedStepLabel !== 'Unstake') return - const executedBlockNumber = executedStepBlockRef.current - if (!client || executedBlockNumber === undefined) return + const executedBlockNumber = executedStepBlockRef.current + const executedChainId = + ((executedStepRef.current?.prepare.data?.request as any)?.chainId as number | undefined) ?? undefined + const blockClient = executedChainId ? getPublicClient(retrieveConfig(), { chainId: executedChainId }) : undefined + if (!blockClient || executedBlockNumber === undefined) return - const targetBlock = executedBlockNumber + 1n - const timeoutMs = 20_000 - const pollIntervalMs = 1_000 - const startedAt = Date.now() + const targetBlock = executedBlockNumber + 1n + const timeoutMs = 20_000 + const pollIntervalMs = 1_000 + const startedAt = Date.now() - while (Date.now() - startedAt < timeoutMs) { - try { - const latestBlock = await client.getBlockNumber() - if (latestBlock >= targetBlock) { - return - } - } catch (error) { - console.warn('[TransactionOverlay] Auto-continue block polling failed', { - step: executedStepRef.current?.label, - error: (error as Error)?.message || error - }) + while (Date.now() - startedAt < timeoutMs) { + try { + const latestBlock = await blockClient.getBlockNumber() + if (latestBlock >= targetBlock) { return } - - await new Promise((resolve) => { - window.setTimeout(resolve, pollIntervalMs) + } catch (error) { + console.warn('[TransactionOverlay] Auto-continue block polling failed', { + step: executedStepRef.current?.label, + error: (error as Error)?.message || error }) + return } - }, - [client] - ) + + await new Promise((resolve) => { + window.setTimeout(resolve, pollIntervalMs) + }) + } + }, []) useEffect(() => { if (step?.prepare.isError) { diff --git a/src/components/pages/vaults/components/widget/shared/WidgetLoadingSkeleton.tsx b/src/components/pages/vaults/components/widget/shared/WidgetLoadingSkeleton.tsx new file mode 100644 index 000000000..b85a75d07 --- /dev/null +++ b/src/components/pages/vaults/components/widget/shared/WidgetLoadingSkeleton.tsx @@ -0,0 +1,24 @@ +import { WidgetHeader } from '@pages/vaults/components/widget/shared/WidgetHeader' +import { cl } from '@shared/utils' +import type { ReactElement, ReactNode } from 'react' + +type TWidgetLoadingSkeletonProps = { + title: string + actions?: ReactNode + disableBorderRadius?: boolean +} + +export function WidgetLoadingSkeleton({ + title, + actions, + disableBorderRadius +}: TWidgetLoadingSkeletonProps): ReactElement { + return ( +
+ +
+
+
+
+ ) +} diff --git a/src/components/pages/vaults/components/widget/shared/usePriceImpactAcceptance.ts b/src/components/pages/vaults/components/widget/shared/usePriceImpactAcceptance.ts new file mode 100644 index 000000000..5098d1081 --- /dev/null +++ b/src/components/pages/vaults/components/widget/shared/usePriceImpactAcceptance.ts @@ -0,0 +1,30 @@ +import { useMemo, useState } from 'react' + +type TPriceImpactAcceptance = { + hasAcceptedPriceImpact: boolean + setAcceptedPriceImpactKey: (key: string | null) => void + priceImpactAcceptanceKey: string +} + +export function usePriceImpactAcceptance( + routeKeyParts: (string | number | bigint | undefined | null)[] +): TPriceImpactAcceptance { + const [acceptedPriceImpactKey, setAcceptedPriceImpactKey] = useState(null) + + // The array parameter IS the useMemo deps — React iterates its elements and + // compares each with Object.is. This works because callers pass inline array + // literals whose elements are primitive (string | number | bigint). + const priceImpactAcceptanceKey = useMemo( + () => routeKeyParts.map((part) => String(part ?? '')).join(':'), + // eslint-disable-next-line react-hooks/exhaustive-deps + routeKeyParts + ) + + const hasAcceptedPriceImpact = acceptedPriceImpactKey === priceImpactAcceptanceKey + + return { + hasAcceptedPriceImpact, + setAcceptedPriceImpactKey, + priceImpactAcceptanceKey + } +} diff --git a/src/components/pages/vaults/components/widget/shared/useWidgetContext.ts b/src/components/pages/vaults/components/widget/shared/useWidgetContext.ts new file mode 100644 index 000000000..d73231c69 --- /dev/null +++ b/src/components/pages/vaults/components/widget/shared/useWidgetContext.ts @@ -0,0 +1,44 @@ +import { usePlausible } from '@hooks/usePlausible' +import { useEnsoEnabled } from '@pages/vaults/hooks/useEnsoEnabled' +import { useWallet } from '@shared/contexts/useWallet' +import { useWeb3 } from '@shared/contexts/useWeb3' +import { useYearn } from '@shared/contexts/useYearn' +import { useAccount } from 'wagmi' + +type TUseWidgetContextParams = { + chainId: number + vaultAddress: `0x${string}` +} + +type TWidgetContext = { + account: `0x${string}` | undefined + openLoginModal: (() => void) | undefined + refreshWalletBalances: ReturnType['onRefresh'] + getToken: ReturnType['getToken'] + zapSlippage: number + isAutoStakingEnabled: boolean + getPrice: ReturnType['getPrice'] + trackEvent: ReturnType + ensoEnabled: boolean +} + +export function useWidgetContext({ chainId, vaultAddress }: TUseWidgetContextParams): TWidgetContext { + const { address: account } = useAccount() + const { openLoginModal } = useWeb3() + const { onRefresh: refreshWalletBalances, getToken } = useWallet() + const { zapSlippage, isAutoStakingEnabled, getPrice } = useYearn() + const trackEvent = usePlausible() + const ensoEnabled = useEnsoEnabled({ chainId, vaultAddress }) + + return { + account, + openLoginModal, + refreshWalletBalances, + getToken, + zapSlippage, + isAutoStakingEnabled, + getPrice, + trackEvent, + ensoEnabled + } +} diff --git a/src/components/pages/vaults/components/widget/tokenSelectorList.utils.test.ts b/src/components/pages/vaults/components/widget/tokenSelectorList.utils.test.ts deleted file mode 100644 index d0027d78e..000000000 --- a/src/components/pages/vaults/components/widget/tokenSelectorList.utils.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import type { TToken } from '@shared/types' -import { describe, expect, it } from 'vitest' -import { - filterAndSortTokenSelectorTokens, - getDepositMinValueExemptTokenAddresses, - getExplicitTokenAddresses -} from './tokenSelectorList.utils' - -const TOKEN_A = '0x0000000000000000000000000000000000000001' as const -const TOKEN_B = '0x0000000000000000000000000000000000000002' as const -const TOKEN_C = '0x0000000000000000000000000000000000000003' as const - -function buildToken({ - address, - name, - symbol, - value = 0, - normalizedBalance = 0, - rawBalance = 0n -}: { - address: `0x${string}` - name: string - symbol: string - value?: number - normalizedBalance?: number - rawBalance?: bigint -}): TToken { - return { - address, - name, - symbol, - decimals: 18, - chainID: 1, - logoURI: undefined, - value, - balance: { - raw: rawBalance, - normalized: normalizedBalance, - display: String(normalizedBalance), - decimals: 18 - } - } -} - -describe('filterAndSortTokenSelectorTokens', () => { - it('puts configured top tokens first before sorting the rest by derived USD value', () => { - const tokens = [ - buildToken({ - address: TOKEN_A, - name: 'High Raw Balance', - symbol: 'RAW', - normalizedBalance: 100, - rawBalance: 100n - }), - buildToken({ - address: TOKEN_B, - name: 'High USD Value', - symbol: 'USD', - value: 150, - normalizedBalance: 5, - rawBalance: 5n - }), - buildToken({ - address: TOKEN_C, - name: 'No Price', - symbol: 'NOP', - normalizedBalance: 200, - rawBalance: 200n - }) - ] - - const filtered = filterAndSortTokenSelectorTokens({ - tokens, - mode: 'deposit', - topTokenAddresses: [TOKEN_A], - yearnKnownTokenAddresses: new Set([TOKEN_A.toLowerCase(), TOKEN_B.toLowerCase()]), - explicitTokenAddresses: new Set(), - getTokenUsdValue: (token) => { - if (token.address === TOKEN_A) { - return 20 - } - if (token.address === TOKEN_B) { - return 150 - } - return 0 - } - }) - - expect(filtered.map((token) => token.address)).toEqual([TOKEN_A, TOKEN_B]) - }) - - it('hides tokens below the minimum USD value threshold unless they are explicit', () => { - const filtered = filterAndSortTokenSelectorTokens({ - tokens: [ - buildToken({ - address: TOKEN_A, - name: 'Dust Token', - symbol: 'DST', - normalizedBalance: 10, - rawBalance: 10n - }), - buildToken({ - address: TOKEN_B, - name: 'Visible Token', - symbol: 'VIS', - normalizedBalance: 2, - rawBalance: 2n - }) - ], - mode: 'deposit', - yearnKnownTokenAddresses: new Set([TOKEN_A.toLowerCase(), TOKEN_B.toLowerCase()]), - explicitTokenAddresses: new Set(), - getTokenUsdValue: (token) => { - if (token.address === TOKEN_A) { - return 0.009 - } - return 0.02 - } - }) - - expect(filtered.map((token) => token.address)).toEqual([TOKEN_B]) - }) - - it('keeps supported deposit tokens with positive balances visible when pricing is temporarily unavailable', () => { - const filtered = filterAndSortTokenSelectorTokens({ - tokens: [ - buildToken({ - address: TOKEN_A, - name: 'Known Unpriced Token', - symbol: 'KUT', - normalizedBalance: 10, - rawBalance: 10n - }), - buildToken({ - address: TOKEN_B, - name: 'Unknown Unpriced Token', - symbol: 'UUT', - normalizedBalance: 10, - rawBalance: 10n - }) - ], - mode: 'deposit', - yearnKnownTokenAddresses: new Set([TOKEN_A.toLowerCase()]), - explicitTokenAddresses: new Set(), - getTokenUsdValue: () => 0 - }) - - expect(filtered.map((token) => token.address)).toEqual([TOKEN_A]) - }) - - it('still hides known deposit-token dust when a valid price puts it below the minimum threshold', () => { - const filtered = filterAndSortTokenSelectorTokens({ - tokens: [ - buildToken({ - address: TOKEN_A, - name: 'Known Dust Token', - symbol: 'KDT', - normalizedBalance: 10, - rawBalance: 10n - }) - ], - mode: 'deposit', - yearnKnownTokenAddresses: new Set([TOKEN_A.toLowerCase()]), - explicitTokenAddresses: new Set(), - getTokenUsdValue: () => 0.009 - }) - - expect(filtered).toEqual([]) - }) - - it('keeps an explicit custom token visible even when it is below the minimum USD value', () => { - const explicitTokenAddresses = getExplicitTokenAddresses({ - customAddress: TOKEN_A - }) - - const filtered = filterAndSortTokenSelectorTokens({ - tokens: [ - buildToken({ - address: TOKEN_A, - name: 'Unknown Token', - symbol: 'UNK', - normalizedBalance: 10, - rawBalance: 10n - }) - ], - mode: 'deposit', - searchText: TOKEN_A, - yearnKnownTokenAddresses: new Set(), - explicitTokenAddresses, - getTokenUsdValue: () => 0.009 - }) - - expect(filtered.map((token) => token.address)).toEqual([TOKEN_A]) - }) - - it('keeps the deposit asset exempt on its own chain even when the selector was opened from another chain', () => { - const minValueExemptTokenAddresses = getDepositMinValueExemptTokenAddresses({ - value: TOKEN_B, - assetAddress: TOKEN_A, - selectedChainId: 1, - assetChainId: 1 - }) - - expect(minValueExemptTokenAddresses.has(TOKEN_A.toLowerCase())).toBe(true) - expect(minValueExemptTokenAddresses.has(TOKEN_B.toLowerCase())).toBe(true) - - const mismatchedChainExemptions = getDepositMinValueExemptTokenAddresses({ - value: TOKEN_B, - assetAddress: TOKEN_A, - selectedChainId: 10, - assetChainId: 1 - }) - - expect(mismatchedChainExemptions.has(TOKEN_A.toLowerCase())).toBe(false) - expect(mismatchedChainExemptions.has(TOKEN_B.toLowerCase())).toBe(true) - }) - - it('hides low-value withdraw tokens unless they are explicit common options', () => { - const filtered = filterAndSortTokenSelectorTokens({ - tokens: [ - buildToken({ - address: TOKEN_A, - name: 'Dust Token', - symbol: 'DST', - normalizedBalance: 10, - rawBalance: 10n - }), - buildToken({ - address: TOKEN_B, - name: 'Pinned Common Token', - symbol: 'PIN', - normalizedBalance: 1, - rawBalance: 1n - }), - buildToken({ - address: TOKEN_C, - name: 'Visible Token', - symbol: 'VIS', - normalizedBalance: 2, - rawBalance: 2n - }) - ], - mode: 'withdraw', - explicitTokenAddresses: new Set([TOKEN_B.toLowerCase()]), - getTokenUsdValue: (token) => { - if (token.address === TOKEN_C) { - return 0.02 - } - return 0.009 - } - }) - - expect(filtered.map((token) => token.address)).toEqual([TOKEN_C, TOKEN_B]) - }) -}) diff --git a/src/components/pages/vaults/components/widget/withdraw/index.tsx b/src/components/pages/vaults/components/widget/withdraw/index.tsx index c69abe5cd..742cad3aa 100644 --- a/src/components/pages/vaults/components/widget/withdraw/index.tsx +++ b/src/components/pages/vaults/components/widget/withdraw/index.tsx @@ -1,10 +1,5 @@ -import { usePlausible } from '@hooks/usePlausible' import { useDebouncedInput } from '@pages/vaults/hooks/useDebouncedInput' -import { useEnsoEnabled } from '@pages/vaults/hooks/useEnsoEnabled' import { Button } from '@shared/components/Button' -import { useWallet } from '@shared/contexts/useWallet' -import { useWeb3 } from '@shared/contexts/useWeb3' -import { useYearn } from '@shared/contexts/useYearn' import { IconChevron } from '@shared/icons/IconChevron' import { IconCross } from '@shared/icons/IconCross' import { IconSettings } from '@shared/icons/IconSettings' @@ -13,15 +8,18 @@ import { PLAUSIBLE_EVENTS } from '@shared/utils/plausible' import type { ReactElement, ReactNode } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { formatUnits } from 'viem' -import { useAccount } from 'wagmi' import { ApprovalOverlay } from '../deposit/ApprovalOverlay' import { InputTokenAmount } from '../InputTokenAmount' import { SettingsPanel } from '../SettingsPanel' +import { PriceImpactWarning } from '../shared/PriceImpactWarning' import { TokenSelectorOverlay } from '../shared/TokenSelectorOverlay' import { TransactionOverlay, type TransactionStep } from '../shared/TransactionOverlay' +import { usePriceImpactAcceptance } from '../shared/usePriceImpactAcceptance' import { useResetEnsoSelection } from '../shared/useResetEnsoSelection' +import { useWidgetContext } from '../shared/useWidgetContext' import { formatWidgetAllowance, formatWidgetValue } from '../shared/valueDisplay' import { WidgetHeader } from '../shared/WidgetHeader' +import { WidgetLoadingSkeleton } from '../shared/WidgetLoadingSkeleton' import { getPriorityTokens } from './constants' import { SourceSelector } from './SourceSelector' import type { WithdrawalSource, WithdrawWidgetProps } from './types' @@ -124,29 +122,16 @@ export function WidgetWithdraw({ contentBelowInput, hideContainerBorder = false }: WidgetWithdrawProps): ReactElement { - const { address: account } = useAccount() - const { openLoginModal } = useWeb3() - const { onRefresh: refreshWalletBalances, getToken } = useWallet() - const { zapSlippage, getPrice } = useYearn() - const trackEvent = usePlausible() - const ensoEnabled = useEnsoEnabled({ chainId, vaultAddress }) + const { account, openLoginModal, refreshWalletBalances, getToken, zapSlippage, getPrice, trackEvent, ensoEnabled } = + useWidgetContext({ chainId, vaultAddress }) + const resolvedDisplayAssetAddress = displayAssetAddress ?? assetAddress - const [selectedToken, setSelectedToken] = useState<`0x${string}` | undefined>( - prefill?.address ?? resolvedDisplayAssetAddress - ) - const [selectedChainId, setSelectedChainId] = useState(prefill?.chainId) const [showWithdrawDetailsModal, setShowWithdrawDetailsModal] = useState(false) const [showApprovalOverlay, setShowApprovalOverlay] = useState(false) - const [showTokenSelector, setShowTokenSelector] = useState(false) const [showTransactionOverlay, setShowTransactionOverlay] = useState(false) const [withdrawalSource, setWithdrawalSource] = useState(stakingAddress ? null : 'vault') const [isDetailsPanelOpen, setIsDetailsPanelOpen] = useState(false) - const [priceImpactAcceptance, setPriceImpactAcceptance] = useState<{ key: string; isAccepted: boolean }>({ - key: '', - isAccepted: false - }) - const appliedPrefillRef = useRef(null) const [fallbackStep, setFallbackStep] = useState<'unstake' | 'withdraw'>('unstake') const [redeemSharesOverride, setRedeemSharesOverride] = useState(0n) const [awaitingPostUnstakeShares, setAwaitingPostUnstakeShares] = useState(false) @@ -166,6 +151,41 @@ export function WidgetWithdraw({ const priorityTokens = getPriorityTokens(chainId, vaultAddress, stakingAddress) + const [selectedToken, setSelectedToken] = useState<`0x${string}` | undefined>( + prefill?.address ?? resolvedDisplayAssetAddress + ) + const [selectedChainId, setSelectedChainId] = useState(prefill?.chainId) + const [showTokenSelector, setShowTokenSelector] = useState(false) + const appliedPrefillRef = useRef(null) + + const withdrawInput = useDebouncedInput(assetToken?.decimals ?? 18) + const [withdrawAmount, , setWithdrawInput] = withdrawInput + + useResetEnsoSelection({ + ensoEnabled, + selectedToken, + selectedChainId, + assetAddress: resolvedDisplayAssetAddress, + chainId, + showTokenSelector, + setSelectedToken, + setSelectedChainId, + setShowTokenSelector + }) + + useEffect(() => { + if (!prefill) return + const key = `${prefillRequestKey ?? ''}-${prefill.address}-${prefill.chainId}-${prefill.amount}` + if (appliedPrefillRef.current === key) return + appliedPrefillRef.current = key + setSelectedToken(prefill.address) + setSelectedChainId(prefill.chainId) + if (prefill.amount !== undefined) { + setWithdrawInput(prefill.amount) + } + onPrefillApplied?.() + }, [prefill, prefillRequestKey, setWithdrawInput, onPrefillApplied]) + // Derived token values const withdrawToken = selectedToken || resolvedDisplayAssetAddress const destinationChainId = selectedChainId || chainId @@ -194,17 +214,13 @@ export function WidgetWithdraw({ const hasBothBalances = hasVaultBalance && hasStakingBalance const singleSource = resolveWithdrawalSource(hasVaultBalance, hasStakingBalance) - useEffect(() => { - if (singleSource) { - setWithdrawalSource(singleSource) - } - }, [singleSource]) - - useEffect(() => { - if (!collapseDetails && isDetailsPanelOpen) { - setIsDetailsPanelOpen(false) - } - }, [collapseDetails, isDetailsPanelOpen]) + // Render-time state adjustments + if (singleSource && withdrawalSource !== singleSource) { + setWithdrawalSource(singleSource) + } + if (!collapseDetails && isDetailsPanelOpen) { + setIsDetailsPanelOpen(false) + } useResetEnsoSelection({ ensoEnabled, @@ -253,22 +269,6 @@ export function WidgetWithdraw({ return toNormalizedBN(underlyingAmount, assetToken.decimals ?? 18) }, [sourceVaultSharesRaw, pricePerShare, vaultDecimals, assetToken]) - const withdrawInput = useDebouncedInput(assetToken?.decimals ?? 18) - const [withdrawAmount, , setWithdrawInput] = withdrawInput - - useEffect(() => { - if (!prefill) return - const key = `${prefillRequestKey ?? ''}-${prefill.address}-${prefill.chainId}-${prefill.amount}` - if (appliedPrefillRef.current === key) return - appliedPrefillRef.current = key - setSelectedToken(prefill.address) - setSelectedChainId(prefill.chainId) - if (prefill.amount !== undefined) { - setWithdrawInput(prefill.amount) - } - onPrefillApplied?.() - }, [prefill, prefillRequestKey, setWithdrawInput, onPrefillApplied]) - useEffect(() => { onAmountChange?.(withdrawAmount.bn) }, [withdrawAmount.bn, onAmountChange]) @@ -369,11 +369,10 @@ export function WidgetWithdraw({ : directWithdrawFlow.actions.prepareWithdraw const effectiveWithdrawAmountRaw = expectedOutOverride ?? withdrawAmount.bn - useEffect(() => { - if (optimisticApprovedShares !== null && activeFlow.periphery.allowance >= optimisticApprovedShares) { - setOptimisticApprovedShares(null) - } - }, [activeFlow.periphery.allowance, optimisticApprovedShares]) + // Render-time adjustment: clear optimistic approval when actual allowance catches up + if (optimisticApprovedShares !== null && activeFlow.periphery.allowance >= optimisticApprovedShares) { + setOptimisticApprovedShares(null) + } useEffect(() => { if (optimisticApprovedShares === null) return @@ -493,31 +492,17 @@ export function WidgetWithdraw({ outputTokenPrice ]) - const priceImpactAcceptanceKey = useMemo(() => { - return [ - withdrawAmount.bn.toString(), - effectiveRequiredShares.toString(), - routeType, - withdrawalSource ?? '', - sourceToken, - withdrawToken, - destinationChainId, - activeFlow.periphery.routerAddress ?? '', - effectiveExpectedOut.toString() - ].join(':') - }, [ + const { hasAcceptedPriceImpact, priceImpactAcceptanceKey, setAcceptedPriceImpactKey } = usePriceImpactAcceptance([ withdrawAmount.bn, effectiveRequiredShares, routeType, - withdrawalSource, + withdrawalSource ?? '', sourceToken, withdrawToken, destinationChainId, - activeFlow.periphery.routerAddress, + activeFlow.periphery.routerAddress ?? '', effectiveExpectedOut ]) - const hasAcceptedPriceImpact = - priceImpactAcceptance.key === priceImpactAcceptanceKey && priceImpactAcceptance.isAccepted const canOpenTokenSelector = ensoEnabled && !disableTokenSelector const shouldShowZapUi = !isBaseWithdrawToken @@ -703,14 +688,7 @@ export function WidgetWithdraw({ ]) if (isLoadingVaultData) { - return ( -
- -
-
-
-
- ) + return } // ============================================================================ @@ -725,7 +703,6 @@ export function WidgetWithdraw({ setWithdrawInput(formatUnits(underlyingAmount, assetToken?.decimals ?? 18)) } : undefined - const showSettingsButton = !!account && !!onOpenSettings const zapNotificationText = getZapNotificationText(isUnstake, shouldShowZapUi) const onRemoveZap = canOpenTokenSelector ? (): void => { @@ -764,32 +741,40 @@ export function WidgetWithdraw({ /> ) - const priceImpactWarning = priceImpactInfo.isHigh && - !isFetchingQuote && - !withdrawAmount.isDebouncing && - withdrawAmount.bn === withdrawAmount.debouncedBn && - withdrawAmount.bn > 0n && ( -
-

- Price impact is high ({priceImpactInfo.percentage.toFixed(2)}%). Consider withdrawing less or waiting for - better liquidity conditions. -

- -
- ) + const priceImpactWarning = ( + 0n} + hasAcceptedPriceImpact={hasAcceptedPriceImpact} + priceImpactAcceptanceKey={priceImpactAcceptanceKey} + setAcceptedPriceImpactKey={setAcceptedPriceImpactKey} + actionVerb="withdrawing" + /> + ) + + const showSettingsButton = !!account && !!onOpenSettings + const withdrawButtonLabel = getWithdrawCtaLabel({ + isFetchingQuote, + showApprove: approvalState.hasApprovalStep, + isAllowanceSufficient: approvalState.isAllowanceSufficient, + transactionName + }) + const isWithdrawButtonDisabled = + isWithdrawCtaDisabled({ + hasError: !!effectiveWithdrawError || isActionDisabled, + withdrawAmountRaw: withdrawAmount.bn, + isFetchingQuote, + isDebouncing: withdrawAmount.isDebouncing, + showApprove: approvalState.hasApprovalStep, + isAllowanceSufficient: approvalState.isAllowanceSufficient, + prepareApproveEnabled: Boolean(activeFlow.periphery.prepareApproveEnabled), + prepareWithdrawEnabled: Boolean(activeFlow.periphery.prepareWithdrawEnabled) + }) || + (priceImpactInfo.isHigh && !hasAcceptedPriceImpact) const actionRow = (
@@ -810,28 +795,11 @@ export function WidgetWithdraw({ onClick={handleOpenTransactionOverlay} variant={isFetchingQuote ? 'busy' : 'filled'} isBusy={isFetchingQuote} - disabled={ - isWithdrawCtaDisabled({ - hasError: !!effectiveWithdrawError || isActionDisabled, - withdrawAmountRaw: withdrawAmount.bn, - isFetchingQuote, - isDebouncing: withdrawAmount.isDebouncing, - showApprove: approvalState.hasApprovalStep, - isAllowanceSufficient: approvalState.isAllowanceSufficient, - prepareApproveEnabled: Boolean(activeFlow.periphery.prepareApproveEnabled), - prepareWithdrawEnabled: Boolean(activeFlow.periphery.prepareWithdrawEnabled) - }) || - (priceImpactInfo.isHigh && !hasAcceptedPriceImpact) - } + disabled={isWithdrawButtonDisabled} className="w-full" classNameOverride="yearn--button--nextgen w-full" > - {getWithdrawCtaLabel({ - isFetchingQuote, - showApprove: approvalState.hasApprovalStep, - isAllowanceSufficient: approvalState.isAllowanceSufficient, - transactionName - })} + {withdrawButtonLabel} )}
@@ -1002,9 +970,9 @@ export function WidgetWithdraw({ isOpen={showTokenSelector} onClose={() => setShowTokenSelector(false)} onChange={(address, chainIdValue) => { + setWithdrawInput('') setSelectedToken(address) setSelectedChainId(chainIdValue) - setWithdrawInput('') setShowTokenSelector(false) activeFlow.periphery.resetQuote?.() }} diff --git a/src/components/pages/vaults/components/widget/withdraw/useWithdrawRoute.test.ts b/src/components/pages/vaults/components/widget/withdraw/useWithdrawRoute.test.ts deleted file mode 100644 index 48e27065d..000000000 --- a/src/components/pages/vaults/components/widget/withdraw/useWithdrawRoute.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { Address } from 'viem' -import { describe, expect, it } from 'vitest' -import { resolveWithdrawRouteType } from './useWithdrawRoute' - -const ASSET = '0x0000000000000000000000000000000000000001' as Address -const OTHER = '0x0000000000000000000000000000000000000002' as Address - -describe('resolveWithdrawRouteType', () => { - it('returns DIRECT_UNSTAKE when withdrawing as unstake flow', () => { - const route = resolveWithdrawRouteType({ - withdrawToken: ASSET, - assetAddress: ASSET, - withdrawalSource: 'staking', - chainId: 1, - outputChainId: 1, - isUnstake: true, - ensoEnabled: false - }) - - expect(route).toBe('DIRECT_UNSTAKE') - }) - - it('returns DIRECT_WITHDRAW for same-asset vault withdrawals on same chain', () => { - const route = resolveWithdrawRouteType({ - withdrawToken: ASSET, - assetAddress: ASSET, - withdrawalSource: 'vault', - chainId: 1, - outputChainId: 1, - isUnstake: false, - ensoEnabled: true - }) - - expect(route).toBe('DIRECT_WITHDRAW') - }) - - it('returns ENSO for non-direct routes when Enso is enabled', () => { - const route = resolveWithdrawRouteType({ - withdrawToken: OTHER, - assetAddress: ASSET, - withdrawalSource: 'vault', - chainId: 1, - outputChainId: 1, - isUnstake: false, - ensoEnabled: true - }) - - expect(route).toBe('ENSO') - }) - - it('returns DIRECT_WITHDRAW for non-direct routes when Enso is disabled', () => { - const route = resolveWithdrawRouteType({ - withdrawToken: OTHER, - assetAddress: ASSET, - withdrawalSource: 'vault', - chainId: 1, - outputChainId: 10, - isUnstake: false, - ensoEnabled: false - }) - - expect(route).toBe('DIRECT_WITHDRAW') - }) -}) diff --git a/src/components/pages/vaults/components/widget/withdraw/withdrawStepHelpers.test.ts b/src/components/pages/vaults/components/widget/withdraw/withdrawStepHelpers.test.ts deleted file mode 100644 index efd0d051a..000000000 --- a/src/components/pages/vaults/components/widget/withdraw/withdrawStepHelpers.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - buildWithdrawTransactionStep, - getWithdrawCtaLabel, - getWithdrawTransactionName, - isWithdrawCtaDisabled, - isWithdrawLastStep -} from './withdrawStepHelpers' - -const mockPrepare = { isSuccess: true, data: { request: {} } } as any - -describe('withdrawStepHelpers', () => { - it('returns transaction names for route types', () => { - expect(getWithdrawTransactionName('DIRECT_WITHDRAW', false)).toBe('Withdraw') - expect(getWithdrawTransactionName('DIRECT_UNSTAKE', false)).toBe('Unstake') - expect(getWithdrawTransactionName('DIRECT_UNSTAKE_WITHDRAW', false)).toBe('Unstake & Withdraw') - expect(getWithdrawTransactionName('ENSO', true)).toBe('Fetching quote') - }) - - it('builds approval step when approval is required', () => { - const step = buildWithdrawTransactionStep({ - needsApproval: true, - approvePrepare: mockPrepare, - activeWithdrawPrepare: mockPrepare, - fallbackStep: 'unstake', - routeType: 'ENSO', - isCrossChain: false, - formattedApprovalAmount: '1.23', - formattedRequiredShares: '1.23', - formattedWithdrawAmount: '1.23', - approvalTokenSymbol: 'yvUSDC', - withdrawNotificationParams: undefined - }) - - expect(step?.label).toBe('Approve') - expect(step?.confirmMessage).toContain('Approving 1.23 yvUSDC') - }) - - it('builds unstake and withdraw fallback steps', () => { - const unstakeStep = buildWithdrawTransactionStep({ - needsApproval: false, - activeWithdrawPrepare: mockPrepare, - directUnstakePrepare: mockPrepare, - directWithdrawPrepare: mockPrepare, - fallbackStep: 'unstake', - routeType: 'DIRECT_UNSTAKE_WITHDRAW', - isCrossChain: false, - formattedApprovalAmount: '1.00', - formattedRequiredShares: '2.00', - formattedWithdrawAmount: '3.00', - stakingTokenSymbol: 'st-yvUSDC', - assetTokenSymbol: 'USDC', - withdrawNotificationParams: undefined - }) - - const withdrawStep = buildWithdrawTransactionStep({ - needsApproval: false, - activeWithdrawPrepare: mockPrepare, - directUnstakePrepare: mockPrepare, - directWithdrawPrepare: mockPrepare, - fallbackStep: 'withdraw', - routeType: 'DIRECT_UNSTAKE_WITHDRAW', - isCrossChain: false, - formattedApprovalAmount: '1.00', - formattedRequiredShares: '2.00', - formattedWithdrawAmount: '3.00', - stakingTokenSymbol: 'st-yvUSDC', - assetTokenSymbol: 'USDC', - withdrawNotificationParams: undefined - }) - - expect(unstakeStep?.label).toBe('Unstake') - expect(withdrawStep?.label).toBe('Withdraw') - }) - - it('builds cross-chain success messaging for regular routes', () => { - const step = buildWithdrawTransactionStep({ - needsApproval: false, - activeWithdrawPrepare: mockPrepare, - fallbackStep: 'unstake', - routeType: 'ENSO', - isCrossChain: true, - formattedApprovalAmount: '1.00', - formattedRequiredShares: '2.00', - formattedWithdrawAmount: '3.00', - assetTokenSymbol: 'USDC', - withdrawNotificationParams: undefined - }) - - expect(step?.successTitle).toBe('Transaction Submitted') - }) - - it('computes last step state correctly', () => { - expect( - isWithdrawLastStep({ - currentStep: undefined, - needsApproval: false, - routeType: 'ENSO' - }) - ).toBe(true) - - expect( - isWithdrawLastStep({ - currentStep: { - prepare: mockPrepare, - label: 'Approve', - confirmMessage: '', - successTitle: '', - successMessage: '' - }, - needsApproval: true, - routeType: 'ENSO' - }) - ).toBe(false) - - expect( - isWithdrawLastStep({ - currentStep: { - prepare: mockPrepare, - label: 'Unstake', - confirmMessage: '', - successTitle: '', - successMessage: '' - }, - needsApproval: false, - routeType: 'DIRECT_UNSTAKE_WITHDRAW' - }) - ).toBe(false) - - expect( - isWithdrawLastStep({ - currentStep: { - prepare: mockPrepare, - label: 'Withdraw', - confirmMessage: '', - successTitle: '', - successMessage: '' - }, - needsApproval: false, - routeType: 'DIRECT_UNSTAKE_WITHDRAW' - }) - ).toBe(true) - }) - - it('computes CTA disabled state and label', () => { - expect( - isWithdrawCtaDisabled({ - hasError: false, - withdrawAmountRaw: 1n, - isFetchingQuote: false, - isDebouncing: false, - showApprove: true, - isAllowanceSufficient: false, - prepareApproveEnabled: false, - prepareWithdrawEnabled: true - }) - ).toBe(true) - - expect( - isWithdrawCtaDisabled({ - hasError: false, - withdrawAmountRaw: 1n, - isFetchingQuote: false, - isDebouncing: false, - showApprove: false, - isAllowanceSufficient: true, - prepareApproveEnabled: false, - prepareWithdrawEnabled: true - }) - ).toBe(false) - - expect( - getWithdrawCtaLabel({ - isFetchingQuote: false, - showApprove: true, - isAllowanceSufficient: false, - transactionName: 'Withdraw' - }) - ).toBe('Approve & Withdraw') - }) -}) diff --git a/src/components/pages/vaults/components/widget/yvUSD/YvUsdDeposit.test.tsx b/src/components/pages/vaults/components/widget/yvUSD/YvUsdDeposit.test.tsx deleted file mode 100644 index 81f118bc2..000000000 --- a/src/components/pages/vaults/components/widget/yvUSD/YvUsdDeposit.test.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { scheduleAdditionalYvUsdDepositRefetch, shouldRefetchUnlockedAfterYvUsdDeposit } from './YvUsdDeposit.helpers' - -describe('YvUsdDeposit', () => { - beforeEach(() => { - vi.useRealTimers() - }) - - it('defers the unlocked branch refetch after a locked deposit success', () => { - vi.useFakeTimers() - const unlockedRefetch = vi.fn() - scheduleAdditionalYvUsdDepositRefetch('locked', unlockedRefetch) - - expect(unlockedRefetch).not.toHaveBeenCalled() - - vi.runAllTimers() - - expect(unlockedRefetch).toHaveBeenCalledTimes(1) - }) - - it('does not schedule an extra refetch after an unlocked deposit success', () => { - vi.useFakeTimers() - const unlockedRefetch = vi.fn() - scheduleAdditionalYvUsdDepositRefetch('unlocked', unlockedRefetch) - - vi.runAllTimers() - - expect(unlockedRefetch).not.toHaveBeenCalled() - }) - - it('only schedules the extra unlocked refetch for locked deposits', () => { - expect(shouldRefetchUnlockedAfterYvUsdDeposit('locked')).toBe(true) - expect(shouldRefetchUnlockedAfterYvUsdDeposit('unlocked')).toBe(false) - expect(shouldRefetchUnlockedAfterYvUsdDeposit(null)).toBe(false) - }) -}) diff --git a/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.helpers.test.ts b/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.helpers.test.ts deleted file mode 100644 index 790adf034..000000000 --- a/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.helpers.test.ts +++ /dev/null @@ -1,522 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - buildLockedWithdrawNoZapExecutionPlan, - buildLockedWithdrawTransactionStep, - getLockedCooldownMaxAssetAmount, - getLockedCooldownMaxDisplayAmount, - resolveCooldownSharesToStart, - resolveLockedRedeemAssets, - resolveLockedRequestedAmountFromInput, - resolveLockedRequestedWithdrawAssets, - resolveLockedRequestedWithdrawShares, - resolveLockedWithdrawDisplayAmount, - resolveLockedWithdrawExecutionSnapshot, - resolveLockedWithdrawExpectedOut, - resolveLockedWithdrawMethod, - shouldNormalizeLockedWithdrawDisplayAmount, - shouldUseLockedManagedWithdrawFlow -} from './YvUsdWithdraw.helpers' - -const TOKEN_A = '0x0000000000000000000000000000000000000001' as const -const TOKEN_B = '0x0000000000000000000000000000000000000002' as const - -const mockPrepare = (functionName: string, args: readonly unknown[]) => - ({ - isSuccess: true, - data: { - request: { - functionName, - args - } - } - }) as any - -describe('resolveLockedWithdrawDisplayAmount', () => { - it('uses previewRedeem for the displayed max when it is available', () => { - expect( - resolveLockedWithdrawDisplayAmount({ - maxWithdrawAssets: 50_000000000000000000n, - previewRedeemAssets: 51_000000n, - unlockedPricePerShare: 1_020000n, - unlockedVaultDecimals: 18 - }) - ).toBe(51_000000n) - }) - - it('falls back to price-per-share conversion when previewRedeem is unavailable', () => { - expect( - resolveLockedWithdrawDisplayAmount({ - maxWithdrawAssets: 50_000000000000000000n, - unlockedPricePerShare: 1_020000n, - unlockedVaultDecimals: 18 - }) - ).toBe(51_000000n) - }) -}) - -describe('resolveLockedRequestedWithdrawAssets', () => { - it('snaps an exact max input back to the exact locked asset cap', () => { - expect( - resolveLockedRequestedWithdrawAssets({ - requestedDisplayAmount: 51_000000n, - maxDisplayAmount: 51_000000n, - maxWithdrawAssets: 50_000000000000000000n, - previewWithdrawShares: 49_999999999999999999n - }) - ).toBe(50_000000000000000000n) - }) - - it('uses previewWithdraw for partial requests and clamps them to the cap', () => { - expect( - resolveLockedRequestedWithdrawAssets({ - requestedDisplayAmount: 25_000000n, - maxDisplayAmount: 51_000000n, - maxWithdrawAssets: 50_000000000000000000n, - previewWithdrawShares: 60_000000000000000000n - }) - ).toBe(50_000000000000000000n) - }) - - it('keeps over-limit withdraw inputs above the cap so the widget can show an error instead of snapping to max', () => { - expect( - resolveLockedRequestedWithdrawAssets({ - requestedDisplayAmount: 52_000000n, - maxDisplayAmount: 51_000000n, - maxWithdrawAssets: 50_000000000000000000n, - previewWithdrawShares: 51_500000000000000000n - }) - ).toBe(51_500000000000000000n) - }) -}) - -describe('resolveLockedRequestedWithdrawShares', () => { - it('snaps an exact max asset request back to the exact redeemable share cap', () => { - expect( - resolveLockedRequestedWithdrawShares({ - requestedLockedAssets: 50_000000000000000000n, - maxWithdrawAssets: 50_000000000000000000n, - maxRedeemShares: 49_999999999999999999n, - previewWithdrawShares: 49_900000000000000000n, - lockedPricePerShare: 1_000000000000000000n, - lockedVaultTokenDecimals: 18 - }) - ).toBe(49_999999999999999999n) - }) - - it('uses previewWithdraw shares for partial requests and clamps them to maxRedeem', () => { - expect( - resolveLockedRequestedWithdrawShares({ - requestedLockedAssets: 25_000000000000000000n, - maxWithdrawAssets: 50_000000000000000000n, - maxRedeemShares: 49_000000000000000000n, - previewWithdrawShares: 60_000000000000000000n, - lockedPricePerShare: 1_000000000000000000n, - lockedVaultTokenDecimals: 18 - }) - ).toBe(49_000000000000000000n) - }) -}) - -describe('cooldown max selection', () => { - it('derives the exact cooldown asset cap from the locked share balance', () => { - expect( - getLockedCooldownMaxAssetAmount({ - lockedWalletShares: 50_000000000000000000n, - lockedPricePerShare: 1_000000000000000000n, - lockedVaultTokenDecimals: 18 - }) - ).toBe(50_000000000000000000n) - }) - - it('derives the displayed cooldown max amount from the exact cooldown asset cap', () => { - expect( - getLockedCooldownMaxDisplayAmount({ - maxCooldownAssetAmount: 50_000000000000000000n, - isLockedUnderlyingDisplay: true, - unlockedPricePerShare: 1_020000n, - unlockedVaultDecimals: 18 - }) - ).toBe(51_000000n) - }) - - it('snaps a pre-cooldown max input back to the exact cooldown asset cap', () => { - expect( - resolveLockedRequestedAmountFromInput({ - amount: 51_000000n, - inputUnit: 'underlying', - canWithdrawNow: false, - needsCooldownStart: true, - maxCooldownDisplayAmount: 51_000000n, - maxCooldownAssetAmount: 50_000000000000000000n, - previewWithdrawLockedAssets: 49_999999999999999999n, - lockedDisplayPricePerShare: 1_020000n, - lockedVaultTokenDecimals: 18, - unlockedPricePerShare: 1_020000n, - unlockedVaultDecimals: 18 - }) - ).toBe(50_000000000000000000n) - }) - - it('uses previewWithdraw for partial pre-cooldown underlying input when available', () => { - expect( - resolveLockedRequestedAmountFromInput({ - amount: 25_000000n, - inputUnit: 'underlying', - canWithdrawNow: false, - needsCooldownStart: true, - maxCooldownDisplayAmount: 51_000000n, - maxCooldownAssetAmount: 50_000000000000000000n, - previewWithdrawLockedAssets: 24_600000000000000000n, - lockedDisplayPricePerShare: 1_020000n, - lockedVaultTokenDecimals: 18, - unlockedPricePerShare: 1_020000n, - unlockedVaultDecimals: 18 - }) - ).toBe(24_600000000000000000n) - }) - - it('starts cooldown with the full share balance when the exact cooldown asset cap is selected', () => { - expect( - resolveCooldownSharesToStart({ - needsCooldownStart: true, - lockedRequestedAmountRaw: 50_000000000000000000n, - maxCooldownAssetAmount: 50_000000000000000000n, - previewWithdrawShares: 49_999999999999999999n, - lockedPricePerShare: 1_000000000000000000n, - lockedVaultTokenDecimals: 18, - lockedWalletShares: 50_000000000000000000n - }) - ).toBe(50_000000000000000000n) - }) - - it('uses previewWithdraw shares for partial cooldown selection when available', () => { - expect( - resolveCooldownSharesToStart({ - needsCooldownStart: true, - lockedRequestedAmountRaw: 24_600000000000000000n, - maxCooldownAssetAmount: 50_000000000000000000n, - previewWithdrawShares: 23_700000000000000000n, - lockedPricePerShare: 1_000000000000000000n, - lockedVaultTokenDecimals: 18, - lockedWalletShares: 50_000000000000000000n - }) - ).toBe(23_700000000000000000n) - }) -}) - -describe('resolveLockedRedeemAssets', () => { - it('uses previewRedeem for partial locked-leg share redemptions when it is available', () => { - expect( - resolveLockedRedeemAssets({ - requestedLockedShares: 25_000000000000000000n, - maxWithdrawAssets: 50_000000000000000000n, - maxRedeemShares: 49_999999999999999999n, - previewRedeemAssets: 24_800000000000000000n, - lockedPricePerShare: 1_000000000000000000n, - lockedVaultTokenDecimals: 18 - }) - ).toBe(24_800000000000000000n) - }) - - it('uses the authoritative maxWithdraw assets for an exact max redeem', () => { - expect( - resolveLockedRedeemAssets({ - requestedLockedShares: 49_999999999999999999n, - maxWithdrawAssets: 50_000000000000000000n, - maxRedeemShares: 49_999999999999999999n, - lockedPricePerShare: 1_000000000000000000n, - lockedVaultTokenDecimals: 18 - }) - ).toBe(50_000000000000000000n) - }) -}) - -describe('resolveLockedWithdrawMethod', () => { - it('uses redeem when the redeem quote can satisfy the requested locked assets', () => { - expect( - resolveLockedWithdrawMethod({ - requestedLockedAssets: 50_000000000000000000n, - requestedLockedShares: 49_999999999999999999n, - redeemableLockedAssets: 50_000000000000000000n - }) - ).toBe('redeem') - }) - - it('falls back to withdraw when redeemable shares or assets cannot satisfy the requested locked assets', () => { - expect( - resolveLockedWithdrawMethod({ - requestedLockedAssets: 1n, - requestedLockedShares: 0n, - redeemableLockedAssets: 0n - }) - ).toBe('withdraw') - - expect( - resolveLockedWithdrawMethod({ - requestedLockedAssets: 100n, - requestedLockedShares: 9n, - redeemableLockedAssets: 99n - }) - ).toBe('withdraw') - }) -}) - -describe('resolveLockedWithdrawExecutionSnapshot', () => { - it('preserves the snapshotted locked-leg output when live quotes collapse after step 1', () => { - expect( - resolveLockedWithdrawExecutionSnapshot({ - executionSnapshot: { - lockedStepMethod: 'redeem', - requestedLockedAssets: 50_000000000000000000n, - requestedLockedShares: 49_999999999999999999n, - receivedLockedAssets: 50_000000000000000000n - }, - currentLockedWithdrawMethod: 'withdraw', - currentRequestedLockedAssets: 0n, - currentRequestedLockedShares: 0n, - currentReceivedLockedAssets: 0n - }) - ).toEqual({ - lockedStepMethod: 'redeem', - requestedLockedAssets: 50_000000000000000000n, - requestedLockedShares: 49_999999999999999999n, - receivedLockedAssets: 50_000000000000000000n - }) - }) - - it('uses the current live quote before a step snapshot exists', () => { - expect( - resolveLockedWithdrawExecutionSnapshot({ - executionSnapshot: null, - currentLockedWithdrawMethod: 'withdraw', - currentRequestedLockedAssets: 25_000000000000000000n, - currentRequestedLockedShares: 24_500000000000000000n, - currentReceivedLockedAssets: 25_000000000000000000n - }) - ).toEqual({ - lockedStepMethod: 'withdraw', - requestedLockedAssets: 25_000000000000000000n, - requestedLockedShares: 24_500000000000000000n, - receivedLockedAssets: 25_000000000000000000n - }) - }) -}) - -describe('shouldUseLockedManagedWithdrawFlow', () => { - it('keeps the managed locked flow for the default same-chain underlying exit', () => { - expect( - shouldUseLockedManagedWithdrawFlow({ - canWithdrawNow: true, - chainId: 1, - underlyingAssetAddress: TOKEN_A - }) - ).toBe(true) - }) - - it('lets the generic widget flow handle alternate token or cross-chain exits once locked funds are withdrawable', () => { - expect( - shouldUseLockedManagedWithdrawFlow({ - canWithdrawNow: true, - selectedTokenAddress: TOKEN_B, - chainId: 1, - underlyingAssetAddress: TOKEN_A - }) - ).toBe(false) - - expect( - shouldUseLockedManagedWithdrawFlow({ - canWithdrawNow: true, - selectedTokenAddress: TOKEN_A, - selectedChainId: 10, - chainId: 1, - underlyingAssetAddress: TOKEN_A - }) - ).toBe(false) - }) - - it('continues to use the managed flow while the locked position is not yet withdrawable', () => { - expect( - shouldUseLockedManagedWithdrawFlow({ - canWithdrawNow: false, - selectedTokenAddress: TOKEN_B, - selectedChainId: 10, - chainId: 1, - underlyingAssetAddress: TOKEN_A - }) - ).toBe(true) - }) -}) - -describe('resolveLockedWithdrawExpectedOut', () => { - it('uses previewRedeem for the exact expected output when it is available', () => { - expect( - resolveLockedWithdrawExpectedOut({ - requestedLockedAssets: 25_000000000000000000n, - previewRedeemAssets: 25_500000n, - unlockedPricePerShare: 1_020000n, - unlockedVaultDecimals: 18 - }) - ).toBe(25_500000n) - }) - - it('falls back to price-per-share conversion when previewRedeem is unavailable', () => { - expect( - resolveLockedWithdrawExpectedOut({ - requestedLockedAssets: 25_000000000000000000n, - unlockedPricePerShare: 1_020000n, - unlockedVaultDecimals: 18 - }) - ).toBe(25_500000n) - }) -}) - -describe('shouldNormalizeLockedWithdrawDisplayAmount', () => { - it('normalizes a post-cooldown max selection back to the authoritative display max', () => { - expect( - shouldNormalizeLockedWithdrawDisplayAmount({ - canWithdrawNow: true, - currentDisplayAmount: 500_000001n, - maxDisplayAmount: 500_000000n, - requestedLockedAssets: 497_396866n, - maxWithdrawAssets: 497_396866n - }) - ).toBe(true) - }) - - it('does not normalize partial withdraws or inactive withdraw windows', () => { - expect( - shouldNormalizeLockedWithdrawDisplayAmount({ - canWithdrawNow: true, - currentDisplayAmount: 250_000000n, - maxDisplayAmount: 500_000000n, - requestedLockedAssets: 248_698433n, - maxWithdrawAssets: 497_396866n - }) - ).toBe(false) - - expect( - shouldNormalizeLockedWithdrawDisplayAmount({ - canWithdrawNow: false, - currentDisplayAmount: 500_000001n, - maxDisplayAmount: 500_000000n, - requestedLockedAssets: 497_396866n, - maxWithdrawAssets: 497_396866n - }) - ).toBe(false) - - expect( - shouldNormalizeLockedWithdrawDisplayAmount({ - canWithdrawNow: true, - currentDisplayAmount: 520_000000n, - maxDisplayAmount: 500_000000n, - requestedLockedAssets: 520_000001n, - maxWithdrawAssets: 497_396866n - }) - ).toBe(false) - }) -}) - -describe('buildLockedWithdrawNoZapExecutionPlan', () => { - it('builds a two-step no-zap execution plan with a locked redeem followed by an unlocked withdraw', () => { - const plan = buildLockedWithdrawNoZapExecutionPlan({ - account: '0x1111111111111111111111111111111111111111', - lockedStepMethod: 'redeem', - requestedLockedAssets: 50_000000000000000000n, - requestedLockedShares: 49_999999999999999999n, - requestedUnderlyingAssets: 51_000000n - }) - - expect(plan).toHaveLength(2) - expect(plan[0]?.functionName).toBe('redeem') - expect(plan[1]?.functionName).toBe('withdraw') - expect(plan[0]?.args[0]).toBe(49_999999999999999999n) - expect(plan[1]?.args[0]).toBe(51_000000n) - }) - - it('does not introduce an approval step for the locked no-zap flow', () => { - const plan = buildLockedWithdrawNoZapExecutionPlan({ - account: '0x1111111111111111111111111111111111111111', - lockedStepMethod: 'redeem', - requestedLockedAssets: 1n, - requestedLockedShares: 1n, - requestedUnderlyingAssets: 1n - }) - - expect(plan.map((step) => step.functionName)).toEqual(['redeem', 'withdraw']) - }) - - it('falls back to a locked withdraw when the redeem path cannot represent the requested assets', () => { - const plan = buildLockedWithdrawNoZapExecutionPlan({ - account: '0x1111111111111111111111111111111111111111', - lockedStepMethod: 'withdraw', - requestedLockedAssets: 1n, - requestedLockedShares: 0n, - requestedUnderlyingAssets: 1n - }) - - expect(plan.map((step) => step.functionName)).toEqual(['withdraw', 'withdraw']) - expect(plan[0]?.args[0]).toBe(1n) - }) -}) - -describe('buildLockedWithdrawTransactionStep', () => { - it('builds wrapper-owned redeem and withdraw overlay steps', () => { - const withdrawStep = buildLockedWithdrawTransactionStep({ - phase: 'withdraw', - lockedStepMethod: 'redeem', - prepareLockedWithdraw: mockPrepare('redeem', [49n, '0x1', '0x1']), - prepareUnlockedWithdraw: mockPrepare('withdraw', [51n, '0x1', '0x1']), - requestedLockedShares: 49_999999999999999999n, - receivedLockedAssets: 50_000000000000000000n, - expectedUnderlyingOut: 51_000000n, - lockedVaultTokenDecimals: 18, - lockedAssetDecimals: 18, - underlyingDecimals: 6, - lockedVaultTokenSymbol: 'yvUSD (Locked)', - lockedAssetSymbol: 'yvUSD', - underlyingSymbol: 'USDC' - }) - const withdrawUnderlyingStep = buildLockedWithdrawTransactionStep({ - phase: 'redeem', - lockedStepMethod: 'redeem', - prepareLockedWithdraw: mockPrepare('redeem', [49n, '0x1', '0x1']), - prepareUnlockedWithdraw: mockPrepare('withdraw', [51n, '0x1', '0x1']), - requestedLockedShares: 49_999999999999999999n, - receivedLockedAssets: 50_000000000000000000n, - expectedUnderlyingOut: 51_000000n, - lockedVaultTokenDecimals: 18, - lockedAssetDecimals: 18, - underlyingDecimals: 6, - lockedVaultTokenSymbol: 'yvUSD (Locked)', - lockedAssetSymbol: 'yvUSD', - underlyingSymbol: 'USDC' - }) - - expect(withdrawStep.label).toBe('Withdraw to yvUSD') - expect(withdrawUnderlyingStep.label).toBe('Withdraw to USDC') - expect(withdrawStep.completesFlow).toBe(false) - expect(withdrawUnderlyingStep.completesFlow).toBe(true) - expect(withdrawStep.confirmMessage).toContain('Redeeming') - }) - - it('shows withdraw copy when the locked leg falls back to asset-based withdraw', () => { - const withdrawStep = buildLockedWithdrawTransactionStep({ - phase: 'withdraw', - lockedStepMethod: 'withdraw', - prepareLockedWithdraw: mockPrepare('withdraw', [1n, '0x1', '0x1']), - prepareUnlockedWithdraw: mockPrepare('withdraw', [1n, '0x1', '0x1']), - requestedLockedShares: 0n, - receivedLockedAssets: 1n, - expectedUnderlyingOut: 1n, - lockedVaultTokenDecimals: 18, - lockedAssetDecimals: 18, - underlyingDecimals: 6, - lockedVaultTokenSymbol: 'yvUSD (Locked)', - lockedAssetSymbol: 'yvUSD', - underlyingSymbol: 'USDC' - }) - - expect(withdrawStep.confirmMessage).toContain('Withdrawing') - expect(withdrawStep.successTitle).toBe('Locked withdraw successful') - }) -}) diff --git a/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.tsx b/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.tsx index ad75fe096..35f6999a7 100644 --- a/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.tsx +++ b/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.tsx @@ -14,14 +14,14 @@ import { Button } from '@shared/components/Button' import { useWallet } from '@shared/contexts/useWallet' import { erc4626Abi } from '@shared/contracts/abi/4626.abi' import { yvUsdLockedVaultAbi } from '@shared/contracts/abi/yvUsdLockedVault.abi' +import { type AppUseSimulateContractReturnType, useReadContract, useSimulateContract } from '@shared/hooks/useAppWagmi' import { useChainTimestamp } from '@shared/hooks/useChainTimestamp' import { IconCheck } from '@shared/icons/IconCheck' import { formatTAmount, toAddress } from '@shared/utils' import type { ReactElement } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { formatUnits } from 'viem' -import type { UseSimulateContractReturnType } from 'wagmi' -import { useAccount, useReadContract, useSimulateContract } from 'wagmi' +import { useAccount } from 'wagmi' import { InfoOverlay } from '../shared/InfoOverlay' import { TransactionOverlay, type TransactionStep } from '../shared/TransactionOverlay' import { WidgetWithdraw } from '../withdraw' @@ -486,7 +486,7 @@ export function YvUsdWithdraw({ chainId, assetAddress, onWithdrawSuccess, collap windowRemainingSeconds ) - const prepareStartCooldown: UseSimulateContractReturnType = useSimulateContract({ + const prepareStartCooldown: AppUseSimulateContractReturnType = useSimulateContract({ address: YVUSD_LOCKED_ADDRESS, abi: yvUsdLockedVaultAbi, functionName: 'startCooldown', @@ -497,7 +497,7 @@ export function YvUsdWithdraw({ chainId, assetAddress, onWithdrawSuccess, collap enabled: !!account && isLockedVariant && needsCooldownStart && cooldownSharesToStart > 0n } }) - const prepareCancelCooldown: UseSimulateContractReturnType = useSimulateContract({ + const prepareCancelCooldown: AppUseSimulateContractReturnType = useSimulateContract({ address: YVUSD_LOCKED_ADDRESS, abi: yvUsdLockedVaultAbi, functionName: 'cancelCooldown', @@ -821,7 +821,7 @@ export function YvUsdWithdraw({ chainId, assetAddress, onWithdrawSuccess, collap const lockedWithdrawArgs = lockedWithdrawExecutionPlan[0]?.args const unlockedWithdrawArgs = lockedWithdrawExecutionPlan[1]?.args - const prepareLockedRedeemNow: UseSimulateContractReturnType = useSimulateContract({ + const prepareLockedRedeemNow: AppUseSimulateContractReturnType = useSimulateContract({ address: YVUSD_LOCKED_ADDRESS, abi: erc4626Abi, functionName: 'redeem', @@ -838,7 +838,7 @@ export function YvUsdWithdraw({ chainId, assetAddress, onWithdrawSuccess, collap executionLockedWithdrawShares > 0n } }) - const prepareLockedWithdrawNow: UseSimulateContractReturnType = useSimulateContract({ + const prepareLockedWithdrawNow: AppUseSimulateContractReturnType = useSimulateContract({ address: YVUSD_LOCKED_ADDRESS, abi: erc4626Abi, functionName: 'withdraw', @@ -858,7 +858,7 @@ export function YvUsdWithdraw({ chainId, assetAddress, onWithdrawSuccess, collap const prepareLockedWithdrawStep = executionLockedWithdrawMethod === 'redeem' ? prepareLockedRedeemNow : prepareLockedWithdrawNow - const prepareUnlockedWithdraw: UseSimulateContractReturnType = useSimulateContract({ + const prepareUnlockedWithdraw: AppUseSimulateContractReturnType = useSimulateContract({ address: YVUSD_UNLOCKED_ADDRESS, abi: erc4626Abi, functionName: 'withdraw', diff --git a/src/components/pages/vaults/components/widget/yvUSD/cooldownUtils.test.ts b/src/components/pages/vaults/components/widget/yvUSD/cooldownUtils.test.ts deleted file mode 100644 index 95eb44f19..000000000 --- a/src/components/pages/vaults/components/widget/yvUSD/cooldownUtils.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { formatDays, resolveCooldownWindowState, resolveDurationSeconds } from './cooldownUtils' - -describe('resolveDurationSeconds', () => { - it('uses the contract-provided duration when available', () => { - expect(resolveDurationSeconds(7n * 86_400n, 5)).toBe(604800) - }) - - it('falls back to the configured number of days when contract data is unavailable', () => { - expect(resolveDurationSeconds(undefined, 5)).toBe(432000) - }) -}) - -describe('formatDays', () => { - it('formats the 5-day withdrawal fallback label correctly', () => { - expect(formatDays(resolveDurationSeconds(undefined, 5), 5)).toBe('5 days') - }) - - it('formats contract-provided withdrawal windows without forcing the fallback value', () => { - expect(formatDays(resolveDurationSeconds(7n * 86_400n, 5), 5)).toBe('7 days') - }) -}) - -describe('resolveCooldownWindowState', () => { - it('treats a positive withdraw limit as an open window even if local countdown math would lag', () => { - expect( - resolveCooldownWindowState({ - hasActiveCooldown: true, - nowTimestamp: 100, - cooldownEnd: 200, - windowEnd: 300, - availableWithdrawLimit: 1n - }) - ).toEqual({ - isCooldownActive: false, - isWithdrawalWindowOpen: true, - isCooldownWindowExpired: false - }) - }) - - it('marks the cooldown expired only when the withdrawal window has passed and no withdraw limit exists', () => { - expect( - resolveCooldownWindowState({ - hasActiveCooldown: true, - nowTimestamp: 301, - cooldownEnd: 200, - windowEnd: 300, - availableWithdrawLimit: 0n - }) - ).toEqual({ - isCooldownActive: false, - isWithdrawalWindowOpen: false, - isCooldownWindowExpired: true - }) - }) -}) diff --git a/src/components/pages/vaults/components/yvUSD/YvUsdBreakdown.test.tsx b/src/components/pages/vaults/components/yvUSD/YvUsdBreakdown.test.tsx deleted file mode 100644 index 739b6312b..000000000 --- a/src/components/pages/vaults/components/yvUSD/YvUsdBreakdown.test.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it } from 'vitest' -import { YvUsdApyDetailsContent } from './YvUsdBreakdown' - -describe('YvUsdApyDetailsContent', () => { - it('describes the locked withdrawal window as 5 days', () => { - const html = renderToStaticMarkup() - - expect(html).toContain('Withdrawals are open for 5 days once the cooldown ends.') - }) -}) diff --git a/src/components/pages/vaults/components/yvUSD/YvUsdHeaderBanner.test.tsx b/src/components/pages/vaults/components/yvUSD/YvUsdHeaderBanner.test.tsx deleted file mode 100644 index 12bbc1379..000000000 --- a/src/components/pages/vaults/components/yvUSD/YvUsdHeaderBanner.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { YVUSD_LEARN_MORE_URL } from '@pages/vaults/utils/yvUsd' -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it } from 'vitest' -import { YvUsdHeaderBanner } from './YvUsdHeaderBanner' - -describe('YvUsdHeaderBanner', () => { - it('renders the mockup copy, links, and shipped banner assets', () => { - const html = renderToStaticMarkup() - - expect(html).toContain('Transparent, Verifiable, Real Yield') - expect(html).toContain(`href="${YVUSD_LEARN_MORE_URL}"`) - expect(html).toContain('yvusd-banner-bg.png') - expect(html).toContain('Learn more') - expect(html).toContain('about Yearn's new cross-chain, cross-asset, delta-neutral vault.') - }) -}) diff --git a/src/components/pages/vaults/domain/kongVaultSelectors.test.ts b/src/components/pages/vaults/domain/kongVaultSelectors.test.ts deleted file mode 100644 index 9e299a524..000000000 --- a/src/components/pages/vaults/domain/kongVaultSelectors.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getVaultAPR, getVaultStaking, getVaultStrategies } from './kongVaultSelectors' - -const LIST_REWARD = { - address: '0x3333333333333333333333333333333333333333', - name: 'List Reward', - symbol: 'LR', - decimals: 18, - price: 1, - isFinished: false, - finishedAt: 0, - apr: 0.5, - perWeek: 10 -} - -const SNAPSHOT_REWARD = { - address: '0x4444444444444444444444444444444444444444', - name: 'Snapshot Reward', - symbol: 'SR', - decimals: 6, - price: 2, - isFinished: true, - finishedAt: 123, - apr: 1.5, - perWeek: 20 -} - -describe('getVaultStaking', () => { - it('preserves list staking source and rewards when snapshot metadata is missing', () => { - const vault = { - staking: { - address: '0x2222222222222222222222222222222222222222', - available: false, - source: 'yBOLD', - rewards: [LIST_REWARD] - } - } as any - - const staking = getVaultStaking(vault, { - staking: { - address: '0x2222222222222222222222222222222222222222', - available: true - } - } as any) - - expect(staking.source).toBe('yBOLD') - expect(staking.rewards ?? []).toHaveLength(1) - expect(staking.rewards?.[0].symbol).toBe('LR') - }) - - it('prefers snapshot staking source and rewards when they are present', () => { - const vault = { - staking: { - address: '0x2222222222222222222222222222222222222222', - available: false, - source: 'legacy', - rewards: [LIST_REWARD] - } - } as any - - const staking = getVaultStaking(vault, { - staking: { - address: '0x2222222222222222222222222222222222222222', - available: true, - source: 'VeYFI', - rewards: [SNAPSHOT_REWARD] - } - } as any) - - expect(staking.source).toBe('VeYFI') - expect(staking.rewards ?? []).toHaveLength(1) - expect(staking.rewards?.[0].symbol).toBe('SR') - }) -}) - -describe('getVaultAPR', () => { - it('uses list pricePerShare when snapshot pricePerShare is missing', () => { - const apr = getVaultAPR({ - chainId: 1, - address: '0x1111111111111111111111111111111111111111', - name: 'Vault', - symbol: 'yvTEST', - decimals: 18, - asset: { - address: '0x2222222222222222222222222222222222222222', - name: 'USDC', - symbol: 'USDC', - decimals: 6 - }, - tvl: 1000, - performance: { - oracle: { apr: 0.02, apy: 0.02 }, - estimated: { apr: 0.02, apy: 0.02, type: 'oracle', components: {} }, - historical: { net: 0.01, weeklyNet: 0.01, monthlyNet: 0.01, inceptionNet: 0.01 } - }, - fees: { - managementFee: 0, - performanceFee: 0 - }, - category: 'Stablecoin', - type: 'Standard', - kind: 'Single Strategy', - v3: true, - yearn: true, - isRetired: false, - isHidden: false, - isBoosted: false, - isHighlighted: false, - strategiesCount: 1, - riskLevel: 1, - staking: null, - pricePerShare: '1050000' - } as any) - - expect(apr.pricePerShare.today).toBeCloseTo(1.05, 8) - }) -}) - -describe('getVaultStrategies', () => { - const vault = { - chainId: 1, - address: '0x1111111111111111111111111111111111111111' - } as any - - it('prefers composition estimated apy over oracle apy', () => { - const strategies = getVaultStrategies(vault, { - totalAssets: '1000000', - composition: [ - { - address: '0x5555555555555555555555555555555555555555', - name: 'Strategy A', - status: 'active', - totalDebt: '500000', - currentDebt: '500000', - performance: { - estimated: { - apr: 0.07, - apy: 0.12, - type: 'yvusd-estimated-apr', - components: {} - }, - oracle: { - apr: 0.08, - apy: 0.09 - } - } - } - ] - } as any) - - expect(strategies[0]?.estimatedAPY).toBe(0.12) - }) - - it('falls back to composition oracle apy when estimated apy is missing', () => { - const strategies = getVaultStrategies(vault, { - totalAssets: '1000000', - composition: [ - { - address: '0x6666666666666666666666666666666666666666', - name: 'Strategy B', - status: 'active', - totalDebt: '500000', - currentDebt: '500000', - performance: { - estimated: { - apr: 0.11, - type: 'yvusd-estimated-apr', - components: {} - }, - oracle: { - apr: 0.08, - apy: 0.09 - } - } - } - ] - } as any) - - expect(strategies[0]?.estimatedAPY).toBe(0.09) - }) - - it('leaves estimated apy unset when neither estimated nor oracle apy exists', () => { - const strategies = getVaultStrategies(vault, { - totalAssets: '1000000', - composition: [ - { - address: '0x7777777777777777777777777777777777777777', - name: 'Strategy C', - status: 'active', - totalDebt: '500000', - currentDebt: '500000', - performance: { - estimated: { - apr: 0.11, - type: 'yvusd-estimated-apr', - components: {} - }, - oracle: { - apr: 0.08 - } - } - } - ] - } as any) - - expect(strategies[0]?.estimatedAPY).toBeUndefined() - }) - - it('uses oracle apy as base for katana strategies — estimated apr is KAT rewards only', () => { - const strategies = getVaultStrategies(vault, { - totalAssets: '1000000', - composition: [ - { - address: '0x8888888888888888888888888888888888888888', - name: 'Morpho Strategy', - status: 'active', - totalDebt: '500000', - currentDebt: '500000', - performance: { - estimated: { - apr: 0.0028, - type: 'katana-estimated-apr' - }, - oracle: { - apr: 0.03, - apy: 0.04 - } - } - } - ] - } as any) - - // estimatedAPY should be oracle.apy (base yield), not estimated.apr (KAT rewards) - expect(strategies[0]?.estimatedAPY).toBe(0.04) - expect(strategies[0]?.katRewardsAPR).toBe(0.0028) - }) - - it('leaves estimatedAPY undefined for katana strategies when neither estimated.apy nor oracle.apy exists', () => { - const strategies = getVaultStrategies(vault, { - totalAssets: '1000000', - composition: [ - { - address: '0x8888888888888888888888888888888888888888', - name: 'Morpho Strategy', - status: 'active', - totalDebt: '500000', - currentDebt: '500000', - performance: { - estimated: { - apr: 0.0028, - type: 'katana-estimated-apr' - } - } - } - ] - } as any) - - expect(strategies[0]?.estimatedAPY).toBeUndefined() - expect(strategies[0]?.katRewardsAPR).toBe(0.0028) - }) - - it('reads katRewardsAPR from estimated components when apr is omitted', () => { - const strategies = getVaultStrategies(vault, { - totalAssets: '1000000', - composition: [ - { - address: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - name: 'Katana Strategy with Components', - status: 'active', - totalDebt: '500000', - currentDebt: '500000', - performance: { - estimated: { - type: 'katana-estimated-apr', - components: { - katRewardsAPR: 0.002978698024448475 - } - }, - oracle: { - apr: 0.013945609013431531, - apy: 0.014041406702504533 - } - } - } - ] - } as any) - - expect(strategies[0]?.estimatedAPY).toBe(0.014041406702504533) - expect(strategies[0]?.katRewardsAPR).toBe(0.002978698024448475) - }) - - it('does not set katRewardsAPR for non-katana strategies', () => { - const strategies = getVaultStrategies(vault, { - totalAssets: '1000000', - composition: [ - { - address: '0x9999999999999999999999999999999999999999', - name: 'Regular Strategy', - status: 'active', - totalDebt: '500000', - currentDebt: '500000', - performance: { - estimated: { - apr: 0.05, - apy: 0.06, - type: 'yvusd-estimated-apr', - components: {} - } - } - } - ] - } as any) - - expect(strategies[0]?.estimatedAPY).toBe(0.06) - expect(strategies[0]?.katRewardsAPR).toBeUndefined() - }) - - it('prefers estimated apy over estimated apr even for katana strategies', () => { - const strategies = getVaultStrategies(vault, { - totalAssets: '1000000', - composition: [ - { - address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - name: 'Katana Strategy with APY', - status: 'active', - totalDebt: '500000', - currentDebt: '500000', - performance: { - estimated: { - apr: 0.003, - apy: 0.05, - type: 'katana-estimated-apr', - components: {} - } - } - } - ] - } as any) - - expect(strategies[0]?.estimatedAPY).toBe(0.05) - expect(strategies[0]?.katRewardsAPR).toBe(0.003) - }) -}) diff --git a/src/components/pages/vaults/domain/normalizeVault.test.ts b/src/components/pages/vaults/domain/normalizeVault.test.ts deleted file mode 100644 index 80f46f6ad..000000000 --- a/src/components/pages/vaults/domain/normalizeVault.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - getCanonicalHoldingsVaultAddress, - getHoldingsAliasVaultAddress, - YBOLD_STAKING_ADDRESS, - YBOLD_VAULT_ADDRESS -} from './normalizeVault' - -describe('holdings alias helpers', () => { - it('maps the yBOLD staking wrapper to the base vault', () => { - expect(getHoldingsAliasVaultAddress(YBOLD_STAKING_ADDRESS)).toBe(YBOLD_VAULT_ADDRESS) - expect(getCanonicalHoldingsVaultAddress(YBOLD_STAKING_ADDRESS)).toBe(YBOLD_VAULT_ADDRESS) - }) - - it('keeps non-aliased vaults canonicalized to themselves', () => { - expect(getHoldingsAliasVaultAddress(YBOLD_VAULT_ADDRESS)).toBeUndefined() - expect(getCanonicalHoldingsVaultAddress(YBOLD_VAULT_ADDRESS)).toBe(YBOLD_VAULT_ADDRESS) - }) -}) diff --git a/src/components/pages/vaults/domain/vaultWarnings.test.ts b/src/components/pages/vaults/domain/vaultWarnings.test.ts deleted file mode 100644 index 47b44c45d..000000000 --- a/src/components/pages/vaults/domain/vaultWarnings.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { isNonYearnErc4626Vault, NON_YEARN_ERC4626_WARNING_MESSAGE } from './vaultWarnings' - -describe('NON_YEARN_ERC4626_WARNING_MESSAGE', () => { - it('matches the expected copy', () => { - expect(NON_YEARN_ERC4626_WARNING_MESSAGE).toBe( - 'This is a non-Yearn ERC-4626 Vault. Please be careful when interacting with it.' - ) - }) -}) - -describe('isNonYearnErc4626Vault', () => { - it('returns false for catalog Yearn vaults', () => { - expect( - isNonYearnErc4626Vault({ - vault: { - origin: 'yearn', - inclusion: { isYearn: true } - } as any - }) - ).toBe(false) - }) - - it('returns true when the list origin is missing', () => { - expect( - isNonYearnErc4626Vault({ - vault: { - origin: null, - inclusion: {} - } as any - }) - ).toBe(true) - }) - - it('returns true when the list origin is not yearn', () => { - expect( - isNonYearnErc4626Vault({ - vault: { - origin: 'partner', - inclusion: { isYearn: true } - } as any - }) - ).toBe(true) - }) - - it('returns true when inclusion explicitly marks the vault as non-Yearn', () => { - expect( - isNonYearnErc4626Vault({ - vault: { - origin: 'yearn', - inclusion: { isYearn: false } - } as any - }) - ).toBe(true) - }) - - it('returns true from snapshot metadata when list metadata is unavailable', () => { - expect( - isNonYearnErc4626Vault({ - snapshot: { - inclusion: { isYearn: false } - } as any - }) - ).toBe(true) - }) -}) diff --git a/src/components/pages/vaults/hooks/actions/stakingAdapter.test.ts b/src/components/pages/vaults/hooks/actions/stakingAdapter.test.ts deleted file mode 100644 index a79f2d770..000000000 --- a/src/components/pages/vaults/hooks/actions/stakingAdapter.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { erc4626Abi } from '@shared/contracts/abi/4626.abi' -import { STAKING_REWARDS_ABI } from '@shared/contracts/abi/stakingRewards.abi' -import { TOKENIZED_STRATEGY_ABI } from '@shared/contracts/abi/tokenizedStrategy.abi' -import { VEYFI_GAUGE_ABI } from '@shared/contracts/abi/veYFIGauge.abi' -import { describe, expect, it } from 'vitest' -import { - getDirectStakeCall, - getDirectUnstakeCalls, - getRedeemPreviewCall, - getStakePreviewCall, - getStakingWithdrawableAssets, - normalizeStakingSource -} from './stakingAdapter' - -describe('stakingAdapter', () => { - it('normalizes known and unknown staking sources', () => { - expect(normalizeStakingSource('VeYFI')).toBe('VeYFI') - expect(normalizeStakingSource('yBOLD')).toBe('yBOLD') - expect(normalizeStakingSource('Legacy')).toBe('default') - }) - - it('builds stake preview calls for source-specific ERC4626 staking', () => { - const amount = 42n - expect(getStakePreviewCall('VeYFI', amount)).toMatchObject({ - abi: VEYFI_GAUGE_ABI, - functionName: 'previewDeposit', - args: [amount] - }) - expect(getStakePreviewCall('yBOLD', amount)).toMatchObject({ - abi: TOKENIZED_STRATEGY_ABI, - functionName: 'previewDeposit', - args: [amount] - }) - expect(getStakePreviewCall('Legacy', amount)).toBeUndefined() - }) - - it('builds redeem preview calls for vault-backed staking wrappers', () => { - const shares = 42n - expect(getRedeemPreviewCall('VeYFI', shares)).toMatchObject({ - abi: VEYFI_GAUGE_ABI, - functionName: 'previewRedeem', - args: [shares] - }) - expect(getRedeemPreviewCall('yBOLD', shares)).toMatchObject({ - abi: TOKENIZED_STRATEGY_ABI, - functionName: 'previewRedeem', - args: [shares] - }) - expect(getRedeemPreviewCall('Legacy', shares)).toBeUndefined() - }) - - it('builds direct stake calls per staking source', () => { - const amount = 100n - const account = '0x1111111111111111111111111111111111111111' - - expect(getDirectStakeCall({ stakingSource: 'VeYFI', amount, account })).toMatchObject({ - abi: VEYFI_GAUGE_ABI, - functionName: 'deposit', - args: [amount] - }) - - expect(getDirectStakeCall({ stakingSource: 'yBOLD', amount, account })).toMatchObject({ - abi: TOKENIZED_STRATEGY_ABI, - functionName: 'deposit', - args: [amount, account] - }) - - expect(getDirectStakeCall({ stakingSource: 'Legacy', amount, account })).toMatchObject({ - abi: STAKING_REWARDS_ABI, - functionName: 'stake', - args: [amount] - }) - }) - - it('builds direct unstake calls with source-first + fallback behavior', () => { - const amount = 321n - const account = '0x1111111111111111111111111111111111111111' - - const yboldCalls = getDirectUnstakeCalls({ stakingSource: 'yBOLD', amount, account }) - expect(yboldCalls.primary).toMatchObject({ - abi: TOKENIZED_STRATEGY_ABI, - functionName: 'withdraw', - args: [amount, account, account] - }) - expect(yboldCalls.fallback).toMatchObject({ - abi: STAKING_REWARDS_ABI, - functionName: 'withdraw', - args: [amount] - }) - - const defaultCalls = getDirectUnstakeCalls({ stakingSource: 'Legacy', amount, account }) - expect(defaultCalls.primary).toMatchObject({ - abi: STAKING_REWARDS_ABI, - functionName: 'withdraw', - args: [amount] - }) - expect(defaultCalls.fallback).toMatchObject({ - abi: erc4626Abi, - functionName: 'withdraw', - args: [amount, account, account] - }) - }) - - it('falls back to maxWithdraw when maxRedeem conversion is unavailable', async () => { - const account = '0x1111111111111111111111111111111111111111' - const stakingAddress = '0x2222222222222222222222222222222222222222' - const read = async ({ - functionName - }: { - functionName: string - address: `0x${string}` - abi: readonly unknown[] - args?: readonly unknown[] - }) => { - if (functionName === 'maxRedeem') throw new Error('missing') - if (functionName === 'maxWithdraw') return 123n - return 0n - } - - const result = await getStakingWithdrawableAssets({ - read, - stakingAddress, - account, - stakingSource: 'yBOLD', - stakingShareBalance: 99n - }) - - expect(result).toBe(123n) - }) - - it('prefers maxRedeem + convertToAssets for ERC4626 wrappers', async () => { - const account = '0x1111111111111111111111111111111111111111' - const stakingAddress = '0x2222222222222222222222222222222222222222' - const read = async ({ - functionName - }: { - functionName: string - address: `0x${string}` - abi: readonly unknown[] - args?: readonly unknown[] - }) => { - if (functionName === 'maxRedeem') return 99n - if (functionName === 'convertToAssets') return 150n - if (functionName === 'maxWithdraw') return 123n - return 0n - } - - const result = await getStakingWithdrawableAssets({ - read, - stakingAddress, - account, - stakingSource: 'yBOLD', - stakingShareBalance: 99n - }) - - expect(result).toBe(150n) - }) - - it('falls back to convertToAssets and then raw balance for withdrawable assets', async () => { - const account = '0x1111111111111111111111111111111111111111' - const stakingAddress = '0x2222222222222222222222222222222222222222' - - const convertRead = async ({ - functionName - }: { - functionName: string - address: `0x${string}` - abi: readonly unknown[] - args?: readonly unknown[] - }) => { - if (functionName === 'maxRedeem') { - throw new Error('missing') - } - if (functionName === 'maxWithdraw') { - throw new Error('missing') - } - if (functionName === 'convertToAssets') { - return 456n - } - return 0n - } - - const converted = await getStakingWithdrawableAssets({ - read: convertRead, - stakingAddress, - account, - stakingSource: 'yBOLD', - stakingShareBalance: 99n - }) - expect(converted).toBe(456n) - - const failingRead = async () => { - throw new Error('missing') - } - const fallback = await getStakingWithdrawableAssets({ - read: failingRead, - stakingAddress, - account, - stakingSource: 'yBOLD', - stakingShareBalance: 99n - }) - expect(fallback).toBe(99n) - }) -}) diff --git a/src/components/pages/vaults/hooks/actions/useDirectDeposit.ts b/src/components/pages/vaults/hooks/actions/useDirectDeposit.ts index b40997612..0039c44cf 100644 --- a/src/components/pages/vaults/hooks/actions/useDirectDeposit.ts +++ b/src/components/pages/vaults/hooks/actions/useDirectDeposit.ts @@ -1,10 +1,10 @@ import type { UseWidgetDepositFlowReturn } from '@pages/vaults/types' import { erc4626Abi } from '@shared/contracts/abi/4626.abi' import { vaultAbi } from '@shared/contracts/abi/vaultV2.abi' +import { type AppUseSimulateContractReturnType, useReadContract, useSimulateContract } from '@shared/hooks/useAppWagmi' import { toAddress } from '@shared/utils' import { getApproveAbi } from '@shared/utils/approve' import type { Address } from 'viem' -import { type UseSimulateContractReturnType, useReadContract, useSimulateContract } from 'wagmi' import { useTokenAllowance } from '../useTokenAllowance' interface UseDirectDepositParams { @@ -43,7 +43,7 @@ export function useDirectDeposit(params: UseDirectDepositParams): UseWidgetDepos const prepareDepositEnabled = isAllowanceSufficient && isValidInput && !!params.account // Prepare approve transaction using useSimulateContract - const prepareApprove: UseSimulateContractReturnType = useSimulateContract({ + const prepareApprove: AppUseSimulateContractReturnType = useSimulateContract({ abi: getApproveAbi(params.assetAddress), functionName: 'approve', address: params.assetAddress, @@ -53,7 +53,7 @@ export function useDirectDeposit(params: UseDirectDepositParams): UseWidgetDepos }) // Prepare deposit transaction using useSimulateContract - const prepareDeposit: UseSimulateContractReturnType = useSimulateContract({ + const prepareDeposit: AppUseSimulateContractReturnType = useSimulateContract({ abi: vaultAbi, functionName: 'deposit', address: params.vaultAddress, diff --git a/src/components/pages/vaults/hooks/actions/useDirectStake.ts b/src/components/pages/vaults/hooks/actions/useDirectStake.ts index 59397d0d9..dfc638e92 100644 --- a/src/components/pages/vaults/hooks/actions/useDirectStake.ts +++ b/src/components/pages/vaults/hooks/actions/useDirectStake.ts @@ -1,7 +1,7 @@ import type { UseWidgetDepositFlowReturn } from '@pages/vaults/types' +import { type AppUseSimulateContractReturnType, useReadContract, useSimulateContract } from '@shared/hooks/useAppWagmi' import { getApproveAbi } from '@shared/utils/approve' import type { Address } from 'viem' -import { type UseSimulateContractReturnType, useReadContract, useSimulateContract } from 'wagmi' import { useTokenAllowance } from '../useTokenAllowance' import { getDirectStakeCall, getStakePreviewCall, normalizeStakingSource } from './stakingAdapter' @@ -49,7 +49,7 @@ export function useDirectStake(params: UseDirectStakeParams): UseWidgetDepositFl const prepareDepositEnabled = isAllowanceSufficient && isValidInput && !!params.account // Prepare approve transaction - const prepareApprove: UseSimulateContractReturnType = useSimulateContract({ + const prepareApprove: AppUseSimulateContractReturnType = useSimulateContract({ abi: getApproveAbi(params.vaultAddress), functionName: 'approve', address: params.vaultAddress, @@ -64,7 +64,7 @@ export function useDirectStake(params: UseDirectStakeParams): UseWidgetDepositFl account: params.account }) - const prepareDeposit: UseSimulateContractReturnType = useSimulateContract({ + const prepareDeposit: AppUseSimulateContractReturnType = useSimulateContract({ abi: stakeCall.abi as any, functionName: stakeCall.functionName as any, address: params.stakingAddress, diff --git a/src/components/pages/vaults/hooks/actions/useDirectUnstake.ts b/src/components/pages/vaults/hooks/actions/useDirectUnstake.ts index 92196fadf..fc589397a 100644 --- a/src/components/pages/vaults/hooks/actions/useDirectUnstake.ts +++ b/src/components/pages/vaults/hooks/actions/useDirectUnstake.ts @@ -1,7 +1,7 @@ import type { UseWidgetWithdrawFlowReturn } from '@pages/vaults/types' +import { type AppUseSimulateContractReturnType, useSimulateContract } from '@shared/hooks/useAppWagmi' import type { Address } from 'viem' import { maxUint256 } from 'viem' -import { type UseSimulateContractReturnType, useSimulateContract } from 'wagmi' import { getDirectUnstakeCalls } from './stakingAdapter' interface UseDirectUnstakeParams { @@ -27,7 +27,7 @@ export function useDirectUnstake(params: UseDirectUnstakeParams): UseWidgetWithd maxRedeemShares: params.maxRedeemShares }) - const preparePrimaryWithdraw: UseSimulateContractReturnType = useSimulateContract({ + const preparePrimaryWithdraw: AppUseSimulateContractReturnType = useSimulateContract({ abi: unstakeCalls.primary.abi as any, functionName: unstakeCalls.primary.functionName as any, address: params.stakingAddress, @@ -40,7 +40,7 @@ export function useDirectUnstake(params: UseDirectUnstakeParams): UseWidgetWithd const shouldTryFallback = prepareWithdrawEnabled && !!unstakeCalls.fallback && !!params.stakingAddress && preparePrimaryWithdraw.isError - const prepareFallbackWithdraw: UseSimulateContractReturnType = useSimulateContract({ + const prepareFallbackWithdraw: AppUseSimulateContractReturnType = useSimulateContract({ abi: (unstakeCalls.fallback?.abi || []) as any, functionName: (unstakeCalls.fallback?.functionName || 'withdraw') as any, address: params.stakingAddress, @@ -50,7 +50,7 @@ export function useDirectUnstake(params: UseDirectUnstakeParams): UseWidgetWithd query: { enabled: shouldTryFallback } }) - const prepareWithdraw: UseSimulateContractReturnType = + const prepareWithdraw: AppUseSimulateContractReturnType = unstakeCalls.fallback && preparePrimaryWithdraw.isError ? prepareFallbackWithdraw : preparePrimaryWithdraw return { diff --git a/src/components/pages/vaults/hooks/actions/useDirectWithdraw.ts b/src/components/pages/vaults/hooks/actions/useDirectWithdraw.ts index 60fdb26a0..e003dd41e 100644 --- a/src/components/pages/vaults/hooks/actions/useDirectWithdraw.ts +++ b/src/components/pages/vaults/hooks/actions/useDirectWithdraw.ts @@ -1,11 +1,11 @@ import type { UseWidgetWithdrawFlowReturn } from '@pages/vaults/types' import { erc4626Abi } from '@shared/contracts/abi/4626.abi' import { vaultAbi } from '@shared/contracts/abi/vaultV2.abi' +import { type AppUseSimulateContractReturnType, useSimulateContract } from '@shared/hooks/useAppWagmi' import { toAddress } from '@shared/utils' import { useMemo } from 'react' import type { Address } from 'viem' import { maxUint256 } from 'viem' -import { type UseSimulateContractReturnType, useSimulateContract } from 'wagmi' interface UseDirectWithdrawParams { vaultAddress: Address @@ -85,7 +85,7 @@ export function useDirectWithdraw(params: UseDirectWithdrawParams): UseWidgetWit // Prepare withdraw transaction using ERC4626 withdraw function // withdraw(assets, receiver, owner) - no approval needed when owner == msg.sender - const prepareWithdrawErc4626: UseSimulateContractReturnType = useSimulateContract({ + const prepareWithdrawErc4626: AppUseSimulateContractReturnType = useSimulateContract({ abi: erc4626Abi, functionName: erc4626FunctionName, address: params.vaultAddress, @@ -95,7 +95,7 @@ export function useDirectWithdraw(params: UseDirectWithdrawParams): UseWidgetWit query: { enabled: prepareWithdrawEnabled && params.useErc4626 } }) - const prepareWithdrawV2: UseSimulateContractReturnType = useSimulateContract({ + const prepareWithdrawV2: AppUseSimulateContractReturnType = useSimulateContract({ abi: vaultAbi, functionName: 'withdraw', address: params.vaultAddress, @@ -105,7 +105,7 @@ export function useDirectWithdraw(params: UseDirectWithdrawParams): UseWidgetWit query: { enabled: prepareWithdrawEnabled && !params.useErc4626 } }) - const prepareWithdraw = useMemo((): UseSimulateContractReturnType => { + const prepareWithdraw = useMemo((): AppUseSimulateContractReturnType => { const livePrepare = params.useErc4626 ? prepareWithdrawErc4626 : prepareWithdrawV2 const expectedArgs = params.useErc4626 ? erc4626Args : withdrawV2Args const expectedFunctionName = params.useErc4626 ? erc4626FunctionName : 'withdraw' @@ -128,7 +128,7 @@ export function useDirectWithdraw(params: UseDirectWithdrawParams): UseWidgetWit ...livePrepare, data: undefined, isSuccess: false - } as UseSimulateContractReturnType + } as AppUseSimulateContractReturnType }, [ prepareWithdrawEnabled, params.useErc4626, diff --git a/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapDeposit.ts b/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapDeposit.ts index 4e52ac611..0c128a384 100644 --- a/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapDeposit.ts +++ b/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapDeposit.ts @@ -1,11 +1,10 @@ import type { UseWidgetDepositFlowReturn } from '@pages/vaults/types' import { YVUSD_LOCKED_ZAP_ADDRESS } from '@pages/vaults/utils/yvUsd' import { yvUsdLockedZapAbi } from '@shared/contracts/abi/yvUsdLockedZap.abi' +import { type AppUseSimulateContractReturnType, useReadContract, useSimulateContract } from '@shared/hooks/useAppWagmi' import { toAddress } from '@shared/utils' import { getApproveAbi } from '@shared/utils/approve' import type { Address } from 'viem' -import type { UseSimulateContractReturnType } from 'wagmi' -import { useReadContract, useSimulateContract } from 'wagmi' import { useTokenAllowance } from '../useTokenAllowance' interface UseYvUsdLockedZapDepositParams { @@ -39,7 +38,7 @@ export function useYvUsdLockedZapDeposit(params: UseYvUsdLockedZapDepositParams) query: { enabled: params.enabled && isValidInput } }) - const prepareApprove: UseSimulateContractReturnType = useSimulateContract({ + const prepareApprove: AppUseSimulateContractReturnType = useSimulateContract({ abi: getApproveAbi(params.depositToken), functionName: 'approve', address: params.depositToken, @@ -48,7 +47,7 @@ export function useYvUsdLockedZapDeposit(params: UseYvUsdLockedZapDepositParams) query: { enabled: prepareApproveEnabled } }) - const prepareDeposit: UseSimulateContractReturnType = useSimulateContract({ + const prepareDeposit: AppUseSimulateContractReturnType = useSimulateContract({ address: YVUSD_LOCKED_ZAP_ADDRESS, abi: yvUsdLockedZapAbi, functionName: 'zapIn', diff --git a/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapWithdraw.ts b/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapWithdraw.ts index ef2da7b7c..42596489b 100644 --- a/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapWithdraw.ts +++ b/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapWithdraw.ts @@ -1,11 +1,10 @@ import type { UseWidgetWithdrawFlowReturn } from '@pages/vaults/types' import { YVUSD_LOCKED_ADDRESS, YVUSD_LOCKED_ZAP_ADDRESS } from '@pages/vaults/utils/yvUsd' import { yvUsdLockedZapAbi } from '@shared/contracts/abi/yvUsdLockedZap.abi' +import { type AppUseSimulateContractReturnType, useSimulateContract } from '@shared/hooks/useAppWagmi' import { toAddress } from '@shared/utils' import type { Address } from 'viem' import { erc20Abi } from 'viem' -import type { UseSimulateContractReturnType } from 'wagmi' -import { useSimulateContract } from 'wagmi' import { useTokenAllowance } from '../useTokenAllowance' interface UseYvUsdLockedZapWithdrawParams { @@ -41,7 +40,7 @@ export function useYvUsdLockedZapWithdraw(params: UseYvUsdLockedZapWithdrawParam const prepareWithdrawEnabled = !!params.account && params.enabled && params.amount > 0n && params.requiredShares > 0n && isAllowanceSufficient - const prepareApprove: UseSimulateContractReturnType = useSimulateContract({ + const prepareApprove: AppUseSimulateContractReturnType = useSimulateContract({ abi: erc20Abi, functionName: 'approve', address: YVUSD_LOCKED_ADDRESS, @@ -50,7 +49,7 @@ export function useYvUsdLockedZapWithdraw(params: UseYvUsdLockedZapWithdrawParam query: { enabled: prepareApproveEnabled } }) - const prepareWithdraw: UseSimulateContractReturnType = useSimulateContract({ + const prepareWithdraw: AppUseSimulateContractReturnType = useSimulateContract({ address: YVUSD_LOCKED_ZAP_ADDRESS, abi: yvUsdLockedZapAbi, functionName: 'zapOut', diff --git a/src/components/pages/vaults/hooks/rewards/useClaimMerkleRewards.ts b/src/components/pages/vaults/hooks/rewards/useClaimMerkleRewards.ts index c22098d55..fe02aea84 100644 --- a/src/components/pages/vaults/hooks/rewards/useClaimMerkleRewards.ts +++ b/src/components/pages/vaults/hooks/rewards/useClaimMerkleRewards.ts @@ -1,7 +1,7 @@ import type { TGroupedMerkleReward } from '@pages/vaults/components/widget/rewards/types' import { MERKLE_DISTRIBUTOR_ABI } from '@shared/contracts/abi/merkleDistributor.abi' +import { useSimulateContract } from '@shared/hooks/useAppWagmi' import { MERKLE_DISTRIBUTOR_ADDRESS } from '@shared/utils/constants' -import { useSimulateContract } from 'wagmi' type UseClaimMerkleRewardsParams = { groupedReward?: TGroupedMerkleReward diff --git a/src/components/pages/vaults/hooks/rewards/useClaimStakingRewards.ts b/src/components/pages/vaults/hooks/rewards/useClaimStakingRewards.ts index 6743668bd..d929cf4b7 100644 --- a/src/components/pages/vaults/hooks/rewards/useClaimStakingRewards.ts +++ b/src/components/pages/vaults/hooks/rewards/useClaimStakingRewards.ts @@ -2,7 +2,7 @@ import { JUICED_STAKING_REWARDS_ABI } from '@shared/contracts/abi/juicedStakingR import { STAKING_REWARDS_ABI } from '@shared/contracts/abi/stakingRewards.abi' import { V3_STAKING_REWARDS_ABI } from '@shared/contracts/abi/V3StakingRewards.abi' import { VEYFI_GAUGE_ABI } from '@shared/contracts/abi/veYFIGauge.abi' -import { useSimulateContract } from 'wagmi' +import { useSimulateContract } from '@shared/hooks/useAppWagmi' type UseClaimStakingRewardsParams = { stakingAddress?: `0x${string}` diff --git a/src/components/pages/vaults/hooks/rewards/useStakingRewards.ts b/src/components/pages/vaults/hooks/rewards/useStakingRewards.ts index a5c883c87..efacbd5f5 100644 --- a/src/components/pages/vaults/hooks/rewards/useStakingRewards.ts +++ b/src/components/pages/vaults/hooks/rewards/useStakingRewards.ts @@ -3,9 +3,11 @@ import { JUICED_STAKING_REWARDS_ABI } from '@shared/contracts/abi/juicedStakingR import { STAKING_REWARDS_ABI } from '@shared/contracts/abi/stakingRewards.abi' import { V3_STAKING_REWARDS_ABI } from '@shared/contracts/abi/V3StakingRewards.abi' import { VEYFI_GAUGE_ABI } from '@shared/contracts/abi/veYFIGauge.abi' +import { useReadContract } from '@shared/hooks/useAppWagmi' import { toNormalizedValue } from '@shared/utils' import { useCallback, useMemo } from 'react' -import { useReadContract, useReadContracts } from 'wagmi' +import { useReadContracts } from 'wagmi' +import { resolveExecutionChainId } from '@/config/tenderly' export type TRewardToken = { address: `0x${string}` @@ -32,6 +34,7 @@ type UseStakingRewardsReturn = { export function useStakingRewards(params: UseStakingRewardsParams): UseStakingRewardsReturn { const { stakingAddress, stakingSource, rewardTokens, userAddress, chainId, enabled = true } = params + const executionChainId = resolveExecutionChainId(chainId) const isEnabled = enabled && !!stakingAddress && !!userAddress && rewardTokens.length > 0 @@ -74,9 +77,9 @@ export function useStakingRewards(params: UseStakingRewardsParams): UseStakingRe abi: JUICED_STAKING_REWARDS_ABI, functionName: 'earned' as const, args: [userAddress, token.address] as const, - chainId + chainId: executionChainId })) - }, [isJuiced, stakingAddress, userAddress, rewardTokens, chainId]) + }, [executionChainId, isJuiced, stakingAddress, userAddress, rewardTokens]) const { data: juicedEarned, @@ -84,7 +87,7 @@ export function useStakingRewards(params: UseStakingRewardsParams): UseStakingRe refetch: refetchJuiced } = useReadContracts({ contracts: juicedContracts, - query: { enabled: isEnabled && isJuiced && juicedContracts.length > 0 } + query: { enabled: isEnabled && isJuiced && juicedContracts.length > 0 && !!executionChainId } }) // Legacy/OP Boost staking uses earned(account) for single reward token diff --git a/src/components/pages/vaults/hooks/solvers/useSolverEnso.ts b/src/components/pages/vaults/hooks/solvers/useSolverEnso.ts index a2d3e045c..5a19d27dc 100644 --- a/src/components/pages/vaults/hooks/solvers/useSolverEnso.ts +++ b/src/components/pages/vaults/hooks/solvers/useSolverEnso.ts @@ -1,9 +1,9 @@ +import { type AppUseSimulateContractReturnType, useSimulateContract } from '@shared/hooks/useAppWagmi' import type { TNormalizedBN } from '@shared/types' import { isZeroAddress, toNormalizedBN } from '@shared/utils' import { getApproveAbi } from '@shared/utils/approve' import { useCallback, useEffect, useRef, useState } from 'react' import type { Address, Hex } from 'viem' -import { type UseSimulateContractReturnType, useSimulateContract } from 'wagmi' import { useTokenAllowance } from '../useTokenAllowance' const ENSO_ROUTE_PROXY = '/api/enso/route' @@ -53,7 +53,7 @@ interface UseSolverEnsoProps { interface UseSolverEnsoReturn { actions: { - prepareApprove: UseSimulateContractReturnType + prepareApprove: AppUseSimulateContractReturnType } periphery: { prepareApproveEnabled: boolean @@ -204,7 +204,7 @@ export const useSolverEnso = ({ const isValidInput = amountIn > 0n const isAllowanceSufficient = !allowanceSpender || allowance >= amountIn const prepareApproveEnabled = routerAddress && !isAllowanceSufficient && isValidInput && enabled - const prepareApprove: UseSimulateContractReturnType = useSimulateContract({ + const prepareApprove: AppUseSimulateContractReturnType = useSimulateContract({ abi: getApproveAbi(tokenIn), functionName: 'approve', address: tokenIn, diff --git a/src/components/pages/vaults/hooks/useDebouncedInput.ts b/src/components/pages/vaults/hooks/useDebouncedInput.ts index 9820fa50a..c7bb4408f 100644 --- a/src/components/pages/vaults/hooks/useDebouncedInput.ts +++ b/src/components/pages/vaults/hooks/useDebouncedInput.ts @@ -44,26 +44,23 @@ const tryParseString = (value: string, decimals: number) => { export const useDebouncedInput = (decimals = 18, debounceMs = 500): UseDebouncedInputReturnValue => { const [formValue, setFormValue] = useState('') const [debouncedFormValue, setDebouncedFormValue] = useState('') - const [isDebouncing, setIsDebouncing] = useState(false) const activity = useState(false) - // Debounce the form value - useEffect(() => { - if (formValue === debouncedFormValue) { - setIsDebouncing(false) - return - } + // Render-time state adjustment: trim form value when decimals decrease + const [whole, fraction] = formValue.split('.') + if (fraction && fraction.length > decimals) { + setFormValue(`${whole}.${fraction.slice(0, decimals)}`) + } - setIsDebouncing(true) - const handler = setTimeout(() => { - setDebouncedFormValue(formValue) - setIsDebouncing(false) - }, debounceMs) + // Derived: debouncing when form value differs from debounced value + const isDebouncing = formValue !== debouncedFormValue - return () => { - clearTimeout(handler) - } + // Timer to settle debounced value — external sync (useEffect required) + useEffect(() => { + if (formValue === debouncedFormValue) return + const handler = setTimeout(() => setDebouncedFormValue(formValue), debounceMs) + return () => clearTimeout(handler) }, [formValue, debounceMs]) // Handle callback, no change if number is invalid @@ -83,14 +80,6 @@ export const useDebouncedInput = (decimals = 18, debounceMs = 500): UseDebounced [decimals] ) - // Trim existing if beyond decimal limit - useEffect(() => { - setFormValue((prev) => { - const [whole, fraction] = prev.split('.') - return fraction && fraction.length > decimals ? `${whole}.${fraction.slice(0, decimals)}` : prev - }) - }, [decimals]) - // State change on formValue / decimals const state = useMemo(() => { const bn = tryParseString(formValue, decimals) || 0n diff --git a/src/components/pages/vaults/hooks/useEnsoOrder.ts b/src/components/pages/vaults/hooks/useEnsoOrder.ts index 949b2f730..2ef9ae343 100644 --- a/src/components/pages/vaults/hooks/useEnsoOrder.ts +++ b/src/components/pages/vaults/hooks/useEnsoOrder.ts @@ -1,8 +1,13 @@ +import { + type AppUseSimulateContractReturnType, + usePublicClient, + useWaitForTransactionReceipt +} from '@shared/hooks/useAppWagmi' import { useCallback, useEffect, useMemo, useState } from 'react' import type { Address, Hash, Hex } from 'viem' -import type { UseSimulateContractReturnType } from 'wagmi' -import { usePublicClient, useWaitForTransactionReceipt, useWalletClient } from 'wagmi' -import { supportedChains } from '@/config/supportedChains' +import { useWalletClient } from 'wagmi' +import { supportedWalletChains } from '@/config/supportedChains' +import { resolveExecutionChainId } from '@/config/tenderly' interface EnsoTransaction { to: Address @@ -19,7 +24,7 @@ interface UseEnsoOrderProps { } interface UseEnsoOrderReturn { - prepareEnsoOrder: UseSimulateContractReturnType + prepareEnsoOrder: AppUseSimulateContractReturnType receiptSuccess: boolean txHash: Hash | undefined } @@ -34,12 +39,13 @@ export const useEnsoOrder = ({ const [error, setError] = useState(null) const [txHash, setTxHash] = useState() const [waitingForTx, setWaitingForTx] = useState(false) + const executionChainId = resolveExecutionChainId(chainId) // Don't specify chainId - use current chain. TxButton handles chain switching before execution. const publicClient = usePublicClient() const { data: walletClient } = useWalletClient() const { data: receipt, isSuccess: receiptSuccess } = useWaitForTransactionReceipt({ hash: txHash, - chainId + chainId: executionChainId }) const executeOrder = useCallback(async () => { @@ -51,10 +57,11 @@ export const useEnsoOrder = ({ if (!ensoTx) throw new Error('No Enso transaction data') if (!walletClient) throw new Error('No wallet client available') if (!publicClient) throw new Error('No public client available') + if (!executionChainId) throw new Error(`No execution chain configured for chain ${chainId}`) // Note: Chain switching is handled by TransactionOverlay before calling executeOrder // We use the target chain from props, not walletClient.chain which may be stale - const targetChain = supportedChains.find((c) => c.id === chainId) + const targetChain = supportedWalletChains.find((c) => c.id === executionChainId) // Send the transaction const hash = await walletClient.sendTransaction({ @@ -74,7 +81,7 @@ export const useEnsoOrder = ({ setIsExecuting(false) throw err } - }, [getEnsoTransaction, walletClient, publicClient, chainId]) + }, [chainId, executionChainId, getEnsoTransaction, walletClient, publicClient]) const ensoTx = getEnsoTransaction() useEffect(() => { @@ -95,10 +102,10 @@ export const useEnsoOrder = ({ }, [receipt, waitingForTx]) // Create a mock simulate contract result that TxButton can understand - const prepareEnsoOrder: UseSimulateContractReturnType = useMemo(() => { + const prepareEnsoOrder: AppUseSimulateContractReturnType = useMemo(() => { return { data: - enabled && ensoTx + enabled && ensoTx && executionChainId ? { request: { // Standard contract fields for gas estimation @@ -108,7 +115,7 @@ export const useEnsoOrder = ({ args: [] as readonly unknown[], data: ensoTx.data, value: BigInt(ensoTx.value || 0), - chainId, // Use chainId prop (source chain), not ensoTx.chainId which may be undefined + chainId: executionChainId, account: ensoTx.from, // Custom marker to identify this as an Enso order __isEnsoOrder: true, @@ -124,7 +131,7 @@ export const useEnsoOrder = ({ error: null, isError: false, isLoading: isExecuting || waitingForTx, - isSuccess: enabled && !!ensoTx && !isExecuting && !waitingForTx, + isSuccess: enabled && !!ensoTx && !!executionChainId && !isExecuting && !waitingForTx, isFetching: false, isPending: false, isRefetching: false, @@ -132,8 +139,8 @@ export const useEnsoOrder = ({ status: isExecuting ? 'pending' : error ? 'error' : 'success', fetchStatus: 'idle', dataUpdatedAt: Date.now() - } as UseSimulateContractReturnType - }, [enabled, error, isExecuting, executeOrder, ensoTx, txHash, waitingForTx, chainId]) + } as AppUseSimulateContractReturnType + }, [enabled, error, executeOrder, executionChainId, isExecuting, ensoTx, txHash, waitingForTx]) return { prepareEnsoOrder, diff --git a/src/components/pages/vaults/hooks/useEnsureVaultListFetch.test.ts b/src/components/pages/vaults/hooks/useEnsureVaultListFetch.test.ts deleted file mode 100644 index 2e4609ad8..000000000 --- a/src/components/pages/vaults/hooks/useEnsureVaultListFetch.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { shouldEnableVaultListFetch } from '@pages/vaults/hooks/useEnsureVaultListFetch' -import type { TKongVaultSnapshot } from '@shared/utils/schemas/kongVaultSnapshotSchema' -import { describe, expect, it } from 'vitest' - -describe('shouldEnableVaultListFetch', () => { - it('returns false for direct detail loads until snapshot metadata is available', () => { - expect( - shouldEnableVaultListFetch({ - hasTriggeredVaultListFetch: false, - hasVaultList: false, - isYvUsd: false, - snapshotVault: undefined - }) - ).toBe(false) - }) - - it('returns true once snapshot metadata is available for a direct detail load', () => { - expect( - shouldEnableVaultListFetch({ - hasTriggeredVaultListFetch: false, - hasVaultList: false, - isYvUsd: false, - snapshotVault: { address: '0x0000000000000000000000000000000000000001' } as unknown as TKongVaultSnapshot - }) - ).toBe(true) - }) - - it('returns false after the vault list bootstrap has already been triggered', () => { - expect( - shouldEnableVaultListFetch({ - hasTriggeredVaultListFetch: true, - hasVaultList: false, - isYvUsd: false, - snapshotVault: { address: '0x0000000000000000000000000000000000000001' } as unknown as TKongVaultSnapshot - }) - ).toBe(false) - }) - - it('returns true for yvUSD detail routes even before snapshot metadata arrives', () => { - expect( - shouldEnableVaultListFetch({ - hasTriggeredVaultListFetch: false, - hasVaultList: false, - isYvUsd: true, - snapshotVault: undefined - }) - ).toBe(true) - }) -}) diff --git a/src/components/pages/vaults/hooks/useInput.ts b/src/components/pages/vaults/hooks/useInput.ts index 55c77e5c3..087af043b 100644 --- a/src/components/pages/vaults/hooks/useInput.ts +++ b/src/components/pages/vaults/hooks/useInput.ts @@ -1,5 +1,5 @@ import { exactToSimple } from '@shared/utils' -import { type ChangeEvent, type Dispatch, type SetStateAction, useCallback, useEffect, useMemo, useState } from 'react' +import { type ChangeEvent, type Dispatch, type SetStateAction, useCallback, useMemo, useState } from 'react' import { parseUnits } from 'viem' export interface InputValue { @@ -61,13 +61,11 @@ const createUseInputHook = [setFormValue, decimals] ) - // Trim existing if beyond decimal limit - useEffect(() => { - setFormValue((prev) => { - const [whole, fraction] = prev.split('.') - return fraction && fraction.length > decimals ? `${whole}.${fraction.slice(0, decimals)}` : prev - }) - }, [setFormValue, decimals]) + // Render-time state adjustment: trim form value when decimals decrease + const [whole, fraction] = formValue.split('.') + if (fraction && fraction.length > decimals) { + setFormValue(`${whole}.${fraction.slice(0, decimals)}`) + } // State change on formValue / decimals const state = useMemo(() => { diff --git a/src/components/pages/vaults/hooks/useTokenAllowance.ts b/src/components/pages/vaults/hooks/useTokenAllowance.ts index 9e5960ce1..c486a192b 100644 --- a/src/components/pages/vaults/hooks/useTokenAllowance.ts +++ b/src/components/pages/vaults/hooks/useTokenAllowance.ts @@ -1,6 +1,6 @@ +import { useBlockNumber, useReadContract } from '@shared/hooks/useAppWagmi' import { useEffect } from 'react' import { type Address, erc4626Abi } from 'viem' -import { useBlockNumber, useReadContract } from 'wagmi' export const useTokenAllowance = ({ account, diff --git a/src/components/pages/vaults/hooks/useTokens.ts b/src/components/pages/vaults/hooks/useTokens.ts index b9aa68467..5858b2c73 100644 --- a/src/components/pages/vaults/hooks/useTokens.ts +++ b/src/components/pages/vaults/hooks/useTokens.ts @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query' import { type Address, erc20Abi, getContract } from 'viem' import { useConfig } from 'wagmi' import { getClient } from 'wagmi/actions' +import { resolveExecutionChainId } from '@/config/tenderly' export interface Token { address?: Address @@ -14,11 +15,17 @@ export interface Token { balance: TNormalizedBN } -async function fetchTokenData(config: any, addresses: Address[], chainId: number, account?: Address): Promise { - const client = getClient(config, { chainId }) +async function fetchTokenData( + config: any, + addresses: Address[], + canonicalChainId: number, + executionChainId: number, + account?: Address +): Promise { + const client = getClient(config, { chainId: executionChainId }) if (!client) { - throw new Error(`No client found for chainId ${chainId}`) + throw new Error(`No client found for chainId ${canonicalChainId}`) } const results = await Promise.all( @@ -46,7 +53,7 @@ async function fetchTokenData(config: any, addresses: Address[], chainId: number decimals, symbol, name, - chainID: chainId, + chainID: canonicalChainId, balance: toNormalizedBN(balance, decimals) } } catch (error) { @@ -56,7 +63,7 @@ async function fetchTokenData(config: any, addresses: Address[], chainId: number decimals: 18, symbol: '???', name: 'Unknown', - chainID: chainId, + chainID: canonicalChainId, balance: toNormalizedBN(0n, 18) } } @@ -78,13 +85,14 @@ async function fetchTokenData(config: any, addresses: Address[], chainId: number ******************************************************************************/ export const useTokens = (addresses: (Address | undefined)[], chainId?: number, account?: Address) => { const config = useConfig() + const executionChainId = resolveExecutionChainId(chainId) const validAddresses = addresses.filter((addr): addr is Address => !isZeroAddress(addr)) const { data, isLoading, refetch } = useQuery({ - queryKey: ['tokens', validAddresses.map((a) => a.toLowerCase()).join('.'), chainId, account], - queryFn: () => fetchTokenData(config, validAddresses, chainId || 1, account), - enabled: validAddresses.length > 0 && !!chainId, + queryKey: ['tokens', validAddresses.map((a) => a.toLowerCase()).join('.'), chainId, executionChainId, account], + queryFn: () => fetchTokenData(config, validAddresses, chainId || 1, executionChainId || 1, account), + enabled: validAddresses.length > 0 && !!chainId && !!executionChainId, refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false diff --git a/src/components/pages/vaults/hooks/useVaultUserData.ts b/src/components/pages/vaults/hooks/useVaultUserData.ts index 75d45d499..4d6c0c15b 100644 --- a/src/components/pages/vaults/hooks/useVaultUserData.ts +++ b/src/components/pages/vaults/hooks/useVaultUserData.ts @@ -5,6 +5,7 @@ import { useCallback, useMemo } from 'react' import { type Address, getContract } from 'viem' import { useConfig } from 'wagmi' import { getClient, readContract } from 'wagmi/actions' +import { resolveExecutionChainId } from '@/config/tenderly' import { getStakingRedeemableShares, getStakingWithdrawableAssets } from './actions/stakingAdapter' import { type Token, useTokens } from './useTokens' @@ -47,6 +48,7 @@ export const useVaultUserData = ({ account }: UseVaultUserDataParams): VaultUserData => { const config = useConfig() + const executionChainId = resolveExecutionChainId(chainId) // Reuse useTokens for token data + balances const priorityAddresses = useMemo(() => { @@ -63,9 +65,12 @@ export const useVaultUserData = ({ isLoading: isLoadingPPS, refetch: refetchPPS } = useQuery({ - queryKey: ['vaultPricePerShare', vaultAddress?.toLowerCase(), chainId], + queryKey: ['vaultPricePerShare', vaultAddress?.toLowerCase(), chainId, executionChainId], queryFn: async () => { - const client = getClient(config, { chainId }) + if (!executionChainId) { + throw new Error(`No execution chain found for chainId ${chainId}`) + } + const client = getClient(config, { chainId: executionChainId }) if (!client) { throw new Error(`No client found for chainId ${chainId}`) } @@ -76,7 +81,7 @@ export const useVaultUserData = ({ }) return contract.read.pricePerShare() }, - enabled: !!vaultAddress && !!chainId, + enabled: !!vaultAddress && !!chainId && !!executionChainId, refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false @@ -117,6 +122,7 @@ export const useVaultUserData = ({ stakingAddress?.toLowerCase(), account?.toLowerCase(), chainId, + executionChainId, stakingSource || '', stakingShareBalance.toString() ], @@ -135,7 +141,7 @@ export const useVaultUserData = ({ args?: readonly unknown[] }) => readContract(config, { - chainId, + chainId: executionChainId!, address: request.address, abi: request.abi as any, functionName: request.functionName as any, @@ -161,7 +167,7 @@ export const useVaultUserData = ({ return { withdrawableAssets, redeemableShares } }, - enabled: !!stakingAddress && !!account && !!chainId, + enabled: !!stakingAddress && !!account && !!chainId && !!executionChainId, refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false diff --git a/src/components/pages/vaults/hooks/useVaultsPageModel.ts b/src/components/pages/vaults/hooks/useVaultsPageModel.ts index 31680164c..153f6e880 100644 --- a/src/components/pages/vaults/hooks/useVaultsPageModel.ts +++ b/src/components/pages/vaults/hooks/useVaultsPageModel.ts @@ -49,6 +49,7 @@ import type { TMultiSelectOptionProps } from '@shared/components/MultiSelectDrop import { TokenLogo } from '@shared/components/TokenLogo' import { useWeb3 } from '@shared/contexts/useWeb3' import { usePrefetchYearnVaults } from '@shared/hooks/useFetchYearnVaults' +import { useOptimisticValue } from '@shared/hooks/useOptimisticValue' import { getVaultKey } from '@shared/hooks/useVaultFilterUtils' import type { TSortDirection } from '@shared/types' import type { RefObject } from 'react' @@ -69,6 +70,24 @@ import { VAULTS_FILTERS_STORAGE_KEY } from './vaultsFiltersStorage' const DEFAULT_VAULT_TYPES = ['multi', 'single'] const DEFAULT_SORT_BY: TPossibleSortBy = 'tvl' +const areArraysEquivalent = ( + a: Array | null | undefined, + b: Array | null | undefined +): boolean => { + const normalize = (value: Array | null | undefined): Array => { + if (!value || value.length === 0) { + return [] + } + return [...new Set(value)].sort((left, right) => String(left).localeCompare(String(right))) + } + const normalizedA = normalize(a) + const normalizedB = normalize(b) + if (normalizedA.length !== normalizedB.length) { + return false + } + return normalizedA.every((value, index) => value === normalizedB[index]) +} + type TVaultsPinnedSection = { key: string vaults: TKongVaultInput[] @@ -255,16 +274,6 @@ export function useVaultsPageModel(): TVaultsPageModel { usePrefetchYearnVaults(vaultType === 'v3') - useEffect(() => { - if (sortBy !== 'featuringScore') { - return - } - onChangeSortBy(DEFAULT_SORT_BY) - if (sortDirection !== 'desc') { - onChangeSortDirection('desc') - } - }, [sortBy, sortDirection, onChangeSortBy, onChangeSortDirection]) - const varsRef = useRef(null) const filtersRef = useRef(null) const searchValue = search ?? '' @@ -279,16 +288,22 @@ export function useVaultsPageModel(): TVaultsPageModel { }) ?? false const shouldCollapseChips = isBelow1000 const shouldStackFilters = isBelow1000 && !isBelow768 - const [optimisticVaultType, setOptimisticVaultType] = useState(null) - const [optimisticChains, setOptimisticChains] = useState(null) - const [optimisticTypes, setOptimisticTypes] = useState(null) - const [optimisticCategories, setOptimisticCategories] = useState(null) - const [optimisticAggressiveness, setOptimisticAggressiveness] = useState(null) - const [optimisticUnderlyingAssets, setOptimisticUnderlyingAssets] = useState(null) - const [optimisticMinTvl, setOptimisticMinTvl] = useState(null) - const [optimisticShowLegacyVaults, setOptimisticShowLegacyVaults] = useState(null) - const [optimisticShowHiddenVaults, setOptimisticShowHiddenVaults] = useState(null) - const [optimisticShowStrategies, setOptimisticShowStrategies] = useState(null) + + // Optimistic UI — show immediate feedback while URL state catches up + const [displayedVaultType, setOptimisticVaultType] = useOptimisticValue(vaultType) + const [displayedChains, setOptimisticChains] = useOptimisticValue(chains, areArraysEquivalent) + const [displayedTypes, setOptimisticTypes] = useOptimisticValue(types, areArraysEquivalent) + const [displayedCategories, setOptimisticCategories] = useOptimisticValue(categories, areArraysEquivalent) + const [displayedAggressiveness, setOptimisticAggressiveness] = useOptimisticValue(aggressiveness, areArraysEquivalent) + const [displayedUnderlyingAssets, setOptimisticUnderlyingAssets] = useOptimisticValue( + underlyingAssets, + areArraysEquivalent + ) + const [displayedMinTvl, setOptimisticMinTvl] = useOptimisticValue(minTvl) + const [displayedShowLegacyVaults, setOptimisticShowLegacyVaults] = useOptimisticValue(showLegacyVaults) + const [displayedShowHiddenVaults, setOptimisticShowHiddenVaults] = useOptimisticValue(showHiddenVaults) + const [displayedShowStrategies, setOptimisticShowStrategies] = useOptimisticValue(showStrategies) + const listChains = useDeferredValue(chains) const listTypes = useDeferredValue(types) const listCategories = useDeferredValue(categories) @@ -298,95 +313,7 @@ export function useVaultsPageModel(): TVaultsPageModel { const listShowLegacyVaults = useDeferredValue(showLegacyVaults) const listShowHiddenVaults = useDeferredValue(showHiddenVaults) const listShowStrategies = useDeferredValue(showStrategies) - const areArraysEquivalent = useCallback( - (a: Array | null | undefined, b: Array | null | undefined): boolean => { - const normalize = (value: Array | null | undefined): Array => { - if (!value || value.length === 0) { - return [] - } - return [...new Set(value)].sort((left, right) => String(left).localeCompare(String(right))) - } - const normalizedA = normalize(a) - const normalizedB = normalize(b) - if (normalizedA.length !== normalizedB.length) { - return false - } - return normalizedA.every((value, index) => value === normalizedB[index]) - }, - [] - ) - - useEffect(() => { - if (optimisticVaultType && optimisticVaultType === vaultType) { - setOptimisticVaultType(null) - } - }, [optimisticVaultType, vaultType]) - - useEffect(() => { - if (optimisticChains !== null && areArraysEquivalent(optimisticChains, chains)) { - setOptimisticChains(null) - } - }, [optimisticChains, chains, areArraysEquivalent]) - - useEffect(() => { - if (optimisticTypes !== null && areArraysEquivalent(optimisticTypes, types)) { - setOptimisticTypes(null) - } - }, [optimisticTypes, types, areArraysEquivalent]) - - useEffect(() => { - if (optimisticCategories !== null && areArraysEquivalent(optimisticCategories, categories)) { - setOptimisticCategories(null) - } - }, [optimisticCategories, categories, areArraysEquivalent]) - - useEffect(() => { - if (optimisticAggressiveness !== null && areArraysEquivalent(optimisticAggressiveness, aggressiveness)) { - setOptimisticAggressiveness(null) - } - }, [optimisticAggressiveness, aggressiveness, areArraysEquivalent]) - - useEffect(() => { - if (optimisticUnderlyingAssets !== null && areArraysEquivalent(optimisticUnderlyingAssets, underlyingAssets)) { - setOptimisticUnderlyingAssets(null) - } - }, [optimisticUnderlyingAssets, underlyingAssets, areArraysEquivalent]) - - useEffect(() => { - if (optimisticMinTvl !== null && optimisticMinTvl === minTvl) { - setOptimisticMinTvl(null) - } - }, [optimisticMinTvl, minTvl]) - - useEffect(() => { - if (optimisticShowLegacyVaults !== null && optimisticShowLegacyVaults === showLegacyVaults) { - setOptimisticShowLegacyVaults(null) - } - }, [optimisticShowLegacyVaults, showLegacyVaults]) - - useEffect(() => { - if (optimisticShowHiddenVaults !== null && optimisticShowHiddenVaults === showHiddenVaults) { - setOptimisticShowHiddenVaults(null) - } - }, [optimisticShowHiddenVaults, showHiddenVaults]) - - useEffect(() => { - if (optimisticShowStrategies !== null && optimisticShowStrategies === showStrategies) { - setOptimisticShowStrategies(null) - } - }, [optimisticShowStrategies, showStrategies]) - - const displayedVaultType = optimisticVaultType ?? vaultType - const displayedChains = optimisticChains ?? chains - const displayedTypes = optimisticTypes ?? types - const displayedCategories = optimisticCategories ?? categories - const displayedAggressiveness = optimisticAggressiveness ?? aggressiveness - const displayedUnderlyingAssets = optimisticUnderlyingAssets ?? underlyingAssets - const displayedMinTvl = optimisticMinTvl ?? minTvl - const displayedShowLegacyVaults = optimisticShowLegacyVaults ?? showLegacyVaults - const displayedShowHiddenVaults = optimisticShowHiddenVaults ?? showHiddenVaults - const displayedShowStrategies = optimisticShowStrategies ?? showStrategies - const hasDisplayedTypesParam = hasTypesParam || optimisticTypes !== null + const hasDisplayedTypesParam = hasTypesParam || displayedTypes !== types const hasListTypesParam = hasTypesParam const resolveV3Types = useCallback( @@ -876,13 +803,13 @@ export function useVaultsPageModel(): TVaultsPageModel { const handleVaultVersionToggle = useCallback( (nextType: TVaultType): void => { - if (nextType === vaultType && !optimisticVaultType) { + if (nextType === displayedVaultType) { return } setOptimisticVaultType(nextType) onChangeVaultType(nextType) }, - [optimisticVaultType, onChangeVaultType, vaultType] + [displayedVaultType, onChangeVaultType] ) const handleToggleVaultType = useCallback( (nextType: 'v3' | 'lp'): void => { diff --git a/src/components/pages/vaults/hooks/useYvUsdVaults.helpers.test.ts b/src/components/pages/vaults/hooks/useYvUsdVaults.helpers.test.ts deleted file mode 100644 index 87474d39b..000000000 --- a/src/components/pages/vaults/hooks/useYvUsdVaults.helpers.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from '../utils/yvUsd' -import { buildSyntheticBaseVault, buildYvUsdVaultsModel, getYvUsdTvlBreakdown } from './useYvUsdVaults.helpers' - -const UNDERLYING_ASSET = { - address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', - name: 'USD Coin', - symbol: 'USDC', - decimals: 6, - chainId: 1 -} as const - -const UNLOCKED_SNAPSHOT = { - address: YVUSD_UNLOCKED_ADDRESS, - apiVersion: '3.0.4', - decimals: 6, - totalAssets: '1000000000', - asset: UNDERLYING_ASSET, - apy: { - net: 0.04, - weeklyNet: 0.05, - monthlyNet: 0.04, - pricePerShare: '1010000', - weeklyPricePerShare: '1008000', - monthlyPricePerShare: '1005000' - }, - tvl: { - close: 1000 - }, - fees: { - managementFee: 0, - performanceFee: 0 - }, - meta: { - kind: 'Multi Strategy', - type: 'Automated Yearn Vault', - category: 'Stablecoin', - token: UNDERLYING_ASSET, - isRetired: false, - isHidden: true, - isBoosted: false - }, - composition: [ - { - address: '0x5f9DBa2805411a8382FDb4E69d4f2Da8EFaF1F89', - name: 'Infinifi sIUSD Morpho Looper', - status: 'active', - currentDebt: '250000000', - totalDebt: '250000000', - totalGain: '0', - totalLoss: '0', - performanceFee: 0, - lastReport: 1_773_076_679, - performance: { - oracle: { - apy: 0, - apr: 0 - }, - historical: { - net: 0.02, - weeklyNet: 0.03, - monthlyNet: 0.02, - inceptionNet: 0.01 - } - } - } - ], - debts: [] -} as const - -const LOCKED_SNAPSHOT = { - address: YVUSD_LOCKED_ADDRESS, - apiVersion: '3.0.4', - decimals: 6, - totalAssets: '500000000', - asset: { - address: YVUSD_UNLOCKED_ADDRESS, - name: 'USD yVault', - symbol: 'yvUSD', - decimals: 6, - chainId: 1 - }, - apy: { - net: 0.5, - weeklyNet: 0.52, - monthlyNet: 0.5, - pricePerShare: '1030000', - weeklyPricePerShare: '1020000', - monthlyPricePerShare: '1010000' - }, - tvl: { - close: 500 - }, - fees: { - managementFee: 0, - performanceFee: 0 - }, - meta: { - kind: 'Multi Strategy', - type: 'Automated Yearn Vault', - category: 'Stablecoin', - token: { - address: YVUSD_UNLOCKED_ADDRESS, - name: 'USD yVault', - symbol: 'yvUSD', - decimals: 6, - chainId: 1 - }, - isRetired: false, - isHidden: true, - isBoosted: false - }, - performance: { - estimated: { - apr: 0.1, - apy: 0.11, - type: 'yvusd-estimated-apr', - components: { - baseNetAPR: 0.08, - lockerBonusAPR: 0.03 - } - }, - historical: { - net: 0.5, - weeklyNet: 0.52, - monthlyNet: 0.5, - inceptionNet: 0.4 - } - }, - composition: [], - debts: [] -} as const - -const LOCKED_LIST_BASE_VAULT = { - chainId: 1, - address: YVUSD_LOCKED_ADDRESS, - name: 'Locked yvUSD', - symbol: 'Locked yvUSD', - apiVersion: '3.0.4', - decimals: 6, - asset: { - address: YVUSD_UNLOCKED_ADDRESS, - name: 'USD yVault', - symbol: 'yvUSD', - decimals: 6 - }, - tvl: 500, - performance: { - oracle: { - apr: 0.02, - apy: 0.02 - }, - estimated: { - apr: 0.1, - apy: 0.11, - type: 'yvusd-estimated-apr', - components: {} - }, - historical: { - net: 0.5, - weeklyNet: 0.52, - monthlyNet: 0.5, - inceptionNet: 0.4 - } - }, - fees: { - managementFee: 0, - performanceFee: 0 - }, - category: 'Stablecoin', - type: 'Automated Yearn Vault', - kind: 'Multi Strategy', - v3: true, - yearn: true, - isRetired: false, - isHidden: true, - isBoosted: false, - isHighlighted: false, - inclusion: { - isYearn: true - }, - migration: false, - origin: 'yearn', - strategiesCount: 0, - riskLevel: 1, - staking: null, - pricePerShare: '1030000' -} as const - -describe('buildYvUsdVaultsModel', () => { - it('builds the combined yvUSD model from Kong snapshots and points booleans', () => { - const baseVault = buildSyntheticBaseVault(UNLOCKED_SNAPSHOT as any) - const model = buildYvUsdVaultsModel({ - baseVault, - unlockedSnapshot: UNLOCKED_SNAPSHOT as any, - lockedSnapshot: LOCKED_SNAPSHOT as any, - points: { - unlocked: true, - locked: false - } - }) - - expect(model.assetAddress).toBe(UNDERLYING_ASSET.address) - expect(model.listVault.tvl.tvl).toBe(1000) - expect(model.lockedVault.apr.forwardAPR.netAPR).toBe(0.11) - expect(model.unlockedVault.strategies?.[0].name).toBe('Infinifi sIUSD Morpho Looper') - expect(model.metrics.unlocked.hasInfinifiPoints).toBe(true) - expect(model.metrics.locked.hasInfinifiPoints).toBe(false) - }) - - it('prefers the unlocked snapshot asset over a locked-base vault asset when resolving deposits', () => { - const model = buildYvUsdVaultsModel({ - baseVault: LOCKED_LIST_BASE_VAULT as any, - unlockedSnapshot: UNLOCKED_SNAPSHOT as any, - lockedSnapshot: LOCKED_SNAPSHOT as any - }) - - expect(model.assetAddress).toBe(UNDERLYING_ASSET.address) - }) -}) - -describe('getYvUsdTvlBreakdown', () => { - it('treats the unlocked vault TVL as total and derives the unlocked remainder after locked TVL', () => { - expect(getYvUsdTvlBreakdown({ totalTvl: 1000, lockedTvl: 500 })).toEqual({ - totalTvl: 1000, - unlockedTvl: 500, - lockedTvl: 500 - }) - }) - - it('clamps the unlocked remainder at zero when locked TVL exceeds total TVL', () => { - expect(getYvUsdTvlBreakdown({ totalTvl: 100, lockedTvl: 120 })).toEqual({ - totalTvl: 100, - unlockedTvl: 0, - lockedTvl: 120 - }) - }) -}) diff --git a/src/components/pages/vaults/index.tsx b/src/components/pages/vaults/index.tsx index 9b67fe7b1..c28ec44b9 100644 --- a/src/components/pages/vaults/index.tsx +++ b/src/components/pages/vaults/index.tsx @@ -216,11 +216,10 @@ export default function Index(): ReactElement { return !chainsMatch || isUpdatingProductType }, [activeChains, listChains, isUpdatingProductType, areArraysEquivalent]) - useEffect(() => { - if (isCompareOpen && compareVaultKeys.length < 2) { - setIsCompareOpen(false) - } - }, [compareVaultKeys.length, isCompareOpen]) + // Render-time state adjustment: close compare when fewer than 2 vaults selected + if (isCompareOpen && compareVaultKeys.length < 2) { + setIsCompareOpen(false) + } useEffect(() => { if (!tourState.isOpen) { diff --git a/src/components/pages/vaults/types/index.ts b/src/components/pages/vaults/types/index.ts index 5766bafac..43a7e271c 100644 --- a/src/components/pages/vaults/types/index.ts +++ b/src/components/pages/vaults/types/index.ts @@ -1,6 +1,6 @@ +import type { AppUseSimulateContractReturnType } from '@shared/hooks/useAppWagmi' import type { TAddress, TNormalizedBN } from '@shared/types' import type { Hex } from 'viem' -import type { UseSimulateContractReturnType } from 'wagmi' export type T = { actions: Actions @@ -9,8 +9,8 @@ export type T = { export type UseStakeReturn = T< { - prepareApprove: UseSimulateContractReturnType - prepareStake: UseSimulateContractReturnType + prepareApprove: AppUseSimulateContractReturnType + prepareStake: AppUseSimulateContractReturnType }, { prepareApproveEnabled: boolean @@ -22,7 +22,7 @@ export type UseStakeReturn = T< export type UseUnstakeReturn = T< { - prepareUnstake: UseSimulateContractReturnType + prepareUnstake: AppUseSimulateContractReturnType }, { prepareUnstakeEnabled: boolean @@ -32,8 +32,8 @@ export type UseUnstakeReturn = T< export type UseDepositReturn = T< { - prepareApprove: UseSimulateContractReturnType - prepareDeposit: UseSimulateContractReturnType + prepareApprove: AppUseSimulateContractReturnType + prepareDeposit: AppUseSimulateContractReturnType }, { prepareApproveEnabled: boolean @@ -45,7 +45,7 @@ export type UseDepositReturn = T< export type UseWithdrawReturn = T< { - prepareWithdraw: UseSimulateContractReturnType + prepareWithdraw: AppUseSimulateContractReturnType }, { prepareWithdrawEnabled: boolean @@ -57,8 +57,8 @@ export type UseWithdrawReturn = T< // Unified interface for WidgetDepositFinal flows (direct deposit, direct stake, Enso) export type UseWidgetDepositFlowReturn = T< { - prepareApprove: UseSimulateContractReturnType - prepareDeposit: UseSimulateContractReturnType + prepareApprove: AppUseSimulateContractReturnType + prepareDeposit: AppUseSimulateContractReturnType }, { prepareApproveEnabled: boolean @@ -83,8 +83,8 @@ export type UseWidgetDepositFlowReturn = T< // Unified interface for WidgetWithdrawFinal flows (direct withdraw, direct unstake, Enso) export type UseWidgetWithdrawFlowReturn = T< { - prepareWithdraw: UseSimulateContractReturnType - prepareApprove?: UseSimulateContractReturnType // Optional: only needed for ENSO withdrawals + prepareWithdraw: AppUseSimulateContractReturnType + prepareApprove?: AppUseSimulateContractReturnType // Optional: only needed for ENSO withdrawals }, { prepareWithdrawEnabled: boolean @@ -118,9 +118,9 @@ export type MigrateRouteType = 'permit' | 'approve' export type UseMigrateFlowReturn = T< { - prepareApprove: UseSimulateContractReturnType - prepareMigrate: UseSimulateContractReturnType - prepareMulticall: UseSimulateContractReturnType // For permit flow: multicall(selfPermit + migrate) + prepareApprove: AppUseSimulateContractReturnType + prepareMigrate: AppUseSimulateContractReturnType + prepareMulticall: AppUseSimulateContractReturnType // For permit flow: multicall(selfPermit + migrate) }, { isAllowanceSufficient: boolean diff --git a/src/components/pages/vaults/utils/blockingFilterInsights.test.ts b/src/components/pages/vaults/utils/blockingFilterInsights.test.ts deleted file mode 100644 index f5f53da05..000000000 --- a/src/components/pages/vaults/utils/blockingFilterInsights.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - getAdditionalResultsForCombo, - getCommonBlockingKeys, - shouldShowComboBlockingAction -} from './blockingFilterInsights' - -type TKey = 'showAllChains' | 'showAllVaults' | 'clearMinTvl' | 'showAllCategories' | 'showStrategies' - -describe('blockingFilterInsights', () => { - it('derives common blockers and combo additions from combo-applied filters', () => { - const hiddenBlockingKeys: TKey[][] = [ - ['showAllVaults', 'showAllChains'], - ['showAllVaults', 'showAllChains', 'clearMinTvl'], - ['showAllVaults', 'showAllChains', 'showAllCategories'] - ] - - const comboKeys = getCommonBlockingKeys(hiddenBlockingKeys) - const comboAdditionalResults = getAdditionalResultsForCombo(hiddenBlockingKeys, comboKeys) - - expect(comboKeys).toEqual(['showAllVaults', 'showAllChains']) - expect(comboAdditionalResults).toBe(1) - }) - - it('does not count hidden entries without detected blockers', () => { - const comboAdditionalResults = getAdditionalResultsForCombo( - [[], ['showAllChains'], ['showAllChains', 'clearMinTvl']], - ['showAllChains'] - ) - - expect(comboAdditionalResults).toBe(1) - }) - - it('shows fallback combo action for a single common blocker when no individual actions are available', () => { - const shouldShow = shouldShowComboBlockingAction({ - hiddenByFiltersCount: 3, - comboKeys: ['showAllChains'], - actionableKeys: new Set() - }) - - expect(shouldShow).toBe(true) - }) - - it('hides combo action when hidden results do not exist', () => { - const shouldShow = shouldShowComboBlockingAction({ - hiddenByFiltersCount: 0, - comboKeys: ['showAllChains'], - actionableKeys: new Set() - }) - - expect(shouldShow).toBe(false) - }) - - it('hides combo action when all combo keys are already individually actionable', () => { - const shouldShow = shouldShowComboBlockingAction({ - hiddenByFiltersCount: 4, - comboKeys: ['showAllChains', 'showStrategies'], - actionableKeys: new Set(['showAllChains', 'showStrategies']) - }) - - expect(shouldShow).toBe(false) - }) -}) diff --git a/src/components/pages/vaults/utils/chainSelection.test.ts b/src/components/pages/vaults/utils/chainSelection.test.ts deleted file mode 100644 index 66c581a62..000000000 --- a/src/components/pages/vaults/utils/chainSelection.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { resolveNextSingleChainSelection } from './chainSelection' - -describe('resolveNextSingleChainSelection', () => { - it('selects the requested chain when nothing is selected', () => { - expect(resolveNextSingleChainSelection(null, 1)).toEqual([1]) - expect(resolveNextSingleChainSelection([], 10)).toEqual([10]) - }) - - it('switches directly to the requested chain', () => { - expect(resolveNextSingleChainSelection([1], 10)).toEqual([10]) - expect(resolveNextSingleChainSelection([1, 10], 42161)).toEqual([42161]) - }) - - it('clears the filter when reselecting the only active chain', () => { - expect(resolveNextSingleChainSelection([1], 1)).toBeNull() - }) -}) diff --git a/src/components/pages/vaults/utils/vaultLogo.test.ts b/src/components/pages/vaults/utils/vaultLogo.test.ts deleted file mode 100644 index 685695f12..000000000 --- a/src/components/pages/vaults/utils/vaultLogo.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' -import { describe, expect, it } from 'vitest' -import { getVaultPrimaryLogoSrc } from './vaultLogo' -import { YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from './yvUsd' - -const STANDARD_VAULT = { - version: '3.0.0', - chainID: 1, - address: '0x0000000000000000000000000000000000000001', - token: { - address: '0x0000000000000000000000000000000000000002', - symbol: 'TKN', - decimals: 18 - } -} - -describe('getVaultPrimaryLogoSrc', () => { - it('returns the yvUSD seal logo for the unlocked vault', () => { - expect( - getVaultPrimaryLogoSrc({ - ...STANDARD_VAULT, - address: YVUSD_UNLOCKED_ADDRESS - } as unknown as TKongVaultInput) - ).toMatch(/yvUSD-seal\.png$/) - }) - - it('returns the yvUSD seal logo for the locked vault', () => { - expect( - getVaultPrimaryLogoSrc({ - ...STANDARD_VAULT, - address: YVUSD_LOCKED_ADDRESS - } as unknown as TKongVaultInput) - ).toMatch(/yvUSD-seal\.png$/) - }) - - it('returns the standard token asset logo for non-yvUSD vaults', () => { - expect(getVaultPrimaryLogoSrc(STANDARD_VAULT as unknown as TKongVaultInput)).toContain('logo-128.png') - }) -}) diff --git a/src/components/shared/components/Header.tsx b/src/components/shared/components/Header.tsx index 06bbede8e..680143853 100644 --- a/src/components/shared/components/Header.tsx +++ b/src/components/shared/components/Header.tsx @@ -1,5 +1,6 @@ import { setThemePreference, useThemePreference } from '@hooks/useThemePreference' import { useNotifications } from '@shared/contexts/useNotifications' +import { useTenderlyPanel } from '@shared/contexts/useTenderlyPanel' import useWallet from '@shared/contexts/useWallet' import { useWeb3 } from '@shared/contexts/useWeb3' import { IconBurgerPlain } from '@shared/icons/IconBurgerPlain' @@ -14,6 +15,7 @@ import { truncateHex } from '@shared/utils/tools.address' import type { ReactElement } from 'react' import { useMemo, useState } from 'react' import { useLocation } from 'react-router' +import { isTenderlyModeEnabled, tenderlyRuntime } from '@/config/tenderly' import Link from '/src/components/Link' import { AccountDropdown } from './AccountDropdown' import { HeaderNavMenu } from './HeaderNavMenu' @@ -98,6 +100,50 @@ function WalletSelector({ onAccountClick, notificationStatus }: TWalletSelectorP ) } +function TenderlyIndicator(): ReactElement | null { + const { isPanelAvailable, isOpen, togglePanel } = useTenderlyPanel() + + if (!isTenderlyModeEnabled()) { + return null + } + + const configuredMappings = tenderlyRuntime.configuredCanonicalChainIds + .map((canonicalChainId) => { + const executionChainId = tenderlyRuntime.configuredByCanonicalId[canonicalChainId]?.executionChainId + return executionChainId ? `${canonicalChainId} -> ${executionChainId}` : String(canonicalChainId) + }) + .join(', ') + + if (!isPanelAvailable) { + return ( + + {'Tenderly'} + + ) + } + + return ( + + ) +} + function AppHeader(): ReactElement { const location = useLocation() const pathname = location.pathname @@ -144,6 +190,8 @@ function AppHeader(): ReactElement { + + + ) +} + +function PanelShell(props: { + title: string + subtitle?: string + onClose?: () => void + children: ReactElement + className?: string +}): ReactElement { + return ( +
+
+
+

{props.title}

+ {props.subtitle ?

{props.subtitle}

: null} +
+ {props.onClose ? : null} +
+
{props.children}
+
+ ) +} + +function ChainSelector(props: { + availableChains: Array<{ + canonicalChainId: number + canonicalChainName: string + executionChainId: number + hasAdminRpc: boolean + }> + selectedCanonicalChainId?: number + setSelectedCanonicalChainId: (chainId: number) => void +}): ReactElement | null { + if (props.availableChains.length <= 1) { + return null + } + + return ( + + ) +} + +function BaselineGateCard(props: { + pendingAction: string | null + disabled?: boolean + onCreateBaseline: () => Promise +}): ReactElement { + return ( +
+

{'Create a baseline snapshot first'}

+

+ {'All Tenderly mutations stay locked until this chain has a baseline snapshot you can return to.'} +

+
+ void props.onCreateBaseline()} + disabled={props.disabled || props.pendingAction !== null} + variant="primary" + /> +
+
+ ) +} + +function SnapshotList(props: { + pendingAction: string | null + snapshotRecords: TTenderlySnapshotRecord[] + onRevert: (snapshotRecord: TTenderlySnapshotRecord) => Promise +}): ReactElement { + if (props.snapshotRecords.length === 0) { + return

{'No snapshots stored for this Tenderly chain yet.'}

+ } + + return ( +
+ {props.snapshotRecords.map((snapshotRecord) => ( +
+
+
+

{snapshotRecord.label}

+ + {snapshotRecord.kind} + + {snapshotRecord.lastKnownStatus === 'invalid' ? ( + + {'invalid'} + + ) : null} +
+

+ {new Date(snapshotRecord.createdAt).toLocaleString('en-US')} +

+
+ void props.onRevert(snapshotRecord)} + disabled={props.pendingAction !== null || snapshotRecord.lastKnownStatus !== 'valid'} + /> +
+ ))} +
+ ) +} + +function WalletAssetRow(props: { + asset: TTenderlyFundableAsset + isSelected: boolean + onSelect: () => void +}): ReactElement { + return ( + + ) +} + +function useTenderlyControlState() { + const { + status, + selectedCanonicalChainId, + selectedExecutionChainId, + snapshotRecords, + baselineSnapshot, + fundableAssets, + connectedWalletAddress, + pendingAction, + setSelectedCanonicalChainId, + createBaselineSnapshot, + createSnapshot, + clearSnapshotHistory, + revertToSnapshot, + increaseTime, + fundWallet + } = useTenderlyPanel() + const [customAmount, setCustomAmount] = useState('14') + const [customUnit, setCustomUnit] = useState('days') + const [fundSearch, setFundSearch] = useState('') + const [selectedAssetAddress, setSelectedAssetAddress] = useState<`0x${string}` | undefined>(undefined) + const [fundAmount, setFundAmount] = useState('') + const [nativeFundMode, setNativeFundMode] = useState<'add' | 'set'>('add') + const availableChains = useMemo( + () => (status?.configuredChains || []).filter((chain) => chain.hasAdminRpc), + [status?.configuredChains] + ) + const selectedChain = useMemo( + () => availableChains.find((chain) => chain.canonicalChainId === selectedCanonicalChainId), + [availableChains, selectedCanonicalChainId] + ) + const controlsUnlocked = Boolean(baselineSnapshot) + const lastRestorableSnapshot = useMemo(() => getLastRestorableTenderlySnapshot(snapshotRecords), [snapshotRecords]) + + const selectedAssetAddressOrFallback = + selectedAssetAddress && fundableAssets.some((asset) => asset.address === selectedAssetAddress) + ? selectedAssetAddress + : fundableAssets[0]?.address + const selectedAsset = fundableAssets.find((asset) => asset.address === selectedAssetAddressOrFallback) + const filteredAssets = useMemo(() => { + const normalizedSearch = fundSearch.trim().toLowerCase() + if (!normalizedSearch) { + return getDefaultTenderlyFundableAssets(fundableAssets, 14) + } + + return fundableAssets + .filter((asset) => + [asset.symbol, asset.name, asset.address, asset.tokenType].some((value) => + value.toLowerCase().includes(normalizedSearch) + ) + ) + .slice(0, 14) + }, [fundSearch, fundableAssets]) + + const customSeconds = convertTenderlyTimeAmountToSeconds(Number(customAmount), customUnit) + + const handleQuickForwardInput = (amount: number, unit: TTenderlyFastForwardUnit): void => { + const nextInput = addTenderlyTimeIncrement({ + currentAmount: Number(customAmount), + currentUnit: customUnit, + addedAmount: amount, + addedUnit: unit + }) + + setCustomAmount(String(nextInput.amount)) + setCustomUnit(nextInput.unit) + } + + const handleCustomFastForward = async (): Promise => { + if (customSeconds <= 0) { + return + } + + await increaseTime({ seconds: customSeconds, mineBlock: true }) + } + + const handleFundWallet = async (): Promise => { + if (!selectedAsset || !fundAmount) { + return + } + + await fundWallet({ + assetKind: selectedAsset.assetKind, + tokenAddress: selectedAsset.assetKind === 'erc20' ? selectedAsset.address : undefined, + symbol: selectedAsset.symbol, + decimals: selectedAsset.decimals, + amount: fundAmount, + mode: selectedAsset.assetKind === 'native' ? nativeFundMode : 'set' + }) + setFundAmount('') + } + + const handleResetToLast = async (): Promise => { + if (!lastRestorableSnapshot) { + return + } + + await revertToSnapshot(lastRestorableSnapshot) + } + + return { + availableChains, + selectedChain, + selectedCanonicalChainId, + selectedExecutionChainId, + snapshotRecords, + baselineSnapshot, + controlsUnlocked, + lastRestorableSnapshot, + fundableAssets, + filteredAssets, + selectedAsset, + selectedAssetAddress: selectedAssetAddressOrFallback, + setSelectedAssetAddress, + connectedWalletAddress, + pendingAction, + setSelectedCanonicalChainId, + createBaselineSnapshot, + createSnapshot, + clearSnapshotHistory, + revertToSnapshot, + handleResetToLast, + customAmount, + setCustomAmount, + customUnit, + setCustomUnit, + customSeconds, + handleQuickForwardInput, + handleCustomFastForward, + fundSearch, + setFundSearch, + fundAmount, + setFundAmount, + nativeFundMode, + setNativeFundMode, + handleFundWallet + } +} + +type TTenderlyControlState = ReturnType + +function SnapshotPanel(props: { state: TTenderlyControlState; onClose?: () => void }): ReactElement { + const { state } = props + + return ( + execution ${state.selectedExecutionChainId}` + : 'Select a configured Tenderly chain' + } + onClose={props.onClose} + > +
+ {!state.controlsUnlocked ? ( + + ) : ( +
+
+

{'Saved snapshots'}

+

+ {state.lastRestorableSnapshot?.kind === 'snapshot' + ? `Reset currently targets ${state.lastRestorableSnapshot.label}.` + : 'Reset currently falls back to the baseline snapshot.'} +

+
+
+ + void state.createSnapshot()} + disabled={state.pendingAction !== null} + variant="primary" + /> +
+
+ )} + +
+
+ ) +} + +function FastForwardPanel(props: { state: TTenderlyControlState; onClose?: () => void }): ReactElement { + const { state } = props + + return ( + +
+ {!state.controlsUnlocked ? ( + + ) : ( + <> +
+ {QUICK_FORWARD_OPTIONS.map((option) => ( + state.handleQuickForwardInput(option.amount, option.unit)} + /> + ))} +
+
+ state.setCustomAmount(event.target.value)} + className="rounded-full border border-border bg-surface px-3 py-2 text-sm text-text-primary" + placeholder="Amount" + /> + + void state.handleCustomFastForward()} + disabled={state.pendingAction !== null || state.customSeconds <= 0} + variant="primary" + /> +
+

+ {state.customSeconds > 0 + ? `Ready to move Tenderly forward by ${state.customSeconds.toLocaleString('en-US')} seconds and mine one block.` + : 'Enter a positive amount to fast-forward Tenderly.'} +

+ + )} +
+
+ ) +} + +function WalletFaucetPanel(props: { state: TTenderlyControlState; onClose?: () => void }): ReactElement { + const { state } = props + + return ( + +
+ {!state.controlsUnlocked ? ( + + ) : ( + <> + state.setFundSearch(event.target.value)} + className="w-full rounded-2xl border border-border bg-surface px-3 py-2 text-sm text-text-primary" + placeholder="Search common Yearn and Kong assets" + /> +
+ {state.filteredAssets.length === 0 ? ( +

{'No matching assets'}

+ ) : ( + state.filteredAssets.map((asset) => ( + state.setSelectedAssetAddress(asset.address)} + /> + )) + )} +
+
+

{'Selected asset'}

+ {state.selectedAsset ? ( + <> +

+ {`${state.selectedAsset.symbol} · ${state.selectedAsset.name}`} +

+

+ {state.selectedAsset.address} +

+ + ) : ( +

{'Choose an asset to fund the connected wallet.'}

+ )} +
+
+ state.setFundAmount(event.target.value)} + className="rounded-full border border-border bg-surface px-3 py-2 text-sm text-text-primary" + placeholder={state.selectedAsset ? `Amount in ${state.selectedAsset.symbol}` : 'Amount'} + /> + {state.selectedAsset?.assetKind === 'native' ? ( + + ) : null} + void state.handleFundWallet()} + disabled={ + state.pendingAction !== null || + !state.connectedWalletAddress || + !state.selectedAsset || + !state.fundAmount + } + variant="primary" + /> +
+ + )} +
+
+ ) +} + +function MobileTenderlyPanelContent(props: { state: TTenderlyControlState }): ReactElement { + const { state } = props + + return ( +
+
+
+

{'Tenderly Control Panel'}

+

+ {state.selectedCanonicalChainId && state.selectedExecutionChainId + ? `Canonical ${state.selectedCanonicalChainId} -> execution ${state.selectedExecutionChainId}` + : 'Select a configured Tenderly chain'} +

+
+
+ + {state.availableChains.length <= 1 ? ( + + {state.selectedChain?.canonicalChainName || 'No Tenderly chains'} + + ) : null} +
+
+ + {!state.controlsUnlocked ? ( + + ) : ( + <> +
+
+ void state.handleResetToLast()} + disabled={state.pendingAction !== null || !state.lastRestorableSnapshot} + variant="primary" + /> + void state.createSnapshot()} + disabled={state.pendingAction !== null} + /> + +
+
+ + + + + + )} +
+ ) +} + +function DesktopTenderlyPanel(props: { state: TTenderlyControlState }): ReactElement { + const { state } = props + const [activePanel, setActivePanel] = useState(null) + const panelRootRef = useRef(null) + + useEffect(() => { + if (!state.controlsUnlocked) { + setActivePanel('snapshots') + } + }, [state.controlsUnlocked]) + + useEffect(() => { + if (!activePanel) { + return + } + + const handlePointerDown = (event: PointerEvent): void => { + const target = event.target + if (!(target instanceof Node)) { + return + } + + if (!panelRootRef.current?.contains(target)) { + setActivePanel(null) + } + } + + document.addEventListener('pointerdown', handlePointerDown) + return () => document.removeEventListener('pointerdown', handlePointerDown) + }, [activePanel]) + + const selectedChainLabel = state.selectedChain?.canonicalChainName || 'Tenderly chain' + const statusLabel = state.selectedExecutionChainId + ? `${selectedChainLabel} · execution ${state.selectedExecutionChainId}` + : selectedChainLabel + + return ( +
+
+ {activePanel === 'snapshots' ? ( + setActivePanel(null)} /> + ) : activePanel === 'faucet' ? ( + setActivePanel(null)} /> + ) : activePanel === 'fast-forward' ? ( + setActivePanel(null)} /> + ) : null} + +
+
+

{statusLabel}

+ +
+
+ setActivePanel((current) => (current === 'snapshots' ? null : 'snapshots'))} + isActive={activePanel === 'snapshots'} + /> + { + setActivePanel(null) + void state.handleResetToLast() + }} + disabled={state.pendingAction !== null || !state.controlsUnlocked || !state.lastRestorableSnapshot} + variant="primary" + /> + setActivePanel((current) => (current === 'faucet' ? null : 'faucet'))} + disabled={!state.controlsUnlocked} + isActive={activePanel === 'faucet'} + /> + setActivePanel((current) => (current === 'fast-forward' ? null : 'fast-forward'))} + disabled={!state.controlsUnlocked} + isActive={activePanel === 'fast-forward'} + /> +
+
+
+
+ ) +} + +function TenderlyControlPanelBody(props: { isOpen: boolean; closePanel: () => void }): ReactElement { + const state = useTenderlyControlState() + const isDesktop = useMediaQuery('(min-width: 768px)', { initializeWithValue: true }) ?? false + + if (isDesktop) { + return + } + + return ( + + + + ) +} + +export function TenderlyControlPanel(): ReactElement | null { + const { isTenderlyMode, isPanelAvailable, isOpen, closePanel } = useTenderlyPanel() + + if (!isTenderlyMode || !isPanelAvailable || !isOpen) { + return null + } + + return +} diff --git a/src/components/shared/components/Tooltip.test.tsx b/src/components/shared/components/Tooltip.test.tsx deleted file mode 100644 index 4a22987d2..000000000 --- a/src/components/shared/components/Tooltip.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -// @vitest-environment jsdom -import { fireEvent, render } from '@testing-library/react' -import { act } from 'react' -import { describe, expect, it, vi } from 'vitest' -import { Tooltip } from './Tooltip' - -describe('Tooltip', () => { - it('delays opening when openDelayMs is set', () => { - vi.useFakeTimers() - const { container, queryByText } = render( - Tip
} openDelayMs={150}> - - - ) - - const trigger = container.firstChild as HTMLElement - fireEvent.mouseEnter(trigger) - expect(queryByText('Tip')).toBeNull() - - act(() => { - vi.advanceTimersByTime(150) - }) - expect(queryByText('Tip')).not.toBeNull() - vi.useRealTimers() - }) - - it('toggles on click when toggleOnClick is true', () => { - const { container, queryByText } = render( - Tip
} toggleOnClick> - - - ) - - const trigger = container.firstChild as HTMLElement - fireEvent.click(trigger) - expect(queryByText('Tip')).not.toBeNull() - fireEvent.click(trigger) - expect(queryByText('Tip')).toBeNull() - }) - - it('does not open on hover on devices without hover support', () => { - const originalMatchMedia = window.matchMedia - Object.defineProperty(window, 'matchMedia', { - writable: true, - configurable: true, - value: vi.fn().mockImplementation((query: string) => ({ - matches: false, - media: query, - onchange: null, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - addListener: vi.fn(), - removeListener: vi.fn(), - dispatchEvent: vi.fn() - })) - }) - - try { - const { container, queryByText } = render( - Tip
}> - - - ) - - const trigger = container.firstChild as HTMLElement - fireEvent.mouseEnter(trigger) - expect(queryByText('Tip')).toBeNull() - } finally { - Object.defineProperty(window, 'matchMedia', { - writable: true, - configurable: true, - value: originalMatchMedia - }) - } - }) -}) diff --git a/src/components/shared/components/YearnApps.test.ts b/src/components/shared/components/YearnApps.test.ts deleted file mode 100644 index 906bd019d..000000000 --- a/src/components/shared/components/YearnApps.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { APP_GROUPS } from './YearnApps' - -describe('APP_GROUPS', () => { - const allNames = APP_GROUPS.flatMap((group) => group.items.map((item) => item.name)) - - it('includes newly requested internal tools', () => { - expect(allNames).toEqual( - expect.arrayContaining(['YearnX', 'APR Oracle', 'yCMS', 'Token Assets', 'Seafood', 'Kong', 'PowerGlove']) - ) - }) - - it('includes resource links for community navigation', () => { - expect(allNames).toEqual(expect.arrayContaining(['Docs', 'Support', 'Blog', 'Discourse'])) - }) - - it('exposes both v3 and LP vault entrypoints', () => { - expect(allNames).toEqual(expect.arrayContaining(['V3 Vaults', 'LP Vaults'])) - }) -}) diff --git a/src/components/shared/contexts/useChartStyle.test.ts b/src/components/shared/contexts/useChartStyle.test.ts deleted file mode 100644 index 22f9ec665..000000000 --- a/src/components/shared/contexts/useChartStyle.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { CHART_STYLE_OPTIONS, getChartStyleVariables } from '@shared/utils/chartStyles' -import { describe, expect, it } from 'vitest' - -describe('chart styles', () => { - it('exposes minimal and powerglove options', () => { - expect(CHART_STYLE_OPTIONS.map((o) => o.id)).toEqual(expect.arrayContaining(['minimal', 'blended', 'powerglove'])) - }) - - it('provides a palette for each style', () => { - const minimal = getChartStyleVariables('minimal') - const powerglove = getChartStyleVariables('powerglove') - - expect(minimal['--chart-1']).toBeTruthy() - expect(minimal['--chart-2']).toBeTruthy() - expect(minimal['--chart-3']).toBeTruthy() - expect(minimal['--chart-4']).toBeTruthy() - expect(powerglove['--chart-1']).toBeTruthy() - expect(powerglove['--chart-2']).toBeTruthy() - expect(powerglove['--chart-3']).toBeTruthy() - expect(powerglove['--chart-4']).toBeTruthy() - }) -}) diff --git a/src/components/shared/contexts/useNotifications.helpers.test.ts b/src/components/shared/contexts/useNotifications.helpers.test.ts deleted file mode 100644 index 93e5bd0b0..000000000 --- a/src/components/shared/contexts/useNotifications.helpers.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { appendCachedNotification, mergeCachedNotificationEntry } from './useNotifications.helpers' - -describe('useNotifications.helpers', () => { - it('appends new notifications without reloading the existing list', () => { - const first = { - id: 1, - type: 'deposit' as const, - address: '0x111' as const, - chainId: 1, - amount: '1', - status: 'pending' as const - } - const second = { - id: 2, - type: 'withdraw' as const, - address: '0x222' as const, - chainId: 1, - amount: '2', - status: 'pending' as const - } - - expect(appendCachedNotification([first], second)).toEqual([first, second]) - }) - - it('merges notification updates in place', () => { - const original = { - id: 7, - type: 'deposit' as const, - address: '0x111' as const, - chainId: 1, - amount: '1', - status: 'pending' as const - } - - expect(mergeCachedNotificationEntry([original], 7, { status: 'success', txHash: '0xabc' })).toEqual([ - { - ...original, - status: 'success', - txHash: '0xabc' - } - ]) - }) - - it('adds the updated notification if the cache does not contain it yet', () => { - expect( - mergeCachedNotificationEntry([], 3, { - type: 'deposit', - address: '0x333', - chainId: 1, - amount: '3', - status: 'success' - }) - ).toEqual([ - { - id: 3, - type: 'deposit', - address: '0x333', - chainId: 1, - amount: '3', - status: 'success' - } - ]) - }) -}) diff --git a/src/components/shared/contexts/useNotificationsActions.tsx b/src/components/shared/contexts/useNotificationsActions.tsx index d917616e6..9942b7d6d 100644 --- a/src/components/shared/contexts/useNotificationsActions.tsx +++ b/src/components/shared/contexts/useNotificationsActions.tsx @@ -30,6 +30,7 @@ export const WithNotificationsActions = ({ children }: { children: React.ReactEl fromAddress: toAddress(params.fromAddress), fromTokenName: params.fromSymbol, chainId: params.fromChainId, + executionChainId: params.executionChainId ?? params.fromChainId, toAddress: params.toAddress ? toAddress(params.toAddress) : undefined, toTokenName: params.toSymbol, toAmount: params.toAmount, diff --git a/src/components/shared/contexts/useTenderlyPanel.tsx b/src/components/shared/contexts/useTenderlyPanel.tsx new file mode 100644 index 000000000..68cdb4134 --- /dev/null +++ b/src/components/shared/contexts/useTenderlyPanel.tsx @@ -0,0 +1,514 @@ +import { useLocalStorageValue } from '@react-hookz/web' +import { toast } from '@shared/components/yToast' +import { useWallet } from '@shared/contexts/useWallet' +import { useWeb3 } from '@shared/contexts/useWeb3' +import { useYearn } from '@shared/contexts/useYearn' +import { useTokenList } from '@shared/contexts/WithTokenList' +import type { + TTenderlyFundableAsset, + TTenderlyFundRequest, + TTenderlyIncreaseTimeRequest, + TTenderlyPanelStatus, + TTenderlySnapshotRecord, + TTenderlySnapshotRequest +} from '@shared/types/tenderly' +import { + buildTenderlyFundableAssets, + clearTenderlySnapshotBucket, + getTenderlySnapshotBucketKey, + getValidBaselineSnapshot, + markTenderlySnapshotInvalid, + reconcileTenderlySnapshotStorageAfterRevert, + resolveDefaultTenderlyCanonicalChainId, + sortTenderlySnapshotRecords, + TENDERLY_SNAPSHOT_STORAGE_KEY, + type TTenderlySnapshotStorage, + upsertTenderlySnapshotRecord +} from '@shared/utils/tenderlyPanel' +import { useQueryClient } from '@tanstack/react-query' +import { + createContext, + type ReactElement, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState +} from 'react' +import { isTenderlyModeEnabled } from '@/config/tenderly' +import { useChains } from '@/context/chainsContext' + +type TTenderlyPanelContext = { + isTenderlyMode: boolean + isStatusLoading: boolean + isPanelAvailable: boolean + isOpen: boolean + status?: TTenderlyPanelStatus + selectedCanonicalChainId?: number + selectedExecutionChainId?: number + snapshotRecords: TTenderlySnapshotRecord[] + baselineSnapshot?: TTenderlySnapshotRecord + fundableAssets: TTenderlyFundableAsset[] + connectedWalletAddress?: `0x${string}` + pendingAction: string | null + openPanel: () => void + closePanel: () => void + togglePanel: () => void + setSelectedCanonicalChainId: (chainId: number) => void + refetchStatus: () => Promise + createBaselineSnapshot: () => Promise + createSnapshot: () => Promise + clearSnapshotHistory: () => void + revertToSnapshot: (snapshotRecord: TTenderlySnapshotRecord) => Promise + increaseTime: (params: Omit) => Promise + fundWallet: (params: Omit) => Promise +} + +const TenderlyPanelContext = createContext({ + isTenderlyMode: false, + isStatusLoading: false, + isPanelAvailable: false, + isOpen: false, + snapshotRecords: [], + fundableAssets: [], + pendingAction: null, + openPanel: (): void => undefined, + closePanel: (): void => undefined, + togglePanel: (): void => undefined, + setSelectedCanonicalChainId: (): void => undefined, + refetchStatus: async (): Promise => undefined, + createBaselineSnapshot: async (): Promise => undefined, + createSnapshot: async (): Promise => undefined, + clearSnapshotHistory: (): void => undefined, + revertToSnapshot: async (): Promise => undefined, + increaseTime: async (): Promise => undefined, + fundWallet: async (): Promise => undefined +}) + +async function fetchTenderlyApi( + path: string, + options?: { + method?: 'GET' | 'POST' + body?: TRequest + } +): Promise { + const response = await fetch(path, { + method: options?.method || 'GET', + headers: options?.body ? { 'content-type': 'application/json' } : undefined, + body: options?.body ? JSON.stringify(options.body) : undefined + }) + const payload = (await response.json().catch(() => undefined)) as { error?: string } & TResponse + + if (!response.ok) { + throw new Error(payload?.error || `Tenderly API request failed with status ${response.status}`) + } + + return payload +} + +export function TenderlyPanelProvider({ children }: { children: ReactNode }): ReactElement { + const queryClient = useQueryClient() + const { chainIdIntent } = useChains() + const { chainID, address } = useWeb3() + const { onRefresh } = useWallet() + const { allVaults, enableVaultListFetch } = useYearn() + const { tokenLists } = useTokenList() + const [status, setStatus] = useState(undefined) + const [isStatusLoading, setIsStatusLoading] = useState(false) + const [isOpen, setIsOpen] = useState(false) + const [selectedCanonicalChainId, setSelectedCanonicalChainIdState] = useState(undefined) + const [pendingAction, setPendingAction] = useState(null) + const { value: snapshotStorageValue, set: setSnapshotStorage } = useLocalStorageValue( + TENDERLY_SNAPSHOT_STORAGE_KEY, + { + defaultValue: {} + } + ) + const snapshotStorage = snapshotStorageValue || {} + const isTenderlyMode = isTenderlyModeEnabled() + + const refetchStatus = useCallback(async (): Promise => { + if (!isTenderlyMode) { + setStatus(undefined) + return + } + + setIsStatusLoading(true) + try { + const nextStatus = await fetchTenderlyApi('/api/tenderly/status') + setStatus(nextStatus) + } catch (error) { + console.error('Failed to fetch Tenderly status:', error) + setStatus(undefined) + } finally { + setIsStatusLoading(false) + } + }, [isTenderlyMode]) + + useEffect(() => { + void refetchStatus() + }, [refetchStatus]) + + useEffect(() => { + if (isOpen) { + enableVaultListFetch() + } + }, [enableVaultListFetch, isOpen]) + + const availableConfiguredChains = useMemo( + () => (status?.configuredChains || []).filter((chain) => chain.hasAdminRpc), + [status?.configuredChains] + ) + + useEffect(() => { + const defaultCanonicalChainId = resolveDefaultTenderlyCanonicalChainId(availableConfiguredChains, [ + chainID, + chainIdIntent + ]) + const canKeepSelectedChain = availableConfiguredChains.some( + (chain) => chain.canonicalChainId === selectedCanonicalChainId + ) + + if (canKeepSelectedChain) { + return + } + + setSelectedCanonicalChainIdState(defaultCanonicalChainId) + }, [availableConfiguredChains, chainID, chainIdIntent, selectedCanonicalChainId]) + + const selectedChain = useMemo( + () => availableConfiguredChains.find((chain) => chain.canonicalChainId === selectedCanonicalChainId), + [availableConfiguredChains, selectedCanonicalChainId] + ) + const snapshotBucketKey = + selectedChain && selectedCanonicalChainId !== undefined + ? getTenderlySnapshotBucketKey(selectedCanonicalChainId, selectedChain.executionChainId) + : undefined + const snapshotRecords = useMemo( + () => (snapshotBucketKey ? sortTenderlySnapshotRecords(snapshotStorage[snapshotBucketKey] || []) : []), + [snapshotBucketKey, snapshotStorage] + ) + const baselineSnapshot = useMemo(() => getValidBaselineSnapshot(snapshotRecords), [snapshotRecords]) + const fundableAssets = useMemo( + () => + selectedCanonicalChainId + ? buildTenderlyFundableAssets({ + chainId: selectedCanonicalChainId, + tokenLists, + allVaults + }) + : [], + [allVaults, selectedCanonicalChainId, tokenLists] + ) + + const isPanelAvailable = isTenderlyMode && availableConfiguredChains.length > 0 + + const refreshTenderlyDependentState = useCallback(async (): Promise => { + await queryClient.invalidateQueries() + if (address) { + await onRefresh().catch((error) => { + console.error('Failed to refresh wallet balances after Tenderly action:', error) + }) + } + }, [address, onRefresh, queryClient]) + + const updateSnapshotStorage = useCallback( + (updater: (snapshotStorage: TTenderlySnapshotStorage) => TTenderlySnapshotStorage): void => { + setSnapshotStorage(updater(snapshotStorage)) + }, + [setSnapshotStorage, snapshotStorage] + ) + + const requestSnapshotRecord = useCallback( + async (request: TTenderlySnapshotRequest): Promise => + await fetchTenderlyApi('/api/tenderly/snapshot', { + method: 'POST', + body: request + }), + [] + ) + + const createSnapshotWithKind = useCallback( + async (isBaseline: boolean): Promise => { + if (!selectedCanonicalChainId) { + throw new Error('No Tenderly chain selected') + } + + setPendingAction(isBaseline ? 'create-baseline' : 'create-snapshot') + try { + const snapshotRecord = await requestSnapshotRecord({ + canonicalChainId: selectedCanonicalChainId, + isBaseline + }) + updateSnapshotStorage((currentStorage) => upsertTenderlySnapshotRecord(currentStorage, snapshotRecord)) + toast({ + content: isBaseline ? 'Baseline snapshot created' : 'Snapshot created', + type: 'success' + }) + } finally { + setPendingAction(null) + } + }, + [requestSnapshotRecord, selectedCanonicalChainId, updateSnapshotStorage] + ) + + const revertToSnapshot = useCallback( + async (snapshotRecord: TTenderlySnapshotRecord): Promise => { + setPendingAction('revert-snapshot') + try { + try { + await fetchTenderlyApi('/api/tenderly/revert', { + method: 'POST', + body: { + canonicalChainId: snapshotRecord.canonicalChainId, + snapshotId: snapshotRecord.snapshotId + } + }) + } catch (error) { + updateSnapshotStorage((currentStorage) => + markTenderlySnapshotInvalid(currentStorage, { + canonicalChainId: snapshotRecord.canonicalChainId, + executionChainId: snapshotRecord.executionChainId, + snapshotId: snapshotRecord.snapshotId + }) + ) + throw error + } + + let replacementSnapshotRecord: TTenderlySnapshotRecord | undefined + try { + replacementSnapshotRecord = await requestSnapshotRecord({ + canonicalChainId: snapshotRecord.canonicalChainId, + isBaseline: snapshotRecord.kind === 'baseline', + label: snapshotRecord.label + }) + } catch (error) { + console.error('Failed to create replacement Tenderly snapshot after revert:', error) + } + + updateSnapshotStorage((currentStorage) => + reconcileTenderlySnapshotStorageAfterRevert(currentStorage, { + revertedSnapshotRecord: snapshotRecord, + replacementSnapshotRecord + }) + ) + + try { + await refreshTenderlyDependentState() + } catch (error) { + console.error('Failed to refresh state after Tenderly revert:', error) + } + + toast({ + content: replacementSnapshotRecord + ? snapshotRecord.kind === 'baseline' + ? 'Reset to baseline complete' + : 'Snapshot revert complete' + : snapshotRecord.kind === 'baseline' + ? 'Reset to baseline complete, but the baseline was consumed. Create a new baseline snapshot before resetting again.' + : 'Snapshot revert complete, but that snapshot was consumed. Create a new snapshot if you want to reuse it.', + type: replacementSnapshotRecord ? 'success' : 'warning' + }) + } finally { + setPendingAction(null) + } + }, + [refreshTenderlyDependentState, requestSnapshotRecord, updateSnapshotStorage] + ) + + const increaseTime = useCallback( + async (params: Omit): Promise => { + if (!selectedCanonicalChainId) { + throw new Error('No Tenderly chain selected') + } + + setPendingAction('increase-time') + try { + await fetchTenderlyApi('/api/tenderly/increase-time', { + method: 'POST', + body: { + canonicalChainId: selectedCanonicalChainId, + seconds: params.seconds, + mineBlock: params.mineBlock + } + }) + await refreshTenderlyDependentState() + toast({ + content: `Fast-forwarded Tenderly by ${params.seconds} seconds`, + type: 'success' + }) + } finally { + setPendingAction(null) + } + }, + [refreshTenderlyDependentState, selectedCanonicalChainId] + ) + + const fundWallet = useCallback( + async (params: Omit): Promise => { + if (!selectedCanonicalChainId) { + throw new Error('No Tenderly chain selected') + } + if (!address) { + throw new Error('Connect a wallet before using the Tenderly faucet') + } + + setPendingAction('fund-wallet') + try { + await fetchTenderlyApi('/api/tenderly/fund', { + method: 'POST', + body: { + canonicalChainId: selectedCanonicalChainId, + walletAddress: address, + ...params + } + }) + await refreshTenderlyDependentState() + toast({ + content: `Funded ${address}`, + type: 'success' + }) + } finally { + setPendingAction(null) + } + }, + [address, refreshTenderlyDependentState, selectedCanonicalChainId] + ) + + const createBaselineSnapshot = useCallback(async (): Promise => { + try { + await createSnapshotWithKind(true) + } catch (error) { + toast({ + content: error instanceof Error ? error.message : 'Failed to create baseline snapshot', + type: 'error' + }) + } + }, [createSnapshotWithKind]) + + const createSnapshot = useCallback(async (): Promise => { + try { + await createSnapshotWithKind(false) + } catch (error) { + toast({ + content: error instanceof Error ? error.message : 'Failed to create snapshot', + type: 'error' + }) + } + }, [createSnapshotWithKind]) + + const clearSnapshotHistory = useCallback((): void => { + if (!selectedCanonicalChainId || !selectedChain?.executionChainId) { + return + } + + updateSnapshotStorage((currentStorage) => + clearTenderlySnapshotBucket(currentStorage, { + canonicalChainId: selectedCanonicalChainId, + executionChainId: selectedChain.executionChainId + }) + ) + toast({ + content: 'Cleared local Tenderly snapshot history for this chain', + type: 'success' + }) + }, [selectedCanonicalChainId, selectedChain?.executionChainId, updateSnapshotStorage]) + + const revertToSnapshotWithToast = useCallback( + async (snapshotRecord: TTenderlySnapshotRecord): Promise => { + try { + await revertToSnapshot(snapshotRecord) + } catch (error) { + toast({ + content: error instanceof Error ? error.message : 'Failed to revert snapshot', + type: 'error' + }) + } + }, + [revertToSnapshot] + ) + + const increaseTimeWithToast = useCallback( + async (params: Omit): Promise => { + try { + await increaseTime(params) + } catch (error) { + toast({ + content: error instanceof Error ? error.message : 'Failed to fast-forward Tenderly', + type: 'error' + }) + } + }, + [increaseTime] + ) + + const fundWalletWithToast = useCallback( + async (params: Omit): Promise => { + try { + await fundWallet(params) + } catch (error) { + toast({ + content: error instanceof Error ? error.message : 'Failed to fund wallet on Tenderly', + type: 'error' + }) + } + }, + [fundWallet] + ) + + const contextValue = useMemo( + (): TTenderlyPanelContext => ({ + isTenderlyMode, + isStatusLoading, + isPanelAvailable, + isOpen, + status, + selectedCanonicalChainId, + selectedExecutionChainId: selectedChain?.executionChainId, + snapshotRecords, + baselineSnapshot, + fundableAssets, + connectedWalletAddress: address, + pendingAction, + openPanel: (): void => setIsOpen(true), + closePanel: (): void => setIsOpen(false), + togglePanel: (): void => setIsOpen((current) => !current), + setSelectedCanonicalChainId: setSelectedCanonicalChainIdState, + refetchStatus, + createBaselineSnapshot, + createSnapshot, + clearSnapshotHistory, + revertToSnapshot: revertToSnapshotWithToast, + increaseTime: increaseTimeWithToast, + fundWallet: fundWalletWithToast + }), + [ + isTenderlyMode, + isStatusLoading, + isPanelAvailable, + isOpen, + status, + selectedCanonicalChainId, + selectedChain?.executionChainId, + snapshotRecords, + baselineSnapshot, + fundableAssets, + address, + pendingAction, + refetchStatus, + createBaselineSnapshot, + createSnapshot, + clearSnapshotHistory, + revertToSnapshotWithToast, + increaseTimeWithToast, + fundWalletWithToast + ] + ) + + return {children} +} + +export function useTenderlyPanel(): TTenderlyPanelContext { + return useContext(TenderlyPanelContext) +} diff --git a/src/components/shared/contexts/useWallet.helpers.test.ts b/src/components/shared/contexts/useWallet.helpers.test.ts deleted file mode 100644 index e46ca3bd8..000000000 --- a/src/components/shared/contexts/useWallet.helpers.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { zeroNormalizedBN } from '../utils' -import { hasWalletBalanceSnapshot, shouldExposeWalletLoading } from './useWallet.helpers' - -describe('useWallet.helpers', () => { - it('detects when the wallet has a settled balance snapshot', () => { - expect(hasWalletBalanceSnapshot({})).toBe(false) - expect(hasWalletBalanceSnapshot({ 1: {} })).toBe(false) - expect( - hasWalletBalanceSnapshot({ - 1: { - '0x123': { - address: '0x123', - name: 'Token', - symbol: 'TOK', - decimals: 18, - chainID: 1, - value: 0, - balance: zeroNormalizedBN - } - } - }) - ).toBe(true) - }) - - it('exposes loading only for cold wallet loads', () => { - expect( - shouldExposeWalletLoading({ - userAddress: undefined, - hasVisibleBalances: false, - isLoading: true, - isBalancesPending: true - }) - ).toBe(false) - - expect( - shouldExposeWalletLoading({ - userAddress: '0x123', - hasVisibleBalances: false, - isLoading: true, - isBalancesPending: false - }) - ).toBe(true) - - expect( - shouldExposeWalletLoading({ - userAddress: '0x123', - hasVisibleBalances: false, - isLoading: false, - isBalancesPending: true - }) - ).toBe(true) - }) - - it('keeps background refreshes visually quiet once balances are visible', () => { - expect( - shouldExposeWalletLoading({ - userAddress: '0x123', - hasVisibleBalances: true, - isLoading: true, - isBalancesPending: false - }) - ).toBe(false) - - expect( - shouldExposeWalletLoading({ - userAddress: '0x123', - hasVisibleBalances: true, - isLoading: false, - isBalancesPending: true - }) - ).toBe(false) - }) -}) diff --git a/src/components/shared/contexts/useWeb3.tsx b/src/components/shared/contexts/useWeb3.tsx index cfe10effb..ecd31b20c 100755 --- a/src/components/shared/contexts/useWeb3.tsx +++ b/src/components/shared/contexts/useWeb3.tsx @@ -9,6 +9,7 @@ import type { ReactElement } from 'react' import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { mainnet } from 'viem/chains' import { useAccount, useConnect, useDisconnect, useEnsName } from 'wagmi' +import { resolveConnectedCanonicalChainId, resolveExecutionChainId } from '@/config/tenderly' type TWeb3Context = { address: TAddress | undefined @@ -53,13 +54,12 @@ export const Web3ContextApp = (props: { children: ReactElement }): ReactElement const { openChainModal } = useChainModal() const trackEvent = usePlausible() const [clusters, setClusters] = useState<{ name: string; avatar: string } | undefined>(undefined) - const [isUserConnecting, setIsUserConnecting] = useState(false) const [isFetchingClusters, setIsFetchingClusters] = useState(false) const wasConnectedRef = useRef(false) const previousChainIDRef = useRef(undefined) const hasUserRequestedConnectionRef = useRef(false) - const chainID = chain?.id ?? 1 + const chainID = resolveConnectedCanonicalChainId(chain?.id) ?? (isConnected ? 0 : 1) useEffect(() => { if (!wasConnectedRef.current && isConnected && hasUserRequestedConnectionRef.current) { @@ -106,7 +106,7 @@ export const Web3ContextApp = (props: { children: ReactElement }): ReactElement hasUserRequestedConnectionRef.current = true await connectAsync({ connector: ledgerConnector, - chainId: chainID + chainId: resolveExecutionChainId(chainID) ?? chainID }) return } @@ -210,16 +210,7 @@ export const Web3ContextApp = (props: { children: ReactElement }): ReactElement } }, [address, ensName, isConnected]) - useEffect(() => { - if (isConnecting) { - if (hasUserRequestedConnectionRef.current) { - setIsUserConnecting(true) - } - return - } - - setIsUserConnecting(false) - }, [isConnecting]) + const isUserConnecting = isConnecting && hasUserRequestedConnectionRef.current const isIdentityLoading = Boolean((isEnsLoading && !!address) || isFetchingClusters) const isWalletSafe = connector?.id.toLowerCase().includes('safe') ?? false diff --git a/src/components/shared/contexts/useYearn.tsx b/src/components/shared/contexts/useYearn.tsx index b95489b1c..dbce91a69 100755 --- a/src/components/shared/contexts/useYearn.tsx +++ b/src/components/shared/contexts/useYearn.tsx @@ -9,7 +9,7 @@ import type { TYDaemonEarned } from '@shared/utils/schemas/yDaemonEarnedSchema' import type { TYDaemonPricesChain } from '@shared/utils/schemas/yDaemonPricesSchema' import type { QueryObserverResult } from '@tanstack/react-query' import type { ReactElement } from 'react' -import { createContext, memo, useCallback, useContext, useEffect, useState } from 'react' +import { createContext, memo, useCallback, useContext, useState } from 'react' import { useLocation } from 'react-router' import { deserialize, serialize } from 'wagmi' @@ -90,16 +90,11 @@ export const YearnContextApp = memo(function YearnContextApp({ children }: { chi const isVaultDetailPage = isVaultsRoute && location.pathname.split('/').length === 4 const isPortfolioRoute = location.pathname.startsWith('/portfolio') const shouldEnableVaultList = (isVaultsRoute && !isVaultDetailPage) || isPortfolioRoute - const [isVaultListEnabled, setIsVaultListEnabled] = useState(shouldEnableVaultList) - - useEffect(() => { - if (shouldEnableVaultList) { - setIsVaultListEnabled(true) - } - }, [shouldEnableVaultList]) + const [isManuallyEnabled, setIsManuallyEnabled] = useState(false) + const isVaultListEnabled = shouldEnableVaultList || isManuallyEnabled const enableVaultListFetch = useCallback(() => { - setIsVaultListEnabled(true) + setIsManuallyEnabled(true) }, []) const prices = useFetchYearnPrices() diff --git a/src/components/shared/hooks/useAppWagmi.ts b/src/components/shared/hooks/useAppWagmi.ts new file mode 100644 index 000000000..a311258de --- /dev/null +++ b/src/components/shared/hooks/useAppWagmi.ts @@ -0,0 +1,205 @@ +import type { SimulateContractData } from '@wagmi/core/query' +import { useCallback } from 'react' +import type { Abi, ContractFunctionArgs, ContractFunctionName } from 'viem' +import type { + Config, + UseBlockNumberParameters, + UseBlockNumberReturnType, + UsePublicClientParameters, + UsePublicClientReturnType, + UseReadContractParameters, + UseReadContractReturnType, + UseSimulateContractParameters, + UseSimulateContractReturnType, + UseSwitchChainReturnType, + UseWaitForTransactionReceiptParameters, + UseWaitForTransactionReceiptReturnType +} from 'wagmi' +import { + useBlockNumber as useWagmiBlockNumber, + useChainId as useWagmiChainId, + usePublicClient as useWagmiPublicClient, + useReadContract as useWagmiReadContract, + useSimulateContract as useWagmiSimulateContract, + useSwitchChain as useWagmiSwitchChain, + useWaitForTransactionReceipt as useWagmiWaitForTransactionReceipt +} from 'wagmi' +import { isTenderlyModeEnabled, resolveConnectedCanonicalChainId, resolveExecutionChainId } from '@/config/tenderly' + +const DISABLED_CHAIN_ID = Number.MAX_SAFE_INTEGER + +function resolveHookChainId(chainId?: number): number | undefined { + if (!Number.isInteger(chainId)) { + return undefined + } + + const resolvedChainId = resolveExecutionChainId(chainId) + if (resolvedChainId !== undefined) { + return resolvedChainId + } + + return isTenderlyModeEnabled() ? DISABLED_CHAIN_ID : chainId +} + +function isUnsupportedRequestedChain(chainId?: number): boolean { + return Number.isInteger(chainId) && resolveExecutionChainId(chainId) === undefined +} + +export type AppUseSimulateContractReturnType = { + data?: { + request?: { + chainId?: number + address?: unknown + functionName?: unknown + args?: readonly unknown[] + [key: string]: unknown + } + result?: unknown + [key: string]: unknown + } + error?: unknown + isError: boolean + isFetching: boolean + isLoading: boolean + isPending?: boolean + isRefetching?: boolean + isSuccess: boolean + refetch?: () => Promise | unknown + status: string + [key: string]: unknown +} + +export function useChainId(): number { + const rawChainId = useWagmiChainId() + return resolveConnectedCanonicalChainId(rawChainId) ?? (isTenderlyModeEnabled() ? 0 : rawChainId) +} + +export function useSwitchChain(): UseSwitchChainReturnType { + const wagmiSwitchChain = useWagmiSwitchChain() + + const switchChain = useCallback( + (parameters) => { + const executionChainId = resolveExecutionChainId(parameters.chainId) + if (executionChainId === undefined) { + throw new Error(`Chain ${parameters.chainId} is not enabled for execution`) + } + + return wagmiSwitchChain.switchChain?.({ ...parameters, chainId: executionChainId }) + }, + [wagmiSwitchChain] + ) + + const switchChainAsync = useCallback( + async (parameters) => { + const executionChainId = resolveExecutionChainId(parameters.chainId) + if (executionChainId === undefined) { + throw new Error(`Chain ${parameters.chainId} is not enabled for execution`) + } + + return await wagmiSwitchChain.switchChainAsync?.({ ...parameters, chainId: executionChainId }) + }, + [wagmiSwitchChain] + ) + + return { + ...wagmiSwitchChain, + switchChain, + switchChainAsync + } +} + +export function useReadContract< + abi extends Abi | readonly unknown[] = Abi, + functionName extends ContractFunctionName = ContractFunctionName, + args extends ContractFunctionArgs = ContractFunctionArgs< + abi, + 'pure' | 'view', + functionName + > +>(parameters: UseReadContractParameters): UseReadContractReturnType { + const unsupportedRequestedChain = isUnsupportedRequestedChain(parameters.chainId) + + return useWagmiReadContract({ + ...parameters, + chainId: resolveHookChainId(parameters.chainId), + query: { + ...(parameters.query || {}), + enabled: !unsupportedRequestedChain && (parameters.query?.enabled ?? true) + } + } as UseReadContractParameters) +} + +export function useSimulateContract< + abi extends Abi | readonly unknown[] = Abi, + functionName extends ContractFunctionName = ContractFunctionName< + abi, + 'nonpayable' | 'payable' + >, + args extends ContractFunctionArgs = ContractFunctionArgs< + abi, + 'nonpayable' | 'payable', + functionName + >, + chainId extends Config['chains'][number]['id'] | undefined = undefined, + selectData = SimulateContractData +>( + parameters?: UseSimulateContractParameters +): UseSimulateContractReturnType { + const unsupportedRequestedChain = isUnsupportedRequestedChain(parameters?.chainId) + const resolvedParameters = + parameters === undefined + ? undefined + : ({ + ...parameters, + chainId: resolveHookChainId(parameters.chainId) as chainId, + query: { + ...(parameters.query || {}), + enabled: !unsupportedRequestedChain && (parameters.query?.enabled ?? true) + } + } as any) + + return useWagmiSimulateContract(resolvedParameters) +} + +export function useBlockNumber( + parameters?: UseBlockNumberParameters +): UseBlockNumberReturnType { + const unsupportedRequestedChain = isUnsupportedRequestedChain(parameters?.chainId) + + return useWagmiBlockNumber({ + ...(parameters || {}), + chainId: resolveHookChainId(parameters?.chainId) as chainId, + query: { + ...(parameters?.query || {}), + enabled: !unsupportedRequestedChain && (parameters?.query?.enabled ?? true) + }, + watch: !unsupportedRequestedChain && Boolean(parameters?.watch) + } as UseBlockNumberParameters) +} + +export function useWaitForTransactionReceipt< + chainId extends Config['chains'][number]['id'] = Config['chains'][number]['id'] +>( + parameters: UseWaitForTransactionReceiptParameters +): UseWaitForTransactionReceiptReturnType { + const unsupportedRequestedChain = isUnsupportedRequestedChain(parameters.chainId) + + return useWagmiWaitForTransactionReceipt({ + ...parameters, + chainId: resolveHookChainId(parameters.chainId) as chainId, + hash: unsupportedRequestedChain ? undefined : parameters.hash, + query: { + ...(parameters.query || {}), + enabled: !unsupportedRequestedChain && (parameters.query?.enabled ?? true) + } + } as UseWaitForTransactionReceiptParameters) +} + +export function usePublicClient( + parameters?: UsePublicClientParameters +): UsePublicClientReturnType { + return useWagmiPublicClient({ + ...(parameters || {}), + chainId: resolveHookChainId(parameters?.chainId) as chainId + } as UsePublicClientParameters) +} diff --git a/src/components/shared/hooks/useBalances.multichains.ts b/src/components/shared/hooks/useBalances.multichains.ts index 0a273ac2e..40a28a084 100644 --- a/src/components/shared/hooks/useBalances.multichains.ts +++ b/src/components/shared/hooks/useBalances.multichains.ts @@ -3,6 +3,7 @@ import type { DependencyList } from 'react' import { erc20Abi, type MulticallParameters } from 'viem' import type { Connector } from 'wagmi' import { multicall } from 'wagmi/actions' +import { resolveExecutionChainId } from '@/config/tenderly' import type { TAddress } from '../types/address' import type { TChainTokens, TDefaultStatus, TDict, TNDict, TToken } from '../types/mixed' import { ETH_TOKEN_ADDRESS, MULTICALL3_ADDRESS } from '../utils/constants' @@ -62,12 +63,17 @@ export type TUseBalancesRes = { type TUpdates = TDict const TOKEN_UPDATE: TUpdates = {} +function getTokenUpdateKey(chainID: number, executionChainId: number | undefined, address: TAddress): string { + return `${chainID}:${executionChainId ?? 'none'}/${toAddress(address)}` +} + export function hasPositiveCachedBalance(chainID: number, address: TAddress, ownerAddress?: TAddress): boolean { if (!ownerAddress || isZeroAddress(ownerAddress)) { return false } - const tokenUpdateInfo = TOKEN_UPDATE[`${chainID}/${toAddress(address)}`] + const executionChainId = resolveExecutionChainId(chainID) + const tokenUpdateInfo = TOKEN_UPDATE[getTokenUpdateKey(chainID, executionChainId, address)] if (!tokenUpdateInfo) { return false } @@ -81,6 +87,11 @@ export async function performCall( tokens: TUseBalancesTokens[], ownerAddress: TAddress ): Promise<[TDict, Error | undefined]> { + const executionChainId = resolveExecutionChainId(chainID) + if (executionChainId === undefined) { + return [{}, new Error(`Chain ${chainID} is not enabled for execution`)] + } + type TMulticallResult = | { error?: undefined; result: never; status: 'success' } | { error: Error; result?: undefined; status: 'failure' } @@ -89,11 +100,11 @@ export async function performCall( try { const results = await multicall(retrieveConfig(), { contracts: chunckCalls as never[], - chainId: chainID + chainId: executionChainId }) return { results } } catch (error) { - console.error(`Failed to trigger multicall on chain ${chainID}`, error) + console.error(`Failed to trigger multicall on chain ${executionChainId}`, error) return { results: [], error: error as Error } } })() @@ -176,7 +187,7 @@ export async function performCall( _data[toAddress(address)].decimals = 18 } - TOKEN_UPDATE[`${chainID}/${toAddress(address)}`] = { + TOKEN_UPDATE[getTokenUpdateKey(chainID, executionChainId, address)] = { ..._data[toAddress(address)], owner: toAddress(ownerAddress), lastUpdate: Date.now() @@ -198,10 +209,11 @@ export async function getBalances( shouldForceFetch = false ): Promise<[TDict, Error | undefined, TBalanceFetchStats]> { const ownerAddress = address + const executionChainId = resolveExecutionChainId(chainID) const cachedResults: TDict = {} for (const element of tokens) { - const tokenUpdateInfo = TOKEN_UPDATE[`${chainID}/${toAddress(element.address)}`] + const tokenUpdateInfo = TOKEN_UPDATE[getTokenUpdateKey(chainID, executionChainId, element.address)] if (tokenUpdateInfo?.lastUpdate && Date.now() - tokenUpdateInfo?.lastUpdate < 60_000 && !shouldForceFetch) { if (toAddress(tokenUpdateInfo.owner) === toAddress(ownerAddress)) { cachedResults[toAddress(element.address)] = tokenUpdateInfo diff --git a/src/components/shared/hooks/useBalancesCombined.test.ts b/src/components/shared/hooks/useBalancesCombined.test.ts deleted file mode 100644 index 5856c8def..000000000 --- a/src/components/shared/hooks/useBalancesCombined.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { shouldUseDiscoveryFallbackToken } from './balanceDiscoveryFallback' - -describe('shouldUseDiscoveryFallbackToken', () => { - it('uses discovery fallback for staking tokens', () => { - expect( - shouldUseDiscoveryFallbackToken({ - token: { address: '0x1111111111111111111111111111111111111111', chainID: 1, isStakingToken: true }, - hasPositiveBalanceCache: false - }) - ).toBe(true) - }) - - it('skips discovery fallback for non-catalog vault shares without other signals', () => { - expect( - shouldUseDiscoveryFallbackToken({ - token: { address: '0x1111111111111111111111111111111111111111', chainID: 1, isCatalogVault: false }, - hasPositiveBalanceCache: false - }) - ).toBe(false) - }) - - it('uses discovery fallback for previously positive cached balances', () => { - expect( - shouldUseDiscoveryFallbackToken({ - token: { address: '0x1111111111111111111111111111111111111111', chainID: 1, isCatalogVault: true }, - hasPositiveBalanceCache: true - }) - ).toBe(true) - }) - - it('skips discovery fallback for ordinary omitted catalog vault shares', () => { - expect( - shouldUseDiscoveryFallbackToken({ - token: { address: '0x1111111111111111111111111111111111111111', chainID: 1, isCatalogVault: true }, - hasPositiveBalanceCache: false - }) - ).toBe(false) - }) -}) diff --git a/src/components/shared/hooks/useBalancesCombined.ts b/src/components/shared/hooks/useBalancesCombined.ts index 81e8f51be..86e3b2c3a 100644 --- a/src/components/shared/hooks/useBalancesCombined.ts +++ b/src/components/shared/hooks/useBalancesCombined.ts @@ -1,5 +1,6 @@ import { useQueryClient } from '@tanstack/react-query' import { useCallback, useMemo } from 'react' +import { getTenderlyBackedCanonicalChainIds, resolveExecutionChainId } from '@/config/tenderly' import { useWeb3 } from '../contexts/useWeb3' import type { TChainTokens, TDict, TNDict, TToken } from '../types/mixed' import { toAddress } from '../utils/tools.address' @@ -58,13 +59,17 @@ function mergeBalanceSources(...sources: TChainTokens[]): TChainTokens { export function useBalancesCombined(props?: TUseBalancesReq): TUseBalancesRes { const { address: userAddress } = useWeb3() const queryClient = useQueryClient() + const ensoUnsupportedNetworks = useMemo( + () => [...new Set([...ENSO_UNSUPPORTED_NETWORKS, ...getTenderlyBackedCanonicalChainIds()])], + [] + ) const tokens = useMemo(() => (userAddress ? props?.tokens || [] : []), [props?.tokens, userAddress]) // Split tokens into Enso-supported and multicall-required groups const { ensoTokens, multicallTokens: requiredMulticallTokens } = useMemo(() => { - return partitionTokensByBalanceSource(tokens, ENSO_UNSUPPORTED_NETWORKS) - }, [tokens]) + return partitionTokensByBalanceSource(tokens, ensoUnsupportedNetworks) + }, [ensoUnsupportedNetworks, tokens]) // Fetch from Enso for supported chains const { @@ -250,12 +255,13 @@ export function useBalancesCombined(props?: TUseBalancesReq): TUseBalancesRes { for (const [chainIdStr, chainTokens] of Object.entries(tokensByChain)) { const chainId = Number(chainIdStr) + const executionChainId = resolveExecutionChainId(chainId) const freshBalances = await fetchTokenBalances(chainId, userAddress, chainTokens, true) // Update multicall query cache const allQueries = queryClient.getQueriesData>({ - queryKey: balanceQueryKeys.byChainAndUser(chainId, userAddress), + queryKey: balanceQueryKeys.byChainAndUser(chainId, executionChainId, userAddress), exact: false }) @@ -277,7 +283,7 @@ export function useBalancesCombined(props?: TUseBalancesReq): TUseBalancesRes { const mergedEnsoData = { ...currentEnsoData } for (const [chainIdStr, tokens] of Object.entries(updatedBalances)) { const chainId = Number(chainIdStr) - if (!ENSO_UNSUPPORTED_NETWORKS.includes(chainId)) { + if (!ensoUnsupportedNetworks.includes(chainId)) { if (!mergedEnsoData[chainId]) { mergedEnsoData[chainId] = {} } @@ -289,7 +295,7 @@ export function useBalancesCombined(props?: TUseBalancesReq): TUseBalancesRes { return updatedBalances }, - [queryClient, userAddress] + [ensoUnsupportedNetworks, queryClient, userAddress] ) const status = useMemo((): 'error' | 'loading' | 'success' | 'unknown' => { diff --git a/src/components/shared/hooks/useBalancesQueries.test.ts b/src/components/shared/hooks/useBalancesQueries.test.ts deleted file mode 100644 index ec2db5f13..000000000 --- a/src/components/shared/hooks/useBalancesQueries.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { getAddress } from 'viem' -import { describe, expect, it } from 'vitest' -import type { TChainTokens } from '../types/mixed' -import type { TUseBalancesTokens } from './useBalances.multichains' -import { mergeStagedQueryData, partitionTokensByQueryStage } from './useBalancesQueries.helpers' - -const CHAIN1_TOKEN_A = '0x1111111111111111111111111111111111111111' -const CHAIN1_TOKEN_B = '0x2222222222222222222222222222222222222222' -const CHAIN10_TOKEN = '0x3333333333333333333333333333333333333333' -const CHAIN250_TOKEN = '0x4444444444444444444444444444444444444444' - -function normalizedBalance(raw: bigint, normalized: number, decimals: number = 18) { - return { - raw, - normalized, - display: normalized.toString(), - decimals - } -} - -function tokenKey(token: TUseBalancesTokens): string { - return `${token.chainID}:${getAddress(token.address)}` -} - -describe('partitionTokensByQueryStage', () => { - it('dedupes tokens and keeps the priority chain separate', () => { - const tokens: TUseBalancesTokens[] = [ - { address: CHAIN10_TOKEN, chainID: 10 }, - { address: CHAIN1_TOKEN_A, chainID: 1 }, - { address: CHAIN1_TOKEN_A, chainID: 1, symbol: 'dup' }, - { address: CHAIN1_TOKEN_B, chainID: 1 }, - { address: CHAIN250_TOKEN, chainID: 250 } - ] - - const { priorityTokensByChain, secondaryTokensByChain } = partitionTokensByQueryStage(tokens, 1) - - expect(priorityTokensByChain[1]?.map(tokenKey)).toEqual([ - `1:${getAddress(CHAIN1_TOKEN_A)}`, - `1:${getAddress(CHAIN1_TOKEN_B)}` - ]) - expect(secondaryTokensByChain[10]?.map(tokenKey)).toEqual([`10:${getAddress(CHAIN10_TOKEN)}`]) - expect(secondaryTokensByChain[250]?.map(tokenKey)).toEqual([`250:${getAddress(CHAIN250_TOKEN)}`]) - }) - - it('keeps all chains in the secondary stage when the priority chain is absent', () => { - const tokens: TUseBalancesTokens[] = [ - { address: CHAIN10_TOKEN, chainID: 10 }, - { address: CHAIN250_TOKEN, chainID: 250 } - ] - - const { priorityTokensByChain, secondaryTokensByChain } = partitionTokensByQueryStage(tokens, 1) - - expect(priorityTokensByChain).toEqual({}) - expect(secondaryTokensByChain[10]?.map(tokenKey)).toEqual([`10:${getAddress(CHAIN10_TOKEN)}`]) - expect(secondaryTokensByChain[250]?.map(tokenKey)).toEqual([`250:${getAddress(CHAIN250_TOKEN)}`]) - }) -}) - -describe('mergeStagedQueryData', () => { - it('reuses the previous object when staged query data is deep-equal', () => { - const previousData: TChainTokens = { - 1: { - [getAddress(CHAIN1_TOKEN_A)]: { - address: getAddress(CHAIN1_TOKEN_A), - chainID: 1, - symbol: 'AAA', - name: 'Token AAA', - decimals: 18, - value: 1, - balance: normalizedBalance(1n, 1) - } - }, - 10: { - [getAddress(CHAIN10_TOKEN)]: { - address: getAddress(CHAIN10_TOKEN), - chainID: 10, - symbol: 'OP', - name: 'Optimism', - decimals: 18, - value: 2, - balance: normalizedBalance(2n, 2) - } - } - } - - const nextData = mergeStagedQueryData({ - previousData, - priorityChainIds: [1], - priorityQueryData: [ - { - [getAddress(CHAIN1_TOKEN_A)]: { - address: getAddress(CHAIN1_TOKEN_A), - chainID: 1, - symbol: 'AAA', - name: 'Token AAA', - decimals: 18, - value: 1, - balance: normalizedBalance(1n, 1) - } - } - ], - secondaryChainIds: [10], - secondaryQueryData: [ - { - [getAddress(CHAIN10_TOKEN)]: { - address: getAddress(CHAIN10_TOKEN), - chainID: 10, - symbol: 'OP', - name: 'Optimism', - decimals: 18, - value: 2, - balance: normalizedBalance(2n, 2) - } - } - ] - }) - - expect(nextData).toBe(previousData) - }) - - it('preserves unchanged chain branches when only one staged chain changes', () => { - const previousData: TChainTokens = { - 1: { - [getAddress(CHAIN1_TOKEN_A)]: { - address: getAddress(CHAIN1_TOKEN_A), - chainID: 1, - symbol: 'AAA', - name: 'Token AAA', - decimals: 18, - value: 1, - balance: normalizedBalance(1n, 1) - } - }, - 10: { - [getAddress(CHAIN10_TOKEN)]: { - address: getAddress(CHAIN10_TOKEN), - chainID: 10, - symbol: 'OP', - name: 'Optimism', - decimals: 18, - value: 2, - balance: normalizedBalance(2n, 2) - } - } - } - - const nextData = mergeStagedQueryData({ - previousData, - priorityChainIds: [1], - priorityQueryData: [previousData[1]], - secondaryChainIds: [10], - secondaryQueryData: [ - { - [getAddress(CHAIN10_TOKEN)]: { - address: getAddress(CHAIN10_TOKEN), - chainID: 10, - symbol: 'OP', - name: 'Optimism', - decimals: 18, - value: 3, - balance: normalizedBalance(3n, 3) - } - } - ] - }) - - expect(nextData).not.toBe(previousData) - expect(nextData[1]).toBe(previousData[1]) - expect(nextData[10]).not.toBe(previousData[10]) - }) -}) diff --git a/src/components/shared/hooks/useBalancesQueries.ts b/src/components/shared/hooks/useBalancesQueries.ts index 4c1b0b168..21b966ef8 100644 --- a/src/components/shared/hooks/useBalancesQueries.ts +++ b/src/components/shared/hooks/useBalancesQueries.ts @@ -1,6 +1,7 @@ import { useDeepCompareMemo } from '@react-hookz/web' import { type UseQueryOptions, useQueries } from '@tanstack/react-query' import { useCallback, useMemo, useRef } from 'react' +import { resolveExecutionChainId } from '@/config/tenderly' import type { TAddress } from '../types/address' import type { TChainTokens, TDict, TNDict, TToken } from '../types/mixed' import { isZeroAddress } from '../utils/tools.is' @@ -23,9 +24,10 @@ function buildBalanceQueryOptions( ): TBalanceQueryOptions[] { return Object.entries(tokensByChain).map(([chainIdStr, chainTokens]) => { const chainId = Number(chainIdStr) + const executionChainId = resolveExecutionChainId(chainId) const config = getChainConfig(chainId) const tokenAddresses = chainTokens.map((t) => t.address) - const queryKey = balanceQueryKeys.byTokens(chainId, userAddress, tokenAddresses) + const queryKey = balanceQueryKeys.byTokens(chainId, executionChainId, userAddress, tokenAddresses) return { queryKey, diff --git a/src/components/shared/hooks/useBalancesQuery.ts b/src/components/shared/hooks/useBalancesQuery.ts index 332e36426..7bccddae1 100644 --- a/src/components/shared/hooks/useBalancesQuery.ts +++ b/src/components/shared/hooks/useBalancesQuery.ts @@ -4,16 +4,35 @@ import { toAddress } from '../utils/tools.address' /******************************************************************************* ** Query key factory for consistent cache key generation ******************************************************************************/ +function toExecutionKey(executionChainId?: number): number | 'none' { + return Number.isInteger(executionChainId) ? (executionChainId as number) : 'none' +} + export const balanceQueryKeys = { all: ['balances'] as const, - byChain: (chainId: number) => [...balanceQueryKeys.all, 'chain', chainId] as const, - byChainAndUser: (chainId: number, userAddress?: TAddress) => - [...balanceQueryKeys.byChain(chainId), 'user', toAddress(userAddress)] as const, - byToken: (chainId: number, userAddress: TAddress | undefined, tokenAddress: TAddress) => - [...balanceQueryKeys.byChainAndUser(chainId, userAddress), 'token', toAddress(tokenAddress)] as const, - byTokens: (chainId: number, userAddress: TAddress | undefined, tokenAddresses: TAddress[]) => + byChain: (chainId: number, executionChainId?: number) => + [...balanceQueryKeys.all, 'chain', chainId, 'execution', toExecutionKey(executionChainId)] as const, + byChainAndUser: (chainId: number, executionChainId: number | undefined, userAddress?: TAddress) => + [...balanceQueryKeys.byChain(chainId, executionChainId), 'user', toAddress(userAddress)] as const, + byToken: ( + chainId: number, + executionChainId: number | undefined, + userAddress: TAddress | undefined, + tokenAddress: TAddress + ) => + [ + ...balanceQueryKeys.byChainAndUser(chainId, executionChainId, userAddress), + 'token', + toAddress(tokenAddress) + ] as const, + byTokens: ( + chainId: number, + executionChainId: number | undefined, + userAddress: TAddress | undefined, + tokenAddresses: TAddress[] + ) => [ - ...balanceQueryKeys.byChainAndUser(chainId, userAddress), + ...balanceQueryKeys.byChainAndUser(chainId, executionChainId, userAddress), 'tokens', tokenAddresses.map(toAddress).toSorted().join(',') ] as const diff --git a/src/components/shared/hooks/useBalancesRouting.test.ts b/src/components/shared/hooks/useBalancesRouting.test.ts deleted file mode 100644 index 606529886..000000000 --- a/src/components/shared/hooks/useBalancesRouting.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { getAddress } from 'viem' -import { describe, expect, it } from 'vitest' -import type { TUseBalancesTokens } from './useBalances.multichains' -import { partitionTokensByBalanceSource } from './useBalancesRouting' - -const VAULT_A = '0x1111111111111111111111111111111111111111' -const STAKING_A = '0x2222222222222222222222222222222222222222' -const VAULT_B = '0x3333333333333333333333333333333333333333' -const STAKING_B = '0x4444444444444444444444444444444444444444' -const VAULT_C = '0x5555555555555555555555555555555555555555' - -function tokenKey(token: TUseBalancesTokens): string { - return `${token.chainID}:${getAddress(token.address)}` -} - -describe('partitionTokensByBalanceSource', () => { - it('routes staking-only pair tokens to multicall', () => { - const tokens: TUseBalancesTokens[] = [ - { - address: VAULT_A, - chainID: 1, - for: 'vault', - isVaultToken: true, - isStakingOnlyPair: true, - pairedStakingAddress: STAKING_A - }, - { - address: STAKING_A, - chainID: 1, - for: 'staking', - isStakingToken: true, - isStakingOnlyPair: true, - pairedVaultAddress: VAULT_A - } - ] - - const { ensoTokens, multicallTokens } = partitionTokensByBalanceSource(tokens, []) - expect(ensoTokens).toHaveLength(0) - expect(multicallTokens.map(tokenKey)).toEqual([`1:${getAddress(VAULT_A)}`, `1:${getAddress(STAKING_A)}`]) - }) - - it('routes vault-backed staking tokens to enso on supported chains', () => { - const tokens: TUseBalancesTokens[] = [ - { - address: VAULT_B, - chainID: 1, - for: 'vault', - isVaultToken: true, - isVaultBackedStaking: true, - pairedStakingAddress: STAKING_B - }, - { - address: STAKING_B, - chainID: 1, - for: 'staking', - isVaultToken: true, - isStakingToken: true, - isVaultBackedStaking: true, - pairedVaultAddress: VAULT_B - } - ] - - const { ensoTokens, multicallTokens } = partitionTokensByBalanceSource(tokens, []) - expect(multicallTokens).toHaveLength(0) - expect(ensoTokens.map(tokenKey)).toEqual([`1:${getAddress(VAULT_B)}`, `1:${getAddress(STAKING_B)}`]) - }) - - it('routes unsupported chains to multicall even for vault-backed staking', () => { - const tokens: TUseBalancesTokens[] = [ - { - address: STAKING_B, - chainID: 250, - for: 'staking', - isStakingToken: true, - isVaultBackedStaking: true - } - ] - - const { ensoTokens, multicallTokens } = partitionTokensByBalanceSource(tokens, [250]) - expect(ensoTokens).toHaveLength(0) - expect(multicallTokens.map(tokenKey)).toEqual([`250:${getAddress(STAKING_B)}`]) - }) - - it('dedupes duplicate entries and never routes same token to both sources', () => { - const tokens: TUseBalancesTokens[] = [ - { address: VAULT_A, chainID: 1, for: 'vault' }, - { address: VAULT_A, chainID: 1 }, - { address: VAULT_C, chainID: 1, for: 'vault', isStakingOnlyPair: true, pairedStakingAddress: STAKING_A }, - { address: STAKING_A, chainID: 1, for: 'staking' }, - { address: STAKING_A, chainID: 1, isStakingToken: true, isStakingOnlyPair: true, pairedVaultAddress: VAULT_C } - ] - - const { ensoTokens, multicallTokens } = partitionTokensByBalanceSource(tokens, []) - const ensoKeys = new Set(ensoTokens.map(tokenKey)) - const multicallKeys = new Set(multicallTokens.map(tokenKey)) - const overlap = [...ensoKeys].filter((key) => multicallKeys.has(key)) - - expect(overlap).toHaveLength(0) - expect([...ensoKeys]).toEqual([`1:${getAddress(VAULT_A)}`]) - expect([...multicallKeys]).toEqual([`1:${getAddress(VAULT_C)}`, `1:${getAddress(STAKING_A)}`]) - }) - - it('preserves discovery metadata when duplicate token entries are merged', () => { - const aliasVault = getAddress(VAULT_B) - const tokens: TUseBalancesTokens[] = [ - { - address: VAULT_A, - chainID: 1, - for: 'vault', - isCatalogVault: true - }, - { - address: VAULT_A, - chainID: 1, - isCatalogVault: false, - holdingsAliasVaultAddress: aliasVault - } - ] - - const { ensoTokens, multicallTokens } = partitionTokensByBalanceSource(tokens, []) - - expect(multicallTokens).toHaveLength(0) - expect(ensoTokens).toHaveLength(1) - expect(ensoTokens[0].isCatalogVault).toBe(false) - expect(ensoTokens[0].holdingsAliasVaultAddress).toBe(aliasVault) - }) -}) diff --git a/src/components/shared/hooks/useBalancesWithQuery.ts b/src/components/shared/hooks/useBalancesWithQuery.ts index 60222a181..f7a1ac864 100644 --- a/src/components/shared/hooks/useBalancesWithQuery.ts +++ b/src/components/shared/hooks/useBalancesWithQuery.ts @@ -1,5 +1,6 @@ import { useQueryClient } from '@tanstack/react-query' import { useCallback, useMemo } from 'react' +import { resolveExecutionChainId } from '@/config/tenderly' import { useWeb3 } from '../contexts/useWeb3' import type { TChainTokens, TDict, TToken } from '../types/mixed' import { isZeroAddress } from '../utils/tools.is' @@ -66,13 +67,14 @@ export function useBalancesWithQuery(props?: TUseBalancesReq): TUseBalancesRes { // Process each chain's tokens for (const [chainIdStr, chainTokens] of Object.entries(tokensByChain)) { const chainId = Number(chainIdStr) + const executionChainId = resolveExecutionChainId(chainId) // Fetch fresh balances for the requested tokens const freshBalances = await fetchTokenBalances(chainId, userAddress, chainTokens, true) // Update ALL possible query keys that might contain these tokens const allQueries = queryClient.getQueriesData>({ - queryKey: balanceQueryKeys.byChainAndUser(chainId, userAddress), + queryKey: balanceQueryKeys.byChainAndUser(chainId, executionChainId, userAddress), exact: false }) diff --git a/src/components/shared/hooks/useChainID.tsx b/src/components/shared/hooks/useChainID.tsx index fbf944a3a..b78e3fa24 100644 --- a/src/components/shared/hooks/useChainID.tsx +++ b/src/components/shared/hooks/useChainID.tsx @@ -7,12 +7,8 @@ export type TUseChainIDRes = { safeChainID: number } -export const toSafeChainID = (chainID: number, fallback: number): number => { - if ([1337, 31337].includes(chainID)) { - return fallback - } - return chainID -} +export const toSafeChainID = (chainID: number, fallback: number): number => + Number.isInteger(chainID) && chainID > 0 ? chainID : fallback export function useChainID(defaultChainID?: number): TUseChainIDRes { const { chainId, switchNetwork } = useChains() diff --git a/src/components/shared/hooks/useChainTimestamp.ts b/src/components/shared/hooks/useChainTimestamp.ts index 9eca195ac..7ee9a0014 100644 --- a/src/components/shared/hooks/useChainTimestamp.ts +++ b/src/components/shared/hooks/useChainTimestamp.ts @@ -1,5 +1,5 @@ +import { useBlockNumber, usePublicClient } from '@shared/hooks/useAppWagmi' import { useCallback, useEffect, useRef, useState } from 'react' -import { useBlockNumber, usePublicClient } from 'wagmi' type TChainTimestampState = { blockTimestamp: number diff --git a/src/components/shared/hooks/useChains.tsx b/src/components/shared/hooks/useChains.tsx index 7f83b2ee7..9757bb1c9 100644 --- a/src/components/shared/hooks/useChains.tsx +++ b/src/components/shared/hooks/useChains.tsx @@ -1,22 +1,20 @@ import { useCustomCompareMemo, useDeepCompareMemo } from '@react-hookz/web' import type { TMultiSelectOptionProps } from '@shared/components/MultiSelectDropdown' - +import { SUPPORTED_NETWORKS } from '@shared/utils/constants' import type { Chain } from 'viem' import type { Connector } from 'wagmi' -import { useConfig, useConnect } from 'wagmi' +import { useConnect } from 'wagmi' export function useChainOptions(chains: number[] | null): TMultiSelectOptionProps[] { const { connectors } = useConnect() - const config = useConfig() const injectedChains = useCustomCompareMemo( (): Chain[] | undefined => { connectors //Hard trigger re-render when connectors change - const noFork = config.chains.filter(({ id }): boolean => id !== 1337) - return noFork as Chain[] + return SUPPORTED_NETWORKS as Chain[] }, - [[...connectors], config], - (savedDeps: [Connector[], typeof config], deps: [Connector[], typeof config]): boolean => { + [[...connectors]], + (savedDeps: [Connector[]], deps: [Connector[]]): boolean => { for (const savedDep of savedDeps[0]) { if (!deps[0].find((dep): boolean => dep.id === savedDep.id)) { return false diff --git a/src/components/shared/hooks/useFetchYearnVaults.test.ts b/src/components/shared/hooks/useFetchYearnVaults.test.ts deleted file mode 100644 index 78d7c8fa4..000000000 --- a/src/components/shared/hooks/useFetchYearnVaults.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { TKongVaultListItem } from '@shared/utils/schemas/kongVaultListSchema' -import { describe, expect, it } from 'vitest' -import { isCatalogYearnVault } from './useFetchYearnVaults' - -function makeVault(overrides: Partial): TKongVaultListItem { - return { - address: '0x1111111111111111111111111111111111111111', - chainId: 1, - origin: 'yearn', - inclusion: undefined, - token: { - address: '0x2222222222222222222222222222222222222222', - name: 'Token', - symbol: 'TKN', - decimals: 18 - }, - staking: undefined, - metadata: { - protocols: [] - }, - ...overrides - } as TKongVaultListItem -} - -describe('isCatalogYearnVault', () => { - it('keeps yearn vaults in the public catalog by default', () => { - expect(isCatalogYearnVault(makeVault({ origin: 'yearn' }))).toBe(true) - }) - - it('excludes explicitly non-yearn catalog entries', () => { - expect(isCatalogYearnVault(makeVault({ origin: 'partner', inclusion: { isYearn: true } as never }))).toBe(false) - }) - - it('excludes yearn vaults that Kong marks as not included', () => { - expect(isCatalogYearnVault(makeVault({ origin: 'yearn', inclusion: { isYearn: false } as never }))).toBe(false) - }) -}) diff --git a/src/components/shared/hooks/useFetchYearnVaults.ts b/src/components/shared/hooks/useFetchYearnVaults.ts index 27feaaaab..225ae09cd 100644 --- a/src/components/shared/hooks/useFetchYearnVaults.ts +++ b/src/components/shared/hooks/useFetchYearnVaults.ts @@ -3,14 +3,14 @@ import { KONG_REST_BASE } from '@pages/vaults/utils/kongRest' import { useDeepCompareMemo } from '@react-hookz/web' import { fetchWithSchema, getFetchQueryKey, useFetch } from '@shared/hooks/useFetch' import type { TDict } from '@shared/types' -import { toAddress } from '@shared/utils' +import { SUPPORTED_NETWORKS, toAddress } from '@shared/utils' import type { TKongVaultList, TKongVaultListItem } from '@shared/utils/schemas/kongVaultListSchema' import { kongVaultListSchema } from '@shared/utils/schemas/kongVaultListSchema' import type { QueryObserverResult } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query' import { useEffect, useMemo } from 'react' -const DEFAULT_CHAIN_IDS = [1, 10, 137, 146, 250, 8453, 42161, 747474] +const DEFAULT_CHAIN_IDS = SUPPORTED_NETWORKS.map((network) => network.id) const VAULT_LIST_ENDPOINT = `${KONG_REST_BASE}/list/vaults` export const isCatalogYearnVault = (item: TKongVaultListItem): boolean => diff --git a/src/components/shared/hooks/useOptimisticValue.ts b/src/components/shared/hooks/useOptimisticValue.ts new file mode 100644 index 000000000..36d45d672 --- /dev/null +++ b/src/components/shared/hooks/useOptimisticValue.ts @@ -0,0 +1,27 @@ +import { type Dispatch, type SetStateAction, useState } from 'react' + +/** + * Manages optimistic state that overrides the actual value until actual catches up. + * + * Replaces the useState + useEffect pattern for clearing optimistic overrides: + * const [optimistic, setOptimistic] = useState(null) + * useEffect(() => { if (optimistic !== null && optimistic === actual) setOptimistic(null) }, [optimistic, actual]) + * const displayed = optimistic ?? actual + * + * Instead: const [displayed, setOptimistic] = useOptimisticValue(actual) + * + * Uses render-time state adjustment (React-approved) instead of useEffect. + */ +export function useOptimisticValue( + actual: T, + isEqual: (a: T, b: T) => boolean = Object.is +): [displayed: T, setOptimistic: Dispatch>] { + const [optimistic, setOptimistic] = useState(null) + + // Clear optimistic when actual catches up — render-time adjustment avoids useEffect + if (optimistic !== null && isEqual(optimistic, actual)) { + setOptimistic(null) + } + + return [optimistic ?? actual, setOptimistic] +} diff --git a/src/components/shared/hooks/useStakingAssetConversions.ts b/src/components/shared/hooks/useStakingAssetConversions.ts index bd673fb8d..f82258772 100644 --- a/src/components/shared/hooks/useStakingAssetConversions.ts +++ b/src/components/shared/hooks/useStakingAssetConversions.ts @@ -8,6 +8,7 @@ import { useMemo } from 'react' import type { Address } from 'viem' import { useConfig } from 'wagmi' import { readContract } from 'wagmi/actions' +import { resolveExecutionChainId } from '@/config/tenderly' type TTokenAndChain = { address: TAddress; chainID: number } type TBalanceGetter = (params: TTokenAndChain) => TNormalizedBN @@ -88,14 +89,20 @@ export function useStakingAssetConversions({ abi: readonly unknown[] functionName: string args?: readonly unknown[] - }) => - readContract(config, { - chainId: position.chainID, + }) => { + const executionChainId = resolveExecutionChainId(position.chainID) + if (!executionChainId) { + throw new Error(`No execution chain found for chainId ${position.chainID}`) + } + + return readContract(config, { + chainId: executionChainId, address: request.address, abi: request.abi as any, functionName: request.functionName as any, args: request.args as any }) + } return getStakingWithdrawableAssets({ read, @@ -105,7 +112,7 @@ export function useStakingAssetConversions({ stakingShareBalance: position.stakingShareBalance }) }, - enabled: Boolean(userAddress && position.stakingShareBalance > 0n), + enabled: Boolean(userAddress && position.stakingShareBalance > 0n && resolveExecutionChainId(position.chainID)), staleTime: 60_000, gcTime: 5 * 60_000, retry: 1, diff --git a/src/components/shared/hooks/useSupportedChains.ts b/src/components/shared/hooks/useSupportedChains.ts index 3b76117a8..88925c228 100644 --- a/src/components/shared/hooks/useSupportedChains.ts +++ b/src/components/shared/hooks/useSupportedChains.ts @@ -1,17 +1,13 @@ import { useMemo } from 'react' import type { Chain } from 'viem/chains' -import { retrieveConfig } from '../utils/wagmi' +import { supportedAppChains } from '@/config/supportedChains' /****************************************************************************** - ** The useSupportedChains hook returns an array of supported chains, based on - ** the injected connector. + ** The useSupportedChains hook returns the canonical app chains used by vault + ** filters, URLs, and query-state serialization. *****************************************************************************/ export function useSupportedChains(): Chain[] { - const supportedChains = useMemo((): Chain[] => { - const config = retrieveConfig() - const noFork = config.chains.filter(({ id }): boolean => id !== 1337) - return noFork - }, []) + const chains = useMemo((): Chain[] => [...supportedAppChains], []) - return supportedChains + return chains } diff --git a/src/components/shared/hooks/useTransactionStatusPoller.ts b/src/components/shared/hooks/useTransactionStatusPoller.ts index 3b70085a6..7a67a80be 100644 --- a/src/components/shared/hooks/useTransactionStatusPoller.ts +++ b/src/components/shared/hooks/useTransactionStatusPoller.ts @@ -1,7 +1,6 @@ import { useNotifications } from '@shared/contexts/useNotifications' import type { TNotification } from '@shared/types/notifications' -import { SUPPORTED_NETWORKS } from '@shared/utils' -import { retrieveConfig } from '@shared/utils/wagmi' +import { getNetwork, retrieveConfig } from '@shared/utils/wagmi' import { useCallback, useEffect, useRef } from 'react' import { getBlock, waitForTransactionReceipt } from 'wagmi/actions' @@ -28,16 +27,17 @@ export function useTransactionStatusPoller(notification: TNotification): void { try { const config = retrieveConfig() - const chain = SUPPORTED_NETWORKS.find((network) => network.id === notification.chainId) + const pollingChainId = notification.executionChainId ?? notification.chainId + const chain = getNetwork(pollingChainId) if (!chain) { - console.warn(`Chain ${notification.chainId} not supported for transaction polling`) + console.warn(`Chain ${pollingChainId} not supported for transaction polling`) return } // Wait for transaction receipt with a short timeout to avoid blocking const receipt = await waitForTransactionReceipt(config, { - chainId: notification.chainId, + chainId: pollingChainId, hash: notification.txHash, timeout: 5000 // 5 second timeout to avoid long waits }) @@ -47,7 +47,7 @@ export function useTransactionStatusPoller(notification: TNotification): void { // Get the block information to retrieve the timestamp const block = await getBlock(config, { - chainId: notification.chainId, + chainId: pollingChainId, blockNumber: receipt.blockNumber }) diff --git a/src/components/shared/types/index.ts b/src/components/shared/types/index.ts index a2eae2aad..fb1041b7a 100644 --- a/src/components/shared/types/index.ts +++ b/src/components/shared/types/index.ts @@ -2,3 +2,4 @@ export * from './address' export * from './dropdown' export * from './mixed' export * from './prices' +export * from './tenderly' diff --git a/src/components/shared/types/notifications.ts b/src/components/shared/types/notifications.ts index 93824a85e..fe76c349b 100644 --- a/src/components/shared/types/notifications.ts +++ b/src/components/shared/types/notifications.ts @@ -26,6 +26,7 @@ export type TNotification = { type: TNotificationType address: TAddress chainId: number + executionChainId?: number toChainId?: number // Destination chain ID for cross-chain transactions spenderAddress?: TAddress spenderName?: string @@ -60,6 +61,7 @@ export type TCreateNotificationParams = { fromAddress: TAddress fromSymbol: string fromChainId: number + executionChainId?: number toAddress?: TAddress // optional for approve/claim toSymbol?: string toAmount?: string // expected output amount for withdrawals diff --git a/src/components/shared/types/tenderly.ts b/src/components/shared/types/tenderly.ts new file mode 100644 index 000000000..1fc7e4f5f --- /dev/null +++ b/src/components/shared/types/tenderly.ts @@ -0,0 +1,83 @@ +import type { TAddress } from './address' + +export type TTenderlyConfiguredChainStatus = { + canonicalChainId: number + canonicalChainName: string + executionChainId: number + hasAdminRpc: boolean +} + +export type TTenderlyPanelStatus = { + isTenderlyModeEnabled: boolean + configuredChains: TTenderlyConfiguredChainStatus[] +} + +export type TTenderlySnapshotKind = 'baseline' | 'snapshot' +export type TTenderlySnapshotRecordStatus = 'valid' | 'invalid' + +export type TTenderlySnapshotRecord = { + snapshotId: string + canonicalChainId: number + executionChainId: number + label: string + createdAt: string + kind: TTenderlySnapshotKind + lastKnownStatus: TTenderlySnapshotRecordStatus +} + +export type TTenderlySnapshotRequest = { + canonicalChainId: number + label?: string + isBaseline?: boolean +} + +export type TTenderlyRevertRequest = { + canonicalChainId: number + snapshotId: string +} + +export type TTenderlyRevertResponse = { + success: boolean + revertedSnapshotId: string +} + +export type TTenderlyIncreaseTimeRequest = { + canonicalChainId: number + seconds: number + mineBlock?: boolean +} + +export type TTenderlyIncreaseTimeResponse = { + timeResult: unknown + mineResult?: unknown +} + +export type TTenderlyFundAssetKind = 'native' | 'erc20' +export type TTenderlyFundTokenType = 'asset' | 'vault' | 'staking' + +export type TTenderlyFundableAsset = { + chainId: number + address: TAddress + name: string + symbol: string + decimals: number + assetKind: TTenderlyFundAssetKind + tokenType: TTenderlyFundTokenType + logoURI?: string +} + +export type TTenderlyFundRequest = { + canonicalChainId: number + walletAddress: TAddress + assetKind: TTenderlyFundAssetKind + tokenAddress?: TAddress + symbol: string + decimals: number + amount: string + mode?: 'set' | 'add' +} + +export type TTenderlyFundResponse = { + method: string + result: unknown +} diff --git a/src/components/shared/utils/constants.tsx b/src/components/shared/utils/constants.tsx index b40c2bced..541b0f31b 100755 --- a/src/components/shared/utils/constants.tsx +++ b/src/components/shared/utils/constants.tsx @@ -1,9 +1,11 @@ import type { TAddress, TNDict, TToken } from '@shared/types' import { arbitrum, base, fantom, mainnet, optimism, polygon, sonic } from 'viem/chains' +import { supportedAppChains } from '@/config/supportedChains' import { toAddress } from './tools.address' -import { katana } from './wagmi' -export const SUPPORTED_NETWORKS = [mainnet, optimism, polygon, fantom, base, arbitrum, sonic, katana] +export const SUPPORTED_NETWORKS = supportedAppChains.length + ? supportedAppChains + : [mainnet, optimism, polygon, fantom, base, arbitrum, sonic] export const MULTICALL3_ADDRESS = toAddress('0xcA11bde05977b3631167028862bE2a173976CA11') diff --git a/src/components/shared/utils/index.ts b/src/components/shared/utils/index.ts index 0147f4c51..98a185b72 100644 --- a/src/components/shared/utils/index.ts +++ b/src/components/shared/utils/index.ts @@ -9,6 +9,7 @@ export * from './format' export * from './handlers' export * from './helpers' export * from './selectorStyles' +export * from './tenderlyPanel' export * from './tools.address' export * from './tools.is' // export * from './tools.math'; diff --git a/src/components/shared/utils/routes.test.ts b/src/components/shared/utils/routes.test.ts deleted file mode 100644 index 19c3ec3f1..000000000 --- a/src/components/shared/utils/routes.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { isVaultsIndexPath, normalizePathname } from './routes' - -describe('normalizePathname', () => { - it('keeps root pathname', () => { - expect(normalizePathname('/')).toBe('/') - }) - - it('removes trailing slashes', () => { - expect(normalizePathname('/vaults/')).toBe('/vaults') - expect(normalizePathname('/vaults///')).toBe('/vaults') - }) -}) - -describe('isVaultsIndexPath', () => { - it('matches only /vaults (after normalization)', () => { - expect(isVaultsIndexPath('/vaults')).toBe(true) - expect(isVaultsIndexPath('/vaults/')).toBe(true) - expect(isVaultsIndexPath('/vaults/1/0x0')).toBe(false) - expect(isVaultsIndexPath('/vaults-beta')).toBe(false) - }) -}) diff --git a/src/components/shared/utils/schemas/kongVaultSnapshotSchema.test.ts b/src/components/shared/utils/schemas/kongVaultSnapshotSchema.test.ts deleted file mode 100644 index 26a0325bd..000000000 --- a/src/components/shared/utils/schemas/kongVaultSnapshotSchema.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { kongVaultSnapshotSchema } from './kongVaultSnapshotSchema' - -describe('kongVaultSnapshotSchema', () => { - it('retains Katana estimated component fields', () => { - const parsed = kongVaultSnapshotSchema.parse({ - address: '0x0000000000000000000000000000000000000001', - chainId: 747474, - performance: { - estimated: { - apr: 0.4687, - apy: 0.068, - type: 'katana-estimated-apr', - components: { - netAPR: 0.4687, - netAPY: 0.068, - katanaBonusAPY: 0.068, - katanaNativeYield: 0.027, - katanaAppRewardsAPR: 0.0916, - steerPointsPerDollar: 0.1883, - fixedRateKatanaRewards: 0.35, - FixedRateKatanaRewards: 0.36 - } - } - } - }) - - expect(parsed.performance?.estimated?.components?.netAPR).toBe(0.4687) - expect(parsed.performance?.estimated?.components?.netAPY).toBe(0.068) - expect(parsed.performance?.estimated?.components?.katanaBonusAPY).toBe(0.068) - expect(parsed.performance?.estimated?.components?.katanaNativeYield).toBe(0.027) - expect(parsed.performance?.estimated?.components?.katanaAppRewardsAPR).toBe(0.0916) - expect(parsed.performance?.estimated?.components?.steerPointsPerDollar).toBe(0.1883) - expect(parsed.performance?.estimated?.components?.fixedRateKatanaRewards).toBe(0.35) - expect(parsed.performance?.estimated?.components?.FixedRateKatanaRewards).toBe(0.36) - }) -}) diff --git a/src/components/shared/utils/tenderlyPanel.ts b/src/components/shared/utils/tenderlyPanel.ts new file mode 100644 index 000000000..d4d7063bf --- /dev/null +++ b/src/components/shared/utils/tenderlyPanel.ts @@ -0,0 +1,449 @@ +import { + getVaultAddress, + getVaultChainID, + getVaultDecimals, + getVaultName, + getVaultStaking, + getVaultSymbol, + getVaultToken, + type TKongVaultInput +} from '@pages/vaults/domain/kongVaultSelectors' +import { canonicalChains } from '@/config/chainDefinitions' +import type { TDict, TNDict, TToken } from '../types' +import type { + TTenderlyConfiguredChainStatus, + TTenderlyFundableAsset, + TTenderlyFundTokenType, + TTenderlySnapshotRecord +} from '../types/tenderly' +import { ETH_TOKEN_ADDRESS } from './constants' +import { toAddress } from './tools.address' +import { isZeroAddress } from './tools.is' + +export type TTenderlySnapshotStorage = Record +export type TTenderlyFastForwardUnit = 'minutes' | 'hours' | 'days' + +type TTenderlyTimeInput = { + amount: number + unit: TTenderlyFastForwardUnit + seconds: number +} + +const SNAPSHOT_KIND_ORDER: Record = { + baseline: 0, + snapshot: 1 +} + +const TOKEN_TYPE_PRIORITY: Record = { + asset: 0, + vault: 1, + staking: 2 +} + +const TIME_UNIT_SECONDS: Record = { + minutes: 60, + hours: 3_600, + days: 86_400 +} + +const COMMON_TENDERLY_ASSET_SYMBOLS = [ + 'ETH', + 'WETH', + 'WBTC', + 'USDC', + 'USDT', + 'DAI', + 'USDS', + 'USDE', + 'SUSDE', + 'CRVUSD', + 'SDAI', + 'YVUSD' +] + +const COMMON_TENDERLY_ASSET_PRIORITY = COMMON_TENDERLY_ASSET_SYMBOLS.reduce>( + (accumulator, symbol, index) => { + accumulator[symbol] = index + return accumulator + }, + {} +) + +const COMMON_TENDERLY_PREFERRED_ADDRESSES: Record>> = { + 1: { + WETH: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + WBTC: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + USDC: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + USDT: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + DAI: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + USDS: '0xdC035D45d973E3EC169d2276DDab16f1e407384F', + USDE: '0x4c9EDD5852cd905f086C759E8383e09bff1E68B3', + SUSDE: '0x9D39A5DE30e57443BfF2A8307A4256c8797A3497', + CRVUSD: '0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E', + SDAI: '0x83F20F44975D03b1b09e64809B757c47f942BEeA' + } +} + +export const TENDERLY_SNAPSHOT_STORAGE_KEY = 'yearn.fi/tenderly/snapshots' + +export function getTenderlySnapshotBucketKey(canonicalChainId: number, executionChainId: number): string { + return `${canonicalChainId}:${executionChainId}` +} + +export function sortTenderlySnapshotRecords(records: TTenderlySnapshotRecord[]): TTenderlySnapshotRecord[] { + return records.toSorted((left, right) => { + const kindOrder = SNAPSHOT_KIND_ORDER[left.kind] - SNAPSHOT_KIND_ORDER[right.kind] + if (kindOrder !== 0) { + return kindOrder + } + + return new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime() + }) +} + +export function getValidBaselineSnapshot(records: TTenderlySnapshotRecord[]): TTenderlySnapshotRecord | undefined { + return sortTenderlySnapshotRecords(records).find( + (record) => record.kind === 'baseline' && record.lastKnownStatus === 'valid' + ) +} + +export function getLastRestorableTenderlySnapshot( + records: TTenderlySnapshotRecord[] +): TTenderlySnapshotRecord | undefined { + const sortedRecords = sortTenderlySnapshotRecords(records) + const latestSnapshot = sortedRecords.find( + (record) => record.kind === 'snapshot' && record.lastKnownStatus === 'valid' + ) + + return latestSnapshot || getValidBaselineSnapshot(sortedRecords) +} + +export function upsertTenderlySnapshotRecord( + snapshotStorage: TTenderlySnapshotStorage, + record: TTenderlySnapshotRecord +): TTenderlySnapshotStorage { + const bucketKey = getTenderlySnapshotBucketKey(record.canonicalChainId, record.executionChainId) + const previousRecords = snapshotStorage[bucketKey] || [] + const filteredRecords = previousRecords.filter((existingRecord) => { + if (existingRecord.snapshotId === record.snapshotId) { + return false + } + + if (record.kind === 'baseline' && existingRecord.kind === 'baseline') { + return false + } + + return true + }) + + return { + ...snapshotStorage, + [bucketKey]: sortTenderlySnapshotRecords([record, ...filteredRecords]) + } +} + +export function markTenderlySnapshotInvalid( + snapshotStorage: TTenderlySnapshotStorage, + params: { canonicalChainId: number; executionChainId: number; snapshotId: string } +): TTenderlySnapshotStorage { + const bucketKey = getTenderlySnapshotBucketKey(params.canonicalChainId, params.executionChainId) + const updatedRecords = (snapshotStorage[bucketKey] || []).map((record) => + record.snapshotId === params.snapshotId ? { ...record, lastKnownStatus: 'invalid' as const } : record + ) + + return { + ...snapshotStorage, + [bucketKey]: sortTenderlySnapshotRecords(updatedRecords) + } +} + +export function reconcileTenderlySnapshotStorageAfterRevert( + snapshotStorage: TTenderlySnapshotStorage, + params: { + revertedSnapshotRecord: TTenderlySnapshotRecord + replacementSnapshotRecord?: TTenderlySnapshotRecord + } +): TTenderlySnapshotStorage { + const bucketKey = getTenderlySnapshotBucketKey( + params.revertedSnapshotRecord.canonicalChainId, + params.revertedSnapshotRecord.executionChainId + ) + const previousRecords = snapshotStorage[bucketKey] || [] + + if (previousRecords.length === 0) { + return params.replacementSnapshotRecord + ? upsertTenderlySnapshotRecord(snapshotStorage, params.replacementSnapshotRecord) + : snapshotStorage + } + + const revertedAt = Date.parse(params.revertedSnapshotRecord.createdAt) + const updatedRecords = previousRecords.map((record) => { + const recordCreatedAt = Date.parse(record.createdAt) + const isLaterSnapshot = + record.kind === 'snapshot' && + Number.isFinite(revertedAt) && + Number.isFinite(recordCreatedAt) && + recordCreatedAt > revertedAt + + if (record.snapshotId !== params.revertedSnapshotRecord.snapshotId && !isLaterSnapshot) { + return record + } + + return { ...record, lastKnownStatus: 'invalid' as const } + }) + const nextStorage = { + ...snapshotStorage, + [bucketKey]: sortTenderlySnapshotRecords(updatedRecords) + } + + return params.replacementSnapshotRecord + ? upsertTenderlySnapshotRecord(nextStorage, params.replacementSnapshotRecord) + : nextStorage +} + +export function clearTenderlySnapshotBucket( + snapshotStorage: TTenderlySnapshotStorage, + params: { canonicalChainId: number; executionChainId: number } +): TTenderlySnapshotStorage { + const bucketKey = getTenderlySnapshotBucketKey(params.canonicalChainId, params.executionChainId) + + if (!(bucketKey in snapshotStorage)) { + return snapshotStorage + } + + const nextStorage = { ...snapshotStorage } + delete nextStorage[bucketKey] + return nextStorage +} + +export function resolveDefaultTenderlyCanonicalChainId( + configuredChains: TTenderlyConfiguredChainStatus[], + preferredChainIds: Array +): number | undefined { + const availableCanonicalChainIds = configuredChains + .filter((chain) => chain.hasAdminRpc) + .map((chain) => chain.canonicalChainId) + const preferredChainId = preferredChainIds.find((chainId) => availableCanonicalChainIds.includes(Number(chainId))) + + return preferredChainId ?? availableCanonicalChainIds[0] +} + +export function convertTenderlyTimeAmountToSeconds(amount: number, unit: TTenderlyFastForwardUnit): number { + const normalizedAmount = Number.isFinite(amount) ? Math.max(0, amount) : 0 + + return Math.round(normalizedAmount * TIME_UNIT_SECONDS[unit]) +} + +export function addTenderlyTimeIncrement(params: { + currentAmount: number + currentUnit: TTenderlyFastForwardUnit + addedAmount: number + addedUnit: TTenderlyFastForwardUnit +}): TTenderlyTimeInput { + const currentSeconds = convertTenderlyTimeAmountToSeconds(params.currentAmount, params.currentUnit) + const addedSeconds = convertTenderlyTimeAmountToSeconds(params.addedAmount, params.addedUnit) + + if (currentSeconds <= 0) { + return { + amount: params.addedAmount, + unit: params.addedUnit, + seconds: addedSeconds + } + } + + const totalSeconds = currentSeconds + addedSeconds + + return { + amount: Number((totalSeconds / TIME_UNIT_SECONDS[params.currentUnit]).toFixed(6)), + unit: params.currentUnit, + seconds: totalSeconds + } +} + +function getCanonicalNativeAsset(chainId: number): TTenderlyFundableAsset | undefined { + const chain = canonicalChains.find((item) => item.id === chainId) + if (!chain) { + return undefined + } + + return { + chainId, + address: toAddress(ETH_TOKEN_ADDRESS), + name: chain.nativeCurrency.name, + symbol: chain.nativeCurrency.symbol, + decimals: chain.nativeCurrency.decimals, + assetKind: 'native', + tokenType: 'asset' + } +} + +function mergeFundableAsset( + accumulator: Record, + asset: TTenderlyFundableAsset +): Record { + const key = `${asset.chainId}:${toAddress(asset.address)}` + const existingAsset = accumulator[key] + + if (!existingAsset) { + accumulator[key] = asset + return accumulator + } + + accumulator[key] = + TOKEN_TYPE_PRIORITY[asset.tokenType] > TOKEN_TYPE_PRIORITY[existingAsset.tokenType] + ? { ...asset, logoURI: asset.logoURI || existingAsset.logoURI } + : { + ...existingAsset, + logoURI: existingAsset.logoURI || asset.logoURI + } + + return accumulator +} + +function getTenderlyFundableAssetPriority(asset: TTenderlyFundableAsset): number { + return COMMON_TENDERLY_ASSET_PRIORITY[String(asset.symbol || '').toUpperCase()] ?? Number.MAX_SAFE_INTEGER +} + +function getTenderlyPreferredAddressPriority(asset: TTenderlyFundableAsset): number { + const preferredAddress = + COMMON_TENDERLY_PREFERRED_ADDRESSES[asset.chainId]?.[String(asset.symbol || '').toUpperCase()] + + if (!preferredAddress) { + return 1 + } + + return toAddress(asset.address) === toAddress(preferredAddress) ? 0 : 1 +} + +function compareTenderlyFundableAssets(left: TTenderlyFundableAsset, right: TTenderlyFundableAsset): number { + if (left.assetKind !== right.assetKind) { + return left.assetKind === 'native' ? -1 : 1 + } + + const commonAssetPriority = getTenderlyFundableAssetPriority(left) - getTenderlyFundableAssetPriority(right) + if (commonAssetPriority !== 0) { + return commonAssetPriority + } + + const preferredAddressPriority = + getTenderlyPreferredAddressPriority(left) - getTenderlyPreferredAddressPriority(right) + if (preferredAddressPriority !== 0) { + return preferredAddressPriority + } + + if (left.tokenType !== right.tokenType) { + return TOKEN_TYPE_PRIORITY[left.tokenType] - TOKEN_TYPE_PRIORITY[right.tokenType] + } + + const symbolOrder = left.symbol.localeCompare(right.symbol, 'en-US') + if (symbolOrder !== 0) { + return symbolOrder + } + + const nameOrder = left.name.localeCompare(right.name, 'en-US') + if (nameOrder !== 0) { + return nameOrder + } + + return left.address.localeCompare(right.address, 'en-US') +} + +function toFundableAssetFromToken(token: TToken, tokenType: TTenderlyFundTokenType = 'asset'): TTenderlyFundableAsset { + return { + chainId: token.chainID, + address: toAddress(token.address), + name: token.name, + symbol: token.symbol, + decimals: token.decimals, + assetKind: toAddress(token.address) === toAddress(ETH_TOKEN_ADDRESS) ? 'native' : 'erc20', + tokenType, + logoURI: token.logoURI + } +} + +export function buildTenderlyFundableAssets(params: { + chainId: number + tokenLists: TNDict> + allVaults: TDict +}): TTenderlyFundableAsset[] { + const nativeAsset = getCanonicalNativeAsset(params.chainId) + const tokenListAssets = Object.values(params.tokenLists[params.chainId] || {}).map((token) => + toFundableAssetFromToken(token) + ) + const vaultAssets = Object.values(params.allVaults) + .filter((vault) => getVaultChainID(vault) === params.chainId) + .reduce((accumulator, vault) => { + const assetToken = getVaultToken(vault) + const vaultAddress = toAddress(getVaultAddress(vault)) + const staking = getVaultStaking(vault) + + const nextAssets = [ + !isZeroAddress(toAddress(assetToken.address)) + ? { + chainId: params.chainId, + address: toAddress(assetToken.address), + name: assetToken.name, + symbol: assetToken.symbol, + decimals: assetToken.decimals, + assetKind: toAddress(assetToken.address) === toAddress(ETH_TOKEN_ADDRESS) ? 'native' : 'erc20', + tokenType: 'asset' as const + } + : undefined, + !isZeroAddress(vaultAddress) + ? { + chainId: params.chainId, + address: vaultAddress, + name: getVaultName(vault), + symbol: getVaultSymbol(vault), + decimals: getVaultDecimals(vault), + assetKind: 'erc20' as const, + tokenType: 'vault' as const + } + : undefined, + !isZeroAddress(toAddress(staking.address)) + ? { + chainId: params.chainId, + address: toAddress(staking.address), + name: `${getVaultName(vault)} Staking`, + symbol: getVaultSymbol(vault), + decimals: getVaultDecimals(vault), + assetKind: 'erc20' as const, + tokenType: 'staking' as const + } + : undefined + ].filter(Boolean) + + return accumulator.concat(nextAssets as TTenderlyFundableAsset[]) + }, []) + + return Object.values( + [nativeAsset, ...tokenListAssets, ...vaultAssets] + .filter(Boolean) + .reduce>( + (accumulator, asset) => mergeFundableAsset(accumulator, asset as TTenderlyFundableAsset), + {} + ) + ).toSorted(compareTenderlyFundableAssets) +} + +export function getDefaultTenderlyFundableAssets( + assets: TTenderlyFundableAsset[], + limit = 14 +): TTenderlyFundableAsset[] { + const seenKeys = new Set() + const dedupedAssets = assets.toSorted(compareTenderlyFundableAssets).filter((asset) => { + const dedupeKey = + asset.assetKind === 'native' + ? `native:${asset.chainId}` + : `${asset.tokenType}:${String(asset.symbol || '').toUpperCase()}` + + if (seenKeys.has(dedupeKey)) { + return false + } + + seenKeys.add(dedupeKey) + return true + }) + + return dedupedAssets.slice(0, limit) +} diff --git a/src/components/shared/utils/wagmi/actions.ts b/src/components/shared/utils/wagmi/actions.ts index 3d5ff4f04..ab9a9890d 100755 --- a/src/components/shared/utils/wagmi/actions.ts +++ b/src/components/shared/utils/wagmi/actions.ts @@ -15,20 +15,14 @@ import { handleTx, retrieveConfig, toWagmiProvider } from '@shared/utils/wagmi' import { erc20Abi } from 'viem' import type { Connector } from 'wagmi' import { readContract } from 'wagmi/actions' +import { resolveExecutionChainId } from '@/config/tenderly' -interface WindowWithCustomEthereum extends Window { - ethereum?: { - useForknetForMainnet?: boolean +function getExecutionChainID(chainID: number): number { + const executionChainId = resolveExecutionChainId(chainID) + if (executionChainId === undefined) { + throw new Error(`Chain ${chainID} is not enabled for execution`) } -} - -function getChainID(chainID: number): number { - if (typeof window !== 'undefined' && (window as WindowWithCustomEthereum)?.ethereum?.useForknetForMainnet) { - if (chainID === 1) { - return 1337 - } - } - return chainID + return executionChainId } //Because USDT do not return a boolean on approve, we need to use this ABI @@ -64,7 +58,7 @@ export async function isApprovedERC20( const result = await readContract(retrieveConfig(), { ...wagmiProvider, abi: erc20Abi, - chainId: getChainID(chainID), + chainId: getExecutionChainID(chainID), address: tokenAddress, functionName: 'allowance', args: [wagmiProvider.address, spender] @@ -86,7 +80,7 @@ export async function allowanceOf(props: TAllowanceOf): Promise { const wagmiProvider = await toWagmiProvider(props.connector) const result = await readContract(retrieveConfig(), { ...wagmiProvider, - chainId: getChainID(props.chainID), + chainId: getExecutionChainID(props.chainID), abi: erc20Abi, address: props.tokenAddress, functionName: 'allowance', @@ -198,14 +192,6 @@ export async function depositETH(props: TDepositEth): Promise { value: props.amount }) } - case 1337: { - return await handleTx(props, { - address: getEthZapperContract(1), - abi: ZAP_ETH_TO_YVETH_ABI, - functionName: 'deposit', - value: props.amount - }) - } default: { throw new Error('Invalid chainId') } @@ -277,14 +263,6 @@ export async function withdrawETH(props: TWithdrawEth): Promise { args: [props.amount] }) } - case 1337: { - return await handleTx(props, { - address: getEthZapperContract(1), - abi: ZAP_ETH_TO_YVETH_ABI, - functionName: 'withdraw', - args: [props.amount] - }) - } default: { throw new Error('Invalid chainId') } diff --git a/src/components/shared/utils/wagmi/networks.ts b/src/components/shared/utils/wagmi/networks.ts index 369ad2386..b07b25082 100644 --- a/src/components/shared/utils/wagmi/networks.ts +++ b/src/components/shared/utils/wagmi/networks.ts @@ -26,30 +26,3 @@ export const localhost = { } } } as const satisfies Chain - -export const anotherLocalhost = { - id: 5402, - name: 'Localhost', - nativeCurrency: { - decimals: 18, - name: 'Ether', - symbol: 'ETH' - }, - rpcUrls: { - default: { http: ['http://localhost:8555', 'http://0.0.0.0:8555'] }, - public: { http: ['http://localhost:8555', 'http://0.0.0.0:8555'] } - }, - contracts: { - ensRegistry: { - address: '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e' - }, - ensUniversalResolver: { - address: '0xE4Acdd618deED4e6d2f03b9bf62dc6118FC9A4da', - blockCreated: 16773775 - }, - multicall3: { - address: '0xca11bde05977b3631167028862be2a173976ca11', - blockCreated: 14353601 - } - } -} as const satisfies Chain diff --git a/src/components/shared/utils/wagmi/provider.ts b/src/components/shared/utils/wagmi/provider.ts index c6786a831..1c89c77fc 100644 --- a/src/components/shared/utils/wagmi/provider.ts +++ b/src/components/shared/utils/wagmi/provider.ts @@ -10,6 +10,7 @@ import { waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { resolveExecutionChainId } from '@/config/tenderly' import type { TAddress } from '../../types/address' import { assert, assertAddress } from '../assert' import { toBigInt } from '../format' @@ -18,12 +19,6 @@ import { retrieveConfig } from './config' import type { TTxResponse } from './transaction' import { defaultTxStatus } from './transaction' -interface WindowWithCustomEthereum extends Window { - ethereum?: { - useForknetForMainnet?: boolean - } -} - export type TWagmiProviderContract = { walletClient: Client chainId: number @@ -73,21 +68,21 @@ export async function handleTx(args: TWriteTransaction, props: TPrepareWriteCont const config = retrieveConfig() args.statusHandler?.({ ...defaultTxStatus, pending: true }) - let wagmiProvider = await toWagmiProvider(args.connector) - - // Use debug mode - if ((window as WindowWithCustomEthereum).ethereum?.useForknetForMainnet) { - if (args.chainID === 1) { - args.chainID = 1337 + const targetChainId = resolveExecutionChainId(args.chainID) + if (targetChainId === undefined) { + return { + isSuccessful: false, + error: new Error(`Chain ${args.chainID} is not enabled for execution`) } } + let wagmiProvider = await toWagmiProvider(args.connector) /******************************************************************************************* ** First, make sure we are using the correct chainID. ******************************************************************************************/ - if (wagmiProvider.chainId !== args.chainID) { + if (wagmiProvider.chainId !== targetChainId) { try { - await switchChain(config, { chainId: args.chainID }) + await switchChain(config, { chainId: targetChainId }) } catch (error) { if (!(error instanceof BaseError)) { return { isSuccessful: false, error } @@ -105,18 +100,19 @@ export async function handleTx(args: TWriteTransaction, props: TPrepareWriteCont wagmiProvider = await toWagmiProvider(args.connector) assertAddress(props.address, 'contractAddress') assertAddress(wagmiProvider.address, 'userAddress') - assert(wagmiProvider.chainId === args.chainID, 'ChainID mismatch') + assert(wagmiProvider.chainId === targetChainId, 'ChainID mismatch') try { const simulateContractConfig = await simulateContract(config, { ...wagmiProvider, ...(props as SimulateContractParameters), + chainId: targetChainId, address: props.address, value: toBigInt(props.value) }) const hash = await writeContract(config, simulateContractConfig.request) args.txHashHandler?.(hash) const receipt = await waitForTransactionReceipt(config, { - chainId: wagmiProvider.chainId, + chainId: targetChainId, hash, confirmations: props.confirmation || 2 }) diff --git a/src/components/shared/utils/wagmi/utils.ts b/src/components/shared/utils/wagmi/utils.ts index 24fdfded1..3c92f2d16 100644 --- a/src/components/shared/utils/wagmi/utils.ts +++ b/src/components/shared/utils/wagmi/utils.ts @@ -1,10 +1,17 @@ import type { Chain, PublicClient } from 'viem' import { createPublicClient, defineChain, http } from 'viem' import * as wagmiChains from 'viem/chains' +import { katana } from '@/config/chainDefinitions' +import { + resolveExecutionChainId, + resolveTenderlyExplorerUriForExecutionChainId, + resolveTenderlyRpcUriForExecutionChainId, + supportedChainLookup +} from '@/config/tenderly' import type { TAddress } from '../../types/address' import type { TDict, TNDict } from '../../types/mixed' import { retrieveConfig } from './config' -import { anotherLocalhost, localhost } from './networks' +import { localhost } from './networks' export type TChainContract = { address: TAddress @@ -39,32 +46,6 @@ const rari = defineChain({ } }) -export const katana = defineChain({ - id: 747474, - name: 'Katana', - nativeCurrency: { - decimals: 18, - name: 'Ether', - symbol: 'ETH' - }, - rpcUrls: { - default: { http: ['https://rpc.katanarpc.com'] } - }, - blockExplorers: { - default: { - name: 'Katana Explorer', - url: 'https://katanascan.com' - } - }, - contracts: { - multicall3: { - address: '0xcA11bde05977b3631167028862bE2a173976CA11', - blockCreated: 1898013 - } - }, - testnet: false -}) - /*************************************************************************************************** ** Extended Chain type is used to add additional properties to the basic wagmi Chain type. ** Ee need to add: @@ -88,6 +69,14 @@ const isChain = (chain: wagmiChains.Chain | unknown): chain is wagmiChains.Chain } export function getRpcUriFor(chainId: number | string): string { + const normalizedChainId = Number(chainId) + if (Number.isInteger(normalizedChainId)) { + const tenderlyRpc = resolveTenderlyRpcUriForExecutionChainId(normalizedChainId) + if (tenderlyRpc) { + return tenderlyRpc + } + } + const key = `VITE_RPC_URI_FOR_${chainId}` const value = import.meta.env[key] if (typeof value !== 'string') { @@ -138,46 +127,73 @@ function getInfuraBaseURL(chainID: number): string { return '' } -function initIndexedWagmiChains(): TNDict { - const _indexedWagmiChains: TNDict = {} - for (const chain of Object.values({ ...wagmiChains, rari, katana })) { - if (isChain(chain)) { - const baseChain = chain as unknown as TExtendedChain - const extendedChain = - baseChain.id === 1337 - ? (localhost as unknown as TExtendedChain) - : baseChain.id === 5402 - ? (anotherLocalhost as unknown as TExtendedChain) - : baseChain - - extendedChain.contracts = { - ...extendedChain.contracts - } +function toExtendedChain(chain: Chain): TExtendedChain { + const baseChain = (chain.id === localhost.id ? localhost : chain) as Chain - const newRPC = getRpcUriFor(extendedChain.id) - const oldRPC = - import.meta.env.VITE_JSON_RPC_URI?.[extendedChain.id] || import.meta.env.VITE_JSON_RPC_URL?.[extendedChain.id] - if (!newRPC && oldRPC) { - console.debug( - `VITE_JSON_RPC_URI[${extendedChain.id}] is deprecated. Please use VITE_RPC_URI_FOR_${extendedChain.id}` - ) - } - const defaultJsonRPCURL = extendedChain?.rpcUrls?.public?.http?.[0] - - extendedChain.defaultRPC = newRPC || oldRPC || defaultJsonRPCURL || '' - extendedChain.rpcUrls.alchemy = { http: [getAlchemyBaseURL(extendedChain.id)] } - extendedChain.rpcUrls.infura = { http: [getInfuraBaseURL(extendedChain.id)] } - - const http = [extendedChain.defaultRPC, ...extendedChain.rpcUrls.default.http].filter(Boolean) - extendedChain.rpcUrls.default.http = http - extendedChain.defaultBlockExplorer = - extendedChain.blockExplorers?.etherscan?.url || - extendedChain.blockExplorers?.default.url || - 'https://etherscan.io' - _indexedWagmiChains[extendedChain.id] = extendedChain + const extendedChain = { + ...(baseChain as TExtendedChain), + rpcUrls: { + ...baseChain.rpcUrls, + default: { + ...baseChain.rpcUrls.default, + http: [...(baseChain.rpcUrls.default?.http || [])] + }, + public: baseChain.rpcUrls.public + ? { + ...baseChain.rpcUrls.public, + http: [...(baseChain.rpcUrls.public.http || [])] + } + : undefined + }, + blockExplorers: baseChain.blockExplorers + ? { + ...baseChain.blockExplorers, + default: { + ...baseChain.blockExplorers.default + } + } + : undefined, + contracts: { + ...((baseChain as TExtendedChain).contracts || {}) } + } as TExtendedChain + + const newRPC = getRpcUriFor(extendedChain.id) + const oldRPC = + import.meta.env.VITE_JSON_RPC_URI?.[extendedChain.id] || import.meta.env.VITE_JSON_RPC_URL?.[extendedChain.id] + if (!newRPC && oldRPC) { + console.debug( + `VITE_JSON_RPC_URI[${extendedChain.id}] is deprecated. Please use VITE_RPC_URI_FOR_${extendedChain.id}` + ) } - return _indexedWagmiChains + + const defaultJsonRPCURL = extendedChain.rpcUrls.public?.http?.[0] || extendedChain.rpcUrls.default?.http?.[0] + extendedChain.defaultRPC = newRPC || oldRPC || defaultJsonRPCURL || '' + extendedChain.rpcUrls.alchemy = { http: [getAlchemyBaseURL(extendedChain.id)] } + extendedChain.rpcUrls.infura = { http: [getInfuraBaseURL(extendedChain.id)] } + extendedChain.rpcUrls.default.http = [ + ...new Set([extendedChain.defaultRPC, ...extendedChain.rpcUrls.default.http].filter(Boolean)) + ] + const tenderlyExecutionExplorer = resolveTenderlyExplorerUriForExecutionChainId(extendedChain.id) + const isTenderlyExecutionChain = Boolean(resolveTenderlyRpcUriForExecutionChainId(extendedChain.id)) + extendedChain.defaultBlockExplorer = + tenderlyExecutionExplorer || + extendedChain.blockExplorers?.etherscan?.url || + extendedChain.blockExplorers?.default.url || + (isTenderlyExecutionChain ? '' : 'https://etherscan.io') + + return extendedChain +} + +function initIndexedWagmiChains(): TNDict { + const indexedChains: TNDict = {} + const baseChains = Object.values({ ...wagmiChains, rari, katana, localhost }).filter(isChain) + + for (const chain of [...baseChains, ...supportedChainLookup]) { + indexedChains[chain.id] = toExtendedChain(chain) + } + + return indexedChains } export const indexedWagmiChains: TNDict = initIndexedWagmiChains() @@ -208,21 +224,24 @@ export function getNetwork(chainID: number): TExtendedChain { } export function getClient(chainID: number): PublicClient { - if (!indexedWagmiChains[chainID]) { + const executionChainId = resolveExecutionChainId(chainID) + if (executionChainId === undefined || !indexedWagmiChains[executionChainId]) { throw new Error(`Chain ${chainID} is not supported`) } - const chainConfig = indexedWagmiChains?.[chainID] || retrieveConfig().chains.find((chain) => chain.id === chainID) + const chainConfig = + indexedWagmiChains[executionChainId] || retrieveConfig().chains.find((chain) => chain.id === executionChainId) - const newRPC = getRpcUriFor(chainID) - const oldRPC = import.meta.env.VITE_JSON_RPC_URI?.[chainID] || import.meta.env.VITE_JSON_RPC_URL?.[chainID] + const newRPC = getRpcUriFor(executionChainId) + const oldRPC = + import.meta.env.VITE_JSON_RPC_URI?.[executionChainId] || import.meta.env.VITE_JSON_RPC_URL?.[executionChainId] const url = newRPC || oldRPC || - chainConfig.rpcUrls.default.http[0] || - chainConfig.rpcUrls.alchemy.http[0] || - chainConfig.rpcUrls.infura.http[0] || - indexedWagmiChains?.[chainID]?.rpcUrls?.public?.http?.[0] || + chainConfig?.rpcUrls.default.http[0] || + chainConfig?.rpcUrls.alchemy.http[0] || + chainConfig?.rpcUrls.infura.http[0] || + indexedWagmiChains[executionChainId]?.rpcUrls?.public?.http?.[0] || '' try { @@ -232,11 +251,11 @@ export function getClient(chainID: number): PublicClient { const headers = { Authorization: `Basic ${btoa(urlAsNodeURL.username + ':' + urlAsNodeURL.password)}` } const cleanUrl = urlAsNodeURL.href.replace(`${urlAsNodeURL.username}:${urlAsNodeURL.password}@`, '') return createPublicClient({ - chain: indexedWagmiChains[chainID], + chain: indexedWagmiChains[executionChainId], transport: http(cleanUrl, { fetchOptions: { headers } }) }) } - return createPublicClient({ chain: indexedWagmiChains[chainID], transport: http(url) }) + return createPublicClient({ chain: indexedWagmiChains[executionChainId], transport: http(url) }) } catch { throw new Error(`We couldn't get a valid RPC URL for chain ${chainID}`) } diff --git a/src/config/chainDefinitions.ts b/src/config/chainDefinitions.ts new file mode 100644 index 000000000..41759dd6d --- /dev/null +++ b/src/config/chainDefinitions.ts @@ -0,0 +1,32 @@ +import { defineChain } from 'viem' +import { arbitrum, base, fantom, mainnet, optimism, polygon, sonic } from 'viem/chains' + +export const katana = defineChain({ + id: 747474, + name: 'Katana', + nativeCurrency: { + decimals: 18, + name: 'Ether', + symbol: 'ETH' + }, + rpcUrls: { + default: { http: ['https://rpc.katanarpc.com'] } + }, + blockExplorers: { + default: { + name: 'Katana Explorer', + url: 'https://katanascan.com' + } + }, + contracts: { + multicall3: { + address: '0xcA11bde05977b3631167028862bE2a173976CA11', + blockCreated: 1898013 + } + }, + testnet: false +}) + +export const canonicalChains = [mainnet, optimism, polygon, fantom, base, arbitrum, sonic, katana] as const + +export type TCanonicalChainId = (typeof canonicalChains)[number]['id'] diff --git a/src/config/supportedChains.ts b/src/config/supportedChains.ts index beb026a3c..6ba3bf913 100644 --- a/src/config/supportedChains.ts +++ b/src/config/supportedChains.ts @@ -1,5 +1,10 @@ -import { katana } from '@shared/utils/wagmi' -import { arbitrum, base, fantom, mainnet, optimism, polygon, sonic } from 'viem/chains' +import type { Chain } from 'viem' +import type { TCanonicalChainId } from './chainDefinitions' +import { supportedCanonicalChains, supportedExecutionChains } from './tenderly' -export const supportedChains = [mainnet, optimism, polygon, fantom, base, arbitrum, sonic, katana] as const -export type TSupportedChainId = (typeof supportedChains)[number]['id'] +export const supportedChains = supportedCanonicalChains +export const supportedAppChains = supportedCanonicalChains +export const supportedWalletChains = supportedExecutionChains + +export type TSupportedChainId = TCanonicalChainId +export type TSupportedChain = Chain diff --git a/src/config/tenderly.ts b/src/config/tenderly.ts new file mode 100644 index 000000000..3131ba502 --- /dev/null +++ b/src/config/tenderly.ts @@ -0,0 +1,403 @@ +import type { Chain } from 'viem' +import { canonicalChains, type TCanonicalChainId } from './chainDefinitions' + +type TEnvValue = boolean | string | undefined + +type TTenderlyEnv = Record + +export type TTenderlyChainConfig = { + canonicalChainId: TCanonicalChainId + executionChainId: number + rpcUri: string + explorerUri?: string +} + +export type TTenderlyRuntime = { + isEnabled: boolean + configuredByCanonicalId: Readonly> + configuredCanonicalChainIds: readonly TCanonicalChainId[] + canonicalToExecutionChainId: ReadonlyMap + executionToCanonicalChainId: ReadonlyMap +} + +// Legacy localhost fork support is intentionally limited to 1337. +// The older 5402 alias is retired unless we explicitly revive that workflow. +const LOCAL_EXECUTION_CHAIN_ALIASES = new Map([[1337, 1]]) + +function readEnvString(value: TEnvValue): string { + if (typeof value === 'string') { + return value.trim() + } + + if (typeof value === 'boolean') { + return value ? 'true' : 'false' + } + + return '' +} + +function isTruthyEnvValue(value: TEnvValue): boolean { + return ['1', 'on', 'true', 'yes'].includes(readEnvString(value).toLowerCase()) +} + +function withTenderlyOverrides(chain: Chain, config?: TTenderlyChainConfig): Chain { + if (!config) { + return chain + } + + const defaultHttp = [config.rpcUri, ...(chain.rpcUrls.default?.http || [])].filter(Boolean) + const publicHttp = [config.rpcUri, ...(chain.rpcUrls.public?.http || chain.rpcUrls.default?.http || [])].filter( + Boolean + ) + + return { + ...chain, + rpcUrls: { + ...chain.rpcUrls, + default: { + ...chain.rpcUrls.default, + http: [...new Set(defaultHttp)] + }, + public: { + ...chain.rpcUrls.public, + http: [...new Set(publicHttp)] + } + }, + blockExplorers: + config.explorerUri && chain.blockExplorers + ? { + ...chain.blockExplorers, + default: { + ...chain.blockExplorers.default, + url: config.explorerUri + } + } + : chain.blockExplorers + } +} + +function buildExecutionChain(chain: Chain, config: TTenderlyChainConfig): Chain { + return { + ...withTenderlyOverrides(chain, config), + id: config.executionChainId, + name: `${chain.name} Tenderly`, + blockExplorers: + config.explorerUri && chain.blockExplorers + ? { + ...chain.blockExplorers, + default: { + name: `${chain.name} Tenderly Explorer`, + url: config.explorerUri + } + } + : undefined, + testnet: true + } +} + +export function parseTenderlyRuntime(env: TTenderlyEnv): TTenderlyRuntime { + const isEnabled = isTruthyEnvValue(env.VITE_TENDERLY_MODE) + + if (!isEnabled) { + return { + isEnabled: false, + configuredByCanonicalId: {}, + configuredCanonicalChainIds: [], + canonicalToExecutionChainId: new Map(), + executionToCanonicalChainId: new Map() + } + } + + const configuredChains = canonicalChains.reduce((accumulator, chain) => { + const rawExecutionChainId = readEnvString(env[`VITE_TENDERLY_CHAIN_ID_FOR_${chain.id}`]) + const rawRpcUri = readEnvString(env[`VITE_TENDERLY_RPC_URI_FOR_${chain.id}`]) + const rawExplorerUri = readEnvString(env[`VITE_TENDERLY_EXPLORER_URI_FOR_${chain.id}`]) + const hasAnyConfig = Boolean(rawExecutionChainId || rawRpcUri || rawExplorerUri) + + if (!hasAnyConfig) { + return accumulator + } + + if (!rawExecutionChainId || !rawRpcUri) { + throw new Error( + `Tenderly chain ${chain.id} requires both VITE_TENDERLY_CHAIN_ID_FOR_${chain.id} and VITE_TENDERLY_RPC_URI_FOR_${chain.id}` + ) + } + + const executionChainId = Number(rawExecutionChainId) + if (!Number.isInteger(executionChainId) || executionChainId <= 0) { + throw new Error(`Invalid Tenderly execution chain ID for canonical chain ${chain.id}: ${rawExecutionChainId}`) + } + + accumulator.push({ + canonicalChainId: chain.id, + executionChainId, + rpcUri: rawRpcUri, + explorerUri: rawExplorerUri || undefined + }) + + return accumulator + }, []) + + if (configuredChains.length === 0) { + throw new Error('Tenderly mode is enabled but no Tenderly execution chains are configured') + } + + const executionToCanonicalChainId = configuredChains.reduce>((accumulator, chain) => { + const existingCanonicalChainId = accumulator.get(chain.executionChainId) + if (existingCanonicalChainId !== undefined) { + throw new Error( + `Duplicate Tenderly execution chain ID ${chain.executionChainId} configured for canonical chains ${existingCanonicalChainId} and ${chain.canonicalChainId}` + ) + } + + accumulator.set(chain.executionChainId, chain.canonicalChainId) + return accumulator + }, new Map()) + + const configuredByCanonicalId = configuredChains.reduce>( + (accumulator, chain) => { + accumulator[chain.canonicalChainId] = chain + return accumulator + }, + {} + ) + + return { + isEnabled: true, + configuredByCanonicalId, + configuredCanonicalChainIds: configuredChains.map((chain) => chain.canonicalChainId), + canonicalToExecutionChainId: new Map( + configuredChains.map((chain) => [chain.canonicalChainId, chain.executionChainId]) + ), + executionToCanonicalChainId + } +} + +export const tenderlyRuntime = parseTenderlyRuntime(import.meta.env) + +export function getSupportedCanonicalChainsForRuntime(runtime: TTenderlyRuntime): readonly Chain[] { + return runtime.isEnabled + ? canonicalChains + .filter((chain) => chain.id in runtime.configuredByCanonicalId) + .map((chain) => withTenderlyOverrides(chain, runtime.configuredByCanonicalId[chain.id])) + : canonicalChains +} + +export function getSupportedExecutionChainsForRuntime( + runtime: TTenderlyRuntime, + canonicalChainSet: readonly Chain[] = getSupportedCanonicalChainsForRuntime(runtime) +): readonly Chain[] { + return runtime.isEnabled + ? canonicalChainSet.map((chain) => + buildExecutionChain(chain, runtime.configuredByCanonicalId[chain.id] as TTenderlyChainConfig) + ) + : canonicalChainSet +} + +export function getSupportedChainLookupForRuntime( + runtime: TTenderlyRuntime, + canonicalChainSet: readonly Chain[] = getSupportedCanonicalChainsForRuntime(runtime), + executionChainSet: readonly Chain[] = getSupportedExecutionChainsForRuntime(runtime, canonicalChainSet) +): readonly Chain[] { + return [ + ...canonicalChainSet, + ...executionChainSet.filter((chain) => !canonicalChainSet.some((canonicalChain) => canonicalChain.id === chain.id)) + ] +} + +export const supportedCanonicalChains: readonly Chain[] = getSupportedCanonicalChainsForRuntime(tenderlyRuntime) + +export const supportedExecutionChains: readonly Chain[] = getSupportedExecutionChainsForRuntime( + tenderlyRuntime, + supportedCanonicalChains +) + +export const supportedChainLookup: readonly Chain[] = getSupportedChainLookupForRuntime( + tenderlyRuntime, + supportedCanonicalChains, + supportedExecutionChains +) + +export function isTenderlyModeEnabled(): boolean { + return tenderlyRuntime.isEnabled +} + +export function getTenderlyBackedCanonicalChainIds(): readonly TCanonicalChainId[] { + return tenderlyRuntime.configuredCanonicalChainIds +} + +export function isCanonicalChainEnabled(chainId: number): chainId is TCanonicalChainId { + return supportedCanonicalChains.some((chain) => chain.id === chainId) +} + +export function getCanonicalChain(chainId: number): Chain | undefined { + return supportedCanonicalChains.find((chain) => chain.id === chainId) +} + +function isCanonicalChainEnabledForRuntime(runtime: TTenderlyRuntime, chainId: number): chainId is TCanonicalChainId { + return getSupportedCanonicalChainsForRuntime(runtime).some((chain) => chain.id === chainId) +} + +function resolveLocalExecutionCanonicalAliasForRuntime( + runtime: TTenderlyRuntime, + chainId: number +): TCanonicalChainId | undefined { + const canonicalChainId = LOCAL_EXECUTION_CHAIN_ALIASES.get(chainId) + if (canonicalChainId === undefined) { + return undefined + } + + return isCanonicalChainEnabledForRuntime(runtime, canonicalChainId) ? canonicalChainId : undefined +} + +export function resolveCanonicalChainIdForRuntime( + runtime: TTenderlyRuntime, + chainId: number | undefined +): TCanonicalChainId | undefined { + if (!Number.isInteger(chainId)) { + return undefined + } + + const executionChainId = runtime.executionToCanonicalChainId.get(chainId as number) + if (executionChainId !== undefined) { + return executionChainId + } + + const localExecutionAlias = resolveLocalExecutionCanonicalAliasForRuntime(runtime, chainId as number) + if (localExecutionAlias !== undefined) { + return localExecutionAlias + } + + if (isCanonicalChainEnabledForRuntime(runtime, chainId as number)) { + return chainId as TCanonicalChainId + } + + return undefined +} + +export function resolveCanonicalChainId(chainId: number | undefined): TCanonicalChainId | undefined { + return resolveCanonicalChainIdForRuntime(tenderlyRuntime, chainId) +} + +export function resolveConnectedCanonicalChainIdForRuntime( + runtime: TTenderlyRuntime, + chainId: number | undefined +): TCanonicalChainId | undefined { + if (!Number.isInteger(chainId)) { + return undefined + } + + const localExecutionAlias = resolveLocalExecutionCanonicalAliasForRuntime(runtime, chainId as number) + if (localExecutionAlias !== undefined) { + return localExecutionAlias + } + + if (runtime.isEnabled) { + const canonicalChainId = runtime.executionToCanonicalChainId.get(chainId as number) + if (canonicalChainId !== undefined) { + return canonicalChainId + } + + return isCanonicalChainEnabledForRuntime(runtime, chainId as number) ? (chainId as TCanonicalChainId) : undefined + } + + return isCanonicalChainEnabledForRuntime(runtime, chainId as number) ? (chainId as TCanonicalChainId) : undefined +} + +export function resolveConnectedCanonicalChainId(chainId: number | undefined): TCanonicalChainId | undefined { + return resolveConnectedCanonicalChainIdForRuntime(tenderlyRuntime, chainId) +} + +export function resolveExecutionChainIdForRuntime( + runtime: TTenderlyRuntime, + chainId: number | undefined +): number | undefined { + if (!Number.isInteger(chainId)) { + return undefined + } + + const normalizedChainId = chainId as number + + if (resolveLocalExecutionCanonicalAliasForRuntime(runtime, normalizedChainId) !== undefined) { + return normalizedChainId + } + + if (runtime.executionToCanonicalChainId.has(normalizedChainId)) { + return normalizedChainId + } + + if (runtime.isEnabled) { + return runtime.canonicalToExecutionChainId.get(normalizedChainId) + } + + return isCanonicalChainEnabledForRuntime(runtime, normalizedChainId) ? normalizedChainId : undefined +} + +export function resolveExecutionChainId(chainId: number | undefined): number | undefined { + return resolveExecutionChainIdForRuntime(tenderlyRuntime, chainId) +} + +export function isConnectedToExecutionChainForRuntime( + runtime: TTenderlyRuntime, + connectedChainId: number | undefined, + targetChainId: number | undefined +): boolean { + if (!Number.isInteger(connectedChainId)) { + return false + } + + const requiredExecutionChainId = resolveExecutionChainIdForRuntime(runtime, targetChainId) + if (requiredExecutionChainId === undefined) { + return false + } + + return connectedChainId === requiredExecutionChainId +} + +export function isConnectedToExecutionChain( + connectedChainId: number | undefined, + targetChainId: number | undefined +): boolean { + return isConnectedToExecutionChainForRuntime(tenderlyRuntime, connectedChainId, targetChainId) +} + +export function resolveTenderlyRpcUriForExecutionChainIdForRuntime( + runtime: TTenderlyRuntime, + chainId: number | undefined +): string | undefined { + if (!Number.isInteger(chainId)) { + return undefined + } + + const canonicalChainId = runtime.executionToCanonicalChainId.get(chainId as number) + if (canonicalChainId === undefined) { + return undefined + } + + return runtime.configuredByCanonicalId[canonicalChainId]?.rpcUri +} + +export function resolveTenderlyRpcUriForExecutionChainId(chainId: number | undefined): string | undefined { + return resolveTenderlyRpcUriForExecutionChainIdForRuntime(tenderlyRuntime, chainId) +} + +export function resolveTenderlyExplorerUriForExecutionChainIdForRuntime( + runtime: TTenderlyRuntime, + chainId: number | undefined +): string | undefined { + if (!Number.isInteger(chainId)) { + return undefined + } + + const canonicalChainId = runtime.executionToCanonicalChainId.get(chainId as number) + if (canonicalChainId === undefined) { + return undefined + } + + return runtime.configuredByCanonicalId[canonicalChainId]?.explorerUri +} + +export function resolveTenderlyExplorerUriForExecutionChainId(chainId: number | undefined): string | undefined { + return resolveTenderlyExplorerUriForExecutionChainIdForRuntime(tenderlyRuntime, chainId) +} diff --git a/src/config/wagmi.ts b/src/config/wagmi.ts index 4ab41a3a8..902c6c820 100644 --- a/src/config/wagmi.ts +++ b/src/config/wagmi.ts @@ -1,4 +1,4 @@ -import { getNetwork, getRpcUriFor, registerConfig } from '@shared/utils/wagmi' +import { registerConfig } from '@shared/utils/wagmi' import { connectorsForWallets } from '@rainbow-me/rainbowkit' import { frameWallet, @@ -9,9 +9,10 @@ import { safeWallet, walletConnectWallet } from '@rainbow-me/rainbowkit/wallets' -import type { Transport } from 'viem' -import { cookieStorage, createConfig, createStorage, fallback, http } from 'wagmi' -import { supportedChains, type TSupportedChainId } from './supportedChains' +import { cookieStorage, createConfig, createStorage } from 'wagmi' +import { supportedAppChains, supportedWalletChains } from './supportedChains' +import { getWagmiConfigChains } from './wagmiChains' +import { buildTransports } from './wagmiTransports' const projectId = import.meta.env.VITE_WALLETCONNECT_PROJECT_ID as string const appName = (import.meta.env.VITE_WALLETCONNECT_PROJECT_NAME as string) || 'Yearn Finance' @@ -34,39 +35,12 @@ const connectors = connectorsForWallets( { projectId, appName } ) -function buildTransports(): Record { - const transports = {} as Record - - for (const chain of supportedChains) { - const network = getNetwork(chain.id) - const availableTransports: Transport[] = [] - - if (network?.defaultRPC) { - availableTransports.push(http(network.defaultRPC, { batch: true })) - } - - const envRPC = getRpcUriFor(chain.id) - if (envRPC) { - availableTransports.push(http(envRPC, { batch: true })) - } - - const publicRPC = chain.rpcUrls?.default?.http?.[0] - if (publicRPC && !availableTransports.length) { - availableTransports.push(http(publicRPC, { batch: true })) - } - - availableTransports.push(http()) - - transports[chain.id as TSupportedChainId] = fallback(availableTransports) - } - - return transports -} +const wagmiChains = getWagmiConfigChains(supportedWalletChains, supportedAppChains) export const wagmiConfig = createConfig({ - chains: supportedChains, + chains: wagmiChains, connectors, - transports: buildTransports(), + transports: buildTransports(wagmiChains), storage: createStorage({ storage: cookieStorage }), ssr: true }) diff --git a/src/config/wagmiChains.ts b/src/config/wagmiChains.ts new file mode 100644 index 000000000..6baf89d86 --- /dev/null +++ b/src/config/wagmiChains.ts @@ -0,0 +1,19 @@ +import type { Chain } from 'viem' +import { mainnet } from 'viem/chains' + +function normalizeCanonicalChain(chain: Chain): Chain { + return chain.id === mainnet.id ? mainnet : chain +} + +export function getWagmiConfigChains( + walletChains: readonly Chain[], + canonicalChains: readonly Chain[] = [] +): [Chain, ...Chain[]] { + const uniqueChains = Array.from( + new Map( + [...walletChains, ...canonicalChains.map(normalizeCanonicalChain)].map((chain) => [chain.id, chain]) + ).values() + ) + + return uniqueChains as [Chain, ...Chain[]] +} diff --git a/src/config/wagmiTransports.ts b/src/config/wagmiTransports.ts new file mode 100644 index 000000000..54d519b19 --- /dev/null +++ b/src/config/wagmiTransports.ts @@ -0,0 +1,41 @@ +import { getNetwork, getRpcUriFor } from '@shared/utils/wagmi' +import type { Chain, Transport } from 'viem' +import { mainnet } from 'viem/chains' +import { fallback, http } from 'wagmi' + +function getLegacyRpcUri(chainId: number): string { + const value = import.meta.env.VITE_JSON_RPC_URI?.[chainId] || import.meta.env.VITE_JSON_RPC_URL?.[chainId] + return typeof value === 'string' ? value.trim() : '' +} + +function isCanonicalMainnetChain(chain: Chain): boolean { + return chain.id === mainnet.id && chain.rpcUrls.default.http[0] === mainnet.rpcUrls.default.http[0] +} + +export function getTransportRpcUrlsForChain(chain: Chain): string[] { + const indexedNetwork = isCanonicalMainnetChain(chain) ? undefined : getNetwork(chain.id) + + return [ + ...new Set( + [ + indexedNetwork?.defaultRPC, + getRpcUriFor(chain.id), + getLegacyRpcUri(chain.id), + chain.rpcUrls.default.http[0], + chain.rpcUrls.public?.http?.[0] + ].filter((value): value is string => Boolean(value)) + ) + ] +} + +export function buildTransports(chains: readonly Chain[]): Record { + const transports: Record = {} + + for (const chain of chains) { + const availableTransports = getTransportRpcUrlsForChain(chain).map((rpcUrl) => http(rpcUrl, { batch: true })) + availableTransports.push(http()) + transports[chain.id] = fallback(availableTransports) + } + + return transports +} diff --git a/src/context/ChainsProvider.tsx b/src/context/ChainsProvider.tsx index f84a50518..9ac208a8a 100644 --- a/src/context/ChainsProvider.tsx +++ b/src/context/ChainsProvider.tsx @@ -2,17 +2,14 @@ import type { FC, PropsWithChildren } from 'react' import { useMemo } from 'react' import { useLocation } from 'react-router' import { useAccount, useChainId, useSwitchChain } from 'wagmi' -import { supportedChains, type TSupportedChainId } from '@/config/supportedChains' +import { supportedAppChains, type TSupportedChainId } from '@/config/supportedChains' +import { getCanonicalChain, resolveConnectedCanonicalChainId, resolveExecutionChainId } from '@/config/tenderly' import { chainsContext, type TChainsContext } from '@/context/chainsContext' const DEFAULT_CHAIN_ID = 1 as TSupportedChainId -function getSupportedChain(chainId: number) { - return supportedChains.find((chain: { id: number }) => chain.id === chainId) -} - export const ChainsProvider: FC = ({ children }) => { - const chainId = useChainId() + const rawChainId = useChainId() const { pathname } = useLocation() const account = useAccount() const { switchChain } = useSwitchChain() @@ -23,21 +20,32 @@ export const ChainsProvider: FC = ({ children }) => { }, [pathname]) const contextValue = useMemo((): TChainsContext => { - const isConnectedChainValid = !!(account.chain?.id && getSupportedChain(account.chain.id)) - const chainIdIntent = (chainIdParam || chainId || DEFAULT_CHAIN_ID) as TSupportedChainId + const connectedCanonicalChainId = resolveConnectedCanonicalChainId(account.chain?.id) + const resolvedCanonicalChainId = resolveConnectedCanonicalChainId(rawChainId) ?? DEFAULT_CHAIN_ID + const chainIdIntent = ( + chainIdParam && getCanonicalChain(chainIdParam)?.id ? chainIdParam : resolvedCanonicalChainId + ) as TSupportedChainId + const executionChainId = resolveExecutionChainId(chainIdIntent) ?? resolveExecutionChainId(resolvedCanonicalChainId) return { - chains: supportedChains, - chainId: (chainId || DEFAULT_CHAIN_ID) as TSupportedChainId, + chains: supportedAppChains, + chainId: resolvedCanonicalChainId, chainIdIntent, - getChainFromId: getSupportedChain, + executionChainId: executionChainId ?? resolvedCanonicalChainId, + getChainFromId: getCanonicalChain, + getExecutionChainId: resolveExecutionChainId, switchNetwork: (newChainId: number) => { - switchChain?.({ chainId: newChainId }) + const nextExecutionChainId = resolveExecutionChainId(newChainId) + if (nextExecutionChainId === undefined) { + console.warn(`Chain ${newChainId} is not enabled for execution`) + return + } + switchChain?.({ chainId: nextExecutionChainId }) }, - isConnectedChainValid, + isConnectedChainValid: Boolean(connectedCanonicalChainId), isConnected: !!account.address } - }, [chainId, chainIdParam, account.chain?.id, account.address, switchChain]) + }, [rawChainId, chainIdParam, account.chain?.id, account.address, switchChain]) return {children} } diff --git a/src/context/chainsContext.ts b/src/context/chainsContext.ts index aa0f18a28..96cba76b4 100644 --- a/src/context/chainsContext.ts +++ b/src/context/chainsContext.ts @@ -6,7 +6,9 @@ export type TChainsContext = { chains: readonly Chain[] chainId: TSupportedChainId chainIdIntent: TSupportedChainId + executionChainId: number getChainFromId: (chainId: number) => Chain | undefined + getExecutionChainId: (chainId: number) => number | undefined switchNetwork: (chainId: number) => void isConnectedChainValid: boolean isConnected: boolean diff --git a/src/contexts/useDevFlags.tsx b/src/contexts/useDevFlags.tsx index e4609ecf8..e9e4e54b2 100644 --- a/src/contexts/useDevFlags.tsx +++ b/src/contexts/useDevFlags.tsx @@ -1,5 +1,4 @@ -import type { ReactElement, ReactNode } from 'react' -import { createContext, useContext, useEffect, useMemo, useState } from 'react' +import { createContext, type ReactElement, type ReactNode, useCallback, useContext, useMemo, useState } from 'react' export type HeaderDisplayMode = 'collapsible' | 'full' | 'minimal' | 'sticky-name' @@ -24,35 +23,37 @@ const HEADER_DISPLAY_MODE_STORAGE_KEY = 'dev-header-display-mode' const ENABLE_TOOLBAR = !import.meta.env.PROD || import.meta.env.VITE_ENABLE_DEV_TOOLBAR === 'true' || import.meta.env.MODE !== 'production' -export function DevFlagsProvider({ children }: { children: ReactNode }): ReactElement { - const [headerDisplayMode, setHeaderDisplayMode] = useState('collapsible') +const VALID_MODES: HeaderDisplayMode[] = ['collapsible', 'full', 'minimal', 'sticky-name'] - useEffect(() => { - if (!ENABLE_TOOLBAR) { - return +function readStoredDisplayMode(): HeaderDisplayMode { + if (!ENABLE_TOOLBAR) return 'collapsible' + try { + const stored = window.localStorage.getItem(HEADER_DISPLAY_MODE_STORAGE_KEY) as HeaderDisplayMode | null + if (stored && VALID_MODES.includes(stored)) { + return stored } + } catch { + // no-op + } + return 'collapsible' +} - try { - const stored = window.localStorage.getItem(HEADER_DISPLAY_MODE_STORAGE_KEY) as HeaderDisplayMode | null - if (stored && ['collapsible', 'full', 'minimal', 'sticky-name'].includes(stored)) { - setHeaderDisplayMode(stored) - } - } catch { - // no-op - } - }, []) +function persistDisplayMode(mode: HeaderDisplayMode): void { + if (!ENABLE_TOOLBAR) return + try { + window.localStorage.setItem(HEADER_DISPLAY_MODE_STORAGE_KEY, mode) + } catch { + // no-op + } +} - useEffect(() => { - if (!ENABLE_TOOLBAR) { - return - } +export function DevFlagsProvider({ children }: { children: ReactNode }): ReactElement { + const [headerDisplayMode, setHeaderDisplayModeRaw] = useState(readStoredDisplayMode) - try { - window.localStorage.setItem(HEADER_DISPLAY_MODE_STORAGE_KEY, headerDisplayMode) - } catch { - // no-op - } - }, [headerDisplayMode]) + const setHeaderDisplayMode = useCallback((mode: HeaderDisplayMode) => { + setHeaderDisplayModeRaw(mode) + persistDisplayMode(mode) + }, []) // Legacy support - map new modes to old boolean const headerCompressionEnabled = useMemo(() => headerDisplayMode === 'collapsible', [headerDisplayMode]) @@ -60,7 +61,7 @@ export function DevFlagsProvider({ children }: { children: ReactNode }): ReactEl () => (value: boolean) => { setHeaderDisplayMode(value ? 'collapsible' : 'full') }, - [] + [setHeaderDisplayMode] ) const value = useMemo( diff --git a/src/routes.tsx b/src/routes.tsx index 045207d70..8512a40c9 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -8,6 +8,7 @@ const PortfolioPage = lazy(() => import('@pages/portfolio/index')) const VaultsPage = lazy(() => import('@pages/vaults/index')) const VaultsDetailPage = lazy(() => import('@pages/vaults/[chainID]/[address]')) const IconListPage = lazy(() => import('@pages/icon-list/index')) +const FontFallbacksPage = lazy(() => import('@pages/font-fallbacks/index')) // Loading component const PageLoader = (): ReactElement => ( @@ -38,6 +39,9 @@ export function Routes(): ReactElement { {/* Icon inventory page */} } /> + {/* Font fallback debug page */} + } /> + {/* Unified Vaults routes */} } /> diff --git a/src/test/YvUsdWithdraw.helpers.test.ts b/src/test/YvUsdWithdraw.helpers.test.ts new file mode 100644 index 000000000..db1dd175d --- /dev/null +++ b/src/test/YvUsdWithdraw.helpers.test.ts @@ -0,0 +1,211 @@ +import { + getLockedCooldownMaxAssetAmount, + getLockedCooldownMaxDisplayAmount, + resolveCooldownSharesToStart, + resolveLockedRedeemAssets, + resolveLockedRequestedAmountFromInput, + resolveLockedRequestedWithdrawAssets, + resolveLockedRequestedWithdrawShares, + resolveLockedWithdrawDisplayAmount +} from '@pages/vaults/components/widget/yvUSD/YvUsdWithdraw.helpers' +import { describe, expect, it } from 'vitest' + +describe('resolveLockedWithdrawDisplayAmount', () => { + it('uses previewRedeem for the displayed max when it is available', () => { + expect( + resolveLockedWithdrawDisplayAmount({ + maxWithdrawAssets: 50_000000000000000000n, + previewRedeemAssets: 51_000000n, + unlockedPricePerShare: 1_020000n, + unlockedVaultDecimals: 18 + }) + ).toBe(51_000000n) + }) + + it('falls back to price-per-share conversion when previewRedeem is unavailable', () => { + expect( + resolveLockedWithdrawDisplayAmount({ + maxWithdrawAssets: 50_000000000000000000n, + unlockedPricePerShare: 1_020000n, + unlockedVaultDecimals: 18 + }) + ).toBe(51_000000n) + }) +}) + +describe('resolveLockedRequestedWithdrawAssets', () => { + it('snaps an exact max input back to the exact locked asset cap', () => { + expect( + resolveLockedRequestedWithdrawAssets({ + requestedDisplayAmount: 51_000000n, + maxDisplayAmount: 51_000000n, + maxWithdrawAssets: 50_000000000000000000n, + previewWithdrawShares: 49_999999999999999999n + }) + ).toBe(50_000000000000000000n) + }) + + it('uses previewWithdraw for partial requests and clamps them to the cap', () => { + expect( + resolveLockedRequestedWithdrawAssets({ + requestedDisplayAmount: 25_000000n, + maxDisplayAmount: 51_000000n, + maxWithdrawAssets: 50_000000000000000000n, + previewWithdrawShares: 60_000000000000000000n + }) + ).toBe(50_000000000000000000n) + }) + + it('keeps over-limit withdraw inputs above the cap so the widget can show an error instead of snapping to max', () => { + expect( + resolveLockedRequestedWithdrawAssets({ + requestedDisplayAmount: 52_000000n, + maxDisplayAmount: 51_000000n, + maxWithdrawAssets: 50_000000000000000000n, + previewWithdrawShares: 51_500000000000000000n + }) + ).toBe(51_500000000000000000n) + }) +}) + +describe('resolveLockedRequestedWithdrawShares', () => { + it('snaps an exact max asset request back to the exact redeemable share cap', () => { + expect( + resolveLockedRequestedWithdrawShares({ + requestedLockedAssets: 50_000000000000000000n, + maxWithdrawAssets: 50_000000000000000000n, + maxRedeemShares: 49_999999999999999999n, + previewWithdrawShares: 49_900000000000000000n, + lockedPricePerShare: 1_000000000000000000n, + lockedVaultTokenDecimals: 18 + }) + ).toBe(49_999999999999999999n) + }) + + it('uses previewWithdraw shares for partial requests and clamps them to maxRedeem', () => { + expect( + resolveLockedRequestedWithdrawShares({ + requestedLockedAssets: 25_000000000000000000n, + maxWithdrawAssets: 50_000000000000000000n, + maxRedeemShares: 49_000000000000000000n, + previewWithdrawShares: 60_000000000000000000n, + lockedPricePerShare: 1_000000000000000000n, + lockedVaultTokenDecimals: 18 + }) + ).toBe(49_000000000000000000n) + }) +}) + +describe('cooldown max selection', () => { + it('derives the exact cooldown asset cap from the locked share balance', () => { + expect( + getLockedCooldownMaxAssetAmount({ + lockedWalletShares: 50_000000000000000000n, + lockedPricePerShare: 1_000000000000000000n, + lockedVaultTokenDecimals: 18 + }) + ).toBe(50_000000000000000000n) + }) + + it('derives the displayed cooldown max amount from the exact cooldown asset cap', () => { + expect( + getLockedCooldownMaxDisplayAmount({ + maxCooldownAssetAmount: 50_000000000000000000n, + isLockedUnderlyingDisplay: true, + unlockedPricePerShare: 1_020000n, + unlockedVaultDecimals: 18 + }) + ).toBe(51_000000n) + }) + + it('snaps a pre-cooldown max input back to the exact cooldown asset cap', () => { + expect( + resolveLockedRequestedAmountFromInput({ + amount: 51_000000n, + inputUnit: 'underlying', + canWithdrawNow: false, + needsCooldownStart: true, + maxCooldownDisplayAmount: 51_000000n, + maxCooldownAssetAmount: 50_000000000000000000n, + previewWithdrawLockedAssets: 49_999999999999999999n, + lockedDisplayPricePerShare: 1_020000n, + lockedVaultTokenDecimals: 18, + unlockedPricePerShare: 1_020000n, + unlockedVaultDecimals: 18 + }) + ).toBe(50_000000000000000000n) + }) + + it('uses previewWithdraw for partial pre-cooldown underlying input when available', () => { + expect( + resolveLockedRequestedAmountFromInput({ + amount: 25_000000n, + inputUnit: 'underlying', + canWithdrawNow: false, + needsCooldownStart: true, + maxCooldownDisplayAmount: 51_000000n, + maxCooldownAssetAmount: 50_000000000000000000n, + previewWithdrawLockedAssets: 24_600000000000000000n, + lockedDisplayPricePerShare: 1_020000n, + lockedVaultTokenDecimals: 18, + unlockedPricePerShare: 1_020000n, + unlockedVaultDecimals: 18 + }) + ).toBe(24_600000000000000000n) + }) + + it('starts cooldown with the full share balance when the exact cooldown asset cap is selected', () => { + expect( + resolveCooldownSharesToStart({ + needsCooldownStart: true, + lockedRequestedAmountRaw: 50_000000000000000000n, + maxCooldownAssetAmount: 50_000000000000000000n, + previewWithdrawShares: 49_999999999999999999n, + lockedPricePerShare: 1_000000000000000000n, + lockedVaultTokenDecimals: 18, + lockedWalletShares: 50_000000000000000000n + }) + ).toBe(50_000000000000000000n) + }) + + it('uses previewWithdraw shares for partial cooldown selection when available', () => { + expect( + resolveCooldownSharesToStart({ + needsCooldownStart: true, + lockedRequestedAmountRaw: 24_600000000000000000n, + maxCooldownAssetAmount: 50_000000000000000000n, + previewWithdrawShares: 23_700000000000000000n, + lockedPricePerShare: 1_000000000000000000n, + lockedVaultTokenDecimals: 18, + lockedWalletShares: 50_000000000000000000n + }) + ).toBe(23_700000000000000000n) + }) +}) + +describe('resolveLockedRedeemAssets', () => { + it('uses previewRedeem for partial locked-leg share redemptions when it is available', () => { + expect( + resolveLockedRedeemAssets({ + requestedLockedShares: 25_000000000000000000n, + maxWithdrawAssets: 50_000000000000000000n, + maxRedeemShares: 49_999999999999999999n, + previewRedeemAssets: 24_800000000000000000n, + lockedPricePerShare: 1_000000000000000000n, + lockedVaultTokenDecimals: 18 + }) + ).toBe(24_800000000000000000n) + }) + + it('uses the authoritative maxWithdraw assets for an exact max redeem', () => { + expect( + resolveLockedRedeemAssets({ + requestedLockedShares: 49_999999999999999999n, + maxWithdrawAssets: 50_000000000000000000n, + maxRedeemShares: 49_999999999999999999n, + lockedPricePerShare: 1_000000000000000000n, + lockedVaultTokenDecimals: 18 + }) + ).toBe(50_000000000000000000n) + }) +}) diff --git a/src/test/cooldownUtils.test.ts b/src/test/cooldownUtils.test.ts new file mode 100644 index 000000000..cb33bc8b2 --- /dev/null +++ b/src/test/cooldownUtils.test.ts @@ -0,0 +1,22 @@ +import { formatDays, resolveDurationSeconds } from '@pages/vaults/components/widget/yvUSD/cooldownUtils' +import { describe, expect, it } from 'vitest' + +describe('resolveDurationSeconds', () => { + it('uses the contract-provided duration when available', () => { + expect(resolveDurationSeconds(7n * 86_400n, 5)).toBe(604800) + }) + + it('falls back to the configured number of days when contract data is unavailable', () => { + expect(resolveDurationSeconds(undefined, 5)).toBe(432000) + }) +}) + +describe('formatDays', () => { + it('formats the 5-day withdrawal fallback label correctly', () => { + expect(formatDays(resolveDurationSeconds(undefined, 5), 5)).toBe('5 days') + }) + + it('formats contract-provided withdrawal windows without forcing the fallback value', () => { + expect(formatDays(resolveDurationSeconds(7n * 86_400n, 5), 5)).toBe('7 days') + }) +}) diff --git a/src/components/shared/utils/format.test.ts b/src/test/format.test.ts similarity index 96% rename from src/components/shared/utils/format.test.ts rename to src/test/format.test.ts index b6a14cb51..d101db1d7 100644 --- a/src/components/shared/utils/format.test.ts +++ b/src/test/format.test.ts @@ -1,5 +1,5 @@ +import { formatTAmount } from '@shared/utils/format' import { afterEach, describe, expect, it } from 'vitest' -import { formatTAmount } from './format' const originalNavigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator') diff --git a/src/test/kongVaultSelectors.test.ts b/src/test/kongVaultSelectors.test.ts new file mode 100644 index 000000000..3582bd615 --- /dev/null +++ b/src/test/kongVaultSelectors.test.ts @@ -0,0 +1,45 @@ +import { getVaultAPR } from '@pages/vaults/domain/kongVaultSelectors' +import { describe, expect, it } from 'vitest' + +describe('getVaultAPR', () => { + it('uses list pricePerShare when snapshot pricePerShare is missing', () => { + const apr = getVaultAPR({ + chainId: 1, + address: '0x1111111111111111111111111111111111111111', + name: 'Vault', + symbol: 'yvTEST', + decimals: 18, + asset: { + address: '0x2222222222222222222222222222222222222222', + name: 'USDC', + symbol: 'USDC', + decimals: 6 + }, + tvl: 1000, + performance: { + oracle: { apr: 0.02, apy: 0.02 }, + estimated: { apr: 0.02, apy: 0.02, type: 'oracle', components: {} }, + historical: { net: 0.01, weeklyNet: 0.01, monthlyNet: 0.01, inceptionNet: 0.01 } + }, + fees: { + managementFee: 0, + performanceFee: 0 + }, + category: 'Stablecoin', + type: 'Standard', + kind: 'Single Strategy', + v3: true, + yearn: true, + isRetired: false, + isHidden: false, + isBoosted: false, + isHighlighted: false, + strategiesCount: 1, + riskLevel: 1, + staking: null, + pricePerShare: '1050000' + } as any) + + expect(apr.pricePerShare.today).toBeCloseTo(1.05, 8) + }) +}) diff --git a/src/test/stakingWithdrawable.test.ts b/src/test/stakingWithdrawable.test.ts new file mode 100644 index 000000000..acb3d57c7 --- /dev/null +++ b/src/test/stakingWithdrawable.test.ts @@ -0,0 +1,85 @@ +import { getStakingWithdrawableAssets } from '@pages/vaults/hooks/actions/stakingAdapter' +import { describe, expect, it } from 'vitest' + +describe('getStakingWithdrawableAssets', () => { + it('prefers maxRedeem + convertToAssets for ERC4626 wrappers', async () => { + const result = await getStakingWithdrawableAssets({ + read: async ({ + functionName + }: { + functionName: string + address: `0x${string}` + abi: readonly unknown[] + args?: readonly unknown[] + }) => { + if (functionName === 'maxRedeem') return 99n + if (functionName === 'convertToAssets') return 150n + if (functionName === 'maxWithdraw') return 123n + return 0n + }, + stakingAddress: '0x2222222222222222222222222222222222222222', + account: '0x1111111111111111111111111111111111111111', + stakingSource: 'yBOLD', + stakingShareBalance: 99n + }) + + expect(result).toBe(150n) + }) + + it('falls back to maxWithdraw when maxRedeem conversion is unavailable', async () => { + const result = await getStakingWithdrawableAssets({ + read: async ({ + functionName + }: { + functionName: string + address: `0x${string}` + abi: readonly unknown[] + args?: readonly unknown[] + }) => { + if (functionName === 'maxRedeem') throw new Error('missing') + if (functionName === 'maxWithdraw') return 123n + return 0n + }, + stakingAddress: '0x2222222222222222222222222222222222222222', + account: '0x1111111111111111111111111111111111111111', + stakingSource: 'yBOLD', + stakingShareBalance: 99n + }) + + expect(result).toBe(123n) + }) + + it('falls back to convertToAssets then raw balance when maxRedeem and maxWithdraw both fail', async () => { + const converted = await getStakingWithdrawableAssets({ + read: async ({ + functionName + }: { + functionName: string + address: `0x${string}` + abi: readonly unknown[] + args?: readonly unknown[] + }) => { + if (functionName === 'maxRedeem') throw new Error('missing') + if (functionName === 'maxWithdraw') throw new Error('missing') + if (functionName === 'convertToAssets') return 456n + return 0n + }, + stakingAddress: '0x2222222222222222222222222222222222222222', + account: '0x1111111111111111111111111111111111111111', + stakingSource: 'yBOLD', + stakingShareBalance: 99n + }) + expect(converted).toBe(456n) + + const fallback = await getStakingWithdrawableAssets({ + read: async () => { + throw new Error('missing') + }, + stakingAddress: '0x2222222222222222222222222222222222222222', + account: '0x1111111111111111111111111111111111111111', + stakingSource: 'yBOLD', + stakingShareBalance: 99n + }) + expect(fallback).toBe(99n) + }) +}) diff --git a/src/components/pages/vaults/components/detail/strategiesPercentFormat.test.ts b/src/test/strategiesPercentFormat.test.ts similarity index 88% rename from src/components/pages/vaults/components/detail/strategiesPercentFormat.test.ts rename to src/test/strategiesPercentFormat.test.ts index 896b97150..50fe387b1 100644 --- a/src/components/pages/vaults/components/detail/strategiesPercentFormat.test.ts +++ b/src/test/strategiesPercentFormat.test.ts @@ -1,7 +1,6 @@ +import { formatStrategiesApy, formatStrategiesPercent } from '@pages/vaults/components/detail/strategiesPercentFormat' import { describe, expect, it } from 'vitest' -import { formatStrategiesApy, formatStrategiesPercent } from './strategiesPercentFormat' - describe('strategiesPercentFormat', () => { it('pads percentages to the strategy card precision rules', () => { expect(formatStrategiesPercent(12.34)).toBe('12.3%') diff --git a/src/components/pages/vaults/components/widget/shared/TransactionOverlay.test.ts b/src/test/transactionOverlay.test.ts similarity index 95% rename from src/components/pages/vaults/components/widget/shared/TransactionOverlay.test.ts rename to src/test/transactionOverlay.test.ts index 066cfa890..cb611acf6 100644 --- a/src/components/pages/vaults/components/widget/shared/TransactionOverlay.test.ts +++ b/src/test/transactionOverlay.test.ts @@ -1,5 +1,8 @@ +import { + resolveCompletionDeferral, + shouldAutoContinuePermitSuccess +} from '@pages/vaults/components/widget/shared/transactionOverlay.helpers' import { describe, expect, it } from 'vitest' -import { resolveCompletionDeferral, shouldAutoContinuePermitSuccess } from './transactionOverlay.helpers' describe('shouldAutoContinuePermitSuccess', () => { it('continues permit steps once the next step is ready', () => { diff --git a/src/components/shared/utils/curveUrlUtils.test.ts b/src/test/urlUtils.test.ts similarity index 59% rename from src/components/shared/utils/curveUrlUtils.test.ts rename to src/test/urlUtils.test.ts index dddf9bc9f..e4e2f90aa 100644 --- a/src/components/shared/utils/curveUrlUtils.test.ts +++ b/src/test/urlUtils.test.ts @@ -1,5 +1,10 @@ +import { isCurveHostUrl, normalizeCurveUrl } from '@shared/utils/curveUrlUtils' +import { isVaultsIndexPath, normalizePathname } from '@shared/utils/routes' import { describe, expect, it } from 'vitest' -import { isCurveHostUrl, normalizeCurveUrl } from './curveUrlUtils' + +// --------------------------------------------------------------------------- +// Curve URL normalization +// --------------------------------------------------------------------------- describe('normalizeCurveUrl', () => { it('canonicalizes curve.fi to www.curve.finance', () => { @@ -42,3 +47,27 @@ describe('isCurveHostUrl', () => { expect(isCurveHostUrl('ftp://www.curve.finance/pools')).toBe(false) }) }) + +// --------------------------------------------------------------------------- +// Route pathname normalization +// --------------------------------------------------------------------------- + +describe('normalizePathname', () => { + it('keeps root pathname', () => { + expect(normalizePathname('/')).toBe('/') + }) + + it('removes trailing slashes', () => { + expect(normalizePathname('/vaults/')).toBe('/vaults') + expect(normalizePathname('/vaults///')).toBe('/vaults') + }) +}) + +describe('isVaultsIndexPath', () => { + it('matches only /vaults (after normalization)', () => { + expect(isVaultsIndexPath('/vaults')).toBe(true) + expect(isVaultsIndexPath('/vaults/')).toBe(true) + expect(isVaultsIndexPath('/vaults/1/0x0')).toBe(false) + expect(isVaultsIndexPath('/vaults-beta')).toBe(false) + }) +}) diff --git a/src/components/pages/vaults/hooks/useVaultApyData.test.ts b/src/test/useVaultApyData.test.ts similarity index 96% rename from src/components/pages/vaults/hooks/useVaultApyData.test.ts rename to src/test/useVaultApyData.test.ts index 5814bdf21..15766eb8f 100644 --- a/src/components/pages/vaults/hooks/useVaultApyData.test.ts +++ b/src/test/useVaultApyData.test.ts @@ -1,6 +1,6 @@ import type { TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' +import { computeKatanaTotalApr, resolveKatanaExtras } from '@pages/vaults/hooks/useVaultApyData' import { describe, expect, it } from 'vitest' -import { computeKatanaTotalApr, resolveKatanaExtras } from './useVaultApyData' const DETAIL_VAULT_WITH_COMPONENTS = { version: '3.0.0', diff --git a/src/components/shared/hooks/useVaultFilterUtils.test.ts b/src/test/useVaultFilterUtils.test.ts similarity index 80% rename from src/components/shared/hooks/useVaultFilterUtils.test.ts rename to src/test/useVaultFilterUtils.test.ts index b749b5f85..955e4e774 100644 --- a/src/components/shared/hooks/useVaultFilterUtils.test.ts +++ b/src/test/useVaultFilterUtils.test.ts @@ -1,5 +1,5 @@ +import { getVaultHoldingsUsdValue } from '@shared/hooks/useVaultFilterUtils' import { describe, expect, it } from 'vitest' -import { getVaultHoldingsUsdValue, matchesSelectedChains } from './useVaultFilterUtils' const VAULT_ADDRESS = '0x8589462548984c5C0f2C0140FB276351B5a77fe1' const ASSET_ADDRESS = '0x0000000000000000000000000000000000000002' @@ -69,15 +69,3 @@ describe('getVaultHoldingsUsdValue', () => { expect(value).toBeCloseTo(2.1, 8) }) }) - -describe('matchesSelectedChains', () => { - it('treats null or empty selections as all chains', () => { - expect(matchesSelectedChains(1, null)).toBe(true) - expect(matchesSelectedChains(1, [])).toBe(true) - }) - - it('only matches vaults from the selected chains', () => { - expect(matchesSelectedChains(1, [1])).toBe(true) - expect(matchesSelectedChains(10, [1])).toBe(false) - }) -}) diff --git a/src/components/pages/vaults/hooks/useYvUsdCharts.helpers.test.ts b/src/test/useYvUsdCharts.helpers.test.ts similarity index 97% rename from src/components/pages/vaults/hooks/useYvUsdCharts.helpers.test.ts rename to src/test/useYvUsdCharts.helpers.test.ts index d2f90f95b..88b325427 100644 --- a/src/components/pages/vaults/hooks/useYvUsdCharts.helpers.test.ts +++ b/src/test/useYvUsdCharts.helpers.test.ts @@ -1,5 +1,5 @@ +import { buildApyDataFromPpsSeries, buildUnderlyingLockedPpsSeries } from '@pages/vaults/hooks/useYvUsdCharts.helpers' import { describe, expect, it } from 'vitest' -import { buildApyDataFromPpsSeries, buildUnderlyingLockedPpsSeries } from './useYvUsdCharts.helpers' describe('buildUnderlyingLockedPpsSeries', () => { it('converts locked PPS into underlying terms using the unlocked PPS for the same date', () => { diff --git a/src/test/useYvUsdVaults.helpers.test.ts b/src/test/useYvUsdVaults.helpers.test.ts new file mode 100644 index 000000000..783d474b4 --- /dev/null +++ b/src/test/useYvUsdVaults.helpers.test.ts @@ -0,0 +1,20 @@ +import { getYvUsdTvlBreakdown } from '@pages/vaults/hooks/useYvUsdVaults.helpers' +import { describe, expect, it } from 'vitest' + +describe('getYvUsdTvlBreakdown', () => { + it('treats the unlocked vault TVL as total and derives the unlocked remainder after locked TVL', () => { + expect(getYvUsdTvlBreakdown({ totalTvl: 1000, lockedTvl: 500 })).toEqual({ + totalTvl: 1000, + unlockedTvl: 500, + lockedTvl: 500 + }) + }) + + it('clamps the unlocked remainder at zero when locked TVL exceeds total TVL', () => { + expect(getYvUsdTvlBreakdown({ totalTvl: 100, lockedTvl: 120 })).toEqual({ + totalTvl: 100, + unlockedTvl: 0, + lockedTvl: 120 + }) + }) +}) diff --git a/src/components/pages/vaults/components/widget/deposit/valuation.test.ts b/src/test/valuation.test.ts similarity index 96% rename from src/components/pages/vaults/components/widget/deposit/valuation.test.ts rename to src/test/valuation.test.ts index d6a00e6fd..54fc79225 100644 --- a/src/components/pages/vaults/components/widget/deposit/valuation.test.ts +++ b/src/test/valuation.test.ts @@ -1,5 +1,8 @@ +import { + calculateDepositValueInfo, + resolveValuationShareCount +} from '@pages/vaults/components/widget/deposit/valuation' import { describe, expect, it } from 'vitest' -import { calculateDepositValueInfo, resolveValuationShareCount } from './valuation' const VAULT = '0x0000000000000000000000000000000000000001' const STAKING = '0x0000000000000000000000000000000000000002' diff --git a/src/components/shared/utils/vaultApy.test.ts b/src/test/vaultApy.test.ts similarity index 99% rename from src/components/shared/utils/vaultApy.test.ts rename to src/test/vaultApy.test.ts index f7da728ce..08ce8f78e 100644 --- a/src/components/shared/utils/vaultApy.test.ts +++ b/src/test/vaultApy.test.ts @@ -1,11 +1,11 @@ import type { TKongVault, TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' -import { describe, expect, it } from 'vitest' import { calculateKatanaThirtyDayAPY, calculateVaultEstimatedAPY, calculateVaultHistoricalAPY, getKatanaAprData -} from './vaultApy' +} from '@shared/utils/vaultApy' +import { describe, expect, it } from 'vitest' const BASE_VAULT: TKongVault = { chainId: 747474, diff --git a/src/components/pages/vaults/utils/yvUsd.test.ts b/src/test/yvUsd.test.ts similarity index 85% rename from src/components/pages/vaults/utils/yvUsd.test.ts rename to src/test/yvUsd.test.ts index e3f4830d9..cfcf8b38c 100644 --- a/src/components/pages/vaults/utils/yvUsd.test.ts +++ b/src/test/yvUsd.test.ts @@ -1,4 +1,3 @@ -import { describe, expect, it } from 'vitest' import { calculateHistoricalAprFromPricePerShares, calculateHistoricalApyFromPricePerShares, @@ -9,11 +8,9 @@ import { convertYvUsdVariantAmountString, convertYvUsdVariantRawAmount, getWeightedYvUsdApy, - getYvUsdLockedWithdrawDisplayMode, - getYvUsdUnderlyingPricePerShare, - YVUSD_CUSTOM_RISK_SCORE, - YVUSD_RISK_SCORE_ITEMS -} from './yvUsd' + getYvUsdUnderlyingPricePerShare +} from '@pages/vaults/utils/yvUsd' +import { describe, expect, it } from 'vitest' describe('getWeightedYvUsdApy', () => { it('returns the unlocked APY when only unlocked value is present', () => { @@ -72,22 +69,6 @@ describe('getWeightedYvUsdApy', () => { }) }) -describe('yvUSD risk override', () => { - it('uses the provisional custom score for the detail risk section', () => { - expect(YVUSD_CUSTOM_RISK_SCORE).toBe('3/5') - expect(YVUSD_RISK_SCORE_ITEMS[0]?.score).toBe('3/5') - }) - - it('keeps the current published risk sections intact', () => { - expect(YVUSD_RISK_SCORE_ITEMS.map((item) => item.label)).toEqual([ - 'Overall Risk Score', - 'Leverage Looping', - 'Duration and PT Strategies', - 'Cross-Chain Routing' - ]) - }) -}) - describe('yvUSD variant amount conversion', () => { const unlockedPricePerShare = 1_050_000n const unlockedVaultDecimals = 18 @@ -240,13 +221,3 @@ describe('yvUSD historical PPS normalization', () => { ).toBe(0) }) }) - -describe('yvUSD locked withdraw display mode', () => { - it('uses underlying display mode when the helper is called with Enso enabled', () => { - expect(getYvUsdLockedWithdrawDisplayMode(true)).toBe('underlying') - }) - - it('keeps underlying display mode even when Enso is unavailable', () => { - expect(getYvUsdLockedWithdrawDisplayMode(false)).toBe('underlying') - }) -}) diff --git a/style.css b/style.css index 20da34ec4..dbc212e44 100755 --- a/style.css +++ b/style.css @@ -10,14 +10,11 @@ @theme { /* Font families */ --font-family-aeonik: - Aeonik, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, - Arial, sans-serif; + 'Aeonik', Helvetica, Arial, system-ui, sans-serif; --font-family-aeonik-fono: - 'Aeonik Fono', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, - Helvetica, Arial, sans-serif; - --font-family-aeonik-mono: 'Aeonik Mono', Consolas, 'Courier New', monospace; - --font-family-source-code-pro: - 'Source Code Pro', 'Aeonik Mono', Consolas, 'Courier New', monospace; + 'Aeonik Fono', Helvetica, Arial, system-ui, sans-serif; + --font-family-aeonik-mono: + 'Aeonik Mono', Menlo, Consolas, 'Liberation Mono', monospace; --color-black: hsl(0 0% 0%); --color-white: hsl(0 0% 100%); @@ -166,9 +163,7 @@ html { body { background-color: var(--color-app); color: var(--color-text-primary); - font-family: - aeonik, 'aeonik Fallback', 'Source Code Pro', 'Source Code Pro Fallback', - sans-serif; + font-family: 'Aeonik', Helvetica, Arial, system-ui, sans-serif; /* Mobile-optimized line height for readability */ line-height: 1.5; } @@ -181,8 +176,7 @@ body { @layer utilities { .font-number { font-family: - 'Aeonik Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, - 'Liberation Mono', 'Courier New', monospace; + 'Aeonik Mono', Menlo, Consolas, 'Liberation Mono', monospace; } .scrollbar-none { diff --git a/vite.config.ts b/vite.config.ts index 8ec33d3b6..0e38b27ea 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,6 @@ import react from '@vitejs/plugin-react' import path from 'path' import { defineConfig } from 'vite' -import webfontDownload from 'vite-plugin-webfont-dl' const API_PROXY_TARGET = 'http://localhost:3001' const API_HEALTHCHECK_PATH = '/api/enso/balances' @@ -64,17 +63,7 @@ function previewApiGuard() { } export default defineConfig({ - plugins: [ - react(), - previewApiGuard(), - webfontDownload(['https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@400;500;600;700&display=swap'], { - injectAsStyleTag: true, - minifyCss: true, - async: true, - cache: true, - proxy: false - }) - ], + plugins: [react(), previewApiGuard()], resolve: { alias: { '@': path.resolve(__dirname, './src'),