import crypto from 'node:crypto'; import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(SCRIPT_DIR, '..', '..'); const TOKENS_DIR = path.join(PROJECT_ROOT, 'tokens'); function readJsonFile(filePath) { try { const raw = fs.readFileSync(filePath, 'utf8'); return JSON.parse(raw); } catch { return undefined; } } function getIsoNow() { return new Date().toISOString(); } function base64Url(buf) { return Buffer.from(buf) .toString('base64') .replace(/=/g, '') .replace(/\+/g, '-') .replace(/\//g, '_'); } function sha256Hex(text) { return crypto.createHash('sha256').update(text, 'utf8').digest('hex'); } function generateToken() { return `alg_${base64Url(crypto.randomBytes(32))}`; } function getHeader(req, name) { const v = req.headers[String(name).toLowerCase()]; return Array.isArray(v) ? v[0] : v; } function readBearerToken(req) { const auth = getHeader(req, 'authorization'); if (auth && typeof auth === 'string') { const m = auth.match(/^Bearer\s+(.+)$/i); if (m && m[1]) return m[1].trim(); } const apiKey = getHeader(req, 'x-api-key'); if (apiKey && typeof apiKey === 'string' && apiKey.trim()) return apiKey.trim(); return undefined; } function withCors(res, corsOrigin) { res.setHeader('access-control-allow-origin', corsOrigin); res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS'); res.setHeader( 'access-control-allow-headers', 'content-type, authorization, x-api-key, x-admin-secret' ); } function sendJson(res, status, body, corsOrigin) { withCors(res, corsOrigin); res.statusCode = status; res.setHeader('content-type', 'application/json; charset=utf-8'); res.end(JSON.stringify(body)); } async function readBodyJson(req, { maxBytes }) { const chunks = []; let total = 0; for await (const chunk of req) { total += chunk.length; if (total > maxBytes) throw new Error('payload_too_large'); chunks.push(chunk); } const text = Buffer.concat(chunks).toString('utf8'); if (!text.trim()) return {}; try { return JSON.parse(text); } catch { throw new Error('invalid_json'); } } function normalizeGraphqlName(value, fallback, label) { const v = String(value || fallback || '') .trim() .toLowerCase(); if (!v) return String(fallback || ''); if (!/^[a-z][a-z0-9_]*$/.test(v)) throw new Error(`Invalid ${label}: ${value}`); return v; } function resolveConfig() { const hasuraTokens = readJsonFile(path.join(TOKENS_DIR, 'hasura.json')) || {}; const apiTokens = readJsonFile(path.join(TOKENS_DIR, 'api.json')) || {}; const portRaw = process.env.PORT || process.env.API_PORT || apiTokens.port || '8787'; const port = Number.parseInt(String(portRaw), 10); if (!Number.isInteger(port) || port <= 0) throw new Error(`Invalid PORT: ${portRaw}`); const hasuraUrl = process.env.HASURA_GRAPHQL_URL || hasuraTokens.graphqlUrl || hasuraTokens.apiUrl || 'http://localhost:8080/v1/graphql'; const hasuraAdminSecret = process.env.HASURA_ADMIN_SECRET || hasuraTokens.adminSecret || hasuraTokens.hasuraAdminSecret; const apiAdminSecret = process.env.API_ADMIN_SECRET || apiTokens.adminSecret; const corsOrigin = process.env.CORS_ORIGIN || apiTokens.corsOrigin || '*'; const appVersion = String(process.env.APP_VERSION || 'v1').trim() || 'v1'; const buildTimestamp = String(process.env.BUILD_TIMESTAMP || '').trim() || undefined; const startedAt = getIsoNow(); const ticksTable = normalizeGraphqlName(process.env.TICKS_TABLE, 'drift_ticks', 'TICKS_TABLE'); const candlesFunction = normalizeGraphqlName( process.env.CANDLES_FUNCTION, 'get_drift_candles', 'CANDLES_FUNCTION' ); return { port, hasuraUrl, hasuraAdminSecret, apiAdminSecret, corsOrigin, appVersion, buildTimestamp, startedAt, ticksTable, candlesFunction, }; } async function hasuraRequest(cfg, { admin }, query, variables) { const headers = { 'content-type': 'application/json' }; if (admin) { if (!cfg.hasuraAdminSecret) throw new Error('Missing HASURA_ADMIN_SECRET (or tokens/hasura.json adminSecret)'); headers['x-hasura-admin-secret'] = cfg.hasuraAdminSecret; } const res = await fetch(cfg.hasuraUrl, { method: 'POST', headers, body: JSON.stringify({ query, variables }), }); const text = await res.text(); if (!res.ok) throw new Error(`Hasura HTTP ${res.status}: ${text}`); let json; try { json = JSON.parse(text); } catch { throw new Error(`Hasura invalid JSON: ${text}`); } if (json.errors?.length) { throw new Error(json.errors.map((e) => e.message).join(' | ')); } return json.data; } function normalizeScopes(value) { if (!value) return []; if (Array.isArray(value)) return value.map((v) => String(v)).filter(Boolean); if (typeof value === 'string') { return value .split(',') .map((s) => s.trim()) .filter(Boolean); } return []; } async function requireValidToken(cfg, req, requiredScope) { const token = readBearerToken(req); if (!token) return { ok: false, status: 401, error: 'missing_token' }; const hash = sha256Hex(token); const query = ` query ValidToken($hash: String!) { api_tokens(where: {token_hash: {_eq: $hash}, revoked_at: {_is_null: true}}, limit: 1) { id name scopes } } `; let data; try { data = await hasuraRequest(cfg, { admin: true }, query, { hash }); } catch (err) { return { ok: false, status: 500, error: String(err?.message || err) }; } const row = data?.api_tokens?.[0]; if (!row?.id) return { ok: false, status: 401, error: 'invalid_or_revoked_token' }; const scopes = normalizeScopes(row.scopes); if (requiredScope && !scopes.includes(requiredScope)) { return { ok: false, status: 403, error: 'missing_scope' }; } // best-effort touch const touch = ` mutation TouchToken($id: uuid!, $ts: timestamptz!) { update_api_tokens_by_pk(pk_columns: {id: $id}, _set: {last_used_at: $ts}) { id } } `; hasuraRequest(cfg, { admin: true }, touch, { id: row.id, ts: new Date().toISOString() }).catch(() => {}); return { ok: true, token: { id: row.id, name: row.name } }; } function toNumericString(value, fieldName) { if (value == null) throw new Error(`invalid_${fieldName}`); if (typeof value === 'number') { if (!Number.isFinite(value)) throw new Error(`invalid_${fieldName}`); return String(value); } if (typeof value === 'string') { const s = value.trim(); if (!s) throw new Error(`invalid_${fieldName}`); const n = Number(s); if (!Number.isFinite(n)) throw new Error(`invalid_${fieldName}`); return s; } // best-effort: allow bigint-like or BN-like objects if (typeof value?.toString === 'function') { const s = String(value.toString()).trim(); if (!s) throw new Error(`invalid_${fieldName}`); const n = Number(s); if (!Number.isFinite(n)) throw new Error(`invalid_${fieldName}`); return s; } throw new Error(`invalid_${fieldName}`); } function normalizeTick(input, tokenInfo) { const ts = (input?.ts || input?.timestamp || getIsoNow())?.toString?.(); const market_index = input?.market_index ?? input?.marketIndex; const symbol = input?.symbol; const oracle_price = input?.oracle_price ?? input?.oraclePrice ?? input?.price; const mark_price = input?.mark_price ?? input?.markPrice ?? input?.mark; const oracle_slot = input?.oracle_slot ?? input?.oracleSlot ?? input?.slot; const source = (input?.source || 'api')?.toString?.(); const raw = input?.raw && typeof input.raw === 'object' ? input.raw : undefined; if (!ts || Number.isNaN(Date.parse(ts))) throw new Error('invalid_ts'); if (!Number.isInteger(market_index)) throw new Error('invalid_market_index'); if (typeof symbol !== 'string' || !symbol.trim()) throw new Error('invalid_symbol'); const oracleStr = toNumericString(oracle_price, 'oracle_price'); const markStr = mark_price == null ? undefined : toNumericString(mark_price, 'mark_price'); const slotNum = oracle_slot == null ? undefined : Number.isFinite(Number(oracle_slot)) ? Number(oracle_slot) : undefined; const mergedRaw = raw || tokenInfo ? { ...(raw || {}), ingestedBy: tokenInfo ? { tokenId: tokenInfo.id, name: tokenInfo.name } : undefined, } : undefined; return { ts, market_index, symbol: symbol.trim(), // Postgres columns are NUMERIC; Hasura `numeric` scalar returns strings and expects string inputs. oracle_price: oracleStr, mark_price: markStr, oracle_slot: slotNum, source, raw: mergedRaw, }; } async function insertTick(cfg, tick) { const table = cfg.ticksTable; const insertField = `insert_${table}_one`; const mutation = ` mutation InsertTick($object: ${table}_insert_input!) { ${insertField}(object: $object) { id } } `; const data = await hasuraRequest(cfg, { admin: true }, mutation, { object: tick }); return data?.[insertField]?.id; } async function createApiToken(cfg, name, scopes, meta) { const mutation = ` mutation CreateToken($name: String!, $hash: String!, $scopes: [String!]!, $meta: jsonb) { insert_api_tokens_one(object: {name: $name, token_hash: $hash, scopes: $scopes, meta: $meta}) { id name created_at } } `; for (let attempt = 0; attempt < 5; attempt++) { const token = generateToken(); const hash = sha256Hex(token); try { const data = await hasuraRequest(cfg, { admin: true }, mutation, { name, hash, scopes, meta }); const row = data?.insert_api_tokens_one; if (!row?.id) throw new Error('token_insert_failed'); return { token, row }; } catch (err) { const msg = String(err?.message || err); const isUniqueConflict = msg.toLowerCase().includes('unique') || msg.toLowerCase().includes('constraint'); if (!isUniqueConflict) throw err; } } throw new Error('token_generation_failed'); } async function revokeApiToken(cfg, id) { const mutation = ` mutation RevokeToken($id: uuid!, $ts: timestamptz!) { update_api_tokens_by_pk(pk_columns: {id: $id}, _set: {revoked_at: $ts}) { id revoked_at } } `; const data = await hasuraRequest(cfg, { admin: true }, mutation, { id, ts: new Date().toISOString() }); return data?.update_api_tokens_by_pk?.id; } function clampInt(value, min, max) { const n = Number.parseInt(String(value), 10); if (!Number.isFinite(n) || !Number.isInteger(n)) return min; return Math.min(max, Math.max(min, n)); } function clampNumber(value, min, max, fallback = min) { const n = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN; if (!Number.isFinite(n)) return fallback; return Math.min(max, Math.max(min, n)); } function parseNumeric(value) { if (value == null) return null; if (typeof value === 'number') return Number.isFinite(value) ? value : null; if (typeof value === 'string') { const t = value.trim(); if (!t) return null; const n = Number(t); return Number.isFinite(n) ? n : null; } if (typeof value?.toString === 'function') { try { const n = Number(String(value.toString()).trim()); return Number.isFinite(n) ? n : null; } catch { return null; } } return null; } function clampSeriesLimit(value, fallback) { const n = Number.parseInt(String(value ?? ''), 10); if (!Number.isFinite(n) || !Number.isInteger(n)) return fallback; return Math.min(10_000, Math.max(1, n)); } function parseTimeframeToSeconds(tf) { const v = String(tf || '').trim().toLowerCase(); if (!v) return 60; const m = v.match(/^(\d+)(s|m|h|d)$/); if (!m) throw new Error(`invalid_tf`); const num = Number.parseInt(m[1], 10); if (!Number.isInteger(num) || num <= 0) throw new Error(`invalid_tf`); const unit = m[2]; const mult = unit === 's' ? 1 : unit === 'm' ? 60 : unit === 'h' ? 3600 : 86400; return num * mult; } function isTruthy(value) { if (typeof value === 'boolean') return value; const v = String(value ?? '').trim().toLowerCase(); return v === '1' || v === 'true' || v === 'yes' || v === 'on'; } const solPriceCache = { ts: 0, value: null }; async function readSolPriceUsd(cfg) { const now = Date.now(); if (now - solPriceCache.ts < 5000) return solPriceCache.value; const table = cfg.ticksTable; const query = ` query SolPrice($where: ${table}_bool_exp!) { ${table}(where: $where, order_by: {ts: desc}, limit: 1) { oracle_price mark_price ts } } `; try { const data = await hasuraRequest(cfg, { admin: true }, query, { where: { symbol: { _eq: 'SOL-PERP' } } }); const row = (data?.[table] || [])[0]; const price = parseNumeric(row?.oracle_price) ?? parseNumeric(row?.mark_price) ?? null; solPriceCache.ts = now; solPriceCache.value = price; return price; } catch { solPriceCache.ts = now; solPriceCache.value = null; return null; } } function mean(values) { if (!values.length) return 0; return values.reduce((a, b) => a + b, 0) / values.length; } function stddev(values) { if (!values.length) return 0; const m = mean(values); const v = values.reduce((acc, x) => acc + (x - m) * (x - m), 0) / values.length; return Math.sqrt(v); } function sma(values, period) { if (period <= 0) throw new Error('period must be > 0'); const out = new Array(values.length).fill(null); let sum = 0; for (let i = 0; i < values.length; i++) { sum += values[i]; if (i >= period) sum -= values[i - period]; if (i >= period - 1) out[i] = sum / period; } return out; } function ema(values, period) { if (period <= 0) throw new Error('period must be > 0'); const out = new Array(values.length).fill(null); const k = 2 / (period + 1); if (values.length < period) return out; const first = mean(values.slice(0, period)); out[period - 1] = first; let prev = first; for (let i = period; i < values.length; i++) { const next = values[i] * k + prev * (1 - k); out[i] = next; prev = next; } return out; } function rsi(values, period) { if (period <= 0) throw new Error('period must be > 0'); const out = new Array(values.length).fill(null); if (values.length <= period) return out; let gains = 0; let losses = 0; for (let i = 1; i <= period; i++) { const change = values[i] - values[i - 1]; if (change >= 0) gains += change; else losses -= change; } let avgGain = gains / period; let avgLoss = losses / period; const rs = avgLoss === 0 ? Number.POSITIVE_INFINITY : avgGain / avgLoss; out[period] = 100 - 100 / (1 + rs); for (let i = period + 1; i < values.length; i++) { const change = values[i] - values[i - 1]; const gain = Math.max(change, 0); const loss = Math.max(-change, 0); avgGain = (avgGain * (period - 1) + gain) / period; avgLoss = (avgLoss * (period - 1) + loss) / period; const rs2 = avgLoss === 0 ? Number.POSITIVE_INFINITY : avgGain / avgLoss; out[i] = 100 - 100 / (1 + rs2); } return out; } function bollingerBands(values, period, stdDevMult) { if (period <= 0) throw new Error('period must be > 0'); const upper = new Array(values.length).fill(null); const lower = new Array(values.length).fill(null); const mid = sma(values, period); for (let i = period - 1; i < values.length; i++) { const window = values.slice(i - period + 1, i + 1); const sd = stddev(window); const m = mid[i]; if (m == null) continue; upper[i] = m + stdDevMult * sd; lower[i] = m - stdDevMult * sd; } return { upper, lower, mid }; } function macd(values, fastPeriod = 12, slowPeriod = 26, signalPeriod = 9) { const fast = ema(values, fastPeriod); const slow = ema(values, slowPeriod); const macdLine = values.map((_, i) => { const f = fast[i]; const s = slow[i]; return f == null || s == null ? null : f - s; }); const signal = new Array(values.length).fill(null); const k = 2 / (signalPeriod + 1); let seeded = false; let prev = 0; const buf = []; for (let i = 0; i < macdLine.length; i++) { const v = macdLine[i]; if (v == null) continue; if (!seeded) { buf.push(v); if (buf.length === signalPeriod) { const first = mean(buf); signal[i] = first; prev = first; seeded = true; } continue; } const next = v * k + prev * (1 - k); signal[i] = next; prev = next; } return { macd: macdLine, signal }; } function toSeries(times, values) { return times.map((t, i) => ({ time: t, value: values[i] ?? null })); } function isAdmin(cfg, req) { if (!cfg.apiAdminSecret) return false; const provided = getHeader(req, 'x-admin-secret'); return typeof provided === 'string' && provided === cfg.apiAdminSecret; } async function handler(cfg, req, res) { if (req.method === 'OPTIONS') { withCors(res, cfg.corsOrigin); res.statusCode = 204; res.end(); return; } const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); const pathname = url.pathname; if (req.method === 'GET' && pathname === '/healthz') { sendJson( res, 200, { ok: true, version: cfg.appVersion, buildTimestamp: cfg.buildTimestamp, startedAt: cfg.startedAt, ticksTable: cfg.ticksTable, candlesFunction: cfg.candlesFunction, }, cfg.corsOrigin ); return; } if (req.method === 'GET' && pathname === '/v1/chart') { const auth = await requireValidToken(cfg, req, 'read'); if (!auth.ok) { sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin); return; } const symbol = (url.searchParams.get('symbol') || '').trim(); const source = (url.searchParams.get('source') || '').trim(); const tf = (url.searchParams.get('tf') || url.searchParams.get('timeframe') || '1m').trim(); const limit = clampInt(url.searchParams.get('limit') || '300', 10, 2000); if (!symbol) { sendJson(res, 400, { ok: false, error: 'missing_symbol' }, cfg.corsOrigin); return; } let bucketSeconds; try { bucketSeconds = parseTimeframeToSeconds(tf); } catch { sendJson(res, 400, { ok: false, error: 'invalid_tf' }, cfg.corsOrigin); return; } const fn = cfg.candlesFunction; const query = ` query Candles($symbol: String!, $bucket: Int!, $limit: Int!, $source: String) { ${fn}(args: { p_symbol: $symbol, p_bucket_seconds: $bucket, p_limit: $limit, p_source: $source }) { bucket open high low close oracle_close ticks } } `; try { const data = await hasuraRequest(cfg, { admin: true }, query, { symbol, bucket: bucketSeconds, limit, source: source || null, }); const rows = data?.[fn] || []; const candles = rows .slice() .reverse() .map((r) => { const time = Math.floor(Date.parse(r.bucket) / 1000); const open = Number(r.open); const high = Number(r.high); const low = Number(r.low); const close = Number(r.close); const oracle = r.oracle_close == null ? null : Number(r.oracle_close); const volume = Number(r.ticks || 0); return { time, open, high, low, close, volume, oracle }; }) .filter((c) => Number.isFinite(c.time) && Number.isFinite(c.open) && Number.isFinite(c.close)); const times = candles.map((c) => c.time); const closes = candles.map((c) => c.close); const oracleSeries = toSeries(times, candles.map((c) => (c.oracle == null ? null : c.oracle))); const sma20 = toSeries(times, sma(closes, 20)); const ema20 = toSeries(times, ema(closes, 20)); const bb = bollingerBands(closes, 20, 2); const bbUpper = toSeries(times, bb.upper); const bbLower = toSeries(times, bb.lower); const bbMid = toSeries(times, bb.mid); const rsi14 = toSeries(times, rsi(closes, 14)); const macdOut = macd(closes, 12, 26, 9); const macdLine = toSeries(times, macdOut.macd); const macdSignal = toSeries(times, macdOut.signal); sendJson( res, 200, { ok: true, version: cfg.appVersion, buildTimestamp: cfg.buildTimestamp, ticksTable: cfg.ticksTable, candlesFunction: cfg.candlesFunction, symbol, source: source || null, tf, bucketSeconds, candles, indicators: { oracle: oracleSeries, sma20, ema20, bb20: { upper: bbUpper, lower: bbLower, mid: bbMid }, rsi14, macd: { macd: macdLine, signal: macdSignal }, }, }, cfg.corsOrigin ); } catch (err) { sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin); } return; } // Estimate costs for a new contract (read scope). // Uses DLOB slippage table and simple fee/tx estimates (inputs). if (req.method === 'POST' && pathname === '/v1/contracts/costs/estimate') { const auth = await requireValidToken(cfg, req, 'read'); if (!auth.ok) { sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin); return; } let body; try { body = await readBodyJson(req, { maxBytes: 256 * 1024 }); } catch (err) { const msg = String(err?.message || err); if (msg === 'payload_too_large') { sendJson(res, 413, { ok: false, error: 'payload_too_large' }, cfg.corsOrigin); return; } sendJson(res, 400, { ok: false, error: 'invalid_json' }, cfg.corsOrigin); return; } const market = String(body?.market_name || body?.market || '').trim(); if (!market) { sendJson(res, 400, { ok: false, error: 'missing_market' }, cfg.corsOrigin); return; } const notionalUsd = parseNumeric(body?.notional_usd ?? body?.notionalUsd ?? body?.size_usd ?? body?.sizeUsd); if (notionalUsd == null || !(notionalUsd > 0)) { sendJson(res, 400, { ok: false, error: 'invalid_notional_usd' }, cfg.corsOrigin); return; } const sideRaw = String(body?.side || 'long').trim().toLowerCase(); const entrySide = sideRaw === 'short' || sideRaw === 'sell' ? 'short' : 'long'; const defaultTakerBps = parseNumeric(process.env.FEE_TAKER_BPS_DEFAULT) ?? 5; const defaultMakerBps = parseNumeric(process.env.FEE_MAKER_BPS_DEFAULT) ?? 0; const takerBps = clampNumber(parseNumeric(body?.fee_taker_bps) ?? defaultTakerBps, 0, 1000, defaultTakerBps); const makerBps = clampNumber(parseNumeric(body?.fee_maker_bps) ?? defaultMakerBps, -1000, 1000, defaultMakerBps); const orderType = String(body?.order_type || body?.orderType || 'market').trim().toLowerCase(); const isMarket = orderType === 'market' || orderType === 'taker'; const feeBps = isMarket ? takerBps : makerBps; let txFeeUsdEst = parseNumeric(body?.tx_fee_usd_est); if (txFeeUsdEst == null) { const baseLamports = parseNumeric(process.env.TX_BASE_FEE_LAMPORTS_EST) ?? 5000; const sigs = parseNumeric(process.env.TX_SIGNATURES_EST) ?? 1; const priorityLamports = parseNumeric(process.env.TX_PRIORITY_FEE_LAMPORTS_EST) ?? 0; const lamports = Math.max(0, baseLamports) * Math.max(1, sigs) + Math.max(0, priorityLamports); const sol = lamports / 1_000_000_000; const solUsd = await readSolPriceUsd(cfg); txFeeUsdEst = solUsd != null ? sol * solUsd : 0; } txFeeUsdEst = clampNumber(txFeeUsdEst, 0, 100, 0); const defaultReprices = parseNumeric(process.env.EXPECTED_REPRICES_PER_ENTRY_DEFAULT) ?? 0; const expectedReprices = clampInt(body?.expected_reprices_per_entry ?? body?.expectedReprices ?? String(defaultReprices), 0, 500); const modifyTxCount = clampInt(body?.modify_tx_count ?? body?.modifyTxCount ?? '2', 0, 10); const wantedSide = entrySide === 'long' ? 'buy' : 'sell'; const pickNearest = (rows) => { const candidates = (rows || []).filter((r) => String(r.side || '').toLowerCase() === wantedSide); let best = null; let bestD = Number.POSITIVE_INFINITY; for (const r of candidates) { const s = parseNumeric(r.size_usd); if (s == null) continue; const d = Math.abs(s - notionalUsd); if (d < bestD) { bestD = d; best = r; } } return best; }; const fetchSlippageRows = async (tableName) => { const query = ` query Slippage($market: String!, $limit: Int!) { ${tableName}(where: {market_name: {_eq: $market}}, order_by: {size_usd: asc}, limit: $limit) { market_name side size_usd mid_price vwap_price worst_price impact_bps fill_pct updated_at } } `; const data = await hasuraRequest(cfg, { admin: true }, query, { market, limit: 500 }); return data?.[tableName] || []; }; try { let rows = []; let usedTable = null; try { rows = await fetchSlippageRows('dlob_slippage_latest_v2'); usedTable = 'dlob_slippage_latest_v2'; } catch (e) { rows = await fetchSlippageRows('dlob_slippage_latest'); usedTable = 'dlob_slippage_latest'; } const best = pickNearest(rows); const impactBps = parseNumeric(best?.impact_bps) ?? 0; const slippageUsd = (notionalUsd * impactBps) / 10_000; const tradeFeeUsd = (notionalUsd * feeBps) / 10_000; const modifyCostUsd = expectedReprices * modifyTxCount * txFeeUsdEst; const totalUsd = tradeFeeUsd + slippageUsd + txFeeUsdEst + modifyCostUsd; const totalBps = (totalUsd / notionalUsd) * 10_000; sendJson( res, 200, { ok: true, input: { market_name: market, notional_usd: notionalUsd, side: entrySide, order_type: orderType, fee_bps: feeBps, tx_fee_usd_est: txFeeUsdEst, expected_reprices_per_entry: expectedReprices, }, dlob: best ? { table: usedTable, size_usd: best.size_usd, side: best.side, mid_price: best.mid_price, vwap_price: best.vwap_price, impact_bps: best.impact_bps, fill_pct: best.fill_pct, updated_at: best.updated_at, } : null, breakdown: { trade_fee_usd: tradeFeeUsd, slippage_usd: slippageUsd, tx_fee_usd: txFeeUsdEst, expected_modify_usd: modifyCostUsd, total_usd: totalUsd, total_bps: totalBps, breakeven_bps: totalBps, }, }, cfg.corsOrigin ); } catch (err) { sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin); } return; } // Monitor a pushed contract (read scope). // Best-effort: if bot tables are not present yet in Hasura schema, return a clear error. const monitorMatch = pathname.match(/^\/v1\/contracts\/([0-9a-f-]{36})\/monitor$/i); if (req.method === 'GET' && monitorMatch) { const auth = await requireValidToken(cfg, req, 'read'); if (!auth.ok) { sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin); return; } const contractId = monitorMatch[1]; const eventsLimit = clampSeriesLimit(url.searchParams.get('eventsLimit') || '2000', 2000); const wantSeries = isTruthy(url.searchParams.get('series') || ''); const seriesMax = clampSeriesLimit(url.searchParams.get('seriesMax') || '600', 600); const pickNum = (obj, keys) => { if (!obj || typeof obj !== 'object') return 0; for (const k of keys) { const v = obj[k]; const n = parseNumeric(v); if (n != null) return n; } return 0; }; try { const q = ` query ContractMonitor($id: uuid!, $limit: Int!) { bot_contracts_by_pk(id: $id) { id } bot_events(where: {contract_id: {_eq: $id}}, order_by: {created_at: asc}, limit: $limit) { id created_at type payload } } `; const data = await hasuraRequest(cfg, { admin: true }, q, { id: contractId, limit: eventsLimit }); const contract = data?.bot_contracts_by_pk || null; const events = Array.isArray(data?.bot_events) ? data.bot_events : []; const costs = { tradeFeeUsd: 0, txFeeUsd: 0, slippageUsd: 0, fundingUsd: 0, realizedPnlUsd: 0, totalCostsUsd: 0, netPnlUsd: 0, txCount: 0, fillCount: 0, cancelCount: 0, modifyCount: 0, errorCount: 0, }; const series = []; let cumTrade = 0; let cumTx = 0; let cumSlip = 0; let cumFunding = 0; let cumRealized = 0; for (const ev of events) { const payload = ev?.payload && typeof ev.payload === 'object' ? ev.payload : {}; const type = String(ev?.type || '').toLowerCase(); const tradeFeeUsd = pickNum(payload, ['trade_fee_usd', 'tradeFeeUsd', 'fee_usd', 'feeUsd']); const txFeeUsd = pickNum(payload, ['tx_fee_usd', 'txFeeUsd', 'tx_usd', 'txUsd']); const slippageUsd = pickNum(payload, ['slippage_usd', 'slippageUsd', 'impact_usd', 'impactUsd']); const fundingUsd = pickNum(payload, ['funding_usd', 'fundingUsd']); const realizedPnlUsd = pickNum(payload, ['realized_pnl_usd', 'realizedPnlUsd', 'pnl_usd', 'pnlUsd']); const txCount = Math.max( 0, Math.round( pickNum(payload, ['tx_count', 'txCount', 'txs', 'txs_count']) || (txFeeUsd > 0 ? 1 : 0) ) ); cumTrade += tradeFeeUsd; cumTx += txFeeUsd; cumSlip += slippageUsd; cumFunding += fundingUsd; cumRealized += realizedPnlUsd; costs.tradeFeeUsd = cumTrade; costs.txFeeUsd = cumTx; costs.slippageUsd = cumSlip; costs.fundingUsd = cumFunding; costs.realizedPnlUsd = cumRealized; costs.txCount += txCount; const isFill = type.includes('fill'); const isCancel = type.includes('cancel'); const isModify = type.includes('modify') || type.includes('replace') || type.includes('reprice'); const isError = type.includes('error') || type.includes('panic'); if (isFill) costs.fillCount += 1; if (isCancel) costs.cancelCount += 1; if (isModify) costs.modifyCount += 1; if (isError) costs.errorCount += 1; if (wantSeries) { const totalCostsUsd = cumTrade + cumTx + cumSlip + cumFunding; const netPnlUsd = cumRealized - totalCostsUsd; series.push({ ts: ev?.created_at || getIsoNow(), tradeFeeUsd: cumTrade, txFeeUsd: cumTx, slippageUsd: cumSlip, fundingUsd: cumFunding, totalCostsUsd, realizedPnlUsd: cumRealized, netPnlUsd, }); } } costs.totalCostsUsd = costs.tradeFeeUsd + costs.txFeeUsd + costs.slippageUsd + costs.fundingUsd; costs.netPnlUsd = costs.realizedPnlUsd - costs.totalCostsUsd; // Downsample to seriesMax if needed (keep last point). let outSeries = null; if (wantSeries) { if (series.length <= seriesMax) { outSeries = series; } else { const step = Math.max(1, Math.floor(series.length / seriesMax)); const sampled = []; for (let i = 0; i < series.length; i += step) sampled.push(series[i]); if (sampled[sampled.length - 1] !== series[series.length - 1]) sampled.push(series[series.length - 1]); outSeries = sampled.slice(-seriesMax); } } sendJson( res, 200, { ok: true, contract, eventsCount: events.length, costs, series: outSeries, }, cfg.corsOrigin ); } catch (err) { const msg = String(err?.message || err); if (msg.includes('bot_contracts') || msg.includes('bot_events')) { sendJson(res, 501, { ok: false, error: 'bot_tables_missing' }, cfg.corsOrigin); return; } sendJson(res, 500, { ok: false, error: msg }, cfg.corsOrigin); } return; } if (req.method === 'POST' && pathname === '/v1/ingest/tick') { const auth = await requireValidToken(cfg, req, 'write'); if (!auth.ok) { sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin); return; } let body; try { body = await readBodyJson(req, { maxBytes: 1024 * 1024 }); } catch (err) { const msg = String(err?.message || err); if (msg === 'payload_too_large') { sendJson(res, 413, { ok: false, error: 'payload_too_large' }, cfg.corsOrigin); return; } sendJson(res, 400, { ok: false, error: 'invalid_json' }, cfg.corsOrigin); return; } let tick; try { tick = normalizeTick(body, auth.token); } catch (err) { sendJson(res, 400, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin); return; } try { const id = await insertTick(cfg, tick); sendJson(res, 200, { ok: true, id }, cfg.corsOrigin); } catch (err) { sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin); } return; } if (req.method === 'GET' && pathname === '/v1/ticks') { const auth = await requireValidToken(cfg, req, 'read'); if (!auth.ok) { sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin); return; } const symbol = url.searchParams.get('symbol') || ''; const source = url.searchParams.get('source'); const limitRaw = url.searchParams.get('limit') || '1000'; const limit = Math.min(5000, Math.max(1, Number.parseInt(limitRaw, 10) || 1000)); const from = url.searchParams.get('from'); const to = url.searchParams.get('to'); if (!symbol.trim()) { sendJson(res, 400, { ok: false, error: 'missing_symbol' }, cfg.corsOrigin); return; } const where = { symbol: { _eq: symbol.trim() } }; if (source && source.trim()) where.source = { _eq: source.trim() }; if (from || to) { where.ts = {}; if (from) where.ts._gte = from; if (to) where.ts._lte = to; } const table = cfg.ticksTable; const query = ` query Ticks($where: ${table}_bool_exp!, $limit: Int!) { ${table}(where: $where, order_by: {ts: desc}, limit: $limit) { ts market_index symbol oracle_price mark_price oracle_slot source } } `; try { const data = await hasuraRequest(cfg, { admin: true }, query, { where, limit }); const ticks = (data?.[table] || []).slice().reverse(); sendJson(res, 200, { ok: true, version: cfg.appVersion, ticksTable: cfg.ticksTable, ticks }, cfg.corsOrigin); } catch (err) { sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin); } return; } if (req.method === 'POST' && pathname === '/v1/admin/tokens') { if (!isAdmin(cfg, req)) { sendJson(res, 401, { ok: false, error: 'admin_unauthorized' }, cfg.corsOrigin); return; } let body; try { body = await readBodyJson(req, { maxBytes: 1024 * 1024 }); } catch { sendJson(res, 400, { ok: false, error: 'invalid_json' }, cfg.corsOrigin); return; } const name = (body?.name || 'algo')?.toString?.().trim(); if (!name) { sendJson(res, 400, { ok: false, error: 'missing_name' }, cfg.corsOrigin); return; } const scopes = normalizeScopes(body?.scopes); const resolvedScopes = scopes.length ? scopes : ['write']; try { const { token, row } = await createApiToken(cfg, name, resolvedScopes, body?.meta); sendJson(res, 200, { ok: true, token, id: row.id, name: row.name, created_at: row.created_at }, cfg.corsOrigin); } catch (err) { sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin); } return; } if (req.method === 'POST' && pathname === '/v1/admin/tokens/revoke') { if (!isAdmin(cfg, req)) { sendJson(res, 401, { ok: false, error: 'admin_unauthorized' }, cfg.corsOrigin); return; } let body; try { body = await readBodyJson(req, { maxBytes: 1024 * 1024 }); } catch { sendJson(res, 400, { ok: false, error: 'invalid_json' }, cfg.corsOrigin); return; } const id = (body?.id || '')?.toString?.().trim(); if (!id) { sendJson(res, 400, { ok: false, error: 'missing_id' }, cfg.corsOrigin); return; } try { const revokedId = await revokeApiToken(cfg, id); sendJson(res, 200, { ok: true, id: revokedId }, cfg.corsOrigin); } catch (err) { sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin); } return; } sendJson(res, 404, { ok: false, error: 'not_found' }, cfg.corsOrigin); } function main() { const cfg = resolveConfig(); const server = http.createServer((req, res) => void handler(cfg, req, res)); server.listen(cfg.port, () => { console.log( JSON.stringify( { service: 'trade-api', version: cfg.appVersion, buildTimestamp: cfg.buildTimestamp, port: cfg.port, hasuraUrl: cfg.hasuraUrl, ticksTable: cfg.ticksTable, candlesFunction: cfg.candlesFunction, hasuraAdminSecret: cfg.hasuraAdminSecret ? '***' : undefined, apiAdminSecret: cfg.apiAdminSecret ? '***' : undefined, }, null, 2 ) ); }); } main();