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