SPB Git

Repository Public

server.ts 22.4 KB 295 lines typescript

1import { existsSync } from 'node:fs';2import { join } from 'node:path';3import Fastify, { type FastifyInstance } from 'fastify';4import cookie from '@fastify/cookie';5import rateLimit from '@fastify/rate-limit';6import fastifyStatic from '@fastify/static';7import { z, ZodError } from 'zod';8import { formatUnits, getNetwork, NETWORKS, normalizeAddress, type NetworkDef, type NetworkId } from '@spw/core';9import { authRoutes, deviceRoutes, isAuthed, listCreds } from './auth.ts';10import { adapterFor } from './chains/index.ts';11import { http } from './chains/rpc.ts';12import { createGecko } from './gecko.ts';13import { config, secureCookies, UserError } from './config.ts';14import { audit, auditList, getSetting, setSetting, sql } from './db.ts';15import { marketDataRoutes } from './market/routes.ts';16import { derivativesRoutes } from './derivatives/routes.ts';17import type { Derivatives } from './derivatives/index.ts';18import { liveRoutes } from './derivatives/live-routes.ts';19import { collateralRoutes } from './derivatives/collateral-routes.ts';20import { gmxRoutes } from './derivatives/gmx-routes.ts'; // v8 GMX v2 trading21import type { LiveService } from './derivatives/exec.ts';22import { addressFor, signer } from './signer-client.ts';23import { swapRoutes } from './swap.ts';24import { swapExecRoutes } from './swap-exec.ts';25import { bridgeRoutes } from './bridge.ts';26import { xswapRoutes } from './xswap.ts';27import { agentRoutes } from './agents/routes.ts';28import { strategyRoutes } from './agents/strategies.ts';29import { perpAgentRoutes } from './agents/perp-routes.ts'; // v6 perp agents30import { transferRoutes, view, type ProposalRow } from './transfers.ts';31import { healthCache } from './worker.ts';32import { alertRoutes } from './alerts/routes.ts';     // v6-alerts33import { monitorRoutes } from './monitor/routes.ts'; // v6-alerts34import { researchRoutes, RUNNER_PREFIX, runnerGate } from './research/routes.ts'; // v7 research runtime35import { perpRuntime } from './agents/perp-live.ts'; // v7 research runtime (perp data for research jobs)36import { planRoutes, type PlanDeps } from './plan-exec.ts'; // v7 any-to-any plans3738const PUBLIC = new Set(['/api/v1/health/live', '/api/v1/auth/state', '/api/v1/auth/setup/options', '/api/v1/auth/setup/verify', '/api/v1/auth/login/options', '/api/v1/auth/login/verify', '/api/v1/auth/logout']);39const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'";4041const NETS = Object.values(NETWORKS) as NetworkDef[];42const cache = new Map<string, { at: number; v: unknown }>();43async function cached<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T> {44  const c = cache.get(key);45  if (c && Date.now() - c.at < ttlMs) return c.v as T;46  const v = await fn();47  cache.set(key, { at: Date.now(), v });48  return v;49}5051const COINGECKO: Record<string, string> = {52  ETH: 'ethereum', BTC: 'bitcoin', SOL: 'solana', POL: 'polygon-ecosystem-token', BNB: 'binancecoin', AVAX: 'avalanche-2', HYPE: 'hyperliquid',53  LTC: 'litecoin', DOGE: 'dogecoin', BCH: 'bitcoin-cash', TRX: 'tron', XRP: 'ripple', XLM: 'stellar',54  APT: 'aptos',55  ATOM: 'cosmos',56  SUI: 'sui',57  NEAR: 'near',58  TON: 'the-open-network',59  USDC: 'usd-coin', USDT: 'tether', PYUSD: 'paypal-usd', DAI: 'dai', USDS: 'usds', USDe: 'ethena-usde', FDUSD: 'first-digital-usd', EURC: 'euro-coin', RLUSD: 'ripple-usd', USDG: 'global-dollar',60  LINK: 'chainlink', UNI: 'uniswap', AAVE: 'aave', LDO: 'lido-dao', WBTC: 'wrapped-bitcoin', WETH: 'weth', PAXG: 'pax-gold', ONDO: 'ondo-finance', RENDER: 'render-token',61  JUP: 'jupiter-exchange-solana', RAY: 'raydium', BONK: 'bonk', PEPE: 'pepe', SHIB: 'shiba-inu',62  // registry v5. A symbol has one price: a bridged or exchange-pegged representation registered under the asset's own ticker63  // (Binance-Peg ETH / XRP / DOGE…, bridged DAI / WBTC / WETH) is valued at the asset's price; representations with their own64  // ticker use their own CoinGecko entry where one exists. A symbol missing here is shown without a fiat value, never with a guess.65  USDT0: 'usdt0', 'USDC.e': 'usd-coin', GHO: 'gho', crvUSD: 'crvusd', frxUSD: 'frax-usd', USDD: 'usdd',66  cbBTC: 'coinbase-wrapped-btc', tBTC: 'tbtc', BTCB: 'binance-bitcoin', 'BTC.b': 'bitcoin-avalanche-bridged-btc-b',67  'WETH.e': 'avalanche-bridged-weth-avalanche', 'LINK.e': 'chainlink', wstETH: 'wrapped-steth', rETH: 'rocket-pool-eth', cbETH: 'coinbase-wrapped-staked-eth', weETH: 'wrapped-eeth',68  ARB: 'arbitrum', OP: 'optimism', WPOL: 'wmatic', WAVAX: 'wrapped-avax', WBNB: 'wbnb', WHYPE: 'wrapped-hype', MNT: 'mantle',69  cbXRP: 'coinbase-wrapped-xrp', cbDOGE: 'coinbase-wrapped-doge', cbLTC: 'coinbase-wrapped-ltc', cbADA: 'coinbase-wrapped-ada', ADA: 'cardano', DOT: 'polkadot',70  MKR: 'maker', SKY: 'sky', CRV: 'curve-dao-token', COMP: 'compound-governance-token', SNX: 'havven', PENDLE: 'pendle', ENA: 'ethena', GMX: 'gmx',71  AERO: 'aerodrome-finance', VELO: 'velodrome-finance', CAKE: 'pancakeswap-token', JOE: 'joe', MORPHO: 'morpho', EIGEN: 'eigenlayer',72  BRETT: 'based-brett', TOSHI: 'toshi', DEGEN: 'degen-base', JTO: 'jito-governance-token', PYTH: 'pyth-network', HNT: 'helium', ORCA: 'orca',73  mSOL: 'msol', jitoSOL: 'jito-staked-sol', bSOL: 'blazestake-staked-sol',74};75interface Market { usd: number; cad: number; change24h: number | null; change7d: number | null; marketCap: number | null; volume24h: number | null; high24h: number | null; low24h: number | null; ath: number | null; rank: number | null; spark: number[] }76// CoinGecko GET with stale-on-error and a short retry after a failure: gecko.ts (v10)77const gecko = createGecko((url, init) => http(url, init));78type GeckoRow = { id: string; current_price: number; price_change_percentage_24h_in_currency?: number; price_change_percentage_7d_in_currency?: number; market_cap?: number; total_volume?: number; high_24h?: number; low_24h?: number; ath?: number; market_cap_rank?: number; sparkline_in_7d?: { price: number[] } };79const thin = (a: number[], max: number) => (a.length <= max ? a : Array.from({ length: max }, (_, k) => a[Math.round((k * (a.length - 1)) / (max - 1))]!));80async function markets(): Promise<Record<string, Market> | null> {81  if (!(await getSetting('prices', true))) return null;82  const ids = [...new Set(Object.values(COINGECKO))].join(',');83  const q = (cur: string) => gecko<GeckoRow[]>(`markets:${cur}`, 4 * 60_000, `/coins/markets?vs_currency=${cur}&ids=${ids}&per_page=250&sparkline=${cur === 'usd'}&price_change_percentage=24h,7d`);84  const [usd, cad] = await Promise.all([q('usd'), q('cad')]);85  if (!usd || !cad) return null;86  const out: Record<string, Market> = {};87  for (const [sym, id] of Object.entries(COINGECKO)) {88    const u = usd.find((r) => r.id === id), c = cad.find((r) => r.id === id);89    if (!u || !c) continue;90    out[sym] = { usd: u.current_price, cad: c.current_price, change24h: u.price_change_percentage_24h_in_currency ?? null, change7d: u.price_change_percentage_7d_in_currency ?? null,91      marketCap: u.market_cap ?? null, volume24h: u.total_volume ?? null, high24h: u.high_24h ?? null, low24h: u.low_24h ?? null, ath: u.ath ?? null, rank: u.market_cap_rank ?? null,92      spark: thin(u.sparkline_in_7d?.price ?? [], 42) };93  }94  return out;95}96async function prices(): Promise<Record<string, Market> | null> { return markets(); }9798export async function buildServer(opts: { swapProviders?: import('@spw/swap').SwapProvider[]; derivatives?: Derivatives; live?: LiveService; collateral?: import('./derivatives/collateral.ts').CollateralService; gmx?: import('./derivatives/gmx-exec.ts').GmxService; plan?: Partial<PlanDeps> } = {}): Promise<FastifyInstance> {99  const swapProviders = opts.swapProviders;100  const app = Fastify({ logger: { level: 'warn', redact: ['req.headers.cookie', 'req.headers.authorization'] }, bodyLimit: 64 * 1024, trustProxy: config.trustProxy });101  await app.register(cookie);102  await app.register(rateLimit, { max: 240, timeWindow: '1 minute' });103104  app.addHook('onRequest', async (req, reply) => {105    reply.header('content-security-policy', CSP).header('x-content-type-options', 'nosniff').header('referrer-policy', 'no-referrer')106      .header('x-frame-options', 'DENY').header('x-robots-tag', 'noindex, nofollow, noarchive')107      .header('permissions-policy', 'camera=(), microphone=(), geolocation=(), payment=(), publickey-credentials-get=(self), publickey-credentials-create=(self)')108      .header('cross-origin-opener-policy', 'same-origin').header('cross-origin-resource-policy', 'same-origin');109    if (secureCookies) reply.header('strict-transport-security', 'max-age=31536000; includeSubDomains');110    const path = req.url.split('?')[0]!;111    if (!path.startsWith('/api/')) return;112    reply.header('cache-control', 'no-store');113    // >>> v7 research runtime: the runner's endpoints carry their own shared secret — no owner session, no cookie, no CSRF header;114    // that secret opens these paths and no other, and the owner session does not open them115    if (path.startsWith(RUNNER_PREFIX)) { runnerGate(req); return; }116    // <<< v7 research runtime117    if (req.method !== 'GET' && req.method !== 'HEAD') {118      // CSRF: strict same-origin + custom header (cannot be set cross-site without CORS, which is never enabled)119      if (req.headers.origin !== config.origin || req.headers['x-spw-csrf'] !== '1') throw new UserError('csrf', 'cross-site request refused', 403);120    }121    if (!PUBLIC.has(path) && !(await isAuthed(req))) throw new UserError('unauthenticated', 'authentication required', 401);122  });123124  app.setErrorHandler((err, req, reply) => {125    const e = err as Error & { statusCode?: number };126    if (e instanceof UserError) return reply.status(e.status).send({ error: e.code, message: e.message });127    if (e instanceof ZodError) return reply.status(400).send({ error: 'bad_request', message: 'invalid request' });128    if (e.statusCode === 429) return reply.status(429).send({ error: 'rate_limited', message: 'too many requests' });129    if (e.statusCode && e.statusCode < 500) return reply.status(e.statusCode).send({ error: 'bad_request', message: 'invalid request' });130    req.log.error({ msg: e.message, path: req.url.split('?')[0] });131    return reply.status(500).send({ error: 'internal', message: 'internal error' });132  });133134  authRoutes(app);135  deviceRoutes(app);136  transferRoutes(app);137  swapRoutes(app, { providers: swapProviders, prices: markets });138  swapExecRoutes(app);139  bridgeRoutes(app);140  xswapRoutes(app);141  planRoutes(app, { prices: markets, ...opts.plan }); // v7 any-to-any plans: a saga over the routes above; signs nothing itself142  agentRoutes(app, { prices: markets });143  strategyRoutes(app);144  perpAgentRoutes(app, opts.derivatives, opts.live); // v6 perp agents on paper accounts; v7: LIVE on a venue account under a signer-enforced mandate145  marketDataRoutes(app);146  derivativesRoutes(app, opts.derivatives);147  liveRoutes(app, opts.live); // controlled live trading: nothing trades until the owner authorizes a venue account with his passkey148  collateralRoutes(app, opts.collateral); // deposit to / withdraw from a venue account: passkey per operation, pinned bridge, own address only149  gmxRoutes(app, opts.gmx); // v8: GMX v2 orders as wallet transactions; off unless SPW_DERIV_GMX_LIVE=on and the owner approved a venue policy150  await alertRoutes(app); // v6-alerts: in-app alerts, push subscriptions, webhook — all behind the owner session151  monitorRoutes(app);     // v6-alerts: read-only monitoring dashboard152  await researchRoutes(app, { perp: () => perpRuntime().lab }); // v7 research runtime: jobs for the owner, a narrow endpoint pair for the runner153154  app.get('/api/v1/health/live', async () => ({ ok: true }));155  app.get('/api/v1/health/ready', async () => {156    const db = await sql`select 1`.then(() => true, () => false);157    const s = await signer.status().then((x) => ({ ok: true, halted: x.halted }), () => ({ ok: false, halted: null }));158    return { db, signer: s, networks: [...healthCache.values()] };159  });160161  const liveTokens = (n: NetworkDef) => { const off = new Set(adapterFor(n.id).disabledAssets?.() ?? []); return n.tokens.filter((t) => !off.has(t.id)); };162  app.get('/api/v1/networks', async () => NETS.map((n) => ({163    id: n.id, name: n.name, family: n.family, chainRef: n.chainRef, native: n.native, tokens: liveTokens(n), memo: n.memo ?? null, activation: n.activation ?? null, explorerAddr: n.explorerAddr, minConf: n.minConf,164    health: healthCache.get(n.id) ?? null })));165  app.get('/api/v1/assets', async (req) => {166    const q = z.object({ network: z.string().optional() }).parse(req.query);167    return NETS.filter((n) => !q.network || n.id === q.network).flatMap((n) => [n.native, ...liveTokens(n)].map((a) => ({ network: n.id, ...a })));168  });169170  // One vault, one signer-derived address per chain family. Addresses come from the signer, never from the client.171  app.get('/api/v1/accounts', async () => {172    const st = await signer.status();173    return { keyId: st.keyId, accounts: NETS.filter((n) => st.addresses[n.id]).map((n) => ({ network: n.id, name: n.name, family: n.family, address: st.addresses[n.id]!, explorer: n.explorerAddr + st.addresses[n.id] })) };174  });175176  app.get('/api/v1/portfolio', async () => {177    const st = await signer.status();178    const px = await prices();179    const rows = await Promise.all(NETS.filter((n) => st.addresses[n.id]).map(async (n) => {180      const address = addressFor(st, n.id);181      try {182        const b = await cached(`bal:${n.id}`, 12_000, () => adapterFor(n.id).balances(address));183        return [n.native, ...liveTokens(n)].map((a) => (b[a.id] === undefined184          ? { network: n.id, networkName: n.name, asset: a.id, symbol: a.symbol, name: a.name, decimals: a.decimals, amount: null, display: null, error: 'unavailable' as string | null }185          : { network: n.id, networkName: n.name, asset: a.id, symbol: a.symbol, name: a.name, decimals: a.decimals,186              amount: b[a.id]!.toString(), display: formatUnits(b[a.id]!, a.decimals), error: null as string | null }));187      } catch {188        return [n.native, ...n.tokens].map((a) => ({ network: n.id, networkName: n.name, asset: a.id, symbol: a.symbol, name: a.name, decimals: a.decimals, amount: null, display: null, error: 'unavailable' }));189      }190    }));191    const pending = await sql<ProposalRow[]>`select * from proposals where state in ('pending_approval','approved','signed','broadcast','confirming','reorged') order by created_at desc`;192    return { asOf: new Date().toISOString(), prices: px, balances: rows.flat(), pending: pending.map(view), halted: st.halted };193  });194195  // price history for one asset (chart). `symbol` must be in the pinned map; nothing about the wallet is sent upstream.196  app.get('/api/v1/market/:symbol', async (req) => {197    const symbol = (req.params as { symbol: string }).symbol;198    const q = z.object({ days: z.enum(['1', '7', '30', '365']).default('7'), currency: z.enum(['usd', 'cad']).default('cad') }).parse(req.query);199    const id = COINGECKO[symbol];200    if (!id || !(await getSetting('prices', true))) return { symbol, points: [], market: null };201    const ttl = q.days === '1' ? 5 * 60_000 : q.days === '7' ? 20 * 60_000 : 60 * 60_000;202    const j = await gecko<{ prices: [number, number][] }>(`chart:${id}:${q.currency}:${q.days}`, ttl, `/coins/${id}/market_chart?vs_currency=${q.currency}&days=${q.days}`);203    const pts = j?.prices ?? [];204    const step = Math.max(1, Math.ceil(pts.length / 240));205    const points = pts.filter((_, k) => k % step === 0 || k === pts.length - 1).map(([t, p]) => ({ t, p }));206    return { symbol, currency: q.currency, days: q.days, points, market: (await markets())?.[symbol] ?? null };207  });208209  app.get('/api/v1/activity', async (req) => {210    const q = z.object({ limit: z.coerce.number().int().min(1).max(500).default(100) }).parse(req.query);211    const out = await sql<ProposalRow[]>`select * from proposals where state <> 'draft' order by created_at desc limit ${q.limit}`;212    const dep = await sql<{ network: string; txid: string; idx: number; asset: string; amount: string; from_addr: string; block: string; block_time: Date | null; seen_at: Date; finalized: string | null }[]>`213      select d.*, c.finalized from deposits d left join scan_cursor c on c.network = d.network order by coalesce(d.block_time, d.seen_at) desc limit ${q.limit}`;214    const items = [215      ...out.map((p) => ({ ...view(p), kind: (p.kind === 'swap' || p.kind === 'xswap' ? 'swap' : p.kind === 'bridge' ? 'bridge' : 'send') as 'send' | 'swap' | 'bridge', at: p.created_at })),216      ...dep.map((d) => {217        const n = getNetwork(d.network), a = d.asset === 'native' ? n.native : n.tokens.find((t) => t.id === d.asset)!;218        return { kind: 'receive' as const, at: d.block_time ?? d.seen_at, id: `${d.network}:${d.txid}:${d.idx}`, network: d.network, networkName: n.name, asset: d.asset, symbol: a.symbol,219          decimals: a.decimals, amount: d.amount, amountDisplay: formatUnits(BigInt(d.amount), a.decimals), from: d.from_addr, txid: d.txid, explorer: n.explorerTx + d.txid,220          state: Number(d.block) <= Number(d.finalized ?? 0) ? 'finalized' : 'confirming' };221      }),222    ].sort((x, y) => new Date(y.at).getTime() - new Date(x.at).getTime()).slice(0, q.limit);223    return { items };224  });225226  app.get('/api/v1/export/activity.csv', async (_req, reply) => {227    const out = await sql<ProposalRow[]>`select * from proposals where state <> 'draft' order by created_at`;228    const dep = await sql`select * from deposits order by seen_at`;229    const esc = (v: unknown) => `"${String(v ?? '').replace(/"/g, '""').replace(/^([=+\-@])/, "'$1")}"`;230    const lines = ['time_utc,direction,network,symbol,amount_base_units,amount,counterparty,state,txid,fee_estimate_base_units'];231    for (const p of out) lines.push([p.created_at.toISOString(), 'send', p.network, p.symbol, p.amount, formatUnits(BigInt(p.amount), p.decimals), p.to_addr, p.state, p.txid, p.fee_estimate].map(esc).join(','));232    for (const d of dep) { const n = getNetwork(d.network), a = d.asset === 'native' ? n.native : n.tokens.find((t) => t.id === d.asset)!;233      lines.push([(d.block_time ?? d.seen_at).toISOString(), 'receive', d.network, a.symbol, d.amount, formatUnits(BigInt(d.amount), a.decimals), d.from_addr, 'confirmed', d.txid, ''].map(esc).join(',')); }234    return reply.header('content-type', 'text/csv; charset=utf-8').header('content-disposition', 'attachment; filename="sp-wallet-activity.csv"').send(lines.join('\n') + '\n');235  });236237  app.get('/api/v1/addressbook', async () => sql`select id, network, address, label, created_at from address_book order by label`);238  app.post('/api/v1/addressbook', async (req) => {239    const b = z.strictObject({ network: z.string().max(20), address: z.string().max(110), label: z.string().min(1).max(40) }).parse(req.body);240    let address: string;241    try { address = normalizeAddress(getNetwork(b.network).id, b.address); } catch (e) { throw new UserError('bad_address', (e as Error).message); }242    const [r] = await sql`insert into address_book (id, network, address, label) values (${crypto.randomUUID()}, ${b.network}, ${address}, ${b.label})243      on conflict (network, address) do update set label = excluded.label returning id, network, address, label`;244    await audit('addressbook.saved', 'owner', { network: b.network, address, label: b.label });245    return r;246  });247  app.delete('/api/v1/addressbook/:id', async (req) => {248    const id = z.uuid().parse((req.params as { id: string }).id);249    const [r] = await sql`delete from address_book where id = ${id} returning network, address`;250    if (r) await audit('addressbook.removed', 'owner', { network: r.network, address: r.address });251    return { ok: true };252  });253254  const Settings = z.strictObject({ locale: z.enum(['fr', 'en']), currency: z.enum(['USD', 'CAD']), hideBalances: z.boolean(), prices: z.boolean() });255  const readSettings = async () => ({ locale: await getSetting('locale', 'fr'), currency: await getSetting('currency', 'CAD'), hideBalances: await getSetting('hideBalances', false), prices: await getSetting('prices', true) });256  app.get('/api/v1/settings', readSettings);257  app.put('/api/v1/settings', async (req) => {258    const b = Settings.partial().parse(req.body);259    for (const [k, v] of Object.entries(b)) await setSetting(k, v);260    cache.delete('prices');261    return readSettings();262  });263264  app.get('/api/v1/security/overview', async () => {265    const st = await signer.status(true).catch(() => null);266    const devices = (await listCreds()).map((c) => ({ id: c.id.slice(0, 12), label: c.label, deviceType: c.device_type, backedUp: c.backed_up, createdAt: c.created_at, lastUsedAt: c.last_used_at,267      enrolledOnSigner: !!st?.credentials.some((x) => x.id === c.id) }));268    const sessions = await sql`select created_at, last_seen, ua, ip from sessions where not revoked and expires_at > now() order by last_seen desc limit 20`;269    return {270      signer: st ? { reachable: true, keyId: st.keyId, halted: st.halted, journalLength: st.journalLength, journalHead: st.journalHead, policy: st.policy, spentToday: st.spentToday } : { reachable: false },271      devices, sessions, origin: config.origin, broadcast: config.broadcast,272    };273  });274  app.get('/api/v1/security/devices', async () => (await listCreds()).map((c) => ({ id: c.id.slice(0, 12), label: c.label, createdAt: c.created_at, lastUsedAt: c.last_used_at })));275  app.post('/api/v1/security/emergency-halt', async (req) => {276    await signer.halt(); // one-way from the app: re-enabling requires the CLI on the signer machine277    await audit('halt.on', 'owner', { ip: req.ip });278    return { ok: true, halted: true };279  });280  app.get('/api/v1/security/audit', async (req) => auditList(z.object({ limit: z.coerce.number().int().min(1).max(500).default(100) }).parse(req.query).limit));281282  if (config.webDir && existsSync(join(config.webDir, 'index.html'))) {283    // wildcard serving: files are resolved per request, so a rebuilt bundle is served without restarting the API284    await app.register(fastifyStatic, { root: config.webDir, cacheControl: false,285      setHeaders: (res, path) => { res.header('cache-control', path.includes('/assets/') ? 'public, max-age=31536000, immutable' : 'no-cache'); } });286    app.setNotFoundHandler((req, reply) => {287      // never answer a missing script/style with the HTML shell (that is what produced a blank page)288      if (req.url.startsWith('/api/') || req.url.startsWith('/assets/') || /\.[a-z0-9]{2,5}(\?|$)/i.test(req.url) || req.method !== 'GET') return reply.status(404).send({ error: 'not_found', message: 'not found' });289      return reply.header('cache-control', 'no-cache').sendFile('index.html');290    });291  }292  return app;293}294export type { NetworkId };295
Files main
Menu

Theme