# Ostium Builder SDK

![Ostium Builder Service Banner](./builder-service.png)

TypeScript SDK for trading Stocks, Commodities, Forex, Indices, Crypto and ETF perps on [Ostium](https://ostium.com).

```bash
npm install @ostium/builder-sdk viem
# or
bun add @ostium/builder-sdk viem
```

**Guides:**
- [BUILDER.md](BUILDER.md) — Integrating Ostium into your app with builder fees
- [TRADER.md](TRADER.md) — Building trading strategies programmatically on Ostium

---

## Examples

Runnable programs live in [`examples/`](./examples) — an order ticket, a live positions view, and a complete trade loop. Two of the three need no credentials.

```bash
bun examples/order-ticket.ts
```

Building with an AI coding agent? [`llms.txt`](./llms.txt) is a flat, prescriptive reference written for exactly that, and ships with the package.

## Project Structure

```
src/
  client.ts          # OstiumClient — public API
  config.ts          # Named param interfaces for each mode
  types.ts           # OpenTradeParams, CloseTradeParams, etc.
  errors.ts          # OstiumError + OstiumErrorCode
  encoder.ts         # ABI encoding helpers (openTrade, closeTrade, ...)
  cli.ts             # Interactive CLI (bunx ostium)
  signer/            # SignerStrategy — Self vs Delegated wrapping
  submitter/         # SubmissionStrategy — EOA vs Pimlico UserOp
  internal/
    contracts.ts     # On-chain addresses (mainnet + testnet)
    decimal.ts       # parseUsdc, parsePrice, parseLeverage
    erc20.ts         # Minimal ERC-20 ABI
    contract.ts      # Ostium Trading ABI
    open-builder.ts  # Trade struct construction
    precision.ts     # Precision constants
    validation.ts    # Input validation helpers
  data/          # Read-only subgraph + price feed client
    client.ts        # OstiumSubgraphClient
    types.ts         # Pair, Position, Fill, OpenOrder, Order, …
    queries.ts       # GraphQL queries
    internal/        # Formatters, calculations, pagination
dist/                # Compiled output (bun run build)
```

---

## Development

```bash
bun install          # install dependencies
bun run build        # compile to dist/
bun run typecheck    # tsc --noEmit
```

---

## Modes

The four client modes differ in **who signs** and **how the transaction is submitted**.

### Signing

| Mode | Who owns USDC + positions | Who signs transactions |
|:--|:--|:--|
| **Self** | Your EOA | Your EOA |
| **Delegated** | Trader address (separate EOA) | Delegate EOA, wrapped in `delegatedAction(traderAddress, data)` |

### Submission

| Mode | How the tx is sent | Gas |
|:--|:--|:--|
| **Self** (EOA) | Standard `eth_sendRawTransaction` | ETH required per tx |
| **Gasless** | Pimlico ERC-4337 UserOperation via Safe smart account | ETH-free after one-time setup |

### The Four Combinations

| Constructor | Signer | Submitter | Notes |
|:--|:--|:--|:--|
| `createSelfAndSelf` | EOA | EOA | Simplest — one key, pays gas |
| `createSelfAndGasless` | EOA (owner) + Safe (delegate) | Pimlico UserOp | One-time setup, then free |
| `createDelegatedAndSelf` | Delegate EOA | Delegate EOA | Delegate holds ETH, trader holds USDC |
| `createDelegatedAndGasless` | Delegate EOA (owner) + Safe (delegate) | Pimlico UserOp | No ETH after trader calls `setDelegate(safeAddress)` |

```ts
import { OstiumClient, OrderType } from '@ostium/builder-sdk';

// Self + Self
const client = await OstiumClient.createSelfAndSelf({
  traderPrivateKey: '0x...',
  rpcUrl: 'https://arb-mainnet.g.alchemy.com/v2/...',
});

// Self + Gasless
const client = await OstiumClient.createSelfAndGasless({
  traderPrivateKey: '0x...',
  pimlicoUrl: 'https://builder.prod.bedrock.ostium.io/v1/sponsor?chainId=42161',
});

// Delegated + Self
const client = await OstiumClient.createDelegatedAndSelf({
  delegatePrivateKey: '0xDelegateKey',
  traderAddress: '0xTraderAddress',
  rpcUrl: 'https://arb-mainnet.g.alchemy.com/v2/...',
});

// Delegated + Gasless
const client = await OstiumClient.createDelegatedAndGasless({
  delegatePrivateKey: '0xDelegateKey',
  traderAddress: '0xTraderAddress',
  pimlicoUrl: 'https://builder.prod.bedrock.ostium.io/v1/sponsor?chainId=42161',
});
```

Construction is network-free, with one exception: the gasless factories derive the
smart-account address with a single `eth_call` the first time. Pass the known
address back as `safeAddress` (from `client.getSmartAccountAddress()`) to skip
that call and make gasless construction instant too:

```ts
const client = await OstiumClient.createSelfAndGasless({
  traderPrivateKey: '0x...',
  safeAddress: '0xKnownSmartAccountAddress', // optional — deterministic per key + chain
});
```

### Common Options

All `OstiumClient.create*` factories and `OstiumClient.createReadOnly()` accept shared read/stream options:

```ts
const client = await OstiumClient.createSelfAndSelf({
  traderPrivateKey: '0x...',
  rpcUrl: 'https://arb-mainnet.g.alchemy.com/v2/...',
  alchemyApiKey: 'alchemy-key',          // streamAccountUpdates()
  subgraphUrl: 'https://...',            // optional subgraph override
  builderApiUrl: 'https://builder.prod.bedrock.ostium.io',
  testnet: false,
});
```

`alchemyApiKey` is only required when starting `streamAccountUpdates()`. You can
also pass it per stream call to override the constructor value.

### Build Unsigned Transactions

Every write method now has a `get*Tx()` counterpart that returns transaction
data without signing or submitting it.

```ts
const client = await OstiumClient.createSelfAndSelf({
  traderAddress: '0xTraderAddress',
});

const tx = client.getOpenTradeTx({
  pairId: 0,
  buy: true,
  price: '65000',
  collateral: '100',
  leverage: '5',
  type: OrderType.Market,
});

if (tx.kind === 'eoa') {
  await window.ethereum.request({
    method: 'eth_sendTransaction',
    params: [{
      from: tx.from,
      to: tx.to,
      data: tx.data,
      value: `0x${tx.value.toString(16)}`,
    }],
  });
}
```

Gasless build-only clients return a Safe-style request instead:

```ts
const gaslessClient = await OstiumClient.createSelfAndGasless({
  traderAddress: '0xTraderAddress',
  safeAddress: '0xSafeAddress',
});

const safeTx = gaslessClient.getOpenTradeTx({ ... });
// safeTx.kind === 'safe'
// safeTx.safeAddress identifies the Safe that should execute safeTx.calls[0]
```

The existing write methods (`openTrade`, `closeTrade`, `approveUsdc`, etc.)
still work the same way, but they now submit the transaction built by the
corresponding `get*Tx()` method.

### Read-only (no private key)

Use `createReadOnly` to access market data without a signer. All read methods are available; write methods throw `INVALID_CONFIG`.

```ts
const reader = await OstiumClient.createReadOnly();
const { pairs } = await reader.getPairs();
const positions = await reader.getOpenPositions({ user: '0xTrader...' });
const balances = await reader.getBalances('0xTrader...');
```

### Stream Position Updates

If you already have an `OpenPositionsResponse`, you can stream live price-driven
updates for those positions without re-fetching the full position set.

```ts
const positions = await client.getOpenPositions();
const stream = client.streamPositionUpdates(positions);

stream.onUpdate(next => {
  console.log(next.marginSummary.totalRawPnlUsd);
  console.log(next.pairPositions[0]?.position.unrealizedPnl);
});

stream.onError(err => console.error(err));
stream.close();
```

The SDK subscribes once per unique `pairId` in the response, then updates the
full `OpenPositionsResponse` as prices change.

### Common Options

All constructors (including `createReadOnly`) accept these optional overrides:

```ts
{
  testnet?:       boolean;  // Arbitrum Sepolia when true (default false)
  subgraphUrl?:   string;   // override the default subgraph endpoint
  builderApiUrl?: string;   // override the builder API base URL (prices, OHLC, WebSocket)
}
```

Example:

```ts
const client = await OstiumClient.createSelfAndSelf({
  traderPrivateKey: '0x...',
  rpcUrl: '...',
  subgraphUrl:   'https://your-subgraph.example.com/graphql',
  builderApiUrl: 'https://your-builder-api.example.com',
});

const reader = await OstiumClient.createReadOnly({
  subgraphUrl:   'https://your-subgraph.example.com/graphql',
  builderApiUrl: 'https://your-builder-api.example.com',
});
```

### Self + Gasless: One-Time Setup

A Safe smart account is derived deterministically from your private key. You need to register it as your on-chain delegate once:

```ts
const client = await OstiumClient.createSelfAndGasless({
  traderPrivateKey: '0x...',
  pimlicoUrl: 'https://builder.prod.bedrock.ostium.io/v1/sponsor?chainId=42161',
});

console.log('EOA (trader):', client.getTraderAddress());
console.log('Safe (delegate):', client.getSmartAccountAddress());

await client.approveUsdc('max');         // EOA → TradingStorage approval (gas, once)
await client.setupGaslessDelegation();   // EOA → setDelegate(Safe) (gas, once)

// All trades from here are gasless UserOps
await client.openTrade({ ... });
```

### Delegated Modes: Setup Checklist

The trader account must perform two one-time operations before the delegate can trade:

```ts
// 1. Trader approves USDC
const traderClient = await OstiumClient.createSelfAndSelf({
  traderPrivateKey: '0xTraderKey',
  rpcUrl: '...',
});
await traderClient.approveUsdc('max');

// 2a. Delegated + Self: trader registers the delegate EOA
await traderClient.setDelegate('0xDelegateEOA');

// 2b. Delegated + Gasless: trader registers the delegate's Safe
const delegateClient = await OstiumClient.createDelegatedAndGasless({
  delegatePrivateKey: '0xDelegateKey',
  traderAddress: '0xTraderAddress',
  pimlicoUrl: '...',
});
await traderClient.setDelegate(delegateClient.getSmartAccountAddress()!);
```

---

## CLI — Environment Variable Configuration

The `ostium` CLI can load all credentials from a `.env` file (via `dotenv`). Create a `.env` file in your working directory with the variables relevant to your mode:

```dotenv
# ── Universal ─────────────────────────────────────────────────────────────────

# Self / main EOA private key (required for Self modes; also used as the main key in Test + Delegated)
PRIVATE_KEY=0x...

# Arbitrum RPC URL — required for Self+Self and Delegated+Self; optional for gasless modes
ARB_RPC_URL=https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY

# ── Delegated modes ───────────────────────────────────────────────────────────

# Delegate EOA private key (falls back to PRIVATE_KEY if not set)
DELEGATE_PRIVATE_KEY=0x...

# Trader address the delegate acts on behalf of (required for Trade + Delegated)
TRADER_ADDRESS=0x...

# ── Gasless modes ─────────────────────────────────────────────────────────────

# Pimlico sponsor URL (required for Self+Gasless and Delegated+Gasless)
PIMLICO_URL=https://builder.prod.bedrock.ostium.io/v1/sponsor?chainId=42161
```

When the CLI detects a `.env` file it skips the interactive key/URL prompts and loads all values automatically. Fields not present in the env file are prompted interactively as normal.

---

## Networks

| Network | Chain ID | `testnet` |
|:--|:-:|:-:|
| Arbitrum One | 42161 | `false` (default) |
| Arbitrum Sepolia | 421614 | `true` |

```ts
const client = await OstiumClient.createSelfAndSelf({
  traderPrivateKey: '0x...',
  rpcUrl: 'https://sepolia-rollup.arbitrum.io/rpc',
  testnet: true,
});
```

---

## API Reference

### Enums

```ts
import { OrderType, CancelOrderType } from '@ostium/builder-sdk';

OrderType.Market  // 'market'
OrderType.Limit   // 'limit'
OrderType.Stop    // 'stop'

CancelOrderType.Limit        // 'limit'
CancelOrderType.PendingOpen  // 'pendingOpen'
CancelOrderType.PendingClose // 'pendingClose'
```

### Trading Methods

All return `Promise<SubmissionResult>` as soon as the transaction is submitted (UserOp or EOA). The hash is available immediately; the SDK does not wait for receipt or parse oracle events.

```ts
interface SubmissionResult {
  txHash: `0x${string}`;
  smartAccountAddress?: `0x${string}`; // gasless modes only
}
```

Use `getOrders({ initiatedTxHashes: [result.txHash] })` to poll by submission hash, or `orderIds` / recent orders for the trader.

Submissions that target the Trading contract also report their transaction hash
to the builder API for SDK usage attribution. This is a fire-and-forget
background request — it is never awaited and never affects trading calls.

#### `openTrade(params)`

```ts
const result = await client.openTrade({
  pairId:      0,                  // trading pair (use getPairs() to list all)
  buy:         true,               // true = long, false = short
  price:       '65000',            // entry price
  collateral:  '100',              // USD; fixed minimum is MIN_OPEN_SIZE_USD ($5)
  leverage:    '10',               // multiplier; contract-side limits apply
  type:        OrderType.Market,
  takeProfit?: '70000',
  stopLoss?:   '60000',
  slippage?:   50,                 // bps, market orders only
  isDayTrade?: false,              // true when leverage > pair.overnightMaxLeverage
});
console.log(result.txHash);

// Override fee only (address from client config):
await client.openTrade({ /* ... */, builder: { feeBps: 10 } });

// Override address only (fee from client config):
await client.openTrade({ /* ... */, builder: { address: '0x...' } });
```

> **Day trades** are auto-closed before market close. Set `isDayTrade: true` when the desired leverage exceeds `pair.overnightMaxLeverage` (and `overnightMaxLeverage > 0`). Use `getPairs()` to read both thresholds per pair.

#### `closeTrade(params)`

`pairId` and `idx` come from `getOpenPositions()`:

```ts
const { pairPositions } = await client.getOpenPositions();
const { pairId, idx } = pairPositions[0].position;

await client.closeTrade({
  pairId,           // from Position.pairId
  idx,              // from Position.idx
  price: '66000',
  closePercent: 100,  // 1–100, partial closes supported
  slippage?: 50,
});
```

#### `cancelOrder(params)`

TypeScript narrows the required fields by `type`:

```ts
// Cancel a pending limit order
const [order] = await client.getOpenOrders();
await client.cancelOrder({ type: CancelOrderType.Limit, pairId: order.pairId, idx: order.idx });

// Cancel a timed-out market open (orderId from chain / indexer)
await client.cancelOrder({ type: CancelOrderType.PendingOpen, orderId: 123 });

// Cancel a timed-out market close — retry: true re-submits the close
await client.cancelOrder({ type: CancelOrderType.PendingClose, orderId: 123, retry: true });
```

#### `modifyOrder(params)`

`pairId` and `idx` come from `getOpenPositions()` (for TP/SL) or `getOpenOrders()` (for limit order price):

```ts
// Update TP on an open trade
await client.modifyOrder({ pairId, idx, takeProfit: '72000' });

// Update SL on an open trade
await client.modifyOrder({ pairId, idx, stopLoss: '61000' });

// Reprice a limit order (with optional new TP/SL)
await client.modifyOrder({ pairId, idx, price: '64000', takeProfit: '70000' });
```

Note: you cannot update both `takeProfit` and `stopLoss` in a single call without also providing `price`. Send two separate calls instead.

#### `updateCollateral(params)`

`pairId` and `idx` come from `getOpenPositions()`:

```ts
await client.updateCollateral({ pairId, idx, amount: '50' });   // add $50
await client.updateCollateral({ pairId, idx, amount: '-30' });  // remove $30
```

### USDC + Balance Helpers

```ts
const { current, required, sufficient } = await client.checkUsdcAllowance('100');
await client.approveUsdc('1000');   // approve $1000
await client.approveUsdc('max');    // approve MaxUint256

// All three values are decimal strings produced by formatUnits (e.g. "1234.56")
// Parse with Number() / parseFloat() only when you need numeric arithmetic.
const { usdc, eth, allowance } = await client.getBalances();
// In read-only mode, pass the address explicitly:
const { usdc, eth, allowance } = await client.getBalances('0xTrader...');
```

### Onboarding

#### `getOnboardingStatus(params?)`

What still stands between a trader and their first trade, with the transaction that clears each step. Replaces hand-rolling the approve / delegate / gasless-setup sequence.

```ts
const status = await client.getOnboardingStatus({ requiredUsd: '1000' });

status.ready;              // true when nothing is outstanding
status.needsApproval;      // USDC allowance below requiredUsd
status.needsDelegate;      // no delegate registered, or the wrong one
status.needsGaslessSetup;  // Self + Gasless only
status.currentDelegate;    // whatever is registered on-chain today
status.allowance;          // and usdcBalance / ethBalance

for (const step of status.steps) {
  console.log(step.description);        // render a checklist
  await wallet.sendTransaction(step.tx);
}
```

Every step must be sent from the **trader's own account** — a delegate can neither approve USDC nor register itself.

### Read Methods

All read methods are available on both `OstiumClient` and `OstiumSubgraphClient`. On `OstiumClient` the connected trader address is used by default wherever `user` is optional.

#### `getPairs(params?)`

All trading pairs with live prices, market-status flags, computed size limits, and rollover rates.

```ts
const { pairs } = await client.getPairs();
// Optional — filter to specific pairs:
const { pairs } = await client.getPairs({ pairIds: [0, 1, 4] });

// Pair fields:
// pairId, pairTo, pairFrom, category
// maxLeverage, overnightMaxLeverage
// openFee, closeFee               — bps; openFee includes configured builder fee
// minSz, maxBSz, maxSSz, minNtl
// openInterest, buyOpenInterest, sellOpenInterest, maxOpenInterest
// rolloverRate: { long, short }   — 8hr % by side
// rolloverFeePerBlock
// midPx, askPx, bidPx
// isMarketOpen, isDayTradingClosed, secondsToToggleIsDayTradingClosed
// schedule                        — market hours (see below)
```

Pair symbol names from the subgraph are normalized automatically (e.g. CL → WTI, FTSE → UK100, SPX → US500).

`schedule` describes the pair's market hours as reported by the live price feed
(omitted when the feed is unavailable):

```ts
// 24/7 markets (crypto):
// schedule → { id: 1, alwaysOpen: true }

// Scheduled markets:
// schedule → {
//   id: 3,
//   timezone: 'America/New_York',
//   openingHours: ['Mo-Th 00:00-16:59,18:00-24:00', 'Fr 00:00-16:59', 'Su 18:00-24:00'],
// }
```

#### `getAllPrices()`

Live mid/bid/ask prices for every pair, keyed by `pairId`.

```ts
const { prices } = await client.getAllPrices();
// prices['0'] → { mid: '65000', bid: '64990', ask: '65010' }
```

#### `getOpenPositions(params?)`

Open positions, margin summary, and per-position live PnL. Block number is fetched automatically on `OstiumClient`.

```ts
const { pairPositions, marginSummary, time } =
  await client.getOpenPositions();

// Each position:
const { pairId, pid, idx, side, szi, entryPx, leverage, ntl, trader,
        unrealizedPnl, returnOnEquity, liquidationPx,
        collateralUsed, cumRollover, tpPx, slPx,
        openTimestamp, isDayTrade } = pairPositions[0].position;

// marginSummary: accountValue, totalCollateralUsed, totalNtlPos, totalRawPnlUsd
// marginSummary.totalWithdrawable: max collateral removable across all positions
// time: server time (Unix ms)
```

#### `getFills(params?)`

Executed fills, newest first.

```ts
const fills = await client.getFills();
// Filter options:
const fills = await client.getFills({ pairId: 0, limit: 50 });
// All traders (no address filter):
const fills = await client.getFills({ user: 'ALL' });
```

#### `getFillsByTime(params)`

Same as `getFills` but with a time range filter (Unix milliseconds).

```ts
const fills = await client.getFillsByTime({
  startTime: Date.now() - 7 * 86_400_000,  // last 7 days
  endTime:   Date.now(),                    // optional, defaults to now
  pairId:    0,                             // optional pair filter
});
```

#### `getOpenOrders(params?)`

Active limit orders for a trader.

```ts
const orders = await client.getOpenOrders();
// Each order: pairId, pairTo, pairFrom, idx, side, limitPx, szi,
// ntl, collateralUsed, leverage, orderType, tpPx?, slPx?, timestamp
```

#### `getOrders(params?)`

Orders at any status — pending, executed, or cancelled. For fast user-facing confirmations after `openTrade` / `closeTrade`, prefer `streamAccountUpdates()`; use `getOrders()` as a lower-level lookup or fallback when you need specific order ids or submission transaction hashes.

```ts
const result = await client.openTrade({ /* … */ });

// Poll by the submission tx (matches subgraph `initiatedTx`):
const [order] = await client.getOrders({ initiatedTxHashes: [result.txHash] });
console.log(order.isPending, order.isCancelled);

// Or by on-chain order id:
const [byId] = await client.getOrders({ orderIds: [123] });

// Recent orders for the connected trader (no filter args):
const orders = await client.getOrders();

// Global orders and additional filters:
const globalOrders = await client.getOrders({ user: 'ALL' });
const builderOrders = await client.getOrders({ builder: '0xBuilder...' });
const pendingBtcOrders = await client.getOrders({
  isPending: true,
  pairIds: ['0'],
});
const cancelledBuilderOrders = await client.getOrders({
  builder: '0xBuilder...',
  isCanceled: true,
});
const recentBuilderOrders = await client.getOrders({
  builder: '0xBuilder...',
  start: Math.floor(Date.now() / 1000) - 7 * 86_400,
  end: Math.floor(Date.now() / 1000),
});
```

Each `Fill` includes `builder`, `ntl` (USD notional), `trader`, and `timestamp` (execution time, Unix seconds UTC — subgraph `executedAt`); each `Order` extends `Fill` with `initiatedTx`, `initiatedTime`, `isPending`, `isCancelled`, and optional `cancelReason`. `time` is retained as an alias for `timestamp`.

`start` / `end` are inclusive bounds on `executedAt` (matching `getFillsByTime`). Only executed orders have an execution time, so combining them with `isPending: true` matches nothing.

#### `streamAccountUpdates(params?)`

Low-latency account confirmation stream. It emits the full processed account
snapshot keyed by normalized trader address whenever pending orders, active
limit/stop orders, or open positions change:

```ts
const stream = client.streamAccountUpdates();

stream.onUpdate(snapshot => {
  const account = snapshot[client.getTraderAddress().toLowerCase()];
  console.log(account.positions); // open PairPosition[]
  console.log(account.orders);    // pending Order[]
  console.log(account.limits);    // active OpenOrder[]
  console.log(account.closeExecutions); // recent CloseExecution[]
});
stream.onError(console.error);

// Clean up:
stream.close();
```

For frontend-style latency, insert a local optimistic market open before or while
submitting, then attach the on-chain `orderId` as soon as your receipt watcher
sees the oracle request log:

```ts
import { extractOrderIdFromReceipt } from '@ostium/builder-sdk';

const localId = stream.addOptimisticOpen(openParams);
const result = await client.openTrade(openParams);

// Wherever your app waits for the EOA receipt or UserOp receipt:
const orderId = extractOrderIdFromReceipt(receipt, client.getTraderAddress());
if (orderId) {
  stream.attachOrderId(localId, orderId, { initiatedTx: result.txHash });
}
```

Optimistic stream positions include `position.confirmationStatus`
(`"optimistic"`, `"initiated"`, or `"executed"`) plus `orderId` once known.
Only `"executed"` overlays and indexed positions carry a trustworthy
`position.idx`. `"optimistic"` and `"initiated"` overlays use `idx: -1`; do not
pass those entries to `closeTradeMarket`, TP/SL updates, or collateral updates
until they are executed or indexed. Indexed positions returned by normal read
methods are unchanged.

The stream polls the account subgraph snapshot every 3 s by default (one
batched query regardless of how many traders are subscribed — override with
`pollIntervalMs`), opens a live price subscription immediately, and uses
Alchemy contract logs to show market opens and close executions before the
subgraph indexes them, so confirmations stay sub-second at any poll interval.
Full closes remove the position immediately; partial closes are reported in
`closeExecutions` and the open position is reconciled on the next poll. Open
trades are repriced on live ticks so PnL stays fresh between subgraph polls.
Configure `alchemyApiKey` on the client or override it per stream:

```ts
const stream = client.streamAccountUpdates({
  user: ['0xTraderA...', '0xTraderB...'],
  alchemyApiKey: 'override-key',
  pollIntervalMs: 1000,
});
```

Read-only clients and standalone `OstiumSubgraphClient` calls require an
explicit `user` address array:

```ts
const reader = await OstiumClient.createReadOnly({ alchemyApiKey: 'alchemy-key' });
const stream = reader.streamAccountUpdates({ user: ['0xTrader...'] });
```

#### `getBuilderOrders(builder, params?)`

Fetch the full order history for trades routed through a builder. The subgraph only stores `builder` on open orders; this method supplements those with sibling close/TP/SL orders on the same positions (`pid`).

```ts
const orders = await client.getBuilderOrders('0xBuilder...');

// Inherits getOrders filters (user, isPending, isCanceled, pairIds, start, end, limit)
const pending = await client.getBuilderOrders('0xBuilder...', {
  isPending: true,
  pairIds: ['0'],
  limit: 50, // caps phase-1 builder-tagged orders; siblings are appended without cap
});
```

Sibling orders from phase 2 have `builder` stamped to the requested address. Use `getOrders({ builder })` when you only need orders that have `builder` set on the subgraph (typically opens).

#### `previewOpenTrade(params)`

Everything an order ticket needs before a trade is signed — execution price after dynamic spread, the fee breakdown, collateral left backing the position, the resulting liquidation price, and the checks that would otherwise revert on-chain.

Derived from `@ostium/formulae`, the same math the contracts and ostium.io run, so your numbers match the official frontend. Call this before every `openTrade`.

```ts
const preview = await client.previewOpenTrade({
  pairId: 0,
  isLong: true,
  collateral: 1000,       // USDC, gross — fees come out of this
  leverage: 10,
  limitPrice: 95_000,     // optional; omit for a market order
  isDayTrade: false,      // optional; unlocks the pair's higher intraday cap
});

preview.entryPx;             // execution price after spread
preview.priceImpactP;        // spread paid, as a percentage
preview.spreadDecaySeconds;  // until the imbalance spread clears
preview.fees.total;          // openFee + oracleFee, deducted at open
preview.collateralAtOpen;    // what actually backs the position
preview.exposure;            // notional USD
preview.positionSize;        // base-asset units
preview.liquidationPx;
preview.effectiveMaxLeverage;
preview.withinExposureLimit; // undefined if the vault read failed
preview.isValid;             // false when `warnings` is non-empty
```

Warning codes: `LEVERAGE_ABOVE_MAX`, `LEVERAGE_BELOW_MIN`, `BELOW_MIN_POSITION_SIZE`, `FEES_EXCEED_COLLATERAL`, `ABOVE_EXPOSURE_LIMIT`, `MARKET_CLOSED`.

Note the shapes differ from submission: preview takes **numbers** and `isLong`; `openTrade` takes decimal **strings** and `buy`.

#### `getVaultBalance()`

Protocol vault balance in USDC — the liquidity backing trader PnL, and the cap on per-pair open interest. Cached for 60 seconds.

```ts
const balance = await client.getVaultBalance();   // 12109444.289
```

#### `getSimSlippage(params)`

Simulate price impact for a list of pairs and notionals.

```ts
const result = await client.getSimSlippage({
  pairIds: [0, 1],
  ntls: ['1000', '10000', '100000'],
});
// result['0'].long  → [{ ntl, slippage }, ...]
// result['0'].short → [{ ntl, slippage }, ...]
```

#### `getSimOrderbook(params)`

Synthetic bid/ask orderbook for a pair, following Hyperliquid L2Book format. Levels are log-spaced from the minimum order size to the remaining OI capacity on each side.

```ts
const book = await client.getSimOrderbook({ pairId: 0, levels: 20 });
// book.levels[0] → bids (short entries), best bid first
// book.levels[1] → asks (long entries), best ask first
// Each level: { px: string, sz: string, n: 1 }
```

#### `getCandles(params)`

OHLC candles from the builder API. `from`/`to` are Unix milliseconds. `pairId` is the same value returned by `getPairs()`. Pass `sets` to fetch multiple candle pages — only needed if a single response doesn't cover the requested range; the API currently returns the full allowed range in one page, so `sets > 1` is rarely required.

```ts
const candles = await client.getCandles({
  pairId: 0,
  from: Date.now() - 30 * 86_400_000,  // last 30 days
  to: Date.now(),                       // optional, defaults to now
  resolution: '1D',                     // '1' | '5' | '15' | '60' | '240' | '1D'
  sets: 3,                              // optional, defaults to 1 page
});
// Each candle: { pairFrom, pairTo, time, open, high, low, close }
// pairFrom / pairTo use normalized display names (e.g. "WTI" not "CL")
```

#### `streamPrices(pairIds?)`

WebSocket connection to the live price feed. Fires a `snapshot` on connect, then a `tick` on every update. Accepts the same `pairId` values as everywhere else in the SDK — no pair name strings needed.

```ts
const stream = client.streamPrices([0, 1]);  // BTC and ETH by pairId

stream.onOpen(() => console.log('connected'));
stream.onSnapshot(ticks => console.log('initial snapshot:', ticks.length));
stream.onTick(tick => console.log(tick.pair, tick.mid));
// tick.pair / tick.from / tick.to use normalized display names
// ticks also carry isMarketOpen / isDayTradingClosed / secondsToToggleIsDayTradingClosed,
// plus the pair's market-hours `schedule` when reported by the feed

// Dynamically add / remove by pairId:
stream.subscribe([2]);
stream.unsubscribe([0]);

// Clean up:
stream.close();
```

Requires Node.js 18+, Bun, or a browser environment.

### Getters

```ts
client.getTraderAddress();       // on-chain trader address (throws in read-only mode)
client.getSmartAccountAddress(); // Safe address — gasless modes only, else undefined
client.isReadOnly();             // true when created via createReadOnly()
```

### Error Codes

```ts
import { OstiumError, OstiumErrorCode } from '@ostium/builder-sdk';
```

| Code | When thrown |
|:--|:--|
| `INVALID_CONFIG` | Missing or malformed config (privateKey format, address format, fee range) |
| `VALIDATION_FAILED` | Invalid trade params (TP/SL direction, leverage range, close percent) |
| `ALLOWANCE_INSUFFICIENT` | USDC allowance too low for `openTrade` / `updateCollateral` |
| `DELEGATION_FAILED` | `approveUsdc()` called in delegated mode |
| `CONTRACT_ERROR` | Contract revert (e.g. `WrongLeverage`, `NoDelegate`) |
| `NETWORK_ERROR` | RPC / Pimlico connectivity issues or rate limits |
| `SUBMISSION_FAILED` | Transaction rejected for other reasons |

### Decimal Utilities

```ts
import { parseUsdc, parsePrice, parseLeverage, MIN_OPEN_SIZE_USD, MAX_COLLATERAL_USD } from '@ostium/builder-sdk';

parsePrice('65000.50');   // 65000500000000000000000n (18 decimals)
parseUsdc('100.50');      // 100500000n (6 decimals)
MIN_OPEN_SIZE_USD;        // 5
MAX_COLLATERAL_USD;       // 2_000_000
```

### Standalone Math

Pure functions over plain numbers — no client, no network. For bots, backtesters, and risk dashboards.

```ts
import { liquidationPrice, pnl } from '@ostium/builder-sdk';

liquidationPrice({
  entryPx: 100_000, isLong: true, collateral: 1000, leverage: 10, maxLeverage: 100,
  rollover: 0, funding: 0,   // optional; accrued fees move liquidation toward entry
});

pnl({ entryPx: 100_000, markPx: 105_000, isLong: true, collateral: 1000, leverage: 10 });
// → { netPnl: 500, netPnlPercent: 50, netValue: 1500 }
```

For positions you already fetched, don't recompute — `Position` carries `unrealizedPnl`, `liquidationPx` and `returnOnEquity` already.

### Standalone Subgraph Client

For read-only market data without a trading key, use `OstiumSubgraphClient` directly:

```ts
import { OstiumSubgraphClient } from '@ostium/builder-sdk';

const subgraph = await OstiumSubgraphClient.create({ testnet: false });

const { pairs }    = await subgraph.getPairs();
const { prices }   = await subgraph.getAllPrices();
const positions    = await subgraph.getOpenPositions({ user: '0xTrader...' });
const fills        = await subgraph.getFills({ user: '0xTrader...', limit: 50 });
```

`OstiumSubgraphClient.getOpenPositions` accepts an optional `blockNumber` (required for accurate PnL). On `OstiumClient` this is fetched automatically.
