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(CreateSessionParams),
19    Continue(ContinueParams),
20    CloseBacktestSession,
21    AttachBacktestSession {
22        session_id: String,
23        /// Last sequence number the client received. Responses after this sequence
24        /// will be replayed from the session's buffer. None = replay entire buffer.
25        last_sequence: Option<u64>,
26    },
27}
28
29/// Parameters required to start a new backtest session.
30#[serde_with::serde_as]
31#[derive(Debug, Serialize, Deserialize)]
32#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
33#[serde(rename_all = "camelCase")]
34pub struct CreateSessionParams {
35    /// First slot (inclusive) to replay.
36    pub start_slot: u64,
37    /// Last slot (inclusive) to replay.
38    pub end_slot: u64,
39    #[serde_as(as = "BTreeSet<serde_with::DisplayFromStr>")]
40    #[serde(default)]
41    #[cfg_attr(feature = "ts-rs", ts(as = "Vec<String>"))]
42    /// Skip transactions signed by these addresses.
43    pub signer_filter: BTreeSet<Address>,
44    #[serde_as(as = "BTreeSet<serde_with::DisplayFromStr>")]
45    #[serde(default)]
46    #[cfg_attr(feature = "ts-rs", ts(as = "Vec<String>"))]
47    /// Programs to preload before executing.
48    pub preload_programs: BTreeSet<Address>,
49    /// Account bundle IDs to preload before executing.
50    #[serde(default)]
51    pub preload_account_bundles: Vec<String>,
52    /// When true, include a session summary with transaction statistics in client-facing
53    /// `Completed` responses. Summary generation remains enabled internally for metrics.
54    #[serde(default)]
55    pub send_summary: bool,
56    /// Maximum seconds to keep the session alive after the control websocket disconnects.
57    /// If not set (or 0), the session tears down immediately on disconnect.
58    /// Maximum value: 900 (15 minutes).
59    #[serde(default)]
60    pub disconnect_timeout_secs: Option<u16>,
61}
62
63/// Account state modifications to apply.
64#[serde_with::serde_as]
65#[derive(Debug, Serialize, Deserialize, Default)]
66#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
67pub struct AccountModifications(
68    #[serde_as(as = "BTreeMap<serde_with::DisplayFromStr, _>")]
69    #[serde(default)]
70    #[cfg_attr(feature = "ts-rs", ts(as = "BTreeMap<String, AccountData>"))]
71    pub BTreeMap<Address, AccountData>,
72);
73
74/// Arguments used to continue an existing session.
75#[serde_with::serde_as]
76#[derive(Debug, Serialize, Deserialize)]
77#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
78#[serde(rename_all = "camelCase")]
79pub struct ContinueParams {
80    #[serde(default = "ContinueParams::default_advance_count")]
81    /// Number of blocks to advance before waiting.
82    pub advance_count: u64,
83    #[serde(default)]
84    /// Base64-encoded transactions to execute before advancing.
85    pub transactions: Vec<String>,
86    #[serde(default)]
87    /// Account state overrides to apply ahead of execution.
88    pub modify_account_states: AccountModifications,
89}
90
91impl Default for ContinueParams {
92    fn default() -> Self {
93        Self {
94            advance_count: Self::default_advance_count(),
95            transactions: Vec::new(),
96            modify_account_states: AccountModifications(BTreeMap::new()),
97        }
98    }
99}
100
101impl ContinueParams {
102    pub fn default_advance_count() -> u64 {
103        1
104    }
105}
106
107/// Supported binary encodings for account/transaction payloads.
108#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
109#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
110#[serde(rename_all = "lowercase")]
111pub enum BinaryEncoding {
112    Base64,
113}
114
115impl BinaryEncoding {
116    pub fn encode(self, bytes: &[u8]) -> String {
117        match self {
118            Self::Base64 => BASE64.encode(bytes),
119        }
120    }
121
122    pub fn decode(self, data: &str) -> Result<Vec<u8>, Base64DecodeError> {
123        match self {
124            Self::Base64 => BASE64.decode(data),
125        }
126    }
127}
128
129/// A blob paired with the encoding needed to decode it.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
132#[serde(rename_all = "camelCase")]
133pub struct EncodedBinary {
134    /// Encoded payload.
135    pub data: String,
136    /// Encoding scheme used for the payload.
137    pub encoding: BinaryEncoding,
138}
139
140impl EncodedBinary {
141    pub fn new(data: String, encoding: BinaryEncoding) -> Self {
142        Self { data, encoding }
143    }
144
145    pub fn from_bytes(bytes: &[u8], encoding: BinaryEncoding) -> Self {
146        Self {
147            data: encoding.encode(bytes),
148            encoding,
149        }
150    }
151
152    pub fn decode(&self) -> Result<Vec<u8>, Base64DecodeError> {
153        self.encoding.decode(&self.data)
154    }
155}
156
157/// Account snapshot used to seed or modify state in a session.
158#[serde_with::serde_as]
159#[derive(Debug, Serialize, Deserialize)]
160#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
161#[serde(rename_all = "camelCase")]
162pub struct AccountData {
163    /// Account data bytes and encoding.
164    pub data: EncodedBinary,
165    /// Whether the account is executable.
166    pub executable: bool,
167    /// Lamport balance.
168    pub lamports: u64,
169    #[serde_as(as = "serde_with::DisplayFromStr")]
170    #[cfg_attr(feature = "ts-rs", ts(as = "String"))]
171    /// Account owner pubkey.
172    pub owner: Address,
173    /// Allocated space in bytes.
174    pub space: u64,
175}
176
177/// Responses returned over the backtest RPC channel.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
180#[serde(tag = "method", content = "params", rename_all = "camelCase")]
181#[cfg_attr(feature = "ts-rs", ts(export))]
182pub enum BacktestResponse {
183    SessionCreated {
184        session_id: String,
185        rpc_endpoint: String,
186    },
187    SessionAttached {
188        session_id: String,
189        rpc_endpoint: String,
190    },
191    ReadyForContinue,
192    SlotNotification(u64),
193    Error(BacktestError),
194    Success,
195    Completed {
196        /// Session summary with transaction statistics.
197        /// The session always computes this summary, but management may omit it from
198        /// client-facing responses unless `send_summary` was requested at session creation.
199        #[serde(skip_serializing_if = "Option::is_none")]
200        summary: Option<SessionSummary>,
201    },
202    Status {
203        status: BacktestStatus,
204    },
205}
206
207impl BacktestResponse {
208    pub fn is_completed(&self) -> bool {
209        matches!(self, BacktestResponse::Completed { .. })
210    }
211}
212
213impl From<BacktestStatus> for BacktestResponse {
214    fn from(status: BacktestStatus) -> Self {
215        Self::Status { status }
216    }
217}
218
219impl From<String> for BacktestResponse {
220    fn from(message: String) -> Self {
221        BacktestError::Internal { error: message }.into()
222    }
223}
224
225impl From<&str> for BacktestResponse {
226    fn from(message: &str) -> Self {
227        BacktestError::Internal {
228            error: message.to_string(),
229        }
230        .into()
231    }
232}
233
234/// Wire format wrapper for responses sent over the control websocket.
235/// Sessions with `disconnect_timeout_secs > 0` use this to track client position.
236#[derive(Debug, Clone, Serialize, Deserialize)]
237#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
238#[serde(rename_all = "camelCase")]
239#[cfg_attr(feature = "ts-rs", ts(export))]
240pub struct SequencedResponse {
241    pub seq_id: u64,
242    #[serde(flatten)]
243    pub response: BacktestResponse,
244}
245
246/// High-level progress states during a `Continue` call.
247#[derive(Debug, Clone, Serialize, Deserialize)]
248#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
249#[serde(rename_all = "camelCase")]
250pub enum BacktestStatus {
251    /// Manager is warming history prerequisites on clerk storage.
252    PreparingBundle,
253    /// History prerequisites are warmed and runtime startup can begin.
254    BundleReady,
255    /// Runtime startup is in progress.
256    StartingRuntime,
257    DecodedTransactions,
258    AppliedAccountModifications,
259    ReadyToExecuteUserTransactions,
260    ExecutedUserTransactions,
261    ExecutingBlockTransactions,
262    ExecutedBlockTransactions,
263    ProgramAccountsLoaded,
264}
265
266/// Summary of transaction execution statistics for a completed backtest session.
267#[derive(Debug, Clone, Default, Serialize, Deserialize)]
268#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
269#[serde(rename_all = "camelCase")]
270pub struct SessionSummary {
271    /// Number of simulations where simulator outcome matched on-chain outcome
272    /// (`true_success + true_failure`).
273    pub correct_simulation: usize,
274    /// Number of simulations where simulator outcome did not match on-chain outcome
275    /// (`false_success + false_failure`).
276    pub incorrect_simulation: usize,
277    /// Number of transactions that had execution errors in simulation.
278    pub execution_errors: usize,
279    /// Number of transactions with different balance diffs.
280    pub balance_diff: usize,
281    /// Number of transactions with different log diffs.
282    pub log_diff: usize,
283}
284
285impl SessionSummary {
286    /// Returns true if there were any execution deviations (errors or mismatched results).
287    pub fn has_deviations(&self) -> bool {
288        self.incorrect_simulation > 0 || self.execution_errors > 0 || self.balance_diff > 0
289    }
290
291    /// Total number of transactions processed.
292    pub fn total_transactions(&self) -> usize {
293        self.correct_simulation
294            + self.incorrect_simulation
295            + self.execution_errors
296            + self.balance_diff
297            + self.log_diff
298    }
299}
300
301impl std::fmt::Display for SessionSummary {
302    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
303        let total = self.total_transactions();
304        write!(
305            f,
306            "Session summary: {total} transactions\n\
307             \x20  - {} correct simulation\n\
308             \x20  - {} incorrect simulation\n\
309             \x20  - {} execution errors\n\
310             \x20  - {} balance diffs\n\
311             \x20  - {} log diffs",
312            self.correct_simulation,
313            self.incorrect_simulation,
314            self.execution_errors,
315            self.balance_diff,
316            self.log_diff,
317        )
318    }
319}
320
321/// Error variants surfaced to backtest RPC clients.
322#[derive(Debug, Clone, Serialize, Deserialize)]
323#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
324#[serde(rename_all = "camelCase")]
325pub enum BacktestError {
326    InvalidTransactionEncoding {
327        index: usize,
328        error: String,
329    },
330    InvalidTransactionFormat {
331        index: usize,
332        error: String,
333    },
334    InvalidAccountEncoding {
335        address: String,
336        encoding: BinaryEncoding,
337        error: String,
338    },
339    InvalidAccountOwner {
340        address: String,
341        error: String,
342    },
343    InvalidAccountPubkey {
344        address: String,
345        error: String,
346    },
347    NoMoreBlocks,
348    AdvanceSlotFailed {
349        slot: u64,
350        error: String,
351    },
352    InvalidRequest {
353        error: String,
354    },
355    Internal {
356        error: String,
357    },
358    InvalidBlockhashFormat {
359        slot: u64,
360        error: String,
361    },
362    InitializingSysvarsFailed {
363        slot: u64,
364        error: String,
365    },
366    ClerkError {
367        error: String,
368    },
369    SimulationError {
370        error: String,
371    },
372    SessionNotFound {
373        session_id: String,
374    },
375    SessionOwnerMismatch,
376}
377
378impl From<BacktestError> for BacktestResponse {
379    fn from(error: BacktestError) -> Self {
380        Self::Error(error)
381    }
382}
383
384impl std::error::Error for BacktestError {}
385
386impl fmt::Display for BacktestError {
387    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
388        match self {
389            BacktestError::InvalidTransactionEncoding { index, error } => {
390                write!(f, "invalid transaction encoding at index {index}: {error}")
391            }
392            BacktestError::InvalidTransactionFormat { index, error } => {
393                write!(f, "invalid transaction format at index {index}: {error}")
394            }
395            BacktestError::InvalidAccountEncoding {
396                address,
397                encoding,
398                error,
399            } => write!(
400                f,
401                "invalid encoding for account {address} ({encoding:?}): {error}"
402            ),
403            BacktestError::InvalidAccountOwner { address, error } => {
404                write!(f, "invalid owner for account {address}: {error}")
405            }
406            BacktestError::InvalidAccountPubkey { address, error } => {
407                write!(f, "invalid account pubkey {address}: {error}")
408            }
409            BacktestError::NoMoreBlocks => write!(f, "no more blocks available"),
410            BacktestError::AdvanceSlotFailed { slot, error } => {
411                write!(f, "failed to advance to slot {slot}: {error}")
412            }
413            BacktestError::InvalidRequest { error } => write!(f, "invalid request: {error}"),
414            BacktestError::Internal { error } => write!(f, "internal error: {error}"),
415            BacktestError::InvalidBlockhashFormat { slot, error } => {
416                write!(f, "invalid blockhash at slot {slot}: {error}")
417            }
418            BacktestError::InitializingSysvarsFailed { slot, error } => {
419                write!(f, "failed to initialize sysvars at slot {slot}: {error}")
420            }
421            BacktestError::ClerkError { error } => write!(f, "clerk error: {error}"),
422            BacktestError::SimulationError { error } => {
423                write!(f, "simulation error: {error}")
424            }
425            BacktestError::SessionNotFound { session_id } => {
426                write!(f, "session not found: {session_id}")
427            }
428            BacktestError::SessionOwnerMismatch => {
429                write!(f, "session owner mismatch")
430            }
431        }
432    }
433}