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, 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}
991
992/// What a bundle build produces.
993#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
994#[serde(rename_all = "snake_case")]
995pub enum BundleKind {
996    /// The `.blocks` bundle a session replays transactions from.
997    #[default]
998    Blocks,
999    /// An `.adlt` covering exactly the requested range, which later sessions
1000    /// replay as state. Ensures the underlying `.blocks` first.
1001    AccountState,
1002}
1003
1004impl BundleKind {
1005    /// Stable string form matching the serde `snake_case` wire representation.
1006    pub fn as_str(self) -> &'static str {
1007        match self {
1008            Self::Blocks => "blocks",
1009            Self::AccountState => "account_state",
1010        }
1011    }
1012
1013    /// Inverse of [`Self::as_str`]; returns `None` for an unrecognized value.
1014    pub fn from_str_opt(value: &str) -> Option<Self> {
1015        match value {
1016            "blocks" => Some(Self::Blocks),
1017            "account_state" => Some(Self::AccountState),
1018            _ => None,
1019        }
1020    }
1021}
1022
1023/// Request body of `POST /build-bundle`.
1024///
1025/// Construct via `..Default::default()` so a new field stays source-compatible.
1026#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1027pub struct BundleBuildRequest {
1028    pub start_slot: u64,
1029    pub end_slot: u64,
1030    #[serde(default)]
1031    pub bundle_size: Option<u64>,
1032    /// Optional repeat key; a resubmit dedupes the quota charge. The same key
1033    /// under a different `kind` is a distinct request.
1034    #[serde(default)]
1035    pub idempotency_key: Option<String>,
1036    /// Absent on the wire means [`BundleKind::Blocks`].
1037    #[serde(default)]
1038    pub kind: BundleKind,
1039}
1040
1041#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1042#[serde(rename_all = "snake_case")]
1043pub enum BundleBuildStatus {
1044    /// The bundle was built and is ready to use.
1045    Completed,
1046    /// The build failed and will not produce a bundle.
1047    Failed,
1048    /// The build has not finished yet; poll the status endpoint again later.
1049    InProgress,
1050    /// The outcome of the build could not be determined; do not assume success
1051    /// or failure.
1052    NeedsInvestigation,
1053}
1054
1055impl BundleBuildStatus {
1056    /// Stable string form matching the serde `snake_case` wire representation.
1057    pub fn as_str(self) -> &'static str {
1058        match self {
1059            Self::Completed => "completed",
1060            Self::Failed => "failed",
1061            Self::InProgress => "in_progress",
1062            Self::NeedsInvestigation => "needs_investigation",
1063        }
1064    }
1065}
1066
1067/// Response of the management bundle-build endpoints.
1068#[derive(Debug, Clone, Serialize, Deserialize)]
1069pub struct BundleBuildStatusResponse {
1070    pub request_id: String,
1071    pub start_slot: u64,
1072    pub end_slot: u64,
1073    pub bundle_size: Option<u64>,
1074    pub status: BundleBuildStatus,
1075    /// When the request id was created (unix ms) — lets clients show a live ETA
1076    /// countdown by computing elapsed since this.
1077    pub created_at_unix_ms: u64,
1078    /// Absent on records predating the field, which are all `blocks`.
1079    #[serde(default)]
1080    pub kind: BundleKind,
1081}
1082
1083/// Response of `GET /build-bundle`: the caller's own builds, newest first.
1084#[derive(Debug, Clone, Serialize, Deserialize)]
1085pub struct BundleBuildListResponse {
1086    pub builds: Vec<BundleBuildStatusResponse>,
1087}
1088
1089/// Response of the management `/snapshot-slot` endpoint. The chosen source and
1090/// import state are internal, so only the resolved slot is exposed.
1091#[derive(Debug, Clone, Serialize, Deserialize)]
1092pub struct SnapshotSlotResponse {
1093    pub snapshot_slot: u64,
1094}
1095
1096/// Split a user-requested `[start_slot, end_slot]` range across the available
1097/// bundle ranges, returning a list of contiguous, non-overlapping `(start, end)`
1098/// pairs — one per bundle that the server can serve as its own session.
1099///
1100/// Each emitted `start` is a real `bundle_start_slot` and each `end` is within
1101/// the `max_bundle_end_slot` of a bundle that begins *exactly* at that start
1102/// (the server requires `start_slot == bundle_start_slot` and caps `end_slot` at
1103/// that bundle's `max_bundle_end_slot`). Among all such gap-free splits we pick
1104/// the one with the most sub-ranges, i.e. the highest parallelism: a fine-grained
1105/// (e.g. 10k) bundle grid yields one session per fine bundle instead of
1106/// collapsing onto a coarser series that overlaps the same slots — even when the
1107/// coarse and fine bundles share a start slot. A coarser bundle is only ridden
1108/// where no finer grid continues the walk, which still lets us serve a request
1109/// the coarse data covers even across a hole in the fine grid.
1110///
1111/// Returns an error if the requested start slot is not a bundle start, or if no
1112/// gap-free split reaches `requested_end`.
1113pub fn split_range(
1114    ranges: &[AvailableRange],
1115    requested: SlotRange,
1116) -> Result<Vec<(u64, u64)>, String> {
1117    let (requested_start, requested_end) = (requested.start(), requested.end());
1118
1119    // Every advertised end per bundle start. A start can carry several ends — a
1120    // 50k snapshot bundle and the 10k bundle built on the same snapshot share a
1121    // start slot but reach different ends — so we keep them all and let the walk
1122    // pick whichever maximises parallelism.
1123    let mut ends_by_start: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
1124    for r in ranges {
1125        if let Some(end) = r.max_bundle_end_slot
1126            && end > r.bundle_start_slot
1127        {
1128            ends_by_start
1129                .entry(r.bundle_start_slot)
1130                .or_default()
1131                .insert(end);
1132        }
1133    }
1134
1135    // Anchor a request that lands mid-bundle to the covering bundle's start —
1136    // the latest bundle beginning at or before `requested_start` that still
1137    // covers it. The server's `select_parallel_requests` shares this fn and
1138    // anchors the same way, so the client must too or it would falsely reject a
1139    // start that isn't itself a bundle boundary. An exact bundle-start request
1140    // anchors to itself.
1141    let Some((&anchor_start, _)) = ends_by_start.range(..=requested_start).rfind(|(_, ends)| {
1142        ends.iter()
1143            .next_back()
1144            .is_some_and(|&end| end >= requested_start)
1145    }) else {
1146        return Err(format!(
1147            "start_slot {requested_start} is not covered by any available bundle range"
1148        ));
1149    };
1150
1151    // Walk the bundle starts from `requested_end` backwards, recording for each
1152    // start the gap-free split with the most sub-ranges (highest parallelism).
1153    // A sub-range ends within one of its start's bundles and the next sub-range
1154    // must begin on the very next slot, so a coarse bundle is ridden only when no
1155    // finer bundle continues the walk.
1156    let mut best_from: BTreeMap<u64, Vec<(u64, u64)>> = BTreeMap::new();
1157    for (&start, ends) in ends_by_start.range(anchor_start..=requested_end).rev() {
1158        let mut best: Option<Vec<(u64, u64)>> = None;
1159        for &end in ends {
1160            let candidate = if end >= requested_end {
1161                Some(vec![(start, requested_end)])
1162            } else {
1163                best_from.get(&(end + 1)).map(|rest| {
1164                    std::iter::once((start, end))
1165                        .chain(rest.iter().copied())
1166                        .collect()
1167                })
1168            };
1169            if let Some(candidate) = candidate
1170                && best.as_ref().is_none_or(|b| candidate.len() > b.len())
1171            {
1172                best = Some(candidate);
1173            }
1174        }
1175        if let Some(best) = best {
1176            best_from.insert(start, best);
1177        }
1178    }
1179
1180    best_from.remove(&anchor_start).ok_or_else(|| {
1181        // Point at the first uncovered slot when the bundles leave a hole; fall
1182        // back to a generic message when the range is covered but cannot be
1183        // tiled to bundle boundaries.
1184        let mut covered_to = anchor_start.saturating_sub(1);
1185        for (&start, ends) in ends_by_start.range(anchor_start..=requested_end) {
1186            if start > covered_to.saturating_add(1) {
1187                break;
1188            }
1189            if let Some(&end) = ends.iter().next_back() {
1190                covered_to = covered_to.max(end);
1191            }
1192        }
1193        if covered_to < requested_end {
1194            format!("gap in coverage at slot {}", covered_to + 1)
1195        } else {
1196            format!(
1197                "no gap-free split of [{requested_start}, {requested_end}] aligns with the available bundle ranges"
1198            )
1199        }
1200    })
1201}
1202
1203impl From<BacktestError> for BacktestResponse {
1204    fn from(error: BacktestError) -> Self {
1205        Self::Error(error)
1206    }
1207}
1208
1209impl std::error::Error for BacktestError {}
1210
1211impl fmt::Display for BacktestError {
1212    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1213        match self {
1214            BacktestError::InvalidTransactionEncoding { index, error } => {
1215                write!(f, "invalid transaction encoding at index {index}: {error}")
1216            }
1217            BacktestError::InvalidTransactionFormat { index, error } => {
1218                write!(f, "invalid transaction format at index {index}: {error}")
1219            }
1220            BacktestError::InvalidAccountEncoding {
1221                address,
1222                encoding,
1223                error,
1224            } => write!(
1225                f,
1226                "invalid encoding for account {address} ({encoding:?}): {error}"
1227            ),
1228            BacktestError::InvalidAccountOwner { address, error } => {
1229                write!(f, "invalid owner for account {address}: {error}")
1230            }
1231            BacktestError::InvalidAccountPubkey { address, error } => {
1232                write!(f, "invalid account pubkey {address}: {error}")
1233            }
1234            BacktestError::NoMoreBlocks => write!(f, "no more blocks available"),
1235            BacktestError::BundleNotFound {
1236                start_slot,
1237                end_slot,
1238            } => write!(
1239                f,
1240                "block bundle not found for start_slot={start_slot}, end_slot={end_slot}"
1241            ),
1242            BacktestError::AdvanceSlotFailed { slot, error } => {
1243                write!(f, "failed to advance to slot {slot}: {error}")
1244            }
1245            BacktestError::FinalizeSlotFailed { slot, error } => {
1246                write!(f, "failed to finalize slot {slot}: {error}")
1247            }
1248            BacktestError::InvalidRequest { error } => write!(f, "invalid request: {error}"),
1249            BacktestError::Internal { error } => write!(f, "internal error: {error}"),
1250            BacktestError::InvalidBlockhashFormat { slot, error } => {
1251                write!(f, "invalid blockhash at slot {slot}: {error}")
1252            }
1253            BacktestError::InitializingSysvarsFailed { slot, error } => {
1254                write!(f, "failed to initialize sysvars at slot {slot}: {error}")
1255            }
1256            BacktestError::ClerkError { error } => write!(f, "clerk error: {error}"),
1257            BacktestError::SimulationError { error } => {
1258                write!(f, "simulation error: {error}")
1259            }
1260            BacktestError::SessionNotFound { session_id } => {
1261                write!(f, "session not found: {session_id}")
1262            }
1263            BacktestError::SessionOwnerMismatch => {
1264                write!(f, "session owner mismatch")
1265            }
1266            BacktestError::SessionOwnershipBusy { reason } => {
1267                write!(f, "session ownership busy: {reason}")
1268            }
1269        }
1270    }
1271}
1272
1273#[cfg(test)]
1274mod tests {
1275    use super::*;
1276
1277    #[test]
1278    fn fail_fast_divergence_kind_str_round_trips() {
1279        for kind in [
1280            FailFastDivergenceKind::AnyNonBenign,
1281            FailFastDivergenceKind::Tracked,
1282        ] {
1283            assert_eq!(
1284                FailFastDivergenceKind::from_str_opt(kind.as_str()),
1285                Some(kind)
1286            );
1287        }
1288        assert_eq!(FailFastDivergenceKind::from_str_opt("nonsense"), None);
1289        assert_eq!(
1290            FailFastDivergenceKind::default(),
1291            FailFastDivergenceKind::AnyNonBenign
1292        );
1293    }
1294
1295    #[test]
1296    fn bundle_not_found_is_terminal_and_descriptive() {
1297        let error = BacktestError::BundleNotFound {
1298            start_slot: 100,
1299            end_slot: 600,
1300        };
1301
1302        assert!(error.is_terminal());
1303        assert_eq!(
1304            error.to_string(),
1305            "block bundle not found for start_slot=100, end_slot=600"
1306        );
1307    }
1308
1309    #[test]
1310    fn bundle_build_request_optional_fields_default_to_none() {
1311        let req: BundleBuildRequest =
1312            serde_json::from_str(r#"{"start_slot":1,"end_slot":2}"#).expect("parse");
1313        assert_eq!((req.start_slot, req.end_slot), (1, 2));
1314        assert_eq!(req.bundle_size, None);
1315        assert_eq!(req.idempotency_key, None);
1316    }
1317
1318    #[test]
1319    fn bundle_build_request_parses_optional_fields() {
1320        let req: BundleBuildRequest = serde_json::from_str(
1321            r#"{"start_slot":1,"end_slot":2,"bundle_size":500,"idempotency_key":"abc"}"#,
1322        )
1323        .expect("parse");
1324        assert_eq!(req.bundle_size, Some(500));
1325        assert_eq!(req.idempotency_key.as_deref(), Some("abc"));
1326    }
1327
1328    #[test]
1329    fn bundle_build_status_serde_round_trips_with_snake_case() {
1330        let cases = [
1331            (BundleBuildStatus::Completed, "\"completed\""),
1332            (BundleBuildStatus::Failed, "\"failed\""),
1333            (BundleBuildStatus::InProgress, "\"in_progress\""),
1334            (
1335                BundleBuildStatus::NeedsInvestigation,
1336                "\"needs_investigation\"",
1337            ),
1338        ];
1339        for (status, expected) in cases {
1340            assert_eq!(serde_json::to_string(&status).unwrap(), expected);
1341            assert_eq!(
1342                serde_json::from_str::<BundleBuildStatus>(expected).unwrap(),
1343                status
1344            );
1345            assert_eq!(status.as_str(), expected.trim_matches('"'));
1346        }
1347        assert!(serde_json::from_str::<BundleBuildStatus>("\"queued\"").is_err());
1348    }
1349
1350    #[test]
1351    fn bundle_build_request_without_kind_deserializes_as_blocks() {
1352        // An old client's payload must keep meaning a plain block build.
1353        let request: BundleBuildRequest =
1354            serde_json::from_str(r#"{"start_slot":100,"end_slot":200}"#).unwrap();
1355        assert_eq!(request.kind, BundleKind::Blocks);
1356    }
1357
1358    #[test]
1359    fn bundle_kind_round_trips_over_serde_and_as_str() {
1360        for kind in [BundleKind::Blocks, BundleKind::AccountState] {
1361            let json = serde_json::to_string(&kind).unwrap();
1362            assert_eq!(json, format!("\"{}\"", kind.as_str()));
1363            assert_eq!(serde_json::from_str::<BundleKind>(&json).unwrap(), kind);
1364            // The store round-trips through `as_str`, not serde, so the two
1365            // maps have to agree for a stored kind to read back.
1366            assert_eq!(BundleKind::from_str_opt(kind.as_str()), Some(kind));
1367        }
1368        assert!(serde_json::from_str::<BundleKind>("\"accountState\"").is_err());
1369        assert_eq!(BundleKind::from_str_opt("accountState"), None);
1370    }
1371
1372    #[test]
1373    fn bundle_build_request_carries_kind_through_serde() {
1374        let request = BundleBuildRequest {
1375            start_slot: 100,
1376            end_slot: 200,
1377            kind: BundleKind::AccountState,
1378            ..Default::default()
1379        };
1380        let json = serde_json::to_string(&request).unwrap();
1381        assert!(json.contains(r#""kind":"account_state""#), "{json}");
1382        let parsed: BundleBuildRequest = serde_json::from_str(&json).unwrap();
1383        assert_eq!(parsed.kind, BundleKind::AccountState);
1384    }
1385
1386    #[test]
1387    fn bundle_build_status_response_serializes_request_and_status() {
1388        let response = BundleBuildStatusResponse {
1389            request_id: "r".to_string(),
1390            start_slot: 100,
1391            end_slot: 200,
1392            bundle_size: Some(50),
1393            status: BundleBuildStatus::InProgress,
1394            created_at_unix_ms: 0,
1395            kind: BundleKind::Blocks,
1396        };
1397        let json = serde_json::to_value(&response).unwrap();
1398        assert_eq!(json["request_id"].as_str(), Some("r"));
1399        assert_eq!(json["start_slot"].as_u64(), Some(100));
1400        assert_eq!(json["bundle_size"].as_u64(), Some(50));
1401        assert_eq!(json["status"].as_str(), Some("in_progress"));
1402        assert_eq!(json["created_at_unix_ms"].as_u64(), Some(0));
1403    }
1404
1405    fn range(start: u64, end: u64) -> AvailableRange {
1406        AvailableRange {
1407            bundle_start_slot: start,
1408            bundle_start_slot_utc: None,
1409            max_bundle_end_slot: Some(end),
1410            max_bundle_end_slot_utc: None,
1411            max_bundle_size: None,
1412        }
1413    }
1414
1415    /// Each case lists the available bundles, the requested `[start, end]`, and
1416    /// the expected split — `Some(_)` for an accepted plan, `None` when the range
1417    /// is unservable and `split_range` must error.
1418    #[rstest::rstest]
1419    #[case::single(vec![range(100, 300)], 100, 300, Some(vec![(100, 300)]))]
1420    #[case::multi(
1421        vec![range(100, 200), range(201, 300), range(301, 400)],
1422        100, 300, Some(vec![(100, 200), (201, 300)])
1423    )]
1424    // Smaller bundles nested inside a larger one don't bridge the gap, but the
1425    // range is still coverable by riding the coarse series.
1426    #[case::nested(
1427        vec![range(100, 500), range(110, 150), range(150, 190), range(501, 900)],
1428        100, 900, Some(vec![(100, 500), (501, 900)])
1429    )]
1430    // A fine grid (1k bundles standing in for the 10k grid) overlapped by a
1431    // coarser, differently-aligned series: ride the fine grid, never landing on
1432    // the coarse bundle's drifted start.
1433    #[case::prefers_finer_grid(
1434        vec![range(1_000, 1_999), range(1_500, 3_400), range(2_000, 2_999), range(3_000, 3_999)],
1435        1_000, 3_999, Some(vec![(1_000, 1_999), (2_000, 2_999), (3_000, 3_999)])
1436    )]
1437    // A 50k snapshot bundle and the 10k bundles built on it share start 100: ride
1438    // the 10k grid instead of the coarse end, which would strand the cursor on a
1439    // slot no bundle starts at.
1440    #[case::shared_start_prefers_finer(
1441        vec![range(100, 150), range(100, 120), range(121, 140), range(141, 160)],
1442        100, 160, Some(vec![(100, 120), (121, 140), (141, 160)])
1443    )]
1444    // A coarse bundle (100, 200) overlaps a finer pair covering the same span:
1445    // split_range maximises parallelism, riding the fine pair (two sessions)
1446    // rather than merging onto the coarse max end (one session).
1447    #[case::coarse_overlap_prefers_finer_pair(
1448        vec![range(100, 200), range(100, 150), range(151, 160)],
1449        100, 160, Some(vec![(100, 150), (151, 160)])
1450    )]
1451    // The fine grid has a hole (nothing starts at 141), but a coarse bundle
1452    // sharing the start spans it: serve the request off the coarse bundle rather
1453    // than reporting an unsupported range.
1454    #[case::falls_back_to_coarse(
1455        vec![range(100, 160), range(100, 120), range(121, 140)],
1456        100, 160, Some(vec![(100, 160)])
1457    )]
1458    // The last bundle overshoots the requested end and is clamped.
1459    #[case::clamps_final_bundle(vec![range(100, 199), range(200, 999)], 100, 450, Some(vec![(100, 199), (200, 450)]))]
1460    // A request landing mid-bundle anchors to the covering bundle's start
1461    // (matching the server) instead of erroring as "not covered".
1462    #[case::anchors_mid_bundle(vec![range(150, 350)], 200, 300, Some(vec![(150, 300)]))]
1463    #[case::anchors_then_continues(
1464        vec![range(150, 350), range(351, 600)],
1465        200, 600, Some(vec![(150, 350), (351, 600)])
1466    )]
1467    #[case::start_inside_bundle_anchors(vec![range(200, 400)], 300, 400, Some(vec![(200, 400)]))]
1468    // Errors: a start before any bundle's coverage has no covering bundle...
1469    #[case::start_before_first_bundle(vec![range(200, 400)], 100, 400, None)]
1470    // ...the end must be reachable...
1471    #[case::end_not_covered(vec![range(100, 200)], 100, 300, None)]
1472    #[case::gap_in_coverage(vec![range(100, 200), range(210, 300)], 100, 300, None)]
1473    // Per-API-key isolation: a user's finer bundles and a coarse GLOBAL bundle
1474    // sharing a start are fed as SEPARATE entries (never merged on snapshot_slot,
1475    // which would keep only the max end). split_range set-unions the ends and
1476    // rides the finer user grid — a regression guard against re-merging.
1477    #[case::user_and_global_ranges_not_collapsed(
1478        vec![range(100, 200), range(100, 150), range(151, 200)],
1479        100, 200, Some(vec![(100, 150), (151, 200)])
1480    )]
1481    fn split_range_cases(
1482        #[case] ranges: Vec<AvailableRange>,
1483        #[case] start: u64,
1484        #[case] end: u64,
1485        #[case] expected: Option<Vec<(u64, u64)>>,
1486    ) {
1487        match expected {
1488            Some(expected) => assert_eq!(
1489                split_range(&ranges, SlotRange::new_unchecked(start, end)).unwrap(),
1490                expected
1491            ),
1492            None => assert!(split_range(&ranges, SlotRange::new_unchecked(start, end)).is_err()),
1493        }
1494    }
1495
1496    /// Servable end per bundle start, as `split_range` sees it — every advertised
1497    /// end with `end > start`, keyed by start.
1498    fn ends_by_start(ranges: &[AvailableRange]) -> BTreeMap<u64, BTreeSet<u64>> {
1499        let mut ends: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
1500        for r in ranges {
1501            if let Some(end) = r.max_bundle_end_slot
1502                && end > r.bundle_start_slot
1503            {
1504                ends.entry(r.bundle_start_slot).or_default().insert(end);
1505            }
1506        }
1507        ends
1508    }
1509
1510    /// Independent spec for the optimum: the gap-free split of `[start, end]` with
1511    /// the most sub-ranges, found by exhaustively trying every advertised end at
1512    /// each cursor. `None` when no split aligns with the bundle starts.
1513    fn reference_max_split(
1514        ends: &BTreeMap<u64, BTreeSet<u64>>,
1515        cursor: u64,
1516        end: u64,
1517    ) -> Option<Vec<(u64, u64)>> {
1518        ends.get(&cursor)?
1519            .iter()
1520            .filter_map(|&bundle_end| {
1521                if bundle_end >= end {
1522                    Some(vec![(cursor, end)])
1523                } else {
1524                    reference_max_split(ends, bundle_end + 1, end).map(|mut rest| {
1525                        rest.insert(0, (cursor, bundle_end));
1526                        rest
1527                    })
1528                }
1529            })
1530            .max_by_key(Vec::len)
1531    }
1532
1533    /// Structure-independent check that a split is one the server can serve: it
1534    /// tiles `[start, end]` gap-free, every start is a real bundle start, and
1535    /// every end is within that start's largest advertised end.
1536    fn is_valid_split(
1537        split: &[(u64, u64)],
1538        ends: &BTreeMap<u64, BTreeSet<u64>>,
1539        start: u64,
1540        end: u64,
1541    ) -> bool {
1542        split.first().is_some_and(|&(s, _)| s == start)
1543            && split.last().is_some_and(|&(_, e)| e == end)
1544            && split.windows(2).all(|w| w[1].0 == w[0].1 + 1)
1545            && split.iter().all(|&(s, e)| {
1546                e >= s
1547                    && ends
1548                        .get(&s)
1549                        .and_then(|bundle_ends| bundle_ends.iter().next_back())
1550                        .is_some_and(|&max_end| e <= max_end)
1551            })
1552    }
1553
1554    /// Property test: across many randomized bundle layouts, `split_range` must
1555    /// return a *valid* split with the *maximum* number of sub-ranges whenever one
1556    /// exists, and error exactly when none does. Deterministic (fixed LCG seed) so
1557    /// a failure is reproducible.
1558    #[test]
1559    fn split_range_matches_reference() {
1560        let mut seed: u64 = 0x9E3779B97F4A7C15;
1561        let mut next = || {
1562            seed = seed
1563                .wrapping_mul(6364136223846793005)
1564                .wrapping_add(1442695040888963407);
1565            seed >> 33
1566        };
1567
1568        for _ in 0..50_000 {
1569            // A small slot universe makes shared starts, overlaps, nesting, and
1570            // gaps all common.
1571            let ranges: Vec<AvailableRange> = (0..next() % 6)
1572                .map(|_| {
1573                    let start = next() % 12;
1574                    range(start, start + next() % 6) // len 0 => filtered out
1575                })
1576                .collect();
1577            let start = next() % 12;
1578            let end = start + next() % 6; // sometimes start == end, sometimes unservable
1579
1580            let got = split_range(&ranges, SlotRange::new_unchecked(start, end));
1581            let ends = ends_by_start(&ranges);
1582            // split_range anchors a mid-bundle start to the covering bundle's
1583            // start, so the reference must walk from the same anchor.
1584            let anchor = ends
1585                .range(..=start)
1586                .rfind(|(_, e)| e.iter().next_back().is_some_and(|&x| x >= start))
1587                .map(|(&s, _)| s);
1588            let reference = anchor.and_then(|a| reference_max_split(&ends, a, end));
1589
1590            let layout: Vec<_> = ranges
1591                .iter()
1592                .map(|r| (r.bundle_start_slot, r.max_bundle_end_slot))
1593                .collect();
1594            match (&got, &reference) {
1595                (Ok(split), Some(best)) => {
1596                    assert!(
1597                        is_valid_split(split, &ends, anchor.unwrap(), end),
1598                        "invalid split {split:?} for {layout:?} [{start},{end}]"
1599                    );
1600                    assert_eq!(
1601                        split.len(),
1602                        best.len(),
1603                        "suboptimal split {split:?} vs {best:?} for {layout:?} [{start},{end}]"
1604                    );
1605                }
1606                (Err(_), None) => {}
1607                _ => panic!(
1608                    "disagreement: split_range={got:?}, reference={reference:?} for {layout:?} [{start},{end}]"
1609                ),
1610            }
1611        }
1612    }
1613}