diff --git a/scripts/warmup-prices.ts b/scripts/warmup-prices.ts index 8d1ba3e..a9dee5c 100644 --- a/scripts/warmup-prices.ts +++ b/scripts/warmup-prices.ts @@ -1,15 +1,17 @@ -import { config as loadEnv } from 'dotenv' -loadEnv(); +import { config as loadEnv } from 'dotenv'; import { chainIdToName, normalizeTokenAddress } from "../src/chains"; -import { createPool } from '../src/db' -import { DefiLlamaClient } from '../src/defillama' +import { priceCurveLpUsd } from '../src/curve'; +import { createPool } from '../src/db'; +import { DefiLlamaClient } from '../src/defillama'; +import { isBadDefiLlamaTokenKey } from '../src/bad-defillama-tokens'; +import { shouldAttemptCurveFallback } from '../src/curve-fallback'; +import { capConfidence } from '../src/format'; import { getBatchHistoricalPrices, getExistingExactTimestamps, insertTokenPrices, } from "../src/queries"; -import { estimateBlockByTimestamp, getChainClient, readVaultSharePrice } from '../src/rpc' -import { priceCurveLpUsd } from '../src/curve' +import { estimateBlockByTimestamp, getChainClient, readVaultSharePrice } from '../src/rpc'; import { isTodayNormalized, normalizedDaysInRange, @@ -17,7 +19,8 @@ import { nowUnix, parseCliDate, } from "../src/time"; -import type { HistoricalRequestTuple, KongVaultListItem, TokenPriceWrite } from '../src/types' +import type { HistoricalRequestTuple, KongVaultListItem, TokenPriceWrite } from '../src/types'; +loadEnv(); const databaseUrl = process.env.DATABASE_URL if (!databaseUrl) { @@ -33,6 +36,7 @@ const stats: WarmupStats = { insertedDirect: 0, insertedDerived: 0, insertedCurve: 0, + skippedBadDefiLlama: 0, } const defiLlama = new DefiLlamaClient(undefined, () => { stats.retries += 1 @@ -48,6 +52,7 @@ interface NormalizedVault { chainId: number vaultToken: `0x${string}` underlyingToken: `0x${string}` + underlyingSymbol: string | null symbol: string | null apiVersion: string | null decimals: number @@ -61,13 +66,14 @@ interface WarmupStats { insertedDirect: number insertedDerived: number insertedCurve: number + skippedBadDefiLlama: number } function sleep(milliseconds: number): Promise { return new Promise(resolve => setTimeout(resolve, milliseconds)) } -function parseArgs(argv: string[]): { start: number; end: number } { +function parseArgs(argv: string[]): { start: number; end: number; chain?: string; token?: `0x${string}` } { const options = new Map() for (let index = 0; index < argv.length; index += 1) { const current = argv[index] @@ -87,7 +93,10 @@ function parseArgs(argv: string[]): { start: number; end: number } { throw new Error('--start must be <= --end') } - return { start, end } + const chain = options.get('--chain')?.toLowerCase() + const token = options.has('--token') ? normalizeTokenAddress(options.get('--token')!) : undefined + + return { start, end, chain, token } } async function fetchYearnVaults(): Promise { @@ -111,6 +120,7 @@ async function fetchYearnVaults(): Promise { chainId: item.chainId, vaultToken: normalizeTokenAddress(item.address), underlyingToken: normalizeTokenAddress(item.asset.address), + underlyingSymbol: item.asset.symbol ?? null, symbol: item.symbol, apiVersion: item.apiVersion, decimals: item.decimals, @@ -123,6 +133,22 @@ async function fetchYearnVaults(): Promise { return vaults } +function filterVaults(vaults: NormalizedVault[], chain?: string, token?: `0x${string}`): NormalizedVault[] { + if (!chain && !token) { + return vaults + } + + return vaults.filter(vault => { + if (chain && vault.chain !== chain) { + return false + } + if (token && vault.vaultToken !== token && vault.underlyingToken !== token) { + return false + } + return true + }) +} + function buildDailyTimestamps(start: number, end: number): number[] { return normalizedDaysInRange(start, end) } @@ -214,6 +240,16 @@ async function warmDirectPrices( }).length const groupedMissing = groupMissingRequests(requests, existing) + // Hardcoded denylist: legacy Curve LPs where DefiLlama returns 0 / multi-million + // garbage that would outrank the Curve virtual-price fallback. Skip the API + // call so Curve can fill instead. + for (const tokenKey of Object.keys(groupedMissing)) { + if (isBadDefiLlamaTokenKey(tokenKey)) { + stats.skippedBadDefiLlama += groupedMissing[tokenKey].length + console.warn(`skip:bad-defillama-curve ${tokenKey} (${groupedMissing[tokenKey].length} timestamps)`) + delete groupedMissing[tokenKey] + } + } const payloads = buildDefiLlamaPayloads(groupedMissing) await runInGroups(payloads, async payload => { @@ -236,7 +272,7 @@ async function warmDirectPrices( timestamp: normalizeToEndOfDay(price.timestamp), price: price.price, symbol: responseCoin.symbol ?? null, - confidence: price.confidence ?? null, + confidence: capConfidence(price.confidence), source: 'defillama', }) } @@ -266,12 +302,13 @@ async function warmCurveFallbackPrices( // Underlying tokens DefiLlama can't price (e.g. old Curve LP tokens) leave a // gap that cascades into derived vault prices. Fill those from the Curve // pool's on-chain virtual price. - const underlyings = new Map() + const underlyings = new Map() for (const vault of vaults) { underlyings.set(`${vault.chain}:${vault.underlyingToken}`, { chain: vault.chain, chainId: vault.chainId, token: vault.underlyingToken, + symbol: vault.underlyingSymbol, }) } @@ -282,14 +319,18 @@ async function warmCurveFallbackPrices( } } + // Gate historical Curve probes on both sources: a valid DefiLlama row fills the + // gap (SOURCE_PRIORITY), and an existing Curve row must not be recomputed. + // getExistingExactTimestamps('defillama') already excludes denylisted LP rows, so + // those four tokens still appear missing and remain eligible for Curve fallback. + // Today always refreshes regardless of what is already stored. const [existingDefillama, existingCurve] = await Promise.all([ getExistingExactTimestamps(pool, requests, 'defillama'), getExistingExactTimestamps(pool, requests, 'curve'), ]) - const missing = requests.filter(request => { - const key = `${request.chain}:${request.token}:${request.timestamp}` - return isTodayNormalized(request.timestamp) || (!existingDefillama.has(key) && !existingCurve.has(key)) - }) + const missing = requests.filter(request => + shouldAttemptCurveFallback(request, existingDefillama, existingCurve), + ) await runInGroups(missing, async request => { try { @@ -303,6 +344,8 @@ async function warmCurveFallbackPrices( const blockNumber = await estimateBlockByTimestamp(client, underlying.chainId, request.timestamp) + // Approximate: virtual_price × DefiLlama(coin0). Residual uncertainty includes + // coin0 (interest-bearing yTokens on legacy Y/yBUSD/PAX pools). const price = await priceCurveLpUsd(client, underlying.chainId, underlying.token, blockNumber, async coin => { const coinKey = `${request.chain}:${coin}` stats.apiCalls += 1 @@ -320,7 +363,7 @@ async function warmCurveFallbackPrices( token: request.token, timestamp: request.timestamp, price, - symbol: null, + symbol: underlying.symbol, confidence: null, source: 'curve', }]) @@ -409,9 +452,13 @@ async function warmDerivedVaultPrices( } try { - const { start, end } = parseArgs(process.argv.slice(2)) + const { start, end, chain, token } = parseArgs(process.argv.slice(2)) const timestamps = buildDailyTimestamps(start, end) - const vaults = await fetchYearnVaults() + const vaults = filterVaults(await fetchYearnVaults(), chain, token) + + if ((chain || token) && vaults.length === 0) { + throw new Error(`No vaults matched filter: ${chain ?? 'any chain'} / ${token ?? 'any token'}`) + } console.info(`Warmup start: ${timestamps.length} days, ${vaults.length} vaults`) await warmDirectPrices(vaults, timestamps, stats) diff --git a/src/bad-defillama-tokens.ts b/src/bad-defillama-tokens.ts new file mode 100644 index 0000000..13f8b27 --- /dev/null +++ b/src/bad-defillama-tokens.ts @@ -0,0 +1,41 @@ +import { normalizeTokenKey } from './chains' + +// Legacy Curve LP tokens where DefiLlama stores garbage (price = 0 and/or +// multi-million spikes) that outranks the Curve virtual-price fallback. +// Enumerated from production token_prices: source=defillama AND +// (price<=0 OR price>1e6) AND a curve-source row exists for the same token. +// +// Curve is a best-effort fallback via priceCurveLpUsd (virtual_price × coin0): +// for Y / yBUSD / PAX the goal is suppressing invalid DefiLlama spikes on drained +// legacy lending pools (coin0 is an interest-bearing yToken, so the price is +// approximate). sUSD plain3 is the straightforward stablecoin case. +const BAD_DEFILLAMA_CURVE_LPS = [ + // Curve Y pool — all-zero DefiLlama history in DB (~901 rows); drained lending pool + ['ethereum', '0xdF5e0e81Dff6FAF3A7e52BA697820c5e32D806A8'], // yDAI+yUSDC+yUSDT+yTUSD + // Curve yBUSD pool — zeros + ~7e7 spikes (~920 rows); drained lending pool + ['ethereum', '0x3B3Ac5386837Dc563660FB6a0937DFAa5924333B'], // yDAI+yUSDC+yUSDT+yBUSD + // Curve PAX pool — zeros + ~2e6 spikes (~916 rows); drained lending pool + ['ethereum', '0xD905e2eaeBe188fc92179b6350807D8bd91Db0D8'], // ypaxCrv + // Curve sUSD plain3 — all-zero DefiLlama (~329 rows); plain stablecoin LP + ['ethereum', '0xC25a3A3b969415c80451098fa907EC722572917F'], // crvPlain3andSUSD +] as const + +export const BAD_DEFILLAMA_TOKEN_KEYS: ReadonlySet = new Set( + BAD_DEFILLAMA_CURVE_LPS.map(([chain, token]) => normalizeTokenKey(chain, token)), +) + +export function isBadDefiLlamaToken(chain: string, token: string): boolean { + try { + return BAD_DEFILLAMA_TOKEN_KEYS.has(normalizeTokenKey(chain, token)) + } catch { + return false + } +} + +export function isBadDefiLlamaTokenKey(tokenKey: string): boolean { + const separatorIndex = tokenKey.indexOf(':') + if (separatorIndex <= 0 || separatorIndex === tokenKey.length - 1) { + return false + } + return isBadDefiLlamaToken(tokenKey.slice(0, separatorIndex), tokenKey.slice(separatorIndex + 1)) +} diff --git a/src/curve-fallback.ts b/src/curve-fallback.ts new file mode 100644 index 0000000..c36ab71 --- /dev/null +++ b/src/curve-fallback.ts @@ -0,0 +1,20 @@ +import { isTodayNormalized } from './time' +import type { HistoricalRequestTuple } from './types' + +// Decide whether warmCurveFallbackPrices should probe Curve for this request. +// Historical: only when neither a usable DefiLlama row nor a Curve row exists. +// Today: always refresh. Denylisted DefiLlama rows are already excluded by +// getExistingExactTimestamps(..., 'defillama'), so they appear missing here. +export function shouldAttemptCurveFallback( + request: HistoricalRequestTuple, + existingDefillama: ReadonlySet, + existingCurve: ReadonlySet, + now?: number, +): boolean { + if (isTodayNormalized(request.timestamp, now)) { + return true + } + + const key = `${request.chain}:${request.token}:${request.timestamp}` + return !existingDefillama.has(key) && !existingCurve.has(key) +} diff --git a/src/format.ts b/src/format.ts index e4e46de..ae85dcb 100644 --- a/src/format.ts +++ b/src/format.ts @@ -23,3 +23,9 @@ export function optionalResponseNumber(value: string | number | null): number | return toResponseNumber(value) } + +// DeFiLlama's confidence is a 0-1 quality score, but it occasionally comes back +// marginally above 1 (e.g. 1.01). Clamp to [0, 1] so stored values stay in range. +export function capConfidence(value: number | null | undefined): number | null { + return value == null ? null : Math.max(0, Math.min(value, 1)) +} diff --git a/src/queries.ts b/src/queries.ts index a3f8484..6c0215b 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -1,4 +1,5 @@ import type { Pool } from '@neondatabase/serverless' +import { BAD_DEFILLAMA_TOKEN_KEYS } from './bad-defillama-tokens' import { SOURCE_PRIORITY, type DbPriceRow, type ExactPriceRecord, type HistoricalRequestTuple, type PriceSource, type RangeRequest, type TokenPriceWrite } from './types' import { optionalResponseNumber, toResponseNumber } from './format' import { pgTimestampToUnix, unixToIsoTimestamp, isTodayNormalized } from './time' @@ -7,6 +8,24 @@ function buildSourceCaseExpression(column = 'tp.source'): string { return `CASE ${column} ${SOURCE_PRIORITY.map((source, index) => `WHEN '${source}' THEN ${index + 1}`).join(' ')} ELSE 999 END` } +// Drop DefiLlama rows for legacy Curve LPs known to return garbage +// (see bad-defillama-tokens). Those rows would otherwise outrank the Curve +// virtual-price fallback via SOURCE_PRIORITY. +function buildBadDefiLlamaFilter(columnPrefix = 'tp'): string { + if (BAD_DEFILLAMA_TOKEN_KEYS.size === 0) { + return 'TRUE' + } + const pairs = [...BAD_DEFILLAMA_TOKEN_KEYS].map(key => { + const separatorIndex = key.indexOf(':') + const chain = key.slice(0, separatorIndex).replace(/'/g, "''") + const token = key.slice(separatorIndex + 1).replace(/'/g, "''") + return `('${chain}', '${token}')` + }) + return `NOT (${columnPrefix}.source = 'defillama' AND (${columnPrefix}.chain, ${columnPrefix}.token) IN (${pairs.join(', ')}))` +} + +const BAD_DEFILLAMA_SQL = buildBadDefiLlamaFilter() + export async function getExactHistoricalPrice( pool: Pool, request: HistoricalRequestTuple, @@ -49,7 +68,7 @@ export async function getBatchHistoricalPrices( ON tp.chain = r.chain AND tp.token = r.token AND tp.timestamp = r.timestamp - WHERE tp.source = $${sourceIndex} + WHERE tp.source = $${sourceIndex} AND ${BAD_DEFILLAMA_SQL} ORDER BY tp.chain, tp.token, tp.timestamp ` } else { @@ -61,6 +80,7 @@ export async function getBatchHistoricalPrices( ON tp.chain = r.chain AND tp.token = r.token AND tp.timestamp = r.timestamp + WHERE ${BAD_DEFILLAMA_SQL} ORDER BY tp.chain, tp.token, tp.timestamp, ${buildSourceCaseExpression()} ` } @@ -107,7 +127,7 @@ export async function getRangeHistoricalPrices( ON tp.chain = r.chain AND tp.token = r.token AND tp.timestamp BETWEEN r.start_timestamp AND r.end_timestamp - WHERE tp.source = $${sourceIndex} + WHERE tp.source = $${sourceIndex} AND ${BAD_DEFILLAMA_SQL} ORDER BY tp.chain, tp.token, tp.timestamp ` } else { @@ -119,6 +139,7 @@ export async function getRangeHistoricalPrices( ON tp.chain = r.chain AND tp.token = r.token AND tp.timestamp BETWEEN r.start_timestamp AND r.end_timestamp + WHERE ${BAD_DEFILLAMA_SQL} ORDER BY tp.chain, tp.token, tp.timestamp, ${buildSourceCaseExpression()} ` } diff --git a/test/curve-fallback.test.ts b/test/curve-fallback.test.ts new file mode 100644 index 0000000..01db7cb --- /dev/null +++ b/test/curve-fallback.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { shouldAttemptCurveFallback } from '../src/curve-fallback' +import { normalizeToEndOfDay } from '../src/time' + +const CHAIN = 'ethereum' +const TOKEN = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' +const DENYLISTED = '0xdF5e0e81Dff6FAF3A7e52BA697820c5e32D806A8' + +// Fixed "now" so today's end-of-day is deterministic in tests. +const NOW = 1_700_000_000 // 2023-11-14T22:13:20Z +const TODAY = normalizeToEndOfDay(NOW) +const YESTERDAY = TODAY - 86_400 +const LAST_WEEK = TODAY - 7 * 86_400 + +function key(token: string, timestamp: number): string { + return `${CHAIN}:${token}:${timestamp}` +} + +function request(token: string, timestamp: number) { + return { chain: CHAIN, token, timestamp } +} + +describe('shouldAttemptCurveFallback', () => { + it('skips historical days that already have a valid DefiLlama row', () => { + const existingDefillama = new Set([key(TOKEN, YESTERDAY)]) + const existingCurve = new Set() + + expect( + shouldAttemptCurveFallback(request(TOKEN, YESTERDAY), existingDefillama, existingCurve, NOW), + ).toBe(false) + }) + + it('probes when DefiLlama is absent (including denylisted rows filtered out of the set)', () => { + // getExistingExactTimestamps(..., 'defillama') excludes denylisted LPs, so the + // key never appears here even if a garbage DefiLlama row is stored. + const existingDefillama = new Set() + const existingCurve = new Set() + + expect( + shouldAttemptCurveFallback(request(DENYLISTED, YESTERDAY), existingDefillama, existingCurve, NOW), + ).toBe(true) + expect( + shouldAttemptCurveFallback(request(TOKEN, LAST_WEEK), existingDefillama, existingCurve, NOW), + ).toBe(true) + }) + + it('skips historical days that already have a Curve row', () => { + const existingDefillama = new Set() + const existingCurve = new Set([key(DENYLISTED, YESTERDAY)]) + + expect( + shouldAttemptCurveFallback(request(DENYLISTED, YESTERDAY), existingDefillama, existingCurve, NOW), + ).toBe(false) + }) + + it('skips when both DefiLlama and Curve already exist', () => { + const existingDefillama = new Set([key(TOKEN, YESTERDAY)]) + const existingCurve = new Set([key(TOKEN, YESTERDAY)]) + + expect( + shouldAttemptCurveFallback(request(TOKEN, YESTERDAY), existingDefillama, existingCurve, NOW), + ).toBe(false) + }) + + it('always refreshes today even when both sources already have a row', () => { + const existingDefillama = new Set([key(TOKEN, TODAY)]) + const existingCurve = new Set([key(TOKEN, TODAY), key(DENYLISTED, TODAY)]) + + expect( + shouldAttemptCurveFallback(request(TOKEN, TODAY), existingDefillama, existingCurve, NOW), + ).toBe(true) + expect( + shouldAttemptCurveFallback(request(DENYLISTED, TODAY), existingDefillama, existingCurve, NOW), + ).toBe(true) + }) +}) diff --git a/test/format.test.ts b/test/format.test.ts new file mode 100644 index 0000000..f5294d6 --- /dev/null +++ b/test/format.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { + BAD_DEFILLAMA_TOKEN_KEYS, + isBadDefiLlamaToken, + isBadDefiLlamaTokenKey, +} from '../src/bad-defillama-tokens' +import { capConfidence } from '../src/format' + +describe('capConfidence', () => { + it('clamps to [0, 1]', () => { + expect(capConfidence(1.01)).toBe(1) + expect(capConfidence(-0.5)).toBe(0) + expect(capConfidence(0.7)).toBe(0.7) + }) + + it('passes through null/undefined as null', () => { + expect(capConfidence(null)).toBeNull() + expect(capConfidence(undefined)).toBeNull() + }) +}) + +describe('bad DefiLlama Curve LP denylist', () => { + it('flags the four legacy Curve LPs with garbage DefiLlama rows', () => { + // Y pool + expect(isBadDefiLlamaToken('ethereum', '0xdF5e0e81Dff6FAF3A7e52BA697820c5e32D806A8')).toBe(true) + // yBUSD pool + expect(isBadDefiLlamaToken('ethereum', '0x3B3Ac5386837Dc563660FB6a0937DFAa5924333B')).toBe(true) + // PAX pool + expect(isBadDefiLlamaToken('ethereum', '0xD905e2eaeBe188fc92179b6350807D8bd91Db0D8')).toBe(true) + // sUSD plain3 + expect(isBadDefiLlamaToken('ethereum', '0xC25a3A3b969415c80451098fa907EC722572917F')).toBe(true) + expect(isBadDefiLlamaTokenKey('ethereum:0xdF5e0e81Dff6FAF3A7e52BA697820c5e32D806A8')).toBe(true) + }) + + it('does not flag non-Curve garbage (Aerodrome / Pendle) or normal tokens', () => { + // Aerodrome — out of scope for this PR + expect(isBadDefiLlamaToken('base', '0x2223F9FE624F69Da4D8256A7bCc9104FBA7F8f75')).toBe(false) + // Pendle + expect(isBadDefiLlamaToken('arbitrum', '0x0A21291A184cf36aD3B0a0def4A17C12Cbd66A14')).toBe(false) + // USDC + expect(isBadDefiLlamaToken('ethereum', '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48')).toBe(false) + }) + + it('is case-insensitive on address', () => { + expect(isBadDefiLlamaToken('ethereum', '0xdf5e0e81dff6faf3a7e52ba697820c5e32d806a8')).toBe(true) + }) + + it('is exactly the four Curve LPs found in production', () => { + expect(BAD_DEFILLAMA_TOKEN_KEYS.size).toBe(4) + }) +}) diff --git a/test/queries.test.ts b/test/queries.test.ts new file mode 100644 index 0000000..b1e6701 --- /dev/null +++ b/test/queries.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Pool } from '@neondatabase/serverless' +import { BAD_DEFILLAMA_TOKEN_KEYS } from '../src/bad-defillama-tokens' +import { getBatchHistoricalPrices, getRangeHistoricalPrices } from '../src/queries' +import { normalizeToEndOfDay, unixToIsoTimestamp } from '../src/time' +import type { DbPriceRow } from '../src/types' + +const Y_POOL = '0xdF5e0e81Dff6FAF3A7e52BA697820c5e32D806A8' +const USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' +const TS = normalizeToEndOfDay(1_700_000_000) + +function dbRow(overrides: Partial & Pick): DbPriceRow { + return { + chain: 'ethereum', + timestamp: new Date(TS * 1000), + symbol: null, + confidence: null, + ...overrides, + } +} + +function mockPool(rows: DbPriceRow[] = []) { + const query = vi.fn(async () => ({ rows })) + return { pool: { query } as unknown as Pool, query } +} + +describe('query denylist filter', () => { + it('batch reads exclude denylisted DefiLlama rows in SQL (with and without source)', async () => { + const { pool, query } = mockPool([]) + const request = { chain: 'ethereum', token: Y_POOL, timestamp: TS } + + await getBatchHistoricalPrices(pool, [request]) + await getBatchHistoricalPrices(pool, [request], 'defillama') + await getBatchHistoricalPrices(pool, [request], 'curve') + + expect(query).toHaveBeenCalledTimes(3) + for (const [sql] of query.mock.calls) { + expect(sql).toContain("source = 'defillama'") + expect(sql).toContain(Y_POOL) + for (const key of BAD_DEFILLAMA_TOKEN_KEYS) { + const token = key.slice(key.indexOf(':') + 1) + expect(sql).toContain(token) + } + } + + // Explicit source is parameterized; denylist still ANDed in. + const [, defillamaParams] = query.mock.calls[1] + expect(defillamaParams).toContain('defillama') + const [, curveParams] = query.mock.calls[2] + expect(curveParams).toContain('curve') + }) + + it('range reads exclude denylisted DefiLlama rows in SQL (with and without source)', async () => { + const { pool, query } = mockPool([]) + const request = { + chain: 'ethereum', + token: Y_POOL, + startTimestamp: TS - 86_400, + endTimestamp: TS, + } + + await getRangeHistoricalPrices(pool, [request]) + await getRangeHistoricalPrices(pool, [request], 'defillama') + await getRangeHistoricalPrices(pool, [request], 'curve') + + expect(query).toHaveBeenCalledTimes(3) + for (const [sql] of query.mock.calls) { + expect(sql).toContain("source = 'defillama'") + expect(sql).toContain(Y_POOL) + } + }) + + it('batch without source returns Curve for denylisted LPs and DefiLlama for normal tokens', async () => { + // Simulates DISTINCT ON + SOURCE_PRIORITY after the denylist filter has already + // dropped the bad DefiLlama Y-pool row; USDC still wins via DefiLlama. + const { pool, query } = mockPool([ + dbRow({ token: Y_POOL, source: 'curve', price: '1.02', confidence: null, symbol: 'yCRV' }), + dbRow({ token: USDC, source: 'defillama', price: '1', confidence: '0.99', symbol: 'USDC' }), + ]) + + const rows = await getBatchHistoricalPrices(pool, [ + { chain: 'ethereum', token: Y_POOL, timestamp: TS }, + { chain: 'ethereum', token: USDC, timestamp: TS }, + ]) + + expect(query).toHaveBeenCalledOnce() + const [sql] = query.mock.calls[0] + expect(sql).toContain('DISTINCT ON') + expect(sql).toMatch(/CASE tp\.source[\s\S]*WHEN 'defillama' THEN 1/) + expect(sql).toMatch(/WHEN 'curve' THEN 4/) + + expect(rows).toEqual([ + { + chain: 'ethereum', + token: Y_POOL, + timestamp: TS, + price: 1.02, + symbol: 'yCRV', + confidence: null, + source: 'curve', + }, + { + chain: 'ethereum', + token: USDC, + timestamp: TS, + price: 1, + symbol: 'USDC', + confidence: 0.99, + source: 'defillama', + }, + ]) + }) + + it('batch with source=curve still returns Curve rows for denylisted LPs', async () => { + const { pool, query } = mockPool([ + dbRow({ token: Y_POOL, source: 'curve', price: '1.02', confidence: null }), + ]) + + const rows = await getBatchHistoricalPrices( + pool, + [{ chain: 'ethereum', token: Y_POOL, timestamp: TS }], + 'curve', + ) + + const [sql, params] = query.mock.calls[0] + expect(sql).toContain('tp.source = $') + expect(params).toContain('curve') + expect(params).toContain(unixToIsoTimestamp(TS)) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ token: Y_POOL, source: 'curve', price: 1.02, confidence: null }) + }) + + it('range without source preserves SOURCE_PRIORITY ordering expression', async () => { + const { pool, query } = mockPool([]) + + await getRangeHistoricalPrices(pool, [{ + chain: 'ethereum', + token: USDC, + startTimestamp: TS - 86_400, + endTimestamp: TS, + }]) + + const [sql] = query.mock.calls[0] + expect(sql).toContain('DISTINCT ON') + expect(sql).toMatch(/WHEN 'defillama' THEN 1/) + expect(sql).toMatch(/WHEN 'curve' THEN 4/) + expect(sql).toMatch(/WHEN 'derived' THEN 5/) + }) +})