Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ VITE_RPC_URI_FOR_8453=
VITE_RPC_URI_FOR_42161=
VITE_RPC_URI_FOR_747474=

# Tenderly Virtual TestNet mode: opt-in execution-chain override layer.
# Keep the app canonical on chain IDs like 1/10/8453 and map execution only through these vars.
# Repeat the *_FOR_<canonical_chain_id> pattern for each canonical chain you enable.
VITE_TENDERLY_MODE=
VITE_TENDERLY_CHAIN_ID_FOR_1=
VITE_TENDERLY_RPC_URI_FOR_1=
# Server-only Admin RPC for local scripts and cheatcodes. Keep this private and out of client-exposed VITE_ vars.
TENDERLY_ADMIN_RPC_URI_FOR_1=
VITE_TENDERLY_EXPLORER_URI_FOR_1=

PERSONAL_TENDERLY_API_KEY=
PERSONAL_ACCOUNT_SLUG=
PERSONAL_PROJECT_SLUG=
PERSONAL_TENDERLY_RPC_NAME=
TENDERLY_TEST_TX_FROM_ADDRESS=

VITE_ALCHEMY_KEY=
VITE_INFURA_PROJECT_ID=
VITE_PARTNER_ID_ADDRESS=
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,4 @@ yearn.fi-worktree-*

