Skip to content
Merged
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
24 changes: 22 additions & 2 deletions server/api/internal/proxy/intrinsic-apy-overrides.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createError, getMethod, getQuery, setResponseHeaders } from 'h3'
import type { Address } from 'viem'
import { fetchWithTimeout } from '~/server/utils/fetchWithTimeout'
import { createRateLimiter } from '~/server/utils/rate-limit'
import { createTtlCache } from '~/server/utils/cache'
import { logger } from '~/server/utils/logger'
import {
extractHyperbeatWeightedApr,
Expand All @@ -12,6 +13,9 @@ import {
const ALLOWED_METHODS = new Set(['GET', 'HEAD'])
const PENDLE_STALE_MS = 2 * 60 * 60 * 1000

const cache = createTtlCache<IntrinsicApyOverrideRow[]>({ ttlMs: 300_000, maxEntries: 2 })
const inFlight = new Map<number, Promise<IntrinsicApyOverrideRow[]>>()

const URLS = {
defillama: 'https://yields.llama.fi/pools',
hyperbeat: 'https://api.hyperbeat.org/api/v1/staking?address=0xCeaD893b162D38e714D82d06a7fe0b0dc3c38E0b',
Expand Down Expand Up @@ -123,6 +127,23 @@ const fetchMonad = async (): Promise<IntrinsicApyOverrideRow[]> => {
return await fetchDefillama(143, monadDefillamaSources)
}

const fetchCached = async (chainId: number): Promise<IntrinsicApyOverrideRow[]> => {
const key = String(chainId)
const cached = cache.get(key)
if (cached !== undefined) return cached
const pending = inFlight.get(chainId)
if (pending) return pending

const request = (chainId === 143 ? fetchMonad() : fetchHyperevm())
.then((rows) => {
cache.set(key, rows)
return rows
})
.finally(() => { inFlight.delete(chainId) })
inFlight.set(chainId, request)
return request
}

export default defineEventHandler(async (event) => {
const method = getMethod(event).toUpperCase()
if (!ALLOWED_METHODS.has(method)) {
Expand All @@ -142,7 +163,6 @@ export default defineEventHandler(async (event) => {
})

if (method === 'HEAD') return undefined
if (chainId === 143) return await fetchMonad()
if (chainId === 999) return await fetchHyperevm()
if (chainId === 143 || chainId === 999) return await fetchCached(chainId)
return []
})
100 changes: 100 additions & 0 deletions tests/server/intrinsic-apy-overrides.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { H3Event } from 'h3'

const mocks = vi.hoisted(() => ({ fetch: vi.fn(), consume: vi.fn() }))
vi.mock('~/server/utils/fetchWithTimeout', () => ({ fetchWithTimeout: mocks.fetch }))
vi.mock('~/server/utils/rate-limit', () => ({ createRateLimiter: () => ({ consume: mocks.consume }) }))
vi.mock('~/server/utils/logger', () => ({ logger: { warn: vi.fn() } }))
vi.mock('h3', () => ({
getMethod: (event: { method: string }) => event.method,
getQuery: (event: { query: object }) => event.query,
setResponseHeaders: vi.fn(), createError: (error: object) => Object.assign(new Error(), error),
}))
const event = (chainId: number, extra = '', method = 'GET') => ({ method, query: { chainId, extra } }) as unknown as H3Event
const response = () => ({ ok: true, json: async () => ({ data: [] }), text: async () => '{}' })

beforeEach(() => {
vi.resetModules()
vi.useFakeTimers()
vi.setSystemTime(0)
mocks.fetch.mockReset().mockImplementation(async () => response())
mocks.consume.mockClear()
})
afterEach(() => vi.useRealTimers())

describe('intrinsic APY origin caching', () => {
it.each([[999, 8], [143, 1]])('caches chain %i despite extra query parameters', async (chain, count) => {
const { default: handler } = await import('~/server/api/internal/proxy/intrinsic-apy-overrides.get')
await handler(event(chain, 'first'))
await handler(event(chain, 'second'))
expect(mocks.fetch).toHaveBeenCalledTimes(count)
expect(mocks.consume).toHaveBeenCalledTimes(2)
vi.setSystemTime(300_000)
await handler(event(chain, 'third'))
expect(mocks.fetch).toHaveBeenCalledTimes(count * 2)
})

it.each([[999, 8], [143, 1]])('coalesces concurrent chain %i requests', async (chain, count) => {
let resolve!: () => void
const gate = new Promise<void>((done) => {
resolve = done
})
mocks.fetch.mockImplementation(async () => {
await gate
return response()
})
const { default: handler } = await import('~/server/api/internal/proxy/intrinsic-apy-overrides.get')
const first = handler(event(chain, 'first'))
const second = handler(event(chain, 'second'))
await vi.waitFor(() => expect(mocks.fetch).toHaveBeenCalledTimes(count))
resolve()
await Promise.all([first, second])
expect(mocks.fetch).toHaveBeenCalledTimes(count)
})

it('keeps chain caches separate and does not fetch for HEAD or unsupported chains', async () => {
const { default: handler } = await import('~/server/api/internal/proxy/intrinsic-apy-overrides.get')
await handler(event(999))
await handler(event(143))
expect(mocks.fetch).toHaveBeenCalledTimes(9)
await handler(event(999, '', 'HEAD'))
await handler(event(1))
expect(mocks.fetch).toHaveBeenCalledTimes(9)
})

it('keeps nonempty results isolated across concurrent chains', async () => {
mocks.fetch.mockImplementation(async () => ({
ok: true, text: async () => '{}',
json: async () => ({ data: [
{ pool: '88c6f0fd-5371-4b60-8032-ddf168b4bdd6', project: 'hyper', apy: 7 },
{ pool: '18147bfe-ee41-4762-9a95-c0ff28215798', project: 'monad', apy: 11 },
] }),
}))
const { default: handler } = await import('~/server/api/internal/proxy/intrinsic-apy-overrides.get')
const [hyper, monad] = await Promise.all([handler(event(999)), handler(event(143))])
expect(hyper).toEqual([expect.objectContaining({ chainId: 999, apy: 7 })])
expect(monad).toEqual([expect.objectContaining({ chainId: 143, apy: 11 })])
await expect(handler(event(999, 'cached'))).resolves.toEqual(hyper)
await expect(handler(event(143, 'cached'))).resolves.toEqual(monad)
expect(mocks.fetch).toHaveBeenCalledTimes(9)
})

it('caches HyperEVM all-source failure results until the origin TTL expires', async () => {
mocks.fetch.mockRejectedValue(new Error('offline'))
const { default: handler } = await import('~/server/api/internal/proxy/intrinsic-apy-overrides.get')
await expect(handler(event(999))).resolves.toEqual([])
await expect(handler(event(999, 'cached'))).resolves.toEqual([])
expect(mocks.fetch).toHaveBeenCalledTimes(8)
vi.setSystemTime(300_000)
await expect(handler(event(999))).resolves.toEqual([])
expect(mocks.fetch).toHaveBeenCalledTimes(16)
})

it('allows retry after an upstream rejection', async () => {
mocks.fetch.mockRejectedValueOnce(new Error('offline'))
const { default: handler } = await import('~/server/api/internal/proxy/intrinsic-apy-overrides.get')
await expect(handler(event(143))).rejects.toThrow('offline')
await expect(handler(event(143))).resolves.toEqual([])
expect(mocks.fetch).toHaveBeenCalledTimes(2)
})
})
Loading