Skip to main content

simulator_api/
lib.rs

1pub mod subscribe_config;
2pub mod usage;
3
4use std::{
5    collections::{BTreeMap, BTreeSet, HashSet},
6    fmt,
7};
8
9use base64::{
10    DecodeError as Base64DecodeError, Engine as _, engine::general_purpose::STANDARD as BASE64,
11};
12use serde::{Deserialize, Serialize};
13use solana_address::Address;
14
15pub mod ws_compression;
16
17/// Backtest RPC methods exposed to the client.
18#[derive(Debug, Serialize, Deserialize)]
19#[serde(tag = "method", content = "params", rename_all = "camelCase")]
20pub enum BacktestRequest {
21    CreateBacktestSession(CreateBacktestSessionRequest),
22    Continue(ContinueParams),
23    ContinueTo(ContinueToParams),
24    ContinueSessionV1(ContinueSessionRequestV1),
25    ContinueToSessionV1(ContinueToSessionRequestV1),
26    CloseBacktestSession,
27    CloseSessionV1(CloseSessionRequestV1),
28    AttachBacktestSession {
29        session_id: String,
30        /// Last sequence number the client received. Responses after this sequence
31        /// will be replayed from the session's buffer. None = replay entire buffer.
32        last_sequence: Option<u64>,
33    },
34    /// Sent after reattaching and rebuilding any dependent subscriptions.
35    /// Allows the manager to resume a session that was paused for handoff.
36    ResumeAttachedSession,
37    AttachParallelControlSessionV2 {
38        control_session_id: String,
39        /// Last per-session sequence number received by the client. Responses after
40        /// these sequence numbers will be replayed from the manager's per-session
41        /// replay store. Missing sessions replay their entire retained history.
42        #[serde(default)]
43        last_sequences: BTreeMap<String, u64>,
44    },
45}
46
47/// Versioned payload for `CreateBacktestSession`.
48///
49/// - `V0` keeps backwards-compatible shape by using `CreateSessionParams` directly.
50/// - `V1` keeps the same shape and adds `parallel`.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[serde(untagged)]
53pub enum CreateBacktestSessionRequest {
54    V1(CreateBacktestSessionRequestV1),
55    V0(CreateSessionParams),
56}
57
58impl CreateBacktestSessionRequest {
59    pub fn into_request_options(self) -> CreateBacktestSessionRequestOptions {
60        match self {
61            Self::V0(request) => CreateBacktestSessionRequestOptions {
62                request,
63                parallel: false,
64            },
65            Self::V1(CreateBacktestSessionRequestV1 { request, parallel }) => {
66                CreateBacktestSessionRequestOptions { request, parallel }
67            }
68        }
69    }
70
71    pub fn into_request_and_parallel(self) -> (CreateSessionParams, bool) {
72        let options = self.into_request_options();
73        (options.request, options.parallel)
74    }
75}
76
77impl From<CreateSessionParams> for CreateBacktestSessionRequest {
78    fn from(value: CreateSessionParams) -> Self {
79        Self::V0(value)
80    }
81}
82
83impl From<CreateBacktestSessionRequestV1> for CreateBacktestSessionRequest {
84    fn from(value: CreateBacktestSessionRequestV1) -> Self {
85        Self::V1(value)
86    }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub struct CreateBacktestSessionRequestV1 {
92    #[serde(flatten)]
93    pub request: CreateSessionParams,
94    pub parallel: bool,
95}
96
97#[derive(Debug, Clone)]
98pub struct CreateBacktestSessionRequestOptions {
99    pub request: CreateSessionParams,
100    pub parallel: bool,
101}
102
103#[derive(Debug, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct ContinueSessionRequestV1 {
106    pub session_id: String,
107    pub request: ContinueParams,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct ContinueToSessionRequestV1 {
113    pub session_id: String,
114    pub request: ContinueToParams,
115}
116
117#[derive(Debug, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct CloseSessionRequestV1 {
120    pub session_id: String,
121}
122
123/// A filter registered at session creation describing which upcoming batches
124/// the session should announce ahead of execution via
125/// [`BacktestResponse::DiscoveryBatch`] (and its session-event twins). Each
126/// filter describes an event of interest (e.g. a specific program executing);
127/// the session "discovers" the batch in which that event will occur and
128/// emits a `DiscoveryBatch` so the client can pause immediately before it.
129#[serde_with::serde_as]
130#[derive(Debug, Clone, Serialize, Deserialize)]
131#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
132pub enum DiscoveryFilter {
133    /// Discover batches containing a transaction that invokes this program.
134    ProgramExecuted(#[serde_as(as = "serde_with::DisplayFromStr")] Address),
135}
136
137/// Per-transaction facts a [`DiscoveryFilter`] can inspect when deciding
138/// whether to match. Callers build this once per transaction and feed it to
139/// every registered filter; new variants add fields here rather than growing
140/// the [`DiscoveryFilter::matches`] signature.
141pub struct TxMatchContext<'a> {
142    /// Programs invoked by the transaction (top-level + CPI observed in logs).
143    pub invoked_programs: &'a HashSet<Address>,
144}
145
146impl DiscoveryFilter {
147    /// Return `true` when this filter is satisfied by the transaction
148    /// described by `ctx`.
149    pub fn matches(&self, ctx: &TxMatchContext<'_>) -> bool {
150        match self {
151            Self::ProgramExecuted(target) => ctx.invoked_programs.contains(target),
152        }
153    }
154}
155
156/// Parameters required to start a new backtest session.
157#[serde_with::serde_as]
158#[derive(Debug, Clone, Serialize, Deserialize)]
159#[serde(rename_all = "camelCase")]
160pub struct CreateSessionParams {
161    /// First slot (inclusive) to replay.
162    pub start_slot: u64,
163    /// Last slot (inclusive) to replay.
164    pub end_slot: u64,
165    #[serde_as(as = "BTreeSet<serde_with::DisplayFromStr>")]
166    #[serde(default)]
167    /// Skip transactions signed by these addresses.
168    pub signer_filter: BTreeSet<Address>,
169    /// When true, include a session summary with transaction statistics in client-facing
170    /// `Completed` responses. Summary generation remains enabled internally for metrics.
171    #[serde(default)]
172    pub send_summary: bool,
173    /// Maximum seconds to wait for ECS capacity-related startup retries before
174    /// failing session creation. If not set (or 0), capacity errors fail immediately.
175    #[serde(default)]
176    pub capacity_wait_timeout_secs: Option<u16>,
177    /// Maximum seconds to keep the session alive after the control websocket disconnects.
178    /// If not set (or 0), the session tears down immediately on disconnect.
179    /// Maximum value: 900 (15 minutes).
180    #[serde(default)]
181    pub disconnect_timeout_secs: Option<u16>,
182    /// Extra compute units to add to each transaction's `SetComputeUnitLimit` budget.
183    /// Useful when replaying with an account override whose program uses more CU than
184    /// the original, causing otherwise-healthy transactions to run out of budget.
185    /// Only applied when a `SetComputeUnitLimit` instruction is already present.
186    #[serde(default)]
187    pub extra_compute_units: Option<u32>,
188    /// Agent configurations to run as sidecars alongside this session.
189    #[serde(default)]
190    pub agents: Vec<AgentParams>,
191    /// Events of interest the session should watch for. When an upcoming
192    /// batch matches any filter, the server emits
193    /// [`BacktestResponse::DiscoveryBatch`] (and its session-event twins)
194    /// ahead of execution so the client can follow up with
195    /// [`BacktestRequest::ContinueTo`] to pause before the batch. Empty
196    /// means no batch discoveries are performed (existing behaviour).
197    #[serde(default, skip_serializing_if = "Vec::is_empty")]
198    pub discoveries: Vec<DiscoveryFilter>,
199}
200
201/// What counts as a "non-benign" divergence for the backtest session fail-fast
202/// behaviour (see `BacktestSessionOptions::fail_fast`).
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
204#[serde(rename_all = "kebab-case")]
205pub enum FailFastDivergenceKind {
206    /// Any divergence other than a benign log diff (the full `is_divergent()` set:
207    /// log mismatch, error mismatch, balance-diff mismatch).
208    #[default]
209    AnyNonBenign,
210    /// Only divergences on transactions that touch an account currently watched by an
211    /// account-diff subscription (subscribed directly, or owned by a subscribed program).
212    Tracked,
213}
214
215impl FailFastDivergenceKind {
216    /// Stable string form used for CLI/env-var serialization. Matches the serde
217    /// `kebab-case` representation.
218    pub fn as_str(self) -> &'static str {
219        match self {
220            Self::AnyNonBenign => "any-non-benign",
221            Self::Tracked => "tracked",
222        }
223    }
224
225    /// Inverse of [`Self::as_str`]; returns `None` for an unrecognized value.
226    pub fn from_str_opt(value: &str) -> Option<Self> {
227        match value {
228            "any-non-benign" => Some(Self::AnyNonBenign),
229            "tracked" => Some(Self::Tracked),
230            _ => None,
231        }
232    }
233}
234
235/// Available agent types for sidecar participation in backtest sessions.
236#[derive(Debug, Clone, Serialize, Deserialize)]
237#[serde(rename_all = "camelCase")]
238pub enum AgentType {
239    Arb,
240}
241
242/// Parameters for a circular arbitrage route.
243#[derive(Debug, Clone, Serialize, Deserialize)]
244#[serde(rename_all = "camelCase")]
245pub struct ArbRouteParams {
246    pub base_mint: String,
247    pub temp_mint: String,
248    #[serde(default)]
249    pub buy_dexes: Vec<String>,
250    #[serde(default)]
251    pub sell_dexes: Vec<String>,
252    pub min_input: u64,
253    pub max_input: u64,
254    #[serde(default)]
255    pub min_profit: u64,
256}
257
258/// Configuration for an agent to run alongside a backtest session.
259#[derive(Debug, Clone, Serialize, Deserialize)]
260#[serde(rename_all = "camelCase")]
261pub struct AgentParams {
262    pub agent_type: AgentType,
263    pub wallet: Option<String>,
264    /// Base58-encoded 64-byte keypair for signing transactions (compatible with `solana-keygen`).
265    pub keypair: Option<String>,
266    pub seed_sol_lamports: Option<u64>,
267    #[serde(default)]
268    pub seed_token_accounts: BTreeMap<String, u64>,
269    #[serde(default)]
270    pub arb_routes: Vec<ArbRouteParams>,
271}
272
273/// Account state modifications to apply.
274#[serde_with::serde_as]
275#[derive(Debug, Serialize, Deserialize, Default)]
276pub struct AccountModifications(
277    #[serde_as(as = "BTreeMap<serde_with::DisplayFromStr, _>")]
278    #[serde(default)]
279    pub BTreeMap<Address, AccountData>,
280);
281
282/// Arguments used to continue an existing session.
283#[serde_with::serde_as]
284#[derive(Debug, Serialize, Deserialize)]
285#[serde(rename_all = "camelCase")]
286pub struct ContinueParams {
287    #[serde(default = "ContinueParams::default_advance_count")]
288    /// Number of blocks to advance before waiting.
289    pub advance_count: u64,
290    #[serde(default)]
291    /// Base64-encoded transactions to execute before advancing.
292    pub transactions: Vec<String>,
293    #[serde(default)]
294    /// Account state overrides to apply ahead of execution.
295    pub modify_account_states: AccountModifications,
296}
297
298impl Default for ContinueParams {
299    fn default() -> Self {
300        Self {
301            advance_count: Self::default_advance_count(),
302            transactions: Vec::new(),
303            modify_account_states: AccountModifications(BTreeMap::new()),
304        }
305    }
306}
307
308impl ContinueParams {
309    pub fn default_advance_count() -> u64 {
310        1
311    }
312}
313
314/// Payload emitted when a session halts at a caller-specified point.
315/// `batch_index` is `None` for block-boundary pauses and `Some(n)` when the
316/// session stopped *before* batch `n` within `slot` (no transaction from
317/// batch `n` has been applied). While paused, RPC reads against the session
318/// observe partial state up through batch `n - 1`.
319#[derive(Debug, Clone, Serialize, Deserialize)]
320#[serde(rename_all = "camelCase")]
321pub struct PausedEvent {
322    pub slot: u64,
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub batch_index: Option<u32>,
325}
326
327/// Payload emitted when the session has *discovered* an upcoming batch that
328/// matches one or more registered [`DiscoveryFilter`]s from session creation
329/// (for example, a batch containing a transaction that invokes a program of
330/// interest). The `(slot, batch_index)` pair can be fed directly to
331/// [`BacktestRequest::ContinueTo`] to pause immediately before the batch
332/// executes. After each `Continue` / `ContinueTo`, the session emits the
333/// next `DiscoveryBatchEvent` ahead of the next matching batch, enabling a
334/// reactive "pause on every discovery" driver loop.
335#[serde_with::serde_as]
336#[derive(Debug, Clone, Serialize, Deserialize)]
337#[serde(rename_all = "camelCase")]
338pub struct DiscoveryBatchEvent {
339    pub slot: u64,
340    pub batch_index: u32,
341    /// Filters that matched this batch (always non-empty).
342    pub matched: Vec<DiscoveryFilter>,
343    /// Encoded transactions in this batch that triggered the match. Each
344    /// entry carries the serialized `VersionedTransaction` bytes paired with
345    /// the encoding used.
346    pub transactions: Vec<EncodedBinary>,
347}
348
349/// Arguments used to step an existing session to a precise point.
350#[derive(Debug, Clone, Serialize, Deserialize)]
351#[serde(rename_all = "camelCase")]
352pub struct ContinueToParams {
353    /// Target slot to stop in (or at, if `batch_index` is `None`).
354    pub slot: u64,
355    /// Batch within the target slot at which to pause, **exclusive** — the
356    /// session halts immediately *before* batch `n` executes, so no
357    /// transaction in that batch has been applied yet. `None` runs the
358    /// whole slot, pausing at the block boundary. While paused, RPC reads
359    /// observe partial state up through batch `n - 1`.
360    #[serde(default)]
361    pub batch_index: Option<u32>,
362}
363
364/// Supported binary encodings for account/transaction payloads.
365#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
366#[serde(rename_all = "lowercase")]
367pub enum BinaryEncoding {
368    Base64,
369}
370
371impl BinaryEncoding {
372    pub fn encode(self, bytes: &[u8]) -> String {
373        match self {
374            Self::Base64 => BASE64.encode(bytes),
375        }
376    }
377
378    pub fn decode(self, data: &str) -> Result<Vec<u8>, Base64DecodeError> {
379        match self {
380            Self::Base64 => BASE64.decode(data),
381        }
382    }
383}
384
385/// A blob paired with the encoding needed to decode it.
386#[derive(Debug, Clone, Serialize, Deserialize)]
387#[serde(rename_all = "camelCase")]
388pub struct EncodedBinary {
389    /// Encoded payload.
390    pub data: String,
391    /// Encoding scheme used for the payload.
392    pub encoding: BinaryEncoding,
393}
394
395impl EncodedBinary {
396    pub fn new(data: String, encoding: BinaryEncoding) -> Self {
397        Self { data, encoding }
398    }
399
400    pub fn from_bytes(bytes: &[u8], encoding: BinaryEncoding) -> Self {
401        Self {
402            data: encoding.encode(bytes),
403            encoding,
404        }
405    }
406
407    pub fn decode(&self) -> Result<Vec<u8>, Base64DecodeError> {
408        self.encoding.decode(&self.data)
409    }
410}
411
412/// Account snapshot used to seed or modify state in a session.
413#[serde_with::serde_as]
414#[derive(Debug, Serialize, Deserialize)]
415#[serde(rename_all = "camelCase")]
416pub struct AccountData {
417    /// Account data bytes and encoding.
418    pub data: EncodedBinary,
419    /// Whether the account is executable.
420    pub executable: bool,
421    /// Lamport balance.
422    pub lamports: u64,
423    #[serde_as(as = "serde_with::DisplayFromStr")]
424    /// Account owner pubkey.
425    pub owner: Address,
426    /// Allocated space in bytes.
427    pub space: u64,
428}
429
430/// Responses returned over the backtest RPC channel.
431#[derive(Debug, Clone, Serialize, Deserialize)]
432#[serde(tag = "method", content = "params", rename_all = "camelCase")]
433pub enum BacktestResponse {
434    SessionCreated {
435        session_id: String,
436        rpc_endpoint: String,
437        #[serde(default, skip_serializing_if = "Option::is_none")]
438        task_id: Option<String>,
439    },
440    SessionAttached {
441        session_id: String,
442        rpc_endpoint: String,
443        #[serde(default, skip_serializing_if = "Option::is_none")]
444        task_id: Option<String>,
445    },
446    SessionsCreated {
447        session_ids: Vec<String>,
448    },
449    SessionsCreatedV2 {
450        control_session_id: String,
451        session_ids: Vec<String>,
452        #[serde(default)]
453        task_ids: Vec<Option<String>>,
454        /// Per-sub-session start/end slots, parallel to `session_ids`. The
455        /// server's split is authoritative (a mid-bundle request anchors to the
456        /// covering bundle), so the client binds each sub-session to its range
457        /// from here rather than re-deriving the split locally.
458        #[serde(default)]
459        start_slots: Vec<u64>,
460        #[serde(default)]
461        end_slots: Vec<u64>,
462    },
463    ParallelSessionAttachedV2 {
464        control_session_id: String,
465        session_ids: Vec<String>,
466        #[serde(default)]
467        task_ids: Vec<Option<String>>,
468    },
469    ReadyForContinue,
470    SlotNotification(u64),
471    Paused(PausedEvent),
472    DiscoveryBatch(DiscoveryBatchEvent),
473    Error(BacktestError),
474    Success,
475    Completed {
476        /// Session summary with transaction statistics.
477        /// The session always computes this summary, but management may omit it from
478        /// client-facing responses unless `send_summary` was requested at session creation.
479        #[serde(skip_serializing_if = "Option::is_none")]
480        summary: Option<SessionSummary>,
481        #[serde(default, skip_serializing_if = "Option::is_none")]
482        agent_stats: Option<Vec<AgentStatsReport>>,
483    },
484    Status {
485        status: BacktestStatus,
486    },
487    SessionEventV1 {
488        session_id: String,
489        event: SessionEventV1,
490    },
491    SessionEventV2 {
492        session_id: String,
493        seq_id: u64,
494        event: SessionEventKind,
495    },
496}
497
498impl BacktestResponse {
499    pub fn is_completed(&self) -> bool {
500        matches!(self, BacktestResponse::Completed { .. })
501    }
502
503    pub fn is_terminal(&self) -> bool {
504        match self {
505            BacktestResponse::Completed { .. } => true,
506            BacktestResponse::Error(e) => matches!(
507                e,
508                BacktestError::NoMoreBlocks
509                    | BacktestError::AdvanceSlotFailed { .. }
510                    | BacktestError::FinalizeSlotFailed { .. }
511                    | BacktestError::Internal { .. }
512            ),
513            _ => false,
514        }
515    }
516}
517
518impl From<BacktestStatus> for BacktestResponse {
519    fn from(status: BacktestStatus) -> Self {
520        Self::Status { status }
521    }
522}
523
524impl From<String> for BacktestResponse {
525    fn from(message: String) -> Self {
526        BacktestError::Internal { error: message }.into()
527    }
528}
529
530impl From<&str> for BacktestResponse {
531    fn from(message: &str) -> Self {
532        BacktestError::Internal {
533            error: message.to_string(),
534        }
535        .into()
536    }
537}
538
539#[derive(Debug, Clone, Serialize, Deserialize)]
540#[serde(tag = "method", content = "params", rename_all = "camelCase")]
541pub enum SessionEventV1 {
542    ReadyForContinue,
543    SlotNotification(u64),
544    Paused(PausedEvent),
545    DiscoveryBatch(DiscoveryBatchEvent),
546    Error(BacktestError),
547    Success,
548    Completed {
549        #[serde(skip_serializing_if = "Option::is_none")]
550        summary: Option<SessionSummary>,
551        #[serde(default, skip_serializing_if = "Option::is_none")]
552        agent_stats: Option<Vec<AgentStatsReport>>,
553    },
554    Status {
555        status: BacktestStatus,
556    },
557}
558
559#[derive(Debug, Clone, Serialize, Deserialize)]
560#[serde(tag = "method", content = "params", rename_all = "camelCase")]
561pub enum SessionEventKind {
562    ReadyForContinue,
563    SlotNotification(u64),
564    Paused(PausedEvent),
565    DiscoveryBatch(DiscoveryBatchEvent),
566    Error(BacktestError),
567    Success,
568    Completed {
569        #[serde(skip_serializing_if = "Option::is_none")]
570        summary: Option<SessionSummary>,
571    },
572    Status {
573        status: BacktestStatus,
574    },
575}
576
577impl SessionEventKind {
578    pub fn is_terminal(&self) -> bool {
579        match self {
580            Self::Completed { .. } => true,
581            Self::Error(e) => matches!(
582                e,
583                BacktestError::NoMoreBlocks
584                    | BacktestError::AdvanceSlotFailed { .. }
585                    | BacktestError::FinalizeSlotFailed { .. }
586                    | BacktestError::Internal { .. }
587            ),
588            _ => false,
589        }
590    }
591}
592
593/// Wire format wrapper for responses sent over the control websocket.
594/// Sessions with `disconnect_timeout_secs > 0` use this to track client position.
595#[derive(Debug, Clone, Serialize, Deserialize)]
596#[serde(rename_all = "camelCase")]
597pub struct SequencedResponse {
598    pub seq_id: u64,
599    #[serde(flatten)]
600    pub response: BacktestResponse,
601}
602
603/// High-level progress states during a `Continue` call.
604#[derive(Debug, Clone, Serialize, Deserialize)]
605#[serde(rename_all = "camelCase")]
606pub enum BacktestStatus {
607    /// Runtime startup is in progress.
608    StartingRuntime,
609    DecodedTransactions,
610    AppliedAccountModifications,
611    ReadyToExecuteUserTransactions,
612    ExecutedUserTransactions,
613    ExecutingBlockTransactions,
614    ExecutedBlockTransactions,
615    ProgramAccountsLoaded,
616}
617
618impl std::fmt::Display for BacktestStatus {
619    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
620        let s = match self {
621            Self::StartingRuntime => "starting runtime",
622            Self::DecodedTransactions => "decoded transactions",
623            Self::AppliedAccountModifications => "applied account modifications",
624            Self::ReadyToExecuteUserTransactions => "ready to execute user transactions",
625            Self::ExecutedUserTransactions => "executed user transactions",
626            Self::ExecutingBlockTransactions => "executing block transactions",
627            Self::ExecutedBlockTransactions => "executed block transactions",
628            Self::ProgramAccountsLoaded => "program accounts loaded",
629        };
630        f.write_str(s)
631    }
632}
633
634/// Structured stats reported by an agent during a backtest session.
635#[derive(Debug, Clone, Default, Serialize, Deserialize)]
636#[serde(rename_all = "camelCase")]
637pub struct AgentStatsReport {
638    pub name: String,
639    pub slots_processed: u64,
640    pub opportunities_found: u64,
641    pub opportunities_skipped: u64,
642    pub no_routes: u64,
643    pub txs_produced: u64,
644    /// Cumulative expected profit per base mint, keyed by mint address.
645    pub expected_gain_by_mint: BTreeMap<String, i64>,
646    /// Transactions successfully executed by the sidecar.
647    #[serde(default)]
648    pub txs_submitted: u64,
649    /// Transactions that failed execution.
650    #[serde(default)]
651    pub txs_failed: u64,
652    /// Transactions rejected by preflight simulation (unprofitable).
653    #[serde(default)]
654    pub txs_simulation_rejected: u64,
655    /// Preflight simulation RPC calls that errored.
656    #[serde(default)]
657    pub txs_simulation_failed: u64,
658}
659
660/// Summary of transaction execution statistics for a completed backtest session.
661#[derive(Debug, Clone, Default, Serialize, Deserialize)]
662#[serde(rename_all = "camelCase")]
663pub struct SessionSummary {
664    /// Number of simulations where simulator outcome matched on-chain outcome
665    /// (`true_success + true_failure`).
666    pub correct_simulation: usize,
667    /// Number of simulations where simulator outcome did not match on-chain outcome
668    /// (`false_success + false_failure`).
669    pub incorrect_simulation: usize,
670    /// Number of transactions that had execution errors in simulation.
671    pub execution_errors: usize,
672    /// Number of transactions with different balance diffs.
673    pub balance_diff: usize,
674    /// Number of transactions with different log diffs.
675    pub log_diff: usize,
676}
677
678impl SessionSummary {
679    /// Returns true if there were any execution deviations (errors or mismatched results).
680    pub fn has_deviations(&self) -> bool {
681        self.incorrect_simulation > 0 || self.execution_errors > 0 || self.balance_diff > 0
682    }
683
684    /// Total number of transactions processed.
685    pub fn total_transactions(&self) -> usize {
686        self.correct_simulation
687            + self.incorrect_simulation
688            + self.execution_errors
689            + self.balance_diff
690            + self.log_diff
691    }
692}
693
694impl std::fmt::Display for SessionSummary {
695    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
696        let total = self.total_transactions();
697        write!(
698            f,
699            "Session summary: {total} transactions\n\
700             \x20  - {} correct simulation\n\
701             \x20  - {} incorrect simulation\n\
702             \x20  - {} execution errors\n\
703             \x20  - {} balance diffs\n\
704             \x20  - {} log diffs",
705            self.correct_simulation,
706            self.incorrect_simulation,
707            self.execution_errors,
708            self.balance_diff,
709            self.log_diff,
710        )
711    }
712}
713
714/// Error variants surfaced to backtest RPC clients.
715#[derive(Debug, Clone, Serialize, Deserialize)]
716#[serde(rename_all = "camelCase")]
717pub enum BacktestError {
718    InvalidTransactionEncoding {
719        index: usize,
720        error: String,
721    },
722    InvalidTransactionFormat {
723        index: usize,
724        error: String,
725    },
726    InvalidAccountEncoding {
727        address: String,
728        encoding: BinaryEncoding,
729        error: String,
730    },
731    InvalidAccountOwner {
732        address: String,
733        error: String,
734    },
735    InvalidAccountPubkey {
736        address: String,
737        error: String,
738    },
739    NoMoreBlocks,
740    AdvanceSlotFailed {
741        slot: u64,
742        error: String,
743    },
744    FinalizeSlotFailed {
745        slot: u64,
746        error: String,
747    },
748    InvalidRequest {
749        error: String,
750    },
751    Internal {
752        error: String,
753    },
754    InvalidBlockhashFormat {
755        slot: u64,
756        error: String,
757    },
758    InitializingSysvarsFailed {
759        slot: u64,
760        error: String,
761    },
762    ClerkError {
763        error: String,
764    },
765    SimulationError {
766        error: String,
767    },
768    SessionNotFound {
769        session_id: String,
770    },
771    SessionOwnerMismatch,
772    /// Session ownership is in transition (e.g. the previous manager is
773    /// shutting down, or another attach raced this one). Clients should retry
774    /// the attach within their reconnect budget; the route is expected to
775    /// become claimable shortly.
776    SessionOwnershipBusy {
777        reason: String,
778    },
779}
780
781/// One contiguous block range available on the history clerk.
782#[derive(Debug, Clone, Serialize, Deserialize)]
783pub struct AvailableRange {
784    pub bundle_start_slot: u64,
785    pub bundle_start_slot_utc: Option<String>,
786    pub max_bundle_end_slot: Option<u64>,
787    pub max_bundle_end_slot_utc: Option<String>,
788    pub max_bundle_size: Option<u64>,
789}
790
791/// Split a user-requested `[start_slot, end_slot]` range across the available
792/// bundle ranges, returning a list of contiguous, non-overlapping `(start, end)`
793/// pairs — one per bundle that the server can serve as its own session.
794///
795/// Each emitted `start` is a real `bundle_start_slot` and each `end` is within
796/// the `max_bundle_end_slot` of a bundle that begins *exactly* at that start
797/// (the server requires `start_slot == bundle_start_slot` and caps `end_slot` at
798/// that bundle's `max_bundle_end_slot`). Among all such gap-free splits we pick
799/// the one with the most sub-ranges, i.e. the highest parallelism: a fine-grained
800/// (e.g. 10k) bundle grid yields one session per fine bundle instead of
801/// collapsing onto a coarser series that overlaps the same slots — even when the
802/// coarse and fine bundles share a start slot. A coarser bundle is only ridden
803/// where no finer grid continues the walk, which still lets us serve a request
804/// the coarse data covers even across a hole in the fine grid.
805///
806/// Returns an error if the requested start slot is not a bundle start, or if no
807/// gap-free split reaches `requested_end`.
808pub fn split_range(
809    ranges: &[AvailableRange],
810    requested_start: u64,
811    requested_end: u64,
812) -> Result<Vec<(u64, u64)>, String> {
813    if requested_end < requested_start {
814        return Err(format!(
815            "invalid range: start_slot {requested_start} > end_slot {requested_end}"
816        ));
817    }
818
819    // Every advertised end per bundle start. A start can carry several ends — a
820    // 50k snapshot bundle and the 10k bundle built on the same snapshot share a
821    // start slot but reach different ends — so we keep them all and let the walk
822    // pick whichever maximises parallelism.
823    let mut ends_by_start: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
824    for r in ranges {
825        if let Some(end) = r.max_bundle_end_slot
826            && end > r.bundle_start_slot
827        {
828            ends_by_start
829                .entry(r.bundle_start_slot)
830                .or_default()
831                .insert(end);
832        }
833    }
834
835    // Anchor a request that lands mid-bundle to the covering bundle's start —
836    // the latest bundle beginning at or before `requested_start` that still
837    // covers it. The server's `select_parallel_requests` shares this fn and
838    // anchors the same way, so the client must too or it would falsely reject a
839    // start that isn't itself a bundle boundary. An exact bundle-start request
840    // anchors to itself.
841    let Some((&anchor_start, _)) = ends_by_start.range(..=requested_start).rfind(|(_, ends)| {
842        ends.iter()
843            .next_back()
844            .is_some_and(|&end| end >= requested_start)
845    }) else {
846        return Err(format!(
847            "start_slot {requested_start} is not covered by any available bundle range"
848        ));
849    };
850
851    // Walk the bundle starts from `requested_end` backwards, recording for each
852    // start the gap-free split with the most sub-ranges (highest parallelism).
853    // A sub-range ends within one of its start's bundles and the next sub-range
854    // must begin on the very next slot, so a coarse bundle is ridden only when no
855    // finer bundle continues the walk.
856    let mut best_from: BTreeMap<u64, Vec<(u64, u64)>> = BTreeMap::new();
857    for (&start, ends) in ends_by_start.range(anchor_start..=requested_end).rev() {
858        let mut best: Option<Vec<(u64, u64)>> = None;
859        for &end in ends {
860            let candidate = if end >= requested_end {
861                Some(vec![(start, requested_end)])
862            } else {
863                best_from.get(&(end + 1)).map(|rest| {
864                    std::iter::once((start, end))
865                        .chain(rest.iter().copied())
866                        .collect()
867                })
868            };
869            if let Some(candidate) = candidate
870                && best.as_ref().is_none_or(|b| candidate.len() > b.len())
871            {
872                best = Some(candidate);
873            }
874        }
875        if let Some(best) = best {
876            best_from.insert(start, best);
877        }
878    }
879
880    best_from.remove(&anchor_start).ok_or_else(|| {
881        // Point at the first uncovered slot when the bundles leave a hole; fall
882        // back to a generic message when the range is covered but cannot be
883        // tiled to bundle boundaries.
884        let mut covered_to = anchor_start.saturating_sub(1);
885        for (&start, ends) in ends_by_start.range(anchor_start..=requested_end) {
886            if start > covered_to.saturating_add(1) {
887                break;
888            }
889            if let Some(&end) = ends.iter().next_back() {
890                covered_to = covered_to.max(end);
891            }
892        }
893        if covered_to < requested_end {
894            format!("gap in coverage at slot {}", covered_to + 1)
895        } else {
896            format!(
897                "no gap-free split of [{requested_start}, {requested_end}] aligns with the available bundle ranges"
898            )
899        }
900    })
901}
902
903impl From<BacktestError> for BacktestResponse {
904    fn from(error: BacktestError) -> Self {
905        Self::Error(error)
906    }
907}
908
909impl std::error::Error for BacktestError {}
910
911impl fmt::Display for BacktestError {
912    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
913        match self {
914            BacktestError::InvalidTransactionEncoding { index, error } => {
915                write!(f, "invalid transaction encoding at index {index}: {error}")
916            }
917            BacktestError::InvalidTransactionFormat { index, error } => {
918                write!(f, "invalid transaction format at index {index}: {error}")
919            }
920            BacktestError::InvalidAccountEncoding {
921                address,
922                encoding,
923                error,
924            } => write!(
925                f,
926                "invalid encoding for account {address} ({encoding:?}): {error}"
927            ),
928            BacktestError::InvalidAccountOwner { address, error } => {
929                write!(f, "invalid owner for account {address}: {error}")
930            }
931            BacktestError::InvalidAccountPubkey { address, error } => {
932                write!(f, "invalid account pubkey {address}: {error}")
933            }
934            BacktestError::NoMoreBlocks => write!(f, "no more blocks available"),
935            BacktestError::AdvanceSlotFailed { slot, error } => {
936                write!(f, "failed to advance to slot {slot}: {error}")
937            }
938            BacktestError::FinalizeSlotFailed { slot, error } => {
939                write!(f, "failed to finalize slot {slot}: {error}")
940            }
941            BacktestError::InvalidRequest { error } => write!(f, "invalid request: {error}"),
942            BacktestError::Internal { error } => write!(f, "internal error: {error}"),
943            BacktestError::InvalidBlockhashFormat { slot, error } => {
944                write!(f, "invalid blockhash at slot {slot}: {error}")
945            }
946            BacktestError::InitializingSysvarsFailed { slot, error } => {
947                write!(f, "failed to initialize sysvars at slot {slot}: {error}")
948            }
949            BacktestError::ClerkError { error } => write!(f, "clerk error: {error}"),
950            BacktestError::SimulationError { error } => {
951                write!(f, "simulation error: {error}")
952            }
953            BacktestError::SessionNotFound { session_id } => {
954                write!(f, "session not found: {session_id}")
955            }
956            BacktestError::SessionOwnerMismatch => {
957                write!(f, "session owner mismatch")
958            }
959            BacktestError::SessionOwnershipBusy { reason } => {
960                write!(f, "session ownership busy: {reason}")
961            }
962        }
963    }
964}
965
966#[cfg(test)]
967mod tests {
968    use super::*;
969
970    #[test]
971    fn fail_fast_divergence_kind_str_round_trips() {
972        for kind in [
973            FailFastDivergenceKind::AnyNonBenign,
974            FailFastDivergenceKind::Tracked,
975        ] {
976            assert_eq!(
977                FailFastDivergenceKind::from_str_opt(kind.as_str()),
978                Some(kind)
979            );
980        }
981        assert_eq!(FailFastDivergenceKind::from_str_opt("nonsense"), None);
982        assert_eq!(
983            FailFastDivergenceKind::default(),
984            FailFastDivergenceKind::AnyNonBenign
985        );
986    }
987
988    fn range(start: u64, end: u64) -> AvailableRange {
989        AvailableRange {
990            bundle_start_slot: start,
991            bundle_start_slot_utc: None,
992            max_bundle_end_slot: Some(end),
993            max_bundle_end_slot_utc: None,
994            max_bundle_size: None,
995        }
996    }
997
998    /// Each case lists the available bundles, the requested `[start, end]`, and
999    /// the expected split — `Some(_)` for an accepted plan, `None` when the range
1000    /// is unservable and `split_range` must error.
1001    #[rstest::rstest]
1002    #[case::single(vec![range(100, 300)], 100, 300, Some(vec![(100, 300)]))]
1003    #[case::multi(
1004        vec![range(100, 200), range(201, 300), range(301, 400)],
1005        100, 300, Some(vec![(100, 200), (201, 300)])
1006    )]
1007    // Smaller bundles nested inside a larger one don't bridge the gap, but the
1008    // range is still coverable by riding the coarse series.
1009    #[case::nested(
1010        vec![range(100, 500), range(110, 150), range(150, 190), range(501, 900)],
1011        100, 900, Some(vec![(100, 500), (501, 900)])
1012    )]
1013    // A fine grid (1k bundles standing in for the 10k grid) overlapped by a
1014    // coarser, differently-aligned series: ride the fine grid, never landing on
1015    // the coarse bundle's drifted start.
1016    #[case::prefers_finer_grid(
1017        vec![range(1_000, 1_999), range(1_500, 3_400), range(2_000, 2_999), range(3_000, 3_999)],
1018        1_000, 3_999, Some(vec![(1_000, 1_999), (2_000, 2_999), (3_000, 3_999)])
1019    )]
1020    // A 50k snapshot bundle and the 10k bundles built on it share start 100: ride
1021    // the 10k grid instead of the coarse end, which would strand the cursor on a
1022    // slot no bundle starts at.
1023    #[case::shared_start_prefers_finer(
1024        vec![range(100, 150), range(100, 120), range(121, 140), range(141, 160)],
1025        100, 160, Some(vec![(100, 120), (121, 140), (141, 160)])
1026    )]
1027    // The fine grid has a hole (nothing starts at 141), but a coarse bundle
1028    // sharing the start spans it: serve the request off the coarse bundle rather
1029    // than reporting an unsupported range.
1030    #[case::falls_back_to_coarse(
1031        vec![range(100, 160), range(100, 120), range(121, 140)],
1032        100, 160, Some(vec![(100, 160)])
1033    )]
1034    // The last bundle overshoots the requested end and is clamped.
1035    #[case::clamps_final_bundle(vec![range(100, 199), range(200, 999)], 100, 450, Some(vec![(100, 199), (200, 450)]))]
1036    // A request landing mid-bundle anchors to the covering bundle's start
1037    // (matching the server) instead of erroring as "not covered".
1038    #[case::anchors_mid_bundle(vec![range(150, 350)], 200, 300, Some(vec![(150, 300)]))]
1039    #[case::anchors_then_continues(
1040        vec![range(150, 350), range(351, 600)],
1041        200, 600, Some(vec![(150, 350), (351, 600)])
1042    )]
1043    #[case::start_inside_bundle_anchors(vec![range(200, 400)], 300, 400, Some(vec![(200, 400)]))]
1044    // Errors: a start before any bundle's coverage has no covering bundle...
1045    #[case::start_before_first_bundle(vec![range(200, 400)], 100, 400, None)]
1046    // ...the end must be reachable...
1047    #[case::end_not_covered(vec![range(100, 200)], 100, 300, None)]
1048    #[case::gap_in_coverage(vec![range(100, 200), range(210, 300)], 100, 300, None)]
1049    // ...and the range must not be inverted.
1050    #[case::inverted_range(vec![range(100, 300)], 300, 100, None)]
1051    fn split_range_cases(
1052        #[case] ranges: Vec<AvailableRange>,
1053        #[case] start: u64,
1054        #[case] end: u64,
1055        #[case] expected: Option<Vec<(u64, u64)>>,
1056    ) {
1057        match expected {
1058            Some(expected) => assert_eq!(split_range(&ranges, start, end).unwrap(), expected),
1059            None => assert!(split_range(&ranges, start, end).is_err()),
1060        }
1061    }
1062
1063    /// Servable end per bundle start, as `split_range` sees it — every advertised
1064    /// end with `end > start`, keyed by start.
1065    fn ends_by_start(ranges: &[AvailableRange]) -> BTreeMap<u64, BTreeSet<u64>> {
1066        let mut ends: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
1067        for r in ranges {
1068            if let Some(end) = r.max_bundle_end_slot
1069                && end > r.bundle_start_slot
1070            {
1071                ends.entry(r.bundle_start_slot).or_default().insert(end);
1072            }
1073        }
1074        ends
1075    }
1076
1077    /// Independent spec for the optimum: the gap-free split of `[start, end]` with
1078    /// the most sub-ranges, found by exhaustively trying every advertised end at
1079    /// each cursor. `None` when no split aligns with the bundle starts.
1080    fn reference_max_split(
1081        ends: &BTreeMap<u64, BTreeSet<u64>>,
1082        cursor: u64,
1083        end: u64,
1084    ) -> Option<Vec<(u64, u64)>> {
1085        ends.get(&cursor)?
1086            .iter()
1087            .filter_map(|&bundle_end| {
1088                if bundle_end >= end {
1089                    Some(vec![(cursor, end)])
1090                } else {
1091                    reference_max_split(ends, bundle_end + 1, end).map(|mut rest| {
1092                        rest.insert(0, (cursor, bundle_end));
1093                        rest
1094                    })
1095                }
1096            })
1097            .max_by_key(Vec::len)
1098    }
1099
1100    /// Structure-independent check that a split is one the server can serve: it
1101    /// tiles `[start, end]` gap-free, every start is a real bundle start, and
1102    /// every end is within that start's largest advertised end.
1103    fn is_valid_split(
1104        split: &[(u64, u64)],
1105        ends: &BTreeMap<u64, BTreeSet<u64>>,
1106        start: u64,
1107        end: u64,
1108    ) -> bool {
1109        split.first().is_some_and(|&(s, _)| s == start)
1110            && split.last().is_some_and(|&(_, e)| e == end)
1111            && split.windows(2).all(|w| w[1].0 == w[0].1 + 1)
1112            && split.iter().all(|&(s, e)| {
1113                e >= s
1114                    && ends
1115                        .get(&s)
1116                        .and_then(|bundle_ends| bundle_ends.iter().next_back())
1117                        .is_some_and(|&max_end| e <= max_end)
1118            })
1119    }
1120
1121    /// Property test: across many randomized bundle layouts, `split_range` must
1122    /// return a *valid* split with the *maximum* number of sub-ranges whenever one
1123    /// exists, and error exactly when none does. Deterministic (fixed LCG seed) so
1124    /// a failure is reproducible.
1125    #[test]
1126    fn split_range_matches_reference() {
1127        let mut seed: u64 = 0x9E3779B97F4A7C15;
1128        let mut next = || {
1129            seed = seed
1130                .wrapping_mul(6364136223846793005)
1131                .wrapping_add(1442695040888963407);
1132            seed >> 33
1133        };
1134
1135        for _ in 0..50_000 {
1136            // A small slot universe makes shared starts, overlaps, nesting, and
1137            // gaps all common.
1138            let ranges: Vec<AvailableRange> = (0..next() % 6)
1139                .map(|_| {
1140                    let start = next() % 12;
1141                    range(start, start + next() % 6) // len 0 => filtered out
1142                })
1143                .collect();
1144            let start = next() % 12;
1145            let end = start + next() % 6; // sometimes start == end, sometimes unservable
1146
1147            let got = split_range(&ranges, start, end);
1148            let ends = ends_by_start(&ranges);
1149            // split_range anchors a mid-bundle start to the covering bundle's
1150            // start, so the reference must walk from the same anchor.
1151            let anchor = ends
1152                .range(..=start)
1153                .rfind(|(_, e)| e.iter().next_back().is_some_and(|&x| x >= start))
1154                .map(|(&s, _)| s);
1155            let reference = anchor.and_then(|a| reference_max_split(&ends, a, end));
1156
1157            let layout: Vec<_> = ranges
1158                .iter()
1159                .map(|r| (r.bundle_start_slot, r.max_bundle_end_slot))
1160                .collect();
1161            match (&got, &reference) {
1162                (Ok(split), Some(best)) => {
1163                    assert!(
1164                        is_valid_split(split, &ends, anchor.unwrap(), end),
1165                        "invalid split {split:?} for {layout:?} [{start},{end}]"
1166                    );
1167                    assert_eq!(
1168                        split.len(),
1169                        best.len(),
1170                        "suboptimal split {split:?} vs {best:?} for {layout:?} [{start},{end}]"
1171                    );
1172                }
1173                (Err(_), None) => {}
1174                _ => panic!(
1175                    "disagreement: split_range={got:?}, reference={reference:?} for {layout:?} [{start},{end}]"
1176                ),
1177            }
1178        }
1179    }
1180}