diff --git a/README.md b/README.md index 108abfb..2f5411f 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,124 @@ 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. + +**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. + +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. + +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. + +**Response:** +```json +[ + { + "trading_pairs": "ZKFHiLAfAFMTcDAuCtjNW54VzpERvoe7PBF9mYgmeta_EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "base_currency": "ZKFHiLAfAFMTcDAuCtjNW54VzpERvoe7PBF9mYgmeta", + "quote_currency": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "type": "spot", + "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, + "price_change_percent_24h": 4.28 + } +] +``` + +`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 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` + +24h price and volume keyed by the `BASE_QUOTE` trading pair. + +**Response:** +```json +{ + "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, + "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. @@ -236,7 +354,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` | @@ -246,6 +364,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 | — | @@ -263,6 +382,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 +398,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..7814260 100644 --- a/src/config.ts +++ b/src/config.ts @@ -51,6 +51,25 @@ 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. + // + // 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: { 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..e43a477 --- /dev/null +++ b/src/routes/coinmarketcap.ts @@ -0,0 +1,467 @@ +import { Router, type Request, type Response } from 'express'; +import BN from 'bn.js'; +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; + +/** + * 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. + * + * 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 = raw.trim() === '' ? NaN : Number(raw); + if (!Number.isFinite(value)) { + throw AppError.internal( + `Malformed 24h ${field} from the served ETL for ${mint}`, + 'CMC_MALFORMED_METRIC' + ); + } + 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 + * and DexScreener adapters. + * + * 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 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 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 includes markets not currently discovered', + 'CMC_ALLOWLIST_NO_MATCH' + ); + } + + 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 + * and /cmc/ticker so both feeds are always internally consistent. + */ +interface CmcPair { + tradingPair: string; // `${baseMint}_${quoteMint}` + baseId: string; + quoteId: string; + baseName: string; + baseSymbol: string; + quoteName: string; + quoteSymbol: string; + lastPrice: number; + bid: number; + ask: number; + baseVolume: number; + 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 +// 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; + + /** + * 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, + opts: { withPriceChange?: boolean } = {} + ): 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 = 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. + 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, + }); + } + + // 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 { + const { + daoAddress, baseMint, quoteMint, baseDecimals, quoteDecimals, poolData, + baseSymbol, baseName, quoteSymbol, quoteName, + } = 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; + + // 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 = 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()); + } + } + + const pair: CmcPair = { + 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), + baseVolume, + quoteVolume, + }; + 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 + // 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, + }); + } + } + + return pairs; + } + + // --------------------------------------------------------------- + // GET /cmc/summary — 24h overview of every tradeable pair (array). + // + // 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, { withPriceChange: true }); + + const summary: CoinMarketCapSummaryPair[] = pairs.map(p => { + const entry: CoinMarketCapSummaryPair = { + 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, + 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; + if (p.priceChangePercent24h !== undefined) entry.price_change_percent_24h = p.priceChangePercent24h; + return entry; + }); + + res.json(summary); + })); + + // --------------------------------------------------------------- + // 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(cmcPaths('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, + 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, + 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(cmcPaths('assets'), asyncHandler(async (req: Request, res: Response) => { + const futarchyService = getFutarchyService(); + const allDaos = servedDaos(await futarchyService.getAllDaos()); + + const assets: CoinMarketCapAssetsResponse = {}; + + const addAsset = (mint: string, symbol: string | undefined, name: string | undefined): void => { + // 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), + symbol: identityLabel(symbol, mint), + 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..bef2f32 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. 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', + }, dex: { fork_type: config.dex.forkType, factory_address: config.dex.factoryAddress, diff --git a/src/services/externalDatabaseService.ts b/src/services/externalDatabaseService.ts index c8d6563..0e1773d 100644 --- a/src/services/externalDatabaseService.ts +++ b/src/services/externalDatabaseService.ts @@ -143,6 +143,92 @@ 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. + * + * 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. 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) { + 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 { + // 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 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] + ); + + 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 new file mode 100644 index 0000000..f27054a --- /dev/null +++ b/src/types/coinmarketcap.ts @@ -0,0 +1,73 @@ +// 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; + // 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; + /** 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; + // 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; + 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. + 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. */ +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..bfe23cf --- /dev/null +++ b/tests/routes/coinmarketcap.test.ts @@ -0,0 +1,522 @@ +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'; +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 +// 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 }], + ]), + // 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; +} + +// 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 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] = bad; + return { + isAvailable: () => true, + getSpotRolling24hMetrics: async () => new Map([['BASE1', base]]), + getSpotReserves24hAgo: async () => new Map(), + } 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({ + 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.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); + 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'); + // 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), + 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'); + }); + }); + + 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: extDbWithMalformedMetric('base_volume_24h'), + }); + const res = await request(app).get('/cmc/summary'); + expect(res.status).toBe(500); + 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'); + }); + + 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)', () => { + // 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'); + 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); + }); + + 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'); + }); + }); + + 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 + // 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'); + }); + }); +});