Skip to main content

simulator_api/
lib.rs

1use std::{
2    collections::{BTreeMap, BTreeSet, HashMap, HashSet},
3    fmt,
4    str::FromStr,
5    sync::Arc,
6};
7
8use base64::{
9    DecodeError as Base64DecodeError, Engine as _, engine::general_purpose::STANDARD as BASE64,
10};
11use parking_lot::RwLock;
12use serde::{Deserialize, Serialize};
13use solana_account::AccountSharedData;
14use solana_address::{Address, error::ParseAddressError};
15
16mod session_id;
17mod session_type;
18mod slot_range;
19
20pub use session_id::{ControlSessionId, SessionId};
21pub use session_type::SessionType;
22pub use slot_range::{InvertedSlotRange, SlotRange};
23
24pub mod client_identity;
25pub mod geyser_route;
26pub mod route_plan;
27pub mod subscribe_config;
28pub mod usage;
29pub mod ws_compression;
30
31/// Backtest RPC methods exposed to the client.
32#[derive(Debug, Serialize, Deserialize)]
33#[serde(tag = "method", content = "params", rename_all = "camelCase")]
34pub enum BacktestRequest {
35    CreateBacktestSession(CreateBacktestSessionRequest),
36    Continue(ContinueParams),
37    ContinueTo(ContinueToParams),
38    ContinueSessionV1(ContinueSessionRequestV1),
39    ContinueToSessionV1(ContinueToSessionRequestV1),
40    CloseBacktestSession,
41    CloseSessionV1(CloseSessionRequestV1),
42    AttachBacktestSession {
43        session_id: SessionId,
44        /// Last sequence number the client received. Responses after this sequence
45        /// will be replayed from the session's buffer. None = replay entire buffer.
46        last_sequence: Option<u64>,
47    },
48    /// Sent after reattaching and rebuilding any dependent subscriptions.
49    /// Allows the manager to resume a session that was paused for handoff.
50    ResumeAttachedSession,
51    AttachParallelControlSessionV2 {
52        control_session_id: ControlSessionId,
53        /// Last per-session sequence number received by the client. Responses after
54        /// these sequence numbers will be replayed from the manager's per-session
55        /// replay store. Missing sessions replay their entire retained history.
56        #[serde(default)]
57        last_sequences: BTreeMap<SessionId, u64>,
58    },
59}
60
61/// Versioned payload for `CreateBacktestSession`.
62///
63/// - `V0` keeps backwards-compatible shape by using `CreateSessionParams` directly.
64/// - `V1` keeps the same shape and adds `parallel`.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(untagged)]
67pub enum CreateBacktestSessionRequest {
68    V1(CreateBacktestSessionRequestV1),
69    V0(CreateSessionParams),
70}
71
72impl CreateBacktestSessionRequest {
73    pub fn into_request_options(self) -> CreateBacktestSessionRequestOptions {
74        match self {
75            Self::V0(request) => CreateBacktestSessionRequestOptions {
76                request,
77                parallel: false,
78            },
79            Self::V1(CreateBacktestSessionRequestV1 { request, parallel }) => {
80                CreateBacktestSessionRequestOptions { request, parallel }
81            }
82        }
83    }
84
85    pub fn into_request_and_parallel(self) -> (CreateSessionParams, bool) {
86        let options = self.into_request_options();
87        (options.request, options.parallel)
88    }
89}
90
91impl From<CreateSessionParams> for CreateBacktestSessionRequest {
92    fn from(value: CreateSessionParams) -> Self {
93        Self::V0(value)
94    }
95}
96
97impl From<CreateBacktestSessionRequestV1> for CreateBacktestSessionRequest {
98    fn from(value: CreateBacktestSessionRequestV1) -> Self {
99        Self::V1(value)
100    }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct CreateBacktestSessionRequestV1 {
106    #[serde(flatten)]
107    pub request: CreateSessionParams,
108    pub parallel: bool,
109}
110
111#[derive(Debug, Clone)]
112pub struct CreateBacktestSessionRequestOptions {
113    pub request: CreateSessionParams,
114    pub parallel: bool,
115}
116
117#[derive(Debug, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct ContinueSessionRequestV1 {
120    pub session_id: SessionId,
121    pub request: ContinueParams,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(rename_all = "camelCase")]
126pub struct ContinueToSessionRequestV1 {
127    pub session_id: SessionId,
128    pub request: ContinueToParams,
129}
130
131#[derive(Debug, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase")]
133pub struct CloseSessionRequestV1 {
134    pub session_id: SessionId,
135}
136
137/// A filter registered at session creation describing which upcoming batches
138/// the session should announce ahead of execution via
139/// [`BacktestResponse::DiscoveryBatch`] (and its session-event twins). Each
140/// filter describes an event of interest (e.g. a specific program executing);
141/// the session "discovers" the batch in which that event will occur and
142/// emits a `DiscoveryBatch` so the client can pause immediately before it.
143#[serde_with::serde_as]
144#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
145#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
146pub enum DiscoveryFilter {
147    /// Discover batches containing a transaction that invokes this program.
148    ProgramExecuted(#[serde_as(as = "serde_with::DisplayFromStr")] Address),
149}
150
151/// Per-transaction facts a [`DiscoveryFilter`] can inspect when deciding
152/// whether to match. Callers build this once per transaction and feed it to
153/// every registered filter; new variants add fields here rather than growing
154/// the [`DiscoveryFilter::matches`] signature.
155pub struct TxMatchContext<'a> {
156    /// Programs invoked by the transaction (top-level + CPI observed in logs).
157    pub invoked_programs: &'a HashSet<Address>,
158}
159
160impl DiscoveryFilter {
161    /// Return `true` when this filter is satisfied by the transaction
162    /// described by `ctx`.
163    pub fn matches(&self, ctx: &TxMatchContext<'_>) -> bool {
164        match self {
165            Self::ProgramExecuted(target) => ctx.invoked_programs.contains(target),
166        }
167    }
168}
169
170/// The two mints a swap trades, without a direction: a maker quotes a pair both ways, so
171/// `(a, b)` and `(b, a)` are the same pair. Normalized on construction and on deserialization,
172/// which is what makes the set lookup direction-agnostic.
173#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
174#[serde(try_from = "[String; 2]", into = "[String; 2]")]
175pub struct MintPair([Address; 2]);
176
177impl MintPair {
178    pub fn new(one: Address, other: Address) -> Self {
179        Self(if one <= other {
180            [one, other]
181        } else {
182            [other, one]
183        })
184    }
185
186    pub fn mints(&self) -> (Address, Address) {
187        (self.0[0], self.0[1])
188    }
189}
190
191#[derive(Debug, thiserror::Error)]
192#[error("invalid mint {mint} in pair")]
193pub struct ParseMintPairError {
194    mint: String,
195    source: ParseAddressError,
196}
197
198impl TryFrom<[String; 2]> for MintPair {
199    type Error = ParseMintPairError;
200
201    fn try_from([one, other]: [String; 2]) -> Result<Self, Self::Error> {
202        fn parse(mint: String) -> Result<Address, ParseMintPairError> {
203            Address::from_str(&mint).map_err(|source| ParseMintPairError { mint, source })
204        }
205        Ok(Self::new(parse(one)?, parse(other)?))
206    }
207}
208
209impl From<MintPair> for [String; 2] {
210    fn from(pair: MintPair) -> Self {
211        let (one, other) = pair.mints();
212        [one.to_string(), other.to_string()]
213    }
214}
215
216/// Restricts which detected swaps are re-quoted. A transaction is rerouted when at least one
217/// of its swaps trades a listed pair; the rest replay unchanged, counted as `swaps_filtered`.
218#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "camelCase")]
220pub struct RerouteFilter {
221    /// A swap matches when the mints it trades are one of these pairs, either direction.
222    #[serde(default)]
223    pub pairs: BTreeSet<MintPair>,
224}
225
226impl RerouteFilter {
227    /// An empty filter admits nothing, which creation-time validation rejects: omit the filter
228    /// to admit everything.
229    pub fn is_empty(&self) -> bool {
230        self.pairs.is_empty()
231    }
232
233    pub fn matches_mints(&self, input: &Address, output: &Address) -> bool {
234        self.pairs.contains(&MintPair::new(*input, *output))
235    }
236
237    /// Whether any listed pair trades `mint`. An arbitrage cycle trades one mint with itself, so
238    /// it is admitted by the book naming that mint rather than by a pair it can never match.
239    pub fn mentions_mint(&self, mint: &Address) -> bool {
240        self.pairs.iter().any(|pair| {
241            let (one, other) = pair.mints();
242            one == *mint || other == *mint
243        })
244    }
245}
246
247/// How an action's transactions run. Only meaningful for an action that carries
248/// transactions: one that carries none is a schedule entry for `account_overrides`.
249#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(rename_all = "camelCase")]
251pub enum ActionKind {
252    /// Runs against session state without committing. Each transaction's writes are
253    /// visible to the next one in the same action and to nothing else, so the replay is
254    /// unaffected.
255    #[default]
256    Simulate,
257    /// Executes and commits to the session ledger. The writes are visible to every later
258    /// slot, including the historical transactions that follow — a deliberate change to
259    /// the replayed world, not a simulation of one. Rejected under `replay_account_state`,
260    /// where the recording restamps account state and the commit would be lost.
261    Send,
262}
263
264/// Where a [`ScheduledAction`] fires.
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266#[serde(tag = "at", rename_all = "camelCase")]
267pub enum ActionAnchor {
268    /// At the start of each slot, before its transactions execute and before
269    /// reroute quoting. Fires the entry for the current slot, if any.
270    BeforeSlot {
271        #[serde(default, with = "compressed")]
272        slots: Vec<u64>,
273    },
274    /// At the end of each slot, after its transactions execute but before it
275    /// finalizes. Fires the entry for the current slot, if any.
276    AfterSlot {
277        #[serde(default, with = "compressed")]
278        slots: Vec<u64>,
279    },
280    /// Before each batch matching `filter` executes. The whole sequence fires at
281    /// each matching batch.
282    BeforeMatch { filter: DiscoveryFilter },
283    /// After each batch matching `filter` commits. The whole sequence fires at
284    /// each matching batch.
285    AfterMatch { filter: DiscoveryFilter },
286}
287
288/// Upper bound on a decompressed transactions blob, guarding the server against
289/// a decompression bomb in client input.
290const MAX_ACTION_TRANSACTIONS_BYTES: u64 = 64 * 1024 * 1024;
291
292/// serde adapter that stores any value as a base64-encoded, zstd-compressed JSON
293/// blob on the wire, so transaction bytes never travel raw. Used via
294/// `#[serde(with = "compressed")]` on [`ScheduledAction`]'s `transactions` field
295/// and [`ActionAnchor`]'s `slots` field.
296mod compressed {
297    use std::io::Read;
298
299    use base64::Engine as _;
300    use serde::{Deserialize, Deserializer, Serialize, Serializer, de::DeserializeOwned};
301
302    use super::{BASE64, MAX_ACTION_TRANSACTIONS_BYTES};
303
304    /// zstd compression level; `0` selects zstd's default (level 3).
305    const COMPRESSION_LEVEL: i32 = 0;
306
307    pub fn serialize<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
308    where
309        T: Serialize,
310        S: Serializer,
311    {
312        let json = serde_json::to_vec(value).map_err(serde::ser::Error::custom)?;
313        let compressed = zstd::stream::encode_all(json.as_slice(), COMPRESSION_LEVEL)
314            .map_err(serde::ser::Error::custom)?;
315        serializer.serialize_str(&BASE64.encode(compressed))
316    }
317
318    pub fn deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
319    where
320        T: DeserializeOwned,
321        D: Deserializer<'de>,
322    {
323        let blob = String::deserialize(deserializer)?;
324        let compressed = BASE64
325            .decode(blob.as_bytes())
326            .map_err(serde::de::Error::custom)?;
327        let mut json = Vec::new();
328        zstd::stream::read::Decoder::new(compressed.as_slice())
329            .and_then(|d| Read::take(d, MAX_ACTION_TRANSACTIONS_BYTES).read_to_end(&mut json))
330            .map_err(serde::de::Error::custom)?;
331        serde_json::from_slice(&json).map_err(serde::de::Error::custom)
332    }
333}
334
335/// A sequence of transactions the server runs automatically during a backtest,
336/// with results streamed over the `actionSubscribe` subscription. Under
337/// `feeds_reroute` the state it ends with also reaches the session's override
338/// schedule, which every simulation surface reads — reroute candidates and later
339/// scheduled actions alike. An action carrying only `account_overrides` is purely
340/// declarative: it contributes to that schedule with or without the flag, and
341/// emits no result.
342#[serde_with::serde_as]
343#[derive(Debug, Clone, Serialize, Deserialize)]
344#[serde(rename_all = "camelCase")]
345pub struct ScheduledAction {
346    pub anchor: ActionAnchor,
347    /// Ignored when `transactions` is empty — that action is a schedule entry, not a run.
348    #[serde(default)]
349    pub kind: ActionKind,
350    /// Base64-encoded transactions, carried over the wire as a base64-encoded,
351    /// zstd-compressed blob. For a slot anchor (`beforeSlot`/`afterSlot`),
352    /// entry `i` fires at `slots[i]` — the two lists must be the same length,
353    /// and repeating a slot runs its entries there in order as one sequence.
354    /// For match anchors the whole list fires, in order, at each matching
355    /// batch. For `simulate`, each transaction's writes are visible to the next
356    /// without committing to the session ledger. Empty under `feeds_reroute`
357    /// makes the action declarative: `account_overrides` is then the whole payload.
358    #[serde(default, with = "compressed")]
359    pub transactions: Vec<String>,
360    /// Account state overrides seeding the first transaction's simulation, per
361    /// `simulateTransaction` `modifyAccountStates` semantics. Invalid for
362    /// `kind: send`. Without transactions they are themselves the contribution to
363    /// the session's override schedule, `feeds_reroute` or not.
364    #[serde(default)]
365    pub account_overrides: AccountModifications,
366    /// Feed the account state this action ends with to the session's override
367    /// schedule, where it applies from the anchored slot to every simulation —
368    /// reroute candidates and later scheduled actions — and carries forward per
369    /// account until a later action names the same one. Requires a `beforeSlot`
370    /// anchor.
371    ///
372    /// With no transactions that state is `account_overrides` itself, known at session
373    /// creation. With transactions it is their simulated post-state, so the slot's
374    /// overrides are not known until the action runs at slot start.
375    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
376    pub feeds_reroute: bool,
377    /// Accounts whose post-execution state to return, following
378    /// `simulateTransaction` semantics; reflects cumulative state after the
379    /// final transaction.
380    #[serde_as(as = "Vec<serde_with::DisplayFromStr>")]
381    #[serde(default)]
382    pub return_accounts: Vec<Address>,
383    /// Optional client tag echoed back on each `actionSubscribe` result to correlate it to this action.
384    #[serde(default)]
385    pub label: Option<String>,
386}
387
388impl ScheduledAction {
389    /// Whether the action's resulting state has to be simulated before it can be
390    /// published — a slot's overrides are then unknown until the action runs.
391    pub fn is_computed_override(&self) -> bool {
392        self.feeds_reroute && !self.transactions.is_empty()
393    }
394}
395
396/// One ordered contribution to a slot's override state.
397#[derive(Debug, Clone)]
398enum OverrideContribution {
399    Static(HashMap<Address, AccountSharedData>),
400    Pending,
401    Resolved(HashMap<Address, AccountSharedData>),
402    Failed,
403}
404
405#[derive(Debug, Clone, Default)]
406struct OverrideScheduleInner {
407    entries: BTreeMap<u64, Vec<(u32, OverrideContribution)>>,
408    /// Every account some contribution names. The geyser rewrite path asks `active_for` about each
409    /// account event it sees, and almost none are overridden; without this the miss walks every
410    /// entry up to the slot.
411    keys: HashSet<Address>,
412}
413
414impl OverrideScheduleInner {
415    fn new(entries: BTreeMap<u64, Vec<(u32, OverrideContribution)>>) -> Self {
416        let keys = entries
417            .values()
418            .flatten()
419            .filter_map(|(_, contribution)| match contribution {
420                OverrideContribution::Static(accounts)
421                | OverrideContribution::Resolved(accounts) => Some(accounts.keys().copied()),
422                OverrideContribution::Pending | OverrideContribution::Failed => None,
423            })
424            .flatten()
425            .collect();
426        Self { entries, keys }
427    }
428}
429
430/// Slot-keyed account overrides, carried forward per account. Clones share the same runtime
431/// resolutions so the session, reroute engine, and geyser pump observe one schedule.
432#[derive(Debug, Clone, Default)]
433pub struct OverrideSchedule {
434    inner: Arc<RwLock<OverrideScheduleInner>>,
435}
436
437impl From<BTreeMap<u64, HashMap<Address, AccountSharedData>>> for OverrideSchedule {
438    fn from(entries: BTreeMap<u64, HashMap<Address, AccountSharedData>>) -> Self {
439        Self {
440            inner: Arc::new(RwLock::new(OverrideScheduleInner::new(
441                entries
442                    .into_iter()
443                    .map(|(slot, accounts)| {
444                        (slot, vec![(0, OverrideContribution::Static(accounts))])
445                    })
446                    .collect(),
447            ))),
448        }
449    }
450}
451
452impl OverrideSchedule {
453    pub fn is_empty(&self) -> bool {
454        self.inner.read().entries.is_empty()
455    }
456
457    /// Slots at which some entry activates, ascending.
458    pub fn slots(&self) -> impl Iterator<Item = u64> + '_ {
459        self.inner
460            .read()
461            .entries
462            .keys()
463            .copied()
464            .collect::<Vec<_>>()
465            .into_iter()
466    }
467
468    /// Everything active at `slot`, resolved per account.
469    pub fn active_at(&self, slot: u64) -> HashMap<Address, AccountSharedData> {
470        self.inner.read().entries.range(..=slot).fold(
471            HashMap::new(),
472            |mut active, (_, contributions)| {
473                active.extend(fold_contributions(contributions));
474                active
475            },
476        )
477    }
478
479    pub fn active_for(&self, key: &Address, slot: u64) -> Option<AccountSharedData> {
480        let inner = self.inner.read();
481        if !inner.keys.contains(key) {
482            return None;
483        }
484        inner
485            .entries
486            .range(..=slot)
487            .rev()
488            .find_map(|(_, contributions)| {
489                contributions
490                    .iter()
491                    .rev()
492                    .find_map(|(_, contribution)| match contribution {
493                        OverrideContribution::Static(accounts)
494                        | OverrideContribution::Resolved(accounts) => accounts.get(key).cloned(),
495                        OverrideContribution::Pending | OverrideContribution::Failed => None,
496                    })
497            })
498    }
499
500    /// Final values activated in `(after, up_to]`, deduplicated per account.
501    pub fn newly_active(
502        &self,
503        after: Option<u64>,
504        up_to: u64,
505    ) -> Vec<(Address, AccountSharedData)> {
506        let lower = after.map_or(std::ops::Bound::Unbounded, std::ops::Bound::Excluded);
507        self.inner
508            .read()
509            .entries
510            .range((lower, std::ops::Bound::Included(up_to)))
511            .flat_map(|(_, contributions)| fold_contributions(contributions))
512            .collect::<HashMap<_, _>>()
513            .into_iter()
514            .collect()
515    }
516
517    /// Resolve one computed contribution; `None` marks the action failed, contributing no accounts.
518    /// Counting failures is the caller's job — it also sees the errors returned here.
519    pub fn resolve(
520        &self,
521        slot: u64,
522        action_index: u32,
523        accounts: Option<HashMap<Address, AccountSharedData>>,
524    ) -> Result<(), String> {
525        let mut inner = self.inner.write();
526        let OverrideScheduleInner { entries, keys } = &mut *inner;
527        let contribution = entries
528            .get_mut(&slot)
529            .and_then(|contributions| {
530                contributions
531                    .iter_mut()
532                    .find(|(index, _)| *index == action_index)
533                    .map(|(_, contribution)| contribution)
534            })
535            .ok_or_else(|| {
536                format!("override action {action_index} has no contribution at slot {slot}")
537            })?;
538        if !matches!(contribution, OverrideContribution::Pending) {
539            return Err(format!(
540                "override action {action_index} at slot {slot} is not pending"
541            ));
542        }
543        *contribution = match accounts {
544            Some(accounts) => {
545                keys.extend(accounts.keys().copied());
546                OverrideContribution::Resolved(accounts)
547            }
548            None => OverrideContribution::Failed,
549        };
550        Ok(())
551    }
552}
553
554fn fold_contributions(
555    contributions: &[(u32, OverrideContribution)],
556) -> HashMap<Address, AccountSharedData> {
557    let mut accounts = HashMap::new();
558    for (_, contribution) in contributions {
559        match contribution {
560            OverrideContribution::Static(next) | OverrideContribution::Resolved(next) => {
561                accounts.extend(next.iter().map(|(key, account)| (*key, account.clone())));
562            }
563            OverrideContribution::Pending | OverrideContribution::Failed => {}
564        }
565    }
566    accounts
567}
568
569/// Validate and compile the publishing actions into the slot-keyed override schedule every
570/// simulation surface consumes — reroute candidates and scheduled-action runs alike. Static
571/// contributions are immediately available; computed contributions retain their action order as
572/// pending entries until the action scheduler resolves them.
573///
574/// `feeds_reroute` actions contribute their end-state; a declarative override action (account
575/// overrides, no transactions) contributes without the flag, so overrides stay usable in a
576/// session that never reroutes.
577fn decode_override_actions(actions: &[ScheduledAction]) -> Result<OverrideSchedule, String> {
578    let mut entries: BTreeMap<u64, Vec<(u32, OverrideContribution)>> = BTreeMap::new();
579    for (action_index, action) in actions.iter().enumerate().filter(|(_, action)| {
580        action.feeds_reroute
581            || (action.transactions.is_empty() && !action.account_overrides.0.is_empty())
582    }) {
583        // Without this an action that meant to send would feed the schedule instead, silently.
584        if action.kind == ActionKind::Send {
585            return Err("account overrides are not valid for kind: send".to_string());
586        }
587        if action.transactions.is_empty() && action.account_overrides.0.is_empty() {
588            return Err(
589                "feedsReroute needs something to feed: transactions to simulate, or \
590                 accountOverrides"
591                    .to_string(),
592            );
593        }
594        let ActionAnchor::BeforeSlot { slots } = &action.anchor else {
595            return Err("scheduled account overrides require a beforeSlot anchor".to_string());
596        };
597        let accounts = decode_override_accounts(&action.account_overrides)?;
598        let contribution = if action.transactions.is_empty() {
599            OverrideContribution::Static(accounts)
600        } else {
601            OverrideContribution::Pending
602        };
603        // Deduplicated: a slot repeated in one action would push two contributions sharing an
604        // action index, and `resolve` only ever finds the first — leaving the second pending.
605        for slot in slots.iter().copied().collect::<BTreeSet<_>>() {
606            entries
607                .entry(slot)
608                .or_default()
609                .push((action_index as u32, contribution.clone()));
610        }
611    }
612    Ok(OverrideSchedule {
613        inner: Arc::new(RwLock::new(OverrideScheduleInner::new(entries))),
614    })
615}
616
617/// Decode account modifications into engine override form, rejecting
618/// executable/program accounts (cached bytecode makes program-code overrides
619/// stale).
620#[allow(
621    deprecated,
622    reason = "`PROGRAM_OWNERS` remains the canonical loader-owner list"
623)]
624pub fn decode_account_modifications(
625    modifications: &AccountModifications,
626) -> Result<HashMap<Address, AccountSharedData>, String> {
627    modifications
628        .0
629        .iter()
630        .map(|(address, data)| {
631            let account = data
632                .to_account()
633                .map_err(|error| format!("invalid account data for {address}: {error}"))?;
634            if account.executable || solana_account::PROGRAM_OWNERS.contains(&account.owner) {
635                return Err(format!(
636                    "overriding executable/program accounts is not supported (account {address})"
637                ));
638            }
639            Ok((*address, AccountSharedData::from(account)))
640        })
641        .collect()
642}
643
644/// [`decode_account_modifications`] plus rejection of token accounts, which the
645/// reroute-only read path cannot honor consistently.
646fn decode_override_accounts(
647    modifications: &AccountModifications,
648) -> Result<HashMap<Address, AccountSharedData>, String> {
649    let accounts = decode_account_modifications(modifications)?;
650    if let Some(address) = modifications
651        .0
652        .iter()
653        .find_map(|(address, data)| TOKEN_PROGRAM_IDS.contains(&data.owner).then_some(address))
654    {
655        return Err(format!(
656            "override for {address}: token accounts cannot be overridden \
657             (prepare-stage reads would desync from simulation)"
658        ));
659    }
660    Ok(accounts)
661}
662
663/// Server-side ceiling on [`CreateSessionParams::disconnect_timeout_secs`]:
664/// 900 seconds (15 minutes). A larger request is silently clamped to this, so
665/// clients must keep their reconnect budget under it to survive a detach.
666pub const MAX_DISCONNECT_TIMEOUT_SECS: u16 = 900;
667
668/// A router whose historical swaps a session can re-quote. The wire spelling; the session maps
669/// each onto its own aggregator type.
670#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
671#[serde(rename_all = "lowercase", try_from = "String")]
672pub enum SwapAggregator {
673    Jupiter,
674    Okx,
675    Titan,
676    Dflow,
677}
678
679impl SwapAggregator {
680    /// Matches the serialized spelling and the aggregator labels in the per-requote rows.
681    pub const fn as_str(self) -> &'static str {
682        match self {
683            Self::Jupiter => "jupiter",
684            Self::Okx => "okx",
685            Self::Titan => "titan",
686            Self::Dflow => "dflow",
687        }
688    }
689}
690
691impl std::fmt::Display for SwapAggregator {
692    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
693        f.write_str(self.as_str())
694    }
695}
696
697#[derive(Debug, thiserror::Error)]
698pub enum ParseAggregatorError {
699    #[error("unknown swap aggregator {0:?}; expected one of jupiter, okx, titan, dflow")]
700    Unknown(String),
701    /// A run that admits no aggregator drives the whole reroute apparatus and re-quotes nothing.
702    #[error("name at least one aggregator (jupiter, okx, titan, dflow)")]
703    NoAggregator,
704}
705
706impl TryFrom<String> for SwapAggregator {
707    type Error = ParseAggregatorError;
708
709    fn try_from(value: String) -> Result<Self, Self::Error> {
710        value.parse()
711    }
712}
713
714impl std::str::FromStr for SwapAggregator {
715    type Err = ParseAggregatorError;
716
717    fn from_str(s: &str) -> Result<Self, Self::Err> {
718        match s.trim().to_ascii_lowercase().as_str() {
719            "jupiter" => Ok(Self::Jupiter),
720            "okx" => Ok(Self::Okx),
721            "titan" => Ok(Self::Titan),
722            "dflow" => Ok(Self::Dflow),
723            _ => Err(ParseAggregatorError::Unknown(s.to_string())),
724        }
725    }
726}
727
728/// The aggregators a session re-quotes. Jupiter alone by default: the router builds Jupiter
729/// replacements, so other aggregators' order flow is a cross-router counterfactual a run asks for
730/// deliberately. Parsed from one comma-separated string so a flag or an env var carries the set.
731#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
732#[serde(transparent)]
733pub struct RerouteAggregators(BTreeSet<SwapAggregator>);
734
735impl Default for RerouteAggregators {
736    fn default() -> Self {
737        Self([SwapAggregator::Jupiter].into_iter().collect())
738    }
739}
740
741impl RerouteAggregators {
742    pub fn new(aggregators: impl IntoIterator<Item = SwapAggregator>) -> Self {
743        Self(aggregators.into_iter().collect())
744    }
745
746    pub fn contains(&self, aggregator: SwapAggregator) -> bool {
747        self.0.contains(&aggregator)
748    }
749
750    pub fn is_empty(&self) -> bool {
751        self.0.is_empty()
752    }
753}
754
755impl std::fmt::Display for RerouteAggregators {
756    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
757        let mut aggregators = self.0.iter();
758        if let Some(first) = aggregators.next() {
759            write!(f, "{first}")?;
760        }
761        aggregators.try_for_each(|aggregator| write!(f, ",{aggregator}"))
762    }
763}
764
765impl std::str::FromStr for RerouteAggregators {
766    type Err = ParseAggregatorError;
767
768    fn from_str(s: &str) -> Result<Self, Self::Err> {
769        let aggregators: BTreeSet<SwapAggregator> = s
770            .split(',')
771            .filter(|aggregator| !aggregator.trim().is_empty())
772            .map(str::parse)
773            .collect::<Result<_, _>>()?;
774        if aggregators.is_empty() {
775            return Err(ParseAggregatorError::NoAggregator);
776        }
777        Ok(Self(aggregators))
778    }
779}
780
781/// Re-quoting historical swaps through the metis router: each admitted swap is re-quoted and the
782/// routed transaction is *simulated* just before the original executes — never committed, so the
783/// replay is unaffected. Each simulation reads the state of the batch its original belonged to;
784/// reroutes never observe one another.
785///
786/// Presence is the switch, which makes "a knob but no rerouting" unrepresentable rather than a
787/// validation error.
788#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
789#[serde(rename_all = "camelCase")]
790pub struct RerouteParams {
791    #[serde(default)]
792    pub aggregators: RerouteAggregators,
793    /// Restrict re-quoting to the mint pairs of interest — a maker's own book. Absent: every
794    /// detected swap on an admitted aggregator is re-quoted.
795    #[serde(default, skip_serializing_if = "Option::is_none")]
796    pub filter: Option<RerouteFilter>,
797    /// Also re-quote swaps whose L1 transaction failed, when the allowlist admits the failure as
798    /// one re-quoting can answer (a slippage rejection, or a failure after the swap already ran).
799    ///
800    /// Off by default: L1 realised nothing for these, so every comparison against the original is
801    /// undefined and they are tracked as their own population — `swaps_detected` and
802    /// `swaps_rerouted` exclude them, and every row carries `original_failed`.
803    #[serde(default)]
804    pub detect_failed_l1_swaps: bool,
805    /// Also re-quote arbitrage cycles — swaps whose input and output mint are the same. Off by
806    /// default: the router prices a cycle and then refuses to build the route it found.
807    #[serde(default)]
808    pub circular_arbs: bool,
809    /// Re-quote detected swaps through the metis router.
810    ///
811    /// On by default, and the only reason a session starts a sidecar. Turn it off to run
812    /// [`Self::direct_fill`] alone: pricing a venue metis cannot route needs no router, and
813    /// waiting on one would be waiting for something that can never answer.
814    #[serde(default = "RerouteParams::default_requote")]
815    pub requote: bool,
816    /// Price a candidate venue against the hops this run detects, alongside the re-quoting.
817    ///
818    /// Boxed: absent on almost every session, and inline it is the largest thing in
819    /// `BacktestRequest`, which every request would then pay for.
820    #[serde(default, skip_serializing_if = "Option::is_none")]
821    pub direct_fill: Option<Box<DirectFillParams>>,
822}
823
824/// Rebuild matching hops through one router, pinned to one venue, and price the result
825/// against what the hop actually filled at.
826///
827/// Independent of [`RerouteParams::filter`]: a transaction the re-quote funnel drops is still a
828/// hop worth pricing.
829#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
830#[serde(rename_all = "camelCase")]
831pub struct DirectFillParams {
832    /// The router whose encoder builds the replacement hop. Reaching a venue means going
833    /// through whichever router carries it.
834    pub aggregator: SwapAggregator,
835    /// The venue to pin the built route to, spelled as the router's own IDL names it.
836    pub venue: String,
837    /// The pair whose hops to price. A hop matches in either direction.
838    pub pair: MintPair,
839    /// Tolerance written into the built swap, around the expected output the caller derives from
840    /// the hop. Rejected at or above a whole unit.
841    pub slippage_bps: u16,
842}
843
844/// Hand-written rather than derived: `requote` defaults to ON, and a derived `Default` would
845/// give the opposite of what deserializing an empty object gives.
846impl Default for RerouteParams {
847    fn default() -> Self {
848        Self {
849            aggregators: RerouteAggregators::default(),
850            filter: None,
851            detect_failed_l1_swaps: false,
852            circular_arbs: false,
853            requote: Self::default_requote(),
854            direct_fill: None,
855        }
856    }
857}
858
859/// `<base>,<quote>` in base58 — the spelling a CLI flag or env var carries.
860impl std::str::FromStr for MintPair {
861    type Err = String;
862
863    fn from_str(s: &str) -> Result<Self, Self::Err> {
864        let (base, quote) = s
865            .split_once(',')
866            .ok_or_else(|| format!("expected two comma-separated mints, got {s:?}"))?;
867        Self::try_from([base.trim().to_owned(), quote.trim().to_owned()])
868            .map_err(|error| error.to_string())
869    }
870}
871
872impl std::fmt::Display for MintPair {
873    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
874        let (one, other) = self.mints();
875        write!(f, "{one},{other}")
876    }
877}
878
879impl RerouteParams {
880    const fn default_requote() -> bool {
881        true
882    }
883
884    /// Whether this block asks for anything at all — a run with neither re-quoting nor a direct
885    /// fill book has a reroute section that does nothing.
886    pub const fn is_noop(&self) -> bool {
887        !self.requote && self.direct_fill.is_none()
888    }
889}
890
891impl DirectFillParams {
892    /// Whether `input`/`output` name this book's pair, either direction.
893    pub fn matches_mints(&self, input: &Address, output: &Address) -> bool {
894        MintPair::new(*input, *output) == self.pair
895    }
896}
897
898/// Parameters required to start a new backtest session.
899#[serde_with::serde_as]
900#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
901#[serde(rename_all = "camelCase")]
902pub struct CreateSessionParams {
903    /// First slot (inclusive) to replay.
904    pub start_slot: u64,
905    /// Last slot (inclusive) to replay.
906    pub end_slot: u64,
907    #[serde_as(as = "BTreeSet<serde_with::DisplayFromStr>")]
908    #[serde(default)]
909    #[builder(default)]
910    /// Skip transactions signed by these addresses.
911    pub signer_filter: BTreeSet<Address>,
912    /// When true, include a session summary with transaction statistics in client-facing
913    /// `Completed` responses. Summary generation remains enabled internally for metrics.
914    #[serde(default)]
915    #[builder(default)]
916    pub send_summary: bool,
917    /// Re-quote historical swaps through the metis router. Absent: plain replay.
918    #[serde(default, skip_serializing_if = "Option::is_none")]
919    pub reroute: Option<RerouteParams>,
920    /// Maximum seconds to wait for ECS capacity-related startup retries before
921    /// failing session creation. If not set (or 0), capacity errors fail immediately.
922    #[serde(default)]
923    pub capacity_wait_timeout_secs: Option<u16>,
924    /// Maximum seconds to keep the session alive after the control websocket disconnects.
925    /// If not set (or 0), the session tears down immediately on disconnect.
926    /// Clamped to [`MAX_DISCONNECT_TIMEOUT_SECS`] (900 seconds / 15 minutes).
927    #[serde(default)]
928    pub disconnect_timeout_secs: Option<u16>,
929    /// Extra compute units to add to each transaction's `SetComputeUnitLimit` budget.
930    /// Useful when replaying with an account override whose program uses more CU than
931    /// the original, causing otherwise-healthy transactions to run out of budget.
932    /// Only applied when a `SetComputeUnitLimit` instruction is already present.
933    #[serde(default)]
934    pub extra_compute_units: Option<u32>,
935    /// Events of interest the session should watch for. When an upcoming
936    /// batch matches any filter, the server emits
937    /// [`BacktestResponse::DiscoveryBatch`] (and its session-event twins)
938    /// ahead of execution so the client can follow up with
939    /// [`BacktestRequest::ContinueTo`] to pause before the batch. Empty
940    /// means no batch discoveries are performed (existing behaviour).
941    #[serde(default, skip_serializing_if = "Vec::is_empty")]
942    #[builder(default)]
943    pub discoveries: Vec<DiscoveryFilter>,
944    /// Transactions the server runs automatically during the backtest, with
945    /// results streamed over `actionSubscribe`.
946    ///
947    /// Compressed as a whole rather than per action: a per-slot schedule repeats
948    /// near-identical account bytes thousands of times, and that redundancy is
949    /// only visible across actions. Compressing each action alone leaves a
950    /// 10k-slot schedule near the websocket message limit; compressing the
951    /// vector takes the same schedule from ~28 MiB to ~1 MiB.
952    #[serde(default, with = "compressed", skip_serializing_if = "Vec::is_empty")]
953    #[builder(default)]
954    pub actions: Vec<ScheduledAction>,
955    /// Replay accounts states for this range instead of executing transactions
956    /// — a state-only acceleration.
957    #[serde(default)]
958    #[builder(default)]
959    pub replay_account_state: bool,
960}
961
962/// What account state replay cannot serve, worded once. Client, manager and
963/// session all reject through here, so a user meets the same explanation
964/// wherever the request is stopped.
965pub mod replay_support {
966    use super::{ActionKind, CreateSessionParams};
967
968    /// `getTransaction`/`getSignatureStatuses`: nothing executed, so nothing is found.
969    pub const NO_TRANSACTIONS: &str =
970        "Create the session without replayAccountState to execute and persist transactions.";
971
972    pub const SEND_TRANSACTION: &str = "Use simulateTransaction to run it against the replayed state, or create the session \
973         without replayAccountState.";
974
975    pub const SEND_ACTION: &str = "Use kind: simulate, which runs against the replayed state without committing, post state \
976         directly with an action carrying accountOverrides and no transactions, or create the \
977         session without replayAccountState.";
978
979    /// States the operation, why replay cannot serve it, then the way forward.
980    pub fn unsupported(operation: &str, remedy: &str) -> String {
981        format!(
982            "{operation} is not supported while account state replay is enabled: replay \
983             reconstructs account state from the recorded delta bundle instead of executing \
984             transactions, so this session never executes or persists any. {remedy}"
985        )
986    }
987
988    /// The one rule decidable from the request alone, so it can be caught before
989    /// a session — or a runtime to host it — is ever created.
990    pub fn send_action_conflict(params: &CreateSessionParams) -> Option<String> {
991        (params.replay_account_state
992            && params
993                .actions
994                .iter()
995                .any(|action| action.kind == ActionKind::Send))
996        .then(|| unsupported("A scheduled action with kind: send", SEND_ACTION))
997    }
998}
999
1000impl CreateSessionParams {
1001    /// Whether a session needs the metis sidecar (reroute quotes through it).
1002    /// Only metis re-quoting needs the sidecar; a direct-fill-only run prices without a router.
1003    pub fn requires_sidecar(&self) -> bool {
1004        self.reroute.as_ref().is_some_and(|reroute| reroute.requote)
1005    }
1006
1007    pub fn reroute_order_flow(&self) -> bool {
1008        self.reroute.is_some()
1009    }
1010
1011    /// The Override actions in [`Self::actions`], compiled into the slot-keyed schedule the
1012    /// reroute layer consumes. Same-slot actions merge account-wise, later wins.
1013    pub fn override_schedule(&self) -> Result<OverrideSchedule, String> {
1014        decode_override_actions(&self.actions)
1015    }
1016
1017    /// The mint pairs re-quoting is restricted to, if the run named any.
1018    pub fn reroute_filter(&self) -> Option<&RerouteFilter> {
1019        self.reroute.as_ref()?.filter.as_ref()
1020    }
1021}
1022
1023/// What counts as a "non-benign" divergence for the backtest session fail-fast
1024/// behaviour (see `BacktestSessionOptions::fail_fast`).
1025#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1026#[serde(rename_all = "kebab-case")]
1027pub enum FailFastDivergenceKind {
1028    /// Any divergence other than a benign log diff (the full `is_divergent()` set:
1029    /// log mismatch, error mismatch, balance-diff mismatch).
1030    #[default]
1031    AnyNonBenign,
1032    /// Only divergences on transactions that touch an account currently watched by an
1033    /// account-diff subscription (subscribed directly, or owned by a subscribed program).
1034    Tracked,
1035}
1036
1037impl FailFastDivergenceKind {
1038    /// Stable string form used for CLI/env-var serialization. Matches the serde
1039    /// `kebab-case` representation.
1040    pub fn as_str(self) -> &'static str {
1041        match self {
1042            Self::AnyNonBenign => "any-non-benign",
1043            Self::Tracked => "tracked",
1044        }
1045    }
1046
1047    /// Inverse of [`Self::as_str`]; returns `None` for an unrecognized value.
1048    pub fn from_str_opt(value: &str) -> Option<Self> {
1049        match value {
1050            "any-non-benign" => Some(Self::AnyNonBenign),
1051            "tracked" => Some(Self::Tracked),
1052            _ => None,
1053        }
1054    }
1055}
1056
1057/// Account state modifications to apply.
1058#[serde_with::serde_as]
1059#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1060pub struct AccountModifications(
1061    #[serde_as(as = "BTreeMap<serde_with::DisplayFromStr, _>")]
1062    #[serde(default)]
1063    pub BTreeMap<Address, AccountData>,
1064);
1065
1066const TOKEN_PROGRAM_IDS: [Address; 2] = [spl_token_interface::ID, spl_token_2022_interface::ID];
1067
1068impl From<BTreeMap<Address, AccountData>> for AccountModifications {
1069    fn from(value: BTreeMap<Address, AccountData>) -> Self {
1070        Self(value)
1071    }
1072}
1073
1074/// Arguments used to continue an existing session.
1075#[serde_with::serde_as]
1076#[derive(Debug, Serialize, Deserialize)]
1077#[serde(rename_all = "camelCase")]
1078pub struct ContinueParams {
1079    #[serde(default = "ContinueParams::default_advance_count")]
1080    /// Number of blocks to advance before waiting.
1081    pub advance_count: u64,
1082    #[serde(default)]
1083    /// Base64-encoded transactions to execute before advancing.
1084    pub transactions: Vec<String>,
1085    #[serde(default)]
1086    /// Account state overrides to apply ahead of execution.
1087    pub modify_account_states: AccountModifications,
1088}
1089
1090impl Default for ContinueParams {
1091    fn default() -> Self {
1092        Self {
1093            advance_count: Self::default_advance_count(),
1094            transactions: Vec::new(),
1095            modify_account_states: AccountModifications(BTreeMap::new()),
1096        }
1097    }
1098}
1099
1100impl ContinueParams {
1101    pub fn default_advance_count() -> u64 {
1102        1
1103    }
1104}
1105
1106/// Payload emitted when a session halts at a caller-specified point.
1107/// `batch_index` is `None` for block-boundary pauses and `Some(n)` when the
1108/// session stopped *before* batch `n` within `slot` (no transaction from
1109/// batch `n` has been applied). While paused, RPC reads against the session
1110/// observe partial state up through batch `n - 1`.
1111#[derive(Debug, Clone, Serialize, Deserialize)]
1112#[serde(rename_all = "camelCase")]
1113pub struct PausedEvent {
1114    pub slot: u64,
1115    #[serde(default, skip_serializing_if = "Option::is_none")]
1116    pub batch_index: Option<u32>,
1117}
1118
1119/// Payload emitted when the session has *discovered* an upcoming batch that
1120/// matches one or more registered [`DiscoveryFilter`]s from session creation
1121/// (for example, a batch containing a transaction that invokes a program of
1122/// interest). The `(slot, batch_index)` pair can be fed directly to
1123/// [`BacktestRequest::ContinueTo`] to pause immediately before the batch
1124/// executes. After each `Continue` / `ContinueTo`, the session emits the
1125/// next `DiscoveryBatchEvent` ahead of the next matching batch, enabling a
1126/// reactive "pause on every discovery" driver loop.
1127#[serde_with::serde_as]
1128#[derive(Debug, Clone, Serialize, Deserialize)]
1129#[serde(rename_all = "camelCase")]
1130pub struct DiscoveryBatchEvent {
1131    pub slot: u64,
1132    pub batch_index: u32,
1133    /// Filters that matched this batch (always non-empty).
1134    pub matched: Vec<DiscoveryFilter>,
1135    /// Encoded transactions in this batch that triggered the match. Each
1136    /// entry carries the serialized `VersionedTransaction` bytes paired with
1137    /// the encoding used.
1138    pub transactions: Vec<EncodedBinary>,
1139}
1140
1141/// Arguments used to step an existing session to a precise point.
1142#[derive(Debug, Clone, Serialize, Deserialize)]
1143#[serde(rename_all = "camelCase")]
1144pub struct ContinueToParams {
1145    /// Target slot to stop in (or at, if `batch_index` is `None`).
1146    pub slot: u64,
1147    /// Batch within the target slot at which to pause, **exclusive** — the
1148    /// session halts immediately *before* batch `n` executes, so no
1149    /// transaction in that batch has been applied yet. `None` runs the
1150    /// whole slot, pausing at the block boundary. While paused, RPC reads
1151    /// observe partial state up through batch `n - 1`.
1152    #[serde(default)]
1153    pub batch_index: Option<u32>,
1154}
1155
1156/// Supported binary encodings for account/transaction payloads.
1157#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1158#[serde(rename_all = "lowercase")]
1159pub enum BinaryEncoding {
1160    Base64,
1161}
1162
1163impl BinaryEncoding {
1164    pub fn encode(self, bytes: &[u8]) -> String {
1165        match self {
1166            Self::Base64 => BASE64.encode(bytes),
1167        }
1168    }
1169
1170    pub fn decode(self, data: &str) -> Result<Vec<u8>, Base64DecodeError> {
1171        match self {
1172            Self::Base64 => BASE64.decode(data),
1173        }
1174    }
1175}
1176
1177/// A blob paired with the encoding needed to decode it.
1178#[derive(Debug, Clone, Serialize, Deserialize)]
1179#[serde(rename_all = "camelCase")]
1180pub struct EncodedBinary {
1181    /// Encoded payload.
1182    pub data: String,
1183    /// Encoding scheme used for the payload.
1184    pub encoding: BinaryEncoding,
1185}
1186
1187impl EncodedBinary {
1188    pub fn new(data: String, encoding: BinaryEncoding) -> Self {
1189        Self { data, encoding }
1190    }
1191
1192    pub fn from_bytes(bytes: &[u8], encoding: BinaryEncoding) -> Self {
1193        Self {
1194            data: encoding.encode(bytes),
1195            encoding,
1196        }
1197    }
1198
1199    pub fn decode(&self) -> Result<Vec<u8>, Base64DecodeError> {
1200        self.encoding.decode(&self.data)
1201    }
1202}
1203
1204/// Account snapshot used to seed or modify state in a session.
1205#[serde_with::serde_as]
1206#[derive(Debug, Clone, Serialize, Deserialize)]
1207#[serde(rename_all = "camelCase")]
1208pub struct AccountData {
1209    /// Account data bytes and encoding.
1210    pub data: EncodedBinary,
1211    /// Whether the account is executable.
1212    pub executable: bool,
1213    /// Lamport balance.
1214    pub lamports: u64,
1215    #[serde_as(as = "serde_with::DisplayFromStr")]
1216    /// Account owner pubkey.
1217    pub owner: Address,
1218    /// Allocated space in bytes.
1219    pub space: u64,
1220}
1221
1222impl AccountData {
1223    pub fn to_account(&self) -> Result<solana_account::Account, Base64DecodeError> {
1224        Ok(solana_account::Account {
1225            data: self.data.decode()?,
1226            lamports: self.lamports,
1227            owner: self.owner,
1228            executable: self.executable,
1229            rent_epoch: 0,
1230        })
1231    }
1232}
1233
1234/// Responses returned over the backtest RPC channel.
1235#[derive(Debug, Clone, Serialize, Deserialize)]
1236#[serde(tag = "method", content = "params", rename_all = "camelCase")]
1237pub enum BacktestResponse {
1238    SessionCreated {
1239        session_id: SessionId,
1240        rpc_endpoint: String,
1241        #[serde(default, skip_serializing_if = "Option::is_none")]
1242        task_id: Option<String>,
1243    },
1244    SessionAttached {
1245        session_id: SessionId,
1246        rpc_endpoint: String,
1247        #[serde(default, skip_serializing_if = "Option::is_none")]
1248        task_id: Option<String>,
1249    },
1250    SessionsCreatedV2 {
1251        control_session_id: ControlSessionId,
1252        session_ids: Vec<SessionId>,
1253        #[serde(default)]
1254        task_ids: Vec<Option<String>>,
1255        /// Per-sub-session start/end slots, parallel to `session_ids`. The
1256        /// server's split is authoritative (a mid-bundle request anchors to the
1257        /// covering bundle), so the client binds each sub-session to its range
1258        /// from here rather than re-deriving the split locally.
1259        #[serde(default)]
1260        start_slots: Vec<u64>,
1261        #[serde(default)]
1262        end_slots: Vec<u64>,
1263    },
1264    ParallelSessionAttachedV2 {
1265        control_session_id: ControlSessionId,
1266        session_ids: Vec<SessionId>,
1267        #[serde(default)]
1268        task_ids: Vec<Option<String>>,
1269    },
1270    ReadyForContinue,
1271    SlotNotification(u64),
1272    Paused(PausedEvent),
1273    DiscoveryBatch(DiscoveryBatchEvent),
1274    Error(BacktestError),
1275    Success,
1276    Completed {
1277        /// Session summary with transaction statistics.
1278        /// The session always computes this summary, but management may omit it from
1279        /// client-facing responses unless `send_summary` was requested at session creation.
1280        #[serde(skip_serializing_if = "Option::is_none")]
1281        summary: Option<SessionSummary>,
1282    },
1283    Status {
1284        status: BacktestStatus,
1285    },
1286    SessionEventV2 {
1287        session_id: SessionId,
1288        seq_id: u64,
1289        event: SessionEventKind,
1290    },
1291}
1292
1293impl BacktestResponse {
1294    pub fn is_completed(&self) -> bool {
1295        matches!(self, BacktestResponse::Completed { .. })
1296    }
1297
1298    pub fn is_terminal(&self) -> bool {
1299        match self {
1300            BacktestResponse::Completed { .. } => true,
1301            BacktestResponse::Error(e) => e.is_terminal(),
1302            _ => false,
1303        }
1304    }
1305}
1306
1307impl From<BacktestStatus> for BacktestResponse {
1308    fn from(status: BacktestStatus) -> Self {
1309        Self::Status { status }
1310    }
1311}
1312
1313impl From<String> for BacktestResponse {
1314    fn from(message: String) -> Self {
1315        BacktestError::Internal { error: message }.into()
1316    }
1317}
1318
1319impl From<&str> for BacktestResponse {
1320    fn from(message: &str) -> Self {
1321        BacktestError::Internal {
1322            error: message.to_string(),
1323        }
1324        .into()
1325    }
1326}
1327
1328#[derive(Debug, Clone, Serialize, Deserialize)]
1329#[serde(tag = "method", content = "params", rename_all = "camelCase")]
1330pub enum SessionEventKind {
1331    ReadyForContinue,
1332    SlotNotification(u64),
1333    Paused(PausedEvent),
1334    DiscoveryBatch(DiscoveryBatchEvent),
1335    Error(BacktestError),
1336    Success,
1337    Completed {
1338        #[serde(skip_serializing_if = "Option::is_none")]
1339        summary: Option<SessionSummary>,
1340    },
1341    Status {
1342        status: BacktestStatus,
1343    },
1344}
1345
1346impl SessionEventKind {
1347    pub fn is_terminal(&self) -> bool {
1348        match self {
1349            Self::Completed { .. } => true,
1350            Self::Error(e) => e.is_terminal(),
1351            _ => false,
1352        }
1353    }
1354}
1355
1356/// Wire format wrapper for responses sent over the control websocket.
1357/// Sessions with `disconnect_timeout_secs > 0` use this to track client position.
1358#[derive(Debug, Clone, Serialize, Deserialize)]
1359#[serde(rename_all = "camelCase")]
1360pub struct SequencedResponse {
1361    pub seq_id: u64,
1362    #[serde(flatten)]
1363    pub response: BacktestResponse,
1364}
1365
1366/// High-level progress states during a `Continue` call.
1367#[derive(Debug, Clone, Serialize, Deserialize)]
1368#[serde(rename_all = "camelCase")]
1369pub enum BacktestStatus {
1370    /// Runtime startup is in progress.
1371    StartingRuntime,
1372    DecodedTransactions,
1373    AppliedAccountModifications,
1374    ReadyToExecuteUserTransactions,
1375    ExecutedUserTransactions,
1376    ExecutingBlockTransactions,
1377    ExecutedBlockTransactions,
1378    ProgramAccountsLoaded,
1379}
1380
1381impl std::fmt::Display for BacktestStatus {
1382    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1383        let s = match self {
1384            Self::StartingRuntime => "starting runtime",
1385            Self::DecodedTransactions => "decoded transactions",
1386            Self::AppliedAccountModifications => "applied account modifications",
1387            Self::ReadyToExecuteUserTransactions => "ready to execute user transactions",
1388            Self::ExecutedUserTransactions => "executed user transactions",
1389            Self::ExecutingBlockTransactions => "executing block transactions",
1390            Self::ExecutedBlockTransactions => "executed block transactions",
1391            Self::ProgramAccountsLoaded => "program accounts loaded",
1392        };
1393        f.write_str(s)
1394    }
1395}
1396
1397/// High-level funnel for a reroute-order-flow session: how many detected swaps made it from
1398/// re-quoting through simulation to a non-reverting execution, and — for a run carrying a
1399/// [`DirectFillParams`] book — how many hops it priced against a candidate venue. Per-swap
1400/// economics and latency are session-internal and land in the analysis DB.
1401#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1402#[serde(rename_all = "camelCase")]
1403pub struct RerouteStatsReport {
1404    /// Requotable swaps detected in the replayed range, across the admitted aggregators.
1405    #[serde(default)]
1406    pub swaps_detected: u64,
1407    /// Detected swaps excluded before quoting — an unadmitted aggregator, an arbitrage cycle, a mint
1408    /// pair the filter does not name. Without it the gap between `swaps_detected` and
1409    /// `swaps_rerouted` reads as routing failures.
1410    #[serde(default)]
1411    pub swaps_filtered: u64,
1412    /// Swaps re-quoted through metis and queued for simulation.
1413    #[serde(default)]
1414    pub swaps_rerouted: u64,
1415    /// Swaps whose routed transaction was simulated.
1416    #[serde(default)]
1417    pub swaps_simulated: u64,
1418    /// Simulated swaps whose routed transaction executed without reverting.
1419    #[serde(default)]
1420    pub swaps_succeeded: u64,
1421    /// Detected swaps metis could not route; nothing was simulated for them.
1422    #[serde(default)]
1423    pub requote_failures: u64,
1424    /// Computed override actions that failed and therefore contributed no state.
1425    #[serde(default)]
1426    pub override_setup_failures: u64,
1427    /// Blocks replayed unchanged because the metis client was unavailable.
1428    #[serde(default)]
1429    pub metis_unavailable_blocks: u64,
1430    /// Quote attempts that could not be made because the metis client was unavailable, summed
1431    /// over every swap left queued by each such block. Each one is retried on the next block,
1432    /// so this counts retries forced rather than swaps lost.
1433    #[serde(default)]
1434    pub quote_attempts_unavailable: u64,
1435    /// Hops whose pair the direct-fill book named. Zero when no book was configured.
1436    #[serde(default)]
1437    pub direct_fill_matched: u64,
1438    /// Of those, the ones a venue could build. Zero while no venue implements the builder.
1439    #[serde(default)]
1440    pub direct_fill_built: u64,
1441    /// Why the rest were not built, keyed by the venue's own rejection reason. A run with a book
1442    /// and no encoder reports every matched hop here under one key, which is what separates
1443    /// "nothing matched" from "nothing could be built".
1444    #[serde(default)]
1445    pub direct_fill_rejections: BTreeMap<String, u64>,
1446    /// Simulated probes by outcome — `filled`, `reverted`, `unattributed`, `unsimulatable`.
1447    #[serde(default)]
1448    pub direct_fill_outcomes: BTreeMap<String, u64>,
1449    /// Probes whose fill could be compared against the hop they replace.
1450    #[serde(default)]
1451    pub direct_fill_scored: u64,
1452    /// Mean of `(probe fill - historical fill) / historical fill` in basis points, over
1453    /// `direct_fill_scored` probes. Positive means the candidate venue beat the one that traded.
1454    #[serde(default)]
1455    pub direct_fill_bps_total: i64,
1456}
1457
1458/// Summary of transaction execution statistics for a completed backtest session.
1459#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1460#[serde(rename_all = "camelCase")]
1461pub struct SessionSummary {
1462    /// Number of simulations where simulator outcome matched on-chain outcome
1463    /// (`true_success + true_failure`).
1464    pub correct_simulation: usize,
1465    /// Number of simulations where simulator outcome did not match on-chain outcome
1466    /// (`false_success + false_failure`).
1467    pub incorrect_simulation: usize,
1468    /// Number of transactions that had execution errors in simulation.
1469    pub execution_errors: usize,
1470    /// Number of transactions with different balance diffs.
1471    pub balance_diff: usize,
1472    /// Number of transactions with different log diffs.
1473    pub log_diff: usize,
1474    /// Transactions skipped because they could not be sanitized (malformed message or
1475    /// unresolvable address lookup table).
1476    #[serde(default)]
1477    pub unsanitizable: usize,
1478    /// Aggregate reroute metrics, populated only for reroute-order-flow sessions. Boxed to
1479    /// keep `SessionSummary` small in the many response enums that carry it.
1480    #[serde(default, skip_serializing_if = "Option::is_none")]
1481    pub reroute_stats: Option<Box<RerouteStatsReport>>,
1482}
1483
1484impl SessionSummary {
1485    /// Returns true if there were any execution deviations (errors or mismatched results).
1486    pub fn has_deviations(&self) -> bool {
1487        self.incorrect_simulation > 0 || self.execution_errors > 0 || self.balance_diff > 0
1488    }
1489
1490    /// Total number of transactions processed.
1491    pub fn total_transactions(&self) -> usize {
1492        self.correct_simulation
1493            + self.incorrect_simulation
1494            + self.execution_errors
1495            + self.balance_diff
1496            + self.log_diff
1497    }
1498
1499    /// Renders the compact "N incorrect simulations, N execution errors, ..." deviation list
1500    /// shared by console reporters. `balance_label` / `log_label` let each caller keep its own
1501    /// historical wording for the trailing two counts (e.g. "balance diffs" vs
1502    /// "different balances").
1503    pub fn deviation_details(&self, balance_label: &str, log_label: &str) -> String {
1504        format!(
1505            "{} incorrect simulations, {} execution errors, {} {balance_label}, {} {log_label}",
1506            self.incorrect_simulation, self.execution_errors, self.balance_diff, self.log_diff,
1507        )
1508    }
1509}
1510
1511impl std::fmt::Display for SessionSummary {
1512    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1513        let total = self.total_transactions();
1514        write!(
1515            f,
1516            "Session summary: {total} transactions\n\
1517             \x20  - {} correct simulation\n\
1518             \x20  - {} incorrect simulation\n\
1519             \x20  - {} execution errors\n\
1520             \x20  - {} unsanitizable\n\
1521             \x20  - {} balance diffs\n\
1522             \x20  - {} log diffs",
1523            self.correct_simulation,
1524            self.incorrect_simulation,
1525            self.execution_errors,
1526            self.unsanitizable,
1527            self.balance_diff,
1528            self.log_diff,
1529        )
1530    }
1531}
1532
1533/// Error variants surfaced to backtest RPC clients.
1534#[derive(Debug, Clone, Serialize, Deserialize)]
1535#[serde(rename_all = "camelCase")]
1536pub enum BacktestError {
1537    InvalidTransactionEncoding {
1538        index: usize,
1539        error: String,
1540    },
1541    InvalidTransactionFormat {
1542        index: usize,
1543        error: String,
1544    },
1545    InvalidAccountEncoding {
1546        address: String,
1547        encoding: BinaryEncoding,
1548        error: String,
1549    },
1550    InvalidAccountOwner {
1551        address: String,
1552        error: String,
1553    },
1554    InvalidAccountPubkey {
1555        address: String,
1556        error: String,
1557    },
1558    NoMoreBlocks,
1559    BundleNotFound {
1560        start_slot: u64,
1561        end_slot: u64,
1562    },
1563    AdvanceSlotFailed {
1564        slot: u64,
1565        error: String,
1566    },
1567    FinalizeSlotFailed {
1568        slot: u64,
1569        error: String,
1570    },
1571    InvalidRequest {
1572        error: String,
1573    },
1574    Internal {
1575        error: String,
1576    },
1577    InvalidBlockhashFormat {
1578        slot: u64,
1579        error: String,
1580    },
1581    InitializingSysvarsFailed {
1582        slot: u64,
1583        error: String,
1584    },
1585    ClerkError {
1586        error: String,
1587    },
1588    SimulationError {
1589        error: String,
1590    },
1591    SessionNotFound {
1592        session_id: String,
1593    },
1594    SessionOwnerMismatch,
1595    /// Session ownership is in transition (e.g. the previous manager is
1596    /// shutting down, or another attach raced this one). Clients should retry
1597    /// the attach within their reconnect budget; the route is expected to
1598    /// become claimable shortly.
1599    SessionOwnershipBusy {
1600        reason: String,
1601    },
1602    /// The requested range contains no produced blocks (every slot in it was
1603    /// skipped on-chain), so there is nothing to execute. A user error: the
1604    /// session cannot start and the range must be adjusted.
1605    // Appended last: the manager<->runtime hop is postcard, where variant
1606    // order is wire-significant across version skew.
1607    NoProducedBlocksInRange {
1608        start_slot: u64,
1609        end_slot: u64,
1610    },
1611}
1612
1613impl BacktestError {
1614    /// Errors the backtest cannot recover from mid-session — the single source
1615    /// of truth for terminal-error classification, shared by the response/event
1616    /// wrappers and the managed client.
1617    pub fn is_terminal(&self) -> bool {
1618        matches!(
1619            self,
1620            BacktestError::NoMoreBlocks
1621                | BacktestError::BundleNotFound { .. }
1622                | BacktestError::AdvanceSlotFailed { .. }
1623                | BacktestError::FinalizeSlotFailed { .. }
1624                | BacktestError::Internal { .. }
1625                | BacktestError::NoProducedBlocksInRange { .. }
1626        )
1627    }
1628}
1629
1630/// One contiguous block range available on the history clerk.
1631#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1632pub struct AvailableRange {
1633    pub bundle_start_slot: u64,
1634    pub bundle_start_slot_utc: Option<String>,
1635    pub max_bundle_end_slot: Option<u64>,
1636    pub max_bundle_end_slot_utc: Option<String>,
1637    pub max_bundle_size: Option<u64>,
1638    /// What the range can be used for. `None` from a server that predates the
1639    /// field, where every listed range is [`BundleKind::Blocks`].
1640    #[serde(default)]
1641    pub kind: Option<BundleKind>,
1642    /// Who the range is available to. `None` from a server that predates the
1643    /// field.
1644    #[serde(default)]
1645    pub scope: Option<RangeScope>,
1646}
1647
1648/// Who a listed range is available to.
1649#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1650#[serde(rename_all = "snake_case")]
1651pub enum RangeScope {
1652    /// Available to every caller.
1653    Global,
1654    /// Built for the calling API key, and listed only for that key.
1655    User,
1656}
1657
1658impl RangeScope {
1659    /// Stable string form matching the serde `snake_case` wire representation.
1660    pub fn as_str(self) -> &'static str {
1661        match self {
1662            Self::Global => "global",
1663            Self::User => "user",
1664        }
1665    }
1666}
1667
1668/// What a bundle build produces.
1669#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1670#[serde(rename_all = "snake_case")]
1671pub enum BundleKind {
1672    /// The block bundle a session replays transactions from.
1673    #[default]
1674    Blocks,
1675    /// An account-state bundle covering exactly the requested range, which
1676    /// later sessions replay instead of executing transactions. Builds the
1677    /// block bundle it needs first.
1678    AccountState,
1679}
1680
1681/// Why a replayed range has to begin where a recording does. Shared so the
1682/// splitter's refusal and the service's single-session refusal cannot drift
1683/// apart.
1684pub const REPLAY_RANGE_ADVICE: &str = "replayed state is resolved by the recording that begins \
1685     where the request does, so request a range that begins where one was recorded and ends \
1686     within it";
1687
1688impl BundleKind {
1689    /// What a caller is short of, named for a coverage error.
1690    fn coverage_noun(self) -> &'static str {
1691        match self {
1692            Self::Blocks => "bundle range",
1693            Self::AccountState => "account-state bundle you own",
1694        }
1695    }
1696
1697    /// [`Self::coverage_noun`] pluralised, for a message about several.
1698    fn coverage_nouns(self) -> &'static str {
1699        match self {
1700            Self::Blocks => "bundle ranges",
1701            Self::AccountState => "account-state bundles you own",
1702        }
1703    }
1704
1705    /// Stable string form matching the serde `snake_case` wire representation.
1706    pub fn as_str(self) -> &'static str {
1707        match self {
1708            Self::Blocks => "blocks",
1709            Self::AccountState => "account_state",
1710        }
1711    }
1712
1713    /// Inverse of [`Self::as_str`]; returns `None` for an unrecognized value.
1714    pub fn from_str_opt(value: &str) -> Option<Self> {
1715        match value {
1716            "blocks" => Some(Self::Blocks),
1717            "account_state" => Some(Self::AccountState),
1718            _ => None,
1719        }
1720    }
1721}
1722
1723/// Spelled for the reader, not the wire: `blocks` shows as `transactions`.
1724/// Use [`BundleKind::as_str`] where the wire spelling is required.
1725impl fmt::Display for BundleKind {
1726    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1727        let label = match self {
1728            Self::Blocks => "transactions",
1729            Self::AccountState => "account_state",
1730        };
1731        f.pad(label)
1732    }
1733}
1734
1735/// Request body of `POST /build-bundle`.
1736///
1737/// Construct via `..Default::default()` so a new field stays source-compatible.
1738#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1739pub struct BundleBuildRequest {
1740    pub start_slot: u64,
1741    pub end_slot: u64,
1742    #[serde(default)]
1743    pub bundle_size: Option<u64>,
1744    /// Optional repeat key; a resubmit dedupes the quota charge. The same key
1745    /// under a different `kind` is a distinct request.
1746    #[serde(default)]
1747    pub idempotency_key: Option<String>,
1748    /// Absent on the wire means [`BundleKind::Blocks`].
1749    #[serde(default)]
1750    pub kind: BundleKind,
1751}
1752
1753#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1754#[serde(rename_all = "snake_case")]
1755pub enum BundleBuildStatus {
1756    /// The bundle was built and is ready to use.
1757    Completed,
1758    /// The build failed and will not produce a bundle.
1759    Failed,
1760    /// The build has not finished yet; poll the status endpoint again later.
1761    InProgress,
1762    /// The outcome of the build could not be determined; do not assume success
1763    /// or failure.
1764    NeedsInvestigation,
1765}
1766
1767impl BundleBuildStatus {
1768    /// Stable string form matching the serde `snake_case` wire representation.
1769    pub fn as_str(self) -> &'static str {
1770        match self {
1771            Self::Completed => "completed",
1772            Self::Failed => "failed",
1773            Self::InProgress => "in_progress",
1774            Self::NeedsInvestigation => "needs_investigation",
1775        }
1776    }
1777}
1778
1779/// Response of the management bundle-build endpoints.
1780#[derive(Debug, Clone, Serialize, Deserialize)]
1781pub struct BundleBuildStatusResponse {
1782    pub request_id: String,
1783    pub start_slot: u64,
1784    pub end_slot: u64,
1785    pub bundle_size: Option<u64>,
1786    pub status: BundleBuildStatus,
1787    /// When the request id was created (unix ms) — lets clients show a live ETA
1788    /// countdown by computing elapsed since this.
1789    pub created_at_unix_ms: u64,
1790    /// Absent on records predating the field, which are all `blocks`.
1791    #[serde(default)]
1792    pub kind: BundleKind,
1793}
1794
1795/// A bundle the caller owns and can use now. Ordered by slot range, then kind.
1796#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1797pub struct CompletedBundle {
1798    pub start_slot: u64,
1799    pub end_slot: u64,
1800    pub kind: BundleKind,
1801    /// When the bundle became available (unix ms), where known.
1802    #[serde(default)]
1803    pub completed_at_unix_ms: Option<i64>,
1804    #[serde(default)]
1805    pub size_bytes: Option<i64>,
1806}
1807
1808/// Query of `DELETE /bundles`: the bundle to delete, by kind and exact range.
1809#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1810pub struct BundleDeleteRequest {
1811    pub start_slot: u64,
1812    pub end_slot: u64,
1813    /// Absent means [`BundleKind::Blocks`].
1814    #[serde(default)]
1815    pub kind: BundleKind,
1816}
1817
1818/// What `DELETE /bundles` removed.
1819#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1820pub struct BundleDeleteResponse {
1821    pub start_slot: u64,
1822    pub end_slot: u64,
1823    pub kind: BundleKind,
1824}
1825
1826/// Response of `GET /build-bundle`.
1827#[derive(Debug, Clone, Serialize, Deserialize)]
1828pub struct BundleBuildListResponse {
1829    /// The caller's own builds, newest first. A build belongs to the endpoint
1830    /// running it, so this covers only builds requested here.
1831    pub builds: Vec<BundleBuildStatusResponse>,
1832    /// The caller's own bundles, ascending by range, wherever they were built.
1833    /// Not a view of `builds`: one build can produce several bundles, and a
1834    /// bundle outlives the record of the build that made it. `None` when the
1835    /// listing was unavailable, as opposed to `Some([])` for owning nothing.
1836    #[serde(default)]
1837    pub bundles: Option<Vec<CompletedBundle>>,
1838}
1839
1840/// Response of the management `/snapshot-slot` endpoint. The chosen source and
1841/// import state are internal, so only the resolved slot is exposed.
1842#[derive(Debug, Clone, Serialize, Deserialize)]
1843pub struct SnapshotSlotResponse {
1844    pub snapshot_slot: u64,
1845}
1846
1847/// Split a user-requested `[start_slot, end_slot]` range across the available
1848/// bundle ranges, returning a list of contiguous, non-overlapping `(start, end)`
1849/// pairs — one per bundle that the server can serve as its own session.
1850///
1851/// Each emitted `start` is a real `bundle_start_slot` and each `end` is within
1852/// the `max_bundle_end_slot` of a bundle that begins *exactly* at that start
1853/// (the server requires `start_slot == bundle_start_slot` and caps `end_slot` at
1854/// that bundle's `max_bundle_end_slot`). Among all such gap-free splits we pick
1855/// the one with the most sub-ranges, i.e. the highest parallelism: a fine-grained
1856/// (e.g. 10k) bundle grid yields one session per fine bundle instead of
1857/// collapsing onto a coarser series that overlaps the same slots — even when the
1858/// coarse and fine bundles share a start slot. A coarser bundle is only ridden
1859/// where no finer grid continues the walk, which still lets us serve a request
1860/// the coarse data covers even across a hole in the fine grid.
1861///
1862/// `kind` picks which bundles the split rides; the walk itself is the same for
1863/// either, since a recording is read slot by slot exactly as a block bundle is.
1864/// An unlabelled range, from a server predating [`AvailableRange::kind`],
1865/// counts as [`BundleKind::Blocks`].
1866///
1867/// Returns an error if the requested start cannot anchor, or if no gap-free
1868/// split reaches `requested_end`.
1869pub fn split_range(
1870    ranges: &[AvailableRange],
1871    requested: SlotRange,
1872    kind: BundleKind,
1873) -> Result<Vec<(u64, u64)>, String> {
1874    let (requested_start, requested_end) = (requested.start(), requested.end());
1875
1876    // Every advertised end per bundle start. A start can carry several ends — a
1877    // 50k snapshot bundle and the 10k bundle built on the same snapshot share a
1878    // start slot but reach different ends — so we keep them all and let the walk
1879    // pick whichever maximises parallelism.
1880    // A one-slot bundle (`end == start`) counts: a series whose last split
1881    // begins on the requested end records exactly one slot.
1882    let ends_by_start = ranges
1883        .iter()
1884        .filter(|r| r.kind.unwrap_or_default() == kind)
1885        .filter_map(|r| {
1886            r.max_bundle_end_slot
1887                .filter(|&end| end >= r.bundle_start_slot)
1888                .map(|end| (r.bundle_start_slot, end))
1889        })
1890        .fold(
1891            BTreeMap::<u64, BTreeSet<u64>>::new(),
1892            |mut ends_by_start, (start, end)| {
1893                ends_by_start.entry(start).or_default().insert(end);
1894                ends_by_start
1895            },
1896        );
1897
1898    // Anchor a request that lands mid-bundle to the covering bundle's start —
1899    // the latest bundle beginning at or before `requested_start` that still
1900    // covers it. The server's `select_parallel_requests` shares this fn and
1901    // anchors the same way, so the client must too or it would falsely reject a
1902    // start that isn't itself a bundle boundary. An exact bundle-start request
1903    // anchors to itself. (Single sessions instead require an exact bundle start
1904    // for every kind: `evaluate_requested_range`.)
1905    let Some((&anchor_start, _)) = ends_by_start.range(..=requested_start).rfind(|(_, ends)| {
1906        ends.iter()
1907            .next_back()
1908            .is_some_and(|&end| end >= requested_start)
1909    }) else {
1910        return Err(format!(
1911            "start_slot {requested_start} is not covered by any available {}",
1912            kind.coverage_noun()
1913        ));
1914    };
1915
1916    // Walk the bundle starts from `requested_end` backwards, recording for each
1917    // start the gap-free split with the most sub-ranges (highest parallelism).
1918    // A sub-range ends within one of its start's bundles and the next sub-range
1919    // must begin on the very next slot, so a coarse bundle is ridden only when no
1920    // finer bundle continues the walk.
1921    let mut best_from: BTreeMap<u64, Vec<(u64, u64)>> = BTreeMap::new();
1922    for (&start, ends) in ends_by_start.range(anchor_start..=requested_end).rev() {
1923        let mut best: Option<Vec<(u64, u64)>> = None;
1924        for &end in ends {
1925            let candidate = if end >= requested_end {
1926                Some(vec![(start, requested_end)])
1927            } else {
1928                best_from.get(&(end + 1)).map(|rest| {
1929                    std::iter::once((start, end))
1930                        .chain(rest.iter().copied())
1931                        .collect()
1932                })
1933            };
1934            if let Some(candidate) = candidate
1935                && best.as_ref().is_none_or(|b| candidate.len() > b.len())
1936            {
1937                best = Some(candidate);
1938            }
1939        }
1940        if let Some(best) = best {
1941            best_from.insert(start, best);
1942        }
1943    }
1944
1945    best_from.remove(&anchor_start).ok_or_else(|| {
1946        // Point at the first uncovered slot when the bundles leave a hole; fall
1947        // back to a generic message when the range is covered but cannot be
1948        // tiled to bundle boundaries.
1949        let mut covered_to = anchor_start.saturating_sub(1);
1950        for (&start, ends) in ends_by_start.range(anchor_start..=requested_end) {
1951            if start > covered_to.saturating_add(1) {
1952                break;
1953            }
1954            if let Some(&end) = ends.iter().next_back() {
1955                covered_to = covered_to.max(end);
1956            }
1957        }
1958        if covered_to < requested_end {
1959            format!("gap in coverage at slot {}", covered_to + 1)
1960        } else {
1961            format!(
1962                "no gap-free split of [{requested_start}, {requested_end}] aligns with the available {}",
1963                kind.coverage_nouns()
1964            )
1965        }
1966    })
1967}
1968
1969impl From<BacktestError> for BacktestResponse {
1970    fn from(error: BacktestError) -> Self {
1971        Self::Error(error)
1972    }
1973}
1974
1975impl std::error::Error for BacktestError {}
1976
1977impl fmt::Display for BacktestError {
1978    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1979        match self {
1980            BacktestError::InvalidTransactionEncoding { index, error } => {
1981                write!(f, "invalid transaction encoding at index {index}: {error}")
1982            }
1983            BacktestError::InvalidTransactionFormat { index, error } => {
1984                write!(f, "invalid transaction format at index {index}: {error}")
1985            }
1986            BacktestError::InvalidAccountEncoding {
1987                address,
1988                encoding,
1989                error,
1990            } => write!(
1991                f,
1992                "invalid encoding for account {address} ({encoding:?}): {error}"
1993            ),
1994            BacktestError::InvalidAccountOwner { address, error } => {
1995                write!(f, "invalid owner for account {address}: {error}")
1996            }
1997            BacktestError::InvalidAccountPubkey { address, error } => {
1998                write!(f, "invalid account pubkey {address}: {error}")
1999            }
2000            BacktestError::NoMoreBlocks => write!(f, "no more blocks available"),
2001            BacktestError::BundleNotFound {
2002                start_slot,
2003                end_slot,
2004            } => write!(
2005                f,
2006                "block bundle not found for start_slot={start_slot}, end_slot={end_slot}"
2007            ),
2008            BacktestError::AdvanceSlotFailed { slot, error } => {
2009                write!(f, "failed to advance to slot {slot}: {error}")
2010            }
2011            BacktestError::FinalizeSlotFailed { slot, error } => {
2012                write!(f, "failed to finalize slot {slot}: {error}")
2013            }
2014            BacktestError::InvalidRequest { error } => write!(f, "invalid request: {error}"),
2015            BacktestError::Internal { error } => write!(f, "internal error: {error}"),
2016            BacktestError::InvalidBlockhashFormat { slot, error } => {
2017                write!(f, "invalid blockhash at slot {slot}: {error}")
2018            }
2019            BacktestError::InitializingSysvarsFailed { slot, error } => {
2020                write!(f, "failed to initialize sysvars at slot {slot}: {error}")
2021            }
2022            BacktestError::ClerkError { error } => write!(f, "clerk error: {error}"),
2023            BacktestError::SimulationError { error } => {
2024                write!(f, "simulation error: {error}")
2025            }
2026            BacktestError::SessionNotFound { session_id } => {
2027                write!(f, "session not found: {session_id}")
2028            }
2029            BacktestError::SessionOwnerMismatch => {
2030                write!(f, "session owner mismatch")
2031            }
2032            BacktestError::SessionOwnershipBusy { reason } => {
2033                write!(f, "session ownership busy: {reason}")
2034            }
2035            BacktestError::NoProducedBlocksInRange {
2036                start_slot,
2037                end_slot,
2038            } => write!(
2039                f,
2040                "no produced blocks in requested range {start_slot}..={end_slot}; \
2041                 slot(s) may be skipped on-chain"
2042            ),
2043        }
2044    }
2045}
2046
2047#[cfg(test)]
2048mod tests {
2049    use rstest::rstest;
2050    use solana_account::ReadableAccount;
2051
2052    use super::*;
2053
2054    fn address(byte: u8) -> Address {
2055        Address::from([byte; 32])
2056    }
2057
2058    #[rstest]
2059    #[case::one("jupiter", Some(&[SwapAggregator::Jupiter][..]))]
2060    #[case::several("jupiter,okx", Some(&[SwapAggregator::Jupiter, SwapAggregator::Okx][..]))]
2061    #[case::spaced_and_uppercased(" Jupiter , OKX ", Some(&[SwapAggregator::Jupiter, SwapAggregator::Okx][..]))]
2062    #[case::repeats_collapse("okx,okx", Some(&[SwapAggregator::Okx][..]))]
2063    #[case::trailing_separator("okx,", Some(&[SwapAggregator::Okx][..]))]
2064    #[case::unknown("jupiter,bogus", None)]
2065    #[case::empty("", None)]
2066    #[case::separators_only(",,", None)]
2067    fn reroute_aggregators_parse_the_shapes_a_flag_carries(
2068        #[case] input: &str,
2069        #[case] expected: Option<&[SwapAggregator]>,
2070    ) {
2071        assert_eq!(
2072            input.parse::<RerouteAggregators>().ok(),
2073            expected.map(|aggregators| RerouteAggregators::new(aggregators.iter().copied()))
2074        );
2075    }
2076
2077    /// `Display` renders `--reroute-aggregators`' default, so clap re-parses whatever it prints.
2078    #[test]
2079    fn reroute_aggregators_round_trip_through_their_rendering() {
2080        let aggregators = RerouteAggregators::new([SwapAggregator::Dflow, SwapAggregator::Jupiter]);
2081
2082        assert_eq!(aggregators.to_string(), "jupiter,dflow");
2083        assert_eq!(
2084            aggregators
2085                .to_string()
2086                .parse::<RerouteAggregators>()
2087                .unwrap(),
2088            aggregators
2089        );
2090        assert_eq!(RerouteAggregators::default().to_string(), "jupiter");
2091    }
2092
2093    /// The API deserializes aggregators through the same parser the flag uses, so a spelling one
2094    /// entry point accepts is not a hard error at the other.
2095    #[test]
2096    fn aggregators_deserialize_through_the_same_parser_the_flag_uses() {
2097        let params: CreateSessionParams = serde_json::from_value(
2098            serde_json::json!({"startSlot": 1, "endSlot": 2, "reroute": {"aggregators": ["Jupiter", "OKX"]}}),
2099        )
2100        .expect("decode");
2101
2102        assert_eq!(
2103            params.reroute.expect("rerouting requested").aggregators,
2104            RerouteAggregators::new([SwapAggregator::Jupiter, SwapAggregator::Okx])
2105        );
2106    }
2107
2108    /// A request that names no knob gets the narrow defaults, not the every-aggregator behaviour a
2109    /// client written before this change expected — the switch these defaults are here to make.
2110    #[test]
2111    fn params_without_the_new_reroute_fields_decode_to_the_narrow_defaults() {
2112        let params: CreateSessionParams = serde_json::from_value(
2113            serde_json::json!({"startSlot": 1, "endSlot": 2, "reroute": {}}),
2114        )
2115        .expect("decode");
2116
2117        let reroute = params.reroute.expect("rerouting requested");
2118        assert_eq!(reroute.aggregators, RerouteAggregators::default());
2119        assert!(reroute.aggregators.contains(SwapAggregator::Jupiter));
2120        assert!(!reroute.aggregators.contains(SwapAggregator::Okx));
2121        assert!(!reroute.circular_arbs);
2122        assert!(!reroute.detect_failed_l1_swaps);
2123    }
2124
2125    /// Presence is the switch: a replay that will never quote must not reserve a metis sidecar.
2126    #[test]
2127    fn a_request_without_reroute_reserves_no_sidecar() {
2128        let params: CreateSessionParams =
2129            serde_json::from_value(serde_json::json!({"startSlot": 1, "endSlot": 2}))
2130                .expect("decode");
2131
2132        assert!(!params.requires_sidecar());
2133    }
2134
2135    /// A per-slot override schedule is the largest thing a client sends. Each
2136    /// action alone is incompressible enough to sit near the websocket message
2137    /// limit; the redundancy lives *across* actions, so the whole vector has to
2138    /// travel as one blob. Pins both the round trip and that size property.
2139    #[test]
2140    fn a_per_slot_action_schedule_compresses_across_actions() {
2141        let slots = 2_000u64;
2142        let actions = (0..slots)
2143            .map(|slot| ScheduledAction {
2144                anchor: ActionAnchor::AfterSlot { slots: vec![slot] },
2145                kind: ActionKind::Simulate,
2146                feeds_reroute: false,
2147                transactions: Vec::new(),
2148                account_overrides: AccountModifications(BTreeMap::from([(
2149                    address(7),
2150                    AccountData {
2151                        // Realistic pool account: 2 KiB that barely moves slot to slot.
2152                        data: EncodedBinary::new(
2153                            BASE64.encode(
2154                                [&slot.to_le_bytes()[..], &[0xAB; 2040][..]]
2155                                    .concat()
2156                                    .as_slice(),
2157                            ),
2158                            BinaryEncoding::Base64,
2159                        ),
2160                        executable: false,
2161                        lamports: 15_144_966,
2162                        owner: address(9),
2163                        space: 2048,
2164                    },
2165                )])),
2166                return_accounts: Vec::new(),
2167                label: None,
2168            })
2169            .collect::<Vec<_>>();
2170        let params = CreateSessionParams::builder()
2171            .start_slot(1)
2172            .end_slot(slots)
2173            .actions(actions.clone())
2174            .build();
2175
2176        let encoded = serde_json::to_string(&params).expect("serialize");
2177        let raw = serde_json::to_string(&actions).expect("serialize raw actions");
2178        assert!(
2179            encoded.len() * 8 < raw.len(),
2180            "expected the schedule to compress well; got {} bytes from {} raw",
2181            encoded.len(),
2182            raw.len()
2183        );
2184
2185        let decoded: CreateSessionParams = serde_json::from_str(&encoded).expect("round trip");
2186        assert_eq!(
2187            serde_json::to_string(&decoded.actions).expect("serialize decoded"),
2188            raw
2189        );
2190    }
2191
2192    /// Absent actions stay absent on the wire, so a session without a schedule
2193    /// is unchanged by the compression.
2194    #[test]
2195    fn an_empty_action_list_is_omitted_entirely() {
2196        let params = CreateSessionParams::builder()
2197            .start_slot(1)
2198            .end_slot(2)
2199            .build();
2200        let json = serde_json::to_value(&params).expect("serialize");
2201        assert!(json.get("actions").is_none(), "{json}");
2202        let decoded: CreateSessionParams = serde_json::from_value(json).expect("round trip");
2203        assert!(decoded.actions.is_empty());
2204    }
2205
2206    /// The conflict is `send` *and* replay: neither alone is a problem, and simulate runs
2207    /// fine under replay because it never commits.
2208    #[test]
2209    fn send_actions_conflict_with_replay_only_together() {
2210        let params = |replay, kind| {
2211            CreateSessionParams::builder()
2212                .start_slot(1)
2213                .end_slot(2)
2214                .replay_account_state(replay)
2215                .actions(vec![ScheduledAction {
2216                    anchor: ActionAnchor::BeforeSlot { slots: vec![1] },
2217                    kind,
2218                    feeds_reroute: false,
2219                    transactions: vec!["dGVzdA==".to_string()],
2220                    account_overrides: Default::default(),
2221                    return_accounts: Default::default(),
2222                    label: None,
2223                }])
2224                .build()
2225        };
2226        assert!(replay_support::send_action_conflict(&params(false, ActionKind::Send)).is_none());
2227        assert!(
2228            replay_support::send_action_conflict(&params(true, ActionKind::Simulate)).is_none()
2229        );
2230        let mut publishing = params(true, ActionKind::Simulate);
2231        publishing.actions[0].feeds_reroute = true;
2232        assert!(replay_support::send_action_conflict(&publishing).is_none());
2233
2234        let conflict = replay_support::send_action_conflict(&params(true, ActionKind::Send))
2235            .expect("send under replay conflicts");
2236        assert!(conflict.contains("kind: send"), "{conflict}");
2237        assert!(conflict.contains(replay_support::SEND_ACTION), "{conflict}");
2238    }
2239
2240    fn override_entry(owner: Address, executable: bool) -> AccountData {
2241        AccountData {
2242            data: EncodedBinary::from_bytes(&[1, 2, 3], BinaryEncoding::Base64),
2243            executable,
2244            lamports: 1,
2245            owner,
2246            space: 3,
2247        }
2248    }
2249
2250    fn before() -> ActionAnchor {
2251        ActionAnchor::BeforeSlot { slots: vec![1] }
2252    }
2253
2254    fn valid() -> ScheduledAction {
2255        override_action(before(), address(3), override_entry(address(4), false))
2256    }
2257
2258    fn override_action(
2259        anchor: ActionAnchor,
2260        account: Address,
2261        data: AccountData,
2262    ) -> ScheduledAction {
2263        ScheduledAction {
2264            anchor,
2265            kind: ActionKind::default(),
2266            transactions: Vec::new(),
2267            account_overrides: AccountModifications(BTreeMap::from([(account, data)])),
2268            return_accounts: Vec::new(),
2269            label: None,
2270            feeds_reroute: true,
2271        }
2272    }
2273
2274    #[test]
2275    fn override_action_round_trips() {
2276        let key = address(1);
2277        let entry = override_entry(address(2), false);
2278        let action = override_action(
2279            ActionAnchor::BeforeSlot { slots: vec![5, 9] },
2280            key,
2281            entry.clone(),
2282        );
2283        let json = serde_json::to_value(&action).unwrap();
2284        assert!(
2285            json["anchor"]["slots"].is_string(),
2286            "slots travel as a compressed blob: {json}"
2287        );
2288        let back: ScheduledAction = serde_json::from_value(json).unwrap();
2289        assert_eq!(back.anchor, ActionAnchor::BeforeSlot { slots: vec![5, 9] });
2290        assert!(back.feeds_reroute);
2291        assert!(!back.is_computed_override(), "no transactions to simulate");
2292        let survived = &back.account_overrides.0[&key];
2293        assert_eq!(survived.lamports, entry.lamports);
2294        assert_eq!(survived.data.data, entry.data.data);
2295    }
2296
2297    #[rstest]
2298    #[case::executable(override_action(before(), address(3), override_entry(address(10), true)))]
2299    #[case::program_owned(override_action(
2300        before(),
2301        address(3),
2302        override_entry(solana_account::PROGRAM_OWNERS[0], false),
2303    ))]
2304    #[case::token_owned(override_action(
2305        before(),
2306        address(3),
2307        override_entry(TOKEN_PROGRAM_IDS[0], false),
2308    ))]
2309    #[case::after_slot_anchor(override_action(
2310        ActionAnchor::AfterSlot { slots: vec![1] },
2311        address(3),
2312        override_entry(address(4), false),
2313    ))]
2314    #[case::nothing_to_post(ScheduledAction {
2315        account_overrides: AccountModifications::default(),
2316        ..valid()
2317    })]
2318    // Otherwise an action that meant to send would silently post overrides instead.
2319    #[case::send(ScheduledAction {
2320        kind: ActionKind::Send,
2321        ..valid()
2322    })]
2323    #[allow(deprecated, reason = "exercise the loader-owner rejection path")]
2324    fn override_actions_reject_invalid_accounts_and_shapes(#[case] action: ScheduledAction) {
2325        assert!(decode_override_actions(&[action]).is_err());
2326    }
2327
2328    /// The shapes the rejections above are variations of.
2329    #[test]
2330    fn override_actions_accept_a_static_and_a_computed_shape() {
2331        assert!(decode_override_actions(&[valid()]).is_ok());
2332        // Transactions make the contribution computed rather than static — still valid,
2333        // just not resolvable until the action runs.
2334        assert!(
2335            decode_override_actions(&[ScheduledAction {
2336                transactions: vec!["dGVzdA==".to_string()],
2337                ..valid()
2338            }])
2339            .is_ok()
2340        );
2341    }
2342
2343    #[test]
2344    fn override_actions_merge_account_wise_later_wins() {
2345        let entry = |lamports| AccountData {
2346            lamports,
2347            ..override_entry(address(9), false)
2348        };
2349        let slot_action = |slots, account, lamports| {
2350            override_action(ActionAnchor::BeforeSlot { slots }, account, entry(lamports))
2351        };
2352        let actions = [
2353            slot_action(vec![5, 7], address(1), 1),
2354            slot_action(vec![5], address(2), 2),
2355            slot_action(vec![5], address(1), 3),
2356            ScheduledAction {
2357                anchor: ActionAnchor::AfterSlot { slots: vec![5] },
2358                kind: ActionKind::Simulate,
2359                feeds_reroute: false,
2360                transactions: vec!["dGVzdA==".to_string()],
2361                account_overrides: AccountModifications::default(),
2362                return_accounts: Vec::new(),
2363                label: None,
2364            },
2365        ];
2366        let schedule = decode_override_actions(&actions).unwrap();
2367        assert_eq!(schedule.slots().collect::<Vec<_>>(), vec![5, 7]);
2368        let lamports_at = |key, slot| {
2369            schedule
2370                .active_for(&key, slot)
2371                .map(|account| account.lamports())
2372        };
2373        assert_eq!(lamports_at(address(1), 5), Some(3));
2374        assert_eq!(lamports_at(address(2), 5), Some(2));
2375        assert_eq!(lamports_at(address(1), 7), Some(1));
2376    }
2377
2378    #[test]
2379    fn computed_override_resolution_preserves_action_order_and_failure() {
2380        let key = address(1);
2381        let static_action = |lamports| {
2382            override_action(
2383                ActionAnchor::BeforeSlot { slots: vec![5] },
2384                key,
2385                AccountData {
2386                    lamports,
2387                    ..override_entry(address(9), false)
2388                },
2389            )
2390        };
2391        let computed_action = || ScheduledAction {
2392            transactions: vec!["dGVzdA==".to_string()],
2393            ..static_action(2)
2394        };
2395
2396        let computed_last =
2397            decode_override_actions(&[static_action(1), computed_action()]).unwrap();
2398        computed_last
2399            .resolve(5, 1, Some(HashMap::from([(key, shared_account(2))])))
2400            .unwrap();
2401        assert_eq!(
2402            computed_last.active_for(&key, 5).map(|a| a.lamports()),
2403            Some(2)
2404        );
2405
2406        let static_last = decode_override_actions(&[computed_action(), static_action(3)]).unwrap();
2407        static_last
2408            .resolve(5, 0, Some(HashMap::from([(key, shared_account(2))])))
2409            .unwrap();
2410        assert_eq!(
2411            static_last.active_for(&key, 5).map(|a| a.lamports()),
2412            Some(3)
2413        );
2414
2415        let failed = decode_override_actions(&[static_action(1), computed_action()]).unwrap();
2416        failed.resolve(5, 1, None).unwrap();
2417        assert_eq!(failed.active_for(&key, 5).map(|a| a.lamports()), Some(1));
2418        assert!(failed.resolve(5, 1, None).is_err(), "resolving twice");
2419    }
2420
2421    fn shared_account(lamports: u64) -> AccountSharedData {
2422        AccountSharedData::from(solana_account::Account {
2423            lamports,
2424            data: vec![1],
2425            owner: address(9),
2426            executable: false,
2427            rent_epoch: 0,
2428        })
2429    }
2430
2431    fn carry_forward_schedule() -> (Address, Address, OverrideSchedule) {
2432        let (a, b) = (address(1), address(2));
2433        let schedule = OverrideSchedule::from(BTreeMap::from([
2434            (10, HashMap::from([(a, shared_account(1))])),
2435            (
2436                20,
2437                HashMap::from([(a, shared_account(2)), (b, shared_account(9))]),
2438            ),
2439        ]));
2440        (a, b, schedule)
2441    }
2442
2443    #[test]
2444    fn override_schedule_resolves_carry_forward_per_account() {
2445        let (a, b, schedule) = carry_forward_schedule();
2446
2447        assert!(schedule.active_for(&a, 9).is_none());
2448        let cases = [
2449            (9, None, None),
2450            (10, Some(1), None),
2451            (15, Some(1), None),
2452            (20, Some(2), Some(9)),
2453            (u64::MAX, Some(2), Some(9)),
2454        ];
2455        for (slot, want_a, want_b) in cases {
2456            assert_eq!(
2457                schedule
2458                    .active_for(&a, slot)
2459                    .map(|account| account.lamports()),
2460                want_a,
2461                "active_for a at {slot}"
2462            );
2463            let active = schedule.active_at(slot);
2464            assert_eq!(
2465                active.get(&a).map(ReadableAccount::lamports),
2466                want_a,
2467                "active_at a at {slot}"
2468            );
2469            assert_eq!(
2470                active.get(&b).map(ReadableAccount::lamports),
2471                want_b,
2472                "active_at b at {slot}"
2473            );
2474        }
2475    }
2476
2477    #[test]
2478    fn newly_active_yields_final_values_in_window() {
2479        let (a, b, schedule) = carry_forward_schedule();
2480
2481        let newly: HashMap<_, _> = schedule.newly_active(Some(10), 20).into_iter().collect();
2482        assert_eq!(newly.len(), 2);
2483        assert_eq!(newly[&a].lamports(), 2);
2484        assert_eq!(newly[&b].lamports(), 9);
2485        let spanning: HashMap<_, _> = schedule.newly_active(None, 20).into_iter().collect();
2486        assert_eq!(spanning[&a].lamports(), 2, "later slot wins");
2487        assert_eq!(schedule.newly_active(None, 15).len(), 1);
2488    }
2489
2490    #[test]
2491    fn reroute_filter_round_trips_camel_case() {
2492        let filter = RerouteFilter {
2493            pairs: BTreeSet::from([MintPair::new(address(1), address(2))]),
2494        };
2495        let json = serde_json::to_value(&filter).unwrap();
2496        assert!(
2497            json["pairs"][0][0].is_string() && json["pairs"][0][1].is_string(),
2498            "a pair serializes as two base58 strings: {json}"
2499        );
2500        let back: RerouteFilter = serde_json::from_value(json).unwrap();
2501        assert_eq!(back, filter);
2502    }
2503
2504    /// The set lookup is only direction-agnostic because construction normalizes, and
2505    /// values arriving over the wire have to be normalized on that path too.
2506    #[test]
2507    fn mint_pairs_are_normalized_however_they_are_built() {
2508        let (one, other) = (address(1), address(2));
2509        assert_eq!(MintPair::new(one, other), MintPair::new(other, one));
2510
2511        let reversed: MintPair =
2512            serde_json::from_value(serde_json::json!([other.to_string(), one.to_string()]))
2513                .unwrap();
2514        assert_eq!(reversed, MintPair::new(one, other));
2515    }
2516
2517    #[test]
2518    fn fail_fast_divergence_kind_str_round_trips() {
2519        for kind in [
2520            FailFastDivergenceKind::AnyNonBenign,
2521            FailFastDivergenceKind::Tracked,
2522        ] {
2523            assert_eq!(
2524                FailFastDivergenceKind::from_str_opt(kind.as_str()),
2525                Some(kind)
2526            );
2527        }
2528        assert_eq!(FailFastDivergenceKind::from_str_opt("nonsense"), None);
2529        assert_eq!(
2530            FailFastDivergenceKind::default(),
2531            FailFastDivergenceKind::AnyNonBenign
2532        );
2533    }
2534
2535    #[test]
2536    fn bundle_not_found_is_terminal_and_descriptive() {
2537        let error = BacktestError::BundleNotFound {
2538            start_slot: 100,
2539            end_slot: 600,
2540        };
2541
2542        assert!(error.is_terminal());
2543        assert_eq!(
2544            error.to_string(),
2545            "block bundle not found for start_slot=100, end_slot=600"
2546        );
2547    }
2548
2549    #[test]
2550    fn no_produced_blocks_is_terminal_and_descriptive() {
2551        let error = BacktestError::NoProducedBlocksInRange {
2552            start_slot: 100,
2553            end_slot: 100,
2554        };
2555
2556        assert!(error.is_terminal());
2557        // placement-e2e matches this phrase to classify the failure as
2558        // transient; keep it stable.
2559        assert_eq!(
2560            error.to_string(),
2561            "no produced blocks in requested range 100..=100; slot(s) may be skipped on-chain"
2562        );
2563    }
2564
2565    #[test]
2566    fn bundle_build_request_optional_fields_default_to_none() {
2567        let req: BundleBuildRequest =
2568            serde_json::from_str(r#"{"start_slot":1,"end_slot":2}"#).expect("parse");
2569        assert_eq!((req.start_slot, req.end_slot), (1, 2));
2570        assert_eq!(req.bundle_size, None);
2571        assert_eq!(req.idempotency_key, None);
2572    }
2573
2574    #[test]
2575    fn bundle_build_request_parses_optional_fields() {
2576        let req: BundleBuildRequest = serde_json::from_str(
2577            r#"{"start_slot":1,"end_slot":2,"bundle_size":500,"idempotency_key":"abc"}"#,
2578        )
2579        .expect("parse");
2580        assert_eq!(req.bundle_size, Some(500));
2581        assert_eq!(req.idempotency_key.as_deref(), Some("abc"));
2582    }
2583
2584    #[test]
2585    fn bundle_build_status_serde_round_trips_with_snake_case() {
2586        let cases = [
2587            (BundleBuildStatus::Completed, "\"completed\""),
2588            (BundleBuildStatus::Failed, "\"failed\""),
2589            (BundleBuildStatus::InProgress, "\"in_progress\""),
2590            (
2591                BundleBuildStatus::NeedsInvestigation,
2592                "\"needs_investigation\"",
2593            ),
2594        ];
2595        for (status, expected) in cases {
2596            assert_eq!(serde_json::to_string(&status).unwrap(), expected);
2597            assert_eq!(
2598                serde_json::from_str::<BundleBuildStatus>(expected).unwrap(),
2599                status
2600            );
2601            assert_eq!(status.as_str(), expected.trim_matches('"'));
2602        }
2603        assert!(serde_json::from_str::<BundleBuildStatus>("\"queued\"").is_err());
2604    }
2605
2606    #[test]
2607    fn bundle_build_request_without_kind_deserializes_as_blocks() {
2608        // An old client's payload must keep meaning a plain block build.
2609        let request: BundleBuildRequest =
2610            serde_json::from_str(r#"{"start_slot":100,"end_slot":200}"#).unwrap();
2611        assert_eq!(request.kind, BundleKind::Blocks);
2612    }
2613
2614    #[test]
2615    fn bundle_kind_round_trips_over_serde_and_as_str() {
2616        for kind in [BundleKind::Blocks, BundleKind::AccountState] {
2617            let json = serde_json::to_string(&kind).unwrap();
2618            assert_eq!(json, format!("\"{}\"", kind.as_str()));
2619            assert_eq!(serde_json::from_str::<BundleKind>(&json).unwrap(), kind);
2620            // The store round-trips through `as_str`, not serde, so the two
2621            // maps have to agree for a stored kind to read back.
2622            assert_eq!(BundleKind::from_str_opt(kind.as_str()), Some(kind));
2623        }
2624        assert!(serde_json::from_str::<BundleKind>("\"accountState\"").is_err());
2625        assert_eq!(BundleKind::from_str_opt("accountState"), None);
2626    }
2627
2628    #[test]
2629    fn bundle_build_request_carries_kind_through_serde() {
2630        let request = BundleBuildRequest {
2631            start_slot: 100,
2632            end_slot: 200,
2633            kind: BundleKind::AccountState,
2634            ..Default::default()
2635        };
2636        let json = serde_json::to_string(&request).unwrap();
2637        assert!(json.contains(r#""kind":"account_state""#), "{json}");
2638        let parsed: BundleBuildRequest = serde_json::from_str(&json).unwrap();
2639        assert_eq!(parsed.kind, BundleKind::AccountState);
2640    }
2641
2642    #[test]
2643    fn bundle_build_status_response_serializes_request_and_status() {
2644        let response = BundleBuildStatusResponse {
2645            request_id: "r".to_string(),
2646            start_slot: 100,
2647            end_slot: 200,
2648            bundle_size: Some(50),
2649            status: BundleBuildStatus::InProgress,
2650            created_at_unix_ms: 0,
2651            kind: BundleKind::Blocks,
2652        };
2653        let json = serde_json::to_value(&response).unwrap();
2654        assert_eq!(json["request_id"].as_str(), Some("r"));
2655        assert_eq!(json["start_slot"].as_u64(), Some(100));
2656        assert_eq!(json["bundle_size"].as_u64(), Some(50));
2657        assert_eq!(json["status"].as_str(), Some("in_progress"));
2658        assert_eq!(json["created_at_unix_ms"].as_u64(), Some(0));
2659    }
2660
2661    #[test]
2662    fn an_empty_bundles_list_is_distinct_from_an_absent_one() {
2663        // A server that predates the section sends no `bundles` at all.
2664        let absent: BundleBuildListResponse = serde_json::from_str(r#"{"builds":[]}"#).unwrap();
2665        let empty: BundleBuildListResponse =
2666            serde_json::from_str(r#"{"builds":[],"bundles":[]}"#).unwrap();
2667        assert_eq!(absent.bundles, None);
2668        assert_eq!(empty.bundles, Some(Vec::new()));
2669    }
2670
2671    #[test]
2672    fn completed_bundle_round_trips() {
2673        let bundle = CompletedBundle {
2674            start_slot: 100,
2675            end_slot: 200,
2676            kind: BundleKind::AccountState,
2677            completed_at_unix_ms: Some(1_700_000_000_000),
2678            size_bytes: Some(4_096),
2679        };
2680        let json = serde_json::to_string(&bundle).unwrap();
2681        assert!(json.contains(r#""kind":"account_state""#), "{json}");
2682        assert_eq!(
2683            serde_json::from_str::<CompletedBundle>(&json).unwrap(),
2684            bundle
2685        );
2686
2687        let sparse = r#"{"start_slot":1,"end_slot":2,"kind":"blocks"}"#;
2688        let parsed: CompletedBundle = serde_json::from_str(sparse).unwrap();
2689        assert_eq!(parsed.completed_at_unix_ms, None);
2690        assert_eq!(parsed.size_bytes, None);
2691    }
2692
2693    #[test]
2694    fn available_range_reads_a_server_that_predates_kind_and_scope() {
2695        // Deploys are not atomic: a new client must not fail on the old shape.
2696        let json = r#"{
2697            "bundle_start_slot": 100,
2698            "bundle_start_slot_utc": null,
2699            "max_bundle_end_slot": 200,
2700            "max_bundle_end_slot_utc": null,
2701            "max_bundle_size": 101
2702        }"#;
2703        let parsed: AvailableRange = serde_json::from_str(json).unwrap();
2704        assert_eq!(parsed.bundle_start_slot, 100);
2705        assert_eq!(parsed.kind, None);
2706        assert_eq!(parsed.scope, None);
2707    }
2708
2709    #[test]
2710    fn available_range_ignores_fields_it_does_not_know() {
2711        // The other direction: an old client against a newer server.
2712        let json = r#"{"bundle_start_slot": 1, "some_future_field": "x"}"#;
2713        assert_eq!(
2714            serde_json::from_str::<AvailableRange>(json)
2715                .unwrap()
2716                .bundle_start_slot,
2717            1
2718        );
2719    }
2720
2721    #[test]
2722    fn available_range_labels_round_trip() {
2723        let range = AvailableRange {
2724            bundle_start_slot: 100,
2725            max_bundle_end_slot: Some(200),
2726            kind: Some(BundleKind::AccountState),
2727            scope: Some(RangeScope::User),
2728            ..Default::default()
2729        };
2730        let json = serde_json::to_string(&range).unwrap();
2731        assert!(json.contains(r#""kind":"account_state""#), "{json}");
2732        assert!(json.contains(r#""scope":"user""#), "{json}");
2733        let parsed: AvailableRange = serde_json::from_str(&json).unwrap();
2734        assert_eq!(parsed.kind, Some(BundleKind::AccountState));
2735        assert_eq!(parsed.scope, Some(RangeScope::User));
2736    }
2737
2738    fn range(start: u64, end: u64) -> AvailableRange {
2739        AvailableRange {
2740            bundle_start_slot: start,
2741            max_bundle_end_slot: Some(end),
2742            ..Default::default()
2743        }
2744    }
2745
2746    /// Each case lists the available bundles, the requested `[start, end]`, and
2747    /// the expected split — `Some(_)` for an accepted plan, `None` when the range
2748    /// is unservable and `split_range` must error.
2749    #[rstest::rstest]
2750    #[case::single(vec![range(100, 300)], 100, 300, Some(vec![(100, 300)]))]
2751    #[case::multi(
2752        vec![range(100, 200), range(201, 300), range(301, 400)],
2753        100, 300, Some(vec![(100, 200), (201, 300)])
2754    )]
2755    // Smaller bundles nested inside a larger one don't bridge the gap, but the
2756    // range is still coverable by riding the coarse series.
2757    #[case::nested(
2758        vec![range(100, 500), range(110, 150), range(150, 190), range(501, 900)],
2759        100, 900, Some(vec![(100, 500), (501, 900)])
2760    )]
2761    // A fine grid (1k bundles standing in for the 10k grid) overlapped by a
2762    // coarser, differently-aligned series: ride the fine grid, never landing on
2763    // the coarse bundle's drifted start.
2764    #[case::prefers_finer_grid(
2765        vec![range(1_000, 1_999), range(1_500, 3_400), range(2_000, 2_999), range(3_000, 3_999)],
2766        1_000, 3_999, Some(vec![(1_000, 1_999), (2_000, 2_999), (3_000, 3_999)])
2767    )]
2768    // A 50k snapshot bundle and the 10k bundles built on it share start 100: ride
2769    // the 10k grid instead of the coarse end, which would strand the cursor on a
2770    // slot no bundle starts at.
2771    #[case::shared_start_prefers_finer(
2772        vec![range(100, 150), range(100, 120), range(121, 140), range(141, 160)],
2773        100, 160, Some(vec![(100, 120), (121, 140), (141, 160)])
2774    )]
2775    // A coarse bundle (100, 200) overlaps a finer pair covering the same span:
2776    // split_range maximises parallelism, riding the fine pair (two sessions)
2777    // rather than merging onto the coarse max end (one session).
2778    #[case::coarse_overlap_prefers_finer_pair(
2779        vec![range(100, 200), range(100, 150), range(151, 160)],
2780        100, 160, Some(vec![(100, 150), (151, 160)])
2781    )]
2782    // The fine grid has a hole (nothing starts at 141), but a coarse bundle
2783    // sharing the start spans it: serve the request off the coarse bundle rather
2784    // than reporting an unsupported range.
2785    #[case::falls_back_to_coarse(
2786        vec![range(100, 160), range(100, 120), range(121, 140)],
2787        100, 160, Some(vec![(100, 160)])
2788    )]
2789    // The last bundle overshoots the requested end and is clamped.
2790    #[case::clamps_final_bundle(vec![range(100, 199), range(200, 999)], 100, 450, Some(vec![(100, 199), (200, 450)]))]
2791    // A request landing mid-bundle anchors to the covering bundle's start
2792    // (matching the server) instead of erroring as "not covered".
2793    #[case::anchors_mid_bundle(vec![range(150, 350)], 200, 300, Some(vec![(150, 300)]))]
2794    #[case::anchors_then_continues(
2795        vec![range(150, 350), range(351, 600)],
2796        200, 600, Some(vec![(150, 350), (351, 600)])
2797    )]
2798    #[case::start_inside_bundle_anchors(vec![range(200, 400)], 300, 400, Some(vec![(200, 400)]))]
2799    // Errors: a start before any bundle's coverage has no covering bundle...
2800    #[case::start_before_first_bundle(vec![range(200, 400)], 100, 400, None)]
2801    // ...the end must be reachable...
2802    #[case::end_not_covered(vec![range(100, 200)], 100, 300, None)]
2803    #[case::gap_in_coverage(vec![range(100, 200), range(210, 300)], 100, 300, None)]
2804    // Per-API-key isolation: a user's finer bundles and a coarse GLOBAL bundle
2805    // sharing a start are fed as SEPARATE entries (never merged on snapshot_slot,
2806    // which would keep only the max end). split_range set-unions the ends and
2807    // rides the finer user grid — a regression guard against re-merging.
2808    #[case::user_and_global_ranges_not_collapsed(
2809        vec![range(100, 200), range(100, 150), range(151, 200)],
2810        100, 200, Some(vec![(100, 150), (151, 200)])
2811    )]
2812    fn split_range_cases(
2813        #[case] ranges: Vec<AvailableRange>,
2814        #[case] start: u64,
2815        #[case] end: u64,
2816        #[case] expected: Option<Vec<(u64, u64)>>,
2817    ) {
2818        match expected {
2819            Some(expected) => assert_eq!(
2820                split_range(
2821                    &ranges,
2822                    SlotRange::new_unchecked(start, end),
2823                    BundleKind::Blocks
2824                )
2825                .unwrap(),
2826                expected
2827            ),
2828            None => assert!(
2829                split_range(
2830                    &ranges,
2831                    SlotRange::new_unchecked(start, end),
2832                    BundleKind::Blocks
2833                )
2834                .is_err()
2835            ),
2836        }
2837    }
2838
2839    fn account_state_range(start: u64, end: u64) -> AvailableRange {
2840        AvailableRange {
2841            kind: Some(BundleKind::AccountState),
2842            ..range(start, end)
2843        }
2844    }
2845
2846    #[test]
2847    fn a_replay_split_rides_the_recordings_not_a_finer_block_grid() {
2848        // The finer block grid wins on piece count, which is precisely what would
2849        // hand a replay run sub-ranges nothing ever recorded.
2850        let ranges = vec![
2851            range(100, 149),
2852            range(150, 199),
2853            account_state_range(100, 199),
2854        ];
2855        let requested = SlotRange::new_unchecked(100, 199);
2856
2857        assert_eq!(
2858            split_range(&ranges, requested, BundleKind::Blocks).unwrap(),
2859            [(100, 149), (150, 199)]
2860        );
2861        assert_eq!(
2862            split_range(&ranges, requested, BundleKind::AccountState).unwrap(),
2863            [(100, 199)]
2864        );
2865    }
2866
2867    #[test]
2868    fn a_replay_split_tiles_consecutive_recordings() {
2869        let ranges = vec![account_state_range(100, 199), account_state_range(200, 299)];
2870        assert_eq!(
2871            split_range(
2872                &ranges,
2873                SlotRange::new_unchecked(100, 299),
2874                BundleKind::AccountState
2875            )
2876            .unwrap(),
2877            [(100, 199), (200, 299)]
2878        );
2879    }
2880
2881    #[test]
2882    fn a_replay_split_ends_short_of_a_recording() {
2883        // A recording is read slot by slot, so a session stops wherever asked
2884        // inside it — the same as a block session does inside its bundle.
2885        let requested = SlotRange::new_unchecked(100, 150);
2886        assert_eq!(
2887            split_range(&[range(100, 199)], requested, BundleKind::Blocks).unwrap(),
2888            [(100, 150)]
2889        );
2890        assert_eq!(
2891            split_range(
2892                &[account_state_range(100, 199)],
2893                requested,
2894                BundleKind::AccountState
2895            )
2896            .unwrap(),
2897            [(100, 150)]
2898        );
2899    }
2900
2901    #[test]
2902    fn a_replay_split_ends_short_of_its_last_recording() {
2903        // Only the last sub-range may stop mid-recording; the ones before it
2904        // still have to hand over on the very next slot.
2905        let ranges = vec![account_state_range(100, 199), account_state_range(200, 299)];
2906        assert_eq!(
2907            split_range(
2908                &ranges,
2909                SlotRange::new_unchecked(100, 250),
2910                BundleKind::AccountState
2911            )
2912            .unwrap(),
2913            [(100, 199), (200, 250)]
2914        );
2915    }
2916
2917    #[test]
2918    fn a_replay_split_tiles_a_one_slot_recording() {
2919        // A series whose last split begins on the requested end records exactly
2920        // one slot.
2921        let ranges = vec![account_state_range(100, 249), account_state_range(250, 250)];
2922        assert_eq!(
2923            split_range(
2924                &ranges,
2925                SlotRange::new_unchecked(100, 250),
2926                BundleKind::AccountState
2927            )
2928            .unwrap(),
2929            [(100, 249), (250, 250)]
2930        );
2931    }
2932
2933    #[test]
2934    fn a_replay_split_anchors_a_mid_recording_start() {
2935        // A mid-bundle start anchors to the bundle covering it, whichever kind
2936        // the walk rides — the two differ in the bundles they read, not in the
2937        // ranges they admit.
2938        let requested = SlotRange::new_unchecked(150, 199);
2939        assert_eq!(
2940            split_range(&[range(100, 199)], requested, BundleKind::Blocks).unwrap(),
2941            [(100, 199)]
2942        );
2943        assert_eq!(
2944            split_range(
2945                &[account_state_range(100, 199)],
2946                requested,
2947                BundleKind::AccountState
2948            )
2949            .unwrap(),
2950            [(100, 199)]
2951        );
2952    }
2953
2954    #[test]
2955    fn a_replay_split_refuses_a_start_no_recording_covers() {
2956        let error = split_range(
2957            &[account_state_range(200, 299)],
2958            SlotRange::new_unchecked(100, 299),
2959            BundleKind::AccountState,
2960        )
2961        .expect_err("no recording covers 100");
2962        assert!(
2963            error.contains("is not covered by any available account-state bundle"),
2964            "{error}"
2965        );
2966    }
2967
2968    #[test]
2969    fn a_replay_split_ignores_block_ranges() {
2970        let error = split_range(
2971            &[range(100, 199)],
2972            SlotRange::new_unchecked(100, 199),
2973            BundleKind::AccountState,
2974        )
2975        .expect_err("owns no recordings");
2976        assert!(error.contains("account-state bundle"), "{error}");
2977    }
2978
2979    #[test]
2980    fn blocks_mode_admits_ranges_from_a_server_that_predates_kind() {
2981        // A server predating the field omits it, and those ranges must still
2982        // split exactly as they did before the kind was consulted.
2983        let unlabelled = range(100, 199);
2984        assert_eq!(unlabelled.kind, None);
2985        assert_eq!(
2986            split_range(
2987                &[unlabelled],
2988                SlotRange::new_unchecked(100, 150),
2989                BundleKind::Blocks
2990            )
2991            .unwrap(),
2992            [(100, 150)]
2993        );
2994    }
2995
2996    /// Servable end per bundle start, as `split_range` sees it — every advertised
2997    /// end at or past its start, keyed by start.
2998    fn ends_by_start(ranges: &[AvailableRange]) -> BTreeMap<u64, BTreeSet<u64>> {
2999        let mut ends: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
3000        for r in ranges {
3001            if let Some(end) = r.max_bundle_end_slot
3002                && end >= r.bundle_start_slot
3003            {
3004                ends.entry(r.bundle_start_slot).or_default().insert(end);
3005            }
3006        }
3007        ends
3008    }
3009
3010    /// Independent spec for the optimum: the gap-free split of `[start, end]` with
3011    /// the most sub-ranges, found by exhaustively trying every advertised end at
3012    /// each cursor. `None` when no split aligns with the bundle starts.
3013    fn reference_max_split(
3014        ends: &BTreeMap<u64, BTreeSet<u64>>,
3015        cursor: u64,
3016        end: u64,
3017    ) -> Option<Vec<(u64, u64)>> {
3018        ends.get(&cursor)?
3019            .iter()
3020            .filter_map(|&bundle_end| {
3021                if bundle_end >= end {
3022                    Some(vec![(cursor, end)])
3023                } else {
3024                    reference_max_split(ends, bundle_end + 1, end).map(|mut rest| {
3025                        rest.insert(0, (cursor, bundle_end));
3026                        rest
3027                    })
3028                }
3029            })
3030            .max_by_key(Vec::len)
3031    }
3032
3033    /// Structure-independent check that a split is one the server can serve: it
3034    /// tiles `[start, end]` gap-free, every start is a real bundle start, and
3035    /// every end is within that start's largest advertised end.
3036    fn is_valid_split(
3037        split: &[(u64, u64)],
3038        ends: &BTreeMap<u64, BTreeSet<u64>>,
3039        start: u64,
3040        end: u64,
3041    ) -> bool {
3042        split.first().is_some_and(|&(s, _)| s == start)
3043            && split.last().is_some_and(|&(_, e)| e == end)
3044            && split.windows(2).all(|w| w[1].0 == w[0].1 + 1)
3045            && split.iter().all(|&(s, e)| {
3046                e >= s
3047                    && ends
3048                        .get(&s)
3049                        .and_then(|bundle_ends| bundle_ends.iter().next_back())
3050                        .is_some_and(|&max_end| e <= max_end)
3051            })
3052    }
3053
3054    /// Property test: across many randomized bundle layouts, `split_range` must
3055    /// return a *valid* split with the *maximum* number of sub-ranges whenever one
3056    /// exists, and error exactly when none does. Deterministic (fixed LCG seed) so
3057    /// a failure is reproducible.
3058    #[test]
3059    fn split_range_matches_reference() {
3060        let mut seed: u64 = 0x9E3779B97F4A7C15;
3061        let mut next = || {
3062            seed = seed
3063                .wrapping_mul(6364136223846793005)
3064                .wrapping_add(1442695040888963407);
3065            seed >> 33
3066        };
3067
3068        for _ in 0..50_000 {
3069            // A small slot universe makes shared starts, overlaps, nesting, and
3070            // gaps all common.
3071            let ranges: Vec<AvailableRange> = (0..next() % 6)
3072                .map(|_| {
3073                    let start = next() % 12;
3074                    range(start, start + next() % 6) // len 0 => filtered out
3075                })
3076                .collect();
3077            let start = next() % 12;
3078            let end = start + next() % 6; // sometimes start == end, sometimes unservable
3079
3080            let got = split_range(
3081                &ranges,
3082                SlotRange::new_unchecked(start, end),
3083                BundleKind::Blocks,
3084            );
3085            let ends = ends_by_start(&ranges);
3086            // split_range anchors a mid-bundle start to the covering bundle's
3087            // start, so the reference must walk from the same anchor.
3088            let anchor = ends
3089                .range(..=start)
3090                .rfind(|(_, e)| e.iter().next_back().is_some_and(|&x| x >= start))
3091                .map(|(&s, _)| s);
3092            let reference = anchor.and_then(|a| reference_max_split(&ends, a, end));
3093
3094            let layout: Vec<_> = ranges
3095                .iter()
3096                .map(|r| (r.bundle_start_slot, r.max_bundle_end_slot))
3097                .collect();
3098            match (&got, &reference) {
3099                (Ok(split), Some(best)) => {
3100                    assert!(
3101                        is_valid_split(split, &ends, anchor.unwrap(), end),
3102                        "invalid split {split:?} for {layout:?} [{start},{end}]"
3103                    );
3104                    assert_eq!(
3105                        split.len(),
3106                        best.len(),
3107                        "suboptimal split {split:?} vs {best:?} for {layout:?} [{start},{end}]"
3108                    );
3109                }
3110                (Err(_), None) => {}
3111                _ => panic!(
3112                    "disagreement: split_range={got:?}, reference={reference:?} for {layout:?} [{start},{end}]"
3113                ),
3114            }
3115        }
3116    }
3117}