diff --git a/.env.example b/.env.example index de84436f6..cae0fe74a 100644 --- a/.env.example +++ b/.env.example @@ -53,7 +53,6 @@ VITE_ENSO_DISABLED= # Holdings History API (server-only) ENVIO_GRAPHQL_URL=http://localhost:8080/v1/graphql ENVIO_PASSWORD=testing -HOLDINGS_TEST_WALLET_ADDRESS= # Holdings storage UPSTASH_REDIS_REST_URL_PORTFOLIO= UPSTASH_REDIS_REST_TOKEN_PORTFOLIO= diff --git a/api/admin/invalidate-cache.test.ts b/api/admin/invalidate-cache.test.ts new file mode 100644 index 000000000..4e3ba7e0c --- /dev/null +++ b/api/admin/invalidate-cache.test.ts @@ -0,0 +1,96 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import handler from './invalidate-cache' + +const { invalidateVaultsMock, isHoldingsStorageEnabledMock } = vi.hoisted(() => ({ + invalidateVaultsMock: vi.fn(), + isHoldingsStorageEnabledMock: vi.fn() +})) + +vi.mock('../lib/holdings/services/cache', () => ({ + invalidateVaults: invalidateVaultsMock +})) + +vi.mock('../lib/holdings/storage/redis', () => ({ + isHoldingsStorageEnabled: isHoldingsStorageEnabledMock +})) + +const ORIGINAL_ENV = { ...process.env } + +function createResponse() { + const headers = new Map() + const res = { + statusCode: 200, + body: undefined as unknown, + ended: false, + setHeader: vi.fn((key: string, value: string) => { + headers.set(key, value) + return res + }), + status: vi.fn((statusCode: number) => { + res.statusCode = statusCode + return res + }), + json: vi.fn((body: unknown) => { + res.body = body + return res + }), + end: vi.fn(() => { + res.ended = true + return res + }), + getHeader: (key: string) => headers.get(key) + } + + return res as typeof res & VercelResponse +} + +describe('admin cache invalidation CORS', () => { + beforeEach(() => { + vi.clearAllMocks() + process.env = { ...ORIGINAL_ENV, ADMIN_SECRET: 'test-secret' } + isHoldingsStorageEnabledMock.mockReturnValue(true) + invalidateVaultsMock.mockResolvedValue(1) + }) + + afterEach(() => { + process.env = { ...ORIGINAL_ENV } + }) + + it('reflects allowlisted origins without wildcard CORS', async () => { + process.env.ADMIN_ALLOWED_ORIGINS = 'https://preview.example' + const res = createResponse() + + await handler( + { + method: 'OPTIONS', + headers: { origin: 'https://preview.example' } + } as VercelRequest, + res + ) + + expect(res.status).toHaveBeenCalledWith(204) + expect(res.getHeader('Access-Control-Allow-Origin')).toBe('https://preview.example') + expect(res.getHeader('Vary')).toBe('Origin') + }) + + it('rejects unallowlisted browser origins without CORS headers', async () => { + const res = createResponse() + + await handler( + { + method: 'POST', + headers: { origin: 'https://evil.example', 'x-admin-secret': 'test-secret' }, + body: { + vaults: [{ address: '0xBe53A109B494E5c9f97b9Cd39Fe969BE68BF6204', chainId: 1 }] + } + } as VercelRequest, + res + ) + + expect(res.status).toHaveBeenCalledWith(403) + expect(res.body).toEqual({ error: 'Origin not allowed' }) + expect(res.getHeader('Access-Control-Allow-Origin')).toBeUndefined() + expect(invalidateVaultsMock).not.toHaveBeenCalled() + }) +}) diff --git a/api/admin/invalidate-cache.ts b/api/admin/invalidate-cache.ts new file mode 100644 index 000000000..0bc5d5298 --- /dev/null +++ b/api/admin/invalidate-cache.ts @@ -0,0 +1,133 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import { invalidateVaults, type VaultIdentifier } from '../lib/holdings/services/cache' +import { isHoldingsStorageEnabled } from '../lib/holdings/storage/redis' + +const ADMIN_ALLOWED_ORIGIN_DEFAULTS = ['http://localhost:3000', 'http://127.0.0.1:3000'] +const ADMIN_CORS_HEADERS = { + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, x-admin-secret' +} + +function isValidAddress(address: string): boolean { + return /^0x[a-fA-F0-9]{40}$/.test(address) +} + +interface InvalidateRequestBody { + vaults: Array<{ address: string; chainId: number }> +} + +function validateBody(body: unknown): body is InvalidateRequestBody { + if (!body || typeof body !== 'object') return false + const b = body as Record + if (!Array.isArray(b.vaults)) return false + if (b.vaults.length === 0) return false + + for (const vault of b.vaults) { + if (!vault || typeof vault !== 'object') return false + const v = vault as Record + if (typeof v.address !== 'string' || !isValidAddress(v.address)) return false + if (typeof v.chainId !== 'number' || !Number.isInteger(v.chainId)) return false + } + + return true +} + +function readCsvValues(value: string | undefined): string[] { + return ( + value + ?.split(',') + .map((origin) => origin.trim()) + .filter(Boolean) ?? [] + ) +} + +function getAdminAllowedOrigins(): Set { + return new Set([...ADMIN_ALLOWED_ORIGIN_DEFAULTS, ...readCsvValues(process.env.ADMIN_ALLOWED_ORIGINS)]) +} + +function getRequestOrigin(req: VercelRequest): string | null { + const origin = req.headers.origin + if (Array.isArray(origin)) { + return origin[0] ?? null + } + return origin ?? null +} + +function applyAdminCorsHeaders(req: VercelRequest, res: VercelResponse): boolean { + const origin = getRequestOrigin(req) + if (origin && !getAdminAllowedOrigins().has(origin)) { + return false + } + + for (const [key, value] of Object.entries(ADMIN_CORS_HEADERS)) { + res.setHeader(key, value) + } + + if (origin) { + res.setHeader('Access-Control-Allow-Origin', origin) + res.setHeader('Vary', 'Origin') + } + + return true +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (!applyAdminCorsHeaders(req, res)) { + return res.status(403).json({ error: 'Origin not allowed' }) + } + + if (req.method === 'OPTIONS') { + return res.status(204).end() + } + + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }) + } + + // Check admin secret + const adminSecret = process.env.ADMIN_SECRET + if (!adminSecret) { + return res.status(503).json({ error: 'Admin endpoint not configured' }) + } + + const providedSecret = req.headers['x-admin-secret'] + if (providedSecret !== adminSecret) { + return res.status(401).json({ error: 'Unauthorized' }) + } + + // Check Redis storage is enabled + if (!isHoldingsStorageEnabled()) { + return res.status(503).json({ error: 'Caching not enabled (Redis storage not configured)' }) + } + + // Validate request body + const body = req.body + if (!validateBody(body)) { + return res.status(400).json({ + error: 'Invalid request body', + expected: { + vaults: [{ address: '0x...', chainId: 1 }] + } + }) + } + + try { + const vaults: VaultIdentifier[] = body.vaults.map((v) => ({ + address: v.address, + chainId: v.chainId + })) + + const invalidatedCount = await invalidateVaults(vaults) + const timestamp = new Date().toISOString() + + return res.status(200).json({ + success: true, + invalidated: invalidatedCount, + vaults: vaults.map((v) => `${v.chainId}:${v.address.toLowerCase()}`), + timestamp + }) + } catch (error) { + console.error('[Admin] Invalidate cache error:', error) + return res.status(500).json({ error: 'Failed to invalidate cache' }) + } +} diff --git a/api/lib/holdings/services/cache.test.ts b/api/lib/holdings/services/cache.test.ts index d27159ef8..78e32277c 100644 --- a/api/lib/holdings/services/cache.test.ts +++ b/api/lib/holdings/services/cache.test.ts @@ -63,4 +63,27 @@ describe('Redis cache writes', () => { ]) expect(result.oldestUpdatedAt?.getTime()).toBe(2000) }) + + it('marks vaults as invalidated in Redis', async () => { + const msetMock = vi.fn().mockResolvedValue('OK') + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1_779_564_000_000) + isHoldingsStorageEnabledMock.mockReturnValue(true) + getHoldingsRedisClientMock.mockReturnValue({ + mset: msetMock + }) + + const { invalidateVaults } = await import('./cache') + const invalidated = await invalidateVaults([ + { + address: '0xBe53A109B494E5c9f97b9Cd39Fe969BE68BF6204', + chainId: 1 + } + ]) + + expect(invalidated).toBe(1) + expect(msetMock).toHaveBeenCalledWith({ + 'holdings:vault-invalidated:1:0xbe53a109b494e5c9f97b9cd39fe969be68bf6204': 1_779_564_000_000 + }) + nowSpy.mockRestore() + }) }) diff --git a/api/lib/holdings/services/cache.ts b/api/lib/holdings/services/cache.ts index ab6a37f48..000198a32 100644 --- a/api/lib/holdings/services/cache.ts +++ b/api/lib/holdings/services/cache.ts @@ -269,6 +269,34 @@ export async function checkCacheStaleness( } } +export async function invalidateVaults(vaults: VaultIdentifier[]): Promise { + if (!isHoldingsStorageEnabled() || vaults.length === 0) { + if (vaults.length > 0) { + debugLog('cache', 'skipping vault invalidation because Redis storage is disabled', { vaults: vaults.length }) + } + return 0 + } + + const redis = getHoldingsRedisClient() + if (!redis) { + debugLog('cache', 'skipping vault invalidation because Redis client is unavailable', { vaults: vaults.length }) + return 0 + } + + try { + const invalidatedAt = Date.now() + const valuesByKey = Object.fromEntries(vaults.map((vault) => [getVaultInvalidationKey(vault), invalidatedAt])) + + await redis.mset(valuesByKey) + console.log(`[Cache] Invalidated ${vaults.length} vault cache markers`) + return vaults.length + } catch (error) { + handleHoldingsRedisError('vault invalidation failed', error) + debugError('cache', 'vault invalidation failed', error, { vaults: vaults.length }) + return 0 + } +} + export async function getCachedTotalsWithTimestamp( userAddress: string, version: string, diff --git a/api/server.ts b/api/server.ts index 2aa51c79f..0baf9ebae 100644 --- a/api/server.ts +++ b/api/server.ts @@ -53,7 +53,11 @@ import { requireTenderlyServerChain, resolveTenderlyFundRpcRequest } from './tenderly.helpers' -import { buildTenderlyAdminAccessDeniedResponse } from './tenderlyAccess' +import { + buildTenderlyAdminAccessDeniedResponse, + buildTenderlyAdminCorsPreflightResponse, + withTenderlyAdminCors +} from './tenderlyAccess' const ENSO_API_BASE = 'https://api.enso.finance' const DEFAULT_API_PORT = 3001 @@ -156,6 +160,15 @@ function handleCorsPreFlight(): Response { }) } +function isTenderlyAdminPath(pathname: string): boolean { + return ( + pathname === '/api/tenderly/snapshot' || + pathname === '/api/tenderly/revert' || + pathname === '/api/tenderly/increase-time' || + pathname === '/api/tenderly/fund' + ) +} + async function handleHoldingsProgress(req: Request): Promise { const url = new URL(req.url) const progress = await getHoldingsProgress(url.searchParams.get('id')) @@ -1295,6 +1308,10 @@ async function main() { console.log(`[Server] ${req.method} ${url.pathname}`) try { + if (req.method === 'OPTIONS' && isTenderlyAdminPath(url.pathname)) { + return buildTenderlyAdminCorsPreflightResponse(req) + } + if (req.method === 'OPTIONS') { return handleCorsPreFlight() } @@ -1371,35 +1388,35 @@ async function main() { } if (url.pathname === '/api/tenderly/snapshot') { - const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address) + const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address, req) if (accessDeniedResponse) { - return withCors(accessDeniedResponse) + return withTenderlyAdminCors(accessDeniedResponse, req) } - return withCors(await handleTenderlySnapshot(req)) + return withTenderlyAdminCors(await handleTenderlySnapshot(req), req) } if (url.pathname === '/api/tenderly/revert') { - const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address) + const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address, req) if (accessDeniedResponse) { - return withCors(accessDeniedResponse) + return withTenderlyAdminCors(accessDeniedResponse, req) } - return withCors(await handleTenderlyRevert(req)) + return withTenderlyAdminCors(await handleTenderlyRevert(req), req) } if (url.pathname === '/api/tenderly/increase-time') { - const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address) + const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address, req) if (accessDeniedResponse) { - return withCors(accessDeniedResponse) + return withTenderlyAdminCors(accessDeniedResponse, req) } - return withCors(await handleTenderlyIncreaseTime(req)) + return withTenderlyAdminCors(await handleTenderlyIncreaseTime(req), req) } if (url.pathname === '/api/tenderly/fund') { - const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address) + const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address, req) if (accessDeniedResponse) { - return withCors(accessDeniedResponse) + return withTenderlyAdminCors(accessDeniedResponse, req) } - return withCors(await handleTenderlyFund(req)) + return withTenderlyAdminCors(await handleTenderlyFund(req), req) } return withCors(new Response('Not found', { status: 404 })) diff --git a/api/tenderly.helpers.test.ts b/api/tenderly.helpers.test.ts index 1c180b6a6..e2e79b8e3 100644 --- a/api/tenderly.helpers.test.ts +++ b/api/tenderly.helpers.test.ts @@ -27,6 +27,48 @@ describe('parseTenderlyServerChains', () => { } ]) }) + + it('rejects Tenderly execution chain ids that shadow other canonical chain ids', () => { + expect(() => + parseTenderlyServerChains({ + VITE_TENDERLY_MODE: 'true', + VITE_TENDERLY_CHAIN_ID_FOR_1: '10', + VITE_TENDERLY_RPC_URI_FOR_1: 'https://public.rpc' + }) + ).toThrow( + /Tenderly canonical chain 1 \(Ethereum\) uses execution chain ID 10, which shadows supported canonical chain 10 .*Tenderly execution chain IDs must not shadow supported canonical chain IDs/ + ) + }) + + it('accepts identity Tenderly execution chain ids for the same canonical chain', () => { + expect( + parseTenderlyServerChains({ + VITE_TENDERLY_MODE: 'true', + VITE_TENDERLY_CHAIN_ID_FOR_1: '1', + VITE_TENDERLY_RPC_URI_FOR_1: 'https://public.rpc' + }) + ).toEqual([ + { + canonicalChainId: 1, + canonicalChainName: 'Ethereum', + executionChainId: 1, + rpcUri: 'https://public.rpc', + adminRpcUri: undefined + } + ]) + }) + + it('rejects duplicate Tenderly execution chain ids', () => { + expect(() => + parseTenderlyServerChains({ + VITE_TENDERLY_MODE: 'true', + VITE_TENDERLY_CHAIN_ID_FOR_1: '73571', + VITE_TENDERLY_RPC_URI_FOR_1: 'https://public.ethereum.rpc', + VITE_TENDERLY_CHAIN_ID_FOR_10: '73571', + VITE_TENDERLY_RPC_URI_FOR_10: 'https://public.optimism.rpc' + }) + ).toThrow(/Duplicate Tenderly execution chain ID 73571 configured for canonical chains 1 and 10/) + }) }) describe('buildTenderlyPanelStatus', () => { diff --git a/api/tenderly.helpers.ts b/api/tenderly.helpers.ts index 5b22c0077..02826e11e 100644 --- a/api/tenderly.helpers.ts +++ b/api/tenderly.helpers.ts @@ -8,6 +8,9 @@ import type { import { canonicalChains } from '../src/config/chainDefinitions' type TTenderlyServerEnv = Record +const supportedCanonicalChainById = new Map( + canonicalChains.map((chain) => [chain.id, chain]) +) export type TTenderlyServerChainConfig = { canonicalChainId: number @@ -51,6 +54,20 @@ export function parseTenderlyServerChains(env: TTenderlyServerEnv): TTenderlySer throw new Error(`Invalid Tenderly execution chain ID for canonical chain ${chain.id}: ${rawExecutionChainId}`) } + const collidingCanonicalChain = supportedCanonicalChainById.get(executionChainId) + if (collidingCanonicalChain && collidingCanonicalChain.id !== chain.id) { + throw new Error( + `Tenderly canonical chain ${chain.id} (${chain.name}) uses execution chain ID ${executionChainId}, which shadows supported canonical chain ${collidingCanonicalChain.id} (${collidingCanonicalChain.name}). Tenderly execution chain IDs must not shadow supported canonical chain IDs.` + ) + } + + const existingChain = accumulator.find((configuredChain) => configuredChain.executionChainId === executionChainId) + if (existingChain) { + throw new Error( + `Duplicate Tenderly execution chain ID ${executionChainId} configured for canonical chains ${existingChain.canonicalChainId} and ${chain.id}` + ) + } + accumulator.push({ canonicalChainId: chain.id, canonicalChainName: chain.name, diff --git a/api/tenderlyAccess.test.ts b/api/tenderlyAccess.test.ts index 2151e5996..13fb31d40 100644 --- a/api/tenderlyAccess.test.ts +++ b/api/tenderlyAccess.test.ts @@ -1,10 +1,19 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { buildTenderlyAdminAccessDeniedResponse, + buildTenderlyAdminCorsPreflightResponse, isLoopbackAddress, - isTenderlyAdminRequestAllowed + isTenderlyAdminOriginAllowed, + isTenderlyAdminRequestAllowed, + withTenderlyAdminCors } from './tenderlyAccess' +const ORIGINAL_ENV = { ...process.env } + +afterEach(() => { + process.env = { ...ORIGINAL_ENV } +}) + describe('isLoopbackAddress', () => { it('accepts loopback addresses', () => { expect(isLoopbackAddress('localhost')).toBe(true) @@ -30,10 +39,110 @@ describe('isTenderlyAdminRequestAllowed', () => { it('rejects remote client addresses', async () => { expect(isTenderlyAdminRequestAllowed('10.0.0.8')).toBe(false) - const response = buildTenderlyAdminAccessDeniedResponse('10.0.0.8') + const response = buildTenderlyAdminAccessDeniedResponse( + '10.0.0.8', + new Request('http://localhost:3001/api/tenderly/snapshot', { method: 'POST' }) + ) expect(response?.status).toBe(403) await expect(response?.json()).resolves.toEqual({ error: 'Tenderly admin routes are only available from localhost' }) }) }) + +describe('Tenderly admin browser-origin access', () => { + it('allows only configured local browser origins', () => { + expect(isTenderlyAdminOriginAllowed(null)).toBe(true) + expect(isTenderlyAdminOriginAllowed('http://localhost:3000')).toBe(true) + expect(isTenderlyAdminOriginAllowed('http://127.0.0.1:3000')).toBe(true) + expect(isTenderlyAdminOriginAllowed('http://localhost:5173')).toBe(false) + + process.env.TENDERLY_ADMIN_ALLOWED_ORIGINS = 'http://localhost:5173' + expect(isTenderlyAdminOriginAllowed('http://localhost:5173')).toBe(true) + }) + + it('reflects allowlisted origins on admin preflight responses', () => { + const response = buildTenderlyAdminCorsPreflightResponse( + new Request('http://localhost:3001/api/tenderly/snapshot', { + method: 'OPTIONS', + headers: { Origin: 'http://localhost:3000' } + }) + ) + + expect(response.status).toBe(204) + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('http://localhost:3000') + expect(response.headers.get('Access-Control-Allow-Methods')).toBe('POST, OPTIONS') + expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type, x-admin-secret') + }) + + it('rejects unallowlisted browser preflights without permissive CORS', () => { + const response = buildTenderlyAdminCorsPreflightResponse( + new Request('http://localhost:3001/api/tenderly/snapshot', { + method: 'OPTIONS', + headers: { Origin: 'http://evil.localhost:3000' } + }) + ) + + expect(response.status).toBe(403) + expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull() + }) + + it('requires a configured admin secret for loopback mutation requests', async () => { + process.env.TENDERLY_ADMIN_SECRET = 'test-secret' + + const missingSecret = buildTenderlyAdminAccessDeniedResponse( + '127.0.0.1', + new Request('http://localhost:3001/api/tenderly/snapshot', { method: 'POST' }) + ) + expect(missingSecret?.status).toBe(401) + + const validSecret = buildTenderlyAdminAccessDeniedResponse( + '127.0.0.1', + new Request('http://localhost:3001/api/tenderly/snapshot', { + method: 'POST', + headers: { 'x-admin-secret': 'test-secret' } + }) + ) + expect(validSecret).toBeUndefined() + }) + + it('rejects unallowlisted browser mutation requests before handlers run', () => { + process.env.TENDERLY_ADMIN_SECRET = 'test-secret' + + const response = buildTenderlyAdminAccessDeniedResponse( + '127.0.0.1', + new Request('http://localhost:3001/api/tenderly/snapshot', { + method: 'POST', + headers: { + Origin: 'http://evil.localhost:3000', + 'x-admin-secret': 'test-secret' + } + }) + ) + + expect(response?.status).toBe(403) + expect(response?.headers.get('Access-Control-Allow-Origin')).toBeNull() + }) + + it('does not add wildcard CORS to Tenderly admin mutation responses', () => { + const response = withTenderlyAdminCors( + Response.json( + { ok: true }, + { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Credentials': 'true' + } + } + ), + new Request('http://localhost:3001/api/tenderly/snapshot', { + method: 'POST', + headers: { Origin: 'http://evil.localhost:3000' } + }) + ) + + expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull() + expect(response.headers.get('Access-Control-Allow-Credentials')).toBeNull() + expect(response.headers.get('Access-Control-Allow-Methods')).toBe('POST, OPTIONS') + }) +}) diff --git a/api/tenderlyAccess.ts b/api/tenderlyAccess.ts index 949848e10..eeb1502a3 100644 --- a/api/tenderlyAccess.ts +++ b/api/tenderlyAccess.ts @@ -1,4 +1,23 @@ const LOOPBACK_ADDRESSES = new Set(['localhost', '::1']) +const TENDERLY_ADMIN_ORIGIN_DEFAULTS = ['http://localhost:3000', 'http://127.0.0.1:3000'] + +export const TENDERLY_ADMIN_CORS_HEADERS = { + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, x-admin-secret' +} + +function readCsvValues(value: string | undefined): string[] { + return ( + value + ?.split(',') + .map((origin) => origin.trim()) + .filter(Boolean) ?? [] + ) +} + +function getTenderlyAdminAllowedOrigins(): Set { + return new Set([...TENDERLY_ADMIN_ORIGIN_DEFAULTS, ...readCsvValues(process.env.TENDERLY_ADMIN_ALLOWED_ORIGINS)]) +} function isLoopbackIpv4(address: string): boolean { const octets = address.split('.') @@ -39,12 +58,75 @@ export function isTenderlyAdminRequestAllowed(requestIpAddress: string | null | return isLoopbackAddress(requestIpAddress) } +export function isTenderlyAdminOriginAllowed(origin: string | null): boolean { + return origin === null || getTenderlyAdminAllowedOrigins().has(origin) +} + +export function getTenderlyAdminSecret(): string | undefined { + return process.env.TENDERLY_ADMIN_SECRET || process.env.ADMIN_SECRET +} + export function buildTenderlyAdminAccessDeniedResponse( - requestIpAddress: string | null | undefined + requestIpAddress: string | null | undefined, + req: Request ): Response | undefined { + if (!isTenderlyAdminOriginAllowed(req.headers.get('Origin'))) { + return Response.json({ error: 'Origin not allowed' }, { status: 403 }) + } + if (isTenderlyAdminRequestAllowed(requestIpAddress)) { - return undefined + const adminSecret = getTenderlyAdminSecret() + if (!adminSecret) { + return Response.json({ error: 'Tenderly admin endpoint not configured' }, { status: 503 }) + } + + if (req.headers.get('x-admin-secret') === adminSecret) { + return undefined + } + + return Response.json({ error: 'Unauthorized' }, { status: 401 }) } return Response.json({ error: 'Tenderly admin routes are only available from localhost' }, { status: 403 }) } + +export function buildTenderlyAdminCorsPreflightResponse(req: Request): Response { + const origin = req.headers.get('Origin') + if (!isTenderlyAdminOriginAllowed(origin)) { + return Response.json({ error: 'Origin not allowed' }, { status: 403 }) + } + + return new Response(null, { + status: 204, + headers: origin + ? { + ...TENDERLY_ADMIN_CORS_HEADERS, + 'Access-Control-Allow-Origin': origin, + Vary: 'Origin' + } + : TENDERLY_ADMIN_CORS_HEADERS + }) +} + +export function withTenderlyAdminCors(response: Response, req: Request): Response { + const origin = req.headers.get('Origin') + const newHeaders = new Headers(response.headers) + + newHeaders.delete('Access-Control-Allow-Origin') + newHeaders.delete('Access-Control-Allow-Credentials') + + Object.entries(TENDERLY_ADMIN_CORS_HEADERS).forEach(([key, value]) => { + newHeaders.set(key, value) + }) + + if (isTenderlyAdminOriginAllowed(origin) && origin) { + newHeaders.set('Access-Control-Allow-Origin', origin) + newHeaders.set('Vary', 'Origin') + } + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: newHeaders + }) +} diff --git a/bun.lock b/bun.lock index c4b05447f..54a57b005 100644 --- a/bun.lock +++ b/bun.lock @@ -905,7 +905,7 @@ "edge-runtime": ["edge-runtime@2.5.9", "", { "dependencies": { "@edge-runtime/format": "2.2.1", "@edge-runtime/ponyfill": "2.4.2", "@edge-runtime/vm": "3.2.0", "async-listen": "3.0.1", "mri": "1.2.0", "picocolors": "1.0.0", "pretty-ms": "7.0.1", "signal-exit": "4.0.2", "time-span": "4.0.0" }, "bin": { "edge-runtime": "dist/cli/index.js" } }, "sha512-pk+k0oK0PVXdlT4oRp4lwh+unuKB7Ng4iZ2HB+EZ7QCEQizX360Rp/F4aRpgpRgdP2ufB35N+1KppHmYjqIGSg=="], - "electron-to-chromium": ["electron-to-chromium@1.5.364", "", {}, "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw=="], + "electron-to-chromium": ["electron-to-chromium@1.5.365", "", {}, "sha512-xfip4u1QF1s+URFqpA6N+OeFpDGpN7VJz1f3MO3bVL0QYBjpGiZ5/Of7kugvM+o8TTqmanUlviHN3c8M9vYWCw=="], "elliptic": ["elliptic@6.5.4", "", { "dependencies": { "bn.js": "^4.11.9", "brorand": "^1.1.0", "hash.js": "^1.0.0", "hmac-drbg": "^1.0.1", "inherits": "^2.0.4", "minimalistic-assert": "^1.0.1", "minimalistic-crypto-utils": "^1.0.1" } }, "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ=="], @@ -1291,7 +1291,7 @@ "node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="], - "node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="], + "node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="], "nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], @@ -1775,7 +1775,7 @@ "@walletconnect/jsonrpc-ws-connection/ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="], - "@walletconnect/keyvaluestorage/idb-keyval": ["idb-keyval@6.2.4", "", {}, "sha512-D/NzHWUmYJGXi++z67aMSrnisb9A3621CyRK5G89JyTlN13C8xf0g04DLxUKMufPem3e3L2JAXR6Z00OWy183Q=="], + "@walletconnect/keyvaluestorage/idb-keyval": ["idb-keyval@6.2.5", "", {}, "sha512-eKQkTnS0relYsSOYomx8ozIbmdsQCKUdhyuIaQ2DZgKuaxtyQQMkyD/wlnQN32pO3yutN1b1L8uqwcDKaJd7/Q=="], "@walletconnect/relay-auth/@noble/curves": ["@noble/curves@1.8.0", "", { "dependencies": { "@noble/hashes": "1.7.0" } }, "sha512-j84kjAbzEnQHaSIhRPUmB3/eVXu2k3dKPl2LOrR8fSOIL+89U+7lV117EWHtq/GHM3ReGHM46iRBdZfpc4HRUQ=="], @@ -1857,7 +1857,7 @@ "path-scurry/lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], - "porto/idb-keyval": ["idb-keyval@6.2.4", "", {}, "sha512-D/NzHWUmYJGXi++z67aMSrnisb9A3621CyRK5G89JyTlN13C8xf0g04DLxUKMufPem3e3L2JAXR6Z00OWy183Q=="], + "porto/idb-keyval": ["idb-keyval@6.2.5", "", {}, "sha512-eKQkTnS0relYsSOYomx8ozIbmdsQCKUdhyuIaQ2DZgKuaxtyQQMkyD/wlnQN32pO3yutN1b1L8uqwcDKaJd7/Q=="], "porto/ox": ["ox@0.9.17", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.0.9", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-rKAnhzhRU3Xh3hiko+i1ZxywZ55eWQzeS/Q4HRKLx2PqfHOolisZHErSsJVipGlmQKHW5qwOED/GighEw9dbLg=="], diff --git a/docs/manual-allocation-reallocation-data-spec.md b/docs/manual-allocation-reallocation-data-spec.md deleted file mode 100644 index 786f178fb..000000000 --- a/docs/manual-allocation-reallocation-data-spec.md +++ /dev/null @@ -1,393 +0,0 @@ -# Manual Allocation And Reallocation Data Spec - -Initial pass as of 2026-04-24. - -## Goal - -Build a vault-scoped allocation timeline that can explain both: - -- DOA optimizer-driven reallocations. -- Manual/operator reallocations and configuration changes. - -The current API path is not a complete allocation history. It reads DOA optimization snapshots from Redis. That is enough to expose optimizer proposal history to external consumers, but it cannot explain manual reallocations between those points. - -The desired model should treat on-chain events as the canonical history and DOA data as metadata that annotates some of those on-chain state changes. - -## Current Inputs - -### DOA Redis History - -Current API: - -- `GET /api/optimization/change?vault=
&history=1` - -Source: - -- Redis keys matching `doa:optimizations:*`. -- Parsed by `api/optimization/_lib/redis.ts`. - -Current record shape: - -```ts -type DoaOptimizationRecord = { - vault: `0x${string}` - strategyDebtRatios: Array<{ - strategy: `0x${string}` - name?: string - currentRatio: number // bps - targetRatio: number // bps - currentApr?: number | null - targetApr?: number | null - }> - currentApr: number - proposedApr: number - explain: string - source: { - key: string - chainId: number | null - revision: string - isLatestAlias: boolean - timestampUtc: string | null - latestMatchedTimestampUtc: string | null - } -} -``` - -What it means: - -- This is optimizer intent/proposal data. -- `currentRatio` is the optimizer's observed starting state. -- `targetRatio` is the optimizer's proposed target. -- It is not proof that a vault debt update happened on-chain. - -### Current Kong Vault Snapshot - -What it means: - -- Kong is useful for current strategy names and current live allocation. -- Kong's public snapshot endpoint is not a historical allocation-change feed. - -## Events Needed - -Minimum event set for a useful allocation timeline: - -| Event | Source contract | Required | Why | -| --- | --- | --- | --- | -| `DebtUpdated(strategy,current_debt,new_debt)` | V3 vault | Yes | Canonical vault debt allocation change. | -| `StrategyChanged(strategy,change_type)` | V3 vault | Yes | Strategy universe changes, including add/revoke/migration style changes. | -| `StrategyReported(strategy,...,current_debt,...)` | V3 vault | Yes | Changes observable strategy debt without necessarily being an allocation command. Needed to keep state accurate. | -| `UpdatedMaxDebtForStrategy(sender,strategy,new_debt)` | V3 vault | Yes | Manual/config change that affects allowed allocation and operator intent. | -| `UpdateDefaultQueue(new_default_queue)` | V3 vault | Useful | Queue changes affect withdrawal behavior and strategy ordering context. | -| `UpdateUseDefaultQueue(use_default_queue)` | V3 vault | Useful | Explains whether the default queue is active. | -| `RoleSet(account,role)` | V3 vault | Useful | Helps classify whether a sender was authorized as debt/max debt/queue/role manager at the time. | -| `RoleStatusChanged(role,status)` | V3 vault | Useful | Helps interpret role state over time. | -| `UpdateRoleManager(role_manager)` | V3 vault | Useful | Helps follow role-manager changes. | -| Debt allocator ratio update events | Debt allocator / applicator | Yes for DOA attribution | Connects optimizer target ratios to later on-chain debt movements. Exact ABI/event names should be confirmed against the deployed allocator contracts. | -| Debt allocator keeper updates | Debt allocator | Useful | Identifies keeper set changes for DOA/manual classification. | - -Required metadata for every indexed event: - -```ts -type AllocationSourceEvent = { - id: string // `${chainId}:${txHash}:${logIndex}` - chainId: number - vaultAddress: `0x${string}` - eventName: string - blockNumber: number - blockTimestamp: number - blockTimestampUtc: string - transactionHash: `0x${string}` - logIndex: number - transactionFrom: `0x${string}` | null - transactionTo: `0x${string}` | null - inputSelector: `0x${string}` | null - strategyAddress?: `0x${string}` | null - args: Record -} -``` - -`transactionFrom` is not optional for classification quality. Without it we can still build a state timeline, but we cannot reliably label a transition as DOA, manual, governance, keeper, or unknown. - -## Snapshot Data Needed - -The chart should be built from states, not raw events alone. A state is the vault allocation after a relevant event or at a boundary. - -Required per-state fields: - -```ts -type AllocationState = { - id: string - chainId: number - vaultAddress: `0x${string}` - blockNumber: number | null - blockTimestamp: number | null - timestampUtc: string - txHash: `0x${string}` | null - totalAssets: string - totalDebt?: string - totalIdle?: string - unallocatedBps: number - strategies: AllocationStateStrategy[] - sourceEventIds: string[] -} - -type AllocationStateStrategy = { - strategyAddress: `0x${string}` - name: string | null - currentDebt: string - currentDebtBps: number - maxDebt?: string | null - maxDebtBps?: number | null - targetDebtRatioBps?: number | null - isActive?: boolean - lastReport?: number | null -} -``` - -Required snapshots: - -- Initial seed state at the beginning of the requested range. -- A post-event state after each allocation-relevant event group. -- Current live tail state from Kong or latest indexed state. - -For exact historical percentages, the denominator should match the current UI model: - -- Use `vault.totalAssets()` as the denominator. -- `unallocatedBps = totalAssets - sum(strategy currentDebt)` expressed in bps. -- Do not use "share of deployed strategy debt only" unless the product intentionally changes the chart semantics. - -Historical state reconstruction can come from either: - -- Indexer-materialized snapshots after event replay. -- Archive RPC reads at relevant block numbers: - - `vault.totalAssets()` - - `vault.strategies(strategy)` for each known strategy - -Raw `DebtUpdated` events are not enough by themselves because: - -- `StrategyReported` can change `current_debt`. -- Strategy set changes affect visible nodes. -- We need `totalAssets` at the same block to show idle/unallocated correctly. - -## How DOA Data Fits In - -DOA should be an overlay, not the canonical allocation history. - -DOA data provides: - -- Optimizer proposal timestamp. -- Optimizer current and target ratios. -- Strategy-level target changes. -- APR before/after metadata. -- Human-readable `explain` text. -- Redis source key and revision metadata. - -On-chain events provide: - -- Whether a change actually happened. -- When it happened. -- Which transaction caused it. -- Which sender/contract executed it. -- The exact resulting vault state. - -Recommended join model: - -```ts -type DoaAnnotation = { - sourceKey: string - proposalTimestampUtc: string | null - optimizerCurrentApr: number - optimizerProposedApr: number - explain: string - strategyTargets: Array<{ - strategyAddress: `0x${string}` - currentRatioBps: number - targetRatioBps: number - currentApr?: number | null - targetApr?: number | null - }> - confidence: 'exact' | 'high' | 'medium' | 'low' - matchReason: string -} -``` - -Suggested DOA matching signals: - -- Same `chainId` and `vaultAddress`. -- On-chain event timestamp is near the DOA proposal timestamp. -- Strategy delta directions match `targetRatio - currentRatio`. -- Debt allocator ratio update event matches the proposed target ratios. -- `DebtUpdated` transaction sender or wrapper is a known DOA keeper/applicator path. - -If DOA exists but no matching on-chain transition exists, it should be modeled as a proposal/pending annotation, not as an executed allocation state. - -## Classification - -Each transition should have a classification independent from the raw event name: - -```ts -type AllocationTransitionKind = - | 'doa_proposal' - | 'doa_execution' - | 'manual_debt_update' - | 'manual_config_change' - | 'report_only_state_change' - | 'strategy_lifecycle_change' - | 'current_live_tail' - | 'unknown' -``` - -Initial classification rules: - -- `doa_proposal`: Redis optimizer snapshot and/or allocator target-ratio update, with no direct vault state change implied. -- `doa_execution`: vault `DebtUpdated` caused by a known DOA keeper/applicator path and matched to a DOA proposal. -- `manual_debt_update`: vault `DebtUpdated` caused by a non-DOA authorized sender. -- `manual_config_change`: max debt, queue, role, or allocator configuration event caused by an operator. -- `report_only_state_change`: `StrategyReported` changes observable debt/ratio without an allocation command. -- `strategy_lifecycle_change`: strategy added, revoked, or otherwise changed through `StrategyChanged`. -- `current_live_tail`: synthetic transition from latest historical state to current Kong state. -- `unknown`: insufficient sender/contract metadata to classify. - -Known DOA keeper/applicator addresses should be configured data, not hard-coded in chart code. The current alignment helper only queries one keeper address, `0x283132390eA87D6ecc20255B59Ba94329eE17961`, so it is not complete enough for this final model. - -## Desired Final Data Shape - -The backend should return a vault-scoped canonical timeline that the UI can transform into Sankey panels. - -```ts -type VaultAllocationTimeline = { - schemaVersion: 1 - generatedAt: string - vault: { - chainId: number - address: `0x${string}` - name?: string | null - assetAddress?: `0x${string}` | null - assetSymbol?: string | null - assetDecimals?: number | null - } - range: { - fromBlock: number | null - toBlock: number | 'latest' - fromTimestampUtc: string | null - toTimestampUtc: string | null - } - strategies: Array<{ - address: `0x${string}` - name: string | null - firstSeenBlock: number | null - lastSeenBlock: number | null - }> - events: AllocationSourceEvent[] - states: AllocationState[] - transitions: AllocationTransition[] -} - -type AllocationTransition = { - id: string - kind: AllocationTransitionKind - fromStateId: string | null - toStateId: string - timestampUtc: string - blockNumber: number | null - transactionHash: `0x${string}` | null - actor: `0x${string}` | null - sourceEventIds: string[] - doa?: DoaAnnotation - summary: string -} -``` - -The UI panel shape can be derived from `states` and `transitions`: - -```ts -type ReallocationPanel = { - id: string - kind: AllocationTransitionKind - beforeState: { - timestampUtc: string | null - strategies: Array<{ - strategyAddress: `0x${string}` | null - name: string - allocationPct: number - isUnallocated: boolean - }> - } - afterState: { - timestampUtc: string | null - strategies: Array<{ - strategyAddress: `0x${string}` | null - name: string - allocationPct: number - isUnallocated: boolean - }> - } - source: { - transactionHash: `0x${string}` | null - actor: `0x${string}` | null - doaSourceKey?: string - } -} -``` - -## Fetch Model - -Primary fetch should be by vault: - -```http -GET /api/vault-allocation-history?chainId=1&vault=0x... -GET /api/vault-allocation-history?chainId=1&vault=0x...&fromBlock=... -GET /api/vault-allocation-history?chainId=1&vault=0x...&fromTimestamp=... -``` - -Recommended query params: - -- `chainId`: required. -- `vault`: required. -- `fromBlock` or `fromTimestamp`: optional. Defaults should be bounded, not "all history" for every UI load. -- `toBlock`: optional, defaults to latest. -- `includeRawEvents`: optional debug flag. -- `includeDoa`: optional, defaults true. - -Recommended backend fetch pipeline: - -1. Resolve vault metadata and current strategy list from Kong. -2. Fetch indexed events by `chainId + vaultAddress` from the canonical event store. -3. Ensure every event has `transactionFrom`, `transactionTo`, and `inputSelector`. -4. Build or read materialized post-event allocation states. -5. Fetch DOA Redis records for the same `chainId + vaultAddress`. -6. Join DOA annotations to transitions. -7. Append current Kong snapshot as a live tail state if it differs from latest indexed state. -8. Return `VaultAllocationTimeline`. - -Recommended storage key if this is materialized: - -```text -vault-allocation-history:{chainId}:{vaultAddressLower} -``` - -For long-term storage, a database table is better than a single Redis blob because this is an append/update timeline: - -```text -allocation_events(chain_id, vault_address, block_number, log_index, tx_hash, event_name, tx_from, args_json) -allocation_states(chain_id, vault_address, state_id, block_number, tx_hash, total_assets, strategies_json) -allocation_transitions(chain_id, vault_address, transition_id, from_state_id, to_state_id, kind, doa_source_key) -``` - -Redis can still cache the final per-vault response. - -## Open Questions - -- Should `StrategyReported` create visible chart panels, or should it only update hidden state used for the next visible allocation transition? -- Should the chart denominator always be `totalAssets`, or do we want a toggle for "deployed debt only"? -- What is the complete DOA keeper/applicator address set per chain? -- Are debt allocator ratio update events already indexed in the available Envio/Kong data source? -- Can the chosen event source provide `transactionFrom` directly, or do we need RPC transaction lookups? -- What is the default history window for UI loads: last N transitions, since first DOA record, or full vault history? -- Should manual max-debt and queue changes appear in the same timeline as allocation changes, or be shown as annotations on nearby allocation panels? - -## Implementation Notes From The Current Branches - -- Current DOA Redis reader: `api/optimization/_lib/redis.ts`. -- Current DOA alignment helper: `api/optimization/_lib/envio.ts`. -- Prototype archive-RPC branch: `manual-allocation-events`. diff --git a/plans/2026-04-06-withdraw-zap-usd-amounts.md b/plans/2026-04-06-withdraw-zap-usd-amounts.md deleted file mode 100644 index 1de0b2b9e..000000000 --- a/plans/2026-04-06-withdraw-zap-usd-amounts.md +++ /dev/null @@ -1,438 +0,0 @@ -# Withdraw Zap USD Amounts Implementation Plan - -> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. - -**Goal:** Fix the yearn.fi withdraw zap flow so the output USD amount is computed from raw quote data instead of compact display text, and add the missing USD values to the withdraw details section for zap flows. - -**Architecture:** Keep the quote math in the withdraw flow, where raw `bigint` quote values and token decimals already exist. Pass either raw amount metadata or preformatted USD strings into presentational components so `InputTokenAmount` and `WithdrawDetails` do not have to reverse-engineer values from compact strings like `16.1K`. Reuse existing shared formatting utilities (`formatCounterValue` / `formatAmount`) instead of introducing a new currency formatter. - -**Tech Stack:** React 19, TypeScript, Vitest, Vite, Wagmi/Viem, shared formatting helpers in `src/components/shared/utils/format.ts` - ---- - -## Problem statement - -The current withdraw zap UI has two related bugs: - -1. In `InputTokenAmount.tsx`, the zap output USD value is derived from `parseFloat(zapToken.expectedAmount) * outputTokenUsdPrice`. In withdraw flows, `expectedAmount` is currently built with `formatWidgetValue(...)`, which intentionally produces compact text like `16.1K`. `parseFloat('16.1K')` becomes `16.1`, so the UI shows `$17.85` instead of roughly `$17,850`. -2. The withdraw details section (`WithdrawDetails.tsx`) currently shows token quantities for zap rows but does not show their USD equivalents, matching the second screenshot in `/home/dev/todos/images/withdraw-zap-usd-amounts/`. - -## Known evidence - -- To-do entry: `/home/dev/todos/todo.md:65-75` -- Screenshot 1: `/home/dev/todos/images/withdraw-zap-usd-amounts/incorrect-usd-display.jpg` -- Screenshot 2: `/home/dev/todos/images/withdraw-zap-usd-amounts/more-info-missing-usd-values.jpg` -- Current buggy code: - - `src/components/pages/vaults/components/widget/InputTokenAmount.tsx:95-102` - - `src/components/pages/vaults/components/widget/withdraw/index.tsx:525-540` - - `src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.tsx:102-136` - -## Acceptance criteria - -- In withdraw zap mode, the zap output USD line under the selected output token uses the raw quote amount and renders the correct order of magnitude. -- The withdraw details section shows USD equivalents for the zap rows that currently only show token amounts. -- Non-zap withdraw flows are visually unchanged. -- Existing `InputTokenAmount` behavior outside withdraw zaps remains unchanged. -- New tests fail before the fix and pass after the fix. - ---- - -### Task 1: Document the intended UI contract for zap USD display - -**Objective:** Lock down exactly which rows should show USD values so implementation does not drift. - -**Files:** -- Modify: `plans/2026-04-06-withdraw-zap-usd-amounts.md` -- Reference: `/home/dev/todos/todo.md:65-75` -- Reference: `/home/dev/todos/images/withdraw-zap-usd-amounts/incorrect-usd-display.jpg` -- Reference: `/home/dev/todos/images/withdraw-zap-usd-amounts/more-info-missing-usd-values.jpg` - -**Step 1: Confirm target rows from current code** - -Inspect: -- `src/components/pages/vaults/components/widget/InputTokenAmount.tsx` -- `src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.tsx` -- `src/components/pages/vaults/components/widget/withdraw/WithdrawDetailsOverlay.tsx` - -Expected finding: -- The first screenshot maps to the zap token section in `InputTokenAmount`. -- The second screenshot most likely maps to the inline details rows in `WithdrawDetails`, not the overlay component. - -**Step 2: Freeze the expected copy/layout before coding** - -Use this exact rule during implementation: -- Zap output chip under the target token: show a correct USD value. -- In `WithdrawDetails`, for zap flows show USD values for: - - `You will swap` - - `You will receive at least` -- Do not add USD values to non-zap rows unless required to support the zap display. - -**Step 3: Verification note** - -When implementation is complete, manually compare the result against the screenshots and ensure the displayed USD order of magnitude is consistent with the input amount. - -**Step 4: Commit** - -No commit for this task alone; it is specification work for the following code tasks. - ---- - -### Task 2: Write failing tests for the compact-value parsing bug in `InputTokenAmount` - -**Objective:** Reproduce the broken `$17.85` style behavior in a component test before changing code. - -**Files:** -- Modify: `src/components/pages/vaults/components/widget/InputTokenAmount.test.tsx` -- Modify later: `src/components/pages/vaults/components/widget/InputTokenAmount.tsx` - -**Step 1: Add a failing test for zap USD rendering from raw quote metadata** - -Add a test similar to: - -```tsx -it('renders zap output USD from raw quote amounts instead of compact display text', async () => { - const InputTokenAmount = await loadInputTokenAmount() - const html = renderToStaticMarkup( - - ) - - expect(html).toContain('$17,871') -}) -``` - -Notes: -- Use whatever exact raw amount/price pair is easiest to assert deterministically. -- The test should prove the component no longer depends on `parseFloat('16.1K')`. - -**Step 2: Run the focused test to verify failure** - -Run: -`bunx vitest run src/components/pages/vaults/components/widget/InputTokenAmount.test.tsx` - -Expected: FAIL — new prop fields are missing and/or the HTML still contains a mis-scaled USD amount. - -**Step 3: Keep the existing tests green in the same file** - -Do not weaken the logo/balance tests already present in this file. - -**Step 4: Commit** - -```bash -git add src/components/pages/vaults/components/widget/InputTokenAmount.test.tsx -git commit -m "test: cover withdraw zap usd display regression" -``` - ---- - -### Task 3: Fix zap output USD display in `InputTokenAmount` - -**Objective:** Stop deriving USD from compact display strings and use raw quote data or an explicitly formatted USD string. - -**Files:** -- Modify: `src/components/pages/vaults/components/widget/InputTokenAmount.tsx` -- Modify: `src/components/pages/vaults/components/widget/withdraw/index.tsx` -- Reference: `src/components/shared/utils/format.ts:653-662` - -**Step 1: Extend the zap token contract with raw-amount metadata** - -Change the `zapToken` type in `InputTokenAmount.tsx` from: - -```ts -zapToken?: { - symbol: string - address: string - chainId: number - expectedAmount?: string - isLoading?: boolean -} -``` - -to something like: - -```ts -zapToken?: { - symbol: string - address: string - chainId: number - expectedAmount?: string - expectedAmountRaw?: bigint - expectedAmountDecimals?: number - isLoading?: boolean -} -``` - -**Step 2: Replace the buggy USD calculation** - -Remove the current logic: - -```ts -const outputUsdValue = useMemo(() => { - if (!zapToken?.expectedAmount || !outputTokenUsdPrice) return '0.00' - return (parseFloat(zapToken.expectedAmount) * outputTokenUsdPrice).toFixed(2) -}, [zapToken?.expectedAmount, outputTokenUsdPrice]) -``` - -Replace it with raw-aware logic using the shared formatter: - -```ts -const outputUsdValue = useMemo(() => { - if (!zapToken?.expectedAmountRaw || !zapToken?.expectedAmountDecimals || !outputTokenUsdPrice) { - return '0.00' - } - - return formatCounterValue( - formatUnits(zapToken.expectedAmountRaw, zapToken.expectedAmountDecimals), - outputTokenUsdPrice - ).replace(/^\$/, '') -}, [zapToken?.expectedAmountRaw, zapToken?.expectedAmountDecimals, outputTokenUsdPrice]) -``` - -If `expectedAmountDecimals` can be `0`, use an explicit `=== undefined` check instead of a truthy check. - -**Step 3: Populate the new fields from the withdraw flow** - -In `src/components/pages/vaults/components/widget/withdraw/index.tsx`, update the `zapToken` object to pass: - -```ts -expectedAmount: effectiveExpectedOut > 0n ? formatWidgetValue(effectiveExpectedOut, outputToken?.decimals ?? 18) : '0', -expectedAmountRaw: effectiveExpectedOut, -expectedAmountDecimals: outputToken?.decimals ?? 18, -``` - -This preserves the compact token display while making the USD calculation exact. - -**Step 4: Run the focused test to verify pass** - -Run: -`bunx vitest run src/components/pages/vaults/components/widget/InputTokenAmount.test.tsx` - -Expected: PASS - -**Step 5: Commit** - -```bash -git add src/components/pages/vaults/components/widget/InputTokenAmount.tsx src/components/pages/vaults/components/widget/withdraw/index.tsx src/components/pages/vaults/components/widget/InputTokenAmount.test.tsx -git commit -m "fix: use raw withdraw zap quote for usd display" -``` - ---- - -### Task 4: Write failing tests for missing USD values in withdraw details - -**Objective:** Prove that withdraw zap details currently omit USD values and lock in the desired text. - -**Files:** -- Create: `src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.test.tsx` -- Modify later: `src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.tsx` - -**Step 1: Add a focused test file for withdraw details** - -Create a new test file that renders `WithdrawDetails` with a zap route: - -```tsx -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it } from 'vitest' -import { WithdrawDetails } from './WithdrawDetails' - -describe('WithdrawDetails', () => { - it('shows usd values for zap swap and receive rows', () => { - const html = renderToStaticMarkup( - {}} - /> - ) - - expect(html).toContain('($17,900') - expect(html).toContain('($17,871') - }) -}) -``` - -Use exact assertion strings that match the formatter selected in implementation. - -**Step 2: Run the focused test to verify failure** - -Run: -`bunx vitest run src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.test.tsx` - -Expected: FAIL — current markup contains token quantities only. - -**Step 3: Add a non-zap safety test if needed** - -If implementation adds conditional rendering branches, add a second test asserting non-zap rows do not suddenly render extra USD text. - -**Step 4: Commit** - -```bash -git add src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.test.tsx -git commit -m "test: cover missing withdraw zap detail usd values" -``` - ---- - -### Task 5: Add USD values to the withdraw details rows for zap flows - -**Objective:** Render the missing USD values in the withdraw details section without changing non-zap behavior. - -**Files:** -- Modify: `src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.tsx` -- Reference: `src/components/shared/utils/format.ts:653-662` - -**Step 1: Compute formatted USD strings inside `WithdrawDetails`** - -Add memoized or inline derived strings such as: - -```ts -const withdrawUsdDisplay = formatCounterValue(formatUnits(withdrawAmountBn, assetDecimals), assetUsdPrice) -const expectedOutUsdDisplay = formatCounterValue(formatUnits(expectedOut, outputDecimals), outputUsdPrice) -``` - -Keep the existing price impact math unchanged unless refactoring makes it cleaner to reuse the normalized values. - -**Step 2: Update the zap-specific rows to render USD text** - -Change the swap row from: - -```tsx -{withdrawAmountSimple} -{assetSymbol} -``` - -to a rendering pattern like: - -```tsx -{withdrawAmountSimple}{' '} -{assetSymbol} -{` (${withdrawUsdDisplay})`} -``` - -And change the receive row to append the USD equivalent for zap routes: - -```tsx -{formatWidgetValue(expectedOut, outputDecimals)}{' '} -{outputSymbol} -{routeType === 'ENSO' && {` (${expectedOutUsdDisplay})`}} -``` - -**Step 3: Preserve existing loading and high-price-impact behavior** - -Do not regress: -- the skeleton shown during quote loading -- the red highlight when price impact is high -- the `at least` wording for ENSO routes - -**Step 4: Run the focused test to verify pass** - -Run: -`bunx vitest run src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.test.tsx` - -Expected: PASS - -**Step 5: Commit** - -```bash -git add src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.tsx src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.test.tsx -git commit -m "fix: show usd values in withdraw zap details" -``` - ---- - -### Task 6: Verify the end-to-end withdraw zap path and clean up - -**Objective:** Ensure the new props and formatting changes integrate cleanly with the existing withdraw widget. - -**Files:** -- Verify: `src/components/pages/vaults/components/widget/InputTokenAmount.tsx` -- Verify: `src/components/pages/vaults/components/widget/withdraw/index.tsx` -- Verify: `src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.tsx` -- Verify: `src/components/pages/vaults/components/widget/InputTokenAmount.test.tsx` -- Verify: `src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.test.tsx` - -**Step 1: Run the targeted tests together** - -Run: -`bunx vitest run src/components/pages/vaults/components/widget/InputTokenAmount.test.tsx src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.test.tsx` - -Expected: PASS - -**Step 2: Run type-checking** - -Run: -`bun run tslint` - -Expected: PASS - -**Step 3: Run formatting/linting** - -Run: -`bun run lint:fix` - -Expected: PASS - -**Step 4: Optional manual verification in dev server** - -Run: -`bun run dev` - -Then verify on a vault with a working ENSO withdraw route that: -- the output token USD line under the zap token shows the correct magnitude -- the inline details rows now include USD values for the zap swap/receive rows -- direct withdraws remain visually unchanged - -**Step 5: Commit** - -```bash -git add src/components/pages/vaults/components/widget/InputTokenAmount.tsx src/components/pages/vaults/components/widget/withdraw/index.tsx src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.tsx src/components/pages/vaults/components/widget/InputTokenAmount.test.tsx src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.test.tsx -git commit -m "fix: correct withdraw zap usd displays" -``` - ---- - -## Notes and pitfalls - -- Do not compute USD from `formatWidgetValue(...)` output; that formatter is intentionally human-readable, not machine-parseable. -- Prefer passing raw quote metadata or already formatted USD strings down to presentational components. -- Use explicit `undefined` checks for decimal props; `0` is a valid numeric value even if not expected here. -- Keep non-zap withdraw rendering stable. -- If product review decides the missing-USD screenshot actually refers to `WithdrawDetailsOverlay.tsx`, add a follow-up task to pass the same formatted USD strings into the overlay bullets too. Do not conflate that with the inline `WithdrawDetails` fix unless verified. - -## Execution handoff - -Plan complete and saved. Ready to execute using subagent-driven-development — I’ll dispatch a fresh subagent per task with two-stage review (spec compliance then code quality) if you want me to implement it next. diff --git a/src/components/IframeAutoConnect.test.tsx b/src/components/IframeAutoConnect.test.tsx new file mode 100644 index 000000000..d1fa88c83 --- /dev/null +++ b/src/components/IframeAutoConnect.test.tsx @@ -0,0 +1,81 @@ +import { IframeAutoConnect } from '@components/IframeAutoConnect' +import { isIframe, isTrustedEmbed } from '@shared/utils/helpers' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useAccount, useConnect, useDisconnect } from 'wagmi' + +vi.mock('wagmi', () => ({ + useAccount: vi.fn(), + useConnect: vi.fn(), + useDisconnect: vi.fn() +})) + +vi.mock('@shared/hooks/useAsyncTrigger', () => ({ + useAsyncTrigger: vi.fn((effect: () => Promise) => { + void effect() + return effect + }) +})) + +vi.mock('@shared/utils/helpers', async () => { + const actual = await vi.importActual('@shared/utils/helpers') + + return { + ...actual, + isIframe: vi.fn(), + isTrustedEmbed: vi.fn() + } +}) + +const connectAsync = vi.fn() +const disconnectAsync = vi.fn() +const safeConnector = { + id: 'safe', + isAuthorized: vi.fn().mockResolvedValue(false) +} +const originalWindow = globalThis.window + +function mockWindow(ancestorOrigins: string[] = []): void { + ;(globalThis as unknown as { window: { location: { ancestorOrigins: string[] } } }).window = { + location: { ancestorOrigins } + } +} + +function mockWalletHooks(connector?: { id: string }): void { + vi.mocked(useAccount).mockReturnValue({ connector } as ReturnType) + vi.mocked(useConnect).mockReturnValue({ + connectors: [safeConnector], + connectAsync + } as unknown as ReturnType) + vi.mocked(useDisconnect).mockReturnValue({ disconnectAsync } as unknown as ReturnType) +} + +describe('IframeAutoConnect', () => { + afterEach(() => { + vi.clearAllMocks() + ;(globalThis as unknown as { window: typeof originalWindow }).window = originalWindow + }) + + it('does not connect in an arbitrary iframe when a Safe connector exists', () => { + mockWindow() + mockWalletHooks() + vi.mocked(isIframe).mockReturnValue(true) + vi.mocked(isTrustedEmbed).mockReturnValue(false) + + IframeAutoConnect({ children:
child
}) + + expect(connectAsync).not.toHaveBeenCalled() + expect(disconnectAsync).not.toHaveBeenCalled() + }) + + it('connects the Safe connector only in a trusted iframe', async () => { + mockWindow(['https://app.safe.global']) + mockWalletHooks() + vi.mocked(isIframe).mockReturnValue(true) + vi.mocked(isTrustedEmbed).mockReturnValue(true) + + IframeAutoConnect({ children:
child
}) + + await vi.waitFor(() => expect(connectAsync).toHaveBeenCalledWith({ connector: safeConnector })) + expect(disconnectAsync).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/IframeAutoConnect.tsx b/src/components/IframeAutoConnect.tsx index 2290637e4..7663d6829 100644 --- a/src/components/IframeAutoConnect.tsx +++ b/src/components/IframeAutoConnect.tsx @@ -1,5 +1,5 @@ import { useAsyncTrigger } from '@shared/hooks/useAsyncTrigger' -import { isIframe } from '@shared/utils/helpers' +import { isIframe, isTrustedEmbed } from '@shared/utils/helpers' import type { FC, PropsWithChildren } from 'react' import { useAccount, useConnect, useDisconnect } from 'wagmi' @@ -9,7 +9,7 @@ export const IframeAutoConnect: FC = ({ children }) => { const { disconnectAsync } = useDisconnect() useAsyncTrigger(async () => { - if (typeof window === 'undefined' || !isIframe()) { + if (typeof window === 'undefined' || !isIframe() || !isTrustedEmbed()) { return } diff --git a/src/components/pages/vaults/components/widget/deposit/ApprovalOverlay.tsx b/src/components/pages/vaults/components/widget/deposit/ApprovalOverlay.tsx index baf6b7ecf..3821de10e 100644 --- a/src/components/pages/vaults/components/widget/deposit/ApprovalOverlay.tsx +++ b/src/components/pages/vaults/components/widget/deposit/ApprovalOverlay.tsx @@ -110,7 +110,11 @@ export const ApprovalOverlay: FC = ({ if (nextTxState === 'error') { setTxState('error') - setErrorMessage('Transaction failed in Safe. Please review your Safe queue and try again.') + setErrorMessage( + isWalletSafe + ? 'Transaction failed in Safe. Please review your Safe queue and try again.' + : 'Transaction failed. Please try again.' + ) reset() } }, [ diff --git a/src/components/pages/vaults/components/widget/shared/TransactionOverlay.tsx b/src/components/pages/vaults/components/widget/shared/TransactionOverlay.tsx index f83c10383..e026241d9 100644 --- a/src/components/pages/vaults/components/widget/shared/TransactionOverlay.tsx +++ b/src/components/pages/vaults/components/widget/shared/TransactionOverlay.tsx @@ -878,7 +878,11 @@ export const TransactionOverlay: FC = ({ if (nextOverlayState === 'error') { setOverlayState('error') - setErrorMessage('Transaction failed in Safe. Please review your Safe queue and try again.') + setErrorMessage( + isWalletSafe + ? 'Transaction failed in Safe. Please review your Safe queue and try again.' + : 'Transaction failed. Please try again.' + ) resetTxState() void handleUpdateNotification({ status: 'error' }) setNotificationId(undefined) diff --git a/src/components/pages/vaults/hooks/actions/useDirectDeposit.ts b/src/components/pages/vaults/hooks/actions/useDirectDeposit.ts index 480c64c7e..34e14c636 100644 --- a/src/components/pages/vaults/hooks/actions/useDirectDeposit.ts +++ b/src/components/pages/vaults/hooks/actions/useDirectDeposit.ts @@ -48,6 +48,7 @@ export function useDirectDeposit(params: UseDirectDepositParams): UseWidgetDepos functionName: 'approve', address: params.assetAddress, args: params.amount > 0n && params.vaultAddress ? [params.vaultAddress, params.amount] : undefined, + account: params.account ? toAddress(params.account) : undefined, chainId: params.chainId, query: { enabled: prepareApproveEnabled } }) diff --git a/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapDeposit.ts b/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapDeposit.ts index 4f68b4dff..57ee77d82 100644 --- a/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapDeposit.ts +++ b/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapDeposit.ts @@ -43,6 +43,7 @@ export function useYvUsdLockedZapDeposit(params: UseYvUsdLockedZapDepositParams) functionName: 'approve', address: params.depositToken, args: params.amount > 0n ? [YVUSD_LOCKED_ZAP_ADDRESS, params.amount] : undefined, + account: params.account ? toAddress(params.account) : undefined, chainId: params.chainId, query: { enabled: prepareApproveEnabled } }) diff --git a/src/components/pages/vaults/hooks/useTenderlyVaultBalanceOverrides.test.ts b/src/components/pages/vaults/hooks/useTenderlyVaultBalanceOverrides.test.ts new file mode 100644 index 000000000..31f2eed3f --- /dev/null +++ b/src/components/pages/vaults/hooks/useTenderlyVaultBalanceOverrides.test.ts @@ -0,0 +1,82 @@ +import { YVBTC_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvBtc' +import { YVUSD_CHAIN_ID, YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' +import { toAddress } from '@shared/utils' +import { describe, expect, it } from 'vitest' +import { getVaultTenderlyOverrideTokens } from './useTenderlyVaultBalanceOverrides' + +const ASSET_ADDRESS = toAddress('0x1111111111111111111111111111111111111111') +const STAKING_ADDRESS = toAddress('0x2222222222222222222222222222222222222222') + +function tokenKeys(tokens: ReturnType): string[] { + return tokens.map((token) => `${token.chainID}:${token.address}`) +} + +describe('getVaultTenderlyOverrideTokens', () => { + it('includes the current vault, underlying asset, and staking token', () => { + const tokens = getVaultTenderlyOverrideTokens({ + currentVault: { + address: toAddress('0x3333333333333333333333333333333333333333'), + chainID: 1, + decimals: 18, + name: 'Example Vault', + symbol: 'yvEX', + token: { + address: ASSET_ADDRESS, + decimals: 6, + name: 'Example Asset', + symbol: 'EX' + } + }, + stakingAddress: STAKING_ADDRESS + }) + + expect(tokenKeys(tokens)).toEqual([ + `1:${toAddress('0x3333333333333333333333333333333333333333')}`, + `1:${ASSET_ADDRESS}`, + `1:${STAKING_ADDRESS}` + ]) + expect(tokens[0].isVaultToken).toBe(true) + expect(tokens[2].isStakingToken).toBe(true) + }) + + it('adds both yvUSD variants when the current vault is a yvUSD vault', () => { + const tokens = getVaultTenderlyOverrideTokens({ + currentVault: { + address: YVUSD_UNLOCKED_ADDRESS, + chainID: YVUSD_CHAIN_ID, + decimals: 6, + name: 'yvUSD', + symbol: 'yvUSD', + token: { + address: ASSET_ADDRESS, + decimals: 6, + name: 'USDC', + symbol: 'USDC' + } + } + }) + + expect(tokenKeys(tokens)).toContain(`${YVUSD_CHAIN_ID}:${YVUSD_UNLOCKED_ADDRESS}`) + expect(tokenKeys(tokens)).toContain(`${YVUSD_CHAIN_ID}:${YVUSD_LOCKED_ADDRESS}`) + }) + + it('keeps the yvBTC locked placeholder out of the override list until it has a real address', () => { + const tokens = getVaultTenderlyOverrideTokens({ + currentVault: { + address: YVBTC_UNLOCKED_ADDRESS, + chainID: 1, + decimals: 8, + name: 'yvBTC', + symbol: 'yvBTC', + token: { + address: ASSET_ADDRESS, + decimals: 8, + name: 'Bitcoin', + symbol: 'BTC' + } + } + }) + + expect(tokenKeys(tokens)).toEqual([`1:${YVBTC_UNLOCKED_ADDRESS}`, `1:${ASSET_ADDRESS}`]) + }) +}) diff --git a/src/components/pages/vaults/hooks/useTenderlyVaultBalanceOverrides.ts b/src/components/pages/vaults/hooks/useTenderlyVaultBalanceOverrides.ts new file mode 100644 index 000000000..da25c1de0 --- /dev/null +++ b/src/components/pages/vaults/hooks/useTenderlyVaultBalanceOverrides.ts @@ -0,0 +1,154 @@ +import { isYvBtcAddress, YVBTC_LOCKED_ADDRESS, YVBTC_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvBtc' +import { + isYvUsdAddress, + YVUSD_CHAIN_ID, + YVUSD_DECIMALS, + YVUSD_LOCKED_ADDRESS, + YVUSD_UNLOCKED_ADDRESS +} from '@pages/vaults/utils/yvUsd' +import type { TAddress } from '@shared/types' +import { isZeroAddress, toAddress } from '@shared/utils' +import { useEffect, useRef } from 'react' +import type { TUseBalancesTokens } from '@/components/shared/hooks/useBalances.multichains' +import { isTenderlyModeEnabled } from '@/config/tenderly' +import type { TKongVaultView } from '../domain/kongVaultSelectors' + +type TTenderlyVaultBalanceOverrideVault = Pick< + TKongVaultView, + 'address' | 'chainID' | 'decimals' | 'name' | 'symbol' +> & { + token: Pick +} + +type TUseTenderlyVaultBalanceOverridesProps = { + account?: TAddress + currentVault?: TTenderlyVaultBalanceOverrideVault + onRefresh: (tokens: TUseBalancesTokens[]) => Promise + stakingAddress?: TAddress +} + +function addTenderlyOverrideToken( + tokens: Map, + address: string | undefined, + chainID: number | undefined, + metadata?: Partial +): void { + if (!address || !Number.isInteger(chainID) || isZeroAddress(toAddress(address))) { + return + } + + const token = { + address: toAddress(address), + chainID: chainID as number, + ...metadata + } + tokens.set(`${token.chainID}:${token.address}`, token) +} + +export function getVaultTenderlyOverrideTokens({ + currentVault, + stakingAddress +}: { + currentVault: TTenderlyVaultBalanceOverrideVault + stakingAddress?: TAddress +}): TUseBalancesTokens[] { + const tokens = new Map() + + addTenderlyOverrideToken(tokens, currentVault.address, currentVault.chainID, { + decimals: currentVault.decimals, + name: currentVault.name, + symbol: currentVault.symbol, + isVaultToken: true + }) + addTenderlyOverrideToken(tokens, currentVault.token.address, currentVault.chainID, { + decimals: currentVault.token.decimals, + name: currentVault.token.name, + symbol: currentVault.token.symbol + }) + addTenderlyOverrideToken(tokens, stakingAddress, currentVault.chainID, { + decimals: currentVault.decimals, + name: currentVault.name, + symbol: currentVault.symbol, + isStakingToken: true + }) + + if (isYvUsdAddress(currentVault.address)) { + addTenderlyOverrideToken(tokens, YVUSD_UNLOCKED_ADDRESS, YVUSD_CHAIN_ID, { + decimals: YVUSD_DECIMALS, + name: 'yvUSD', + symbol: 'yvUSD', + isVaultToken: true + }) + addTenderlyOverrideToken(tokens, YVUSD_LOCKED_ADDRESS, YVUSD_CHAIN_ID, { + decimals: YVUSD_DECIMALS, + name: 'yvUSD (Locked)', + symbol: 'yvUSD', + isVaultToken: true + }) + } + + if (isYvBtcAddress(currentVault.address)) { + addTenderlyOverrideToken(tokens, YVBTC_UNLOCKED_ADDRESS, currentVault.chainID, { + decimals: currentVault.decimals, + name: currentVault.name, + symbol: currentVault.symbol, + isVaultToken: true + }) + addTenderlyOverrideToken(tokens, YVBTC_LOCKED_ADDRESS, currentVault.chainID, { + decimals: currentVault.decimals, + name: currentVault.name, + symbol: currentVault.symbol, + isVaultToken: true + }) + } + + return [...tokens.values()] +} + +function getTenderlyVaultOverrideRefreshKey({ + account, + currentVault, + stakingAddress +}: { + account: TAddress + currentVault: TTenderlyVaultBalanceOverrideVault + stakingAddress?: TAddress +}): string { + return [account, currentVault.chainID, currentVault.address, currentVault.token.address, stakingAddress ?? ''].join( + ':' + ) +} + +export function useTenderlyVaultBalanceOverrides({ + account, + currentVault, + onRefresh, + stakingAddress +}: TUseTenderlyVaultBalanceOverridesProps): void { + const refreshKeyRef = useRef(null) + + useEffect(() => { + if (!isTenderlyModeEnabled() || !account || !currentVault) { + return + } + + const refreshKey = getTenderlyVaultOverrideRefreshKey({ account, currentVault, stakingAddress }) + if (refreshKeyRef.current === refreshKey) { + return + } + refreshKeyRef.current = refreshKey + + const tokensToRefresh = getVaultTenderlyOverrideTokens({ + currentVault, + stakingAddress + }) + if (tokensToRefresh.length === 0) { + return + } + + void onRefresh(tokensToRefresh).catch((error) => { + console.error('Failed to refresh Tenderly vault override balances:', error) + refreshKeyRef.current = null + }) + }, [account, currentVault, onRefresh, stakingAddress]) +} diff --git a/src/components/shared/components/Header.tsx b/src/components/shared/components/Header.tsx index 56fb41a8e..359c147a0 100644 --- a/src/components/shared/components/Header.tsx +++ b/src/components/shared/components/Header.tsx @@ -119,7 +119,7 @@ function getConfiguredTenderlyMappingsLabel(): string { } function TenderlyBadge(): ReactElement | null { - const { isPanelAvailable, isOpen, togglePanel } = useTenderlyPanel() + const { isPanelAvailable, isOpen, isStatusLoading, openPanel, refetchStatus, togglePanel } = useTenderlyPanel() const { chain } = useAccount() const { switchChainAsync, isPending: isSwitchingChain } = useSwitchChain() const isTenderlyConfigured = isTenderlyModeConfigured() @@ -134,22 +134,28 @@ function TenderlyBadge(): ReactElement | null { const canToggleControls = isTenderlyActive && isPanelAvailable const connectedTenderlyExecutionChain = resolveConnectedTenderlyExecutionChain(chain?.id) - const handleBadgeClick = (): void => { - if (!canToggleControls) { + const handleBadgeClick = async (): Promise => { + if (!isTenderlyActive || isStatusLoading) { return } - togglePanel() + if (canToggleControls) { + togglePanel() + return + } + + openPanel() + await refetchStatus() } const handleBadgeKeyDown = (event: KeyboardEvent): void => { - if (!canToggleControls) { + if (!isTenderlyActive || isStatusLoading) { return } if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() - togglePanel() + void handleBadgeClick() } } @@ -179,18 +185,18 @@ function TenderlyBadge(): ReactElement | null { return (
void handleBadgeClick()} onKeyDown={handleBadgeKeyDown} - role={canToggleControls ? 'button' : undefined} - tabIndex={canToggleControls ? 0 : undefined} + role={isTenderlyActive && !isStatusLoading ? 'button' : undefined} + tabIndex={isTenderlyActive && !isStatusLoading ? 0 : undefined} title={ - canToggleControls + isTenderlyActive ? `Tenderly mode enabled${configuredMappings ? ` (${configuredMappings})` : ''}. Click to ${isOpen ? 'hide' : 'show'} controls.` : `Use ${isTenderlyActive ? 'Tenderly RPCs and vnets' : 'normal RPCs'}${configuredMappings ? ` (${configuredMappings})` : ''}` } className={cl( 'inline-flex min-h-[32px] items-center gap-2 rounded-md border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] transition-all', - canToggleControls ? 'cursor-pointer hover:border-text-primary/60' : 'cursor-default', + isTenderlyActive && !isStatusLoading ? 'cursor-pointer hover:border-text-primary/60' : 'cursor-default', 'border-border bg-surface-secondary' )} > diff --git a/src/components/shared/contexts/useWeb3.tsx b/src/components/shared/contexts/useWeb3.tsx index 491f85695..bda1b4347 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 { CODEX_WALLET_ID, isCodexWalletEnabled } from '@/config/codexWallet' import { resolveConnectedCanonicalChainId, resolveExecutionChainId } from '@/config/tenderly' type TWeb3Context = { @@ -58,9 +59,31 @@ export const Web3ContextApp = (props: { children: ReactElement }): ReactElement const wasConnectedRef = useRef(false) const previousChainIDRef = useRef(undefined) const hasUserRequestedConnectionRef = useRef(false) + const hasAttemptedCodexAutoConnectRef = useRef(false) const chainID = resolveConnectedCanonicalChainId(chain?.id) ?? (isConnected ? 0 : 1) + useEffect(() => { + if (isConnected || hasAttemptedCodexAutoConnectRef.current || !isCodexWalletEnabled()) { + return + } + + const codexConnector = connectors.find((item) => item.id === CODEX_WALLET_ID) + if (!codexConnector) { + return + } + + hasAttemptedCodexAutoConnectRef.current = true + hasUserRequestedConnectionRef.current = true + void connectAsync({ + connector: codexConnector, + chainId: resolveExecutionChainId(chainID) ?? chainID + }).catch((error) => { + hasUserRequestedConnectionRef.current = false + console.error('Failed to auto-connect Codex wallet:', error) + }) + }, [chainID, connectAsync, connectors, isConnected]) + useEffect(() => { if (!wasConnectedRef.current && isConnected && hasUserRequestedConnectionRef.current) { trackEvent(PLAUSIBLE_EVENTS.CONNECT_WALLET, { diff --git a/src/components/shared/hooks/useBalancesCombined.ts b/src/components/shared/hooks/useBalancesCombined.ts index 86e3b2c3a..1f91c53dd 100644 --- a/src/components/shared/hooks/useBalancesCombined.ts +++ b/src/components/shared/hooks/useBalancesCombined.ts @@ -1,6 +1,6 @@ import { useQueryClient } from '@tanstack/react-query' import { useCallback, useMemo } from 'react' -import { getTenderlyBackedCanonicalChainIds, resolveExecutionChainId } from '@/config/tenderly' +import { resolveExecutionChainId } from '@/config/tenderly' import { useWeb3 } from '../contexts/useWeb3' import type { TChainTokens, TDict, TNDict, TToken } from '../types/mixed' import { toAddress } from '../utils/tools.address' @@ -59,10 +59,7 @@ 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 ensoUnsupportedNetworks = useMemo(() => [...ENSO_UNSUPPORTED_NETWORKS], []) const tokens = useMemo(() => (userAddress ? props?.tokens || [] : []), [props?.tokens, userAddress]) diff --git a/src/components/shared/hooks/useBalancesRouting.test.ts b/src/components/shared/hooks/useBalancesRouting.test.ts index 80ffc34e6..ca3924eb8 100644 --- a/src/components/shared/hooks/useBalancesRouting.test.ts +++ b/src/components/shared/hooks/useBalancesRouting.test.ts @@ -81,7 +81,7 @@ describe('partitionTokensByBalanceSource', () => { expect(multicallTokens.map(tokenKey)).toEqual([`250:${getAddress(STAKING_B)}`]) }) - it('routes Tenderly-backed canonical chains to multicall when Enso is disabled for them', () => { + it('routes Tenderly-backed canonical chains to Enso unless specifically requested as unsupported', () => { const tokens: TUseBalancesTokens[] = [ { address: VAULT_B, @@ -91,9 +91,9 @@ describe('partitionTokensByBalanceSource', () => { } ] - const { ensoTokens, multicallTokens } = partitionTokensByBalanceSource(tokens, [1]) - expect(ensoTokens).toHaveLength(0) - expect(multicallTokens.map(tokenKey)).toEqual([`1:${getAddress(VAULT_B)}`]) + const { ensoTokens, multicallTokens } = partitionTokensByBalanceSource(tokens, []) + expect(multicallTokens).toHaveLength(0) + expect(ensoTokens.map(tokenKey)).toEqual([`1:${getAddress(VAULT_B)}`]) }) it('dedupes duplicate entries and never routes same token to both sources', () => { diff --git a/src/components/shared/utils/helpers.test.ts b/src/components/shared/utils/helpers.test.ts new file mode 100644 index 000000000..61c50533d --- /dev/null +++ b/src/components/shared/utils/helpers.test.ts @@ -0,0 +1,86 @@ +import { isTrustedEmbed } from '@shared/utils/helpers' +import { afterEach, describe, expect, it } from 'vitest' + +type TMockWindow = { + location: { + ancestorOrigins?: string[] + } + self?: unknown + top?: unknown +} + +const originalWindow = globalThis.window +const originalDocument = globalThis.document + +function mockBrowser({ + ancestorOrigins = [], + isIframe = true, + referrer = '' +}: { + ancestorOrigins?: string[] + isIframe?: boolean + referrer?: string +}): void { + const mockWindow: TMockWindow = { + location: { ancestorOrigins } + } + + mockWindow.self = mockWindow + mockWindow.top = isIframe ? {} : mockWindow + + ;(globalThis as unknown as { window: TMockWindow }).window = mockWindow + ;(globalThis as unknown as { document: { referrer: string; location: { ancestorOrigins: string[] } } }).document = { + referrer, + location: { ancestorOrigins } + } +} + +describe('isTrustedEmbed', () => { + afterEach(() => { + ;(globalThis as unknown as { window: typeof originalWindow }).window = originalWindow + ;(globalThis as unknown as { document: typeof originalDocument }).document = originalDocument + }) + + it('returns false during server-side rendering', () => { + ;(globalThis as unknown as { window: undefined }).window = undefined + ;(globalThis as unknown as { document: undefined }).document = undefined + + expect(isTrustedEmbed()).toBe(false) + }) + + it('returns false in a top-level window', () => { + mockBrowser({ isIframe: false, referrer: 'https://app.safe.global' }) + + expect(isTrustedEmbed()).toBe(false) + }) + + it('returns false in a generic iframe', () => { + mockBrowser({ ancestorOrigins: ['https://evil-safe.example'] }) + + expect(isTrustedEmbed()).toBe(false) + }) + + it('returns true for an explicitly trusted ancestor origin', () => { + mockBrowser({ ancestorOrigins: ['https://app.safe.global'] }) + + expect(isTrustedEmbed()).toBe(true) + }) + + it('does not trust a known host over an unlisted origin', () => { + mockBrowser({ ancestorOrigins: ['https://app.safe.global:444'] }) + + expect(isTrustedEmbed()).toBe(false) + }) + + it('falls back to a trusted document referrer when ancestor origins are unavailable', () => { + mockBrowser({ referrer: 'https://app.gnosis-safe.io/apps' }) + + expect(isTrustedEmbed()).toBe(true) + }) + + it('returns false for malformed referrer data', () => { + mockBrowser({ referrer: 'not a url' }) + + expect(isTrustedEmbed()).toBe(false) + }) +}) diff --git a/src/components/shared/utils/helpers.ts b/src/components/shared/utils/helpers.ts index bbdbdb6d2..3d44bd9b7 100755 --- a/src/components/shared/utils/helpers.ts +++ b/src/components/shared/utils/helpers.ts @@ -49,6 +49,42 @@ export function isIframe(): boolean { return false } +const TRUSTED_EMBED_ORIGINS = new Set(['https://app.safe.global', 'https://app.gnosis-safe.io']) + +function getAncestorOrigin(): string | undefined { + const ancestorOrigin = window.location.ancestorOrigins?.[0]?.toString() + if (ancestorOrigin) { + return ancestorOrigin + } + + if (!document.referrer) { + return undefined + } + + try { + return new URL(document.referrer).origin + } catch (_error) { + return undefined + } +} + +export function isTrustedEmbed(): boolean { + if (typeof window === 'undefined' || typeof document === 'undefined' || !isIframe()) { + return false + } + + const ancestorOrigin = getAncestorOrigin() + if (!ancestorOrigin) { + return false + } + + try { + return TRUSTED_EMBED_ORIGINS.has(new URL(ancestorOrigin).origin) + } catch (_error) { + return false + } +} + /*************************************************************************** ** Helper function to sort elements based on the type of the element. **************************************************************************/ diff --git a/src/config/codexWallet.test.ts b/src/config/codexWallet.test.ts new file mode 100644 index 000000000..06bb6fef4 --- /dev/null +++ b/src/config/codexWallet.test.ts @@ -0,0 +1,28 @@ +import type { Chain } from 'viem' +import { describe, expect, it } from 'vitest' +import { getConnectorChain } from './codexWallet' + +const ethereum = { + id: 1, + name: 'Ethereum', + nativeCurrency: { decimals: 18, name: 'Ether', symbol: 'ETH' }, + rpcUrls: { + default: { http: ['https://ethereum.example'] } + } +} as Chain + +describe('getConnectorChain', () => { + it('returns configured chains by id', () => { + expect(getConnectorChain([ethereum], 1)).toBe(ethereum) + }) + + it('rejects unsupported chain ids instead of falling back to the default chain', () => { + expect(() => getConnectorChain([ethereum], 999)).toThrow(/Codex wallet chain 999 is not configured/) + + try { + getConnectorChain([ethereum], 999) + } catch (error) { + expect((error as { code?: number }).code).toBe(4902) + } + }) +}) diff --git a/src/config/codexWallet.ts b/src/config/codexWallet.ts new file mode 100644 index 000000000..d753f3b2a --- /dev/null +++ b/src/config/codexWallet.ts @@ -0,0 +1,340 @@ +import type { Wallet, WalletDetailsParams } from '@rainbow-me/rainbowkit' +import { + type Address, + type Chain, + createWalletClient, + custom, + fromHex, + getAddress, + type Hex, + http, + numberToHex +} from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { createConnector } from 'wagmi' +import { mock } from 'wagmi/connectors' + +export const CODEX_WALLET_ID = 'codex' +const CODEX_WALLET_NAME = 'Codex Wallet' +const CODEX_WALLET_QUERY_PARAM = 'codexWallet' +const CODEX_WALLET_ADDRESS_QUERY_PARAM = 'codexWalletAddress' +const CODEX_WALLET_STORAGE_KEY = 'dev-codex-wallet-enabled' +const CODEX_WALLET_ADDRESS_STORAGE_KEY = 'dev-codex-wallet-address' +const DEFAULT_CODEX_WALLET_ADDRESS = '0x000000000000000000000000000000000000c0DE' +const CODEX_WALLET_ICON = + 'data:image/svg+xml,%3Csvg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Crect width="48" height="48" rx="12" fill="%23065CF9"/%3E%3Cpath d="M15 15.5C15 13.57 16.57 12 18.5 12H31V17H20V31H31V36H18.5C16.57 36 15 34.43 15 32.5V15.5Z" fill="white"/%3E%3Cpath d="M25 21H34V27H25V21Z" fill="white"/%3E%3C/svg%3E' + +function readBooleanFlag(value: string | boolean | undefined): boolean | undefined { + if (typeof value === 'boolean') { + return value + } + + const normalizedValue = value?.trim().toLowerCase() + if (!normalizedValue) { + return undefined + } + + if (['1', 'on', 'true', 'yes'].includes(normalizedValue)) { + return true + } + + if (['0', 'false', 'no', 'off'].includes(normalizedValue)) { + return false + } + + return undefined +} + +function readRuntimeFlag(): boolean | undefined { + if (typeof window === 'undefined') { + return undefined + } + + try { + const url = new URL(window.location.href) + const queryFlag = readBooleanFlag(url.searchParams.get(CODEX_WALLET_QUERY_PARAM) ?? undefined) + if (queryFlag !== undefined) { + window.localStorage.setItem(CODEX_WALLET_STORAGE_KEY, queryFlag ? 'true' : 'false') + return queryFlag + } + + return readBooleanFlag(window.localStorage.getItem(CODEX_WALLET_STORAGE_KEY) ?? undefined) + } catch { + return undefined + } +} + +function resolveCodexWalletAddress(): Address { + const privateKeyAccount = resolveCodexWalletPrivateKeyAccount() + if (privateKeyAccount) { + return privateKeyAccount.address + } + + const runtimeAddress = readRuntimeAddress() + if (runtimeAddress) { + return runtimeAddress + } + + const configuredAddress = (import.meta.env.VITE_CODEX_WALLET_ADDRESS as string | undefined)?.trim() + if (!configuredAddress) { + return DEFAULT_CODEX_WALLET_ADDRESS + } + + try { + return getAddress(configuredAddress) + } catch { + console.warn(`Invalid VITE_CODEX_WALLET_ADDRESS "${configuredAddress}", using ${DEFAULT_CODEX_WALLET_ADDRESS}`) + return DEFAULT_CODEX_WALLET_ADDRESS + } +} + +function resolveCodexWalletPrivateKey(): Hex | undefined { + const configuredPrivateKey = (import.meta.env.VITE_CODEX_WALLET_PRIVATE_KEY as string | undefined)?.trim() + if (!configuredPrivateKey) { + return undefined + } + + if (!/^0x[0-9a-fA-F]{64}$/.test(configuredPrivateKey)) { + console.warn('Invalid VITE_CODEX_WALLET_PRIVATE_KEY, falling back to mock Codex wallet') + return undefined + } + + return configuredPrivateKey as Hex +} + +function resolveCodexWalletPrivateKeyAccount(): ReturnType | undefined { + const privateKey = resolveCodexWalletPrivateKey() + return privateKey ? privateKeyToAccount(privateKey) : undefined +} + +async function requestRpc(rpcUrl: string, method: string, params?: unknown): Promise { + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: 1, jsonrpc: '2.0', method, params: params ?? [] }) + }) + const payload = (await response.json()) as { + result?: unknown + error?: { message: string; code: number; data?: unknown } + } + if (payload.error) { + throw new Error(`${payload.error.message} (code ${payload.error.code})`) + } + return payload.result +} + +type UnsupportedChainError = Error & { code: number } + +export function getConnectorChain(chains: readonly Chain[], chainId: number): Chain { + const chain = chains.find((candidate) => candidate.id === chainId) + if (chain) { + return chain + } + + const error = new Error(`Codex wallet chain ${chainId} is not configured`) as UnsupportedChainError + error.code = 4902 + throw error +} + +function codexPrivateKeyConnector(walletDetails: WalletDetailsParams) { + const account = resolveCodexWalletPrivateKeyAccount() + if (!account) { + return undefined + } + + return createConnector((config) => { + let connected = true + let connectedChainId = config.chains[0].id + + const switchToConfiguredChain = (chainId: number): Chain => { + const chain = getConnectorChain(config.chains, chainId) + connectedChainId = chain.id + config.emitter.emit('change', { chainId: connectedChainId }) + return chain + } + + const getWalletClient = (chainId = connectedChainId) => { + const chain = getConnectorChain(config.chains, chainId) + const rpcUrl = chain.rpcUrls.default.http[0] + return createWalletClient({ account, chain, transport: http(rpcUrl) }) + } + + const request = async ({ method, params }: { method: string; params?: unknown }): Promise => { + if (method === 'eth_chainId') { + return numberToHex(connectedChainId) + } + + if (method === 'eth_requestAccounts' || method === 'eth_accounts') { + return connected ? [account.address] : [] + } + + if (method === 'wallet_switchEthereumChain') { + const [requestedChain] = params as [{ chainId: Hex }] + switchToConfiguredChain(fromHex(requestedChain.chainId, 'number')) + return null + } + + if (method === 'wallet_addEthereumChain') { + return null + } + + if (method === 'personal_sign') { + const [message] = params as [Hex, Address] + return getWalletClient().signMessage({ message: { raw: message } }) + } + + if (method === 'eth_sign') { + const [, message] = params as [Address, Hex] + return getWalletClient().signMessage({ message: { raw: message } }) + } + + if (method === 'eth_signTypedData_v4') { + const [, typedDataPayload] = params as [Address, string | Record] + const typedData = typeof typedDataPayload === 'string' ? JSON.parse(typedDataPayload) : typedDataPayload + const { domain, message, primaryType, types } = typedData as { + domain: Record + message: Record + primaryType: string + types: Record + } + const { EIP712Domain: _domainType, ...typedDataTypes } = types + return getWalletClient().signTypedData({ + account, + domain, + message, + primaryType, + types: typedDataTypes + } as never) + } + + if (method === 'eth_sendTransaction') { + const [transaction] = params as [Record] + const { from: _from, ...transactionRequest } = transaction + return getWalletClient().sendTransaction({ + ...transactionRequest, + account, + chain: getConnectorChain(config.chains, connectedChainId) + } as never) + } + + const chain = getConnectorChain(config.chains, connectedChainId) + return requestRpc(chain.rpcUrls.default.http[0], method, params) + } + + return { + ...walletDetails, + id: CODEX_WALLET_ID, + name: CODEX_WALLET_NAME, + type: 'codex', + async connect(parameters?: { chainId?: number }) { + const chainId = parameters?.chainId + if (chainId) { + connectedChainId = getConnectorChain(config.chains, chainId).id + } + connected = true + return { accounts: [account.address], chainId: connectedChainId } as unknown as { + accounts: withCapabilities extends true + ? readonly { address: Address; capabilities: Record }[] + : readonly Address[] + chainId: number + } + }, + async disconnect() { + connected = false + }, + async getAccounts() { + return connected ? [account.address] : [] + }, + async getChainId() { + return connectedChainId + }, + async getProvider() { + return custom({ request })({ retryCount: 0 }) + }, + async isAuthorized() { + return connected + }, + async switchChain({ chainId }) { + return switchToConfiguredChain(chainId) + }, + onAccountsChanged(accounts) { + config.emitter.emit('change', { accounts: accounts.map((item) => getAddress(item)) }) + }, + onChainChanged(chainId) { + config.emitter.emit('change', { chainId: Number(chainId) }) + }, + async onDisconnect() { + connected = false + config.emitter.emit('disconnect') + } + } + }) +} + +function parseCodexWalletAddress(value: string | null | undefined): Address | undefined { + const rawAddress = value?.trim() + if (!rawAddress) { + return undefined + } + + try { + return getAddress(rawAddress) + } catch { + console.warn(`Invalid ${CODEX_WALLET_ADDRESS_QUERY_PARAM} "${rawAddress}"`) + return undefined + } +} + +function readRuntimeAddress(): Address | undefined { + if (typeof window === 'undefined') { + return undefined + } + + try { + const url = new URL(window.location.href) + const queryAddress = parseCodexWalletAddress(url.searchParams.get(CODEX_WALLET_ADDRESS_QUERY_PARAM)) + if (queryAddress) { + window.localStorage.setItem(CODEX_WALLET_ADDRESS_STORAGE_KEY, queryAddress) + return queryAddress + } + + return parseCodexWalletAddress(window.localStorage.getItem(CODEX_WALLET_ADDRESS_STORAGE_KEY)) + } catch { + return undefined + } +} + +export function isCodexWalletEnabled(): boolean { + const configuredFlag = readBooleanFlag(import.meta.env.VITE_CODEX_WALLET) + if (import.meta.env.PROD) { + return configuredFlag === true + } + + return configuredFlag ?? readRuntimeFlag() ?? false +} + +export function codexWallet(): Wallet { + const account = resolveCodexWalletAddress() + + return { + id: CODEX_WALLET_ID, + name: CODEX_WALLET_NAME, + iconUrl: CODEX_WALLET_ICON, + iconBackground: '#065CF9', + installed: true, + createConnector: (walletDetails: WalletDetailsParams) => + codexPrivateKeyConnector(walletDetails) ?? + createConnector((config) => ({ + ...mock({ + accounts: [account], + features: { + defaultConnected: true, + reconnect: true + } + })(config), + ...walletDetails, + id: CODEX_WALLET_ID, + name: CODEX_WALLET_NAME + })) + } +} diff --git a/src/config/tenderly.test.ts b/src/config/tenderly.test.ts index 3253148d0..43f0b06ff 100644 --- a/src/config/tenderly.test.ts +++ b/src/config/tenderly.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { canonicalChains } from './chainDefinitions' import { + canToggleTenderlyModeForRuntime, getSupportedCanonicalChainsForRuntime, getSupportedChainLookupForRuntime, getSupportedExecutionChainsForRuntime, @@ -15,6 +16,25 @@ import { resolveTenderlyRpcUriForExecutionChainIdForRuntime } from './tenderly' +describe('canToggleTenderlyModeForRuntime', () => { + it('allows toggling in dev mode', () => { + expect(canToggleTenderlyModeForRuntime({ isDev: true, hostname: 'yearn.fi' })).toBe(true) + }) + + it('allows toggling on loopback hosts', () => { + expect(canToggleTenderlyModeForRuntime({ isDev: false, hostname: 'localhost' })).toBe(true) + expect(canToggleTenderlyModeForRuntime({ isDev: false, hostname: '127.0.0.1' })).toBe(true) + }) + + it('allows toggling on private Tailscale preview hosts', () => { + expect(canToggleTenderlyModeForRuntime({ isDev: false, hostname: 'dev-vm.tail197cc7.ts.net' })).toBe(true) + }) + + it('keeps toggling unavailable on regular production hosts', () => { + expect(canToggleTenderlyModeForRuntime({ isDev: false, hostname: 'yearn.fi' })).toBe(false) + }) +}) + describe('parseTenderlyRuntime', () => { it('returns the canonical chain set when Tenderly mode is disabled', () => { const runtime = parseTenderlyRuntime({}) @@ -51,6 +71,29 @@ describe('parseTenderlyRuntime', () => { ).toThrow(/Duplicate Tenderly execution chain ID 73571 configured for canonical chains 1 and 10/) }) + it('rejects Tenderly execution chain ids that shadow other canonical chain ids', () => { + expect(() => + parseTenderlyRuntime({ + VITE_TENDERLY_MODE: 'true', + VITE_TENDERLY_CHAIN_ID_FOR_1: '10', + VITE_TENDERLY_RPC_URI_FOR_1: 'https://rpc.tenderly.ethereum.example' + }) + ).toThrow( + /Tenderly canonical chain 1 \(Ethereum\) uses execution chain ID 10, which shadows supported canonical chain 10 .*Tenderly execution chain IDs must not shadow supported canonical chain IDs/ + ) + }) + + it('accepts identity Tenderly execution chain ids for the same canonical chain', () => { + const runtime = parseTenderlyRuntime({ + VITE_TENDERLY_MODE: 'true', + VITE_TENDERLY_CHAIN_ID_FOR_1: '1', + VITE_TENDERLY_RPC_URI_FOR_1: 'https://rpc.tenderly.ethereum.example' + }) + + expect(runtime.canonicalToExecutionChainId.get(1)).toBe(1) + expect(runtime.executionToCanonicalChainId.get(1)).toBe(1) + }) + it('filters canonical chains and resolves execution chain ids from runtime config', () => { const runtime = parseTenderlyRuntime({ VITE_TENDERLY_MODE: 'true', diff --git a/src/config/tenderly.ts b/src/config/tenderly.ts index fdb55750f..7290816e4 100644 --- a/src/config/tenderly.ts +++ b/src/config/tenderly.ts @@ -7,6 +7,7 @@ type TTenderlyEnv = Record const TENDERLY_MODE_STORAGE_KEY = 'dev-tenderly-mode-enabled' const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', '[::1]']) +const PRIVATE_PREVIEW_HOSTNAME_SUFFIXES = ['.ts.net'] export type TTenderlyChainConfig = { canonicalChainId: TCanonicalChainId @@ -31,13 +32,23 @@ function isLoopbackHostname(hostname: string | undefined): boolean { return LOOPBACK_HOSTNAMES.has(hostname.trim().toLowerCase()) } -function canToggleTenderlyModeForRuntime({ isDev, hostname }: { isDev: boolean; hostname?: string }): boolean { - return isDev || isLoopbackHostname(hostname) +function isPrivatePreviewHostname(hostname: string | undefined): boolean { + if (!hostname) { + return false + } + + const normalizedHostname = hostname.trim().toLowerCase() + return PRIVATE_PREVIEW_HOSTNAME_SUFFIXES.some((suffix) => normalizedHostname.endsWith(suffix)) +} + +export function canToggleTenderlyModeForRuntime({ isDev, hostname }: { isDev: boolean; hostname?: string }): boolean { + return isDev || isLoopbackHostname(hostname) || isPrivatePreviewHostname(hostname) } // 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]]) +const supportedCanonicalChainById = new Map(canonicalChains.map((chain) => [chain.id, chain])) function readEnvString(value: TEnvValue): string { if (typeof value === 'string') { @@ -164,6 +175,13 @@ export function parseTenderlyRuntime(env: TTenderlyEnv): TTenderlyRuntime { throw new Error(`Invalid Tenderly execution chain ID for canonical chain ${chain.id}: ${rawExecutionChainId}`) } + const collidingCanonicalChain = supportedCanonicalChainById.get(executionChainId) + if (collidingCanonicalChain && collidingCanonicalChain.id !== chain.id) { + throw new Error( + `Tenderly canonical chain ${chain.id} (${chain.name}) uses execution chain ID ${executionChainId}, which shadows supported canonical chain ${collidingCanonicalChain.id} (${collidingCanonicalChain.name}). Tenderly execution chain IDs must not shadow supported canonical chain IDs.` + ) + } + accumulator.push({ canonicalChainId: chain.id, executionChainId, diff --git a/src/config/wagmi.ts b/src/config/wagmi.ts index 902c6c820..d8daeb4cf 100644 --- a/src/config/wagmi.ts +++ b/src/config/wagmi.ts @@ -1,5 +1,5 @@ import { registerConfig } from '@shared/utils/wagmi' -import { connectorsForWallets } from '@rainbow-me/rainbowkit' +import { connectorsForWallets, type WalletList } from '@rainbow-me/rainbowkit' import { frameWallet, injectedWallet, @@ -10,30 +10,40 @@ import { walletConnectWallet } from '@rainbow-me/rainbowkit/wallets' import { cookieStorage, createConfig, createStorage } from 'wagmi' +import { codexWallet, isCodexWalletEnabled } from '@/config/codexWallet' 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' +const codexWallets = isCodexWalletEnabled() ? [codexWallet] : [] +const popularWallets = [ + injectedWallet, + rabbyWallet, + frameWallet, + walletConnectWallet, + rainbowWallet, + ledgerWallet, + safeWallet +] -const connectors = connectorsForWallets( - [ - { - groupName: 'Popular', - wallets: [ - injectedWallet, - rabbyWallet, - frameWallet, - walletConnectWallet, - rainbowWallet, - ledgerWallet, - safeWallet +const walletGroups: WalletList = [ + ...(codexWallets.length > 0 + ? [ + { + groupName: 'Development', + wallets: codexWallets + } ] - } - ], - { projectId, appName } -) + : []), + { + groupName: 'Popular', + wallets: popularWallets + } +] + +const connectors = connectorsForWallets(walletGroups, { projectId, appName }) const wagmiChains = getWagmiConfigChains(supportedWalletChains, supportedAppChains)