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