Trion Documentation V2 · Live on Robinhood Chain
Browse documentation

Options order-book API (V3)

In one paragraph. Options V3 is an off-chain order book settled on chain. A bot signs an OptionOrder with EIP-712, posts it to the book service under /v3/options, and the service matches it against resting orders and sends batches of fills to OptionsExchangeV3.settleFills. The contract re-verifies every signature and every limit, so the operator can only ever execute what you signed; you can withdraw an order off chain with a signed cancel, or on chain without anyone's cooperation. Everything below comes from overdrive/shared/v3/OPTIONS_V3.md, overdrive/shared/v3/IOptionsExchangeV3.sol and overdrive/exchange/core/src/v3.ts; the TypeScript definitions are exported as @trion/exchange-core/v3 and the ABI as @trion/exchange-core/v3-abi. Product rules (payout, collateral, settlement) are on the Options page.

Conventions

Signing an order

Domain

name              "TrionOptions"
version           "3"
chainId           4663 (Robinhood Chain)
verifyingContract the OptionsExchangeV3 address from GET /config

OptionOrder

Field order is the EIP-712 type string. The type hash keccak256("OptionOrder(address trader,bytes32 seriesId,uint8 side,uint128 price,uint64 quantity,uint64 nonce,uint64 expiry,uint32 accountEpoch,uint16 maxFeeBps,uint8 flags)") is 0xef522e3ee01f6446c3417f6beb7c88266abf81977a75f6310ce85bfd51cac223.

FieldTypeUnit / meaningRules
traderaddressThe signerSignatures are 65-byte ECDSA recovering trader; account code is not consulted, so an EOA with EIP-7702 delegation keeps signing with its key. EIP-1271 contract signatures are not supported
seriesIdbytes32keccak256(abi.encode(chainId, exchange, marketId, kind, strike, cap, expiry)), from GET /series or the manifestMust be listed, not halted, not settled
sideuint80 BUY, 1 SELL
priceuint128USDG atoms per contractMultiple of the series tickAtoms (1,000 = $0.001); 0 < price ≤ widthAtoms (500,000 = $0.50)
quantityuint64Whole contracts> 0; the book service accepts at most 1,000,000
nonceuint64Uniqueness onlyAny unused value; replay protection is by order hash
expiryuint64Unix seconds; the order is dead at or after itBook service: between 10 s and 90 days from now
accountEpochuint32Must equal getAccount(trader).epochadvanceEpoch() revokes every order signed under the old value
maxFeeBpsuint16Highest fee you accept, bps of premiumBook service requires ≥ max(makerFeeBps, takerFeeBps) (100 at launch); a fill whose fee for your side exceeds it is skipped
flagsuint8Bit 0 POST_ONLY (1), bit 1 REDUCE_ONLY (2), bit 2 IOC (4)Other bits invalid; POST_ONLY and IOC are exclusive

POST_ONLY and IOC are enforced by the book service: a post-only order that would cross is rejected, an IOC remainder is cancelled. REDUCE_ONLY is enforced on chain: a reduce-only buy needs a short of at least the quantity, a reduce-only sell a long of at least the quantity, measured before the fill.

Order hash

The order hash is the full EIP-712 digest (hashTypedData in viem, hashOrder(order) on the contract, hashOptionOrderV3 in core). It keys orderState, cancelOrder, the Trade/FillSkipped events and every API route. Cross-language vector: chain 4663, verifyingContract 0x000000000000000000000000000000000000dEaD, order {trader 0x00655230Bb2eFbd421B9027ab1C9c22D8673BDfC, seriesId 0x1111…11 (32 bytes of 0x11), side 1, price 70000, quantity 25, nonce 1790000000123, expiry 1790200000, accountEpoch 0, maxFeeBps 100, flags 1} hashes to 0xd41144d4d1874db9a3efa02e94a0d97662956d261cc723f65303b76fb2b95bbc.

viem example

Nothing private is imported; the key comes from the environment of the process that runs the bot.

