Skip to main content

perpl_sdk/state/
mod.rs

1//! Exchange state tracking.
2//!
3//! Initial state snapshot has to be taken from the recent on-chain state by the
4//! [`SnapshotBuilder`], then the snapshot can be kept up to date by the event
5//! data from [`crate::stream::raw`] in a consistent manner.
6//!
7//! [`Exchange`] is at the root of indexed state and provides access to all
8//! nested state entities, as well as basic market data derived from observed
9//! trading activity.
10//!
11//! Some of the state and market data can be retrieved/computed only from the
12//! event stream and is not available from the plain snapshot, the documentation
13//! for corresponding access methods explicitly covers such cases.
14//!
15//! The deployed contract can lag behind the revision the SDK targets, so the
16//! snapshot detects its [`ContractFeatures`] first and degrades gracefully.
17
18mod account;
19mod event;
20mod exchange;
21mod fee;
22mod l3_book;
23mod order;
24mod perpetual;
25mod position;
26mod version;
27
28use std::collections::{HashMap, hash_map};
29
30pub use account::*;
31use alloy::{
32    eips::BlockId,
33    primitives::U256,
34    providers::{CallItem, Provider},
35};
36pub use event::*;
37pub use exchange::*;
38use fastnum::UD64;
39pub use fee::*;
40use itertools::Itertools;
41pub use l3_book::*;
42pub use order::*;
43pub use perpetual::*;
44pub use position::*;
45pub use version::*;
46
47use crate::{
48    Chain,
49    abi::dex::{
50        self,
51        Exchange::{
52            Order as OrderV0, OrderV2, PerpetualInfo, PerpetualInfoV2, PositionInfo,
53            PositionInfoV2, getExchangeInfoReturn,
54        },
55    },
56    error::{DexError, ProviderError},
57    num, types,
58};
59
60/// Default number of orders to fetch via single call.
61/// Assuming Monad's 8100 gas per storage slot access and 30M gas limit of
62/// `eth_call`, plus some buffer.
63const DEFAULT_ORDERS_PER_BATCH: usize = 1000;
64
65/// Default number of positions to fetch via single call.
66/// Assuming Monad's 8100 gas per storage slot access and 30M gas limit of
67/// `eth_call`, plus some buffer.
68const DEFAULT_POSITIONS_PER_BATCH: usize = 1000;
69
70/// Number of perpetual IDs to probe for existence via single call on contracts
71/// without the existence bitmap. Bounded by the same gas budget as the batches
72/// above, with `getMarginFractions` being a couple of slots per ID.
73const PERPETUAL_PROBES_PER_BATCH: usize = 256;
74
75/// Builds a consistent snapshot of the exchange state
76/// that can be then kept up-to-date by the data from [`crate::stream::raw`].
77pub struct SnapshotBuilder<P> {
78    chain: Chain,
79    instance: dex::Exchange::ExchangeInstance<P>,
80    provider: P,
81    block_id: BlockId,
82    perpetuals: Vec<types::PerpetualId>,
83    accounts: Vec<types::AccountAddressOrID>,
84    all_positions: bool,
85    orders_per_batch: usize,
86    positions_per_batch: usize,
87}
88
89impl<P: Provider + Clone> SnapshotBuilder<P> {
90    /// Creates a new [`SnapshotBuilder`] which fetches the full exchange state
91    /// at the latest safe/voted block.
92    pub fn new(chain: &Chain, provider: P) -> Self {
93        Self {
94            chain: chain.clone(),
95            instance: dex::Exchange::new(chain.exchange(), provider.clone()),
96            provider,
97            block_id: BlockId::Number(alloy::eips::BlockNumberOrTag::Safe),
98            perpetuals: chain.perpetuals.clone(),
99            accounts: vec![],
100            all_positions: false,
101            orders_per_batch: DEFAULT_ORDERS_PER_BATCH,
102            positions_per_batch: DEFAULT_POSITIONS_PER_BATCH,
103        }
104    }
105
106    /// Sets the block number or tag to fetch the state at (default:
107    /// [`alloy::eips::BlockNumberOrTag::Safe`]). If tag is provided, it gets
108    /// converted to a specific block number first to ensure state
109    /// consistency.
110    pub fn at_block(mut self, block: BlockId) -> Self {
111        self.block_id = block;
112        self
113    }
114
115    /// Sets the list of perpetual contract IDs to fetch the state for.
116    ///
117    /// An empty list (the default, see [`Chain::perpetuals`]) means *every*
118    /// perpetual listed on the exchange, discovered on-chain.
119    pub fn with_perpetuals(mut self, perpetuals: Vec<types::PerpetualId>) -> Self {
120        self.perpetuals = perpetuals;
121        self
122    }
123
124    /// Sets the list of addresses to fetch the state of exchange accounts for.
125    /// Assumes accounts already exist, snapshot creation will fail otherwise.
126    pub fn with_accounts(mut self, accounts: Vec<types::AccountAddressOrID>) -> Self {
127        self.accounts = accounts;
128        self.all_positions = false;
129        self
130    }
131
132    /// Forces to fetch all available positions, along with corresponding
133    /// accounts, but without account state snapshot.
134    /// Mutually exclusive with [`Self::with_accounts`].
135    pub fn with_all_positions(mut self) -> Self {
136        self.accounts = vec![];
137        self.all_positions = true;
138        self
139    }
140
141    /// Sets the number of orders to fetch in a single batch via multicall
142    /// (default: 3000). Use if default does not fit node/provider gas and
143    /// response size limits.
144    pub fn with_orders_per_batch(mut self, orders_per_batch: usize) -> Self {
145        self.orders_per_batch = orders_per_batch;
146        self
147    }
148
149    /// Sets the number of positions to fetch in a single batch (default: 3000).
150    /// Use if default does not fit node/provider gas and response size limits.
151    pub fn with_positions_per_batch(mut self, positions_per_batch: usize) -> Self {
152        self.positions_per_batch = positions_per_batch;
153        self
154    }
155
156    /// Build the snapshot
157    pub async fn build(mut self) -> Result<Exchange, DexError> {
158        // Normalize block ID to fetch consistent state
159        let instant = self.normalize_block().await?;
160
161        // Probe once to learn what the deployed contract exposes - it can lag
162        // behind the revision the SDK is compiled against.
163        let mut features = ContractFeatures::probe(
164            &self.instance,
165            self.block_id,
166            self.perpetuals.first().copied(),
167        )
168        .await;
169
170        // Resolve the set of perpetuals to track, discovering it on-chain when
171        // it was not configured explicitly
172        if self.perpetuals.is_empty() {
173            self.perpetuals = discover_perpetuals(
174                &self.instance,
175                &self.provider,
176                self.block_id,
177                features,
178                self.chain.excluded_perpetuals(),
179            )
180            .await?;
181            // An unversioned contract could not be probed for the V2 getters
182            // without a perpetual to probe against; now there is one
183            if let Some(perp_id) = self.perpetuals.first().copied() {
184                features
185                    .probe_v2_state_getters(&self.instance, self.block_id, perp_id)
186                    .await;
187            }
188        }
189
190        // Global exchange parameters and state
191        let (
192            exchange_info,
193            funding_interval,
194            min_post,
195            min_settle,
196            recycle_fee,
197            is_halted,
198            num_of_accounts,
199        ) = self.exchange_info().await?;
200        let collateral_converter = num::Converter::new(exchange_info.collateralDecimals.to());
201
202        // Every fee schedule perpetuals resolve their fees from, alongside the
203        // perpetual contracts' own parameters, state and active orders. Both
204        // are keyed off the perpetual ids resolved above and pinned to the same
205        // block, so they are independent of each other.
206        let (fee_schedules, perpetuals) =
207            futures::try_join!(self.fee_schedules(features), self.perpetuals(instant, features))?;
208
209        let accounts = if !self.accounts.is_empty() {
210            // Accounts parameters, state and open positions if specific accounts requested
211            self.accounts(instant, &perpetuals, collateral_converter, features)
212                .await?
213        } else if self.all_positions {
214            // All positions with corresponding accounts without parameters and balance
215            // snapshot
216            self.position_accounts(
217                instant,
218                &perpetuals,
219                num_of_accounts.to(),
220                collateral_converter,
221                features,
222            )
223            .await?
224        } else {
225            HashMap::new()
226        };
227
228        Ok(Exchange::new(
229            self.chain.clone(),
230            instant,
231            features,
232            collateral_converter,
233            funding_interval.to(),
234            collateral_converter.from_unsigned(min_post),
235            collateral_converter.from_unsigned(min_settle),
236            collateral_converter.from_unsigned(recycle_fee),
237            fee_schedules,
238            perpetuals,
239            accounts,
240            is_halted,
241            self.all_positions,
242        ))
243    }
244
245    /// Fetches every fee schedule perpetuals resolve their fees from: the two
246    /// exchange-wide ones plus the custom schedule keyed by each perpetual
247    /// being tracked.
248    ///
249    /// A custom schedule is fetched whether or not the perpetual it is keyed by
250    /// currently points at it - the two are independent, and the registry has
251    /// to be able to resolve the rates of a `PerpFeeSchedIdSet` repoint that
252    /// arrives without a `FeeScheduleSet` of its own.
253    ///
254    /// Pre-v1.1.7.4 contracts have no schedule registry - fees live on the
255    /// perpetual itself and no event ever repoints one at a shared schedule, so
256    /// empty schedules are returned and never consulted.
257    async fn fee_schedules(
258        &self,
259        features: ContractFeatures,
260    ) -> Result<FeeScheduleRegistry, DexError> {
261        if !features.keyed_fee_schedules() {
262            return Ok(FeeScheduleRegistry::new(
263                FeeSchedule::flat(FeeScheduleKey::Default, UD64::ZERO, UD64::ZERO),
264                FeeSchedule::flat(FeeScheduleKey::RwaDefault, UD64::ZERO, UD64::ZERO),
265                HashMap::new(),
266            ));
267        }
268        // Resolved from the deployed version: v1.1.7.5 redenominated the stored
269        // rates from hundred-thousandths to millionths, so the same integer means
270        // a tenth of what it used to.
271        let fee_converter = features.fee_rate_converter();
272        let (default_call, rwa_call) = (
273            self.instance
274                .getDefaultPerpFeeSchedule()
275                .block(self.block_id),
276            self.instance
277                .getFeeScheduleById(FeeScheduleKey::RwaDefault.to_raw())
278                .block(self.block_id),
279        );
280        let custom_calls = self.perpetuals.iter().map(|perp_id| {
281            let key = FeeScheduleKey::Custom(*perp_id);
282            let call = self
283                .instance
284                .getFeeScheduleById(key.to_raw())
285                .block(self.block_id);
286            async move {
287                call.call().await.map(|schedule| {
288                    (
289                        *perp_id,
290                        FeeSchedule::new(
291                            key,
292                            schedule.takerFeesPer100K,
293                            schedule.makerFeesPer100K,
294                            fee_converter,
295                        ),
296                    )
297                })
298            }
299        });
300        let (default, rwa, custom) = futures::try_join!(
301            default_call.call().into_future(),
302            rwa_call.call().into_future(),
303            futures::future::try_join_all(custom_calls),
304        )
305        .map_err(|err| DexError::Provider(err.into()))?;
306        Ok(FeeScheduleRegistry::new(
307            FeeSchedule::new(
308                FeeScheduleKey::Default,
309                default.takerFeesPer100K,
310                default.makerFeesPer100K,
311                fee_converter,
312            ),
313            FeeSchedule::new(
314                FeeScheduleKey::RwaDefault,
315                rwa.takerFeesPer100K,
316                rwa.makerFeesPer100K,
317                fee_converter,
318            ),
319            custom.into_iter().collect(),
320        ))
321    }
322
323    /// Fetches the fee schedule a perpetual resolves its fees from.
324    ///
325    /// Pre-v1.1.7.4 contracts have a single fee pair per perpetual, which is
326    /// normalized to a flat schedule under the default key - the same rate in
327    /// every tier, as no tiers exist there.
328    async fn fetch_fee_schedule(
329        &self,
330        perp_id: U256,
331        features: ContractFeatures,
332    ) -> Result<FeeSchedule, alloy::contract::Error> {
333        let fee_converter = features.fee_rate_converter();
334        if features.keyed_fee_schedules() {
335            self.instance
336                .getPerpFeeSchedule(perp_id)
337                .block(self.block_id)
338                .call()
339                .await
340                .map(|schedule| {
341                    FeeSchedule::new(
342                        FeeScheduleKey::from_raw(schedule.feeSchedId),
343                        schedule.takerFeesPer100K,
344                        schedule.makerFeesPer100K,
345                        fee_converter,
346                    )
347                })
348        } else {
349            let (maker_fee_call, taker_fee_call) = (
350                self.instance.getMakerFee(perp_id).block(self.block_id),
351                self.instance.getTakerFee(perp_id).block(self.block_id),
352            );
353            let (maker_fee, taker_fee) = futures::try_join!(
354                maker_fee_call.call().into_future(),
355                taker_fee_call.call().into_future(),
356            )?;
357            Ok(FeeSchedule::flat(
358                FeeScheduleKey::Default,
359                fee_converter.from_unsigned(taker_fee),
360                fee_converter.from_unsigned(maker_fee),
361            ))
362        }
363    }
364
365    /// Fetches `PerpetualInfoV2`, falling back to the V0 ABI when the contract
366    /// has not been upgraded yet (the V0 layout omits `fundingSumScalingExp`,
367    /// which is defaulted to zero on the V0 path).
368    async fn fetch_perpetual_info(
369        &self,
370        perp_id: U256,
371        features: ContractFeatures,
372    ) -> Result<PerpetualInfoV2, alloy::contract::Error> {
373        if features.v2_state_getters() {
374            self.instance
375                .getPerpetualInfoV2(perp_id)
376                .block(self.block_id)
377                .call()
378                .await
379        } else {
380            self.instance
381                .getPerpetualInfo(perp_id)
382                .block(self.block_id)
383                .call()
384                .await
385                .map(perpetual_info_v0_to_v2)
386        }
387    }
388
389    /// Fetches `PositionInfoV2`, falling back to the V0 ABI when the contract
390    /// has not been upgraded yet (the V0 layout omits `priceResiduePNSQ16`,
391    /// which is defaulted to zero on the V0 path).
392    async fn fetch_position_info(
393        &self,
394        perp_id: U256,
395        account_id: U256,
396        features: ContractFeatures,
397    ) -> Result<PositionInfoV2, alloy::contract::Error> {
398        if features.v2_state_getters() {
399            self.instance
400                .getPositionV2(perp_id, account_id)
401                .block(self.block_id)
402                .call()
403                .await
404                .map(|r| r.positionInfo)
405        } else {
406            self.instance
407                .getPosition(perp_id, account_id)
408                .block(self.block_id)
409                .call()
410                .await
411                .map(|r| position_info_v0_to_v2(r.positionInfo))
412        }
413    }
414
415    async fn normalize_block(&mut self) -> Result<types::StateInstant, DexError> {
416        // Transform provided block ID to fixed number block ID and use if for all calls
417        // to retrieve consistent state
418        let block_header = self
419            .provider
420            .get_block(self.block_id)
421            .await
422            .map_err(|err| DexError::Provider(err.into()))?
423            .map(|b| b.into_header())
424            .ok_or(DexError::Provider(ProviderError::InvalidRequest(
425                "block not found".to_string(),
426            )))?;
427        self.block_id = BlockId::number(block_header.number);
428        Ok(types::StateInstant::new(block_header.number, block_header.timestamp))
429    }
430
431    async fn exchange_info(
432        &self,
433    ) -> Result<(getExchangeInfoReturn, U256, U256, U256, U256, bool, U256), DexError> {
434        let (
435            exchange_info_call,
436            funding_interval_call,
437            min_post_call,
438            min_settle_call,
439            recycle_fee_call,
440            is_halted_call,
441            num_of_accounts_call,
442        ) = (
443            self.instance.getExchangeInfo().block(self.block_id),
444            self.instance.getFundingInterval().block(self.block_id),
445            self.instance.getMinimumPostCNS().block(self.block_id),
446            self.instance.getMinimumSettleCNS().block(self.block_id),
447            self.instance.getRecycleFeeCNS().block(self.block_id),
448            self.instance.isHalted().block(self.block_id),
449            // Must be pinned like every other call here: the count bounds the
450            // account IDs `position_accounts` reads, and `getPosition*` reverts
451            // for an account that does not exist at the snapshot block.
452            self.instance.numberOfAccounts().block(self.block_id),
453        );
454        futures::try_join!(
455            exchange_info_call.call().into_future(),
456            funding_interval_call.call().into_future(),
457            min_post_call.call().into_future(),
458            min_settle_call.call().into_future(),
459            recycle_fee_call.call().into_future(),
460            is_halted_call.call().into_future(),
461            num_of_accounts_call.call().into_future(),
462        )
463        .map_err(|err| DexError::Provider(err.into()))
464    }
465
466    async fn perpetuals(
467        &self,
468        instant: types::StateInstant,
469        features: ContractFeatures,
470    ) -> Result<HashMap<types::PerpetualId, perpetual::Perpetual>, DexError> {
471        let perpetual_futs = self.perpetuals.iter().map(|perp_id| async move {
472            let pid = U256::from(*perp_id);
473            let margins_call = self
474                .instance
475                .getMarginFractions(pid, U256::ZERO)
476                .block(self.block_id);
477
478            futures::try_join!(
479                self.fetch_perpetual_info(pid, features),
480                self.fetch_fee_schedule(pid, features),
481                margins_call.call().into_future(),
482            )
483            .map(|(perp_info, fee_schedule, margins)| (*perp_id, perp_info, fee_schedule, margins))
484        });
485
486        let mut perpetuals = futures::future::try_join_all(perpetual_futs)
487            .await
488            .map_err(|err| DexError::Provider(err.into()))?
489            .into_iter()
490            .map(|(perp_id, perp_info, fee_schedule, margins)| {
491                let perp = Perpetual::new(
492                    instant,
493                    perp_id,
494                    &perp_info,
495                    fee_schedule,
496                    margins.perpInitMarginFracHdths,
497                    margins.perpMaintMarginFracHdths,
498                );
499                (perp_id, perp)
500            })
501            .collect::<HashMap<_, _>>();
502
503        // Fetching orders one perp at a time to bound parallel requests
504        for perp in perpetuals.values_mut() {
505            self.perpetual_orders(perp, features).await?;
506        }
507
508        Ok(perpetuals)
509    }
510
511    async fn perpetual_orders(
512        &self,
513        perp: &mut perpetual::Perpetual,
514        features: ContractFeatures,
515    ) -> Result<(), DexError> {
516        let pid = U256::from(perp.id());
517        let order_id_index = self
518            .instance
519            .getOrderIdIndex(pid)
520            .block(self.block_id)
521            .call()
522            .await
523            .map_err(|err| DexError::Provider(err.into()))?;
524
525        let order_ids = order_id_index
526            .leaves
527            .into_iter()
528            .enumerate()
529            .flat_map(|(leaf, bitmap)| {
530                // Skip the first bit of the first leaf slot (_NULL_ORDER_ID)
531                // All remaining IDs are guaranteed non-zero since we start at bit 1
532                ((if leaf == 0 { 1 } else { 0 })..U256::BITS)
533                    .filter(move |bit| bitmap.bit(*bit))
534                    .map(move |bit| {
535                        let id = (leaf * U256::BITS + bit) as u16;
536                        // Safety: we skip bit 0 of leaf 0, so id is always >= 1
537                        std::num::NonZeroU16::new(id).expect("order id from bitmap cannot be 0")
538                    })
539            })
540            .collect::<Vec<_>>();
541
542        let orders = self.fetch_orders(pid, &order_ids, features).await?;
543
544        let (instant, base_price, price_converter, size_converter, leverage_converter) = (
545            perp.instant(),
546            perp.base_price(),
547            perp.price_converter(),
548            perp.size_converter(),
549            perp.leverage_converter(),
550        );
551
552        // Collect all orders first, then add via snapshot method to preserve FIFO
553        // ordering
554        let orders: Vec<Order> = orders
555            .into_iter()
556            .map(|ord| {
557                Order::from_snapshot(
558                    instant,
559                    ord,
560                    base_price,
561                    price_converter,
562                    size_converter,
563                    leverage_converter,
564                )
565            })
566            .collect::<Result<Vec<_>, _>>()
567            .map_err(|err| DexError::OrderParse(perp.id(), err))?;
568
569        perp.add_orders_from_snapshot(orders)
570    }
571
572    /// Batches `getOrder`/`getOrderV2` calls for the given order IDs of a
573    /// single perpetual. Normalizes both ABI versions to `OrderV2`; the V0
574    /// layout omits the builder attribution, which is defaulted to none on
575    /// the V0 path.
576    async fn fetch_orders(
577        &self,
578        perp_id: U256,
579        order_ids: &[types::OrderId],
580        features: ContractFeatures,
581    ) -> Result<Vec<OrderV2>, DexError> {
582        let order_ids = order_ids.to_vec();
583        if features.builder_attribution() {
584            aggregate_batched(order_ids, self.orders_per_batch, |chunk| {
585                let multicall = self
586                    .provider
587                    .multicall()
588                    .block(self.block_id)
589                    .dynamic()
590                    .extend(
591                        chunk
592                            .iter()
593                            .map(|oid| self.instance.getOrderV2(perp_id, U256::from(oid.get()))),
594                    );
595                async move { multicall.aggregate().await }
596            })
597            .await
598        } else {
599            Ok(aggregate_batched(order_ids, self.orders_per_batch, |chunk| {
600                let multicall = self
601                    .provider
602                    .multicall()
603                    .block(self.block_id)
604                    .dynamic()
605                    .extend(
606                        chunk
607                            .iter()
608                            .map(|oid| self.instance.getOrder(perp_id, U256::from(oid.get()))),
609                    );
610                async move { multicall.aggregate().await }
611            })
612            .await?
613            .into_iter()
614            .map(order_v0_to_v2)
615            .collect())
616        }
617    }
618
619    async fn accounts(
620        &self,
621        instant: types::StateInstant,
622        perpetuals: &HashMap<types::PerpetualId, perpetual::Perpetual>,
623        collateral_converter: num::Converter,
624        features: ContractFeatures,
625    ) -> Result<HashMap<types::AccountId, Account>, DexError> {
626        let account_futs = self.accounts.iter().map(|acc| async move {
627            let acc_info = match acc {
628                types::AccountAddressOrID::Address(addr) => self
629                    .instance
630                    .getAccountByAddr(*addr)
631                    .block(self.block_id)
632                    .call()
633                    .await
634                    .map_err(|err| DexError::Provider(err.into()))?,
635                types::AccountAddressOrID::ID(id) => self
636                    .instance
637                    .getAccountById(U256::from(*id))
638                    .block(self.block_id)
639                    .call()
640                    .await
641                    .map_err(|err| DexError::Provider(err.into()))?,
642            };
643            let fee_tier = self
644                .fetch_account_fee_tier(acc_info.accountId, features)
645                .await?;
646            let perps_with_positions = perpetuals_with_position(&acc_info.positions);
647            let position_futs = perps_with_positions.iter().map(|perp_id| async {
648                self.fetch_position_info(U256::from(*perp_id), acc_info.accountId, features)
649                    .await
650                    .map(|pos_info| (*perp_id, pos_info))
651                    .map_err(|err| DexError::Provider(err.into()))
652            });
653            let positions = futures::future::try_join_all(position_futs).await?;
654            Ok::<_, DexError>((acc_info.accountId, acc_info, fee_tier, positions))
655        });
656
657        Ok(futures::future::try_join_all(account_futs)
658            .await?
659            .into_iter()
660            .map(|(acc_id, acc_info, fee_tier, positions)| {
661                (
662                    acc_id.to(),
663                    Account::new(
664                        instant,
665                        acc_id.to(),
666                        &acc_info,
667                        fee_tier,
668                        positions
669                            .into_iter()
670                            .filter_map(|(perp_id, pos_info)| {
671                                perpetuals.get(&perp_id).map(|perp| {
672                                    (
673                                        perp_id,
674                                        Position::new(
675                                            instant,
676                                            perp_id,
677                                            &pos_info,
678                                            collateral_converter,
679                                            perp.price_converter(),
680                                            perp.size_converter(),
681                                            perp.maintenance_margin(),
682                                        ),
683                                    )
684                                })
685                            })
686                            .collect(),
687                        collateral_converter,
688                    ),
689                )
690            })
691            .collect())
692    }
693
694    /// Fetches the fee tier of an account, `None` on contracts that have no
695    /// per-account tiers.
696    async fn fetch_account_fee_tier(
697        &self,
698        account_id: U256,
699        features: ContractFeatures,
700    ) -> Result<Option<types::FeeTier>, DexError> {
701        if !features.keyed_fee_schedules() {
702            return Ok(None);
703        }
704        self.instance
705            .getAccountFeeTier(account_id)
706            .block(self.block_id)
707            .call()
708            .await
709            .map(|tier| Some(tier.to()))
710            .map_err(|err| DexError::Provider(err.into()))
711    }
712
713    async fn position_accounts(
714        &self,
715        instant: types::StateInstant,
716        perpetuals: &HashMap<types::PerpetualId, perpetual::Perpetual>,
717        num_accounts: usize,
718        collateral_converter: num::Converter,
719        features: ContractFeatures,
720    ) -> Result<HashMap<types::AccountId, Account>, DexError> {
721        let mut accounts: HashMap<types::AccountId, Account> = HashMap::new();
722        for (perp_id, perp) in perpetuals {
723            let pid = U256::from(*perp_id);
724            let infos = self
725                .fetch_position_infos_for_perp(pid, num_accounts, features)
726                .await?;
727            for info in infos {
728                if info.lotLNS.is_zero() {
729                    continue;
730                }
731                let position = Position::new(
732                    instant,
733                    *perp_id,
734                    &info,
735                    collateral_converter,
736                    perp.price_converter(),
737                    perp.size_converter(),
738                    perp.maintenance_margin(),
739                );
740                match accounts.entry(info.accountId.to()) {
741                    hash_map::Entry::Occupied(mut e) => {
742                        e.get_mut().positions_mut().insert(*perp_id, position);
743                    },
744                    hash_map::Entry::Vacant(e) => {
745                        e.insert(Account::from_position(instant, position));
746                    },
747                }
748            }
749        }
750
751        Ok(accounts)
752    }
753
754    /// Batches `getPosition`/`getPositionV2` calls for every account id of a
755    /// single perpetual. Normalizes both ABI versions to `PositionInfoV2`.
756    async fn fetch_position_infos_for_perp(
757        &self,
758        perp_id: U256,
759        num_accounts: usize,
760        features: ContractFeatures,
761    ) -> Result<Vec<PositionInfoV2>, DexError> {
762        let account_ids = (1..num_accounts + 1).collect::<Vec<_>>();
763        if features.v2_state_getters() {
764            Ok(aggregate_batched(account_ids, self.positions_per_batch, |chunk| {
765                let multicall = self
766                    .provider
767                    .multicall()
768                    .block(self.block_id)
769                    .dynamic()
770                    .extend(
771                        chunk
772                            .iter()
773                            .map(|aid| self.instance.getPositionV2(perp_id, U256::from(*aid))),
774                    );
775                async move { multicall.aggregate().await }
776            })
777            .await?
778            .into_iter()
779            .map(|r| r.positionInfo)
780            .collect())
781        } else {
782            Ok(aggregate_batched(account_ids, self.positions_per_batch, |chunk| {
783                let multicall = self
784                    .provider
785                    .multicall()
786                    .block(self.block_id)
787                    .dynamic()
788                    .extend(
789                        chunk
790                            .iter()
791                            .map(|aid| self.instance.getPosition(perp_id, U256::from(*aid))),
792                    );
793                async move { multicall.aggregate().await }
794            })
795            .await?
796            .into_iter()
797            .map(|r| position_info_v0_to_v2(r.positionInfo))
798            .collect())
799        }
800    }
801}
802
803/// Runs `call` over `items` in concurrent batches of `batch_size`, halving any
804/// batch that fails and retrying it.
805///
806/// A multicall can fail for reasons that belong to the batch rather than to any
807/// single call in it - overwhelmingly, exhausting the node's `eth_call` gas
808/// budget. Per-call cost is not uniform across perpetual contracts: reading a
809/// position from a paused contract with no funding history has been measured at
810/// ~30x the cost of reading one from an active contract, so no single batch
811/// size is both efficient and safe. Since the perpetual set is discovered
812/// rather than configured, such a contract is found rather than chosen, and a
813/// fixed batch size would fail the whole snapshot on it.
814///
815/// Splitting converges on a size the node will serve, keeping the batch large
816/// (and the snapshot fast) for the common case. A batch of one that still fails
817/// is a genuine error and propagates - the alternative, dropping it, would
818/// silently omit state from a snapshot that presents itself as complete.
819async fn aggregate_batched<T, R, F, Fut>(
820    items: Vec<T>,
821    batch_size: usize,
822    call: F,
823) -> Result<Vec<R>, DexError>
824where
825    T: Clone,
826    F: Fn(Vec<T>) -> Fut,
827    Fut: Future<Output = Result<Vec<R>, alloy::providers::MulticallError>>,
828{
829    // Batches still to fetch, each with its offset in `items` so the results can
830    // be restored to the original order after any amount of splitting
831    let mut pending = items
832        .chunks(batch_size.max(1))
833        .enumerate()
834        .map(|(i, chunk)| (i * batch_size, chunk.to_vec()))
835        .collect::<Vec<_>>();
836    let mut fetched: Vec<(usize, Vec<R>)> = Vec::with_capacity(pending.len());
837
838    while !pending.is_empty() {
839        let results =
840            futures::future::join_all(pending.iter().map(|(_, chunk)| call(chunk.clone()))).await;
841        let mut retry = Vec::new();
842        for ((offset, chunk), result) in pending.into_iter().zip(results) {
843            match result {
844                Ok(values) => fetched.push((offset, values)),
845                Err(_) if chunk.len() > 1 => {
846                    let mid = chunk.len() / 2;
847                    retry.push((offset + mid, chunk[mid..].to_vec()));
848                    retry.push((offset, chunk[..mid].to_vec()));
849                },
850                Err(err) => return Err(DexError::Provider(err.into())),
851            }
852        }
853        pending = retry;
854    }
855
856    fetched.sort_by_key(|(offset, _)| *offset);
857    Ok(fetched.into_iter().flat_map(|(_, values)| values).collect())
858}
859
860/// Returns the IDs of every perpetual contract listed on the exchange at
861/// `block_id`.
862///
863/// The exchange reports its own listings, so a client does not need to be
864/// configured with them - see [`Chain::perpetuals`].
865pub async fn listed_perpetuals<P: Provider + Clone>(
866    chain: &Chain,
867    provider: P,
868    block_id: BlockId,
869) -> Result<Vec<types::PerpetualId>, DexError> {
870    let instance = dex::Exchange::new(chain.exchange(), provider.clone());
871    let features = ContractFeatures::probe(&instance, block_id, None).await;
872    discover_perpetuals(&instance, &provider, block_id, features, chain.excluded_perpetuals()).await
873}
874
875/// Returns the IDs of every perpetual listed on the exchange, less the ones
876/// [`Chain::excluded_perpetuals`] leaves out.
877///
878/// Reads the existence bitmap on v1.1.7.4+, a single call covering the whole
879/// `0..=`[`types::MAX_PERPETUAL_ID`] ID space. Older deployments have no
880/// bitmap, so existence is probed by batching `getMarginFractions` over that ID
881/// space - it reverts `ContractDoesNotExist` for unlisted IDs and reads only a
882/// couple of slots for listed ones.
883async fn discover_perpetuals<P: Provider + Clone>(
884    instance: &dex::Exchange::ExchangeInstance<P>,
885    provider: &P,
886    block_id: BlockId,
887    features: ContractFeatures,
888    excluded: &[types::PerpetualId],
889) -> Result<Vec<types::PerpetualId>, DexError> {
890    if features.perpetual_discovery() {
891        let bitmap = instance
892            .getPerpetualExistsBitmap()
893            .block(block_id)
894            .call()
895            .await
896            .map_err(|err| DexError::Provider(err.into()))?;
897        return Ok(bitmap
898            .into_iter()
899            .enumerate()
900            .flat_map(|(word, bits)| {
901                (0..U256::BITS).filter_map(move |bit| {
902                    let perp_id = (word * U256::BITS + bit) as types::PerpetualId;
903                    (bits.bit(bit) && perp_id <= types::MAX_PERPETUAL_ID).then_some(perp_id)
904                })
905            })
906            .filter(|perp_id| !excluded.contains(perp_id))
907            .collect());
908    }
909
910    let probe_batch_futs = (0..=types::MAX_PERPETUAL_ID)
911        .filter(|perp_id| !excluded.contains(perp_id))
912        .chunks(PERPETUAL_PROBES_PER_BATCH)
913        .into_iter()
914        .map(|chunk| {
915            let perp_ids = chunk.collect::<Vec<_>>();
916            let multicall = provider
917                .multicall()
918                .block(block_id)
919                .dynamic::<dex::Exchange::getMarginFractionsCall>()
920                // Probing IS the point here: an unlisted ID reverts, and the
921                // batch must survive that
922                .extend_calls(perp_ids.iter().map(|perp_id| {
923                    CallItem::from(instance.getMarginFractions(U256::from(*perp_id), U256::ZERO))
924                        .with_failure_allowed()
925                }));
926            async move { multicall.aggregate3().await.map(|res| (perp_ids, res)) }
927        })
928        .collect::<Vec<_>>();
929
930    Ok(futures::future::try_join_all(probe_batch_futs)
931        .await
932        .map_err(|err| DexError::Provider(err.into()))?
933        .into_iter()
934        .flat_map(|(perp_ids, results)| {
935            perp_ids
936                .into_iter()
937                .zip(results)
938                .filter_map(|(perp_id, result)| result.is_ok().then_some(perp_id))
939        })
940        .collect())
941}
942
943fn position_info_v0_to_v2(v0: PositionInfo) -> PositionInfoV2 {
944    PositionInfoV2 {
945        accountId: v0.accountId,
946        nextNodeId: v0.nextNodeId,
947        prevNodeId: v0.prevNodeId,
948        positionType: v0.positionType,
949        depositCNS: v0.depositCNS,
950        pricePNS: v0.pricePNS,
951        lotLNS: v0.lotLNS,
952        entryBlock: v0.entryBlock,
953        pnlCNS: v0.pnlCNS,
954        deltaPnlCNS: v0.deltaPnlCNS,
955        premiumPnlCNS: v0.premiumPnlCNS,
956        priceResiduePNSQ16: U256::ZERO,
957    }
958}
959
960fn order_v0_to_v2(v0: OrderV0) -> OrderV2 {
961    OrderV2 {
962        accountId: v0.accountId,
963        orderType: v0.orderType,
964        priceONS: v0.priceONS,
965        lotLNS: v0.lotLNS,
966        recycleFeeRaw: v0.recycleFeeRaw,
967        expiryBlock: v0.expiryBlock,
968        leverageHdths: v0.leverageHdths,
969        orderId: v0.orderId,
970        prevOrderId: v0.prevOrderId,
971        nextOrderId: v0.nextOrderId,
972        maxNegPnlCollatBPS: v0.maxNegPnlCollatBPS,
973        builderId: 0,
974        builderFeePer100K: 0,
975    }
976}
977
978fn perpetual_info_v0_to_v2(v0: PerpetualInfo) -> PerpetualInfoV2 {
979    PerpetualInfoV2 {
980        name: v0.name,
981        symbol: v0.symbol,
982        priceDecimals: v0.priceDecimals,
983        lotDecimals: v0.lotDecimals,
984        linkFeedId: v0.linkFeedId,
985        priceTolPer100K: v0.priceTolPer100K,
986        marginTol: v0.marginTol,
987        marginTolDecimals: v0.marginTolDecimals,
988        refPriceMaxAgeSec: v0.refPriceMaxAgeSec,
989        positionBalanceCNS: v0.positionBalanceCNS,
990        insuranceBalanceCNS: v0.insuranceBalanceCNS,
991        markPNS: v0.markPNS,
992        markTimestamp: v0.markTimestamp,
993        lastPNS: v0.lastPNS,
994        lastTimestamp: v0.lastTimestamp,
995        oraclePNS: v0.oraclePNS,
996        oracleTimestampSec: v0.oracleTimestampSec,
997        longOpenInterestLNS: v0.longOpenInterestLNS,
998        shortOpenInterestLNS: v0.shortOpenInterestLNS,
999        fundingStartBlock: v0.fundingStartBlock,
1000        fundingRatePct100k: v0.fundingRatePct100k,
1001        absFundingClampPctPer100K: v0.absFundingClampPctPer100K,
1002        status: v0.status,
1003        basePricePNS: v0.basePricePNS,
1004        maxBidPriceONS: v0.maxBidPriceONS,
1005        minBidPriceONS: v0.minBidPriceONS,
1006        maxAskPriceONS: v0.maxAskPriceONS,
1007        minAskPriceONS: v0.minAskPriceONS,
1008        numOrders: v0.numOrders,
1009        ignOracle: v0.ignOracle,
1010        fundingSumScalingExp: U256::ZERO,
1011    }
1012}