1use 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
68const BASE_CHAIN_ID: u64 = 8453;
72
73const DEFAULT_GAS_TTL_MS: u64 = 2_000;
75
76const DEFAULT_PRIORITY_FEE: u64 = 10_000_000;
81
82const RECEIPT_TIMEOUT: Duration = Duration::from_secs(30);
84
85const MAX_APPROVAL: U256 = U256::MAX;
87
88const SCALE_F64: f64 = SCALE_1E6 as f64;
90
91fn 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
98impl 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
144pub struct PerpClient {
153 provider: RootProvider<Ethereum>,
155 transport: HftTransport,
157 wallet: EthereumWallet,
159 address: Address,
161 deployments: Deployments,
163 chain_id: u64,
165 pipeline: Mutex<TxPipeline>,
167 fee_cache: Mutex<FeeCache>,
169 gas_limit_cache: Mutex<GasLimitCache>,
171 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 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: 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 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 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 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 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 pub fn base_fee(&self) -> Option<u64> {
290 self.fee_cache.lock().unwrap().base_fee()
291 }
292
293 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 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 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 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 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 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 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 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 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 let config: PerpManager::PerpConfig = contract.cfgs(perp_id).call().await?;
565
566 if config.beacon == Address::ZERO {
568 return Err(PerpCityError::PerpNotFound { perp_id });
569 }
570
571 let beacon = config.beacon;
572
573 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 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 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 if pos.perpId == B256::ZERO {
619 return Err(PerpCityError::PositionNotFound { pos_id });
620 }
621
622 Ok(pos)
623 }
624
625 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 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(_) => {} }
657 }
658
659 Ok(owned)
660 }
661
662 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 {
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 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 {
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 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 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 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 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 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 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 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 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 {
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 {
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 pub async fn get_usdc_balance(&self) -> Result<f64> {
935 let now_ts = now_secs();
936
937 {
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 {
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 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 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 let mut calls = Vec::with_capacity(2 * n);
996
997 for &addr in addresses {
998 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 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 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 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(ð_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 pub async fn get_perp_snapshot(&self, perp_id: B256) -> Result<(PerpData, PerpSnapshot)> {
1077 let pm = self.deployments.perp_manager;
1078
1079 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 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 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 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 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 let index_price = self.get_index_price(config.beacon).await?;
1179
1180 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 pub fn address(&self) -> Address {
1208 self.address
1209 }
1210
1211 pub fn deployments(&self) -> &Deployments {
1213 &self.deployments
1214 }
1215
1216 pub fn provider(&self) -> &RootProvider<Ethereum> {
1218 &self.provider
1219 }
1220
1221 pub fn wallet(&self) -> &EthereumWallet {
1223 &self.wallet
1224 }
1225
1226 pub fn transport(&self) -> &HftTransport {
1228 &self.transport
1229 }
1230
1231 pub fn invalidate_fast_cache(&self) {
1235 let mut cache = self.state_cache.lock().unwrap();
1236 cache.invalidate_fast_layer();
1237 }
1238
1239 pub fn invalidate_all_cache(&self) {
1241 let mut cache = self.state_cache.lock().unwrap();
1242 cache.invalidate_all();
1243 }
1244
1245 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 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 pub fn in_flight_count(&self) -> usize {
1259 let pipeline = self.pipeline.lock().unwrap();
1260 pipeline.in_flight_count()
1261 }
1262
1263 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 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 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 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 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 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 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 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 {
1391 let mut pipeline = self.pipeline.lock().unwrap();
1392 pipeline.record_submission(tx_hash_bytes, prepared, now);
1393 }
1394
1395 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 {
1410 let mut pipeline = self.pipeline.lock().unwrap();
1411 pipeline.confirm(&tx_hash_bytes);
1412 }
1413
1414 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 async fn resolve_gas_limit(
1437 &self,
1438 to: Address,
1439 calldata: &Bytes,
1440 value: u128,
1441 now: u64,
1442 ) -> Result<u64> {
1443 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 {
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 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 {
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 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 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 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 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) .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#[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#[inline]
1598fn u24_to_u32(v: alloy::primitives::Uint<24, 1>) -> u32 {
1599 v.to::<u32>()
1600}
1601
1602#[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#[inline]
1614fn i24_to_i32(v: alloy::primitives::Signed<24, 1>) -> i32 {
1615 v.as_i32()
1617}
1618
1619fn now_ms() -> u64 {
1623 SystemTime::now()
1624 .duration_since(UNIX_EPOCH)
1625 .unwrap_or_default()
1626 .as_millis() as u64
1627}
1628
1629fn now_secs() -> u64 {
1631 SystemTime::now()
1632 .duration_since(UNIX_EPOCH)
1633 .unwrap_or_default()
1634 .as_secs()
1635}
1636
1637#[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
1649fn u256_to_f64_6dec(v: U256) -> f64 {
1651 v.to::<u128>() as f64 / 1_000_000.0
1652}
1653
1654fn 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
1676fn 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
1699fn 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
1715fn 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 #[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 #[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 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 #[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}