diff --git a/.changeset/sdk-outcome-market-reads-v2.md b/.changeset/sdk-outcome-market-reads-v2.md new file mode 100644 index 0000000..5814249 --- /dev/null +++ b/.changeset/sdk-outcome-market-reads-v2.md @@ -0,0 +1,7 @@ +--- +'@usdh-kit/sdk': minor +--- + +Add experimental read-only outcome market reads. The SDK now exposes outcome +metadata, encoded side books, and outcome mids through `createUsdhKit`, with +runtime `outcomeMeta` validation and safe outcome id encoding. diff --git a/README.md b/README.md index 7086f5e..87fd19b 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,10 @@ A few real flows the SDK is shaped for today. Runnable examples are still on the - `USDC → USDH` quote and swap via the canonical HL spot pair - HyperEVM → HyperCore bridge with credit polling (`bridgeToCore`) - HyperCore balance, route/preflight helpers plus `bridgeAndSwap()` orchestration +- Experimental read-only outcome market metadata, books, and mids - Wallet-agnostic `Signer` interface (works with viem, ethers, Privy, Turnkey, raw private key) - Approved Hyperliquid agent wallet flow for browser apps (`approveAgent`, `accountAddress`) -- Read-only `InfoClient` (spotMeta, spot clearinghouse state, L2 book) for consumers building custom UIs +- Read-only `InfoClient` (spotMeta, outcomeMeta, spot clearinghouse state, L2 book, allMids) for consumers building custom UIs - Typed error hierarchy rooted at `UsdhKitError`, including `BridgeAndSwapError` phase/cause context and `isBridgeAndSwapError()` for orchestration failures - `friendlyError()` helper to map SDK errors to short, copy-safe strings - React widget (`@usdh-kit/widget`) with light, dark and auto theming (WCAG AA defaults, CSS variables for integrator overrides) diff --git a/docs/architecture.md b/docs/architecture.md index 9df6669..aca1ce6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -141,7 +141,11 @@ The widget's `friendlyError(err)` helper maps these to short copy-safe strings ( ## Transport -`InfoClient` (`createInfoClient`) is exposed as a public export so consumers can build read-only UIs without re-implementing the wire format. Methods include `spotMeta()`, `spotClearinghouseState(user)`, `l2Book(coin)`. Server-friendly (works on Node, Bun, edge, browser). +`InfoClient` (`createInfoClient`) is exposed as a public export so consumers can build read-only UIs without re-implementing the wire format. Methods include `spotMeta()`, `outcomeMeta()`, `spotClearinghouseState(user)`, `l2Book(coin)`, and `allMids()`. Server-friendly (works on Node, Bun, edge, browser). + +Outcome reads are experimental and read-only. The SDK validates `outcomeMeta`, +derives encoded side coins like `#200`, and reuses the hardened `l2Book()` path +for books. It does not place outcome orders or claim a settlement asset. `ExchangeClient` is internal — consumers should call `kit.swap()` rather than building actions themselves. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 07a49a4..47bcb3d 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -24,7 +24,8 @@ What works today: * `getRoute()` / `preflightSwap()` for HyperCore-vs-HyperEVM source selection * `bridgeAndSwap()` for route → optional bridge → swap orchestration * USDH spot market discovery (`listPairs`, `getPair`, `getBook`, `getMids`) -* Read-only `InfoClient` (spotMeta, spotClearinghouseState, L2 book, allMids) +* Experimental read-only outcome market metadata, books, and mids +* Read-only `InfoClient` (spotMeta, outcomeMeta, spotClearinghouseState, L2 book, allMids) Deferred to follow-up PRs: USDT pricing/swap, reverse direction (USDH → USDC), multi-chain source. @@ -148,6 +149,27 @@ const mids = await kit.getMids({ quote: 'USDH' }) console.log(pairs.length, usdhQuotes.length, book.coin, mids[hypeUsdh.name]) ``` +## Read outcome markets + +Outcome support is experimental and read-only. It exposes Hyperliquid outcome +metadata and encoded side books without making settlement or denomination +claims. Outcome side coins use Hyperliquid's `#` format where +`encoding = 10 * outcome + side`. + +```ts +const outcomes = await kit.listOutcomeMarkets() +const market = await kit.getOutcomeMarket({ outcome: outcomes[0].outcome }) + +const yesBook = await kit.getOutcomeBook({ + outcome: market.outcome, + side: 0, + nSigFigs: 5, +}) +const outcomeMids = await kit.getOutcomeMids() + +console.log(market.name, yesBook.coin, outcomeMids[market.sides[0].coin]) +``` + ## Bridge and swap `bridgeAndSwap()` composes the common retail flow: @@ -182,8 +204,9 @@ Unexpected route, bridge, or swap failures are wrapped in `BridgeAndSwapError`. * `getRoute()` / `preflightSwap()` route selection and preflight metadata * `bridgeAndSwap()` high-level orchestration with progress callbacks * USDH spot market discovery and read-only books/mids for USDH pairs +* Experimental read-only outcome market metadata, books, and mids * Wallet-agnostic `Signer` interface (works with viem, ethers, Privy, Turnkey, raw private key) -* Read-only `InfoClient` (spotMeta, spot clearinghouse state, L2 book, allMids) +* Read-only `InfoClient` (spotMeta, outcomeMeta, spot clearinghouse state, L2 book, allMids) * Typed error hierarchy rooted at `UsdhKitError`, including `BridgeAndSwapError` phase/cause context and `isBridgeAndSwapError()` narrowing * npm provenance on every release * Mainnet and testnet support, no signing on read paths diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 89efa50..4b45984 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -10,12 +10,30 @@ export type { ListUsdhPairsOpts, UsdhPair, } from './discovery.js' +export { + normalizeOutcomeMeta, + outcomeAssetId, + outcomeCoin, + outcomeEncoding, + outcomeTokenName, +} from './outcomes.js' +export type { + GetOutcomeBookInput, + GetOutcomeMarketInput, + OutcomeSide, + OutcomeSideMarket, + UsdhOutcomeMarket, +} from './outcomes.js' export { createInfoClient } from './transport/info.js' export type { InfoClient, InfoClientConfig, NSigFigs } from './transport/info.js' export type { L2Book, L2Level, + OutcomeMeta, + OutcomeMetaOutcome, + OutcomeMetaQuestion, + OutcomeSideSpec, SpotBalance, SpotClearinghouseState, SpotMeta, diff --git a/packages/sdk/src/kit.ts b/packages/sdk/src/kit.ts index d465ccd..aafe042 100644 --- a/packages/sdk/src/kit.ts +++ b/packages/sdk/src/kit.ts @@ -14,6 +14,12 @@ import { NetworkError, NotImplementedError, } from './errors.js' +import { + type GetOutcomeBookInput, + type GetOutcomeMarketInput, + type UsdhOutcomeMarket, + createOutcomeDiscovery, +} from './outcomes.js' import { type ResolvedPair, createPairResolver } from './pair-resolver.js' import { applyPriceInverse, @@ -95,6 +101,14 @@ export interface UsdhKit { getBook(pair: string, opts?: { nSigFigs?: NSigFigs }): Promise /** Fetch mid prices, optionally filtered to USDH-quote pairs. */ getMids(opts?: GetMidsOpts): Promise> + /** List experimental read-only outcome markets from Hyperliquid outcome metadata. */ + listOutcomeMarkets(): Promise + /** Find one experimental read-only outcome market by numeric outcome id. */ + getOutcomeMarket(input: GetOutcomeMarketInput): Promise + /** Fetch the L2 book for one experimental outcome side. */ + getOutcomeBook(input: GetOutcomeBookInput): Promise + /** Fetch mid prices keyed by encoded outcome side coin, e.g. `#200`. */ + getOutcomeMids(): Promise> } /** @@ -118,6 +132,7 @@ export function createUsdhKit(config: KitConfig): UsdhKit { }) const resolvePair = createPairResolver(info) const discovery = createDiscovery(info) + const outcomeDiscovery = createOutcomeDiscovery(info) let lastNonce = 0n function nextNonce(): bigint { @@ -302,6 +317,22 @@ export function createUsdhKit(config: KitConfig): UsdhKit { return discovery.getMids(opts) }, + listOutcomeMarkets() { + return outcomeDiscovery.listOutcomeMarkets() + }, + + getOutcomeMarket(input) { + return outcomeDiscovery.getOutcomeMarket(input) + }, + + getOutcomeBook(input) { + return outcomeDiscovery.getOutcomeBook(input) + }, + + getOutcomeMids() { + return outcomeDiscovery.getOutcomeMids() + }, + async getQuote(input: QuoteInput): Promise { validateQuoteInput(input) if (input.from === 'USDT') { diff --git a/packages/sdk/src/outcomes.ts b/packages/sdk/src/outcomes.ts new file mode 100644 index 0000000..43b8b3e --- /dev/null +++ b/packages/sdk/src/outcomes.ts @@ -0,0 +1,146 @@ +import { InvalidInputError, NetworkError } from './errors.js' +import type { InfoClient, NSigFigs } from './transport/info.js' +import type { L2Book, OutcomeMeta, OutcomeMetaOutcome } from './transport/types.js' + +export type OutcomeSide = 0 | 1 + +export interface OutcomeSideMarket { + side: OutcomeSide + name: string + encoding: number + coin: `#${number}` + tokenName: `+${number}` + assetId: number +} + +export interface UsdhOutcomeMarket { + outcome: number + name: string + description: string + descriptionFields: Record + sides: [OutcomeSideMarket, OutcomeSideMarket] +} + +export interface GetOutcomeMarketInput { + outcome: number +} + +export interface GetOutcomeBookInput extends GetOutcomeMarketInput { + side: OutcomeSide + nSigFigs?: NSigFigs +} + +const OUTCOME_ASSET_ID_OFFSET = 100_000_000 +const MAX_OUTCOME_ID = Math.floor((Number.MAX_SAFE_INTEGER - OUTCOME_ASSET_ID_OFFSET - 1) / 10) + +export function createOutcomeDiscovery(info: InfoClient): { + listOutcomeMarkets(): Promise + getOutcomeMarket(input: GetOutcomeMarketInput): Promise + getOutcomeBook(input: GetOutcomeBookInput): Promise + getOutcomeMids(): Promise> +} { + let marketsCache: Promise | null = null + + async function loadMarkets(): Promise { + if (marketsCache === null) { + marketsCache = info.outcomeMeta().then(normalizeOutcomeMeta) + } + return marketsCache + } + + return { + listOutcomeMarkets() { + return loadMarkets() + }, + async getOutcomeMarket(input) { + validateOutcome(input.outcome) + const market = (await loadMarkets()).find((candidate) => candidate.outcome === input.outcome) + if (market === undefined) { + throw new NetworkError(`outcome ${input.outcome} not found in outcomeMeta`) + } + return market + }, + getOutcomeBook(input) { + return info.l2Book(outcomeCoin(input.outcome, input.side), input.nSigFigs) + }, + async getOutcomeMids() { + const mids = await info.allMids() + return Object.fromEntries(Object.entries(mids).filter(([coin]) => coin.startsWith('#'))) + }, + } +} + +export function normalizeOutcomeMeta(meta: OutcomeMeta): UsdhOutcomeMarket[] { + return meta.outcomes.map(normalizeOutcome) +} + +export function outcomeEncoding(outcome: number, side: OutcomeSide): number { + validateOutcome(outcome) + validateOutcomeSide(side) + return 10 * outcome + side +} + +export function outcomeCoin(outcome: number, side: OutcomeSide): `#${number}` { + return `#${outcomeEncoding(outcome, side)}` +} + +export function outcomeTokenName(outcome: number, side: OutcomeSide): `+${number}` { + return `+${outcomeEncoding(outcome, side)}` +} + +export function outcomeAssetId(outcome: number, side: OutcomeSide): number { + return OUTCOME_ASSET_ID_OFFSET + outcomeEncoding(outcome, side) +} + +function normalizeOutcome(outcome: OutcomeMetaOutcome): UsdhOutcomeMarket { + validateOutcome(outcome.outcome) + const yes = sideMarket(outcome, 0) + const no = sideMarket(outcome, 1) + return { + outcome: outcome.outcome, + name: outcome.name, + description: outcome.description, + descriptionFields: parseDescriptionFields(outcome.description), + sides: [yes, no], + } +} + +function sideMarket(outcome: OutcomeMetaOutcome, side: OutcomeSide): OutcomeSideMarket { + const sideSpec = outcome.sideSpecs[side] + if (sideSpec === undefined) { + throw new NetworkError(`outcome ${outcome.outcome} is missing side ${side}`) + } + const encoding = outcomeEncoding(outcome.outcome, side) + return { + side, + name: sideSpec.name, + encoding, + coin: `#${encoding}`, + tokenName: `+${encoding}`, + assetId: OUTCOME_ASSET_ID_OFFSET + encoding, + } +} + +function parseDescriptionFields(description: string): Record { + const fields: Record = {} + for (const part of description.split('|')) { + const separator = part.indexOf(':') + if (separator <= 0) continue + const key = part.slice(0, separator) + const value = part.slice(separator + 1) + if (key !== '' && value !== '') fields[key] = value + } + return fields +} + +function validateOutcome(outcome: number): void { + if (!Number.isSafeInteger(outcome) || outcome < 0 || outcome > MAX_OUTCOME_ID) { + throw new InvalidInputError(`outcome must be a safe integer in [0, ${MAX_OUTCOME_ID}]`) + } +} + +function validateOutcomeSide(side: number): asserts side is OutcomeSide { + if (side !== 0 && side !== 1) { + throw new InvalidInputError('outcome side must be 0 or 1') + } +} diff --git a/packages/sdk/src/transport/info.ts b/packages/sdk/src/transport/info.ts index d9f4027..e9072de 100644 --- a/packages/sdk/src/transport/info.ts +++ b/packages/sdk/src/transport/info.ts @@ -1,7 +1,14 @@ import { InvalidInputError, NetworkError } from '../errors.js' import type { Address } from '../types/hex.js' import type { Network } from '../types/network.js' -import type { L2Book, SpotClearinghouseState, SpotMeta } from './types.js' +import type { + L2Book, + OutcomeMeta, + OutcomeMetaQuestion, + OutcomeSideSpec, + SpotClearinghouseState, + SpotMeta, +} from './types.js' const ENDPOINTS: Record = { mainnet: 'https://api.hyperliquid.xyz/info', @@ -22,6 +29,7 @@ export type NSigFigs = 2 | 3 | 4 | 5 export interface InfoClient { spotMeta(): Promise + outcomeMeta(): Promise l2Book(coin: string, nSigFigs?: NSigFigs): Promise spotClearinghouseState(user: Address): Promise /** Returns mid prices keyed by coin name (perps) or `@` (spot). */ @@ -87,6 +95,9 @@ export function createInfoClient(config: InfoClientConfig): InfoClient { spotMeta() { return post({ type: 'spotMeta' }) }, + outcomeMeta() { + return post({ type: 'outcomeMeta' }).then(assertOutcomeMeta) + }, l2Book(coin, nSigFigs) { if (nSigFigs !== undefined && ![2, 3, 4, 5].includes(nSigFigs)) { throw new InvalidInputError('nSigFigs must be 2, 3, 4, or 5') @@ -104,6 +115,94 @@ export function createInfoClient(config: InfoClientConfig): InfoClient { } } +function assertOutcomeMeta(data: unknown): OutcomeMeta { + const meta = data as { outcomes?: unknown; questions?: unknown } + if (!isRecord(data) || !Array.isArray(meta.outcomes)) { + throw new NetworkError('invalid outcomeMeta response') + } + const outcomes = meta.outcomes.map(assertOutcomeMetaOutcome) + const questionsRaw = meta.questions + if (questionsRaw !== undefined && !Array.isArray(questionsRaw)) { + throw new NetworkError('invalid outcomeMeta questions') + } + const questions = questionsRaw?.map(assertOutcomeMetaQuestion) + return questions === undefined ? { outcomes } : { outcomes, questions } +} + +function assertOutcomeMetaOutcome(value: unknown): OutcomeMeta['outcomes'][number] { + if (!isRecord(value)) { + throw new NetworkError('invalid outcomeMeta outcome') + } + const outcomeRaw = value as { + outcome?: unknown + name?: unknown + description?: unknown + sideSpecs?: unknown + } + const outcome = outcomeRaw.outcome + const name = outcomeRaw.name + const description = outcomeRaw.description + const sideSpecs = outcomeRaw.sideSpecs + if ( + !isSafeNonNegativeInteger(outcome) || + typeof name !== 'string' || + typeof description !== 'string' || + !Array.isArray(sideSpecs) || + sideSpecs.length !== 2 + ) { + throw new NetworkError('invalid outcomeMeta outcome') + } + const yes = assertOutcomeSideSpec(sideSpecs[0]) + const no = assertOutcomeSideSpec(sideSpecs[1]) + return { outcome, name, description, sideSpecs: [yes, no] } +} + +function assertOutcomeSideSpec(value: unknown): OutcomeSideSpec { + const sideSpec = value as { name?: unknown } + if (!isRecord(value) || typeof sideSpec.name !== 'string') { + throw new NetworkError('invalid outcomeMeta sideSpec') + } + return { name: sideSpec.name } +} + +function assertOutcomeMetaQuestion(value: unknown): OutcomeMetaQuestion { + if (!isRecord(value)) { + throw new NetworkError('invalid outcomeMeta question') + } + const questionRaw = value as { + question?: unknown + name?: unknown + description?: unknown + fallbackOutcome?: unknown + namedOutcomes?: unknown + settledNamedOutcomes?: unknown + } + const question = questionRaw.question + const name = questionRaw.name + const description = questionRaw.description + const fallbackOutcome = questionRaw.fallbackOutcome + const namedOutcomes = questionRaw.namedOutcomes + const settledNamedOutcomes = questionRaw.settledNamedOutcomes + if ( + !isSafeNonNegativeInteger(question) || + typeof name !== 'string' || + typeof description !== 'string' || + !isSafeNonNegativeInteger(fallbackOutcome) || + !isSafeIntegerArray(namedOutcomes) || + !isSafeIntegerArray(settledNamedOutcomes) + ) { + throw new NetworkError('invalid outcomeMeta question') + } + return { + question, + name, + description, + fallbackOutcome, + namedOutcomes, + settledNamedOutcomes, + } +} + function assertL2Book(data: unknown, coin: string): L2Book { if (!isRecord(data)) { throw new NetworkError(`invalid l2Book response for ${coin}`) @@ -147,6 +246,14 @@ function isL2Level(value: unknown): boolean { ) } +function isSafeNonNegativeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0 +} + +function isSafeIntegerArray(value: unknown): value is number[] { + return Array.isArray(value) && value.every(isSafeNonNegativeInteger) +} + function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } diff --git a/packages/sdk/src/transport/types.ts b/packages/sdk/src/transport/types.ts index fd076f2..cf23875 100644 --- a/packages/sdk/src/transport/types.ts +++ b/packages/sdk/src/transport/types.ts @@ -58,3 +58,28 @@ export interface SpotBalance { export interface SpotClearinghouseState { balances: SpotBalance[] } + +export interface OutcomeSideSpec { + name: string +} + +export interface OutcomeMetaOutcome { + outcome: number + name: string + description: string + sideSpecs: [OutcomeSideSpec, OutcomeSideSpec] +} + +export interface OutcomeMetaQuestion { + question: number + name: string + description: string + fallbackOutcome: number + namedOutcomes: number[] + settledNamedOutcomes: number[] +} + +export interface OutcomeMeta { + outcomes: OutcomeMetaOutcome[] + questions?: OutcomeMetaQuestion[] +} diff --git a/packages/sdk/test/bridge.test.ts b/packages/sdk/test/bridge.test.ts index ec689fe..46d50ca 100644 --- a/packages/sdk/test/bridge.test.ts +++ b/packages/sdk/test/bridge.test.ts @@ -51,6 +51,7 @@ function stubInfo(states: SpotClearinghouseState[]): InfoClient { let i = 0 return { spotMeta: vi.fn(async () => sampleSpotMeta), + outcomeMeta: vi.fn(), l2Book: vi.fn(), spotClearinghouseState: vi.fn(async () => { const s = states[Math.min(i, states.length - 1)] @@ -194,6 +195,7 @@ describe('runBridgeToCore', () => { } const info: InfoClient = { spotMeta: vi.fn(async () => meta), + outcomeMeta: vi.fn(), l2Book: vi.fn(), spotClearinghouseState: vi.fn(), allMids: vi.fn(), @@ -384,6 +386,7 @@ describe('runBridgeToCore', () => { let calls = 0 const info: InfoClient = { spotMeta: vi.fn(async () => sampleSpotMeta), + outcomeMeta: vi.fn(), l2Book: vi.fn(), spotClearinghouseState: vi.fn(async () => { calls += 1 diff --git a/packages/sdk/test/discovery.test.ts b/packages/sdk/test/discovery.test.ts index 81f94bc..23f5a2c 100644 --- a/packages/sdk/test/discovery.test.ts +++ b/packages/sdk/test/discovery.test.ts @@ -143,6 +143,7 @@ describe('createDiscovery', () => { function stubInfo(overrides: Partial = {}): InfoClient { return { spotMeta: vi.fn(async () => meta), + outcomeMeta: vi.fn(), l2Book: vi.fn(), spotClearinghouseState: vi.fn(), allMids: vi.fn(), diff --git a/packages/sdk/test/info.test.ts b/packages/sdk/test/info.test.ts index 18bd60f..be0a769 100644 --- a/packages/sdk/test/info.test.ts +++ b/packages/sdk/test/info.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { InvalidInputError, NetworkError } from '../src/errors.js' import { createInfoClient } from '../src/transport/info.js' -import type { L2Book, SpotMeta } from '../src/transport/types.js' +import type { L2Book, OutcomeMeta, SpotMeta } from '../src/transport/types.js' function jsonResponse(body: unknown, init?: ResponseInit): Response { return new Response(JSON.stringify(body), { @@ -40,6 +40,27 @@ const sampleL2Book: L2Book = { levels: [[{ px: '0.9998', sz: '10000', n: 1 }], [{ px: '1.0001', sz: '10000', n: 1 }]], } +const sampleOutcomeMeta: OutcomeMeta = { + outcomes: [ + { + outcome: 20, + name: 'Recurring', + description: 'class:priceBinary|underlying:BTC|expiry:20260511-0600|targetPrice:80657', + sideSpecs: [{ name: 'Yes' }, { name: 'No' }], + }, + ], + questions: [ + { + question: 3, + name: 'Recurring', + description: 'class:priceBucket|underlying:BTC|expiry:20260511-0600', + fallbackOutcome: 21, + namedOutcomes: [22, 23, 24], + settledNamedOutcomes: [], + }, + ], +} + describe('createInfoClient', () => { it('falls back to globalThis.fetch when no fetch is provided', () => { expect(() => createInfoClient({ network: 'mainnet' })).not.toThrow() @@ -85,6 +106,44 @@ describe('spotMeta', () => { }) }) +describe('outcomeMeta', () => { + it('posts an outcomeMeta body and returns the validated payload', async () => { + const fetch = vi.fn(async () => jsonResponse(sampleOutcomeMeta)) + const client = createInfoClient({ network: 'mainnet', fetch }) + const result = await client.outcomeMeta() + const [, init] = fetch.mock.calls[0] ?? [] + expect(JSON.parse(init?.body as string)).toEqual({ type: 'outcomeMeta' }) + expect(result).toEqual(sampleOutcomeMeta) + }) + + it('rejects missing outcomes', async () => { + const fetch = vi.fn(async () => jsonResponse({ questions: [] })) + const client = createInfoClient({ network: 'mainnet', fetch }) + await expect(client.outcomeMeta()).rejects.toThrow(/invalid outcomeMeta response/) + }) + + it('rejects malformed outcome sideSpecs', async () => { + const fetch = vi.fn(async () => + jsonResponse({ + outcomes: [{ ...sampleOutcomeMeta.outcomes[0], sideSpecs: [{ name: 'Yes' }] }], + }), + ) + const client = createInfoClient({ network: 'mainnet', fetch }) + await expect(client.outcomeMeta()).rejects.toThrow(/invalid outcomeMeta outcome/) + }) + + it('rejects malformed questions', async () => { + const fetch = vi.fn(async () => + jsonResponse({ + ...sampleOutcomeMeta, + questions: [{ ...sampleOutcomeMeta.questions?.[0], namedOutcomes: ['22'] }], + }), + ) + const client = createInfoClient({ network: 'mainnet', fetch }) + await expect(client.outcomeMeta()).rejects.toThrow(/invalid outcomeMeta question/) + }) +}) + describe('l2Book', () => { it('passes coin and nSigFigs in the body', async () => { const fetch = vi.fn(async () => jsonResponse(sampleL2Book)) diff --git a/packages/sdk/test/kit.test.ts b/packages/sdk/test/kit.test.ts index d9fbff3..cd5bf4b 100644 --- a/packages/sdk/test/kit.test.ts +++ b/packages/sdk/test/kit.test.ts @@ -11,7 +11,7 @@ import { createUsdhKit, isBridgeAndSwapError, } from '../src/index.js' -import type { L2Book, SpotMeta } from '../src/transport/types.js' +import type { L2Book, OutcomeMeta, SpotMeta } from '../src/transport/types.js' import type { EvmWallet } from '../src/types/evm-wallet.js' const stubSigner: Signer = { @@ -52,6 +52,18 @@ const sampleL2Book: L2Book = { levels: [[{ px: '0.9998', sz: '10000', n: 1 }], [{ px: '1.0002', sz: '10000', n: 1 }]], } +const sampleOutcomeMeta: OutcomeMeta = { + outcomes: [ + { + outcome: 20, + name: 'Recurring', + description: 'class:priceBinary|underlying:BTC|expiry:20260511-0600|targetPrice:80657', + sideSpecs: [{ name: 'Yes' }, { name: 'No' }], + }, + ], + questions: [], +} + function jsonResponse(body: unknown): Response { return new Response(JSON.stringify(body), { status: 200, @@ -81,6 +93,32 @@ function backend(exchangeResponse: unknown): { return { fetch, getExchangeBody: () => exchangeBody } } +function outcomeBackend(): { + fetch: typeof fetch + getInfoBodies: () => Record[] +} { + const infoBodies: Record[] = [] + const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString() + const body = JSON.parse(init?.body as string) as Record + if (!url.endsWith('/info')) throw new Error(`unexpected url: ${url}`) + infoBodies.push(body) + if (body.type === 'outcomeMeta') return jsonResponse(sampleOutcomeMeta) + if (body.type === 'l2Book') { + return jsonResponse({ + coin: body.coin, + time: 1778427457824, + levels: [[{ px: '0.73331', sz: '136.0', n: 1 }], [{ px: '0.73332', sz: '222.0', n: 2 }]], + }) + } + if (body.type === 'allMids') { + return jsonResponse({ '#200': '0.733315', '#201': '0.266685', BTC: '80657' }) + } + throw new Error(`unexpected /info body: ${JSON.stringify(body)}`) + }) as unknown as typeof fetch + return { fetch, getInfoBodies: () => infoBodies } +} + type HcBalanceFixture = string | { total: string; hold?: string } function routingBackend( @@ -133,6 +171,39 @@ function stubEvmWallet(txHash = `0x${'c'.repeat(64)}`): EvmWallet & { calls: unk } } +describe('outcome market reads', () => { + it('lists and fetches experimental outcome markets', async () => { + const { fetch } = outcomeBackend() + const kit = createUsdhKit({ network: 'testnet', signer: stubSigner, fetch }) + + await expect(kit.listOutcomeMarkets()).resolves.toMatchObject([ + { outcome: 20, sides: [{ coin: '#200' }, { coin: '#201' }] }, + ]) + await expect(kit.getOutcomeMarket({ outcome: 20 })).resolves.toMatchObject({ + outcome: 20, + descriptionFields: { underlying: 'BTC' }, + }) + }) + + it('reads outcome books and mids through the kit', async () => { + const { fetch, getInfoBodies } = outcomeBackend() + const kit = createUsdhKit({ network: 'testnet', signer: stubSigner, fetch }) + + await expect(kit.getOutcomeBook({ outcome: 20, side: 0, nSigFigs: 5 })).resolves.toMatchObject({ + coin: '#200', + }) + await expect(kit.getOutcomeMids()).resolves.toEqual({ + '#200': '0.733315', + '#201': '0.266685', + }) + + expect(getInfoBodies()).toEqual([ + { type: 'l2Book', coin: '#200', nSigFigs: 5 }, + { type: 'allMids' }, + ]) + }) +}) + describe('createUsdhKit', () => { it('binds the configured network', () => { const kit = createUsdhKit({ network: 'testnet', signer: stubSigner }) diff --git a/packages/sdk/test/outcomes.test.ts b/packages/sdk/test/outcomes.test.ts new file mode 100644 index 0000000..9d41d40 --- /dev/null +++ b/packages/sdk/test/outcomes.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest' + +import { InvalidInputError, NetworkError } from '../src/errors.js' +import { + createOutcomeDiscovery, + normalizeOutcomeMeta, + outcomeAssetId, + outcomeCoin, + outcomeEncoding, + outcomeTokenName, +} from '../src/outcomes.js' +import type { InfoClient } from '../src/transport/info.js' +import type { L2Book, OutcomeMeta } from '../src/transport/types.js' + +const maxOutcomeId = Math.floor((Number.MAX_SAFE_INTEGER - 100_000_000 - 1) / 10) + +const sampleOutcomeMeta: OutcomeMeta = { + outcomes: [ + { + outcome: 20, + name: 'Recurring', + description: 'class:priceBinary|underlying:BTC|expiry:20260511-0600|targetPrice:80657', + sideSpecs: [{ name: 'Yes' }, { name: 'No' }], + }, + ], + questions: [], +} + +const sampleBook: L2Book = { + coin: '#200', + time: 1778427457824, + levels: [[{ px: '0.73331', sz: '136.0', n: 1 }], [{ px: '0.73332', sz: '222.0', n: 2 }]], +} + +function stubInfo(overrides: Partial = {}): InfoClient { + return { + spotMeta: vi.fn(), + outcomeMeta: vi.fn(async () => sampleOutcomeMeta), + l2Book: vi.fn(async () => sampleBook), + spotClearinghouseState: vi.fn(), + allMids: vi.fn(async () => ({ '#200': '0.733315', '#201': '0.266685', BTC: '80657' })), + ...overrides, + } +} + +describe('outcome encoding helpers', () => { + it('derives the Hyperliquid coin, token name, and asset id', () => { + expect(outcomeEncoding(20, 0)).toBe(200) + expect(outcomeEncoding(20, 1)).toBe(201) + expect(outcomeCoin(20, 0)).toBe('#200') + expect(outcomeTokenName(20, 1)).toBe('+201') + expect(outcomeAssetId(20, 1)).toBe(100_000_201) + }) + + it('accepts the maximum safe outcome id', () => { + expect(outcomeAssetId(maxOutcomeId, 1)).toBeLessThanOrEqual(Number.MAX_SAFE_INTEGER) + }) + + it('rejects unsafe outcome ids and non-binary sides', () => { + expect(() => outcomeEncoding(-1, 0)).toThrow(InvalidInputError) + expect(() => outcomeEncoding(1.5, 0)).toThrow(InvalidInputError) + expect(() => outcomeEncoding(maxOutcomeId + 1, 0)).toThrow(InvalidInputError) + // biome-ignore lint/suspicious/noExplicitAny: deliberately bad input + expect(() => outcomeEncoding(1, 2 as any)).toThrow(InvalidInputError) + }) +}) + +describe('outcome market reads', () => { + it('normalizes outcomeMeta into market sides', () => { + expect(normalizeOutcomeMeta(sampleOutcomeMeta)).toEqual([ + { + outcome: 20, + name: 'Recurring', + description: 'class:priceBinary|underlying:BTC|expiry:20260511-0600|targetPrice:80657', + descriptionFields: { + class: 'priceBinary', + underlying: 'BTC', + expiry: '20260511-0600', + targetPrice: '80657', + }, + sides: [ + { + side: 0, + name: 'Yes', + encoding: 200, + coin: '#200', + tokenName: '+200', + assetId: 100_000_200, + }, + { + side: 1, + name: 'No', + encoding: 201, + coin: '#201', + tokenName: '+201', + assetId: 100_000_201, + }, + ], + }, + ]) + }) + + it('caches outcomeMeta across list and get calls', async () => { + const info = stubInfo() + const outcomes = createOutcomeDiscovery(info) + await outcomes.listOutcomeMarkets() + await outcomes.getOutcomeMarket({ outcome: 20 }) + expect(info.outcomeMeta).toHaveBeenCalledOnce() + }) + + it('fetches one outcome market by id', async () => { + const outcomes = createOutcomeDiscovery(stubInfo()) + await expect(outcomes.getOutcomeMarket({ outcome: 20 })).resolves.toMatchObject({ + outcome: 20, + sides: [{ coin: '#200' }, { coin: '#201' }], + }) + await expect(outcomes.getOutcomeMarket({ outcome: 999 })).rejects.toThrow(NetworkError) + }) + + it('fetches the book for the encoded outcome side coin', async () => { + const info = stubInfo() + const outcomes = createOutcomeDiscovery(info) + await expect(outcomes.getOutcomeBook({ outcome: 20, side: 0, nSigFigs: 5 })).resolves.toEqual( + sampleBook, + ) + expect(info.l2Book).toHaveBeenCalledWith('#200', 5) + }) + + it('propagates l2Book validation failures', async () => { + const outcomes = createOutcomeDiscovery( + stubInfo({ + l2Book: vi.fn(async () => { + throw new NetworkError('invalid l2Book response for #200') + }), + }), + ) + await expect(outcomes.getOutcomeBook({ outcome: 20, side: 0 })).rejects.toThrow( + /invalid l2Book response/, + ) + }) + + it('filters allMids to encoded outcome side coins', async () => { + const outcomes = createOutcomeDiscovery(stubInfo()) + await expect(outcomes.getOutcomeMids()).resolves.toEqual({ + '#200': '0.733315', + '#201': '0.266685', + }) + }) +}) diff --git a/packages/sdk/test/pair-resolver.test.ts b/packages/sdk/test/pair-resolver.test.ts index a1e3bf8..1f69936 100644 --- a/packages/sdk/test/pair-resolver.test.ts +++ b/packages/sdk/test/pair-resolver.test.ts @@ -108,6 +108,7 @@ describe('createPairResolver', () => { function stubInfo(): InfoClient { return { spotMeta: vi.fn(async () => meta), + outcomeMeta: vi.fn(), l2Book: vi.fn(), spotClearinghouseState: vi.fn(), allMids: vi.fn(),