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