Repository Public
1// The signer's own price view against tickers recorded from Coinbase Exchange and Kraken (scripts/record-signer-tickers.mts).2// Both exchanges must answer and agree within 1 %; anything else is a refusal.3import { readFileSync } from 'node:fs';4import { join } from 'node:path';5import { describe, expect, it } from 'vitest';6import { MARKET_LISTINGS, hasSignerPrice } from '@spw/core';7import { exchangePrices, pricedSymbols, type JsonGet } from '../src/prices.ts';89const rec = JSON.parse(readFileSync(join(import.meta.dirname, 'fixtures/tickers.json'), 'utf8')) as { recordedAt: string; symbols: string[]; responses: Record<string, any> };10const NOW = Date.parse(rec.recordedAt);11const CB = (pair: string) => `https://api.exchange.coinbase.com/products/${pair}/ticker`, KR = (pair: string) => `https://api.kraken.com/0/public/Ticker?pair=${pair}`;12/** Serves the recorded answers; `over` replaces or removes one. Every URL asked is logged. */13function recorded(over: Record<string, unknown> = {}) {14 const asked: string[] = [];15 const get: JsonGet = async (url) => { asked.push(url); const v = Object.hasOwn(over, url) ? over[url] : rec.responses[url]; if (v === undefined || v === null) throw new Error('HTTP 404'); if (v instanceof Error) throw v; return structuredClone(v); };16 return { get, asked };17}18const mid = (url: string) => { const r = rec.responses[url]; if (url.includes('coinbase')) return (Number(r.ask) + Number(r.bid)) / 2; const row = Object.values(r.result)[0] as any; return (Number(row.a[0]) + Number(row.b[0])) / 2; };19const view = (over: Record<string, unknown> = {}, now = NOW) => { const r = recorded(over); return { v: exchangePrices(() => now, r.get), asked: r.asked }; };2021describe("signer price view: more symbols, same rule", () => {22 it('prices every recorded symbol listed on both exchanges at the mean of the two mids, using the pair names of the reviewed table', async () => {23 const live = rec.symbols.filter((s) => hasSignerPrice(s));24 expect(live).toEqual(expect.arrayContaining(['ETH', 'BTC', 'LINK', 'AAVE', 'UNI', 'POL', 'HYPE', 'AVAX', 'BNB', 'XRP', 'DOGE']));25 for (const s of live) {26 if (s === 'USDT') continue;27 const l = MARKET_LISTINGS[s]!, { v, asked } = view();28 const got = await v.get([s]).then((p) => p[s]!, (e: Error) => e);29 const a = mid(CB(l.coinbase!)), b = mid(KR(l.kraken!));30 if (got instanceof Error) { expect(got.message, s).toMatch(/stale quote|abnormal spread|disagree/); continue; } // a thin book at recording time: refused, never guessed31 expect(asked.sort(), s).toEqual([CB(l.coinbase!), KR(l.kraken!)].sort()); // both, and nothing else32 expect(got, s).toBeCloseTo((a + b) / 2, 10);33 expect(Math.abs(a - b) / Math.min(a, b), s).toBeLessThanOrEqual(0.01);34 }35 // the liquid ones must really have been priced by the loop above36 for (const s of ['ETH', 'BTC', 'LINK']) expect((await view().v.get([s]))[s]).toBeGreaterThan(0);37 expect((await view().v.get(['BTC'])).BTC).toBeCloseTo((mid(CB('BTC-USD')) + mid(KR('XBTUSD'))) / 2, 10); // Kraken's XBT38 expect((await view().v.get(['DOGE'])).DOGE).toBeCloseTo((mid(CB('DOGE-USD')) + mid(KR('XDGUSD'))) / 2, 10);39 });4041 it('cash legs are worth one dollar only while the peg holds on the exchange(s)', async () => {42 const usdc = view();43 expect(await usdc.v.get(['USDC'])).toEqual({ USDC: 1 });44 expect(usdc.asked).toEqual([KR('USDCUSD')]);45 const usdt = view();46 expect(await usdt.v.get(['USDT'])).toEqual({ USDT: 1 });47 expect(usdt.asked.sort()).toEqual([CB('USDT-USD'), KR('USDTUSD')].sort()); // both for USDT48 const off = (pair: string, p: string) => ({ error: [], result: { [pair]: { a: [p, '1', '1'], b: [p, '1', '1'] } } });49 await expect(view({ [KR('USDCUSD')]: off('USDCUSD', '0.9700') }).v.get(['USDC'])).rejects.toThrow(/off its peg/);50 await expect(view({ [KR('USDTUSD')]: off('USDTZUSD', '0.9850') }).v.get(['USDT'])).rejects.toThrow(/disagree|off its peg/);51 await expect(view({ [CB('USDT-USD')]: null }).v.get(['USDT'])).rejects.toThrow(/HTTP 404/);52 });5354 it('fails closed: one source down, stale, malformed, or the two disagreeing by more than 1 %', async () => {55 const eth = rec.responses[CB('ETH-USD')], kr = rec.responses[KR('ETHUSD')], row = Object.values(kr.result)[0] as any;56 const krAt = (p: number) => ({ error: [], result: { XETHZUSD: { ...row, a: [p.toFixed(2), '1', '1'], b: [(p - 0.01).toFixed(2), '1', '1'] } } });57 const m = mid(CB('ETH-USD'));58 expect((await view({ [KR('ETHUSD')]: krAt(m * 1.009) }).v.get(['ETH'])).ETH).toBeGreaterThan(m); // 0.9 %: still agreeing59 await expect(view({ [KR('ETHUSD')]: krAt(m * 1.011) }).v.get(['ETH'])).rejects.toThrow(/price sources disagree on ETH/); // 1.1 %60 await expect(view({ [KR('ETHUSD')]: krAt(m * 0.9) }).v.get(['ETH'])).rejects.toThrow(/disagree/);61 await expect(view({ [KR('ETHUSD')]: null }).v.get(['ETH'])).rejects.toThrow(/HTTP 404/); // Kraken down62 await expect(view({ [CB('ETH-USD')]: new Error('timeout') }).v.get(['ETH'])).rejects.toThrow(/timeout/); // Coinbase down63 await expect(view({ [KR('ETHUSD')]: { error: ['EGeneral:Too many requests'] } }).v.get(['ETH'])).rejects.toThrow(/exchange error/);64 await expect(view({ [KR('ETHUSD')]: { error: [], result: { A: row, B: row } } }).v.get(['ETH'])).rejects.toThrow(/unexpected answer/);65 await expect(view({}, NOW + 10 * 60_000).v.get(['ETH'])).rejects.toThrow(/stale quote/); // ten minutes old66 await expect(view({ [CB('ETH-USD')]: { ...eth, ask: '0', bid: '0' } }).v.get(['ETH'])).rejects.toThrow(/bad price/);67 await expect(view({ [CB('ETH-USD')]: { ...eth, ask: String(Number(eth.bid) * 1.05) } }).v.get(['ETH'])).rejects.toThrow(/abnormal spread/);68 await expect(view({ [CB('ETH-USD')]: { ...eth, bid: 2000 } }).v.get(['ETH'])).rejects.toThrow(/bad price/); // a number where a string is specified69 // one bad symbol refuses the whole request (an order needs both of its prices)70 await expect(view({ [KR('LINKUSD')]: null }).v.get(['ETH', 'LINK'])).rejects.toThrow();71 });7273 it('never prices a symbol that is not on both exchanges, whatever is asked', async () => {74 const { v, asked } = view();75 await expect(v.get(['EUR'])).rejects.toThrow(/no price source for EUR: it is not listed on coinbase/); // Bitstamp + Kraken only76 for (const s of ['WBTC', 'cbBTC', 'WETH', 'NOPE', 'eth', '', 'ETH-USD', 'constructor', '__proto__', '../x'])77 await expect(v.get([s]), s).rejects.toThrow(/no price source/); // wrappers are priced as their underlying by the signer, never by ticker78 expect(asked).toEqual([]); // nothing was even requested79 expect(pricedSymbols()).toEqual(expect.arrayContaining(['ETH', 'BTC', 'USDC', 'USDT', 'UNI']));80 expect(pricedSymbols()).not.toContain('EUR');81 for (const s of pricedSymbols()) if (s !== 'USDC') expect(hasSignerPrice(s), s).toBe(true);82 });8384 it('caches a price for 15 seconds, per symbol', async () => {85 let now = NOW; const r = recorded(), v = exchangePrices(() => now, r.get);86 await v.get(['ETH']); await v.get(['ETH', 'ETH']);87 expect(r.asked).toHaveLength(2);88 now += 16_000; await v.get(['ETH']);89 expect(r.asked).toHaveLength(4);90 });91});92