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}
141
142/// Account state modifications to apply.
143#[serde_with::serde_as]
144#[derive(Debug, Serialize, Deserialize, Default)]
145#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
146pub struct AccountModifications(
147    #[serde_as(as = "BTreeMap<serde_with::DisplayFromStr, _>")]
148    #[serde(default)]
149    #[cfg_attr(feature = "ts-rs", ts(as = "BTreeMap<String, AccountData>"))]
150    pub BTreeMap<Address, AccountData>,
151);
152
153/// Arguments used to continue an existing session.
154#[serde_with::serde_as]
155#[derive(Debug, Serialize, Deserialize)]
156#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
157#[serde(rename_all = "camelCase")]
158pub struct ContinueParams {
159    #[serde(default = "ContinueParams::default_advance_count")]
160    /// Number of blocks to advance before waiting.
161    pub advance_count: u64,
162    #[serde(default)]
163    /// Base64-encoded transactions to execute before advancing.
164    pub transactions: Vec<String>,
165    #[serde(default)]
166    /// Account state overrides to apply ahead of execution.
167    pub modify_account_states: AccountModifications,
168}
169
170impl Default for ContinueParams {
171    fn default() -> Self {
172        Self {
173            advance_count: Self::default_advance_count(),
174            transactions: Vec::new(),
175            modify_account_states: AccountModifications(BTreeMap::new()),
176        }
177    }
178}
179
180impl ContinueParams {
181    pub fn default_advance_count() -> u64 {
182        1
183    }
184}
185
186/// Supported binary encodings for account/transaction payloads.
187#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
188#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
189#[serde(rename_all = "lowercase")]
190pub enum BinaryEncoding {
191    Base64,
192}
193
194impl BinaryEncoding {
195    pub fn encode(self, bytes: &[u8]) -> String {
196        match self {
197            Self::Base64 => BASE64.encode(bytes),
198        }
199    }
200
201    pub fn decode(self, data: &str) -> Result<Vec<u8>, Base64DecodeError> {
202        match self {
203            Self::Base64 => BASE64.decode(data),
204        }
205    }
206}
207
208/// A blob paired with the encoding needed to decode it.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
211#[serde(rename_all = "camelCase")]
212pub struct EncodedBinary {
213    /// Encoded payload.
214    pub data: String,
215    /// Encoding scheme used for the payload.
216    pub encoding: BinaryEncoding,
217}
218
219impl EncodedBinary {
220    pub fn new(data: String, encoding: BinaryEncoding) -> Self {
221        Self { data, encoding }
222    }
223
224    pub fn from_bytes(bytes: &[u8], encoding: BinaryEncoding) -> Self {
225        Self {
226            data: encoding.encode(bytes),
227            encoding,
228        }
229    }
230
231    pub fn decode(&self) -> Result<Vec<u8>, Base64DecodeError> {
232        self.encoding.decode(&self.data)
233    }
234}
235
236/// Account snapshot used to seed or modify state in a session.
237#[serde_with::serde_as]
238#[derive(Debug, Serialize, Deserialize)]
239#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
240#[serde(rename_all = "camelCase")]
241pub struct AccountData {
242    /// Account data bytes and encoding.
243    pub data: EncodedBinary,
244    /// Whether the account is executable.
245    pub executable: bool,
246    /// Lamport balance.
247    pub lamports: u64,
248    #[serde_as(as = "serde_with::DisplayFromStr")]
249    #[cfg_attr(feature = "ts-rs", ts(as = "String"))]
250    /// Account owner pubkey.
251    pub owner: Address,
252    /// Allocated space in bytes.
253    pub space: u64,
254}
255
256/// Responses returned over the backtest RPC channel.
257#[derive(Debug, Clone, Serialize, Deserialize)]
258#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
259#[serde(tag = "method", content = "params", rename_all = "camelCase")]
260#[cfg_attr(feature = "ts-rs", ts(export))]
261pub enum BacktestResponse {
262    SessionCreated {
263        session_id: String,
264        rpc_endpoint: String,
265    },
266    SessionAttached {
267        session_id: String,
268        rpc_endpoint: String,
269    },
270    SessionsCreated {
271        session_ids: Vec<String>,
272    },
273    ReadyForContinue,
274    SlotNotification(u64),
275    Error(BacktestError),
276    Success,
277    Completed {
278        /// Session summary with transaction statistics.
279        /// The session always computes this summary, but management may omit it from
280        /// client-facing responses unless `send_summary` was requested at session creation.
281        #[serde(skip_serializing_if = "Option::is_none")]
282        summary: Option<SessionSummary>,
283    },
284    Status {
285        status: BacktestStatus,
286    },
287    SessionEventV1 {
288        session_id: String,
289        event: SessionEventV1,
290    },
291}
292
293impl BacktestResponse {
294    pub fn is_completed(&self) -> bool {
295        matches!(self, BacktestResponse::Completed { .. })
296    }
297
298    pub fn is_terminal(&self) -> bool {
299        match self {
300            BacktestResponse::Completed { .. } => true,
301            BacktestResponse::Error(e) => matches!(
302                e,
303                BacktestError::NoMoreBlocks
304                    | BacktestError::AdvanceSlotFailed { .. }
305                    | BacktestError::Internal { .. }
306            ),
307            _ => false,
308        }
309    }
310}
311
312impl From<BacktestStatus> for BacktestResponse {
313    fn from(status: BacktestStatus) -> Self {
314        Self::Status { status }
315    }
316}
317
318impl From<String> for BacktestResponse {
319    fn from(message: String) -> Self {
320        BacktestError::Internal { error: message }.into()
321    }
322}
323
324impl From<&str> for BacktestResponse {
325    fn from(message: &str) -> Self {
326        BacktestError::Internal {
327            error: message.to_string(),
328        }
329        .into()
330    }
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize)]
334#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
335#[serde(tag = "method", content = "params", rename_all = "camelCase")]
336#[cfg_attr(feature = "ts-rs", ts(export))]
337pub enum SessionEventV1 {
338    ReadyForContinue,
339    SlotNotification(u64),
340    Error(BacktestError),
341    Success,
342    Completed {
343        #[serde(skip_serializing_if = "Option::is_none")]
344        summary: Option<SessionSummary>,
345    },
346    Status {
347        status: BacktestStatus,
348    },
349}
350
351/// Wire format wrapper for responses sent over the control websocket.
352/// Sessions with `disconnect_timeout_secs > 0` use this to track client position.
353#[derive(Debug, Clone, Serialize, Deserialize)]
354#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
355#[serde(rename_all = "camelCase")]
356#[cfg_attr(feature = "ts-rs", ts(export))]
357pub struct SequencedResponse {
358    pub seq_id: u64,
359    #[serde(flatten)]
360    pub response: BacktestResponse,
361}
362
363/// High-level progress states during a `Continue` call.
364#[derive(Debug, Clone, Serialize, Deserialize)]
365#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
366#[serde(rename_all = "camelCase")]
367pub enum BacktestStatus {
368    /// Manager is warming history prerequisites on clerk storage.
369    PreparingBundle,
370    /// History prerequisites are warmed and runtime startup can begin.
371    BundleReady,
372    /// Runtime startup is in progress.
373    StartingRuntime,
374    DecodedTransactions,
375    AppliedAccountModifications,
376    ReadyToExecuteUserTransactions,
377    ExecutedUserTransactions,
378    ExecutingBlockTransactions,
379    ExecutedBlockTransactions,
380    ProgramAccountsLoaded,
381}
382
383/// Summary of transaction execution statistics for a completed backtest session.
384#[derive(Debug, Clone, Default, Serialize, Deserialize)]
385#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
386#[serde(rename_all = "camelCase")]
387pub struct SessionSummary {
388    /// Number of simulations where simulator outcome matched on-chain outcome
389    /// (`true_success + true_failure`).
390    pub correct_simulation: usize,
391    /// Number of simulations where simulator outcome did not match on-chain outcome
392    /// (`false_success + false_failure`).
393    pub incorrect_simulation: usize,
394    /// Number of transactions that had execution errors in simulation.
395    pub execution_errors: usize,
396    /// Number of transactions with different balance diffs.
397    pub balance_diff: usize,
398    /// Number of transactions with different log diffs.
399    pub log_diff: usize,
400}
401
402impl SessionSummary {
403    /// Returns true if there were any execution deviations (errors or mismatched results).
404    pub fn has_deviations(&self) -> bool {
405        self.incorrect_simulation > 0 || self.execution_errors > 0 || self.balance_diff > 0
406    }
407
408    /// Total number of transactions processed.
409    pub fn total_transactions(&self) -> usize {
410        self.correct_simulation
411            + self.incorrect_simulation
412            + self.execution_errors
413            + self.balance_diff
414            + self.log_diff
415    }
416}
417
418impl std::fmt::Display for SessionSummary {
419    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
420        let total = self.total_transactions();
421        write!(
422            f,
423            "Session summary: {total} transactions\n\
424             \x20  - {} correct simulation\n\
425             \x20  - {} incorrect simulation\n\
426             \x20  - {} execution errors\n\
427             \x20  - {} balance diffs\n\
428             \x20  - {} log diffs",
429            self.correct_simulation,
430            self.incorrect_simulation,
431            self.execution_errors,
432            self.balance_diff,
433            self.log_diff,
434        )
435    }
436}
437
438/// Error variants surfaced to backtest RPC clients.
439#[derive(Debug, Clone, Serialize, Deserialize)]
440#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
441#[serde(rename_all = "camelCase")]
442pub enum BacktestError {
443    InvalidTransactionEncoding {
444        index: usize,
445        error: String,
446    },
447    InvalidTransactionFormat {
448        index: usize,
449        error: String,
450    },
451    InvalidAccountEncoding {
452        address: String,
453        encoding: BinaryEncoding,
454        error: String,
455    },
456    InvalidAccountOwner {
457        address: String,
458        error: String,
459    },
460    InvalidAccountPubkey {
461        address: String,
462        error: String,
463    },
464    NoMoreBlocks,
465    AdvanceSlotFailed {
466        slot: u64,
467        error: String,
468    },
469    InvalidRequest {
470        error: String,
471    },
472    Internal {
473        error: String,
474    },
475    InvalidBlockhashFormat {
476        slot: u64,
477        error: String,
478    },
479    InitializingSysvarsFailed {
480        slot: u64,
481        error: String,
482    },
483    ClerkError {
484        error: String,
485    },
486    SimulationError {
487        error: String,
488    },
489    SessionNotFound {
490        session_id: String,
491    },
492    SessionOwnerMismatch,
493}
494
495impl From<BacktestError> for BacktestResponse {
496    fn from(error: BacktestError) -> Self {
497        Self::Error(error)
498    }
499}
500
501impl std::error::Error for BacktestError {}
502
503impl fmt::Display for BacktestError {
504    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
505        match self {
506            BacktestError::InvalidTransactionEncoding { index, error } => {
507                write!(f, "invalid transaction encoding at index {index}: {error}")
508            }
509            BacktestError::InvalidTransactionFormat { index, error } => {
510                write!(f, "invalid transaction format at index {index}: {error}")
511            }
512            BacktestError::InvalidAccountEncoding {
513                address,
514                encoding,
515                error,
516            } => write!(
517                f,
518                "invalid encoding for account {address} ({encoding:?}): {error}"
519            ),
520            BacktestError::InvalidAccountOwner { address, error } => {
521                write!(f, "invalid owner for account {address}: {error}")
522            }
523            BacktestError::InvalidAccountPubkey { address, error } => {
524                write!(f, "invalid account pubkey {address}: {error}")
525            }
526            BacktestError::NoMoreBlocks => write!(f, "no more blocks available"),
527            BacktestError::AdvanceSlotFailed { slot, error } => {
528                write!(f, "failed to advance to slot {slot}: {error}")
529            }
530            BacktestError::InvalidRequest { error } => write!(f, "invalid request: {error}"),
531            BacktestError::Internal { error } => write!(f, "internal error: {error}"),
532            BacktestError::InvalidBlockhashFormat { slot, error } => {
533                write!(f, "invalid blockhash at slot {slot}: {error}")
534            }
535            BacktestError::InitializingSysvarsFailed { slot, error } => {
536                write!(f, "failed to initialize sysvars at slot {slot}: {error}")
537            }
538            BacktestError::ClerkError { error } => write!(f, "clerk error: {error}"),
539            BacktestError::SimulationError { error } => {
540                write!(f, "simulation error: {error}")
541            }
542            BacktestError::SessionNotFound { session_id } => {
543                write!(f, "session not found: {session_id}")
544            }
545            BacktestError::SessionOwnerMismatch => {
546                write!(f, "session owner mismatch")
547            }
548        }
549    }
550}