Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
37782f7
Fix simple return accounting and finalize portfolio return charts (#1…
rossgalloway Apr 26, 2026
80ef04c
fix: show pending transaction function names in overlay (#1179)
rossgalloway Apr 27, 2026
f753645
fix: refresh portfolio balances without hard reload (#1187)
rossgalloway May 4, 2026
70b5bf1
Devex: improve tenderly and dev tools (#1182)
rossgalloway May 4, 2026
10a23b8
feat: add codex wallet
w84april May 4, 2026
06ddec7
Add merkle rewards filters and make them refresh (#1180)
rossgalloway May 8, 2026
9eb7d87
bump deps
rossgalloway May 8, 2026
f0a6df9
update lockfile
rossgalloway May 8, 2026
101ebc0
feat: add codex wallet
w84april May 4, 2026
3296f8c
feat: add selective portfolio impersonation benchmark flow
rossgalloway May 12, 2026
0514966
Prep for yvBTC (#1185)
rossgalloway May 14, 2026
9414a34
update gitignore
rossgalloway May 14, 2026
566788f
Fix/pnl misc 2 (#1203)
w84april May 15, 2026
b5515a0
add vaults link to header
rossgalloway May 15, 2026
24011b1
fix: suggested vault yvUSD suggestions
rossgalloway May 15, 2026
6c9aff3
fix: handle Safe app Katana approval and improve safe transaction ove…
rossgalloway May 15, 2026
e41e852
docs: investigate tenderly rpc mismatch
rossgalloway May 16, 2026
060c1e2
feat: use enso base for tenderly balances
rossgalloway May 16, 2026
a39a1fb
Lint
rossgalloway May 16, 2026
b6ea152
Lint
rossgalloway May 16, 2026
3e8a100
Lint
rossgalloway May 16, 2026
43d9f13
Lint
rossgalloway May 16, 2026
c7147a0
feat: add codex wallet
w84april May 4, 2026
2308abe
feat: enable codex wallet signing
rossgalloway May 16, 2026
9dbcbf2
Lint
rossgalloway May 16, 2026
64953da
Lint
rossgalloway May 16, 2026
e26551b
Lint
rossgalloway May 16, 2026
43e51e2
Lint
rossgalloway May 16, 2026
e497005
Merge branch 'feat/portfolio-impersonation-perf-selective' into codex…
rossgalloway May 16, 2026
53c67aa
Merge branch 'codex/investigate-tenderly-rpc-mismatch' into codex/rec…
rossgalloway May 16, 2026
9223d97
Fix codex wallet transaction readiness
rossgalloway May 16, 2026
e20cd56
Sync Tenderly status test fixture
rossgalloway May 16, 2026
ef5d61c
Isolate Tenderly vault balance overrides
rossgalloway May 16, 2026
2bbd505
fix: tests
w84april May 18, 2026
bfd857a
chore: remove pg
w84april May 18, 2026
a705931
Merge branch 'main' into release/26-04-17
w84april May 18, 2026
564c960
chore: upd docs & remove logging
w84april May 18, 2026
c19510c
Merge branch 'release/26-04-17' into codex/reconcile-vnet-portfolio-i…
rossgalloway May 18, 2026
36c0653
delete plan docs
rossgalloway May 18, 2026
a2cbb1e
update test
rossgalloway May 18, 2026
eebfb59
Fix Tenderly preview controls
rossgalloway May 28, 2026
500eb65
fix: address Tenderly admin mutations are reachable from browser origins
rossgalloway May 21, 2026
9c33ca1
review: harden fix for Tenderly admin mutations are reachable from br…
rossgalloway May 21, 2026
029d090
fix: address Tenderly execution chain IDs can shadow canonical chain IDs
rossgalloway May 21, 2026
9bae073
fix: address Iframe and embedded wallet modes have weak session/origi…
rossgalloway May 21, 2026
1266f76
review: harden fix for Iframe and embedded wallet modes have weak ses…
rossgalloway May 21, 2026
553339f
Merge branch 'codex/fix-yearn-fi-group-tenderly-local-admin-boundary'…
rossgalloway Jun 9, 2026
9fc5249
Merge branch 'codex/reconcile-vnet-portfolio-impersonation' into feat…
rossgalloway Jun 9, 2026
29c1dc3
fix test
rossgalloway Jun 9, 2026
ca712ec
Merge remote-tracking branch 'origin/main' into feat/impersonate
rossgalloway Jun 9, 2026
1e3cccc
cleanup
rossgalloway Jun 9, 2026
f24d7fa
Fix admin access review issues
rossgalloway Jun 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
96 changes: 96 additions & 0 deletions api/admin/invalidate-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>()
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()
})
})
133 changes: 133 additions & 0 deletions api/admin/invalidate-cache.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
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<string, unknown>
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<string> {
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' })
}
}
23 changes: 23 additions & 0 deletions api/lib/holdings/services/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
28 changes: 28 additions & 0 deletions api/lib/holdings/services/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,34 @@ export async function checkCacheStaleness(
}
}

export async function invalidateVaults(vaults: VaultIdentifier[]): Promise<number> {
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,
Expand Down
43 changes: 30 additions & 13 deletions api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Response> {
const url = new URL(req.url)
const progress = await getHoldingsProgress(url.searchParams.get('id'))
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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 }))
Expand Down
Loading