Skip to main content

simulator_api/
lib.rs

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