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    #[serde(default = "DirectFillParams::default_slippage_bps")]
842    pub slippage_bps: u16,
843    /// The venue's own contribution to the route: what only the venue knows.
844    pub market: DirectFillMarket,
845}
846
847impl DirectFillParams {
848    /// What a spec that omits `slippageBps` gets: enough room that a fill is not lost to a tick,
849    /// while still bounding the counterfactual.
850    const fn default_slippage_bps() -> u16 {
851        50
852    }
853}
854
855/// A venue's account run and the mint ordering its direction byte indexes into.
856///
857/// Supplied by the caller because nothing can derive it: a router's IDL declares only the
858/// accounts the ROUTER touches, and a venue's run reaches the program as `remaining_accounts`
859/// it forwards to the CPI untouched. The only sources are the venue itself, or a real
860/// instruction that used it. Carrying it as a parameter keeps adding a venue a matter of
861/// configuration, and makes a venue that migrates its accounts the caller's line to change.
862#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
863#[serde(try_from = "DirectFillMarketWire", into = "DirectFillMarketWire")]
864pub struct DirectFillMarket {
865    /// The pair this market quotes, in the VENUE's own ordering — a direction byte is an index
866    /// into it, so unlike [`MintPair`] the order is load-bearing and must not be normalized.
867    pub mints: [Address; 2],
868    /// The venue's account run, in the order its own program expects.
869    pub accounts: Vec<DirectFillAccount>,
870}
871
872/// One account in a venue's run, with the writability the venue's own instructions carry.
873#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
874#[serde(try_from = "DirectFillAccountWire", into = "DirectFillAccountWire")]
875pub struct DirectFillAccount {
876    pub address: Address,
877    /// Read-only unless the venue writes to it. A run that understates this fails at execution
878    /// rather than at build, so it is the caller's to get right.
879    pub writable: bool,
880}
881
882#[derive(Debug, Clone, Serialize, Deserialize)]
883#[serde(rename_all = "camelCase")]
884struct DirectFillMarketWire {
885    mints: [String; 2],
886    accounts: Vec<DirectFillAccount>,
887}
888
889#[derive(Debug, Clone, Serialize, Deserialize)]
890#[serde(rename_all = "camelCase")]
891struct DirectFillAccountWire {
892    address: String,
893    #[serde(default)]
894    writable: bool,
895}
896
897/// Why a supplied market could not be used.
898#[derive(Debug, thiserror::Error, PartialEq, Eq)]
899pub enum ParseDirectFillMarketError {
900    #[error("invalid address {address} in the venue's market")]
901    Address { address: String },
902    /// The route arg carries the run's length in one byte, and an empty run names no venue.
903    #[error("a venue's account run must hold 1..=255 accounts, got {count}")]
904    RunLength { count: usize },
905    /// The direction byte is an index into the pair, which a repeated mint makes meaningless.
906    #[error("a market quotes two different mints, got {mint} twice")]
907    RepeatedMint { mint: String },
908}
909
910impl TryFrom<DirectFillAccountWire> for DirectFillAccount {
911    type Error = ParseDirectFillMarketError;
912
913    fn try_from(wire: DirectFillAccountWire) -> Result<Self, Self::Error> {
914        Ok(Self {
915            address: Address::from_str(&wire.address).map_err(|_| {
916                ParseDirectFillMarketError::Address {
917                    address: wire.address.clone(),
918                }
919            })?,
920            writable: wire.writable,
921        })
922    }
923}
924
925impl From<DirectFillAccount> for DirectFillAccountWire {
926    fn from(account: DirectFillAccount) -> Self {
927        Self {
928            address: account.address.to_string(),
929            writable: account.writable,
930        }
931    }
932}
933
934impl TryFrom<DirectFillMarketWire> for DirectFillMarket {
935    type Error = ParseDirectFillMarketError;
936
937    fn try_from(wire: DirectFillMarketWire) -> Result<Self, Self::Error> {
938        let [one, other] = wire.mints;
939        let parse = |mint: &str| {
940            Address::from_str(mint).map_err(|_| ParseDirectFillMarketError::Address {
941                address: mint.to_owned(),
942            })
943        };
944        let mints = [parse(&one)?, parse(&other)?];
945        if mints[0] == mints[1] {
946            return Err(ParseDirectFillMarketError::RepeatedMint { mint: one });
947        }
948        if wire.accounts.is_empty() || wire.accounts.len() > usize::from(u8::MAX) {
949            return Err(ParseDirectFillMarketError::RunLength {
950                count: wire.accounts.len(),
951            });
952        }
953        Ok(Self {
954            mints,
955            accounts: wire.accounts,
956        })
957    }
958}
959
960impl From<DirectFillMarket> for DirectFillMarketWire {
961    fn from(market: DirectFillMarket) -> Self {
962        Self {
963            mints: [market.mints[0].to_string(), market.mints[1].to_string()],
964            accounts: market.accounts,
965        }
966    }
967}
968
969/// Hand-written rather than derived: `requote` defaults to ON, and a derived `Default` would
970/// give the opposite of what deserializing an empty object gives.
971impl Default for RerouteParams {
972    fn default() -> Self {
973        Self {
974            aggregators: RerouteAggregators::default(),
975            filter: None,
976            detect_failed_l1_swaps: false,
977            circular_arbs: false,
978            requote: Self::default_requote(),
979            direct_fill: None,
980        }
981    }
982}
983
984/// `<base>,<quote>` in base58 — the spelling a CLI flag or env var carries.
985impl std::str::FromStr for MintPair {
986    type Err = String;
987
988    fn from_str(s: &str) -> Result<Self, Self::Err> {
989        let (base, quote) = s
990            .split_once(',')
991            .ok_or_else(|| format!("expected two comma-separated mints, got {s:?}"))?;
992        Self::try_from([base.trim().to_owned(), quote.trim().to_owned()])
993            .map_err(|error| error.to_string())
994    }
995}
996
997impl std::fmt::Display for MintPair {
998    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
999        let (one, other) = self.mints();
1000        write!(f, "{one},{other}")
1001    }
1002}
1003
1004impl RerouteParams {
1005    const fn default_requote() -> bool {
1006        true
1007    }
1008
1009    /// Whether this block asks for anything at all — a run with neither re-quoting nor a direct
1010    /// fill book has a reroute section that does nothing.
1011    pub const fn is_noop(&self) -> bool {
1012        !self.requote && self.direct_fill.is_none()
1013    }
1014}
1015
1016impl DirectFillParams {
1017    /// Whether `input`/`output` name this book's pair, either direction.
1018    pub fn matches_mints(&self, input: &Address, output: &Address) -> bool {
1019        MintPair::new(*input, *output) == self.pair
1020    }
1021}
1022
1023/// Parameters required to start a new backtest session.
1024#[serde_with::serde_as]
1025#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
1026#[serde(rename_all = "camelCase")]
1027pub struct CreateSessionParams {
1028    /// First slot (inclusive) to replay.
1029    pub start_slot: u64,
1030    /// Last slot (inclusive) to replay.
1031    pub end_slot: u64,
1032    #[serde_as(as = "BTreeSet<serde_with::DisplayFromStr>")]
1033    #[serde(default)]
1034    #[builder(default)]
1035    /// Skip transactions signed by these addresses.
1036    pub signer_filter: BTreeSet<Address>,
1037    /// When true, include a session summary with transaction statistics in client-facing
1038    /// `Completed` responses. Summary generation remains enabled internally for metrics.
1039    #[serde(default)]
1040    #[builder(default)]
1041    pub send_summary: bool,
1042    /// Re-quote historical swaps through the metis router. Absent: plain replay.
1043    #[serde(default, skip_serializing_if = "Option::is_none")]
1044    pub reroute: Option<RerouteParams>,
1045    /// Maximum seconds to wait for ECS capacity-related startup retries before
1046    /// failing session creation. If not set (or 0), capacity errors fail immediately.
1047    #[serde(default)]
1048    pub capacity_wait_timeout_secs: Option<u16>,
1049    /// Maximum seconds to keep the session alive after the control websocket disconnects.
1050    /// If not set (or 0), the session tears down immediately on disconnect.
1051    /// Clamped to [`MAX_DISCONNECT_TIMEOUT_SECS`] (900 seconds / 15 minutes).
1052    #[serde(default)]
1053    pub disconnect_timeout_secs: Option<u16>,
1054    /// Extra compute units to add to each transaction's `SetComputeUnitLimit` budget.
1055    /// Useful when replaying with an account override whose program uses more CU than
1056    /// the original, causing otherwise-healthy transactions to run out of budget.
1057    /// Only applied when a `SetComputeUnitLimit` instruction is already present.
1058    #[serde(default)]
1059    pub extra_compute_units: Option<u32>,
1060    /// Events of interest the session should watch for. When an upcoming
1061    /// batch matches any filter, the server emits
1062    /// [`BacktestResponse::DiscoveryBatch`] (and its session-event twins)
1063    /// ahead of execution so the client can follow up with
1064    /// [`BacktestRequest::ContinueTo`] to pause before the batch. Empty
1065    /// means no batch discoveries are performed (existing behaviour).
1066    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1067    #[builder(default)]
1068    pub discoveries: Vec<DiscoveryFilter>,
1069    /// Transactions the server runs automatically during the backtest, with
1070    /// results streamed over `actionSubscribe`.
1071    ///
1072    /// Compressed as a whole rather than per action: a per-slot schedule repeats
1073    /// near-identical account bytes thousands of times, and that redundancy is
1074    /// only visible across actions. Compressing each action alone leaves a
1075    /// 10k-slot schedule near the websocket message limit; compressing the
1076    /// vector takes the same schedule from ~28 MiB to ~1 MiB.
1077    #[serde(default, with = "compressed", skip_serializing_if = "Vec::is_empty")]
1078    #[builder(default)]
1079    pub actions: Vec<ScheduledAction>,
1080    /// Replay accounts states for this range instead of executing transactions
1081    /// — a state-only acceleration.
1082    #[serde(default)]
1083    #[builder(default)]
1084    pub replay_account_state: bool,
1085}
1086
1087/// What account state replay cannot serve, worded once. Client, manager and
1088/// session all reject through here, so a user meets the same explanation
1089/// wherever the request is stopped.
1090pub mod replay_support {
1091    use super::{ActionKind, CreateSessionParams};
1092
1093    /// `getTransaction`/`getSignatureStatuses`: nothing executed, so nothing is found.
1094    pub const NO_TRANSACTIONS: &str =
1095        "Create the session without replayAccountState to execute and persist transactions.";
1096
1097    pub const SEND_TRANSACTION: &str = "Use simulateTransaction to run it against the replayed state, or create the session \
1098         without replayAccountState.";
1099
1100    pub const SEND_ACTION: &str = "Use kind: simulate, which runs against the replayed state without committing, post state \
1101         directly with an action carrying accountOverrides and no transactions, or create the \
1102         session without replayAccountState.";
1103
1104    /// States the operation, why replay cannot serve it, then the way forward.
1105    pub fn unsupported(operation: &str, remedy: &str) -> String {
1106        format!(
1107            "{operation} is not supported while account state replay is enabled: replay \
1108             reconstructs account state from the recorded delta bundle instead of executing \
1109             transactions, so this session never executes or persists any. {remedy}"
1110        )
1111    }
1112
1113    /// The one rule decidable from the request alone, so it can be caught before
1114    /// a session — or a runtime to host it — is ever created.
1115    pub fn send_action_conflict(params: &CreateSessionParams) -> Option<String> {
1116        (params.replay_account_state
1117            && params
1118                .actions
1119                .iter()
1120                .any(|action| action.kind == ActionKind::Send))
1121        .then(|| unsupported("A scheduled action with kind: send", SEND_ACTION))
1122    }
1123}
1124
1125impl CreateSessionParams {
1126    /// Whether a session needs the metis sidecar (reroute quotes through it).
1127    /// Only metis re-quoting needs the sidecar; a direct-fill-only run prices without a router.
1128    pub fn requires_sidecar(&self) -> bool {
1129        self.reroute.as_ref().is_some_and(|reroute| reroute.requote)
1130    }
1131
1132    pub fn reroute_order_flow(&self) -> bool {
1133        self.reroute.is_some()
1134    }
1135
1136    /// The Override actions in [`Self::actions`], compiled into the slot-keyed schedule the
1137    /// reroute layer consumes. Same-slot actions merge account-wise, later wins.
1138    pub fn override_schedule(&self) -> Result<OverrideSchedule, String> {
1139        decode_override_actions(&self.actions)
1140    }
1141
1142    /// The mint pairs re-quoting is restricted to, if the run named any.
1143    pub fn reroute_filter(&self) -> Option<&RerouteFilter> {
1144        self.reroute.as_ref()?.filter.as_ref()
1145    }
1146}
1147
1148/// What counts as a "non-benign" divergence for the backtest session fail-fast
1149/// behaviour (see `BacktestSessionOptions::fail_fast`).
1150#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1151#[serde(rename_all = "kebab-case")]
1152pub enum FailFastDivergenceKind {
1153    /// Any divergence other than a benign log diff (the full `is_divergent()` set:
1154    /// log mismatch, error mismatch, balance-diff mismatch).
1155    #[default]
1156    AnyNonBenign,
1157    /// Only divergences on transactions that touch an account currently watched by an
1158    /// account-diff subscription (subscribed directly, or owned by a subscribed program).
1159    Tracked,
1160}
1161
1162impl FailFastDivergenceKind {
1163    /// Stable string form used for CLI/env-var serialization. Matches the serde
1164    /// `kebab-case` representation.
1165    pub fn as_str(self) -> &'static str {
1166        match self {
1167            Self::AnyNonBenign => "any-non-benign",
1168            Self::Tracked => "tracked",
1169        }
1170    }
1171
1172    /// Inverse of [`Self::as_str`]; returns `None` for an unrecognized value.
1173    pub fn from_str_opt(value: &str) -> Option<Self> {
1174        match value {
1175            "any-non-benign" => Some(Self::AnyNonBenign),
1176            "tracked" => Some(Self::Tracked),
1177            _ => None,
1178        }
1179    }
1180}
1181
1182/// Account state modifications to apply.
1183#[serde_with::serde_as]
1184#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1185pub struct AccountModifications(
1186    #[serde_as(as = "BTreeMap<serde_with::DisplayFromStr, _>")]
1187    #[serde(default)]
1188    pub BTreeMap<Address, AccountData>,
1189);
1190
1191const TOKEN_PROGRAM_IDS: [Address; 2] = [spl_token_interface::ID, spl_token_2022_interface::ID];
1192
1193impl From<BTreeMap<Address, AccountData>> for AccountModifications {
1194    fn from(value: BTreeMap<Address, AccountData>) -> Self {
1195        Self(value)
1196    }
1197}
1198
1199/// Arguments used to continue an existing session.
1200#[serde_with::serde_as]
1201#[derive(Debug, Serialize, Deserialize)]
1202#[serde(rename_all = "camelCase")]
1203pub struct ContinueParams {
1204    #[serde(default = "ContinueParams::default_advance_count")]
1205    /// Number of blocks to advance before waiting.
1206    pub advance_count: u64,
1207    #[serde(default)]
1208    /// Base64-encoded transactions to execute before advancing.
1209    pub transactions: Vec<String>,
1210    #[serde(default)]
1211    /// Account state overrides to apply ahead of execution.
1212    pub modify_account_states: AccountModifications,
1213}
1214
1215impl Default for ContinueParams {
1216    fn default() -> Self {
1217        Self {
1218            advance_count: Self::default_advance_count(),
1219            transactions: Vec::new(),
1220            modify_account_states: AccountModifications(BTreeMap::new()),
1221        }
1222    }
1223}
1224
1225impl ContinueParams {
1226    pub fn default_advance_count() -> u64 {
1227        1
1228    }
1229}
1230
1231/// Payload emitted when a session halts at a caller-specified point.
1232/// `batch_index` is `None` for block-boundary pauses and `Some(n)` when the
1233/// session stopped *before* batch `n` within `slot` (no transaction from
1234/// batch `n` has been applied). While paused, RPC reads against the session
1235/// observe partial state up through batch `n - 1`.
1236#[derive(Debug, Clone, Serialize, Deserialize)]
1237#[serde(rename_all = "camelCase")]
1238pub struct PausedEvent {
1239    pub slot: u64,
1240    #[serde(default, skip_serializing_if = "Option::is_none")]
1241    pub batch_index: Option<u32>,
1242}
1243
1244/// Payload emitted when the session has *discovered* an upcoming batch that
1245/// matches one or more registered [`DiscoveryFilter`]s from session creation
1246/// (for example, a batch containing a transaction that invokes a program of
1247/// interest). The `(slot, batch_index)` pair can be fed directly to
1248/// [`BacktestRequest::ContinueTo`] to pause immediately before the batch
1249/// executes. After each `Continue` / `ContinueTo`, the session emits the
1250/// next `DiscoveryBatchEvent` ahead of the next matching batch, enabling a
1251/// reactive "pause on every discovery" driver loop.
1252#[serde_with::serde_as]
1253#[derive(Debug, Clone, Serialize, Deserialize)]
1254#[serde(rename_all = "camelCase")]
1255pub struct DiscoveryBatchEvent {
1256    pub slot: u64,
1257    pub batch_index: u32,
1258    /// Filters that matched this batch (always non-empty).
1259    pub matched: Vec<DiscoveryFilter>,
1260    /// Encoded transactions in this batch that triggered the match. Each
1261    /// entry carries the serialized `VersionedTransaction` bytes paired with
1262    /// the encoding used.
1263    pub transactions: Vec<EncodedBinary>,
1264}
1265
1266/// Arguments used to step an existing session to a precise point.
1267#[derive(Debug, Clone, Serialize, Deserialize)]
1268#[serde(rename_all = "camelCase")]
1269pub struct ContinueToParams {
1270    /// Target slot to stop in (or at, if `batch_index` is `None`).
1271    pub slot: u64,
1272    /// Batch within the target slot at which to pause, **exclusive** — the
1273    /// session halts immediately *before* batch `n` executes, so no
1274    /// transaction in that batch has been applied yet. `None` runs the
1275    /// whole slot, pausing at the block boundary. While paused, RPC reads
1276    /// observe partial state up through batch `n - 1`.
1277    #[serde(default)]
1278    pub batch_index: Option<u32>,
1279}
1280
1281/// Supported binary encodings for account/transaction payloads.
1282#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1283#[serde(rename_all = "lowercase")]
1284pub enum BinaryEncoding {
1285    Base64,
1286}
1287
1288impl BinaryEncoding {
1289    pub fn encode(self, bytes: &[u8]) -> String {
1290        match self {
1291            Self::Base64 => BASE64.encode(bytes),
1292        }
1293    }
1294
1295    pub fn decode(self, data: &str) -> Result<Vec<u8>, Base64DecodeError> {
1296        match self {
1297            Self::Base64 => BASE64.decode(data),
1298        }
1299    }
1300}
1301
1302/// A blob paired with the encoding needed to decode it.
1303#[derive(Debug, Clone, Serialize, Deserialize)]
1304#[serde(rename_all = "camelCase")]
1305pub struct EncodedBinary {
1306    /// Encoded payload.
1307    pub data: String,
1308    /// Encoding scheme used for the payload.
1309    pub encoding: BinaryEncoding,
1310}
1311
1312impl EncodedBinary {
1313    pub fn new(data: String, encoding: BinaryEncoding) -> Self {
1314        Self { data, encoding }
1315    }
1316
1317    pub fn from_bytes(bytes: &[u8], encoding: BinaryEncoding) -> Self {
1318        Self {
1319            data: encoding.encode(bytes),
1320            encoding,
1321        }
1322    }
1323
1324    pub fn decode(&self) -> Result<Vec<u8>, Base64DecodeError> {
1325        self.encoding.decode(&self.data)
1326    }
1327}
1328
1329/// Account snapshot used to seed or modify state in a session.
1330#[serde_with::serde_as]
1331#[derive(Debug, Clone, Serialize, Deserialize)]
1332#[serde(rename_all = "camelCase")]
1333pub struct AccountData {
1334    /// Account data bytes and encoding.
1335    pub data: EncodedBinary,
1336    /// Whether the account is executable.
1337    pub executable: bool,
1338    /// Lamport balance.
1339    pub lamports: u64,
1340    #[serde_as(as = "serde_with::DisplayFromStr")]
1341    /// Account owner pubkey.
1342    pub owner: Address,
1343    /// Allocated space in bytes.
1344    pub space: u64,
1345}
1346
1347impl AccountData {
1348    pub fn to_account(&self) -> Result<solana_account::Account, Base64DecodeError> {
1349        Ok(solana_account::Account {
1350            data: self.data.decode()?,
1351            lamports: self.lamports,
1352            owner: self.owner,
1353            executable: self.executable,
1354            rent_epoch: 0,
1355        })
1356    }
1357}
1358
1359/// Responses returned over the backtest RPC channel.
1360#[derive(Debug, Clone, Serialize, Deserialize)]
1361#[serde(tag = "method", content = "params", rename_all = "camelCase")]
1362pub enum BacktestResponse {
1363    SessionCreated {
1364        session_id: SessionId,
1365        rpc_endpoint: String,
1366        #[serde(default, skip_serializing_if = "Option::is_none")]
1367        task_id: Option<String>,
1368    },
1369    SessionAttached {
1370        session_id: SessionId,
1371        rpc_endpoint: String,
1372        #[serde(default, skip_serializing_if = "Option::is_none")]
1373        task_id: Option<String>,
1374    },
1375    SessionsCreatedV2 {
1376        control_session_id: ControlSessionId,
1377        session_ids: Vec<SessionId>,
1378        #[serde(default)]
1379        task_ids: Vec<Option<String>>,
1380        /// Per-sub-session start/end slots, parallel to `session_ids`. The
1381        /// server's split is authoritative (a mid-bundle request anchors to the
1382        /// covering bundle), so the client binds each sub-session to its range
1383        /// from here rather than re-deriving the split locally.
1384        #[serde(default)]
1385        start_slots: Vec<u64>,
1386        #[serde(default)]
1387        end_slots: Vec<u64>,
1388    },
1389    ParallelSessionAttachedV2 {
1390        control_session_id: ControlSessionId,
1391        session_ids: Vec<SessionId>,
1392        #[serde(default)]
1393        task_ids: Vec<Option<String>>,
1394    },
1395    ReadyForContinue,
1396    SlotNotification(u64),
1397    Paused(PausedEvent),
1398    DiscoveryBatch(DiscoveryBatchEvent),
1399    Error(BacktestError),
1400    Success,
1401    Completed {
1402        /// Session summary with transaction statistics.
1403        /// The session always computes this summary, but management may omit it from
1404        /// client-facing responses unless `send_summary` was requested at session creation.
1405        #[serde(skip_serializing_if = "Option::is_none")]
1406        summary: Option<SessionSummary>,
1407    },
1408    Status {
1409        status: BacktestStatus,
1410    },
1411    SessionEventV2 {
1412        session_id: SessionId,
1413        seq_id: u64,
1414        event: SessionEventKind,
1415    },
1416}
1417
1418impl BacktestResponse {
1419    pub fn is_completed(&self) -> bool {
1420        matches!(self, BacktestResponse::Completed { .. })
1421    }
1422
1423    pub fn is_terminal(&self) -> bool {
1424        match self {
1425            BacktestResponse::Completed { .. } => true,
1426            BacktestResponse::Error(e) => e.is_terminal(),
1427            _ => false,
1428        }
1429    }
1430}
1431
1432impl From<BacktestStatus> for BacktestResponse {
1433    fn from(status: BacktestStatus) -> Self {
1434        Self::Status { status }
1435    }
1436}
1437
1438impl From<String> for BacktestResponse {
1439    fn from(message: String) -> Self {
1440        BacktestError::Internal { error: message }.into()
1441    }
1442}
1443
1444impl From<&str> for BacktestResponse {
1445    fn from(message: &str) -> Self {
1446        BacktestError::Internal {
1447            error: message.to_string(),
1448        }
1449        .into()
1450    }
1451}
1452
1453#[derive(Debug, Clone, Serialize, Deserialize)]
1454#[serde(tag = "method", content = "params", rename_all = "camelCase")]
1455pub enum SessionEventKind {
1456    ReadyForContinue,
1457    SlotNotification(u64),
1458    Paused(PausedEvent),
1459    DiscoveryBatch(DiscoveryBatchEvent),
1460    Error(BacktestError),
1461    Success,
1462    Completed {
1463        #[serde(skip_serializing_if = "Option::is_none")]
1464        summary: Option<SessionSummary>,
1465    },
1466    Status {
1467        status: BacktestStatus,
1468    },
1469}
1470
1471impl SessionEventKind {
1472    pub fn is_terminal(&self) -> bool {
1473        match self {
1474            Self::Completed { .. } => true,
1475            Self::Error(e) => e.is_terminal(),
1476            _ => false,
1477        }
1478    }
1479}
1480
1481/// Wire format wrapper for responses sent over the control websocket.
1482/// Sessions with `disconnect_timeout_secs > 0` use this to track client position.
1483#[derive(Debug, Clone, Serialize, Deserialize)]
1484#[serde(rename_all = "camelCase")]
1485pub struct SequencedResponse {
1486    pub seq_id: u64,
1487    #[serde(flatten)]
1488    pub response: BacktestResponse,
1489}
1490
1491/// High-level progress states during a `Continue` call.
1492#[derive(Debug, Clone, Serialize, Deserialize)]
1493#[serde(rename_all = "camelCase")]
1494pub enum BacktestStatus {
1495    /// Runtime startup is in progress.
1496    StartingRuntime,
1497    DecodedTransactions,
1498    AppliedAccountModifications,
1499    ReadyToExecuteUserTransactions,
1500    ExecutedUserTransactions,
1501    ExecutingBlockTransactions,
1502    ExecutedBlockTransactions,
1503    ProgramAccountsLoaded,
1504}
1505
1506impl std::fmt::Display for BacktestStatus {
1507    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1508        let s = match self {
1509            Self::StartingRuntime => "starting runtime",
1510            Self::DecodedTransactions => "decoded transactions",
1511            Self::AppliedAccountModifications => "applied account modifications",
1512            Self::ReadyToExecuteUserTransactions => "ready to execute user transactions",
1513            Self::ExecutedUserTransactions => "executed user transactions",
1514            Self::ExecutingBlockTransactions => "executing block transactions",
1515            Self::ExecutedBlockTransactions => "executed block transactions",
1516            Self::ProgramAccountsLoaded => "program accounts loaded",
1517        };
1518        f.write_str(s)
1519    }
1520}
1521
1522/// High-level funnel for a reroute-order-flow session: how many detected swaps made it from
1523/// re-quoting through simulation to a non-reverting execution, and — for a run carrying a
1524/// [`DirectFillParams`] book — how many hops it priced against a candidate venue. Per-swap
1525/// economics and latency are session-internal and land in the analysis DB.
1526#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1527#[serde(rename_all = "camelCase")]
1528pub struct RerouteStatsReport {
1529    /// Requotable swaps detected in the replayed range, across the admitted aggregators.
1530    #[serde(default)]
1531    pub swaps_detected: u64,
1532    /// Detected swaps excluded before quoting — an unadmitted aggregator, an arbitrage cycle, a mint
1533    /// pair the filter does not name. Without it the gap between `swaps_detected` and
1534    /// `swaps_rerouted` reads as routing failures.
1535    #[serde(default)]
1536    pub swaps_filtered: u64,
1537    /// Swaps re-quoted through metis and queued for simulation.
1538    #[serde(default)]
1539    pub swaps_rerouted: u64,
1540    /// Swaps whose routed transaction was simulated.
1541    #[serde(default)]
1542    pub swaps_simulated: u64,
1543    /// Simulated swaps whose routed transaction executed without reverting.
1544    #[serde(default)]
1545    pub swaps_succeeded: u64,
1546    /// Detected swaps metis could not route; nothing was simulated for them.
1547    #[serde(default)]
1548    pub requote_failures: u64,
1549    /// Computed override actions that failed and therefore contributed no state.
1550    #[serde(default)]
1551    pub override_setup_failures: u64,
1552    /// Blocks replayed unchanged because the metis client was unavailable.
1553    #[serde(default)]
1554    pub metis_unavailable_blocks: u64,
1555    /// Quote attempts that could not be made because the metis client was unavailable, summed
1556    /// over every swap left queued by each such block. Each one is retried on the next block,
1557    /// so this counts retries forced rather than swaps lost.
1558    #[serde(default)]
1559    pub quote_attempts_unavailable: u64,
1560    /// Hops whose pair the direct-fill book named. Zero when no book was configured.
1561    #[serde(default)]
1562    pub direct_fill_matched: u64,
1563    /// Of those, the ones a venue could build. Zero while no aggregator implements the builder.
1564    #[serde(default)]
1565    pub direct_fill_built: u64,
1566    /// Why the rest were not built, keyed by the venue's own rejection reason. A run with a book
1567    /// and no encoder reports every matched hop here under one key, which is what separates
1568    /// "nothing matched" from "nothing could be built".
1569    #[serde(default)]
1570    pub direct_fill_rejections: BTreeMap<String, u64>,
1571    /// Simulated probes by outcome — `filled`, `reverted`, `unattributed`, `unsimulatable`.
1572    #[serde(default)]
1573    pub direct_fill_outcomes: BTreeMap<String, u64>,
1574    /// Probes whose fill could be compared against the hop they replace.
1575    #[serde(default)]
1576    pub direct_fill_scored: u64,
1577    /// Sum of `(probe fill - historical fill) / historical fill` in basis points over
1578    /// `direct_fill_scored` probes — divide by that count for the mean. Positive means the
1579    /// candidate venue beat the one that traded.
1580    #[serde(default)]
1581    pub direct_fill_bps_total: i64,
1582}
1583
1584/// Summary of transaction execution statistics for a completed backtest session.
1585#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1586#[serde(rename_all = "camelCase")]
1587pub struct SessionSummary {
1588    /// Number of simulations where simulator outcome matched on-chain outcome
1589    /// (`true_success + true_failure`).
1590    pub correct_simulation: usize,
1591    /// Number of simulations where simulator outcome did not match on-chain outcome
1592    /// (`false_success + false_failure`).
1593    pub incorrect_simulation: usize,
1594    /// Number of transactions that had execution errors in simulation.
1595    pub execution_errors: usize,
1596    /// Number of transactions with different balance diffs.
1597    pub balance_diff: usize,
1598    /// Number of transactions with different log diffs.
1599    pub log_diff: usize,
1600    /// Transactions skipped because they could not be sanitized (malformed message or
1601    /// unresolvable address lookup table).
1602    #[serde(default)]
1603    pub unsanitizable: usize,
1604    /// Aggregate reroute metrics, populated only for reroute-order-flow sessions. Boxed to
1605    /// keep `SessionSummary` small in the many response enums that carry it.
1606    #[serde(default, skip_serializing_if = "Option::is_none")]
1607    pub reroute_stats: Option<Box<RerouteStatsReport>>,
1608}
1609
1610impl SessionSummary {
1611    /// Returns true if there were any execution deviations (errors or mismatched results).
1612    pub fn has_deviations(&self) -> bool {
1613        self.incorrect_simulation > 0 || self.execution_errors > 0 || self.balance_diff > 0
1614    }
1615
1616    /// Total number of transactions processed.
1617    pub fn total_transactions(&self) -> usize {
1618        self.correct_simulation
1619            + self.incorrect_simulation
1620            + self.execution_errors
1621            + self.balance_diff
1622            + self.log_diff
1623    }
1624
1625    /// Renders the compact "N incorrect simulations, N execution errors, ..." deviation list
1626    /// shared by console reporters. `balance_label` / `log_label` let each caller keep its own
1627    /// historical wording for the trailing two counts (e.g. "balance diffs" vs
1628    /// "different balances").
1629    pub fn deviation_details(&self, balance_label: &str, log_label: &str) -> String {
1630        format!(
1631            "{} incorrect simulations, {} execution errors, {} {balance_label}, {} {log_label}",
1632            self.incorrect_simulation, self.execution_errors, self.balance_diff, self.log_diff,
1633        )
1634    }
1635}
1636
1637impl std::fmt::Display for SessionSummary {
1638    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1639        let total = self.total_transactions();
1640        write!(
1641            f,
1642            "Session summary: {total} transactions\n\
1643             \x20  - {} correct simulation\n\
1644             \x20  - {} incorrect simulation\n\
1645             \x20  - {} execution errors\n\
1646             \x20  - {} unsanitizable\n\
1647             \x20  - {} balance diffs\n\
1648             \x20  - {} log diffs",
1649            self.correct_simulation,
1650            self.incorrect_simulation,
1651            self.execution_errors,
1652            self.unsanitizable,
1653            self.balance_diff,
1654            self.log_diff,
1655        )
1656    }
1657}
1658
1659/// Error variants surfaced to backtest RPC clients.
1660#[derive(Debug, Clone, Serialize, Deserialize)]
1661#[serde(rename_all = "camelCase")]
1662pub enum BacktestError {
1663    InvalidTransactionEncoding {
1664        index: usize,
1665        error: String,
1666    },
1667    InvalidTransactionFormat {
1668        index: usize,
1669        error: String,
1670    },
1671    InvalidAccountEncoding {
1672        address: String,
1673        encoding: BinaryEncoding,
1674        error: String,
1675    },
1676    InvalidAccountOwner {
1677        address: String,
1678        error: String,
1679    },
1680    InvalidAccountPubkey {
1681        address: String,
1682        error: String,
1683    },
1684    NoMoreBlocks,
1685    BundleNotFound {
1686        start_slot: u64,
1687        end_slot: u64,
1688    },
1689    AdvanceSlotFailed {
1690        slot: u64,
1691        error: String,
1692    },
1693    FinalizeSlotFailed {
1694        slot: u64,
1695        error: String,
1696    },
1697    InvalidRequest {
1698        error: String,
1699    },
1700    Internal {
1701        error: String,
1702    },
1703    InvalidBlockhashFormat {
1704        slot: u64,
1705        error: String,
1706    },
1707    InitializingSysvarsFailed {
1708        slot: u64,
1709        error: String,
1710    },
1711    ClerkError {
1712        error: String,
1713    },
1714    SimulationError {
1715        error: String,
1716    },
1717    SessionNotFound {
1718        session_id: String,
1719    },
1720    SessionOwnerMismatch,
1721    /// Session ownership is in transition (e.g. the previous manager is
1722    /// shutting down, or another attach raced this one). Clients should retry
1723    /// the attach within their reconnect budget; the route is expected to
1724    /// become claimable shortly.
1725    SessionOwnershipBusy {
1726        reason: String,
1727    },
1728    /// The requested range contains no produced blocks (every slot in it was
1729    /// skipped on-chain), so there is nothing to execute. A user error: the
1730    /// session cannot start and the range must be adjusted.
1731    // Appended last: the manager<->runtime hop is postcard, where variant
1732    // order is wire-significant across version skew.
1733    NoProducedBlocksInRange {
1734        start_slot: u64,
1735        end_slot: u64,
1736    },
1737}
1738
1739impl BacktestError {
1740    /// Errors the backtest cannot recover from mid-session — the single source
1741    /// of truth for terminal-error classification, shared by the response/event
1742    /// wrappers and the managed client.
1743    pub fn is_terminal(&self) -> bool {
1744        matches!(
1745            self,
1746            BacktestError::NoMoreBlocks
1747                | BacktestError::BundleNotFound { .. }
1748                | BacktestError::AdvanceSlotFailed { .. }
1749                | BacktestError::FinalizeSlotFailed { .. }
1750                | BacktestError::Internal { .. }
1751                | BacktestError::NoProducedBlocksInRange { .. }
1752        )
1753    }
1754}
1755
1756/// One contiguous block range available on the history clerk.
1757#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1758pub struct AvailableRange {
1759    pub bundle_start_slot: u64,
1760    pub bundle_start_slot_utc: Option<String>,
1761    pub max_bundle_end_slot: Option<u64>,
1762    pub max_bundle_end_slot_utc: Option<String>,
1763    pub max_bundle_size: Option<u64>,
1764    /// What the range can be used for. `None` from a server that predates the
1765    /// field, where every listed range is [`BundleKind::Blocks`].
1766    #[serde(default)]
1767    pub kind: Option<BundleKind>,
1768    /// Who the range is available to. `None` from a server that predates the
1769    /// field.
1770    #[serde(default)]
1771    pub scope: Option<RangeScope>,
1772}
1773
1774/// Who a listed range is available to.
1775#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1776#[serde(rename_all = "snake_case")]
1777pub enum RangeScope {
1778    /// Available to every caller.
1779    Global,
1780    /// Built for the calling API key, and listed only for that key.
1781    User,
1782}
1783
1784impl RangeScope {
1785    /// Stable string form matching the serde `snake_case` wire representation.
1786    pub fn as_str(self) -> &'static str {
1787        match self {
1788            Self::Global => "global",
1789            Self::User => "user",
1790        }
1791    }
1792}
1793
1794/// What a bundle build produces.
1795#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1796#[serde(rename_all = "snake_case")]
1797pub enum BundleKind {
1798    /// The block bundle a session replays transactions from.
1799    #[default]
1800    Blocks,
1801    /// An account-state bundle covering exactly the requested range, which
1802    /// later sessions replay instead of executing transactions. Builds the
1803    /// block bundle it needs first.
1804    AccountState,
1805}
1806
1807/// Why a replayed range has to begin where a recording does. Shared so the
1808/// splitter's refusal and the service's single-session refusal cannot drift
1809/// apart.
1810pub const REPLAY_RANGE_ADVICE: &str = "replayed state is resolved by the recording that begins \
1811     where the request does, so request a range that begins where one was recorded and ends \
1812     within it";
1813
1814impl BundleKind {
1815    /// What a caller is short of, named for a coverage error.
1816    fn coverage_noun(self) -> &'static str {
1817        match self {
1818            Self::Blocks => "bundle range",
1819            Self::AccountState => "account-state bundle you own",
1820        }
1821    }
1822
1823    /// [`Self::coverage_noun`] pluralised, for a message about several.
1824    fn coverage_nouns(self) -> &'static str {
1825        match self {
1826            Self::Blocks => "bundle ranges",
1827            Self::AccountState => "account-state bundles you own",
1828        }
1829    }
1830
1831    /// Stable string form matching the serde `snake_case` wire representation.
1832    pub fn as_str(self) -> &'static str {
1833        match self {
1834            Self::Blocks => "blocks",
1835            Self::AccountState => "account_state",
1836        }
1837    }
1838
1839    /// Inverse of [`Self::as_str`]; returns `None` for an unrecognized value.
1840    pub fn from_str_opt(value: &str) -> Option<Self> {
1841        match value {
1842            "blocks" => Some(Self::Blocks),
1843            "account_state" => Some(Self::AccountState),
1844            _ => None,
1845        }
1846    }
1847}
1848
1849/// Spelled for the reader, not the wire: `blocks` shows as `transactions`.
1850/// Use [`BundleKind::as_str`] where the wire spelling is required.
1851impl fmt::Display for BundleKind {
1852    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1853        let label = match self {
1854            Self::Blocks => "transactions",
1855            Self::AccountState => "account_state",
1856        };
1857        f.pad(label)
1858    }
1859}
1860
1861/// Request body of `POST /build-bundle`.
1862///
1863/// Construct via `..Default::default()` so a new field stays source-compatible.
1864#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1865pub struct BundleBuildRequest {
1866    pub start_slot: u64,
1867    pub end_slot: u64,
1868    #[serde(default)]
1869    pub bundle_size: Option<u64>,
1870    /// Optional repeat key; a resubmit dedupes the quota charge. The same key
1871    /// under a different `kind` is a distinct request.
1872    #[serde(default)]
1873    pub idempotency_key: Option<String>,
1874    /// Absent on the wire means [`BundleKind::Blocks`].
1875    #[serde(default)]
1876    pub kind: BundleKind,
1877}
1878
1879#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1880#[serde(rename_all = "snake_case")]
1881pub enum BundleBuildStatus {
1882    /// The bundle was built and is ready to use.
1883    Completed,
1884    /// The build failed and will not produce a bundle.
1885    Failed,
1886    /// The build has not finished yet; poll the status endpoint again later.
1887    InProgress,
1888    /// The outcome of the build could not be determined; do not assume success
1889    /// or failure.
1890    NeedsInvestigation,
1891}
1892
1893impl BundleBuildStatus {
1894    /// Stable string form matching the serde `snake_case` wire representation.
1895    pub fn as_str(self) -> &'static str {
1896        match self {
1897            Self::Completed => "completed",
1898            Self::Failed => "failed",
1899            Self::InProgress => "in_progress",
1900            Self::NeedsInvestigation => "needs_investigation",
1901        }
1902    }
1903}
1904
1905/// Response of the management bundle-build endpoints.
1906#[derive(Debug, Clone, Serialize, Deserialize)]
1907pub struct BundleBuildStatusResponse {
1908    pub request_id: String,
1909    pub start_slot: u64,
1910    pub end_slot: u64,
1911    pub bundle_size: Option<u64>,
1912    pub status: BundleBuildStatus,
1913    /// When the request id was created (unix ms) — lets clients show a live ETA
1914    /// countdown by computing elapsed since this.
1915    pub created_at_unix_ms: u64,
1916    /// Absent on records predating the field, which are all `blocks`.
1917    #[serde(default)]
1918    pub kind: BundleKind,
1919}
1920
1921/// A bundle the caller owns and can use now. Ordered by slot range, then kind.
1922#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1923pub struct CompletedBundle {
1924    pub start_slot: u64,
1925    pub end_slot: u64,
1926    pub kind: BundleKind,
1927    /// When the bundle became available (unix ms), where known.
1928    #[serde(default)]
1929    pub completed_at_unix_ms: Option<i64>,
1930    #[serde(default)]
1931    pub size_bytes: Option<i64>,
1932}
1933
1934/// Query of `DELETE /bundles`: the bundle to delete, by kind and exact range.
1935#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1936pub struct BundleDeleteRequest {
1937    pub start_slot: u64,
1938    pub end_slot: u64,
1939    /// Absent means [`BundleKind::Blocks`].
1940    #[serde(default)]
1941    pub kind: BundleKind,
1942}
1943
1944/// What `DELETE /bundles` removed.
1945#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1946pub struct BundleDeleteResponse {
1947    pub start_slot: u64,
1948    pub end_slot: u64,
1949    pub kind: BundleKind,
1950}
1951
1952/// Response of `GET /build-bundle`.
1953#[derive(Debug, Clone, Serialize, Deserialize)]
1954pub struct BundleBuildListResponse {
1955    /// The caller's own builds, newest first. A build belongs to the endpoint
1956    /// running it, so this covers only builds requested here.
1957    pub builds: Vec<BundleBuildStatusResponse>,
1958    /// The caller's own bundles, ascending by range, wherever they were built.
1959    /// Not a view of `builds`: one build can produce several bundles, and a
1960    /// bundle outlives the record of the build that made it. `None` when the
1961    /// listing was unavailable, as opposed to `Some([])` for owning nothing.
1962    #[serde(default)]
1963    pub bundles: Option<Vec<CompletedBundle>>,
1964}
1965
1966/// Response of the management `/snapshot-slot` endpoint. The chosen source and
1967/// import state are internal, so only the resolved slot is exposed.
1968#[derive(Debug, Clone, Serialize, Deserialize)]
1969pub struct SnapshotSlotResponse {
1970    pub snapshot_slot: u64,
1971}
1972
1973/// Split a user-requested `[start_slot, end_slot]` range across the available
1974/// bundle ranges, returning a list of contiguous, non-overlapping `(start, end)`
1975/// pairs — one per bundle that the server can serve as its own session.
1976///
1977/// Each emitted `start` is a real `bundle_start_slot` and each `end` is within
1978/// the `max_bundle_end_slot` of a bundle that begins *exactly* at that start
1979/// (the server requires `start_slot == bundle_start_slot` and caps `end_slot` at
1980/// that bundle's `max_bundle_end_slot`). Among all such gap-free splits we pick
1981/// the one with the most sub-ranges, i.e. the highest parallelism: a fine-grained
1982/// (e.g. 10k) bundle grid yields one session per fine bundle instead of
1983/// collapsing onto a coarser series that overlaps the same slots — even when the
1984/// coarse and fine bundles share a start slot. A coarser bundle is only ridden
1985/// where no finer grid continues the walk, which still lets us serve a request
1986/// the coarse data covers even across a hole in the fine grid.
1987///
1988/// `kind` picks which bundles the split rides; the walk itself is the same for
1989/// either, since a recording is read slot by slot exactly as a block bundle is.
1990/// An unlabelled range, from a server predating [`AvailableRange::kind`],
1991/// counts as [`BundleKind::Blocks`].
1992///
1993/// Returns an error if the requested start cannot anchor, or if no gap-free
1994/// split reaches `requested_end`.
1995pub fn split_range(
1996    ranges: &[AvailableRange],
1997    requested: SlotRange,
1998    kind: BundleKind,
1999) -> Result<Vec<(u64, u64)>, String> {
2000    let (requested_start, requested_end) = (requested.start(), requested.end());
2001
2002    // Every advertised end per bundle start. A start can carry several ends — a
2003    // 50k snapshot bundle and the 10k bundle built on the same snapshot share a
2004    // start slot but reach different ends — so we keep them all and let the walk
2005    // pick whichever maximises parallelism.
2006    // A one-slot bundle (`end == start`) counts: a series whose last split
2007    // begins on the requested end records exactly one slot.
2008    let ends_by_start = ranges
2009        .iter()
2010        .filter(|r| r.kind.unwrap_or_default() == kind)
2011        .filter_map(|r| {
2012            r.max_bundle_end_slot
2013                .filter(|&end| end >= r.bundle_start_slot)
2014                .map(|end| (r.bundle_start_slot, end))
2015        })
2016        .fold(
2017            BTreeMap::<u64, BTreeSet<u64>>::new(),
2018            |mut ends_by_start, (start, end)| {
2019                ends_by_start.entry(start).or_default().insert(end);
2020                ends_by_start
2021            },
2022        );
2023
2024    // Anchor a request that lands mid-bundle to the covering bundle's start —
2025    // the latest bundle beginning at or before `requested_start` that still
2026    // covers it. The server's `select_parallel_requests` shares this fn and
2027    // anchors the same way, so the client must too or it would falsely reject a
2028    // start that isn't itself a bundle boundary. An exact bundle-start request
2029    // anchors to itself. (Single sessions instead require an exact bundle start
2030    // for every kind: `evaluate_requested_range`.)
2031    let Some((&anchor_start, _)) = ends_by_start.range(..=requested_start).rfind(|(_, ends)| {
2032        ends.iter()
2033            .next_back()
2034            .is_some_and(|&end| end >= requested_start)
2035    }) else {
2036        return Err(format!(
2037            "start_slot {requested_start} is not covered by any available {}",
2038            kind.coverage_noun()
2039        ));
2040    };
2041
2042    // Walk the bundle starts from `requested_end` backwards, recording for each
2043    // start the gap-free split with the most sub-ranges (highest parallelism).
2044    // A sub-range ends within one of its start's bundles and the next sub-range
2045    // must begin on the very next slot, so a coarse bundle is ridden only when no
2046    // finer bundle continues the walk.
2047    let mut best_from: BTreeMap<u64, Vec<(u64, u64)>> = BTreeMap::new();
2048    for (&start, ends) in ends_by_start.range(anchor_start..=requested_end).rev() {
2049        let mut best: Option<Vec<(u64, u64)>> = None;
2050        for &end in ends {
2051            let candidate = if end >= requested_end {
2052                Some(vec![(start, requested_end)])
2053            } else {
2054                best_from.get(&(end + 1)).map(|rest| {
2055                    std::iter::once((start, end))
2056                        .chain(rest.iter().copied())
2057                        .collect()
2058                })
2059            };
2060            if let Some(candidate) = candidate
2061                && best.as_ref().is_none_or(|b| candidate.len() > b.len())
2062            {
2063                best = Some(candidate);
2064            }
2065        }
2066        if let Some(best) = best {
2067            best_from.insert(start, best);
2068        }
2069    }
2070
2071    best_from.remove(&anchor_start).ok_or_else(|| {
2072        // Point at the first uncovered slot when the bundles leave a hole; fall
2073        // back to a generic message when the range is covered but cannot be
2074        // tiled to bundle boundaries.
2075        let mut covered_to = anchor_start.saturating_sub(1);
2076        for (&start, ends) in ends_by_start.range(anchor_start..=requested_end) {
2077            if start > covered_to.saturating_add(1) {
2078                break;
2079            }
2080            if let Some(&end) = ends.iter().next_back() {
2081                covered_to = covered_to.max(end);
2082            }
2083        }
2084        if covered_to < requested_end {
2085            format!("gap in coverage at slot {}", covered_to + 1)
2086        } else {
2087            format!(
2088                "no gap-free split of [{requested_start}, {requested_end}] aligns with the available {}",
2089                kind.coverage_nouns()
2090            )
2091        }
2092    })
2093}
2094
2095impl From<BacktestError> for BacktestResponse {
2096    fn from(error: BacktestError) -> Self {
2097        Self::Error(error)
2098    }
2099}
2100
2101impl std::error::Error for BacktestError {}
2102
2103impl fmt::Display for BacktestError {
2104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2105        match self {
2106            BacktestError::InvalidTransactionEncoding { index, error } => {
2107                write!(f, "invalid transaction encoding at index {index}: {error}")
2108            }
2109            BacktestError::InvalidTransactionFormat { index, error } => {
2110                write!(f, "invalid transaction format at index {index}: {error}")
2111            }
2112            BacktestError::InvalidAccountEncoding {
2113                address,
2114                encoding,
2115                error,
2116            } => write!(
2117                f,
2118                "invalid encoding for account {address} ({encoding:?}): {error}"
2119            ),
2120            BacktestError::InvalidAccountOwner { address, error } => {
2121                write!(f, "invalid owner for account {address}: {error}")
2122            }
2123            BacktestError::InvalidAccountPubkey { address, error } => {
2124                write!(f, "invalid account pubkey {address}: {error}")
2125            }
2126            BacktestError::NoMoreBlocks => write!(f, "no more blocks available"),
2127            BacktestError::BundleNotFound {
2128                start_slot,
2129                end_slot,
2130            } => write!(
2131                f,
2132                "block bundle not found for start_slot={start_slot}, end_slot={end_slot}"
2133            ),
2134            BacktestError::AdvanceSlotFailed { slot, error } => {
2135                write!(f, "failed to advance to slot {slot}: {error}")
2136            }
2137            BacktestError::FinalizeSlotFailed { slot, error } => {
2138                write!(f, "failed to finalize slot {slot}: {error}")
2139            }
2140            BacktestError::InvalidRequest { error } => write!(f, "invalid request: {error}"),
2141            BacktestError::Internal { error } => write!(f, "internal error: {error}"),
2142            BacktestError::InvalidBlockhashFormat { slot, error } => {
2143                write!(f, "invalid blockhash at slot {slot}: {error}")
2144            }
2145            BacktestError::InitializingSysvarsFailed { slot, error } => {
2146                write!(f, "failed to initialize sysvars at slot {slot}: {error}")
2147            }
2148            BacktestError::ClerkError { error } => write!(f, "clerk error: {error}"),
2149            BacktestError::SimulationError { error } => {
2150                write!(f, "simulation error: {error}")
2151            }
2152            BacktestError::SessionNotFound { session_id } => {
2153                write!(f, "session not found: {session_id}")
2154            }
2155            BacktestError::SessionOwnerMismatch => {
2156                write!(f, "session owner mismatch")
2157            }
2158            BacktestError::SessionOwnershipBusy { reason } => {
2159                write!(f, "session ownership busy: {reason}")
2160            }
2161            BacktestError::NoProducedBlocksInRange {
2162                start_slot,
2163                end_slot,
2164            } => write!(
2165                f,
2166                "no produced blocks in requested range {start_slot}..={end_slot}; \
2167                 slot(s) may be skipped on-chain"
2168            ),
2169        }
2170    }
2171}
2172
2173#[cfg(test)]
2174mod tests {
2175    use rstest::rstest;
2176    use solana_account::ReadableAccount;
2177
2178    use super::*;
2179
2180    fn address(byte: u8) -> Address {
2181        Address::from([byte; 32])
2182    }
2183
2184    #[rstest]
2185    #[case::one("jupiter", Some(&[SwapAggregator::Jupiter][..]))]
2186    #[case::several("jupiter,okx", Some(&[SwapAggregator::Jupiter, SwapAggregator::Okx][..]))]
2187    #[case::spaced_and_uppercased(" Jupiter , OKX ", Some(&[SwapAggregator::Jupiter, SwapAggregator::Okx][..]))]
2188    #[case::repeats_collapse("okx,okx", Some(&[SwapAggregator::Okx][..]))]
2189    #[case::trailing_separator("okx,", Some(&[SwapAggregator::Okx][..]))]
2190    #[case::unknown("jupiter,bogus", None)]
2191    #[case::empty("", None)]
2192    #[case::separators_only(",,", None)]
2193    fn reroute_aggregators_parse_the_shapes_a_flag_carries(
2194        #[case] input: &str,
2195        #[case] expected: Option<&[SwapAggregator]>,
2196    ) {
2197        assert_eq!(
2198            input.parse::<RerouteAggregators>().ok(),
2199            expected.map(|aggregators| RerouteAggregators::new(aggregators.iter().copied()))
2200        );
2201    }
2202
2203    /// `Display` renders `--reroute-aggregators`' default, so clap re-parses whatever it prints.
2204    #[test]
2205    fn reroute_aggregators_round_trip_through_their_rendering() {
2206        let aggregators = RerouteAggregators::new([SwapAggregator::Dflow, SwapAggregator::Jupiter]);
2207
2208        assert_eq!(aggregators.to_string(), "jupiter,dflow");
2209        assert_eq!(
2210            aggregators
2211                .to_string()
2212                .parse::<RerouteAggregators>()
2213                .unwrap(),
2214            aggregators
2215        );
2216        assert_eq!(RerouteAggregators::default().to_string(), "jupiter");
2217    }
2218
2219    /// The API deserializes aggregators through the same parser the flag uses, so a spelling one
2220    /// entry point accepts is not a hard error at the other.
2221    #[test]
2222    fn aggregators_deserialize_through_the_same_parser_the_flag_uses() {
2223        let params: CreateSessionParams = serde_json::from_value(
2224            serde_json::json!({"startSlot": 1, "endSlot": 2, "reroute": {"aggregators": ["Jupiter", "OKX"]}}),
2225        )
2226        .expect("decode");
2227
2228        assert_eq!(
2229            params.reroute.expect("rerouting requested").aggregators,
2230            RerouteAggregators::new([SwapAggregator::Jupiter, SwapAggregator::Okx])
2231        );
2232    }
2233
2234    /// A request that names no knob gets the narrow defaults, not the every-aggregator behaviour a
2235    /// client written before this change expected — the switch these defaults are here to make.
2236    #[test]
2237    fn params_without_the_new_reroute_fields_decode_to_the_narrow_defaults() {
2238        let params: CreateSessionParams = serde_json::from_value(
2239            serde_json::json!({"startSlot": 1, "endSlot": 2, "reroute": {}}),
2240        )
2241        .expect("decode");
2242
2243        let reroute = params.reroute.expect("rerouting requested");
2244        assert_eq!(reroute.aggregators, RerouteAggregators::default());
2245        assert!(reroute.aggregators.contains(SwapAggregator::Jupiter));
2246        assert!(!reroute.aggregators.contains(SwapAggregator::Okx));
2247        assert!(!reroute.circular_arbs);
2248        assert!(!reroute.detect_failed_l1_swaps);
2249    }
2250
2251    /// Presence is the switch: a replay that will never quote must not reserve a metis sidecar.
2252    #[test]
2253    fn a_request_without_reroute_reserves_no_sidecar() {
2254        let params: CreateSessionParams =
2255            serde_json::from_value(serde_json::json!({"startSlot": 1, "endSlot": 2}))
2256                .expect("decode");
2257
2258        assert!(!params.requires_sidecar());
2259    }
2260
2261    /// A per-slot override schedule is the largest thing a client sends. Each
2262    /// action alone is incompressible enough to sit near the websocket message
2263    /// limit; the redundancy lives *across* actions, so the whole vector has to
2264    /// travel as one blob. Pins both the round trip and that size property.
2265    #[test]
2266    fn a_per_slot_action_schedule_compresses_across_actions() {
2267        let slots = 2_000u64;
2268        let actions = (0..slots)
2269            .map(|slot| ScheduledAction {
2270                anchor: ActionAnchor::AfterSlot { slots: vec![slot] },
2271                kind: ActionKind::Simulate,
2272                feeds_reroute: false,
2273                transactions: Vec::new(),
2274                account_overrides: AccountModifications(BTreeMap::from([(
2275                    address(7),
2276                    AccountData {
2277                        // Realistic pool account: 2 KiB that barely moves slot to slot.
2278                        data: EncodedBinary::new(
2279                            BASE64.encode(
2280                                [&slot.to_le_bytes()[..], &[0xAB; 2040][..]]
2281                                    .concat()
2282                                    .as_slice(),
2283                            ),
2284                            BinaryEncoding::Base64,
2285                        ),
2286                        executable: false,
2287                        lamports: 15_144_966,
2288                        owner: address(9),
2289                        space: 2048,
2290                    },
2291                )])),
2292                return_accounts: Vec::new(),
2293                label: None,
2294            })
2295            .collect::<Vec<_>>();
2296        let params = CreateSessionParams::builder()
2297            .start_slot(1)
2298            .end_slot(slots)
2299            .actions(actions.clone())
2300            .build();
2301
2302        let encoded = serde_json::to_string(&params).expect("serialize");
2303        let raw = serde_json::to_string(&actions).expect("serialize raw actions");
2304        assert!(
2305            encoded.len() * 8 < raw.len(),
2306            "expected the schedule to compress well; got {} bytes from {} raw",
2307            encoded.len(),
2308            raw.len()
2309        );
2310
2311        let decoded: CreateSessionParams = serde_json::from_str(&encoded).expect("round trip");
2312        assert_eq!(
2313            serde_json::to_string(&decoded.actions).expect("serialize decoded"),
2314            raw
2315        );
2316    }
2317
2318    /// Absent actions stay absent on the wire, so a session without a schedule
2319    /// is unchanged by the compression.
2320    #[test]
2321    fn an_empty_action_list_is_omitted_entirely() {
2322        let params = CreateSessionParams::builder()
2323            .start_slot(1)
2324            .end_slot(2)
2325            .build();
2326        let json = serde_json::to_value(&params).expect("serialize");
2327        assert!(json.get("actions").is_none(), "{json}");
2328        let decoded: CreateSessionParams = serde_json::from_value(json).expect("round trip");
2329        assert!(decoded.actions.is_empty());
2330    }
2331
2332    /// The conflict is `send` *and* replay: neither alone is a problem, and simulate runs
2333    /// fine under replay because it never commits.
2334    #[test]
2335    fn send_actions_conflict_with_replay_only_together() {
2336        let params = |replay, kind| {
2337            CreateSessionParams::builder()
2338                .start_slot(1)
2339                .end_slot(2)
2340                .replay_account_state(replay)
2341                .actions(vec![ScheduledAction {
2342                    anchor: ActionAnchor::BeforeSlot { slots: vec![1] },
2343                    kind,
2344                    feeds_reroute: false,
2345                    transactions: vec!["dGVzdA==".to_string()],
2346                    account_overrides: Default::default(),
2347                    return_accounts: Default::default(),
2348                    label: None,
2349                }])
2350                .build()
2351        };
2352        assert!(replay_support::send_action_conflict(&params(false, ActionKind::Send)).is_none());
2353        assert!(
2354            replay_support::send_action_conflict(&params(true, ActionKind::Simulate)).is_none()
2355        );
2356        let mut publishing = params(true, ActionKind::Simulate);
2357        publishing.actions[0].feeds_reroute = true;
2358        assert!(replay_support::send_action_conflict(&publishing).is_none());
2359
2360        let conflict = replay_support::send_action_conflict(&params(true, ActionKind::Send))
2361            .expect("send under replay conflicts");
2362        assert!(conflict.contains("kind: send"), "{conflict}");
2363        assert!(conflict.contains(replay_support::SEND_ACTION), "{conflict}");
2364    }
2365
2366    fn override_entry(owner: Address, executable: bool) -> AccountData {
2367        AccountData {
2368            data: EncodedBinary::from_bytes(&[1, 2, 3], BinaryEncoding::Base64),
2369            executable,
2370            lamports: 1,
2371            owner,
2372            space: 3,
2373        }
2374    }
2375
2376    fn before() -> ActionAnchor {
2377        ActionAnchor::BeforeSlot { slots: vec![1] }
2378    }
2379
2380    fn valid() -> ScheduledAction {
2381        override_action(before(), address(3), override_entry(address(4), false))
2382    }
2383
2384    fn override_action(
2385        anchor: ActionAnchor,
2386        account: Address,
2387        data: AccountData,
2388    ) -> ScheduledAction {
2389        ScheduledAction {
2390            anchor,
2391            kind: ActionKind::default(),
2392            transactions: Vec::new(),
2393            account_overrides: AccountModifications(BTreeMap::from([(account, data)])),
2394            return_accounts: Vec::new(),
2395            label: None,
2396            feeds_reroute: true,
2397        }
2398    }
2399
2400    #[test]
2401    fn override_action_round_trips() {
2402        let key = address(1);
2403        let entry = override_entry(address(2), false);
2404        let action = override_action(
2405            ActionAnchor::BeforeSlot { slots: vec![5, 9] },
2406            key,
2407            entry.clone(),
2408        );
2409        let json = serde_json::to_value(&action).unwrap();
2410        assert!(
2411            json["anchor"]["slots"].is_string(),
2412            "slots travel as a compressed blob: {json}"
2413        );
2414        let back: ScheduledAction = serde_json::from_value(json).unwrap();
2415        assert_eq!(back.anchor, ActionAnchor::BeforeSlot { slots: vec![5, 9] });
2416        assert!(back.feeds_reroute);
2417        assert!(!back.is_computed_override(), "no transactions to simulate");
2418        let survived = &back.account_overrides.0[&key];
2419        assert_eq!(survived.lamports, entry.lamports);
2420        assert_eq!(survived.data.data, entry.data.data);
2421    }
2422
2423    #[rstest]
2424    #[case::executable(override_action(before(), address(3), override_entry(address(10), true)))]
2425    #[case::program_owned(override_action(
2426        before(),
2427        address(3),
2428        override_entry(solana_account::PROGRAM_OWNERS[0], false),
2429    ))]
2430    #[case::token_owned(override_action(
2431        before(),
2432        address(3),
2433        override_entry(TOKEN_PROGRAM_IDS[0], false),
2434    ))]
2435    #[case::after_slot_anchor(override_action(
2436        ActionAnchor::AfterSlot { slots: vec![1] },
2437        address(3),
2438        override_entry(address(4), false),
2439    ))]
2440    #[case::nothing_to_post(ScheduledAction {
2441        account_overrides: AccountModifications::default(),
2442        ..valid()
2443    })]
2444    // Otherwise an action that meant to send would silently post overrides instead.
2445    #[case::send(ScheduledAction {
2446        kind: ActionKind::Send,
2447        ..valid()
2448    })]
2449    #[allow(deprecated, reason = "exercise the loader-owner rejection path")]
2450    fn override_actions_reject_invalid_accounts_and_shapes(#[case] action: ScheduledAction) {
2451        assert!(decode_override_actions(&[action]).is_err());
2452    }
2453
2454    /// The shapes the rejections above are variations of.
2455    #[test]
2456    fn override_actions_accept_a_static_and_a_computed_shape() {
2457        assert!(decode_override_actions(&[valid()]).is_ok());
2458        // Transactions make the contribution computed rather than static — still valid,
2459        // just not resolvable until the action runs.
2460        assert!(
2461            decode_override_actions(&[ScheduledAction {
2462                transactions: vec!["dGVzdA==".to_string()],
2463                ..valid()
2464            }])
2465            .is_ok()
2466        );
2467    }
2468
2469    #[test]
2470    fn override_actions_merge_account_wise_later_wins() {
2471        let entry = |lamports| AccountData {
2472            lamports,
2473            ..override_entry(address(9), false)
2474        };
2475        let slot_action = |slots, account, lamports| {
2476            override_action(ActionAnchor::BeforeSlot { slots }, account, entry(lamports))
2477        };
2478        let actions = [
2479            slot_action(vec![5, 7], address(1), 1),
2480            slot_action(vec![5], address(2), 2),
2481            slot_action(vec![5], address(1), 3),
2482            ScheduledAction {
2483                anchor: ActionAnchor::AfterSlot { slots: vec![5] },
2484                kind: ActionKind::Simulate,
2485                feeds_reroute: false,
2486                transactions: vec!["dGVzdA==".to_string()],
2487                account_overrides: AccountModifications::default(),
2488                return_accounts: Vec::new(),
2489                label: None,
2490            },
2491        ];
2492        let schedule = decode_override_actions(&actions).unwrap();
2493        assert_eq!(schedule.slots().collect::<Vec<_>>(), vec![5, 7]);
2494        let lamports_at = |key, slot| {
2495            schedule
2496                .active_for(&key, slot)
2497                .map(|account| account.lamports())
2498        };
2499        assert_eq!(lamports_at(address(1), 5), Some(3));
2500        assert_eq!(lamports_at(address(2), 5), Some(2));
2501        assert_eq!(lamports_at(address(1), 7), Some(1));
2502    }
2503
2504    #[test]
2505    fn computed_override_resolution_preserves_action_order_and_failure() {
2506        let key = address(1);
2507        let static_action = |lamports| {
2508            override_action(
2509                ActionAnchor::BeforeSlot { slots: vec![5] },
2510                key,
2511                AccountData {
2512                    lamports,
2513                    ..override_entry(address(9), false)
2514                },
2515            )
2516        };
2517        let computed_action = || ScheduledAction {
2518            transactions: vec!["dGVzdA==".to_string()],
2519            ..static_action(2)
2520        };
2521
2522        let computed_last =
2523            decode_override_actions(&[static_action(1), computed_action()]).unwrap();
2524        computed_last
2525            .resolve(5, 1, Some(HashMap::from([(key, shared_account(2))])))
2526            .unwrap();
2527        assert_eq!(
2528            computed_last.active_for(&key, 5).map(|a| a.lamports()),
2529            Some(2)
2530        );
2531
2532        let static_last = decode_override_actions(&[computed_action(), static_action(3)]).unwrap();
2533        static_last
2534            .resolve(5, 0, Some(HashMap::from([(key, shared_account(2))])))
2535            .unwrap();
2536        assert_eq!(
2537            static_last.active_for(&key, 5).map(|a| a.lamports()),
2538            Some(3)
2539        );
2540
2541        let failed = decode_override_actions(&[static_action(1), computed_action()]).unwrap();
2542        failed.resolve(5, 1, None).unwrap();
2543        assert_eq!(failed.active_for(&key, 5).map(|a| a.lamports()), Some(1));
2544        assert!(failed.resolve(5, 1, None).is_err(), "resolving twice");
2545    }
2546
2547    fn shared_account(lamports: u64) -> AccountSharedData {
2548        AccountSharedData::from(solana_account::Account {
2549            lamports,
2550            data: vec![1],
2551            owner: address(9),
2552            executable: false,
2553            rent_epoch: 0,
2554        })
2555    }
2556
2557    fn carry_forward_schedule() -> (Address, Address, OverrideSchedule) {
2558        let (a, b) = (address(1), address(2));
2559        let schedule = OverrideSchedule::from(BTreeMap::from([
2560            (10, HashMap::from([(a, shared_account(1))])),
2561            (
2562                20,
2563                HashMap::from([(a, shared_account(2)), (b, shared_account(9))]),
2564            ),
2565        ]));
2566        (a, b, schedule)
2567    }
2568
2569    #[test]
2570    fn override_schedule_resolves_carry_forward_per_account() {
2571        let (a, b, schedule) = carry_forward_schedule();
2572
2573        assert!(schedule.active_for(&a, 9).is_none());
2574        let cases = [
2575            (9, None, None),
2576            (10, Some(1), None),
2577            (15, Some(1), None),
2578            (20, Some(2), Some(9)),
2579            (u64::MAX, Some(2), Some(9)),
2580        ];
2581        for (slot, want_a, want_b) in cases {
2582            assert_eq!(
2583                schedule
2584                    .active_for(&a, slot)
2585                    .map(|account| account.lamports()),
2586                want_a,
2587                "active_for a at {slot}"
2588            );
2589            let active = schedule.active_at(slot);
2590            assert_eq!(
2591                active.get(&a).map(ReadableAccount::lamports),
2592                want_a,
2593                "active_at a at {slot}"
2594            );
2595            assert_eq!(
2596                active.get(&b).map(ReadableAccount::lamports),
2597                want_b,
2598                "active_at b at {slot}"
2599            );
2600        }
2601    }
2602
2603    #[test]
2604    fn newly_active_yields_final_values_in_window() {
2605        let (a, b, schedule) = carry_forward_schedule();
2606
2607        let newly: HashMap<_, _> = schedule.newly_active(Some(10), 20).into_iter().collect();
2608        assert_eq!(newly.len(), 2);
2609        assert_eq!(newly[&a].lamports(), 2);
2610        assert_eq!(newly[&b].lamports(), 9);
2611        let spanning: HashMap<_, _> = schedule.newly_active(None, 20).into_iter().collect();
2612        assert_eq!(spanning[&a].lamports(), 2, "later slot wins");
2613        assert_eq!(schedule.newly_active(None, 15).len(), 1);
2614    }
2615
2616    #[test]
2617    fn reroute_filter_round_trips_camel_case() {
2618        let filter = RerouteFilter {
2619            pairs: BTreeSet::from([MintPair::new(address(1), address(2))]),
2620        };
2621        let json = serde_json::to_value(&filter).unwrap();
2622        assert!(
2623            json["pairs"][0][0].is_string() && json["pairs"][0][1].is_string(),
2624            "a pair serializes as two base58 strings: {json}"
2625        );
2626        let back: RerouteFilter = serde_json::from_value(json).unwrap();
2627        assert_eq!(back, filter);
2628    }
2629
2630    /// The set lookup is only direction-agnostic because construction normalizes, and
2631    /// values arriving over the wire have to be normalized on that path too.
2632    #[test]
2633    fn mint_pairs_are_normalized_however_they_are_built() {
2634        let (one, other) = (address(1), address(2));
2635        assert_eq!(MintPair::new(one, other), MintPair::new(other, one));
2636
2637        let reversed: MintPair =
2638            serde_json::from_value(serde_json::json!([other.to_string(), one.to_string()]))
2639                .unwrap();
2640        assert_eq!(reversed, MintPair::new(one, other));
2641    }
2642
2643    #[test]
2644    fn fail_fast_divergence_kind_str_round_trips() {
2645        for kind in [
2646            FailFastDivergenceKind::AnyNonBenign,
2647            FailFastDivergenceKind::Tracked,
2648        ] {
2649            assert_eq!(
2650                FailFastDivergenceKind::from_str_opt(kind.as_str()),
2651                Some(kind)
2652            );
2653        }
2654        assert_eq!(FailFastDivergenceKind::from_str_opt("nonsense"), None);
2655        assert_eq!(
2656            FailFastDivergenceKind::default(),
2657            FailFastDivergenceKind::AnyNonBenign
2658        );
2659    }
2660
2661    #[test]
2662    fn bundle_not_found_is_terminal_and_descriptive() {
2663        let error = BacktestError::BundleNotFound {
2664            start_slot: 100,
2665            end_slot: 600,
2666        };
2667
2668        assert!(error.is_terminal());
2669        assert_eq!(
2670            error.to_string(),
2671            "block bundle not found for start_slot=100, end_slot=600"
2672        );
2673    }
2674
2675    #[test]
2676    fn no_produced_blocks_is_terminal_and_descriptive() {
2677        let error = BacktestError::NoProducedBlocksInRange {
2678            start_slot: 100,
2679            end_slot: 100,
2680        };
2681
2682        assert!(error.is_terminal());
2683        // placement-e2e matches this phrase to classify the failure as
2684        // transient; keep it stable.
2685        assert_eq!(
2686            error.to_string(),
2687            "no produced blocks in requested range 100..=100; slot(s) may be skipped on-chain"
2688        );
2689    }
2690
2691    #[test]
2692    fn bundle_build_request_optional_fields_default_to_none() {
2693        let req: BundleBuildRequest =
2694            serde_json::from_str(r#"{"start_slot":1,"end_slot":2}"#).expect("parse");
2695        assert_eq!((req.start_slot, req.end_slot), (1, 2));
2696        assert_eq!(req.bundle_size, None);
2697        assert_eq!(req.idempotency_key, None);
2698    }
2699
2700    #[test]
2701    fn bundle_build_request_parses_optional_fields() {
2702        let req: BundleBuildRequest = serde_json::from_str(
2703            r#"{"start_slot":1,"end_slot":2,"bundle_size":500,"idempotency_key":"abc"}"#,
2704        )
2705        .expect("parse");
2706        assert_eq!(req.bundle_size, Some(500));
2707        assert_eq!(req.idempotency_key.as_deref(), Some("abc"));
2708    }
2709
2710    #[test]
2711    fn bundle_build_status_serde_round_trips_with_snake_case() {
2712        let cases = [
2713            (BundleBuildStatus::Completed, "\"completed\""),
2714            (BundleBuildStatus::Failed, "\"failed\""),
2715            (BundleBuildStatus::InProgress, "\"in_progress\""),
2716            (
2717                BundleBuildStatus::NeedsInvestigation,
2718                "\"needs_investigation\"",
2719            ),
2720        ];
2721        for (status, expected) in cases {
2722            assert_eq!(serde_json::to_string(&status).unwrap(), expected);
2723            assert_eq!(
2724                serde_json::from_str::<BundleBuildStatus>(expected).unwrap(),
2725                status
2726            );
2727            assert_eq!(status.as_str(), expected.trim_matches('"'));
2728        }
2729        assert!(serde_json::from_str::<BundleBuildStatus>("\"queued\"").is_err());
2730    }
2731
2732    #[test]
2733    fn bundle_build_request_without_kind_deserializes_as_blocks() {
2734        // An old client's payload must keep meaning a plain block build.
2735        let request: BundleBuildRequest =
2736            serde_json::from_str(r#"{"start_slot":100,"end_slot":200}"#).unwrap();
2737        assert_eq!(request.kind, BundleKind::Blocks);
2738    }
2739
2740    #[test]
2741    fn bundle_kind_round_trips_over_serde_and_as_str() {
2742        for kind in [BundleKind::Blocks, BundleKind::AccountState] {
2743            let json = serde_json::to_string(&kind).unwrap();
2744            assert_eq!(json, format!("\"{}\"", kind.as_str()));
2745            assert_eq!(serde_json::from_str::<BundleKind>(&json).unwrap(), kind);
2746            // The store round-trips through `as_str`, not serde, so the two
2747            // maps have to agree for a stored kind to read back.
2748            assert_eq!(BundleKind::from_str_opt(kind.as_str()), Some(kind));
2749        }
2750        assert!(serde_json::from_str::<BundleKind>("\"accountState\"").is_err());
2751        assert_eq!(BundleKind::from_str_opt("accountState"), None);
2752    }
2753
2754    #[test]
2755    fn bundle_build_request_carries_kind_through_serde() {
2756        let request = BundleBuildRequest {
2757            start_slot: 100,
2758            end_slot: 200,
2759            kind: BundleKind::AccountState,
2760            ..Default::default()
2761        };
2762        let json = serde_json::to_string(&request).unwrap();
2763        assert!(json.contains(r#""kind":"account_state""#), "{json}");
2764        let parsed: BundleBuildRequest = serde_json::from_str(&json).unwrap();
2765        assert_eq!(parsed.kind, BundleKind::AccountState);
2766    }
2767
2768    #[test]
2769    fn bundle_build_status_response_serializes_request_and_status() {
2770        let response = BundleBuildStatusResponse {
2771            request_id: "r".to_string(),
2772            start_slot: 100,
2773            end_slot: 200,
2774            bundle_size: Some(50),
2775            status: BundleBuildStatus::InProgress,
2776            created_at_unix_ms: 0,
2777            kind: BundleKind::Blocks,
2778        };
2779        let json = serde_json::to_value(&response).unwrap();
2780        assert_eq!(json["request_id"].as_str(), Some("r"));
2781        assert_eq!(json["start_slot"].as_u64(), Some(100));
2782        assert_eq!(json["bundle_size"].as_u64(), Some(50));
2783        assert_eq!(json["status"].as_str(), Some("in_progress"));
2784        assert_eq!(json["created_at_unix_ms"].as_u64(), Some(0));
2785    }
2786
2787    #[test]
2788    fn an_empty_bundles_list_is_distinct_from_an_absent_one() {
2789        // A server that predates the section sends no `bundles` at all.
2790        let absent: BundleBuildListResponse = serde_json::from_str(r#"{"builds":[]}"#).unwrap();
2791        let empty: BundleBuildListResponse =
2792            serde_json::from_str(r#"{"builds":[],"bundles":[]}"#).unwrap();
2793        assert_eq!(absent.bundles, None);
2794        assert_eq!(empty.bundles, Some(Vec::new()));
2795    }
2796
2797    #[test]
2798    fn completed_bundle_round_trips() {
2799        let bundle = CompletedBundle {
2800            start_slot: 100,
2801            end_slot: 200,
2802            kind: BundleKind::AccountState,
2803            completed_at_unix_ms: Some(1_700_000_000_000),
2804            size_bytes: Some(4_096),
2805        };
2806        let json = serde_json::to_string(&bundle).unwrap();
2807        assert!(json.contains(r#""kind":"account_state""#), "{json}");
2808        assert_eq!(
2809            serde_json::from_str::<CompletedBundle>(&json).unwrap(),
2810            bundle
2811        );
2812
2813        let sparse = r#"{"start_slot":1,"end_slot":2,"kind":"blocks"}"#;
2814        let parsed: CompletedBundle = serde_json::from_str(sparse).unwrap();
2815        assert_eq!(parsed.completed_at_unix_ms, None);
2816        assert_eq!(parsed.size_bytes, None);
2817    }
2818
2819    #[test]
2820    fn available_range_reads_a_server_that_predates_kind_and_scope() {
2821        // Deploys are not atomic: a new client must not fail on the old shape.
2822        let json = r#"{
2823            "bundle_start_slot": 100,
2824            "bundle_start_slot_utc": null,
2825            "max_bundle_end_slot": 200,
2826            "max_bundle_end_slot_utc": null,
2827            "max_bundle_size": 101
2828        }"#;
2829        let parsed: AvailableRange = serde_json::from_str(json).unwrap();
2830        assert_eq!(parsed.bundle_start_slot, 100);
2831        assert_eq!(parsed.kind, None);
2832        assert_eq!(parsed.scope, None);
2833    }
2834
2835    #[test]
2836    fn available_range_ignores_fields_it_does_not_know() {
2837        // The other direction: an old client against a newer server.
2838        let json = r#"{"bundle_start_slot": 1, "some_future_field": "x"}"#;
2839        assert_eq!(
2840            serde_json::from_str::<AvailableRange>(json)
2841                .unwrap()
2842                .bundle_start_slot,
2843            1
2844        );
2845    }
2846
2847    #[test]
2848    fn available_range_labels_round_trip() {
2849        let range = AvailableRange {
2850            bundle_start_slot: 100,
2851            max_bundle_end_slot: Some(200),
2852            kind: Some(BundleKind::AccountState),
2853            scope: Some(RangeScope::User),
2854            ..Default::default()
2855        };
2856        let json = serde_json::to_string(&range).unwrap();
2857        assert!(json.contains(r#""kind":"account_state""#), "{json}");
2858        assert!(json.contains(r#""scope":"user""#), "{json}");
2859        let parsed: AvailableRange = serde_json::from_str(&json).unwrap();
2860        assert_eq!(parsed.kind, Some(BundleKind::AccountState));
2861        assert_eq!(parsed.scope, Some(RangeScope::User));
2862    }
2863
2864    fn range(start: u64, end: u64) -> AvailableRange {
2865        AvailableRange {
2866            bundle_start_slot: start,
2867            max_bundle_end_slot: Some(end),
2868            ..Default::default()
2869        }
2870    }
2871
2872    /// Each case lists the available bundles, the requested `[start, end]`, and
2873    /// the expected split — `Some(_)` for an accepted plan, `None` when the range
2874    /// is unservable and `split_range` must error.
2875    #[rstest::rstest]
2876    #[case::single(vec![range(100, 300)], 100, 300, Some(vec![(100, 300)]))]
2877    #[case::multi(
2878        vec![range(100, 200), range(201, 300), range(301, 400)],
2879        100, 300, Some(vec![(100, 200), (201, 300)])
2880    )]
2881    // Smaller bundles nested inside a larger one don't bridge the gap, but the
2882    // range is still coverable by riding the coarse series.
2883    #[case::nested(
2884        vec![range(100, 500), range(110, 150), range(150, 190), range(501, 900)],
2885        100, 900, Some(vec![(100, 500), (501, 900)])
2886    )]
2887    // A fine grid (1k bundles standing in for the 10k grid) overlapped by a
2888    // coarser, differently-aligned series: ride the fine grid, never landing on
2889    // the coarse bundle's drifted start.
2890    #[case::prefers_finer_grid(
2891        vec![range(1_000, 1_999), range(1_500, 3_400), range(2_000, 2_999), range(3_000, 3_999)],
2892        1_000, 3_999, Some(vec![(1_000, 1_999), (2_000, 2_999), (3_000, 3_999)])
2893    )]
2894    // A 50k snapshot bundle and the 10k bundles built on it share start 100: ride
2895    // the 10k grid instead of the coarse end, which would strand the cursor on a
2896    // slot no bundle starts at.
2897    #[case::shared_start_prefers_finer(
2898        vec![range(100, 150), range(100, 120), range(121, 140), range(141, 160)],
2899        100, 160, Some(vec![(100, 120), (121, 140), (141, 160)])
2900    )]
2901    // A coarse bundle (100, 200) overlaps a finer pair covering the same span:
2902    // split_range maximises parallelism, riding the fine pair (two sessions)
2903    // rather than merging onto the coarse max end (one session).
2904    #[case::coarse_overlap_prefers_finer_pair(
2905        vec![range(100, 200), range(100, 150), range(151, 160)],
2906        100, 160, Some(vec![(100, 150), (151, 160)])
2907    )]
2908    // The fine grid has a hole (nothing starts at 141), but a coarse bundle
2909    // sharing the start spans it: serve the request off the coarse bundle rather
2910    // than reporting an unsupported range.
2911    #[case::falls_back_to_coarse(
2912        vec![range(100, 160), range(100, 120), range(121, 140)],
2913        100, 160, Some(vec![(100, 160)])
2914    )]
2915    // The last bundle overshoots the requested end and is clamped.
2916    #[case::clamps_final_bundle(vec![range(100, 199), range(200, 999)], 100, 450, Some(vec![(100, 199), (200, 450)]))]
2917    // A request landing mid-bundle anchors to the covering bundle's start
2918    // (matching the server) instead of erroring as "not covered".
2919    #[case::anchors_mid_bundle(vec![range(150, 350)], 200, 300, Some(vec![(150, 300)]))]
2920    #[case::anchors_then_continues(
2921        vec![range(150, 350), range(351, 600)],
2922        200, 600, Some(vec![(150, 350), (351, 600)])
2923    )]
2924    #[case::start_inside_bundle_anchors(vec![range(200, 400)], 300, 400, Some(vec![(200, 400)]))]
2925    // Errors: a start before any bundle's coverage has no covering bundle...
2926    #[case::start_before_first_bundle(vec![range(200, 400)], 100, 400, None)]
2927    // ...the end must be reachable...
2928    #[case::end_not_covered(vec![range(100, 200)], 100, 300, None)]
2929    #[case::gap_in_coverage(vec![range(100, 200), range(210, 300)], 100, 300, None)]
2930    // Per-API-key isolation: a user's finer bundles and a coarse GLOBAL bundle
2931    // sharing a start are fed as SEPARATE entries (never merged on snapshot_slot,
2932    // which would keep only the max end). split_range set-unions the ends and
2933    // rides the finer user grid — a regression guard against re-merging.
2934    #[case::user_and_global_ranges_not_collapsed(
2935        vec![range(100, 200), range(100, 150), range(151, 200)],
2936        100, 200, Some(vec![(100, 150), (151, 200)])
2937    )]
2938    fn split_range_cases(
2939        #[case] ranges: Vec<AvailableRange>,
2940        #[case] start: u64,
2941        #[case] end: u64,
2942        #[case] expected: Option<Vec<(u64, u64)>>,
2943    ) {
2944        match expected {
2945            Some(expected) => assert_eq!(
2946                split_range(
2947                    &ranges,
2948                    SlotRange::new_unchecked(start, end),
2949                    BundleKind::Blocks
2950                )
2951                .unwrap(),
2952                expected
2953            ),
2954            None => assert!(
2955                split_range(
2956                    &ranges,
2957                    SlotRange::new_unchecked(start, end),
2958                    BundleKind::Blocks
2959                )
2960                .is_err()
2961            ),
2962        }
2963    }
2964
2965    fn account_state_range(start: u64, end: u64) -> AvailableRange {
2966        AvailableRange {
2967            kind: Some(BundleKind::AccountState),
2968            ..range(start, end)
2969        }
2970    }
2971
2972    #[test]
2973    fn a_replay_split_rides_the_recordings_not_a_finer_block_grid() {
2974        // The finer block grid wins on piece count, which is precisely what would
2975        // hand a replay run sub-ranges nothing ever recorded.
2976        let ranges = vec![
2977            range(100, 149),
2978            range(150, 199),
2979            account_state_range(100, 199),
2980        ];
2981        let requested = SlotRange::new_unchecked(100, 199);
2982
2983        assert_eq!(
2984            split_range(&ranges, requested, BundleKind::Blocks).unwrap(),
2985            [(100, 149), (150, 199)]
2986        );
2987        assert_eq!(
2988            split_range(&ranges, requested, BundleKind::AccountState).unwrap(),
2989            [(100, 199)]
2990        );
2991    }
2992
2993    #[test]
2994    fn a_replay_split_tiles_consecutive_recordings() {
2995        let ranges = vec![account_state_range(100, 199), account_state_range(200, 299)];
2996        assert_eq!(
2997            split_range(
2998                &ranges,
2999                SlotRange::new_unchecked(100, 299),
3000                BundleKind::AccountState
3001            )
3002            .unwrap(),
3003            [(100, 199), (200, 299)]
3004        );
3005    }
3006
3007    #[test]
3008    fn a_replay_split_ends_short_of_a_recording() {
3009        // A recording is read slot by slot, so a session stops wherever asked
3010        // inside it — the same as a block session does inside its bundle.
3011        let requested = SlotRange::new_unchecked(100, 150);
3012        assert_eq!(
3013            split_range(&[range(100, 199)], requested, BundleKind::Blocks).unwrap(),
3014            [(100, 150)]
3015        );
3016        assert_eq!(
3017            split_range(
3018                &[account_state_range(100, 199)],
3019                requested,
3020                BundleKind::AccountState
3021            )
3022            .unwrap(),
3023            [(100, 150)]
3024        );
3025    }
3026
3027    #[test]
3028    fn a_replay_split_ends_short_of_its_last_recording() {
3029        // Only the last sub-range may stop mid-recording; the ones before it
3030        // still have to hand over on the very next slot.
3031        let ranges = vec![account_state_range(100, 199), account_state_range(200, 299)];
3032        assert_eq!(
3033            split_range(
3034                &ranges,
3035                SlotRange::new_unchecked(100, 250),
3036                BundleKind::AccountState
3037            )
3038            .unwrap(),
3039            [(100, 199), (200, 250)]
3040        );
3041    }
3042
3043    #[test]
3044    fn a_replay_split_tiles_a_one_slot_recording() {
3045        // A series whose last split begins on the requested end records exactly
3046        // one slot.
3047        let ranges = vec![account_state_range(100, 249), account_state_range(250, 250)];
3048        assert_eq!(
3049            split_range(
3050                &ranges,
3051                SlotRange::new_unchecked(100, 250),
3052                BundleKind::AccountState
3053            )
3054            .unwrap(),
3055            [(100, 249), (250, 250)]
3056        );
3057    }
3058
3059    #[test]
3060    fn a_replay_split_anchors_a_mid_recording_start() {
3061        // A mid-bundle start anchors to the bundle covering it, whichever kind
3062        // the walk rides — the two differ in the bundles they read, not in the
3063        // ranges they admit.
3064        let requested = SlotRange::new_unchecked(150, 199);
3065        assert_eq!(
3066            split_range(&[range(100, 199)], requested, BundleKind::Blocks).unwrap(),
3067            [(100, 199)]
3068        );
3069        assert_eq!(
3070            split_range(
3071                &[account_state_range(100, 199)],
3072                requested,
3073                BundleKind::AccountState
3074            )
3075            .unwrap(),
3076            [(100, 199)]
3077        );
3078    }
3079
3080    #[test]
3081    fn a_replay_split_refuses_a_start_no_recording_covers() {
3082        let error = split_range(
3083            &[account_state_range(200, 299)],
3084            SlotRange::new_unchecked(100, 299),
3085            BundleKind::AccountState,
3086        )
3087        .expect_err("no recording covers 100");
3088        assert!(
3089            error.contains("is not covered by any available account-state bundle"),
3090            "{error}"
3091        );
3092    }
3093
3094    #[test]
3095    fn a_replay_split_ignores_block_ranges() {
3096        let error = split_range(
3097            &[range(100, 199)],
3098            SlotRange::new_unchecked(100, 199),
3099            BundleKind::AccountState,
3100        )
3101        .expect_err("owns no recordings");
3102        assert!(error.contains("account-state bundle"), "{error}");
3103    }
3104
3105    #[test]
3106    fn blocks_mode_admits_ranges_from_a_server_that_predates_kind() {
3107        // A server predating the field omits it, and those ranges must still
3108        // split exactly as they did before the kind was consulted.
3109        let unlabelled = range(100, 199);
3110        assert_eq!(unlabelled.kind, None);
3111        assert_eq!(
3112            split_range(
3113                &[unlabelled],
3114                SlotRange::new_unchecked(100, 150),
3115                BundleKind::Blocks
3116            )
3117            .unwrap(),
3118            [(100, 150)]
3119        );
3120    }
3121
3122    /// Servable end per bundle start, as `split_range` sees it — every advertised
3123    /// end at or past its start, keyed by start.
3124    fn ends_by_start(ranges: &[AvailableRange]) -> BTreeMap<u64, BTreeSet<u64>> {
3125        let mut ends: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
3126        for r in ranges {
3127            if let Some(end) = r.max_bundle_end_slot
3128                && end >= r.bundle_start_slot
3129            {
3130                ends.entry(r.bundle_start_slot).or_default().insert(end);
3131            }
3132        }
3133        ends
3134    }
3135
3136    /// Independent spec for the optimum: the gap-free split of `[start, end]` with
3137    /// the most sub-ranges, found by exhaustively trying every advertised end at
3138    /// each cursor. `None` when no split aligns with the bundle starts.
3139    fn reference_max_split(
3140        ends: &BTreeMap<u64, BTreeSet<u64>>,
3141        cursor: u64,
3142        end: u64,
3143    ) -> Option<Vec<(u64, u64)>> {
3144        ends.get(&cursor)?
3145            .iter()
3146            .filter_map(|&bundle_end| {
3147                if bundle_end >= end {
3148                    Some(vec![(cursor, end)])
3149                } else {
3150                    reference_max_split(ends, bundle_end + 1, end).map(|mut rest| {
3151                        rest.insert(0, (cursor, bundle_end));
3152                        rest
3153                    })
3154                }
3155            })
3156            .max_by_key(Vec::len)
3157    }
3158
3159    /// Structure-independent check that a split is one the server can serve: it
3160    /// tiles `[start, end]` gap-free, every start is a real bundle start, and
3161    /// every end is within that start's largest advertised end.
3162    fn is_valid_split(
3163        split: &[(u64, u64)],
3164        ends: &BTreeMap<u64, BTreeSet<u64>>,
3165        start: u64,
3166        end: u64,
3167    ) -> bool {
3168        split.first().is_some_and(|&(s, _)| s == start)
3169            && split.last().is_some_and(|&(_, e)| e == end)
3170            && split.windows(2).all(|w| w[1].0 == w[0].1 + 1)
3171            && split.iter().all(|&(s, e)| {
3172                e >= s
3173                    && ends
3174                        .get(&s)
3175                        .and_then(|bundle_ends| bundle_ends.iter().next_back())
3176                        .is_some_and(|&max_end| e <= max_end)
3177            })
3178    }
3179
3180    /// Property test: across many randomized bundle layouts, `split_range` must
3181    /// return a *valid* split with the *maximum* number of sub-ranges whenever one
3182    /// exists, and error exactly when none does. Deterministic (fixed LCG seed) so
3183    /// a failure is reproducible.
3184    #[test]
3185    fn split_range_matches_reference() {
3186        let mut seed: u64 = 0x9E3779B97F4A7C15;
3187        let mut next = || {
3188            seed = seed
3189                .wrapping_mul(6364136223846793005)
3190                .wrapping_add(1442695040888963407);
3191            seed >> 33
3192        };
3193
3194        for _ in 0..50_000 {
3195            // A small slot universe makes shared starts, overlaps, nesting, and
3196            // gaps all common.
3197            let ranges: Vec<AvailableRange> = (0..next() % 6)
3198                .map(|_| {
3199                    let start = next() % 12;
3200                    range(start, start + next() % 6) // len 0 => filtered out
3201                })
3202                .collect();
3203            let start = next() % 12;
3204            let end = start + next() % 6; // sometimes start == end, sometimes unservable
3205
3206            let got = split_range(
3207                &ranges,
3208                SlotRange::new_unchecked(start, end),
3209                BundleKind::Blocks,
3210            );
3211            let ends = ends_by_start(&ranges);
3212            // split_range anchors a mid-bundle start to the covering bundle's
3213            // start, so the reference must walk from the same anchor.
3214            let anchor = ends
3215                .range(..=start)
3216                .rfind(|(_, e)| e.iter().next_back().is_some_and(|&x| x >= start))
3217                .map(|(&s, _)| s);
3218            let reference = anchor.and_then(|a| reference_max_split(&ends, a, end));
3219
3220            let layout: Vec<_> = ranges
3221                .iter()
3222                .map(|r| (r.bundle_start_slot, r.max_bundle_end_slot))
3223                .collect();
3224            match (&got, &reference) {
3225                (Ok(split), Some(best)) => {
3226                    assert!(
3227                        is_valid_split(split, &ends, anchor.unwrap(), end),
3228                        "invalid split {split:?} for {layout:?} [{start},{end}]"
3229                    );
3230                    assert_eq!(
3231                        split.len(),
3232                        best.len(),
3233                        "suboptimal split {split:?} vs {best:?} for {layout:?} [{start},{end}]"
3234                    );
3235                }
3236                (Err(_), None) => {}
3237                _ => panic!(
3238                    "disagreement: split_range={got:?}, reference={reference:?} for {layout:?} [{start},{end}]"
3239                ),
3240            }
3241        }
3242    }
3243}