Skip to main content

nautilus_blockchain/exchanges/
mod.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use nautilus_model::defi::{Blockchain, Chain, DexType};
17
18use crate::exchanges::{
19    arbitrum::ARBITRUM_DEX_EXTENDED_MAP, base::BASE_DEX_EXTENDED_MAP, bsc::BSC_DEX_EXTENDED_MAP,
20    ethereum::ETHEREUM_DEX_EXTENDED_MAP, extended::DexExtended,
21};
22
23pub mod arbitrum;
24pub mod base;
25pub mod bsc;
26pub mod ethereum;
27pub mod extended;
28pub mod parsing;
29
30/// Returns a map of all DEX names to Dex instances across all chains
31#[must_use]
32pub fn get_dex_extended(
33    blockchain: Blockchain,
34    dex_type: &DexType,
35) -> Option<&'static DexExtended> {
36    match blockchain {
37        Blockchain::Ethereum => ETHEREUM_DEX_EXTENDED_MAP.get(dex_type).copied(),
38        Blockchain::Base => BASE_DEX_EXTENDED_MAP.get(dex_type).copied(),
39        Blockchain::Arbitrum => ARBITRUM_DEX_EXTENDED_MAP.get(dex_type).copied(),
40        Blockchain::Bsc => BSC_DEX_EXTENDED_MAP.get(dex_type).copied(),
41        _ => None,
42    }
43}
44
45/// Returns the supported DEX names for a given blockchain.
46#[must_use]
47pub fn get_supported_dexes_for_chain(blockchain: Blockchain) -> Vec<String> {
48    let dex_types: Vec<DexType> = match blockchain {
49        Blockchain::Ethereum => ETHEREUM_DEX_EXTENDED_MAP.keys().copied().collect(),
50        Blockchain::Base => BASE_DEX_EXTENDED_MAP.keys().copied().collect(),
51        Blockchain::Arbitrum => ARBITRUM_DEX_EXTENDED_MAP.keys().copied().collect(),
52        Blockchain::Bsc => BSC_DEX_EXTENDED_MAP.keys().copied().collect(),
53        _ => vec![],
54    };
55
56    dex_types
57        .into_iter()
58        .map(|dex_type| format!("{dex_type}"))
59        .collect()
60}
61
62/// Chains with a registered DEX map, so `sync-dex` and `analyze-pool(s)` can operate on them.
63///
64/// Other chains (for example Polygon) are valid for block-level `sync-blocks` but have no DEX
65/// registrations, so DEX commands reject them.
66pub const DEX_SUPPORTED_CHAINS: [Blockchain; 4] = [
67    Blockchain::Ethereum,
68    Blockchain::Base,
69    Blockchain::Arbitrum,
70    Blockchain::Bsc,
71];
72
73/// The capability tiers a registered DEX reaches on a chain, derived from its parser presence.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct DexCapability {
76    /// The DEX type.
77    pub dex_type: DexType,
78    /// Whether `sync-dex` can discover pools (a HyperSync `PoolCreated` parser is registered).
79    pub discovery: bool,
80    /// Whether `analyze-pool(s)` can build snapshots (the full pool-event parser set is registered).
81    pub snapshots: bool,
82    /// Whether replay keeps `fee_protocol` correct (snapshot-capable plus a `SetFeeProtocol` parser).
83    pub replay_ready: bool,
84}
85
86/// Returns the capability tier of every DEX registered on `blockchain`, sorted by DEX name.
87///
88/// Returns an empty vector for chains without a DEX map (see [`DEX_SUPPORTED_CHAINS`]).
89#[must_use]
90pub fn dex_capabilities_for_chain(blockchain: Blockchain) -> Vec<DexCapability> {
91    let mut dex_types: Vec<DexType> = match blockchain {
92        Blockchain::Ethereum => ETHEREUM_DEX_EXTENDED_MAP.keys().copied().collect(),
93        Blockchain::Base => BASE_DEX_EXTENDED_MAP.keys().copied().collect(),
94        Blockchain::Arbitrum => ARBITRUM_DEX_EXTENDED_MAP.keys().copied().collect(),
95        Blockchain::Bsc => BSC_DEX_EXTENDED_MAP.keys().copied().collect(),
96        _ => return Vec::new(),
97    };
98    dex_types.sort_by_key(ToString::to_string);
99
100    dex_types
101        .into_iter()
102        .filter_map(|dex_type| {
103            let dex = get_dex_extended(blockchain, &dex_type)?;
104            Some(DexCapability {
105                dex_type,
106                discovery: dex.supports_pool_discovery(),
107                snapshots: dex.missing_pool_analysis_parsers().is_empty(),
108                replay_ready: dex.supports_fee_protocol_replay(),
109            })
110        })
111        .collect()
112}
113
114/// Attempts to match a DEX name in a case-insensitive manner.
115pub fn find_dex_type_case_insensitive(dex_name: &str, chain: &Chain) -> Option<DexType> {
116    let supported_dexes = get_supported_dexes_for_chain(chain.name);
117
118    // First try exact match (for performance)
119    if let Some(dex_type) = DexType::from_dex_name(dex_name) {
120        return Some(dex_type);
121    }
122
123    // Try case-insensitive match
124    for supported_dex in supported_dexes {
125        if supported_dex.to_lowercase() == dex_name.to_lowercase() {
126            return DexType::from_dex_name(&supported_dex);
127        }
128    }
129
130    None
131}
132
133#[cfg(test)]
134mod tests {
135    use alloy::primitives::keccak256;
136    use nautilus_core::hex;
137    use nautilus_model::defi::pool_analysis::PoolEventKind;
138    use rstest::rstest;
139
140    use super::*;
141
142    /// keccak256 of the empty string, used as the sentinel hash when a DEX
143    /// registration leaves an event signature blank.
144    fn empty_signature_hash() -> String {
145        hex::encode_prefixed(keccak256("".as_bytes()))
146    }
147
148    /// Pre-existing (chain, dex, event) parser gaps that predate the structured-error
149    /// patch. New gaps must not be added without also wiring the parser; legacy gaps
150    /// belong to a separate cleanup.
151    const KNOWN_PARSER_GAPS: &[(Blockchain, DexType, &str)] = &[];
152
153    fn is_known_gap(blockchain: Blockchain, dex_type: DexType, event: &str) -> bool {
154        KNOWN_PARSER_GAPS
155            .iter()
156            .any(|(b, d, e)| *b == blockchain && *d == dex_type && *e == event)
157    }
158
159    fn collect_parity_gaps(blockchain: Blockchain, dex_type: DexType, gaps: &mut Vec<String>) {
160        let dex_extended = get_dex_extended(blockchain, &dex_type).unwrap_or_else(|| {
161            panic!("{blockchain:?}:{dex_type:?} should be registered in the DEX map")
162        });
163        let empty = empty_signature_hash();
164        let dex = &dex_extended.dex;
165
166        let mut record = |event: &str, has_parser: bool| {
167            if !has_parser && !is_known_gap(blockchain, dex_type, event) {
168                gaps.push(format!(
169                    "{blockchain:?}:{dex_type:?} advertises {event} but has no HyperSync parser"
170                ));
171            }
172        };
173
174        if dex.pool_created_event.as_ref() != empty {
175            record(
176                "PoolCreated",
177                dex_extended.parse_pool_created_event_hypersync_fn.is_some(),
178            );
179        }
180
181        if dex.swap_created_event.as_ref() != empty {
182            record("Swap", dex_extended.parse_swap_event_hypersync_fn.is_some());
183        }
184
185        if dex.mint_created_event.as_ref() != empty {
186            record("Mint", dex_extended.parse_mint_event_hypersync_fn.is_some());
187        }
188
189        if dex.burn_created_event.as_ref() != empty {
190            record("Burn", dex_extended.parse_burn_event_hypersync_fn.is_some());
191        }
192
193        if dex.collect_created_event.as_ref() != empty {
194            record(
195                "Collect",
196                dex_extended.parse_collect_event_hypersync_fn.is_some(),
197            );
198        }
199
200        if dex.initialize_event.is_some() {
201            record(
202                "Initialize",
203                dex_extended.parse_initialize_event_hypersync_fn.is_some(),
204            );
205        }
206
207        if dex.flash_created_event.is_some() {
208            record(
209                "Flash",
210                dex_extended.parse_flash_event_hypersync_fn.is_some(),
211            );
212        }
213
214        if dex.fee_protocol_update_event.is_some() {
215            record(
216                "SetFeeProtocol",
217                dex_extended
218                    .parse_fee_protocol_update_event_hypersync_fn
219                    .is_some(),
220            );
221        }
222
223        if dex.fee_protocol_collect_event.is_some() {
224            record(
225                "CollectProtocol",
226                dex_extended
227                    .parse_fee_protocol_collect_event_hypersync_fn
228                    .is_some(),
229            );
230        }
231    }
232
233    #[rstest]
234    #[case(Blockchain::Ethereum)]
235    #[case(Blockchain::Base)]
236    #[case(Blockchain::Arbitrum)]
237    #[case(Blockchain::Bsc)]
238    fn test_dex_signature_parser_parity_for_chain(#[case] blockchain: Blockchain) {
239        let dex_types: Vec<DexType> = match blockchain {
240            Blockchain::Ethereum => ETHEREUM_DEX_EXTENDED_MAP.keys().copied().collect(),
241            Blockchain::Base => BASE_DEX_EXTENDED_MAP.keys().copied().collect(),
242            Blockchain::Arbitrum => ARBITRUM_DEX_EXTENDED_MAP.keys().copied().collect(),
243            Blockchain::Bsc => BSC_DEX_EXTENDED_MAP.keys().copied().collect(),
244            _ => panic!("unsupported chain in test"),
245        };
246        assert!(
247            !dex_types.is_empty(),
248            "{blockchain:?} should register at least one DEX"
249        );
250
251        let mut gaps = Vec::new();
252        for dex_type in dex_types {
253            collect_parity_gaps(blockchain, dex_type, &mut gaps);
254        }
255        assert!(
256            gaps.is_empty(),
257            "DEX parser parity violations for {blockchain:?}:\n{}",
258            gaps.join("\n")
259        );
260    }
261
262    #[rstest]
263    #[case(Blockchain::Bsc, DexType::UniswapV3)]
264    #[case(Blockchain::Bsc, DexType::PancakeSwapV3)]
265    fn test_bsc_dispatch_returns_registered_dex(
266        #[case] blockchain: Blockchain,
267        #[case] dex_type: DexType,
268    ) {
269        let dex_extended = get_dex_extended(blockchain, &dex_type)
270            .expect("BSC dispatch should return the registered DEX");
271        assert_eq!(dex_extended.dex.chain.name, blockchain);
272        assert_eq!(dex_extended.dex.name, dex_type);
273    }
274
275    #[rstest]
276    #[case(Blockchain::Bsc)]
277    #[case(Blockchain::Base)]
278    #[case(Blockchain::Arbitrum)]
279    #[case(Blockchain::Ethereum)]
280    fn test_pancakeswap_v3_supports_pool_analysis(#[case] blockchain: Blockchain) {
281        let dex_extended = get_dex_extended(blockchain, &DexType::PancakeSwapV3)
282            .expect("PancakeSwapV3 should be registered");
283        assert!(dex_extended.supports_pool_discovery());
284        assert!(
285            dex_extended.missing_pool_analysis_parsers().is_empty(),
286            "PancakeSwapV3 on {blockchain:?} is missing analysis parsers: {:?}",
287            dex_extended.missing_pool_analysis_parsers()
288        );
289    }
290
291    #[rstest]
292    #[case(Blockchain::Ethereum, DexType::UniswapV3, true, true, true)]
293    #[case(Blockchain::Base, DexType::UniswapV3, true, true, true)]
294    #[case(Blockchain::Bsc, DexType::PancakeSwapV3, true, true, false)]
295    #[case(Blockchain::Base, DexType::AerodromeSlipstream, false, true, false)]
296    #[case(Blockchain::Ethereum, DexType::UniswapV2, true, false, false)]
297    #[case(Blockchain::Arbitrum, DexType::SushiSwapV2, false, false, false)]
298    fn test_dex_capability_tiers(
299        #[case] blockchain: Blockchain,
300        #[case] dex_type: DexType,
301        #[case] discovery: bool,
302        #[case] snapshots: bool,
303        #[case] replay_ready: bool,
304    ) {
305        let capability = dex_capabilities_for_chain(blockchain)
306            .into_iter()
307            .find(|c| c.dex_type == dex_type)
308            .unwrap_or_else(|| panic!("{dex_type:?} should be registered on {blockchain:?}"));
309
310        assert_eq!(capability.discovery, discovery);
311        assert_eq!(capability.snapshots, snapshots);
312        assert_eq!(capability.replay_ready, replay_ready);
313    }
314
315    #[rstest]
316    fn test_dex_capabilities_empty_for_chain_without_map() {
317        assert!(dex_capabilities_for_chain(Blockchain::Polygon).is_empty());
318    }
319
320    #[rstest]
321    fn test_dex_capabilities_sorted_by_name() {
322        let names: Vec<String> = dex_capabilities_for_chain(Blockchain::Arbitrum)
323            .iter()
324            .map(|c| c.dex_type.to_string())
325            .collect();
326        let mut sorted = names.clone();
327        sorted.sort();
328
329        assert_eq!(names, sorted);
330    }
331
332    #[rstest]
333    fn test_dex_without_parsers_reports_unsupported() {
334        // SushiSwapV3 on Arbitrum is registered for the pool set but has no event parsers.
335        let dex_extended = get_dex_extended(Blockchain::Arbitrum, &DexType::SushiSwapV3)
336            .expect("SushiSwapV3 should be registered on Arbitrum");
337        assert!(!dex_extended.supports_pool_discovery());
338        assert_eq!(
339            dex_extended.missing_pool_analysis_parsers(),
340            vec![
341                PoolEventKind::Initialize,
342                PoolEventKind::Swap,
343                PoolEventKind::Mint,
344                PoolEventKind::Burn,
345                PoolEventKind::Collect,
346            ]
347        );
348    }
349}