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        let fee_converter = num::fee_converter();
269        let (default_call, rwa_call) = (
270            self.instance
271                .getDefaultPerpFeeSchedule()
272                .block(self.block_id),
273            self.instance
274                .getFeeScheduleById(FeeScheduleKey::RwaDefault.to_raw())
275                .block(self.block_id),
276        );
277        let custom_calls = self.perpetuals.iter().map(|perp_id| {
278            let key = FeeScheduleKey::Custom(*perp_id);
279            let call = self
280                .instance
281                .getFeeScheduleById(key.to_raw())
282                .block(self.block_id);
283            async move {
284                call.call().await.map(|schedule| {
285                    (
286                        *perp_id,
287                        FeeSchedule::new(
288                            key,
289                            schedule.takerFeesPer100K,
290                            schedule.makerFeesPer100K,
291                            fee_converter,
292                        ),
293                    )
294                })
295            }
296        });
297        let (default, rwa, custom) = futures::try_join!(
298            default_call.call().into_future(),
299            rwa_call.call().into_future(),
300            futures::future::try_join_all(custom_calls),
301        )
302        .map_err(|err| DexError::Provider(err.into()))?;
303        Ok(FeeScheduleRegistry::new(
304            FeeSchedule::new(
305                FeeScheduleKey::Default,
306                default.takerFeesPer100K,
307                default.makerFeesPer100K,
308                fee_converter,
309            ),
310            FeeSchedule::new(
311                FeeScheduleKey::RwaDefault,
312                rwa.takerFeesPer100K,
313                rwa.makerFeesPer100K,
314                fee_converter,
315            ),
316            custom.into_iter().collect(),
317        ))
318    }
319
320    /// Fetches the fee schedule a perpetual resolves its fees from.
321    ///
322    /// Pre-v1.1.7.4 contracts have a single fee pair per perpetual, which is
323    /// normalized to a flat schedule under the default key - the same rate in
324    /// every tier, as no tiers exist there.
325    async fn fetch_fee_schedule(
326        &self,
327        perp_id: U256,
328        features: ContractFeatures,
329    ) -> Result<FeeSchedule, alloy::contract::Error> {
330        let fee_converter = num::fee_converter();
331        if features.keyed_fee_schedules() {
332            self.instance
333                .getPerpFeeSchedule(perp_id)
334                .block(self.block_id)
335                .call()
336                .await
337                .map(|schedule| {
338                    FeeSchedule::new(
339                        FeeScheduleKey::from_raw(schedule.feeSchedId),
340                        schedule.takerFeesPer100K,
341                        schedule.makerFeesPer100K,
342                        fee_converter,
343                    )
344                })
345        } else {
346            let (maker_fee_call, taker_fee_call) = (
347                self.instance.getMakerFee(perp_id).block(self.block_id),
348                self.instance.getTakerFee(perp_id).block(self.block_id),
349            );
350            let (maker_fee, taker_fee) = futures::try_join!(
351                maker_fee_call.call().into_future(),
352                taker_fee_call.call().into_future(),
353            )?;
354            Ok(FeeSchedule::flat(
355                FeeScheduleKey::Default,
356                fee_converter.from_unsigned(taker_fee),
357                fee_converter.from_unsigned(maker_fee),
358            ))
359        }
360    }
361
362    /// Fetches `PerpetualInfoV2`, falling back to the V0 ABI when the contract
363    /// has not been upgraded yet (the V0 layout omits `fundingSumScalingExp`,
364    /// which is defaulted to zero on the V0 path).
365    async fn fetch_perpetual_info(
366        &self,
367        perp_id: U256,
368        features: ContractFeatures,
369    ) -> Result<PerpetualInfoV2, alloy::contract::Error> {
370        if features.v2_state_getters() {
371            self.instance
372                .getPerpetualInfoV2(perp_id)
373                .block(self.block_id)
374                .call()
375                .await
376        } else {
377            self.instance
378                .getPerpetualInfo(perp_id)
379                .block(self.block_id)
380                .call()
381                .await
382                .map(perpetual_info_v0_to_v2)
383        }
384    }
385
386    /// Fetches `PositionInfoV2`, falling back to the V0 ABI when the contract
387    /// has not been upgraded yet (the V0 layout omits `priceResiduePNSQ16`,
388    /// which is defaulted to zero on the V0 path).
389    async fn fetch_position_info(
390        &self,
391        perp_id: U256,
392        account_id: U256,
393        features: ContractFeatures,
394    ) -> Result<PositionInfoV2, alloy::contract::Error> {
395        if features.v2_state_getters() {
396            self.instance
397                .getPositionV2(perp_id, account_id)
398                .block(self.block_id)
399                .call()
400                .await
401                .map(|r| r.positionInfo)
402        } else {
403            self.instance
404                .getPosition(perp_id, account_id)
405                .block(self.block_id)
406                .call()
407                .await
408                .map(|r| position_info_v0_to_v2(r.positionInfo))
409        }
410    }
411
412    async fn normalize_block(&mut self) -> Result<types::StateInstant, DexError> {
413        // Transform provided block ID to fixed number block ID and use if for all calls
414        // to retrieve consistent state
415        let block_header = self
416            .provider
417            .get_block(self.block_id)
418            .await
419            .map_err(|err| DexError::Provider(err.into()))?
420            .map(|b| b.into_header())
421            .ok_or(DexError::Provider(ProviderError::InvalidRequest(
422                "block not found".to_string(),
423            )))?;
424        self.block_id = BlockId::number(block_header.number);
425        Ok(types::StateInstant::new(block_header.number, block_header.timestamp))
426    }
427
428    async fn exchange_info(
429        &self,
430    ) -> Result<(getExchangeInfoReturn, U256, U256, U256, U256, bool, U256), DexError> {
431        let (
432            exchange_info_call,
433            funding_interval_call,
434            min_post_call,
435            min_settle_call,
436            recycle_fee_call,
437            is_halted_call,
438            num_of_accounts_call,
439        ) = (
440            self.instance.getExchangeInfo().block(self.block_id),
441            self.instance.getFundingInterval().block(self.block_id),
442            self.instance.getMinimumPostCNS().block(self.block_id),
443            self.instance.getMinimumSettleCNS().block(self.block_id),
444            self.instance.getRecycleFeeCNS().block(self.block_id),
445            self.instance.isHalted().block(self.block_id),
446            // Must be pinned like every other call here: the count bounds the
447            // account IDs `position_accounts` reads, and `getPosition*` reverts
448            // for an account that does not exist at the snapshot block.
449            self.instance.numberOfAccounts().block(self.block_id),
450        );
451        futures::try_join!(
452            exchange_info_call.call().into_future(),
453            funding_interval_call.call().into_future(),
454            min_post_call.call().into_future(),
455            min_settle_call.call().into_future(),
456            recycle_fee_call.call().into_future(),
457            is_halted_call.call().into_future(),
458            num_of_accounts_call.call().into_future(),
459        )
460        .map_err(|err| DexError::Provider(err.into()))
461    }
462
463    async fn perpetuals(
464        &self,
465        instant: types::StateInstant,
466        features: ContractFeatures,
467    ) -> Result<HashMap<types::PerpetualId, perpetual::Perpetual>, DexError> {
468        let perpetual_futs = self.perpetuals.iter().map(|perp_id| async move {
469            let pid = U256::from(*perp_id);
470            let margins_call = self
471                .instance
472                .getMarginFractions(pid, U256::ZERO)
473                .block(self.block_id);
474
475            futures::try_join!(
476                self.fetch_perpetual_info(pid, features),
477                self.fetch_fee_schedule(pid, features),
478                margins_call.call().into_future(),
479            )
480            .map(|(perp_info, fee_schedule, margins)| (*perp_id, perp_info, fee_schedule, margins))
481        });
482
483        let mut perpetuals = futures::future::try_join_all(perpetual_futs)
484            .await
485            .map_err(|err| DexError::Provider(err.into()))?
486            .into_iter()
487            .map(|(perp_id, perp_info, fee_schedule, margins)| {
488                let perp = Perpetual::new(
489                    instant,
490                    perp_id,
491                    &perp_info,
492                    fee_schedule,
493                    margins.perpInitMarginFracHdths,
494                    margins.perpMaintMarginFracHdths,
495                );
496                (perp_id, perp)
497            })
498            .collect::<HashMap<_, _>>();
499
500        // Fetching orders one perp at a time to bound parallel requests
501        for perp in perpetuals.values_mut() {
502            self.perpetual_orders(perp, features).await?;
503        }
504
505        Ok(perpetuals)
506    }
507
508    async fn perpetual_orders(
509        &self,
510        perp: &mut perpetual::Perpetual,
511        features: ContractFeatures,
512    ) -> Result<(), DexError> {
513        let pid = U256::from(perp.id());
514        let order_id_index = self
515            .instance
516            .getOrderIdIndex(pid)
517            .block(self.block_id)
518            .call()
519            .await
520            .map_err(|err| DexError::Provider(err.into()))?;
521
522        let order_ids = order_id_index
523            .leaves
524            .into_iter()
525            .enumerate()
526            .flat_map(|(leaf, bitmap)| {
527                // Skip the first bit of the first leaf slot (_NULL_ORDER_ID)
528                // All remaining IDs are guaranteed non-zero since we start at bit 1
529                ((if leaf == 0 { 1 } else { 0 })..U256::BITS)
530                    .filter(move |bit| bitmap.bit(*bit))
531                    .map(move |bit| {
532                        let id = (leaf * U256::BITS + bit) as u16;
533                        // Safety: we skip bit 0 of leaf 0, so id is always >= 1
534                        std::num::NonZeroU16::new(id).expect("order id from bitmap cannot be 0")
535                    })
536            })
537            .collect::<Vec<_>>();
538
539        let orders = self.fetch_orders(pid, &order_ids, features).await?;
540
541        let (instant, base_price, price_converter, size_converter, leverage_converter) = (
542            perp.instant(),
543            perp.base_price(),
544            perp.price_converter(),
545            perp.size_converter(),
546            perp.leverage_converter(),
547        );
548
549        // Collect all orders first, then add via snapshot method to preserve FIFO
550        // ordering
551        let orders: Vec<Order> = orders
552            .into_iter()
553            .map(|ord| {
554                Order::from_snapshot(
555                    instant,
556                    ord,
557                    base_price,
558                    price_converter,
559                    size_converter,
560                    leverage_converter,
561                )
562            })
563            .collect::<Result<Vec<_>, _>>()
564            .map_err(|err| DexError::OrderParse(perp.id(), err))?;
565
566        perp.add_orders_from_snapshot(orders)
567    }
568
569    /// Batches `getOrder`/`getOrderV2` calls for the given order IDs of a
570    /// single perpetual. Normalizes both ABI versions to `OrderV2`; the V0
571    /// layout omits the builder attribution, which is defaulted to none on
572    /// the V0 path.
573    async fn fetch_orders(
574        &self,
575        perp_id: U256,
576        order_ids: &[types::OrderId],
577        features: ContractFeatures,
578    ) -> Result<Vec<OrderV2>, DexError> {
579        let order_ids = order_ids.to_vec();
580        if features.builder_attribution() {
581            aggregate_batched(order_ids, self.orders_per_batch, |chunk| {
582                let multicall = self
583                    .provider
584                    .multicall()
585                    .block(self.block_id)
586                    .dynamic()
587                    .extend(
588                        chunk
589                            .iter()
590                            .map(|oid| self.instance.getOrderV2(perp_id, U256::from(oid.get()))),
591                    );
592                async move { multicall.aggregate().await }
593            })
594            .await
595        } else {
596            Ok(aggregate_batched(order_ids, self.orders_per_batch, |chunk| {
597                let multicall = self
598                    .provider
599                    .multicall()
600                    .block(self.block_id)
601                    .dynamic()
602                    .extend(
603                        chunk
604                            .iter()
605                            .map(|oid| self.instance.getOrder(perp_id, U256::from(oid.get()))),
606                    );
607                async move { multicall.aggregate().await }
608            })
609            .await?
610            .into_iter()
611            .map(order_v0_to_v2)
612            .collect())
613        }
614    }
615
616    async fn accounts(
617        &self,
618        instant: types::StateInstant,
619        perpetuals: &HashMap<types::PerpetualId, perpetual::Perpetual>,
620        collateral_converter: num::Converter,
621        features: ContractFeatures,
622    ) -> Result<HashMap<types::AccountId, Account>, DexError> {
623        let account_futs = self.accounts.iter().map(|acc| async move {
624            let acc_info = match acc {
625                types::AccountAddressOrID::Address(addr) => self
626                    .instance
627                    .getAccountByAddr(*addr)
628                    .block(self.block_id)
629                    .call()
630                    .await
631                    .map_err(|err| DexError::Provider(err.into()))?,
632                types::AccountAddressOrID::ID(id) => self
633                    .instance
634                    .getAccountById(U256::from(*id))
635                    .block(self.block_id)
636                    .call()
637                    .await
638                    .map_err(|err| DexError::Provider(err.into()))?,
639            };
640            let fee_tier = self
641                .fetch_account_fee_tier(acc_info.accountId, features)
642                .await?;
643            let perps_with_positions = perpetuals_with_position(&acc_info.positions);
644            let position_futs = perps_with_positions.iter().map(|perp_id| async {
645                self.fetch_position_info(U256::from(*perp_id), acc_info.accountId, features)
646                    .await
647                    .map(|pos_info| (*perp_id, pos_info))
648                    .map_err(|err| DexError::Provider(err.into()))
649            });
650            let positions = futures::future::try_join_all(position_futs).await?;
651            Ok::<_, DexError>((acc_info.accountId, acc_info, fee_tier, positions))
652        });
653
654        Ok(futures::future::try_join_all(account_futs)
655            .await?
656            .into_iter()
657            .map(|(acc_id, acc_info, fee_tier, positions)| {
658                (
659                    acc_id.to(),
660                    Account::new(
661                        instant,
662                        acc_id.to(),
663                        &acc_info,
664                        fee_tier,
665                        positions
666                            .into_iter()
667                            .filter_map(|(perp_id, pos_info)| {
668                                perpetuals.get(&perp_id).map(|perp| {
669                                    (
670                                        perp_id,
671                                        Position::new(
672                                            instant,
673                                            perp_id,
674                                            &pos_info,
675                                            collateral_converter,
676                                            perp.price_converter(),
677                                            perp.size_converter(),
678                                            perp.maintenance_margin(),
679                                        ),
680                                    )
681                                })
682                            })
683                            .collect(),
684                        collateral_converter,
685                    ),
686                )
687            })
688            .collect())
689    }
690
691    /// Fetches the fee tier of an account, `None` on contracts that have no
692    /// per-account tiers.
693    async fn fetch_account_fee_tier(
694        &self,
695        account_id: U256,
696        features: ContractFeatures,
697    ) -> Result<Option<types::FeeTier>, DexError> {
698        if !features.keyed_fee_schedules() {
699            return Ok(None);
700        }
701        self.instance
702            .getAccountFeeTier(account_id)
703            .block(self.block_id)
704            .call()
705            .await
706            .map(|tier| Some(tier.to()))
707            .map_err(|err| DexError::Provider(err.into()))
708    }
709
710    async fn position_accounts(
711        &self,
712        instant: types::StateInstant,
713        perpetuals: &HashMap<types::PerpetualId, perpetual::Perpetual>,
714        num_accounts: usize,
715        collateral_converter: num::Converter,
716        features: ContractFeatures,
717    ) -> Result<HashMap<types::AccountId, Account>, DexError> {
718        let mut accounts: HashMap<types::AccountId, Account> = HashMap::new();
719        for (perp_id, perp) in perpetuals {
720            let pid = U256::from(*perp_id);
721            let infos = self
722                .fetch_position_infos_for_perp(pid, num_accounts, features)
723                .await?;
724            for info in infos {
725                if info.lotLNS.is_zero() {
726                    continue;
727                }
728                let position = Position::new(
729                    instant,
730                    *perp_id,
731                    &info,
732                    collateral_converter,
733                    perp.price_converter(),
734                    perp.size_converter(),
735                    perp.maintenance_margin(),
736                );
737                match accounts.entry(info.accountId.to()) {
738                    hash_map::Entry::Occupied(mut e) => {
739                        e.get_mut().positions_mut().insert(*perp_id, position);
740                    },
741                    hash_map::Entry::Vacant(e) => {
742                        e.insert(Account::from_position(instant, position));
743                    },
744                }
745            }
746        }
747
748        Ok(accounts)
749    }
750
751    /// Batches `getPosition`/`getPositionV2` calls for every account id of a
752    /// single perpetual. Normalizes both ABI versions to `PositionInfoV2`.
753    async fn fetch_position_infos_for_perp(
754        &self,
755        perp_id: U256,
756        num_accounts: usize,
757        features: ContractFeatures,
758    ) -> Result<Vec<PositionInfoV2>, DexError> {
759        let account_ids = (1..num_accounts + 1).collect::<Vec<_>>();
760        if features.v2_state_getters() {
761            Ok(aggregate_batched(account_ids, self.positions_per_batch, |chunk| {
762                let multicall = self
763                    .provider
764                    .multicall()
765                    .block(self.block_id)
766                    .dynamic()
767                    .extend(
768                        chunk
769                            .iter()
770                            .map(|aid| self.instance.getPositionV2(perp_id, U256::from(*aid))),
771                    );
772                async move { multicall.aggregate().await }
773            })
774            .await?
775            .into_iter()
776            .map(|r| r.positionInfo)
777            .collect())
778        } else {
779            Ok(aggregate_batched(account_ids, self.positions_per_batch, |chunk| {
780                let multicall = self
781                    .provider
782                    .multicall()
783                    .block(self.block_id)
784                    .dynamic()
785                    .extend(
786                        chunk
787                            .iter()
788                            .map(|aid| self.instance.getPosition(perp_id, U256::from(*aid))),
789                    );
790                async move { multicall.aggregate().await }
791            })
792            .await?
793            .into_iter()
794            .map(|r| position_info_v0_to_v2(r.positionInfo))
795            .collect())
796        }
797    }
798}
799
800/// Runs `call` over `items` in concurrent batches of `batch_size`, halving any
801/// batch that fails and retrying it.
802///
803/// A multicall can fail for reasons that belong to the batch rather than to any
804/// single call in it - overwhelmingly, exhausting the node's `eth_call` gas
805/// budget. Per-call cost is not uniform across perpetual contracts: reading a
806/// position from a paused contract with no funding history has been measured at
807/// ~30x the cost of reading one from an active contract, so no single batch
808/// size is both efficient and safe. Since the perpetual set is discovered
809/// rather than configured, such a contract is found rather than chosen, and a
810/// fixed batch size would fail the whole snapshot on it.
811///
812/// Splitting converges on a size the node will serve, keeping the batch large
813/// (and the snapshot fast) for the common case. A batch of one that still fails
814/// is a genuine error and propagates - the alternative, dropping it, would
815/// silently omit state from a snapshot that presents itself as complete.
816async fn aggregate_batched<T, R, F, Fut>(
817    items: Vec<T>,
818    batch_size: usize,
819    call: F,
820) -> Result<Vec<R>, DexError>
821where
822    T: Clone,
823    F: Fn(Vec<T>) -> Fut,
824    Fut: Future<Output = Result<Vec<R>, alloy::providers::MulticallError>>,
825{
826    // Batches still to fetch, each with its offset in `items` so the results can
827    // be restored to the original order after any amount of splitting
828    let mut pending = items
829        .chunks(batch_size.max(1))
830        .enumerate()
831        .map(|(i, chunk)| (i * batch_size, chunk.to_vec()))
832        .collect::<Vec<_>>();
833    let mut fetched: Vec<(usize, Vec<R>)> = Vec::with_capacity(pending.len());
834
835    while !pending.is_empty() {
836        let results =
837            futures::future::join_all(pending.iter().map(|(_, chunk)| call(chunk.clone()))).await;
838        let mut retry = Vec::new();
839        for ((offset, chunk), result) in pending.into_iter().zip(results) {
840            match result {
841                Ok(values) => fetched.push((offset, values)),
842                Err(_) if chunk.len() > 1 => {
843                    let mid = chunk.len() / 2;
844                    retry.push((offset + mid, chunk[mid..].to_vec()));
845                    retry.push((offset, chunk[..mid].to_vec()));
846                },
847                Err(err) => return Err(DexError::Provider(err.into())),
848            }
849        }
850        pending = retry;
851    }
852
853    fetched.sort_by_key(|(offset, _)| *offset);
854    Ok(fetched.into_iter().flat_map(|(_, values)| values).collect())
855}
856
857/// Returns the IDs of every perpetual contract listed on the exchange at
858/// `block_id`.
859///
860/// The exchange reports its own listings, so a client does not need to be
861/// configured with them - see [`Chain::perpetuals`].
862pub async fn listed_perpetuals<P: Provider + Clone>(
863    chain: &Chain,
864    provider: P,
865    block_id: BlockId,
866) -> Result<Vec<types::PerpetualId>, DexError> {
867    let instance = dex::Exchange::new(chain.exchange(), provider.clone());
868    let features = ContractFeatures::probe(&instance, block_id, None).await;
869    discover_perpetuals(&instance, &provider, block_id, features, chain.excluded_perpetuals()).await
870}
871
872/// Returns the IDs of every perpetual listed on the exchange, less the ones
873/// [`Chain::excluded_perpetuals`] leaves out.
874///
875/// Reads the existence bitmap on v1.1.7.4+, a single call covering the whole
876/// `0..=`[`types::MAX_PERPETUAL_ID`] ID space. Older deployments have no
877/// bitmap, so existence is probed by batching `getMarginFractions` over that ID
878/// space - it reverts `ContractDoesNotExist` for unlisted IDs and reads only a
879/// couple of slots for listed ones.
880async fn discover_perpetuals<P: Provider + Clone>(
881    instance: &dex::Exchange::ExchangeInstance<P>,
882    provider: &P,
883    block_id: BlockId,
884    features: ContractFeatures,
885    excluded: &[types::PerpetualId],
886) -> Result<Vec<types::PerpetualId>, DexError> {
887    if features.perpetual_discovery() {
888        let bitmap = instance
889            .getPerpetualExistsBitmap()
890            .block(block_id)
891            .call()
892            .await
893            .map_err(|err| DexError::Provider(err.into()))?;
894        return Ok(bitmap
895            .into_iter()
896            .enumerate()
897            .flat_map(|(word, bits)| {
898                (0..U256::BITS).filter_map(move |bit| {
899                    let perp_id = (word * U256::BITS + bit) as types::PerpetualId;
900                    (bits.bit(bit) && perp_id <= types::MAX_PERPETUAL_ID).then_some(perp_id)
901                })
902            })
903            .filter(|perp_id| !excluded.contains(perp_id))
904            .collect());
905    }
906
907    let probe_batch_futs = (0..=types::MAX_PERPETUAL_ID)
908        .filter(|perp_id| !excluded.contains(perp_id))
909        .chunks(PERPETUAL_PROBES_PER_BATCH)
910        .into_iter()
911        .map(|chunk| {
912            let perp_ids = chunk.collect::<Vec<_>>();
913            let multicall = provider
914                .multicall()
915                .block(block_id)
916                .dynamic::<dex::Exchange::getMarginFractionsCall>()
917                // Probing IS the point here: an unlisted ID reverts, and the
918                // batch must survive that
919                .extend_calls(perp_ids.iter().map(|perp_id| {
920                    CallItem::from(instance.getMarginFractions(U256::from(*perp_id), U256::ZERO))
921                        .with_failure_allowed()
922                }));
923            async move { multicall.aggregate3().await.map(|res| (perp_ids, res)) }
924        })
925        .collect::<Vec<_>>();
926
927    Ok(futures::future::try_join_all(probe_batch_futs)
928        .await
929        .map_err(|err| DexError::Provider(err.into()))?
930        .into_iter()
931        .flat_map(|(perp_ids, results)| {
932            perp_ids
933                .into_iter()
934                .zip(results)
935                .filter_map(|(perp_id, result)| result.is_ok().then_some(perp_id))
936        })
937        .collect())
938}
939
940fn position_info_v0_to_v2(v0: PositionInfo) -> PositionInfoV2 {
941    PositionInfoV2 {
942        accountId: v0.accountId,
943        nextNodeId: v0.nextNodeId,
944        prevNodeId: v0.prevNodeId,
945        positionType: v0.positionType,
946        depositCNS: v0.depositCNS,
947        pricePNS: v0.pricePNS,
948        lotLNS: v0.lotLNS,
949        entryBlock: v0.entryBlock,
950        pnlCNS: v0.pnlCNS,
951        deltaPnlCNS: v0.deltaPnlCNS,
952        premiumPnlCNS: v0.premiumPnlCNS,
953        priceResiduePNSQ16: U256::ZERO,
954    }
955}
956
957fn order_v0_to_v2(v0: OrderV0) -> OrderV2 {
958    OrderV2 {
959        accountId: v0.accountId,
960        orderType: v0.orderType,
961        priceONS: v0.priceONS,
962        lotLNS: v0.lotLNS,
963        recycleFeeRaw: v0.recycleFeeRaw,
964        expiryBlock: v0.expiryBlock,
965        leverageHdths: v0.leverageHdths,
966        orderId: v0.orderId,
967        prevOrderId: v0.prevOrderId,
968        nextOrderId: v0.nextOrderId,
969        maxNegPnlCollatBPS: v0.maxNegPnlCollatBPS,
970        builderId: 0,
971        builderFeePer100K: 0,
972    }
973}
974
975fn perpetual_info_v0_to_v2(v0: PerpetualInfo) -> PerpetualInfoV2 {
976    PerpetualInfoV2 {
977        name: v0.name,
978        symbol: v0.symbol,
979        priceDecimals: v0.priceDecimals,
980        lotDecimals: v0.lotDecimals,
981        linkFeedId: v0.linkFeedId,
982        priceTolPer100K: v0.priceTolPer100K,
983        marginTol: v0.marginTol,
984        marginTolDecimals: v0.marginTolDecimals,
985        refPriceMaxAgeSec: v0.refPriceMaxAgeSec,
986        positionBalanceCNS: v0.positionBalanceCNS,
987        insuranceBalanceCNS: v0.insuranceBalanceCNS,
988        markPNS: v0.markPNS,
989        markTimestamp: v0.markTimestamp,
990        lastPNS: v0.lastPNS,
991        lastTimestamp: v0.lastTimestamp,
992        oraclePNS: v0.oraclePNS,
993        oracleTimestampSec: v0.oracleTimestampSec,
994        longOpenInterestLNS: v0.longOpenInterestLNS,
995        shortOpenInterestLNS: v0.shortOpenInterestLNS,
996        fundingStartBlock: v0.fundingStartBlock,
997        fundingRatePct100k: v0.fundingRatePct100k,
998        absFundingClampPctPer100K: v0.absFundingClampPctPer100K,
999        status: v0.status,
1000        basePricePNS: v0.basePricePNS,
1001        maxBidPriceONS: v0.maxBidPriceONS,
1002        minBidPriceONS: v0.minBidPriceONS,
1003        maxAskPriceONS: v0.maxAskPriceONS,
1004        minAskPriceONS: v0.minAskPriceONS,
1005        numOrders: v0.numOrders,
1006        ignOracle: v0.ignOracle,
1007        fundingSumScalingExp: U256::ZERO,
1008    }
1009}