Skip to main content

simulator_api/
lib.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
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
12/// Backtest RPC methods exposed to the client.
13#[derive(Debug, Serialize, Deserialize)]
14#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
15#[serde(tag = "method", content = "params", rename_all = "camelCase")]
16#[cfg_attr(feature = "ts-rs", ts(export))]
17pub enum BacktestRequest {
18    CreateBacktestSession(CreateBacktestSessionRequest),
19    Continue(ContinueParams),
20    ContinueSessionV1(ContinueSessionRequestV1),
21    CloseBacktestSession,
22    CloseSessionV1(CloseSessionRequestV1),
23    AttachBacktestSession {
24        session_id: String,
25        /// Last sequence number the client received. Responses after this sequence
26        /// will be replayed from the session's buffer. None = replay entire buffer.
27        last_sequence: Option<u64>,
28    },
29}
30
31/// Versioned payload for `CreateBacktestSession`.
32///
33/// - `V0` keeps backwards-compatible shape by using `CreateSessionParams` directly.
34/// - `V1` keeps the same shape and adds `parallel`.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
37#[serde(untagged)]
38#[cfg_attr(feature = "ts-rs", ts(export))]
39pub enum CreateBacktestSessionRequest {
40    V1(CreateBacktestSessionRequestV1),
41    V0(CreateSessionParams),
42}
43
44impl CreateBacktestSessionRequest {
45    pub fn into_request_options(self) -> CreateBacktestSessionRequestOptions {
46        match self {
47            Self::V0(request) => CreateBacktestSessionRequestOptions {
48                request,
49                parallel: false,
50            },
51            Self::V1(CreateBacktestSessionRequestV1 { request, parallel }) => {
52                CreateBacktestSessionRequestOptions { request, parallel }
53            }
54        }
55    }
56
57    pub fn into_request_and_parallel(self) -> (CreateSessionParams, bool) {
58        let options = self.into_request_options();
59        (options.request, options.parallel)
60    }
61}
62
63impl From<CreateSessionParams> for CreateBacktestSessionRequest {
64    fn from(value: CreateSessionParams) -> Self {
65        Self::V0(value)
66    }
67}
68
69impl From<CreateBacktestSessionRequestV1> for CreateBacktestSessionRequest {
70    fn from(value: CreateBacktestSessionRequestV1) -> Self {
71        Self::V1(value)
72    }
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
77#[serde(rename_all = "camelCase")]
78#[cfg_attr(feature = "ts-rs", ts(export))]
79pub struct CreateBacktestSessionRequestV1 {
80    #[serde(flatten)]
81    pub request: CreateSessionParams,
82    pub parallel: bool,
83}
84
85#[derive(Debug, Clone)]
86pub struct CreateBacktestSessionRequestOptions {
87    pub request: CreateSessionParams,
88    pub parallel: bool,
89}
90
91#[derive(Debug, Serialize, Deserialize)]
92#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
93#[serde(rename_all = "camelCase")]
94#[cfg_attr(feature = "ts-rs", ts(export))]
95pub struct ContinueSessionRequestV1 {
96    pub session_id: String,
97    pub request: ContinueParams,
98}
99
100#[derive(Debug, Serialize, Deserialize)]
101#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
102#[serde(rename_all = "camelCase")]
103#[cfg_attr(feature = "ts-rs", ts(export))]
104pub struct CloseSessionRequestV1 {
105    pub session_id: String,
106}
107
108/// Parameters required to start a new backtest session.
109#[serde_with::serde_as]
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
112#[serde(rename_all = "camelCase")]
113pub struct CreateSessionParams {
114    /// First slot (inclusive) to replay.
115    pub start_slot: u64,
116    /// Last slot (inclusive) to replay.
117    pub end_slot: u64,
118    #[serde_as(as = "BTreeSet<serde_with::DisplayFromStr>")]
119    #[serde(default)]
120    #[cfg_attr(feature = "ts-rs", ts(as = "Vec<String>"))]
121    /// Skip transactions signed by these addresses.
122    pub signer_filter: BTreeSet<Address>,
123    #[serde_as(as = "BTreeSet<serde_with::DisplayFromStr>")]
124    #[serde(default)]
125    #[cfg_attr(feature = "ts-rs", ts(as = "Vec<String>"))]
126    /// Programs to preload before executing.
127    pub preload_programs: BTreeSet<Address>,
128    /// Account bundle IDs to preload before executing.
129    #[serde(default)]
130    pub preload_account_bundles: Vec<String>,
131    /// When true, include a session summary with transaction statistics in client-facing
132    /// `Completed` responses. Summary generation remains enabled internally for metrics.
133    #[serde(default)]
134    pub send_summary: bool,
135    /// Maximum seconds to keep the session alive after the control websocket disconnects.
136    /// If not set (or 0), the session tears down immediately on disconnect.
137    /// Maximum value: 900 (15 minutes).
138    #[serde(default)]
139    pub disconnect_timeout_secs: Option<u16>,
140    /// Extra compute units to add to each transaction's `SetComputeUnitLimit` budget.
141    /// Useful when replaying with an account override whose program uses more CU than
142    /// the original, causing otherwise-healthy transactions to run out of budget.
143    /// Only applied when a `SetComputeUnitLimit` instruction is already present.
144    #[serde(default)]
145    pub extra_compute_units: Option<u32>,
146}
147
148/// Account state modifications to apply.
149#[serde_with::serde_as]
150#[derive(Debug, Serialize, Deserialize, Default)]
151#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
152pub struct AccountModifications(
153    #[serde_as(as = "BTreeMap<serde_with::DisplayFromStr, _>")]
154    #[serde(default)]
155    #[cfg_attr(feature = "ts-rs", ts(as = "BTreeMap<String, AccountData>"))]
156    pub BTreeMap<Address, AccountData>,
157);
158
159/// Arguments used to continue an existing session.
160#[serde_with::serde_as]
161#[derive(Debug, Serialize, Deserialize)]
162#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
163#[serde(rename_all = "camelCase")]
164pub struct ContinueParams {
165    #[serde(default = "ContinueParams::default_advance_count")]
166    /// Number of blocks to advance before waiting.
167    pub advance_count: u64,
168    #[serde(default)]
169    /// Base64-encoded transactions to execute before advancing.
170    pub transactions: Vec<String>,
171    #[serde(default)]
172    /// Account state overrides to apply ahead of execution.
173    pub modify_account_states: AccountModifications,
174}
175
176impl Default for ContinueParams {
177    fn default() -> Self {
178        Self {
179            advance_count: Self::default_advance_count(),
180            transactions: Vec::new(),
181            modify_account_states: AccountModifications(BTreeMap::new()),
182        }
183    }
184}
185
186impl ContinueParams {
187    pub fn default_advance_count() -> u64 {
188        1
189    }
190}
191
192/// Supported binary encodings for account/transaction payloads.
193#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
194#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
195#[serde(rename_all = "lowercase")]
196pub enum BinaryEncoding {
197    Base64,
198}
199
200impl BinaryEncoding {
201    pub fn encode(self, bytes: &[u8]) -> String {
202        match self {
203            Self::Base64 => BASE64.encode(bytes),
204        }
205    }
206
207    pub fn decode(self, data: &str) -> Result<Vec<u8>, Base64DecodeError> {
208        match self {
209            Self::Base64 => BASE64.decode(data),
210        }
211    }
212}
213
214/// A blob paired with the encoding needed to decode it.
215#[derive(Debug, Clone, Serialize, Deserialize)]
216#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
217#[serde(rename_all = "camelCase")]
218pub struct EncodedBinary {
219    /// Encoded payload.
220    pub data: String,
221    /// Encoding scheme used for the payload.
222    pub encoding: BinaryEncoding,
223}
224
225impl EncodedBinary {
226    pub fn new(data: String, encoding: BinaryEncoding) -> Self {
227        Self { data, encoding }
228    }
229
230    pub fn from_bytes(bytes: &[u8], encoding: BinaryEncoding) -> Self {
231        Self {
232            data: encoding.encode(bytes),
233            encoding,
234        }
235    }
236
237    pub fn decode(&self) -> Result<Vec<u8>, Base64DecodeError> {
238        self.encoding.decode(&self.data)
239    }
240}
241
242/// Account snapshot used to seed or modify state in a session.
243#[serde_with::serde_as]
244#[derive(Debug, Serialize, Deserialize)]
245#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
246#[serde(rename_all = "camelCase")]
247pub struct AccountData {
248    /// Account data bytes and encoding.
249    pub data: EncodedBinary,
250    /// Whether the account is executable.
251    pub executable: bool,
252    /// Lamport balance.
253    pub lamports: u64,
254    #[serde_as(as = "serde_with::DisplayFromStr")]
255    #[cfg_attr(feature = "ts-rs", ts(as = "String"))]
256    /// Account owner pubkey.
257    pub owner: Address,
258    /// Allocated space in bytes.
259    pub space: u64,
260}
261
262/// Responses returned over the backtest RPC channel.
263#[derive(Debug, Clone, Serialize, Deserialize)]
264#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
265#[serde(tag = "method", content = "params", rename_all = "camelCase")]
266#[cfg_attr(feature = "ts-rs", ts(export))]
267pub enum BacktestResponse {
268    SessionCreated {
269        session_id: String,
270        rpc_endpoint: String,
271    },
272    SessionAttached {
273        session_id: String,
274        rpc_endpoint: String,
275    },
276    SessionsCreated {
277        session_ids: Vec<String>,
278    },
279    ReadyForContinue,
280    SlotNotification(u64),
281    Error(BacktestError),
282    Success,
283    Completed {
284        /// Session summary with transaction statistics.
285        /// The session always computes this summary, but management may omit it from
286        /// client-facing responses unless `send_summary` was requested at session creation.
287        #[serde(skip_serializing_if = "Option::is_none")]
288        summary: Option<SessionSummary>,
289    },
290    Status {
291        status: BacktestStatus,
292    },
293    SessionEventV1 {
294        session_id: String,
295        event: SessionEventV1,
296    },
297}
298
299impl BacktestResponse {
300    pub fn is_completed(&self) -> bool {
301        matches!(self, BacktestResponse::Completed { .. })
302    }
303
304    pub fn is_terminal(&self) -> bool {
305        match self {
306            BacktestResponse::Completed { .. } => true,
307            BacktestResponse::Error(e) => matches!(
308                e,
309                BacktestError::NoMoreBlocks
310                    | BacktestError::AdvanceSlotFailed { .. }
311                    | BacktestError::Internal { .. }
312            ),
313            _ => false,
314        }
315    }
316}
317
318impl From<BacktestStatus> for BacktestResponse {
319    fn from(status: BacktestStatus) -> Self {
320        Self::Status { status }
321    }
322}
323
324impl From<String> for BacktestResponse {
325    fn from(message: String) -> Self {
326        BacktestError::Internal { error: message }.into()
327    }
328}
329
330impl From<&str> for BacktestResponse {
331    fn from(message: &str) -> Self {
332        BacktestError::Internal {
333            error: message.to_string(),
334        }
335        .into()
336    }
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
340#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
341#[serde(tag = "method", content = "params", rename_all = "camelCase")]
342#[cfg_attr(feature = "ts-rs", ts(export))]
343pub enum SessionEventV1 {
344    ReadyForContinue,
345    SlotNotification(u64),
346    Error(BacktestError),
347    Success,
348    Completed {
349        #[serde(skip_serializing_if = "Option::is_none")]
350        summary: Option<SessionSummary>,
351    },
352    Status {
353        status: BacktestStatus,
354    },
355}
356
357/// Wire format wrapper for responses sent over the control websocket.
358/// Sessions with `disconnect_timeout_secs > 0` use this to track client position.
359#[derive(Debug, Clone, Serialize, Deserialize)]
360#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
361#[serde(rename_all = "camelCase")]
362#[cfg_attr(feature = "ts-rs", ts(export))]
363pub struct SequencedResponse {
364    pub seq_id: u64,
365    #[serde(flatten)]
366    pub response: BacktestResponse,
367}
368
369/// High-level progress states during a `Continue` call.
370#[derive(Debug, Clone, Serialize, Deserialize)]
371#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
372#[serde(rename_all = "camelCase")]
373pub enum BacktestStatus {
374    /// Manager is warming history prerequisites on clerk storage.
375    PreparingBundle,
376    /// History prerequisites are warmed and runtime startup can begin.
377    BundleReady,
378    /// Runtime startup is in progress.
379    StartingRuntime,
380    DecodedTransactions,
381    AppliedAccountModifications,
382    ReadyToExecuteUserTransactions,
383    ExecutedUserTransactions,
384    ExecutingBlockTransactions,
385    ExecutedBlockTransactions,
386    ProgramAccountsLoaded,
387}
388
389/// Summary of transaction execution statistics for a completed backtest session.
390#[derive(Debug, Clone, Default, Serialize, Deserialize)]
391#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
392#[serde(rename_all = "camelCase")]
393pub struct SessionSummary {
394    /// Number of simulations where simulator outcome matched on-chain outcome
395    /// (`true_success + true_failure`).
396    pub correct_simulation: usize,
397    /// Number of simulations where simulator outcome did not match on-chain outcome
398    /// (`false_success + false_failure`).
399    pub incorrect_simulation: usize,
400    /// Number of transactions that had execution errors in simulation.
401    pub execution_errors: usize,
402    /// Number of transactions with different balance diffs.
403    pub balance_diff: usize,
404    /// Number of transactions with different log diffs.
405    pub log_diff: usize,
406}
407
408impl SessionSummary {
409    /// Returns true if there were any execution deviations (errors or mismatched results).
410    pub fn has_deviations(&self) -> bool {
411        self.incorrect_simulation > 0 || self.execution_errors > 0 || self.balance_diff > 0
412    }
413
414    /// Total number of transactions processed.
415    pub fn total_transactions(&self) -> usize {
416        self.correct_simulation
417            + self.incorrect_simulation
418            + self.execution_errors
419            + self.balance_diff
420            + self.log_diff
421    }
422}
423
424impl std::fmt::Display for SessionSummary {
425    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
426        let total = self.total_transactions();
427        write!(
428            f,
429            "Session summary: {total} transactions\n\
430             \x20  - {} correct simulation\n\
431             \x20  - {} incorrect simulation\n\
432             \x20  - {} execution errors\n\
433             \x20  - {} balance diffs\n\
434             \x20  - {} log diffs",
435            self.correct_simulation,
436            self.incorrect_simulation,
437            self.execution_errors,
438            self.balance_diff,
439            self.log_diff,
440        )
441    }
442}
443
444/// Error variants surfaced to backtest RPC clients.
445#[derive(Debug, Clone, Serialize, Deserialize)]
446#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
447#[serde(rename_all = "camelCase")]
448pub enum BacktestError {
449    InvalidTransactionEncoding {
450        index: usize,
451        error: String,
452    },
453    InvalidTransactionFormat {
454        index: usize,
455        error: String,
456    },
457    InvalidAccountEncoding {
458        address: String,
459        encoding: BinaryEncoding,
460        error: String,
461    },
462    InvalidAccountOwner {
463        address: String,
464        error: String,
465    },
466    InvalidAccountPubkey {
467        address: String,
468        error: String,
469    },
470    NoMoreBlocks,
471    AdvanceSlotFailed {
472        slot: u64,
473        error: String,
474    },
475    InvalidRequest {
476        error: String,
477    },
478    Internal {
479        error: String,
480    },
481    InvalidBlockhashFormat {
482        slot: u64,
483        error: String,
484    },
485    InitializingSysvarsFailed {
486        slot: u64,
487        error: String,
488    },
489    ClerkError {
490        error: String,
491    },
492    SimulationError {
493        error: String,
494    },
495    SessionNotFound {
496        session_id: String,
497    },
498    SessionOwnerMismatch,
499}
500
501/// One contiguous block range available on the history clerk.
502#[derive(Debug, Clone, Serialize, Deserialize)]
503pub struct AvailableRange {
504    pub bundle_start_slot: u64,
505    pub max_bundle_end_slot: Option<u64>,
506}
507
508/// Split a user-requested `[start_slot, end_slot]` range across the available
509/// bundle ranges, returning a list of non-overlapping `(start, end)` pairs — one
510/// per bundle that intersects the requested range.
511///
512/// Returns an error if the requested start slot is not covered by any range.
513pub fn split_range(
514    ranges: &[AvailableRange],
515    requested_start: u64,
516    requested_end: u64,
517) -> Result<Vec<(u64, u64)>, String> {
518    if requested_end < requested_start {
519        return Err(format!(
520            "invalid range: start_slot {requested_start} > end_slot {requested_end}"
521        ));
522    }
523
524    let mut candidates: Vec<(u64, u64)> = ranges
525        .iter()
526        .filter_map(|r| Some((r.bundle_start_slot, r.max_bundle_end_slot?)))
527        .filter(|(start, end)| end >= start)
528        .collect();
529
530    candidates.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)));
531    candidates.dedup_by_key(|(start, _)| *start);
532
533    if candidates.is_empty() {
534        return Err("no available bundle ranges found on server".to_string());
535    }
536
537    let mut non_overlapping: Vec<(u64, u64)> = Vec::with_capacity(candidates.len());
538    for (i, (start, mut end)) in candidates.iter().copied().enumerate() {
539        if let Some((next_start, _)) = candidates.get(i + 1).copied()
540            && next_start <= end
541        {
542            end = next_start.saturating_sub(1);
543        }
544        if end >= start {
545            non_overlapping.push((start, end));
546        }
547    }
548
549    let anchor = non_overlapping
550        .iter()
551        .enumerate()
552        .rev()
553        .find(|(_, (s, e))| *s <= requested_start && *e >= requested_start)
554        .map(|(i, _)| i)
555        .ok_or_else(|| {
556            format!("start_slot {requested_start} is not covered by any available bundle range")
557        })?;
558
559    let mut result = Vec::new();
560    for (start, range_end) in non_overlapping.into_iter().skip(anchor) {
561        if start > requested_end {
562            break;
563        }
564        let end = range_end.min(requested_end);
565        if end >= start {
566            result.push((start, end));
567        }
568        if end == requested_end {
569            break;
570        }
571    }
572
573    if result.is_empty() {
574        return Err(format!(
575            "no available bundle ranges intersect requested range [{requested_start}-{requested_end}]"
576        ));
577    }
578
579    Ok(result)
580}
581
582impl From<BacktestError> for BacktestResponse {
583    fn from(error: BacktestError) -> Self {
584        Self::Error(error)
585    }
586}
587
588impl std::error::Error for BacktestError {}
589
590impl fmt::Display for BacktestError {
591    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
592        match self {
593            BacktestError::InvalidTransactionEncoding { index, error } => {
594                write!(f, "invalid transaction encoding at index {index}: {error}")
595            }
596            BacktestError::InvalidTransactionFormat { index, error } => {
597                write!(f, "invalid transaction format at index {index}: {error}")
598            }
599            BacktestError::InvalidAccountEncoding {
600                address,
601                encoding,
602                error,
603            } => write!(
604                f,
605                "invalid encoding for account {address} ({encoding:?}): {error}"
606            ),
607            BacktestError::InvalidAccountOwner { address, error } => {
608                write!(f, "invalid owner for account {address}: {error}")
609            }
610            BacktestError::InvalidAccountPubkey { address, error } => {
611                write!(f, "invalid account pubkey {address}: {error}")
612            }
613            BacktestError::NoMoreBlocks => write!(f, "no more blocks available"),
614            BacktestError::AdvanceSlotFailed { slot, error } => {
615                write!(f, "failed to advance to slot {slot}: {error}")
616            }
617            BacktestError::InvalidRequest { error } => write!(f, "invalid request: {error}"),
618            BacktestError::Internal { error } => write!(f, "internal error: {error}"),
619            BacktestError::InvalidBlockhashFormat { slot, error } => {
620                write!(f, "invalid blockhash at slot {slot}: {error}")
621            }
622            BacktestError::InitializingSysvarsFailed { slot, error } => {
623                write!(f, "failed to initialize sysvars at slot {slot}: {error}")
624            }
625            BacktestError::ClerkError { error } => write!(f, "clerk error: {error}"),
626            BacktestError::SimulationError { error } => {
627                write!(f, "simulation error: {error}")
628            }
629            BacktestError::SessionNotFound { session_id } => {
630                write!(f, "session not found: {session_id}")
631            }
632            BacktestError::SessionOwnerMismatch => {
633                write!(f, "session owner mismatch")
634            }
635        }
636    }
637}