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 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
64const BASE_CHAIN_ID: u64 = 8453;
68
69const DEFAULT_GAS_TTL_MS: u64 = 2_000;
71
72const DEFAULT_PRIORITY_FEE: u64 = 10_000_000;
77
78const RECEIPT_TIMEOUT: Duration = Duration::from_secs(30);
80
81const MAX_APPROVAL: U256 = U256::MAX;
83
84const SCALE_F64: f64 = SCALE_1E6 as f64;
86
87impl 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
133pub struct PerpClient {
142 provider: RootProvider<Ethereum>,
144 transport: HftTransport,
146 wallet: EthereumWallet,
148 address: Address,
150 deployments: Deployments,
152 chain_id: u64,
154 pipeline: Mutex<TxPipeline>,
156 gas_cache: Mutex<GasCache>,
158 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 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: 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let config: PerpManager::PerpConfig = contract.cfgs(perp_id).call().await?;
555 let beacon = config.beacon;
556
557 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 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 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 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 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 if pos.perpId == B256::ZERO {
636 return Err(PerpCityError::PositionNotFound { pos_id });
637 }
638
639 Ok(pos)
640 }
641
642 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 {
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 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 {
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 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 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 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 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 {
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 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 {
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 pub async fn get_usdc_balance(&self) -> Result<f64> {
751 let now_ts = now_secs();
752
753 {
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 {
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 pub fn address(&self) -> Address {
781 self.address
782 }
783
784 pub fn deployments(&self) -> &Deployments {
786 &self.deployments
787 }
788
789 pub fn provider(&self) -> &RootProvider<Ethereum> {
791 &self.provider
792 }
793
794 pub fn transport(&self) -> &HftTransport {
796 &self.transport
797 }
798
799 pub fn invalidate_fast_cache(&self) {
803 let mut cache = self.state_cache.lock().unwrap();
804 cache.invalidate_fast_layer();
805 }
806
807 pub fn invalidate_all_cache(&self) {
809 let mut cache = self.state_cache.lock().unwrap();
810 cache.invalidate_all();
811 }
812
813 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 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 pub fn in_flight_count(&self) -> usize {
827 let pipeline = self.pipeline.lock().unwrap();
828 pipeline.in_flight_count()
829 }
830
831 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 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 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 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 {
884 let mut pipeline = self.pipeline.lock().unwrap();
885 pipeline.record_submission(tx_hash_bytes, prepared, now);
886 }
887
888 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 {
900 let mut pipeline = self.pipeline.lock().unwrap();
901 pipeline.confirm(&tx_hash_bytes);
902 }
903
904 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 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 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) .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#[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#[inline]
976fn u24_to_u32(v: alloy::primitives::Uint<24, 1>) -> u32 {
977 v.to::<u32>()
978}
979
980#[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#[inline]
992fn i24_to_i32(v: alloy::primitives::Signed<24, 1>) -> i32 {
993 v.as_i32()
995}
996
997fn now_ms() -> u64 {
1001 SystemTime::now()
1002 .duration_since(UNIX_EPOCH)
1003 .unwrap_or_default()
1004 .as_millis() as u64
1005}
1006
1007fn now_secs() -> u64 {
1009 SystemTime::now()
1010 .duration_since(UNIX_EPOCH)
1011 .unwrap_or_default()
1012 .as_secs()
1013}
1014
1015#[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 #[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 #[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 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 #[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}