Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/sdk-outcome-market-reads-v2.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
27 changes: 25 additions & 2 deletions packages/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 `#<encoding>` 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:
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions packages/sdk/src/kit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -95,6 +101,14 @@ export interface UsdhKit {
getBook(pair: string, opts?: { nSigFigs?: NSigFigs }): Promise<L2Book>
/** Fetch mid prices, optionally filtered to USDH-quote pairs. */
getMids(opts?: GetMidsOpts): Promise<Record<string, string>>
/** List experimental read-only outcome markets from Hyperliquid outcome metadata. */
listOutcomeMarkets(): Promise<UsdhOutcomeMarket[]>
/** Find one experimental read-only outcome market by numeric outcome id. */
getOutcomeMarket(input: GetOutcomeMarketInput): Promise<UsdhOutcomeMarket>
/** Fetch the L2 book for one experimental outcome side. */
getOutcomeBook(input: GetOutcomeBookInput): Promise<L2Book>
/** Fetch mid prices keyed by encoded outcome side coin, e.g. `#200`. */
getOutcomeMids(): Promise<Record<string, string>>
}

/**
Expand All @@ -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 {
Expand Down Expand Up @@ -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<Quote> {
validateQuoteInput(input)
if (input.from === 'USDT') {
Expand Down
146 changes: 146 additions & 0 deletions packages/sdk/src/outcomes.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
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<UsdhOutcomeMarket[]>
getOutcomeMarket(input: GetOutcomeMarketInput): Promise<UsdhOutcomeMarket>
getOutcomeBook(input: GetOutcomeBookInput): Promise<L2Book>
getOutcomeMids(): Promise<Record<string, string>>
} {
let marketsCache: Promise<UsdhOutcomeMarket[]> | null = null

async function loadMarkets(): Promise<UsdhOutcomeMarket[]> {
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<string, string> {
const fields: Record<string, string> = {}
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')
}
}
Loading
Loading