Repository Public
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