src/markets/exchange.ts
v0.1.0 · 3.9 KB
import { isBase58Pubkey } from '../address/base58.js';
import { GLOBAL_CONFIGURATION } from '../onchain/global.js';
export interface ExchangeMarket {
symbol: string;
assetId: number;
status: string;
marketPubkey: string;
splinePubkey: string;
tickSize: string;
baseLotsDecimals: number;
isolatedOnly: boolean;
}
function reqAddr(v: unknown, name: string): string {
const a = typeof v === 'string' && isBase58Pubkey(v) ? v : null;
if (!a) throw new Error(`${name}: expected a canonical 32-byte base58 public key`);
return a;
}
export function decodeExchangeView(json: unknown) {
if (!json || typeof json !== 'object' || Array.isArray(json))
throw new Error('Exchange view must be an object with a nonempty market list');
const payload = json as Record<string, any>;
const k = payload.keys;
if (!k || typeof k !== 'object') throw new Error('Exchange view keys missing');
const globalConfig = reqAddr(k.globalConfig, 'keys.globalConfig');
if (globalConfig !== GLOBAL_CONFIGURATION)
throw new Error(
`REST keys.globalConfig ${globalConfig} differs from the Phoenix mainnet configuration ${GLOBAL_CONFIGURATION}`,
);
const perpAssetMap = reqAddr(k.perpAssetMap, 'keys.perpAssetMap');
const arr = (v: unknown, name: string): string[] => {
if (!Array.isArray(v) || v.length === 0 || v.length > 16)
throw new Error(`${name}: expected between 1 and 16 public keys`);
return v.map((x: unknown, i: number) => reqAddr(x, `${name}[${i}]`));
};
const keys = {
globalConfig,
perpAssetMap,
globalTraderIndex: arr(k.globalTraderIndex, 'keys.globalTraderIndex'),
activeTraderBuffer: arr(k.activeTraderBuffer, 'keys.activeTraderBuffer'),
};
if (!Array.isArray(payload.markets) || payload.markets.length === 0)
throw new Error('Exchange view must be an object with a nonempty market list');
const markets = new Map<string, ExchangeMarket>();
const skipped: Array<{
symbol: string;
why: string;
}> = [];
for (const m of payload.markets) {
const symbol = m?.symbol;
if (typeof symbol !== 'string' || !/^[A-Za-z0-9._-]{1,32}$/.test(symbol)) {
skipped.push({ symbol: String(symbol).slice(0, 40), why: 'Invalid market symbol' });
continue;
}
if (markets.has(symbol)) throw new Error('Duplicate exchange market symbol');
try {
const tick = m.tickSize;
const tickOk =
(typeof tick === 'number' && Number.isSafeInteger(tick) && tick > 0) ||
(typeof tick === 'string' && /^[1-9]\d{0,19}$/.test(tick));
if (!tickOk) throw new Error(`tickSize ${tick}`);
const bld = m.baseLotsDecimals;
if (!Number.isInteger(bld) || bld < -12 || bld > 18) throw new Error(`baseLotsDecimals ${bld}`);
if (typeof m.isolatedOnly !== 'boolean') throw new Error('isolatedOnly');
if (typeof m.marketStatus !== 'string') throw new Error('marketStatus');
markets.set(symbol, {
symbol,
assetId: m.assetId,
status: m.marketStatus,
marketPubkey: reqAddr(m.marketPubkey, 'marketPubkey'),
splinePubkey: reqAddr(m.splinePubkey, 'splinePubkey'),
tickSize: String(tick),
baseLotsDecimals: bld,
isolatedOnly: m.isolatedOnly,
});
} catch (e) {
skipped.push({ symbol, why: (e as Error).message });
}
}
if (markets.size === 0) throw new Error('Exchange view must be an object with a nonempty market list');
return { keys, markets, skipped };
}
export function pinMarkets(pins: Map<string, ExchangeMarket>, markets: Map<string, ExchangeMarket>): string[] {
const p: string[] = [];
for (const [symbol, m] of markets) {
const old = pins.get(symbol);
if (
old &&
(old.marketPubkey !== m.marketPubkey ||
old.splinePubkey !== m.splinePubkey ||
old.tickSize !== m.tickSize ||
old.baseLotsDecimals !== m.baseLotsDecimals)
)
p.push('Market pin conflict: ' + symbol);
else if (!old) pins.set(symbol, { ...m });
}
return p;
}