API Reference
Complete reference for the PerpCity Rust SDK -- client, trading, liquidations, maker equity, math, and HFT infrastructure.
Complete reference for the PerpCity Rust SDK (v0.3.0).
Each market is its own Perp contract, identified by its address. A PerpClient is constructed per market, so trading and read methods take no market parameter.
Prelude
use perpcity_sdk::prelude::*; imports the everyday surface in one line: the client and transport, well-known chain ids/addresses, errors, gas/urgency, feeds, client-facing params/result types, maker-equity types, liquidity sizing, and tick/price conversion. Every item is also available individually at the crate root.
PerpClient
The main client for interacting with a PerpCity market.
use perpcity_sdk::prelude::*;
let transport = HftTransport::new(
TransportConfig::builder()
.shared_endpoint("https://arb-mainnet.g.alchemy.com/v2/KEY")
.read_endpoint("https://arbitrum-one-rpc.publicnode.com")
.build()?,
)?;
let deployments = Deployments {
perp: "0x...".parse()?, // the market's Perp contract
usdc: ARBITRUM_USDC,
pool_manager: ARBITRUM_POOL_MANAGER,
};
let client = PerpClient::new_arbitrum(transport, signer, deployments)?;
client.sync_nonce().await?;
client.refresh_gas().await?;Initialization
| Method | Returns | Description |
|---|---|---|
new(transport, signer, deployments, chain_id) | Result<Self> | Create client with explicit chain ID |
new_arbitrum(transport, signer, deployments) | Result<Self> | Create client for Arbitrum One mainnet |
new_arbitrum_sepolia(transport, signer, deployments) | Result<Self> | Create client for Arbitrum Sepolia testnet |
sync_nonce() | Result<()> | Initialize lock-free nonce tracking from on-chain state |
refresh_gas() | Result<()> | Update EIP-1559 fee cache from latest block header |
ensure_approval(min_amount) | Result<Option<B256>> | Check/set USDC spending approval |
The client accepts any alloy transaction signer (S: TxSigner<Signature>), not just PrivateKeySigner -- with the crate's aws feature enabled, pass an alloy::signers::aws::AwsSigner to sign with an AWS KMS key that never leaves AWS.
Accessors
| Method | Returns | Description |
|---|---|---|
address() | Address | Signer's wallet address |
deployments() | &Deployments | Contract deployment addresses |
provider() | &RootProvider<Ethereum> | Underlying Alloy provider |
wallet() | &EthereumWallet | Signing wallet |
transport() | &HftTransport | Transport for health diagnostics |
base_fee() | Option<u64> | Current cached base fee (ignores TTL) |
set_base_fee(base_fee) | -- | Inject a base fee from an external source (e.g. a shared poller) |
set_gas_ttl(ttl_ms) | -- | Override the gas cache TTL for externally-managed clients |
Trading Functions
Sign conventions used throughout: perp_delta positive = long / buy, negative = short / sell; margin_delta positive = deposit, negative = withdraw; amt1_limit is slippage protection in USDC terms, 0 = no limit.
open_taker
Opens a leveraged position. Direction and size come from the signed perp_delta.
let result = client.open_taker(&OpenTakerParams {
margin: 100.0, // 100 USDC
perp_delta: 1.0, // +1 perp of exposure (positive = long)
amt1_limit: 0,
}, Urgency::Normal).await?;
println!("Position ID: {}", result.pos_id);
println!("Entry deltas: perp={}, usd={}", result.perp_delta, result.usd_delta);Returns: Result<OpenResult> -- tx_hash, pos_id, perp_delta, usd_delta (realized swap amounts decoded from the TakerOpened event; no follow-up RPC read needed)
For latency-sensitive paths, open_taker_exact(&ExactOpenTakerParams, urgency) takes exact wire units (margin: u128 atoms, perp_delta: i128 atoms) and skips float conversion.
open_maker
Opens a liquidity provider (maker) position. Price range is aligned to tick spacing automatically.
let result = client.open_maker(&OpenMakerParams {
margin: 10000.0,
price_lower: 2900.0,
price_upper: 3100.0,
liquidity: 1_000_000,
max_amt0_in: 1_000_000,
max_amt1_in: 1_000_000,
}, Urgency::Normal).await?;Returns: Result<OpenResult> -- maker opens emit no swap, so perp_delta/usd_delta are 0.0.
adjust_taker / close_taker
adjust_taker combines margin and notional adjustment in a single call. Closing is a full reversal of the position's current delta -- close_taker is the convenience wrapper.
// Adjust: withdraw 20 USDC of margin, add +0.5 perp of exposure
let result = client.adjust_taker(&AdjustTakerParams {
pos_id,
margin_delta: -20.0,
perp_delta: 0.5,
amt1_limit: 0,
}, Urgency::Normal).await?;
// Close: pass the position's CURRENT perp delta; the SDK sends the reversal
let result = client.close_taker(pos_id, current_perp_delta, Urgency::Normal).await?;Returns: Result<AdjustTakerResult> -- tx_hash, plus realized perp_delta/usd_delta from the adjust swap (0.0 for a margin-only adjust). adjust_taker_exact(&ExactAdjustTakerParams, urgency) is the exact-wire-unit twin.
adjust_maker / close_maker
// Adjust margin and/or liquidity
let result = client.adjust_maker(&AdjustMakerParams {
pos_id,
margin_delta: 50.0, // positive = deposit
liquidity_delta: 0, // positive = add, negative = remove
amt0_limit: 0,
amt1_limit: 0,
}, Urgency::Normal).await?;
// Close: pass the position's CURRENT liquidity; the SDK removes all of it
let result = client.close_maker(pos_id, current_liquidity, Urgency::Normal).await?;Returns: Result<AdjustMakerResult> -- tx_hash only (maker adjust events are parameterless; read position state via view functions if needed).
transfer_eth / transfer_usdc
Transfers routed through the transaction pipeline for correct nonce management. transfer_usdc takes human units (100.0 = 100 USDC).
let tx_hash = client.transfer_eth(recipient, amount_wei, Urgency::Normal).await?;
let tx_hash = client.transfer_usdc(recipient, 100.0, Urgency::Normal).await?;Liquidations
Liquidations are permissionless: any account can liquidate an unhealthy position and collect the liquidation fee. The deployed contracts always liquidate the full position.
// Scanner probe: is this position liquidatable right now? (read-only eth_call)
if client.simulate_liquidate_maker(pos_id, fee_recipient).await.is_ok() {
// Send for real
let receipt = client.liquidate_maker(pos_id, fee_recipient, Urgency::Critical).await?;
}| Method | Description |
|---|---|
simulate_liquidate_maker(pos_id, fee_recipient) | Health probe via eth_call -- for batch scanning |
liquidate_maker(pos_id, fee_recipient, urgency) | Liquidate a maker position |
simulate_liquidate_taker(pos_id, fee_recipient) | Taker-side probe |
liquidate_taker(pos_id, fee_recipient, urgency) | Liquidate a taker position |
Sends use the fixed GasLimits::LIQUIDATE bound (3,000,000) instead of estimating -- Arbitrum liquidations have gone out-of-gas where the estimate passed. The zero address is rejected as a fee recipient (it would burn the reward).
Safe to call directly in the liquidation race -- no prior simulate needed: every send preflights via eth_call before broadcast, so a position that turns out healthy (or was already liquidated by a competitor) surfaces as a decoded TransactionError::SimulationReverted without burning gas. Triage the typed revert:
use perpcity_sdk::{Perp, PerpCityError, TransactionError};
match client.liquidate_maker(pos_id, me, Urgency::Critical).await {
Ok(receipt) => println!("liquidated: {}", receipt.transaction_hash),
Err(PerpCityError::Transaction(e)) if e.is_revert::<Perp::NotLiquidatable>() => {
// healthy right now -- retry later
}
Err(PerpCityError::Transaction(e)) if e.is_revert::<Perp::NonMakerPosition>() => {
// wrong book -- drop the id (or route to liquidate_taker)
}
Err(e) if e.is_transient() => { /* transport failure -- keep going */ }
Err(e) => return Err(e),
}Maker Equity
get_maker_equities computes, off-chain and pinned to one block, exactly what the contract would settle for each open maker position if it were touched now -- margin, accrued range funding, utilization earnings, uncollected Uniswap V4 LP fees, and inventory PnL. The math is validated end-to-end against a real on-chain liquidation settle.
for outcome in client.get_maker_equities(&pos_ids).await? {
match outcome.kind {
MakerEquityKind::Computed(b) => {
println!("pos {}: equity {:.6} (accrued {:+.6})",
outcome.pos_id, b.equity(), b.accrued_income());
if b.is_liquidatable(config.fees.liquidation_fee) {
// screening gate -- confirm with simulate_liquidate_maker
}
}
MakerEquityKind::NotAMaker => { /* taker, burned, or never minted */ }
MakerEquityKind::Failed(e) => {
// this one position's reads failed; the rest of the batch is fine.
// retry exactly when e.is_transient()
}
}
}Every input id comes back as exactly one MakerEquityOutcome, in input order. Inputs above MAX_MAKER_EQUITY_BATCH (500) are chunked internally, every chunk pinned to the same block. Reads are batched (multicalls + one PoolManager extsload + one eth_getProof, with an eth_getStorageAt fallback), and failures degrade per position rather than failing the batch.
Conventions:
- Atoms vs USD. The breakdown's primary representation is exact signed 6-decimal USDC atoms (the units the contract settles in) via
*_atoms()getters;*_usd()accessors andequity(),settled_margin(),accrued_income()convert tof64at the display boundary. Do accounting in atoms; print in USD. - Funding sign.
funding_owed_atoms()is what the position OWES since its last settle: positive = the position pays. Earnings components (utilization, LP fees) are positive when the position receives. - Health is per position. The contract liquidates when
(equity − posVal·liqFee) / posValdrops under the ratio stored on THAT position -- not a market-wide ratio, and not relative to margin. The breakdown carries both sides:position_value_usd(),liq_margin_ratio(),margin_ratio(), andis_liquidatable(liquidation_fee)as a screening gate. The contract stays the oracle: confirm withsimulate_liquidate_makerbefore sending. - Pinning is automatic. Every read in a batch -- including the mark that prices
valPnl-- comes from one reorg-safe block a few blocks behind the head.get_maker_equities_at_mark(pos_ids, mark_price_x96)exists for what-if pricing at a caller-chosen X96 mark over the same pinned state.
The pure settlement math lives in math::maker_equity (MakerMarketSnapshot, AccruedMakerSnapshot, MakerState, MakerEquityBreakdown) and can be driven directly with pre-fetched inputs.
Market Data
Multicall3 Batching
The SDK uses Multicall3 to bundle multiple contract reads into a single eth_call. The RPC provider charges 1 compute unit (CU) regardless of how many sub-calls the multicall executes internally.
| Approach | HTTP requests | Provider CUs |
|---|---|---|
| Sequential reads | N | N |
| JSON-RPC batch | 1 | N |
| Multicall3 | 1 | 1 |
get_perp_snapshot
Fetch perp config and live market data in 2 multicalls (2 CUs instead of 5+). Phase 1 multicalls config + mark + funding + open interest against the Perp contract (1 CU). Phase 2 fetches the index price from the oracle beacon (1 CU).
let (config, snapshot) = client.get_perp_snapshot().await?;
// Static config
println!("Beacon: {:?}", config.beacon);
println!("Max leverage: {:.0}x", config.bounds.max_taker_leverage);
// Live market data
println!("Mark: {:.2}, Index: {:.2}", snapshot.mark_price, snapshot.index_price);
println!("Funding: {:.4}/day", snapshot.funding_rate_daily);
println!("OI -- long: {:.2}, short: {:.2}", snapshot.open_interest.long_oi, snapshot.open_interest.short_oi);Returns: Result<(PerpData, PerpSnapshot)>
get_balances / get_balances_batch
Fetch USDC and ETH balances via Multicall3 (1 CU instead of 2N).
let (usdc, eth) = client.get_balances(address).await?;
let balances = client.get_balances_batch(&[addr1, addr2, addr3]).await?;Individual reads
let config = client.get_perp_config().await?; // bounds, fees, beacon
let mark = client.get_mark_price().await?; // f64 price
let funding = client.get_funding_rate().await?; // daily rate
let oi = client.get_open_interest().await?; // long/short OI
let index = client.get_index_price(beacon).await?; // oracle index price
let pos = client.get_position(pos_id).await?; // raw on-chain Position
let ids = client.get_positions_by_owner(owner).await?; // position NFT scan
let balance = client.get_usdc_balance().await?; // wallet USDCLocal Swap Simulation (Quotes)
Taker quotes run locally against a block-pinned snapshot of the order book -- contract-exact Q64.96 integer math with Solidity-compatible rounding, no RPC on the quote path.
// One-shot: load the book at a reorg-safe block
let book = client.load_taker_market_snapshot().await?;
// Quote +1 perp of exposure (positive = exact-output buy)
let quote = book.quote_perp(1_000_000)?; // i128 perp atoms
println!("fills {} perp for {} usd", quote.perp_delta, quote.usd_delta);
// Or quote toward a target price under constraints
let quote = book.quote_to_price(target_sqrt_price_x96, QuoteConstraints::default())?;
// Slippage limit for the real send, derived from the quote
let params = ExactOpenTakerParams {
margin: 100_000_000,
perp_delta: quote.perp_delta,
amt1_limit: quote.amt1_limit(50), // 50 bps of slippage
};QuoteLimit on the returned TakerQuote says what stopped the quote: Filled, TargetPrice, PriceImpact, TerminalPrice, MaxPerp, or InsufficientLiquidity.
For a continuously-updated book, LiveTakerMarket::subscribe(client, &ws) maintains the snapshot from WebSocket events -- latest() returns the current snapshot, changes() a watch::Receiver for reacting to updates.
Cache Management
| Method | Description |
|---|---|
invalidate_fast_cache() | Clear prices, funding, balance (2s TTL layer) |
invalidate_all_cache() | Clear all cached state |
resolve_tx(tx_hash) | Remove a transaction from in-flight tracking |
fail_tx(tx_hash) | Release nonce on transaction failure |
in_flight_count() | Number of unconfirmed transactions |
Calculation Functions
Pure math -- no network required. Tick and liquidity functions are re-exported at the crate root (and in the prelude).
Position Math
| Function | Returns | Description |
|---|---|---|
math::position::entry_price(perp_delta, usd_delta) | f64 | Entry price from raw deltas |
math::position::position_size(perp_delta) | f64 | Position size in base asset |
math::position::position_value(perp_delta, mark_price) | f64 | Notional value at mark price |
math::position::leverage(position_value, effective_margin) | f64 | Current leverage ratio |
math::position::liquidation_price(perp_delta, usd_delta, margin, liq_ratio, is_long) | Option<f64> | Liquidation price |
Tick/Price Conversions
| Function | Returns | Description |
|---|---|---|
tick_to_price(tick) | Result<f64> | Tick to price (1.0001^tick) |
price_to_tick(price) | Result<i32> | Price to Uniswap tick |
get_sqrt_ratio_at_tick(tick) | Result<U256> | sqrtPriceX96 for a tick |
get_tick_at_sqrt_ratio(sqrt) | Result<i32> | Tick for a sqrtPriceX96 |
align_tick_down(tick, spacing) | i32 | Round tick down to spacing |
align_tick_up(tick, spacing) | i32 | Round tick up to spacing |
Liquidity
| Function | Returns | Description |
|---|---|---|
estimate_liquidity(tick_lower, tick_upper, usd_amount_scaled) | Result<U256> | Liquidity to deploy a USD amount across a range |
liquidity_for_target_ratio(margin_scaled, tick_lower, tick_upper, sqrt_price, target_ratio) | Result<u128> | Liquidity for a target margin ratio |
Other Conversions
| Function | Returns | Description |
|---|---|---|
convert::price_to_sqrt_price_x96(price) | Result<U256> | Price to sqrtPriceX96 |
convert::sqrt_price_x96_to_price(sqrt) | Result<f64> | sqrtPriceX96 to decimal price |
convert::scale_to_6dec(amount) | Result<i128> | Scale f64 to 6-decimal USDC format |
convert::scale_from_6dec(value) | f64 | Scale from 6-decimal format |
convert::unpack_balance_delta(delta) | (i128, i128) | Unpack a packed BalanceDelta into (amount0, amount1) |
HFT Infrastructure
Urgency Levels
Controls EIP-1559 fee scaling for transaction priority (max_fee = N·base + M·priority).
| Level | Max Fee | Priority Fee | Use Case |
|---|---|---|---|
Low | base + priority | 1x | Non-urgent sends |
Normal | 2·base + priority | 1x | Standard trading (default) |
High | 3·base + 2·priority | 2x | Time-sensitive trades |
Critical | 4·base + 5·priority | 5x | Liquidation racing / defense |
Pre-flight Simulation and Gas Estimation
Every send is simulated before broadcast. With no explicit gas limit (the default), eth_estimateGas provides both the limit and the simulation on a cache miss; on a cache hit, an eth_call preflight verifies the transaction is still valid at the cached limit. With an explicit gas limit (TxBuilder::with_gas_limit, or the liquidation methods' pinned bound), only estimation is skipped -- the eth_call preflight still runs, so a would-be revert decodes into TransactionError::SimulationReverted instead of a mined failure.
Estimate cache behavior:
- Keyed by the 4-byte function selector. TTL: 1 hour (configurable). Buffer: 20% above the raw estimate.
- A cached estimate that has gone too small fails the capped preflight, is evicted, and re-estimates automatically.
The GasLimits struct provides reference values usable as explicit overrides:
| Operation | Reference Limit |
|---|---|
ETH_TRANSFER | 21,000 (L1 intrinsic; actual transfers estimate dynamically) |
APPROVE | 60,000 |
OPEN_TAKER | 700,000 |
OPEN_MAKER | 800,000 |
CLOSE_POSITION | 600,000 |
ADJUST_NOTIONAL | 500,000 |
ADJUST_MARGIN | 500,000 |
LIQUIDATE | 3,000,000 (pinned on liquidation sends) |
TRANSFER | 65,000 |
TxBuilder
Custom transactions route through the same pipeline via the builder:
let receipt = client
.tx(target, calldata)
.with_value(0)
.with_gas_limit(500_000) // optional; still preflighted
.with_urgency(Urgency::High)
.send()
.await?;Transport Configuration
Endpoints are organized into three pools; every request is classified read/write and routed accordingly, falling back to the shared pool when a dedicated pool is unhealthy.
use perpcity_sdk::transport::config::Strategy;
use std::time::Duration;
let transport = HftTransport::new(
TransportConfig::builder()
.shared_endpoint("https://arb-mainnet.g.alchemy.com/v2/KEY") // writes + read fallback
.read_endpoint("https://arbitrum-one-rpc.publicnode.com") // dedicated reads (free)
.strategy(Strategy::LatencyBased)
.request_timeout(Duration::from_secs(2))
.build()?,
)?;| Pool | Builder method | Purpose |
|---|---|---|
| Shared | .shared_endpoint() | Handles any request, fallback for read and write pools |
| Read | .read_endpoint() | Dedicated to reads (eth_call, eth_getBalance, etc.) |
| Write | .write_endpoint() | Dedicated to writes (eth_sendRawTransaction) |
| Strategy | Description |
|---|---|
RoundRobin | Cycle through endpoints |
LatencyBased | Route to lowest-latency healthy endpoint (default) |
Hedged { fan_out } | Fan out to N endpoints, take fastest response |
Circuit Breaker
use perpcity_sdk::transport::config::CircuitBreakerConfig;
let transport = HftTransport::new(
TransportConfig::builder()
.shared_endpoint("https://primary-rpc.com")
.circuit_breaker(CircuitBreakerConfig {
failure_threshold: 3,
recovery_timeout: Duration::from_secs(30),
half_open_max_requests: 1,
})
.build()?,
)?;| Parameter | Default | Description |
|---|---|---|
failure_threshold | 3 | Consecutive failures before circuit opens |
recovery_timeout | 30s | Time in open state before a half-open probe |
half_open_max_requests | 1 | Max concurrent requests during half-open probing |
Retry Policy
Reads and writes have separate retry configurations (read_retry(ReadRetryConfig { .. }), write_retry(WriteRetryConfig { .. })). Both retry on transport errors and timeouts; writes additionally retry on pre-mempool RPC rejections (any error response to eth_sendRawTransaction means the transaction never entered the mempool, so resending is safe).
WebSocket Event Streaming
MarketFeed streams typed market events for one perp; BlockHeaderFeed streams block headers (e.g. to drive refresh_gas).
use perpcity_sdk::{MarketFeed, MarketEvent};
use perpcity_sdk::transport::ws::{WsManager, ReconnectConfig};
let ws = WsManager::connect("wss://arbitrum-one-rpc.publicnode.com", ReconnectConfig::default()).await?;
let mut feed = MarketFeed::subscribe(&ws, perp_address, beacon_address).await?;
while let Some(event) = feed.next().await {
match event {
MarketEvent::TakerOpened { pos_id, .. } => { /* ... */ }
MarketEvent::TakerClosed { pos_id, settle, .. } => { /* ... */ }
MarketEvent::MakerClosed { pos_id, settle, .. } => { /* ... */ }
_ => {}
}
}MarketEvent covers the full per-market event set: position lifecycle on both sides (TakerOpened/TakerAdjusted/TakerClosed/TakerLiquidated/TakerBackstopped, MakerOpened/MakerAdjusted/MakerClosed/MakerConverted/MakerLiquidated/MakerBackstopped) and market-level updates (CapacityUpdated, OpenInterestUpdated, CumulativesAccrued, RatesAndEmasRefreshed, TicksCrossed, tick init/delete, Donated, BadDebtAccounted, oracle index updates). Maker settle events carry a MakerSettle with the funding/utilization/LP-fee breakdown. decode_log(&log) decodes a raw log into a MarketEvent -- both the current and the previously-deployed event shapes are recognized, so historical logs from either era decode correctly.
Funding rate changes are not emitted as events -- poll get_funding_rate(). The shared poller pattern (call once, broadcast to all agents) is the recommended approach for multi-agent systems.
Position Manager
Track positions with automated stop-loss, take-profit, and trailing stop triggers.
use perpcity_sdk::hft::position_manager::{ManagedPosition, PositionManager, TriggerType};
let mut pm = PositionManager::new();
pm.track(ManagedPosition {
perp_id: perp_key, // [u8; 32] key, e.g. the Perp address as a word
position_id: 42,
is_long: true,
entry_price: 3000.0,
margin: 1000.0,
stop_loss: Some(2800.0),
take_profit: Some(3500.0),
trailing_stop_pct: Some(0.05),
trailing_stop_anchor: None,
});
let actions = pm.check_triggers(current_price);Trigger priority (when multiple fire simultaneously): stop-loss, then take-profit, then trailing stop.
Types
Core
pub struct Deployments {
pub perp: Address, // the market's Perp contract
pub usdc: Address,
pub pool_manager: Address, // Uniswap V4 PoolManager
}Well-known addresses and chain ids are exported as constants: ARBITRUM_CHAIN_ID, ARBITRUM_USDC, ARBITRUM_POOL_MANAGER, and the ARBITRUM_SEPOLIA_* equivalents.
Market Data
pub struct PerpData {
pub perp: Address, // market identifier
pub tick_spacing: i32,
pub mark: f64,
pub beacon: Address,
pub bounds: Bounds, // min_margin, min/max_taker_leverage, liquidation_taker_ratio
pub fees: Fees, // creator_fee, insurance_fee, lp_fee, liquidation_fee
}
pub struct PerpSnapshot {
pub mark_price: f64,
pub index_price: f64,
pub funding_rate_daily: f64,
pub open_interest: OpenInterest, // long_oi, short_oi
}Parameters and Results
pub struct OpenTakerParams {
pub margin: f64, // USDC
pub perp_delta: f64, // positive = long, negative = short
pub amt1_limit: u128, // slippage limit, 0 = no limit
}
pub struct AdjustTakerParams {
pub pos_id: U256,
pub margin_delta: f64, // positive = deposit, negative = withdraw
pub perp_delta: f64, // 0.0 for margin-only adjustments
pub amt1_limit: u128,
}
pub struct OpenMakerParams {
pub margin: f64,
pub price_lower: f64,
pub price_upper: f64,
pub liquidity: u128,
pub max_amt0_in: u128,
pub max_amt1_in: u128,
}
pub struct AdjustMakerParams {
pub pos_id: U256,
pub margin_delta: f64,
pub liquidity_delta: i128, // positive = add, negative = remove
pub amt0_limit: u128,
pub amt1_limit: u128,
}
pub struct OpenResult {
pub tx_hash: B256,
pub pos_id: U256,
pub perp_delta: f64, // realized swap amounts (0.0 for maker opens)
pub usd_delta: f64,
}
pub struct AdjustTakerResult {
pub tx_hash: B256,
pub perp_delta: f64, // 0.0 if margin-only
pub usd_delta: f64,
}
pub struct AdjustMakerResult {
pub tx_hash: B256,
}ExactOpenTakerParams / ExactAdjustTakerParams are the exact wire-unit twins (atoms as u128/i128) for latency-sensitive paths.
Error Handling
PerpCityError composes per-module error types (all #[non_exhaustive]):
use perpcity_sdk::{PerpCityError, TransactionError};
match client.open_taker(¶ms, Urgency::Normal).await {
Ok(result) => println!("Opened position: {}", result.pos_id),
Err(PerpCityError::Transaction(TransactionError::SimulationReverted { error_name, .. })) => {
eprintln!("would revert: {error_name}"); // caught pre-broadcast, no gas burned
}
Err(e) if e.is_transient() => { /* RPC blip -- retry */ }
Err(e) => eprintln!("Error: {e}"),
}| Variant | Contains | Description |
|---|---|---|
Transaction(..) | SimulationReverted, SimulationFailed, Reverted, ReceiptTimeout, SigningFailed, GasUnavailable, TooManyInFlight, NonceDesynced | Simulation, signing, gas, pipeline |
Validation(..) | InvalidPrice, InvalidMargin, InvalidLeverage, InvalidTickRange, InvalidMarginRatio, Overflow, DecodeFailed, InvalidConfig | Input and math validation |
Contract(..) | PositionNotFound, ModuleNotRegistered, EventNotFound, MulticallFailed, BlockUnavailable, StorageReadFailed | Perps, positions, events, reads |
Rpc(..) / Abi(..) / Serde(..) | -- | Transport, contract, and serde errors (from Alloy/serde) |
Key distinctions:
SimulationReverted-- the pre-broadcasteth_calldetected a contract revert, with the decoded error name, 4-byte selector, and revert data. Match typed reverts withTransactionError::is_revert::<Perp::NotLiquidatable>()instead of comparing name strings.SimulationFailed-- the node answered the preflight with an execution failure but no decodable revert (an empty revert, or out-of-gas at the pinned limit). Not transient; retrying reproduces it.GasUnavailable-- the simulation got no answer (transport failure, rate limit). Transient; worth retrying.PerpCityError::is_transient()-- true for errors worth retrying (RPC failures,GasUnavailable,ReceiptTimeout,NonceDesynced,BlockUnavailable, storage reads with a transport cause).is_simulation_revert()-- true for pre-broadcast reverts.