nautilus_blockchain/exchanges/ethereum/
uniswap_v3.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Posei Systems Pty Ltd. All rights reserved.
3//  https://poseitrader.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 std::sync::LazyLock;
17
18use alloy::{
19    primitives::{Address, Signed, U160, U256},
20    sol,
21    sol_types::SolType,
22};
23use hypersync_client::simple_types::Log;
24use nautilus_model::{
25    defi::{
26        chain::chains,
27        dex::{AmmType, Dex},
28        token::Token,
29    },
30    enums::OrderSide,
31    types::{Price, Quantity, fixed::FIXED_PRECISION},
32};
33
34use crate::{
35    events::{burn::BurnEvent, mint::MintEvent, pool_created::PoolCreatedEvent, swap::SwapEvent},
36    exchanges::extended::DexExtended,
37    hypersync::helpers::{
38        extract_block_number, extract_log_index, extract_transaction_hash,
39        extract_transaction_index, validate_event_signature_hash,
40    },
41    math::convert_i256_to_f64,
42};
43
44const POOL_CREATED_EVENT_SIGNATURE_HASH: &str =
45    "783cca1c0412dd0d695e784568c96da2e9c22ff989357a2e8b1d9b2b4e6b7118";
46const SWAP_EVENT_SIGNATURE_HASH: &str =
47    "c42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67";
48const MINT_EVENT_SIGNATURE_HASH: &str =
49    "7a53080ba414158be7ec69b987b5fb7d07dee101fe85488f0853ae16239d0bde";
50const BURN_EVENT_SIGNATURE_HASH: &str =
51    "0c396cd989a39f4459b5fa1aed6a9a8dcdbc45908acfd67e028cd568da98982c";
52
53/// Uniswap V3 DEX on Ethereum.
54pub static UNISWAP_V3: LazyLock<DexExtended> = LazyLock::new(|| {
55    let mut dex = DexExtended::new(Dex::new(
56        chains::ETHEREUM.clone(),
57        "Uniswap V3",
58        "0x1F98431c8aD98523631AE4a59f267346ea31F984",
59        AmmType::CLAMM,
60        "PoolCreated(address,address,uint24,int24,address)",
61        "Swap(address,address,int256,int256,uint160,uint128,int24)",
62        "Mint(address,address,int24,int24,uint128,uint256,uint256)",
63        "Burn(address,int24,int24,uint128,uint256,uint256)",
64    ));
65    dex.set_pool_created_event_parsing(parse_pool_created_event);
66    dex.set_swap_event_parsing(parse_swap_event);
67    dex.set_convert_trade_data(convert_to_trade_data);
68    dex.set_mint_event_parsing(parse_mint_event);
69    dex.set_burn_event_parsing(parse_burn_event);
70    dex
71});
72
73fn parse_pool_created_event(log: Log) -> anyhow::Result<PoolCreatedEvent> {
74    validate_event_signature_hash("PoolCreatedEvent", POOL_CREATED_EVENT_SIGNATURE_HASH, &log)?;
75
76    let block_number = log
77        .block_number
78        .expect("Block number should be set in logs");
79
80    let token = if let Some(topic) = log.topics.get(1).and_then(|t| t.as_ref()) {
81        // Address is stored in the last 20 bytes of the 32-byte topic
82        Address::from_slice(&topic.as_ref()[12..32])
83    } else {
84        anyhow::bail!("Missing token0 address in topic1 when parsing pool created event");
85    };
86
87    let token1 = if let Some(topic) = log.topics.get(2).and_then(|t| t.as_ref()) {
88        Address::from_slice(&topic.as_ref()[12..32])
89    } else {
90        anyhow::bail!("Missing token1 address in topic2 when parsing pool created event");
91    };
92
93    let fee = if let Some(topic) = log.topics.get(3).and_then(|t| t.as_ref()) {
94        U256::from_be_slice(topic.as_ref()).as_limbs()[0] as u32
95    } else {
96        anyhow::bail!("Missing fee in topic3 when parsing pool created event");
97    };
98
99    if let Some(data) = log.data {
100        // Data contains: [tick_spacing (32 bytes), pool_address (32 bytes)]
101        let data_bytes = data.as_ref();
102
103        // Extract tick_spacing (first 32 bytes)
104        let tick_spacing_bytes: [u8; 32] = data_bytes[0..32].try_into()?;
105        let tick_spacing = u32::from_be_bytes(tick_spacing_bytes[28..32].try_into()?);
106
107        // Extract pool_address (next 32 bytes)
108        let pool_address_bytes: [u8; 32] = data_bytes[32..64].try_into()?;
109        let pool_address = Address::from_slice(&pool_address_bytes[12..32]);
110
111        Ok(PoolCreatedEvent::new(
112            block_number.into(),
113            token,
114            token1,
115            fee,
116            tick_spacing,
117            pool_address,
118        ))
119    } else {
120        Err(anyhow::anyhow!("Missing data in pool created event log"))
121    }
122}
123
124// Define sol macro for easier parsing of Swap event data
125// It contains 5 parameters of 32 bytes each:
126// amount0 (int256), amount1 (int256), sqrtPriceX96 (uint160), liquidity (uint128), tick (int24)
127sol! {
128    struct SwapEventData {
129        int256 amount0;
130        int256 amount1;
131        uint160 sqrt_price_x96;
132        uint128 liquidity;
133        int24 tick;
134    }
135}
136
137fn parse_swap_event(log: Log) -> anyhow::Result<SwapEvent> {
138    validate_event_signature_hash("SwapEvent", SWAP_EVENT_SIGNATURE_HASH, &log)?;
139
140    let sender = match log.topics.get(1).and_then(|t| t.as_ref()) {
141        Some(topic) => Address::from_slice(&topic.as_ref()[12..32]),
142        None => anyhow::bail!("Missing sender address in topic1 when parsing swap event"),
143    };
144
145    let recipient = match log.topics.get(2).and_then(|t| t.as_ref()) {
146        Some(topic) => Address::from_slice(&topic.as_ref()[12..32]),
147        None => anyhow::bail!("Missing recipient address in topic2 when parsing swap event"),
148    };
149
150    if let Some(data) = &log.data {
151        let data_bytes = data.as_ref();
152
153        // Validate if data contains 5 parameters of 32 bytes each
154        if data_bytes.len() < 5 * 32 {
155            anyhow::bail!("Swap event data is too short");
156        }
157
158        // Decode the data using the SwapEventData struct
159        let decoded = match <SwapEventData as SolType>::abi_decode(data_bytes) {
160            Ok(decoded) => decoded,
161            Err(e) => anyhow::bail!("Failed to decode swap event data: {e}"),
162        };
163        decoded.amount0;
164
165        Ok(SwapEvent::new(
166            extract_block_number(&log)?,
167            extract_transaction_hash(&log)?,
168            extract_transaction_index(&log)?,
169            extract_log_index(&log)?,
170            sender,
171            recipient,
172            decoded.amount0,
173            decoded.amount1,
174            decoded.sqrt_price_x96,
175        ))
176    } else {
177        Err(anyhow::anyhow!("Missing data in swap event log"))
178    }
179}
180
181/// <https://blog.uniswap.org/uniswap-v3-math-primer>
182fn calculate_price_from_sqrt_price(
183    sqrt_price_x96: U160,
184    token0_decimals: u8,
185    token1_decimals: u8,
186) -> f64 {
187    let sqrt_price = sqrt_price_x96 >> 96;
188    let price = sqrt_price * sqrt_price;
189    let price: f64 = U256::from(price)
190        .to_string()
191        .parse()
192        .expect("Failed to parse U256 to f64");
193    let token0_multiplier = 10u128.pow(u32::from(token0_decimals));
194    let token1_multiplier = 10u128.pow(u32::from(token1_decimals));
195    let factor = token1_multiplier as f64 / token0_multiplier as f64;
196    factor / price
197}
198
199fn convert_to_trade_data(
200    token0: &Token,
201    token1: &Token,
202    swap_event: &SwapEvent,
203) -> anyhow::Result<(OrderSide, Quantity, Price)> {
204    let price_f64 = calculate_price_from_sqrt_price(
205        swap_event.sqrt_price_x96,
206        token0.decimals,
207        token1.decimals,
208    );
209    let price = Price::from(format!(
210        "{:.precision$}",
211        price_f64,
212        precision = FIXED_PRECISION as usize
213    ));
214    let quantity_f64 = convert_i256_to_f64(swap_event.amount1, token1.decimals)?.abs();
215    let quantity = Quantity::from(format!(
216        "{:.precision$}",
217        quantity_f64,
218        precision = FIXED_PRECISION as usize
219    ));
220    let zero = Signed::<256, 4>::ZERO;
221    let side = if swap_event.amount1 > zero {
222        OrderSide::Sell
223    } else {
224        OrderSide::Buy
225    };
226    Ok((side, quantity, price))
227}
228
229// Define sol macro for easier parsing of Mint event data
230// It contains 4 parameters of 32 bytes each:
231// sender (address), amount (uint128), amount0 (uint256), amount1 (uint256)
232sol! {
233    struct MintEventData {
234        address sender;
235        uint128 amount;
236        uint256 amount0;
237        uint256 amount1;
238    }
239}
240
241fn parse_mint_event(log: Log) -> anyhow::Result<MintEvent> {
242    validate_event_signature_hash("Mint", MINT_EVENT_SIGNATURE_HASH, &log)?;
243
244    let owner = match log.topics.get(1).and_then(|t| t.as_ref()) {
245        Some(topic) => Address::from_slice(&topic.as_ref()[12..32]),
246        None => anyhow::bail!("Missing owner address in topic1 when parsing mint event"),
247    };
248
249    // Extract int24 tickLower from topic2 (stored as a 32-byte padded value)
250    let tick_lower = match log.topics.get(2).and_then(|t| t.as_ref()) {
251        Some(topic) => {
252            let tick_lower_bytes: [u8; 32] = topic.as_ref().try_into()?;
253            i32::from_be_bytes(tick_lower_bytes[28..32].try_into()?)
254        }
255        None => anyhow::bail!("Missing tickLower in topic2 when parsing mint event"),
256    };
257
258    // Extract int24 tickUpper from topic3 (stored as a 32-byte padded value)
259    let tick_upper = match log.topics.get(3).and_then(|t| t.as_ref()) {
260        Some(topic) => {
261            let tick_upper_bytes: [u8; 32] = topic.as_ref().try_into()?;
262            i32::from_be_bytes(tick_upper_bytes[28..32].try_into()?)
263        }
264        None => anyhow::bail!("Missing tickUpper in topic3 when parsing mint event"),
265    };
266
267    if let Some(data) = &log.data {
268        let data_bytes = data.as_ref();
269
270        // Validate if data contains 4 parameters of 32 bytes each
271        if data_bytes.len() < 4 * 32 {
272            anyhow::bail!("Mint event data is too short");
273        }
274
275        // Decode the data using the MintEventData struct
276        let decoded = match <MintEventData as SolType>::abi_decode(data_bytes) {
277            Ok(decoded) => decoded,
278            Err(e) => anyhow::bail!("Failed to decode mint event data: {e}"),
279        };
280
281        Ok(MintEvent::new(
282            extract_block_number(&log)?,
283            extract_transaction_hash(&log)?,
284            extract_transaction_index(&log)?,
285            extract_log_index(&log)?,
286            decoded.sender,
287            owner,
288            tick_lower,
289            tick_upper,
290            decoded.amount,
291            decoded.amount0,
292            decoded.amount1,
293        ))
294    } else {
295        Err(anyhow::anyhow!("Missing data in mint event log"))
296    }
297}
298
299// Define sol macro for easier parsing of Burn event data
300// It contains 3 parameters of 32 bytes each:
301// amount (uint128), amount0 (uint256), amount1 (uint256)
302sol! {
303    struct BurnEventData {
304        uint128 amount;
305        uint256 amount0;
306        uint256 amount1;
307    }
308}
309
310fn parse_burn_event(log: Log) -> anyhow::Result<BurnEvent> {
311    validate_event_signature_hash("Burn", BURN_EVENT_SIGNATURE_HASH, &log)?;
312
313    let owner = match log.topics.get(1).and_then(|t| t.as_ref()) {
314        Some(topic) => Address::from_slice(&topic.as_ref()[12..32]),
315        None => anyhow::bail!("Missing owner address in topic1 when parsing burn event"),
316    };
317
318    // Extract int24 tickLower from topic2 (stored as a 32-byte padded value)
319    let tick_lower = match log.topics.get(2).and_then(|t| t.as_ref()) {
320        Some(topic) => {
321            let tick_lower_bytes: [u8; 32] = topic.as_ref().try_into()?;
322            i32::from_be_bytes(tick_lower_bytes[28..32].try_into()?)
323        }
324        None => anyhow::bail!("Missing tickLower in topic2 when parsing burn event"),
325    };
326
327    // Extract int24 tickUpper from topic3 (stored as a 32-byte padded value)
328    let tick_upper = match log.topics.get(3).and_then(|t| t.as_ref()) {
329        Some(topic) => {
330            let tick_upper_bytes: [u8; 32] = topic.as_ref().try_into()?;
331            i32::from_be_bytes(tick_upper_bytes[28..32].try_into()?)
332        }
333        None => anyhow::bail!("Missing tickUpper in topic3 when parsing burn event"),
334    };
335
336    if let Some(data) = &log.data {
337        let data_bytes = data.as_ref();
338
339        // Validate if data contains 3 parameters of 32 bytes each
340        if data_bytes.len() < 3 * 32 {
341            anyhow::bail!("Burn event data is too short");
342        }
343
344        // Decode the data using the BurnEventData struct
345        let decoded = match <BurnEventData as SolType>::abi_decode(data_bytes) {
346            Ok(decoded) => decoded,
347            Err(e) => anyhow::bail!("Failed to decode burn event data: {e}"),
348        };
349
350        Ok(BurnEvent::new(
351            extract_block_number(&log)?,
352            extract_transaction_hash(&log)?,
353            extract_transaction_index(&log)?,
354            extract_log_index(&log)?,
355            owner,
356            tick_lower,
357            tick_upper,
358            decoded.amount,
359            decoded.amount0,
360            decoded.amount1,
361        ))
362    } else {
363        Err(anyhow::anyhow!("Missing data in burn event log"))
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use rstest::*;
370
371    use super::*;
372
373    #[fixture]
374    fn mint_event_log() -> Log {
375        serde_json::from_str(r#"{
376            "removed": null,
377            "log_index": "0xa",
378            "transaction_index": "0x5",
379            "transaction_hash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
380            "block_hash": null,
381            "block_number": "0x1581756",
382            "address": null,
383            "data": "0x000000000000000000000000f5a96d43e4b9a2c47f302b54d006d7e20f038658000000000000000000000000000000000000000000000028c8b4995ae1ad0e9e000000000000000000000000000000000000000000000000000009423c32486c0000000000000000000000000000000000000000000000bb5bc19aa32e5d05b4",
384            "topics": [
385                "0x7a53080ba414158be7ec69b987b5fb7d07dee101fe85488f0853ae16239d0bde",
386                "0x000000000000000000000000a69babef1ca67a37ffaf7a485dfff3382056e78c",
387                "0x00000000000000000000000000000000000000000000000000000000000304e4",
388                "0x00000000000000000000000000000000000000000000000000000000000304ee"
389            ]
390        }"#).unwrap()
391    }
392
393    #[rstest]
394    fn test_parse_mint_event(mint_event_log: Log) {
395        let result = parse_mint_event(mint_event_log);
396        assert!(result.is_ok());
397        let mint_event = result.unwrap();
398
399        assert_eq!(mint_event.block_number, 0x1581756);
400        assert_eq!(
401            mint_event.owner.to_string().to_lowercase(),
402            "0xa69babef1ca67a37ffaf7a485dfff3382056e78c"
403        );
404        assert_eq!(mint_event.tick_lower, 197860); // 0x304e4
405        assert_eq!(mint_event.tick_upper, 197870); // 0x304ee
406        assert_eq!(
407            mint_event.sender.to_string().to_lowercase(),
408            "0xf5a96d43e4b9a2c47f302b54d006d7e20f038658"
409        );
410        assert_eq!(mint_event.amount, 0x28c8b4995ae1ad0e9e);
411        assert_eq!(mint_event.amount0.to_string(), "10180082419820");
412        assert_eq!(mint_event.amount1.to_string(), "3456152877537290945972");
413    }
414
415    #[rstest]
416    fn test_parse_mint_event_missing_data() {
417        let mut log = mint_event_log();
418        log.data = None;
419
420        let result = parse_mint_event(log);
421        assert!(result.is_err());
422        assert!(result.unwrap_err().to_string().contains("Missing data"));
423    }
424
425    #[rstest]
426    fn test_parse_mint_event_missing_topics() {
427        let mut log = mint_event_log();
428
429        // Test missing owner
430        log.topics.truncate(1);
431        let result = parse_mint_event(log.clone());
432        assert!(result.is_err());
433        assert!(result.unwrap_err().to_string().contains("Missing owner"));
434
435        // Test missing tickLower
436        log = mint_event_log();
437        log.topics.truncate(2);
438        let result = parse_mint_event(log.clone());
439        assert!(result.is_err());
440        assert!(
441            result
442                .unwrap_err()
443                .to_string()
444                .contains("Missing tickLower")
445        );
446
447        // Test missing tickUpper
448        log = mint_event_log();
449        log.topics.truncate(3);
450        let result = parse_mint_event(log);
451        assert!(result.is_err());
452        assert!(
453            result
454                .unwrap_err()
455                .to_string()
456                .contains("Missing tickUpper")
457        );
458    }
459}