1use std::{
2 collections::{BTreeMap, BTreeSet, HashSet},
3 fmt,
4};
5
6use base64::{
7 DecodeError as Base64DecodeError, Engine as _, engine::general_purpose::STANDARD as BASE64,
8};
9use serde::{Deserialize, Serialize};
10use solana_address::Address;
11
12pub mod subscribe_config;
13pub mod usage;
14pub mod ws_compression;
15
16#[derive(Debug, Serialize, Deserialize)]
18#[serde(tag = "method", content = "params", rename_all = "camelCase")]
19pub enum BacktestRequest {
20 CreateBacktestSession(CreateBacktestSessionRequest),
21 Continue(ContinueParams),
22 ContinueTo(ContinueToParams),
23 ContinueSessionV1(ContinueSessionRequestV1),
24 ContinueToSessionV1(ContinueToSessionRequestV1),
25 CloseBacktestSession,
26 CloseSessionV1(CloseSessionRequestV1),
27 AttachBacktestSession {
28 session_id: String,
29 last_sequence: Option<u64>,
32 },
33 ResumeAttachedSession,
36 AttachParallelControlSessionV2 {
37 control_session_id: String,
38 #[serde(default)]
42 last_sequences: BTreeMap<String, u64>,
43 },
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(untagged)]
52pub enum CreateBacktestSessionRequest {
53 V1(CreateBacktestSessionRequestV1),
54 V0(CreateSessionParams),
55}
56
57impl CreateBacktestSessionRequest {
58 pub fn into_request_options(self) -> CreateBacktestSessionRequestOptions {
59 match self {
60 Self::V0(request) => CreateBacktestSessionRequestOptions {
61 request,
62 parallel: false,
63 },
64 Self::V1(CreateBacktestSessionRequestV1 { request, parallel }) => {
65 CreateBacktestSessionRequestOptions { request, parallel }
66 }
67 }
68 }
69
70 pub fn into_request_and_parallel(self) -> (CreateSessionParams, bool) {
71 let options = self.into_request_options();
72 (options.request, options.parallel)
73 }
74}
75
76impl From<CreateSessionParams> for CreateBacktestSessionRequest {
77 fn from(value: CreateSessionParams) -> Self {
78 Self::V0(value)
79 }
80}
81
82impl From<CreateBacktestSessionRequestV1> for CreateBacktestSessionRequest {
83 fn from(value: CreateBacktestSessionRequestV1) -> Self {
84 Self::V1(value)
85 }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct CreateBacktestSessionRequestV1 {
91 #[serde(flatten)]
92 pub request: CreateSessionParams,
93 pub parallel: bool,
94}
95
96#[derive(Debug, Clone)]
97pub struct CreateBacktestSessionRequestOptions {
98 pub request: CreateSessionParams,
99 pub parallel: bool,
100}
101
102#[derive(Debug, Serialize, Deserialize)]
103#[serde(rename_all = "camelCase")]
104pub struct ContinueSessionRequestV1 {
105 pub session_id: String,
106 pub request: ContinueParams,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct ContinueToSessionRequestV1 {
112 pub session_id: String,
113 pub request: ContinueToParams,
114}
115
116#[derive(Debug, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase")]
118pub struct CloseSessionRequestV1 {
119 pub session_id: String,
120}
121
122#[serde_with::serde_as]
129#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
130#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
131pub enum DiscoveryFilter {
132 ProgramExecuted(#[serde_as(as = "serde_with::DisplayFromStr")] Address),
134}
135
136pub struct TxMatchContext<'a> {
141 pub invoked_programs: &'a HashSet<Address>,
143}
144
145impl DiscoveryFilter {
146 pub fn matches(&self, ctx: &TxMatchContext<'_>) -> bool {
149 match self {
150 Self::ProgramExecuted(target) => ctx.invoked_programs.contains(target),
151 }
152 }
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub enum ActionKind {
158 Simulate,
159 Send,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
164#[serde(tag = "at", rename_all = "camelCase")]
165pub enum ActionAnchor {
166 #[default]
169 AfterSlot,
170 BeforeMatch { filter: DiscoveryFilter },
172 AfterMatch { filter: DiscoveryFilter },
174}
175
176#[serde_with::serde_as]
179#[derive(Debug, Clone, Serialize, Deserialize)]
180#[serde(rename_all = "camelCase")]
181pub struct ScheduledAction {
182 #[serde(default)]
183 pub anchor: ActionAnchor,
184 pub kind: ActionKind,
185 pub transactions: Vec<String>,
189 #[serde(default)]
193 pub account_overrides: AccountModifications,
194 #[serde_as(as = "Vec<serde_with::DisplayFromStr>")]
198 #[serde(default)]
199 pub return_accounts: Vec<Address>,
200 #[serde(default)]
202 pub label: Option<String>,
203}
204
205#[serde_with::serde_as]
207#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
208#[serde(rename_all = "camelCase")]
209pub struct CreateSessionParams {
210 pub start_slot: u64,
212 pub end_slot: u64,
214 #[serde_as(as = "BTreeSet<serde_with::DisplayFromStr>")]
215 #[serde(default)]
216 #[builder(default)]
217 pub signer_filter: BTreeSet<Address>,
219 #[serde(default)]
222 #[builder(default)]
223 pub send_summary: bool,
224 #[serde(default)]
227 pub capacity_wait_timeout_secs: Option<u16>,
228 #[serde(default)]
232 pub disconnect_timeout_secs: Option<u16>,
233 #[serde(default)]
238 pub extra_compute_units: Option<u32>,
239 #[serde(default)]
241 #[builder(default)]
242 pub agents: Vec<AgentParams>,
243 #[serde(default, skip_serializing_if = "Vec::is_empty")]
250 #[builder(default)]
251 pub discoveries: Vec<DiscoveryFilter>,
252 #[serde(default, skip_serializing_if = "Vec::is_empty")]
255 #[builder(default)]
256 pub actions: Vec<ScheduledAction>,
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
262#[serde(rename_all = "kebab-case")]
263pub enum FailFastDivergenceKind {
264 #[default]
267 AnyNonBenign,
268 Tracked,
271}
272
273impl FailFastDivergenceKind {
274 pub fn as_str(self) -> &'static str {
277 match self {
278 Self::AnyNonBenign => "any-non-benign",
279 Self::Tracked => "tracked",
280 }
281 }
282
283 pub fn from_str_opt(value: &str) -> Option<Self> {
285 match value {
286 "any-non-benign" => Some(Self::AnyNonBenign),
287 "tracked" => Some(Self::Tracked),
288 _ => None,
289 }
290 }
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
295#[serde(rename_all = "camelCase")]
296pub enum AgentType {
297 Arb,
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
302#[serde(rename_all = "camelCase")]
303pub struct ArbRouteParams {
304 pub base_mint: String,
305 pub temp_mint: String,
306 #[serde(default)]
307 pub buy_dexes: Vec<String>,
308 #[serde(default)]
309 pub sell_dexes: Vec<String>,
310 pub min_input: u64,
311 pub max_input: u64,
312 #[serde(default)]
313 pub min_profit: u64,
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
318#[serde(rename_all = "camelCase")]
319pub struct AgentParams {
320 pub agent_type: AgentType,
321 pub wallet: Option<String>,
322 pub keypair: Option<String>,
324 pub seed_sol_lamports: Option<u64>,
325 #[serde(default)]
326 pub seed_token_accounts: BTreeMap<String, u64>,
327 #[serde(default)]
328 pub arb_routes: Vec<ArbRouteParams>,
329}
330
331#[serde_with::serde_as]
333#[derive(Debug, Clone, Serialize, Deserialize, Default)]
334pub struct AccountModifications(
335 #[serde_as(as = "BTreeMap<serde_with::DisplayFromStr, _>")]
336 #[serde(default)]
337 pub BTreeMap<Address, AccountData>,
338);
339
340#[serde_with::serde_as]
342#[derive(Debug, Serialize, Deserialize)]
343#[serde(rename_all = "camelCase")]
344pub struct ContinueParams {
345 #[serde(default = "ContinueParams::default_advance_count")]
346 pub advance_count: u64,
348 #[serde(default)]
349 pub transactions: Vec<String>,
351 #[serde(default)]
352 pub modify_account_states: AccountModifications,
354}
355
356impl Default for ContinueParams {
357 fn default() -> Self {
358 Self {
359 advance_count: Self::default_advance_count(),
360 transactions: Vec::new(),
361 modify_account_states: AccountModifications(BTreeMap::new()),
362 }
363 }
364}
365
366impl ContinueParams {
367 pub fn default_advance_count() -> u64 {
368 1
369 }
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize)]
378#[serde(rename_all = "camelCase")]
379pub struct PausedEvent {
380 pub slot: u64,
381 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub batch_index: Option<u32>,
383}
384
385#[serde_with::serde_as]
394#[derive(Debug, Clone, Serialize, Deserialize)]
395#[serde(rename_all = "camelCase")]
396pub struct DiscoveryBatchEvent {
397 pub slot: u64,
398 pub batch_index: u32,
399 pub matched: Vec<DiscoveryFilter>,
401 pub transactions: Vec<EncodedBinary>,
405}
406
407#[derive(Debug, Clone, Serialize, Deserialize)]
409#[serde(rename_all = "camelCase")]
410pub struct ContinueToParams {
411 pub slot: u64,
413 #[serde(default)]
419 pub batch_index: Option<u32>,
420}
421
422#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
424#[serde(rename_all = "lowercase")]
425pub enum BinaryEncoding {
426 Base64,
427}
428
429impl BinaryEncoding {
430 pub fn encode(self, bytes: &[u8]) -> String {
431 match self {
432 Self::Base64 => BASE64.encode(bytes),
433 }
434 }
435
436 pub fn decode(self, data: &str) -> Result<Vec<u8>, Base64DecodeError> {
437 match self {
438 Self::Base64 => BASE64.decode(data),
439 }
440 }
441}
442
443#[derive(Debug, Clone, Serialize, Deserialize)]
445#[serde(rename_all = "camelCase")]
446pub struct EncodedBinary {
447 pub data: String,
449 pub encoding: BinaryEncoding,
451}
452
453impl EncodedBinary {
454 pub fn new(data: String, encoding: BinaryEncoding) -> Self {
455 Self { data, encoding }
456 }
457
458 pub fn from_bytes(bytes: &[u8], encoding: BinaryEncoding) -> Self {
459 Self {
460 data: encoding.encode(bytes),
461 encoding,
462 }
463 }
464
465 pub fn decode(&self) -> Result<Vec<u8>, Base64DecodeError> {
466 self.encoding.decode(&self.data)
467 }
468}
469
470#[serde_with::serde_as]
472#[derive(Debug, Clone, Serialize, Deserialize)]
473#[serde(rename_all = "camelCase")]
474pub struct AccountData {
475 pub data: EncodedBinary,
477 pub executable: bool,
479 pub lamports: u64,
481 #[serde_as(as = "serde_with::DisplayFromStr")]
482 pub owner: Address,
484 pub space: u64,
486}
487
488impl AccountData {
489 pub fn to_account(&self) -> Result<solana_account::Account, Base64DecodeError> {
490 Ok(solana_account::Account {
491 data: self.data.decode()?,
492 lamports: self.lamports,
493 owner: self.owner,
494 executable: self.executable,
495 rent_epoch: 0,
496 })
497 }
498}
499
500#[derive(Debug, Clone, Serialize, Deserialize)]
502#[serde(tag = "method", content = "params", rename_all = "camelCase")]
503pub enum BacktestResponse {
504 SessionCreated {
505 session_id: String,
506 rpc_endpoint: String,
507 #[serde(default, skip_serializing_if = "Option::is_none")]
508 task_id: Option<String>,
509 },
510 SessionAttached {
511 session_id: String,
512 rpc_endpoint: String,
513 #[serde(default, skip_serializing_if = "Option::is_none")]
514 task_id: Option<String>,
515 },
516 SessionsCreated {
520 session_ids: Vec<String>,
521 },
522 SessionsCreatedV2 {
523 control_session_id: String,
524 session_ids: Vec<String>,
525 #[serde(default)]
526 task_ids: Vec<Option<String>>,
527 #[serde(default)]
532 start_slots: Vec<u64>,
533 #[serde(default)]
534 end_slots: Vec<u64>,
535 },
536 ParallelSessionAttachedV2 {
537 control_session_id: String,
538 session_ids: Vec<String>,
539 #[serde(default)]
540 task_ids: Vec<Option<String>>,
541 },
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")]
553 summary: Option<SessionSummary>,
554 #[serde(default, skip_serializing_if = "Option::is_none")]
555 agent_stats: Option<Vec<AgentStatsReport>>,
556 },
557 Status {
558 status: BacktestStatus,
559 },
560 SessionEventV1 {
563 session_id: String,
564 event: SessionEventV1,
565 },
566 SessionEventV2 {
567 session_id: String,
568 seq_id: u64,
569 event: SessionEventKind,
570 },
571}
572
573impl BacktestResponse {
574 pub fn is_completed(&self) -> bool {
575 matches!(self, BacktestResponse::Completed { .. })
576 }
577
578 pub fn is_terminal(&self) -> bool {
579 match self {
580 BacktestResponse::Completed { .. } => true,
581 BacktestResponse::Error(e) => e.is_terminal(),
582 _ => false,
583 }
584 }
585}
586
587impl From<BacktestStatus> for BacktestResponse {
588 fn from(status: BacktestStatus) -> Self {
589 Self::Status { status }
590 }
591}
592
593impl From<String> for BacktestResponse {
594 fn from(message: String) -> Self {
595 BacktestError::Internal { error: message }.into()
596 }
597}
598
599impl From<&str> for BacktestResponse {
600 fn from(message: &str) -> Self {
601 BacktestError::Internal {
602 error: message.to_string(),
603 }
604 .into()
605 }
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize)]
612#[serde(tag = "method", content = "params", rename_all = "camelCase")]
613pub enum SessionEventV1 {
614 ReadyForContinue,
615 SlotNotification(u64),
616 Paused(PausedEvent),
617 DiscoveryBatch(DiscoveryBatchEvent),
618 Error(BacktestError),
619 Success,
620 Completed {
621 #[serde(skip_serializing_if = "Option::is_none")]
622 summary: Option<SessionSummary>,
623 #[serde(default, skip_serializing_if = "Option::is_none")]
624 agent_stats: Option<Vec<AgentStatsReport>>,
625 },
626 Status {
627 status: BacktestStatus,
628 },
629}
630
631#[derive(Debug, Clone, Serialize, Deserialize)]
632#[serde(tag = "method", content = "params", rename_all = "camelCase")]
633pub enum SessionEventKind {
634 ReadyForContinue,
635 SlotNotification(u64),
636 Paused(PausedEvent),
637 DiscoveryBatch(DiscoveryBatchEvent),
638 Error(BacktestError),
639 Success,
640 Completed {
641 #[serde(skip_serializing_if = "Option::is_none")]
642 summary: Option<SessionSummary>,
643 },
644 Status {
645 status: BacktestStatus,
646 },
647}
648
649impl SessionEventKind {
650 pub fn is_terminal(&self) -> bool {
651 match self {
652 Self::Completed { .. } => true,
653 Self::Error(e) => e.is_terminal(),
654 _ => false,
655 }
656 }
657}
658
659#[derive(Debug, Clone, Serialize, Deserialize)]
662#[serde(rename_all = "camelCase")]
663pub struct SequencedResponse {
664 pub seq_id: u64,
665 #[serde(flatten)]
666 pub response: BacktestResponse,
667}
668
669#[derive(Debug, Clone, Serialize, Deserialize)]
671#[serde(rename_all = "camelCase")]
672pub enum BacktestStatus {
673 StartingRuntime,
675 DecodedTransactions,
676 AppliedAccountModifications,
677 ReadyToExecuteUserTransactions,
678 ExecutedUserTransactions,
679 ExecutingBlockTransactions,
680 ExecutedBlockTransactions,
681 ProgramAccountsLoaded,
682}
683
684impl std::fmt::Display for BacktestStatus {
685 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
686 let s = match self {
687 Self::StartingRuntime => "starting runtime",
688 Self::DecodedTransactions => "decoded transactions",
689 Self::AppliedAccountModifications => "applied account modifications",
690 Self::ReadyToExecuteUserTransactions => "ready to execute user transactions",
691 Self::ExecutedUserTransactions => "executed user transactions",
692 Self::ExecutingBlockTransactions => "executing block transactions",
693 Self::ExecutedBlockTransactions => "executed block transactions",
694 Self::ProgramAccountsLoaded => "program accounts loaded",
695 };
696 f.write_str(s)
697 }
698}
699
700#[derive(Debug, Clone, Default, Serialize, Deserialize)]
702#[serde(rename_all = "camelCase")]
703pub struct AgentStatsReport {
704 pub name: String,
705 pub slots_processed: u64,
706 pub opportunities_found: u64,
707 pub opportunities_skipped: u64,
708 pub no_routes: u64,
709 pub txs_produced: u64,
710 pub expected_gain_by_mint: BTreeMap<String, i64>,
712 #[serde(default)]
714 pub txs_submitted: u64,
715 #[serde(default)]
717 pub txs_failed: u64,
718 #[serde(default)]
720 pub txs_simulation_rejected: u64,
721 #[serde(default)]
723 pub txs_simulation_failed: u64,
724}
725
726#[derive(Debug, Clone, Default, Serialize, Deserialize)]
728#[serde(rename_all = "camelCase")]
729pub struct SessionSummary {
730 pub correct_simulation: usize,
733 pub incorrect_simulation: usize,
736 pub execution_errors: usize,
738 pub balance_diff: usize,
740 pub log_diff: usize,
742}
743
744impl SessionSummary {
745 pub fn has_deviations(&self) -> bool {
747 self.incorrect_simulation > 0 || self.execution_errors > 0 || self.balance_diff > 0
748 }
749
750 pub fn total_transactions(&self) -> usize {
752 self.correct_simulation
753 + self.incorrect_simulation
754 + self.execution_errors
755 + self.balance_diff
756 + self.log_diff
757 }
758}
759
760impl std::fmt::Display for SessionSummary {
761 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
762 let total = self.total_transactions();
763 write!(
764 f,
765 "Session summary: {total} transactions\n\
766 \x20 - {} correct simulation\n\
767 \x20 - {} incorrect simulation\n\
768 \x20 - {} execution errors\n\
769 \x20 - {} balance diffs\n\
770 \x20 - {} log diffs",
771 self.correct_simulation,
772 self.incorrect_simulation,
773 self.execution_errors,
774 self.balance_diff,
775 self.log_diff,
776 )
777 }
778}
779
780#[derive(Debug, Clone, Serialize, Deserialize)]
782#[serde(rename_all = "camelCase")]
783pub enum BacktestError {
784 InvalidTransactionEncoding {
785 index: usize,
786 error: String,
787 },
788 InvalidTransactionFormat {
789 index: usize,
790 error: String,
791 },
792 InvalidAccountEncoding {
793 address: String,
794 encoding: BinaryEncoding,
795 error: String,
796 },
797 InvalidAccountOwner {
798 address: String,
799 error: String,
800 },
801 InvalidAccountPubkey {
802 address: String,
803 error: String,
804 },
805 NoMoreBlocks,
806 AdvanceSlotFailed {
807 slot: u64,
808 error: String,
809 },
810 FinalizeSlotFailed {
811 slot: u64,
812 error: String,
813 },
814 InvalidRequest {
815 error: String,
816 },
817 Internal {
818 error: String,
819 },
820 InvalidBlockhashFormat {
821 slot: u64,
822 error: String,
823 },
824 InitializingSysvarsFailed {
825 slot: u64,
826 error: String,
827 },
828 ClerkError {
829 error: String,
830 },
831 SimulationError {
832 error: String,
833 },
834 SessionNotFound {
835 session_id: String,
836 },
837 SessionOwnerMismatch,
838 SessionOwnershipBusy {
843 reason: String,
844 },
845}
846
847impl BacktestError {
848 pub fn is_terminal(&self) -> bool {
852 matches!(
853 self,
854 BacktestError::NoMoreBlocks
855 | BacktestError::AdvanceSlotFailed { .. }
856 | BacktestError::FinalizeSlotFailed { .. }
857 | BacktestError::Internal { .. }
858 )
859 }
860}
861
862#[derive(Debug, Clone, Serialize, Deserialize)]
864pub struct AvailableRange {
865 pub bundle_start_slot: u64,
866 pub bundle_start_slot_utc: Option<String>,
867 pub max_bundle_end_slot: Option<u64>,
868 pub max_bundle_end_slot_utc: Option<String>,
869 pub max_bundle_size: Option<u64>,
870}
871
872#[derive(Debug, Clone, Serialize, Deserialize)]
874pub struct BundleBuildRequest {
875 pub start_slot: u64,
876 pub end_slot: u64,
877 #[serde(default)]
878 pub bundle_size: Option<u64>,
879 #[serde(default)]
881 pub idempotency_key: Option<String>,
882}
883
884#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
885#[serde(rename_all = "snake_case")]
886pub enum BundleBuildStatus {
887 Completed,
889 Failed,
893 InProgress,
895 NeedsInvestigation,
899}
900
901impl BundleBuildStatus {
902 pub fn as_str(self) -> &'static str {
904 match self {
905 Self::Completed => "completed",
906 Self::Failed => "failed",
907 Self::InProgress => "in_progress",
908 Self::NeedsInvestigation => "needs_investigation",
909 }
910 }
911}
912
913#[derive(Debug, Clone, Serialize, Deserialize)]
915pub struct BundleBuildStatusResponse {
916 pub request_id: String,
917 pub start_slot: u64,
918 pub end_slot: u64,
919 pub bundle_size: Option<u64>,
920 pub status: BundleBuildStatus,
921 pub created_at_unix_ms: u64,
924}
925
926#[derive(Debug, Clone, Serialize, Deserialize)]
929pub struct SnapshotSlotResponse {
930 pub snapshot_slot: u64,
931}
932
933pub fn split_range(
951 ranges: &[AvailableRange],
952 requested_start: u64,
953 requested_end: u64,
954) -> Result<Vec<(u64, u64)>, String> {
955 if requested_end < requested_start {
956 return Err(format!(
957 "invalid range: start_slot {requested_start} > end_slot {requested_end}"
958 ));
959 }
960
961 let mut ends_by_start: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
966 for r in ranges {
967 if let Some(end) = r.max_bundle_end_slot
968 && end > r.bundle_start_slot
969 {
970 ends_by_start
971 .entry(r.bundle_start_slot)
972 .or_default()
973 .insert(end);
974 }
975 }
976
977 let Some((&anchor_start, _)) = ends_by_start.range(..=requested_start).rfind(|(_, ends)| {
984 ends.iter()
985 .next_back()
986 .is_some_and(|&end| end >= requested_start)
987 }) else {
988 return Err(format!(
989 "start_slot {requested_start} is not covered by any available bundle range"
990 ));
991 };
992
993 let mut best_from: BTreeMap<u64, Vec<(u64, u64)>> = BTreeMap::new();
999 for (&start, ends) in ends_by_start.range(anchor_start..=requested_end).rev() {
1000 let mut best: Option<Vec<(u64, u64)>> = None;
1001 for &end in ends {
1002 let candidate = if end >= requested_end {
1003 Some(vec![(start, requested_end)])
1004 } else {
1005 best_from.get(&(end + 1)).map(|rest| {
1006 std::iter::once((start, end))
1007 .chain(rest.iter().copied())
1008 .collect()
1009 })
1010 };
1011 if let Some(candidate) = candidate
1012 && best.as_ref().is_none_or(|b| candidate.len() > b.len())
1013 {
1014 best = Some(candidate);
1015 }
1016 }
1017 if let Some(best) = best {
1018 best_from.insert(start, best);
1019 }
1020 }
1021
1022 best_from.remove(&anchor_start).ok_or_else(|| {
1023 let mut covered_to = anchor_start.saturating_sub(1);
1027 for (&start, ends) in ends_by_start.range(anchor_start..=requested_end) {
1028 if start > covered_to.saturating_add(1) {
1029 break;
1030 }
1031 if let Some(&end) = ends.iter().next_back() {
1032 covered_to = covered_to.max(end);
1033 }
1034 }
1035 if covered_to < requested_end {
1036 format!("gap in coverage at slot {}", covered_to + 1)
1037 } else {
1038 format!(
1039 "no gap-free split of [{requested_start}, {requested_end}] aligns with the available bundle ranges"
1040 )
1041 }
1042 })
1043}
1044
1045impl From<BacktestError> for BacktestResponse {
1046 fn from(error: BacktestError) -> Self {
1047 Self::Error(error)
1048 }
1049}
1050
1051impl std::error::Error for BacktestError {}
1052
1053impl fmt::Display for BacktestError {
1054 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1055 match self {
1056 BacktestError::InvalidTransactionEncoding { index, error } => {
1057 write!(f, "invalid transaction encoding at index {index}: {error}")
1058 }
1059 BacktestError::InvalidTransactionFormat { index, error } => {
1060 write!(f, "invalid transaction format at index {index}: {error}")
1061 }
1062 BacktestError::InvalidAccountEncoding {
1063 address,
1064 encoding,
1065 error,
1066 } => write!(
1067 f,
1068 "invalid encoding for account {address} ({encoding:?}): {error}"
1069 ),
1070 BacktestError::InvalidAccountOwner { address, error } => {
1071 write!(f, "invalid owner for account {address}: {error}")
1072 }
1073 BacktestError::InvalidAccountPubkey { address, error } => {
1074 write!(f, "invalid account pubkey {address}: {error}")
1075 }
1076 BacktestError::NoMoreBlocks => write!(f, "no more blocks available"),
1077 BacktestError::AdvanceSlotFailed { slot, error } => {
1078 write!(f, "failed to advance to slot {slot}: {error}")
1079 }
1080 BacktestError::FinalizeSlotFailed { slot, error } => {
1081 write!(f, "failed to finalize slot {slot}: {error}")
1082 }
1083 BacktestError::InvalidRequest { error } => write!(f, "invalid request: {error}"),
1084 BacktestError::Internal { error } => write!(f, "internal error: {error}"),
1085 BacktestError::InvalidBlockhashFormat { slot, error } => {
1086 write!(f, "invalid blockhash at slot {slot}: {error}")
1087 }
1088 BacktestError::InitializingSysvarsFailed { slot, error } => {
1089 write!(f, "failed to initialize sysvars at slot {slot}: {error}")
1090 }
1091 BacktestError::ClerkError { error } => write!(f, "clerk error: {error}"),
1092 BacktestError::SimulationError { error } => {
1093 write!(f, "simulation error: {error}")
1094 }
1095 BacktestError::SessionNotFound { session_id } => {
1096 write!(f, "session not found: {session_id}")
1097 }
1098 BacktestError::SessionOwnerMismatch => {
1099 write!(f, "session owner mismatch")
1100 }
1101 BacktestError::SessionOwnershipBusy { reason } => {
1102 write!(f, "session ownership busy: {reason}")
1103 }
1104 }
1105 }
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110 use super::*;
1111
1112 #[test]
1113 fn fail_fast_divergence_kind_str_round_trips() {
1114 for kind in [
1115 FailFastDivergenceKind::AnyNonBenign,
1116 FailFastDivergenceKind::Tracked,
1117 ] {
1118 assert_eq!(
1119 FailFastDivergenceKind::from_str_opt(kind.as_str()),
1120 Some(kind)
1121 );
1122 }
1123 assert_eq!(FailFastDivergenceKind::from_str_opt("nonsense"), None);
1124 assert_eq!(
1125 FailFastDivergenceKind::default(),
1126 FailFastDivergenceKind::AnyNonBenign
1127 );
1128 }
1129
1130 #[test]
1131 fn bundle_build_request_optional_fields_default_to_none() {
1132 let req: BundleBuildRequest =
1133 serde_json::from_str(r#"{"start_slot":1,"end_slot":2}"#).expect("parse");
1134 assert_eq!((req.start_slot, req.end_slot), (1, 2));
1135 assert_eq!(req.bundle_size, None);
1136 assert_eq!(req.idempotency_key, None);
1137 }
1138
1139 #[test]
1140 fn bundle_build_request_parses_optional_fields() {
1141 let req: BundleBuildRequest = serde_json::from_str(
1142 r#"{"start_slot":1,"end_slot":2,"bundle_size":500,"idempotency_key":"abc"}"#,
1143 )
1144 .expect("parse");
1145 assert_eq!(req.bundle_size, Some(500));
1146 assert_eq!(req.idempotency_key.as_deref(), Some("abc"));
1147 }
1148
1149 #[test]
1150 fn bundle_build_status_serde_round_trips_with_snake_case() {
1151 let cases = [
1152 (BundleBuildStatus::Completed, "\"completed\""),
1153 (BundleBuildStatus::Failed, "\"failed\""),
1154 (BundleBuildStatus::InProgress, "\"in_progress\""),
1155 (
1156 BundleBuildStatus::NeedsInvestigation,
1157 "\"needs_investigation\"",
1158 ),
1159 ];
1160 for (status, expected) in cases {
1161 assert_eq!(serde_json::to_string(&status).unwrap(), expected);
1162 assert_eq!(
1163 serde_json::from_str::<BundleBuildStatus>(expected).unwrap(),
1164 status
1165 );
1166 assert_eq!(status.as_str(), expected.trim_matches('"'));
1167 }
1168 assert!(serde_json::from_str::<BundleBuildStatus>("\"queued\"").is_err());
1169 }
1170
1171 #[test]
1172 fn bundle_build_status_response_serializes_request_and_status() {
1173 let response = BundleBuildStatusResponse {
1174 request_id: "r".to_string(),
1175 start_slot: 100,
1176 end_slot: 200,
1177 bundle_size: Some(50),
1178 status: BundleBuildStatus::InProgress,
1179 created_at_unix_ms: 0,
1180 };
1181 let json = serde_json::to_value(&response).unwrap();
1182 assert_eq!(json["request_id"].as_str(), Some("r"));
1183 assert_eq!(json["start_slot"].as_u64(), Some(100));
1184 assert_eq!(json["bundle_size"].as_u64(), Some(50));
1185 assert_eq!(json["status"].as_str(), Some("in_progress"));
1186 assert_eq!(json["created_at_unix_ms"].as_u64(), Some(0));
1187 }
1188
1189 fn range(start: u64, end: u64) -> AvailableRange {
1190 AvailableRange {
1191 bundle_start_slot: start,
1192 bundle_start_slot_utc: None,
1193 max_bundle_end_slot: Some(end),
1194 max_bundle_end_slot_utc: None,
1195 max_bundle_size: None,
1196 }
1197 }
1198
1199 #[rstest::rstest]
1203 #[case::single(vec![range(100, 300)], 100, 300, Some(vec![(100, 300)]))]
1204 #[case::multi(
1205 vec![range(100, 200), range(201, 300), range(301, 400)],
1206 100, 300, Some(vec![(100, 200), (201, 300)])
1207 )]
1208 #[case::nested(
1211 vec![range(100, 500), range(110, 150), range(150, 190), range(501, 900)],
1212 100, 900, Some(vec![(100, 500), (501, 900)])
1213 )]
1214 #[case::prefers_finer_grid(
1218 vec![range(1_000, 1_999), range(1_500, 3_400), range(2_000, 2_999), range(3_000, 3_999)],
1219 1_000, 3_999, Some(vec![(1_000, 1_999), (2_000, 2_999), (3_000, 3_999)])
1220 )]
1221 #[case::shared_start_prefers_finer(
1225 vec![range(100, 150), range(100, 120), range(121, 140), range(141, 160)],
1226 100, 160, Some(vec![(100, 120), (121, 140), (141, 160)])
1227 )]
1228 #[case::coarse_overlap_prefers_finer_pair(
1232 vec![range(100, 200), range(100, 150), range(151, 160)],
1233 100, 160, Some(vec![(100, 150), (151, 160)])
1234 )]
1235 #[case::falls_back_to_coarse(
1239 vec![range(100, 160), range(100, 120), range(121, 140)],
1240 100, 160, Some(vec![(100, 160)])
1241 )]
1242 #[case::clamps_final_bundle(vec![range(100, 199), range(200, 999)], 100, 450, Some(vec![(100, 199), (200, 450)]))]
1244 #[case::anchors_mid_bundle(vec![range(150, 350)], 200, 300, Some(vec![(150, 300)]))]
1247 #[case::anchors_then_continues(
1248 vec![range(150, 350), range(351, 600)],
1249 200, 600, Some(vec![(150, 350), (351, 600)])
1250 )]
1251 #[case::start_inside_bundle_anchors(vec![range(200, 400)], 300, 400, Some(vec![(200, 400)]))]
1252 #[case::start_before_first_bundle(vec![range(200, 400)], 100, 400, None)]
1254 #[case::end_not_covered(vec![range(100, 200)], 100, 300, None)]
1256 #[case::gap_in_coverage(vec![range(100, 200), range(210, 300)], 100, 300, None)]
1257 #[case::inverted_range(vec![range(100, 300)], 300, 100, None)]
1259 #[case::user_and_global_ranges_not_collapsed(
1264 vec![range(100, 200), range(100, 150), range(151, 200)],
1265 100, 200, Some(vec![(100, 150), (151, 200)])
1266 )]
1267 fn split_range_cases(
1268 #[case] ranges: Vec<AvailableRange>,
1269 #[case] start: u64,
1270 #[case] end: u64,
1271 #[case] expected: Option<Vec<(u64, u64)>>,
1272 ) {
1273 match expected {
1274 Some(expected) => assert_eq!(split_range(&ranges, start, end).unwrap(), expected),
1275 None => assert!(split_range(&ranges, start, end).is_err()),
1276 }
1277 }
1278
1279 fn ends_by_start(ranges: &[AvailableRange]) -> BTreeMap<u64, BTreeSet<u64>> {
1282 let mut ends: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
1283 for r in ranges {
1284 if let Some(end) = r.max_bundle_end_slot
1285 && end > r.bundle_start_slot
1286 {
1287 ends.entry(r.bundle_start_slot).or_default().insert(end);
1288 }
1289 }
1290 ends
1291 }
1292
1293 fn reference_max_split(
1297 ends: &BTreeMap<u64, BTreeSet<u64>>,
1298 cursor: u64,
1299 end: u64,
1300 ) -> Option<Vec<(u64, u64)>> {
1301 ends.get(&cursor)?
1302 .iter()
1303 .filter_map(|&bundle_end| {
1304 if bundle_end >= end {
1305 Some(vec![(cursor, end)])
1306 } else {
1307 reference_max_split(ends, bundle_end + 1, end).map(|mut rest| {
1308 rest.insert(0, (cursor, bundle_end));
1309 rest
1310 })
1311 }
1312 })
1313 .max_by_key(Vec::len)
1314 }
1315
1316 fn is_valid_split(
1320 split: &[(u64, u64)],
1321 ends: &BTreeMap<u64, BTreeSet<u64>>,
1322 start: u64,
1323 end: u64,
1324 ) -> bool {
1325 split.first().is_some_and(|&(s, _)| s == start)
1326 && split.last().is_some_and(|&(_, e)| e == end)
1327 && split.windows(2).all(|w| w[1].0 == w[0].1 + 1)
1328 && split.iter().all(|&(s, e)| {
1329 e >= s
1330 && ends
1331 .get(&s)
1332 .and_then(|bundle_ends| bundle_ends.iter().next_back())
1333 .is_some_and(|&max_end| e <= max_end)
1334 })
1335 }
1336
1337 #[test]
1342 fn split_range_matches_reference() {
1343 let mut seed: u64 = 0x9E3779B97F4A7C15;
1344 let mut next = || {
1345 seed = seed
1346 .wrapping_mul(6364136223846793005)
1347 .wrapping_add(1442695040888963407);
1348 seed >> 33
1349 };
1350
1351 for _ in 0..50_000 {
1352 let ranges: Vec<AvailableRange> = (0..next() % 6)
1355 .map(|_| {
1356 let start = next() % 12;
1357 range(start, start + next() % 6) })
1359 .collect();
1360 let start = next() % 12;
1361 let end = start + next() % 6; let got = split_range(&ranges, start, end);
1364 let ends = ends_by_start(&ranges);
1365 let anchor = ends
1368 .range(..=start)
1369 .rfind(|(_, e)| e.iter().next_back().is_some_and(|&x| x >= start))
1370 .map(|(&s, _)| s);
1371 let reference = anchor.and_then(|a| reference_max_split(&ends, a, end));
1372
1373 let layout: Vec<_> = ranges
1374 .iter()
1375 .map(|r| (r.bundle_start_slot, r.max_bundle_end_slot))
1376 .collect();
1377 match (&got, &reference) {
1378 (Ok(split), Some(best)) => {
1379 assert!(
1380 is_valid_split(split, &ends, anchor.unwrap(), end),
1381 "invalid split {split:?} for {layout:?} [{start},{end}]"
1382 );
1383 assert_eq!(
1384 split.len(),
1385 best.len(),
1386 "suboptimal split {split:?} vs {best:?} for {layout:?} [{start},{end}]"
1387 );
1388 }
1389 (Err(_), None) => {}
1390 _ => panic!(
1391 "disagreement: split_range={got:?}, reference={reference:?} for {layout:?} [{start},{end}]"
1392 ),
1393 }
1394 }
1395 }
1396}