Concepts
The Perpl Rust SDK (perpl-sdk) is a convenient, in-memory cache of on-chain exchange state. Rather than issuing an ad-hoc eth_call every time you need a price or an order book, you take one snapshot of the exchange at a block, then apply a continuous stream of on-chain events to keep that snapshot current. When you want to trade, you build strongly typed order requests and submit them through the exchange contract.
This page explains the model: the Chain config, the module map, the snapshot-then-stream workflow, and how an OrderRequest becomes an on-chain call. Throughout, "SDK" means perpl-sdk, "DEX" means decentralized exchange, and "RPC" means the JSON remote-procedure-call endpoint you point the SDK at.
The SDK targets Rust edition 2024 and a minimum Rust version of 1.85.0. The workspace version is 0.2.0. Local testing additionally requires the anvil binary from Foundry.
The mental model
Three moving parts, in order:
Chain— a small value object describing which deployment you are talking to (chain id, exchange address, collateral token, deploy block, and the list of perpetual markets).state::SnapshotBuilder→state::Exchange— builds the initial in-memory snapshot of exchange state at a chosen block.stream::raw— a per-block stream of raw contract events. You feed each block intoExchange::apply_eventsto keep the snapshot up to date.
From the crate's own overview:
Use
state::SnapshotBuilderto capture the initial state snapshot, thenstream::rawto catch up with recent state and keep the snapshot up to date. Usetypes::OrderRequestto prepare order requests and send them withabi::dex::Exchange::ExchangeInstance::execOrders.
Adding the dependency
The SDK is consumed by path in the reference examples (there is no published crates.io install instruction in the sources):
# Cargo.toml
[dependencies]
perpl-sdk = { path = "../dex-sdk/crates/sdk" }Build the API docs locally with:
Cargo features
Both features are enabled by default:
display
yes
Enables std::fmt::Display implementations for the state types.
testing
yes
Enables the testing module — a local testing environment with a collateral token and exchange contracts deployed.
The crate documents three current limitations: funding-events processing is a follow-up (TODO); the event stream relies on log polling (future versions may use WebSocket subscriptions or Monad execution events); and test coverage is described as below reasonable. Design around the log-polling model for now.
Chain: describing the deployment
Chain is a Clone + Debug struct with private fields and read-only getters. It carries everything the SDK needs to locate and interpret a deployment:
Getters: chain_id(), collateral_token(), deployed_at_block(), exchange(), and perpetuals() -> &[PerpetualId].
Built-in constructors
Chain::mainnet()
Chain::testnet()
chain_id
143
10143
collateral_token
0x00000000eFE302BEAA2b3e6e1b18d08D69a9012a
0xa9012a055bd4e0eDfF8Ce09f960291C09D5322dC
deployed_at_block
54773010
62953
exchange
0x34B6552d57a35a1D042CcAe1951BD1C370112a6F
0x1964C32f0bE608E7D29302AFF5E61268E72080cc
perpetuals
[1, 10, 20, 31, 40, 50]
[16, 32, 48, 64, 256]
On mainnet, SOL is perpetual 31 (SOL was relisted as perp 31), not 30. Always disambiguate the perpetual id from the market symbol.
Custom deployments
For a local anvil node or any other deployment, build the Chain yourself:
Module map
The SDK's public surface is a handful of modules declared in lib.rs:
abi
alloy::sol!-generated bindings from the JSON application binary interface (ABI): dex::Exchange, erc1967_proxy::ERC1967Proxy, errors::Exchange (errors ABI), and testing::TestToken. Also exposes pub const DEX_REVISION (from the build-time env!("DEX_REVISION")).
error
Error types: the top-level DexError; ProviderError<R> for RPC/execution failures (Fatal, InvalidRequest, NullResp, OutOfGas, Reverted, Transport, Timeout); and RevertReason<R> (Known / Generic / Unknown) for decoded reverts.
num
Fixed-point ↔ decimal conversion. A num::n converter maps on-chain integers (U256 / I256 / u64 / i64) to and from fastnum decimals using floor rounding.
state
In-memory exchange-state tracking. SnapshotBuilder captures the snapshot; Exchange is the root object giving access to Account, Perpetual, Position, Order, the L3 (level-3, per-order) OrderBook, and derived market data.
stream
Continuous per-block event streams: stream::raw (raw contract events) and stream::trade (normalized Trades aggregated from the raw stream).
types
Public data types and aliases used across the SDK (see next table).
testing (feature-gated)
Local testing environment with a collateral token and exchange contracts deployed.
Core type aliases (types)
PerpetualId
u32
Perpetual market id.
AccountId
u32
Account id.
OrderId
NonZeroU16
0 is the NULL_ORDER_ID sentinel, so a live order id is always non-zero.
RequestId
u64
Becomes the on-chain client_order_id once an order is placed.
AccountAddressOrID
—
Identify an account either by address or by id.
StateInstant
—
A (block_number, block_timestamp) point in time.
OrderSide / OrderType / RequestType / OrderRequest
—
Order-construction types (see Building and sending orders).
The num converter
Prices, sizes, leverage, and collateral are stored on-chain as scaled integers. The num::n converter translates between those integers and human-readable fastnum decimals, rounding down (floor):
You rarely call this directly for orders — OrderRequest::prepare looks up the right per-perp converters for you (see below).
The snapshot-then-stream workflow
Step 1 — build the snapshot with SnapshotBuilder
SnapshotBuilder::new(chain: &Chain, provider) starts a chainable builder. The provider is any alloy provider that is Provider + Clone. Defaults: block = latest, perpetuals = all of chain.perpetuals(), no accounts, all-positions off, and batch sizes of 1000.
Builder methods (each consumes and returns self):
.at_block(BlockId)
Pin the snapshot to a specific block. A block tag is normalized to a concrete block number first, so every fetch reads the same height.
.with_perpetuals(Vec<PerpetualId>)
Restrict which perpetual markets to fetch.
.with_accounts(Vec<AccountAddressOrID>)
Fetch these accounts' state and positions. Assumes the accounts exist. Mutually exclusive with .with_all_positions().
.with_all_positions()
Fetch all positions and their accounts (no per-account balance snapshot). Mutually exclusive with .with_accounts().
.with_orders_per_batch(usize) / .with_positions_per_batch(usize)
Multicall batch sizes (default 1000 each).
What .build() does, in order:
Normalize the block — resolve any tag to a fixed block number, producing a
StateInstant { block_number, block_timestamp }.Probe for V2 support — call
getPerpetualInfoV2; if the deployed contract predates the V2 getters it reverts on the unknown selector, and the builder falls back to the V0 getters (up-converting to the V2 shape and defaulting the missing V2 fieldsfundingSumScalingExpandpriceResiduePNSQ16to0).Fetch global params — exchange info, funding interval, minimum post / settle / recycle-fee amounts, halt flag, and account count.
Fetch per-perp state and orders — per-perp info, maker fee, taker fee, and margin fractions; active orders are read by walking the
getOrderIdIndexbitmap and issuing batchedgetOrdermulticalls, preserving first-in-first-out (FIFO) order.Fetch positions according to the account / all-positions selection.
The default batch size of 1000 is chosen against Monad's cost of 8100 gas per storage-slot access and the 30M gas limit on eth_call, with buffer. Lower it for very heavy perps if an eth_call runs out of gas.
Step 2 — stream raw events with stream::raw
stream::raw produces a strictly continuous, per-block sequence of raw contract events by polling get_logs at the provider's poll interval, starting from from.block_number().
The stream yields Result<RawBlockEvents, DexError>, one item per block.
On Monad the latest block tag corresponds to a Proposed block, which is not yet final. stream::raw therefore also reads the safe block tag and only yields a block once safe.number >= block_num, erroring with "block is not available yet" otherwise. This keeps the cache consistent with finalized state.
stream::raw is not cancellation-safe — do not drop it mid-poll inside a select! arm without understanding the consequences. The crate recommends wrapping your provider with alloy's FallbackLayer and/or RetryBackoffLayer for resilience.
Step 3 — keep the cache current with apply_events
Feed every streamed block into Exchange::apply_events:
apply_events returns Result<Option<_>, DexError>:
Ok(Some(state_events))— events applied; the returned batch is the normalized set of state-level changes.Ok(None)— this block was already applied; nothing to do.Err(e)— application error.
Once the cache is current you read live market data straight off the Exchange:
Optional — the normalized trade stream stream::trade
When you care about executions rather than raw events, layer stream::trade on top of stream::raw. It listens for MakerOrderFilled and TakerOrderFilled, batches all maker fills belonging to one taker into a single unified Trade, and normalizes the fixed-point values to decimals:
Each Trade exposes: taker_account_id, taker_side, total_size(), avg_price(), perpetual_id, taker_fee, and maker_fills: Vec<MakerFill> — where each MakerFill carries maker_account_id, maker_order_id, size, price, and fee. Like stream::raw, stream::trade is not cancellation-safe.
Building and sending orders
Order construction is fully typed. You describe intent with an OrderRequest, call .prepare(&exchange) to scale the decimal fields into the on-chain fixed-point OrderDesc, and submit the descriptors through the exchange contract.
RequestType
RequestType is a u8-repr enum that selects the operation:
0
OpenLong
Open / decrease / close / invert a long (needs sufficient collateral).
Bid
1
OpenShort
Open / decrease / close / invert a short.
Ask
2
CloseLong
Reduce-only: close all or part of an existing long.
Ask
3
CloseShort
Reduce-only: close all or part of an existing short.
Bid
4
Cancel
Cancel an existing order.
—
5
IncreasePositionCollateral
Add collateral to a position (reduce leverage / fix margin).
—
6
Change
Gas-efficient change of parameters of an existing order.
—
Helpers: RequestType::try_side() -> Option<OrderSide> (Bid for OpenLong / CloseShort, Ask for OpenShort / CloseLong, None otherwise), plus From<u8> and From<RequestType> for OrderType conversions.
OrderRequest
OrderRequest::new takes 15 arguments, in this order:
Notes on the fields:
request_idbecomes the on-chainclient_order_idonce the order is placed.priceandsizearefastnumunsigned 64-bit decimals (UD64);amount(used for collateral operations) is aUD128.The three execution flags are
post_only,fill_or_kill(FOK — fill entirely or reject), andimmediate_or_cancel(IoC — fill what crosses now, cancel the rest).max_neg_pnl_collat_bpsis expressed in basis points (bps).
Prepare → OrderDesc
.prepare(&Exchange) looks up the perpetual's price / size / leverage converters (and the collateral converter) and scales the decimal fields into the on-chain OrderDesc:
The produced OrderDesc carries the scaled fields: orderDescId, perpId, orderType (u8), orderId (0 when None), pricePNS, lotLNS, expiryBlock, postOnly, fillOrKill, immediateOrCancel, maxMatches, leverageHdths, lastExecutionBlock, amountCNS (collateral-scaled, present only when amount and a collateral converter are available), and maxNegPnlCollatBPS.
Submit through the exchange contract
The canonical send path documented on the crate is the generated binding abi::dex::Exchange::ExchangeInstance::execOrders. Construct the instance against your exchange address and a wallet-bearing provider:
Build the provider as an alloy DynProvider carrying your wallet:
The reference example programs submit their prepared descriptors via execOrders, passing the Vec<OrderDesc> and a revert-on-fail flag, then await the receipt:
The revertOnFail boolean is an all-or-nothing flag: pass true when every descriptor must succeed together (the best-bid/offer example uses true), or false for best-effort batch submission (the spread and taker examples use false).
Putting it together
A minimal read-only loop that snapshots one perpetual and then keeps its order book current:
From here, add a wallet-bearing ExchangeInstance, build OrderRequests, .prepare() them, and submit — turning the read-only cache into a trading loop.
Where to go next
perpl-cli— the same snapshot/stream engine wrapped as a command-line tool for reading and tracing exchange state (snapshot,trace,show account,show book,show trades,block <n>,tx <hash>).Example programs — a market-making bot (best-bid/offer, spread, and taker strategies) and utilities (
print_book,print_trades) demonstrate the full snapshot → stream → apply → trade lifecycle end to end.
Last updated