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 are session-internal and land in the analysis DB.
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}
1634
1635/// One template's share of the direct-fill census; the flat `direct_fill_*` counters on
1636/// [`RerouteStatsReport`] are the sums of these.
1637#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1638#[serde(rename_all = "camelCase")]
1639pub struct DirectFillTemplateReport {
1640    pub matched: u64,
1641    pub outcomes: BTreeMap<String, u64>,
1642    pub scored: u64,
1643    pub bps_total: i64,
1644}
1645
1646/// Summary of transaction execution statistics for a completed backtest session.
1647#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1648#[serde(rename_all = "camelCase")]
1649pub struct SessionSummary {
1650    /// Number of simulations where simulator outcome matched on-chain outcome
1651    /// (`true_success + true_failure`).
1652    pub correct_simulation: usize,
1653    /// Number of simulations where simulator outcome did not match on-chain outcome
1654    /// (`false_success + false_failure`).
1655    pub incorrect_simulation: usize,
1656    /// Number of transactions that had execution errors in simulation.
1657    pub execution_errors: usize,
1658    /// Number of transactions with different balance diffs.
1659    pub balance_diff: usize,
1660    /// Number of transactions with different log diffs.
1661    pub log_diff: usize,
1662    /// Transactions skipped because they could not be sanitized (malformed message or
1663    /// unresolvable address lookup table).
1664    #[serde(default)]
1665    pub unsanitizable: usize,
1666    /// Aggregate reroute metrics, populated only for reroute-order-flow sessions. Boxed to
1667    /// keep `SessionSummary` small in the many response enums that carry it.
1668    #[serde(default, skip_serializing_if = "Option::is_none")]
1669    pub reroute_stats: Option<Box<RerouteStatsReport>>,
1670}
1671
1672impl SessionSummary {
1673    /// Returns true if there were any execution deviations (errors or mismatched results).
1674    pub fn has_deviations(&self) -> bool {
1675        self.incorrect_simulation > 0 || self.execution_errors > 0 || self.balance_diff > 0
1676    }
1677
1678    /// Total number of transactions processed.
1679    pub fn total_transactions(&self) -> usize {
1680        self.correct_simulation
1681            + self.incorrect_simulation
1682            + self.execution_errors
1683            + self.balance_diff
1684            + self.log_diff
1685    }
1686
1687    /// Renders the compact "N incorrect simulations, N execution errors, ..." deviation list
1688    /// shared by console reporters. `balance_label` / `log_label` let each caller keep its own
1689    /// historical wording for the trailing two counts (e.g. "balance diffs" vs
1690    /// "different balances").
1691    pub fn deviation_details(&self, balance_label: &str, log_label: &str) -> String {
1692        format!(
1693            "{} incorrect simulations, {} execution errors, {} {balance_label}, {} {log_label}",
1694            self.incorrect_simulation, self.execution_errors, self.balance_diff, self.log_diff,
1695        )
1696    }
1697}
1698
1699impl std::fmt::Display for SessionSummary {
1700    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1701        let total = self.total_transactions();
1702        write!(
1703            f,
1704            "Session summary: {total} transactions\n\
1705             \x20  - {} correct simulation\n\
1706             \x20  - {} incorrect simulation\n\
1707             \x20  - {} execution errors\n\
1708             \x20  - {} unsanitizable\n\
1709             \x20  - {} balance diffs\n\
1710             \x20  - {} log diffs",
1711            self.correct_simulation,
1712            self.incorrect_simulation,
1713            self.execution_errors,
1714            self.unsanitizable,
1715            self.balance_diff,
1716            self.log_diff,
1717        )
1718    }
1719}
1720
1721/// Error variants surfaced to backtest RPC clients.
1722#[derive(Debug, Clone, Serialize, Deserialize)]
1723#[serde(rename_all = "camelCase")]
1724pub enum BacktestError {
1725    InvalidTransactionEncoding {
1726        index: usize,
1727        error: String,
1728    },
1729    InvalidTransactionFormat {
1730        index: usize,
1731        error: String,
1732    },
1733    InvalidAccountEncoding {
1734        address: String,
1735        encoding: BinaryEncoding,
1736        error: String,
1737    },
1738    InvalidAccountOwner {
1739        address: String,
1740        error: String,
1741    },
1742    InvalidAccountPubkey {
1743        address: String,
1744        error: String,
1745    },
1746    NoMoreBlocks,
1747    BundleNotFound {
1748        start_slot: u64,
1749        end_slot: u64,
1750    },
1751    AdvanceSlotFailed {
1752        slot: u64,
1753        error: String,
1754    },
1755    FinalizeSlotFailed {
1756        slot: u64,
1757        error: String,
1758    },
1759    InvalidRequest {
1760        error: String,
1761    },
1762    Internal {
1763        error: String,
1764    },
1765    InvalidBlockhashFormat {
1766        slot: u64,
1767        error: String,
1768    },
1769    InitializingSysvarsFailed {
1770        slot: u64,
1771        error: String,
1772    },
1773    ClerkError {
1774        error: String,
1775    },
1776    SimulationError {
1777        error: String,
1778    },
1779    SessionNotFound {
1780        session_id: String,
1781    },
1782    SessionOwnerMismatch,
1783    /// Session ownership is in transition (e.g. the previous manager is
1784    /// shutting down, or another attach raced this one). Clients should retry
1785    /// the attach within their reconnect budget; the route is expected to
1786    /// become claimable shortly.
1787    SessionOwnershipBusy {
1788        reason: String,
1789    },
1790    /// The requested range contains no produced blocks (every slot in it was
1791    /// skipped on-chain), so there is nothing to execute. A user error: the
1792    /// session cannot start and the range must be adjusted.
1793    // Appended last: the manager<->runtime hop is postcard, where variant
1794    // order is wire-significant across version skew.
1795    NoProducedBlocksInRange {
1796        start_slot: u64,
1797        end_slot: u64,
1798    },
1799}
1800
1801impl BacktestError {
1802    /// Errors the backtest cannot recover from mid-session — the single source
1803    /// of truth for terminal-error classification, shared by the response/event
1804    /// wrappers and the managed client.
1805    pub fn is_terminal(&self) -> bool {
1806        matches!(
1807            self,
1808            BacktestError::NoMoreBlocks
1809                | BacktestError::BundleNotFound { .. }
1810                | BacktestError::AdvanceSlotFailed { .. }
1811                | BacktestError::FinalizeSlotFailed { .. }
1812                | BacktestError::Internal { .. }
1813                | BacktestError::NoProducedBlocksInRange { .. }
1814        )
1815    }
1816}
1817
1818/// One contiguous block range available on the history clerk.
1819#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1820pub struct AvailableRange {
1821    pub bundle_start_slot: u64,
1822    pub bundle_start_slot_utc: Option<String>,
1823    pub max_bundle_end_slot: Option<u64>,
1824    pub max_bundle_end_slot_utc: Option<String>,
1825    pub max_bundle_size: Option<u64>,
1826    /// What the range can be used for. `None` from a server that predates the
1827    /// field, where every listed range is [`BundleKind::Blocks`].
1828    #[serde(default)]
1829    pub kind: Option<BundleKind>,
1830    /// Who the range is available to. `None` from a server that predates the
1831    /// field.
1832    #[serde(default)]
1833    pub scope: Option<RangeScope>,
1834}
1835
1836/// Who a listed range is available to.
1837#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1838#[serde(rename_all = "snake_case")]
1839pub enum RangeScope {
1840    /// Available to every caller.
1841    Global,
1842    /// Built for the calling API key, and listed only for that key.
1843    User,
1844}
1845
1846impl RangeScope {
1847    /// Stable string form matching the serde `snake_case` wire representation.
1848    pub fn as_str(self) -> &'static str {
1849        match self {
1850            Self::Global => "global",
1851            Self::User => "user",
1852        }
1853    }
1854}
1855
1856/// What a bundle build produces.
1857#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1858#[serde(rename_all = "snake_case")]
1859pub enum BundleKind {
1860    /// The block bundle a session replays transactions from.
1861    #[default]
1862    Blocks,
1863    /// An account-state bundle covering exactly the requested range, which
1864    /// later sessions replay instead of executing transactions. Builds the
1865    /// block bundle it needs first.
1866    AccountState,
1867}
1868
1869/// Why a replayed range has to begin where a recording does. Shared so the
1870/// splitter's refusal and the service's single-session refusal cannot drift
1871/// apart.
1872pub const REPLAY_RANGE_ADVICE: &str = "replayed state is resolved by the recording that begins \
1873     where the request does, so request a range that begins where one was recorded and ends \
1874     within it";
1875
1876impl BundleKind {
1877    /// What a caller is short of, named for a coverage error.
1878    fn coverage_noun(self) -> &'static str {
1879        match self {
1880            Self::Blocks => "bundle range",
1881            Self::AccountState => "account-state bundle",
1882        }
1883    }
1884
1885    /// [`Self::coverage_noun`] pluralised, for a message about several.
1886    fn coverage_nouns(self) -> &'static str {
1887        match self {
1888            Self::Blocks => "bundle ranges",
1889            Self::AccountState => "account-state bundles",
1890        }
1891    }
1892
1893    /// Stable string form matching the serde `snake_case` wire representation.
1894    pub fn as_str(self) -> &'static str {
1895        match self {
1896            Self::Blocks => "blocks",
1897            Self::AccountState => "account_state",
1898        }
1899    }
1900
1901    /// Inverse of [`Self::as_str`]; returns `None` for an unrecognized value.
1902    pub fn from_str_opt(value: &str) -> Option<Self> {
1903        match value {
1904            "blocks" => Some(Self::Blocks),
1905            "account_state" => Some(Self::AccountState),
1906            _ => None,
1907        }
1908    }
1909}
1910
1911/// Spelled for the reader, not the wire: `blocks` shows as `transactions`.
1912/// Use [`BundleKind::as_str`] where the wire spelling is required.
1913impl fmt::Display for BundleKind {
1914    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1915        let label = match self {
1916            Self::Blocks => "transactions",
1917            Self::AccountState => "account_state",
1918        };
1919        f.pad(label)
1920    }
1921}
1922
1923/// Which user-triggered job a stored request belongs to.
1924///
1925/// One request table serves both, and they carry different fields beside the range they share,
1926/// so a reader has to know which shape it holds.
1927#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1928#[serde(rename_all = "snake_case")]
1929pub enum JobKind {
1930    /// A bundle build: `POST /build-bundle`.
1931    #[default]
1932    BundleBuild,
1933    /// A venue-depth dataset run: `POST /generate-depth`.
1934    Depth,
1935}
1936
1937impl JobKind {
1938    /// The wire and storage spelling.
1939    pub fn as_str(self) -> &'static str {
1940        match self {
1941            Self::BundleBuild => "bundle_build",
1942            Self::Depth => "depth",
1943        }
1944    }
1945
1946    /// Inverse of [`Self::as_str`]; returns `None` for an unrecognized value.
1947    pub fn from_str_opt(value: &str) -> Option<Self> {
1948        match value {
1949            "bundle_build" => Some(Self::BundleBuild),
1950            "depth" => Some(Self::Depth),
1951            _ => None,
1952        }
1953    }
1954}
1955
1956/// Which side of a venue a depth run measures.
1957///
1958/// The two sources publish separate datasets over the same range, so this picks the dataset as
1959/// much as the measurement.
1960#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1961#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
1962#[serde(rename_all = "snake_case")]
1963pub enum DepthSource {
1964    /// What the venues' own on-chain liquidity would have filled.
1965    #[default]
1966    Onchain,
1967    /// What Jupiter Metis would have quoted.
1968    Router,
1969}
1970
1971impl DepthSource {
1972    /// Stable string form matching the serde wire representation.
1973    pub const fn as_str(self) -> &'static str {
1974        match self {
1975            Self::Onchain => "onchain",
1976            Self::Router => "router",
1977        }
1978    }
1979
1980    /// Inverse of [`Self::as_str`]; returns `None` for an unrecognized value.
1981    pub fn from_str_opt(value: &str) -> Option<Self> {
1982        match value {
1983            "onchain" => Some(Self::Onchain),
1984            "router" => Some(Self::Router),
1985            _ => None,
1986        }
1987    }
1988
1989    /// A frozen identity for hashing, deliberately divorced from [`Self::as_str`].
1990    ///
1991    /// Request ids are derived from it, so renaming what callers see must never move them, or a
1992    /// resubmit of the same measurement would trigger a second run they pay for again.
1993    pub const fn discriminant(self) -> u8 {
1994        match self {
1995            Self::Onchain => 0,
1996            Self::Router => 1,
1997        }
1998    }
1999}
2000
2001impl fmt::Display for DepthSource {
2002    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2003        f.write_str(self.as_str())
2004    }
2005}
2006
2007/// What a depth request will actually sample, per source.
2008///
2009/// The universe is fixed server-side — a caller chooses a range and a source, not markets or
2010/// venues — so this is how they see the coverage before spending a run on it.
2011#[derive(Debug, Clone, Serialize, Deserialize)]
2012pub struct DepthCoverageResponse {
2013    pub sources: Vec<DepthSourceCoverage>,
2014}
2015
2016#[derive(Debug, Clone, Serialize, Deserialize)]
2017pub struct DepthSourceCoverage {
2018    /// Which side of the venue this one measures.
2019    pub source: DepthSource,
2020    /// Bumping this in the universe re-samples every reachable range, so it also
2021    /// partitions the published dataset.
2022    pub revision: u16,
2023    /// Market ids, as the dataset's `market` partition spells them.
2024    pub markets: Vec<String>,
2025    /// Venue labels, as the dataset's `venue` partition spells them.
2026    pub venues: Vec<String>,
2027    /// How many sizes each direction is sampled at.
2028    pub size_rungs: usize,
2029    /// Ranges this caller has already published under the current revision.
2030    /// Re-requesting one republishes it rather than erroring.
2031    pub completed_ranges: Vec<SlotRange>,
2032}
2033
2034/// Request body of `POST /generate-depth`.
2035#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2036pub struct DepthRequest {
2037    /// First slot to sample, inclusive. The UTC date it falls on becomes the
2038    /// dataset's partition key.
2039    pub start_slot: u64,
2040    /// Last slot to sample, inclusive.
2041    pub end_slot: u64,
2042    /// Which side of the venue to measure. The two sources publish separate
2043    /// datasets over the same range.
2044    #[serde(default)]
2045    pub source: DepthSource,
2046    /// Market ids to sample. Empty samples every market in the source's universe.
2047    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2048    pub markets: Vec<String>,
2049}
2050
2051/// Response body of `POST /generate-depth` and `GET /generate-depth/{request_id}`.
2052#[derive(Debug, Clone, Serialize, Deserialize)]
2053pub struct DepthStatusResponse {
2054    pub request_id: String,
2055    pub start_slot: u64,
2056    pub end_slot: u64,
2057    pub source: DepthSource,
2058    pub status: BundleBuildStatus,
2059}
2060
2061/// Request body of `POST /build-bundle`.
2062///
2063/// Construct via `..Default::default()` so a new field stays source-compatible.
2064#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2065pub struct BundleBuildRequest {
2066    pub start_slot: u64,
2067    pub end_slot: u64,
2068    #[serde(default)]
2069    pub bundle_size: Option<u64>,
2070    /// Optional repeat key; a resubmit dedupes the quota charge. The same key
2071    /// under a different `kind` is a distinct request.
2072    #[serde(default)]
2073    pub idempotency_key: Option<String>,
2074    /// Absent on the wire means [`BundleKind::Blocks`].
2075    #[serde(default)]
2076    pub kind: BundleKind,
2077}
2078
2079#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2080#[serde(rename_all = "snake_case")]
2081pub enum BundleBuildStatus {
2082    /// The bundle was built and is ready to use.
2083    Completed,
2084    /// The build failed and will not produce a bundle.
2085    Failed,
2086    /// The build has not finished yet; poll the status endpoint again later.
2087    InProgress,
2088    /// The outcome of the build could not be determined; do not assume success
2089    /// or failure.
2090    NeedsInvestigation,
2091}
2092
2093impl BundleBuildStatus {
2094    /// Stable string form matching the serde `snake_case` wire representation.
2095    pub fn as_str(self) -> &'static str {
2096        match self {
2097            Self::Completed => "completed",
2098            Self::Failed => "failed",
2099            Self::InProgress => "in_progress",
2100            Self::NeedsInvestigation => "needs_investigation",
2101        }
2102    }
2103}
2104
2105/// Response of the management bundle-build endpoints.
2106#[derive(Debug, Clone, Serialize, Deserialize)]
2107pub struct BundleBuildStatusResponse {
2108    pub request_id: String,
2109    pub start_slot: u64,
2110    pub end_slot: u64,
2111    pub bundle_size: Option<u64>,
2112    pub status: BundleBuildStatus,
2113    /// When the request id was created (unix ms) — lets clients show a live ETA
2114    /// countdown by computing elapsed since this.
2115    pub created_at_unix_ms: u64,
2116    /// Absent on records predating the field, which are all `blocks`.
2117    #[serde(default)]
2118    pub kind: BundleKind,
2119    /// Server-side estimate of the seconds left on an in-progress build.
2120    /// `None` on a build that isn't running or if inputs can't be resolved.
2121    #[serde(default)]
2122    pub eta_secs: Option<u64>,
2123}
2124
2125/// A bundle the caller owns and can use now. Ordered by slot range, then kind.
2126#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
2127pub struct CompletedBundle {
2128    pub start_slot: u64,
2129    pub end_slot: u64,
2130    pub kind: BundleKind,
2131    /// When the bundle became available (unix ms), where known.
2132    #[serde(default)]
2133    pub completed_at_unix_ms: Option<i64>,
2134    #[serde(default)]
2135    pub size_bytes: Option<i64>,
2136}
2137
2138/// Query of `DELETE /bundles`: the bundle to delete, by kind and exact range.
2139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2140pub struct BundleDeleteRequest {
2141    pub start_slot: u64,
2142    pub end_slot: u64,
2143    /// Absent means [`BundleKind::Blocks`].
2144    #[serde(default)]
2145    pub kind: BundleKind,
2146}
2147
2148/// What `DELETE /bundles` removed.
2149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2150pub struct BundleDeleteResponse {
2151    pub start_slot: u64,
2152    pub end_slot: u64,
2153    pub kind: BundleKind,
2154}
2155
2156/// Response of `GET /build-bundle`.
2157#[derive(Debug, Clone, Serialize, Deserialize)]
2158pub struct BundleBuildListResponse {
2159    /// The caller's own builds, newest first. A build belongs to the endpoint
2160    /// running it, so this covers only builds requested here.
2161    pub builds: Vec<BundleBuildStatusResponse>,
2162    /// The caller's own bundles, ascending by range, wherever they were built.
2163    /// Not a view of `builds`: one build can produce several bundles, and a
2164    /// bundle outlives the record of the build that made it. `None` when the
2165    /// listing was unavailable, as opposed to `Some([])` for owning nothing.
2166    #[serde(default)]
2167    pub bundles: Option<Vec<CompletedBundle>>,
2168}
2169
2170/// Response of the management `/snapshot-slot` endpoint. The chosen source and
2171/// import state are internal, so only the resolved slot is exposed.
2172#[derive(Debug, Clone, Serialize, Deserialize)]
2173pub struct SnapshotSlotResponse {
2174    pub snapshot_slot: u64,
2175}
2176
2177/// Split a user-requested `[start_slot, end_slot]` range across the available
2178/// bundle ranges, returning a list of contiguous, non-overlapping `(start, end)`
2179/// pairs — one per bundle that the server can serve as its own session.
2180///
2181/// Each emitted `start` is a real `bundle_start_slot` and each `end` is within
2182/// the `max_bundle_end_slot` of a bundle that begins *exactly* at that start
2183/// (the server requires `start_slot == bundle_start_slot` and caps `end_slot` at
2184/// that bundle's `max_bundle_end_slot`). Among all such gap-free splits we pick
2185/// the one with the most sub-ranges, i.e. the highest parallelism: a fine-grained
2186/// (e.g. 10k) bundle grid yields one session per fine bundle instead of
2187/// collapsing onto a coarser series that overlaps the same slots — even when the
2188/// coarse and fine bundles share a start slot. A coarser bundle is only ridden
2189/// where no finer grid continues the walk, which still lets us serve a request
2190/// the coarse data covers even across a hole in the fine grid.
2191///
2192/// `kind` picks which bundles the split rides; the walk itself is the same for
2193/// either, since a recording is read slot by slot exactly as a block bundle is.
2194/// An unlabelled range, from a server predating [`AvailableRange::kind`],
2195/// counts as [`BundleKind::Blocks`].
2196///
2197/// Returns an error if the requested start cannot anchor, or if no gap-free
2198/// split reaches `requested_end`.
2199pub fn split_range(
2200    ranges: &[AvailableRange],
2201    requested: SlotRange,
2202    kind: BundleKind,
2203) -> Result<Vec<(u64, u64)>, String> {
2204    let (requested_start, requested_end) = (requested.start(), requested.end());
2205
2206    // Every advertised end per bundle start. A start can carry several ends — a
2207    // 50k snapshot bundle and the 10k bundle built on the same snapshot share a
2208    // start slot but reach different ends — so we keep them all and let the walk
2209    // pick whichever maximises parallelism.
2210    // A one-slot bundle (`end == start`) counts: a series whose last split
2211    // begins on the requested end records exactly one slot.
2212    let ends_by_start = ranges
2213        .iter()
2214        .filter(|r| r.kind.unwrap_or_default() == kind)
2215        .filter_map(|r| {
2216            r.max_bundle_end_slot
2217                .filter(|&end| end >= r.bundle_start_slot)
2218                .map(|end| (r.bundle_start_slot, end))
2219        })
2220        .fold(
2221            BTreeMap::<u64, BTreeSet<u64>>::new(),
2222            |mut ends_by_start, (start, end)| {
2223                ends_by_start.entry(start).or_default().insert(end);
2224                ends_by_start
2225            },
2226        );
2227
2228    // Anchor a request that lands mid-bundle to the covering bundle's start —
2229    // the latest bundle beginning at or before `requested_start` that still
2230    // covers it. The server's `select_parallel_requests` shares this fn and
2231    // anchors the same way, so the client must too or it would falsely reject a
2232    // start that isn't itself a bundle boundary. An exact bundle-start request
2233    // anchors to itself. (Single sessions instead require an exact bundle start
2234    // for every kind: `evaluate_requested_range`.)
2235    let Some((&anchor_start, _)) = ends_by_start.range(..=requested_start).rfind(|(_, ends)| {
2236        ends.iter()
2237            .next_back()
2238            .is_some_and(|&end| end >= requested_start)
2239    }) else {
2240        return Err(format!(
2241            "start_slot {requested_start} is not covered by any available {}",
2242            kind.coverage_noun()
2243        ));
2244    };
2245
2246    // Walk the bundle starts from `requested_end` backwards, recording for each
2247    // start the gap-free split with the most sub-ranges (highest parallelism).
2248    // A sub-range ends within one of its start's bundles and the next sub-range
2249    // must begin on the very next slot, so a coarse bundle is ridden only when no
2250    // finer bundle continues the walk.
2251    let mut best_from: BTreeMap<u64, Vec<(u64, u64)>> = BTreeMap::new();
2252    for (&start, ends) in ends_by_start.range(anchor_start..=requested_end).rev() {
2253        let mut best: Option<Vec<(u64, u64)>> = None;
2254        for &end in ends {
2255            let candidate = if end >= requested_end {
2256                Some(vec![(start, requested_end)])
2257            } else {
2258                best_from.get(&(end + 1)).map(|rest| {
2259                    std::iter::once((start, end))
2260                        .chain(rest.iter().copied())
2261                        .collect()
2262                })
2263            };
2264            if let Some(candidate) = candidate
2265                && best.as_ref().is_none_or(|b| candidate.len() > b.len())
2266            {
2267                best = Some(candidate);
2268            }
2269        }
2270        if let Some(best) = best {
2271            best_from.insert(start, best);
2272        }
2273    }
2274
2275    best_from.remove(&anchor_start).ok_or_else(|| {
2276        // Point at the first uncovered slot when the bundles leave a hole; fall
2277        // back to a generic message when the range is covered but cannot be
2278        // tiled to bundle boundaries.
2279        let mut covered_to = anchor_start.saturating_sub(1);
2280        for (&start, ends) in ends_by_start.range(anchor_start..=requested_end) {
2281            if start > covered_to.saturating_add(1) {
2282                break;
2283            }
2284            if let Some(&end) = ends.iter().next_back() {
2285                covered_to = covered_to.max(end);
2286            }
2287        }
2288        if covered_to < requested_end {
2289            format!("gap in coverage at slot {}", covered_to + 1)
2290        } else {
2291            format!(
2292                "no gap-free split of [{requested_start}, {requested_end}] aligns with the available {}",
2293                kind.coverage_nouns()
2294            )
2295        }
2296    })
2297}
2298
2299impl From<BacktestError> for BacktestResponse {
2300    fn from(error: BacktestError) -> Self {
2301        Self::Error(error)
2302    }
2303}
2304
2305impl std::error::Error for BacktestError {}
2306
2307impl fmt::Display for BacktestError {
2308    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2309        match self {
2310            BacktestError::InvalidTransactionEncoding { index, error } => {
2311                write!(f, "invalid transaction encoding at index {index}: {error}")
2312            }
2313            BacktestError::InvalidTransactionFormat { index, error } => {
2314                write!(f, "invalid transaction format at index {index}: {error}")
2315            }
2316            BacktestError::InvalidAccountEncoding {
2317                address,
2318                encoding,
2319                error,
2320            } => write!(
2321                f,
2322                "invalid encoding for account {address} ({encoding:?}): {error}"
2323            ),
2324            BacktestError::InvalidAccountOwner { address, error } => {
2325                write!(f, "invalid owner for account {address}: {error}")
2326            }
2327            BacktestError::InvalidAccountPubkey { address, error } => {
2328                write!(f, "invalid account pubkey {address}: {error}")
2329            }
2330            BacktestError::NoMoreBlocks => write!(f, "no more blocks available"),
2331            BacktestError::BundleNotFound {
2332                start_slot,
2333                end_slot,
2334            } => write!(
2335                f,
2336                "block bundle not found for start_slot={start_slot}, end_slot={end_slot}"
2337            ),
2338            BacktestError::AdvanceSlotFailed { slot, error } => {
2339                write!(f, "failed to advance to slot {slot}: {error}")
2340            }
2341            BacktestError::FinalizeSlotFailed { slot, error } => {
2342                write!(f, "failed to finalize slot {slot}: {error}")
2343            }
2344            BacktestError::InvalidRequest { error } => write!(f, "invalid request: {error}"),
2345            BacktestError::Internal { error } => write!(f, "internal error: {error}"),
2346            BacktestError::InvalidBlockhashFormat { slot, error } => {
2347                write!(f, "invalid blockhash at slot {slot}: {error}")
2348            }
2349            BacktestError::InitializingSysvarsFailed { slot, error } => {
2350                write!(f, "failed to initialize sysvars at slot {slot}: {error}")
2351            }
2352            BacktestError::ClerkError { error } => write!(f, "clerk error: {error}"),
2353            BacktestError::SimulationError { error } => {
2354                write!(f, "simulation error: {error}")
2355            }
2356            BacktestError::SessionNotFound { session_id } => {
2357                write!(f, "session not found: {session_id}")
2358            }
2359            BacktestError::SessionOwnerMismatch => {
2360                write!(f, "session owner mismatch")
2361            }
2362            BacktestError::SessionOwnershipBusy { reason } => {
2363                write!(f, "session ownership busy: {reason}")
2364            }
2365            BacktestError::NoProducedBlocksInRange {
2366                start_slot,
2367                end_slot,
2368            } => write!(
2369                f,
2370                "no produced blocks in requested range {start_slot}..={end_slot}; \
2371                 slot(s) may be skipped on-chain"
2372            ),
2373        }
2374    }
2375}
2376
2377#[cfg(test)]
2378mod tests {
2379    use rstest::rstest;
2380    use solana_account::ReadableAccount;
2381
2382    use super::*;
2383
2384    /// Request ids are derived from these, so they are a wire contract: reordering the variants
2385    /// or renumbering them would silently re-issue every caller's measurement as a second run.
2386    #[test]
2387    fn a_sources_discriminant_is_frozen_independently_of_its_label() {
2388        assert_eq!(DepthSource::Onchain.discriminant(), 0);
2389        assert_eq!(DepthSource::Router.discriminant(), 1);
2390    }
2391
2392    /// `as_str`, `from_str_opt` and serde are three tables over the same two names; one of them
2393    /// drifting would change what a stored row or a request body means.
2394    #[test]
2395    fn a_source_spells_itself_the_same_way_three_times() {
2396        for source in [DepthSource::Onchain, DepthSource::Router] {
2397            assert_eq!(
2398                serde_json::to_value(source).expect("a source serialises"),
2399                serde_json::json!(source.as_str())
2400            );
2401            assert_eq!(DepthSource::from_str_opt(source.as_str()), Some(source));
2402        }
2403    }
2404
2405    fn address(byte: u8) -> Address {
2406        Address::from([byte; 32])
2407    }
2408
2409    #[rstest]
2410    #[case::one("jupiter", Some(&[SwapAggregator::Jupiter][..]))]
2411    #[case::several("jupiter,okx", Some(&[SwapAggregator::Jupiter, SwapAggregator::Okx][..]))]
2412    #[case::spaced_and_uppercased(" Jupiter , OKX ", Some(&[SwapAggregator::Jupiter, SwapAggregator::Okx][..]))]
2413    #[case::repeats_collapse("okx,okx", Some(&[SwapAggregator::Okx][..]))]
2414    #[case::trailing_separator("okx,", Some(&[SwapAggregator::Okx][..]))]
2415    #[case::unknown("jupiter,bogus", None)]
2416    #[case::empty("", None)]
2417    #[case::separators_only(",,", None)]
2418    fn reroute_aggregators_parse_the_shapes_a_flag_carries(
2419        #[case] input: &str,
2420        #[case] expected: Option<&[SwapAggregator]>,
2421    ) {
2422        assert_eq!(
2423            input.parse::<RerouteAggregators>().ok(),
2424            expected.map(|aggregators| RerouteAggregators::new(aggregators.iter().copied()))
2425        );
2426    }
2427
2428    /// `Display` renders `--reroute-aggregators`' default, so clap re-parses whatever it prints.
2429    #[test]
2430    fn reroute_aggregators_round_trip_through_their_rendering() {
2431        let aggregators = RerouteAggregators::new([SwapAggregator::Dflow, SwapAggregator::Jupiter]);
2432
2433        assert_eq!(aggregators.to_string(), "jupiter,dflow");
2434        assert_eq!(
2435            aggregators
2436                .to_string()
2437                .parse::<RerouteAggregators>()
2438                .unwrap(),
2439            aggregators
2440        );
2441        assert_eq!(RerouteAggregators::default().to_string(), "jupiter");
2442    }
2443
2444    /// The API deserializes aggregators through the same parser the flag uses, so a spelling one
2445    /// entry point accepts is not a hard error at the other.
2446    #[test]
2447    fn aggregators_deserialize_through_the_same_parser_the_flag_uses() {
2448        let params: CreateSessionParams = serde_json::from_value(
2449            serde_json::json!({"startSlot": 1, "endSlot": 2, "reroute": {"aggregators": ["Jupiter", "OKX"]}}),
2450        )
2451        .expect("decode");
2452
2453        assert_eq!(
2454            params.reroute.expect("rerouting requested").aggregators,
2455            RerouteAggregators::new([SwapAggregator::Jupiter, SwapAggregator::Okx])
2456        );
2457    }
2458
2459    /// A request that names no knob gets the narrow defaults, not the every-aggregator behaviour a
2460    /// client written before this change expected — the switch these defaults are here to make.
2461    #[test]
2462    fn params_without_the_new_reroute_fields_decode_to_the_narrow_defaults() {
2463        let params: CreateSessionParams = serde_json::from_value(
2464            serde_json::json!({"startSlot": 1, "endSlot": 2, "reroute": {}}),
2465        )
2466        .expect("decode");
2467
2468        let reroute = params.reroute.expect("rerouting requested");
2469        assert_eq!(reroute.aggregators, RerouteAggregators::default());
2470        assert!(reroute.aggregators.contains(SwapAggregator::Jupiter));
2471        assert!(!reroute.aggregators.contains(SwapAggregator::Okx));
2472        assert!(!reroute.circular_arbs);
2473        assert!(!reroute.detect_failed_l1_swaps);
2474    }
2475
2476    /// Presence is the switch: a replay that will never quote must not reserve a metis sidecar.
2477    #[test]
2478    fn a_request_without_reroute_reserves_no_sidecar() {
2479        let params: CreateSessionParams =
2480            serde_json::from_value(serde_json::json!({"startSlot": 1, "endSlot": 2}))
2481                .expect("decode");
2482
2483        assert!(!params.requires_sidecar());
2484    }
2485
2486    /// A per-slot override schedule is the largest thing a client sends. Each
2487    /// action alone is incompressible enough to sit near the websocket message
2488    /// limit; the redundancy lives *across* actions, so the whole vector has to
2489    /// travel as one blob. Pins both the round trip and that size property.
2490    #[test]
2491    fn a_per_slot_action_schedule_compresses_across_actions() {
2492        let slots = 2_000u64;
2493        let actions = (0..slots)
2494            .map(|slot| ScheduledAction {
2495                anchor: ActionAnchor::AfterSlot { slots: vec![slot] },
2496                kind: ActionKind::Simulate,
2497                feeds_reroute: false,
2498                transactions: Vec::new(),
2499                account_overrides: AccountModifications(BTreeMap::from([(
2500                    address(7),
2501                    AccountData {
2502                        // Realistic pool account: 2 KiB that barely moves slot to slot.
2503                        data: EncodedBinary::new(
2504                            BASE64.encode(
2505                                [&slot.to_le_bytes()[..], &[0xAB; 2040][..]]
2506                                    .concat()
2507                                    .as_slice(),
2508                            ),
2509                            BinaryEncoding::Base64,
2510                        ),
2511                        executable: false,
2512                        lamports: 15_144_966,
2513                        owner: address(9),
2514                        space: 2048,
2515                    },
2516                )])),
2517                return_accounts: Vec::new(),
2518                label: None,
2519            })
2520            .collect::<Vec<_>>();
2521        let params = CreateSessionParams::builder()
2522            .start_slot(1)
2523            .end_slot(slots)
2524            .actions(actions.clone())
2525            .build();
2526
2527        let encoded = serde_json::to_string(&params).expect("serialize");
2528        let raw = serde_json::to_string(&actions).expect("serialize raw actions");
2529        assert!(
2530            encoded.len() * 8 < raw.len(),
2531            "expected the schedule to compress well; got {} bytes from {} raw",
2532            encoded.len(),
2533            raw.len()
2534        );
2535
2536        let decoded: CreateSessionParams = serde_json::from_str(&encoded).expect("round trip");
2537        assert_eq!(
2538            serde_json::to_string(&decoded.actions).expect("serialize decoded"),
2539            raw
2540        );
2541    }
2542
2543    /// Absent actions stay absent on the wire, so a session without a schedule
2544    /// is unchanged by the compression.
2545    #[test]
2546    fn an_empty_action_list_is_omitted_entirely() {
2547        let params = CreateSessionParams::builder()
2548            .start_slot(1)
2549            .end_slot(2)
2550            .build();
2551        let json = serde_json::to_value(&params).expect("serialize");
2552        assert!(json.get("actions").is_none(), "{json}");
2553        let decoded: CreateSessionParams = serde_json::from_value(json).expect("round trip");
2554        assert!(decoded.actions.is_empty());
2555    }
2556
2557    /// The conflict is `send` *and* replay: neither alone is a problem, and simulate runs
2558    /// fine under replay because it never commits.
2559    #[test]
2560    fn send_actions_conflict_with_replay_only_together() {
2561        let params = |replay, kind| {
2562            CreateSessionParams::builder()
2563                .start_slot(1)
2564                .end_slot(2)
2565                .replay_account_state(replay)
2566                .actions(vec![ScheduledAction {
2567                    anchor: ActionAnchor::BeforeSlot { slots: vec![1] },
2568                    kind,
2569                    feeds_reroute: false,
2570                    transactions: vec!["dGVzdA==".to_string()],
2571                    account_overrides: Default::default(),
2572                    return_accounts: Default::default(),
2573                    label: None,
2574                }])
2575                .build()
2576        };
2577        assert!(replay_support::send_action_conflict(&params(false, ActionKind::Send)).is_none());
2578        assert!(
2579            replay_support::send_action_conflict(&params(true, ActionKind::Simulate)).is_none()
2580        );
2581        let mut publishing = params(true, ActionKind::Simulate);
2582        publishing.actions[0].feeds_reroute = true;
2583        assert!(replay_support::send_action_conflict(&publishing).is_none());
2584
2585        let conflict = replay_support::send_action_conflict(&params(true, ActionKind::Send))
2586            .expect("send under replay conflicts");
2587        assert!(conflict.contains("kind: send"), "{conflict}");
2588        assert!(conflict.contains(replay_support::SEND_ACTION), "{conflict}");
2589    }
2590
2591    fn override_entry(owner: Address, executable: bool) -> AccountData {
2592        AccountData {
2593            data: EncodedBinary::from_bytes(&[1, 2, 3], BinaryEncoding::Base64),
2594            executable,
2595            lamports: 1,
2596            owner,
2597            space: 3,
2598        }
2599    }
2600
2601    fn before() -> ActionAnchor {
2602        ActionAnchor::BeforeSlot { slots: vec![1] }
2603    }
2604
2605    fn valid() -> ScheduledAction {
2606        override_action(before(), address(3), override_entry(address(4), false))
2607    }
2608
2609    fn override_action(
2610        anchor: ActionAnchor,
2611        account: Address,
2612        data: AccountData,
2613    ) -> ScheduledAction {
2614        ScheduledAction {
2615            anchor,
2616            kind: ActionKind::default(),
2617            transactions: Vec::new(),
2618            account_overrides: AccountModifications(BTreeMap::from([(account, data)])),
2619            return_accounts: Vec::new(),
2620            label: None,
2621            feeds_reroute: true,
2622        }
2623    }
2624
2625    #[test]
2626    fn override_action_round_trips() {
2627        let key = address(1);
2628        let entry = override_entry(address(2), false);
2629        let action = override_action(
2630            ActionAnchor::BeforeSlot { slots: vec![5, 9] },
2631            key,
2632            entry.clone(),
2633        );
2634        let json = serde_json::to_value(&action).unwrap();
2635        assert!(
2636            json["anchor"]["slots"].is_string(),
2637            "slots travel as a compressed blob: {json}"
2638        );
2639        let back: ScheduledAction = serde_json::from_value(json).unwrap();
2640        assert_eq!(back.anchor, ActionAnchor::BeforeSlot { slots: vec![5, 9] });
2641        assert!(back.feeds_reroute);
2642        assert!(!back.is_computed_override(), "no transactions to simulate");
2643        let survived = &back.account_overrides.0[&key];
2644        assert_eq!(survived.lamports, entry.lamports);
2645        assert_eq!(survived.data.data, entry.data.data);
2646    }
2647
2648    #[test]
2649    fn an_every_slot_anchor_round_trips_and_defaults_to_every_slot() {
2650        let anchor = ActionAnchor::AfterEverySlot {
2651            every_n_slots: NonZeroU64::new(10).unwrap(),
2652        };
2653        let json = serde_json::to_value(&anchor).unwrap();
2654        assert_eq!(
2655            json,
2656            serde_json::json!({ "at": "afterEverySlot", "everyNSlots": 10 })
2657        );
2658        assert_eq!(
2659            serde_json::from_value::<ActionAnchor>(json).unwrap(),
2660            anchor
2661        );
2662
2663        assert_eq!(
2664            serde_json::from_value::<ActionAnchor>(serde_json::json!({ "at": "beforeEverySlot" }))
2665                .unwrap(),
2666            ActionAnchor::BeforeEverySlot {
2667                every_n_slots: NonZeroU64::MIN
2668            }
2669        );
2670        assert!(
2671            serde_json::from_value::<ActionAnchor>(
2672                serde_json::json!({ "at": "beforeEverySlot", "everyNSlots": 0 })
2673            )
2674            .is_err(),
2675            "a zero cadence names no slot"
2676        );
2677    }
2678
2679    fn context(
2680        invoked: &[u8],
2681        moves_tokens: bool,
2682        written: &[u8],
2683        writes_via_unresolved_lookup: bool,
2684    ) -> TxMatchContext {
2685        TxMatchContext {
2686            invoked_programs: invoked.iter().copied().map(address).collect(),
2687            moves_tokens,
2688            written_accounts: written.iter().copied().map(address).collect(),
2689            writes_via_unresolved_lookup,
2690        }
2691    }
2692
2693    /// The venue lists programs 1 and 2, and a scoped filter names account 10.
2694    #[rstest]
2695    #[case::update(&[], context(&[1], false, &[11], false), true)]
2696    #[case::moves_tokens(&[], context(&[1], true, &[11], false), false)]
2697    #[case::second_program_of_the_set(&[], context(&[2], false, &[11], false), true)]
2698    #[case::other_program(&[], context(&[3], false, &[11], false), false)]
2699    #[case::write_to_the_scope(&[10], context(&[1], false, &[11, 10], false), true)]
2700    #[case::write_elsewhere(&[10], context(&[1], false, &[11], false), false)]
2701    #[case::unresolved_write_through_a_lookup_table(&[10], context(&[1], false, &[11], true), true)]
2702    fn an_oracle_update_is_a_token_free_write_by_a_listed_program(
2703        #[case] accounts: &[u8],
2704        #[case] ctx: TxMatchContext,
2705        #[case] expected: bool,
2706    ) {
2707        let filter = DiscoveryFilter::OracleUpdate {
2708            programs: BTreeSet::from([address(1), address(2)]),
2709            accounts: accounts.iter().copied().map(address).collect(),
2710        };
2711        assert_eq!(filter.matches(&ctx), expected);
2712    }
2713
2714    #[rstest]
2715    #[case::unscoped(
2716        &[],
2717        serde_json::json!({ "kind": "oracleUpdate", "value": { "programs": [address(1).to_string()] } })
2718    )]
2719    #[case::scoped(
2720        &[2],
2721        serde_json::json!({
2722            "kind": "oracleUpdate",
2723            "value": { "programs": [address(1).to_string()], "accounts": [address(2).to_string()] }
2724        })
2725    )]
2726    fn an_oracle_update_filter_round_trips_as_kind_and_value(
2727        #[case] accounts: &[u8],
2728        #[case] expected: serde_json::Value,
2729    ) {
2730        let filter = DiscoveryFilter::OracleUpdate {
2731            programs: BTreeSet::from([address(1)]),
2732            accounts: accounts.iter().copied().map(address).collect(),
2733        };
2734        let json = serde_json::to_value(&filter).unwrap();
2735
2736        assert_eq!(json, expected);
2737        assert_eq!(
2738            serde_json::from_value::<DiscoveryFilter>(json).unwrap(),
2739            filter
2740        );
2741    }
2742
2743    /// A venue's vaults are token accounts, so pricing one against different inventory means
2744    /// overriding them. They were rejected outright on the grounds that prepare-stage reads would
2745    /// desync from simulation — but prepare's only use for them is clerk cache warming, and every
2746    /// simulation applies the schedule on top of whatever that warmed.
2747    #[test]
2748    fn a_token_account_override_is_accepted() {
2749        let action = override_action(
2750            before(),
2751            address(3),
2752            override_entry(spl_token_interface::ID, false),
2753        );
2754        let schedule = decode_override_actions(&[action]).expect("token overrides decode");
2755        assert!(!schedule.active_at(1).is_empty());
2756    }
2757
2758    #[rstest]
2759    #[case::executable(override_action(before(), address(3), override_entry(address(10), true)))]
2760    #[case::program_owned(override_action(
2761        before(),
2762        address(3),
2763        override_entry(solana_account::PROGRAM_OWNERS[0], false),
2764    ))]
2765    #[case::after_slot_anchor(override_action(
2766        ActionAnchor::AfterSlot { slots: vec![1] },
2767        address(3),
2768        override_entry(address(4), false),
2769    ))]
2770    #[case::every_slot_anchor(override_action(
2771        ActionAnchor::BeforeEverySlot { every_n_slots: NonZeroU64::MIN },
2772        address(3),
2773        override_entry(address(4), false),
2774    ))]
2775    #[case::nothing_to_post(ScheduledAction {
2776        account_overrides: AccountModifications::default(),
2777        ..valid()
2778    })]
2779    // Otherwise an action that meant to send would silently post overrides instead.
2780    #[case::send(ScheduledAction {
2781        kind: ActionKind::Send,
2782        ..valid()
2783    })]
2784    #[allow(deprecated, reason = "exercise the loader-owner rejection path")]
2785    fn override_actions_reject_invalid_accounts_and_shapes(#[case] action: ScheduledAction) {
2786        assert!(decode_override_actions(&[action]).is_err());
2787    }
2788
2789    fn diagnostic_probe() -> DiagnosticProbeParams {
2790        DiagnosticProbeParams {
2791            anchor: ActionAnchor::AfterEverySlot {
2792                every_n_slots: NonZeroU64::MIN,
2793            },
2794            markets: vec![address(1), address(2)],
2795            usd_values: vec![100, 25_000],
2796            label: Some("depth-0".to_owned()),
2797        }
2798    }
2799
2800    #[rstest]
2801    #[case::no_markets(DiagnosticProbeParams { markets: Vec::new(), ..diagnostic_probe() })]
2802    #[case::no_usd_values(DiagnosticProbeParams { usd_values: Vec::new(), ..diagnostic_probe() })]
2803    #[case::too_many_markets(DiagnosticProbeParams {
2804        markets: (0..=MAX_DIAGNOSTIC_MARKETS as u8).map(address).collect(),
2805        ..diagnostic_probe()
2806    })]
2807    #[case::too_many_usd_values(DiagnosticProbeParams {
2808        usd_values: (1..=MAX_DIAGNOSTIC_USD_VALUES as u32 + 1).collect(),
2809        ..diagnostic_probe()
2810    })]
2811    #[case::zero_usd_value(DiagnosticProbeParams { usd_values: vec![0, 100], ..diagnostic_probe() })]
2812    #[case::duplicate_market(DiagnosticProbeParams {
2813        markets: vec![address(1), address(1)],
2814        ..diagnostic_probe()
2815    })]
2816    #[case::before_slot(DiagnosticProbeParams {
2817        anchor: ActionAnchor::BeforeSlot { slots: vec![1] },
2818        ..diagnostic_probe()
2819    })]
2820    #[case::before_every_slot(DiagnosticProbeParams {
2821        anchor: ActionAnchor::BeforeEverySlot { every_n_slots: NonZeroU64::MIN },
2822        ..diagnostic_probe()
2823    })]
2824    fn diagnostic_probes_reject_invalid_shapes(#[case] probe: DiagnosticProbeParams) {
2825        assert!(probe.validate().is_err());
2826    }
2827
2828    #[test]
2829    fn diagnostic_probes_accept_both_supported_anchors() {
2830        assert!(diagnostic_probe().validate().is_ok());
2831        assert!(
2832            DiagnosticProbeParams {
2833                anchor: ActionAnchor::AfterSlot { slots: vec![1, 2] },
2834                ..diagnostic_probe()
2835            }
2836            .validate()
2837            .is_ok()
2838        );
2839    }
2840
2841    /// The shapes the rejections above are variations of.
2842    #[test]
2843    fn override_actions_accept_a_static_and_a_computed_shape() {
2844        assert!(decode_override_actions(&[valid()]).is_ok());
2845        // Transactions make the contribution computed rather than static — still valid,
2846        // just not resolvable until the action runs.
2847        assert!(
2848            decode_override_actions(&[ScheduledAction {
2849                transactions: vec!["dGVzdA==".to_string()],
2850                ..valid()
2851            }])
2852            .is_ok()
2853        );
2854    }
2855
2856    #[test]
2857    fn override_actions_merge_account_wise_later_wins() {
2858        let entry = |lamports| AccountData {
2859            lamports,
2860            ..override_entry(address(9), false)
2861        };
2862        let slot_action = |slots, account, lamports| {
2863            override_action(ActionAnchor::BeforeSlot { slots }, account, entry(lamports))
2864        };
2865        let actions = [
2866            slot_action(vec![5, 7], address(1), 1),
2867            slot_action(vec![5], address(2), 2),
2868            slot_action(vec![5], address(1), 3),
2869            ScheduledAction {
2870                anchor: ActionAnchor::AfterSlot { slots: vec![5] },
2871                kind: ActionKind::Simulate,
2872                feeds_reroute: false,
2873                transactions: vec!["dGVzdA==".to_string()],
2874                account_overrides: AccountModifications::default(),
2875                return_accounts: Vec::new(),
2876                label: None,
2877            },
2878        ];
2879        let schedule = decode_override_actions(&actions).unwrap();
2880        assert_eq!(schedule.slots().collect::<Vec<_>>(), vec![5, 7]);
2881        let lamports_at = |key, slot| {
2882            schedule
2883                .active_for(&key, slot)
2884                .map(|account| account.lamports())
2885        };
2886        assert_eq!(lamports_at(address(1), 5), Some(3));
2887        assert_eq!(lamports_at(address(2), 5), Some(2));
2888        assert_eq!(lamports_at(address(1), 7), Some(1));
2889    }
2890
2891    #[test]
2892    fn computed_override_resolution_preserves_action_order_and_failure() {
2893        let key = address(1);
2894        let static_action = |lamports| {
2895            override_action(
2896                ActionAnchor::BeforeSlot { slots: vec![5] },
2897                key,
2898                AccountData {
2899                    lamports,
2900                    ..override_entry(address(9), false)
2901                },
2902            )
2903        };
2904        let computed_action = || ScheduledAction {
2905            transactions: vec!["dGVzdA==".to_string()],
2906            ..static_action(2)
2907        };
2908
2909        let computed_last =
2910            decode_override_actions(&[static_action(1), computed_action()]).unwrap();
2911        computed_last
2912            .resolve(5, 1, Some(HashMap::from([(key, shared_account(2))])))
2913            .unwrap();
2914        assert_eq!(
2915            computed_last.active_for(&key, 5).map(|a| a.lamports()),
2916            Some(2)
2917        );
2918
2919        let static_last = decode_override_actions(&[computed_action(), static_action(3)]).unwrap();
2920        static_last
2921            .resolve(5, 0, Some(HashMap::from([(key, shared_account(2))])))
2922            .unwrap();
2923        assert_eq!(
2924            static_last.active_for(&key, 5).map(|a| a.lamports()),
2925            Some(3)
2926        );
2927
2928        let failed = decode_override_actions(&[static_action(1), computed_action()]).unwrap();
2929        failed.resolve(5, 1, None).unwrap();
2930        assert_eq!(failed.active_for(&key, 5).map(|a| a.lamports()), Some(1));
2931        assert!(failed.resolve(5, 1, None).is_err(), "resolving twice");
2932    }
2933
2934    fn shared_account(lamports: u64) -> AccountSharedData {
2935        AccountSharedData::from(solana_account::Account {
2936            lamports,
2937            data: vec![1],
2938            owner: address(9),
2939            executable: false,
2940            rent_epoch: 0,
2941        })
2942    }
2943
2944    fn carry_forward_schedule() -> (Address, Address, OverrideSchedule) {
2945        let (a, b) = (address(1), address(2));
2946        let schedule = OverrideSchedule::from(BTreeMap::from([
2947            (10, HashMap::from([(a, shared_account(1))])),
2948            (
2949                20,
2950                HashMap::from([(a, shared_account(2)), (b, shared_account(9))]),
2951            ),
2952        ]));
2953        (a, b, schedule)
2954    }
2955
2956    #[test]
2957    fn override_schedule_resolves_carry_forward_per_account() {
2958        let (a, b, schedule) = carry_forward_schedule();
2959
2960        assert!(schedule.active_for(&a, 9).is_none());
2961        let cases = [
2962            (9, None, None),
2963            (10, Some(1), None),
2964            (15, Some(1), None),
2965            (20, Some(2), Some(9)),
2966            (u64::MAX, Some(2), Some(9)),
2967        ];
2968        for (slot, want_a, want_b) in cases {
2969            assert_eq!(
2970                schedule
2971                    .active_for(&a, slot)
2972                    .map(|account| account.lamports()),
2973                want_a,
2974                "active_for a at {slot}"
2975            );
2976            let active = schedule.active_at(slot);
2977            assert_eq!(
2978                active.get(&a).map(ReadableAccount::lamports),
2979                want_a,
2980                "active_at a at {slot}"
2981            );
2982            assert_eq!(
2983                active.get(&b).map(ReadableAccount::lamports),
2984                want_b,
2985                "active_at b at {slot}"
2986            );
2987        }
2988    }
2989
2990    #[test]
2991    fn newly_active_yields_final_values_in_window() {
2992        let (a, b, schedule) = carry_forward_schedule();
2993
2994        let newly: HashMap<_, _> = schedule.newly_active(Some(10), 20).into_iter().collect();
2995        assert_eq!(newly.len(), 2);
2996        assert_eq!(newly[&a].lamports(), 2);
2997        assert_eq!(newly[&b].lamports(), 9);
2998        let spanning: HashMap<_, _> = schedule.newly_active(None, 20).into_iter().collect();
2999        assert_eq!(spanning[&a].lamports(), 2, "later slot wins");
3000        assert_eq!(schedule.newly_active(None, 15).len(), 1);
3001    }
3002
3003    #[test]
3004    fn reroute_filter_round_trips_camel_case() {
3005        let filter = RerouteFilter {
3006            pairs: BTreeSet::from([MintPair::new(address(1), address(2))]),
3007        };
3008        let json = serde_json::to_value(&filter).unwrap();
3009        assert!(
3010            json["pairs"][0][0].is_string() && json["pairs"][0][1].is_string(),
3011            "a pair serializes as two base58 strings: {json}"
3012        );
3013        let back: RerouteFilter = serde_json::from_value(json).unwrap();
3014        assert_eq!(back, filter);
3015    }
3016
3017    /// The set lookup is only direction-agnostic because construction normalizes, and
3018    /// values arriving over the wire have to be normalized on that path too.
3019    #[test]
3020    fn mint_pairs_are_normalized_however_they_are_built() {
3021        let (one, other) = (address(1), address(2));
3022        assert_eq!(MintPair::new(one, other), MintPair::new(other, one));
3023
3024        let reversed: MintPair =
3025            serde_json::from_value(serde_json::json!([other.to_string(), one.to_string()]))
3026                .unwrap();
3027        assert_eq!(reversed, MintPair::new(one, other));
3028    }
3029
3030    #[test]
3031    fn fail_fast_divergence_kind_str_round_trips() {
3032        for kind in [
3033            FailFastDivergenceKind::AnyNonBenign,
3034            FailFastDivergenceKind::Tracked,
3035        ] {
3036            assert_eq!(
3037                FailFastDivergenceKind::from_str_opt(kind.as_str()),
3038                Some(kind)
3039            );
3040        }
3041        assert_eq!(FailFastDivergenceKind::from_str_opt("nonsense"), None);
3042        assert_eq!(
3043            FailFastDivergenceKind::default(),
3044            FailFastDivergenceKind::AnyNonBenign
3045        );
3046    }
3047
3048    #[test]
3049    fn bundle_not_found_is_terminal_and_descriptive() {
3050        let error = BacktestError::BundleNotFound {
3051            start_slot: 100,
3052            end_slot: 600,
3053        };
3054
3055        assert!(error.is_terminal());
3056        assert_eq!(
3057            error.to_string(),
3058            "block bundle not found for start_slot=100, end_slot=600"
3059        );
3060    }
3061
3062    #[test]
3063    fn no_produced_blocks_is_terminal_and_descriptive() {
3064        let error = BacktestError::NoProducedBlocksInRange {
3065            start_slot: 100,
3066            end_slot: 100,
3067        };
3068
3069        assert!(error.is_terminal());
3070        // placement-e2e matches this phrase to classify the failure as
3071        // transient; keep it stable.
3072        assert_eq!(
3073            error.to_string(),
3074            "no produced blocks in requested range 100..=100; slot(s) may be skipped on-chain"
3075        );
3076    }
3077
3078    #[test]
3079    fn bundle_build_request_optional_fields_default_to_none() {
3080        let req: BundleBuildRequest =
3081            serde_json::from_str(r#"{"start_slot":1,"end_slot":2}"#).expect("parse");
3082        assert_eq!((req.start_slot, req.end_slot), (1, 2));
3083        assert_eq!(req.bundle_size, None);
3084        assert_eq!(req.idempotency_key, None);
3085    }
3086
3087    #[test]
3088    fn bundle_build_request_parses_optional_fields() {
3089        let req: BundleBuildRequest = serde_json::from_str(
3090            r#"{"start_slot":1,"end_slot":2,"bundle_size":500,"idempotency_key":"abc"}"#,
3091        )
3092        .expect("parse");
3093        assert_eq!(req.bundle_size, Some(500));
3094        assert_eq!(req.idempotency_key.as_deref(), Some("abc"));
3095    }
3096
3097    #[test]
3098    fn bundle_build_status_serde_round_trips_with_snake_case() {
3099        let cases = [
3100            (BundleBuildStatus::Completed, "\"completed\""),
3101            (BundleBuildStatus::Failed, "\"failed\""),
3102            (BundleBuildStatus::InProgress, "\"in_progress\""),
3103            (
3104                BundleBuildStatus::NeedsInvestigation,
3105                "\"needs_investigation\"",
3106            ),
3107        ];
3108        for (status, expected) in cases {
3109            assert_eq!(serde_json::to_string(&status).unwrap(), expected);
3110            assert_eq!(
3111                serde_json::from_str::<BundleBuildStatus>(expected).unwrap(),
3112                status
3113            );
3114            assert_eq!(status.as_str(), expected.trim_matches('"'));
3115        }
3116        assert!(serde_json::from_str::<BundleBuildStatus>("\"queued\"").is_err());
3117    }
3118
3119    #[test]
3120    fn bundle_build_request_without_kind_deserializes_as_blocks() {
3121        // An old client's payload must keep meaning a plain block build.
3122        let request: BundleBuildRequest =
3123            serde_json::from_str(r#"{"start_slot":100,"end_slot":200}"#).unwrap();
3124        assert_eq!(request.kind, BundleKind::Blocks);
3125    }
3126
3127    #[test]
3128    fn bundle_kind_round_trips_over_serde_and_as_str() {
3129        for kind in [BundleKind::Blocks, BundleKind::AccountState] {
3130            let json = serde_json::to_string(&kind).unwrap();
3131            assert_eq!(json, format!("\"{}\"", kind.as_str()));
3132            assert_eq!(serde_json::from_str::<BundleKind>(&json).unwrap(), kind);
3133            // The store round-trips through `as_str`, not serde, so the two
3134            // maps have to agree for a stored kind to read back.
3135            assert_eq!(BundleKind::from_str_opt(kind.as_str()), Some(kind));
3136        }
3137        assert!(serde_json::from_str::<BundleKind>("\"accountState\"").is_err());
3138        assert_eq!(BundleKind::from_str_opt("accountState"), None);
3139    }
3140
3141    #[test]
3142    fn bundle_build_request_carries_kind_through_serde() {
3143        let request = BundleBuildRequest {
3144            start_slot: 100,
3145            end_slot: 200,
3146            kind: BundleKind::AccountState,
3147            ..Default::default()
3148        };
3149        let json = serde_json::to_string(&request).unwrap();
3150        assert!(json.contains(r#""kind":"account_state""#), "{json}");
3151        let parsed: BundleBuildRequest = serde_json::from_str(&json).unwrap();
3152        assert_eq!(parsed.kind, BundleKind::AccountState);
3153    }
3154
3155    #[test]
3156    fn bundle_build_status_response_serializes_request_and_status() {
3157        let response = BundleBuildStatusResponse {
3158            request_id: "r".to_string(),
3159            start_slot: 100,
3160            end_slot: 200,
3161            bundle_size: Some(50),
3162            status: BundleBuildStatus::InProgress,
3163            created_at_unix_ms: 0,
3164            kind: BundleKind::Blocks,
3165            eta_secs: Some(600),
3166        };
3167        let json = serde_json::to_value(&response).unwrap();
3168        assert_eq!(json["request_id"].as_str(), Some("r"));
3169        assert_eq!(json["start_slot"].as_u64(), Some(100));
3170        assert_eq!(json["bundle_size"].as_u64(), Some(50));
3171        assert_eq!(json["status"].as_str(), Some("in_progress"));
3172        assert_eq!(json["created_at_unix_ms"].as_u64(), Some(0));
3173        assert_eq!(json["eta_secs"].as_u64(), Some(600));
3174    }
3175
3176    #[test]
3177    fn an_empty_bundles_list_is_distinct_from_an_absent_one() {
3178        // A server that predates the section sends no `bundles` at all.
3179        let absent: BundleBuildListResponse = serde_json::from_str(r#"{"builds":[]}"#).unwrap();
3180        let empty: BundleBuildListResponse =
3181            serde_json::from_str(r#"{"builds":[],"bundles":[]}"#).unwrap();
3182        assert_eq!(absent.bundles, None);
3183        assert_eq!(empty.bundles, Some(Vec::new()));
3184    }
3185
3186    #[test]
3187    fn completed_bundle_round_trips() {
3188        let bundle = CompletedBundle {
3189            start_slot: 100,
3190            end_slot: 200,
3191            kind: BundleKind::AccountState,
3192            completed_at_unix_ms: Some(1_700_000_000_000),
3193            size_bytes: Some(4_096),
3194        };
3195        let json = serde_json::to_string(&bundle).unwrap();
3196        assert!(json.contains(r#""kind":"account_state""#), "{json}");
3197        assert_eq!(
3198            serde_json::from_str::<CompletedBundle>(&json).unwrap(),
3199            bundle
3200        );
3201
3202        let sparse = r#"{"start_slot":1,"end_slot":2,"kind":"blocks"}"#;
3203        let parsed: CompletedBundle = serde_json::from_str(sparse).unwrap();
3204        assert_eq!(parsed.completed_at_unix_ms, None);
3205        assert_eq!(parsed.size_bytes, None);
3206    }
3207
3208    #[test]
3209    fn available_range_reads_a_server_that_predates_kind_and_scope() {
3210        // Deploys are not atomic: a new client must not fail on the old shape.
3211        let json = r#"{
3212            "bundle_start_slot": 100,
3213            "bundle_start_slot_utc": null,
3214            "max_bundle_end_slot": 200,
3215            "max_bundle_end_slot_utc": null,
3216            "max_bundle_size": 101
3217        }"#;
3218        let parsed: AvailableRange = serde_json::from_str(json).unwrap();
3219        assert_eq!(parsed.bundle_start_slot, 100);
3220        assert_eq!(parsed.kind, None);
3221        assert_eq!(parsed.scope, None);
3222    }
3223
3224    #[test]
3225    fn available_range_ignores_fields_it_does_not_know() {
3226        // The other direction: an old client against a newer server.
3227        let json = r#"{"bundle_start_slot": 1, "some_future_field": "x"}"#;
3228        assert_eq!(
3229            serde_json::from_str::<AvailableRange>(json)
3230                .unwrap()
3231                .bundle_start_slot,
3232            1
3233        );
3234    }
3235
3236    #[test]
3237    fn available_range_labels_round_trip() {
3238        let range = AvailableRange {
3239            bundle_start_slot: 100,
3240            max_bundle_end_slot: Some(200),
3241            kind: Some(BundleKind::AccountState),
3242            scope: Some(RangeScope::User),
3243            ..Default::default()
3244        };
3245        let json = serde_json::to_string(&range).unwrap();
3246        assert!(json.contains(r#""kind":"account_state""#), "{json}");
3247        assert!(json.contains(r#""scope":"user""#), "{json}");
3248        let parsed: AvailableRange = serde_json::from_str(&json).unwrap();
3249        assert_eq!(parsed.kind, Some(BundleKind::AccountState));
3250        assert_eq!(parsed.scope, Some(RangeScope::User));
3251    }
3252
3253    fn range(start: u64, end: u64) -> AvailableRange {
3254        AvailableRange {
3255            bundle_start_slot: start,
3256            max_bundle_end_slot: Some(end),
3257            ..Default::default()
3258        }
3259    }
3260
3261    /// Each case lists the available bundles, the requested `[start, end]`, and
3262    /// the expected split — `Some(_)` for an accepted plan, `None` when the range
3263    /// is unservable and `split_range` must error.
3264    #[rstest::rstest]
3265    #[case::single(vec![range(100, 300)], 100, 300, Some(vec![(100, 300)]))]
3266    #[case::multi(
3267        vec![range(100, 200), range(201, 300), range(301, 400)],
3268        100, 300, Some(vec![(100, 200), (201, 300)])
3269    )]
3270    // Smaller bundles nested inside a larger one don't bridge the gap, but the
3271    // range is still coverable by riding the coarse series.
3272    #[case::nested(
3273        vec![range(100, 500), range(110, 150), range(150, 190), range(501, 900)],
3274        100, 900, Some(vec![(100, 500), (501, 900)])
3275    )]
3276    // A fine grid (1k bundles standing in for the 10k grid) overlapped by a
3277    // coarser, differently-aligned series: ride the fine grid, never landing on
3278    // the coarse bundle's drifted start.
3279    #[case::prefers_finer_grid(
3280        vec![range(1_000, 1_999), range(1_500, 3_400), range(2_000, 2_999), range(3_000, 3_999)],
3281        1_000, 3_999, Some(vec![(1_000, 1_999), (2_000, 2_999), (3_000, 3_999)])
3282    )]
3283    // A 50k snapshot bundle and the 10k bundles built on it share start 100: ride
3284    // the 10k grid instead of the coarse end, which would strand the cursor on a
3285    // slot no bundle starts at.
3286    #[case::shared_start_prefers_finer(
3287        vec![range(100, 150), range(100, 120), range(121, 140), range(141, 160)],
3288        100, 160, Some(vec![(100, 120), (121, 140), (141, 160)])
3289    )]
3290    // A coarse bundle (100, 200) overlaps a finer pair covering the same span:
3291    // split_range maximises parallelism, riding the fine pair (two sessions)
3292    // rather than merging onto the coarse max end (one session).
3293    #[case::coarse_overlap_prefers_finer_pair(
3294        vec![range(100, 200), range(100, 150), range(151, 160)],
3295        100, 160, Some(vec![(100, 150), (151, 160)])
3296    )]
3297    // The fine grid has a hole (nothing starts at 141), but a coarse bundle
3298    // sharing the start spans it: serve the request off the coarse bundle rather
3299    // than reporting an unsupported range.
3300    #[case::falls_back_to_coarse(
3301        vec![range(100, 160), range(100, 120), range(121, 140)],
3302        100, 160, Some(vec![(100, 160)])
3303    )]
3304    // The last bundle overshoots the requested end and is clamped.
3305    #[case::clamps_final_bundle(vec![range(100, 199), range(200, 999)], 100, 450, Some(vec![(100, 199), (200, 450)]))]
3306    // A request landing mid-bundle anchors to the covering bundle's start
3307    // (matching the server) instead of erroring as "not covered".
3308    #[case::anchors_mid_bundle(vec![range(150, 350)], 200, 300, Some(vec![(150, 300)]))]
3309    #[case::anchors_then_continues(
3310        vec![range(150, 350), range(351, 600)],
3311        200, 600, Some(vec![(150, 350), (351, 600)])
3312    )]
3313    #[case::start_inside_bundle_anchors(vec![range(200, 400)], 300, 400, Some(vec![(200, 400)]))]
3314    // Errors: a start before any bundle's coverage has no covering bundle...
3315    #[case::start_before_first_bundle(vec![range(200, 400)], 100, 400, None)]
3316    // ...the end must be reachable...
3317    #[case::end_not_covered(vec![range(100, 200)], 100, 300, None)]
3318    #[case::gap_in_coverage(vec![range(100, 200), range(210, 300)], 100, 300, None)]
3319    // Per-API-key isolation: a user's finer bundles and a coarse GLOBAL bundle
3320    // sharing a start are fed as SEPARATE entries (never merged on snapshot_slot,
3321    // which would keep only the max end). split_range set-unions the ends and
3322    // rides the finer user grid — a regression guard against re-merging.
3323    #[case::user_and_global_ranges_not_collapsed(
3324        vec![range(100, 200), range(100, 150), range(151, 200)],
3325        100, 200, Some(vec![(100, 150), (151, 200)])
3326    )]
3327    fn split_range_cases(
3328        #[case] ranges: Vec<AvailableRange>,
3329        #[case] start: u64,
3330        #[case] end: u64,
3331        #[case] expected: Option<Vec<(u64, u64)>>,
3332    ) {
3333        match expected {
3334            Some(expected) => assert_eq!(
3335                split_range(
3336                    &ranges,
3337                    SlotRange::new_unchecked(start, end),
3338                    BundleKind::Blocks
3339                )
3340                .unwrap(),
3341                expected
3342            ),
3343            None => assert!(
3344                split_range(
3345                    &ranges,
3346                    SlotRange::new_unchecked(start, end),
3347                    BundleKind::Blocks
3348                )
3349                .is_err()
3350            ),
3351        }
3352    }
3353
3354    fn account_state_range(start: u64, end: u64) -> AvailableRange {
3355        AvailableRange {
3356            kind: Some(BundleKind::AccountState),
3357            ..range(start, end)
3358        }
3359    }
3360
3361    #[test]
3362    fn a_replay_split_rides_the_recordings_not_a_finer_block_grid() {
3363        // The finer block grid wins on piece count, which is precisely what would
3364        // hand a replay run sub-ranges nothing ever recorded.
3365        let ranges = vec![
3366            range(100, 149),
3367            range(150, 199),
3368            account_state_range(100, 199),
3369        ];
3370        let requested = SlotRange::new_unchecked(100, 199);
3371
3372        assert_eq!(
3373            split_range(&ranges, requested, BundleKind::Blocks).unwrap(),
3374            [(100, 149), (150, 199)]
3375        );
3376        assert_eq!(
3377            split_range(&ranges, requested, BundleKind::AccountState).unwrap(),
3378            [(100, 199)]
3379        );
3380    }
3381
3382    #[test]
3383    fn a_replay_split_tiles_consecutive_recordings() {
3384        let ranges = vec![account_state_range(100, 199), account_state_range(200, 299)];
3385        assert_eq!(
3386            split_range(
3387                &ranges,
3388                SlotRange::new_unchecked(100, 299),
3389                BundleKind::AccountState
3390            )
3391            .unwrap(),
3392            [(100, 199), (200, 299)]
3393        );
3394    }
3395
3396    #[test]
3397    fn a_replay_split_ends_short_of_a_recording() {
3398        // A recording is read slot by slot, so a session stops wherever asked
3399        // inside it — the same as a block session does inside its bundle.
3400        let requested = SlotRange::new_unchecked(100, 150);
3401        assert_eq!(
3402            split_range(&[range(100, 199)], requested, BundleKind::Blocks).unwrap(),
3403            [(100, 150)]
3404        );
3405        assert_eq!(
3406            split_range(
3407                &[account_state_range(100, 199)],
3408                requested,
3409                BundleKind::AccountState
3410            )
3411            .unwrap(),
3412            [(100, 150)]
3413        );
3414    }
3415
3416    #[test]
3417    fn a_replay_split_ends_short_of_its_last_recording() {
3418        // Only the last sub-range may stop mid-recording; the ones before it
3419        // still have to hand over on the very next slot.
3420        let ranges = vec![account_state_range(100, 199), account_state_range(200, 299)];
3421        assert_eq!(
3422            split_range(
3423                &ranges,
3424                SlotRange::new_unchecked(100, 250),
3425                BundleKind::AccountState
3426            )
3427            .unwrap(),
3428            [(100, 199), (200, 250)]
3429        );
3430    }
3431
3432    #[test]
3433    fn a_replay_split_tiles_a_one_slot_recording() {
3434        // A series whose last split begins on the requested end records exactly
3435        // one slot.
3436        let ranges = vec![account_state_range(100, 249), account_state_range(250, 250)];
3437        assert_eq!(
3438            split_range(
3439                &ranges,
3440                SlotRange::new_unchecked(100, 250),
3441                BundleKind::AccountState
3442            )
3443            .unwrap(),
3444            [(100, 249), (250, 250)]
3445        );
3446    }
3447
3448    #[test]
3449    fn a_replay_split_anchors_a_mid_recording_start() {
3450        // A mid-bundle start anchors to the bundle covering it, whichever kind
3451        // the walk rides — the two differ in the bundles they read, not in the
3452        // ranges they admit.
3453        let requested = SlotRange::new_unchecked(150, 199);
3454        assert_eq!(
3455            split_range(&[range(100, 199)], requested, BundleKind::Blocks).unwrap(),
3456            [(100, 199)]
3457        );
3458        assert_eq!(
3459            split_range(
3460                &[account_state_range(100, 199)],
3461                requested,
3462                BundleKind::AccountState
3463            )
3464            .unwrap(),
3465            [(100, 199)]
3466        );
3467    }
3468
3469    #[test]
3470    fn a_replay_split_refuses_a_start_no_recording_covers() {
3471        let error = split_range(
3472            &[account_state_range(200, 299)],
3473            SlotRange::new_unchecked(100, 299),
3474            BundleKind::AccountState,
3475        )
3476        .expect_err("no recording covers 100");
3477        assert!(
3478            error.contains("is not covered by any available account-state bundle"),
3479            "{error}"
3480        );
3481    }
3482
3483    #[test]
3484    fn a_replay_split_ignores_block_ranges() {
3485        let error = split_range(
3486            &[range(100, 199)],
3487            SlotRange::new_unchecked(100, 199),
3488            BundleKind::AccountState,
3489        )
3490        .expect_err("owns no recordings");
3491        assert!(error.contains("account-state bundle"), "{error}");
3492    }
3493
3494    #[test]
3495    fn blocks_mode_admits_ranges_from_a_server_that_predates_kind() {
3496        // A server predating the field omits it, and those ranges must still
3497        // split exactly as they did before the kind was consulted.
3498        let unlabelled = range(100, 199);
3499        assert_eq!(unlabelled.kind, None);
3500        assert_eq!(
3501            split_range(
3502                &[unlabelled],
3503                SlotRange::new_unchecked(100, 150),
3504                BundleKind::Blocks
3505            )
3506            .unwrap(),
3507            [(100, 150)]
3508        );
3509    }
3510
3511    /// Servable end per bundle start, as `split_range` sees it — every advertised
3512    /// end at or past its start, keyed by start.
3513    fn ends_by_start(ranges: &[AvailableRange]) -> BTreeMap<u64, BTreeSet<u64>> {
3514        let mut ends: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
3515        for r in ranges {
3516            if let Some(end) = r.max_bundle_end_slot
3517                && end >= r.bundle_start_slot
3518            {
3519                ends.entry(r.bundle_start_slot).or_default().insert(end);
3520            }
3521        }
3522        ends
3523    }
3524
3525    /// Independent spec for the optimum: the gap-free split of `[start, end]` with
3526    /// the most sub-ranges, found by exhaustively trying every advertised end at
3527    /// each cursor. `None` when no split aligns with the bundle starts.
3528    fn reference_max_split(
3529        ends: &BTreeMap<u64, BTreeSet<u64>>,
3530        cursor: u64,
3531        end: u64,
3532    ) -> Option<Vec<(u64, u64)>> {
3533        ends.get(&cursor)?
3534            .iter()
3535            .filter_map(|&bundle_end| {
3536                if bundle_end >= end {
3537                    Some(vec![(cursor, end)])
3538                } else {
3539                    reference_max_split(ends, bundle_end + 1, end).map(|mut rest| {
3540                        rest.insert(0, (cursor, bundle_end));
3541                        rest
3542                    })
3543                }
3544            })
3545            .max_by_key(Vec::len)
3546    }
3547
3548    /// Structure-independent check that a split is one the server can serve: it
3549    /// tiles `[start, end]` gap-free, every start is a real bundle start, and
3550    /// every end is within that start's largest advertised end.
3551    fn is_valid_split(
3552        split: &[(u64, u64)],
3553        ends: &BTreeMap<u64, BTreeSet<u64>>,
3554        start: u64,
3555        end: u64,
3556    ) -> bool {
3557        split.first().is_some_and(|&(s, _)| s == start)
3558            && split.last().is_some_and(|&(_, e)| e == end)
3559            && split.windows(2).all(|w| w[1].0 == w[0].1 + 1)
3560            && split.iter().all(|&(s, e)| {
3561                e >= s
3562                    && ends
3563                        .get(&s)
3564                        .and_then(|bundle_ends| bundle_ends.iter().next_back())
3565                        .is_some_and(|&max_end| e <= max_end)
3566            })
3567    }
3568
3569    /// Property test: across many randomized bundle layouts, `split_range` must
3570    /// return a *valid* split with the *maximum* number of sub-ranges whenever one
3571    /// exists, and error exactly when none does. Deterministic (fixed LCG seed) so
3572    /// a failure is reproducible.
3573    #[test]
3574    fn split_range_matches_reference() {
3575        let mut seed: u64 = 0x9E3779B97F4A7C15;
3576        let mut next = || {
3577            seed = seed
3578                .wrapping_mul(6364136223846793005)
3579                .wrapping_add(1442695040888963407);
3580            seed >> 33
3581        };
3582
3583        for _ in 0..50_000 {
3584            // A small slot universe makes shared starts, overlaps, nesting, and
3585            // gaps all common.
3586            let ranges: Vec<AvailableRange> = (0..next() % 6)
3587                .map(|_| {
3588                    let start = next() % 12;
3589                    range(start, start + next() % 6) // len 0 => filtered out
3590                })
3591                .collect();
3592            let start = next() % 12;
3593            let end = start + next() % 6; // sometimes start == end, sometimes unservable
3594
3595            let got = split_range(
3596                &ranges,
3597                SlotRange::new_unchecked(start, end),
3598                BundleKind::Blocks,
3599            );
3600            let ends = ends_by_start(&ranges);
3601            // split_range anchors a mid-bundle start to the covering bundle's
3602            // start, so the reference must walk from the same anchor.
3603            let anchor = ends
3604                .range(..=start)
3605                .rfind(|(_, e)| e.iter().next_back().is_some_and(|&x| x >= start))
3606                .map(|(&s, _)| s);
3607            let reference = anchor.and_then(|a| reference_max_split(&ends, a, end));
3608
3609            let layout: Vec<_> = ranges
3610                .iter()
3611                .map(|r| (r.bundle_start_slot, r.max_bundle_end_slot))
3612                .collect();
3613            match (&got, &reference) {
3614                (Ok(split), Some(best)) => {
3615                    assert!(
3616                        is_valid_split(split, &ends, anchor.unwrap(), end),
3617                        "invalid split {split:?} for {layout:?} [{start},{end}]"
3618                    );
3619                    assert_eq!(
3620                        split.len(),
3621                        best.len(),
3622                        "suboptimal split {split:?} vs {best:?} for {layout:?} [{start},{end}]"
3623                    );
3624                }
3625                (Err(_), None) => {}
3626                _ => panic!(
3627                    "disagreement: split_range={got:?}, reference={reference:?} for {layout:?} [{start},{end}]"
3628                ),
3629            }
3630        }
3631    }
3632
3633    #[test]
3634    fn a_reroute_without_templates_omits_direct_fill() {
3635        let json = serde_json::to_value(RerouteParams::default()).expect("serialize");
3636        assert!(json.get("directFill").is_none(), "{json}");
3637    }
3638
3639    #[test]
3640    fn extra_markets_cross_the_wire_as_base58() {
3641        let json = serde_json::to_value(RerouteParams::default()).expect("serialize");
3642        assert!(json.get("extraMarkets").is_none(), "{json}");
3643
3644        let reroute = RerouteParams {
3645            extra_markets: BTreeSet::from([address(7)]),
3646            ..Default::default()
3647        };
3648        let json = serde_json::to_value(&reroute).expect("serialize");
3649        assert_eq!(
3650            json["extraMarkets"],
3651            serde_json::json!([address(7).to_string()])
3652        );
3653        assert_eq!(
3654            serde_json::from_value::<RerouteParams>(json).expect("deserialize"),
3655            reroute
3656        );
3657
3658        serde_json::from_value::<RerouteParams>(serde_json::json!({
3659            "extraMarkets": ["not-an-address"]
3660        }))
3661        .expect_err("an invalid address is rejected");
3662    }
3663
3664    /// A valid template, spelled out rather than parsed: the wire twins are their own test.
3665    fn template(label: &str) -> DirectFillTemplate {
3666        let placeholder = Address::default();
3667        DirectFillTemplate {
3668            label: label.to_owned(),
3669            mode: SwapMode::ExactIn,
3670            input_mint: mints::SOL,
3671            output_mint: mints::USDC,
3672            instruction: TemplateInstruction {
3673                program_id: address(7),
3674                accounts: vec![
3675                    TemplateAccount {
3676                        address: placeholder,
3677                        writable: true,
3678                        signer: true,
3679                    },
3680                    TemplateAccount {
3681                        address: placeholder,
3682                        writable: true,
3683                        signer: false,
3684                    },
3685                    TemplateAccount {
3686                        address: placeholder,
3687                        writable: true,
3688                        signer: false,
3689                    },
3690                    TemplateAccount {
3691                        address: address(8),
3692                        writable: false,
3693                        signer: false,
3694                    },
3695                ],
3696                data: vec![0; 16],
3697            },
3698            amount_offset: 2,
3699            taker: TakerSlots {
3700                user: vec![0],
3701                input_token_account: vec![1],
3702                output_token_account: vec![2],
3703            },
3704        }
3705    }
3706
3707    /// Presence of templates is the switch, exactly as `requote` is.
3708    #[rstest]
3709    #[case::requote_alone(true, 0, false)]
3710    #[case::templates_alone(false, 1, false)]
3711    #[case::both(true, 2, false)]
3712    #[case::neither(false, 0, true)]
3713    fn a_reroute_is_a_noop_only_with_nothing_to_ask(
3714        #[case] requote: bool,
3715        #[case] templates: usize,
3716        #[case] noop: bool,
3717    ) {
3718        let reroute = RerouteParams {
3719            requote,
3720            direct_fill: (0..templates).map(|i| template(&format!("t{i}"))).collect(),
3721            ..Default::default()
3722        };
3723        assert_eq!(reroute.is_noop(), noop);
3724    }
3725
3726    /// A template-only run warms the universe its probes trade through, sidecar or not.
3727    #[rstest]
3728    #[case::templates_without_requote(false, 1, true)]
3729    #[case::requote_without_templates(true, 0, true)]
3730    #[case::nothing(false, 0, false)]
3731    fn the_account_universe_warms_for_templates_as_for_requoting(
3732        #[case] requote: bool,
3733        #[case] templates: usize,
3734        #[case] warms: bool,
3735    ) {
3736        let params = CreateSessionParams::builder()
3737            .start_slot(1)
3738            .end_slot(2)
3739            .reroute(RerouteParams {
3740                requote,
3741                direct_fill: (0..templates).map(|i| template(&format!("t{i}"))).collect(),
3742                ..Default::default()
3743            })
3744            .build();
3745        assert_eq!(params.warms_account_universe(), warms);
3746    }
3747}