Skip to content

Developers

Everything a trader can do is a public contract call.

Chain configuration, addresses and ABIs, placing orders and reading positions with viem, the REST and WebSocket quick starts, an indexing guide and every error the contracts can return, in words.

Chain configuration

This build runs on Elysium mainnet, as a beta that settles in test collateral. A local Foundry chain is used for development and the end-to-end rehearsal of a deployment.

SettingElysium mainnetElysium testnetLocal chain
NetworkElysiumElysium TestnetElysta Local (Anvil)
Chain id99901 (0x1863d)99801 (0x185d9)31337 (0x7a69)
Gas assetHYPE (18 decimals)HYPE (18 decimals)ETH (anvil)
Public RPCpublished at launch; provider endpoint https://elysium-mainnet.g.alchemy.com/v2/{API_KEY}https://testnet-rpc.elysium.kinetiq.xyzhttp://127.0.0.1:8545
Explorerpublished at launchhttps://elysium.kinetiq.xyz/testnet-explorernone
Multicall3not declared until its deployment is verified0xcA11bde05977b3631167028862bE2a173976CA11deploy your own or read sequentially
ExecutionArbitrum Orbit (Nitro), settling to HyperEVM (999)Arbitrum Orbit (Nitro), settling to HyperEVM testnet (998)Foundry anvil, 1 s blocks
Settlement collateralElysta Test USD (tUSD), 6 decimals, public faucet (beta)Elysta Test USD (tUSD), 6 decimals, public faucettUSD, minted by the deploy script

This build is configured for chain 99901. The three chains are exported ready-made for viem and wagmi as elysiumMainnet, elysiumTestnet and elystaLocal from @elysta/protocol. elysiumMainnet carries no RPC, so pass a provider endpoint to the transport. explorerTxUrl, explorerAddressUrl and explorerBlockUrl return null on a chain without an explorer rather than a broken link.

Mainnet

Elysium mainnet's chain id (99901) and its Alchemy endpoint are published in the official documentation; the public RPC and the explorer are published at launch. Elysta's services take the RPC from configuration and check the chain id before acting, and every mainnet deploy needs an explicit confirmation. The mainnet beta settles in test collateral; real collateral is a later milestone on the roadmap. Do not hardcode unverified mainnet parameters.

Contract addresses and ABIs

Addresses come from the deployment record the deploy script writes; ABIs are generated from the compiled Solidity. Both ship in the @elysta/protocol workspace package.

No deployment is recorded for chain 99901 in this build.

Addresses, explorer links and role holders appear here once the contracts are deployed to the configured network and the deployment record is generated (pnpm abi). Nothing is shown in their place.

What @elysta/protocol exports

