Skip to main content

nautilus_blockchain/rpc/
helpers.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
16//! Helper functions for parsing RPC log entries.
17//!
18//! These functions work with `RpcLog` from the standard Ethereum JSON-RPC format,
19//! converting hex strings to the appropriate types.
20
21use alloy::primitives::Address;
22use nautilus_core::hex;
23use nautilus_model::defi::rpc::RpcLog;
24
25/// Decode hex string (with or without 0x prefix) to bytes.
26///
27/// # Errors
28///
29/// Returns an error if the hex string is invalid.
30pub fn decode_hex(hex: &str) -> anyhow::Result<Vec<u8>> {
31    hex::decode(hex.trim_start_matches("0x")).map_err(|e| anyhow::anyhow!("Invalid hex: {e}"))
32}
33
34/// Parse hex string to u64.
35///
36/// # Errors
37///
38/// Returns an error if the hex string cannot be parsed as u64.
39pub fn parse_hex_u64(hex: &str) -> anyhow::Result<u64> {
40    u64::from_str_radix(hex.trim_start_matches("0x"), 16)
41        .map_err(|e| anyhow::anyhow!("Invalid hex u64: {e}"))
42}
43
44/// Parse hex string to u32.
45///
46/// # Errors
47///
48/// Returns an error if the hex string cannot be parsed as u32.
49pub fn parse_hex_u32(hex: &str) -> anyhow::Result<u32> {
50    u32::from_str_radix(hex.trim_start_matches("0x"), 16)
51        .map_err(|e| anyhow::anyhow!("Invalid hex u32: {e}"))
52}
53
54/// Extract block number from RPC log.
55///
56/// # Errors
57///
58/// Returns an error if the block number is missing or cannot be parsed.
59pub fn extract_block_number(log: &RpcLog) -> anyhow::Result<u64> {
60    let hex = log
61        .block_number
62        .as_ref()
63        .ok_or_else(|| anyhow::anyhow!("Missing block number"))?;
64    parse_hex_u64(hex)
65}
66
67/// Extract transaction hash from RPC log.
68///
69/// # Errors
70///
71/// Returns an error if the transaction hash is missing.
72pub fn extract_transaction_hash(log: &RpcLog) -> anyhow::Result<String> {
73    log.transaction_hash
74        .clone()
75        .ok_or_else(|| anyhow::anyhow!("Missing transaction hash"))
76}
77
78/// Extract transaction index from RPC log.
79///
80/// # Errors
81///
82/// Returns an error if the transaction index is missing or cannot be parsed.
83pub fn extract_transaction_index(log: &RpcLog) -> anyhow::Result<u32> {
84    let hex = log
85        .transaction_index
86        .as_ref()
87        .ok_or_else(|| anyhow::anyhow!("Missing transaction index"))?;
88    parse_hex_u32(hex)
89}
90
91/// Extract log index from RPC log.
92///
93/// # Errors
94///
95/// Returns an error if the log index is missing or cannot be parsed.
96pub fn extract_log_index(log: &RpcLog) -> anyhow::Result<u32> {
97    let hex = log
98        .log_index
99        .as_ref()
100        .ok_or_else(|| anyhow::anyhow!("Missing log index"))?;
101    parse_hex_u32(hex)
102}
103
104/// Extract contract address from RPC log.
105///
106/// # Errors
107///
108/// Returns an error if the address is invalid.
109pub fn extract_address(log: &RpcLog) -> anyhow::Result<Address> {
110    let bytes = decode_hex(&log.address)?;
111    anyhow::ensure!(
112        bytes.len() == Address::len_bytes(),
113        "Invalid contract address length: expected {} bytes, was {}",
114        Address::len_bytes(),
115        bytes.len()
116    );
117    Ok(Address::from_slice(&bytes))
118}
119
120/// Extract topic bytes at index.
121///
122/// # Errors
123///
124/// Returns an error if the topic at the specified index is missing.
125pub fn extract_topic_bytes(log: &RpcLog, index: usize) -> anyhow::Result<Vec<u8>> {
126    let hex = log
127        .topics
128        .get(index)
129        .ok_or_else(|| anyhow::anyhow!("Missing topic at index {index}"))?;
130    decode_hex(hex)
131}
132
133/// Extract address from topic at index.
134///
135/// In Ethereum event logs, indexed address parameters are stored as 32-byte
136/// values with the 20-byte address right-aligned (padded with zeros on the left).
137///
138/// # Errors
139///
140/// Returns an error if the topic is missing or the address extraction fails.
141pub fn extract_address_from_topic(
142    log: &RpcLog,
143    index: usize,
144    description: &str,
145) -> anyhow::Result<Address> {
146    let bytes = extract_topic_bytes(log, index)
147        .map_err(|_| anyhow::anyhow!("Missing {description} address in topic{index}"))?;
148    anyhow::ensure!(
149        bytes.len() >= 32,
150        "Topic must be at least 32 bytes, was {}",
151        bytes.len()
152    );
153    Ok(Address::from_slice(&bytes[12..32]))
154}
155
156/// Extract data bytes from RPC log.
157///
158/// # Errors
159///
160/// Returns an error if the hex decoding fails.
161pub fn extract_data_bytes(log: &RpcLog) -> anyhow::Result<Vec<u8>> {
162    decode_hex(&log.data)
163}
164
165/// Validate event signature from topic0.
166///
167/// The first topic (topic0) of an Ethereum event log contains the keccak256 hash
168/// of the event signature. This function validates that the actual signature
169/// matches the expected one.
170///
171/// # Errors
172///
173/// Returns an error if the signature doesn't match or topic0 is missing.
174pub fn validate_event_signature(
175    log: &RpcLog,
176    expected_hash: &str,
177    event_name: &str,
178) -> anyhow::Result<()> {
179    let sig_bytes = extract_topic_bytes(log, 0)?;
180    let actual_hex = hex::encode(&sig_bytes);
181    anyhow::ensure!(
182        actual_hex == expected_hash,
183        "Invalid event signature for '{event_name}': expected {expected_hash}, was {actual_hex}",
184    );
185    Ok(())
186}
187
188#[cfg(test)]
189mod tests {
190    use rstest::{fixture, rstest};
191
192    use super::*;
193
194    /// Real RPC log from Arbitrum PoolCreated event at block 185
195    /// Pool: 0xB9Fc136980D98C034a529AadbD5651c087365D5f
196    /// token0: 0x2E5353426C89F4eCD52D1036DA822D47E73376C4
197    /// token1: 0x838930cFE7502dd36B0b1ebbef8001fbF94f3bFb
198    /// fee: 3000, tickSpacing: 60
199    #[fixture]
200    fn log() -> RpcLog {
201        RpcLog {
202            removed: false,
203            log_index: Some("0x0".to_string()),
204            transaction_index: Some("0x0".to_string()),
205            transaction_hash: Some(
206                "0x24058dde7caf5b8b70041de8b27731f20f927365f210247c3e720e947b9098e7".to_string(),
207            ),
208            block_hash: Some(
209                "0xd371b6c7b04ec33d6470f067a82e87d7b294b952bea7a46d7b939b4c7addc275".to_string(),
210            ),
211            block_number: Some("0xb9".to_string()),
212            address: "0x1f98431c8ad98523631ae4a59f267346ea31f984".to_string(),
213            data: "0x000000000000000000000000000000000000000000000000000000000000003c000000000000000000000000b9fc136980d98c034a529aadbd5651c087365d5f".to_string(),
214            topics: vec![
215                "0x783cca1c0412dd0d695e784568c96da2e9c22ff989357a2e8b1d9b2b4e6b7118".to_string(),
216                "0x0000000000000000000000002e5353426c89f4ecd52d1036da822d47e73376c4".to_string(),
217                "0x000000000000000000000000838930cfe7502dd36b0b1ebbef8001fbf94f3bfb".to_string(),
218                "0x0000000000000000000000000000000000000000000000000000000000000bb8".to_string(),
219            ],
220        }
221    }
222
223    #[rstest]
224    fn test_decode_hex_with_prefix() {
225        let result = decode_hex("0x1234").unwrap();
226        assert_eq!(result, vec![0x12, 0x34]);
227    }
228
229    #[rstest]
230    fn test_decode_hex_without_prefix() {
231        let result = decode_hex("1234").unwrap();
232        assert_eq!(result, vec![0x12, 0x34]);
233    }
234
235    #[rstest]
236    fn test_parse_hex_u64_block_185() {
237        // Block 185 = 0xb9
238        assert_eq!(parse_hex_u64("0xb9").unwrap(), 185);
239        assert_eq!(parse_hex_u64("b9").unwrap(), 185);
240    }
241
242    #[rstest]
243    fn test_parse_hex_u32() {
244        assert_eq!(parse_hex_u32("0x0").unwrap(), 0);
245        assert_eq!(parse_hex_u32("0xbb8").unwrap(), 3000); // fee from block 185
246    }
247
248    #[rstest]
249    fn test_extract_block_number(log: RpcLog) {
250        assert_eq!(extract_block_number(&log).unwrap(), 185);
251    }
252
253    #[rstest]
254    fn test_extract_transaction_hash(log: RpcLog) {
255        assert_eq!(
256            extract_transaction_hash(&log).unwrap(),
257            "0x24058dde7caf5b8b70041de8b27731f20f927365f210247c3e720e947b9098e7"
258        );
259    }
260
261    #[rstest]
262    fn test_extract_transaction_index(log: RpcLog) {
263        assert_eq!(extract_transaction_index(&log).unwrap(), 0);
264    }
265
266    #[rstest]
267    fn test_extract_log_index(log: RpcLog) {
268        assert_eq!(extract_log_index(&log).unwrap(), 0);
269    }
270
271    #[rstest]
272    fn test_extract_address(log: RpcLog) {
273        let address = extract_address(&log).unwrap();
274        // Uniswap V3 Factory address on Arbitrum
275        assert_eq!(
276            address.to_string().to_lowercase(),
277            "0x1f98431c8ad98523631ae4a59f267346ea31f984"
278        );
279    }
280
281    #[rstest]
282    #[case("0x", 0)]
283    #[case("0x00112233445566778899aabbccddeeff001122", 19)]
284    #[case("0x00112233445566778899aabbccddeeff0011223344", 21)]
285    fn test_extract_address_rejects_wrong_length(
286        mut log: RpcLog,
287        #[case] address: &str,
288        #[case] actual: usize,
289    ) {
290        log.address = address.to_string();
291
292        let error = extract_address(&log).unwrap_err();
293
294        assert_eq!(
295            error.to_string(),
296            format!("Invalid contract address length: expected 20 bytes, was {actual}")
297        );
298    }
299
300    #[rstest]
301    fn test_extract_address_from_topic_token0(log: RpcLog) {
302        let address = extract_address_from_topic(&log, 1, "token0").unwrap();
303        assert_eq!(
304            address.to_string().to_lowercase(),
305            "0x2e5353426c89f4ecd52d1036da822d47e73376c4"
306        );
307    }
308
309    #[rstest]
310    fn test_extract_address_from_topic_token1(log: RpcLog) {
311        let address = extract_address_from_topic(&log, 2, "token1").unwrap();
312        assert_eq!(
313            address.to_string().to_lowercase(),
314            "0x838930cfe7502dd36b0b1ebbef8001fbf94f3bfb"
315        );
316    }
317
318    #[rstest]
319    fn test_extract_data_bytes(log: RpcLog) {
320        let data = extract_data_bytes(&log).unwrap();
321        // Data contains tickSpacing (60 = 0x3c) and pool address
322        assert_eq!(data.len(), 64); // 2 x 32 bytes
323        // First 32 bytes: tickSpacing = 60 (0x3c)
324        assert_eq!(data[31], 0x3c);
325    }
326
327    #[rstest]
328    fn test_validate_event_signature_pool_created(log: RpcLog) {
329        let expected = "783cca1c0412dd0d695e784568c96da2e9c22ff989357a2e8b1d9b2b4e6b7118";
330        assert!(validate_event_signature(&log, expected, "PoolCreated").is_ok());
331    }
332
333    #[rstest]
334    fn test_validate_event_signature_mismatch(log: RpcLog) {
335        // Swap event signature instead of PoolCreated
336        let wrong = "c42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67";
337        let result = validate_event_signature(&log, wrong, "Swap");
338        assert!(result.is_err());
339        assert!(
340            result
341                .unwrap_err()
342                .to_string()
343                .contains("Invalid event signature")
344        );
345    }
346}