import { createWalletClient, hashTypedData, http, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";

const API = "https://api.trion.market/v3/options";
const account = privateKeyToAccount(process.env.BOT_KEY as Hex);
const client = createWalletClient({ account, transport: http(process.env.RPC_URL) });

const config = await (await fetch(`${API}/config`)).json();       // V3ConfigView
const me = await (await fetch(`${API}/account/${account.address}`)).json(); // V3AccountView

const types = {
  OptionOrder: [
    { name: "trader", type: "address" }, { name: "seriesId", type: "bytes32" },
    { name: "side", type: "uint8" }, { name: "price", type: "uint128" },
    { name: "quantity", type: "uint64" }, { name: "nonce", type: "uint64" },
    { name: "expiry", type: "uint64" }, { name: "accountEpoch", type: "uint32" },
    { name: "maxFeeBps", type: "uint16" }, { name: "flags", type: "uint8" },
  ],
} as const;

const order = {
  trader: account.address,
  seriesId: "0x…" as Hex,              // from GET /series
  side: 1,                              // sell (write)
  price: 70_000n,                       // $0.070 per contract
  quantity: 25n,
  nonce: BigInt(Date.now()),
  expiry: BigInt(Math.floor(Date.now() / 1000) + 900),
  accountEpoch: me.epoch,
  maxFeeBps: 100,
  flags: 1,                             // POST_ONLY
} as const;

const typed = { domain: config.domain, types, primaryType: "OptionOrder", message: order } as const;
const signature = await client.signTypedData(typed);
const orderHash = hashTypedData(typed);

const wire = { ...order, price: order.price.toString(), quantity: order.quantity.toString(),
               nonce: order.nonce.toString(), expiry: order.expiry.toString() };
const response = await fetch(`${API}/orders`, {
  method: "POST", headers: { "content-type": "application/json" },
  body: JSON.stringify({ order: wire, signature }),
});

With @trion/exchange-core/v3 the same is optionOrderTypedData(chainId, exchange, order), hashOptionOrderV3(...) and orderToWire(order); orderFromWire is the strict parser the service applies to your JSON.

Routes

All shapes are the V3* interfaces in overdrive/exchange/core/src/v3.ts. Example values are illustrative.

GET /health

{ "ok": true, "chainId": 4663, "pendingFills": 0, "inFlight": 0 }: whether the service accepts orders, the chain id, the number of matched fills not yet confirmed and the number of batch transactions in flight.

GET /configV3ConfigView

{
  "chainId": 4663,
  "exchange": "0x…",
  "quote": "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
  "quoteDecimals": 6,
  "domain": { "name": "TrionOptions", "version": "3", "chainId": 4663, "verifyingContract": "0x…" },
  "makerFeeBps": 0,
  "takerFeeBps": 100,
  "tradingCutoff": 86400,
  "maxAccountCash": "5000000000",
  "maxTotalCash": "50000000000",
  "totalCash": "…",
  "paused": false,
  "matcher": "0x…",
  "pricer": "0xD4B09bC229E953E10aCCd1d5B75521033D54AA53",
  "manifest": { "chainId": 4663, "deploymentStatus": "verified", "exchange": "0x…", "series": [ "…" ] }
}

pricer is the OptionPricerV2 whose published volatility drives the displayed fair value and the market maker, or null when unavailable. manifest is the verified deployment file (parseV3OptionsDeployment shape) with every listed series, the frozen recipeHash per market and the reviewedChainHash.

GET /seriesV3SeriesView[]

[{
  "seriesId": "0x…",
  "symbol": "CMPT",
  "marketId": "0xaee1f7724c3ed0bd1db9034995237b35f87b9250b1a4a0abedacf59530c161de",
  "kind": 0,
  "strike": "2350000000000000000",
  "cap": "2850000000000000000",
  "widthAtoms": "500000",
  "tickAtoms": "1000",
  "expiry": 1793347200,
  "tradingEnd": 1793260800,
  "status": "trading",
  "payoutAtoms": null,
  "openInterest": "1200",
  "bestBid": "65000",
  "bestAsk": "75000",
  "bidSize": "10",
  "askSize": "10",
  "last": "70000",
  "volume24h": "340"
}]

strike and cap are 18-decimal WAD prices; kind is 0 call, 1 put. status is trading, closed (at or past tradingEnd), halted or settled; payoutAtoms is set once settled.

GET /book/:seriesId?depth=V3BookView (depth 1–100, default 20)

{
  "seriesId": "0x…",
  "bids": [{ "price": "65000", "quantity": "10", "orders": 1 }],
  "asks": [{ "price": "75000", "quantity": "10", "orders": 1 }, { "price": "80000", "quantity": "40", "orders": 2 }],
  "sequence": 1842,
  "updatedAt": 1790200000
}

Levels are resting, admitted orders, best price first, aggregated by price. They are not a fill guarantee: the owner may cancel, and a fill can still be skipped on chain (below). An unknown series answers 404.

GET /trades?seriesId=&limit=V3TradeView[] (limit 1–500, default 100; seriesId optional)

[{ "seriesId": "0x…", "price": "70000", "quantity": "25", "takerSide": 0, "txHash": "0x…", "settledAt": 1790200012 }]

Only receipt-confirmed trades appear, newest first.

POST /orders — body V3PostOrderRequest, response V3PostOrderResponse

Request:

{
  "order": {
    "trader": "0x00655230Bb2eFbd421B9027ab1C9c22D8673BDfC",
    "seriesId": "0x…",
    "side": 1,
    "price": "70000",
    "quantity": "25",
    "nonce": "1790000000123",
    "expiry": "1790200000",
    "accountEpoch": 0,
    "maxFeeBps": 100,
    "flags": 1
  },
  "signature": "0x…"
}

Response for an admitted order (here it rested without matching):

{ "orderHash": "0x…", "status": "open", "filled": "0", "remaining": "25", "fills": [], "reason": null }

An order that crossed returns status partially_filled or filled and one V3FillView per match with status: "pending": matched, not yet confirmed on chain. An IOC remainder is cancelled with reason: "ioc_remainder". While matching, a resting order that can no longer honour its REDUCE_ONLY flag, or whose owner can no longer fund the fill, is cancelled (reduce_only / insufficient_funds) and matching continues with the next level. A rejected order answers 400 with the reason from the admission list.

GET /orders/:orderHashV3OrderView

{
  "orderHash": "0x…",
  "order": { "trader": "0x…", "seriesId": "0x…", "side": 1, "price": "70000", "quantity": "25", "nonce": "1790000000123", "expiry": "1790200000", "accountEpoch": 0, "maxFeeBps": 100, "flags": 1 },
  "status": "partially_filled",
  "filled": "10",
  "remaining": "15",
  "reason": null,
  "createdAt": 1790199000,
  "updatedAt": 1790199005
}

status is one of open, partially_filled, filled, cancelled, expired, rejected; reason explains a cancel or rejection.

GET /orders?trader=&status=open|allV3OrderView[]

Open orders of one trader, or their full history.

POST /orders/:orderHash/cancel — body { "signature": "0x…" }

Off-chain cancel. Sign the EIP-712 type CancelOrder(bytes32 orderHash) under the same domain; the response is the updated V3OrderView. An unknown hash answers 404 not_found; a signature that does not verify against the order's trader answers 400 invalid_signature. Because only the operator's matcher can settle, removal from the book is final for anything not already matched; a match that is already in a sent batch cannot be recalled by this route (use an on-chain cancel, which makes the fill skip).

POST /cancel-all — body { "trader": "0x…", "issuedAt": "1790200000", "signature": "0x…" }

Signs CancelAll(address trader, uint64 issuedAt) and cancels every open order of trader created at or before issuedAt (unix seconds); orders created later, and fills already confirmed, are untouched, so a signed cancel-all can be kept and replayed safely. Only the signature is checked (400 invalid_signature). Responds { "cancelled": n }. The market maker sends this at start-up and shutdown.

GET /fills?trader=&limit=V3FillView[]

[{
  "id": "…",
  "seriesId": "0x…",
  "makerOrderHash": "0x…",
  "takerOrderHash": "0x…",
  "maker": "0x…",
  "taker": "0x…",
  "takerSide": 0,
  "price": "70000",
  "quantity": "25",
  "makerFee": "0",
  "takerFee": "17500",
  "status": "confirmed",
  "skipReason": null,
  "txHash": "0x…",
  "createdAt": 1790200000,
  "settledAt": 1790200012
}]

status is pending (matched, batch not yet confirmed), confirmed (in a receipt with a Trade event) or skipped (see skip reasons). Fees are per side: here the taker paid ceil(1,750,000 × 100 / 10,000) = 17,500 atoms ($0.0175) on a $1.75 premium.

GET /account/:addressV3AccountView

{
  "address": "0x…",
  "cash": "1000000000",
  "locked": "12500000",
  "free": "987500000",
  "reserved": "10767525",
  "available": "976732475",
  "epoch": 0,
  "positions": [{ "seriesId": "0x…", "position": "-25", "lockedAtoms": "12500000" }],
  "openOrders": 1,
  "pendingFills": 0
}

cash, locked and epoch are the chain's getAccount, projected forward through every pending fill; free = cash − locked; reserved is the worst case of your open orders (below); available = free − reserved is what a new order may use.

Admission reasons

POST /orders runs these checks in order and answers 400 with the first failing reason:

ReasonCheck
shutting_down (503)The service is stopping and accepts no new orders
invalid_requestBody is not valid JSON or not an object
invalid_orderorderFromWire failed: missing field, out-of-range integer, zero price or quantity, unknown flag bits, POST_ONLY together with IOC
invalid_signatureNot 65 bytes, s not low, v ∉ {27, 28}, or the recovered address is not trader
unknown_seriesNot in the manifest, or not listed on chain
series_haltedGovernance halted the series
series_closedSettled, or now ≥ tradingEnd − 30 s
invalid_priceAbove widthAtoms or not a multiple of tickAtoms
invalid_quantityAbove 1,000,000 contracts
invalid_lifetimeexpiry less than 10 s or more than 90 days after the chain clock
epoch_revokedaccountEpoch differs from the chain epoch
fee_above_maxmaxFeeBps < max(makerFeeBps, takerFeeBps)
order_knownThis hash was already submitted
order_filled / order_cancelledorderState on chain shows fills, or a cancel
reduce_onlyREDUCE_ONLY not feasible against the projected position
too_many_orders500 open orders already resting for this trader
insufficient_fundsThe reservation check below fails
pausedThe exchange is paused; no fills can settle
self_tradeThe order would cross your own resting order (at any crossed level)
post_only_would_crossPOST_ONLY order would take liquidity

Reservations

The service guarantees that an order it accepts can be settled when it fills, whatever else of yours fills first. Per series, with your projected position p (positive long, negative short), width W (500,000 atoms), and fee rate f = max(maker, taker) bps:

For every open BUY:   cost   = price × remaining + fee(price × remaining) + remaining
For every open SELL:  income = price × remaining
                      fees   = fee(price × remaining) + remaining
fee(x) = ceil(x × f / 10 000)          (+ remaining covers per-fill rounding: one atom per contract)

buyNeed  = Σ cost  − min(Σ buyQty, max(0, −p)) × W        (buying closes a short first, releasing W each)
sellNeed = max(0, Σ sellQty − max(0, p)) × W − Σ income + Σ fees   (selling closes a long first, locking W beyond it)

seriesRequirement = max(0, buyNeed, sellNeed)

Buys and sells are not summed: filling some of each only nets exposure, so the larger side bounds every mix. Collateral that a hypothetical fill would free is never counted as available elsewhere. accountRequirement is the sum over series (series are independent), and admission requires free − accountRequirement(open orders + this order) ≥ 0 after pending fills. This is seriesRequirement / accountRequirement in core; reserved on /account/:address is the current value.

Hypothetical: flat account, one resting sell of 25 CT at $0.070. income = 1,750,000, fees = 17,500 + 25, sellNeed = 25 × 500,000 − 1,750,000 + 17,525 = 10,767,525 atoms ($10.77). The account needs that much free cash for the order to be accepted; when it fills, $12.50 becomes locked and $1.75 arrives as premium.

Every 5 s the service re-reads the chain for traders with open orders: a lower accountEpoch than the chain's cancels the order (epoch_revoked), an on-chain cancel removes it (order_cancelled), a passed expiry expires it (order_expired), a halted, settled or closing series cancels it (series_closed), and if your available funds have gone negative (for example after a withdrawal) your newest orders are cancelled with insufficient_funds until they are not. GET /orders/:orderHash shows the reason.

Cancelling

MethodWhereEffect
POST /orders/:hash/cancel with CancelOrder signatureOff chainRemoves the order from the book; final for unmatched quantity. Free
POST /cancel-all with CancelAll signatureOff chainRemoves every open order created at or before issuedAt; newer orders stay
cancelOrder(order) from the trader addressOn chainMarks the hash cancelled; any later fill of it is skipped with order_cancelled, and the service removes it on its next sync
advanceEpoch() from the trader addressOn chainIncrements your epoch; every order signed with the old accountEpoch becomes unsettleable (epoch_revoked) and the service cancels them all

The on-chain paths need no cooperation from the operator and are your guaranteed exit if the service is unavailable. Letting an order reach its expiry also ends it: the service expires it, and the contract skips any fill at or after that time.

Batching, settlement latency and skip reasons

FillSkipped.reason codes, checked on chain in this order (V3_SKIP_REASONS):

CodeReasonMeaning
1order_expiredblock.timestamp ≥ order.expiry for either order
2order_cancelledEither order cancelled on chain
3epoch_revokedEither trader advanced their epoch
4series_closedSeries halted, settled or block.timestamp ≥ tradingEnd
5fee_above_maxThe maker or taker fee exceeds that order's maxFeeBps
6reduce_onlyREDUCE_ONLY buy without enough short, or sell without enough long
7buyer_fundscash + released < locked + premium + fee for the buyer
8seller_fundscash + premium < locked + added + fee for the seller

A fill applies exactly as projectFill in core: premium = price × quantity; each side pays its own fee, rounded up; the buyer's position rises (closing a short first and releasing W per closed contract), the seller's falls (closing a long first and locking W per new short contract); Trade is emitted with the taker's side. Malformed data (bad signature, off-tick price, unknown series, overfill) reverts the whole batch instead of skipping, because a correct matcher never sends it.

After a series expires, the service's keeper calls settleSeries once the fixing is final and then settleAccounts in batches of 50 accounts; both functions are public, so a bot may call them itself.

Contract calls a bot needs

IOptionsExchangeV3 (@trion/exchange-core/v3-abi): deposit(amount) / withdraw(amount) (approve USDG first; a deposit is refused while paused, above 5,000 USDG of cash per account, or when the exchange's custody, booked cash plus settlement payouts not yet converted by settleAccounts, would exceed 50,000 USDG; it also reverts unless exactly amount arrives, so fee-on-transfer tokens cannot be deposited), getAccount(address) → (cash, locked, epoch), positionOf(address, seriesId), getSeries(seriesId), orderState(orderHash) → (filled, cancelled), hashOrder(order), cancelOrder(order), advanceEpoch(), settleSeries(seriesId), settleAccounts(seriesId, accounts[]). Fair value used by the desk and the market maker is the Black-76 capped spread in @trion/exchange-core/greeks (cappedSpread, effectiveSigma), with volatility from OptionPricerV2.getVol floored at its sigmaFloor; it is a model value, never a price the venue trades at.

Source trail: overdrive/shared/v3/OPTIONS_V3.md §§3.1–3.8, §4; overdrive/shared/v3/IOptionsExchangeV3.sol (OptionOrderV3, FillV3, OptionsV3.SKIP_*, Trade, FillSkipped); overdrive/exchange/core/src/v3.ts (OPTION_ORDER_EIP712_TYPES, CANCEL_ORDER_EIP712_TYPES, CANCEL_ALL_EIP712_TYPES, hashOptionOrderV3, orderFromWire, V3_ORDER_LIMITS, V3_SKIP_REASONS, projectFill, seriesRequirement, accountRequirement, V3*View); overdrive/exchange/options-book/src/server.ts (routes, status codes, rate limit, body limit), engine/index.ts (AdmissionError reasons in check order, submit, cancel, cancelAll, faultOrders), sync.ts, relay/index.ts, keeper.ts.

Repository-owned documentation · September 2026 · Educational material, not investment advice and not an audit.