SPB Git

Repository Public

market-symbols.ts 12.1 KB 168 lines typescript

1// Registry asset <-> market symbol. Pure, no clock, no network.2//3// A MARKET SYMBOL is what the exchanges quote against USD (`BTC`, `ETH`, `LINK`…): candles, strategies and the signer's price4// view speak market symbols. A REGISTRY ASSET is one token contract (or the native coin) on one network. Several registry5// assets can be the same economic asset as one market symbol: native ETH and WETH are both `ETH`; WBTC, cbBTC, tBTC and6// BTCB are all `BTC` — each a distinct tradable asset with its OWN ISSUER. An agent picks ONE representation per symbol.7//8// ISSUER RISK, stated once: a wrapped or pegged asset can lose its peg to what it tracks (custodian failure, bridge9// exploit, redemption halt). The signer prices every asset at the UNDERLYING's exchange price, so after a depeg the swap's10// guaranteed output is no longer worth what the mandate requires and the signer refuses the order — it fails closed, in11// both directions: the agent stops buying the wrapped asset and can no longer sell it below the underlying's value either12// (the owner then withdraws it to the main wallet and deals with it by hand).13//14// Nothing here lists tokens: the tradable set is derived from the registry (networks.ts / tokens.ts) at run time, through15//   1. ASSET_TRACKS  — a small REVIEWED table keyed by registry symbol, for assets that are another symbol's wrapper;16//                      it tolerates symbols the registry does not hold yet;17//   2. identity      — any other registry asset is its own market symbol (LINK is `LINK`);18//   3. MARKET_LISTINGS — the build-time table of USD listings; a symbol without two exchanges has no market data.19import { MARKET_LISTINGS, type MarketListing } from './market-listings.ts';20import type { AssetDef, NetworkDef } from './networks.ts';2122/** Cash legs, in order of preference. They are never universe symbols. */23export const CASH_SYMBOLS: readonly string[] = ['USDC', 'USDT'];24export const MAX_AGENT_SYMBOLS = 12;2526export interface TrackDef {27  /** the market symbol this registry asset is economically the same as */28  tracks: string;29  kind: 'wrapped' | 'pegged';30  /** who must stay solvent and honest for one unit to stay worth one unit of `tracks` */31  issuer: string;32  /** default preference among the representations of one symbol on one network: lowest first (reviewed: deepest on-chain liquidity first) */33  rank: number;34}35/**36 * REVIEWED 2026-11-10. Only 1:1 redeemable wrappers and fiat-backed pegs. Deliberately absent: liquid-staking and37 * yield-bearing tokens (wstETH, cbETH, rETH, weETH, sUSDe, sDAI…): their price drifts away from the underlying by design,38 * so they are NOT the same economic asset and must never be priced as it.39 */40export const ASSET_TRACKS: Record<string, TrackDef> = {41  WETH: { tracks: 'ETH', kind: 'wrapped', issuer: 'the WETH deposit contract of the network (no custodian; canonical bridge on a layer 2)', rank: 1 },42  WBTC: { tracks: 'BTC', kind: 'wrapped', issuer: 'BitGo / BiT Global (custodians of the bitcoin)', rank: 1 },43  cbBTC: { tracks: 'BTC', kind: 'wrapped', issuer: 'Coinbase (custodian of the bitcoin)', rank: 2 },44  tBTC: { tracks: 'BTC', kind: 'wrapped', issuer: 'Threshold Network (signer set holding the bitcoin)', rank: 3 },45  BTCB: { tracks: 'BTC', kind: 'wrapped', issuer: 'Binance (custodian of the bitcoin)', rank: 4 },46  cbXRP: { tracks: 'XRP', kind: 'wrapped', issuer: 'Coinbase (custodian of the XRP)', rank: 1 },47  cbDOGE: { tracks: 'DOGE', kind: 'wrapped', issuer: 'Coinbase (custodian of the DOGE)', rank: 1 },48  cbADA: { tracks: 'ADA', kind: 'wrapped', issuer: 'Coinbase (custodian of the ADA)', rank: 1 },49  cbLTC: { tracks: 'LTC', kind: 'wrapped', issuer: 'Coinbase (custodian of the LTC)', rank: 1 },50  WBNB: { tracks: 'BNB', kind: 'wrapped', issuer: 'the WBNB deposit contract (no custodian)', rank: 1 },51  WAVAX: { tracks: 'AVAX', kind: 'wrapped', issuer: 'the WAVAX deposit contract (no custodian)', rank: 1 },52  WPOL: { tracks: 'POL', kind: 'wrapped', issuer: 'the WPOL deposit contract (no custodian)', rank: 1 },53  WHYPE: { tracks: 'HYPE', kind: 'wrapped', issuer: 'the WHYPE deposit contract (no custodian)', rank: 1 },54  EURC: { tracks: 'EUR', kind: 'pegged', issuer: 'Circle (euro reserves)', rank: 1 },55};5657export type Listings = Record<string, MarketListing>;58export type SourceName = 'coinbase' | 'bitstamp' | 'kraken';59const SOURCES: readonly SourceName[] = ['coinbase', 'bitstamp', 'kraken'];60export const listingSources = (symbol: string, listings: Listings = MARKET_LISTINGS): SourceName[] => { const l = Object.hasOwn(listings, symbol) ? listings[symbol]! : {}; return SOURCES.filter((s) => typeof l[s] === 'string'); };61/** Market data exists when two exchanges list the symbol. */62export const hasMarketData = (symbol: string, listings: Listings = MARKET_LISTINGS): boolean => listingSources(symbol, listings).length >= 2;63/** The signer prices a symbol only from Coinbase AND Kraken together: without both, no delegated order can ever be signed. */64export const hasSignerPrice = (symbol: string, listings: Listings = MARKET_LISTINGS): boolean => { const s = listingSources(symbol, listings); return s.includes('coinbase') && s.includes('kraken'); };6566export interface Representation {67  /** registry id: 'native' or the token address */68  id: string;69  /** the registry asset's own ticker and name (what the owner sees: "cbBTC — Coinbase Wrapped BTC") */70  assetSymbol: string; name: string; decimals: number;71  kind: 'native' | 'token' | 'wrapped' | 'pegged';72  /** null for the native coin and for the asset itself */73  issuer: string | null;74  rank: number;75}76export interface TradableSymbol {77  symbol: string;78  /** exchanges listing it against USD (at least two) */79  sources: SourceName[];80  /** false = paper trading only: the signer could never price it (it needs Coinbase and Kraken) */81  live: boolean;82  representations: Representation[];83  /** the representation chosen when the owner does not choose; null = ambiguous, an explicit choice is required */84  defaultId: string | null;85}8687const isCash = (symbol: string) => CASH_SYMBOLS.includes(symbol);88/** The market symbol a registry asset is priced as; null for a cash leg. Says nothing about market data. */89export function marketSymbolOf(a: Pick<AssetDef, 'id' | 'symbol'>): string | null {90  if (isCash(a.symbol)) return null;91  const t = Object.hasOwn(ASSET_TRACKS, a.symbol) ? ASSET_TRACKS[a.symbol]! : null;92  if (t && a.id === 'native') throw new Error(`${a.symbol} is a native coin and is also listed as a wrapper of ${t.tracks}: ambiguous, refused`);93  return t ? t.tracks : a.symbol;94}95function representation(a: AssetDef): Representation {96  const t = Object.hasOwn(ASSET_TRACKS, a.symbol) ? ASSET_TRACKS[a.symbol]! : null;97  if (a.id === 'native') return { id: a.id, assetSymbol: a.symbol, name: a.name, decimals: a.decimals, kind: 'native', issuer: null, rank: -1 };98  if (!t) return { id: a.id, assetSymbol: a.symbol, name: a.name, decimals: a.decimals, kind: 'token', issuer: null, rank: 0 };99  return { id: a.id, assetSymbol: a.symbol, name: a.name, decimals: a.decimals, kind: t.kind, issuer: t.issuer, rank: t.rank };100}101102/**103 * Every market symbol tradable on a network: registry assets grouped by the symbol they track, kept when the symbol has104 * market data. DEFAULT REPRESENTATION, in this order: the native coin; else the token that IS the symbol; else the wrapper105 * with the lowest reviewed rank. Two candidates at the winning level (two registry tokens with one ticker, equal ranks)106 * give no default: the choice must be explicit.107 */108export function tradableSymbols(n: NetworkDef, listings: Listings = MARKET_LISTINGS): TradableSymbol[] {109  const by = new Map<string, Representation[]>();110  for (const a of [n.native, ...n.tokens]) {111    const s = marketSymbolOf(a);112    if (s === null || !hasMarketData(s, listings)) continue;113    by.set(s, [...(by.get(s) ?? []), representation(a)]);114  }115  return [...by.entries()].map(([symbol, reps]) => {116    const representations = [...reps].sort((x, y) => x.rank - y.rank || x.assetSymbol.localeCompare(y.assetSymbol) || x.id.localeCompare(y.id));117    const best = representations.filter((r) => r.rank === representations[0]!.rank);118    return { symbol, sources: listingSources(symbol, listings), live: hasSignerPrice(symbol, listings), representations, defaultId: best.length === 1 ? best[0]!.id : null };119  }).sort((x, y) => x.symbol.localeCompare(y.symbol));120}121122/** Cash legs available on a network, preferred first. */123export function cashLegs(n: NetworkDef): { id: string; symbol: string; name: string; decimals: number }[] {124  return CASH_SYMBOLS.flatMap((s) => { const found = n.tokens.filter((t) => t.symbol === s); return found.length === 1 ? [{ id: found[0]!.id, symbol: s, name: found[0]!.name, decimals: found[0]!.decimals }] : []; });125}126127export interface AgentAsset { /** market symbol, or the cash symbol for the cash leg */ symbol: string; id: string; decimals: number; assetSymbol: string; name: string; kind: Representation['kind'] | 'cash'; issuer: string | null }128export interface AgentAssets { cash: AgentAsset; assets: AgentAsset[] }129const sameId = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();130131/**132 * Resolve an agent's universe on its network: the cash leg (USDC unless the owner chose USDT; USDT must be chosen133 * explicitly where USDC does not exist) and exactly one registry asset per market symbol. Throws on anything unknown,134 * duplicated, without market data, ambiguous, or chosen wrongly.135 */136export function resolveAgentAssets(n: NetworkDef, universe: readonly string[], o: { cash?: string | null; assets?: Record<string, string> | null } = {}, listings: Listings = MARKET_LISTINGS): AgentAssets {137  if (n.family !== 'evm') throw new Error(`agents do not trade on ${n.name}`);138  const legs = cashLegs(n), want = o.cash ?? 'USDC';139  if (!isCash(want)) throw new Error(`${want} cannot be a cash leg (allowed: ${CASH_SYMBOLS.join(', ')})`);140  const leg = legs.find((l) => l.symbol === want);141  if (!leg) throw new Error(legs.length ? `${want} is not in the wallet registry on ${n.name}; choose ${legs.map((l) => l.symbol).join(' or ')} as the cash leg` : `no cash leg (${CASH_SYMBOLS.join(', ')}) in the wallet registry on ${n.name} yet`);142  if (universe.length < 1 || universe.length > MAX_AGENT_SYMBOLS) throw new Error(`an agent trades between 1 and ${MAX_AGENT_SYMBOLS} symbols`);143  if (new Set(universe).size !== universe.length) throw new Error('duplicate symbol in the universe');144  const chosen = o.assets ?? {};145  for (const k of Object.keys(chosen)) if (!universe.includes(k)) throw new Error(`a representation is chosen for ${k}, which is not in the universe`);146  const table = tradableSymbols(n, listings);147  const assets = universe.map((symbol): AgentAsset => {148    if (isCash(symbol)) throw new Error(`${symbol} is a cash leg, not a tradable symbol`);149    const t = table.find((x) => x.symbol === symbol);150    if (!t) throw new Error(hasMarketData(symbol, listings) ? `no asset of the wallet registry tracks ${symbol} on ${n.name}` : `${symbol} has no market data (it needs a USD pair on two exchanges)`);151    const pickId = Object.hasOwn(chosen, symbol) ? chosen[symbol]! : t.defaultId;152    if (pickId === null) throw new Error(`${symbol} has several representations on ${n.name} (${t.representations.map((r) => r.assetSymbol).join(', ')}) and no default: choose one`);153    const r = t.representations.find((x) => sameId(x.id, pickId));154    if (!r) throw new Error(`the asset chosen for ${symbol} does not track it on ${n.name}`);155    return { symbol, id: r.id, decimals: r.decimals, assetSymbol: r.assetSymbol, name: r.name, kind: r.kind, issuer: r.issuer };156  });157  return { cash: { symbol: leg.symbol, id: leg.id, decimals: leg.decimals, assetSymbol: leg.symbol, name: leg.name, kind: 'cash', issuer: null }, assets };158}159160/**161 * The symbol whose exchange price values one registry asset, for the signer's price check: the UNDERLYING for a wrapper162 * (so a depeg makes the check fail), the asset's own ticker otherwise, the cash symbol for a cash leg.163 */164export function priceSymbolOf(a: Pick<AssetDef, 'id' | 'symbol'>): { symbol: string; cash: boolean } {165  const s = marketSymbolOf(a);166  return s === null ? { symbol: a.symbol, cash: true } : { symbol: s, cash: false };167}168
Files main
Menu

Theme