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//!         .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 crate::contracts::{IERC20, IFees, IMarginRatios, PerpManager};
50use crate::convert::{
51    leverage_to_margin_ratio, margin_ratio_to_leverage, scale_from_6dec, scale_to_6dec,
52};
53use crate::errors::{PerpCityError, Result};
54use crate::hft::gas::{GasCache, GasLimits, Urgency};
55use crate::hft::pipeline::{PipelineConfig, TxPipeline, TxRequest};
56use crate::hft::state_cache::{CachedBounds, CachedFees, StateCache, StateCacheConfig};
57use crate::math::tick::{align_tick_down, align_tick_up, price_to_tick};
58use crate::transport::provider::HftTransport;
59use crate::types::{
60    Bounds, CloseParams, CloseResult, Deployments, Fees, LiveDetails, OpenInterest,
61    OpenMakerParams, OpenTakerParams, PerpData,
62};
63
64// ── Constants ────────────────────────────────────────────────────────
65
66/// Base L2 chain ID.
67const BASE_CHAIN_ID: u64 = 8453;
68
69/// Default gas cache TTL: 2 seconds (2 Base L2 blocks).
70const DEFAULT_GAS_TTL_MS: u64 = 2_000;
71
72/// Default priority fee: 0.01 gwei.
73///
74/// Base L2 uses a single sequencer, so priority fees are near-meaningless.
75/// 10 Mwei is sufficient for reliable inclusion while keeping gas escrow low.
76const DEFAULT_PRIORITY_FEE: u64 = 10_000_000;
77
78/// Default receipt polling timeout.
79const RECEIPT_TIMEOUT: Duration = Duration::from_secs(30);
80
81/// Maximum USDC approval amount (2^256 - 1).
82const MAX_APPROVAL: U256 = U256::MAX;
83
84/// SCALE_1E6 as f64, used for converting on-chain fixed-point values.
85const SCALE_F64: f64 = SCALE_1E6 as f64;
86
87// ── From impls for cache↔client type bridging ────────────────────────
88
89impl From<CachedFees> for Fees {
90    fn from(c: CachedFees) -> Self {
91        Self {
92            creator_fee: c.creator_fee,
93            insurance_fee: c.insurance_fee,
94            lp_fee: c.lp_fee,
95            liquidation_fee: c.liquidation_fee,
96        }
97    }
98}
99
100impl From<Fees> for CachedFees {
101    fn from(f: Fees) -> Self {
102        Self {
103            creator_fee: f.creator_fee,
104            insurance_fee: f.insurance_fee,
105            lp_fee: f.lp_fee,
106            liquidation_fee: f.liquidation_fee,
107        }
108    }
109}
110
111impl From<CachedBounds> for Bounds {
112    fn from(c: CachedBounds) -> Self {
113        Self {
114            min_margin: c.min_margin,
115            min_taker_leverage: c.min_taker_leverage,
116            max_taker_leverage: c.max_taker_leverage,
117            liquidation_taker_ratio: c.liquidation_taker_ratio,
118        }
119    }
120}
121
122impl From<Bounds> for CachedBounds {
123    fn from(b: Bounds) -> Self {
124        Self {
125            min_margin: b.min_margin,
126            min_taker_leverage: b.min_taker_leverage,
127            max_taker_leverage: b.max_taker_leverage,
128            liquidation_taker_ratio: b.liquidation_taker_ratio,
129        }
130    }
131}
132
133// ── PerpClient ───────────────────────────────────────────────────────
134
135/// High-level client for the PerpCity protocol.
136///
137/// Combines transport, signing, transaction pipeline, state caching, and
138/// contract bindings into one ergonomic API. All write operations go
139/// through the [`TxPipeline`] for zero-RPC-on-hot-path nonce/gas resolution.
140/// Read operations use the [`StateCache`] to avoid redundant RPC calls.
141pub struct PerpClient {
142    /// Alloy provider wired to HftTransport (multi-endpoint, health-aware).
143    provider: RootProvider<Ethereum>,
144    /// The underlying transport (kept for health diagnostics).
145    transport: HftTransport,
146    /// Wallet for signing transactions.
147    wallet: EthereumWallet,
148    /// The signer's address.
149    address: Address,
150    /// Deployed contract addresses.
151    deployments: Deployments,
152    /// Chain ID for transaction building.
153    chain_id: u64,
154    /// Transaction pipeline (nonce + gas). Mutex for interior mutability.
155    pipeline: Mutex<TxPipeline>,
156    /// Gas fee cache, updated from block headers.
157    gas_cache: Mutex<GasCache>,
158    /// Multi-layer state cache for on-chain reads.
159    state_cache: Mutex<StateCache>,
160}
161
162impl std::fmt::Debug for PerpClient {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        f.debug_struct("PerpClient")
165            .field("address", &self.address)
166            .field("chain_id", &self.chain_id)
167            .field("deployments", &self.deployments)
168            .finish_non_exhaustive()
169    }
170}
171
172impl PerpClient {
173    /// Create a new PerpClient.
174    ///
175    /// - `transport`: Multi-endpoint RPC transport (from [`crate::TransportConfig`])
176    /// - `signer`: Private key for signing transactions
177    /// - `deployments`: Contract addresses for this PerpCity instance
178    /// - `chain_id`: Chain ID (8453 for Base mainnet, 84532 for Base Sepolia)
179    ///
180    /// This does NOT make any network calls. Call [`Self::refresh_gas`] and
181    /// [`Self::sync_nonce`] before submitting transactions.
182    pub fn new(
183        transport: HftTransport,
184        signer: PrivateKeySigner,
185        deployments: Deployments,
186        chain_id: u64,
187    ) -> Result<Self> {
188        let address = signer.address();
189        let wallet = EthereumWallet::from(signer);
190
191        let boxed = BoxTransport::new(transport.clone());
192        let rpc_client = RpcClient::new(boxed, true);
193        let provider = RootProvider::<Ethereum>::new(rpc_client);
194
195        Ok(Self {
196            provider,
197            transport,
198            wallet,
199            address,
200            deployments,
201            chain_id,
202            // Pipeline starts at nonce 0; call sync_nonce() before first tx
203            pipeline: Mutex::new(TxPipeline::new(0, PipelineConfig::default())),
204            gas_cache: Mutex::new(GasCache::new(DEFAULT_GAS_TTL_MS, DEFAULT_PRIORITY_FEE)),
205            state_cache: Mutex::new(StateCache::new(StateCacheConfig::default())),
206        })
207    }
208
209    /// Create a client pre-configured for Base mainnet.
210    pub fn new_base_mainnet(
211        transport: HftTransport,
212        signer: PrivateKeySigner,
213        deployments: Deployments,
214    ) -> Result<Self> {
215        Self::new(transport, signer, deployments, BASE_CHAIN_ID)
216    }
217
218    // ── Initialization ───────────────────────────────────────────────
219
220    /// Sync the nonce manager with the on-chain transaction count.
221    ///
222    /// Must be called before the first transaction. After this, the
223    /// pipeline manages nonces locally (zero RPC per transaction).
224    pub async fn sync_nonce(&self) -> Result<()> {
225        let count = self.provider.get_transaction_count(self.address).await?;
226        let mut pipeline = self.pipeline.lock().unwrap();
227        *pipeline = TxPipeline::new(count, PipelineConfig::default());
228        Ok(())
229    }
230
231    /// Refresh the gas cache from the latest block header.
232    ///
233    /// Should be called periodically (every 1-2 seconds on Base L2) or
234    /// from a `newHeads` subscription callback.
235    pub async fn refresh_gas(&self) -> Result<()> {
236        let block_num = self.provider.get_block_number().await?;
237        let header = self
238            .provider
239            .get_block_by_number(block_num.into())
240            .await?
241            .ok_or_else(|| PerpCityError::GasPriceUnavailable {
242                reason: format!("block {block_num} not found"),
243            })?;
244
245        let base_fee =
246            header
247                .header
248                .base_fee_per_gas
249                .ok_or_else(|| PerpCityError::GasPriceUnavailable {
250                    reason: "block has no base fee (pre-EIP-1559?)".into(),
251                })?;
252
253        let now = now_ms();
254        let mut gas_cache = self.gas_cache.lock().unwrap();
255        gas_cache.update(base_fee, now);
256        Ok(())
257    }
258
259    // ── Write operations ─────────────────────────────────────────────
260
261    /// Open a taker (long/short) position.
262    ///
263    /// Returns the minted position NFT token ID on success.
264    ///
265    /// # Errors
266    ///
267    /// Returns [`PerpCityError::TxReverted`] if the transaction reverts,
268    /// or [`PerpCityError::EventNotFound`] if the `PositionOpened` event
269    /// is missing from the receipt.
270    pub async fn open_taker(
271        &self,
272        perp_id: B256,
273        params: &OpenTakerParams,
274        urgency: Urgency,
275    ) -> Result<U256> {
276        let margin_scaled = scale_to_6dec(params.margin)?;
277        if margin_scaled <= 0 {
278            return Err(PerpCityError::InvalidMargin {
279                reason: format!("margin must be positive, got {}", params.margin),
280            });
281        }
282        let margin_ratio = leverage_to_margin_ratio(params.leverage)?;
283
284        let wire_params = PerpManager::OpenTakerPositionParams {
285            holder: self.address,
286            isLong: params.is_long,
287            margin: margin_scaled as u128,
288            marginRatio: u32_to_u24(margin_ratio),
289            unspecifiedAmountLimit: params.unspecified_amount_limit,
290        };
291
292        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
293        let calldata = contract
294            .openTakerPos(perp_id, wire_params)
295            .calldata()
296            .clone();
297
298        let receipt = self
299            .send_tx(
300                self.deployments.perp_manager,
301                calldata,
302                GasLimits::OPEN_TAKER,
303                urgency,
304            )
305            .await?;
306
307        // Parse PositionOpened event to get posId
308        for log in receipt.inner.logs() {
309            if let Ok(event) = log.log_decode::<PerpManager::PositionOpened>() {
310                return Ok(event.inner.data.posId);
311            }
312        }
313        Err(PerpCityError::EventNotFound {
314            event_name: "PositionOpened".into(),
315        })
316    }
317
318    /// Open a maker (LP) position within a price range.
319    ///
320    /// Converts `price_lower`/`price_upper` to aligned ticks internally.
321    /// Returns the minted position NFT token ID.
322    pub async fn open_maker(
323        &self,
324        perp_id: B256,
325        params: &OpenMakerParams,
326        urgency: Urgency,
327    ) -> Result<U256> {
328        let margin_scaled = scale_to_6dec(params.margin)?;
329        if margin_scaled <= 0 {
330            return Err(PerpCityError::InvalidMargin {
331                reason: format!("margin must be positive, got {}", params.margin),
332            });
333        }
334
335        let tick_lower = align_tick_down(
336            price_to_tick(params.price_lower)?,
337            crate::constants::TICK_SPACING,
338        );
339        let tick_upper = align_tick_up(
340            price_to_tick(params.price_upper)?,
341            crate::constants::TICK_SPACING,
342        );
343
344        if tick_lower >= tick_upper {
345            return Err(PerpCityError::InvalidTickRange {
346                lower: tick_lower,
347                upper: tick_upper,
348            });
349        }
350
351        // Liquidity must fit in u120 on-chain
352        let liquidity: u128 = params.liquidity;
353        let max_u120: u128 = (1u128 << 120) - 1;
354        if liquidity > max_u120 {
355            return Err(PerpCityError::Overflow {
356                context: format!("liquidity {} exceeds uint120 max", liquidity),
357            });
358        }
359
360        let wire_params = PerpManager::OpenMakerPositionParams {
361            holder: self.address,
362            margin: margin_scaled as u128,
363            liquidity: alloy::primitives::Uint::<120, 2>::from(liquidity),
364            tickLower: i32_to_i24(tick_lower),
365            tickUpper: i32_to_i24(tick_upper),
366            maxAmt0In: params.max_amt0_in,
367            maxAmt1In: params.max_amt1_in,
368        };
369
370        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
371        let calldata = contract
372            .openMakerPos(perp_id, wire_params)
373            .calldata()
374            .clone();
375
376        let receipt = self
377            .send_tx(
378                self.deployments.perp_manager,
379                calldata,
380                GasLimits::OPEN_MAKER,
381                urgency,
382            )
383            .await?;
384
385        for log in receipt.inner.logs() {
386            if let Ok(event) = log.log_decode::<PerpManager::PositionOpened>() {
387                return Ok(event.inner.data.posId);
388            }
389        }
390        Err(PerpCityError::EventNotFound {
391            event_name: "PositionOpened".into(),
392        })
393    }
394
395    /// Close a position (taker or maker).
396    ///
397    /// Returns a [`CloseResult`] with the transaction hash and optional
398    /// remaining position ID (for partial closes).
399    pub async fn close_position(
400        &self,
401        pos_id: U256,
402        params: &CloseParams,
403        urgency: Urgency,
404    ) -> Result<CloseResult> {
405        let wire_params = PerpManager::ClosePositionParams {
406            posId: pos_id,
407            minAmt0Out: params.min_amt0_out,
408            minAmt1Out: params.min_amt1_out,
409            maxAmt1In: params.max_amt1_in,
410        };
411
412        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
413        let calldata = contract.closePosition(wire_params).calldata().clone();
414
415        let receipt = self
416            .send_tx(
417                self.deployments.perp_manager,
418                calldata,
419                GasLimits::CLOSE_POSITION,
420                urgency,
421            )
422            .await?;
423
424        let tx_hash = receipt.transaction_hash;
425
426        // Parse PositionClosed to check for partial close
427        for log in receipt.inner.logs() {
428            if let Ok(event) = log.log_decode::<PerpManager::PositionClosed>() {
429                let was_partial = event.inner.data.wasPartialClose;
430                return Ok(CloseResult {
431                    tx_hash,
432                    remaining_position_id: if was_partial { Some(pos_id) } else { None },
433                });
434            }
435        }
436        Err(PerpCityError::EventNotFound {
437            event_name: "PositionClosed".into(),
438        })
439    }
440
441    /// Adjust the notional exposure of a taker position.
442    ///
443    /// - `usd_delta > 0`: increase notional (add exposure)
444    /// - `usd_delta < 0`: decrease notional (reduce exposure)
445    pub async fn adjust_notional(
446        &self,
447        pos_id: U256,
448        usd_delta: f64,
449        perp_limit: u128,
450        urgency: Urgency,
451    ) -> Result<B256> {
452        let usd_delta_scaled = scale_to_6dec(usd_delta)?;
453
454        let wire_params = PerpManager::AdjustNotionalParams {
455            posId: pos_id,
456            usdDelta: I256::try_from(usd_delta_scaled).map_err(|_| PerpCityError::Overflow {
457                context: format!("usd_delta {} overflows I256", usd_delta_scaled),
458            })?,
459            perpLimit: perp_limit,
460        };
461
462        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
463        let calldata = contract.adjustNotional(wire_params).calldata().clone();
464
465        let receipt = self
466            .send_tx(
467                self.deployments.perp_manager,
468                calldata,
469                GasLimits::ADJUST_NOTIONAL,
470                urgency,
471            )
472            .await?;
473
474        Ok(receipt.transaction_hash)
475    }
476
477    /// Add or remove margin from a position.
478    ///
479    /// - `margin_delta > 0`: deposit more margin
480    /// - `margin_delta < 0`: withdraw margin
481    pub async fn adjust_margin(
482        &self,
483        pos_id: U256,
484        margin_delta: f64,
485        urgency: Urgency,
486    ) -> Result<B256> {
487        let delta_scaled = scale_to_6dec(margin_delta)?;
488
489        let wire_params = PerpManager::AdjustMarginParams {
490            posId: pos_id,
491            marginDelta: I256::try_from(delta_scaled).map_err(|_| PerpCityError::Overflow {
492                context: format!("margin_delta {} overflows I256", delta_scaled),
493            })?,
494        };
495
496        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
497        let calldata = contract.adjustMargin(wire_params).calldata().clone();
498
499        let receipt = self
500            .send_tx(
501                self.deployments.perp_manager,
502                calldata,
503                GasLimits::ADJUST_MARGIN,
504                urgency,
505            )
506            .await?;
507
508        Ok(receipt.transaction_hash)
509    }
510
511    /// Ensure USDC is approved for the PerpManager to spend.
512    ///
513    /// Checks current allowance and only sends an `approve` transaction
514    /// if the allowance is below `min_amount`. Approves for `U256::MAX`
515    /// (infinite approval) to avoid repeated approve calls.
516    pub async fn ensure_approval(&self, min_amount: U256) -> Result<Option<B256>> {
517        let usdc = IERC20::new(self.deployments.usdc, &self.provider);
518        let allowance: U256 = usdc
519            .allowance(self.address, self.deployments.perp_manager)
520            .call()
521            .await?;
522
523        if allowance >= min_amount {
524            return Ok(None);
525        }
526
527        let calldata = usdc
528            .approve(self.deployments.perp_manager, MAX_APPROVAL)
529            .calldata()
530            .clone();
531
532        let receipt = self
533            .send_tx(
534                self.deployments.usdc,
535                calldata,
536                GasLimits::APPROVE,
537                Urgency::Normal,
538            )
539            .await?;
540
541        Ok(Some(receipt.transaction_hash))
542    }
543
544    // ── Read operations ──────────────────────────────────────────────
545
546    /// Get the full perp configuration, fees, and bounds for a market.
547    ///
548    /// Uses the [`StateCache`] for fees and bounds (60s TTL). The perp
549    /// config itself is always fetched fresh (it's cheap and rarely changes).
550    pub async fn get_perp_config(&self, perp_id: B256) -> Result<PerpData> {
551        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
552
553        // Fetch perp config — sol!(rpc) returns the struct directly
554        let config: PerpManager::PerpConfig = contract.cfgs(perp_id).call().await?;
555        let beacon = config.beacon;
556
557        // Fetch mark price via TWAP (short window = ~current price)
558        let sqrt_price_x96: U256 = contract
559            .timeWeightedAvgSqrtPriceX96(perp_id, 1)
560            .call()
561            .await?;
562        let mark = crate::convert::sqrt_price_x96_to_price(sqrt_price_x96)?;
563
564        let now_ts = now_secs();
565        let fees_addr: [u8; 20] = config.fees.into();
566
567        // Try cache for fees
568        let fees = {
569            let cache = self.state_cache.lock().unwrap();
570            cache.get_fees(&fees_addr, now_ts).cloned()
571        };
572
573        let fees = match fees {
574            Some(cached) => Fees::from(cached),
575            None => {
576                let fees = self.fetch_fees(&config).await?;
577                let mut cache = self.state_cache.lock().unwrap();
578                cache.put_fees(fees_addr, CachedFees::from(fees), now_ts);
579                fees
580            }
581        };
582
583        // Try cache for bounds
584        let ratios_addr: [u8; 20] = config.marginRatios.into();
585        let bounds = {
586            let cache = self.state_cache.lock().unwrap();
587            cache.get_bounds(&ratios_addr, now_ts).cloned()
588        };
589
590        let bounds = match bounds {
591            Some(cached) => Bounds::from(cached),
592            None => {
593                let bounds = self.fetch_bounds(&config).await?;
594                let mut cache = self.state_cache.lock().unwrap();
595                cache.put_bounds(ratios_addr, CachedBounds::from(bounds), now_ts);
596                bounds
597            }
598        };
599
600        Ok(PerpData {
601            id: perp_id,
602            tick_spacing: i24_to_i32(config.key.tickSpacing),
603            mark,
604            beacon,
605            bounds,
606            fees,
607        })
608    }
609
610    /// Get perp data: beacon, tick spacing, and current mark price.
611    ///
612    /// Lighter-weight than [`Self::get_perp_config`] — skips fees/bounds lookups.
613    pub async fn get_perp_data(&self, perp_id: B256) -> Result<(Address, i32, f64)> {
614        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
615        let config: PerpManager::PerpConfig = contract.cfgs(perp_id).call().await?;
616
617        let sqrt_price_x96: U256 = contract
618            .timeWeightedAvgSqrtPriceX96(perp_id, 1)
619            .call()
620            .await?;
621        let mark = crate::convert::sqrt_price_x96_to_price(sqrt_price_x96)?;
622
623        Ok((config.beacon, i24_to_i32(config.key.tickSpacing), mark))
624    }
625
626    /// Get an on-chain position by its NFT token ID.
627    ///
628    /// Returns the raw contract position struct. Use [`crate::math::position`]
629    /// functions to compute derived values (entry price, PnL, etc.).
630    pub async fn get_position(&self, pos_id: U256) -> Result<PerpManager::Position> {
631        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
632        let pos: PerpManager::Position = contract.positions(pos_id).call().await?;
633
634        // Check if position exists (empty perpId = uninitialized)
635        if pos.perpId == B256::ZERO {
636            return Err(PerpCityError::PositionNotFound { pos_id });
637        }
638
639        Ok(pos)
640    }
641
642    /// Get the current mark price for a perp (TWAP with 1-second lookback).
643    ///
644    /// Uses the fast cache layer (2s TTL).
645    pub async fn get_mark_price(&self, perp_id: B256) -> Result<f64> {
646        let now_ts = now_secs();
647        let perp_bytes: [u8; 32] = perp_id.into();
648
649        // Check cache
650        {
651            let cache = self.state_cache.lock().unwrap();
652            if let Some(price) = cache.get_mark_price(&perp_bytes, now_ts) {
653                return Ok(price);
654            }
655        }
656
657        // Fetch from chain
658        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
659        let sqrt_price_x96: U256 = contract
660            .timeWeightedAvgSqrtPriceX96(perp_id, 1)
661            .call()
662            .await?;
663        let price = crate::convert::sqrt_price_x96_to_price(sqrt_price_x96)?;
664
665        // Update cache
666        {
667            let mut cache = self.state_cache.lock().unwrap();
668            cache.put_mark_price(perp_bytes, price, now_ts);
669        }
670
671        Ok(price)
672    }
673
674    /// Simulate closing a position to get live PnL, funding, and liquidation status.
675    ///
676    /// This is a read-only call (no transaction sent).
677    pub async fn get_live_details(&self, pos_id: U256) -> Result<LiveDetails> {
678        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
679        let result = contract.quoteClosePosition(pos_id).call().await?;
680
681        // Check for unexpected revert reason
682        if !result.unexpectedReason.is_empty() {
683            return Err(PerpCityError::TxReverted {
684                reason: format!(
685                    "quoteClosePosition reverted: 0x{}",
686                    alloy::primitives::hex::encode(&result.unexpectedReason)
687                ),
688            });
689        }
690
691        let scale = SCALE_F64;
692        Ok(LiveDetails {
693            pnl: i128_from_i256(result.pnl) as f64 / scale,
694            funding_payment: i128_from_i256(result.funding) as f64 / scale,
695            effective_margin: i128_from_i256(result.netMargin) as f64 / scale,
696            is_liquidatable: result.wasLiquidated,
697        })
698    }
699
700    /// Get taker open interest for a perp market.
701    pub async fn get_open_interest(&self, perp_id: B256) -> Result<OpenInterest> {
702        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
703        let result = contract.takerOpenInterest(perp_id).call().await?;
704
705        let scale = SCALE_F64;
706        Ok(OpenInterest {
707            long_oi: result.longOI as f64 / scale,
708            short_oi: result.shortOI as f64 / scale,
709        })
710    }
711
712    /// Get the funding rate per second for a perp, converted to a daily rate.
713    ///
714    /// Uses the fast cache layer (2s TTL).
715    pub async fn get_funding_rate(&self, perp_id: B256) -> Result<f64> {
716        let now_ts = now_secs();
717        let perp_bytes: [u8; 32] = perp_id.into();
718
719        // Check cache
720        {
721            let cache = self.state_cache.lock().unwrap();
722            if let Some(rate) = cache.get_funding_rate(&perp_bytes, now_ts) {
723                return Ok(rate);
724            }
725        }
726
727        let contract = PerpManager::new(self.deployments.perp_manager, &self.provider);
728        let funding_x96: I256 = contract.fundingPerSecondX96(perp_id).call().await?;
729
730        // Convert from X96 fixed-point to human-readable daily rate
731        // rate_per_sec = funding_x96 / 2^96
732        // daily_rate = rate_per_sec * 86400
733        let funding_i128 = i128_from_i256(funding_x96);
734        let q96_f64 = 2.0_f64.powi(96);
735        let rate_per_sec = funding_i128 as f64 / q96_f64;
736        let daily_rate = rate_per_sec * crate::constants::INTERVAL as f64;
737
738        // Update cache
739        {
740            let mut cache = self.state_cache.lock().unwrap();
741            cache.put_funding_rate(perp_bytes, daily_rate, now_ts);
742        }
743
744        Ok(daily_rate)
745    }
746
747    /// Get the USDC balance of the signer's address.
748    ///
749    /// Uses the fast cache layer (2s TTL).
750    pub async fn get_usdc_balance(&self) -> Result<f64> {
751        let now_ts = now_secs();
752
753        // Check cache
754        {
755            let cache = self.state_cache.lock().unwrap();
756            if let Some(bal) = cache.get_usdc_balance(now_ts) {
757                return Ok(bal);
758            }
759        }
760
761        let usdc = IERC20::new(self.deployments.usdc, &self.provider);
762        let raw: U256 = usdc.balanceOf(self.address).call().await?;
763        let raw_i128 = i128::try_from(raw).map_err(|_| PerpCityError::Overflow {
764            context: format!("USDC balance {} exceeds i128::MAX", raw),
765        })?;
766        let balance = scale_from_6dec(raw_i128);
767
768        // Update cache
769        {
770            let mut cache = self.state_cache.lock().unwrap();
771            cache.put_usdc_balance(balance, now_ts);
772        }
773
774        Ok(balance)
775    }
776
777    // ── Accessors ────────────────────────────────────────────────────
778
779    /// The signer's Ethereum address.
780    pub fn address(&self) -> Address {
781        self.address
782    }
783
784    /// The deployed contract addresses.
785    pub fn deployments(&self) -> &Deployments {
786        &self.deployments
787    }
788
789    /// The underlying Alloy provider (for advanced queries).
790    pub fn provider(&self) -> &RootProvider<Ethereum> {
791        &self.provider
792    }
793
794    /// The underlying HFT transport (for health diagnostics).
795    pub fn transport(&self) -> &HftTransport {
796        &self.transport
797    }
798
799    /// Invalidate the fast cache layer (prices, funding, balance).
800    ///
801    /// Call on new-block events to ensure fresh data.
802    pub fn invalidate_fast_cache(&self) {
803        let mut cache = self.state_cache.lock().unwrap();
804        cache.invalidate_fast_layer();
805    }
806
807    /// Invalidate all cached state.
808    pub fn invalidate_all_cache(&self) {
809        let mut cache = self.state_cache.lock().unwrap();
810        cache.invalidate_all();
811    }
812
813    /// Confirm a transaction as mined. Removes from in-flight tracking.
814    pub fn confirm_tx(&self, tx_hash: &[u8; 32]) {
815        let mut pipeline = self.pipeline.lock().unwrap();
816        pipeline.confirm(tx_hash);
817    }
818
819    /// Mark a transaction as failed. Releases the nonce if possible.
820    pub fn fail_tx(&self, tx_hash: &[u8; 32]) {
821        let mut pipeline = self.pipeline.lock().unwrap();
822        pipeline.fail(tx_hash);
823    }
824
825    /// Number of currently in-flight (unconfirmed) transactions.
826    pub fn in_flight_count(&self) -> usize {
827        let pipeline = self.pipeline.lock().unwrap();
828        pipeline.in_flight_count()
829    }
830
831    // ── Internal helpers ─────────────────────────────────────────────
832
833    /// Prepare, sign, send, and wait for a transaction receipt.
834    async fn send_tx(
835        &self,
836        to: Address,
837        calldata: Bytes,
838        gas_limit: u64,
839        urgency: Urgency,
840    ) -> Result<alloy::rpc::types::TransactionReceipt> {
841        let now = now_ms();
842
843        // Prepare via pipeline (zero RPC)
844        let prepared = {
845            let pipeline = self.pipeline.lock().unwrap();
846            let gas_cache = self.gas_cache.lock().unwrap();
847            pipeline.prepare(
848                TxRequest {
849                    to: to.into_array(),
850                    calldata: calldata.to_vec(),
851                    value: 0,
852                    gas_limit,
853                    urgency,
854                },
855                &gas_cache,
856                now,
857            )?
858        };
859
860        // Build EIP-1559 transaction
861        let tx = TransactionRequest::default()
862            .with_to(to)
863            .with_input(calldata)
864            .with_nonce(prepared.nonce)
865            .with_gas_limit(prepared.gas_limit)
866            .with_max_fee_per_gas(prepared.gas_fees.max_fee_per_gas as u128)
867            .with_max_priority_fee_per_gas(prepared.gas_fees.max_priority_fee_per_gas as u128)
868            .with_chain_id(self.chain_id);
869
870        // Sign and send
871        let tx_envelope = tx
872            .build(&self.wallet)
873            .await
874            .map_err(|e| PerpCityError::TxReverted {
875                reason: format!("failed to sign transaction: {e}"),
876            })?;
877
878        let pending = self.provider.send_tx_envelope(tx_envelope).await?;
879        let tx_hash_b256 = *pending.tx_hash();
880        let tx_hash_bytes: [u8; 32] = tx_hash_b256.into();
881
882        // Record in pipeline
883        {
884            let mut pipeline = self.pipeline.lock().unwrap();
885            pipeline.record_submission(tx_hash_bytes, prepared, now);
886        }
887
888        // Wait for receipt
889        let receipt = tokio::time::timeout(RECEIPT_TIMEOUT, pending.get_receipt())
890            .await
891            .map_err(|_| PerpCityError::TxReverted {
892                reason: format!("receipt timeout after {}s", RECEIPT_TIMEOUT.as_secs()),
893            })?
894            .map_err(|e| PerpCityError::TxReverted {
895                reason: format!("failed to get receipt: {e}"),
896            })?;
897
898        // Confirm in pipeline
899        {
900            let mut pipeline = self.pipeline.lock().unwrap();
901            pipeline.confirm(&tx_hash_bytes);
902        }
903
904        // Check if reverted
905        if !receipt.status() {
906            return Err(PerpCityError::TxReverted {
907                reason: format!("transaction {} reverted", tx_hash_b256),
908            });
909        }
910
911        Ok(receipt)
912    }
913
914    /// Fetch fees from the IFees module contract.
915    async fn fetch_fees(&self, config: &PerpManager::PerpConfig) -> Result<Fees> {
916        if config.fees == Address::ZERO {
917            return Err(PerpCityError::ModuleNotRegistered {
918                module: "IFees".into(),
919            });
920        }
921
922        let fees_contract = IFees::new(config.fees, &self.provider);
923
924        let fee_result = fees_contract.fees(config.clone()).call().await?;
925        let c_fee = u24_to_u32(fee_result.cFee);
926        let ins_fee = u24_to_u32(fee_result.insFee);
927        let lp_fee = u24_to_u32(fee_result.lpFee);
928
929        let liq_result = fees_contract.liquidationFee(config.clone()).call().await?;
930        let liq_fee = u24_to_u32(liq_result);
931
932        let scale = SCALE_F64;
933        Ok(Fees {
934            creator_fee: c_fee as f64 / scale,
935            insurance_fee: ins_fee as f64 / scale,
936            lp_fee: lp_fee as f64 / scale,
937            liquidation_fee: liq_fee as f64 / scale,
938        })
939    }
940
941    /// Fetch margin ratio bounds from the IMarginRatios module contract.
942    async fn fetch_bounds(&self, config: &PerpManager::PerpConfig) -> Result<Bounds> {
943        if config.marginRatios == Address::ZERO {
944            return Err(PerpCityError::ModuleNotRegistered {
945                module: "IMarginRatios".into(),
946            });
947        }
948
949        let ratios_contract = IMarginRatios::new(config.marginRatios, &self.provider);
950
951        let ratios: IMarginRatios::MarginRatios = ratios_contract
952            .marginRatios(config.clone(), false) // isMaker = false for taker bounds
953            .call()
954            .await?;
955
956        let scale = SCALE_F64;
957        Ok(Bounds {
958            min_margin: scale_from_6dec(crate::constants::MIN_OPENING_MARGIN as i128),
959            min_taker_leverage: margin_ratio_to_leverage(u24_to_u32(ratios.max))?,
960            max_taker_leverage: margin_ratio_to_leverage(u24_to_u32(ratios.min))?,
961            liquidation_taker_ratio: u24_to_u32(ratios.liq) as f64 / scale,
962        })
963    }
964}
965
966// ── Type conversion helpers for Alloy fixed-size types ───────────────
967
968/// Convert a u32 margin ratio to Alloy's uint24 type.
969#[inline]
970fn u32_to_u24(v: u32) -> alloy::primitives::Uint<24, 1> {
971    alloy::primitives::Uint::<24, 1>::from(v & 0xFF_FFFF)
972}
973
974/// Convert Alloy's uint24 to a u32.
975#[inline]
976fn u24_to_u32(v: alloy::primitives::Uint<24, 1>) -> u32 {
977    v.to::<u32>()
978}
979
980/// Convert an i32 tick to Alloy's int24 type.
981#[inline]
982fn i32_to_i24(v: i32) -> alloy::primitives::Signed<24, 1> {
983    alloy::primitives::Signed::<24, 1>::try_from(v as i64).unwrap_or(if v < 0 {
984        alloy::primitives::Signed::<24, 1>::MIN
985    } else {
986        alloy::primitives::Signed::<24, 1>::MAX
987    })
988}
989
990/// Convert Alloy's int24 to an i32.
991#[inline]
992fn i24_to_i32(v: alloy::primitives::Signed<24, 1>) -> i32 {
993    // int24 always fits in i32
994    v.as_i32()
995}
996
997// ── Utility functions ────────────────────────────────────────────────
998
999/// Get current time in milliseconds.
1000fn now_ms() -> u64 {
1001    SystemTime::now()
1002        .duration_since(UNIX_EPOCH)
1003        .unwrap_or_default()
1004        .as_millis() as u64
1005}
1006
1007/// Get current time in seconds (for state cache).
1008fn now_secs() -> u64 {
1009    SystemTime::now()
1010        .duration_since(UNIX_EPOCH)
1011        .unwrap_or_default()
1012        .as_secs()
1013}
1014
1015/// Convert an I256 to i128 (clamping to i128::MIN/MAX on overflow).
1016#[inline]
1017fn i128_from_i256(v: I256) -> i128 {
1018    i128::try_from(v).unwrap_or_else(|_| {
1019        if v.is_negative() {
1020            i128::MIN
1021        } else {
1022            i128::MAX
1023        }
1024    })
1025}
1026
1027#[cfg(test)]
1028mod tests {
1029    use super::*;
1030
1031    // ── i128_from_i256 tests ─────────────────────────────────────────
1032
1033    #[test]
1034    fn i128_from_i256_small_values() {
1035        assert_eq!(i128_from_i256(I256::ZERO), 0);
1036        assert_eq!(i128_from_i256(I256::try_from(42i64).unwrap()), 42);
1037        assert_eq!(i128_from_i256(I256::try_from(-100i64).unwrap()), -100);
1038    }
1039
1040    #[test]
1041    fn i128_from_i256_boundary_values() {
1042        let max_i128 = I256::try_from(i128::MAX).unwrap();
1043        assert_eq!(i128_from_i256(max_i128), i128::MAX);
1044
1045        let min_i128 = I256::try_from(i128::MIN).unwrap();
1046        assert_eq!(i128_from_i256(min_i128), i128::MIN);
1047    }
1048
1049    #[test]
1050    fn i128_from_i256_overflow_clamps() {
1051        assert_eq!(i128_from_i256(I256::MAX), i128::MAX);
1052        assert_eq!(i128_from_i256(I256::MIN), i128::MIN);
1053    }
1054
1055    #[test]
1056    fn i128_from_i256_just_beyond_i128() {
1057        let beyond = I256::try_from(i128::MAX).unwrap() + I256::try_from(1i64).unwrap();
1058        assert_eq!(i128_from_i256(beyond), i128::MAX);
1059
1060        let below = I256::try_from(i128::MIN).unwrap() - I256::try_from(1i64).unwrap();
1061        assert_eq!(i128_from_i256(below), i128::MIN);
1062    }
1063
1064    // ── Type conversion helpers ──────────────────────────────────────
1065
1066    #[test]
1067    fn u24_roundtrip() {
1068        for v in [0u32, 1, 100_000, 0xFF_FFFF] {
1069            let u24 = u32_to_u24(v);
1070            assert_eq!(u24_to_u32(u24), v);
1071        }
1072    }
1073
1074    #[test]
1075    fn u24_truncates_overflow() {
1076        // Values > 0xFFFFFF get masked
1077        let u24 = u32_to_u24(0x1FF_FFFF);
1078        assert_eq!(u24_to_u32(u24), 0xFF_FFFF);
1079    }
1080
1081    #[test]
1082    fn i24_roundtrip() {
1083        for v in [0i32, 1, -1, 30, -30, 69_090, -69_090] {
1084            let i24 = i32_to_i24(v);
1085            assert_eq!(i24_to_i32(i24), v);
1086        }
1087    }
1088
1089    // ── Funding rate integration test ───────────────────────────────
1090
1091    #[test]
1092    fn funding_rate_x96_conversion() {
1093        let q96 = 2.0_f64.powi(96);
1094        let rate_per_sec = 0.0001;
1095        let x96_value = (rate_per_sec * q96) as i128;
1096        let i256_val = I256::try_from(x96_value).unwrap();
1097
1098        let recovered = i128_from_i256(i256_val) as f64 / q96;
1099        let daily = recovered * 86400.0;
1100
1101        assert!((recovered - rate_per_sec).abs() < 1e-10);
1102        assert!((daily - 8.64).abs() < 0.001);
1103    }
1104}