nautilus_blockchain/rpc/
helpers.rs

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