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.
| Setting | Elysium mainnet | Elysium testnet | Local chain |
|---|---|---|---|
| Network | Elysium | Elysium Testnet | Elysta Local (Anvil) |
| Chain id | 99901 (0x1863d) | 99801 (0x185d9) | 31337 (0x7a69) |
| Gas asset | HYPE (18 decimals) | HYPE (18 decimals) | ETH (anvil) |
| Public RPC | published at launch; provider endpoint https://elysium-mainnet.g.alchemy.com/v2/{API_KEY} | https://testnet-rpc.elysium.kinetiq.xyz | http://127.0.0.1:8545 |
| Explorer | published at launch | https://elysium.kinetiq.xyz/testnet-explorer | none |
| Multicall3 | not declared until its deployment is verified | 0xcA11bde05977b3631167028862bE2a173976CA11 | deploy your own or read sequentially |
| Execution | Arbitrum Orbit (Nitro), settling to HyperEVM (999) | Arbitrum Orbit (Nitro), settling to HyperEVM testnet (998) | Foundry anvil, 1 s blocks |
| Settlement collateral | Elysta Test USD (tUSD), 6 decimals, public faucet (beta) | Elysta Test USD (tUSD), 6 decimals, public faucet | tUSD, 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
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
| Module | Exports |
|---|---|
generated/*.abi.ts | elystaCollateralVaultAbi, 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.ts | getDeployment(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.ts | elysiumMainnet, elysiumTestnet, elystaLocal, ELYSIUM_MAINNET, chainById, explorer URL helpers and setExplorerOverride. |
math.ts | WAD 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.ts | formatWad (truncates, never rounds up), parseWad, tokenToWad, wadToToken, formatPercent, formatBps, truncateMiddle, stepDecimals. |
types.ts | The 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
- The market is tradable (
MarketClosedotherwise: inactive, paused, halted, outside its session or without a valid index) and, on a reduce-only market, the order is reduce-only (ReduceOnlyRequired). - You have fewer than 64 open orders (
TooManyOpenOrders) and any expiry is in the future (InvalidExpiry). - 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). - 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). - 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,OrderFilledgive the whole order lifecycle; candles and volume derive fromFillalone. - Risk engine:
PositionChangedcarries the new quantity, entry price and margin after every fill or liquidation, with the realized PnL and fee it settled;FundingSettledandFundingAppliedgive the funding history;LiquidatedandMarginAdjustedthe rest. - Vault: sixteen ledger events; replaying them reproduces every account’s three buckets and the fund balances, which you then reconcile against
accountOf. - Registry:
MarketAddedand the parameter events for configuration;MarketStatusChangedwith its reason;IndexPriceUpdatedandPriceRejectedfor 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.
| Enumeration | Values | Appears in |
|---|---|---|
SettlementKind (vault `kind`) |
| AvailableCredited, LockedCredited, LockedDebited, LockedTransferred |
MarketStatus |
| MarketStatusChanged previous/current |
StatusReason |
| MarketStatusChanged reason |
PriceHealth |
| PriceRejected reason |
AssetClass |
| MarketAdded |
SessionPolicy |
| SessionUpdated policy |
Side |
| OrderPlaced side, Fill takerSide |
OrderKind |
| OrderPlaced kind |
TimeInForce |
| OrderPlaced tif |
CancelReason |
| OrderCancelled reason |
PositionChanged.reason |
| 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.
| Error | Contract | What it means |
|---|---|---|
FaucetCooldown(uint64 availableAt) | TestUSD | No trader-facing message in the dictionary. |
FeedNotConfigured(bytes32 feedId) | KeeperOracleAdapter | No trader-facing message in the dictionary. |
FillOrKillNotFilled(uint128 remaining) | OrderBookEngine | The fill-or-kill order could not be filled entirely at its limit. |
FundingNotDue(uint64 nextFundingAt) | PerpRiskEngine | The funding interval has not elapsed yet. |
InsufficientAvailable(uint256 requested, uint256 available) | CollateralVault | Not enough free collateral. Reduce the amount, cancel resting orders to release reserved margin, or deposit more. |
InsufficientLocked(uint256 requested, uint256 locked) | CollateralVault | The position's locked margin is smaller than requested. |
InsufficientMargin() | PerpRiskEngine | Not enough margin: the position would fall below its initial margin requirement. |
InsufficientReserved(uint256 requested, uint256 reserved) | CollateralVault | The order's reserved margin no longer matches the ledger. Refresh and try again. |
InvalidExecutionParams() | MarketRegistryOracle | No trader-facing message in the dictionary. |
InvalidExpiry() | OrderBookEngine | The expiry must be in the future (or zero for none). |
InvalidFeeParams() | MarketRegistryOracle | No trader-facing message in the dictionary. |
InvalidLimits() | OrderBookEngine | The matching limits are invalid. |
InvalidOracleParams() | MarketRegistryOracle | No trader-facing message in the dictionary. |
InvalidPrice() | OrderBookEngine | The price must be a positive multiple of the tick size. |
InvalidPrice(bytes32 feedId) | KeeperOracleAdapter | The price must be a positive multiple of the tick size. |
InvalidQuantity() | OrderBookEngine | The quantity must be a multiple of the lot size and within the market's minimum and maximum. |
InvalidRiskParams() | MarketRegistryOracle | No trader-facing message in the dictionary. |
InvalidSession() | MarketRegistryOracle | No trader-facing message in the dictionary. |
InvalidShare() | CollateralVault | No trader-facing message in the dictionary. |
InvalidTimeInForce() | OrderBookEngine | That time-in-force is not valid for this order kind. |
LiquidationQtyTooSmall(uint256 required) | PerpRiskEngine | The liquidation quantity is below the market's lot size. |
MarketClosed(uint32 marketId, uint8 status) | OrderBookEngine | The market is outside its trading session or not active. |
MarketNotTradable(uint32 marketId) | PerpRiskEngine | The market is not tradable right now: inactive, paused or halted. |
NoPosition() | PerpRiskEngine | You have no position in this market. |
NotHaltedByOracle(uint32 marketId) | MarketRegistryOracle | The market was not halted by the oracle, so it cannot be reopened this way. |
NothingPending(uint32 marketId) | MarketRegistryOracle | There is no pending oracle change to confirm. |
NothingToSweep() | CollateralVault | No trader-facing message in the dictionary. |
NotLiquidatable() | PerpRiskEngine | The position's margin is above maintenance; it cannot be liquidated. |
NotOrderOwner(uint64 orderId) | OrderBookEngine | Only the order's owner can change it. |
NotReplaceable(uint64 orderId) | OrderBookEngine | Only resting limit orders can be replaced. |
ObservationInFuture(bytes32 feedId, uint64 publishedAt) | KeeperOracleAdapter | No trader-facing message in the dictionary. |
ObservationNotMonotonic(bytes32 feedId, uint64 publishedAt, uint64 last) | KeeperOracleAdapter | No trader-facing message in the dictionary. |
OpenInterestCapExceeded(uint256 qty, uint256 limit) | PerpRiskEngine | The market's open-interest cap would be exceeded. |
OracleInvalid(uint32 marketId) | PerpRiskEngine | The 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() | PerpRiskEngine | No trader-facing message in the dictionary. |
OrderNotExpired(uint64 orderId) | OrderBookEngine | The order has not expired yet. |
OrderNotOpen(uint64 orderId) | OrderBookEngine | The order is no longer open. |
PauseTooLong() | CollateralVault | The pause duration exceeds the protocol limit. |
PositionLimitExceeded(uint256 qty, uint256 limit) | PerpRiskEngine | The resulting position would exceed this market's per-account limit. |
PostOnlyWouldCross() | OrderBookEngine | A post-only order would have crossed the spread, so it was rejected. |
PriceOutOfBand(uint256 price, uint256 index) | OrderBookEngine | The limit price is outside the allowed band around the mark price. |
PriceOutOfRange() | OrderBookEngine | The price is beyond the order book's representable range. |
ReduceOnlyRequired(uint32 marketId) | OrderBookEngine | The market is reduce-only: only orders that reduce your position are accepted. |
ReduceOnlyViolation() | PerpRiskEngine | This market or order is reduce-only: the trade would increase or flip your position. |
RegistryNotSet() | KeeperOracleAdapter | No trader-facing message in the dictionary. |
SelfLiquidation() | PerpRiskEngine | You cannot liquidate your own position. |
StatusNotAllowed(uint8 status) | MarketRegistryOracle | The market's status does not allow this action. |
SymbolInUse(string symbol) | MarketRegistryOracle | A market with this symbol already exists. |
TooManyOpenOrders() | OrderBookEngine | You have reached the maximum number of open orders per account. |
TooManyPositions() | PerpRiskEngine | You already hold the maximum number of open positions (32). |
UnknownMarket(uint32 marketId) | MarketRegistryOracle | This market is not listed on the configured network. |
UnsupportedDecimals(uint8 decimals) | CollateralVault | No trader-facing message in the dictionary. |
WithdrawalsPaused(uint64 until) | CollateralVault | Withdrawals are paused by the administrator for a bounded period. Trading continues; try again later. |
ZeroAmount() | CollateralVault, PerpRiskEngine | The amount must be greater than zero. |
AccessControlBadConfirmation() | CollateralVault, MarketRegistryOracle, OrderBookEngine, PerpRiskEngine, KeeperOracleAdapter, TestUSD | No trader-facing message in the dictionary. |
AccessControlUnauthorizedAccount(address account, bytes32 neededRole) | CollateralVault, MarketRegistryOracle, OrderBookEngine, PerpRiskEngine, KeeperOracleAdapter, TestUSD | This account lacks the role required for that action. |
EnforcedPause() | CollateralVault, OrderBookEngine | The contract is paused. |
ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed) | TestUSD | The vault is not approved to spend that much. Approve first, then deposit. |
ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed) | TestUSD | Your token balance is too low for this amount. |
ERC20InvalidApprover(address approver) | TestUSD | No trader-facing message in the dictionary. |
ERC20InvalidReceiver(address receiver) | TestUSD | No trader-facing message in the dictionary. |
ERC20InvalidSender(address sender) | TestUSD | No trader-facing message in the dictionary. |
ERC20InvalidSpender(address spender) | TestUSD | No trader-facing message in the dictionary. |
ExpectedPause() | CollateralVault, OrderBookEngine | No trader-facing message in the dictionary. |
ReentrancyGuardReentrantCall() | CollateralVault, OrderBookEngine, PerpRiskEngine | Reentrant call rejected. |
SafeCastOverflowedIntDowncast(uint8 bits, int256 value) | PerpRiskEngine | No trader-facing message in the dictionary. |
SafeCastOverflowedIntToUint(int256 value) | PerpRiskEngine | No trader-facing message in the dictionary. |
SafeCastOverflowedUintDowncast(uint8 bits, uint256 value) | CollateralVault, PerpRiskEngine | No trader-facing message in the dictionary. |
SafeCastOverflowedUintToInt(uint256 value) | PerpRiskEngine | No trader-facing message in the dictionary. |
SafeERC20FailedOperation(address token) | CollateralVault | The 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/protocoland@elysta/api-contractsare 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 answers429with error codeRATE_LIMITED. The WebSocket pings everyWS_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 inpackages/api-contracts; a breaking change would be a new version prefix, not a silent change.
Test collateral