Skip to main content

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