From 98b0d1fc065af173f6017c806c8e9b0b9bfccb8e Mon Sep 17 00:00:00 2001 From: Jinglun Date: Tue, 21 Jul 2026 12:23:03 -0700 Subject: [PATCH 01/10] feat(external-api): add CoinMarketCap DEX adapter [Section C] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /cmc/summary, /cmc/ticker, and /cmc/assets under a new src/routes/coinmarketcap.ts, mirroring the CoinGecko adapter: pairs are built from on-chain DAO discovery (getAllDaos) plus rolling-24h spot metrics from the served user_pool ETL. summary/ticker require the served DB and return 503 (never zero volume) when it is unavailable; assets is pure on-chain metadata and does not. Token→CMC mapping is controlled by an optional CMC_ALLOWED_MINTS env allowlist (empty = serve all, same default as the other adapters). No dedicated auth — CMC reuses the shared trusted-API-key rate-limit tiers. Includes response types, route tests, README docs, and example.env. Co-Authored-By: Claude Opus 4.8 --- README.md | 85 ++++++++++ example.env | 4 + src/config.ts | 13 ++ src/routes/coinmarketcap.ts | 241 +++++++++++++++++++++++++++++ src/routes/index.ts | 2 + src/routes/root.ts | 6 + src/types/coinmarketcap.ts | 58 +++++++ tests/routes/coinmarketcap.test.ts | 148 ++++++++++++++++++ 8 files changed, 557 insertions(+) create mode 100644 src/routes/coinmarketcap.ts create mode 100644 src/types/coinmarketcap.ts create mode 100644 tests/routes/coinmarketcap.test.ts diff --git a/README.md b/README.md index 108abfb..c559680 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,89 @@ served DB is unavailable, the endpoint returns `503` instead of reporting zero v --- +### CoinMarketCap Endpoints + +Implements the DEX endpoints from [Section C] of CoinMarketCap's integration +requirements. Served under `/cmc/`. The shapes mirror the CoinGecko adapter — +CMC's DEX spec is field-for-field close — and both feeds are built from the same +on-chain DAO discovery and rolling-24h ETL metrics. + +`/cmc/summary` and `/cmc/ticker` carry 24h volume, so they require +`DATABASE_PG_URL` and return `503` (never zero volume) if the served DB is +unavailable. `/cmc/assets` is pure on-chain metadata and does not require it. + +No dedicated auth: like every route, CMC reuses the shared rate-limit tiers — +anonymous by IP, or the elevated per-key bucket when a trusted `X-API-Key` +(`TRUSTED_API_KEYS`) is sent. + +Set `CMC_ALLOWED_MINTS` (comma-separated base mints) to restrict the CMC feed to +a specific set of tokens; empty (the default) serves every discovered DAO. + +#### GET `/cmc/summary` + +24h overview of every tradeable pair. + +**Response:** +```json +[ + { + "trading_pairs": "ZKFHiLAfAFMTcDAuCtjNW54VzpERvoe7PBF9mYgmeta_EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "base_currency": "ZKFHiLAfAFMTcDAuCtjNW54VzpERvoe7PBF9mYgmeta", + "quote_currency": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "last_price": 0.081340728222, + "lowest_ask": 0.081747431863, + "highest_bid": 0.080934024581, + "base_volume": 30024.8104, + "quote_volume": 2441.23456789, + "highest_price_24h": 0.085, + "lowest_price_24h": 0.078 + } +] +``` + +`highest_price_24h` / `lowest_price_24h` are omitted when the ETL window has no +real high/low. `price_change_percent_24h` is intentionally not reported — there +is no reliable 24h-ago open, and a fabricated `0%` would be worse than omitting. + +#### GET `/cmc/ticker` + +24h price and volume keyed by the `BASE_QUOTE` trading pair. + +**Response:** +```json +{ + "ZKFHiLAfAFMTcDAuCtjNW54VzpERvoe7PBF9mYgmeta_EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v": { + "base_id": "ZKFHiLAfAFMTcDAuCtjNW54VzpERvoe7PBF9mYgmeta", + "quote_id": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "last_price": 0.081340728222, + "base_volume": 30024.8104, + "quote_volume": 2441.23456789, + "isFrozen": 0 + } +} +``` + +#### GET `/cmc/assets` + +Token identity keyed by mint address (both base and quote of every pair). + +**Response:** +```json +{ + "ZKFHiLAfAFMTcDAuCtjNW54VzpERvoe7PBF9mYgmeta": { + "name": "ZKFG", + "symbol": "ZKFG", + "contractAddress": "ZKFHiLAfAFMTcDAuCtjNW54VzpERvoe7PBF9mYgmeta", + "can_withdraw": "true", + "can_deposit": "true", + "maker_fee": 0.005, + "taker_fee": 0.005 + } +} +``` + +--- + ### DexScreener Adapter Endpoints Implements the [DexScreener Adapter Spec v1.1](https://dexscreener.notion.site/DEX-Screener-Adapter-Specs-cc1223cdf6e74a7799599106b65dcd0e). All endpoints are served under `/dexscreener/`. Requires `DATABASE_PG_URL` to be configured for the served DB. @@ -263,6 +346,7 @@ src/ ├── routes/ │ ├── index.ts # Route registration │ ├── coingecko.ts # GET /api/tickers +│ ├── coinmarketcap.ts # CoinMarketCap DEX adapter (summary/ticker/assets) │ ├── dexscreener.ts # DexScreener adapter (4 endpoints) │ ├── market.ts # GET /api/market-data (user_pool ETL) │ ├── supply.ts # GET /api/supply/* @@ -278,6 +362,7 @@ src/ │ └── metricsService.ts # Prometheus counters/histograms ├── types/ │ ├── coingecko.ts # CoinGecko response types +│ ├── coinmarketcap.ts # CoinMarketCap response types │ └── dexscreener.ts # DexScreener response types ├── middleware/ │ ├── errorHandler.ts # Error handling & asyncHandler diff --git a/example.env b/example.env index c6cb16d..ac30cdf 100644 --- a/example.env +++ b/example.env @@ -50,6 +50,10 @@ SOLANA_RPC_URL=https://api.mainnet-beta.solana.com # DEX_FORK_TYPE=Custom # FACTORY_ADDRESS= # ROUTER_ADDRESS= +# Allowlist of base mints exposed on the CoinMarketCap routes (/cmc/*), +# comma-separated. Empty (default) serves every discovered DAO, same as the +# CoinGecko/DexScreener adapters. +# CMC_ALLOWED_MINTS= # DAOs excluded from /api/tickers (comma-separated mints). Default: none. EXCLUDED_DAOS=DMB74TZgN7Rqfwtqqm3VQBgKBb2WYPdBqVtHbvB4LLeV,AE7jPb9jYzbUE5GYJToKvXaRkJL2Q7Mm3Ek6KqyBGuxe,E3BjsvLSFqUqVtDP76qMw4QbETkxvqvg8RTSbRZxWCK4,CnUUCGbSrAoaJniPifRU8zHRZ6e5uGRVSpCEj2WMeeSv,CLoqV77NtkbrsvtCRDP1vdYxgPZua3nnh7gCNPLzDQQ8,CJCgDqiDtkQvwXT2iiyY7QVajKLH3VRVbcsNQgtttrHn,651uV1hcd7SprwwkumFfkWtx5WrnD53awpjduGtGsHzS,4rW6iVKUq1RWYQ1VBTrjvP9FL4G3Sn7mBj7Yg12kuckv,Eo1BLMVRLJspjP5dDnwzK1m6FxMUcQDG6kDA8CjWPzRW,CTYxPujxrXiiqwG3gSBVNKuBk8u7mPG9qVMUc4aT1L8u,EbcsPbXZa81xUunDSmzYrcAWGURxcZB6BTkgzqvNJBZH,BgNq2V6vea2C7Z3cZhDUJTbmN4Y9bKG6dfEPhH19J7Fb,DHjQLd6LCM4yzZ9e8eabyGofDJLjbouqpuX8wh1rQuBs,BQjNtXjZB7b9WrqgJZQWfR52T1MqZoqMELAoombywDi8,j6Hx7bdAzcj1NsoRBqdafFuRkgEU48QeZ1i5NVXz9fF diff --git a/src/config.ts b/src/config.ts index 763c2d3..c2d71ff 100644 --- a/src/config.ts +++ b/src/config.ts @@ -51,6 +51,19 @@ export const config = { // Protocol fee rate (0.005 = 0.5%); used to report fee bps on DexScreener routes. protocolFeeRate: parseFloat(process.env.PROTOCOL_FEE_RATE || '0.005'), }, + coinmarketcap: { + // Optional allowlist of base-mint addresses exposed on the CoinMarketCap + // routes. Empty (the default) means "serve every discovered DAO", matching + // the CoinGecko/DexScreener adapters. When set, ONLY these base mints appear + // — this is how we map our tokens onto CMC's expected asset ids without + // leaking test/never-listed DAOs into the CMC feed. + allowedMints: new Set( + (process.env.CMC_ALLOWED_MINTS || '') + .split(',') + .map(m => m.trim()) + .filter(Boolean) + ), + }, alerts: { webhookUrl: process.env.ALERT_WEBHOOK_URL || 'https://telegram-webhook-relay.themetadao-org.workers.dev', webhookSecret: process.env.ALERT_WEBHOOK_SECRET || '', diff --git a/src/routes/coinmarketcap.ts b/src/routes/coinmarketcap.ts new file mode 100644 index 0000000..e74622b --- /dev/null +++ b/src/routes/coinmarketcap.ts @@ -0,0 +1,241 @@ +import { Router, type Request, type Response } from 'express'; +import type { + CoinMarketCapTicker, + CoinMarketCapTickerResponse, + CoinMarketCapSummaryPair, + CoinMarketCapAsset, + CoinMarketCapAssetsResponse, +} from '../types/coinmarketcap.js'; +import type { DaoTickerData } from '../services/futarchyService.js'; +import type { ServiceGetters } from './types.js'; +import { AppError, asyncHandler } from '../middleware/errorHandler.js'; +import { config } from '../config.js'; +import { logger } from '../utils/logger.js'; +import { sendAlert } from '../utils/alerts.js'; + +// Fees reported on /cmc/assets. Both maker and taker pay the same flat protocol +// fee on the FutarchyAMM — there is no maker/taker distinction on an AMM. +const PROTOCOL_FEE_RATE = config.fees.protocolFeeRate; + +/** + * Apply the optional CMC allowlist (config.coinmarketcap.allowedMints). An empty + * allowlist means "serve every discovered DAO" — same default as the CoinGecko + * and DexScreener adapters. + */ +function filterAllowed(daos: DaoTickerData[]): DaoTickerData[] { + const allowed = config.coinmarketcap.allowedMints; + if (allowed.size === 0) return daos; + return daos.filter(dao => allowed.has(dao.baseMint.toString())); +} + +/** + * A single tradeable pair, enriched with live price/spread (from spot reserves) + * and rolling-24h volume/high/low (from the served ETL). Shared by /cmc/summary + * and /cmc/ticker so both feeds are always internally consistent. + */ +interface CmcPair { + tradingPair: string; // `${baseMint}_${quoteMint}` + baseId: string; + quoteId: string; + lastPrice: number; + bid: number; + ask: number; + baseVolume: number; + quoteVolume: number; + high24h?: number; + low24h?: number; +} + +export function createCoinMarketCapRouter(services: ServiceGetters): Router { + const router = Router(); + const { getFutarchyService, getPriceService, getExternalDatabaseService } = services; + + /** + * Build the enriched pair list shared by /cmc/summary and /cmc/ticker. + * + * Mirrors the CoinGecko /api/tickers path: the served ETL DB is the source of + * truth for 24h volume, so its absence is surfaced as 503 (never masked as + * zero volume for a financial feed). Price/spread/liquidity come from live + * spot-pool reserves via the same PriceService the CoinGecko adapter uses. + */ + async function buildPairs(req: Request): Promise { + const futarchyService = getFutarchyService(); + const priceService = getPriceService(); + const externalDatabaseService = getExternalDatabaseService(); + + if (!externalDatabaseService?.isAvailable()) { + logger.warn('Served database unavailable for /cmc endpoint', { requestId: req.requestId }); + sendAlert( + 'Served database unavailable for /cmc — refusing to report zero volume', + { cooldownKey: 'cmc-served-db-unavailable', cooldownMs: 10 * 60 * 1000 } + ); + throw AppError.serviceUnavailable('Served database not available', 'SERVED_DB_UNAVAILABLE'); + } + + const allDaos = filterAllowed(await futarchyService.getAllDaos()); + + // Rolling-24h spot metrics keyed by base mint (token) → mapped to dao (pool_id), + // identical to the CoinGecko adapter's single source. + const tokenToDaoMap = new Map(); + for (const dao of allDaos) { + tokenToDaoMap.set(dao.baseMint.toString(), dao.daoAddress.toString()); + } + + const baseMints = allDaos.map(dao => dao.baseMint.toString()); + const spotMetrics = await externalDatabaseService.getSpotRolling24hMetrics(baseMints); + + const volumeByDao = new Map(); + for (const [token, metrics] of spotMetrics.entries()) { + const daoAddress = tokenToDaoMap.get(token); + if (!daoAddress) continue; + volumeByDao.set(daoAddress, { + base_volume_24h: metrics.base_volume_24h, + target_volume_24h: metrics.target_volume_24h, + high_24h: metrics.high_24h, + low_24h: metrics.low_24h, + }); + } + + const pairs: CmcPair[] = []; + for (const dao of allDaos) { + try { + const { daoAddress, baseMint, quoteMint, baseDecimals, quoteDecimals, poolData } = dao; + + const lastPriceStr = priceService.calculatePrice( + poolData.baseReserves, + poolData.quoteReserves, + baseDecimals, + quoteDecimals + ); + if (!lastPriceStr) continue; + + const priceNum = parseFloat(lastPriceStr); + const spread = priceService.calculateSpread(priceNum); + if (!spread) continue; + + const metrics = volumeByDao.get(daoAddress.toString()); + const baseVolume = metrics ? parseFloat(metrics.base_volume_24h) : 0; + const quoteVolume = metrics ? parseFloat(metrics.target_volume_24h) : 0; + if (!Number.isFinite(baseVolume) || !Number.isFinite(quoteVolume)) continue; + + const pair: CmcPair = { + tradingPair: `${baseMint.toString()}_${quoteMint.toString()}`, + baseId: baseMint.toString(), + quoteId: quoteMint.toString(), + lastPrice: priceNum, + bid: parseFloat(spread.bid), + ask: parseFloat(spread.ask), + baseVolume, + quoteVolume, + }; + + // Only attach a 24h high/low when the ETL reports a real (non-zero) one — + // the metrics helper returns '0' as its "no data" sentinel. + if (metrics && metrics.high_24h !== '0') { + const high = parseFloat(metrics.high_24h); + if (Number.isFinite(high)) pair.high24h = high; + } + if (metrics && metrics.low_24h !== '0') { + const low = parseFloat(metrics.low_24h); + if (Number.isFinite(low)) pair.low24h = low; + } + + pairs.push(pair); + } catch (error) { + logger.error('Error building CMC pair', error, { + daoAddress: dao.daoAddress.toString(), + requestId: req.requestId, + }); + } + } + + return pairs; + } + + // --------------------------------------------------------------- + // GET /cmc/summary — 24h overview of every tradeable pair (array). + // --------------------------------------------------------------- + router.get('/cmc/summary', asyncHandler(async (req: Request, res: Response) => { + const pairs = await buildPairs(req); + + const summary: CoinMarketCapSummaryPair[] = pairs.map(p => { + const entry: CoinMarketCapSummaryPair = { + trading_pairs: p.tradingPair, + base_currency: p.baseId, + quote_currency: p.quoteId, + last_price: p.lastPrice, + lowest_ask: p.ask, + highest_bid: p.bid, + base_volume: p.baseVolume, + quote_volume: p.quoteVolume, + }; + if (p.high24h !== undefined) entry.highest_price_24h = p.high24h; + if (p.low24h !== undefined) entry.lowest_price_24h = p.low24h; + return entry; + }); + + res.json(summary); + })); + + // --------------------------------------------------------------- + // GET /cmc/ticker — 24h price/volume keyed by `BASE_QUOTE` pair. + // --------------------------------------------------------------- + router.get('/cmc/ticker', asyncHandler(async (req: Request, res: Response) => { + const pairs = await buildPairs(req); + + const ticker: CoinMarketCapTickerResponse = {}; + for (const p of pairs) { + const entry: CoinMarketCapTicker = { + base_id: p.baseId, + quote_id: p.quoteId, + last_price: p.lastPrice, + base_volume: p.baseVolume, + quote_volume: p.quoteVolume, + isFrozen: 0, + }; + ticker[p.tradingPair] = entry; + } + + res.json(ticker); + })); + + // --------------------------------------------------------------- + // GET /cmc/assets — token identity keyed by mint address. + // + // Pure on-chain metadata (symbol/name/decimals via getAllDaos) — no volume, so + // it does NOT require the served DB. Exposes both the base and quote token of + // every (allowlisted) pair. + // --------------------------------------------------------------- + router.get('/cmc/assets', asyncHandler(async (req: Request, res: Response) => { + const futarchyService = getFutarchyService(); + const allDaos = filterAllowed(await futarchyService.getAllDaos()); + + const assets: CoinMarketCapAssetsResponse = {}; + + const addAsset = (mint: string, symbol: string | undefined, name: string | undefined): void => { + // First writer wins: the base token's own metadata is authoritative, and a + // shared quote (USDC) is identical across pairs, so skipping re-adds is safe. + if (assets[mint]) return; + const asset: CoinMarketCapAsset = { + name: name || mint.slice(0, 8), + symbol: symbol || mint.slice(0, 8), + contractAddress: mint, + can_withdraw: 'true', + can_deposit: 'true', + maker_fee: PROTOCOL_FEE_RATE, + taker_fee: PROTOCOL_FEE_RATE, + }; + assets[mint] = asset; + }; + + for (const dao of allDaos) { + addAsset(dao.baseMint.toString(), dao.baseSymbol, dao.baseName); + addAsset(dao.quoteMint.toString(), dao.quoteSymbol, dao.quoteName); + } + + logger.debug('Built CMC assets', { count: Object.keys(assets).length, requestId: req.requestId }); + res.json(assets); + })); + + return router; +} diff --git a/src/routes/index.ts b/src/routes/index.ts index e925b2d..ad495c2 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import { createHealthRouter } from './health.js'; import { createMetricsRouter } from './metrics.js'; import { createCoinGeckoRouter } from './coingecko.js'; +import { createCoinMarketCapRouter } from './coinmarketcap.js'; import { createSupplyRouter } from './supply.js'; import { createMarketRouter } from './market.js'; @@ -18,6 +19,7 @@ export function createRoutes(services: ServiceGetters): Router { router.use(createHealthRouter(services)); router.use(createMetricsRouter(services)); router.use(createCoinGeckoRouter(services)); + router.use(createCoinMarketCapRouter(services)); router.use(createSupplyRouter(services)); router.use(createMarketRouter(services)); router.use(createDexScreenerRouter(services)); diff --git a/src/routes/root.ts b/src/routes/root.ts index 3fef173..dbff8e0 100644 --- a/src/routes/root.ts +++ b/src/routes/root.ts @@ -27,6 +27,12 @@ export function createRootRouter(_services: ServiceGetters): Router { pair: '/dexscreener/pair?id=:daoAddress - Pair info', events: '/dexscreener/events?fromBlock=:slot&toBlock=:slot - Swap events by slot range', }, + coinmarketcap: { + description: 'CoinMarketCap DEX Adapter [Section C] — requires DATABASE_PG_URL for summary/ticker', + summary: '/cmc/summary - 24h overview of every tradeable pair', + ticker: '/cmc/ticker - 24h price/volume keyed by BASE_QUOTE pair', + assets: '/cmc/assets - Token identity keyed by mint address', + }, dex: { fork_type: config.dex.forkType, factory_address: config.dex.factoryAddress, diff --git a/src/types/coinmarketcap.ts b/src/types/coinmarketcap.ts new file mode 100644 index 0000000..1a6b6f7 --- /dev/null +++ b/src/types/coinmarketcap.ts @@ -0,0 +1,58 @@ +// CoinMarketCap DEX API response types. +// +// Models the endpoints in [Section C] "DEXes" of CoinMarketCap's integration +// requirements. The shapes intentionally mirror our CoinGecko adapter +// (src/routes/coingecko.ts) — CMC's DEX spec is field-for-field close to +// CoinGecko's, so both feeds are built from the same on-chain DAO discovery + +// rolling-24h ETL metrics. +// +// Prices/volumes are numbers here (CMC expects JSON numbers), whereas the +// CoinGecko adapter serves strings. A trading pair is keyed `${baseMint}_${quoteMint}`, +// the same identifier the CoinGecko `ticker_id` uses. + +/** One entry in the `/cmc/ticker` object, keyed by the `BASE_QUOTE` trading pair. */ +export interface CoinMarketCapTicker { + base_id: string; + quote_id: string; + last_price: number; + base_volume: number; + quote_volume: number; + /** 0 = trading, 1 = frozen. Always 0 for an on-chain AMM (never halted). */ + isFrozen: 0 | 1; +} + +/** The `/cmc/ticker` response: an object keyed by `BASE_QUOTE`. */ +export type CoinMarketCapTickerResponse = Record; + +/** One element of the `/cmc/summary` array. */ +export interface CoinMarketCapSummaryPair { + trading_pairs: string; + base_currency: string; + quote_currency: string; + last_price: number; + lowest_ask: number; + highest_bid: number; + base_volume: number; + quote_volume: number; + // Only reported when a real 24h high/low exists in the ETL window. Omitted + // (rather than reported as 0) otherwise — a financial feed must not fabricate + // an extreme. price_change_percent_24h is intentionally absent: we have no + // reliable 24h-ago open, and reporting a fake 0% would be worse than omitting. + highest_price_24h?: number; + lowest_price_24h?: number; +} + +/** One entry in the `/cmc/assets` object, keyed by the token's mint address. */ +export interface CoinMarketCapAsset { + name: string; + symbol: string; + /** Solana mint address — the token's contract address. */ + contractAddress: string; + can_withdraw: 'true' | 'false'; + can_deposit: 'true' | 'false'; + maker_fee: number; + taker_fee: number; +} + +/** The `/cmc/assets` response: an object keyed by mint address. */ +export type CoinMarketCapAssetsResponse = Record; diff --git a/tests/routes/coinmarketcap.test.ts b/tests/routes/coinmarketcap.test.ts new file mode 100644 index 0000000..449aad8 --- /dev/null +++ b/tests/routes/coinmarketcap.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from 'bun:test'; +import { createTestApp } from '../helpers/testApp.js'; +import request from 'supertest'; +import type { FutarchyService, DaoTickerData } from '../../src/services/futarchyService.js'; +import type { ExternalDatabaseService } from '../../src/services/externalDatabaseService.js'; + +// Minimal DAO stand-ins: buildPairs only ever calls `.toString()` on the pubkey +// fields and reads decimals/poolData, and the mock PriceService (testApp) ignores +// the reserve values, so a plain object with a toString() is enough. +function pk(v: string) { + return { toString: () => v } as any; +} + +function dao(base: string, quote: string, addr: string): DaoTickerData { + return { + daoAddress: pk(addr), + baseMint: pk(base), + quoteMint: pk(quote), + baseDecimals: 6, + quoteDecimals: 6, + baseSymbol: `${base}SYM`, + baseName: `${base} Name`, + quoteSymbol: 'USDC', + quoteName: 'USD Coin', + poolData: { baseReserves: {}, quoteReserves: {}, baseProtocolFees: {}, quoteProtocolFees: {} }, + } as unknown as DaoTickerData; +} + +function futarchyReturning(daos: DaoTickerData[]): FutarchyService { + return { getAllDaos: async () => daos } as unknown as FutarchyService; +} + +// getSpotRolling24hMetrics is keyed by base mint (token). BASE1 has real 24h +// high/low; BASE2 carries the '0' no-data sentinel so we assert high/low are +// omitted rather than reported as 0. +function extDbWithMetrics(): ExternalDatabaseService { + return { + isAvailable: () => true, + getSpotRolling24hMetrics: async () => + new Map([ + ['BASE1', { token: 'BASE1', base_volume_24h: '100', target_volume_24h: '5', high_24h: '0.06', low_24h: '0.04', trade_count_24h: 3 }], + ['BASE2', { token: 'BASE2', base_volume_24h: '0', target_volume_24h: '0', high_24h: '0', low_24h: '0', trade_count_24h: 0 }], + ]), + } as unknown as ExternalDatabaseService; +} + +const DAOS = [dao('BASE1', 'USDC', 'DAOA'), dao('BASE2', 'USDC', 'DAOB')]; + +describe('CoinMarketCap Routes', () => { + describe('GET /cmc/summary', () => { + it('returns a 24h summary array with price, spread, and volume per pair', async () => { + const app = createTestApp({ + futarchyService: futarchyReturning(DAOS), + externalDatabaseService: extDbWithMetrics(), + }); + + const res = await request(app).get('/cmc/summary'); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body).toHaveLength(2); + + const a = res.body.find((p: any) => p.trading_pairs === 'BASE1_USDC'); + expect(a.base_currency).toBe('BASE1'); + expect(a.quote_currency).toBe('USDC'); + expect(a.last_price).toBe(0.05); + // Mock PriceService spread → bid 0.04975 / ask 0.05025. + expect(a.lowest_ask).toBe(0.05025); + expect(a.highest_bid).toBe(0.04975); + expect(a.base_volume).toBe(100); + expect(a.quote_volume).toBe(5); + expect(a.highest_price_24h).toBe(0.06); + expect(a.lowest_price_24h).toBe(0.04); + + // BASE2 has the '0' no-data sentinel → high/low omitted, volume 0. + const b = res.body.find((p: any) => p.trading_pairs === 'BASE2_USDC'); + expect(b.base_volume).toBe(0); + expect(b).not.toHaveProperty('highest_price_24h'); + expect(b).not.toHaveProperty('lowest_price_24h'); + }); + + it('returns 503 when the served DB is unavailable', async () => { + const app = createTestApp({ + futarchyService: futarchyReturning(DAOS), + externalDatabaseService: { isAvailable: () => false } as unknown as ExternalDatabaseService, + }); + const res = await request(app).get('/cmc/summary'); + expect(res.status).toBe(503); + expect(res.body.code).toBe('SERVED_DB_UNAVAILABLE'); + }); + }); + + describe('GET /cmc/ticker', () => { + it('returns an object keyed by BASE_QUOTE with base/quote ids and isFrozen', async () => { + const app = createTestApp({ + futarchyService: futarchyReturning(DAOS), + externalDatabaseService: extDbWithMetrics(), + }); + + const res = await request(app).get('/cmc/ticker'); + expect(res.status).toBe(200); + expect(Object.keys(res.body).sort()).toEqual(['BASE1_USDC', 'BASE2_USDC']); + + const t = res.body['BASE1_USDC']; + expect(t.base_id).toBe('BASE1'); + expect(t.quote_id).toBe('USDC'); + expect(t.last_price).toBe(0.05); + expect(t.base_volume).toBe(100); + expect(t.quote_volume).toBe(5); + expect(t.isFrozen).toBe(0); + }); + + it('returns 503 when the served DB is unavailable', async () => { + const app = createTestApp({ + futarchyService: futarchyReturning(DAOS), + externalDatabaseService: { isAvailable: () => false } as unknown as ExternalDatabaseService, + }); + const res = await request(app).get('/cmc/ticker'); + expect(res.status).toBe(503); + }); + }); + + describe('GET /cmc/assets', () => { + it('returns token identity keyed by mint, deduping the shared quote', async () => { + const app = createTestApp({ futarchyService: futarchyReturning(DAOS) }); + + const res = await request(app).get('/cmc/assets'); + expect(res.status).toBe(200); + // BASE1, BASE2, and a single shared USDC entry. + expect(Object.keys(res.body).sort()).toEqual(['BASE1', 'BASE2', 'USDC']); + + expect(res.body['BASE1'].symbol).toBe('BASE1SYM'); + expect(res.body['BASE1'].name).toBe('BASE1 Name'); + expect(res.body['BASE1'].contractAddress).toBe('BASE1'); + expect(res.body['BASE1'].can_withdraw).toBe('true'); + expect(res.body['USDC'].symbol).toBe('USDC'); + }); + + it('does NOT require the served DB (pure on-chain metadata)', async () => { + const app = createTestApp({ + futarchyService: futarchyReturning(DAOS), + externalDatabaseService: { isAvailable: () => false } as unknown as ExternalDatabaseService, + }); + const res = await request(app).get('/cmc/assets'); + expect(res.status).toBe(200); + expect(Object.keys(res.body)).toContain('BASE1'); + }); + }); +}); From 5160732e9bfad326c2a3d89d414aaeb9982823e1 Mon Sep 17 00:00:00 2001 From: Jinglun Date: Tue, 21 Jul 2026 12:30:12 -0700 Subject: [PATCH 02/10] fix(external-api): address CMC adapter review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config: validate CMC_ALLOWED_MINTS entries as Solana pubkeys at startup (fail fast on a typo instead of silently serving an empty /cmc feed), matching the EXCLUDED_DAOS pattern. - summary: add the `type: "spot"` market discriminator — every pair we surface is a spot market (conditional pools are never selected). - document that the per-pair catch only skips uncomputable pairs; infra/DB outages still surface as 5xx before the loop (getAllDaos / getSpotRolling24hMetrics both throw). Co-Authored-By: Claude Opus 4.8 --- README.md | 1 + src/config.ts | 6 ++++++ src/routes/coinmarketcap.ts | 8 ++++++++ src/types/coinmarketcap.ts | 4 ++++ tests/routes/coinmarketcap.test.ts | 1 + 5 files changed, 20 insertions(+) diff --git a/README.md b/README.md index c559680..235c44a 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ a specific set of tokens; empty (the default) serves every discovered DAO. "trading_pairs": "ZKFHiLAfAFMTcDAuCtjNW54VzpERvoe7PBF9mYgmeta_EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "base_currency": "ZKFHiLAfAFMTcDAuCtjNW54VzpERvoe7PBF9mYgmeta", "quote_currency": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "type": "spot", "last_price": 0.081340728222, "lowest_ask": 0.081747431863, "highest_bid": 0.080934024581, diff --git a/src/config.ts b/src/config.ts index c2d71ff..7814260 100644 --- a/src/config.ts +++ b/src/config.ts @@ -57,11 +57,17 @@ export const config = { // the CoinGecko/DexScreener adapters. When set, ONLY these base mints appear // — this is how we map our tokens onto CMC's expected asset ids without // leaking test/never-listed DAOs into the CMC feed. + // + // Each entry is validated as a Solana pubkey at startup (like EXCLUDED_DAOS): + // a typo throws here — fail fast — rather than silently filtering every pair + // and serving an empty /cmc feed that a poller would read as "delisted". The + // normalized base58 form is stored so lookups match baseMint.toString(). allowedMints: new Set( (process.env.CMC_ALLOWED_MINTS || '') .split(',') .map(m => m.trim()) .filter(Boolean) + .map(m => new PublicKey(m).toString()) ), }, alerts: { diff --git a/src/routes/coinmarketcap.ts b/src/routes/coinmarketcap.ts index e74622b..9de3645 100644 --- a/src/routes/coinmarketcap.ts +++ b/src/routes/coinmarketcap.ts @@ -142,6 +142,13 @@ export function createCoinMarketCapRouter(services: ServiceGetters): Router { pairs.push(pair); } catch (error) { + // Per-pair skip ONLY (identical to the CoinGecko adapter's per-ticker + // catch): drop a single pair whose price/spread/volume can't be computed + // so one bad pool doesn't sink the whole feed. This does NOT mask an + // infrastructure/data outage as a 200 — those surface as 5xx before we + // reach here: getAllDaos() throws (its "refusing to serve an empty set" + // guard) if the RPC scan degrades, and getSpotRolling24hMetrics() throws + // on any served-DB/query failure. Both propagate via asyncHandler. logger.error('Error building CMC pair', error, { daoAddress: dao.daoAddress.toString(), requestId: req.requestId, @@ -163,6 +170,7 @@ export function createCoinMarketCapRouter(services: ServiceGetters): Router { trading_pairs: p.tradingPair, base_currency: p.baseId, quote_currency: p.quoteId, + type: 'spot', last_price: p.lastPrice, lowest_ask: p.ask, highest_bid: p.bid, diff --git a/src/types/coinmarketcap.ts b/src/types/coinmarketcap.ts index 1a6b6f7..8acac5f 100644 --- a/src/types/coinmarketcap.ts +++ b/src/types/coinmarketcap.ts @@ -29,6 +29,10 @@ export interface CoinMarketCapSummaryPair { trading_pairs: string; base_currency: string; quote_currency: string; + // Market-type discriminator. Always 'spot': getPoolData only ever selects the + // DAO's spot pool (conditional pass/fail pools are explicitly ignored), so + // every pair we surface is a spot market. + type: 'spot'; last_price: number; lowest_ask: number; highest_bid: number; diff --git a/tests/routes/coinmarketcap.test.ts b/tests/routes/coinmarketcap.test.ts index 449aad8..13d43f3 100644 --- a/tests/routes/coinmarketcap.test.ts +++ b/tests/routes/coinmarketcap.test.ts @@ -62,6 +62,7 @@ describe('CoinMarketCap Routes', () => { const a = res.body.find((p: any) => p.trading_pairs === 'BASE1_USDC'); expect(a.base_currency).toBe('BASE1'); expect(a.quote_currency).toBe('USDC'); + expect(a.type).toBe('spot'); expect(a.last_price).toBe(0.05); // Mock PriceService spread → bid 0.04975 / ask 0.05025. expect(a.lowest_ask).toBe(0.05025); From 97f931d2a0ac3cf16edfb5ea12bf0949f8c33caa Mon Sep 17 00:00:00 2001 From: Jinglun Date: Tue, 21 Jul 2026 12:38:29 -0700 Subject: [PATCH 03/10] fix(external-api): fail closed on CMC edge cases + add regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - allowlist: when CMC_ALLOWED_MINTS is set but matches zero discovered DAOs, fail closed (503 + alert) instead of serving an empty feed a poller would read as "delisted". - volume: malformed 24h volume from the served ETL for an INCLUDED pair now surfaces as 500 (contract-drift) rather than silently dropping the pair; the per-pair catch rethrows AppError so integrity failures propagate while genuine per-pair calc skips are still tolerated. - tests: cover metrics-query failure → 5xx, malformed volume → 5xx, and allowlist filtering + fail-closed behavior. - docs: note /cmc DB requirement and CMC_ALLOWED_MINTS in the env table. Co-Authored-By: Claude Opus 4.8 --- README.md | 3 +- src/routes/coinmarketcap.ts | 59 ++++++++++++++---- tests/routes/coinmarketcap.test.ts | 97 +++++++++++++++++++++++++++++- 3 files changed, 146 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 235c44a..df05895 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,7 @@ Create a `.env` file in the root directory (see `example.env` for reference): | `TRUSTED_RATE_LIMIT_MAX` | Per-bucket request count per minute for trusted keys | `600` | | `CACHE_TICKERS_TTL` | On-chain data cache TTL (ms) | `55000` | | **Served indexer DB (required — the only database this API uses)** | | | -| `DATABASE_PG_URL` | Read-only connection to the served indexer DB (Meteora, tickers, DexScreener, first-trade-dates). **Required** — `/api/market-data` returns 503 without it. | — | +| `DATABASE_PG_URL` | Read-only connection to the served indexer DB (Meteora, tickers, DexScreener, first-trade-dates). **Required** — `/api/market-data`, `/api/tickers`, `/cmc/summary`, `/cmc/ticker`, and the DexScreener routes return 503 without it. | — | | `DATABASE_PG_SSL` | Enable SSL (server cert verified against system CAs) | `false` | | `DATABASE_PG_CA_CERT` | PEM CA cert content for private-CA verification | — | | `DATABASE_PG_SSL_NO_VERIFY` | Explicit opt-out of TLS verification (stopgap only) | `false` | @@ -330,6 +330,7 @@ Create a `.env` file in the root directory (see `example.env` for reference): | **Protocol** | | | | `PROTOCOL_FEE_RATE` | Protocol fee rate | `0.005` (0.5%) | | `EXCLUDED_DAOS` | Comma-separated DAO addresses to exclude | — | +| `CMC_ALLOWED_MINTS` | Comma-separated base-mint allowlist for the `/cmc/*` routes; empty serves all. Validated at startup; if set but matching zero discovered DAOs, the CMC routes fail closed with 503. | — | | **Alerts** | | | | `ALERT_WEBHOOK_URL` | Telegram alert webhook URL | — | | `ALERT_WEBHOOK_SECRET` | Webhook secret | — | diff --git a/src/routes/coinmarketcap.ts b/src/routes/coinmarketcap.ts index 9de3645..e458319 100644 --- a/src/routes/coinmarketcap.ts +++ b/src/routes/coinmarketcap.ts @@ -21,11 +21,28 @@ const PROTOCOL_FEE_RATE = config.fees.protocolFeeRate; * Apply the optional CMC allowlist (config.coinmarketcap.allowedMints). An empty * allowlist means "serve every discovered DAO" — same default as the CoinGecko * and DexScreener adapters. + * + * Fails CLOSED when an allowlist is configured but matches ZERO discovered DAOs: + * that is a misconfiguration (stale/wrong mint) or an upstream discovery outage, + * and returning an empty 200 would read as "every listed market delisted" to a + * poller. We surface it as 503 + alert instead so it retries and we get paged, + * rather than silently serving an empty feed. */ function filterAllowed(daos: DaoTickerData[]): DaoTickerData[] { const allowed = config.coinmarketcap.allowedMints; if (allowed.size === 0) return daos; - return daos.filter(dao => allowed.has(dao.baseMint.toString())); + const filtered = daos.filter(dao => allowed.has(dao.baseMint.toString())); + if (filtered.length === 0) { + sendAlert( + `CMC_ALLOWED_MINTS matched none of ${daos.length} discovered DAOs — refusing to serve an empty feed`, + { cooldownKey: 'cmc-allowlist-no-match', cooldownMs: 10 * 60 * 1000 } + ); + throw AppError.serviceUnavailable( + 'CMC allowlist matched no discovered markets', + 'CMC_ALLOWLIST_NO_MATCH' + ); + } + return filtered; } /** @@ -113,10 +130,26 @@ export function createCoinMarketCapRouter(services: ServiceGetters): Router { const spread = priceService.calculateSpread(priceNum); if (!spread) continue; + // Volume semantics for a financial feed: + // - metrics ABSENT → the pair simply had no spot trades in 24h; 0 is the + // genuine, correct volume (not an error). + // - metrics PRESENT but non-finite → served-DB contract drift produced a + // corrupt SUM for an INCLUDED pair. Do NOT silently drop it (that would + // turn schema drift into a partial 200); throw so the whole request + // fails as 5xx via asyncHandler and we get paged. const metrics = volumeByDao.get(daoAddress.toString()); - const baseVolume = metrics ? parseFloat(metrics.base_volume_24h) : 0; - const quoteVolume = metrics ? parseFloat(metrics.target_volume_24h) : 0; - if (!Number.isFinite(baseVolume) || !Number.isFinite(quoteVolume)) continue; + let baseVolume = 0; + let quoteVolume = 0; + if (metrics) { + baseVolume = parseFloat(metrics.base_volume_24h); + quoteVolume = parseFloat(metrics.target_volume_24h); + if (!Number.isFinite(baseVolume) || !Number.isFinite(quoteVolume)) { + throw AppError.internal( + `Malformed 24h volume from the served ETL for ${baseMint.toString()}`, + 'CMC_MALFORMED_VOLUME' + ); + } + } const pair: CmcPair = { tradingPair: `${baseMint.toString()}_${quoteMint.toString()}`, @@ -142,13 +175,17 @@ export function createCoinMarketCapRouter(services: ServiceGetters): Router { pairs.push(pair); } catch (error) { - // Per-pair skip ONLY (identical to the CoinGecko adapter's per-ticker - // catch): drop a single pair whose price/spread/volume can't be computed - // so one bad pool doesn't sink the whole feed. This does NOT mask an - // infrastructure/data outage as a 200 — those surface as 5xx before we - // reach here: getAllDaos() throws (its "refusing to serve an empty set" - // guard) if the RPC scan degrades, and getSpotRolling24hMetrics() throws - // on any served-DB/query failure. Both propagate via asyncHandler. + // Intentional financial-integrity failures (AppError, e.g. malformed ETL + // volume above) MUST propagate — rethrow so the request fails 5xx rather + // than being downgraded to a silently-dropped pair. + if (error instanceof AppError) throw error; + // Otherwise this is a per-pair skip ONLY (identical to the CoinGecko + // adapter's per-ticker catch): drop a single pair whose price/spread can't + // be computed so one bad pool doesn't sink the whole feed. This does NOT + // mask an infrastructure/data outage as a 200 — those surface as 5xx + // before we reach here: getAllDaos() throws (its "refusing to serve an + // empty set" guard) if the RPC scan degrades, and getSpotRolling24hMetrics() + // throws on any served-DB/query failure. Both propagate via asyncHandler. logger.error('Error building CMC pair', error, { daoAddress: dao.daoAddress.toString(), requestId: req.requestId, diff --git a/tests/routes/coinmarketcap.test.ts b/tests/routes/coinmarketcap.test.ts index 13d43f3..3fd3ecc 100644 --- a/tests/routes/coinmarketcap.test.ts +++ b/tests/routes/coinmarketcap.test.ts @@ -1,6 +1,7 @@ -import { describe, it, expect } from 'bun:test'; +import { describe, it, expect, afterEach } from 'bun:test'; import { createTestApp } from '../helpers/testApp.js'; import request from 'supertest'; +import { config } from '../../src/config.js'; import type { FutarchyService, DaoTickerData } from '../../src/services/futarchyService.js'; import type { ExternalDatabaseService } from '../../src/services/externalDatabaseService.js'; @@ -44,9 +45,37 @@ function extDbWithMetrics(): ExternalDatabaseService { } as unknown as ExternalDatabaseService; } +// Served DB whose 24h-metrics query throws (contract drift / query failure) — a +// financial feed must surface this as 5xx, never as an empty/partial 200. +function extDbThatThrows(): ExternalDatabaseService { + return { + isAvailable: () => true, + getSpotRolling24hMetrics: async () => { + throw new Error('query failed'); + }, + } as unknown as ExternalDatabaseService; +} + +// Served DB that returns a corrupt (non-numeric) volume for an INCLUDED pair. +function extDbWithMalformedVolume(): ExternalDatabaseService { + return { + isAvailable: () => true, + getSpotRolling24hMetrics: async () => + new Map([ + ['BASE1', { token: 'BASE1', base_volume_24h: 'not-a-number', target_volume_24h: '5', high_24h: '0', low_24h: '0', trade_count_24h: 1 }], + ]), + } as unknown as ExternalDatabaseService; +} + const DAOS = [dao('BASE1', 'USDC', 'DAOA'), dao('BASE2', 'USDC', 'DAOB')]; describe('CoinMarketCap Routes', () => { + // The allowlist lives on the config singleton; a couple of tests mutate it, so + // always reset to the default (empty = serve all) afterward. + afterEach(() => { + config.coinmarketcap.allowedMints.clear(); + }); + describe('GET /cmc/summary', () => { it('returns a 24h summary array with price, spread, and volume per pair', async () => { const app = createTestApp({ @@ -146,4 +175,70 @@ describe('CoinMarketCap Routes', () => { expect(Object.keys(res.body)).toContain('BASE1'); }); }); + + describe('served-DB failure semantics (never a partial 200)', () => { + it('/cmc/summary surfaces a 24h-metrics query failure as 5xx with no feed body', async () => { + const app = createTestApp({ + futarchyService: futarchyReturning(DAOS), + externalDatabaseService: extDbThatThrows(), + }); + const res = await request(app).get('/cmc/summary'); + expect(res.status).toBeGreaterThanOrEqual(500); + expect(Array.isArray(res.body)).toBe(false); + }); + + it('/cmc/ticker surfaces a 24h-metrics query failure as 5xx', async () => { + const app = createTestApp({ + futarchyService: futarchyReturning(DAOS), + externalDatabaseService: extDbThatThrows(), + }); + const res = await request(app).get('/cmc/ticker'); + expect(res.status).toBeGreaterThanOrEqual(500); + }); + + it('/cmc/summary surfaces malformed ETL volume for an included pair as 5xx', async () => { + const app = createTestApp({ + futarchyService: futarchyReturning([dao('BASE1', 'USDC', 'DAOA')]), + externalDatabaseService: extDbWithMalformedVolume(), + }); + const res = await request(app).get('/cmc/summary'); + expect(res.status).toBe(500); + expect(res.body.code).toBe('CMC_MALFORMED_VOLUME'); + }); + }); + + describe('CMC_ALLOWED_MINTS filtering', () => { + it('serves only allowlisted base mints when the allowlist is set', async () => { + config.coinmarketcap.allowedMints.add('BASE1'); + const app = createTestApp({ + futarchyService: futarchyReturning(DAOS), + externalDatabaseService: extDbWithMetrics(), + }); + + const res = await request(app).get('/cmc/summary'); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].trading_pairs).toBe('BASE1_USDC'); + + // /cmc/assets respects the same allowlist (only BASE1 + its quote). + const assets = await request(app).get('/cmc/assets'); + expect(Object.keys(assets.body).sort()).toEqual(['BASE1', 'USDC']); + }); + + it('fails closed (503) when the allowlist matches zero discovered DAOs', async () => { + config.coinmarketcap.allowedMints.add('NOTADISCOVEREDMINT'); + const app = createTestApp({ + futarchyService: futarchyReturning(DAOS), + externalDatabaseService: extDbWithMetrics(), + }); + + const res = await request(app).get('/cmc/summary'); + expect(res.status).toBe(503); + expect(res.body.code).toBe('CMC_ALLOWLIST_NO_MATCH'); + + // Same fail-closed behavior on the DB-free /cmc/assets route. + const assets = await request(app).get('/cmc/assets'); + expect(assets.status).toBe(503); + }); + }); }); From e06e362990af402ea652505bc0c7f42758d8d40c Mon Sep 17 00:00:00 2001 From: Jinglun Date: Tue, 21 Jul 2026 12:45:51 -0700 Subject: [PATCH 04/10] fix(external-api): stricter CMC integrity + document spec decisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iteration-3 review follow-ups: - allowlist: fail closed if ANY configured mint is absent from discovery (not only when zero match) — a partial feed can look like a delisting. Alert names the missing mints. - metrics: extend the fail-closed rule to a corrupt non-zero 24h high/low (shared parseFinite helper, code CMC_MALFORMED_METRIC) so no corrupt field slips through as a silently-omitted extreme. - document deliberate spec decisions in code + README: price_change_percent_24h is omitted (no reliable 24h-open; never fabricate — CoinGecko parity), and base_id/quote_id carry the mint/contract address (the same id /cmc/assets is keyed by) because our tokens have no CMC unified id yet. - tests: malformed-high → 5xx and partial-allowlist → 503 regressions. Co-Authored-By: Claude Opus 4.8 --- README.md | 9 ++- src/routes/coinmarketcap.ts | 103 +++++++++++++++++++---------- tests/routes/coinmarketcap.test.ts | 42 +++++++++--- 3 files changed, 108 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index df05895..8875268 100644 --- a/README.md +++ b/README.md @@ -101,9 +101,14 @@ a specific set of tokens; empty (the default) serves every discovered DAO. ] ``` +`base_currency` / `quote_currency` are Solana mint (contract) addresses — the +same ids `/cmc/assets` is keyed by, so CMC maps pairs → assets consistently. + `highest_price_24h` / `lowest_price_24h` are omitted when the ETL window has no -real high/low. `price_change_percent_24h` is intentionally not reported — there -is no reliable 24h-ago open, and a fabricated `0%` would be worse than omitting. +real high/low. `price_change_percent_24h` is intentionally not reported — the +served ETL exposes no reliable 24h-ago open, and this API never fabricates a +financial value (a fake `0%` would be worse than omitting). The sibling CoinGecko +`/api/tickers` adapter omits it for the same reason. #### GET `/cmc/ticker` diff --git a/src/routes/coinmarketcap.ts b/src/routes/coinmarketcap.ts index e458319..fcbc0b9 100644 --- a/src/routes/coinmarketcap.ts +++ b/src/routes/coinmarketcap.ts @@ -17,32 +17,53 @@ import { sendAlert } from '../utils/alerts.js'; // fee on the FutarchyAMM — there is no maker/taker distinction on an AMM. const PROTOCOL_FEE_RATE = config.fees.protocolFeeRate; +/** + * Parse a served-ETL numeric metric that must be finite. A non-finite value for + * an INCLUDED pair means contract drift produced corrupt data — surface it as a + * 500 (never a partial valid-looking 200). Same integrity rule for volume and + * high/low so no corrupt field can slip through as a silently-omitted extreme. + */ +function parseFinite(raw: string, field: string, mint: string): number { + const value = parseFloat(raw); + if (!Number.isFinite(value)) { + throw AppError.internal( + `Malformed 24h ${field} from the served ETL for ${mint}`, + 'CMC_MALFORMED_METRIC' + ); + } + return value; +} + /** * Apply the optional CMC allowlist (config.coinmarketcap.allowedMints). An empty * allowlist means "serve every discovered DAO" — same default as the CoinGecko * and DexScreener adapters. * - * Fails CLOSED when an allowlist is configured but matches ZERO discovered DAOs: - * that is a misconfiguration (stale/wrong mint) or an upstream discovery outage, - * and returning an empty 200 would read as "every listed market delisted" to a - * poller. We surface it as 503 + alert instead so it retries and we get paged, - * rather than silently serving an empty feed. + * Fails CLOSED when ANY configured mint is absent from the discovered DAOs. + * Operators allowlist the exact tokens they expect to serve, so a missing one is + * never benign — it is a stale/typo'd mint, an EXCLUDED_DAOS collision, or a + * discovery outage. Serving the remaining pairs as a 200 would read as "that + * token delisted" to a poller, so we surface it as 503 + alert (naming the + * missing mints) instead — it retries and we get paged. */ function filterAllowed(daos: DaoTickerData[]): DaoTickerData[] { const allowed = config.coinmarketcap.allowedMints; if (allowed.size === 0) return daos; - const filtered = daos.filter(dao => allowed.has(dao.baseMint.toString())); - if (filtered.length === 0) { + + const discovered = new Set(daos.map(dao => dao.baseMint.toString())); + const missing = [...allowed].filter(mint => !discovered.has(mint)); + if (missing.length > 0) { sendAlert( - `CMC_ALLOWED_MINTS matched none of ${daos.length} discovered DAOs — refusing to serve an empty feed`, - { cooldownKey: 'cmc-allowlist-no-match', cooldownMs: 10 * 60 * 1000 } + `CMC_ALLOWED_MINTS not found among ${daos.length} discovered DAOs: ${missing.join(', ')} — refusing to serve a partial feed`, + { cooldownKey: 'cmc-allowlist-missing', cooldownMs: 10 * 60 * 1000 } ); throw AppError.serviceUnavailable( - 'CMC allowlist matched no discovered markets', + 'CMC allowlist includes markets not currently discovered', 'CMC_ALLOWLIST_NO_MATCH' ); } - return filtered; + + return daos.filter(dao => allowed.has(dao.baseMint.toString())); } /** @@ -130,24 +151,29 @@ export function createCoinMarketCapRouter(services: ServiceGetters): Router { const spread = priceService.calculateSpread(priceNum); if (!spread) continue; - // Volume semantics for a financial feed: - // - metrics ABSENT → the pair simply had no spot trades in 24h; 0 is the - // genuine, correct volume (not an error). - // - metrics PRESENT but non-finite → served-DB contract drift produced a - // corrupt SUM for an INCLUDED pair. Do NOT silently drop it (that would - // turn schema drift into a partial 200); throw so the whole request - // fails as 5xx via asyncHandler and we get paged. + // Metric semantics for a financial feed: + // - metrics ABSENT → the pair simply had no spot trades in 24h; 0 volume + // and no high/low is the genuine, correct answer (not an error). + // - metrics PRESENT but a field is corrupt (non-finite) for an INCLUDED + // pair → served-DB contract drift. Do NOT silently drop/omit it (that + // turns schema drift into a valid-looking partial 200); throw so the + // whole request fails 5xx via asyncHandler and we get paged. + // + // '0' is the ETL's "no data" sentinel for high/low, so a genuine '0' is + // treated as absent (omitted), NOT parsed — only non-'0' values are. const metrics = volumeByDao.get(daoAddress.toString()); let baseVolume = 0; let quoteVolume = 0; + let high24h: number | undefined; + let low24h: number | undefined; if (metrics) { - baseVolume = parseFloat(metrics.base_volume_24h); - quoteVolume = parseFloat(metrics.target_volume_24h); - if (!Number.isFinite(baseVolume) || !Number.isFinite(quoteVolume)) { - throw AppError.internal( - `Malformed 24h volume from the served ETL for ${baseMint.toString()}`, - 'CMC_MALFORMED_VOLUME' - ); + baseVolume = parseFinite(metrics.base_volume_24h, 'base volume', baseMint.toString()); + quoteVolume = parseFinite(metrics.target_volume_24h, 'quote volume', baseMint.toString()); + if (metrics.high_24h !== '0') { + high24h = parseFinite(metrics.high_24h, '24h high', baseMint.toString()); + } + if (metrics.low_24h !== '0') { + low24h = parseFinite(metrics.low_24h, '24h low', baseMint.toString()); } } @@ -161,17 +187,8 @@ export function createCoinMarketCapRouter(services: ServiceGetters): Router { baseVolume, quoteVolume, }; - - // Only attach a 24h high/low when the ETL reports a real (non-zero) one — - // the metrics helper returns '0' as its "no data" sentinel. - if (metrics && metrics.high_24h !== '0') { - const high = parseFloat(metrics.high_24h); - if (Number.isFinite(high)) pair.high24h = high; - } - if (metrics && metrics.low_24h !== '0') { - const low = parseFloat(metrics.low_24h); - if (Number.isFinite(low)) pair.low24h = low; - } + if (high24h !== undefined) pair.high24h = high24h; + if (low24h !== undefined) pair.low24h = low24h; pairs.push(pair); } catch (error) { @@ -198,6 +215,13 @@ export function createCoinMarketCapRouter(services: ServiceGetters): Router { // --------------------------------------------------------------- // GET /cmc/summary — 24h overview of every tradeable pair (array). + // + // DELIBERATE OMISSION — price_change_percent_24h: CMC's summary schema lists it, + // but computing it needs a reliable 24h-ago open, which the served ETL's rolling + // metrics (SUM/MAX/MIN over 1m candles) do not expose. This repo's invariant is + // to never fabricate a financial value, so we omit the field rather than report + // a fake 0% — the sibling CoinGecko /api/tickers adapter omits it for the same + // reason. Add it here only alongside a real 24h-open source. // --------------------------------------------------------------- router.get('/cmc/summary', asyncHandler(async (req: Request, res: Response) => { const pairs = await buildPairs(req); @@ -224,6 +248,13 @@ export function createCoinMarketCapRouter(services: ServiceGetters): Router { // --------------------------------------------------------------- // GET /cmc/ticker — 24h price/volume keyed by `BASE_QUOTE` pair. + // + // base_id / quote_id carry the Solana mint (contract address), NOT a CMC + // "unified cryptoasset id". Our tokens are not yet listed on CMC, so no unified + // id exists — and for a DEX the on-chain contract address IS the canonical asset + // identifier. Crucially it is the SAME id /cmc/assets is keyed by, so CMC can map + // ticker → asset consistently (ticker.base_id === assets[key].contractAddress). + // Emitting the "unknown" sentinel 0 instead would make every pair unmappable. // --------------------------------------------------------------- router.get('/cmc/ticker', asyncHandler(async (req: Request, res: Response) => { const pairs = await buildPairs(req); diff --git a/tests/routes/coinmarketcap.test.ts b/tests/routes/coinmarketcap.test.ts index 3fd3ecc..10cb37a 100644 --- a/tests/routes/coinmarketcap.test.ts +++ b/tests/routes/coinmarketcap.test.ts @@ -56,14 +56,15 @@ function extDbThatThrows(): ExternalDatabaseService { } as unknown as ExternalDatabaseService; } -// Served DB that returns a corrupt (non-numeric) volume for an INCLUDED pair. -function extDbWithMalformedVolume(): ExternalDatabaseService { +// Served DB that returns a corrupt (non-numeric) metric for an INCLUDED pair. +// `field` selects which one is poisoned so we can assert both volume and high/low +// fail closed identically. +function extDbWithMalformedMetric(field: 'base_volume_24h' | 'high_24h'): ExternalDatabaseService { + const base: any = { token: 'BASE1', base_volume_24h: '10', target_volume_24h: '5', high_24h: '0.06', low_24h: '0.04', trade_count_24h: 1 }; + base[field] = 'not-a-number'; return { isAvailable: () => true, - getSpotRolling24hMetrics: async () => - new Map([ - ['BASE1', { token: 'BASE1', base_volume_24h: 'not-a-number', target_volume_24h: '5', high_24h: '0', low_24h: '0', trade_count_24h: 1 }], - ]), + getSpotRolling24hMetrics: async () => new Map([['BASE1', base]]), } as unknown as ExternalDatabaseService; } @@ -199,11 +200,21 @@ describe('CoinMarketCap Routes', () => { it('/cmc/summary surfaces malformed ETL volume for an included pair as 5xx', async () => { const app = createTestApp({ futarchyService: futarchyReturning([dao('BASE1', 'USDC', 'DAOA')]), - externalDatabaseService: extDbWithMalformedVolume(), + externalDatabaseService: extDbWithMalformedMetric('base_volume_24h'), }); const res = await request(app).get('/cmc/summary'); expect(res.status).toBe(500); - expect(res.body.code).toBe('CMC_MALFORMED_VOLUME'); + expect(res.body.code).toBe('CMC_MALFORMED_METRIC'); + }); + + it('/cmc/summary surfaces a malformed non-zero 24h high as 5xx (not a silent omit)', async () => { + const app = createTestApp({ + futarchyService: futarchyReturning([dao('BASE1', 'USDC', 'DAOA')]), + externalDatabaseService: extDbWithMalformedMetric('high_24h'), + }); + const res = await request(app).get('/cmc/summary'); + expect(res.status).toBe(500); + expect(res.body.code).toBe('CMC_MALFORMED_METRIC'); }); }); @@ -240,5 +251,20 @@ describe('CoinMarketCap Routes', () => { const assets = await request(app).get('/cmc/assets'); expect(assets.status).toBe(503); }); + + it('fails closed (503) when ONE of several allowlisted mints is missing (partial)', async () => { + // BASE1 IS discovered, BASE_GONE is not — a partial match must not serve a + // BASE1-only 200 that reads as "BASE_GONE delisted". + config.coinmarketcap.allowedMints.add('BASE1'); + config.coinmarketcap.allowedMints.add('BASE_GONE'); + const app = createTestApp({ + futarchyService: futarchyReturning(DAOS), + externalDatabaseService: extDbWithMetrics(), + }); + + const res = await request(app).get('/cmc/summary'); + expect(res.status).toBe(503); + expect(res.body.code).toBe('CMC_ALLOWLIST_NO_MATCH'); + }); }); }); From f15f26fd7fb4cf524976f1a87578fedfc6eda180 Mon Sep 17 00:00:00 2001 From: Jinglun Date: Tue, 21 Jul 2026 16:03:54 -0700 Subject: [PATCH 05/10] fix: address reviewer feedback Add API versioning to the CMC DEX adapter, as requested by CMC. Every endpoint is now also served under an explicit /cmc/v1 prefix (/cmc/v1/summary, /cmc/v1/ticker, /cmc/v1/assets) while the original unversioned paths remain published as-is. Both prefixes map to the same handler (URL aliases), so the current contract is treated as v1 and a future breaking change can land under /cmc/v2. - Register each handler on both prefixes via a cmcPaths() helper - Document the versioning in the README and root endpoint listing - Add tests asserting /cmc/v1/* is byte-identical to /cmc/* and preserves fail-closed (503) semantics Co-Authored-By: Claude Opus 4.8 --- README.md | 11 ++++ src/routes/coinmarketcap.ts | 51 +++++++++++++++--- src/routes/root.ts | 2 +- src/types/coinmarketcap.ts | 8 +++ tests/routes/coinmarketcap.test.ts | 85 ++++++++++++++++++++++++++++++ 5 files changed, 150 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8875268..df1c449 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,13 @@ requirements. Served under `/cmc/`. The shapes mirror the CoinGecko adapter — CMC's DEX spec is field-for-field close — and both feeds are built from the same on-chain DAO discovery and rolling-24h ETL metrics. +**API versioning.** At CMC's request, every endpoint is also served under an +explicit version prefix: `/cmc/v1/summary`, `/cmc/v1/ticker`, `/cmc/v1/assets`. +The unversioned paths remain published as-is and are treated as the current (v1) +contract — the two are URL aliases for the same handler, so they never diverge. +A future breaking change would land under `/cmc/v2/…` while the existing paths +keep serving v1. + `/cmc/summary` and `/cmc/ticker` carry 24h volume, so they require `DATABASE_PG_URL` and return `503` (never zero volume) if the served DB is unavailable. `/cmc/assets` is pure on-chain metadata and does not require it. @@ -120,6 +127,10 @@ financial value (a fake `0%` would be worse than omitting). The sibling CoinGeck "ZKFHiLAfAFMTcDAuCtjNW54VzpERvoe7PBF9mYgmeta_EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v": { "base_id": "ZKFHiLAfAFMTcDAuCtjNW54VzpERvoe7PBF9mYgmeta", "quote_id": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "base_name": "ZKFG", + "base_symbol": "ZKFG", + "quote_name": "USD Coin", + "quote_symbol": "USDC", "last_price": 0.081340728222, "base_volume": 30024.8104, "quote_volume": 2441.23456789, diff --git a/src/routes/coinmarketcap.ts b/src/routes/coinmarketcap.ts index fcbc0b9..cf4acf7 100644 --- a/src/routes/coinmarketcap.ts +++ b/src/routes/coinmarketcap.ts @@ -34,6 +34,16 @@ function parseFinite(raw: string, field: string, mint: string): number { return value; } +/** + * Fallback identity label for a token. Shared by /cmc/ticker (inline name/symbol) + * and /cmc/assets so the two feeds ALWAYS report the same name/symbol for a given + * mint. Falls back to a mint prefix when on-chain metadata is missing — never an + * empty string, since CMC's DEX spec marks base/quote name+symbol mandatory. + */ +function identityLabel(value: string | undefined, mint: string): string { + return value || mint.slice(0, 8); +} + /** * Apply the optional CMC allowlist (config.coinmarketcap.allowedMints). An empty * allowlist means "serve every discovered DAO" — same default as the CoinGecko @@ -75,6 +85,10 @@ interface CmcPair { tradingPair: string; // `${baseMint}_${quoteMint}` baseId: string; quoteId: string; + baseName: string; + baseSymbol: string; + quoteName: string; + quoteSymbol: string; lastPrice: number; bid: number; ask: number; @@ -84,6 +98,20 @@ interface CmcPair { low24h?: number; } +// CMC asked us to version the API URL. We keep serving the original unversioned +// paths (`/cmc/summary`, …) that are already published and consumed AS-IS, and +// ALSO expose the identical handlers under an explicit `/cmc/v1/…` prefix so a +// consumer can pin a version. Both prefixes map to the same handler — this is a +// URL alias, not a behavioural fork — so the two stay in lockstep and there is +// nothing to keep in sync. Introduce `/cmc/v2/…` only when a breaking change +// forces it; the unversioned path is treated as the current (v1) contract. +const CMC_PREFIXES = ['/cmc', '/cmc/v1'] as const; + +/** Both the unversioned and the v1-prefixed path for a CMC endpoint. */ +function cmcPaths(name: string): string[] { + return CMC_PREFIXES.map(prefix => `${prefix}/${name}`); +} + export function createCoinMarketCapRouter(services: ServiceGetters): Router { const router = Router(); const { getFutarchyService, getPriceService, getExternalDatabaseService } = services; @@ -137,7 +165,10 @@ export function createCoinMarketCapRouter(services: ServiceGetters): Router { const pairs: CmcPair[] = []; for (const dao of allDaos) { try { - const { daoAddress, baseMint, quoteMint, baseDecimals, quoteDecimals, poolData } = dao; + const { + daoAddress, baseMint, quoteMint, baseDecimals, quoteDecimals, poolData, + baseSymbol, baseName, quoteSymbol, quoteName, + } = dao; const lastPriceStr = priceService.calculatePrice( poolData.baseReserves, @@ -181,6 +212,10 @@ export function createCoinMarketCapRouter(services: ServiceGetters): Router { tradingPair: `${baseMint.toString()}_${quoteMint.toString()}`, baseId: baseMint.toString(), quoteId: quoteMint.toString(), + baseName: identityLabel(baseName, baseMint.toString()), + baseSymbol: identityLabel(baseSymbol, baseMint.toString()), + quoteName: identityLabel(quoteName, quoteMint.toString()), + quoteSymbol: identityLabel(quoteSymbol, quoteMint.toString()), lastPrice: priceNum, bid: parseFloat(spread.bid), ask: parseFloat(spread.ask), @@ -223,7 +258,7 @@ export function createCoinMarketCapRouter(services: ServiceGetters): Router { // a fake 0% — the sibling CoinGecko /api/tickers adapter omits it for the same // reason. Add it here only alongside a real 24h-open source. // --------------------------------------------------------------- - router.get('/cmc/summary', asyncHandler(async (req: Request, res: Response) => { + router.get(cmcPaths('summary'), asyncHandler(async (req: Request, res: Response) => { const pairs = await buildPairs(req); const summary: CoinMarketCapSummaryPair[] = pairs.map(p => { @@ -256,7 +291,7 @@ export function createCoinMarketCapRouter(services: ServiceGetters): Router { // ticker → asset consistently (ticker.base_id === assets[key].contractAddress). // Emitting the "unknown" sentinel 0 instead would make every pair unmappable. // --------------------------------------------------------------- - router.get('/cmc/ticker', asyncHandler(async (req: Request, res: Response) => { + router.get(cmcPaths('ticker'), asyncHandler(async (req: Request, res: Response) => { const pairs = await buildPairs(req); const ticker: CoinMarketCapTickerResponse = {}; @@ -264,6 +299,10 @@ export function createCoinMarketCapRouter(services: ServiceGetters): Router { const entry: CoinMarketCapTicker = { base_id: p.baseId, quote_id: p.quoteId, + base_name: p.baseName, + base_symbol: p.baseSymbol, + quote_name: p.quoteName, + quote_symbol: p.quoteSymbol, last_price: p.lastPrice, base_volume: p.baseVolume, quote_volume: p.quoteVolume, @@ -282,7 +321,7 @@ export function createCoinMarketCapRouter(services: ServiceGetters): Router { // it does NOT require the served DB. Exposes both the base and quote token of // every (allowlisted) pair. // --------------------------------------------------------------- - router.get('/cmc/assets', asyncHandler(async (req: Request, res: Response) => { + router.get(cmcPaths('assets'), asyncHandler(async (req: Request, res: Response) => { const futarchyService = getFutarchyService(); const allDaos = filterAllowed(await futarchyService.getAllDaos()); @@ -293,8 +332,8 @@ export function createCoinMarketCapRouter(services: ServiceGetters): Router { // shared quote (USDC) is identical across pairs, so skipping re-adds is safe. if (assets[mint]) return; const asset: CoinMarketCapAsset = { - name: name || mint.slice(0, 8), - symbol: symbol || mint.slice(0, 8), + name: identityLabel(name, mint), + symbol: identityLabel(symbol, mint), contractAddress: mint, can_withdraw: 'true', can_deposit: 'true', diff --git a/src/routes/root.ts b/src/routes/root.ts index dbff8e0..bef2f32 100644 --- a/src/routes/root.ts +++ b/src/routes/root.ts @@ -28,7 +28,7 @@ export function createRootRouter(_services: ServiceGetters): Router { events: '/dexscreener/events?fromBlock=:slot&toBlock=:slot - Swap events by slot range', }, coinmarketcap: { - description: 'CoinMarketCap DEX Adapter [Section C] — requires DATABASE_PG_URL for summary/ticker', + description: 'CoinMarketCap DEX Adapter [Section C] — requires DATABASE_PG_URL for summary/ticker. Also served under a versioned /cmc/v1 prefix (e.g. /cmc/v1/summary).', summary: '/cmc/summary - 24h overview of every tradeable pair', ticker: '/cmc/ticker - 24h price/volume keyed by BASE_QUOTE pair', assets: '/cmc/assets - Token identity keyed by mint address', diff --git a/src/types/coinmarketcap.ts b/src/types/coinmarketcap.ts index 8acac5f..269b923 100644 --- a/src/types/coinmarketcap.ts +++ b/src/types/coinmarketcap.ts @@ -14,6 +14,14 @@ export interface CoinMarketCapTicker { base_id: string; quote_id: string; + // Token identity carried INLINE, per CMC's Section C "Uniswap Sample" DEX spec + // (a DEX has no symbol-keyed listing, so name/symbol travel with the pair). Same + // values as /cmc/assets keyed by base_id/quote_id — the two feeds stay consistent. + // Mirrors the sibling CoinGecko /api/tickers adapter, which emits these inline too. + base_name: string; + base_symbol: string; + quote_name: string; + quote_symbol: string; last_price: number; base_volume: number; quote_volume: number; diff --git a/tests/routes/coinmarketcap.test.ts b/tests/routes/coinmarketcap.test.ts index 10cb37a..bf1412c 100644 --- a/tests/routes/coinmarketcap.test.ts +++ b/tests/routes/coinmarketcap.test.ts @@ -134,12 +134,50 @@ describe('CoinMarketCap Routes', () => { const t = res.body['BASE1_USDC']; expect(t.base_id).toBe('BASE1'); expect(t.quote_id).toBe('USDC'); + // Identity carried inline per CMC's Section C DEX spec, consistent with + // /cmc/assets keyed by base_id/quote_id. + expect(t.base_symbol).toBe('BASE1SYM'); + expect(t.base_name).toBe('BASE1 Name'); + expect(t.quote_symbol).toBe('USDC'); + expect(t.quote_name).toBe('USD Coin'); expect(t.last_price).toBe(0.05); expect(t.base_volume).toBe(100); expect(t.quote_volume).toBe(5); expect(t.isFrozen).toBe(0); }); + it('falls back to a mint prefix for inline symbol/name when metadata is missing', async () => { + // A DAO whose base metadata never resolved on-chain: baseSymbol/baseName + // undefined. The ticker must still emit a non-empty string (CMC marks these + // mandatory) and match what /cmc/assets reports for the same mint. + const noMeta = { + daoAddress: pk('DAOC'), + baseMint: pk('BASENOMETA1234567890'), + quoteMint: pk('USDC'), + baseDecimals: 6, + quoteDecimals: 6, + baseSymbol: undefined, + baseName: undefined, + quoteSymbol: 'USDC', + quoteName: 'USD Coin', + poolData: { baseReserves: {}, quoteReserves: {}, baseProtocolFees: {}, quoteProtocolFees: {} }, + } as unknown as DaoTickerData; + + const app = createTestApp({ + futarchyService: futarchyReturning([noMeta]), + externalDatabaseService: extDbWithMetrics(), + }); + + const tRes = await request(app).get('/cmc/ticker'); + const t = tRes.body['BASENOMETA1234567890_USDC']; + expect(t.base_symbol).toBe('BASENOME'); + expect(t.base_name).toBe('BASENOME'); + + const aRes = await request(app).get('/cmc/assets'); + expect(aRes.body['BASENOMETA1234567890'].symbol).toBe(t.base_symbol); + expect(aRes.body['BASENOMETA1234567890'].name).toBe(t.base_name); + }); + it('returns 503 when the served DB is unavailable', async () => { const app = createTestApp({ futarchyService: futarchyReturning(DAOS), @@ -218,6 +256,53 @@ describe('CoinMarketCap Routes', () => { }); }); + describe('API versioning (/cmc/v1 alias)', () => { + // CMC asked for a versioned URL. The /cmc/v1/* paths must be exact aliases of + // the unversioned handlers — same body, same status, same failure semantics. + it('serves /cmc/v1/summary identically to /cmc/summary', async () => { + const app = createTestApp({ + futarchyService: futarchyReturning(DAOS), + externalDatabaseService: extDbWithMetrics(), + }); + + const unversioned = await request(app).get('/cmc/summary'); + const versioned = await request(app).get('/cmc/v1/summary'); + expect(versioned.status).toBe(200); + expect(versioned.body).toEqual(unversioned.body); + }); + + it('serves /cmc/v1/ticker identically to /cmc/ticker', async () => { + const app = createTestApp({ + futarchyService: futarchyReturning(DAOS), + externalDatabaseService: extDbWithMetrics(), + }); + + const unversioned = await request(app).get('/cmc/ticker'); + const versioned = await request(app).get('/cmc/v1/ticker'); + expect(versioned.status).toBe(200); + expect(versioned.body).toEqual(unversioned.body); + }); + + it('serves /cmc/v1/assets identically to /cmc/assets', async () => { + const app = createTestApp({ futarchyService: futarchyReturning(DAOS) }); + + const unversioned = await request(app).get('/cmc/assets'); + const versioned = await request(app).get('/cmc/v1/assets'); + expect(versioned.status).toBe(200); + expect(versioned.body).toEqual(unversioned.body); + }); + + it('preserves fail-closed semantics on the versioned path (503 when served DB is down)', async () => { + const app = createTestApp({ + futarchyService: futarchyReturning(DAOS), + externalDatabaseService: { isAvailable: () => false } as unknown as ExternalDatabaseService, + }); + const res = await request(app).get('/cmc/v1/summary'); + expect(res.status).toBe(503); + expect(res.body.code).toBe('SERVED_DB_UNAVAILABLE'); + }); + }); + describe('CMC_ALLOWED_MINTS filtering', () => { it('serves only allowlisted base mints when the allowlist is set', async () => { config.coinmarketcap.allowedMints.add('BASE1'); From 3bf83a32eb4c82b8dc88067b678be90d59cd6660 Mon Sep 17 00:00:00 2001 From: Jinglun Date: Tue, 21 Jul 2026 16:07:35 -0700 Subject: [PATCH 06/10] fix: address review feedback (cycle 1) Reject partially-parsed ETL metrics in the CMC feed: parseFinite now uses a full-string Number() parse instead of parseFloat, so a corrupt value with a numeric prefix ("12abc") fails closed with CMC_MALFORMED_METRIC instead of being silently truncated to 12 and served as a valid-looking 200. Blank strings are rejected too (would otherwise read as a genuine 0). Adds a regression test for the numeric-prefix case. Co-Authored-By: Claude Opus 4.8 --- src/routes/coinmarketcap.ts | 9 ++++++++- tests/routes/coinmarketcap.test.ts | 27 ++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/routes/coinmarketcap.ts b/src/routes/coinmarketcap.ts index cf4acf7..1354b6a 100644 --- a/src/routes/coinmarketcap.ts +++ b/src/routes/coinmarketcap.ts @@ -22,9 +22,16 @@ const PROTOCOL_FEE_RATE = config.fees.protocolFeeRate; * an INCLUDED pair means contract drift produced corrupt data — surface it as a * 500 (never a partial valid-looking 200). Same integrity rule for volume and * high/low so no corrupt field can slip through as a silently-omitted extreme. + * + * Uses a FULL-STRING numeric parse (Number, not parseFloat): parseFloat accepts + * a valid numeric prefix and silently drops the rest ("12abc" → 12), which would + * emit truncated financial data as a valid-looking 200. Number() rejects any + * trailing garbage outright (→ NaN). An empty/blank string is likewise rejected + * (Number('') is 0, which would masquerade as a genuine zero) so a missing field + * fails closed instead of reading as "no volume". */ function parseFinite(raw: string, field: string, mint: string): number { - const value = parseFloat(raw); + const value = raw.trim() === '' ? NaN : Number(raw); if (!Number.isFinite(value)) { throw AppError.internal( `Malformed 24h ${field} from the served ETL for ${mint}`, diff --git a/tests/routes/coinmarketcap.test.ts b/tests/routes/coinmarketcap.test.ts index bf1412c..a59b713 100644 --- a/tests/routes/coinmarketcap.test.ts +++ b/tests/routes/coinmarketcap.test.ts @@ -56,12 +56,17 @@ function extDbThatThrows(): ExternalDatabaseService { } as unknown as ExternalDatabaseService; } -// Served DB that returns a corrupt (non-numeric) metric for an INCLUDED pair. -// `field` selects which one is poisoned so we can assert both volume and high/low -// fail closed identically. -function extDbWithMalformedMetric(field: 'base_volume_24h' | 'high_24h'): ExternalDatabaseService { +// Served DB that returns a corrupt metric for an INCLUDED pair. `field` selects +// which one is poisoned so we can assert both volume and high/low fail closed +// identically; `bad` selects the corruption shape (fully non-numeric by default, +// or a numeric-prefixed string like '12abc' that a lenient parseFloat would +// silently truncate to 12). +function extDbWithMalformedMetric( + field: 'base_volume_24h' | 'high_24h', + bad: string = 'not-a-number', +): ExternalDatabaseService { const base: any = { token: 'BASE1', base_volume_24h: '10', target_volume_24h: '5', high_24h: '0.06', low_24h: '0.04', trade_count_24h: 1 }; - base[field] = 'not-a-number'; + base[field] = bad; return { isAvailable: () => true, getSpotRolling24hMetrics: async () => new Map([['BASE1', base]]), @@ -254,6 +259,18 @@ describe('CoinMarketCap Routes', () => { expect(res.status).toBe(500); expect(res.body.code).toBe('CMC_MALFORMED_METRIC'); }); + + it('/cmc/summary rejects a numeric-prefixed ETL volume rather than truncating it', async () => { + // parseFloat('12abc') === 12 would emit truncated financial data as a 200; + // the full-string parse must fail closed on the trailing garbage instead. + const app = createTestApp({ + futarchyService: futarchyReturning([dao('BASE1', 'USDC', 'DAOA')]), + externalDatabaseService: extDbWithMalformedMetric('base_volume_24h', '12abc'), + }); + const res = await request(app).get('/cmc/summary'); + expect(res.status).toBe(500); + expect(res.body.code).toBe('CMC_MALFORMED_METRIC'); + }); }); describe('API versioning (/cmc/v1 alias)', () => { From a6430a53582cc3a2f8829cc47e66c95b38598dfd Mon Sep 17 00:00:00 2001 From: Jinglun Date: Tue, 21 Jul 2026 17:17:22 -0700 Subject: [PATCH 07/10] feat(external-api): CMC inline ticker identity + price_change_percent_24h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the two gaps found checking the /cmc adapter against CoinMarketCap's Section C DEX spec (the C1 Uniswap sample), not just CoinGecko parity. 1. Inline token identity on /cmc/ticker — restore base_name/base_symbol/ quote_name/quote_symbol, which C1 requires in each pair object and which the sibling CoinGecko /api/tickers already emits. A shared identityLabel() helper (mint-prefix fallback) keeps /cmc/ticker and /cmc/assets always consistent and never emits an empty string. 2. price_change_percent_24h on /cmc/summary — computed from the AMM's EXACT price 24h ago: FutarchyAMM price is a pure function of pool reserves and reserves only move on a swap, so the reserves of the last swap >=24h ago (user_pool_swaps) are the pool's exact state 24h ago. Priced through the same calculatePrice as last_price (true mid-vs-mid). Omitted, never fabricated, for markets younger than 24h. Summary-only (CMC's ticker/A2 has no such field). The swaps source is non-essential: a query failure degrades to field-absent (200), not 503 — an absent change is "unknown", not a false financial claim. New: externalDatabaseService.getSpotReserves24hAgo(). Tests cover inline identity + fallback, computed %, young-market omit, source-failure graceful degradation, and ticker exclusion. tsc/knip/repo-guard clean; full suite 137 pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 18 +++-- src/routes/coinmarketcap.ts | 64 +++++++++++++++--- src/services/externalDatabaseService.ts | 65 ++++++++++++++++++ src/types/coinmarketcap.ts | 7 +- tests/routes/coinmarketcap.test.ts | 90 +++++++++++++++++++++++++ 5 files changed, 229 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index df1c449..e2fcec7 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,8 @@ a specific set of tokens; empty (the default) serves every discovered DAO. "base_volume": 30024.8104, "quote_volume": 2441.23456789, "highest_price_24h": 0.085, - "lowest_price_24h": 0.078 + "lowest_price_24h": 0.078, + "price_change_percent_24h": 4.28 } ] ``` @@ -112,10 +113,17 @@ a specific set of tokens; empty (the default) serves every discovered DAO. same ids `/cmc/assets` is keyed by, so CMC maps pairs → assets consistently. `highest_price_24h` / `lowest_price_24h` are omitted when the ETL window has no -real high/low. `price_change_percent_24h` is intentionally not reported — the -served ETL exposes no reliable 24h-ago open, and this API never fabricates a -financial value (a fake `0%` would be worse than omitting). The sibling CoinGecko -`/api/tickers` adapter omits it for the same reason. +real high/low. + +`price_change_percent_24h` is the 24h price change in percent, computed from the +AMM's **exact** price 24h ago. The FutarchyAMM price is a pure function of pool +reserves, and reserves only change on a swap, so the reserves of the last swap +≥24h ago (`futarchy.user_pool_swaps`) are the pool's exact state 24h ago — priced +through the same formula as `last_price` (a true mid-vs-mid comparison). It is +**omitted** for a market younger than 24h (no swap before the cutoff), where the +change is undefined — never fabricated as `0%`. If the swaps source is briefly +unavailable, the field is omitted for that response but price/volume still serve +(unlike the volume source, whose absence returns `503`). #### GET `/cmc/ticker` diff --git a/src/routes/coinmarketcap.ts b/src/routes/coinmarketcap.ts index 1354b6a..47288cb 100644 --- a/src/routes/coinmarketcap.ts +++ b/src/routes/coinmarketcap.ts @@ -1,4 +1,5 @@ import { Router, type Request, type Response } from 'express'; +import BN from 'bn.js'; import type { CoinMarketCapTicker, CoinMarketCapTickerResponse, @@ -103,6 +104,9 @@ interface CmcPair { quoteVolume: number; high24h?: number; low24h?: number; + // Present only when a true 24h-ago price exists (the pair had a swap ≥24h ago). + // Omitted for markets younger than 24h — a 24h change is undefined there. + priceChangePercent24h?: number; } // CMC asked us to version the API URL. We keep serving the original unversioned @@ -131,7 +135,10 @@ export function createCoinMarketCapRouter(services: ServiceGetters): Router { * zero volume for a financial feed). Price/spread/liquidity come from live * spot-pool reserves via the same PriceService the CoinGecko adapter uses. */ - async function buildPairs(req: Request): Promise { + async function buildPairs( + req: Request, + opts: { withPriceChange?: boolean } = {} + ): Promise { const futarchyService = getFutarchyService(); const priceService = getPriceService(); const externalDatabaseService = getExternalDatabaseService(); @@ -169,6 +176,27 @@ export function createCoinMarketCapRouter(services: ServiceGetters): Router { }); } + // Reserves from the last spot swap ≥24h ago, per token — the source for + // price_change_percent_24h (summary only). A failure here is NON-fatal: the + // core feed (price + volume) is unaffected, and price_change is definitionally + // optional (young markets omit it), so an outage of the swaps source degrades + // to "field absent" rather than taking the whole feed down. This differs from + // the volume source, whose absence WOULD be a false financial claim (0 volume). + let reserves24hByToken = new Map(); + if (opts.withPriceChange) { + try { + reserves24hByToken = await externalDatabaseService.getSpotReserves24hAgo(baseMints); + } catch (error) { + logger.error('Failed to load 24h-ago reserves for /cmc price change; serving feed without it', error, { + requestId: req.requestId, + }); + sendAlert( + 'CMC price_change_percent_24h source (user_pool_swaps) query failed — serving feed without 24h change', + { cooldownKey: 'cmc-price-change-source', cooldownMs: 10 * 60 * 1000 } + ); + } + } + const pairs: CmcPair[] = []; for (const dao of allDaos) { try { @@ -232,6 +260,26 @@ export function createCoinMarketCapRouter(services: ServiceGetters): Router { if (high24h !== undefined) pair.high24h = high24h; if (low24h !== undefined) pair.low24h = low24h; + // price_change_percent_24h — computed from the last swap ≥24h ago, priced + // through the SAME calculatePrice as last_price (identical decimal handling + // → a true mid-vs-mid comparison). Omitted (never fabricated) when: the + // market is younger than 24h (no ref in the map), or the reference price + // can't be computed / is ≤0. A bad historical reserve is a per-pair omit, + // not a 500 — unlike the primary price/volume feed. + const ref = reserves24hByToken.get(baseMint.toString()); + if (ref) { + const refPriceStr = priceService.calculatePrice( + new BN(ref.baseReserves), + new BN(ref.quoteReserves), + baseDecimals, + quoteDecimals + ); + const refPrice = refPriceStr ? parseFloat(refPriceStr) : NaN; + if (Number.isFinite(refPrice) && refPrice > 0) { + pair.priceChangePercent24h = ((priceNum - refPrice) / refPrice) * 100; + } + } + pairs.push(pair); } catch (error) { // Intentional financial-integrity failures (AppError, e.g. malformed ETL @@ -258,15 +306,14 @@ export function createCoinMarketCapRouter(services: ServiceGetters): Router { // --------------------------------------------------------------- // GET /cmc/summary — 24h overview of every tradeable pair (array). // - // DELIBERATE OMISSION — price_change_percent_24h: CMC's summary schema lists it, - // but computing it needs a reliable 24h-ago open, which the served ETL's rolling - // metrics (SUM/MAX/MIN over 1m candles) do not expose. This repo's invariant is - // to never fabricate a financial value, so we omit the field rather than report - // a fake 0% — the sibling CoinGecko /api/tickers adapter omits it for the same - // reason. Add it here only alongside a real 24h-open source. + // price_change_percent_24h is computed from AMM swap history (see buildPairs): + // the FutarchyAMM price is a pure function of pool reserves and reserves only + // move on a swap, so the last swap ≥24h ago gives the EXACT price 24h ago. It is + // omitted (never fabricated) for markets younger than 24h, where the change is + // undefined. This is the only endpoint that carries it (CMC's ticker/A2 does not). // --------------------------------------------------------------- router.get(cmcPaths('summary'), asyncHandler(async (req: Request, res: Response) => { - const pairs = await buildPairs(req); + const pairs = await buildPairs(req, { withPriceChange: true }); const summary: CoinMarketCapSummaryPair[] = pairs.map(p => { const entry: CoinMarketCapSummaryPair = { @@ -282,6 +329,7 @@ export function createCoinMarketCapRouter(services: ServiceGetters): Router { }; if (p.high24h !== undefined) entry.highest_price_24h = p.high24h; if (p.low24h !== undefined) entry.lowest_price_24h = p.low24h; + if (p.priceChangePercent24h !== undefined) entry.price_change_percent_24h = p.priceChangePercent24h; return entry; }); diff --git a/src/services/externalDatabaseService.ts b/src/services/externalDatabaseService.ts index c8d6563..a254b93 100644 --- a/src/services/externalDatabaseService.ts +++ b/src/services/externalDatabaseService.ts @@ -143,6 +143,71 @@ export class ExternalDatabaseService { } } + /** + * Post-swap AMM reserves from the LAST spot swap at or before `now - 24h`, per + * token (base mint). The caller turns these into the reference price 24h ago and + * computes CMC's `price_change_percent_24h`. + * + * Why this is EXACT, not an estimate: FutarchyAMM spot price is a pure function + * of pool reserves (`quote/base`, decimal-adjusted), and AMM reserves only change + * when a swap executes. So the reserves of the last swap before the cutoff ARE + * the pool's exact state — hence exact price — at the cutoff instant. Carry- + * forward on an AMM is exact (unlike an orderbook, which needs a real snapshot). + * + * `amm_base_reserves`/`amm_quote_reserves` are our decoded post-swap reserves, + * non-NULL for every spot swap (same columns the DexScreener /events route reads). + * Returns RAW reserves (as strings) so the caller prices them through the SAME + * PriceService.calculatePrice used for `last_price` — identical decimal handling, + * so the percent change is a true mid-vs-mid comparison. + * + * A token with NO swap before the cutoff (first traded < 24h ago) is simply + * absent from the map: a 24h change is undefined for a market younger than 24h, + * and the caller omits the field rather than fabricate one. Throws (never masks + * as empty) on connection/query failure, exactly like getSpotRolling24hMetrics. + */ + async getSpotReserves24hAgo(tokens: string[]): Promise> { + if (tokens.length === 0) { + return new Map(); + } + if (!this.pool || !this.isConnected) { + throw new Error('External database not connected'); + } + + const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + + try { + const result = await this.pool.query( + `SELECT DISTINCT ON (base_mint) + base_mint AS token, + amm_base_reserves::text AS base_reserves, + amm_quote_reserves::text AS quote_reserves + FROM futarchy.user_pool_swaps + WHERE source = 'futarchy_amm' + AND market_kind = 'spot' + AND block_time <= $1 + AND base_mint = ANY($2::text[]) + AND amm_base_reserves IS NOT NULL + AND amm_quote_reserves IS NOT NULL + AND amm_base_reserves > 0 + ORDER BY base_mint, block_time DESC`, + [cutoff, tokens] + ); + + const reservesMap = new Map(); + for (const row of result.rows) { + if (row.base_reserves == null || row.quote_reserves == null) continue; + reservesMap.set(row.token, { + baseReserves: row.base_reserves, + quoteReserves: row.quote_reserves, + }); + } + return reservesMap; + } catch (error: any) { + logger.error('[ExternalDB] Error getting 24h-ago spot reserves from user_pool_swaps:', error); + throw error; + } + } + /** * Daily Meteora volumes from the unified `futarchy.user_pool_daily` (source= * 'meteora') in the served DB — the user_pool ETL output (a faithful, v0_6_daos- diff --git a/src/types/coinmarketcap.ts b/src/types/coinmarketcap.ts index 269b923..f27054a 100644 --- a/src/types/coinmarketcap.ts +++ b/src/types/coinmarketcap.ts @@ -48,10 +48,13 @@ export interface CoinMarketCapSummaryPair { quote_volume: number; // Only reported when a real 24h high/low exists in the ETL window. Omitted // (rather than reported as 0) otherwise — a financial feed must not fabricate - // an extreme. price_change_percent_24h is intentionally absent: we have no - // reliable 24h-ago open, and reporting a fake 0% would be worse than omitting. + // an extreme. highest_price_24h?: number; lowest_price_24h?: number; + // 24h price change, percent. Derived from the AMM's exact price 24h ago (the + // reserves of the last swap ≥24h ago). Omitted for markets younger than 24h, + // where the change is undefined — never fabricated as 0. + price_change_percent_24h?: number; } /** One entry in the `/cmc/assets` object, keyed by the token's mint address. */ diff --git a/tests/routes/coinmarketcap.test.ts b/tests/routes/coinmarketcap.test.ts index a59b713..b73e9e2 100644 --- a/tests/routes/coinmarketcap.test.ts +++ b/tests/routes/coinmarketcap.test.ts @@ -4,6 +4,7 @@ import request from 'supertest'; import { config } from '../../src/config.js'; import type { FutarchyService, DaoTickerData } from '../../src/services/futarchyService.js'; import type { ExternalDatabaseService } from '../../src/services/externalDatabaseService.js'; +import type { PriceService } from '../../src/services/priceService.js'; // Minimal DAO stand-ins: buildPairs only ever calls `.toString()` on the pubkey // fields and reads decimals/poolData, and the mock PriceService (testApp) ignores @@ -42,6 +43,9 @@ function extDbWithMetrics(): ExternalDatabaseService { ['BASE1', { token: 'BASE1', base_volume_24h: '100', target_volume_24h: '5', high_24h: '0.06', low_24h: '0.04', trade_count_24h: 3 }], ['BASE2', { token: 'BASE2', base_volume_24h: '0', target_volume_24h: '0', high_24h: '0', low_24h: '0', trade_count_24h: 0 }], ]), + // Default: no 24h-ago reserves → price_change_percent_24h omitted (the price- + // change tests below override this with a dedicated mock). + getSpotReserves24hAgo: async () => new Map(), } as unknown as ExternalDatabaseService; } @@ -70,6 +74,7 @@ function extDbWithMalformedMetric( return { isAvailable: () => true, getSpotRolling24hMetrics: async () => new Map([['BASE1', base]]), + getSpotReserves24hAgo: async () => new Map(), } as unknown as ExternalDatabaseService; } @@ -369,4 +374,89 @@ describe('CoinMarketCap Routes', () => { expect(res.body.code).toBe('CMC_ALLOWLIST_NO_MATCH'); }); }); + + describe('price_change_percent_24h (from AMM swap history)', () => { + // last_price comes from the (empty) live poolData reserves → the mock returns + // its default 0.05. For the 24h-ago reserves we return raw values the route + // wraps in BN, and the mock prices them by their base value: base '40' → 0.04. + // So change = (0.05 − 0.04) / 0.04 × 100 = +25%. + function priceServiceWithRef(): PriceService { + return { + calculatePrice: (base: any) => (base?.toString?.() === '40' ? '0.04' : '0.05'), + calculateSpread: () => ({ bid: '0.04975', ask: '0.05025' }), + calculateLiquidityUSD: () => '1000', + } as unknown as PriceService; + } + + function extDbWithReserves( + reserves: Map, + ): ExternalDatabaseService { + return { + isAvailable: () => true, + getSpotRolling24hMetrics: async () => + new Map([['BASE1', { token: 'BASE1', base_volume_24h: '100', target_volume_24h: '5', high_24h: '0', low_24h: '0', trade_count_24h: 3 }]]), + getSpotReserves24hAgo: async () => reserves, + } as unknown as ExternalDatabaseService; + } + + it('emits price_change_percent_24h from the last swap ≥24h ago', async () => { + const app = createTestApp({ + futarchyService: futarchyReturning([dao('BASE1', 'USDC', 'DAOA')]), + externalDatabaseService: extDbWithReserves(new Map([['BASE1', { baseReserves: '40', quoteReserves: '1000' }]])), + priceService: priceServiceWithRef(), + }); + + const res = await request(app).get('/cmc/summary'); + expect(res.status).toBe(200); + expect(res.body[0].price_change_percent_24h).toBeCloseTo(25, 6); + }); + + it('omits price_change_percent_24h for a market with no swap older than 24h', async () => { + const app = createTestApp({ + futarchyService: futarchyReturning([dao('BASE1', 'USDC', 'DAOA')]), + // Empty reserves map: BASE1 first traded < 24h ago → change is undefined. + externalDatabaseService: extDbWithReserves(new Map()), + priceService: priceServiceWithRef(), + }); + + const res = await request(app).get('/cmc/summary'); + expect(res.status).toBe(200); + expect(res.body[0]).not.toHaveProperty('price_change_percent_24h'); + }); + + it('serves the summary (200) without price_change if the 24h-ago source query fails', async () => { + // The swaps source is non-essential: its failure degrades to "field absent", + // never a 5xx (unlike the volume source), and price/volume still serve. + const extDb = { + isAvailable: () => true, + getSpotRolling24hMetrics: async () => + new Map([['BASE1', { token: 'BASE1', base_volume_24h: '100', target_volume_24h: '5', high_24h: '0', low_24h: '0', trade_count_24h: 3 }]]), + getSpotReserves24hAgo: async () => { throw new Error('swaps query failed'); }, + } as unknown as ExternalDatabaseService; + + const app = createTestApp({ + futarchyService: futarchyReturning([dao('BASE1', 'USDC', 'DAOA')]), + externalDatabaseService: extDb, + priceService: priceServiceWithRef(), + }); + + const res = await request(app).get('/cmc/summary'); + expect(res.status).toBe(200); + expect(res.body[0].last_price).toBe(0.05); + expect(res.body[0].base_volume).toBe(100); + expect(res.body[0]).not.toHaveProperty('price_change_percent_24h'); + }); + + it('does NOT include price_change_percent_24h on /cmc/ticker (not in CMC ticker spec)', async () => { + const app = createTestApp({ + futarchyService: futarchyReturning([dao('BASE1', 'USDC', 'DAOA')]), + externalDatabaseService: extDbWithReserves(new Map([['BASE1', { baseReserves: '40', quoteReserves: '1000' }]])), + priceService: priceServiceWithRef(), + }); + + const res = await request(app).get('/cmc/ticker'); + expect(res.status).toBe(200); + expect(res.body['BASE1_USDC']).not.toHaveProperty('price_change_percent_24h'); + }); + }); }); From 75cb69083009355686ec9bcef5f96a18b259299d Mon Sep 17 00:00:00 2001 From: Jinglun Date: Wed, 22 Jul 2026 10:05:51 -0700 Subject: [PATCH 08/10] fix: address reviewer feedback Greptile flagged three defects that all share one root cause: the CMC adapter keys volume, 24h-ago reserves, and asset identity by base mint, but the served-ETL tables carry no per-pool dimension. If two discovered markets ever share a base mint: - getSpotReserves24hAgo (DISTINCT ON base_mint) picks one market's reserves and prices them with the other pair's decimals (P1). - tokenToDaoMap keeps one dao per base mint, so the other pair emits a false zero volume (P1). - /cmc/assets first-writer-wins can map a ticker.base_id to a different symbol/name than the ticker row (P2). Fix them at the common root: assertUniqueBaseMints() fails the whole feed closed (503 CMC_DUPLICATE_BASE_MINT + alert) when two served DAOs share a base mint, since per-market attribution is impossible. Runs after the allowlist, so narrowing to a single side of a collision serves normally. Both feeds route through a shared servedDaos() so they can't diverge. Adds regression tests (summary/ticker/assets fail closed; allowlist narrows a collision to a served single pair) and documents the guard in the README. Co-Authored-By: Claude Opus 4.8 --- README.md | 10 +++++ src/routes/coinmarketcap.ts | 64 ++++++++++++++++++++++++++++-- tests/routes/coinmarketcap.test.ts | 60 ++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e2fcec7..2f5411f 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,16 @@ anonymous by IP, or the elevated per-key bucket when a trusted `X-API-Key` Set `CMC_ALLOWED_MINTS` (comma-separated base mints) to restrict the CMC feed to a specific set of tokens; empty (the default) serves every discovered DAO. +Every CMC feed keys its per-token data (24h volume/high/low, the 24h-ago +reference reserves, and the `/cmc/assets` identity entry) by **base mint**, and +the served-ETL tables carry no per-pool dimension. If two discovered markets ever +share a base mint the numbers can't be attributed to the right pair, so all three +endpoints **fail closed** with `503` (`CMC_DUPLICATE_BASE_MINT`) rather than serve +one market's volume/price for another. The check runs after `CMC_ALLOWED_MINTS`, +so narrowing the allowlist to a single side of a collision serves normally. In the +futarchy model each DAO launches its own token, so this is an anomaly guard, not +an expected path. + #### GET `/cmc/summary` 24h overview of every tradeable pair. diff --git a/src/routes/coinmarketcap.ts b/src/routes/coinmarketcap.ts index 47288cb..e43a477 100644 --- a/src/routes/coinmarketcap.ts +++ b/src/routes/coinmarketcap.ts @@ -84,6 +84,60 @@ function filterAllowed(daos: DaoTickerData[]): DaoTickerData[] { return daos.filter(dao => allowed.has(dao.baseMint.toString())); } +/** + * Fail CLOSED when two served DAOs share a base mint. + * + * Every CMC feed keys its per-token financial data by base mint: rolling-24h + * volume/high/low (user_pool_spot_ohlcv, GROUP BY token), the 24h-ago reference + * reserves behind price_change_percent_24h (user_pool_swaps, DISTINCT ON + * base_mint), and the /cmc/assets identity entry. None of those served-ETL + * tables carry a per-pool dimension, so if one base mint trades in more than one + * discovered market there is NO way to attribute volume / price / metadata to the + * right pair — one market's numbers would be silently served for another, and the + * pair that loses the base-mint→dao key collision would be emitted with a false + * zero volume. Rather than serve mis-attributed financial data, refuse the whole + * feed with a 503 + alert so a poller retries and we get paged. + * + * In the futarchy model each DAO launches its own token, so a base-mint collision + * is not expected in practice — it signals a launch anomaly, an EXCLUDED_DAOS + * gap, or a discovery bug, exactly the kind of thing that should page rather than + * quietly emit wrong numbers. Runs AFTER the allowlist filter, so a collision + * that the operator has already narrowed away (only one side allowlisted) is not + * treated as ambiguous. + */ +function assertUniqueBaseMints(daos: DaoTickerData[]): void { + const seen = new Set(); + const collisions = new Set(); + for (const dao of daos) { + const mint = dao.baseMint.toString(); + if (seen.has(mint)) collisions.add(mint); + else seen.add(mint); + } + if (collisions.size > 0) { + const mints = [...collisions].join(', '); + sendAlert( + `CMC discovered multiple DAOs sharing a base mint (${mints}) — served ETL metrics are keyed by base mint and cannot be attributed per market; refusing to serve mis-attributed data`, + { cooldownKey: 'cmc-duplicate-base-mint', cooldownMs: 10 * 60 * 1000 } + ); + throw AppError.serviceUnavailable( + 'CMC discovered multiple markets sharing a base mint', + 'CMC_DUPLICATE_BASE_MINT' + ); + } +} + +/** + * The DAO set every CMC endpoint serves: apply the operator allowlist, then + * assert no two survivors share a base mint. Both feeds (buildPairs and the + * DB-free /cmc/assets route) go through this so their notion of "which markets + * exist" — and the fail-closed guards around it — can never diverge. + */ +function servedDaos(daos: DaoTickerData[]): DaoTickerData[] { + const filtered = filterAllowed(daos); + assertUniqueBaseMints(filtered); + return filtered; +} + /** * A single tradeable pair, enriched with live price/spread (from spot reserves) * and rolling-24h volume/high/low (from the served ETL). Shared by /cmc/summary @@ -152,7 +206,7 @@ export function createCoinMarketCapRouter(services: ServiceGetters): Router { throw AppError.serviceUnavailable('Served database not available', 'SERVED_DB_UNAVAILABLE'); } - const allDaos = filterAllowed(await futarchyService.getAllDaos()); + const allDaos = servedDaos(await futarchyService.getAllDaos()); // Rolling-24h spot metrics keyed by base mint (token) → mapped to dao (pool_id), // identical to the CoinGecko adapter's single source. @@ -378,13 +432,15 @@ export function createCoinMarketCapRouter(services: ServiceGetters): Router { // --------------------------------------------------------------- router.get(cmcPaths('assets'), asyncHandler(async (req: Request, res: Response) => { const futarchyService = getFutarchyService(); - const allDaos = filterAllowed(await futarchyService.getAllDaos()); + const allDaos = servedDaos(await futarchyService.getAllDaos()); const assets: CoinMarketCapAssetsResponse = {}; const addAsset = (mint: string, symbol: string | undefined, name: string | undefined): void => { - // First writer wins: the base token's own metadata is authoritative, and a - // shared quote (USDC) is identical across pairs, so skipping re-adds is safe. + // First writer wins. servedDaos() guarantees base mints are unique across + // the served set, so the ONLY re-add here is a shared quote (e.g. USDC), + // identical across pairs — skipping it is safe and cannot mask a divergent + // base-token identity (that case fails closed upstream via assertUniqueBaseMints). if (assets[mint]) return; const asset: CoinMarketCapAsset = { name: identityLabel(name, mint), diff --git a/tests/routes/coinmarketcap.test.ts b/tests/routes/coinmarketcap.test.ts index b73e9e2..bfe23cf 100644 --- a/tests/routes/coinmarketcap.test.ts +++ b/tests/routes/coinmarketcap.test.ts @@ -375,6 +375,66 @@ describe('CoinMarketCap Routes', () => { }); }); + describe('duplicate base mint (fail closed — per-market attribution is impossible)', () => { + // Two DAOs, same base mint, different pools. The served ETL keys volume / + // 24h-ago reserves / identity by base mint with no per-pool dimension, so we + // cannot attribute them to the right pair — every CMC feed must 503, never + // serve one market's numbers for another (or a false zero for the loser). + const COLLIDING = [ + dao('BASE1', 'USDC', 'DAOA'), + dao('BASE1', 'USDC', 'DAOB'), + ]; + + it('fails closed (503) on /cmc/summary when two DAOs share a base mint', async () => { + const app = createTestApp({ + futarchyService: futarchyReturning(COLLIDING), + externalDatabaseService: extDbWithMetrics(), + }); + const res = await request(app).get('/cmc/summary'); + expect(res.status).toBe(503); + expect(res.body.code).toBe('CMC_DUPLICATE_BASE_MINT'); + }); + + it('fails closed (503) on /cmc/ticker when two DAOs share a base mint', async () => { + const app = createTestApp({ + futarchyService: futarchyReturning(COLLIDING), + externalDatabaseService: extDbWithMetrics(), + }); + const res = await request(app).get('/cmc/ticker'); + expect(res.status).toBe(503); + expect(res.body.code).toBe('CMC_DUPLICATE_BASE_MINT'); + }); + + it('fails closed (503) on the DB-free /cmc/assets when two DAOs share a base mint', async () => { + const app = createTestApp({ + futarchyService: futarchyReturning(COLLIDING), + externalDatabaseService: extDbWithMetrics(), + }); + const res = await request(app).get('/cmc/assets'); + expect(res.status).toBe(503); + expect(res.body.code).toBe('CMC_DUPLICATE_BASE_MINT'); + }); + + it('serves normally (200) when the allowlist narrows a collision to a single market', async () => { + // A shared quote (USDC) across DISTINCT base mints is NOT a collision — the + // default DAOS set must still serve. And when only one side of a real base- + // mint collision is allowlisted, the guard (which runs AFTER the allowlist) + // no longer sees ambiguity, so the feed serves the surviving pair. + config.coinmarketcap.allowedMints.add('BASE1'); + const app = createTestApp({ + futarchyService: futarchyReturning([ + dao('BASE1', 'USDC', 'DAOA'), + dao('BASE2', 'USDC', 'DAOB'), + ]), + externalDatabaseService: extDbWithMetrics(), + }); + const res = await request(app).get('/cmc/summary'); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].trading_pairs).toBe('BASE1_USDC'); + }); + }); + describe('price_change_percent_24h (from AMM swap history)', () => { // last_price comes from the (empty) live poolData reserves → the mock returns // its default 0.05. For the 24h-ago reserves we return raw values the route From 9142fbfd3ffbd3c83c34936ec7e82dc5b21c60de Mon Sep 17 00:00:00 2001 From: Jinglun Date: Wed, 22 Jul 2026 10:08:22 -0700 Subject: [PATCH 09/10] fix: address review feedback (cycle 2) Make the 24h-ago reserve snapshot query deterministic. DISTINCT ON (base_mint) with only `ORDER BY base_mint, block_time DESC` could pick any swap row when several share the same block_time, so price_change_percent_24h could be computed from a non-final reserve state (and vary with query plans). Extend the ORDER BY with the same event-order columns the DexScreener /events route uses (slot, signature, inner_group, inner_ix) so the selected row is truly the last swap at or before the cutoff. Co-Authored-By: Claude Opus 4.8 --- src/services/externalDatabaseService.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/services/externalDatabaseService.ts b/src/services/externalDatabaseService.ts index a254b93..2262bac 100644 --- a/src/services/externalDatabaseService.ts +++ b/src/services/externalDatabaseService.ts @@ -164,6 +164,14 @@ export class ExternalDatabaseService { * absent from the map: a 24h change is undefined for a market younger than 24h, * and the caller omits the field rather than fabricate one. Throws (never masks * as empty) on connection/query failure, exactly like getSpotRolling24hMetrics. + * + * DISTINCT ON picks ONE row per base_mint; the ORDER BY must therefore break + * every tie deterministically or the plan could pick a non-final reserve state + * when several swaps share the same block_time. We extend block_time DESC with + * the same event-order columns the DexScreener /events route uses (slot, then + * within-transaction inner_group/inner_ix, with signature to disambiguate + * distinct txns in one slot) — highest wins, so the selected row is truly the + * last swap at or before the cutoff. */ async getSpotReserves24hAgo(tokens: string[]): Promise> { if (tokens.length === 0) { @@ -189,7 +197,7 @@ export class ExternalDatabaseService { AND amm_base_reserves IS NOT NULL AND amm_quote_reserves IS NOT NULL AND amm_base_reserves > 0 - ORDER BY base_mint, block_time DESC`, + ORDER BY base_mint, block_time DESC, slot DESC, signature DESC, inner_group DESC, inner_ix DESC`, [cutoff, tokens] ); From 1ada13c4fae5db4c354af613290446e02c041d0a Mon Sep 17 00:00:00 2001 From: Jinglun Date: Wed, 22 Jul 2026 11:22:03 -0700 Subject: [PATCH 10/10] perf(external-api): index-backed 24h-ago reserves lookup for CMC price change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getSpotReserves24hAgo used a single DISTINCT ON over user_pool_swaps filtered by `block_time <= now-24h`. That range spans nearly all history, so the planner seq-scanned + sorted the whole table on every /cmc/summary request — measured ~7.9s over 1.4M rows on the served DB (EXPLAIN: Parallel Seq Scan), growing unbounded as swaps accumulate. The existing idx_user_pool_swaps_base_mint (base_mint, block_time) index was not used. Rewrite as a per-token LATERAL LIMIT 1 driven off the input mint array: each token does a backward Index Scan on that index and stops at the first matching row. Measured ~30ms (~260x faster), reads ~0.3% of the buffers, and stays flat as history grows. Identical result set and the same deterministic tie-break ordering (block_time, slot, signature, inner_group, inner_ix). Verified with EXPLAIN ANALYZE against the live served DB. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/services/externalDatabaseService.ts | 45 ++++++++++++++++--------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/src/services/externalDatabaseService.ts b/src/services/externalDatabaseService.ts index 2262bac..0e1773d 100644 --- a/src/services/externalDatabaseService.ts +++ b/src/services/externalDatabaseService.ts @@ -165,13 +165,14 @@ export class ExternalDatabaseService { * and the caller omits the field rather than fabricate one. Throws (never masks * as empty) on connection/query failure, exactly like getSpotRolling24hMetrics. * - * DISTINCT ON picks ONE row per base_mint; the ORDER BY must therefore break - * every tie deterministically or the plan could pick a non-final reserve state + * The per-token LATERAL LIMIT 1 picks ONE row per base_mint; its ORDER BY must + * break every tie deterministically or it could pick a non-final reserve state * when several swaps share the same block_time. We extend block_time DESC with * the same event-order columns the DexScreener /events route uses (slot, then * within-transaction inner_group/inner_ix, with signature to disambiguate * distinct txns in one slot) — highest wins, so the selected row is truly the - * last swap at or before the cutoff. + * last swap at or before the cutoff. See the query for why LATERAL beats a + * single DISTINCT ON here (index-backed per-token seek vs full-table seq scan). */ async getSpotReserves24hAgo(tokens: string[]): Promise> { if (tokens.length === 0) { @@ -184,20 +185,32 @@ export class ExternalDatabaseService { const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); try { + // Per-token LATERAL LIMIT 1 rather than a single DISTINCT ON over the whole + // table. `block_time <= cutoff` spans nearly all history (everything but the + // last 24h), so a DISTINCT ON makes the planner seq-scan + sort the entire + // user_pool_swaps table (measured: ~1.4M rows, ~8s, and growing unbounded). + // Driving from the small token array instead lets each token do a backward + // Index Scan on idx_user_pool_swaps_base_mint (base_mint, block_time) and + // stop at the first matching row — 38 index seeks, ~30ms, flat as history + // grows. Same result set and the SAME deterministic tie-break ordering. const result = await this.pool.query( - `SELECT DISTINCT ON (base_mint) - base_mint AS token, - amm_base_reserves::text AS base_reserves, - amm_quote_reserves::text AS quote_reserves - FROM futarchy.user_pool_swaps - WHERE source = 'futarchy_amm' - AND market_kind = 'spot' - AND block_time <= $1 - AND base_mint = ANY($2::text[]) - AND amm_base_reserves IS NOT NULL - AND amm_quote_reserves IS NOT NULL - AND amm_base_reserves > 0 - ORDER BY base_mint, block_time DESC, slot DESC, signature DESC, inner_group DESC, inner_ix DESC`, + `SELECT s.token, s.base_reserves, s.quote_reserves + FROM unnest($2::text[]) AS t(base_mint) + CROSS JOIN LATERAL ( + SELECT u.base_mint AS token, + u.amm_base_reserves::text AS base_reserves, + u.amm_quote_reserves::text AS quote_reserves + FROM futarchy.user_pool_swaps u + WHERE u.base_mint = t.base_mint + AND u.source = 'futarchy_amm' + AND u.market_kind = 'spot' + AND u.block_time <= $1 + AND u.amm_base_reserves IS NOT NULL + AND u.amm_quote_reserves IS NOT NULL + AND u.amm_base_reserves > 0 + ORDER BY u.block_time DESC, u.slot DESC, u.signature DESC, u.inner_group DESC, u.inner_ix DESC + LIMIT 1 + ) s`, [cutoff, tokens] );