# codex settings
.codex
.codex/*
30 changes: 30 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ IMPORTANT: After making code changes, always verify:

Husky runs `lint-staged` + `bun run tslint` on every commit.

## Testing

All tests live in `src/test/` — never mirror the source tree 1:1. Group related tests into a single file per domain.

**Priority: math and calculations.** APY/APR, price impact, share/asset conversions, bigint math, formatting, duration math. A wrong decimal silently loses user funds. Expected values must be human-verified independently of the code — if an AI would just read the implementation and write the expected value to match, the test is worthless.

**Allowed: safety-critical logic.** Transaction state machines (skipping steps or double-submitting costs funds), URL validation (XSS vectors, misdirected pool links). These are kept because the consequences of a bug are severe and not immediately visible.

**Deprioritised: UI/component tests.** Do not use `render`, `screen`, or `@testing-library/react`. When AI writes both the implementation and the tests, it validates its own assumptions against itself — when the implementation changes, the tests get rewritten to match, so they never actually catch anything.

**Do not test:** config assertions, boolean flag logic, string/label mapping, route resolution, schema validation, data selection/preference, viewport/layout (Tailwind standardizes breakpoints), ABI construction, balance partitioning. These are all patterns where the expected value has no independent source of truth.

## Code Style

Formatting is enforced by Biome (biome.jsonc) — do not worry about indentation, quotes, or commas.
Expand All @@ -39,6 +51,24 @@ Naming:
- Utilities: camelCase (`format.ts`)
- Types: T-prefixed (`TSortDirection`, `TVaultType`)

### useEffect — prefer alternatives

Avoid `useEffect` when a better primitive exists. Most `useEffect` usage hides derived state, duplicates event handling, or re-implements what TanStack Query already provides.

**Prefer these instead:**
- **Derived state** — compute inline or with `useMemo` instead of `useEffect(() => setX(f(y)), [y])`
- **Event handlers** — do work directly in `onClick`/`onChange` instead of setting a flag for an effect to pick up
- **TanStack Query** — use `useQuery`/`useMutation` for data fetching, never `useEffect` + `fetch` + `setState`
- **`key` prop for reset** — use `<Component key={id} />` to remount instead of `useEffect` that resets state when an ID changes
- **Conditional rendering** — render children only when preconditions are met (e.g., `{!isLoading && <Player />}`) instead of guarding inside an effect

**When `useEffect` is acceptable:**
- One-time DOM/browser API setup on mount (IntersectionObserver, event listeners, focus)
- Third-party library lifecycle (init/destroy)
- Cases where no declarative alternative exists

When writing a new `useEffect`, add a brief comment explaining why an alternative does not apply.

## Architecture

**Tech stack:** React 19, Vite, React Router (lazy-loaded), Tailwind CSS 4, TanStack Query, Wagmi/Viem/RainbowKit
Expand Down
216 changes: 215 additions & 1 deletion api/server.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,40 @@
import { serve } from 'bun'
import type {
TTenderlyFundRequest,
TTenderlyIncreaseTimeRequest,
TTenderlyRevertRequest,
TTenderlySnapshotRequest
} from '../src/components/shared/types/tenderly'
import {
buildTenderlyPanelStatus,
buildTenderlyRevertResponse,
buildTenderlySnapshotRecord,
requireTenderlyServerChain,
resolveTenderlyFundRpcRequest
} from './tenderly.helpers'
import { buildTenderlyAdminAccessDeniedResponse } from './tenderlyAccess'

const ENSO_API_BASE = 'https://api.enso.finance'
const YVUSD_APR_SERVICE_API = (
process.env.YVUSD_APR_SERVICE_API || 'https://yearn-yvusd-apr-service.vercel.app/api/aprs'
).replace(/\/$/, '')

type TTenderlyJsonRpcSuccess = {
id: string | number | null
jsonrpc: '2.0'
result: unknown
}

type TTenderlyJsonRpcError = {
id: string | number | null
jsonrpc: '2.0'
error: {
code: number
message: string
data?: unknown
}
}

async function handleYvUsdAprs(req: Request): Promise<Response> {
if (req.method !== 'GET') {
return Response.json({ error: 'Method not allowed' }, { status: 405 })
Expand Down Expand Up @@ -43,6 +73,154 @@ async function handleYvUsdAprs(req: Request): Promise<Response> {
}
}

async function parseJsonBody<T>(req: Request): Promise<T> {
try {
return (await req.json()) as T
} catch (_error) {
throw new Error('Invalid JSON body')
}
}

async function callTenderlyAdminRpc(canonicalChainId: number, method: string, params: unknown[]): Promise<unknown> {
const configuredChain = requireTenderlyServerChain(process.env, canonicalChainId)
const response = await fetch(configuredChain.adminRpcUri as string, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
id: 1,
jsonrpc: '2.0',
method,
params
})
})

if (!response.ok) {
const details = await response.text()
throw new Error(`Tenderly RPC request failed with status ${response.status}: ${details}`)
}

const payload = (await response.json()) as TTenderlyJsonRpcSuccess | TTenderlyJsonRpcError
if ('error' in payload) {
throw new Error(`${payload.error.message} (code ${payload.error.code})`)
}

return payload.result
}

function handleTenderlyStatus(req: Request): Response {
if (req.method !== 'GET') {
return Response.json({ error: 'Method not allowed' }, { status: 405 })
}

try {
return Response.json(buildTenderlyPanelStatus(process.env))
} catch (error) {
console.error('Error building Tenderly status:', error)
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to build Tenderly status' },
{ status: 500 }
)
}
}

async function handleTenderlySnapshot(req: Request): Promise<Response> {
if (req.method !== 'POST') {
return Response.json({ error: 'Method not allowed' }, { status: 405 })
}

try {
const body = await parseJsonBody<TTenderlySnapshotRequest>(req)
const configuredChain = requireTenderlyServerChain(process.env, body.canonicalChainId)
const snapshotId = await callTenderlyAdminRpc(body.canonicalChainId, 'evm_snapshot', [])
const snapshotRecord = buildTenderlySnapshotRecord({
canonicalChainId: body.canonicalChainId,
executionChainId: configuredChain.executionChainId,
snapshotId: String(snapshotId),
label: body.label,
isBaseline: body.isBaseline
})

return Response.json(snapshotRecord)
} catch (error) {
console.error('Error creating Tenderly snapshot:', error)
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to create Tenderly snapshot' },
{ status: 400 }
)
}
}

async function handleTenderlyRevert(req: Request): Promise<Response> {
if (req.method !== 'POST') {
return Response.json({ error: 'Method not allowed' }, { status: 405 })
}

try {
const body = await parseJsonBody<TTenderlyRevertRequest>(req)
const result = await callTenderlyAdminRpc(body.canonicalChainId, 'evm_revert', [body.snapshotId])

return Response.json(buildTenderlyRevertResponse(result, body.snapshotId))
} catch (error) {
console.error('Error reverting Tenderly snapshot:', error)
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to revert Tenderly snapshot' },
{ status: 400 }
)
}
}

async function handleTenderlyIncreaseTime(req: Request): Promise<Response> {
if (req.method !== 'POST') {
return Response.json({ error: 'Method not allowed' }, { status: 405 })
}

try {
const body = await parseJsonBody<TTenderlyIncreaseTimeRequest>(req)
if (!Number.isInteger(body.seconds) || body.seconds <= 0) {
throw new Error('seconds must be a positive integer')
}

const timeResult = await callTenderlyAdminRpc(body.canonicalChainId, 'evm_increaseTime', [
`0x${BigInt(body.seconds).toString(16)}`
])
const mineResult = body.mineBlock ? await callTenderlyAdminRpc(body.canonicalChainId, 'evm_mine', []) : undefined

return Response.json({
timeResult,
mineResult
})
} catch (error) {
console.error('Error increasing Tenderly time:', error)
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to increase Tenderly time' },
{ status: 400 }
)
}
}

async function handleTenderlyFund(req: Request): Promise<Response> {
if (req.method !== 'POST') {
return Response.json({ error: 'Method not allowed' }, { status: 405 })
}

try {
const body = await parseJsonBody<TTenderlyFundRequest>(req)
const { method, params } = resolveTenderlyFundRpcRequest(body)
const result = await callTenderlyAdminRpc(body.canonicalChainId, method, params)

return Response.json({
method,
result
})
} catch (error) {
console.error('Error funding Tenderly wallet:', error)
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to fund wallet on Tenderly' },
{ status: 400 }
)
}
}

function handleEnsoStatus(): Response {
const apiKey = process.env.ENSO_API_KEY
return Response.json({ configured: !!apiKey })
Expand Down Expand Up @@ -159,7 +337,7 @@ async function handleEnsoBalances(req: Request): Promise<Response> {
}

serve({
async fetch(req) {
async fetch(req, server) {
const url = new URL(req.url)

if (url.pathname === '/api/enso/status') {
Expand All @@ -178,6 +356,42 @@ serve({
return handleYvUsdAprs(req)
}

if (url.pathname === '/api/tenderly/status') {
return handleTenderlyStatus(req)
}

if (url.pathname === '/api/tenderly/snapshot') {
const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address)
if (accessDeniedResponse) {
return accessDeniedResponse
}
return handleTenderlySnapshot(req)
}

if (url.pathname === '/api/tenderly/revert') {
const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address)
if (accessDeniedResponse) {
return accessDeniedResponse
}
return handleTenderlyRevert(req)
}

if (url.pathname === '/api/tenderly/increase-time') {
const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address)
if (accessDeniedResponse) {
return accessDeniedResponse
}
return handleTenderlyIncreaseTime(req)
}

if (url.pathname === '/api/tenderly/fund') {
const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address)
if (accessDeniedResponse) {
return accessDeniedResponse
}
return handleTenderlyFund(req)
}

return new Response('Not found', { status: 404 })
},
port: 3001
Expand Down
Loading