diff --git a/server/api/internal/proxy/intrinsic-apy-overrides.get.ts b/server/api/internal/proxy/intrinsic-apy-overrides.get.ts index 38f2207c4..840999dbe 100644 --- a/server/api/internal/proxy/intrinsic-apy-overrides.get.ts +++ b/server/api/internal/proxy/intrinsic-apy-overrides.get.ts @@ -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, @@ -12,6 +13,9 @@ import { const ALLOWED_METHODS = new Set(['GET', 'HEAD']) const PENDLE_STALE_MS = 2 * 60 * 60 * 1000 +const cache = createTtlCache({ ttlMs: 300_000, maxEntries: 2 }) +const inFlight = new Map>() + const URLS = { defillama: 'https://yields.llama.fi/pools', hyperbeat: 'https://api.hyperbeat.org/api/v1/staking?address=0xCeaD893b162D38e714D82d06a7fe0b0dc3c38E0b', @@ -123,6 +127,23 @@ const fetchMonad = async (): Promise => { return await fetchDefillama(143, monadDefillamaSources) } +const fetchCached = async (chainId: number): Promise => { + 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)) { @@ -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 [] }) diff --git a/tests/server/intrinsic-apy-overrides.test.ts b/tests/server/intrinsic-apy-overrides.test.ts new file mode 100644 index 000000000..26af9aeca --- /dev/null +++ b/tests/server/intrinsic-apy-overrides.test.ts @@ -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((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) + }) +})