diff --git a/coins/src/adapters/rwa/backed.ts b/coins/src/adapters/rwa/backed.ts index 7deffcb7e1..335a55a6c5 100644 --- a/coins/src/adapters/rwa/backed.ts +++ b/coins/src/adapters/rwa/backed.ts @@ -107,24 +107,6 @@ async function getTokenPrices(chain: string, timestamp: number) { })), ); - writes.map((w: Write) => { - writes.push({ - ...w, - PK: `asset#avax:${w.PK.substring(w.PK.indexOf(":") + 1)}`, - }); - writes.push({ - ...w, - PK: `asset#base:${w.PK.substring(w.PK.indexOf(":") + 1)}`, - }); - writes.push({ - ...w, - PK: `asset#polygon:${w.PK.substring(w.PK.indexOf(":") + 1)}`, - }); - writes.push({ - ...w, - PK: `asset#xdai:${w.PK.substring(w.PK.indexOf(":") + 1)}`, - }); - }); return writes; } diff --git a/coins/src/adapters/tokenMapping.json b/coins/src/adapters/tokenMapping.json index 5a48e01c2b..fb5dc67d8d 100644 --- a/coins/src/adapters/tokenMapping.json +++ b/coins/src/adapters/tokenMapping.json @@ -1010,6 +1010,61 @@ "decimals": "18", "symbol": "BEPRO", "to": "coingecko#bepro-network" + }, + "0x1e2c4fb7ede391d116e6b41cd0608260e8801d59": { + "decimals": "18", + "symbol": "bCSPX", + "to": "asset#ethereum:0x1e2c4fb7ede391d116e6b41cd0608260e8801d59" + }, + "0xbbcb0356bb9e6b3faa5cbf9e5f36185d53403ac9": { + "decimals": "18", + "symbol": "bCOIN", + "to": "asset#ethereum:0xbbcb0356bb9e6b3faa5cbf9e5f36185d53403ac9" + }, + "0x2f11eeee0bf21e7661a22dbbbb9068f4ad191b86": { + "decimals": "18", + "symbol": "bNIU", + "to": "asset#ethereum:0x2f11eeee0bf21e7661a22dbbbb9068f4ad191b86" + }, + "0xca30c93b02514f86d5c86a6e375e3a330b435fb5": { + "decimals": "18", + "symbol": "bIB01", + "to": "asset#ethereum:0xca30c93b02514f86d5c86a6e375e3a330b435fb5" + }, + "0x52d134c6db5889fad3542a09eaf7aa90c0fdf9e4": { + "decimals": "18", + "symbol": "bIBTA", + "to": "asset#ethereum:0x52d134c6db5889fad3542a09eaf7aa90c0fdf9e4" + }, + "0x20c64dee8fda5269a78f2d5bdba861ca1d83df7a": { + "decimals": "18", + "symbol": "bHIGH", + "to": "asset#ethereum:0x20c64dee8fda5269a78f2d5bdba861ca1d83df7a" + }, + "0x2f123cf3f37ce3328cc9b5b8415f9ec5109b45e7": { + "decimals": "18", + "symbol": "bC3M", + "to": "asset#ethereum:0x2f123cf3f37ce3328cc9b5b8415f9ec5109b45e7" + }, + "0x0f76d32cdccdcbd602a55af23eaf58fd1ee17245": { + "decimals": "18", + "symbol": "bERNA", + "to": "asset#ethereum:0x0f76d32cdccdcbd602a55af23eaf58fd1ee17245" + }, + "0x3f95aa88ddbb7d9d484aa3d482bf0a80009c52c9": { + "decimals": "18", + "symbol": "bERNX", + "to": "asset#ethereum:0x3f95aa88ddbb7d9d484aa3d482bf0a80009c52c9" + }, + "0xade6057fcafa57d6d51ffa341c64ce4814995995": { + "decimals": "18", + "symbol": "bZPR1", + "to": "asset#ethereum:0xade6057fcafa57d6d51ffa341c64ce4814995995" + }, + "0xa34c5e0abe843e10461e2c9586ea03e55dbcc495": { + "decimals": "18", + "symbol": "bNVDA", + "to": "asset#ethereum:0xa34c5e0abe843e10461e2c9586ea03e55dbcc495" } }, "evmos": { @@ -4893,6 +4948,61 @@ "decimals": "6", "symbol": "JTRSY", "to": "asset#ethereum:0x8c213ee79581ff4984583c6a801e5263418c4b86" + }, + "0x1e2c4fb7ede391d116e6b41cd0608260e8801d59": { + "decimals": "18", + "symbol": "bCSPX", + "to": "asset#ethereum:0x1e2c4fb7ede391d116e6b41cd0608260e8801d59" + }, + "0xbbcb0356bb9e6b3faa5cbf9e5f36185d53403ac9": { + "decimals": "18", + "symbol": "bCOIN", + "to": "asset#ethereum:0xbbcb0356bb9e6b3faa5cbf9e5f36185d53403ac9" + }, + "0x2f11eeee0bf21e7661a22dbbbb9068f4ad191b86": { + "decimals": "18", + "symbol": "bNIU", + "to": "asset#ethereum:0x2f11eeee0bf21e7661a22dbbbb9068f4ad191b86" + }, + "0xca30c93b02514f86d5c86a6e375e3a330b435fb5": { + "decimals": "18", + "symbol": "bIB01", + "to": "asset#ethereum:0xca30c93b02514f86d5c86a6e375e3a330b435fb5" + }, + "0x52d134c6db5889fad3542a09eaf7aa90c0fdf9e4": { + "decimals": "18", + "symbol": "bIBTA", + "to": "asset#ethereum:0x52d134c6db5889fad3542a09eaf7aa90c0fdf9e4" + }, + "0x20c64dee8fda5269a78f2d5bdba861ca1d83df7a": { + "decimals": "18", + "symbol": "bHIGH", + "to": "asset#ethereum:0x20c64dee8fda5269a78f2d5bdba861ca1d83df7a" + }, + "0x2f123cf3f37ce3328cc9b5b8415f9ec5109b45e7": { + "decimals": "18", + "symbol": "bC3M", + "to": "asset#ethereum:0x2f123cf3f37ce3328cc9b5b8415f9ec5109b45e7" + }, + "0x0f76d32cdccdcbd602a55af23eaf58fd1ee17245": { + "decimals": "18", + "symbol": "bERNA", + "to": "asset#ethereum:0x0f76d32cdccdcbd602a55af23eaf58fd1ee17245" + }, + "0x3f95aa88ddbb7d9d484aa3d482bf0a80009c52c9": { + "decimals": "18", + "symbol": "bERNX", + "to": "asset#ethereum:0x3f95aa88ddbb7d9d484aa3d482bf0a80009c52c9" + }, + "0xade6057fcafa57d6d51ffa341c64ce4814995995": { + "decimals": "18", + "symbol": "bZPR1", + "to": "asset#ethereum:0xade6057fcafa57d6d51ffa341c64ce4814995995" + }, + "0xa34c5e0abe843e10461e2c9586ea03e55dbcc495": { + "decimals": "18", + "symbol": "bNVDA", + "to": "asset#ethereum:0xa34c5e0abe843e10461e2c9586ea03e55dbcc495" } }, "base": { @@ -5515,6 +5625,66 @@ "decimals": "18", "symbol": "efixDI", "to": "asset#polygon:0x04082b283818d9d0dd9ee8742892eee5cc396441" + }, + "0x1e2c4fb7ede391d116e6b41cd0608260e8801d59": { + "decimals": "18", + "symbol": "bCSPX", + "to": "asset#ethereum:0x1e2c4fb7ede391d116e6b41cd0608260e8801d59" + }, + "0xbbcb0356bb9e6b3faa5cbf9e5f36185d53403ac9": { + "decimals": "18", + "symbol": "bCOIN", + "to": "asset#ethereum:0xbbcb0356bb9e6b3faa5cbf9e5f36185d53403ac9" + }, + "0x2f11eeee0bf21e7661a22dbbbb9068f4ad191b86": { + "decimals": "18", + "symbol": "bNIU", + "to": "asset#ethereum:0x2f11eeee0bf21e7661a22dbbbb9068f4ad191b86" + }, + "0xca30c93b02514f86d5c86a6e375e3a330b435fb5": { + "decimals": "18", + "symbol": "bIB01", + "to": "asset#ethereum:0xca30c93b02514f86d5c86a6e375e3a330b435fb5" + }, + "0x52d134c6db5889fad3542a09eaf7aa90c0fdf9e4": { + "decimals": "18", + "symbol": "bIBTA", + "to": "asset#ethereum:0x52d134c6db5889fad3542a09eaf7aa90c0fdf9e4" + }, + "0x20c64dee8fda5269a78f2d5bdba861ca1d83df7a": { + "decimals": "18", + "symbol": "bHIGH", + "to": "asset#ethereum:0x20c64dee8fda5269a78f2d5bdba861ca1d83df7a" + }, + "0x2f123cf3f37ce3328cc9b5b8415f9ec5109b45e7": { + "decimals": "18", + "symbol": "bC3M", + "to": "asset#ethereum:0x2f123cf3f37ce3328cc9b5b8415f9ec5109b45e7" + }, + "0x0f76d32cdccdcbd602a55af23eaf58fd1ee17245": { + "decimals": "18", + "symbol": "bERNA", + "to": "asset#ethereum:0x0f76d32cdccdcbd602a55af23eaf58fd1ee17245" + }, + "0x3f95aa88ddbb7d9d484aa3d482bf0a80009c52c9": { + "decimals": "18", + "symbol": "bERNX", + "to": "asset#ethereum:0x3f95aa88ddbb7d9d484aa3d482bf0a80009c52c9" + }, + "0xade6057fcafa57d6d51ffa341c64ce4814995995": { + "decimals": "18", + "symbol": "bZPR1", + "to": "asset#ethereum:0xade6057fcafa57d6d51ffa341c64ce4814995995" + }, + "0xa34c5e0abe843e10461e2c9586ea03e55dbcc495": { + "decimals": "18", + "symbol": "bNVDA", + "to": "asset#ethereum:0xa34c5e0abe843e10461e2c9586ea03e55dbcc495" + }, + "0xc3ce78b037dda1b966d31ec7979d3f3a38571a8e": { + "decimals": "18", + "symbol": "bCSPX", + "to": "asset#ethereum:0x1e2c4fb7ede391d116e6b41cd0608260e8801d59" } }, "jbc": { @@ -7759,6 +7929,61 @@ "to": "coingecko#savings-dai", "decimals": 18, "symbol": "sDAI" + }, + "0x1e2c4fb7ede391d116e6b41cd0608260e8801d59": { + "decimals": "18", + "symbol": "bCSPX", + "to": "asset#ethereum:0x1e2c4fb7ede391d116e6b41cd0608260e8801d59" + }, + "0xbbcb0356bb9e6b3faa5cbf9e5f36185d53403ac9": { + "decimals": "18", + "symbol": "bCOIN", + "to": "asset#ethereum:0xbbcb0356bb9e6b3faa5cbf9e5f36185d53403ac9" + }, + "0x2f11eeee0bf21e7661a22dbbbb9068f4ad191b86": { + "decimals": "18", + "symbol": "bNIU", + "to": "asset#ethereum:0x2f11eeee0bf21e7661a22dbbbb9068f4ad191b86" + }, + "0xca30c93b02514f86d5c86a6e375e3a330b435fb5": { + "decimals": "18", + "symbol": "bIB01", + "to": "asset#ethereum:0xca30c93b02514f86d5c86a6e375e3a330b435fb5" + }, + "0x52d134c6db5889fad3542a09eaf7aa90c0fdf9e4": { + "decimals": "18", + "symbol": "bIBTA", + "to": "asset#ethereum:0x52d134c6db5889fad3542a09eaf7aa90c0fdf9e4" + }, + "0x20c64dee8fda5269a78f2d5bdba861ca1d83df7a": { + "decimals": "18", + "symbol": "bHIGH", + "to": "asset#ethereum:0x20c64dee8fda5269a78f2d5bdba861ca1d83df7a" + }, + "0x2f123cf3f37ce3328cc9b5b8415f9ec5109b45e7": { + "decimals": "18", + "symbol": "bC3M", + "to": "asset#ethereum:0x2f123cf3f37ce3328cc9b5b8415f9ec5109b45e7" + }, + "0x0f76d32cdccdcbd602a55af23eaf58fd1ee17245": { + "decimals": "18", + "symbol": "bERNA", + "to": "asset#ethereum:0x0f76d32cdccdcbd602a55af23eaf58fd1ee17245" + }, + "0x3f95aa88ddbb7d9d484aa3d482bf0a80009c52c9": { + "decimals": "18", + "symbol": "bERNX", + "to": "asset#ethereum:0x3f95aa88ddbb7d9d484aa3d482bf0a80009c52c9" + }, + "0xade6057fcafa57d6d51ffa341c64ce4814995995": { + "decimals": "18", + "symbol": "bZPR1", + "to": "asset#ethereum:0xade6057fcafa57d6d51ffa341c64ce4814995995" + }, + "0xa34c5e0abe843e10461e2c9586ea03e55dbcc495": { + "decimals": "18", + "symbol": "bNVDA", + "to": "asset#ethereum:0xa34c5e0abe843e10461e2c9586ea03e55dbcc495" } }, "mode": { diff --git a/coins/src/adapters/utils/database.test.ts b/coins/src/adapters/utils/database.test.ts new file mode 100644 index 0000000000..09555112f3 --- /dev/null +++ b/coins/src/adapters/utils/database.test.ts @@ -0,0 +1,168 @@ +jest.mock("../../../../defi/src/utils/discord", () => ({ + sendMessage: jest.fn(() => Promise.resolve()), +})); + +import { addToDBWritesList, __resetNumericWarningsForTests } from "./database"; +import { sendMessage } from "../../../../defi/src/utils/discord"; +import type { Write } from "./dbInterfaces"; + +const mockSendMessage = sendMessage as jest.Mock; + +beforeEach(() => { + mockSendMessage.mockClear(); + __resetNumericWarningsForTests(); + process.env.STALE_COINS_ADAPTERS_WEBHOOK = "https://discord.test/webhook"; +}); + +afterAll(() => { + delete process.env.STALE_COINS_ADAPTERS_WEBHOOK; +}); + +const TS = 1700000000; + +describe("addToDBWritesList numeric guardrail", () => { + test("valid number inputs: write goes through with exact types, no warning", () => { + const writes: Write[] = []; + addToDBWritesList(writes, "ethereum", "0xAAA", 1.23, 18, "AAA", TS, "ok-adapter", 0.99); + expect(writes).toHaveLength(1); + expect(typeof writes[0].price).toBe("number"); + expect(writes[0].price).toBe(1.23); + expect(writes[0].confidence).toBe(0.99); + expect(mockSendMessage).not.toHaveBeenCalled(); + }); + + test("string price: coerced to number and written (does not throw)", () => { + const writes: Write[] = []; + addToDBWritesList( + writes, + "ethereum", + "0xBBB", + "42.5" as any, + 18, + "BBB", + TS, + "str-price-adapter", + 0.99, + ); + expect(writes).toHaveLength(1); + expect(typeof writes[0].price).toBe("number"); + expect(writes[0].price).toBe(42.5); + // String-but-numeric isn't a "non-finite" case, so no warn is expected. + expect(mockSendMessage).not.toHaveBeenCalled(); + }); + + test("unparseable price: write proceeds with NaN, Discord warn fires once", () => { + const writes: Write[] = []; + addToDBWritesList( + writes, + "ethereum", + "0xCCC", + "not-a-number" as any, + 18, + "CCC", + TS, + "bad-price-adapter", + 0.99, + ); + // Write happened (no throw) — preserves pre-PR behaviour. + expect(writes).toHaveLength(1); + expect(Number.isNaN(writes[0].price)).toBe(true); + // Warn fired to Discord. + expect(mockSendMessage).toHaveBeenCalledTimes(1); + expect(mockSendMessage.mock.calls[0][0]).toMatch(/bad-price-adapter/); + expect(mockSendMessage.mock.calls[0][0]).toMatch(/price/); + }); + + test("NaN decimals: write proceeds, warn fires", () => { + const writes: Write[] = []; + addToDBWritesList( + writes, + "ethereum", + "0xDDD", + 1.0, + NaN, + "DDD", + TS, + "nan-decimals-adapter", + 0.99, + ); + expect(writes).toHaveLength(1); + expect(mockSendMessage).toHaveBeenCalledTimes(1); + expect(mockSendMessage.mock.calls[0][0]).toMatch(/decimals/); + }); + + test("undefined confidence: write proceeds with NaN confidence, warn fires", () => { + const writes: Write[] = []; + addToDBWritesList( + writes, + "ethereum", + "0xEEE", + 1.0, + 18, + "EEE", + TS, + "undef-conf-adapter", + undefined as any, + ); + expect(writes).toHaveLength(1); + expect(Number.isNaN(writes[0].confidence)).toBe(true); + expect(mockSendMessage).toHaveBeenCalledTimes(1); + expect(mockSendMessage.mock.calls[0][0]).toMatch(/confidence/); + }); + + test("dedup: same adapter+field+reason emits on threshold boundaries (1, 10, ...)", () => { + const writes: Write[] = []; + // 5 calls — only the first crosses a threshold, so exactly one Discord msg. + for (let i = 0; i < 5; i++) { + addToDBWritesList( + writes, + "ethereum", + `0xF${i}`, + "garbage" as any, + 18, + "X", + TS, + "dedup-adapter", + 0.99, + ); + } + expect(writes).toHaveLength(5); + expect(mockSendMessage).toHaveBeenCalledTimes(1); + + // 5 more calls bring the count to 10 — second threshold, second Discord msg. + for (let i = 5; i < 10; i++) { + addToDBWritesList( + writes, + "ethereum", + `0xF${i}`, + "garbage" as any, + 18, + "X", + TS, + "dedup-adapter", + 0.99, + ); + } + expect(writes).toHaveLength(10); + expect(mockSendMessage).toHaveBeenCalledTimes(2); + expect(mockSendMessage.mock.calls[1][0]).toMatch(/seen 10 time/); + }); + + test("webhook unset: console.error still fires but no Discord call", () => { + delete process.env.STALE_COINS_ADAPTERS_WEBHOOK; + const writes: Write[] = []; + addToDBWritesList( + writes, + "ethereum", + "0xF99", + 1.0, + 18, + "X", + TS, + "no-webhook-adapter", + undefined as any, + ); + expect(writes).toHaveLength(1); + expect(mockSendMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/coins/src/adapters/utils/database.ts b/coins/src/adapters/utils/database.ts index d6dadc927a..adf948545f 100644 --- a/coins/src/adapters/utils/database.ts +++ b/coins/src/adapters/utils/database.ts @@ -130,6 +130,52 @@ export async function getTokenAndRedirectDataMap( return map; } +// Tracks how many times each (adapter, field, reason) tuple has fired. +// We emit on threshold boundaries (1, 10, 100, ...) so a chronically broken +// adapter doesn't go silent for the rest of the pod's lifetime after the first +// warn, while still avoiding one-message-per-row spam. +const numericWarningCounts = new Map(); +const WARN_THRESHOLDS = [1, 10, 100, 1000, 10000]; + +export function __resetNumericWarningsForTests(): void { + numericWarningCounts.clear(); +} + +function warnInvalidNumericField( + value: unknown, + field: string, + adapter: string, + reason: string, +): void { + const key = `${adapter}:${field}:${reason}`; + const next = (numericWarningCounts.get(key) ?? 0) + 1; + numericWarningCounts.set(key, next); + if (!WARN_THRESHOLDS.includes(next)) return; + const msg = `coins: addToDBWritesList[${adapter}] invalid ${field} (${reason}): ${JSON.stringify(value)} (${typeof value}); seen ${next} time(s) since startup. Write proceeds with coerced value; please fix the adapter to pass a finite number.`; + console.error(msg); + if (process.env.STALE_COINS_ADAPTERS_WEBHOOK) { + sendMessage(msg, process.env.STALE_COINS_ADAPTERS_WEBHOOK, false).catch(() => {}); + } +} + +function coerceNumericField(value: unknown, field: string, adapter: string, allowUndefined: true): number | undefined; +function coerceNumericField(value: unknown, field: string, adapter: string, allowUndefined: false): number; +function coerceNumericField( + value: unknown, + field: string, + adapter: string, + allowUndefined: boolean, +): number | undefined { + if (value === undefined || value === null) { + if (allowUndefined) return undefined; + warnInvalidNumericField(value, field, adapter, "missing"); + return NaN; + } + const n = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(n)) warnInvalidNumericField(value, field, adapter, "non-finite"); + return n; +} + export function addToDBWritesList( writes: Write[], chain: string, @@ -142,22 +188,31 @@ export function addToDBWritesList( confidence: number, redirect: string | undefined = undefined, ) { + // NOTE: warn-only by design — coerceNumericField may return NaN for + // decimals/confidence, which propagates into the write record. The downstream + // `batchWriteWithAlerts` filter (search for `isFinite(i.price)`) rejects + // non-finite-priced rows without a redirect; non-finite decimals/confidence + // are not yet filtered, matching pre-PR behaviour. Tightening that filter + // (and dropping non-finite numeric fields rather than writing them) is a + // follow-up tracked in the PR description for #11802. + const priceNum = coerceNumericField(price, "price", adapter, true); + const decimalsNum = coerceNumericField(decimals, "decimals", adapter, true); + const confidenceNum = coerceNumericField(confidence, "confidence", adapter, false); const PK: string = chain == "coingecko" ? `coingecko#${token.toLowerCase()}` : `asset#${chain}:${lowercase(token, chain)}`; - const priceNum = price == null ? undefined : Number(price); if (redirect && timestamp == 0) { writes.push({ SK: 0, PK, price: priceNum, symbol, - decimals: Number(decimals), + decimals: decimalsNum, redirect, timestamp: getCurrentUnixTimestamp(), adapter, - confidence: Number(confidence), + confidence: confidenceNum, }); } else if (timestamp == 0) { writes.push( @@ -167,18 +222,18 @@ export function addToDBWritesList( PK, price: priceNum, adapter, - confidence: Number(confidence), + confidence: confidenceNum, }, { SK: 0, PK, price: priceNum, symbol, - decimals: Number(decimals), + decimals: decimalsNum, redirect, timestamp: getCurrentUnixTimestamp(), adapter, - confidence: Number(confidence), + confidence: confidenceNum, }, ], ); @@ -192,7 +247,7 @@ export function addToDBWritesList( redirect, price: priceNum, adapter, - confidence: Number(confidence), + confidence: confidenceNum, }); } } diff --git a/coins/src/api-spec.test.ts b/coins/src/api-spec.test.ts new file mode 100644 index 0000000000..0deff2e73a --- /dev/null +++ b/coins/src/api-spec.test.ts @@ -0,0 +1,371 @@ +/** + * Live integration tests for the coins API against the published OpenAPI spec. + * + * Runs against COINS_API_BASE (default https://coins.llama.fi). Set SKIP_LIVE=1 + * to skip (e.g. in CI without network). Tests assert response shape + field + * types per the spec at: + * https://github.com/DefiLlama/api-docs/blob/main/defillama-openapi-free.json + * + * Regression guards for the string-price bug fixed in defillama-server#11797 + * are in "type regression guards" blocks per endpoint — these tests will FAIL + * against prod until that PR merges. + */ +import axios, { AxiosResponse } from "axios"; + +const BASE_URL = process.env.COINS_API_BASE ?? "https://coins.llama.fi"; +const LIVE = process.env.SKIP_LIVE !== "1"; +const d = LIVE ? describe : describe.skip; +const REQ_TIMEOUT = 30000; +const TEST_TIMEOUT = 45000; + +const USDC = "ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"; +const WETH = "ethereum:0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"; +const DAI = "ethereum:0x6b175474e89094c44da98b954eedeac495271d0f"; +const CG_ETH = "coingecko:ethereum"; +const CG_BTC = "coingecko:bitcoin"; +// SPYon — Ondo equity token. Before #11797 prod returns price as a STRING. +const SPYON = "ethereum:0xfedc5f4a6c38211c1338aa411018dfaf26612c08"; +// Truly unmapped — zero address is actually ETH, so use a random dead address. +const DEAD_ADDR = "ethereum:0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + +const COMMON_BATCH = [USDC, WETH, DAI, CG_ETH, CG_BTC]; + +function get(path: string, params?: Record): Promise { + return axios.get(`${BASE_URL}${path}`, { + params, + timeout: REQ_TIMEOUT, + validateStatus: () => true, + }); +} + +function assertFiniteNumber(v: unknown, label: string) { + expect({ label, actual: typeof v, value: v }).toMatchObject({ actual: "number" }); + expect(Number.isFinite(v as number)).toBe(true); +} + +function assertPriceEntry(coinKey: string, coin: any) { + expect(coin).toBeDefined(); + assertFiniteNumber(coin.price, `coins[${coinKey}].price`); + assertFiniteNumber(coin.timestamp, `coins[${coinKey}].timestamp`); + expect(typeof coin.symbol).toBe("string"); + // decimals is documented but optional — coingecko-native assets omit it. + if ("decimals" in coin) assertFiniteNumber(coin.decimals, `coins[${coinKey}].decimals`); + // confidence is returned by prod but undocumented in the current spec. + if ("confidence" in coin) { + assertFiniteNumber(coin.confidence, `coins[${coinKey}].confidence`); + expect(coin.confidence).toBeGreaterThanOrEqual(0); + expect(coin.confidence).toBeLessThanOrEqual(1); + } + // No stray string-typed numeric fields. + for (const [k, v] of Object.entries(coin)) { + if (["price", "decimals", "timestamp", "confidence"].includes(k)) { + expect({ field: k, type: typeof v }).toMatchObject({ type: "number" }); + } + } +} + +d("Coins API — spec compliance", () => { + beforeAll(() => { + // eslint-disable-next-line no-console + console.log(`[api-spec] testing against ${BASE_URL}`); + }); + + // --------------------------------------------------------------------------- + describe("GET /prices/current/{coins}", () => { + const P = "/prices/current"; + + test("happy path — USDC: all fields typed per spec", async () => { + const r = await get(`${P}/${USDC}`); + expect(r.status).toBe(200); + expect(r.data).toHaveProperty("coins"); + assertPriceEntry(USDC, r.data.coins[USDC]); + }, TEST_TIMEOUT); + + test("batch of 5 common coins — every returned entry is well-typed", async () => { + const r = await get(`${P}/${COMMON_BATCH.join(",")}`); + expect(r.status).toBe(200); + const coins = r.data.coins; + expect(Object.keys(coins).length).toBeGreaterThan(0); + for (const [k, v] of Object.entries(coins)) assertPriceEntry(k, v); + }, TEST_TIMEOUT); + + test("unknown token → empty coins object (not error)", async () => { + const r = await get(`${P}/${DEAD_ADDR}`); + expect(r.status).toBe(200); + expect(r.data).toEqual({ coins: {} }); + }, TEST_TIMEOUT); + + test("mixed-case address — returned key matches whatever server canonicalises to", async () => { + const mixed = "ethereum:0xA0b86991c6218b36c1D19D4a2e9Eb0cE3606eB48"; + const r = await get(`${P}/${mixed}`); + expect(r.status).toBe(200); + const keys = Object.keys(r.data.coins); + expect(keys.length).toBe(1); + // Whatever casing the server echoes back, the payload must still be valid. + assertPriceEntry(keys[0], r.data.coins[keys[0]]); + }, TEST_TIMEOUT); + + test("duplicate coin — response has one entry, still well-typed", async () => { + const r = await get(`${P}/${USDC},${USDC}`); + expect(r.status).toBe(200); + expect(Object.keys(r.data.coins).length).toBe(1); + assertPriceEntry(USDC, r.data.coins[USDC]); + }, TEST_TIMEOUT); + + test("searchWidth='4h' accepted", async () => { + const r = await get(`${P}/${USDC}`, { searchWidth: "4h" }); + expect(r.status).toBe(200); + assertPriceEntry(USDC, r.data.coins[USDC]); + }, TEST_TIMEOUT); + + test("searchWidth garbage value does not 5xx", async () => { + const r = await get(`${P}/${USDC}`, { searchWidth: "not-a-duration" }); + expect(r.status).toBeLessThan(500); + }, TEST_TIMEOUT); + + describe("type regression guards (will fail until #11797 deploys)", () => { + test("SPYon (Ondo equity) — price must be a number, not a string", async () => { + // Use a wide searchWidth so the regression guard is deterministic — without + // this, the coin can age out and the test silently passes. + const r = await get(`${P}/${SPYON}`, { searchWidth: "30d" }); + expect(r.status).toBe(200); + const coin = r.data.coins[SPYON]; + expect(coin).toBeDefined(); + expect({ field: "price", type: typeof coin.price }).toEqual({ + field: "price", + type: "number", + }); + }, TEST_TIMEOUT); + }); + }); + + // --------------------------------------------------------------------------- + describe("GET /prices/historical/{timestamp}/{coins}", () => { + const P = "/prices/historical"; + const TS_OLD = 1648680149; + + test("happy path at a known timestamp", async () => { + const r = await get(`${P}/${TS_OLD}/${USDC}`); + expect(r.status).toBe(200); + expect(r.data).toHaveProperty("coins"); + if (r.data.coins[USDC]) assertPriceEntry(USDC, r.data.coins[USDC]); + }, TEST_TIMEOUT); + + test("timestamp=0 accepted (no crash, returns object)", async () => { + const r = await get(`${P}/0/${USDC}`); + expect(r.status).toBeLessThan(500); + expect(r.data).toHaveProperty("coins"); + }, TEST_TIMEOUT); + + test("far-future timestamp → empty or no-crash", async () => { + const r = await get(`${P}/9999999999/${USDC}`); + expect(r.status).toBeLessThan(500); + expect(r.data).toHaveProperty("coins"); + }, TEST_TIMEOUT); + + test("very old timestamp (pre-2015) → empty coins", async () => { + const r = await get(`${P}/1000000000/${USDC}`); + expect(r.status).toBeLessThan(500); + expect(r.data.coins).toBeDefined(); + }, TEST_TIMEOUT); + + test("batch at historical timestamp — all returned entries well-typed", async () => { + const r = await get(`${P}/${TS_OLD}/${COMMON_BATCH.join(",")}`); + expect(r.status).toBe(200); + for (const [k, v] of Object.entries(r.data.coins)) assertPriceEntry(k, v); + }, TEST_TIMEOUT); + }); + + // --------------------------------------------------------------------------- + describe("GET /batchHistorical", () => { + const P = "/batchHistorical"; + + test("single coin, single timestamp — prices[] with number price/timestamp", async () => { + const body = { [USDC]: [1700000000] }; + const r = await get(P, { coins: JSON.stringify(body) }); + expect(r.status).toBe(200); + const coin = r.data.coins[USDC]; + if (!coin) return; + expect(typeof coin.symbol).toBe("string"); + expect(Array.isArray(coin.prices)).toBe(true); + for (const p of coin.prices) { + assertFiniteNumber(p.price, "prices[].price"); + assertFiniteNumber(p.timestamp, "prices[].timestamp"); + if ("confidence" in p) assertFiniteNumber(p.confidence, "prices[].confidence"); + } + }, TEST_TIMEOUT); + + test("multi-coin, multi-timestamp — each coin has its own timestamps honoured", async () => { + const body = { + [USDC]: [1700000000, 1710000000], + [CG_ETH]: [1700000000], + }; + const r = await get(P, { coins: JSON.stringify(body) }); + expect(r.status).toBe(200); + for (const [k, v] of Object.entries(r.data.coins)) { + const coin = v as any; + expect(typeof coin.symbol).toBe("string"); + for (const p of coin.prices) { + assertFiniteNumber(p.price, `${k}.prices[].price`); + assertFiniteNumber(p.timestamp, `${k}.prices[].timestamp`); + } + } + }, TEST_TIMEOUT); + + test("same coin in two casings — regression for duplicate-entry bug (PR test C)", async () => { + const checksum = "ethereum:0xA0b86991c6218b36c1D19D4a2e9Eb0cE3606eB48"; + const lower = USDC; + const body = { [checksum]: [1700000000], [lower]: [1700000000] }; + const r = await get(P, { coins: JSON.stringify(body) }); + expect(r.status).toBe(200); + // Regression: the two casings must collapse to a single canonical key. + expect(Object.keys(r.data.coins).length).toBeLessThanOrEqual(1); + const allTimestamps: number[] = []; + for (const [k, v] of Object.entries(r.data.coins)) { + const coin = v as any; + // And each returned key must have ≤1 entry per requested ts. + expect(coin.prices.length).toBeLessThanOrEqual(1); + for (const p of coin.prices) allTimestamps.push(p.timestamp); + } + // Timestamps across all returned entries must be unique (no duplicate (cid, ts)). + expect(new Set(allTimestamps).size).toBe(allTimestamps.length); + }, TEST_TIMEOUT); + + test("empty timestamps array is tolerated", async () => { + const body = { [USDC]: [] }; + const r = await get(P, { coins: JSON.stringify(body) }); + expect(r.status).toBeLessThan(500); + }, TEST_TIMEOUT); + + test("malformed JSON in coins param → 4xx, not 5xx", async () => { + const r = await get(P, { coins: "not-json{{{" }); + expect(r.status).toBeGreaterThanOrEqual(400); + expect(r.status).toBeLessThan(500); + }, TEST_TIMEOUT); + }); + + // --------------------------------------------------------------------------- + describe("GET /chart/{coins}", () => { + const P = "/chart"; + + test("happy path — prices[] with numeric fields", async () => { + const r = await get(`${P}/${USDC}`, { span: 5, period: "1d" }); + expect(r.status).toBe(200); + const coin = r.data.coins[USDC]; + if (!coin) return; + expect(typeof coin.symbol).toBe("string"); + // decimals/confidence are optional in the spec — only assert when present. + if ("decimals" in coin) assertFiniteNumber(coin.decimals, "coin.decimals"); + if ("confidence" in coin) assertFiniteNumber(coin.confidence, "coin.confidence"); + expect(Array.isArray(coin.prices)).toBe(true); + expect(coin.prices.length).toBeGreaterThan(0); + for (const p of coin.prices) { + assertFiniteNumber(p.price, "prices[].price"); + assertFiniteNumber(p.timestamp, "prices[].timestamp"); + } + }, TEST_TIMEOUT); + + test("span=1 returns at least one point", async () => { + const r = await get(`${P}/${USDC}`, { span: 1, period: "1d" }); + expect(r.status).toBe(200); + if (r.data.coins[USDC]) { + expect(r.data.coins[USDC].prices.length).toBeGreaterThanOrEqual(1); + } + }, TEST_TIMEOUT); + + test("period='2d' accepted", async () => { + const r = await get(`${P}/${USDC}`, { span: 3, period: "2d" }); + expect(r.status).toBe(200); + }, TEST_TIMEOUT); + + test("period='1h' accepted", async () => { + const r = await get(`${P}/${USDC}`, { span: 3, period: "1h" }); + expect(r.status).toBe(200); + }, TEST_TIMEOUT); + + test("start without end does not 5xx", async () => { + const r = await get(`${P}/${USDC}`, { start: 1700000000, span: 3 }); + expect(r.status).toBeLessThan(500); + }, TEST_TIMEOUT); + + test("invalid period string does not 5xx", async () => { + const r = await get(`${P}/${USDC}`, { period: "not-a-period" }); + expect(r.status).toBeLessThan(500); + }, TEST_TIMEOUT); + }); + + // --------------------------------------------------------------------------- + describe("GET /percentage/{coins}", () => { + const P = "/percentage"; + + test("happy path — coins[key] is a number", async () => { + const r = await get(`${P}/${USDC}`); + expect(r.status).toBe(200); + const v = r.data.coins[USDC]; + if (v !== undefined) assertFiniteNumber(v, `coins[${USDC}]`); + }, TEST_TIMEOUT); + + test("lookForward=true accepted", async () => { + const r = await get(`${P}/${USDC}`, { lookForward: true, period: "1d" }); + expect(r.status).toBe(200); + }, TEST_TIMEOUT); + + test("lookForward=false (default) works", async () => { + const r = await get(`${P}/${USDC}`, { lookForward: false, period: "1d" }); + expect(r.status).toBe(200); + }, TEST_TIMEOUT); + + test("batch — every value is a number", async () => { + const r = await get(`${P}/${COMMON_BATCH.join(",")}`, { period: "1d" }); + expect(r.status).toBe(200); + for (const [k, v] of Object.entries(r.data.coins)) { + assertFiniteNumber(v, `coins[${k}]`); + } + }, TEST_TIMEOUT); + }); + + // --------------------------------------------------------------------------- + describe("GET /prices/first/{coins}", () => { + const P = "/prices/first"; + + test("happy path — price/symbol/timestamp typed", async () => { + const r = await get(`${P}/${USDC}`); + expect(r.status).toBe(200); + const coin = r.data.coins[USDC]; + if (!coin) return; + assertFiniteNumber(coin.price, "coin.price"); + expect(typeof coin.symbol).toBe("string"); + assertFiniteNumber(coin.timestamp, "coin.timestamp"); + }, TEST_TIMEOUT); + + test("unknown coin — omitted from response", async () => { + const r = await get(`${P}/${DEAD_ADDR}`); + expect(r.status).toBe(200); + expect(r.data.coins[DEAD_ADDR]).toBeUndefined(); + }, TEST_TIMEOUT); + }); + + // --------------------------------------------------------------------------- + describe("GET /block/{chain}/{timestamp}", () => { + const P = "/block"; + + test("happy path — height and timestamp are integers", async () => { + const r = await get(`${P}/ethereum/1700000000`); + expect(r.status).toBe(200); + assertFiniteNumber(r.data.height, "height"); + assertFiniteNumber(r.data.timestamp, "timestamp"); + expect(Number.isInteger(r.data.height)).toBe(true); + expect(Number.isInteger(r.data.timestamp)).toBe(true); + }, TEST_TIMEOUT); + + test("invalid chain → 4xx or empty, not 5xx", async () => { + const r = await get(`${P}/notachain/1700000000`); + expect(r.status).toBeLessThan(500); + }, TEST_TIMEOUT); + + test("far-future timestamp does not 5xx", async () => { + const r = await get(`${P}/ethereum/9999999999`); + expect(r.status).toBeLessThan(500); + }, TEST_TIMEOUT); + }); +}); diff --git a/coins/src/scripts/bootstrapRedis.ts b/coins/src/scripts/bootstrapRedis.ts index daeefd0dd3..8626f154c6 100644 --- a/coins/src/scripts/bootstrapRedis.ts +++ b/coins/src/scripts/bootstrapRedis.ts @@ -87,9 +87,10 @@ async function main() { const pipeline = redis.pipeline(); for (const p of page) { - if (p.price && p.price !== "0") { - pipeline.set(`price:${p.canonical_id}`, JSON.stringify({ price: p.price, confidence: p.confidence ? parseFloat(p.confidence) : null, source: p.adapter || null, timestamp: p.latest_ts || null }), "EX", PRICE_TTL); - } + if (!p.price) continue; + const priceNum = Number(p.price); + if (!Number.isFinite(priceNum)) continue; + pipeline.set(`price:${p.canonical_id}`, JSON.stringify({ price: priceNum, confidence: p.confidence ? parseFloat(p.confidence) : null, source: p.adapter || null, timestamp: p.latest_ts || null }), "EX", PRICE_TTL); } await execPipeline(pipeline); totalOps += page.length; diff --git a/coins/src/utils/servingLayer.ts b/coins/src/utils/servingLayer.ts index d06b17d020..4357759524 100644 --- a/coins/src/utils/servingLayer.ts +++ b/coins/src/utils/servingLayer.ts @@ -97,10 +97,12 @@ export async function redisCurrentPrices(requestedCoins: string[]): Promise { for (let i = 0; i < prices.length; i += BATCH) { const pipeline = redis.pipeline(); for (const p of prices.slice(i, i + BATCH)) { - if (p.price && p.price !== "0") pipeline.set(`price:${p.cid}`, JSON.stringify({ price: p.price, confidence: p.confidence ? parseFloat(p.confidence) : null, source: p.adapter || null, timestamp: p.ts || null }), "EX", 86400); + if (!p.price) continue; + const priceNum = Number(p.price); + if (!Number.isFinite(priceNum)) continue; + pipeline.set(`price:${p.cid}`, JSON.stringify({ price: priceNum, confidence: p.confidence ? parseFloat(p.confidence) : null, source: p.adapter || null, timestamp: p.ts || null }), "EX", 86400); } await execPipeline(pipeline); }