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