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        helpers::{
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::helpers as rpc_helpers,
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_helpers::validate_event_signature(log, SWAP_EVENT_SIGNATURE_HASH, "Swap")?;
112
113    let sender = rpc_helpers::extract_address_from_topic(log, 1, "sender")?;
114    let recipient = rpc_helpers::extract_address_from_topic(log, 2, "recipient")?;
115
116    let data_bytes = rpc_helpers::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_helpers::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_helpers::extract_block_number(log)?,
133        rpc_helpers::extract_transaction_hash(log)?,
134        rpc_helpers::extract_transaction_index(log)?,
135        rpc_helpers::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 alloy::primitives::{I256, U160, U256};
149    use rstest::*;
150
151    use super::*;
152    use crate::exchanges::bsc;
153
154    // Real PancakeSwap V3 Swap from BSC block 105495649 (WBNB/USDT 0.01% pool
155    // 0x172fcd41e0913e95784454622d1c3724f546f849)
156    const HYPERSYNC_SWAP_LOG: &str =
157        include_str!("../../../../test_data/pancakeswap_v3_swap_hypersync.json");
158    const RPC_SWAP_LOG: &str = include_str!("../../../../test_data/pancakeswap_v3_swap_rpc.json");
159
160    #[fixture]
161    fn hypersync_log() -> HypersyncLog {
162        serde_json::from_str(HYPERSYNC_SWAP_LOG).expect("Failed to deserialize HyperSync log")
163    }
164
165    #[fixture]
166    fn rpc_log() -> RpcLog {
167        serde_json::from_str(RPC_SWAP_LOG).expect("Failed to deserialize RPC log")
168    }
169
170    #[rstest]
171    fn test_parse_swap_event_hypersync(hypersync_log: HypersyncLog) {
172        let dex = bsc::PANCAKESWAP_V3.dex.clone();
173        let event = parse_swap_event_hypersync(dex, &hypersync_log).unwrap();
174
175        assert_eq!(
176            event.pool_identifier.to_string(),
177            "0x172fcD41E0913e95784454622d1c3724f546f849"
178        );
179        assert_eq!(
180            event.sender.to_string().to_lowercase(),
181            "0x7eded5ce04fd9bb6d125a0a470cc3ffcd972e182"
182        );
183        assert_eq!(
184            event.receiver.to_string().to_lowercase(),
185            "0x7eded5ce04fd9bb6d125a0a470cc3ffcd972e182"
186        );
187        assert_eq!(
188            event.amount0,
189            I256::try_from(2291588381489685660_i128).unwrap()
190        );
191        let expected_amount1 = I256::from_raw(
192            U256::from_str_radix(
193                "fffffffffffffffffffffffffffffffffffffffffffffffffff22743d8dee163",
194                16,
195            )
196            .unwrap(),
197        );
198        assert_eq!(event.amount1, expected_amount1);
199        let expected_sqrt_price = U160::from_str_radix("a8edeae49c411da42257f71", 16).unwrap();
200        assert_eq!(event.sqrt_price_x96, expected_sqrt_price);
201        let expected_liquidity = u128::from_str_radix("310fdcabce7b0096dfc84", 16).unwrap();
202        assert_eq!(event.liquidity, expected_liquidity);
203        assert_eq!(event.tick, -63769);
204        assert_eq!(event.block_number, 105495649);
205    }
206
207    #[rstest]
208    fn test_parse_swap_event_rpc(rpc_log: RpcLog) {
209        let dex = bsc::PANCAKESWAP_V3.dex.clone();
210        let event = parse_swap_event_rpc(dex, &rpc_log).unwrap();
211
212        assert_eq!(
213            event.pool_identifier.to_string(),
214            "0x172fcD41E0913e95784454622d1c3724f546f849"
215        );
216        assert_eq!(
217            event.amount0,
218            I256::try_from(2291588381489685660_i128).unwrap()
219        );
220        let expected_amount1 = I256::from_raw(
221            U256::from_str_radix(
222                "fffffffffffffffffffffffffffffffffffffffffffffffffff22743d8dee163",
223                16,
224            )
225            .unwrap(),
226        );
227        assert_eq!(event.amount1, expected_amount1);
228        assert_eq!(event.tick, -63769);
229        assert_eq!(event.block_number, 105495649);
230    }
231
232    #[rstest]
233    fn test_hypersync_rpc_match(hypersync_log: HypersyncLog, rpc_log: RpcLog) {
234        let dex = bsc::PANCAKESWAP_V3.dex.clone();
235        let event_hypersync = parse_swap_event_hypersync(dex.clone(), &hypersync_log).unwrap();
236        let event_rpc = parse_swap_event_rpc(dex, &rpc_log).unwrap();
237
238        assert_eq!(event_hypersync.pool_identifier, event_rpc.pool_identifier);
239        assert_eq!(event_hypersync.sender, event_rpc.sender);
240        assert_eq!(event_hypersync.receiver, event_rpc.receiver);
241        assert_eq!(event_hypersync.amount0, event_rpc.amount0);
242        assert_eq!(event_hypersync.amount1, event_rpc.amount1);
243        assert_eq!(event_hypersync.sqrt_price_x96, event_rpc.sqrt_price_x96);
244        assert_eq!(event_hypersync.liquidity, event_rpc.liquidity);
245        assert_eq!(event_hypersync.tick, event_rpc.tick);
246        assert_eq!(event_hypersync.block_number, event_rpc.block_number);
247        assert_eq!(event_hypersync.transaction_hash, event_rpc.transaction_hash);
248    }
249
250    #[rstest]
251    fn test_rejects_uniswap_v3_length_data() {
252        // Truncate the data to the Uniswap V3 5-word layout: PancakeSwap V3 requires the two
253        // appended protocolFees words, so the shorter payload must be rejected.
254        let mut value: serde_json::Value = serde_json::from_str(HYPERSYNC_SWAP_LOG).unwrap();
255        let data = value["data"].as_str().unwrap();
256        // 0x + first 5 of the 7 32-byte words.
257        let truncated = data[..2 + 5 * 64].to_string();
258        value["data"] = serde_json::Value::String(truncated);
259        let log: HypersyncLog = serde_json::from_value(value).unwrap();
260
261        let dex = bsc::PANCAKESWAP_V3.dex.clone();
262        let err = parse_swap_event_hypersync(dex, &log).unwrap_err();
263        assert!(err.to_string().contains("too short"));
264    }
265
266    #[rstest]
267    fn test_rejects_uniswap_v3_swap_topic() {
268        // The Uniswap V3 Swap topic0 must be rejected: PancakeSwap V3 hashes to its own
269        // topic even though the leading data fields overlap.
270        let uniswap_v3_topic = "c42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67";
271        let log_json = HYPERSYNC_SWAP_LOG.replace(SWAP_EVENT_SIGNATURE_HASH, uniswap_v3_topic);
272        let log: HypersyncLog = serde_json::from_str(&log_json).unwrap();
273
274        let dex = bsc::PANCAKESWAP_V3.dex.clone();
275        let result = parse_swap_event_hypersync(dex, &log);
276        assert!(result.is_err());
277    }
278}