Perp CityDocs

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

MethodReturnsDescription
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

MethodReturnsDescription
address()AddressSigner's wallet address
deployments()&DeploymentsContract deployment addresses
provider()&RootProvider<Ethereum>Underlying Alloy provider
wallet()&EthereumWalletSigning wallet
transport()&HftTransportTransport 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?;
}
MethodDescription
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 and equity(), settled_margin(), accrued_income() convert to f64 at 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) / posVal drops 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(), and is_liquidatable(liquidation_fee) as a screening gate. The contract stays the oracle: confirm with simulate_liquidate_maker before 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.

ApproachHTTP requestsProvider CUs
Sequential readsNN
JSON-RPC batch1N
Multicall311

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 USDC

Local 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

MethodDescription
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

FunctionReturnsDescription
math::position::entry_price(perp_delta, usd_delta)f64Entry price from raw deltas
math::position::position_size(perp_delta)f64Position size in base asset
math::position::position_value(perp_delta, mark_price)f64Notional value at mark price
math::position::leverage(position_value, effective_margin)f64Current leverage ratio
math::position::liquidation_price(perp_delta, usd_delta, margin, liq_ratio, is_long)Option<f64>Liquidation price

Tick/Price Conversions

FunctionReturnsDescription
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)i32Round tick down to spacing
align_tick_up(tick, spacing)i32Round tick up to spacing

Liquidity

FunctionReturnsDescription
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

FunctionReturnsDescription
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)f64Scale 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).

LevelMax FeePriority FeeUse Case
Lowbase + priority1xNon-urgent sends
Normal2·base + priority1xStandard trading (default)
High3·base + 2·priority2xTime-sensitive trades
Critical4·base + 5·priority5xLiquidation 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:

OperationReference Limit
ETH_TRANSFER21,000 (L1 intrinsic; actual transfers estimate dynamically)
APPROVE60,000
OPEN_TAKER700,000
OPEN_MAKER800,000
CLOSE_POSITION600,000
ADJUST_NOTIONAL500,000
ADJUST_MARGIN500,000
LIQUIDATE3,000,000 (pinned on liquidation sends)
TRANSFER65,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()?,
)?;
PoolBuilder methodPurpose
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)
StrategyDescription
RoundRobinCycle through endpoints
LatencyBasedRoute 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()?,
)?;
ParameterDefaultDescription
failure_threshold3Consecutive failures before circuit opens
recovery_timeout30sTime in open state before a half-open probe
half_open_max_requests1Max 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(&params, 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}"),
}
VariantContainsDescription
Transaction(..)SimulationReverted, SimulationFailed, Reverted, ReceiptTimeout, SigningFailed, GasUnavailable, TooManyInFlight, NonceDesyncedSimulation, signing, gas, pipeline
Validation(..)InvalidPrice, InvalidMargin, InvalidLeverage, InvalidTickRange, InvalidMarginRatio, Overflow, DecodeFailed, InvalidConfigInput and math validation
Contract(..)PositionNotFound, ModuleNotRegistered, EventNotFound, MulticallFailed, BlockUnavailable, StorageReadFailedPerps, positions, events, reads
Rpc(..) / Abi(..) / Serde(..)--Transport, contract, and serde errors (from Alloy/serde)

Key distinctions:

  • SimulationReverted -- the pre-broadcast eth_call detected a contract revert, with the decoded error name, 4-byte selector, and revert data. Match typed reverts with TransactionError::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.