Skip to main content

perpcity_sdk/
client.rs

1//! High-level client for the PerpCity perpetual futures protocol.
2//!
3//! [`PerpClient`] wires together the transport layer, HFT infrastructure,
4//! and contract bindings into a single ergonomic API. It is the primary
5//! entry point for interacting with PerpCity on Base L2.
6//!
7//! # Example
8//!
9//! ```rust,no_run
10//! use perpcity_sdk::{PerpClient, Deployments, HftTransport, TransportConfig};
11//! use alloy::primitives::{address, Address, B256};
12//! use alloy::signers::local::PrivateKeySigner;
13//!
14//! # async fn example() -> perpcity_sdk::Result<()> {
15//! let transport = HftTransport::new(
16//!     TransportConfig::builder()
17//!         .shared_endpoint("https://mainnet.base.org")
18//!         .build()?
19//! )?;
20//!
21//! let signer: PrivateKeySigner = "your_private_key_hex".parse().unwrap();
22//!
23//! let deployments = Deployments {
24//!     perp_manager: address!("0000000000000000000000000000000000000001"),
25//!     usdc: address!("C1a5D4E99BB224713dd179eA9CA2Fa6600706210"),
26//!     fees_module: None,
27//!     margin_ratios_module: None,
28//!     lockup_period_module: None,
29//!     sqrt_price_impact_limit_module: None,
30//! };
31//!
32//! let client = PerpClient::new(transport, signer, deployments, 8453)?;
33//! # Ok(())
34//! # }
35//! ```
36
37use std::sync::Mutex;
38use std::time::{Duration, SystemTime, UNIX_EPOCH};
39
40use alloy::network::{Ethereum, EthereumWallet, TransactionBuilder};
41use alloy::primitives::{Address, B256, Bytes, I256, U256};
42use alloy::providers::{Provider, RootProvider};
43use alloy::rpc::client::RpcClient;
44use alloy::rpc::types::TransactionRequest;
45use alloy::signers::local::PrivateKeySigner;
46use alloy::transports::BoxTransport;
47
48use crate::constants::SCALE_1E6;
49use alloy::sol_types::{SolCall, SolValue};
50
51use crate::constants::MULTICALL3;
52use crate::contracts::{IBeacon, IERC20, IFees, IMarginRatios, IMulticall3, PerpManager};
53use crate::convert::{
54    leverage_to_margin_ratio, margin_ratio_to_leverage, scale_from_6dec, scale_to_6dec,
55};
56use crate::errors::{PerpCityError, Result};
57use crate::hft::gas::{FeeCache, GasLimitCache, GasLimits, Urgency};
58use crate::hft::pipeline::{PipelineConfig, TxPipeline, TxRequest};
59use crate::hft::state_cache::{CachedBounds, CachedFees, StateCache, StateCacheConfig};
60use crate::math::tick::{align_tick_down, align_tick_up, price_to_tick};
61use crate::transport::provider::HftTransport;
62use crate::types::{
63    AdjustMarginParams, AdjustMarginResult, AdjustNotionalParams, AdjustNotionalResult, Bounds,
64    CloseParams, CloseResult, Deployments, Fees, LiveDetails, OpenInterest, OpenMakerParams,
65    OpenMakerQuote, OpenResult, OpenTakerParams, OpenTakerQuote, PerpData, PerpSnapshot, SwapQuote,
66};
67
68// ── Constants ────────────────────────────────────────────────────────
69
70/// Base L2 chain ID.
71const BASE_CHAIN_ID: u64 = 8453;
72
73/// Default gas cache TTL: 2 seconds (2 Base L2 blocks).
74const DEFAULT_GAS_TTL_MS: u64 = 2_000;
75
76/// Default priority fee: 0.01 gwei.
77///
78/// Base L2 uses a single sequencer, so priority fees are near-meaningless.
79/// 10 Mwei is sufficient for reliable inclusion while keeping gas escrow low.
80const DEFAULT_PRIORITY_FEE: u64 = 10_000_000;
81
82/// Default receipt polling timeout.
83const RECEIPT_TIMEOUT: Duration = Duration::from_secs(30);
84
85/// Maximum USDC approval amount (2^256 - 1).
86const MAX_APPROVAL: U256 = U256::MAX;
87
88/// SCALE_1E6 as f64, used for converting on-chain fixed-point values.
89const SCALE_F64: f64 = SCALE_1E6 as f64;
90
91/// Convert a Q96 fixed-point funding-per-second value to a daily rate.
92fn funding_x96_to_daily(funding_x96: I256) -> f64 {
93    let funding_i128 = i128_from_i256(funding_x96);
94    let rate_per_sec = funding_i128 as f64 / 2.0_f64.powi(96);
95    rate_per_sec * crate::constants::INTERVAL as f64
96}
97
98// ── From impls for cache↔client type bridging ────────────────────────
99
100impl From<CachedFees> for Fees {
101    fn from(c: CachedFees) -> Self {
102        Self {
103            creator_fee: c.creator_fee,
104            insurance_fee: c.insurance_fee,
105            lp_fee: c.lp_fee,
106            liquidation_fee: c.liquidation_fee,
107        }
108    }
109}
110
111impl From<Fees> for CachedFees {
112    fn from(f: Fees) -> Self {
113        Self {
114            creator_fee: f.creator_fee,
115            insurance_fee: f.insurance_fee,
116            lp_fee: f.lp_fee,
117            liquidation_fee: f.liquidation_fee,
118        }
119    }
120}
121
122impl From<CachedBounds> for Bounds {
123    fn from(c: CachedBounds) -> Self {
124        Self {
125            min_margin: c.min_margin,
126            min_taker_leverage: c.min_taker_leverage,
127            max_taker_leverage: c.max_taker_leverage,
128            liquidation_taker_ratio: c.liquidation_taker_ratio,
129        }
130    }
131}
132
133impl From<Bounds> for CachedBounds {
134    fn from(b: Bounds) -> Self {
135        Self {
136            min_margin: b.min_margin,
137            min_taker_leverage: b.min_taker_leverage,
138            max_taker_leverage: b.max_taker_leverage,
139            liquidation_taker_ratio: b.liquidation_taker_ratio,
140        }
141    }
142}
143
144// ── PerpClient ───────────────────────────────────────────────────────
145
146/// High-level client for the PerpCity protocol.
147///
148/// Combines transport, signing, transaction pipeline, state caching, and
149/// contract bindings into one ergonomic API. All write operations go
150/// through the [`TxPipeline`] for zero-RPC-on-hot-path nonce/gas resolution.
151/// Read operations use the [`StateCache`] to avoid redundant RPC calls.
152pub struct PerpClient {
153    /// Alloy provider wired to HftTransport (multi-endpoint, health-aware).
154    provider: RootProvider<Ethereum>,
155    /// The underlying transport (kept for health diagnostics).
156    transport: HftTransport,
157    /// Wallet for signing transactions.
158    wallet: EthereumWallet,
159    /// The signer's address.
160    address: Address,
161    /// Deployed contract addresses.
162    deployments: Deployments,
163    /// Chain ID for transaction building.
164    chain_id: u64,
165    /// Transaction pipeline (nonce + gas). Mutex for interior mutability.
166    pipeline: Mutex<TxPipeline>,
167    /// Gas fee cache, updated from block headers.
168    fee_cache: Mutex<FeeCache>,
169    /// Cached gas estimates from `eth_estimateGas`, keyed by function selector.
170    gas_limit_cache: Mutex<GasLimitCache>,
171    /// Multi-layer state cache for on-chain reads.
172    state_cache: Mutex<StateCache>,
173}
174
175impl std::fmt::Debug for PerpClient {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        f.debug_struct("PerpClient")
178            .field("address", &self.address)
179            .field("chain_id", &self.chain_id)
180            .field("deployments", &self.deployments)
181            .finish_non_exhaustive()
182    }
183}
184
185impl PerpClient {
186    /// Create a new PerpClient.
187    ///
188    /// - `transport`: Multi-endpoint RPC transport (from [`crate::TransportConfig`])
189    /// - `signer`: Private key for signing transactions
190    /// - `deployments`: Contract addresses for this PerpCity instance
191    /// - `chain_id`: Chain ID (8453 for Base mainnet, 84532 for Base Sepolia)
192    ///
193    /// This does NOT make any network calls. Call [`Self::refresh_gas`] and
194    /// [`Self::sync_nonce`] before submitting transactions.
195    pub fn new(
196        transport: HftTransport,
197        signer: PrivateKeySigner,
198        deployments: Deployments,
199        chain_id: u64,
200    ) -> Result<Self> {
201        let address = signer.address();
202        let wallet = EthereumWallet::from(signer);
203
204        let boxed = BoxTransport::new(transport.clone());
205        let rpc_client = RpcClient::new(boxed, false);
206        let provider = RootProvider::<Ethereum>::new(rpc_client);
207
208        Ok(Self {
209            provider,
210            transport,
211            wallet,
212            address,
213            deployments,
214            chain_id,
215            // Pipeline starts at nonce 0; call sync_nonce() before first tx
216            pipeline: Mutex::new(TxPipeline::new(0, PipelineConfig::default())),
217            fee_cache: Mutex::new(FeeCache::new(DEFAULT_GAS_TTL_MS, DEFAULT_PRIORITY_FEE)),
218            gas_limit_cache: Mutex::new(GasLimitCache::new()),
219            state_cache: Mutex::new(StateCache::new(StateCacheConfig::default())),
220        })
221    }
222
223    /// Create a client pre-configured for Base mainnet.
224    pub fn new_base_mainnet(
225        transport: HftTransport,
226        signer: PrivateKeySigner,
227        deployments: Deployments,
228    ) -> Result<Self> {
229        Self::new(transport, signer, deployments, BASE_CHAIN_ID)
230    }
231
232    // ── Initialization ───────────────────────────────────────────────
233
234    /// Sync the nonce manager with the on-chain transaction count.
235    ///
236    /// Must be called before the first transaction. After this, the
237    /// pipeline manages nonces locally (zero RPC per transaction).
238    pub async fn sync_nonce(&self) -> Result<()> {
239        let count = self.provider.get_transaction_count(self.address).await?;
240        let mut pipeline = self.pipeline.lock().unwrap();
241        *pipeline = TxPipeline::new(count, PipelineConfig::default());
242        tracing::info!(nonce = count, address = %self.address, "nonce synced");
243        Ok(())
244    }
245
246    /// Refresh the gas cache from the latest block header.
247    ///
248    /// Fetches the latest block directly in a single RPC call and extracts
249    /// the base fee for EIP-1559 fee computation. Should be called
250    /// periodically (every 1-2 seconds on Base L2) or from a `newHeads`
251    /// subscription callback.
252    pub async fn refresh_gas(&self) -> Result<()> {
253        let header = self
254            .provider
255            .get_block_by_number(alloy::eips::BlockNumberOrTag::Latest)
256            .await?
257            .ok_or_else(|| PerpCityError::GasPriceUnavailable {
258                reason: "latest block not found".into(),
259            })?;
260
261        let base_fee =
262            header
263                .header
264                .base_fee_per_gas
265                .ok_or_else(|| PerpCityError::GasPriceUnavailable {
266                    reason: "block has no base fee (pre-EIP-1559?)".into(),
267                })?;
268
269        let now = now_ms();
270        self.fee_cache.lock().unwrap().update(base_fee, now);
271        tracing::debug!(base_fee, "gas cache refreshed");
272        Ok(())
273    }
274
275    /// Inject a base fee from an external source (e.g. a shared poller).
276    ///
277    /// Updates the gas cache as if `refresh_gas` had been called, but without
278    /// any RPC calls. The cache TTL is reset to now.
279    pub fn set_base_fee(&self, base_fee: u64) {
280        let now = now_ms();
281        self.fee_cache.lock().unwrap().update(base_fee, now);
282        tracing::debug!(base_fee, "base fee injected");
283    }
284
285    /// Return the current cached base fee, if any (ignores TTL).
286    ///
287    /// Intended for reading the base fee after `refresh_gas` in order to
288    /// distribute it to other clients via [`set_base_fee`](Self::set_base_fee).
289    pub fn base_fee(&self) -> Option<u64> {
290        self.fee_cache.lock().unwrap().base_fee()
291    }
292
293    /// Override the gas cache TTL (milliseconds).
294    ///
295    /// When gas is managed externally via [`set_base_fee`](Self::set_base_fee),
296    /// the default 2s TTL may be too tight. Set this to match the poller's
297    /// cadence with headroom (e.g. `tick_secs * 2 * 1000`).
298    pub fn set_gas_ttl(&self, ttl_ms: u64) {
299        self.fee_cache.lock().unwrap().set_ttl(ttl_ms);
300        tracing::debug!(ttl_ms, "gas cache TTL updated");
301    }
302
303    // ── Write operations ─────────────────────────────────────────────
304
305    /// Open a taker (long/short) position.
306    ///
307    /// Returns an [`OpenResult`] with the position ID and entry deltas
308    /// parsed from the `PositionOpened` event, so callers can construct
309    /// position tracking data without a follow-up RPC read.
310    ///
311    /// # Errors
312    ///
313    /// Returns [`PerpCityError::TxReverted`] if the transaction reverts,
314    /// or [`PerpCityError::EventNotFound`] if the `PositionOpened` event
315    /// is missing from the receipt.
316    pub async fn open_taker(
317        &self,
318        perp_id: B256,
319        params: &OpenTakerParams,
320        urgency: Urgency,
321    ) -> Result<OpenResult> {
322        let margin_scaled = scale_to_6dec(params.margin)?;
323        if margin_scaled <= 0 {
324            return Err(PerpCityError::InvalidMargin {
325                reason: format!("margin must be positive, got {}", params.margin),
326            });
327        }
328        let margin_ratio = leverage_to_margin_ratio(params.leverage)?;
329
330        let wire_params = PerpManager::OpenTakerPositionParams {
331            holder: self.address,
332            isLong: params.is_long,
333            margin: margin_scaled as u128,
334            marginRatio: u32_to_u24(margin_ratio),
335            unspecifiedAmountLimit: params.unspecified_amount_limit,
336        };
337
338        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
339        let calldata = contract
340            .openTakerPos(perp_id, wire_params)
341            .calldata()
342            .clone();
343
344        tracing::info!(%perp_id, margin = params.margin, leverage = params.leverage, is_long = params.is_long, ?urgency, "opening taker position");
345
346        let receipt = self
347            .send_tx(self.deployments.perp_manager, calldata, None, urgency)
348            .await?;
349
350        let result = parse_open_result(&receipt)?;
351        tracing::info!(%perp_id, pos_id = %result.pos_id, perp_delta = result.perp_delta, usd_delta = result.usd_delta, "taker position opened");
352        Ok(result)
353    }
354
355    /// Open a maker (LP) position within a price range.
356    ///
357    /// Converts `price_lower`/`price_upper` to aligned ticks internally.
358    /// Returns an [`OpenResult`] with the position ID and entry deltas.
359    pub async fn open_maker(
360        &self,
361        perp_id: B256,
362        params: &OpenMakerParams,
363        urgency: Urgency,
364    ) -> Result<OpenResult> {
365        let margin_scaled = scale_to_6dec(params.margin)?;
366        if margin_scaled <= 0 {
367            return Err(PerpCityError::InvalidMargin {
368                reason: format!("margin must be positive, got {}", params.margin),
369            });
370        }
371
372        let tick_lower = align_tick_down(
373            price_to_tick(params.price_lower)?,
374            crate::constants::TICK_SPACING,
375        );
376        let tick_upper = align_tick_up(
377            price_to_tick(params.price_upper)?,
378            crate::constants::TICK_SPACING,
379        );
380
381        if tick_lower >= tick_upper {
382            return Err(PerpCityError::InvalidTickRange {
383                lower: tick_lower,
384                upper: tick_upper,
385            });
386        }
387
388        // Liquidity must fit in u120 on-chain
389        let liquidity: u128 = params.liquidity;
390        let max_u120: u128 = (1u128 << 120) - 1;
391        if liquidity > max_u120 {
392            return Err(PerpCityError::Overflow {
393                context: format!("liquidity {} exceeds uint120 max", liquidity),
394            });
395        }
396
397        let wire_params = PerpManager::OpenMakerPositionParams {
398            holder: self.address,
399            margin: margin_scaled as u128,
400            liquidity: alloy::primitives::Uint::<120, 2>::from(liquidity),
401            tickLower: i32_to_i24(tick_lower),
402            tickUpper: i32_to_i24(tick_upper),
403            maxAmt0In: params.max_amt0_in,
404            maxAmt1In: params.max_amt1_in,
405        };
406
407        tracing::info!(%perp_id, margin = params.margin, tick_lower, tick_upper, ?urgency, "opening maker position");
408
409        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
410        let calldata = contract
411            .openMakerPos(perp_id, wire_params)
412            .calldata()
413            .clone();
414
415        let receipt = self
416            .send_tx(self.deployments.perp_manager, calldata, None, urgency)
417            .await?;
418
419        let result = parse_open_result(&receipt)?;
420        tracing::info!(%perp_id, pos_id = %result.pos_id, perp_delta = result.perp_delta, usd_delta = result.usd_delta, "maker position opened");
421        Ok(result)
422    }
423
424    /// Close a position (taker or maker).
425    ///
426    /// Returns a [`CloseResult`] with the transaction hash and optional
427    /// remaining position ID (for partial closes).
428    pub async fn close_position(
429        &self,
430        pos_id: U256,
431        params: &CloseParams,
432        urgency: Urgency,
433    ) -> Result<CloseResult> {
434        let wire_params = PerpManager::ClosePositionParams {
435            posId: pos_id,
436            minAmt0Out: params.min_amt0_out,
437            minAmt1Out: params.min_amt1_out,
438            maxAmt1In: params.max_amt1_in,
439        };
440
441        tracing::info!(pos_id = %pos_id, ?urgency, "closing position");
442
443        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
444        let calldata = contract.closePosition(wire_params).calldata().clone();
445
446        let receipt = self
447            .send_tx(self.deployments.perp_manager, calldata, None, urgency)
448            .await?;
449
450        let result = parse_close_result(&receipt, pos_id)?;
451        tracing::info!(pos_id = %pos_id, was_liquidated = result.was_liquidated, net_margin = result.net_margin, "position closed");
452        Ok(result)
453    }
454
455    /// Adjust the notional exposure of a taker position.
456    ///
457    /// - `usd_delta > 0`: receive USD by selling perp tokens (reduce exposure)
458    /// - `usd_delta < 0`: spend USD to buy perp tokens (increase exposure)
459    pub async fn adjust_notional(
460        &self,
461        pos_id: U256,
462        params: &AdjustNotionalParams,
463        urgency: Urgency,
464    ) -> Result<AdjustNotionalResult> {
465        let usd_delta_scaled = scale_to_6dec(params.usd_delta)?;
466
467        let wire_params = PerpManager::AdjustNotionalParams {
468            posId: pos_id,
469            usdDelta: I256::try_from(usd_delta_scaled).map_err(|_| PerpCityError::Overflow {
470                context: format!("usd_delta {} overflows I256", usd_delta_scaled),
471            })?,
472            perpLimit: params.perp_limit,
473        };
474
475        tracing::info!(pos_id = %pos_id, usd_delta = params.usd_delta, ?urgency, "adjusting notional");
476
477        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
478        let calldata = contract.adjustNotional(wire_params).calldata().clone();
479
480        let receipt = self
481            .send_tx(self.deployments.perp_manager, calldata, None, urgency)
482            .await?;
483
484        let result = parse_adjust_result(&receipt)?;
485        tracing::info!(pos_id = %pos_id, new_perp_delta = result.new_perp_delta, "notional adjusted");
486        Ok(result)
487    }
488
489    /// Add or remove margin from a position.
490    ///
491    /// - `margin_delta > 0`: deposit more margin
492    /// - `margin_delta < 0`: withdraw margin
493    pub async fn adjust_margin(
494        &self,
495        pos_id: U256,
496        params: &AdjustMarginParams,
497        urgency: Urgency,
498    ) -> Result<AdjustMarginResult> {
499        let delta_scaled = scale_to_6dec(params.margin_delta)?;
500
501        let wire_params = PerpManager::AdjustMarginParams {
502            posId: pos_id,
503            marginDelta: I256::try_from(delta_scaled).map_err(|_| PerpCityError::Overflow {
504                context: format!("margin_delta {} overflows I256", delta_scaled),
505            })?,
506        };
507
508        tracing::info!(pos_id = %pos_id, margin_delta = params.margin_delta, ?urgency, "adjusting margin");
509
510        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
511        let calldata = contract.adjustMargin(wire_params).calldata().clone();
512
513        let receipt = self
514            .send_tx(self.deployments.perp_manager, calldata, None, urgency)
515            .await?;
516
517        let result = parse_margin_result(&receipt)?;
518        tracing::info!(pos_id = %pos_id, new_margin = result.new_margin, "margin adjusted");
519        Ok(result)
520    }
521
522    /// Ensure USDC is approved for the PerpManager to spend.
523    ///
524    /// Checks current allowance and only sends an `approve` transaction
525    /// if the allowance is below `min_amount`. Approves for `U256::MAX`
526    /// (infinite approval) to avoid repeated approve calls.
527    pub async fn ensure_approval(&self, min_amount: U256) -> Result<Option<B256>> {
528        let usdc = IERC20::new(self.deployments.usdc, &self.provider);
529        let allowance: U256 = usdc
530            .allowance(self.address, self.deployments.perp_manager)
531            .call()
532            .await?;
533
534        if allowance >= min_amount {
535            tracing::debug!(allowance = %allowance, "USDC approval sufficient");
536            return Ok(None);
537        }
538
539        tracing::info!(allowance = %allowance, min_amount = %min_amount, "approving USDC");
540
541        let calldata = usdc
542            .approve(self.deployments.perp_manager, MAX_APPROVAL)
543            .calldata()
544            .clone();
545
546        let receipt = self
547            .send_tx(self.deployments.usdc, calldata, None, Urgency::Normal)
548            .await?;
549
550        tracing::info!(tx_hash = %receipt.transaction_hash, "USDC approved");
551        Ok(Some(receipt.transaction_hash))
552    }
553
554    // ── Read operations ──────────────────────────────────────────────
555
556    /// Get the full perp configuration, fees, and bounds for a market.
557    ///
558    /// Uses the [`StateCache`] for fees and bounds (60s TTL). The perp
559    /// config itself is always fetched fresh (it's cheap and rarely changes).
560    pub async fn get_perp_config(&self, perp_id: B256) -> Result<PerpData> {
561        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
562
563        // Fetch perp config — sol!(rpc) returns the struct directly
564        let config: PerpManager::PerpConfig = contract.cfgs(perp_id).call().await?;
565
566        // Zero beacon means the perp was never created
567        if config.beacon == Address::ZERO {
568            return Err(PerpCityError::PerpNotFound { perp_id });
569        }
570
571        let beacon = config.beacon;
572
573        // Fetch mark price via TWAP (short window = ~current price)
574        let sqrt_price_x96: U256 = contract
575            .timeWeightedAvgSqrtPriceX96(perp_id, 1)
576            .call()
577            .await?;
578        let mark = crate::convert::sqrt_price_x96_to_price(sqrt_price_x96)?;
579
580        let fees = self.get_or_fetch_fees(&config).await?;
581        let bounds = self.get_or_fetch_bounds(&config).await?;
582
583        Ok(PerpData {
584            id: perp_id,
585            tick_spacing: i24_to_i32(config.key.tickSpacing),
586            mark,
587            beacon,
588            bounds,
589            fees,
590        })
591    }
592
593    /// Get perp data: beacon, tick spacing, and current mark price.
594    ///
595    /// Lighter-weight than [`Self::get_perp_config`] — skips fees/bounds lookups.
596    pub async fn get_perp_data(&self, perp_id: B256) -> Result<(Address, i32, f64)> {
597        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
598        let config: PerpManager::PerpConfig = contract.cfgs(perp_id).call().await?;
599
600        let sqrt_price_x96: U256 = contract
601            .timeWeightedAvgSqrtPriceX96(perp_id, 1)
602            .call()
603            .await?;
604        let mark = crate::convert::sqrt_price_x96_to_price(sqrt_price_x96)?;
605
606        Ok((config.beacon, i24_to_i32(config.key.tickSpacing), mark))
607    }
608
609    /// Get an on-chain position by its NFT token ID.
610    ///
611    /// Returns the raw contract position struct. Use [`crate::math::position`]
612    /// functions to compute derived values (entry price, PnL, etc.).
613    pub async fn get_position(&self, pos_id: U256) -> Result<PerpManager::Position> {
614        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
615        let pos: PerpManager::Position = contract.positions(pos_id).call().await?;
616
617        // Check if position exists (empty perpId = uninitialized)
618        if pos.perpId == B256::ZERO {
619            return Err(PerpCityError::PositionNotFound { pos_id });
620        }
621
622        Ok(pos)
623    }
624
625    /// Get all position IDs owned by an address.
626    ///
627    /// Iterates through all minted position NFTs (1..nextPosId) and returns
628    /// those owned by `owner`. Burned or non-existent tokens are skipped.
629    ///
630    /// **Note:** This is O(n) in total positions ever minted. For high-throughput
631    /// use cases, prefer the bot API's position endpoints instead.
632    pub async fn get_positions_by_owner(&self, owner: Address) -> Result<Vec<U256>> {
633        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
634        let next_pos_id: U256 = contract.nextPosId().call().await?;
635
636        let total: u64 = next_pos_id
637            .try_into()
638            .map_err(|_| PerpCityError::Overflow {
639                context: "nextPosId exceeds u64".into(),
640            })?;
641        if total <= 1 {
642            return Ok(vec![]);
643        }
644
645        let mut owned = Vec::new();
646        for id in 1..total {
647            let pos_id = U256::from(id);
648            // ownerOf reverts for burned/non-existent tokens — those
649            // surface as contract errors, which we skip. Other transport
650            // errors propagate so network failures aren't silently ignored.
651            match contract.ownerOf(pos_id).call().await {
652                Ok(addr) if addr == owner => owned.push(pos_id),
653                Ok(_) => {}
654                Err(e @ alloy::contract::Error::TransportError(_)) => return Err(e.into()),
655                Err(_) => {} // burned or non-existent token
656            }
657        }
658
659        Ok(owned)
660    }
661
662    /// Get the current mark price for a perp (TWAP with 1-second lookback).
663    ///
664    /// Uses the fast cache layer (2s TTL).
665    pub async fn get_mark_price(&self, perp_id: B256) -> Result<f64> {
666        let now_ts = now_secs();
667        let perp_bytes: [u8; 32] = perp_id.into();
668
669        // Check cache
670        {
671            let cache = self.state_cache.lock().unwrap();
672            if let Some(price) = cache.get_mark_price(&perp_bytes, now_ts) {
673                tracing::trace!(%perp_id, price, "mark price cache hit");
674                return Ok(price);
675            }
676        }
677
678        // Fetch from chain
679        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
680        let sqrt_price_x96: U256 = contract
681            .timeWeightedAvgSqrtPriceX96(perp_id, 1)
682            .call()
683            .await?;
684        let price = crate::convert::sqrt_price_x96_to_price(sqrt_price_x96)?;
685
686        tracing::debug!(%perp_id, price, "mark price fetched");
687
688        // Update cache
689        {
690            let mut cache = self.state_cache.lock().unwrap();
691            cache.put_mark_price(perp_bytes, price, now_ts);
692        }
693
694        Ok(price)
695    }
696
697    /// Get the oracle index price from a beacon contract.
698    ///
699    /// The beacon address is available from `PerpData.beacon` (returned by
700    /// [`get_perp_config`](Self::get_perp_config)).
701    pub async fn get_index_price(&self, beacon: Address) -> Result<f64> {
702        let contract = IBeacon::new(beacon, &self.provider);
703        let index_x96: U256 = contract.index().call().await?;
704
705        if index_x96.is_zero() {
706            return Err(PerpCityError::InvalidPrice {
707                reason: "beacon returned zero index".into(),
708            });
709        }
710
711        crate::convert::price_x96_to_f64(index_x96)
712    }
713
714    /// Simulate closing a position to get live PnL, funding, and liquidation status.
715    ///
716    /// This is a read-only call (no transaction sent).
717    pub async fn get_live_details(&self, pos_id: U256) -> Result<LiveDetails> {
718        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
719        let result = contract.quoteClosePosition(pos_id).call().await?;
720
721        // Check for unexpected revert reason
722        if !result.unexpectedReason.is_empty() {
723            return Err(PerpCityError::TxReverted {
724                reason: format!(
725                    "quoteClosePosition reverted: 0x{}",
726                    alloy::primitives::hex::encode(&result.unexpectedReason)
727                ),
728            });
729        }
730
731        let scale = SCALE_F64;
732        Ok(LiveDetails {
733            pnl: i128_from_i256(result.pnl) as f64 / scale,
734            funding_payment: i128_from_i256(result.funding) as f64 / scale,
735            effective_margin: i128_from_i256(result.netMargin) as f64 / scale,
736            is_liquidatable: result.wasLiquidated,
737        })
738    }
739
740    /// Get taker open interest for a perp market.
741    pub async fn get_open_interest(&self, perp_id: B256) -> Result<OpenInterest> {
742        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
743        let result = contract.takerOpenInterest(perp_id).call().await?;
744
745        let scale = SCALE_F64;
746        Ok(OpenInterest {
747            long_oi: result.longOI as f64 / scale,
748            short_oi: result.shortOI as f64 / scale,
749        })
750    }
751
752    /// Simulate opening a taker position without sending a transaction.
753    ///
754    /// Returns the perp and USD deltas that would result from the trade.
755    /// Useful for estimating price impact before committing capital.
756    pub async fn quote_open_taker(
757        &self,
758        perp_id: B256,
759        params: &OpenTakerParams,
760    ) -> Result<OpenTakerQuote> {
761        let margin_scaled = scale_to_6dec(params.margin)?;
762        if margin_scaled <= 0 {
763            return Err(PerpCityError::InvalidMargin {
764                reason: format!("margin must be positive, got {}", params.margin),
765            });
766        }
767        let margin_ratio = leverage_to_margin_ratio(params.leverage)?;
768
769        let wire_params = PerpManager::OpenTakerPositionParams {
770            holder: self.address,
771            isLong: params.is_long,
772            margin: margin_scaled as u128,
773            marginRatio: u32_to_u24(margin_ratio),
774            unspecifiedAmountLimit: params.unspecified_amount_limit,
775        };
776
777        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
778        let result = contract
779            .quoteOpenTakerPosition(perp_id, wire_params)
780            .call()
781            .await?;
782
783        if !result.unexpectedReason.is_empty() {
784            return Err(PerpCityError::TxReverted {
785                reason: format!(
786                    "quoteOpenTakerPosition reverted: 0x{}",
787                    alloy::primitives::hex::encode(&result.unexpectedReason)
788                ),
789            });
790        }
791
792        let scale = SCALE_F64;
793        Ok(OpenTakerQuote {
794            perp_delta: i128_from_i256(result.perpDelta) as f64 / scale,
795            usd_delta: i128_from_i256(result.usdDelta) as f64 / scale,
796        })
797    }
798
799    /// Simulate opening a maker (LP) position without sending a transaction.
800    ///
801    /// Returns the perp and USD deltas that would result from the position.
802    pub async fn quote_open_maker(
803        &self,
804        perp_id: B256,
805        params: &OpenMakerParams,
806    ) -> Result<OpenMakerQuote> {
807        let margin_scaled = scale_to_6dec(params.margin)?;
808        if margin_scaled <= 0 {
809            return Err(PerpCityError::InvalidMargin {
810                reason: format!("margin must be positive, got {}", params.margin),
811            });
812        }
813
814        let tick_lower = align_tick_down(
815            price_to_tick(params.price_lower)?,
816            crate::constants::TICK_SPACING,
817        );
818        let tick_upper = align_tick_up(
819            price_to_tick(params.price_upper)?,
820            crate::constants::TICK_SPACING,
821        );
822
823        let wire_params = PerpManager::OpenMakerPositionParams {
824            holder: self.address,
825            margin: margin_scaled as u128,
826            tickLower: i32_to_i24(tick_lower),
827            tickUpper: i32_to_i24(tick_upper),
828            liquidity: alloy::primitives::Uint::<120, 2>::from(params.liquidity),
829            maxAmt0In: params.max_amt0_in,
830            maxAmt1In: params.max_amt1_in,
831        };
832
833        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
834        let result = contract
835            .quoteOpenMakerPosition(perp_id, wire_params)
836            .call()
837            .await?;
838
839        if !result.unexpectedReason.is_empty() {
840            return Err(PerpCityError::TxReverted {
841                reason: format!(
842                    "quoteOpenMakerPosition reverted: 0x{}",
843                    alloy::primitives::hex::encode(&result.unexpectedReason)
844                ),
845            });
846        }
847
848        let scale = SCALE_F64;
849        Ok(OpenMakerQuote {
850            perp_delta: i128_from_i256(result.perpDelta) as f64 / scale,
851            usd_delta: i128_from_i256(result.usdDelta) as f64 / scale,
852        })
853    }
854
855    /// Simulate a raw swap in a perp's Uniswap V4 pool without executing.
856    ///
857    /// This is the lowest-level quote — it simulates a single pool swap and
858    /// returns the resulting token deltas. Use this to estimate price impact
859    /// for a given trade size.
860    ///
861    /// # Arguments
862    ///
863    /// * `perp_id` — The perp market to quote against.
864    /// * `zero_for_one` — Swap direction: `true` sells token0 for token1.
865    /// * `is_exact_in` — `true` if `amount` is the exact input; `false` for exact output.
866    /// * `amount` — The swap amount (scaled to 6 decimals).
867    /// * `sqrt_price_limit_x96` — Price limit in sqrtPriceX96 format. Use `0` for no limit.
868    pub async fn quote_swap(
869        &self,
870        perp_id: B256,
871        zero_for_one: bool,
872        is_exact_in: bool,
873        amount: U256,
874        sqrt_price_limit_x96: U256,
875    ) -> Result<SwapQuote> {
876        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
877        let sqrt_limit = alloy::primitives::Uint::<160, 3>::from(sqrt_price_limit_x96);
878        let result = contract
879            .quoteSwap(perp_id, zero_for_one, is_exact_in, amount, sqrt_limit)
880            .call()
881            .await?;
882
883        if !result.unexpectedReason.is_empty() {
884            return Err(PerpCityError::TxReverted {
885                reason: format!(
886                    "quoteSwap reverted: 0x{}",
887                    alloy::primitives::hex::encode(&result.unexpectedReason)
888                ),
889            });
890        }
891
892        let scale = SCALE_F64;
893        Ok(SwapQuote {
894            perp_delta: i128_from_i256(result.perpDelta) as f64 / scale,
895            usd_delta: i128_from_i256(result.usdDelta) as f64 / scale,
896        })
897    }
898
899    /// Get the funding rate per second for a perp, converted to a daily rate.
900    ///
901    /// Uses the fast cache layer (2s TTL).
902    pub async fn get_funding_rate(&self, perp_id: B256) -> Result<f64> {
903        let now_ts = now_secs();
904        let perp_bytes: [u8; 32] = perp_id.into();
905
906        // Check cache
907        {
908            let cache = self.state_cache.lock().unwrap();
909            if let Some(rate) = cache.get_funding_rate(&perp_bytes, now_ts) {
910                tracing::trace!(%perp_id, rate, "funding rate cache hit");
911                return Ok(rate);
912            }
913        }
914
915        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
916        let funding_x96: I256 = contract.fundingPerSecondX96(perp_id).call().await?;
917
918        let daily_rate = funding_x96_to_daily(funding_x96);
919
920        tracing::debug!(%perp_id, daily_rate, "funding rate fetched");
921
922        // Update cache
923        {
924            let mut cache = self.state_cache.lock().unwrap();
925            cache.put_funding_rate(perp_bytes, daily_rate, now_ts);
926        }
927
928        Ok(daily_rate)
929    }
930
931    /// Get the USDC balance of the signer's address.
932    ///
933    /// Uses the fast cache layer (2s TTL).
934    pub async fn get_usdc_balance(&self) -> Result<f64> {
935        let now_ts = now_secs();
936
937        // Check cache
938        {
939            let cache = self.state_cache.lock().unwrap();
940            if let Some(bal) = cache.get_usdc_balance(now_ts) {
941                tracing::trace!(balance = bal, "USDC balance cache hit");
942                return Ok(bal);
943            }
944        }
945
946        let usdc = IERC20::new(self.deployments.usdc, &self.provider);
947        let raw: U256 = usdc.balanceOf(self.address).call().await?;
948        let raw_i128 = i128::try_from(raw).map_err(|_| PerpCityError::Overflow {
949            context: format!("USDC balance {} exceeds i128::MAX", raw),
950        })?;
951        let balance = scale_from_6dec(raw_i128);
952
953        tracing::debug!(balance, "USDC balance fetched");
954
955        // Update cache
956        {
957            let mut cache = self.state_cache.lock().unwrap();
958            cache.put_usdc_balance(balance, now_ts);
959        }
960
961        Ok(balance)
962    }
963
964    // ── Batch reads (via Multicall3) ──────────────────────────────────
965
966    /// Get the USDC and ETH balances of an address in a single RPC call.
967    ///
968    /// Uses Multicall3 to bundle a `balanceOf` (USDC) and `getEthBalance`
969    /// (native ETH) into one `eth_call`. The RPC provider charges 1 CU
970    /// regardless of how many sub-calls the multicall executes.
971    ///
972    /// Returns `(usdc_balance, eth_balance)` where USDC is in human units
973    /// (e.g. `100.0` = 100 USDC) and ETH is in wei.
974    pub async fn get_balances(&self, address: Address) -> Result<(f64, U256)> {
975        let results = self.get_balances_batch(&[address]).await?;
976        Ok(results.into_iter().next().unwrap())
977    }
978
979    /// Get the USDC and ETH balances for multiple addresses in a single RPC call.
980    ///
981    /// Uses Multicall3 to bundle N × `balanceOf` + N × `getEthBalance` into
982    /// one `eth_call`. For 10 addresses, this is 1 CU instead of 20.
983    ///
984    /// Returns a `Vec<(usdc_balance, eth_balance)>` in the same order as
985    /// the input addresses.
986    pub async fn get_balances_batch(&self, addresses: &[Address]) -> Result<Vec<(f64, U256)>> {
987        if addresses.is_empty() {
988            return Ok(Vec::new());
989        }
990
991        let usdc_addr = self.deployments.usdc;
992        let n = addresses.len();
993
994        // Build sub-calls: N × USDC balanceOf + N × ETH getEthBalance
995        let mut calls = Vec::with_capacity(2 * n);
996
997        for &addr in addresses {
998            // USDC balanceOf(addr)
999            let calldata = IERC20::balanceOfCall { account: addr }.abi_encode();
1000            calls.push(IMulticall3::Call3 {
1001                target: usdc_addr,
1002                allowFailure: false,
1003                callData: calldata.into(),
1004            });
1005        }
1006
1007        for &addr in addresses {
1008            // getEthBalance(addr) — Multicall3 built-in
1009            let calldata = IMulticall3::getEthBalanceCall { addr }.abi_encode();
1010            calls.push(IMulticall3::Call3 {
1011                target: MULTICALL3,
1012                allowFailure: false,
1013                callData: calldata.into(),
1014            });
1015        }
1016
1017        let multicall = IMulticall3::new(MULTICALL3, &self.provider);
1018        let results = multicall.aggregate3(calls).call().await?;
1019
1020        if results.len() != 2 * n {
1021            return Err(PerpCityError::Overflow {
1022                context: format!(
1023                    "multicall returned {} results, expected {}",
1024                    results.len(),
1025                    2 * n
1026                ),
1027            });
1028        }
1029
1030        let mut out = Vec::with_capacity(n);
1031        for i in 0..n {
1032            // Decode USDC balance (first N results)
1033            let usdc_result = &results[i];
1034            if !usdc_result.success {
1035                return Err(PerpCityError::Overflow {
1036                    context: format!("USDC balanceOf failed for address {}", addresses[i]),
1037                });
1038            }
1039            let usdc_raw =
1040                U256::abi_decode(&usdc_result.returnData).map_err(|e| PerpCityError::Overflow {
1041                    context: format!("failed to decode USDC balance: {e}"),
1042                })?;
1043            let usdc_i128 = i128::try_from(usdc_raw).map_err(|_| PerpCityError::Overflow {
1044                context: format!("USDC balance {} exceeds i128::MAX", usdc_raw),
1045            })?;
1046            let usdc = scale_from_6dec(usdc_i128);
1047
1048            // Decode ETH balance (last N results)
1049            let eth_result = &results[n + i];
1050            if !eth_result.success {
1051                return Err(PerpCityError::Overflow {
1052                    context: format!("getEthBalance failed for address {}", addresses[i]),
1053                });
1054            }
1055            let eth =
1056                U256::abi_decode(&eth_result.returnData).map_err(|e| PerpCityError::Overflow {
1057                    context: format!("failed to decode ETH balance: {e}"),
1058                })?;
1059
1060            out.push((usdc, eth));
1061        }
1062
1063        tracing::debug!(count = n, "batch balances fetched via multicall");
1064        Ok(out)
1065    }
1066
1067    /// Get perp config and live market data in two multicalls (2 CUs total).
1068    ///
1069    /// Phase 1 multicalls `cfgs` + `timeWeightedAvgSqrtPriceX96` +
1070    /// `fundingPerSecondX96` + `takerOpenInterest` against PerpManager
1071    /// (4 reads → 1 CU). Phase 2 calls `index()` on the beacon returned
1072    /// by phase 1 (1 CU).
1073    ///
1074    /// Returns `(PerpData, PerpSnapshot)` — static config and live market
1075    /// data. Replaces the typical startup sequence of 5+ individual RPCs.
1076    pub async fn get_perp_snapshot(&self, perp_id: B256) -> Result<(PerpData, PerpSnapshot)> {
1077        let pm = self.deployments.perp_manager;
1078
1079        // Phase 1: multicall cfgs + mark + funding + OI against PerpManager
1080        let calls = vec![
1081            IMulticall3::Call3 {
1082                target: pm,
1083                allowFailure: false,
1084                callData: PerpManager::cfgsCall { perpId: perp_id }
1085                    .abi_encode()
1086                    .into(),
1087            },
1088            IMulticall3::Call3 {
1089                target: pm,
1090                allowFailure: false,
1091                callData: PerpManager::timeWeightedAvgSqrtPriceX96Call {
1092                    perpId: perp_id,
1093                    lookbackWindow: 1,
1094                }
1095                .abi_encode()
1096                .into(),
1097            },
1098            IMulticall3::Call3 {
1099                target: pm,
1100                allowFailure: false,
1101                callData: PerpManager::fundingPerSecondX96Call { perpId: perp_id }
1102                    .abi_encode()
1103                    .into(),
1104            },
1105            IMulticall3::Call3 {
1106                target: pm,
1107                allowFailure: false,
1108                callData: PerpManager::takerOpenInterestCall { perpId: perp_id }
1109                    .abi_encode()
1110                    .into(),
1111            },
1112        ];
1113
1114        let multicall = IMulticall3::new(MULTICALL3, &self.provider);
1115        let results = multicall.aggregate3(calls).call().await?;
1116
1117        if results.len() != 4 {
1118            return Err(PerpCityError::Overflow {
1119                context: format!(
1120                    "perp snapshot multicall returned {} results, expected 4",
1121                    results.len()
1122                ),
1123            });
1124        }
1125
1126        let call_names = [
1127            "cfgs",
1128            "timeWeightedAvgSqrtPriceX96",
1129            "fundingPerSecondX96",
1130            "takerOpenInterest",
1131        ];
1132        for (i, name) in call_names.iter().enumerate() {
1133            if !results[i].success {
1134                return Err(PerpCityError::Overflow {
1135                    context: format!("perp snapshot multicall: {name} call failed"),
1136                });
1137            }
1138        }
1139
1140        // Decode cfgs
1141        let config = PerpManager::PerpConfig::abi_decode(&results[0].returnData).map_err(|e| {
1142            PerpCityError::Overflow {
1143                context: format!("failed to decode PerpConfig: {e}"),
1144            }
1145        })?;
1146
1147        if config.beacon == Address::ZERO {
1148            return Err(PerpCityError::PerpNotFound { perp_id });
1149        }
1150
1151        // Decode mark price
1152        let sqrt_price_x96 =
1153            U256::abi_decode(&results[1].returnData).map_err(|e| PerpCityError::Overflow {
1154                context: format!("failed to decode mark price: {e}"),
1155            })?;
1156        let mark = crate::convert::sqrt_price_x96_to_price(sqrt_price_x96)?;
1157
1158        // Decode funding rate
1159        let funding_x96 =
1160            I256::abi_decode(&results[2].returnData).map_err(|e| PerpCityError::Overflow {
1161                context: format!("failed to decode funding rate: {e}"),
1162            })?;
1163        let funding_rate_daily = funding_x96_to_daily(funding_x96);
1164
1165        // Decode OI — takerOpenInterest returns (uint128 longOI, uint128 shortOI)
1166        let (long_oi, short_oi) =
1167            <(u128, u128)>::abi_decode(&results[3].returnData).map_err(|e| {
1168                PerpCityError::Overflow {
1169                    context: format!("failed to decode open interest: {e}"),
1170                }
1171            })?;
1172        let open_interest = OpenInterest {
1173            long_oi: long_oi as f64 / SCALE_F64,
1174            short_oi: short_oi as f64 / SCALE_F64,
1175        };
1176
1177        // Phase 2: fetch index price from beacon (1 CU)
1178        let index_price = self.get_index_price(config.beacon).await?;
1179
1180        // Build PerpData (fetch fees/bounds from cache or chain)
1181        let fees = self.get_or_fetch_fees(&config).await?;
1182        let bounds = self.get_or_fetch_bounds(&config).await?;
1183
1184        let perp_data = PerpData {
1185            id: perp_id,
1186            tick_spacing: i24_to_i32(config.key.tickSpacing),
1187            mark,
1188            beacon: config.beacon,
1189            bounds,
1190            fees,
1191        };
1192
1193        let snapshot = PerpSnapshot {
1194            mark_price: mark,
1195            index_price,
1196            funding_rate_daily,
1197            open_interest,
1198        };
1199
1200        tracing::debug!(%perp_id, "perp snapshot fetched via multicall");
1201        Ok((perp_data, snapshot))
1202    }
1203
1204    // ── Accessors ────────────────────────────────────────────────────
1205
1206    /// The signer's Ethereum address.
1207    pub fn address(&self) -> Address {
1208        self.address
1209    }
1210
1211    /// The deployed contract addresses.
1212    pub fn deployments(&self) -> &Deployments {
1213        &self.deployments
1214    }
1215
1216    /// The underlying Alloy provider (for advanced queries).
1217    pub fn provider(&self) -> &RootProvider<Ethereum> {
1218        &self.provider
1219    }
1220
1221    /// The signing wallet (for building signed transactions outside the SDK).
1222    pub fn wallet(&self) -> &EthereumWallet {
1223        &self.wallet
1224    }
1225
1226    /// The underlying HFT transport (for health diagnostics).
1227    pub fn transport(&self) -> &HftTransport {
1228        &self.transport
1229    }
1230
1231    /// Invalidate the fast cache layer (prices, funding, balance).
1232    ///
1233    /// Call on new-block events to ensure fresh data.
1234    pub fn invalidate_fast_cache(&self) {
1235        let mut cache = self.state_cache.lock().unwrap();
1236        cache.invalidate_fast_layer();
1237    }
1238
1239    /// Invalidate all cached state.
1240    pub fn invalidate_all_cache(&self) {
1241        let mut cache = self.state_cache.lock().unwrap();
1242        cache.invalidate_all();
1243    }
1244
1245    /// Confirm a transaction as mined. Removes from in-flight tracking.
1246    pub fn confirm_tx(&self, tx_hash: &[u8; 32]) {
1247        let mut pipeline = self.pipeline.lock().unwrap();
1248        pipeline.confirm(tx_hash);
1249    }
1250
1251    /// Mark a transaction as failed. Releases the nonce if possible.
1252    pub fn fail_tx(&self, tx_hash: &[u8; 32]) {
1253        let mut pipeline = self.pipeline.lock().unwrap();
1254        pipeline.fail(tx_hash);
1255    }
1256
1257    /// Number of currently in-flight (unconfirmed) transactions.
1258    pub fn in_flight_count(&self) -> usize {
1259        let pipeline = self.pipeline.lock().unwrap();
1260        pipeline.in_flight_count()
1261    }
1262
1263    // ── Internal helpers ─────────────────────────────────────────────
1264
1265    // ── Transfer helpers ─────────────────────────────────────────────
1266
1267    /// Transfer ETH to an address. Uses the transaction pipeline for
1268    /// correct nonce management.
1269    pub async fn transfer_eth(
1270        &self,
1271        to: Address,
1272        amount_wei: u128,
1273        urgency: Urgency,
1274    ) -> Result<B256> {
1275        tracing::info!(%to, amount_wei, ?urgency, "transferring ETH");
1276        let receipt = self
1277            .send_tx_with_value(
1278                to,
1279                Bytes::new(),
1280                amount_wei,
1281                Some(GasLimits::ETH_TRANSFER),
1282                urgency,
1283            )
1284            .await?;
1285        tracing::info!(tx_hash = %receipt.transaction_hash, "ETH transferred");
1286        Ok(receipt.transaction_hash)
1287    }
1288
1289    /// Transfer USDC to an address. `amount` is in human units (e.g. 100.0 = 100 USDC).
1290    /// Uses the transaction pipeline for correct nonce management.
1291    pub async fn transfer_usdc(&self, to: Address, amount: f64, urgency: Urgency) -> Result<B256> {
1292        tracing::info!(%to, amount, ?urgency, "transferring USDC");
1293        let usdc = IERC20::new(self.deployments.usdc, &self.provider);
1294        let scaled = U256::from(scale_to_6dec(amount)? as u128);
1295        let calldata = usdc.transfer(to, scaled).calldata().clone();
1296        let receipt = self
1297            .send_tx(self.deployments.usdc, calldata, None, urgency)
1298            .await?;
1299        tracing::info!(tx_hash = %receipt.transaction_hash, "USDC transferred");
1300        Ok(receipt.transaction_hash)
1301    }
1302
1303    // ── Internal helpers ─────────────────────────────────────────────
1304
1305    /// Prepare, sign, send, and wait for a transaction receipt.
1306    ///
1307    /// If `gas_limit` is `None`, the gas limit is resolved from the
1308    /// estimate cache (keyed by 4-byte selector) or via `eth_estimateGas`.
1309    async fn send_tx(
1310        &self,
1311        to: Address,
1312        calldata: Bytes,
1313        gas_limit: Option<u64>,
1314        urgency: Urgency,
1315    ) -> Result<alloy::rpc::types::TransactionReceipt> {
1316        self.send_tx_with_value(to, calldata, 0, gas_limit, urgency)
1317            .await
1318    }
1319
1320    /// Like `send_tx` but with an explicit ETH value to attach.
1321    async fn send_tx_with_value(
1322        &self,
1323        to: Address,
1324        calldata: Bytes,
1325        value: u128,
1326        gas_limit: Option<u64>,
1327        urgency: Urgency,
1328    ) -> Result<alloy::rpc::types::TransactionReceipt> {
1329        let now = now_ms();
1330
1331        // Resolve gas limit: explicit override → cached estimate → eth_estimateGas
1332        let resolved_gas_limit = match gas_limit {
1333            Some(limit) => limit,
1334            None => self.resolve_gas_limit(to, &calldata, value, now).await?,
1335        };
1336
1337        // Prepare via pipeline (zero RPC)
1338        let prepared = {
1339            let pipeline = self.pipeline.lock().unwrap();
1340            let fee_cache = self.fee_cache.lock().unwrap();
1341            pipeline.prepare(
1342                TxRequest {
1343                    to: to.into_array(),
1344                    calldata: calldata.to_vec(),
1345                    value,
1346                    gas_limit: resolved_gas_limit,
1347                    urgency,
1348                },
1349                &fee_cache,
1350                now,
1351            )?
1352        };
1353
1354        tracing::debug!(
1355            nonce = prepared.nonce,
1356            gas_limit = prepared.gas_limit,
1357            max_fee = prepared.gas_fees.max_fee_per_gas,
1358            priority_fee = prepared.gas_fees.max_priority_fee_per_gas,
1359            %to,
1360            ?urgency,
1361            "tx prepared"
1362        );
1363
1364        // Build EIP-1559 transaction
1365        let tx = TransactionRequest::default()
1366            .with_to(to)
1367            .with_input(calldata)
1368            .with_value(U256::from(prepared.request.value))
1369            .with_nonce(prepared.nonce)
1370            .with_gas_limit(prepared.gas_limit)
1371            .with_max_fee_per_gas(prepared.gas_fees.max_fee_per_gas as u128)
1372            .with_max_priority_fee_per_gas(prepared.gas_fees.max_priority_fee_per_gas as u128)
1373            .with_chain_id(self.chain_id);
1374
1375        // Sign and send
1376        let tx_envelope = tx
1377            .build(&self.wallet)
1378            .await
1379            .map_err(|e| PerpCityError::TxReverted {
1380                reason: format!("failed to sign transaction: {e}"),
1381            })?;
1382
1383        let pending = self.provider.send_tx_envelope(tx_envelope).await?;
1384        let tx_hash_b256 = *pending.tx_hash();
1385        let tx_hash_bytes: [u8; 32] = tx_hash_b256.into();
1386
1387        tracing::info!(tx_hash = %tx_hash_b256, nonce = prepared.nonce, ?urgency, "tx broadcast");
1388
1389        // Record in pipeline
1390        {
1391            let mut pipeline = self.pipeline.lock().unwrap();
1392            pipeline.record_submission(tx_hash_bytes, prepared, now);
1393        }
1394
1395        // Wait for receipt
1396        let receipt = tokio::time::timeout(RECEIPT_TIMEOUT, pending.get_receipt())
1397            .await
1398            .map_err(|_| {
1399                tracing::warn!(tx_hash = %tx_hash_b256, timeout_secs = RECEIPT_TIMEOUT.as_secs(), "receipt timeout");
1400                PerpCityError::TxReverted {
1401                    reason: format!("receipt timeout after {}s", RECEIPT_TIMEOUT.as_secs()),
1402                }
1403            })?
1404            .map_err(|e| PerpCityError::TxReverted {
1405                reason: format!("failed to get receipt: {e}"),
1406            })?;
1407
1408        // Confirm in pipeline
1409        {
1410            let mut pipeline = self.pipeline.lock().unwrap();
1411            pipeline.confirm(&tx_hash_bytes);
1412        }
1413
1414        // Check if reverted
1415        if !receipt.status() {
1416            tracing::warn!(tx_hash = %tx_hash_b256, "tx reverted");
1417            return Err(PerpCityError::TxReverted {
1418                reason: format!("transaction {} reverted", tx_hash_b256),
1419            });
1420        }
1421
1422        tracing::info!(
1423            tx_hash = %tx_hash_b256,
1424            block = ?receipt.block_number,
1425            gas_used = ?receipt.gas_used,
1426            "tx confirmed"
1427        );
1428
1429        Ok(receipt)
1430    }
1431
1432    /// Resolve gas limit from cache or via `eth_estimateGas`.
1433    ///
1434    /// Extracts the 4-byte function selector from calldata, checks the
1435    /// estimate cache, and falls back to an RPC call on cache miss.
1436    async fn resolve_gas_limit(
1437        &self,
1438        to: Address,
1439        calldata: &Bytes,
1440        value: u128,
1441        now: u64,
1442    ) -> Result<u64> {
1443        // Extract selector (first 4 bytes of calldata)
1444        if calldata.len() < 4 {
1445            return Err(PerpCityError::InvalidConfig {
1446                reason: "calldata too short to extract function selector".into(),
1447            });
1448        }
1449        let selector: [u8; 4] = calldata[..4].try_into().unwrap();
1450
1451        // Check cache
1452        {
1453            let cache = self.gas_limit_cache.lock().unwrap();
1454            if let Some(limit) = cache.get(&selector, now) {
1455                tracing::trace!(selector = %alloy::primitives::hex::encode(selector), limit, "gas estimate cache hit");
1456                return Ok(limit);
1457            }
1458        }
1459
1460        // Cache miss — call eth_estimateGas
1461        let tx = TransactionRequest::default()
1462            .with_from(self.address)
1463            .with_to(to)
1464            .with_input(calldata.clone())
1465            .with_value(U256::from(value));
1466
1467        let raw_estimate = self.provider.estimate_gas(tx).await.map_err(|e| {
1468            PerpCityError::GasPriceUnavailable {
1469                reason: format!("eth_estimateGas failed: {e}"),
1470            }
1471        })?;
1472
1473        // Cache with buffer
1474        {
1475            let mut cache = self.gas_limit_cache.lock().unwrap();
1476            cache.put(selector, raw_estimate, now);
1477        }
1478
1479        let buffered = {
1480            let cache = self.gas_limit_cache.lock().unwrap();
1481            cache.get(&selector, now).unwrap()
1482        };
1483
1484        tracing::debug!(
1485            selector = %alloy::primitives::hex::encode(selector),
1486            raw_estimate,
1487            buffered,
1488            "gas estimate cached"
1489        );
1490
1491        Ok(buffered)
1492    }
1493
1494    /// Get fees from cache or fetch from chain.
1495    async fn get_or_fetch_fees(&self, config: &PerpManager::PerpConfig) -> Result<Fees> {
1496        let now_ts = now_secs();
1497        let fees_addr: [u8; 20] = config.fees.into();
1498
1499        let cached = {
1500            let cache = self.state_cache.lock().unwrap();
1501            cache.get_fees(&fees_addr, now_ts).cloned()
1502        };
1503
1504        match cached {
1505            Some(cached) => Ok(Fees::from(cached)),
1506            None => {
1507                let fees = self.fetch_fees(config).await?;
1508                let mut cache = self.state_cache.lock().unwrap();
1509                cache.put_fees(fees_addr, CachedFees::from(fees), now_ts);
1510                Ok(fees)
1511            }
1512        }
1513    }
1514
1515    /// Get bounds from cache or fetch from chain.
1516    async fn get_or_fetch_bounds(&self, config: &PerpManager::PerpConfig) -> Result<Bounds> {
1517        let now_ts = now_secs();
1518        let ratios_addr: [u8; 20] = config.marginRatios.into();
1519
1520        let cached = {
1521            let cache = self.state_cache.lock().unwrap();
1522            cache.get_bounds(&ratios_addr, now_ts).cloned()
1523        };
1524
1525        match cached {
1526            Some(cached) => Ok(Bounds::from(cached)),
1527            None => {
1528                let bounds = self.fetch_bounds(config).await?;
1529                let mut cache = self.state_cache.lock().unwrap();
1530                cache.put_bounds(ratios_addr, CachedBounds::from(bounds), now_ts);
1531                Ok(bounds)
1532            }
1533        }
1534    }
1535
1536    /// Fetch fees from the IFees module contract.
1537    async fn fetch_fees(&self, config: &PerpManager::PerpConfig) -> Result<Fees> {
1538        if config.fees == Address::ZERO {
1539            return Err(PerpCityError::ModuleNotRegistered {
1540                module: "IFees".into(),
1541            });
1542        }
1543
1544        let fees_contract = IFees::new(config.fees, &self.provider);
1545
1546        let fee_result = fees_contract.fees(config.clone()).call().await?;
1547        let c_fee = u24_to_u32(fee_result.cFee);
1548        let ins_fee = u24_to_u32(fee_result.insFee);
1549        let lp_fee = u24_to_u32(fee_result.lpFee);
1550
1551        let liq_result = fees_contract.liquidationFee(config.clone()).call().await?;
1552        let liq_fee = u24_to_u32(liq_result);
1553
1554        let scale = SCALE_F64;
1555        Ok(Fees {
1556            creator_fee: c_fee as f64 / scale,
1557            insurance_fee: ins_fee as f64 / scale,
1558            lp_fee: lp_fee as f64 / scale,
1559            liquidation_fee: liq_fee as f64 / scale,
1560        })
1561    }
1562
1563    /// Fetch margin ratio bounds from the IMarginRatios module contract.
1564    async fn fetch_bounds(&self, config: &PerpManager::PerpConfig) -> Result<Bounds> {
1565        if config.marginRatios == Address::ZERO {
1566            return Err(PerpCityError::ModuleNotRegistered {
1567                module: "IMarginRatios".into(),
1568            });
1569        }
1570
1571        let ratios_contract = IMarginRatios::new(config.marginRatios, &self.provider);
1572
1573        let ratios: IMarginRatios::MarginRatios = ratios_contract
1574            .marginRatios(config.clone(), false) // isMaker = false for taker bounds
1575            .call()
1576            .await?;
1577
1578        let scale = SCALE_F64;
1579        Ok(Bounds {
1580            min_margin: scale_from_6dec(crate::constants::MIN_OPENING_MARGIN as i128),
1581            min_taker_leverage: margin_ratio_to_leverage(u24_to_u32(ratios.max))?,
1582            max_taker_leverage: margin_ratio_to_leverage(u24_to_u32(ratios.min))?,
1583            liquidation_taker_ratio: u24_to_u32(ratios.liq) as f64 / scale,
1584        })
1585    }
1586}
1587
1588// ── Type conversion helpers for Alloy fixed-size types ───────────────
1589
1590/// Convert a u32 margin ratio to Alloy's uint24 type.
1591#[inline]
1592fn u32_to_u24(v: u32) -> alloy::primitives::Uint<24, 1> {
1593    alloy::primitives::Uint::<24, 1>::from(v & 0xFF_FFFF)
1594}
1595
1596/// Convert Alloy's uint24 to a u32.
1597#[inline]
1598fn u24_to_u32(v: alloy::primitives::Uint<24, 1>) -> u32 {
1599    v.to::<u32>()
1600}
1601
1602/// Convert an i32 tick to Alloy's int24 type.
1603#[inline]
1604fn i32_to_i24(v: i32) -> alloy::primitives::Signed<24, 1> {
1605    alloy::primitives::Signed::<24, 1>::try_from(v as i64).unwrap_or(if v < 0 {
1606        alloy::primitives::Signed::<24, 1>::MIN
1607    } else {
1608        alloy::primitives::Signed::<24, 1>::MAX
1609    })
1610}
1611
1612/// Convert Alloy's int24 to an i32.
1613#[inline]
1614fn i24_to_i32(v: alloy::primitives::Signed<24, 1>) -> i32 {
1615    // int24 always fits in i32
1616    v.as_i32()
1617}
1618
1619// ── Utility functions ────────────────────────────────────────────────
1620
1621/// Get current time in milliseconds.
1622fn now_ms() -> u64 {
1623    SystemTime::now()
1624        .duration_since(UNIX_EPOCH)
1625        .unwrap_or_default()
1626        .as_millis() as u64
1627}
1628
1629/// Get current time in seconds (for state cache).
1630fn now_secs() -> u64 {
1631    SystemTime::now()
1632        .duration_since(UNIX_EPOCH)
1633        .unwrap_or_default()
1634        .as_secs()
1635}
1636
1637/// Convert an I256 to i128 (clamping to i128::MIN/MAX on overflow).
1638#[inline]
1639fn i128_from_i256(v: I256) -> i128 {
1640    i128::try_from(v).unwrap_or_else(|_| {
1641        if v.is_negative() {
1642            i128::MIN
1643        } else {
1644            i128::MAX
1645        }
1646    })
1647}
1648
1649/// Scale an unsigned `U256` from 6-decimal on-chain representation to `f64`.
1650fn u256_to_f64_6dec(v: U256) -> f64 {
1651    v.to::<u128>() as f64 / 1_000_000.0
1652}
1653
1654/// Parse an [`OpenResult`] from a transaction receipt's `PositionOpened` event.
1655fn parse_open_result(receipt: &alloy::rpc::types::TransactionReceipt) -> Result<OpenResult> {
1656    for log in receipt.inner.logs() {
1657        if let Ok(event) = log.log_decode::<PerpManager::PositionOpened>() {
1658            let data = event.inner.data;
1659            let perp_delta = i128_from_i256(data.perpDelta);
1660            let usd_delta = i128_from_i256(data.usdDelta);
1661            return Ok(OpenResult {
1662                pos_id: data.posId,
1663                is_maker: data.isMaker,
1664                perp_delta: scale_from_6dec(perp_delta),
1665                usd_delta: scale_from_6dec(usd_delta),
1666                tick_lower: i24_to_i32(data.tickLower),
1667                tick_upper: i24_to_i32(data.tickUpper),
1668            });
1669        }
1670    }
1671    Err(PerpCityError::EventNotFound {
1672        event_name: "PositionOpened".into(),
1673    })
1674}
1675
1676/// Parse an [`AdjustNotionalResult`] from a transaction receipt's `NotionalAdjusted` event.
1677fn parse_adjust_result(
1678    receipt: &alloy::rpc::types::TransactionReceipt,
1679) -> Result<AdjustNotionalResult> {
1680    for log in receipt.inner.logs() {
1681        if let Ok(event) = log.log_decode::<PerpManager::NotionalAdjusted>() {
1682            let data = event.inner.data;
1683            return Ok(AdjustNotionalResult {
1684                new_perp_delta: scale_from_6dec(i128_from_i256(data.newPerpDelta)),
1685                swap_perp_delta: scale_from_6dec(i128_from_i256(data.swapPerpDelta)),
1686                swap_usd_delta: scale_from_6dec(i128_from_i256(data.swapUsdDelta)),
1687                funding: scale_from_6dec(i128_from_i256(data.funding)),
1688                utilization_fee: u256_to_f64_6dec(data.utilizationFee),
1689                adl: u256_to_f64_6dec(data.adl),
1690                trading_fees: u256_to_f64_6dec(data.tradingFees),
1691            });
1692        }
1693    }
1694    Err(PerpCityError::EventNotFound {
1695        event_name: "NotionalAdjusted".into(),
1696    })
1697}
1698
1699/// Parse an [`AdjustMarginResult`] from a transaction receipt's `MarginAdjusted` event.
1700fn parse_margin_result(
1701    receipt: &alloy::rpc::types::TransactionReceipt,
1702) -> Result<AdjustMarginResult> {
1703    for log in receipt.inner.logs() {
1704        if let Ok(event) = log.log_decode::<PerpManager::MarginAdjusted>() {
1705            return Ok(AdjustMarginResult {
1706                new_margin: u256_to_f64_6dec(event.inner.data.newMargin),
1707            });
1708        }
1709    }
1710    Err(PerpCityError::EventNotFound {
1711        event_name: "MarginAdjusted".into(),
1712    })
1713}
1714
1715/// Parse a [`CloseResult`] from a transaction receipt's `PositionClosed` event.
1716fn parse_close_result(
1717    receipt: &alloy::rpc::types::TransactionReceipt,
1718    pos_id: U256,
1719) -> Result<CloseResult> {
1720    let tx_hash = receipt.transaction_hash;
1721    for log in receipt.inner.logs() {
1722        if let Ok(event) = log.log_decode::<PerpManager::PositionClosed>() {
1723            let data = event.inner.data;
1724            return Ok(CloseResult {
1725                tx_hash,
1726                was_maker: data.wasMaker,
1727                was_liquidated: data.wasLiquidated,
1728                remaining_position_id: if data.wasPartialClose {
1729                    Some(pos_id)
1730                } else {
1731                    None
1732                },
1733                exit_perp_delta: scale_from_6dec(i128_from_i256(data.exitPerpDelta)),
1734                exit_usd_delta: scale_from_6dec(i128_from_i256(data.exitUsdDelta)),
1735                net_usd_delta: scale_from_6dec(i128_from_i256(data.netUsdDelta)),
1736                funding: scale_from_6dec(i128_from_i256(data.funding)),
1737                utilization_fee: u256_to_f64_6dec(data.utilizationFee),
1738                adl: u256_to_f64_6dec(data.adl),
1739                liquidation_fee: u256_to_f64_6dec(data.liquidationFee),
1740                net_margin: scale_from_6dec(i128_from_i256(data.netMargin)),
1741            });
1742        }
1743    }
1744    Err(PerpCityError::EventNotFound {
1745        event_name: "PositionClosed".into(),
1746    })
1747}
1748
1749#[cfg(test)]
1750mod tests {
1751    use super::*;
1752
1753    // ── i128_from_i256 tests ─────────────────────────────────────────
1754
1755    #[test]
1756    fn i128_from_i256_small_values() {
1757        assert_eq!(i128_from_i256(I256::ZERO), 0);
1758        assert_eq!(i128_from_i256(I256::try_from(42i64).unwrap()), 42);
1759        assert_eq!(i128_from_i256(I256::try_from(-100i64).unwrap()), -100);
1760    }
1761
1762    #[test]
1763    fn i128_from_i256_boundary_values() {
1764        let max_i128 = I256::try_from(i128::MAX).unwrap();
1765        assert_eq!(i128_from_i256(max_i128), i128::MAX);
1766
1767        let min_i128 = I256::try_from(i128::MIN).unwrap();
1768        assert_eq!(i128_from_i256(min_i128), i128::MIN);
1769    }
1770
1771    #[test]
1772    fn i128_from_i256_overflow_clamps() {
1773        assert_eq!(i128_from_i256(I256::MAX), i128::MAX);
1774        assert_eq!(i128_from_i256(I256::MIN), i128::MIN);
1775    }
1776
1777    #[test]
1778    fn i128_from_i256_just_beyond_i128() {
1779        let beyond = I256::try_from(i128::MAX).unwrap() + I256::try_from(1i64).unwrap();
1780        assert_eq!(i128_from_i256(beyond), i128::MAX);
1781
1782        let below = I256::try_from(i128::MIN).unwrap() - I256::try_from(1i64).unwrap();
1783        assert_eq!(i128_from_i256(below), i128::MIN);
1784    }
1785
1786    // ── Type conversion helpers ──────────────────────────────────────
1787
1788    #[test]
1789    fn u24_roundtrip() {
1790        for v in [0u32, 1, 100_000, 0xFF_FFFF] {
1791            let u24 = u32_to_u24(v);
1792            assert_eq!(u24_to_u32(u24), v);
1793        }
1794    }
1795
1796    #[test]
1797    fn u24_truncates_overflow() {
1798        // Values > 0xFFFFFF get masked
1799        let u24 = u32_to_u24(0x1FF_FFFF);
1800        assert_eq!(u24_to_u32(u24), 0xFF_FFFF);
1801    }
1802
1803    #[test]
1804    fn i24_roundtrip() {
1805        for v in [0i32, 1, -1, 30, -30, 69_090, -69_090] {
1806            let i24 = i32_to_i24(v);
1807            assert_eq!(i24_to_i32(i24), v);
1808        }
1809    }
1810
1811    // ── Funding rate integration test ───────────────────────────────
1812
1813    #[test]
1814    fn funding_rate_x96_conversion() {
1815        let q96 = 2.0_f64.powi(96);
1816        let rate_per_sec = 0.0001;
1817        let x96_value = (rate_per_sec * q96) as i128;
1818        let i256_val = I256::try_from(x96_value).unwrap();
1819
1820        let recovered = i128_from_i256(i256_val) as f64 / q96;
1821        let daily = recovered * 86400.0;
1822
1823        assert!((recovered - rate_per_sec).abs() < 1e-10);
1824        assert!((daily - 8.64).abs() < 0.001);
1825    }
1826}