Skip to main content

simulator_api/
lib.rs

1use std::{
2    collections::{BTreeMap, BTreeSet, HashSet},
3    fmt,
4};
5
6use base64::{
7    DecodeError as Base64DecodeError, Engine as _, engine::general_purpose::STANDARD as BASE64,
8};
9use serde::{Deserialize, Serialize};
10use solana_address::Address;
11
12mod session_id;
13mod slot_range;
14
15pub use session_id::{ControlSessionId, SessionId};
16pub use slot_range::{InvertedSlotRange, SlotRange};
17
18pub mod subscribe_config;
19pub mod usage;
20pub mod ws_compression;
21
22/// Backtest RPC methods exposed to the client.
23#[derive(Debug, Serialize, Deserialize)]
24#[serde(tag = "method", content = "params", rename_all = "camelCase")]
25pub enum BacktestRequest {
26    CreateBacktestSession(CreateBacktestSessionRequest),
27    Continue(ContinueParams),
28    ContinueTo(ContinueToParams),
29    ContinueSessionV1(ContinueSessionRequestV1),
30    ContinueToSessionV1(ContinueToSessionRequestV1),
31    CloseBacktestSession,
32    CloseSessionV1(CloseSessionRequestV1),
33    AttachBacktestSession {
34        session_id: SessionId,
35        /// Last sequence number the client received. Responses after this sequence
36        /// will be replayed from the session's buffer. None = replay entire buffer.
37        last_sequence: Option<u64>,
38    },
39    /// Sent after reattaching and rebuilding any dependent subscriptions.
40    /// Allows the manager to resume a session that was paused for handoff.
41    ResumeAttachedSession,
42    AttachParallelControlSessionV2 {
43        control_session_id: ControlSessionId,
44        /// Last per-session sequence number received by the client. Responses after
45        /// these sequence numbers will be replayed from the manager's per-session
46        /// replay store. Missing sessions replay their entire retained history.
47        #[serde(default)]
48        last_sequences: BTreeMap<SessionId, u64>,
49    },
50}
51
52/// Versioned payload for `CreateBacktestSession`.
53///
54/// - `V0` keeps backwards-compatible shape by using `CreateSessionParams` directly.
55/// - `V1` keeps the same shape and adds `parallel`.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(untagged)]
58pub enum CreateBacktestSessionRequest {
59    V1(CreateBacktestSessionRequestV1),
60    V0(CreateSessionParams),
61}
62
63impl CreateBacktestSessionRequest {
64    pub fn into_request_options(self) -> CreateBacktestSessionRequestOptions {
65        match self {
66            Self::V0(request) => CreateBacktestSessionRequestOptions {
67                request,
68                parallel: false,
69            },
70            Self::V1(CreateBacktestSessionRequestV1 { request, parallel }) => {
71                CreateBacktestSessionRequestOptions { request, parallel }
72            }
73        }
74    }
75
76    pub fn into_request_and_parallel(self) -> (CreateSessionParams, bool) {
77        let options = self.into_request_options();
78        (options.request, options.parallel)
79    }
80}
81
82impl From<CreateSessionParams> for CreateBacktestSessionRequest {
83    fn from(value: CreateSessionParams) -> Self {
84        Self::V0(value)
85    }
86}
87
88impl From<CreateBacktestSessionRequestV1> for CreateBacktestSessionRequest {
89    fn from(value: CreateBacktestSessionRequestV1) -> Self {
90        Self::V1(value)
91    }
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub struct CreateBacktestSessionRequestV1 {
97    #[serde(flatten)]
98    pub request: CreateSessionParams,
99    pub parallel: bool,
100}
101
102#[derive(Debug, Clone)]
103pub struct CreateBacktestSessionRequestOptions {
104    pub request: CreateSessionParams,
105    pub parallel: bool,
106}
107
108#[derive(Debug, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct ContinueSessionRequestV1 {
111    pub session_id: SessionId,
112    pub request: ContinueParams,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116#[serde(rename_all = "camelCase")]
117pub struct ContinueToSessionRequestV1 {
118    pub session_id: SessionId,
119    pub request: ContinueToParams,
120}
121
122#[derive(Debug, Serialize, Deserialize)]
123#[serde(rename_all = "camelCase")]
124pub struct CloseSessionRequestV1 {
125    pub session_id: SessionId,
126}
127
128/// A filter registered at session creation describing which upcoming batches
129/// the session should announce ahead of execution via
130/// [`BacktestResponse::DiscoveryBatch`] (and its session-event twins). Each
131/// filter describes an event of interest (e.g. a specific program executing);
132/// the session "discovers" the batch in which that event will occur and
133/// emits a `DiscoveryBatch` so the client can pause immediately before it.
134#[serde_with::serde_as]
135#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
136#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
137pub enum DiscoveryFilter {
138    /// Discover batches containing a transaction that invokes this program.
139    ProgramExecuted(#[serde_as(as = "serde_with::DisplayFromStr")] Address),
140}
141
142/// Per-transaction facts a [`DiscoveryFilter`] can inspect when deciding
143/// whether to match. Callers build this once per transaction and feed it to
144/// every registered filter; new variants add fields here rather than growing
145/// the [`DiscoveryFilter::matches`] signature.
146pub struct TxMatchContext<'a> {
147    /// Programs invoked by the transaction (top-level + CPI observed in logs).
148    pub invoked_programs: &'a HashSet<Address>,
149}
150
151impl DiscoveryFilter {
152    /// Return `true` when this filter is satisfied by the transaction
153    /// described by `ctx`.
154    pub fn matches(&self, ctx: &TxMatchContext<'_>) -> bool {
155        match self {
156            Self::ProgramExecuted(target) => ctx.invoked_programs.contains(target),
157        }
158    }
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "camelCase")]
163pub enum ActionKind {
164    Simulate,
165    Send,
166}
167
168/// Where a [`ScheduledAction`] fires.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(tag = "at", rename_all = "camelCase")]
171pub enum ActionAnchor {
172    /// At the end of each slot, after its transactions execute but before it
173    /// finalizes. Fires the entry for the current slot, if any.
174    AfterSlot {
175        #[serde(default, with = "compressed")]
176        slots: Vec<u64>,
177    },
178    /// Before each batch matching `filter` executes. The whole sequence fires at
179    /// each matching batch.
180    BeforeMatch { filter: DiscoveryFilter },
181    /// After each batch matching `filter` commits. The whole sequence fires at
182    /// each matching batch.
183    AfterMatch { filter: DiscoveryFilter },
184}
185
186/// Upper bound on a decompressed transactions blob, guarding the server against
187/// a decompression bomb in client input.
188const MAX_ACTION_TRANSACTIONS_BYTES: u64 = 64 * 1024 * 1024;
189
190/// serde adapter that stores any value as a base64-encoded, zstd-compressed JSON
191/// blob on the wire, so transaction bytes never travel raw. Used via
192/// `#[serde(with = "compressed")]` on [`ScheduledAction`]'s `transactions` field
193/// and [`ActionAnchor`]'s `slots` field.
194mod compressed {
195    use std::io::Read;
196
197    use base64::Engine as _;
198    use serde::{Deserialize, Deserializer, Serialize, Serializer, de::DeserializeOwned};
199
200    use super::{BASE64, MAX_ACTION_TRANSACTIONS_BYTES};
201
202    /// zstd compression level; `0` selects zstd's default (level 3).
203    const COMPRESSION_LEVEL: i32 = 0;
204
205    pub fn serialize<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
206    where
207        T: Serialize,
208        S: Serializer,
209    {
210        let json = serde_json::to_vec(value).map_err(serde::ser::Error::custom)?;
211        let compressed = zstd::stream::encode_all(json.as_slice(), COMPRESSION_LEVEL)
212            .map_err(serde::ser::Error::custom)?;
213        serializer.serialize_str(&BASE64.encode(compressed))
214    }
215
216    pub fn deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
217    where
218        T: DeserializeOwned,
219        D: Deserializer<'de>,
220    {
221        let blob = String::deserialize(deserializer)?;
222        let compressed = BASE64
223            .decode(blob.as_bytes())
224            .map_err(serde::de::Error::custom)?;
225        let mut json = Vec::new();
226        zstd::stream::read::Decoder::new(compressed.as_slice())
227            .and_then(|d| Read::take(d, MAX_ACTION_TRANSACTIONS_BYTES).read_to_end(&mut json))
228            .map_err(serde::de::Error::custom)?;
229        serde_json::from_slice(&json).map_err(serde::de::Error::custom)
230    }
231}
232
233/// A sequence of transactions the server runs automatically during a backtest,
234/// with results streamed over the `actionSubscribe` subscription.
235#[serde_with::serde_as]
236#[derive(Debug, Clone, Serialize, Deserialize)]
237#[serde(rename_all = "camelCase")]
238pub struct ScheduledAction {
239    pub anchor: ActionAnchor,
240    pub kind: ActionKind,
241    /// Base64-encoded transactions, carried over the wire as a base64-encoded,
242    /// zstd-compressed blob. For an `afterSlot` anchor, entry `i` fires at
243    /// `slots[i]` — the two lists must be the same length, and repeating a slot
244    /// runs its entries there in order as one sequence. For match anchors the
245    /// whole list fires, in order, at each matching batch. For `simulate`, each
246    /// transaction's writes are visible to the next without committing to the
247    /// session ledger.
248    #[serde(default, with = "compressed")]
249    pub transactions: Vec<String>,
250    /// Account state overrides seeding the first transaction's simulation, per
251    /// `simulateTransaction` `modifyAccountStates` semantics. Only valid for
252    /// `kind: simulate`.
253    #[serde(default)]
254    pub account_overrides: AccountModifications,
255    /// Accounts whose post-execution state to return, following
256    /// `simulateTransaction` semantics; reflects cumulative state after the
257    /// final transaction.
258    #[serde_as(as = "Vec<serde_with::DisplayFromStr>")]
259    #[serde(default)]
260    pub return_accounts: Vec<Address>,
261    /// Optional client tag echoed back on each `actionSubscribe` result to correlate it to this action.
262    #[serde(default)]
263    pub label: Option<String>,
264}
265
266/// Parameters required to start a new backtest session.
267#[serde_with::serde_as]
268#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
269#[serde(rename_all = "camelCase")]
270pub struct CreateSessionParams {
271    /// First slot (inclusive) to replay.
272    pub start_slot: u64,
273    /// Last slot (inclusive) to replay.
274    pub end_slot: u64,
275    #[serde_as(as = "BTreeSet<serde_with::DisplayFromStr>")]
276    #[serde(default)]
277    #[builder(default)]
278    /// Skip transactions signed by these addresses.
279    pub signer_filter: BTreeSet<Address>,
280    /// When true, include a session summary with transaction statistics in client-facing
281    /// `Completed` responses. Summary generation remains enabled internally for metrics.
282    #[serde(default)]
283    #[builder(default)]
284    pub send_summary: bool,
285    /// When true, each historical swap is re-quoted through the metis router and the routed
286    /// transaction is *simulated* just before the original executes — never committed, so the
287    /// replay is unaffected. Within a block the simulated swaps compose.
288    #[serde(default)]
289    #[builder(default)]
290    pub reroute_order_flow: bool,
291    /// Maximum seconds to wait for ECS capacity-related startup retries before
292    /// failing session creation. If not set (or 0), capacity errors fail immediately.
293    #[serde(default)]
294    pub capacity_wait_timeout_secs: Option<u16>,
295    /// Maximum seconds to keep the session alive after the control websocket disconnects.
296    /// If not set (or 0), the session tears down immediately on disconnect.
297    /// Maximum value: 900 (15 minutes).
298    #[serde(default)]
299    pub disconnect_timeout_secs: Option<u16>,
300    /// Extra compute units to add to each transaction's `SetComputeUnitLimit` budget.
301    /// Useful when replaying with an account override whose program uses more CU than
302    /// the original, causing otherwise-healthy transactions to run out of budget.
303    /// Only applied when a `SetComputeUnitLimit` instruction is already present.
304    #[serde(default)]
305    pub extra_compute_units: Option<u32>,
306    /// Agent configurations to run as sidecars alongside this session.
307    #[serde(default)]
308    #[builder(default)]
309    pub agents: Vec<AgentParams>,
310    /// Events of interest the session should watch for. When an upcoming
311    /// batch matches any filter, the server emits
312    /// [`BacktestResponse::DiscoveryBatch`] (and its session-event twins)
313    /// ahead of execution so the client can follow up with
314    /// [`BacktestRequest::ContinueTo`] to pause before the batch. Empty
315    /// means no batch discoveries are performed (existing behaviour).
316    #[serde(default, skip_serializing_if = "Vec::is_empty")]
317    #[builder(default)]
318    pub discoveries: Vec<DiscoveryFilter>,
319    /// Transactions the server runs automatically during the backtest, with
320    /// results streamed over `actionSubscribe`.
321    #[serde(default, skip_serializing_if = "Vec::is_empty")]
322    #[builder(default)]
323    pub actions: Vec<ScheduledAction>,
324    /// Replay accounts states for this range instead of executing transactions
325    /// — a state-only acceleration.
326    #[serde(default)]
327    #[builder(default)]
328    pub replay_account_state: bool,
329    /// Also re-quote swaps whose L1 transaction failed, when the allowlist admits the failure as
330    /// one re-quoting can answer (a slippage rejection, or a failure after the swap already ran).
331    ///
332    /// Their outcomes are tracked as a separate population — `swaps_detected` and
333    /// `swaps_rerouted` exclude them, and every row carries `original_failed` — so a user who
334    /// retried a failed swap is not counted twice in the headline funnel.
335    #[serde(default = "default_true")]
336    #[builder(default = true)]
337    pub detect_failed_l1_swaps: bool,
338}
339
340/// `serde`'s bool default is `false`; these fields default on.
341const fn default_true() -> bool {
342    true
343}
344
345/// Whether a session needs the metis sidecar (agents and reroute both quote
346/// through it). Loose bools so the launch spec, which only carries a projected
347/// `agents_present` flag, can apply the same rule.
348pub const fn sidecar_required(agents_present: bool, reroute_order_flow: bool) -> bool {
349    agents_present || reroute_order_flow
350}
351
352impl CreateSessionParams {
353    /// See [`sidecar_required`].
354    pub fn requires_sidecar(&self) -> bool {
355        sidecar_required(!self.agents.is_empty(), self.reroute_order_flow)
356    }
357}
358
359/// What counts as a "non-benign" divergence for the backtest session fail-fast
360/// behaviour (see `BacktestSessionOptions::fail_fast`).
361#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
362#[serde(rename_all = "kebab-case")]
363pub enum FailFastDivergenceKind {
364    /// Any divergence other than a benign log diff (the full `is_divergent()` set:
365    /// log mismatch, error mismatch, balance-diff mismatch).
366    #[default]
367    AnyNonBenign,
368    /// Only divergences on transactions that touch an account currently watched by an
369    /// account-diff subscription (subscribed directly, or owned by a subscribed program).
370    Tracked,
371}
372
373impl FailFastDivergenceKind {
374    /// Stable string form used for CLI/env-var serialization. Matches the serde
375    /// `kebab-case` representation.
376    pub fn as_str(self) -> &'static str {
377        match self {
378            Self::AnyNonBenign => "any-non-benign",
379            Self::Tracked => "tracked",
380        }
381    }
382
383    /// Inverse of [`Self::as_str`]; returns `None` for an unrecognized value.
384    pub fn from_str_opt(value: &str) -> Option<Self> {
385        match value {
386            "any-non-benign" => Some(Self::AnyNonBenign),
387            "tracked" => Some(Self::Tracked),
388            _ => None,
389        }
390    }
391}
392
393/// Available agent types for sidecar participation in backtest sessions.
394#[derive(Debug, Clone, Serialize, Deserialize)]
395#[serde(rename_all = "camelCase")]
396pub enum AgentType {
397    Arb,
398}
399
400/// Parameters for a circular arbitrage route.
401#[serde_with::serde_as]
402#[derive(Debug, Clone, Serialize, Deserialize)]
403#[serde(rename_all = "camelCase")]
404pub struct ArbRouteParams {
405    #[serde_as(as = "serde_with::DisplayFromStr")]
406    pub base_mint: Address,
407    #[serde_as(as = "serde_with::DisplayFromStr")]
408    pub temp_mint: Address,
409    #[serde_as(as = "Vec<serde_with::DisplayFromStr>")]
410    #[serde(default)]
411    pub buy_dexes: Vec<Address>,
412    #[serde_as(as = "Vec<serde_with::DisplayFromStr>")]
413    #[serde(default)]
414    pub sell_dexes: Vec<Address>,
415    pub min_input: u64,
416    pub max_input: u64,
417    #[serde(default)]
418    pub min_profit: u64,
419}
420
421/// Configuration for an agent to run alongside a backtest session.
422#[serde_with::serde_as]
423#[derive(Debug, Clone, Serialize, Deserialize)]
424#[serde(rename_all = "camelCase")]
425pub struct AgentParams {
426    pub agent_type: AgentType,
427    #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
428    pub wallet: Option<Address>,
429    /// Base58-encoded 64-byte keypair for signing transactions (compatible with `solana-keygen`).
430    pub keypair: Option<String>,
431    pub seed_sol_lamports: Option<u64>,
432    #[serde_as(as = "BTreeMap<serde_with::DisplayFromStr, _>")]
433    #[serde(default)]
434    pub seed_token_accounts: BTreeMap<Address, u64>,
435    #[serde(default)]
436    pub arb_routes: Vec<ArbRouteParams>,
437}
438
439/// Account state modifications to apply.
440#[serde_with::serde_as]
441#[derive(Debug, Clone, Serialize, Deserialize, Default)]
442pub struct AccountModifications(
443    #[serde_as(as = "BTreeMap<serde_with::DisplayFromStr, _>")]
444    #[serde(default)]
445    pub BTreeMap<Address, AccountData>,
446);
447
448impl From<BTreeMap<Address, AccountData>> for AccountModifications {
449    fn from(value: BTreeMap<Address, AccountData>) -> Self {
450        Self(value)
451    }
452}
453
454/// Arguments used to continue an existing session.
455#[serde_with::serde_as]
456#[derive(Debug, Serialize, Deserialize)]
457#[serde(rename_all = "camelCase")]
458pub struct ContinueParams {
459    #[serde(default = "ContinueParams::default_advance_count")]
460    /// Number of blocks to advance before waiting.
461    pub advance_count: u64,
462    #[serde(default)]
463    /// Base64-encoded transactions to execute before advancing.
464    pub transactions: Vec<String>,
465    #[serde(default)]
466    /// Account state overrides to apply ahead of execution.
467    pub modify_account_states: AccountModifications,
468}
469
470impl Default for ContinueParams {
471    fn default() -> Self {
472        Self {
473            advance_count: Self::default_advance_count(),
474            transactions: Vec::new(),
475            modify_account_states: AccountModifications(BTreeMap::new()),
476        }
477    }
478}
479
480impl ContinueParams {
481    pub fn default_advance_count() -> u64 {
482        1
483    }
484}
485
486/// Payload emitted when a session halts at a caller-specified point.
487/// `batch_index` is `None` for block-boundary pauses and `Some(n)` when the
488/// session stopped *before* batch `n` within `slot` (no transaction from
489/// batch `n` has been applied). While paused, RPC reads against the session
490/// observe partial state up through batch `n - 1`.
491#[derive(Debug, Clone, Serialize, Deserialize)]
492#[serde(rename_all = "camelCase")]
493pub struct PausedEvent {
494    pub slot: u64,
495    #[serde(default, skip_serializing_if = "Option::is_none")]
496    pub batch_index: Option<u32>,
497}
498
499/// Payload emitted when the session has *discovered* an upcoming batch that
500/// matches one or more registered [`DiscoveryFilter`]s from session creation
501/// (for example, a batch containing a transaction that invokes a program of
502/// interest). The `(slot, batch_index)` pair can be fed directly to
503/// [`BacktestRequest::ContinueTo`] to pause immediately before the batch
504/// executes. After each `Continue` / `ContinueTo`, the session emits the
505/// next `DiscoveryBatchEvent` ahead of the next matching batch, enabling a
506/// reactive "pause on every discovery" driver loop.
507#[serde_with::serde_as]
508#[derive(Debug, Clone, Serialize, Deserialize)]
509#[serde(rename_all = "camelCase")]
510pub struct DiscoveryBatchEvent {
511    pub slot: u64,
512    pub batch_index: u32,
513    /// Filters that matched this batch (always non-empty).
514    pub matched: Vec<DiscoveryFilter>,
515    /// Encoded transactions in this batch that triggered the match. Each
516    /// entry carries the serialized `VersionedTransaction` bytes paired with
517    /// the encoding used.
518    pub transactions: Vec<EncodedBinary>,
519}
520
521/// Arguments used to step an existing session to a precise point.
522#[derive(Debug, Clone, Serialize, Deserialize)]
523#[serde(rename_all = "camelCase")]
524pub struct ContinueToParams {
525    /// Target slot to stop in (or at, if `batch_index` is `None`).
526    pub slot: u64,
527    /// Batch within the target slot at which to pause, **exclusive** — the
528    /// session halts immediately *before* batch `n` executes, so no
529    /// transaction in that batch has been applied yet. `None` runs the
530    /// whole slot, pausing at the block boundary. While paused, RPC reads
531    /// observe partial state up through batch `n - 1`.
532    #[serde(default)]
533    pub batch_index: Option<u32>,
534}
535
536/// Supported binary encodings for account/transaction payloads.
537#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
538#[serde(rename_all = "lowercase")]
539pub enum BinaryEncoding {
540    Base64,
541}
542
543impl BinaryEncoding {
544    pub fn encode(self, bytes: &[u8]) -> String {
545        match self {
546            Self::Base64 => BASE64.encode(bytes),
547        }
548    }
549
550    pub fn decode(self, data: &str) -> Result<Vec<u8>, Base64DecodeError> {
551        match self {
552            Self::Base64 => BASE64.decode(data),
553        }
554    }
555}
556
557/// A blob paired with the encoding needed to decode it.
558#[derive(Debug, Clone, Serialize, Deserialize)]
559#[serde(rename_all = "camelCase")]
560pub struct EncodedBinary {
561    /// Encoded payload.
562    pub data: String,
563    /// Encoding scheme used for the payload.
564    pub encoding: BinaryEncoding,
565}
566
567impl EncodedBinary {
568    pub fn new(data: String, encoding: BinaryEncoding) -> Self {
569        Self { data, encoding }
570    }
571
572    pub fn from_bytes(bytes: &[u8], encoding: BinaryEncoding) -> Self {
573        Self {
574            data: encoding.encode(bytes),
575            encoding,
576        }
577    }
578
579    pub fn decode(&self) -> Result<Vec<u8>, Base64DecodeError> {
580        self.encoding.decode(&self.data)
581    }
582}
583
584/// Account snapshot used to seed or modify state in a session.
585#[serde_with::serde_as]
586#[derive(Debug, Clone, Serialize, Deserialize)]
587#[serde(rename_all = "camelCase")]
588pub struct AccountData {
589    /// Account data bytes and encoding.
590    pub data: EncodedBinary,
591    /// Whether the account is executable.
592    pub executable: bool,
593    /// Lamport balance.
594    pub lamports: u64,
595    #[serde_as(as = "serde_with::DisplayFromStr")]
596    /// Account owner pubkey.
597    pub owner: Address,
598    /// Allocated space in bytes.
599    pub space: u64,
600}
601
602impl AccountData {
603    pub fn to_account(&self) -> Result<solana_account::Account, Base64DecodeError> {
604        Ok(solana_account::Account {
605            data: self.data.decode()?,
606            lamports: self.lamports,
607            owner: self.owner,
608            executable: self.executable,
609            rent_epoch: 0,
610        })
611    }
612}
613
614/// Responses returned over the backtest RPC channel.
615#[derive(Debug, Clone, Serialize, Deserialize)]
616#[serde(tag = "method", content = "params", rename_all = "camelCase")]
617pub enum BacktestResponse {
618    SessionCreated {
619        session_id: SessionId,
620        rpc_endpoint: String,
621        #[serde(default, skip_serializing_if = "Option::is_none")]
622        task_id: Option<String>,
623    },
624    SessionAttached {
625        session_id: SessionId,
626        rpc_endpoint: String,
627        #[serde(default, skip_serializing_if = "Option::is_none")]
628        task_id: Option<String>,
629    },
630    SessionsCreatedV2 {
631        control_session_id: ControlSessionId,
632        session_ids: Vec<SessionId>,
633        #[serde(default)]
634        task_ids: Vec<Option<String>>,
635        /// Per-sub-session start/end slots, parallel to `session_ids`. The
636        /// server's split is authoritative (a mid-bundle request anchors to the
637        /// covering bundle), so the client binds each sub-session to its range
638        /// from here rather than re-deriving the split locally.
639        #[serde(default)]
640        start_slots: Vec<u64>,
641        #[serde(default)]
642        end_slots: Vec<u64>,
643    },
644    ParallelSessionAttachedV2 {
645        control_session_id: ControlSessionId,
646        session_ids: Vec<SessionId>,
647        #[serde(default)]
648        task_ids: Vec<Option<String>>,
649    },
650    ReadyForContinue,
651    SlotNotification(u64),
652    Paused(PausedEvent),
653    DiscoveryBatch(DiscoveryBatchEvent),
654    Error(BacktestError),
655    Success,
656    Completed {
657        /// Session summary with transaction statistics.
658        /// The session always computes this summary, but management may omit it from
659        /// client-facing responses unless `send_summary` was requested at session creation.
660        #[serde(skip_serializing_if = "Option::is_none")]
661        summary: Option<SessionSummary>,
662        #[serde(default, skip_serializing_if = "Option::is_none")]
663        agent_stats: Option<Vec<AgentStatsReport>>,
664    },
665    Status {
666        status: BacktestStatus,
667    },
668    SessionEventV2 {
669        session_id: SessionId,
670        seq_id: u64,
671        event: SessionEventKind,
672    },
673}
674
675impl BacktestResponse {
676    pub fn is_completed(&self) -> bool {
677        matches!(self, BacktestResponse::Completed { .. })
678    }
679
680    pub fn is_terminal(&self) -> bool {
681        match self {
682            BacktestResponse::Completed { .. } => true,
683            BacktestResponse::Error(e) => e.is_terminal(),
684            _ => false,
685        }
686    }
687}
688
689impl From<BacktestStatus> for BacktestResponse {
690    fn from(status: BacktestStatus) -> Self {
691        Self::Status { status }
692    }
693}
694
695impl From<String> for BacktestResponse {
696    fn from(message: String) -> Self {
697        BacktestError::Internal { error: message }.into()
698    }
699}
700
701impl From<&str> for BacktestResponse {
702    fn from(message: &str) -> Self {
703        BacktestError::Internal {
704            error: message.to_string(),
705        }
706        .into()
707    }
708}
709
710#[derive(Debug, Clone, Serialize, Deserialize)]
711#[serde(tag = "method", content = "params", rename_all = "camelCase")]
712pub enum SessionEventKind {
713    ReadyForContinue,
714    SlotNotification(u64),
715    Paused(PausedEvent),
716    DiscoveryBatch(DiscoveryBatchEvent),
717    Error(BacktestError),
718    Success,
719    Completed {
720        #[serde(skip_serializing_if = "Option::is_none")]
721        summary: Option<SessionSummary>,
722    },
723    Status {
724        status: BacktestStatus,
725    },
726}
727
728impl SessionEventKind {
729    pub fn is_terminal(&self) -> bool {
730        match self {
731            Self::Completed { .. } => true,
732            Self::Error(e) => e.is_terminal(),
733            _ => false,
734        }
735    }
736}
737
738/// Wire format wrapper for responses sent over the control websocket.
739/// Sessions with `disconnect_timeout_secs > 0` use this to track client position.
740#[derive(Debug, Clone, Serialize, Deserialize)]
741#[serde(rename_all = "camelCase")]
742pub struct SequencedResponse {
743    pub seq_id: u64,
744    #[serde(flatten)]
745    pub response: BacktestResponse,
746}
747
748/// High-level progress states during a `Continue` call.
749#[derive(Debug, Clone, Serialize, Deserialize)]
750#[serde(rename_all = "camelCase")]
751pub enum BacktestStatus {
752    /// Runtime startup is in progress.
753    StartingRuntime,
754    DecodedTransactions,
755    AppliedAccountModifications,
756    ReadyToExecuteUserTransactions,
757    ExecutedUserTransactions,
758    ExecutingBlockTransactions,
759    ExecutedBlockTransactions,
760    ProgramAccountsLoaded,
761}
762
763impl std::fmt::Display for BacktestStatus {
764    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
765        let s = match self {
766            Self::StartingRuntime => "starting runtime",
767            Self::DecodedTransactions => "decoded transactions",
768            Self::AppliedAccountModifications => "applied account modifications",
769            Self::ReadyToExecuteUserTransactions => "ready to execute user transactions",
770            Self::ExecutedUserTransactions => "executed user transactions",
771            Self::ExecutingBlockTransactions => "executing block transactions",
772            Self::ExecutedBlockTransactions => "executed block transactions",
773            Self::ProgramAccountsLoaded => "program accounts loaded",
774        };
775        f.write_str(s)
776    }
777}
778
779/// Structured stats reported by an agent during a backtest session.
780#[derive(Debug, Clone, Default, Serialize, Deserialize)]
781#[serde(rename_all = "camelCase")]
782pub struct AgentStatsReport {
783    pub name: String,
784    pub slots_processed: u64,
785    pub opportunities_found: u64,
786    pub opportunities_skipped: u64,
787    pub no_routes: u64,
788    pub txs_produced: u64,
789    /// Cumulative expected profit per base mint, keyed by mint address.
790    pub expected_gain_by_mint: BTreeMap<String, i64>,
791    /// Transactions successfully executed by the sidecar.
792    #[serde(default)]
793    pub txs_submitted: u64,
794    /// Transactions that failed execution.
795    #[serde(default)]
796    pub txs_failed: u64,
797    /// Transactions rejected by preflight simulation (unprofitable).
798    #[serde(default)]
799    pub txs_simulation_rejected: u64,
800    /// Preflight simulation RPC calls that errored.
801    #[serde(default)]
802    pub txs_simulation_failed: u64,
803}
804
805/// High-level funnel for a reroute-order-flow session: how many detected swaps made it from
806/// re-quoting through simulation to a non-reverting execution. Per-swap economics and latency
807/// are session-internal and land in the analysis DB.
808#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
809#[serde(rename_all = "camelCase")]
810pub struct RerouteStatsReport {
811    /// Requotable Jupiter swaps detected in the replayed range.
812    #[serde(default)]
813    pub swaps_detected: u64,
814    /// Swaps re-quoted through metis and queued for simulation.
815    #[serde(default)]
816    pub swaps_rerouted: u64,
817    /// Swaps whose routed transaction was simulated.
818    #[serde(default)]
819    pub swaps_simulated: u64,
820    /// Simulated swaps whose routed transaction executed without reverting.
821    #[serde(default)]
822    pub swaps_succeeded: u64,
823    /// Detected swaps metis could not route; nothing was simulated for them.
824    #[serde(default)]
825    pub requote_failures: u64,
826    /// Blocks replayed unchanged because the metis client was unavailable.
827    #[serde(default)]
828    pub metis_unavailable_blocks: u64,
829}
830
831/// Summary of transaction execution statistics for a completed backtest session.
832#[derive(Debug, Clone, Default, Serialize, Deserialize)]
833#[serde(rename_all = "camelCase")]
834pub struct SessionSummary {
835    /// Number of simulations where simulator outcome matched on-chain outcome
836    /// (`true_success + true_failure`).
837    pub correct_simulation: usize,
838    /// Number of simulations where simulator outcome did not match on-chain outcome
839    /// (`false_success + false_failure`).
840    pub incorrect_simulation: usize,
841    /// Number of transactions that had execution errors in simulation.
842    pub execution_errors: usize,
843    /// Number of transactions with different balance diffs.
844    pub balance_diff: usize,
845    /// Number of transactions with different log diffs.
846    pub log_diff: usize,
847    /// Transactions skipped because they could not be sanitized (malformed message or
848    /// unresolvable address lookup table).
849    #[serde(default)]
850    pub unsanitizable: usize,
851    /// Aggregate reroute metrics, populated only for reroute-order-flow sessions. Boxed to
852    /// keep `SessionSummary` small in the many response enums that carry it.
853    #[serde(default, skip_serializing_if = "Option::is_none")]
854    pub reroute_stats: Option<Box<RerouteStatsReport>>,
855}
856
857impl SessionSummary {
858    /// Returns true if there were any execution deviations (errors or mismatched results).
859    pub fn has_deviations(&self) -> bool {
860        self.incorrect_simulation > 0 || self.execution_errors > 0 || self.balance_diff > 0
861    }
862
863    /// Total number of transactions processed.
864    pub fn total_transactions(&self) -> usize {
865        self.correct_simulation
866            + self.incorrect_simulation
867            + self.execution_errors
868            + self.balance_diff
869            + self.log_diff
870    }
871}
872
873impl std::fmt::Display for SessionSummary {
874    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
875        let total = self.total_transactions();
876        write!(
877            f,
878            "Session summary: {total} transactions\n\
879             \x20  - {} correct simulation\n\
880             \x20  - {} incorrect simulation\n\
881             \x20  - {} execution errors\n\
882             \x20  - {} unsanitizable\n\
883             \x20  - {} balance diffs\n\
884             \x20  - {} log diffs",
885            self.correct_simulation,
886            self.incorrect_simulation,
887            self.execution_errors,
888            self.unsanitizable,
889            self.balance_diff,
890            self.log_diff,
891        )
892    }
893}
894
895/// Error variants surfaced to backtest RPC clients.
896#[derive(Debug, Clone, Serialize, Deserialize)]
897#[serde(rename_all = "camelCase")]
898pub enum BacktestError {
899    InvalidTransactionEncoding {
900        index: usize,
901        error: String,
902    },
903    InvalidTransactionFormat {
904        index: usize,
905        error: String,
906    },
907    InvalidAccountEncoding {
908        address: String,
909        encoding: BinaryEncoding,
910        error: String,
911    },
912    InvalidAccountOwner {
913        address: String,
914        error: String,
915    },
916    InvalidAccountPubkey {
917        address: String,
918        error: String,
919    },
920    NoMoreBlocks,
921    BundleNotFound {
922        start_slot: u64,
923        end_slot: u64,
924    },
925    AdvanceSlotFailed {
926        slot: u64,
927        error: String,
928    },
929    FinalizeSlotFailed {
930        slot: u64,
931        error: String,
932    },
933    InvalidRequest {
934        error: String,
935    },
936    Internal {
937        error: String,
938    },
939    InvalidBlockhashFormat {
940        slot: u64,
941        error: String,
942    },
943    InitializingSysvarsFailed {
944        slot: u64,
945        error: String,
946    },
947    ClerkError {
948        error: String,
949    },
950    SimulationError {
951        error: String,
952    },
953    SessionNotFound {
954        session_id: String,
955    },
956    SessionOwnerMismatch,
957    /// Session ownership is in transition (e.g. the previous manager is
958    /// shutting down, or another attach raced this one). Clients should retry
959    /// the attach within their reconnect budget; the route is expected to
960    /// become claimable shortly.
961    SessionOwnershipBusy {
962        reason: String,
963    },
964}
965
966impl BacktestError {
967    /// Errors the backtest cannot recover from mid-session — the single source
968    /// of truth for terminal-error classification, shared by the response/event
969    /// wrappers and the managed client.
970    pub fn is_terminal(&self) -> bool {
971        matches!(
972            self,
973            BacktestError::NoMoreBlocks
974                | BacktestError::BundleNotFound { .. }
975                | BacktestError::AdvanceSlotFailed { .. }
976                | BacktestError::FinalizeSlotFailed { .. }
977                | BacktestError::Internal { .. }
978        )
979    }
980}
981
982/// One contiguous block range available on the history clerk.
983#[derive(Debug, Clone, Default, Serialize, Deserialize)]
984pub struct AvailableRange {
985    pub bundle_start_slot: u64,
986    pub bundle_start_slot_utc: Option<String>,
987    pub max_bundle_end_slot: Option<u64>,
988    pub max_bundle_end_slot_utc: Option<String>,
989    pub max_bundle_size: Option<u64>,
990    /// What the range can be used for. `None` from a server that predates the
991    /// field, where every listed range is [`BundleKind::Blocks`].
992    #[serde(default)]
993    pub kind: Option<BundleKind>,
994    /// Who the range is available to. `None` from a server that predates the
995    /// field.
996    #[serde(default)]
997    pub scope: Option<RangeScope>,
998}
999
1000/// Who a listed range is available to.
1001#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1002#[serde(rename_all = "snake_case")]
1003pub enum RangeScope {
1004    /// Available to every caller.
1005    Global,
1006    /// Built for the calling API key, and listed only for that key.
1007    User,
1008}
1009
1010impl RangeScope {
1011    /// Stable string form matching the serde `snake_case` wire representation.
1012    pub fn as_str(self) -> &'static str {
1013        match self {
1014            Self::Global => "global",
1015            Self::User => "user",
1016        }
1017    }
1018}
1019
1020/// What a bundle build produces.
1021#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1022#[serde(rename_all = "snake_case")]
1023pub enum BundleKind {
1024    /// The `.blocks` bundle a session replays transactions from.
1025    #[default]
1026    Blocks,
1027    /// An `.adlt` covering exactly the requested range, which later sessions
1028    /// replay as state. Ensures the underlying `.blocks` first.
1029    AccountState,
1030}
1031
1032impl BundleKind {
1033    /// Stable string form matching the serde `snake_case` wire representation.
1034    pub fn as_str(self) -> &'static str {
1035        match self {
1036            Self::Blocks => "blocks",
1037            Self::AccountState => "account_state",
1038        }
1039    }
1040
1041    /// Inverse of [`Self::as_str`]; returns `None` for an unrecognized value.
1042    pub fn from_str_opt(value: &str) -> Option<Self> {
1043        match value {
1044            "blocks" => Some(Self::Blocks),
1045            "account_state" => Some(Self::AccountState),
1046            _ => None,
1047        }
1048    }
1049}
1050
1051/// Request body of `POST /build-bundle`.
1052///
1053/// Construct via `..Default::default()` so a new field stays source-compatible.
1054#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1055pub struct BundleBuildRequest {
1056    pub start_slot: u64,
1057    pub end_slot: u64,
1058    #[serde(default)]
1059    pub bundle_size: Option<u64>,
1060    /// Optional repeat key; a resubmit dedupes the quota charge. The same key
1061    /// under a different `kind` is a distinct request.
1062    #[serde(default)]
1063    pub idempotency_key: Option<String>,
1064    /// Absent on the wire means [`BundleKind::Blocks`].
1065    #[serde(default)]
1066    pub kind: BundleKind,
1067}
1068
1069#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1070#[serde(rename_all = "snake_case")]
1071pub enum BundleBuildStatus {
1072    /// The bundle was built and is ready to use.
1073    Completed,
1074    /// The build failed and will not produce a bundle.
1075    Failed,
1076    /// The build has not finished yet; poll the status endpoint again later.
1077    InProgress,
1078    /// The outcome of the build could not be determined; do not assume success
1079    /// or failure.
1080    NeedsInvestigation,
1081}
1082
1083impl BundleBuildStatus {
1084    /// Stable string form matching the serde `snake_case` wire representation.
1085    pub fn as_str(self) -> &'static str {
1086        match self {
1087            Self::Completed => "completed",
1088            Self::Failed => "failed",
1089            Self::InProgress => "in_progress",
1090            Self::NeedsInvestigation => "needs_investigation",
1091        }
1092    }
1093}
1094
1095/// Response of the management bundle-build endpoints.
1096#[derive(Debug, Clone, Serialize, Deserialize)]
1097pub struct BundleBuildStatusResponse {
1098    pub request_id: String,
1099    pub start_slot: u64,
1100    pub end_slot: u64,
1101    pub bundle_size: Option<u64>,
1102    pub status: BundleBuildStatus,
1103    /// When the request id was created (unix ms) — lets clients show a live ETA
1104    /// countdown by computing elapsed since this.
1105    pub created_at_unix_ms: u64,
1106    /// Absent on records predating the field, which are all `blocks`.
1107    #[serde(default)]
1108    pub kind: BundleKind,
1109}
1110
1111/// Response of `GET /build-bundle`: the caller's own builds, newest first.
1112#[derive(Debug, Clone, Serialize, Deserialize)]
1113pub struct BundleBuildListResponse {
1114    pub builds: Vec<BundleBuildStatusResponse>,
1115}
1116
1117/// Response of the management `/snapshot-slot` endpoint. The chosen source and
1118/// import state are internal, so only the resolved slot is exposed.
1119#[derive(Debug, Clone, Serialize, Deserialize)]
1120pub struct SnapshotSlotResponse {
1121    pub snapshot_slot: u64,
1122}
1123
1124/// Split a user-requested `[start_slot, end_slot]` range across the available
1125/// bundle ranges, returning a list of contiguous, non-overlapping `(start, end)`
1126/// pairs — one per bundle that the server can serve as its own session.
1127///
1128/// Each emitted `start` is a real `bundle_start_slot` and each `end` is within
1129/// the `max_bundle_end_slot` of a bundle that begins *exactly* at that start
1130/// (the server requires `start_slot == bundle_start_slot` and caps `end_slot` at
1131/// that bundle's `max_bundle_end_slot`). Among all such gap-free splits we pick
1132/// the one with the most sub-ranges, i.e. the highest parallelism: a fine-grained
1133/// (e.g. 10k) bundle grid yields one session per fine bundle instead of
1134/// collapsing onto a coarser series that overlaps the same slots — even when the
1135/// coarse and fine bundles share a start slot. A coarser bundle is only ridden
1136/// where no finer grid continues the walk, which still lets us serve a request
1137/// the coarse data covers even across a hole in the fine grid.
1138///
1139/// Returns an error if the requested start slot is not a bundle start, or if no
1140/// gap-free split reaches `requested_end`.
1141pub fn split_range(
1142    ranges: &[AvailableRange],
1143    requested: SlotRange,
1144) -> Result<Vec<(u64, u64)>, String> {
1145    let (requested_start, requested_end) = (requested.start(), requested.end());
1146
1147    // Every advertised end per bundle start. A start can carry several ends — a
1148    // 50k snapshot bundle and the 10k bundle built on the same snapshot share a
1149    // start slot but reach different ends — so we keep them all and let the walk
1150    // pick whichever maximises parallelism.
1151    let mut ends_by_start: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
1152    for r in ranges {
1153        if let Some(end) = r.max_bundle_end_slot
1154            && end > r.bundle_start_slot
1155        {
1156            ends_by_start
1157                .entry(r.bundle_start_slot)
1158                .or_default()
1159                .insert(end);
1160        }
1161    }
1162
1163    // Anchor a request that lands mid-bundle to the covering bundle's start —
1164    // the latest bundle beginning at or before `requested_start` that still
1165    // covers it. The server's `select_parallel_requests` shares this fn and
1166    // anchors the same way, so the client must too or it would falsely reject a
1167    // start that isn't itself a bundle boundary. An exact bundle-start request
1168    // anchors to itself.
1169    let Some((&anchor_start, _)) = ends_by_start.range(..=requested_start).rfind(|(_, ends)| {
1170        ends.iter()
1171            .next_back()
1172            .is_some_and(|&end| end >= requested_start)
1173    }) else {
1174        return Err(format!(
1175            "start_slot {requested_start} is not covered by any available bundle range"
1176        ));
1177    };
1178
1179    // Walk the bundle starts from `requested_end` backwards, recording for each
1180    // start the gap-free split with the most sub-ranges (highest parallelism).
1181    // A sub-range ends within one of its start's bundles and the next sub-range
1182    // must begin on the very next slot, so a coarse bundle is ridden only when no
1183    // finer bundle continues the walk.
1184    let mut best_from: BTreeMap<u64, Vec<(u64, u64)>> = BTreeMap::new();
1185    for (&start, ends) in ends_by_start.range(anchor_start..=requested_end).rev() {
1186        let mut best: Option<Vec<(u64, u64)>> = None;
1187        for &end in ends {
1188            let candidate = if end >= requested_end {
1189                Some(vec![(start, requested_end)])
1190            } else {
1191                best_from.get(&(end + 1)).map(|rest| {
1192                    std::iter::once((start, end))
1193                        .chain(rest.iter().copied())
1194                        .collect()
1195                })
1196            };
1197            if let Some(candidate) = candidate
1198                && best.as_ref().is_none_or(|b| candidate.len() > b.len())
1199            {
1200                best = Some(candidate);
1201            }
1202        }
1203        if let Some(best) = best {
1204            best_from.insert(start, best);
1205        }
1206    }
1207
1208    best_from.remove(&anchor_start).ok_or_else(|| {
1209        // Point at the first uncovered slot when the bundles leave a hole; fall
1210        // back to a generic message when the range is covered but cannot be
1211        // tiled to bundle boundaries.
1212        let mut covered_to = anchor_start.saturating_sub(1);
1213        for (&start, ends) in ends_by_start.range(anchor_start..=requested_end) {
1214            if start > covered_to.saturating_add(1) {
1215                break;
1216            }
1217            if let Some(&end) = ends.iter().next_back() {
1218                covered_to = covered_to.max(end);
1219            }
1220        }
1221        if covered_to < requested_end {
1222            format!("gap in coverage at slot {}", covered_to + 1)
1223        } else {
1224            format!(
1225                "no gap-free split of [{requested_start}, {requested_end}] aligns with the available bundle ranges"
1226            )
1227        }
1228    })
1229}
1230
1231impl From<BacktestError> for BacktestResponse {
1232    fn from(error: BacktestError) -> Self {
1233        Self::Error(error)
1234    }
1235}
1236
1237impl std::error::Error for BacktestError {}
1238
1239impl fmt::Display for BacktestError {
1240    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1241        match self {
1242            BacktestError::InvalidTransactionEncoding { index, error } => {
1243                write!(f, "invalid transaction encoding at index {index}: {error}")
1244            }
1245            BacktestError::InvalidTransactionFormat { index, error } => {
1246                write!(f, "invalid transaction format at index {index}: {error}")
1247            }
1248            BacktestError::InvalidAccountEncoding {
1249                address,
1250                encoding,
1251                error,
1252            } => write!(
1253                f,
1254                "invalid encoding for account {address} ({encoding:?}): {error}"
1255            ),
1256            BacktestError::InvalidAccountOwner { address, error } => {
1257                write!(f, "invalid owner for account {address}: {error}")
1258            }
1259            BacktestError::InvalidAccountPubkey { address, error } => {
1260                write!(f, "invalid account pubkey {address}: {error}")
1261            }
1262            BacktestError::NoMoreBlocks => write!(f, "no more blocks available"),
1263            BacktestError::BundleNotFound {
1264                start_slot,
1265                end_slot,
1266            } => write!(
1267                f,
1268                "block bundle not found for start_slot={start_slot}, end_slot={end_slot}"
1269            ),
1270            BacktestError::AdvanceSlotFailed { slot, error } => {
1271                write!(f, "failed to advance to slot {slot}: {error}")
1272            }
1273            BacktestError::FinalizeSlotFailed { slot, error } => {
1274                write!(f, "failed to finalize slot {slot}: {error}")
1275            }
1276            BacktestError::InvalidRequest { error } => write!(f, "invalid request: {error}"),
1277            BacktestError::Internal { error } => write!(f, "internal error: {error}"),
1278            BacktestError::InvalidBlockhashFormat { slot, error } => {
1279                write!(f, "invalid blockhash at slot {slot}: {error}")
1280            }
1281            BacktestError::InitializingSysvarsFailed { slot, error } => {
1282                write!(f, "failed to initialize sysvars at slot {slot}: {error}")
1283            }
1284            BacktestError::ClerkError { error } => write!(f, "clerk error: {error}"),
1285            BacktestError::SimulationError { error } => {
1286                write!(f, "simulation error: {error}")
1287            }
1288            BacktestError::SessionNotFound { session_id } => {
1289                write!(f, "session not found: {session_id}")
1290            }
1291            BacktestError::SessionOwnerMismatch => {
1292                write!(f, "session owner mismatch")
1293            }
1294            BacktestError::SessionOwnershipBusy { reason } => {
1295                write!(f, "session ownership busy: {reason}")
1296            }
1297        }
1298    }
1299}
1300
1301#[cfg(test)]
1302mod tests {
1303    use super::*;
1304
1305    #[test]
1306    fn fail_fast_divergence_kind_str_round_trips() {
1307        for kind in [
1308            FailFastDivergenceKind::AnyNonBenign,
1309            FailFastDivergenceKind::Tracked,
1310        ] {
1311            assert_eq!(
1312                FailFastDivergenceKind::from_str_opt(kind.as_str()),
1313                Some(kind)
1314            );
1315        }
1316        assert_eq!(FailFastDivergenceKind::from_str_opt("nonsense"), None);
1317        assert_eq!(
1318            FailFastDivergenceKind::default(),
1319            FailFastDivergenceKind::AnyNonBenign
1320        );
1321    }
1322
1323    #[test]
1324    fn bundle_not_found_is_terminal_and_descriptive() {
1325        let error = BacktestError::BundleNotFound {
1326            start_slot: 100,
1327            end_slot: 600,
1328        };
1329
1330        assert!(error.is_terminal());
1331        assert_eq!(
1332            error.to_string(),
1333            "block bundle not found for start_slot=100, end_slot=600"
1334        );
1335    }
1336
1337    #[test]
1338    fn bundle_build_request_optional_fields_default_to_none() {
1339        let req: BundleBuildRequest =
1340            serde_json::from_str(r#"{"start_slot":1,"end_slot":2}"#).expect("parse");
1341        assert_eq!((req.start_slot, req.end_slot), (1, 2));
1342        assert_eq!(req.bundle_size, None);
1343        assert_eq!(req.idempotency_key, None);
1344    }
1345
1346    #[test]
1347    fn bundle_build_request_parses_optional_fields() {
1348        let req: BundleBuildRequest = serde_json::from_str(
1349            r#"{"start_slot":1,"end_slot":2,"bundle_size":500,"idempotency_key":"abc"}"#,
1350        )
1351        .expect("parse");
1352        assert_eq!(req.bundle_size, Some(500));
1353        assert_eq!(req.idempotency_key.as_deref(), Some("abc"));
1354    }
1355
1356    #[test]
1357    fn bundle_build_status_serde_round_trips_with_snake_case() {
1358        let cases = [
1359            (BundleBuildStatus::Completed, "\"completed\""),
1360            (BundleBuildStatus::Failed, "\"failed\""),
1361            (BundleBuildStatus::InProgress, "\"in_progress\""),
1362            (
1363                BundleBuildStatus::NeedsInvestigation,
1364                "\"needs_investigation\"",
1365            ),
1366        ];
1367        for (status, expected) in cases {
1368            assert_eq!(serde_json::to_string(&status).unwrap(), expected);
1369            assert_eq!(
1370                serde_json::from_str::<BundleBuildStatus>(expected).unwrap(),
1371                status
1372            );
1373            assert_eq!(status.as_str(), expected.trim_matches('"'));
1374        }
1375        assert!(serde_json::from_str::<BundleBuildStatus>("\"queued\"").is_err());
1376    }
1377
1378    #[test]
1379    fn bundle_build_request_without_kind_deserializes_as_blocks() {
1380        // An old client's payload must keep meaning a plain block build.
1381        let request: BundleBuildRequest =
1382            serde_json::from_str(r#"{"start_slot":100,"end_slot":200}"#).unwrap();
1383        assert_eq!(request.kind, BundleKind::Blocks);
1384    }
1385
1386    #[test]
1387    fn bundle_kind_round_trips_over_serde_and_as_str() {
1388        for kind in [BundleKind::Blocks, BundleKind::AccountState] {
1389            let json = serde_json::to_string(&kind).unwrap();
1390            assert_eq!(json, format!("\"{}\"", kind.as_str()));
1391            assert_eq!(serde_json::from_str::<BundleKind>(&json).unwrap(), kind);
1392            // The store round-trips through `as_str`, not serde, so the two
1393            // maps have to agree for a stored kind to read back.
1394            assert_eq!(BundleKind::from_str_opt(kind.as_str()), Some(kind));
1395        }
1396        assert!(serde_json::from_str::<BundleKind>("\"accountState\"").is_err());
1397        assert_eq!(BundleKind::from_str_opt("accountState"), None);
1398    }
1399
1400    #[test]
1401    fn bundle_build_request_carries_kind_through_serde() {
1402        let request = BundleBuildRequest {
1403            start_slot: 100,
1404            end_slot: 200,
1405            kind: BundleKind::AccountState,
1406            ..Default::default()
1407        };
1408        let json = serde_json::to_string(&request).unwrap();
1409        assert!(json.contains(r#""kind":"account_state""#), "{json}");
1410        let parsed: BundleBuildRequest = serde_json::from_str(&json).unwrap();
1411        assert_eq!(parsed.kind, BundleKind::AccountState);
1412    }
1413
1414    #[test]
1415    fn bundle_build_status_response_serializes_request_and_status() {
1416        let response = BundleBuildStatusResponse {
1417            request_id: "r".to_string(),
1418            start_slot: 100,
1419            end_slot: 200,
1420            bundle_size: Some(50),
1421            status: BundleBuildStatus::InProgress,
1422            created_at_unix_ms: 0,
1423            kind: BundleKind::Blocks,
1424        };
1425        let json = serde_json::to_value(&response).unwrap();
1426        assert_eq!(json["request_id"].as_str(), Some("r"));
1427        assert_eq!(json["start_slot"].as_u64(), Some(100));
1428        assert_eq!(json["bundle_size"].as_u64(), Some(50));
1429        assert_eq!(json["status"].as_str(), Some("in_progress"));
1430        assert_eq!(json["created_at_unix_ms"].as_u64(), Some(0));
1431    }
1432
1433    #[test]
1434    fn available_range_reads_a_server_that_predates_kind_and_scope() {
1435        // Deploys are not atomic: a new client must not fail on the old shape.
1436        let json = r#"{
1437            "bundle_start_slot": 100,
1438            "bundle_start_slot_utc": null,
1439            "max_bundle_end_slot": 200,
1440            "max_bundle_end_slot_utc": null,
1441            "max_bundle_size": 101
1442        }"#;
1443        let parsed: AvailableRange = serde_json::from_str(json).unwrap();
1444        assert_eq!(parsed.bundle_start_slot, 100);
1445        assert_eq!(parsed.kind, None);
1446        assert_eq!(parsed.scope, None);
1447    }
1448
1449    #[test]
1450    fn available_range_ignores_fields_it_does_not_know() {
1451        // The other direction: an old client against a newer server.
1452        let json = r#"{"bundle_start_slot": 1, "some_future_field": "x"}"#;
1453        assert_eq!(
1454            serde_json::from_str::<AvailableRange>(json)
1455                .unwrap()
1456                .bundle_start_slot,
1457            1
1458        );
1459    }
1460
1461    #[test]
1462    fn available_range_labels_round_trip() {
1463        let range = AvailableRange {
1464            bundle_start_slot: 100,
1465            max_bundle_end_slot: Some(200),
1466            kind: Some(BundleKind::AccountState),
1467            scope: Some(RangeScope::User),
1468            ..Default::default()
1469        };
1470        let json = serde_json::to_string(&range).unwrap();
1471        assert!(json.contains(r#""kind":"account_state""#), "{json}");
1472        assert!(json.contains(r#""scope":"user""#), "{json}");
1473        let parsed: AvailableRange = serde_json::from_str(&json).unwrap();
1474        assert_eq!(parsed.kind, Some(BundleKind::AccountState));
1475        assert_eq!(parsed.scope, Some(RangeScope::User));
1476    }
1477
1478    fn range(start: u64, end: u64) -> AvailableRange {
1479        AvailableRange {
1480            bundle_start_slot: start,
1481            max_bundle_end_slot: Some(end),
1482            ..Default::default()
1483        }
1484    }
1485
1486    /// Each case lists the available bundles, the requested `[start, end]`, and
1487    /// the expected split — `Some(_)` for an accepted plan, `None` when the range
1488    /// is unservable and `split_range` must error.
1489    #[rstest::rstest]
1490    #[case::single(vec![range(100, 300)], 100, 300, Some(vec![(100, 300)]))]
1491    #[case::multi(
1492        vec![range(100, 200), range(201, 300), range(301, 400)],
1493        100, 300, Some(vec![(100, 200), (201, 300)])
1494    )]
1495    // Smaller bundles nested inside a larger one don't bridge the gap, but the
1496    // range is still coverable by riding the coarse series.
1497    #[case::nested(
1498        vec![range(100, 500), range(110, 150), range(150, 190), range(501, 900)],
1499        100, 900, Some(vec![(100, 500), (501, 900)])
1500    )]
1501    // A fine grid (1k bundles standing in for the 10k grid) overlapped by a
1502    // coarser, differently-aligned series: ride the fine grid, never landing on
1503    // the coarse bundle's drifted start.
1504    #[case::prefers_finer_grid(
1505        vec![range(1_000, 1_999), range(1_500, 3_400), range(2_000, 2_999), range(3_000, 3_999)],
1506        1_000, 3_999, Some(vec![(1_000, 1_999), (2_000, 2_999), (3_000, 3_999)])
1507    )]
1508    // A 50k snapshot bundle and the 10k bundles built on it share start 100: ride
1509    // the 10k grid instead of the coarse end, which would strand the cursor on a
1510    // slot no bundle starts at.
1511    #[case::shared_start_prefers_finer(
1512        vec![range(100, 150), range(100, 120), range(121, 140), range(141, 160)],
1513        100, 160, Some(vec![(100, 120), (121, 140), (141, 160)])
1514    )]
1515    // A coarse bundle (100, 200) overlaps a finer pair covering the same span:
1516    // split_range maximises parallelism, riding the fine pair (two sessions)
1517    // rather than merging onto the coarse max end (one session).
1518    #[case::coarse_overlap_prefers_finer_pair(
1519        vec![range(100, 200), range(100, 150), range(151, 160)],
1520        100, 160, Some(vec![(100, 150), (151, 160)])
1521    )]
1522    // The fine grid has a hole (nothing starts at 141), but a coarse bundle
1523    // sharing the start spans it: serve the request off the coarse bundle rather
1524    // than reporting an unsupported range.
1525    #[case::falls_back_to_coarse(
1526        vec![range(100, 160), range(100, 120), range(121, 140)],
1527        100, 160, Some(vec![(100, 160)])
1528    )]
1529    // The last bundle overshoots the requested end and is clamped.
1530    #[case::clamps_final_bundle(vec![range(100, 199), range(200, 999)], 100, 450, Some(vec![(100, 199), (200, 450)]))]
1531    // A request landing mid-bundle anchors to the covering bundle's start
1532    // (matching the server) instead of erroring as "not covered".
1533    #[case::anchors_mid_bundle(vec![range(150, 350)], 200, 300, Some(vec![(150, 300)]))]
1534    #[case::anchors_then_continues(
1535        vec![range(150, 350), range(351, 600)],
1536        200, 600, Some(vec![(150, 350), (351, 600)])
1537    )]
1538    #[case::start_inside_bundle_anchors(vec![range(200, 400)], 300, 400, Some(vec![(200, 400)]))]
1539    // Errors: a start before any bundle's coverage has no covering bundle...
1540    #[case::start_before_first_bundle(vec![range(200, 400)], 100, 400, None)]
1541    // ...the end must be reachable...
1542    #[case::end_not_covered(vec![range(100, 200)], 100, 300, None)]
1543    #[case::gap_in_coverage(vec![range(100, 200), range(210, 300)], 100, 300, None)]
1544    // Per-API-key isolation: a user's finer bundles and a coarse GLOBAL bundle
1545    // sharing a start are fed as SEPARATE entries (never merged on snapshot_slot,
1546    // which would keep only the max end). split_range set-unions the ends and
1547    // rides the finer user grid — a regression guard against re-merging.
1548    #[case::user_and_global_ranges_not_collapsed(
1549        vec![range(100, 200), range(100, 150), range(151, 200)],
1550        100, 200, Some(vec![(100, 150), (151, 200)])
1551    )]
1552    fn split_range_cases(
1553        #[case] ranges: Vec<AvailableRange>,
1554        #[case] start: u64,
1555        #[case] end: u64,
1556        #[case] expected: Option<Vec<(u64, u64)>>,
1557    ) {
1558        match expected {
1559            Some(expected) => assert_eq!(
1560                split_range(&ranges, SlotRange::new_unchecked(start, end)).unwrap(),
1561                expected
1562            ),
1563            None => assert!(split_range(&ranges, SlotRange::new_unchecked(start, end)).is_err()),
1564        }
1565    }
1566
1567    /// Servable end per bundle start, as `split_range` sees it — every advertised
1568    /// end with `end > start`, keyed by start.
1569    fn ends_by_start(ranges: &[AvailableRange]) -> BTreeMap<u64, BTreeSet<u64>> {
1570        let mut ends: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
1571        for r in ranges {
1572            if let Some(end) = r.max_bundle_end_slot
1573                && end > r.bundle_start_slot
1574            {
1575                ends.entry(r.bundle_start_slot).or_default().insert(end);
1576            }
1577        }
1578        ends
1579    }
1580
1581    /// Independent spec for the optimum: the gap-free split of `[start, end]` with
1582    /// the most sub-ranges, found by exhaustively trying every advertised end at
1583    /// each cursor. `None` when no split aligns with the bundle starts.
1584    fn reference_max_split(
1585        ends: &BTreeMap<u64, BTreeSet<u64>>,
1586        cursor: u64,
1587        end: u64,
1588    ) -> Option<Vec<(u64, u64)>> {
1589        ends.get(&cursor)?
1590            .iter()
1591            .filter_map(|&bundle_end| {
1592                if bundle_end >= end {
1593                    Some(vec![(cursor, end)])
1594                } else {
1595                    reference_max_split(ends, bundle_end + 1, end).map(|mut rest| {
1596                        rest.insert(0, (cursor, bundle_end));
1597                        rest
1598                    })
1599                }
1600            })
1601            .max_by_key(Vec::len)
1602    }
1603
1604    /// Structure-independent check that a split is one the server can serve: it
1605    /// tiles `[start, end]` gap-free, every start is a real bundle start, and
1606    /// every end is within that start's largest advertised end.
1607    fn is_valid_split(
1608        split: &[(u64, u64)],
1609        ends: &BTreeMap<u64, BTreeSet<u64>>,
1610        start: u64,
1611        end: u64,
1612    ) -> bool {
1613        split.first().is_some_and(|&(s, _)| s == start)
1614            && split.last().is_some_and(|&(_, e)| e == end)
1615            && split.windows(2).all(|w| w[1].0 == w[0].1 + 1)
1616            && split.iter().all(|&(s, e)| {
1617                e >= s
1618                    && ends
1619                        .get(&s)
1620                        .and_then(|bundle_ends| bundle_ends.iter().next_back())
1621                        .is_some_and(|&max_end| e <= max_end)
1622            })
1623    }
1624
1625    /// Property test: across many randomized bundle layouts, `split_range` must
1626    /// return a *valid* split with the *maximum* number of sub-ranges whenever one
1627    /// exists, and error exactly when none does. Deterministic (fixed LCG seed) so
1628    /// a failure is reproducible.
1629    #[test]
1630    fn split_range_matches_reference() {
1631        let mut seed: u64 = 0x9E3779B97F4A7C15;
1632        let mut next = || {
1633            seed = seed
1634                .wrapping_mul(6364136223846793005)
1635                .wrapping_add(1442695040888963407);
1636            seed >> 33
1637        };
1638
1639        for _ in 0..50_000 {
1640            // A small slot universe makes shared starts, overlaps, nesting, and
1641            // gaps all common.
1642            let ranges: Vec<AvailableRange> = (0..next() % 6)
1643                .map(|_| {
1644                    let start = next() % 12;
1645                    range(start, start + next() % 6) // len 0 => filtered out
1646                })
1647                .collect();
1648            let start = next() % 12;
1649            let end = start + next() % 6; // sometimes start == end, sometimes unservable
1650
1651            let got = split_range(&ranges, SlotRange::new_unchecked(start, end));
1652            let ends = ends_by_start(&ranges);
1653            // split_range anchors a mid-bundle start to the covering bundle's
1654            // start, so the reference must walk from the same anchor.
1655            let anchor = ends
1656                .range(..=start)
1657                .rfind(|(_, e)| e.iter().next_back().is_some_and(|&x| x >= start))
1658                .map(|(&s, _)| s);
1659            let reference = anchor.and_then(|a| reference_max_split(&ends, a, end));
1660
1661            let layout: Vec<_> = ranges
1662                .iter()
1663                .map(|r| (r.bundle_start_slot, r.max_bundle_end_slot))
1664                .collect();
1665            match (&got, &reference) {
1666                (Ok(split), Some(best)) => {
1667                    assert!(
1668                        is_valid_split(split, &ends, anchor.unwrap(), end),
1669                        "invalid split {split:?} for {layout:?} [{start},{end}]"
1670                    );
1671                    assert_eq!(
1672                        split.len(),
1673                        best.len(),
1674                        "suboptimal split {split:?} vs {best:?} for {layout:?} [{start},{end}]"
1675                    );
1676                }
1677                (Err(_), None) => {}
1678                _ => panic!(
1679                    "disagreement: split_range={got:?}, reference={reference:?} for {layout:?} [{start},{end}]"
1680                ),
1681            }
1682        }
1683    }
1684}