Skip to main content

nautilus_blockchain/exchanges/parsing/pancakeswap_v3/
swap.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 alloy::{dyn_abi::SolType, primitives::Address, sol};
17use nautilus_model::defi::{PoolIdentifier, SharedDex, rpc::RpcLog};
18use ustr::Ustr;
19
20use crate::{
21    events::swap::SwapEvent,
22    hypersync::{
23        HypersyncLog,
24        log::{
25            extract_address_from_topic, extract_block_number, extract_log_index,
26            extract_transaction_hash, extract_transaction_index, validate_event_signature_hash,
27        },
28    },
29    rpc::log as rpc_log,
30};
31
32// PancakeSwap V3's Swap appends protocolFees fields, so its topic0 differs from Uniswap V3.
33const SWAP_EVENT_SIGNATURE_HASH: &str =
34    "19b47279256b2a23a1665c810c8d55a1758940ee09377d4f8d26497a3577dc83";
35
36// Uniswap V3's Swap fields plus PancakeSwap's two appended protocolFees fields.
37sol! {
38    struct SwapEventData {
39        int256 amount0;
40        int256 amount1;
41        uint160 sqrt_price_x96;
42        uint128 liquidity;
43        int24 tick;
44        uint128 protocol_fees_token0;
45        uint128 protocol_fees_token1;
46    }
47}
48
49/// Parses a PancakeSwap V3 swap event from a HyperSync log.
50///
51/// The protocol-fee fields are decoded to validate the layout but dropped: the shared
52/// [`SwapEvent`] mirrors the Uniswap V3 fields, which is all downstream pool profiling uses.
53///
54/// # Errors
55///
56/// Returns an error if the log parsing fails or if the event data is invalid.
57///
58/// # Panics
59///
60/// Panics if the contract address is not set in the log.
61pub fn parse_swap_event_hypersync(dex: SharedDex, log: &HypersyncLog) -> anyhow::Result<SwapEvent> {
62    validate_event_signature_hash("SwapEvent", SWAP_EVENT_SIGNATURE_HASH, log)?;
63
64    let sender = extract_address_from_topic(log, 1, "sender")?;
65    let recipient = extract_address_from_topic(log, 2, "recipient")?;
66
67    if let Some(data) = &log.data {
68        let data_bytes = data.as_ref();
69
70        if data_bytes.len() < 7 * 32 {
71            anyhow::bail!("Swap event data is too short");
72        }
73
74        let decoded = match <SwapEventData as SolType>::abi_decode(data_bytes) {
75            Ok(decoded) => decoded,
76            Err(e) => anyhow::bail!("Failed to decode swap event data: {e}"),
77        };
78        let pool_address = Address::from_slice(
79            log.address
80                .clone()
81                .expect("Contract address should be set in logs")
82                .as_ref(),
83        );
84        let pool_identifier = PoolIdentifier::Address(Ustr::from(&pool_address.to_string()));
85        Ok(SwapEvent::new(
86            dex,
87            pool_identifier,
88            extract_block_number(log)?,
89            extract_transaction_hash(log)?,
90            extract_transaction_index(log)?,
91            extract_log_index(log)?,
92            sender,
93            recipient,
94            decoded.amount0,
95            decoded.amount1,
96            decoded.sqrt_price_x96,
97            decoded.liquidity,
98            decoded.tick.as_i32(),
99        ))
100    } else {
101        Err(anyhow::anyhow!("Missing data in swap event log"))
102    }
103}
104
105/// Parses a PancakeSwap V3 swap event from an RPC log.
106///
107/// # Errors
108///
109/// Returns an error if the log parsing fails or if the event data is invalid.
110pub fn parse_swap_event_rpc(dex: SharedDex, log: &RpcLog) -> anyhow::Result<SwapEvent> {
111    rpc_log::validate_event_signature(log, SWAP_EVENT_SIGNATURE_HASH, "Swap")?;
112
113    let sender = rpc_log::extract_address_from_topic(log, 1, "sender")?;
114    let recipient = rpc_log::extract_address_from_topic(log, 2, "recipient")?;
115
116    let data_bytes = rpc_log::extract_data_bytes(log)?;
117
118    if data_bytes.len() < 7 * 32 {
119        anyhow::bail!("Swap event data is too short");
120    }
121
122    let decoded = match <SwapEventData as SolType>::abi_decode(&data_bytes) {
123        Ok(decoded) => decoded,
124        Err(e) => anyhow::bail!("Failed to decode swap event data: {e}"),
125    };
126
127    let pool_address = rpc_log::extract_address(log)?;
128    let pool_identifier = PoolIdentifier::Address(Ustr::from(&pool_address.to_string()));
129    Ok(SwapEvent::new(
130        dex,
131        pool_identifier,
132        rpc_log::extract_block_number(log)?,
133        rpc_log::extract_transaction_hash(log)?,
134        rpc_log::extract_transaction_index(log)?,
135        rpc_log::extract_log_index(log)?,
136        sender,
137        recipient,
138        decoded.amount0,
139        decoded.amount1,
140        decoded.sqrt_price_x96,
141        decoded.liquidity,
142        decoded.tick.as_i32(),
143    ))
144}
145
146#[cfg(test)]
147mod tests {
148    use std::sync::Arc;
149
150    use alloy::primitives::{I256, U160, U256};
151    use rstest::*;
152
153    use super::*;
154    use crate::exchanges::bsc;
155
156    // Real PancakeSwap V3 Swap from BSC block 105495649 (WBNB/USDT 0.01% pool
157    // 0x172fcd41e0913e95784454622d1c3724f546f849)
158    const HYPERSYNC_SWAP_LOG: &str =
159        include_str!("../../../../test_data/pancakeswap_v3_swap_hypersync.json");
160    const RPC_SWAP_LOG: &str = include_str!("../../../../test_data/pancakeswap_v3_swap_rpc.json");
161
162    #[fixture]
163    fn hypersync_log() -> HypersyncLog {
164        serde_json::from_str(HYPERSYNC_SWAP_LOG).expect("Failed to deserialize HyperSync log")
165    }
166
167    #[fixture]
168    fn rpc_log() -> RpcLog {
169        serde_json::from_str(RPC_SWAP_LOG).expect("Failed to deserialize RPC log")
170    }
171
172    #[rstest]
173    fn test_parse_swap_event_hypersync(hypersync_log: HypersyncLog) {
174        let dex = Arc::clone(&bsc::PANCAKESWAP_V3.dex);
175        let event = parse_swap_event_hypersync(dex, &hypersync_log).unwrap();
176
177        assert_eq!(
178            event.pool_identifier.to_string(),
179            "0x172fcD41E0913e95784454622d1c3724f546f849"
180        );
181        assert_eq!(
182            event.sender.to_string().to_lowercase(),
183            "0x7eded5ce04fd9bb6d125a0a470cc3ffcd972e182"
184        );
185        assert_eq!(
186            event.receiver.to_string().to_lowercase(),
187            "0x7eded5ce04fd9bb6d125a0a470cc3ffcd972e182"
188        );
189        assert_eq!(
190            event.amount0,
191            I256::try_from(2291588381489685660_i128).unwrap()
192        );
193        let expected_amount1 = I256::from_raw(
194            U256::from_str_radix(
195                "fffffffffffffffffffffffffffffffffffffffffffffffffff22743d8dee163",
196                16,
197            )
198            .unwrap(),
199        );
200        assert_eq!(event.amount1, expected_amount1);
201        let expected_sqrt_price = U160::from_str_radix("a8edeae49c411da42257f71", 16).unwrap();
202        assert_eq!(event.sqrt_price_x96, expected_sqrt_price);
203        let expected_liquidity = u128::from_str_radix("310fdcabce7b0096dfc84", 16).unwrap();
204        assert_eq!(event.liquidity, expected_liquidity);
205        assert_eq!(event.tick, -63769);
206        assert_eq!(event.block_number, 105495649);
207    }
208
209    #[rstest]
210    fn test_parse_swap_event_rpc(rpc_log: RpcLog) {
211        let dex = Arc::clone(&bsc::PANCAKESWAP_V3.dex);
212        let event = parse_swap_event_rpc(dex, &rpc_log).unwrap();
213
214        assert_eq!(
215            event.pool_identifier.to_string(),
216            "0x172fcD41E0913e95784454622d1c3724f546f849"
217        );
218        assert_eq!(
219            event.amount0,
220            I256::try_from(2291588381489685660_i128).unwrap()
221        );
222        let expected_amount1 = I256::from_raw(
223            U256::from_str_radix(
224                "fffffffffffffffffffffffffffffffffffffffffffffffffff22743d8dee163",
225                16,
226            )
227            .unwrap(),
228        );
229        assert_eq!(event.amount1, expected_amount1);
230        assert_eq!(event.tick, -63769);
231        assert_eq!(event.block_number, 105495649);
232    }
233
234    #[rstest]
235    fn test_hypersync_rpc_match(hypersync_log: HypersyncLog, rpc_log: RpcLog) {
236        let dex = Arc::clone(&bsc::PANCAKESWAP_V3.dex);
237        let event_hypersync = parse_swap_event_hypersync(Arc::clone(&dex), &hypersync_log).unwrap();
238        let event_rpc = parse_swap_event_rpc(dex, &rpc_log).unwrap();
239
240        assert_eq!(event_hypersync.pool_identifier, event_rpc.pool_identifier);
241        assert_eq!(event_hypersync.sender, event_rpc.sender);
242        assert_eq!(event_hypersync.receiver, event_rpc.receiver);
243        assert_eq!(event_hypersync.amount0, event_rpc.amount0);
244        assert_eq!(event_hypersync.amount1, event_rpc.amount1);
245        assert_eq!(event_hypersync.sqrt_price_x96, event_rpc.sqrt_price_x96);
246        assert_eq!(event_hypersync.liquidity, event_rpc.liquidity);
247        assert_eq!(event_hypersync.tick, event_rpc.tick);
248        assert_eq!(event_hypersync.block_number, event_rpc.block_number);
249        assert_eq!(event_hypersync.transaction_hash, event_rpc.transaction_hash);
250    }
251
252    #[rstest]
253    fn test_rejects_uniswap_v3_length_data() {
254        // Truncate the data to the Uniswap V3 5-word layout: PancakeSwap V3 requires the two
255        // appended protocolFees words, so the shorter payload must be rejected.
256        let mut value: serde_json::Value = serde_json::from_str(HYPERSYNC_SWAP_LOG).unwrap();
257        let data = value["data"].as_str().unwrap();
258        // 0x + first 5 of the 7 32-byte words.
259        let truncated = data[..2 + 5 * 64].to_string();
260        value["data"] = serde_json::Value::String(truncated);
261        let log: HypersyncLog = serde_json::from_value(value).unwrap();
262
263        let dex = Arc::clone(&bsc::PANCAKESWAP_V3.dex);
264        let err = parse_swap_event_hypersync(dex, &log).unwrap_err();
265        assert!(err.to_string().contains("too short"));
266    }
267
268    #[rstest]
269    fn test_rejects_uniswap_v3_swap_topic() {
270        // The Uniswap V3 Swap topic0 must be rejected: PancakeSwap V3 hashes to its own
271        // topic even though the leading data fields overlap.
272        let uniswap_v3_topic = "c42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67";
273        let log_json = HYPERSYNC_SWAP_LOG.replace(SWAP_EVENT_SIGNATURE_HASH, uniswap_v3_topic);
274        let log: HypersyncLog = serde_json::from_str(&log_json).unwrap();
275
276        let dex = Arc::clone(&bsc::PANCAKESWAP_V3.dex);
277        let result = parse_swap_event_hypersync(dex, &log);
278        assert!(result.is_err());
279    }
280}