Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 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
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
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
43d9f13
Lint
rossgalloway May 16, 2026
43e51e2
Lint
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
545f829
chore: remove /icon-list route from prod.
rossgalloway May 19, 2026
2e7ba9f
feat: init redis (#1237)
w84april May 20, 2026
b4c13bc
fix: address Public Enso proxy exposes backend-keyed upstream capacity
rossgalloway May 21, 2026
e5d3780
review: harden fix for Public Enso proxy exposes backend-keyed upstre…
rossgalloway May 21, 2026
30aebeb
fix: address Enso route normalization accepts malformed quote fields
rossgalloway May 21, 2026
74eda90
review: harden fix for Enso route normalization accepts malformed quo…
rossgalloway May 21, 2026
97ec3f0
fix: address Displayed Enso quote/min-out values are not bound to signed
rossgalloway May 21, 2026
e8a93e7
review: harden fix for Displayed Enso quote/min-out values are not bo…
rossgalloway May 21, 2026
9ae3f8f
Format Enso route tests
rossgalloway May 21, 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
11 changes: 9 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ PERSONAL_TENDERLY_API_KEY=
PERSONAL_ACCOUNT_SLUG=
PERSONAL_PROJECT_SLUG=
PERSONAL_TENDERLY_RPC_NAME=
TENDERLY_TEST_TX_FROM_ADDRESS=

VITE_ALCHEMY_KEY=
VITE_INFURA_PROJECT_ID=
Expand All @@ -46,9 +47,15 @@ VITE_PLAUSIBLE_TRACK_LOCALHOST=
# Enso routing: set to 'true' to disable Enso zaps, forcing direct deposit/withdraw only
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=

# Optimization API (migrated from optimization-visualizer)
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_USERNAME=
UPSTASH_REDIS_REST_TOKEN=
ENVIO_GRAPHQL_URL=
ENVIO_PASSWORD=
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ _app
.sentryclirc

# docs
/docs
AGENTS.md
/docs/plans
/docs/temp

# playwright
.playwright-mcp
Expand All @@ -57,3 +57,4 @@ yearn.fi-worktree-*
# codex settings
.codex
.codex/*
.gstack/
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# Repository Guidelines

See `CLAUDE.md` for the canonical project structure, commands, and workflow guidance.

## GitHub PR workflow

- For GitHub write operations in this repo, prefer GitHub CLI (`gh`) over the Codex GitHub connector.
- For pull request creation, use `gh pr create` by default.
- Do not use `codex_apps.github_create_pull_request` / `mcp__codex_apps__github_create_pull_request` unless the user explicitly asks to test or debug the connector.
- You may still use GitHub connector tools for read-only lookup when useful.
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ Yearn Finance vaults interface — React 19 + TypeScript SPA (Vite, TanStack Que

```bash
bun install # Install dependencies
bun run dev # Vite dev server (3000) + API server (3001)
bun run dev # Vite dev server + Bun API server; prompts for an API port when needed
bun run preview # Vite preview + Bun API server; prompts for an API port when needed
bun run build # TypeScript check + Vite build
bun run test # Full Vitest suite
bunx vitest run src/path/to/test.ts # Single test file
Expand Down Expand Up @@ -75,7 +76,7 @@ When writing a new `useEffect`, add a brief comment explaining why an alternativ
**Key patterns:**
- Context provider chain defined in `App.tsx` — read that file for the full order
- Vault data flows through `useYearn` context → filtered/sorted via hooks in `@shared/hooks/`
- Dual server: Vite (3000) proxies `/api/*` to Bun API server (3001). In prod, `api/` runs as Vercel serverless functions.
- Dual server: `bun run dev` starts Vite plus a Bun API server and keeps `/api/*` proxied to the selected API port. In prod, `api/` runs as Vercel serverless functions.

## Multi-Chain

Expand Down
128 changes: 128 additions & 0 deletions api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# API Routes

This directory contains Vercel API functions plus `api/server.ts`, a local Bun server that mirrors the app-facing API routes on `localhost:3001`.

## Local Server

Run the local API with:

```bash
bun api/server.ts
```

`API_PORT` overrides the default `3001`. `API_SERVER_PORT` is still accepted as a backwards-compatible fallback.

`bun run dev:server` runs the same server with file watching. The Vite dev and preview scripts proxy `/api/*` to this server, using `API_PORT`, `VITE_API_PORT`, or `API_SERVER_PORT` when a non-default port is configured.

The local server adds CORS to all handled routes and includes dev-only Tenderly admin routes. Vercel production routes are implemented as individual files under `api/`.

## Route Inventory

| Route | Method | Runtime | Purpose |
|-------|--------|---------|---------|
| `/api/holdings/history` | `GET` | Vercel + local | Daily holdings chart, USD or ETH-denominated |
| `/api/holdings/progress` | `GET` | Vercel + local | Durable progress state for long holdings requests |
| `/api/holdings/breakdown` | `GET` | Vercel + local | Per-vault breakdown for a settled UTC day |
| `/api/holdings/activity` | `GET` | Vercel + local | Recent classified vault activity |
| `/api/holdings/activity-facets` | `GET` | Vercel + local | Chain facets for holdings activity filters |
| `/api/holdings/protocol-return/history` | `GET` | Vercel + local | Protocol-return history for vault exposure |
| `/api/holdings/pnl/simple-history` | `GET` | Vercel + local | Compatibility alias for protocol-return history |
| `/api/admin/invalidate-cache` | `POST` | Vercel + local | Lazy vault cache invalidation, admin-protected |
| `/api/enso/status` | `GET` | Vercel + local | Returns whether `ENSO_API_KEY` is configured |
| `/api/enso/balances` | `GET` | Vercel + local | Proxies Enso wallet balances |
| `/api/enso/route` | `GET` | Vercel + local | Proxies Enso route quotes/transactions |
| `/api/optimization/change` | `GET` | Vercel + local | Latest or historical optimization payloads from Redis |
| `/api/optimization/alignment` | `GET` | Vercel + local | Envio keeper-event alignment for a selected optimization |
| `/api/optimization/vault-state` | `POST` | Vercel + local | Live vault and strategy debt state from chain RPCs |
| `/api/yvusd/aprs` | `GET` | Vercel + local | Proxies the yvUSD APR service |
| `/api/vault/meta` | `GET` | Vercel | Serves SPA HTML with vault-specific SEO and OG tags |
| `/api/tenderly/status` | `GET` | local only | Returns configured local Tenderly chains |
| `/api/tenderly/snapshot` | `POST` | local only | Creates a Tenderly EVM snapshot |
| `/api/tenderly/revert` | `POST` | local only | Reverts a Tenderly EVM snapshot |
| `/api/tenderly/increase-time` | `POST` | local only | Advances Tenderly chain time and optionally mines |
| `/api/tenderly/fund` | `POST` | local only | Funds native or ERC-20 balances on Tenderly |

## Holdings APIs

The holdings implementation is the largest API surface here. See [`lib/holdings/README.md`](./lib/holdings/README.md) for:

- Endpoint query params and response shapes.
- Envio, Kong, yearn-prices, and DefiLlama data flow.
- `timeframe=all` support from `2024-01-01`.
- Cache schema, hashed user cache keys, and invalidation behavior.
- Historical price provider switching and yearn-prices range requests.

## Enso Proxies

`/api/enso/*` routes keep `ENSO_API_KEY` server-side and forward requests to `https://api.enso.finance`.

- `/api/enso/status` returns `{ "configured": boolean }`; the Vercel handler does not currently enforce the HTTP method.
- `/api/enso/balances` requires `eoaAddress`; Vercel always requests `chainId=all`, while the local server also accepts an optional `chainId`.
- `/api/enso/route` requires `fromAddress`, `chainId`, `tokenIn`, `tokenOut`, and `amountIn`. Optional params are `slippage`, `routingStrategy`, `destinationChainId`, and `receiver`.
- `/api/enso/balances` sets `Cache-Control: private, no-store, max-age=0, must-revalidate`.

## Optimization APIs

The optimization routes expose the current DOA optimization payloads and the local verification data used by optimization UI flows.

- `/api/optimization/change` reads optimization records from Upstash Redis keys under `doa:optimizations:*`. `vault=0x...` selects one vault, and `history=1` or `history=true` returns all records for that vault instead of the latest one.
- `/api/optimization/alignment` requires `vault=0x...`, selects the matching optimization, resolves its source chain, and fetches aligned keeper `DebtUpdated` events from Envio. It needs `ENVIO_GRAPHQL_URL`; `ENVIO_PASSWORD` is sent as a bearer token when configured.
- `/api/optimization/vault-state` accepts `POST` JSON shaped as `{ "vault": "0x...", "chainId": 1, "strategies": ["0x..."] }`, then reads live vault and strategy debt state from configured public RPC endpoints.
- Cache headers: `change` uses `public, s-maxage=600, stale-while-revalidate=60`; `alignment` and `vault-state` use `public, s-maxage=60, stale-while-revalidate=30`.

## yvUSD APR Proxy

`/api/yvusd/aprs` forwards query params to `YVUSD_APR_SERVICE_API`.

- Default upstream: `https://yearn-yvusd-apr-service.vercel.app/api/aprs`.
- Cache header: `public, s-maxage=30, stale-while-revalidate=120`.

## Vault Metadata HTML

`/api/vault/meta?chainId=1&address=0x...` validates both params, loads `dist/index.html`, injects vault-specific SEO and Open Graph tags, and returns HTML.

- `chainId` must be digits only.
- `address` must be a 20-byte EVM address.
- CDN cache header: `Vercel-CDN-Cache-Control: public, s-maxage=86400, stale-while-revalidate=604800`.

## Tenderly Local Routes

Tenderly admin routes only exist in `api/server.ts` and are blocked unless the request comes from localhost.

Required env for a configured chain:

- `VITE_TENDERLY_MODE=true`.
- `VITE_TENDERLY_CHAIN_ID_FOR_<canonicalChainId>`.
- `VITE_TENDERLY_RPC_URI_FOR_<canonicalChainId>`.
- `TENDERLY_ADMIN_RPC_URI_FOR_<canonicalChainId>` for snapshot, revert, time travel, and funding actions.

## Environment Variables

| Variable | Used by | Description |
|----------|---------|-------------|
| `API_PORT` | local server, Vite proxy | Local Bun API port, default `3001` |
| `API_SERVER_PORT` | local server, Vite proxy | Backwards-compatible local API port fallback |
| `VITE_API_PORT` | Vite proxy | Client-dev API proxy port fallback |
| `API_PROXY_TARGET` / `VITE_API_PROXY_TARGET` | Vite proxy | Explicit `/api` proxy target, overrides host/port resolution |
| `API_PROXY_HOST` | Vite proxy | Host used when Vite builds the default `/api` proxy target |
| `ENSO_API_KEY` | Enso routes | Bearer token for Enso upstream requests |
| `YVUSD_APR_SERVICE_API` | yvUSD route | Upstream APR service URL |
| `ENVIO_GRAPHQL_URL` | holdings, optimization alignment | Envio GraphQL endpoint |
| `ENVIO_PASSWORD` | holdings, optimization alignment | Optional Envio secret or bearer token |
| `VITE_RPC_URI_FOR_<id>` | holdings activity | Optional chain RPC URL for receipt enrichment |
| `HOLDINGS_PRICE_PROVIDER` | holdings | `auto`, `yearn-prices`, or `defillama` |
| `YEARN_PRICES_BASE_URL` | holdings | yearn-prices base URL |
| `YEARN_PRICES_API_URL` | holdings | Legacy alias for `YEARN_PRICES_BASE_URL` |
| `YEARN_PRICES_API_KEY` | holdings | Bearer token for yearn-prices |
| `API_KEY_PORTFOLIO` | holdings | Fallback bearer token for yearn-prices |
| `DEFILLAMA_API_KEY` | holdings | Enables DefiLlama Pro |
| `ADMIN_SECRET` | holdings admin | Required for `/api/admin/invalidate-cache` |
| `UPSTASH_REDIS_REST_URL_PORTFOLIO` | holdings | Upstash Redis REST URL for holdings cache/progress/rate limits |
| `UPSTASH_REDIS_REST_TOKEN_PORTFOLIO` | holdings | Upstash Redis REST token for holdings storage |
| `UPSTASH_REDIS_REST_URL` | optimization | Upstash Redis REST URL for optimization payloads |
| `UPSTASH_REDIS_REST_TOKEN` | optimization | Upstash Redis REST token for optimization payloads |
| `HOLDINGS_DEBUG` | local holdings | Enables holdings debug logs in `api/server.ts` |
| `VITE_TENDERLY_MODE` | local Tenderly | Enables Tenderly config parsing |
| `VITE_TENDERLY_CHAIN_ID_FOR_<id>` | local Tenderly | Tenderly execution chain ID for a canonical chain |
| `VITE_TENDERLY_RPC_URI_FOR_<id>` | local Tenderly | Public Tenderly RPC URI |
| `TENDERLY_ADMIN_RPC_URI_FOR_<id>` | local Tenderly | Admin Tenderly RPC URI |
97 changes: 97 additions & 0 deletions api/admin/invalidate-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { VercelRequest, VercelResponse } from '@vercel/node'
import { ensureHoldingsStorageInitialized, isHoldingsStorageEnabled } from '../lib/holdings'
import { invalidateVaults, type VaultIdentifier } from '../lib/holdings/services/cache'

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
}

export default async function handler(req: VercelRequest, res: VercelResponse) {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-admin-secret')

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 (UPSTASH_REDIS_REST_URL_PORTFOLIO/TOKEN_PORTFOLIO 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 {
await ensureHoldingsStorageInitialized()
if (!isHoldingsStorageEnabled()) {
return res
.status(503)
.json({ error: 'Caching not enabled (UPSTASH_REDIS_REST_URL_PORTFOLIO/TOKEN_PORTFOLIO not configured)' })
}

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' })
}
}
56 changes: 41 additions & 15 deletions api/enso/balances.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
import type { VercelRequest, VercelResponse } from '@vercel/node'
import { ENSO_BALANCES_CACHE_CONTROL } from './cache'
import { checkEnsoRateLimit, isAbortError, withEnsoTimeout } from './guard'
import { validateEnsoBalancesQuery } from './validation'

const ENSO_API_BASE = 'https://api.enso.finance'
const ENSO_BALANCES_RATE_LIMIT = 20
const ENSO_BALANCES_TIMEOUT_MS = 6_000

export default async function handler(req: VercelRequest, res: VercelResponse) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' })
}

const { eoaAddress } = req.query
const rateLimit = checkEnsoRateLimit(req, 'balances', ENSO_BALANCES_RATE_LIMIT)
if (!rateLimit.allowed) {
res.setHeader('Retry-After', String(rateLimit.retryAfter))
return res.status(429).json({ error: 'Too many Enso balance requests' })
}

if (!eoaAddress || typeof eoaAddress !== 'string') {
return res.status(400).json({ error: 'Missing or invalid eoaAddress' })
const validated = validateEnsoBalancesQuery(req.query)
if (!validated.ok) {
return res.status(400).json({ error: validated.error })
}

const apiKey = process.env.ENSO_API_KEY
Expand All @@ -20,6 +30,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
}

try {
const { eoaAddress } = validated.value
const params = new URLSearchParams({
eoaAddress,
useEoa: 'true',
Expand All @@ -28,27 +39,42 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {

const url = `${ENSO_API_BASE}/api/v1/wallet/balances?${params}`

const response = await fetch(url, {
headers: {
Authorization: `Bearer ${apiKey}`
const result = await withEnsoTimeout(ENSO_BALANCES_TIMEOUT_MS, async (signal) => {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${apiKey}`
},
signal
})

if (!response.ok) {
const errorText = await response.text()

return { errorText, response }
}

const data = await response.json()

return { data, response }
})

if (!response.ok) {
const errorText = await response.text()
console.error(`Enso API error: ${response.status}`, errorText)
return res.status(response.status).json({
if (!result.response.ok) {
const errorText = 'errorText' in result ? result.errorText : ''
console.error(`Enso API error: ${result.response.status}`, errorText)
return res.status(result.response.status).json({
error: 'Enso API error',
status: response.status,
status: result.response.status,
details: errorText
})
}

const data = await response.json()

res.setHeader('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300')
return res.status(200).json(data)
res.setHeader('Cache-Control', ENSO_BALANCES_CACHE_CONTROL)
return res.status(200).json(result.data)
} catch (error) {
if (isAbortError(error)) {
return res.status(504).json({ error: 'Enso balances request timed out' })
}

console.error('Error proxying Enso request:', error)
return res.status(500).json({ error: 'Internal server error' })
}
Expand Down
Loading
Loading