Skip to main content

simulator_api/
lib.rs

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