Skip to main content

simulator_api/
route_plan.rs

1//! One route, hop by hop: what the router quotes, what a decoded transaction reports, and what
2//! both are recorded and streamed as. Only the router names a pool; only a transaction's route
3//! events name the program that ran a hop.
4//!
5//! Addresses and amounts stay as strings — nothing here does address arithmetic, and a raw token
6//! amount exceeds what a JSON number holds exactly.
7//!
8//! Every field is concrete and always written. A plan is recorded through bincode, whose decoder
9//! reads fields positionally: `#[serde(flatten)]` and [`serde_json::Value`] cannot cross it, and
10//! `skip_serializing_if` desynchronizes it. An unset value therefore travels as an explicit null,
11//! which costs a JSON reader nothing — `json_extract` answers null for a missing key either way.
12
13use serde::{Deserialize, Serialize};
14
15/// A whole route in the order its hops run. Deserialization bypasses [`RoutePlan::new`], so a
16/// plan read off the wire can still be empty.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(transparent)]
19pub struct RoutePlan(Vec<RouteHop>);
20
21impl RoutePlan {
22    /// `None` for no hops: an empty route is an absence, not a route that filled nothing.
23    pub fn new(hops: Vec<RouteHop>) -> Option<Self> {
24        (!hops.is_empty()).then_some(Self(hops))
25    }
26
27    pub fn hops(&self) -> &[RouteHop] {
28        &self.0
29    }
30
31    pub fn is_empty(&self) -> bool {
32        self.0.is_empty()
33    }
34
35    pub fn is_split(&self) -> bool {
36        is_split(&self.0)
37    }
38}
39
40/// The trade was split across pools rather than routed as one path, so the hops are branches and
41/// cannot line up positionally against a sequential original.
42pub fn is_split(hops: &[RouteHop]) -> bool {
43    hops.iter().any(|hop| hop.percent != FULL_PERCENT)
44}
45
46/// A route's identity for deduplication: two routes over the same pools fill the same way
47/// whatever order the router lists them in. Skips hops the router gave no pool for.
48pub fn pool_keys(hops: &[RouteHop]) -> impl Iterator<Item = &str> {
49    hops.iter().filter_map(RouteHop::amm_key)
50}
51
52pub fn labels(hops: &[RouteHop]) -> impl Iterator<Item = &str> {
53    hops.iter().filter_map(RouteHop::label)
54}
55
56/// The whole trade through one hop; anything less is a split.
57pub const FULL_PERCENT: u64 = 100;
58
59const fn full_percent() -> u64 {
60    FULL_PERCENT
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct RouteHop {
66    /// Execution order, from a decoded transaction; the router's plan is ordered but unnumbered.
67    #[serde(default)]
68    pub leg: Option<u32>,
69    /// Share of the trade this hop carries.
70    #[serde(default = "full_percent")]
71    pub percent: u64,
72    /// Set by a decoded transaction, where the router sets [`RouteHopSwapInfo::amm_key`].
73    #[serde(default)]
74    pub program: Option<String>,
75    /// Sent by the router, null in everything observed.
76    #[serde(default)]
77    pub bps: Option<u64>,
78    pub swap_info: RouteHopSwapInfo,
79}
80
81/// `percent` starts at [`FULL_PERCENT`]: a hop is the whole trade unless a split says otherwise.
82impl Default for RouteHop {
83    fn default() -> Self {
84        Self {
85            leg: None,
86            percent: FULL_PERCENT,
87            program: None,
88            bps: None,
89            swap_info: RouteHopSwapInfo::default(),
90        }
91    }
92}
93
94impl RouteHop {
95    pub fn label(&self) -> Option<&str> {
96        self.swap_info.label.as_deref()
97    }
98
99    pub fn amm_key(&self) -> Option<&str> {
100        self.swap_info.amm_key.as_deref()
101    }
102
103    pub fn input_mint(&self) -> Option<&str> {
104        self.swap_info.input_mint.as_deref()
105    }
106
107    pub fn output_mint(&self) -> Option<&str> {
108        self.swap_info.output_mint.as_deref()
109    }
110}
111
112#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "camelCase")]
114pub struct RouteHopSwapInfo {
115    /// The DEX's name, e.g. `Raydium`. `None` where the hop's program has no known label, which
116    /// is a hop that cannot be compared by venue rather than a hop with no venue.
117    #[serde(default)]
118    pub label: Option<String>,
119    /// The pool crossed. Only the router names it; route events repeat the program id in `amm`.
120    #[serde(default)]
121    pub amm_key: Option<String>,
122    #[serde(default)]
123    pub input_mint: Option<String>,
124    #[serde(default)]
125    pub in_amount: Option<String>,
126    #[serde(default)]
127    pub output_mint: Option<String>,
128    #[serde(default)]
129    pub out_amount: Option<String>,
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    fn hop(percent: u64) -> RouteHop {
137        RouteHop {
138            percent,
139            ..Default::default()
140        }
141    }
142
143    #[test]
144    fn no_hops_is_absent_rather_than_empty() {
145        assert_eq!(RoutePlan::new(Vec::new()), None);
146        assert_eq!(
147            RoutePlan::new(vec![hop(FULL_PERCENT)]).map(|plan| plan.hops().len()),
148            Some(1)
149        );
150    }
151
152    #[test]
153    fn a_route_is_split_when_any_hop_carries_less_than_the_whole_trade() {
154        assert!(
155            !RoutePlan::new(vec![hop(100), hop(100)])
156                .expect("a plan")
157                .is_split()
158        );
159        assert!(
160            RoutePlan::new(vec![hop(60), hop(40)])
161                .expect("a plan")
162                .is_split()
163        );
164    }
165
166    /// Both sources land on the same paths, so one reader covers every column.
167    #[test]
168    fn both_sources_serialize_onto_one_shape() {
169        let quoted = RouteHop {
170            swap_info: RouteHopSwapInfo {
171                label: Some("SolFi".to_string()),
172                amm_key: Some("pool".to_string()),
173                input_mint: Some("sol".to_string()),
174                in_amount: Some("1000".to_string()),
175                output_mint: Some("usdc".to_string()),
176                out_amount: Some("1010".to_string()),
177            },
178            ..Default::default()
179        };
180        let executed = RouteHop {
181            leg: Some(0),
182            program: Some("prog".to_string()),
183            swap_info: RouteHopSwapInfo {
184                amm_key: None,
185                ..quoted.swap_info.clone()
186            },
187            ..quoted.clone()
188        };
189
190        let json = |hop: &RouteHop| serde_json::to_value(hop).expect("json");
191        for value in [json(&quoted), json(&executed)] {
192            assert_eq!(value["percent"], 100);
193            assert_eq!(value["swapInfo"]["label"], "SolFi");
194            assert_eq!(value["swapInfo"]["inputMint"], "sol");
195            // Amounts are strings: a raw token amount exceeds what a JSON number holds exactly.
196            assert_eq!(value["swapInfo"]["inAmount"], "1000");
197            assert_eq!(value["swapInfo"]["outAmount"], "1010");
198        }
199        assert_eq!(json(&quoted)["swapInfo"]["ammKey"], "pool");
200        assert_eq!(json(&executed)["program"], "prog");
201    }
202
203    /// A stored column comes back out on the paths a reader queries it by.
204    #[test]
205    fn a_recorded_plan_reads_back_onto_the_paths_it_was_written_with() {
206        let recorded = r#"[{"percent":100,"swapInfo":{"label":"Aquifer","ammKey":"CNC5Tae","inputMint":"EPjFWdd","outputMint":"So11111","inAmount":"385638781","outAmount":"4999931316"},"bps":null}]"#;
207
208        let plan: RoutePlan = serde_json::from_str(recorded).expect("a recorded plan decodes");
209        let rewritten = serde_json::to_value(&plan).expect("json");
210
211        assert_eq!(rewritten[0]["percent"], 100);
212        assert_eq!(rewritten[0]["swapInfo"]["label"], "Aquifer");
213        assert_eq!(rewritten[0]["swapInfo"]["ammKey"], "CNC5Tae");
214        assert_eq!(rewritten[0]["swapInfo"]["inputMint"], "EPjFWdd");
215        assert_eq!(rewritten[0]["swapInfo"]["inAmount"], "385638781");
216        assert_eq!(rewritten[0]["swapInfo"]["outputMint"], "So11111");
217        assert_eq!(rewritten[0]["swapInfo"]["outAmount"], "4999931316");
218        assert_eq!(rewritten[0]["bps"], serde_json::Value::Null);
219    }
220
221    /// The positional decoder reads back exactly what was written, and a JSON reader resolves an
222    /// explicit null and an absent key alike.
223    #[test]
224    fn an_unset_field_is_written_as_null_rather_than_omitted() {
225        let value = serde_json::to_value(hop(FULL_PERCENT)).expect("json");
226
227        assert!(value.get("program").is_some(), "written, not skipped");
228        assert_eq!(value["program"], serde_json::Value::Null);
229        assert_eq!(value["swapInfo"]["ammKey"], serde_json::Value::Null);
230        assert_eq!(value["program"], value["neverWritten"]);
231    }
232}