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    Ok(Address::from_slice(&bytes))
112}
113
114/// Extract topic bytes at index.
115///
116/// # Errors
117///
118/// Returns an error if the topic at the specified index is missing.
119pub fn extract_topic_bytes(log: &RpcLog, index: usize) -> anyhow::Result<Vec<u8>> {
120    let hex = log
121        .topics
122        .get(index)
123        .ok_or_else(|| anyhow::anyhow!("Missing topic at index {index}"))?;
124    decode_hex(hex)
125}
126
127/// Extract address from topic at index.
128///
129/// In Ethereum event logs, indexed address parameters are stored as 32-byte
130/// values with the 20-byte address right-aligned (padded with zeros on the left).
131///
132/// # Errors
133///
134/// Returns an error if the topic is missing or the address extraction fails.
135pub fn extract_address_from_topic(
136    log: &RpcLog,
137    index: usize,
138    description: &str,
139) -> anyhow::Result<Address> {
140    let bytes = extract_topic_bytes(log, index)
141        .map_err(|_| anyhow::anyhow!("Missing {description} address in topic{index}"))?;
142    anyhow::ensure!(
143        bytes.len() >= 32,
144        "Topic must be at least 32 bytes, was {}",
145        bytes.len()
146    );
147    Ok(Address::from_slice(&bytes[12..32]))
148}
149
150/// Extract data bytes from RPC log.
151///
152/// # Errors
153///
154/// Returns an error if the hex decoding fails.
155pub fn extract_data_bytes(log: &RpcLog) -> anyhow::Result<Vec<u8>> {
156    decode_hex(&log.data)
157}
158
159/// Validate event signature from topic0.
160///
161/// The first topic (topic0) of an Ethereum event log contains the keccak256 hash
162/// of the event signature. This function validates that the actual signature
163/// matches the expected one.
164///
165/// # Errors
166///
167/// Returns an error if the signature doesn't match or topic0 is missing.
168pub fn validate_event_signature(
169    log: &RpcLog,
170    expected_hash: &str,
171    event_name: &str,
172) -> anyhow::Result<()> {
173    let sig_bytes = extract_topic_bytes(log, 0)?;
174    let actual_hex = hex::encode(&sig_bytes);
175    anyhow::ensure!(
176        actual_hex == expected_hash,
177        "Invalid event signature for '{event_name}': expected {expected_hash}, was {actual_hex}",
178    );
179    Ok(())
180}
181
182#[cfg(test)]
183mod tests {
184    use rstest::{fixture, rstest};
185
186    use super::*;
187
188    /// Real RPC log from Arbitrum PoolCreated event at block 185
189    /// Pool: 0xB9Fc136980D98C034a529AadbD5651c087365D5f
190    /// token0: 0x2E5353426C89F4eCD52D1036DA822D47E73376C4
191    /// token1: 0x838930cFE7502dd36B0b1ebbef8001fbF94f3bFb
192    /// fee: 3000, tickSpacing: 60
193    #[fixture]
194    fn log() -> RpcLog {
195        RpcLog {
196            removed: false,
197            log_index: Some("0x0".to_string()),
198            transaction_index: Some("0x0".to_string()),
199            transaction_hash: Some(
200                "0x24058dde7caf5b8b70041de8b27731f20f927365f210247c3e720e947b9098e7".to_string(),
201            ),
202            block_hash: Some(
203                "0xd371b6c7b04ec33d6470f067a82e87d7b294b952bea7a46d7b939b4c7addc275".to_string(),
204            ),
205            block_number: Some("0xb9".to_string()),
206            address: "0x1f98431c8ad98523631ae4a59f267346ea31f984".to_string(),
207            data: "0x000000000000000000000000000000000000000000000000000000000000003c000000000000000000000000b9fc136980d98c034a529aadbd5651c087365d5f".to_string(),
208            topics: vec![
209                "0x783cca1c0412dd0d695e784568c96da2e9c22ff989357a2e8b1d9b2b4e6b7118".to_string(),
210                "0x0000000000000000000000002e5353426c89f4ecd52d1036da822d47e73376c4".to_string(),
211                "0x000000000000000000000000838930cfe7502dd36b0b1ebbef8001fbf94f3bfb".to_string(),
212                "0x0000000000000000000000000000000000000000000000000000000000000bb8".to_string(),
213            ],
214        }
215    }
216
217    #[rstest]
218    fn test_decode_hex_with_prefix() {
219        let result = decode_hex("0x1234").unwrap();
220        assert_eq!(result, vec![0x12, 0x34]);
221    }
222
223    #[rstest]
224    fn test_decode_hex_without_prefix() {
225        let result = decode_hex("1234").unwrap();
226        assert_eq!(result, vec![0x12, 0x34]);
227    }
228
229    #[rstest]
230    fn test_parse_hex_u64_block_185() {
231        // Block 185 = 0xb9
232        assert_eq!(parse_hex_u64("0xb9").unwrap(), 185);
233        assert_eq!(parse_hex_u64("b9").unwrap(), 185);
234    }
235
236    #[rstest]
237    fn test_parse_hex_u32() {
238        assert_eq!(parse_hex_u32("0x0").unwrap(), 0);
239        assert_eq!(parse_hex_u32("0xbb8").unwrap(), 3000); // fee from block 185
240    }
241
242    #[rstest]
243    fn test_extract_block_number(log: RpcLog) {
244        assert_eq!(extract_block_number(&log).unwrap(), 185);
245    }
246
247    #[rstest]
248    fn test_extract_transaction_hash(log: RpcLog) {
249        assert_eq!(
250            extract_transaction_hash(&log).unwrap(),
251            "0x24058dde7caf5b8b70041de8b27731f20f927365f210247c3e720e947b9098e7"
252        );
253    }
254
255    #[rstest]
256    fn test_extract_transaction_index(log: RpcLog) {
257        assert_eq!(extract_transaction_index(&log).unwrap(), 0);
258    }
259
260    #[rstest]
261    fn test_extract_log_index(log: RpcLog) {
262        assert_eq!(extract_log_index(&log).unwrap(), 0);
263    }
264
265    #[rstest]
266    fn test_extract_address(log: RpcLog) {
267        let address = extract_address(&log).unwrap();
268        // Uniswap V3 Factory address on Arbitrum
269        assert_eq!(
270            address.to_string().to_lowercase(),
271            "0x1f98431c8ad98523631ae4a59f267346ea31f984"
272        );
273    }
274
275    #[rstest]
276    fn test_extract_address_from_topic_token0(log: RpcLog) {
277        let address = extract_address_from_topic(&log, 1, "token0").unwrap();
278        assert_eq!(
279            address.to_string().to_lowercase(),
280            "0x2e5353426c89f4ecd52d1036da822d47e73376c4"
281        );
282    }
283
284    #[rstest]
285    fn test_extract_address_from_topic_token1(log: RpcLog) {
286        let address = extract_address_from_topic(&log, 2, "token1").unwrap();
287        assert_eq!(
288            address.to_string().to_lowercase(),
289            "0x838930cfe7502dd36b0b1ebbef8001fbf94f3bfb"
290        );
291    }
292
293    #[rstest]
294    fn test_extract_data_bytes(log: RpcLog) {
295        let data = extract_data_bytes(&log).unwrap();
296        // Data contains tickSpacing (60 = 0x3c) and pool address
297        assert_eq!(data.len(), 64); // 2 x 32 bytes
298        // First 32 bytes: tickSpacing = 60 (0x3c)
299        assert_eq!(data[31], 0x3c);
300    }
301
302    #[rstest]
303    fn test_validate_event_signature_pool_created(log: RpcLog) {
304        let expected = "783cca1c0412dd0d695e784568c96da2e9c22ff989357a2e8b1d9b2b4e6b7118";
305        assert!(validate_event_signature(&log, expected, "PoolCreated").is_ok());
306    }
307
308    #[rstest]
309    fn test_validate_event_signature_mismatch(log: RpcLog) {
310        // Swap event signature instead of PoolCreated
311        let wrong = "c42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67";
312        let result = validate_event_signature(&log, wrong, "Swap");
313        assert!(result.is_err());
314        assert!(
315            result
316                .unwrap_err()
317                .to_string()
318                .contains("Invalid event signature")
319        );
320    }
321}