ModuleExports
generated/*.abi.tselystaCollateralVaultAbi, elystaMarketRegistryOracleAbi, elystaOrderBookEngineAbi, elystaPerpRiskEngineAbi, elystaKeeperOracleAdapterAbi, elystaTestUSDAbi: the ABIs as const, so viem infers every argument and return type. Regenerated by pnpm abi (scripts/gen-abi.mjs) from the Foundry build; pnpm abi:check fails CI when they drift.
deployment.tsgetDeployment(chainId), requireDeployment(chainId), deployedChainIds() and the DeploymentRecord type: addresses, admin, keeper, start block and the listed markets with their feed ids, from contracts/deployments/<chainId>.json.
chains.tselysiumMainnet, elysiumTestnet, elystaLocal, ELYSIUM_MAINNET, chainById, explorer URL helpers and setExplorerOverride.
math.tsWAD arithmetic in bigint with the contracts’ rounding: mulWad, mulWadUp, divWad, divWadUp, bps; and the risk formulas notional, initialMargin, maintenanceMargin, tradingFee, orderReservation, unrealizedPnl, positionEquity, liquidationPrice, effectiveLeverage, fundingPayment, fundingRate, markPrice, marketOrderLimit.
format.tsformatWad (truncates, never rounds up), parseWad, tokenToWad, wadToToken, formatPercent, formatBps, truncateMiddle, stepDecimals.
types.tsThe enumerations mirroring ElystaTypes.sol with their labels (Side, OrderKind, TimeInForce, OrderStatus, MarketStatus, AssetClass, CancelReason, StatusReason, PriceHealth, SessionPolicy) and the MarketConfig, Position and Order interfaces.

The package is part of this repository’s pnpm workspace and is not yet published to a registry (see SDK). To use it today, depend on it from inside the monorepo or copy the ABI modules, which have no dependencies beyond their own as const arrays.

Placing orders with viem

placeOrder takes one struct. Prices and quantities are WAD; enums are their uint8 values; order ids are uint64 bigints.

struct PlaceOrderParams {
  uint32  marketId;
  Side    side;             // 0 Buy, 1 Sell
  OrderKind kind;           // 0 Limit, 1 Market
  TimeInForce tif;          // 0 GTC, 1 IOC, 2 FOK, 3 PostOnly  (market orders: IOC or FOK only)
  uint128 price;            // WAD limit price; ignored for market orders
  uint128 qty;              // WAD quantity
  bool    reduceOnly;
  uint64  expiresAt;        // unix seconds; 0 for none
  uint128 protectionPrice;  // market orders only: worst acceptable price, 0 for the market's default impact protection
}
function placeOrder(PlaceOrderParams calldata params) external returns (uint64 orderId);
import { createWalletClient, custom } from "viem";
import {
  elysiumMainnet, elystaOrderBookEngineAbi, parseWad, requireDeployment,
  Side, OrderKind, TimeInForce,
} from "@elysta/protocol";

const { contracts } = requireDeployment(99901);
const wallet = createWalletClient({ chain: elysiumMainnet, transport: custom(window.ethereum) });
const [account] = await wallet.getAddresses();

// placeOrder(PlaceOrderParams) -> orderId. Every amount is WAD (1e18); enums are uint8.
const hash = await wallet.writeContract({
  account,
  address: contracts.ElystaOrderBookEngine,
  abi: elystaOrderBookEngineAbi,
  functionName: "placeOrder",
  args: [{
    marketId: 1,                    // BTC-USD in the launch catalogue
    side: Side.Buy,                 // 0 Buy, 1 Sell
    kind: OrderKind.Limit,          // 0 Limit, 1 Market
    tif: TimeInForce.GTC,           // 0 GTC, 1 IOC, 2 FOK, 3 PostOnly
    price: parseWad("60000.0"),     // a multiple of the tick, within priceBandBps of the index
    qty: parseWad("0.01"),          // a multiple of the lot, between min and max order qty
    reduceOnly: false,
    expiresAt: 0n,                  // unix seconds; 0 means no expiry
    protectionPrice: 0n,            // market orders only
  }],
});

What the call checks, in order

  1. The market is tradable (MarketClosed otherwise: inactive, paused, halted, outside its session or without a valid index) and, on a reduce-only market, the order is reduce-only (ReduceOnlyRequired).
  2. You have fewer than 64 open orders (TooManyOpenOrders) and any expiry is in the future (InvalidExpiry).
  3. The quantity is a lot multiple between the minimum and maximum (InvalidQuantity); the limit price is a tick multiple (InvalidPrice) inside the band around the index (PriceOutOfBand).
  4. The risk engine can reserve initial margin plus taker fee from your available collateral (InsufficientAvailable), or, for reduce-only, that you hold an opposite position at least as large (ReduceOnlyViolation).
  5. Post-only does not cross (PostOnlyWouldCross); FOK fills entirely (FillOrKillNotFilled); each fill passes position and open-interest limits (PositionLimitExceeded, OpenInterestCapExceeded).

Simulate before you sign: publicClient.simulateContract decodes a revert into its error name (ContractFunctionRevertedError.data.errorName), which the dictionary below translates. The terminal does exactly this so that a wallet never asks for a signature on a transaction that would revert.

Reading positions, balances and the book

Every figure the terminal shows is a view function. Read them directly when you need the chain's answer rather than the indexer's.

import { createPublicClient, http } from "viem";
import { elysiumMainnet, elystaPerpRiskEngineAbi, formatWad, requireDeployment } from "@elysta/protocol";

const client = createPublicClient({ chain: elysiumMainnet, transport: http("https://elysium-mainnet.g.alchemy.com/v2/<your-key>") });
const { contracts } = requireDeployment(99901);
const risk = { address: contracts.ElystaPerpRiskEngine, abi: elystaPerpRiskEngineAbi } as const;

// Which markets the account has a position in, then each position with the figures the terminal shows.
const marketIds = await client.readContract({ ...risk, functionName: "openMarketsOf", args: [account] });
for (const marketId of marketIds) {
  const [position, pnl, liquidationPrice, [mark]] = await Promise.all([
    client.readContract({ ...risk, functionName: "getPosition", args: [account, marketId] }),
    client.readContract({ ...risk, functionName: "unrealizedPnl", args: [account, marketId] }),
    client.readContract({ ...risk, functionName: "liquidationPrice", args: [account, marketId] }),
    client.readContract({ ...risk, functionName: "markPrice", args: [marketId] }),
  ]);
  // position.qty is signed int128: positive long, negative short. Prices and margin are WAD.
  console.log(marketId, formatWad(position.qty, 4), formatWad(position.avgEntryPrice), formatWad(position.margin),
    "pnl", formatWad(pnl), "mark", formatWad(mark), "liq", formatWad(liquidationPrice));
}

On Elysium testnet, Multicall3 at 0xcA11bde05977b3631167028862bE2a173976CA11 lets viem batch these reads (client.multicall); the testnet chain object declares it, so batch: true on the transport is enough. The mainnet chain object does not declare Multicall3 until its deployment there is verified, so viem reads one call at a time.

REST quick start

The API serves the indexer's projections with provenance on every value. It is not deployed for this site yet, so the samples use https://<api-host> in place of its address.

API=https://<api-host>

curl "$API/api/v1/markets"                               # every market
curl "$API/api/v1/markets/BTC-USD"                       # by symbol or id
curl "$API/api/v1/markets/BTC-USD/orderbook?levels=10"
curl "$API/api/v1/markets/BTC-USD/trades?limit=50"
curl "$API/api/v1/markets/BTC-USD/candles?interval=1h&limit=168"
curl "$API/api/v1/markets/BTC-USD/funding?limit=24"
curl "$API/api/v1/accounts/0xYourAddress"
curl "$API/api/v1/accounts/0xYourAddress/history?types=fill,funding&limit=50"
curl "$API/api/v1/protocol/status"
curl "$API/api/v1/protocol/insurance-fund"
curl "$API/api/v1/protocol/liquidations?limit=20"
curl "$API/api/v1/leaderboard?window=7d"

The full REST and WebSocket reference lists every endpoint, its parameters and response fields, the envelope, the error format and the rate limits.

WebSocket quick start

Five channels: markets, book, trades, account and protocol. Every message carries its block, transaction (when there is one), observation time and freshness.

// wss://<api-host>/api/v1/ws
{ "op": "subscribe", "channel": "markets", "marketId": 1 }
{ "op": "subscribe", "channel": "book", "marketId": 1 }
{ "op": "subscribe", "channel": "trades", "marketId": 1 }
{ "op": "subscribe", "channel": "account", "account": "0xYourAddress" }
{ "op": "subscribe", "channel": "protocol" }
{ "op": "unsubscribe", "channel": "book", "marketId": 1 }
{ "op": "ping" }

Event indexing guide

The contracts emit everything an indexer needs; the only hard parts are reorgs and honesty about gaps.

import { createPublicClient, http, parseEventLogs } from "viem";
import { elysiumMainnet, elystaOrderBookEngineAbi, elystaPerpRiskEngineAbi, requireDeployment } from "@elysta/protocol";

const client = createPublicClient({ chain: elysiumMainnet, transport: http("https://elysium-mainnet.g.alchemy.com/v2/<your-key>") });
const deployment = requireDeployment(99901);
const { contracts, startBlock } = deployment;

// Start at the deployment's first block; nothing of Elysta's exists before it.
const logs = await client.getLogs({
  address: [contracts.ElystaCollateralVault, contracts.ElystaMarketRegistryOracle, contracts.ElystaOrderBookEngine, contracts.ElystaPerpRiskEngine],
  fromBlock: BigInt(startBlock),
  toBlock: "latest",
});

// Decode by contract. Fills carry both order ids, both fees and a sequence number shared with placements.
const fills = parseEventLogs({ abi: elystaOrderBookEngineAbi, eventName: "Fill", logs });
for (const fill of fills) {
  const { marketId, makerOrderId, takerOrderId, maker, taker, takerSide, price, qty, makerFee, takerFee, seq } = fill.args;
  // (blockNumber, logIndex) orders events across the chain; seq orders them within a market.
}
const positions = parseEventLogs({ abi: elystaPerpRiskEngineAbi, eventName: "PositionChanged", logs });

What to index, from which contract

  • Order book: OrderPlaced, OrderRested, Fill, OrderCancelled, OrderFilled give the whole order lifecycle; candles and volume derive from Fill alone.
  • Risk engine: PositionChanged carries the new quantity, entry price and margin after every fill or liquidation, with the realized PnL and fee it settled; FundingSettled and FundingApplied give the funding history; Liquidated and MarginAdjusted the rest.
  • Vault: sixteen ledger events; replaying them reproduces every account’s three buckets and the fund balances, which you then reconcile against accountOf.
  • Registry: MarketAdded and the parameter events for configuration; MarketStatusChanged with its reason; IndexPriceUpdated and PriceRejected for the full oracle record.

The event specification gives every parameter, its indexed flag and topic hash, and names the table each event lands in. Enumerations arrive as uint8; their values are listed in the next section.

Enumerations

On-chain encodings, identical in ElystaTypes.sol, the ABIs, @elysta/protocol and the API.

EnumerationValuesAppears in
SettlementKind (vault `kind`)
  • 0 RealizedPnl
  • 1 Funding
  • 2 TradingFee
  • 3 LiquidationPenalty
  • 4 LiquidatorIncentive
  • 5 BadDebtCover
  • 6 MarginAdjustment
AvailableCredited, LockedCredited, LockedDebited, LockedTransferred
MarketStatus
  • 0 Inactive
  • 1 Active
  • 2 ReduceOnly
  • 3 Paused
  • 4 Halted
MarketStatusChanged previous/current
StatusReason
  • 0 Admin
  • 1 OracleStale
  • 2 OracleDeviation
  • 3 OracleRecovered
  • 4 BadDebt
  • 5 Listing
MarketStatusChanged reason
PriceHealth
  • 0 NoPrice
  • 1 Ok
  • 2 Stale
  • 3 Deviation
PriceRejected reason
AssetClass
  • 0 Crypto
  • 1 Equity
  • 2 Commodity
  • 3 Index
MarketAdded
SessionPolicy
  • 0 Continuous
  • 1 Scheduled
SessionUpdated policy
Side
  • 0 Buy
  • 1 Sell
OrderPlaced side, Fill takerSide
OrderKind
  • 0 Limit
  • 1 Market
OrderPlaced kind
TimeInForce
  • 0 GTC
  • 1 IOC
  • 2 FOK
  • 3 PostOnly
OrderPlaced tif
CancelReason
  • 0 User
  • 1 Expired
  • 2 RiskRejected
  • 3 SelfTrade
  • 4 Remainder
  • 5 Admin
OrderCancelled reason
PositionChanged.reason
  • 0 Fill
  • 1 Liquidated
  • 2 Liquidator
  • 3 Margin (reserved)
PositionChanged

Error dictionary

Every custom error in the six ABIs, with the message the terminal shows when a simulation or transaction reverts with it. Errors without a message are administrative or internal and never reach a trader through the terminal's own flows.

ErrorContractWhat it means
FaucetCooldown(uint64 availableAt)TestUSDNo trader-facing message in the dictionary.
FeedNotConfigured(bytes32 feedId)KeeperOracleAdapterNo trader-facing message in the dictionary.
FillOrKillNotFilled(uint128 remaining)OrderBookEngineThe fill-or-kill order could not be filled entirely at its limit.
FundingNotDue(uint64 nextFundingAt)PerpRiskEngineThe funding interval has not elapsed yet.
InsufficientAvailable(uint256 requested, uint256 available)CollateralVaultNot enough free collateral. Reduce the amount, cancel resting orders to release reserved margin, or deposit more.
InsufficientLocked(uint256 requested, uint256 locked)CollateralVaultThe position's locked margin is smaller than requested.
InsufficientMargin()PerpRiskEngineNot enough margin: the position would fall below its initial margin requirement.
InsufficientReserved(uint256 requested, uint256 reserved)CollateralVaultThe order's reserved margin no longer matches the ledger. Refresh and try again.
InvalidExecutionParams()MarketRegistryOracleNo trader-facing message in the dictionary.
InvalidExpiry()OrderBookEngineThe expiry must be in the future (or zero for none).
InvalidFeeParams()MarketRegistryOracleNo trader-facing message in the dictionary.
InvalidLimits()OrderBookEngineThe matching limits are invalid.
InvalidOracleParams()MarketRegistryOracleNo trader-facing message in the dictionary.
InvalidPrice()OrderBookEngineThe price must be a positive multiple of the tick size.
InvalidPrice(bytes32 feedId)KeeperOracleAdapterThe price must be a positive multiple of the tick size.
InvalidQuantity()OrderBookEngineThe quantity must be a multiple of the lot size and within the market's minimum and maximum.
InvalidRiskParams()MarketRegistryOracleNo trader-facing message in the dictionary.
InvalidSession()MarketRegistryOracleNo trader-facing message in the dictionary.
InvalidShare()CollateralVaultNo trader-facing message in the dictionary.
InvalidTimeInForce()OrderBookEngineThat time-in-force is not valid for this order kind.
LiquidationQtyTooSmall(uint256 required)PerpRiskEngineThe liquidation quantity is below the market's lot size.
MarketClosed(uint32 marketId, uint8 status)OrderBookEngineThe market is outside its trading session or not active.
MarketNotTradable(uint32 marketId)PerpRiskEngineThe market is not tradable right now: inactive, paused or halted.
NoPosition()PerpRiskEngineYou have no position in this market.
NotHaltedByOracle(uint32 marketId)MarketRegistryOracleThe market was not halted by the oracle, so it cannot be reopened this way.
NothingPending(uint32 marketId)MarketRegistryOracleThere is no pending oracle change to confirm.
NothingToSweep()CollateralVaultNo trader-facing message in the dictionary.
NotLiquidatable()PerpRiskEngineThe position's margin is above maintenance; it cannot be liquidated.
NotOrderOwner(uint64 orderId)OrderBookEngineOnly the order's owner can change it.
NotReplaceable(uint64 orderId)OrderBookEngineOnly resting limit orders can be replaced.
ObservationInFuture(bytes32 feedId, uint64 publishedAt)KeeperOracleAdapterNo trader-facing message in the dictionary.
ObservationNotMonotonic(bytes32 feedId, uint64 publishedAt, uint64 last)KeeperOracleAdapterNo trader-facing message in the dictionary.
OpenInterestCapExceeded(uint256 qty, uint256 limit)PerpRiskEngineThe market's open-interest cap would be exceeded.
OracleInvalid(uint32 marketId)PerpRiskEngineThe market has no valid index price right now (stale or deviating), so positions cannot change. Reduce-only closes may still be possible once a price is valid.
OrderEngineAlreadySet()PerpRiskEngineNo trader-facing message in the dictionary.
OrderNotExpired(uint64 orderId)OrderBookEngineThe order has not expired yet.
OrderNotOpen(uint64 orderId)OrderBookEngineThe order is no longer open.
PauseTooLong()CollateralVaultThe pause duration exceeds the protocol limit.
PositionLimitExceeded(uint256 qty, uint256 limit)PerpRiskEngineThe resulting position would exceed this market's per-account limit.
PostOnlyWouldCross()OrderBookEngineA post-only order would have crossed the spread, so it was rejected.
PriceOutOfBand(uint256 price, uint256 index)OrderBookEngineThe limit price is outside the allowed band around the mark price.
PriceOutOfRange()OrderBookEngineThe price is beyond the order book's representable range.
ReduceOnlyRequired(uint32 marketId)OrderBookEngineThe market is reduce-only: only orders that reduce your position are accepted.
ReduceOnlyViolation()PerpRiskEngineThis market or order is reduce-only: the trade would increase or flip your position.
RegistryNotSet()KeeperOracleAdapterNo trader-facing message in the dictionary.
SelfLiquidation()PerpRiskEngineYou cannot liquidate your own position.
StatusNotAllowed(uint8 status)MarketRegistryOracleThe market's status does not allow this action.
SymbolInUse(string symbol)MarketRegistryOracleA market with this symbol already exists.
TooManyOpenOrders()OrderBookEngineYou have reached the maximum number of open orders per account.
TooManyPositions()PerpRiskEngineYou already hold the maximum number of open positions (32).
UnknownMarket(uint32 marketId)MarketRegistryOracleThis market is not listed on the configured network.
UnsupportedDecimals(uint8 decimals)CollateralVaultNo trader-facing message in the dictionary.
WithdrawalsPaused(uint64 until)CollateralVaultWithdrawals are paused by the administrator for a bounded period. Trading continues; try again later.
ZeroAmount()CollateralVault, PerpRiskEngineThe amount must be greater than zero.
AccessControlBadConfirmation()OpenZeppelinCollateralVault, MarketRegistryOracle, OrderBookEngine, PerpRiskEngine, KeeperOracleAdapter, TestUSDNo trader-facing message in the dictionary.
AccessControlUnauthorizedAccount(address account, bytes32 neededRole)OpenZeppelinCollateralVault, MarketRegistryOracle, OrderBookEngine, PerpRiskEngine, KeeperOracleAdapter, TestUSDThis account lacks the role required for that action.
EnforcedPause()OpenZeppelinCollateralVault, OrderBookEngineThe contract is paused.
ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed)OpenZeppelinTestUSDThe vault is not approved to spend that much. Approve first, then deposit.
ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed)OpenZeppelinTestUSDYour token balance is too low for this amount.
ERC20InvalidApprover(address approver)OpenZeppelinTestUSDNo trader-facing message in the dictionary.
ERC20InvalidReceiver(address receiver)OpenZeppelinTestUSDNo trader-facing message in the dictionary.
ERC20InvalidSender(address sender)OpenZeppelinTestUSDNo trader-facing message in the dictionary.
ERC20InvalidSpender(address spender)OpenZeppelinTestUSDNo trader-facing message in the dictionary.
ExpectedPause()OpenZeppelinCollateralVault, OrderBookEngineNo trader-facing message in the dictionary.
ReentrancyGuardReentrantCall()OpenZeppelinCollateralVault, OrderBookEngine, PerpRiskEngineReentrant call rejected.
SafeCastOverflowedIntDowncast(uint8 bits, int256 value)OpenZeppelinPerpRiskEngineNo trader-facing message in the dictionary.
SafeCastOverflowedIntToUint(int256 value)OpenZeppelinPerpRiskEngineNo trader-facing message in the dictionary.
SafeCastOverflowedUintDowncast(uint8 bits, uint256 value)OpenZeppelinCollateralVault, PerpRiskEngineNo trader-facing message in the dictionary.
SafeCastOverflowedUintToInt(uint256 value)OpenZeppelinPerpRiskEngineNo trader-facing message in the dictionary.
SafeERC20FailedOperation(address token)OpenZeppelinCollateralVaultThe token transfer failed.

SDK, authentication and limits

What exists, what does not, and where the limits are set.

SDK
There is no published SDK yet. @elysta/protocol and @elysta/api-contracts are workspace packages in this repository; publishing a versioned TypeScript SDK is Phase 7 of the roadmap. Until then, the ABIs and zod schemas above are the stable surface.
Authentication
None. Every REST and WebSocket endpoint is public and read-only, and every write is a transaction your wallet signs. There is no API key because there is nothing an API key could authorise.
Rate limits
Set per deployment in the API’s configuration (RATE_LIMIT_PER_MINUTE, default 600 requests per minute per client address). Beyond it the API answers 429 with error code RATE_LIMITED. The WebSocket pings every WS_HEARTBEAT_SECONDS (default 20) and drops a client that misses two in a row; the current value is in the welcome message.
CORS
Browser origins are allow-listed per deployment (WEB_ORIGINS). Server-side clients are unaffected.
Versioning
All routes live under /api/v1. The response schemas are published in packages/api-contracts; a breaking change would be a new version prefix, not a silent change.

Test collateral

Everything here runs on Elysium mainnet with test collateral that has no value. Addresses change between deployments; read them from the deployment record, never from a cached copy. Elysta is not affiliated with or endorsed by Kinetiq or the Elysium network.
Elysium mainnetNo SDK published yetNo authentication