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}
1901
1902/// A bundle the caller owns and can use now. Ordered by slot range, then kind.
1903#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1904pub struct CompletedBundle {
1905    pub start_slot: u64,
1906    pub end_slot: u64,
1907    pub kind: BundleKind,
1908    /// When the bundle became available (unix ms), where known.
1909    #[serde(default)]
1910    pub completed_at_unix_ms: Option<i64>,
1911    #[serde(default)]
1912    pub size_bytes: Option<i64>,
1913}
1914
1915/// Query of `DELETE /bundles`: the bundle to delete, by kind and exact range.
1916#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1917pub struct BundleDeleteRequest {
1918    pub start_slot: u64,
1919    pub end_slot: u64,
1920    /// Absent means [`BundleKind::Blocks`].
1921    #[serde(default)]
1922    pub kind: BundleKind,
1923}
1924
1925/// What `DELETE /bundles` removed.
1926#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1927pub struct BundleDeleteResponse {
1928    pub start_slot: u64,
1929    pub end_slot: u64,
1930    pub kind: BundleKind,
1931}
1932
1933/// Response of `GET /build-bundle`.
1934#[derive(Debug, Clone, Serialize, Deserialize)]
1935pub struct BundleBuildListResponse {
1936    /// The caller's own builds, newest first. A build belongs to the endpoint
1937    /// running it, so this covers only builds requested here.
1938    pub builds: Vec<BundleBuildStatusResponse>,
1939    /// The caller's own bundles, ascending by range, wherever they were built.
1940    /// Not a view of `builds`: one build can produce several bundles, and a
1941    /// bundle outlives the record of the build that made it. `None` when the
1942    /// listing was unavailable, as opposed to `Some([])` for owning nothing.
1943    #[serde(default)]
1944    pub bundles: Option<Vec<CompletedBundle>>,
1945}
1946
1947/// Response of the management `/snapshot-slot` endpoint. The chosen source and
1948/// import state are internal, so only the resolved slot is exposed.
1949#[derive(Debug, Clone, Serialize, Deserialize)]
1950pub struct SnapshotSlotResponse {
1951    pub snapshot_slot: u64,
1952}
1953
1954/// Split a user-requested `[start_slot, end_slot]` range across the available
1955/// bundle ranges, returning a list of contiguous, non-overlapping `(start, end)`
1956/// pairs — one per bundle that the server can serve as its own session.
1957///
1958/// Each emitted `start` is a real `bundle_start_slot` and each `end` is within
1959/// the `max_bundle_end_slot` of a bundle that begins *exactly* at that start
1960/// (the server requires `start_slot == bundle_start_slot` and caps `end_slot` at
1961/// that bundle's `max_bundle_end_slot`). Among all such gap-free splits we pick
1962/// the one with the most sub-ranges, i.e. the highest parallelism: a fine-grained
1963/// (e.g. 10k) bundle grid yields one session per fine bundle instead of
1964/// collapsing onto a coarser series that overlaps the same slots — even when the
1965/// coarse and fine bundles share a start slot. A coarser bundle is only ridden
1966/// where no finer grid continues the walk, which still lets us serve a request
1967/// the coarse data covers even across a hole in the fine grid.
1968///
1969/// `kind` picks which bundles the split rides; the walk itself is the same for
1970/// either, since a recording is read slot by slot exactly as a block bundle is.
1971/// An unlabelled range, from a server predating [`AvailableRange::kind`],
1972/// counts as [`BundleKind::Blocks`].
1973///
1974/// Returns an error if the requested start cannot anchor, or if no gap-free
1975/// split reaches `requested_end`.
1976pub fn split_range(
1977    ranges: &[AvailableRange],
1978    requested: SlotRange,
1979    kind: BundleKind,
1980) -> Result<Vec<(u64, u64)>, String> {
1981    let (requested_start, requested_end) = (requested.start(), requested.end());
1982
1983    // Every advertised end per bundle start. A start can carry several ends — a
1984    // 50k snapshot bundle and the 10k bundle built on the same snapshot share a
1985    // start slot but reach different ends — so we keep them all and let the walk
1986    // pick whichever maximises parallelism.
1987    // A one-slot bundle (`end == start`) counts: a series whose last split
1988    // begins on the requested end records exactly one slot.
1989    let ends_by_start = ranges
1990        .iter()
1991        .filter(|r| r.kind.unwrap_or_default() == kind)
1992        .filter_map(|r| {
1993            r.max_bundle_end_slot
1994                .filter(|&end| end >= r.bundle_start_slot)
1995                .map(|end| (r.bundle_start_slot, end))
1996        })
1997        .fold(
1998            BTreeMap::<u64, BTreeSet<u64>>::new(),
1999            |mut ends_by_start, (start, end)| {
2000                ends_by_start.entry(start).or_default().insert(end);
2001                ends_by_start
2002            },
2003        );
2004
2005    // Anchor a request that lands mid-bundle to the covering bundle's start —
2006    // the latest bundle beginning at or before `requested_start` that still
2007    // covers it. The server's `select_parallel_requests` shares this fn and
2008    // anchors the same way, so the client must too or it would falsely reject a
2009    // start that isn't itself a bundle boundary. An exact bundle-start request
2010    // anchors to itself. (Single sessions instead require an exact bundle start
2011    // for every kind: `evaluate_requested_range`.)
2012    let Some((&anchor_start, _)) = ends_by_start.range(..=requested_start).rfind(|(_, ends)| {
2013        ends.iter()
2014            .next_back()
2015            .is_some_and(|&end| end >= requested_start)
2016    }) else {
2017        return Err(format!(
2018            "start_slot {requested_start} is not covered by any available {}",
2019            kind.coverage_noun()
2020        ));
2021    };
2022
2023    // Walk the bundle starts from `requested_end` backwards, recording for each
2024    // start the gap-free split with the most sub-ranges (highest parallelism).
2025    // A sub-range ends within one of its start's bundles and the next sub-range
2026    // must begin on the very next slot, so a coarse bundle is ridden only when no
2027    // finer bundle continues the walk.
2028    let mut best_from: BTreeMap<u64, Vec<(u64, u64)>> = BTreeMap::new();
2029    for (&start, ends) in ends_by_start.range(anchor_start..=requested_end).rev() {
2030        let mut best: Option<Vec<(u64, u64)>> = None;
2031        for &end in ends {
2032            let candidate = if end >= requested_end {
2033                Some(vec![(start, requested_end)])
2034            } else {
2035                best_from.get(&(end + 1)).map(|rest| {
2036                    std::iter::once((start, end))
2037                        .chain(rest.iter().copied())
2038                        .collect()
2039                })
2040            };
2041            if let Some(candidate) = candidate
2042                && best.as_ref().is_none_or(|b| candidate.len() > b.len())
2043            {
2044                best = Some(candidate);
2045            }
2046        }
2047        if let Some(best) = best {
2048            best_from.insert(start, best);
2049        }
2050    }
2051
2052    best_from.remove(&anchor_start).ok_or_else(|| {
2053        // Point at the first uncovered slot when the bundles leave a hole; fall
2054        // back to a generic message when the range is covered but cannot be
2055        // tiled to bundle boundaries.
2056        let mut covered_to = anchor_start.saturating_sub(1);
2057        for (&start, ends) in ends_by_start.range(anchor_start..=requested_end) {
2058            if start > covered_to.saturating_add(1) {
2059                break;
2060            }
2061            if let Some(&end) = ends.iter().next_back() {
2062                covered_to = covered_to.max(end);
2063            }
2064        }
2065        if covered_to < requested_end {
2066            format!("gap in coverage at slot {}", covered_to + 1)
2067        } else {
2068            format!(
2069                "no gap-free split of [{requested_start}, {requested_end}] aligns with the available {}",
2070                kind.coverage_nouns()
2071            )
2072        }
2073    })
2074}
2075
2076impl From<BacktestError> for BacktestResponse {
2077    fn from(error: BacktestError) -> Self {
2078        Self::Error(error)
2079    }
2080}
2081
2082impl std::error::Error for BacktestError {}
2083
2084impl fmt::Display for BacktestError {
2085    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2086        match self {
2087            BacktestError::InvalidTransactionEncoding { index, error } => {
2088                write!(f, "invalid transaction encoding at index {index}: {error}")
2089            }
2090            BacktestError::InvalidTransactionFormat { index, error } => {
2091                write!(f, "invalid transaction format at index {index}: {error}")
2092            }
2093            BacktestError::InvalidAccountEncoding {
2094                address,
2095                encoding,
2096                error,
2097            } => write!(
2098                f,
2099                "invalid encoding for account {address} ({encoding:?}): {error}"
2100            ),
2101            BacktestError::InvalidAccountOwner { address, error } => {
2102                write!(f, "invalid owner for account {address}: {error}")
2103            }
2104            BacktestError::InvalidAccountPubkey { address, error } => {
2105                write!(f, "invalid account pubkey {address}: {error}")
2106            }
2107            BacktestError::NoMoreBlocks => write!(f, "no more blocks available"),
2108            BacktestError::BundleNotFound {
2109                start_slot,
2110                end_slot,
2111            } => write!(
2112                f,
2113                "block bundle not found for start_slot={start_slot}, end_slot={end_slot}"
2114            ),
2115            BacktestError::AdvanceSlotFailed { slot, error } => {
2116                write!(f, "failed to advance to slot {slot}: {error}")
2117            }
2118            BacktestError::FinalizeSlotFailed { slot, error } => {
2119                write!(f, "failed to finalize slot {slot}: {error}")
2120            }
2121            BacktestError::InvalidRequest { error } => write!(f, "invalid request: {error}"),
2122            BacktestError::Internal { error } => write!(f, "internal error: {error}"),
2123            BacktestError::InvalidBlockhashFormat { slot, error } => {
2124                write!(f, "invalid blockhash at slot {slot}: {error}")
2125            }
2126            BacktestError::InitializingSysvarsFailed { slot, error } => {
2127                write!(f, "failed to initialize sysvars at slot {slot}: {error}")
2128            }
2129            BacktestError::ClerkError { error } => write!(f, "clerk error: {error}"),
2130            BacktestError::SimulationError { error } => {
2131                write!(f, "simulation error: {error}")
2132            }
2133            BacktestError::SessionNotFound { session_id } => {
2134                write!(f, "session not found: {session_id}")
2135            }
2136            BacktestError::SessionOwnerMismatch => {
2137                write!(f, "session owner mismatch")
2138            }
2139            BacktestError::SessionOwnershipBusy { reason } => {
2140                write!(f, "session ownership busy: {reason}")
2141            }
2142            BacktestError::NoProducedBlocksInRange {
2143                start_slot,
2144                end_slot,
2145            } => write!(
2146                f,
2147                "no produced blocks in requested range {start_slot}..={end_slot}; \
2148                 slot(s) may be skipped on-chain"
2149            ),
2150        }
2151    }
2152}
2153
2154#[cfg(test)]
2155mod tests {
2156    use rstest::rstest;
2157    use solana_account::ReadableAccount;
2158
2159    use super::*;
2160
2161    fn address(byte: u8) -> Address {
2162        Address::from([byte; 32])
2163    }
2164
2165    #[rstest]
2166    #[case::one("jupiter", Some(&[SwapAggregator::Jupiter][..]))]
2167    #[case::several("jupiter,okx", Some(&[SwapAggregator::Jupiter, SwapAggregator::Okx][..]))]
2168    #[case::spaced_and_uppercased(" Jupiter , OKX ", Some(&[SwapAggregator::Jupiter, SwapAggregator::Okx][..]))]
2169    #[case::repeats_collapse("okx,okx", Some(&[SwapAggregator::Okx][..]))]
2170    #[case::trailing_separator("okx,", Some(&[SwapAggregator::Okx][..]))]
2171    #[case::unknown("jupiter,bogus", None)]
2172    #[case::empty("", None)]
2173    #[case::separators_only(",,", None)]
2174    fn reroute_aggregators_parse_the_shapes_a_flag_carries(
2175        #[case] input: &str,
2176        #[case] expected: Option<&[SwapAggregator]>,
2177    ) {
2178        assert_eq!(
2179            input.parse::<RerouteAggregators>().ok(),
2180            expected.map(|aggregators| RerouteAggregators::new(aggregators.iter().copied()))
2181        );
2182    }
2183
2184    /// `Display` renders `--reroute-aggregators`' default, so clap re-parses whatever it prints.
2185    #[test]
2186    fn reroute_aggregators_round_trip_through_their_rendering() {
2187        let aggregators = RerouteAggregators::new([SwapAggregator::Dflow, SwapAggregator::Jupiter]);
2188
2189        assert_eq!(aggregators.to_string(), "jupiter,dflow");
2190        assert_eq!(
2191            aggregators
2192                .to_string()
2193                .parse::<RerouteAggregators>()
2194                .unwrap(),
2195            aggregators
2196        );
2197        assert_eq!(RerouteAggregators::default().to_string(), "jupiter");
2198    }
2199
2200    /// The API deserializes aggregators through the same parser the flag uses, so a spelling one
2201    /// entry point accepts is not a hard error at the other.
2202    #[test]
2203    fn aggregators_deserialize_through_the_same_parser_the_flag_uses() {
2204        let params: CreateSessionParams = serde_json::from_value(
2205            serde_json::json!({"startSlot": 1, "endSlot": 2, "reroute": {"aggregators": ["Jupiter", "OKX"]}}),
2206        )
2207        .expect("decode");
2208
2209        assert_eq!(
2210            params.reroute.expect("rerouting requested").aggregators,
2211            RerouteAggregators::new([SwapAggregator::Jupiter, SwapAggregator::Okx])
2212        );
2213    }
2214
2215    /// A request that names no knob gets the narrow defaults, not the every-aggregator behaviour a
2216    /// client written before this change expected — the switch these defaults are here to make.
2217    #[test]
2218    fn params_without_the_new_reroute_fields_decode_to_the_narrow_defaults() {
2219        let params: CreateSessionParams = serde_json::from_value(
2220            serde_json::json!({"startSlot": 1, "endSlot": 2, "reroute": {}}),
2221        )
2222        .expect("decode");
2223
2224        let reroute = params.reroute.expect("rerouting requested");
2225        assert_eq!(reroute.aggregators, RerouteAggregators::default());
2226        assert!(reroute.aggregators.contains(SwapAggregator::Jupiter));
2227        assert!(!reroute.aggregators.contains(SwapAggregator::Okx));
2228        assert!(!reroute.circular_arbs);
2229        assert!(!reroute.detect_failed_l1_swaps);
2230    }
2231
2232    /// Presence is the switch: a replay that will never quote must not reserve a metis sidecar.
2233    #[test]
2234    fn a_request_without_reroute_reserves_no_sidecar() {
2235        let params: CreateSessionParams =
2236            serde_json::from_value(serde_json::json!({"startSlot": 1, "endSlot": 2}))
2237                .expect("decode");
2238
2239        assert!(!params.requires_sidecar());
2240    }
2241
2242    /// A per-slot override schedule is the largest thing a client sends. Each
2243    /// action alone is incompressible enough to sit near the websocket message
2244    /// limit; the redundancy lives *across* actions, so the whole vector has to
2245    /// travel as one blob. Pins both the round trip and that size property.
2246    #[test]
2247    fn a_per_slot_action_schedule_compresses_across_actions() {
2248        let slots = 2_000u64;
2249        let actions = (0..slots)
2250            .map(|slot| ScheduledAction {
2251                anchor: ActionAnchor::AfterSlot { slots: vec![slot] },
2252                kind: ActionKind::Simulate,
2253                feeds_reroute: false,
2254                transactions: Vec::new(),
2255                account_overrides: AccountModifications(BTreeMap::from([(
2256                    address(7),
2257                    AccountData {
2258                        // Realistic pool account: 2 KiB that barely moves slot to slot.
2259                        data: EncodedBinary::new(
2260                            BASE64.encode(
2261                                [&slot.to_le_bytes()[..], &[0xAB; 2040][..]]
2262                                    .concat()
2263                                    .as_slice(),
2264                            ),
2265                            BinaryEncoding::Base64,
2266                        ),
2267                        executable: false,
2268                        lamports: 15_144_966,
2269                        owner: address(9),
2270                        space: 2048,
2271                    },
2272                )])),
2273                return_accounts: Vec::new(),
2274                label: None,
2275            })
2276            .collect::<Vec<_>>();
2277        let params = CreateSessionParams::builder()
2278            .start_slot(1)
2279            .end_slot(slots)
2280            .actions(actions.clone())
2281            .build();
2282
2283        let encoded = serde_json::to_string(&params).expect("serialize");
2284        let raw = serde_json::to_string(&actions).expect("serialize raw actions");
2285        assert!(
2286            encoded.len() * 8 < raw.len(),
2287            "expected the schedule to compress well; got {} bytes from {} raw",
2288            encoded.len(),
2289            raw.len()
2290        );
2291
2292        let decoded: CreateSessionParams = serde_json::from_str(&encoded).expect("round trip");
2293        assert_eq!(
2294            serde_json::to_string(&decoded.actions).expect("serialize decoded"),
2295            raw
2296        );
2297    }
2298
2299    /// Absent actions stay absent on the wire, so a session without a schedule
2300    /// is unchanged by the compression.
2301    #[test]
2302    fn an_empty_action_list_is_omitted_entirely() {
2303        let params = CreateSessionParams::builder()
2304            .start_slot(1)
2305            .end_slot(2)
2306            .build();
2307        let json = serde_json::to_value(&params).expect("serialize");
2308        assert!(json.get("actions").is_none(), "{json}");
2309        let decoded: CreateSessionParams = serde_json::from_value(json).expect("round trip");
2310        assert!(decoded.actions.is_empty());
2311    }
2312
2313    /// The conflict is `send` *and* replay: neither alone is a problem, and simulate runs
2314    /// fine under replay because it never commits.
2315    #[test]
2316    fn send_actions_conflict_with_replay_only_together() {
2317        let params = |replay, kind| {
2318            CreateSessionParams::builder()
2319                .start_slot(1)
2320                .end_slot(2)
2321                .replay_account_state(replay)
2322                .actions(vec![ScheduledAction {
2323                    anchor: ActionAnchor::BeforeSlot { slots: vec![1] },
2324                    kind,
2325                    feeds_reroute: false,
2326                    transactions: vec!["dGVzdA==".to_string()],
2327                    account_overrides: Default::default(),
2328                    return_accounts: Default::default(),
2329                    label: None,
2330                }])
2331                .build()
2332        };
2333        assert!(replay_support::send_action_conflict(&params(false, ActionKind::Send)).is_none());
2334        assert!(
2335            replay_support::send_action_conflict(&params(true, ActionKind::Simulate)).is_none()
2336        );
2337        let mut publishing = params(true, ActionKind::Simulate);
2338        publishing.actions[0].feeds_reroute = true;
2339        assert!(replay_support::send_action_conflict(&publishing).is_none());
2340
2341        let conflict = replay_support::send_action_conflict(&params(true, ActionKind::Send))
2342            .expect("send under replay conflicts");
2343        assert!(conflict.contains("kind: send"), "{conflict}");
2344        assert!(conflict.contains(replay_support::SEND_ACTION), "{conflict}");
2345    }
2346
2347    fn override_entry(owner: Address, executable: bool) -> AccountData {
2348        AccountData {
2349            data: EncodedBinary::from_bytes(&[1, 2, 3], BinaryEncoding::Base64),
2350            executable,
2351            lamports: 1,
2352            owner,
2353            space: 3,
2354        }
2355    }
2356
2357    fn before() -> ActionAnchor {
2358        ActionAnchor::BeforeSlot { slots: vec![1] }
2359    }
2360
2361    fn valid() -> ScheduledAction {
2362        override_action(before(), address(3), override_entry(address(4), false))
2363    }
2364
2365    fn override_action(
2366        anchor: ActionAnchor,
2367        account: Address,
2368        data: AccountData,
2369    ) -> ScheduledAction {
2370        ScheduledAction {
2371            anchor,
2372            kind: ActionKind::default(),
2373            transactions: Vec::new(),
2374            account_overrides: AccountModifications(BTreeMap::from([(account, data)])),
2375            return_accounts: Vec::new(),
2376            label: None,
2377            feeds_reroute: true,
2378        }
2379    }
2380
2381    #[test]
2382    fn override_action_round_trips() {
2383        let key = address(1);
2384        let entry = override_entry(address(2), false);
2385        let action = override_action(
2386            ActionAnchor::BeforeSlot { slots: vec![5, 9] },
2387            key,
2388            entry.clone(),
2389        );
2390        let json = serde_json::to_value(&action).unwrap();
2391        assert!(
2392            json["anchor"]["slots"].is_string(),
2393            "slots travel as a compressed blob: {json}"
2394        );
2395        let back: ScheduledAction = serde_json::from_value(json).unwrap();
2396        assert_eq!(back.anchor, ActionAnchor::BeforeSlot { slots: vec![5, 9] });
2397        assert!(back.feeds_reroute);
2398        assert!(!back.is_computed_override(), "no transactions to simulate");
2399        let survived = &back.account_overrides.0[&key];
2400        assert_eq!(survived.lamports, entry.lamports);
2401        assert_eq!(survived.data.data, entry.data.data);
2402    }
2403
2404    /// A venue's vaults are token accounts, so pricing one against different inventory means
2405    /// overriding them. They were rejected outright on the grounds that prepare-stage reads would
2406    /// desync from simulation — but prepare's only use for them is clerk cache warming, and every
2407    /// simulation applies the schedule on top of whatever that warmed.
2408    #[test]
2409    fn a_token_account_override_is_accepted() {
2410        let action = override_action(
2411            before(),
2412            address(3),
2413            override_entry(spl_token_interface::ID, false),
2414        );
2415        let schedule = decode_override_actions(&[action]).expect("token overrides decode");
2416        assert!(!schedule.active_at(1).is_empty());
2417    }
2418
2419    #[rstest]
2420    #[case::executable(override_action(before(), address(3), override_entry(address(10), true)))]
2421    #[case::program_owned(override_action(
2422        before(),
2423        address(3),
2424        override_entry(solana_account::PROGRAM_OWNERS[0], false),
2425    ))]
2426    #[case::after_slot_anchor(override_action(
2427        ActionAnchor::AfterSlot { slots: vec![1] },
2428        address(3),
2429        override_entry(address(4), false),
2430    ))]
2431    #[case::nothing_to_post(ScheduledAction {
2432        account_overrides: AccountModifications::default(),
2433        ..valid()
2434    })]
2435    // Otherwise an action that meant to send would silently post overrides instead.
2436    #[case::send(ScheduledAction {
2437        kind: ActionKind::Send,
2438        ..valid()
2439    })]
2440    #[allow(deprecated, reason = "exercise the loader-owner rejection path")]
2441    fn override_actions_reject_invalid_accounts_and_shapes(#[case] action: ScheduledAction) {
2442        assert!(decode_override_actions(&[action]).is_err());
2443    }
2444
2445    /// The shapes the rejections above are variations of.
2446    #[test]
2447    fn override_actions_accept_a_static_and_a_computed_shape() {
2448        assert!(decode_override_actions(&[valid()]).is_ok());
2449        // Transactions make the contribution computed rather than static — still valid,
2450        // just not resolvable until the action runs.
2451        assert!(
2452            decode_override_actions(&[ScheduledAction {
2453                transactions: vec!["dGVzdA==".to_string()],
2454                ..valid()
2455            }])
2456            .is_ok()
2457        );
2458    }
2459
2460    #[test]
2461    fn override_actions_merge_account_wise_later_wins() {
2462        let entry = |lamports| AccountData {
2463            lamports,
2464            ..override_entry(address(9), false)
2465        };
2466        let slot_action = |slots, account, lamports| {
2467            override_action(ActionAnchor::BeforeSlot { slots }, account, entry(lamports))
2468        };
2469        let actions = [
2470            slot_action(vec![5, 7], address(1), 1),
2471            slot_action(vec![5], address(2), 2),
2472            slot_action(vec![5], address(1), 3),
2473            ScheduledAction {
2474                anchor: ActionAnchor::AfterSlot { slots: vec![5] },
2475                kind: ActionKind::Simulate,
2476                feeds_reroute: false,
2477                transactions: vec!["dGVzdA==".to_string()],
2478                account_overrides: AccountModifications::default(),
2479                return_accounts: Vec::new(),
2480                label: None,
2481            },
2482        ];
2483        let schedule = decode_override_actions(&actions).unwrap();
2484        assert_eq!(schedule.slots().collect::<Vec<_>>(), vec![5, 7]);
2485        let lamports_at = |key, slot| {
2486            schedule
2487                .active_for(&key, slot)
2488                .map(|account| account.lamports())
2489        };
2490        assert_eq!(lamports_at(address(1), 5), Some(3));
2491        assert_eq!(lamports_at(address(2), 5), Some(2));
2492        assert_eq!(lamports_at(address(1), 7), Some(1));
2493    }
2494
2495    #[test]
2496    fn computed_override_resolution_preserves_action_order_and_failure() {
2497        let key = address(1);
2498        let static_action = |lamports| {
2499            override_action(
2500                ActionAnchor::BeforeSlot { slots: vec![5] },
2501                key,
2502                AccountData {
2503                    lamports,
2504                    ..override_entry(address(9), false)
2505                },
2506            )
2507        };
2508        let computed_action = || ScheduledAction {
2509            transactions: vec!["dGVzdA==".to_string()],
2510            ..static_action(2)
2511        };
2512
2513        let computed_last =
2514            decode_override_actions(&[static_action(1), computed_action()]).unwrap();
2515        computed_last
2516            .resolve(5, 1, Some(HashMap::from([(key, shared_account(2))])))
2517            .unwrap();
2518        assert_eq!(
2519            computed_last.active_for(&key, 5).map(|a| a.lamports()),
2520            Some(2)
2521        );
2522
2523        let static_last = decode_override_actions(&[computed_action(), static_action(3)]).unwrap();
2524        static_last
2525            .resolve(5, 0, Some(HashMap::from([(key, shared_account(2))])))
2526            .unwrap();
2527        assert_eq!(
2528            static_last.active_for(&key, 5).map(|a| a.lamports()),
2529            Some(3)
2530        );
2531
2532        let failed = decode_override_actions(&[static_action(1), computed_action()]).unwrap();
2533        failed.resolve(5, 1, None).unwrap();
2534        assert_eq!(failed.active_for(&key, 5).map(|a| a.lamports()), Some(1));
2535        assert!(failed.resolve(5, 1, None).is_err(), "resolving twice");
2536    }
2537
2538    fn shared_account(lamports: u64) -> AccountSharedData {
2539        AccountSharedData::from(solana_account::Account {
2540            lamports,
2541            data: vec![1],
2542            owner: address(9),
2543            executable: false,
2544            rent_epoch: 0,
2545        })
2546    }
2547
2548    fn carry_forward_schedule() -> (Address, Address, OverrideSchedule) {
2549        let (a, b) = (address(1), address(2));
2550        let schedule = OverrideSchedule::from(BTreeMap::from([
2551            (10, HashMap::from([(a, shared_account(1))])),
2552            (
2553                20,
2554                HashMap::from([(a, shared_account(2)), (b, shared_account(9))]),
2555            ),
2556        ]));
2557        (a, b, schedule)
2558    }
2559
2560    #[test]
2561    fn override_schedule_resolves_carry_forward_per_account() {
2562        let (a, b, schedule) = carry_forward_schedule();
2563
2564        assert!(schedule.active_for(&a, 9).is_none());
2565        let cases = [
2566            (9, None, None),
2567            (10, Some(1), None),
2568            (15, Some(1), None),
2569            (20, Some(2), Some(9)),
2570            (u64::MAX, Some(2), Some(9)),
2571        ];
2572        for (slot, want_a, want_b) in cases {
2573            assert_eq!(
2574                schedule
2575                    .active_for(&a, slot)
2576                    .map(|account| account.lamports()),
2577                want_a,
2578                "active_for a at {slot}"
2579            );
2580            let active = schedule.active_at(slot);
2581            assert_eq!(
2582                active.get(&a).map(ReadableAccount::lamports),
2583                want_a,
2584                "active_at a at {slot}"
2585            );
2586            assert_eq!(
2587                active.get(&b).map(ReadableAccount::lamports),
2588                want_b,
2589                "active_at b at {slot}"
2590            );
2591        }
2592    }
2593
2594    #[test]
2595    fn newly_active_yields_final_values_in_window() {
2596        let (a, b, schedule) = carry_forward_schedule();
2597
2598        let newly: HashMap<_, _> = schedule.newly_active(Some(10), 20).into_iter().collect();
2599        assert_eq!(newly.len(), 2);
2600        assert_eq!(newly[&a].lamports(), 2);
2601        assert_eq!(newly[&b].lamports(), 9);
2602        let spanning: HashMap<_, _> = schedule.newly_active(None, 20).into_iter().collect();
2603        assert_eq!(spanning[&a].lamports(), 2, "later slot wins");
2604        assert_eq!(schedule.newly_active(None, 15).len(), 1);
2605    }
2606
2607    #[test]
2608    fn reroute_filter_round_trips_camel_case() {
2609        let filter = RerouteFilter {
2610            pairs: BTreeSet::from([MintPair::new(address(1), address(2))]),
2611        };
2612        let json = serde_json::to_value(&filter).unwrap();
2613        assert!(
2614            json["pairs"][0][0].is_string() && json["pairs"][0][1].is_string(),
2615            "a pair serializes as two base58 strings: {json}"
2616        );
2617        let back: RerouteFilter = serde_json::from_value(json).unwrap();
2618        assert_eq!(back, filter);
2619    }
2620
2621    /// The set lookup is only direction-agnostic because construction normalizes, and
2622    /// values arriving over the wire have to be normalized on that path too.
2623    #[test]
2624    fn mint_pairs_are_normalized_however_they_are_built() {
2625        let (one, other) = (address(1), address(2));
2626        assert_eq!(MintPair::new(one, other), MintPair::new(other, one));
2627
2628        let reversed: MintPair =
2629            serde_json::from_value(serde_json::json!([other.to_string(), one.to_string()]))
2630                .unwrap();
2631        assert_eq!(reversed, MintPair::new(one, other));
2632    }
2633
2634    #[test]
2635    fn fail_fast_divergence_kind_str_round_trips() {
2636        for kind in [
2637            FailFastDivergenceKind::AnyNonBenign,
2638            FailFastDivergenceKind::Tracked,
2639        ] {
2640            assert_eq!(
2641                FailFastDivergenceKind::from_str_opt(kind.as_str()),
2642                Some(kind)
2643            );
2644        }
2645        assert_eq!(FailFastDivergenceKind::from_str_opt("nonsense"), None);
2646        assert_eq!(
2647            FailFastDivergenceKind::default(),
2648            FailFastDivergenceKind::AnyNonBenign
2649        );
2650    }
2651
2652    #[test]
2653    fn bundle_not_found_is_terminal_and_descriptive() {
2654        let error = BacktestError::BundleNotFound {
2655            start_slot: 100,
2656            end_slot: 600,
2657        };
2658
2659        assert!(error.is_terminal());
2660        assert_eq!(
2661            error.to_string(),
2662            "block bundle not found for start_slot=100, end_slot=600"
2663        );
2664    }
2665
2666    #[test]
2667    fn no_produced_blocks_is_terminal_and_descriptive() {
2668        let error = BacktestError::NoProducedBlocksInRange {
2669            start_slot: 100,
2670            end_slot: 100,
2671        };
2672
2673        assert!(error.is_terminal());
2674        // placement-e2e matches this phrase to classify the failure as
2675        // transient; keep it stable.
2676        assert_eq!(
2677            error.to_string(),
2678            "no produced blocks in requested range 100..=100; slot(s) may be skipped on-chain"
2679        );
2680    }
2681
2682    #[test]
2683    fn bundle_build_request_optional_fields_default_to_none() {
2684        let req: BundleBuildRequest =
2685            serde_json::from_str(r#"{"start_slot":1,"end_slot":2}"#).expect("parse");
2686        assert_eq!((req.start_slot, req.end_slot), (1, 2));
2687        assert_eq!(req.bundle_size, None);
2688        assert_eq!(req.idempotency_key, None);
2689    }
2690
2691    #[test]
2692    fn bundle_build_request_parses_optional_fields() {
2693        let req: BundleBuildRequest = serde_json::from_str(
2694            r#"{"start_slot":1,"end_slot":2,"bundle_size":500,"idempotency_key":"abc"}"#,
2695        )
2696        .expect("parse");
2697        assert_eq!(req.bundle_size, Some(500));
2698        assert_eq!(req.idempotency_key.as_deref(), Some("abc"));
2699    }
2700
2701    #[test]
2702    fn bundle_build_status_serde_round_trips_with_snake_case() {
2703        let cases = [
2704            (BundleBuildStatus::Completed, "\"completed\""),
2705            (BundleBuildStatus::Failed, "\"failed\""),
2706            (BundleBuildStatus::InProgress, "\"in_progress\""),
2707            (
2708                BundleBuildStatus::NeedsInvestigation,
2709                "\"needs_investigation\"",
2710            ),
2711        ];
2712        for (status, expected) in cases {
2713            assert_eq!(serde_json::to_string(&status).unwrap(), expected);
2714            assert_eq!(
2715                serde_json::from_str::<BundleBuildStatus>(expected).unwrap(),
2716                status
2717            );
2718            assert_eq!(status.as_str(), expected.trim_matches('"'));
2719        }
2720        assert!(serde_json::from_str::<BundleBuildStatus>("\"queued\"").is_err());
2721    }
2722
2723    #[test]
2724    fn bundle_build_request_without_kind_deserializes_as_blocks() {
2725        // An old client's payload must keep meaning a plain block build.
2726        let request: BundleBuildRequest =
2727            serde_json::from_str(r#"{"start_slot":100,"end_slot":200}"#).unwrap();
2728        assert_eq!(request.kind, BundleKind::Blocks);
2729    }
2730
2731    #[test]
2732    fn bundle_kind_round_trips_over_serde_and_as_str() {
2733        for kind in [BundleKind::Blocks, BundleKind::AccountState] {
2734            let json = serde_json::to_string(&kind).unwrap();
2735            assert_eq!(json, format!("\"{}\"", kind.as_str()));
2736            assert_eq!(serde_json::from_str::<BundleKind>(&json).unwrap(), kind);
2737            // The store round-trips through `as_str`, not serde, so the two
2738            // maps have to agree for a stored kind to read back.
2739            assert_eq!(BundleKind::from_str_opt(kind.as_str()), Some(kind));
2740        }
2741        assert!(serde_json::from_str::<BundleKind>("\"accountState\"").is_err());
2742        assert_eq!(BundleKind::from_str_opt("accountState"), None);
2743    }
2744
2745    #[test]
2746    fn bundle_build_request_carries_kind_through_serde() {
2747        let request = BundleBuildRequest {
2748            start_slot: 100,
2749            end_slot: 200,
2750            kind: BundleKind::AccountState,
2751            ..Default::default()
2752        };
2753        let json = serde_json::to_string(&request).unwrap();
2754        assert!(json.contains(r#""kind":"account_state""#), "{json}");
2755        let parsed: BundleBuildRequest = serde_json::from_str(&json).unwrap();
2756        assert_eq!(parsed.kind, BundleKind::AccountState);
2757    }
2758
2759    #[test]
2760    fn bundle_build_status_response_serializes_request_and_status() {
2761        let response = BundleBuildStatusResponse {
2762            request_id: "r".to_string(),
2763            start_slot: 100,
2764            end_slot: 200,
2765            bundle_size: Some(50),
2766            status: BundleBuildStatus::InProgress,
2767            created_at_unix_ms: 0,
2768            kind: BundleKind::Blocks,
2769        };
2770        let json = serde_json::to_value(&response).unwrap();
2771        assert_eq!(json["request_id"].as_str(), Some("r"));
2772        assert_eq!(json["start_slot"].as_u64(), Some(100));
2773        assert_eq!(json["bundle_size"].as_u64(), Some(50));
2774        assert_eq!(json["status"].as_str(), Some("in_progress"));
2775        assert_eq!(json["created_at_unix_ms"].as_u64(), Some(0));
2776    }
2777
2778    #[test]
2779    fn an_empty_bundles_list_is_distinct_from_an_absent_one() {
2780        // A server that predates the section sends no `bundles` at all.
2781        let absent: BundleBuildListResponse = serde_json::from_str(r#"{"builds":[]}"#).unwrap();
2782        let empty: BundleBuildListResponse =
2783            serde_json::from_str(r#"{"builds":[],"bundles":[]}"#).unwrap();
2784        assert_eq!(absent.bundles, None);
2785        assert_eq!(empty.bundles, Some(Vec::new()));
2786    }
2787
2788    #[test]
2789    fn completed_bundle_round_trips() {
2790        let bundle = CompletedBundle {
2791            start_slot: 100,
2792            end_slot: 200,
2793            kind: BundleKind::AccountState,
2794            completed_at_unix_ms: Some(1_700_000_000_000),
2795            size_bytes: Some(4_096),
2796        };
2797        let json = serde_json::to_string(&bundle).unwrap();
2798        assert!(json.contains(r#""kind":"account_state""#), "{json}");
2799        assert_eq!(
2800            serde_json::from_str::<CompletedBundle>(&json).unwrap(),
2801            bundle
2802        );
2803
2804        let sparse = r#"{"start_slot":1,"end_slot":2,"kind":"blocks"}"#;
2805        let parsed: CompletedBundle = serde_json::from_str(sparse).unwrap();
2806        assert_eq!(parsed.completed_at_unix_ms, None);
2807        assert_eq!(parsed.size_bytes, None);
2808    }
2809
2810    #[test]
2811    fn available_range_reads_a_server_that_predates_kind_and_scope() {
2812        // Deploys are not atomic: a new client must not fail on the old shape.
2813        let json = r#"{
2814            "bundle_start_slot": 100,
2815            "bundle_start_slot_utc": null,
2816            "max_bundle_end_slot": 200,
2817            "max_bundle_end_slot_utc": null,
2818            "max_bundle_size": 101
2819        }"#;
2820        let parsed: AvailableRange = serde_json::from_str(json).unwrap();
2821        assert_eq!(parsed.bundle_start_slot, 100);
2822        assert_eq!(parsed.kind, None);
2823        assert_eq!(parsed.scope, None);
2824    }
2825
2826    #[test]
2827    fn available_range_ignores_fields_it_does_not_know() {
2828        // The other direction: an old client against a newer server.
2829        let json = r#"{"bundle_start_slot": 1, "some_future_field": "x"}"#;
2830        assert_eq!(
2831            serde_json::from_str::<AvailableRange>(json)
2832                .unwrap()
2833                .bundle_start_slot,
2834            1
2835        );
2836    }
2837
2838    #[test]
2839    fn available_range_labels_round_trip() {
2840        let range = AvailableRange {
2841            bundle_start_slot: 100,
2842            max_bundle_end_slot: Some(200),
2843            kind: Some(BundleKind::AccountState),
2844            scope: Some(RangeScope::User),
2845            ..Default::default()
2846        };
2847        let json = serde_json::to_string(&range).unwrap();
2848        assert!(json.contains(r#""kind":"account_state""#), "{json}");
2849        assert!(json.contains(r#""scope":"user""#), "{json}");
2850        let parsed: AvailableRange = serde_json::from_str(&json).unwrap();
2851        assert_eq!(parsed.kind, Some(BundleKind::AccountState));
2852        assert_eq!(parsed.scope, Some(RangeScope::User));
2853    }
2854
2855    fn range(start: u64, end: u64) -> AvailableRange {
2856        AvailableRange {
2857            bundle_start_slot: start,
2858            max_bundle_end_slot: Some(end),
2859            ..Default::default()
2860        }
2861    }
2862
2863    /// Each case lists the available bundles, the requested `[start, end]`, and
2864    /// the expected split — `Some(_)` for an accepted plan, `None` when the range
2865    /// is unservable and `split_range` must error.
2866    #[rstest::rstest]
2867    #[case::single(vec![range(100, 300)], 100, 300, Some(vec![(100, 300)]))]
2868    #[case::multi(
2869        vec![range(100, 200), range(201, 300), range(301, 400)],
2870        100, 300, Some(vec![(100, 200), (201, 300)])
2871    )]
2872    // Smaller bundles nested inside a larger one don't bridge the gap, but the
2873    // range is still coverable by riding the coarse series.
2874    #[case::nested(
2875        vec![range(100, 500), range(110, 150), range(150, 190), range(501, 900)],
2876        100, 900, Some(vec![(100, 500), (501, 900)])
2877    )]
2878    // A fine grid (1k bundles standing in for the 10k grid) overlapped by a
2879    // coarser, differently-aligned series: ride the fine grid, never landing on
2880    // the coarse bundle's drifted start.
2881    #[case::prefers_finer_grid(
2882        vec![range(1_000, 1_999), range(1_500, 3_400), range(2_000, 2_999), range(3_000, 3_999)],
2883        1_000, 3_999, Some(vec![(1_000, 1_999), (2_000, 2_999), (3_000, 3_999)])
2884    )]
2885    // A 50k snapshot bundle and the 10k bundles built on it share start 100: ride
2886    // the 10k grid instead of the coarse end, which would strand the cursor on a
2887    // slot no bundle starts at.
2888    #[case::shared_start_prefers_finer(
2889        vec![range(100, 150), range(100, 120), range(121, 140), range(141, 160)],
2890        100, 160, Some(vec![(100, 120), (121, 140), (141, 160)])
2891    )]
2892    // A coarse bundle (100, 200) overlaps a finer pair covering the same span:
2893    // split_range maximises parallelism, riding the fine pair (two sessions)
2894    // rather than merging onto the coarse max end (one session).
2895    #[case::coarse_overlap_prefers_finer_pair(
2896        vec![range(100, 200), range(100, 150), range(151, 160)],
2897        100, 160, Some(vec![(100, 150), (151, 160)])
2898    )]
2899    // The fine grid has a hole (nothing starts at 141), but a coarse bundle
2900    // sharing the start spans it: serve the request off the coarse bundle rather
2901    // than reporting an unsupported range.
2902    #[case::falls_back_to_coarse(
2903        vec![range(100, 160), range(100, 120), range(121, 140)],
2904        100, 160, Some(vec![(100, 160)])
2905    )]
2906    // The last bundle overshoots the requested end and is clamped.
2907    #[case::clamps_final_bundle(vec![range(100, 199), range(200, 999)], 100, 450, Some(vec![(100, 199), (200, 450)]))]
2908    // A request landing mid-bundle anchors to the covering bundle's start
2909    // (matching the server) instead of erroring as "not covered".
2910    #[case::anchors_mid_bundle(vec![range(150, 350)], 200, 300, Some(vec![(150, 300)]))]
2911    #[case::anchors_then_continues(
2912        vec![range(150, 350), range(351, 600)],
2913        200, 600, Some(vec![(150, 350), (351, 600)])
2914    )]
2915    #[case::start_inside_bundle_anchors(vec![range(200, 400)], 300, 400, Some(vec![(200, 400)]))]
2916    // Errors: a start before any bundle's coverage has no covering bundle...
2917    #[case::start_before_first_bundle(vec![range(200, 400)], 100, 400, None)]
2918    // ...the end must be reachable...
2919    #[case::end_not_covered(vec![range(100, 200)], 100, 300, None)]
2920    #[case::gap_in_coverage(vec![range(100, 200), range(210, 300)], 100, 300, None)]
2921    // Per-API-key isolation: a user's finer bundles and a coarse GLOBAL bundle
2922    // sharing a start are fed as SEPARATE entries (never merged on snapshot_slot,
2923    // which would keep only the max end). split_range set-unions the ends and
2924    // rides the finer user grid — a regression guard against re-merging.
2925    #[case::user_and_global_ranges_not_collapsed(
2926        vec![range(100, 200), range(100, 150), range(151, 200)],
2927        100, 200, Some(vec![(100, 150), (151, 200)])
2928    )]
2929    fn split_range_cases(
2930        #[case] ranges: Vec<AvailableRange>,
2931        #[case] start: u64,
2932        #[case] end: u64,
2933        #[case] expected: Option<Vec<(u64, u64)>>,
2934    ) {
2935        match expected {
2936            Some(expected) => assert_eq!(
2937                split_range(
2938                    &ranges,
2939                    SlotRange::new_unchecked(start, end),
2940                    BundleKind::Blocks
2941                )
2942                .unwrap(),
2943                expected
2944            ),
2945            None => assert!(
2946                split_range(
2947                    &ranges,
2948                    SlotRange::new_unchecked(start, end),
2949                    BundleKind::Blocks
2950                )
2951                .is_err()
2952            ),
2953        }
2954    }
2955
2956    fn account_state_range(start: u64, end: u64) -> AvailableRange {
2957        AvailableRange {
2958            kind: Some(BundleKind::AccountState),
2959            ..range(start, end)
2960        }
2961    }
2962
2963    #[test]
2964    fn a_replay_split_rides_the_recordings_not_a_finer_block_grid() {
2965        // The finer block grid wins on piece count, which is precisely what would
2966        // hand a replay run sub-ranges nothing ever recorded.
2967        let ranges = vec![
2968            range(100, 149),
2969            range(150, 199),
2970            account_state_range(100, 199),
2971        ];
2972        let requested = SlotRange::new_unchecked(100, 199);
2973
2974        assert_eq!(
2975            split_range(&ranges, requested, BundleKind::Blocks).unwrap(),
2976            [(100, 149), (150, 199)]
2977        );
2978        assert_eq!(
2979            split_range(&ranges, requested, BundleKind::AccountState).unwrap(),
2980            [(100, 199)]
2981        );
2982    }
2983
2984    #[test]
2985    fn a_replay_split_tiles_consecutive_recordings() {
2986        let ranges = vec![account_state_range(100, 199), account_state_range(200, 299)];
2987        assert_eq!(
2988            split_range(
2989                &ranges,
2990                SlotRange::new_unchecked(100, 299),
2991                BundleKind::AccountState
2992            )
2993            .unwrap(),
2994            [(100, 199), (200, 299)]
2995        );
2996    }
2997
2998    #[test]
2999    fn a_replay_split_ends_short_of_a_recording() {
3000        // A recording is read slot by slot, so a session stops wherever asked
3001        // inside it — the same as a block session does inside its bundle.
3002        let requested = SlotRange::new_unchecked(100, 150);
3003        assert_eq!(
3004            split_range(&[range(100, 199)], requested, BundleKind::Blocks).unwrap(),
3005            [(100, 150)]
3006        );
3007        assert_eq!(
3008            split_range(
3009                &[account_state_range(100, 199)],
3010                requested,
3011                BundleKind::AccountState
3012            )
3013            .unwrap(),
3014            [(100, 150)]
3015        );
3016    }
3017
3018    #[test]
3019    fn a_replay_split_ends_short_of_its_last_recording() {
3020        // Only the last sub-range may stop mid-recording; the ones before it
3021        // still have to hand over on the very next slot.
3022        let ranges = vec![account_state_range(100, 199), account_state_range(200, 299)];
3023        assert_eq!(
3024            split_range(
3025                &ranges,
3026                SlotRange::new_unchecked(100, 250),
3027                BundleKind::AccountState
3028            )
3029            .unwrap(),
3030            [(100, 199), (200, 250)]
3031        );
3032    }
3033
3034    #[test]
3035    fn a_replay_split_tiles_a_one_slot_recording() {
3036        // A series whose last split begins on the requested end records exactly
3037        // one slot.
3038        let ranges = vec![account_state_range(100, 249), account_state_range(250, 250)];
3039        assert_eq!(
3040            split_range(
3041                &ranges,
3042                SlotRange::new_unchecked(100, 250),
3043                BundleKind::AccountState
3044            )
3045            .unwrap(),
3046            [(100, 249), (250, 250)]
3047        );
3048    }
3049
3050    #[test]
3051    fn a_replay_split_anchors_a_mid_recording_start() {
3052        // A mid-bundle start anchors to the bundle covering it, whichever kind
3053        // the walk rides — the two differ in the bundles they read, not in the
3054        // ranges they admit.
3055        let requested = SlotRange::new_unchecked(150, 199);
3056        assert_eq!(
3057            split_range(&[range(100, 199)], requested, BundleKind::Blocks).unwrap(),
3058            [(100, 199)]
3059        );
3060        assert_eq!(
3061            split_range(
3062                &[account_state_range(100, 199)],
3063                requested,
3064                BundleKind::AccountState
3065            )
3066            .unwrap(),
3067            [(100, 199)]
3068        );
3069    }
3070
3071    #[test]
3072    fn a_replay_split_refuses_a_start_no_recording_covers() {
3073        let error = split_range(
3074            &[account_state_range(200, 299)],
3075            SlotRange::new_unchecked(100, 299),
3076            BundleKind::AccountState,
3077        )
3078        .expect_err("no recording covers 100");
3079        assert!(
3080            error.contains("is not covered by any available account-state bundle"),
3081            "{error}"
3082        );
3083    }
3084
3085    #[test]
3086    fn a_replay_split_ignores_block_ranges() {
3087        let error = split_range(
3088            &[range(100, 199)],
3089            SlotRange::new_unchecked(100, 199),
3090            BundleKind::AccountState,
3091        )
3092        .expect_err("owns no recordings");
3093        assert!(error.contains("account-state bundle"), "{error}");
3094    }
3095
3096    #[test]
3097    fn blocks_mode_admits_ranges_from_a_server_that_predates_kind() {
3098        // A server predating the field omits it, and those ranges must still
3099        // split exactly as they did before the kind was consulted.
3100        let unlabelled = range(100, 199);
3101        assert_eq!(unlabelled.kind, None);
3102        assert_eq!(
3103            split_range(
3104                &[unlabelled],
3105                SlotRange::new_unchecked(100, 150),
3106                BundleKind::Blocks
3107            )
3108            .unwrap(),
3109            [(100, 150)]
3110        );
3111    }
3112
3113    /// Servable end per bundle start, as `split_range` sees it — every advertised
3114    /// end at or past its start, keyed by start.
3115    fn ends_by_start(ranges: &[AvailableRange]) -> BTreeMap<u64, BTreeSet<u64>> {
3116        let mut ends: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
3117        for r in ranges {
3118            if let Some(end) = r.max_bundle_end_slot
3119                && end >= r.bundle_start_slot
3120            {
3121                ends.entry(r.bundle_start_slot).or_default().insert(end);
3122            }
3123        }
3124        ends
3125    }
3126
3127    /// Independent spec for the optimum: the gap-free split of `[start, end]` with
3128    /// the most sub-ranges, found by exhaustively trying every advertised end at
3129    /// each cursor. `None` when no split aligns with the bundle starts.
3130    fn reference_max_split(
3131        ends: &BTreeMap<u64, BTreeSet<u64>>,
3132        cursor: u64,
3133        end: u64,
3134    ) -> Option<Vec<(u64, u64)>> {
3135        ends.get(&cursor)?
3136            .iter()
3137            .filter_map(|&bundle_end| {
3138                if bundle_end >= end {
3139                    Some(vec![(cursor, end)])
3140                } else {
3141                    reference_max_split(ends, bundle_end + 1, end).map(|mut rest| {
3142                        rest.insert(0, (cursor, bundle_end));
3143                        rest
3144                    })
3145                }
3146            })
3147            .max_by_key(Vec::len)
3148    }
3149
3150    /// Structure-independent check that a split is one the server can serve: it
3151    /// tiles `[start, end]` gap-free, every start is a real bundle start, and
3152    /// every end is within that start's largest advertised end.
3153    fn is_valid_split(
3154        split: &[(u64, u64)],
3155        ends: &BTreeMap<u64, BTreeSet<u64>>,
3156        start: u64,
3157        end: u64,
3158    ) -> bool {
3159        split.first().is_some_and(|&(s, _)| s == start)
3160            && split.last().is_some_and(|&(_, e)| e == end)
3161            && split.windows(2).all(|w| w[1].0 == w[0].1 + 1)
3162            && split.iter().all(|&(s, e)| {
3163                e >= s
3164                    && ends
3165                        .get(&s)
3166                        .and_then(|bundle_ends| bundle_ends.iter().next_back())
3167                        .is_some_and(|&max_end| e <= max_end)
3168            })
3169    }
3170
3171    /// Property test: across many randomized bundle layouts, `split_range` must
3172    /// return a *valid* split with the *maximum* number of sub-ranges whenever one
3173    /// exists, and error exactly when none does. Deterministic (fixed LCG seed) so
3174    /// a failure is reproducible.
3175    #[test]
3176    fn split_range_matches_reference() {
3177        let mut seed: u64 = 0x9E3779B97F4A7C15;
3178        let mut next = || {
3179            seed = seed
3180                .wrapping_mul(6364136223846793005)
3181                .wrapping_add(1442695040888963407);
3182            seed >> 33
3183        };
3184
3185        for _ in 0..50_000 {
3186            // A small slot universe makes shared starts, overlaps, nesting, and
3187            // gaps all common.
3188            let ranges: Vec<AvailableRange> = (0..next() % 6)
3189                .map(|_| {
3190                    let start = next() % 12;
3191                    range(start, start + next() % 6) // len 0 => filtered out
3192                })
3193                .collect();
3194            let start = next() % 12;
3195            let end = start + next() % 6; // sometimes start == end, sometimes unservable
3196
3197            let got = split_range(
3198                &ranges,
3199                SlotRange::new_unchecked(start, end),
3200                BundleKind::Blocks,
3201            );
3202            let ends = ends_by_start(&ranges);
3203            // split_range anchors a mid-bundle start to the covering bundle's
3204            // start, so the reference must walk from the same anchor.
3205            let anchor = ends
3206                .range(..=start)
3207                .rfind(|(_, e)| e.iter().next_back().is_some_and(|&x| x >= start))
3208                .map(|(&s, _)| s);
3209            let reference = anchor.and_then(|a| reference_max_split(&ends, a, end));
3210
3211            let layout: Vec<_> = ranges
3212                .iter()
3213                .map(|r| (r.bundle_start_slot, r.max_bundle_end_slot))
3214                .collect();
3215            match (&got, &reference) {
3216                (Ok(split), Some(best)) => {
3217                    assert!(
3218                        is_valid_split(split, &ends, anchor.unwrap(), end),
3219                        "invalid split {split:?} for {layout:?} [{start},{end}]"
3220                    );
3221                    assert_eq!(
3222                        split.len(),
3223                        best.len(),
3224                        "suboptimal split {split:?} vs {best:?} for {layout:?} [{start},{end}]"
3225                    );
3226                }
3227                (Err(_), None) => {}
3228                _ => panic!(
3229                    "disagreement: split_range={got:?}, reference={reference:?} for {layout:?} [{start},{end}]"
3230                ),
3231            }
3232        }
3233    }
3234}