Docs

API Reference

Complete reference for the PerpCity Rust SDK -- client, trading, math, and HFT infrastructure.

Complete reference for the PerpCity Rust SDK (v0.2.0).

PerpClient

The main client for interacting with the PerpCity protocol.

use perpcity_sdk::{PerpClient, Deployments, HftTransport, TransportConfig};

let transport = HftTransport::new(
    TransportConfig::builder()
        .shared_endpoint("https://base.g.alchemy.com/v2/KEY")
        .read_endpoint("https://base-rpc.publicnode.com")
        .build()?,
)?;

let deployments = Deployments {
    perp_manager: "0x...".parse()?,
    usdc: "0x...".parse()?,
    ..Default::default()
};

let client = PerpClient::new_base_mainnet(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_base_mainnet(transport, signer, deployments)Result<Self>Create client for Base L2 mainnet
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

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

Trading Functions

open_taker

Opens a leveraged long or short position. Returns an OpenResult with position ID and entry deltas parsed from the transaction receipt.

use perpcity_sdk::{OpenTakerParams, Urgency};

let result = client.open_taker(perp_id, &OpenTakerParams {
    is_long: true,
    margin: 100.0,       // 100 USDC
    leverage: 10.0,      // 10x
    unspecified_amount_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> -- Contains pos_id, is_maker, perp_delta, usd_delta, tick_lower, tick_upper

open_maker

Opens a liquidity provider (maker) position. Price range is aligned to tick spacing automatically.

use perpcity_sdk::OpenMakerParams;

let result = client.open_maker(perp_id, &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?;

Maker positions are subject to a lockup period (7 days on testnet). You cannot close a maker position before the lockup expires.

close_position

Closes any position (taker or maker).

use perpcity_sdk::CloseParams;

let result = client.close_position(pos_id, &CloseParams {
    min_amt0_out: 0,
    min_amt1_out: 0,
    max_amt1_in: u128::MAX,
}, Urgency::Normal).await?;

println!("Net margin returned: {:.2}", result.net_margin);
println!("Was liquidated: {}", result.was_liquidated);

Returns: Result<CloseResult> -- Contains tx_hash, was_maker, was_liquidated, exit_perp_delta, exit_usd_delta, net_usd_delta, funding, utilization_fee, adl, liquidation_fee, net_margin, remaining_position_id

adjust_notional

Increase or decrease position exposure.

use perpcity_sdk::AdjustNotionalParams;

let result = client.adjust_notional(pos_id, &AdjustNotionalParams {
    usd_delta: 5.0,          // positive = receive USD / reduce exposure
    perp_limit: u128::MAX,   // slippage limit
}, Urgency::Normal).await?;

println!("New perp delta: {}", result.new_perp_delta);

Returns: Result<AdjustNotionalResult> -- Contains new_perp_delta, swap_perp_delta, swap_usd_delta, funding, utilization_fee, adl, trading_fees

adjust_margin

Add or remove collateral from a position.

use perpcity_sdk::AdjustMarginParams;

let result = client.adjust_margin(pos_id, &AdjustMarginParams {
    margin_delta: 50.0,  // positive = deposit, negative = withdraw
}, Urgency::Normal).await?;

println!("New margin: {:.2}", result.new_margin);

Returns: Result<AdjustMarginResult> -- Contains new_margin

transfer_eth

Transfer ETH to an address. Routed through the transaction pipeline for correct nonce management.

let tx_hash = client.transfer_eth(recipient, amount_wei, Urgency::Normal).await?;

transfer_usdc

Transfer USDC to an address. Amount is in human units (e.g. 100.0 = 100 USDC).

let tx_hash = client.transfer_usdc(recipient, 100.0, Urgency::Normal).await?;

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. This is a real cost reduction -- not just a latency improvement.

ApproachHTTP requestsProvider CUs
Sequential readsNN
JSON-RPC batch1N
Multicall311

Use get_perp_snapshot for startup market data and get_balances_batch for multi-address balance checks. Fall back to individual methods when you only need one piece of data and it's likely cached.

get_perp_snapshot

Fetch perp config and live market data in 2 multicalls (2 CUs instead of 5+). Returns both static config (PerpData -- beacon, tick spacing, fees, bounds) and live market state (PerpSnapshot -- mark price, index price, funding rate, open interest).

Phase 1 multicalls cfgs + mark price + funding rate + open interest against PerpManager (1 CU). Phase 2 fetches the index price from the oracle beacon (1 CU).

let (config, snapshot) = client.get_perp_snapshot(perp_id).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).

// Single address
let (usdc, eth) = client.get_balances(address).await?;

// Multiple addresses -- 1 CU regardless of count
let balances = client.get_balances_batch(&[addr1, addr2, addr3]).await?;
for (usdc, eth) in &balances {
    println!("USDC: {usdc}, ETH: {eth}");
}

Individual reads

let config  = client.get_perp_config(perp_id).await?;   // mark, fees, bounds
let mark    = client.get_mark_price(perp_id).await?;     // f64 price
let funding = client.get_funding_rate(perp_id).await?;   // daily rate
let oi      = client.get_open_interest(perp_id).await?;  // long/short OI
let live    = client.get_live_details(pos_id).await?;     // PnL, funding, liquidation
let balance = client.get_usdc_balance().await?;           // wallet USDC

Simulations (Quotes)

Read-only simulation of trades without submitting transactions.

quote_open_taker

let quote = client.quote_open_taker(perp_id, &OpenTakerParams {
    is_long: true,
    margin: 100.0,
    leverage: 10.0,
    unspecified_amount_limit: 0,
}).await?;
println!("Perp delta: {:.6}, USD delta: {:.2}", quote.perp_delta, quote.usd_delta);

quote_open_maker

let quote = client.quote_open_maker(perp_id, &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,
}).await?;

quote_swap

let quote = client.quote_swap(
    perp_id,
    true,           // zero_for_one
    true,           // is_exact_in
    1_000_000,      // amount (6-decimal)
    None,           // sqrt_price_limit (optional)
).await?;

Cache Management

The SDK maintains a 2-tier TTL cache tuned for Base L2 block times.

MethodDescription
invalidate_fast_cache()Clear prices, funding, balance (2s TTL layer)
invalidate_all_cache()Clear all cached state
confirm_tx(tx_hash)Remove 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 math functions are re-exported at the crate root.

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

Available at perpcity_sdk::tick_to_price(...) or perpcity_sdk::math::tick::tick_to_price(...).

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

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

Liquidity

FunctionReturnsDescription
math::liquidity::estimate_liquidity(tick_lower, tick_upper, margin_scaled)Result<U256>Liquidity estimate for USD amount

HFT Infrastructure

Urgency Levels

Controls EIP-1559 fee scaling for transaction priority.

LevelBase Fee MultiplierPriority Fee MultiplierUse Case
Low1x1xNon-urgent reads/approvals
Normal2x1xStandard trading (default)
High3x2xTime-sensitive trades
Critical4x5xLiquidation avoidance

Gas Estimation

Gas limits are estimated dynamically rather than hardcoded. On the first call to each operation type (e.g. open_taker), the SDK calls eth_estimateGas and caches the result keyed by the 4-byte function selector. Subsequent calls reuse the cached value with no RPC overhead.

Cache behavior:

  • TTL: 1 hour (configurable). After expiry, the next call re-estimates.
  • Buffer: 20% above the raw estimate (e.g. raw 580K → cached 696K). Absorbs gas variance from pool state changes.
  • Override: Pass an explicit gas limit to skip estimation entirely. Useful for HFT where even the first-call estimation latency (~100ms) matters.

If a transaction runs out of gas, it reverts on-chain (no funds lost) and can be retried -- the next attempt will re-estimate.

The GasLimits struct provides empirical reference values that can be used as explicit overrides:

OperationReference Limit
ETH_TRANSFER21,000 (protocol invariant)
APPROVE60,000
OPEN_TAKER700,000
OPEN_MAKER800,000
CLOSE_POSITION600,000
ADJUST_NOTIONAL500,000
ADJUST_MARGIN500,000
TRANSFER65,000

Transport Configuration

The transport organizes endpoints into three pools. Every RPC request is classified as a read or write, and routed to the appropriate pool. If the dedicated pool is unhealthy, requests fall back to the shared pool automatically via the circuit breaker.

Read request:  read_endpoints → shared_endpoints (fallback)
Write request: write_endpoints → shared_endpoints (fallback)

This enables routing reads to free public RPCs (balance checks, gas price, funding rate) while reserving paid endpoints for writes (transactions). The typical setup:

use perpcity_sdk::{HftTransport, TransportConfig};
use perpcity_sdk::transport::config::Strategy;
use std::time::Duration;

let transport = HftTransport::new(
    TransportConfig::builder()
        .shared_endpoint("https://base.g.alchemy.com/v2/KEY")  // writes + read fallback
        .read_endpoint("https://base-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)

Each pool supports multiple endpoints with strategy-based selection:

StrategyDescription
RoundRobinCycle through endpoints
LatencyBasedRoute to lowest-latency healthy endpoint (default)
Hedged { fan_out }Fan out to N endpoints, take fastest response

Circuit Breaker

Each endpoint has an independent circuit breaker. When a dedicated pool's endpoints are all unhealthy, requests fall back to the shared pool automatically.

use perpcity_sdk::transport::config::CircuitBreakerConfig;
use std::time::Duration;

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.

use perpcity_sdk::transport::config::{ReadRetryConfig, WriteRetryConfig};

let transport = HftTransport::new(
    TransportConfig::builder()
        .shared_endpoint("https://rpc.com")
        .read_retry(ReadRetryConfig {
            max_retries: 2,
            base_delay: Duration::from_millis(100),
        })
        .write_retry(WriteRetryConfig {
            max_retries: 3,
            base_delay: Duration::from_millis(500),
        })
        .build()?,
)?;

Both reads and writes retry on transport errors and timeouts (resending the same signed transaction is safe -- Ethereum nodes deduplicate by transaction hash). Writes additionally retry on pre-mempool RPC rejections (e.g. -32003 insufficient funds from a stale read replica), where the node rejected the transaction before it entered the mempool.

WebSocket Event Streaming

The MarketFeed streams typed market events over WebSocket. It subscribes to PerpManager and beacon contract logs, decodes them into MarketEvent variants, and filters by perp ID.

use perpcity_sdk::{MarketFeed, MarketEvent};
use perpcity_sdk::transport::ws::{WsManager, ReconnectConfig};

// Connect to WebSocket
let ws = WsManager::connect("wss://base-rpc.publicnode.com", ReconnectConfig::default()).await?;

// Subscribe to events for a specific perp
let mut feed = MarketFeed::new(&ws, perp_id).await?;

while let Some(event) = feed.next().await {
    match event {
        MarketEvent::PositionOpened { pos_id, mark_price, long_oi, short_oi, .. } => {
            println!("Position opened: {pos_id}, mark: {mark_price}");
        }
        MarketEvent::PositionClosed { pos_id, was_liquidated, net_margin, .. } => {
            println!("Position closed: {pos_id}, liquidated: {was_liquidated}");
        }
        MarketEvent::NotionalAdjusted { pos_id, mark_price, .. } => {
            println!("Notional adjusted: {pos_id}");
        }
        MarketEvent::IndexUpdated { index } => {
            println!("Oracle index: {index}");
        }
    }
}

Event types:

EventFieldsDescription
PositionOpenedpos_id, mark_price, long_oi, short_oiA new position was opened
PositionClosedpos_id, was_liquidated, net_margin, settlement fieldsA position was closed or liquidated
NotionalAdjustedpos_id, mark_price, swap/funding fieldsPosition exposure was adjusted
IndexUpdatedindexOracle beacon price updated

Funding rate changes are not emitted as events -- they must be polled via 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::PositionManager;

let mut pm = PositionManager::new();

pm.track(ManagedPosition {
    perp_id,
    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);
for action in actions {
    match action.trigger {
        TriggerType::StopLoss => { /* close position */ }
        TriggerType::TakeProfit => { /* close position */ }
        TriggerType::TrailingStop => { /* close position */ }
    }
}

Trigger priority (when multiple fire simultaneously):

  1. Stop-loss (highest)
  2. Take-profit
  3. Trailing stop

Types

Core

pub struct Deployments {
    pub perp_manager: Address,
    pub usdc: Address,
    pub fees_module: Option<Address>,
    pub margin_ratios_module: Option<Address>,
    pub lockup_period_module: Option<Address>,
    pub sqrt_price_impact_limit_module: Option<Address>,
}

Market Data

pub struct PerpData {
    pub id: B256,
    pub tick_spacing: i32,
    pub mark: f64,
    pub beacon: Address,
    pub bounds: Bounds,
    pub fees: Fees,
}

pub struct PerpSnapshot {
    pub mark_price: f64,
    pub index_price: f64,
    pub funding_rate_daily: f64,
    pub open_interest: OpenInterest,
}

pub struct OpenInterest {
    pub long_oi: f64,
    pub short_oi: f64,
}

Trading Results

All trading methods return rich result types parsed directly from on-chain transaction receipts. No follow-up RPC calls are needed to get position details.

CloseResult includes full settlement accounting: net_margin is the USDC returned to the wallet, funding is accumulated funding paid or received, and was_liquidated indicates whether the close was triggered by the liquidation engine.

pub struct OpenResult {
    pub pos_id: U256,
    pub is_maker: bool,
    pub perp_delta: f64,
    pub usd_delta: f64,
    pub tick_lower: i32,
    pub tick_upper: i32,
}

pub struct CloseResult {
    pub tx_hash: B256,
    pub was_maker: bool,
    pub was_liquidated: bool,
    pub exit_perp_delta: f64,
    pub exit_usd_delta: f64,
    pub net_usd_delta: f64,
    pub funding: f64,
    pub utilization_fee: f64,
    pub adl: f64,
    pub liquidation_fee: f64,
    pub net_margin: f64,
    pub remaining_position_id: Option<U256>,
}

pub struct AdjustNotionalResult {
    pub new_perp_delta: f64,
    pub swap_perp_delta: f64,
    pub swap_usd_delta: f64,
    pub funding: f64,
    pub utilization_fee: f64,
    pub adl: f64,
    pub trading_fees: f64,
}

pub struct AdjustMarginResult {
    pub new_margin: f64,
}

Parameters

pub struct OpenTakerParams {
    pub is_long: bool,
    pub margin: f64,
    pub leverage: f64,
    pub unspecified_amount_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 CloseParams {
    pub min_amt0_out: u128,
    pub min_amt1_out: u128,
    pub max_amt1_in: u128,
}

pub struct AdjustNotionalParams {
    pub usd_delta: f64,
    pub perp_limit: u128,
}

pub struct AdjustMarginParams {
    pub margin_delta: f64,
}

Error Handling

All errors use the PerpCityError enum with thiserror:

use perpcity_sdk::PerpCityError;

match client.open_taker(perp_id, &params, Urgency::Normal).await {
    Ok(result) => println!("Opened position: {}", result.pos_id),
    Err(PerpCityError::InvalidMargin { reason }) => eprintln!("Bad margin: {reason}"),
    Err(PerpCityError::TxReverted { reason }) => eprintln!("Reverted: {reason}"),
    Err(PerpCityError::TooManyInFlight { count, max }) => {
        eprintln!("Too many in-flight txs: {count}/{max}");
    }
    Err(e) => eprintln!("Error: {e}"),
}
ErrorDescription
InvalidPrice { reason }Invalid price value
InvalidMargin { reason }Margin validation failure
InvalidLeverage { reason }Leverage out of bounds
InvalidTickRange { lower, upper }Lower tick >= upper tick
InvalidConfig { reason }Configuration error
Overflow { context }Arithmetic overflow
TxReverted { reason }On-chain transaction reverted
EventNotFound { event_name }Expected event not emitted
GasPriceUnavailable { reason }Fee cache not initialized
TooManyInFlight { count, max }Nonce exhaustion
PerpNotFound { perp_id }Unknown perp market
PositionNotFound { pos_id }Unknown position
ModuleNotRegistered { module }Required module address not set in Deployments
AlloyContract(...)Contract interaction error (from Alloy)
AlloyTransport(...)RPC transport error (from Alloy)