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
| Method | Returns | Description |
|---|---|---|
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
| 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 |
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.
| Approach | HTTP requests | Provider CUs |
|---|---|---|
| Sequential reads | N | N |
| JSON-RPC batch | 1 | N |
| Multicall3 | 1 | 1 |
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 USDCSimulations (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.
| Method | Description |
|---|---|
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
| 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
Available at perpcity_sdk::tick_to_price(...) or perpcity_sdk::math::tick::tick_to_price(...).
| 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 |
align_tick_down(tick, spacing) | i32 | Round tick down to spacing |
align_tick_up(tick, spacing) | i32 | Round tick up to spacing |
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 |
Liquidity
| Function | Returns | Description |
|---|---|---|
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.
| Level | Base Fee Multiplier | Priority Fee Multiplier | Use Case |
|---|---|---|---|
Low | 1x | 1x | Non-urgent reads/approvals |
Normal | 2x | 1x | Standard trading (default) |
High | 3x | 2x | Time-sensitive trades |
Critical | 4x | 5x | Liquidation 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:
| Operation | Reference Limit |
|---|---|
ETH_TRANSFER | 21,000 (protocol invariant) |
APPROVE | 60,000 |
OPEN_TAKER | 700,000 |
OPEN_MAKER | 800,000 |
CLOSE_POSITION | 600,000 |
ADJUST_NOTIONAL | 500,000 |
ADJUST_MARGIN | 500,000 |
TRANSFER | 65,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()?,
)?;| 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) |
Each pool supports multiple endpoints with strategy-based selection:
| 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
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()?,
)?;| 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.
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:
| Event | Fields | Description |
|---|---|---|
PositionOpened | pos_id, mark_price, long_oi, short_oi | A new position was opened |
PositionClosed | pos_id, was_liquidated, net_margin, settlement fields | A position was closed or liquidated |
NotionalAdjusted | pos_id, mark_price, swap/funding fields | Position exposure was adjusted |
IndexUpdated | index | Oracle 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):
- Stop-loss (highest)
- Take-profit
- 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, ¶ms, 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}"),
}| Error | Description |
|---|---|
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) |