nautilus_blockchain/contracts/
erc20.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
16use std::{collections::HashMap, sync::Arc};
17
18use alloy::{
19    primitives::{Address, Bytes, U256},
20    sol,
21    sol_types::SolCall,
22};
23use strum::Display;
24use thiserror::Error;
25
26use super::base::{BaseContract, ContractCall, Multicall3};
27use crate::rpc::{error::BlockchainRpcClientError, http::BlockchainHttpRpcClient};
28
29sol! {
30    #[sol(rpc)]
31    contract ERC20 {
32        function name() external view returns (string);
33        function symbol() external view returns (string);
34        function decimals() external view returns (uint8);
35        function balanceOf(address account) external view returns (uint256);
36    }
37}
38
39#[derive(Debug, Display)]
40pub enum Erc20Field {
41    Name,
42    Symbol,
43    Decimals,
44}
45
46/// Represents the essential metadata information for an ERC20 token.
47#[derive(Debug, Clone)]
48pub struct TokenInfo {
49    /// The full name of the token.
50    pub name: String,
51    /// The ticker symbol of the token.
52    pub symbol: String,
53    /// The number of decimal places the token uses for representing fractional amounts.
54    pub decimals: u8,
55}
56
57/// Represents errors that can occur when interacting with a blockchain RPC client.
58#[derive(Debug, Error)]
59pub enum TokenInfoError {
60    #[error("RPC error: {0}")]
61    RpcError(#[from] BlockchainRpcClientError),
62    #[error("Token {field} is empty for address {address}")]
63    EmptyTokenField { field: Erc20Field, address: Address },
64    #[error("Multicall returned unexpected number of results: expected {expected}, was {actual}")]
65    UnexpectedResultCount { expected: usize, actual: usize },
66    #[error("Call failed for {field} at address {address}: {reason} (raw data: {raw_data})")]
67    CallFailed {
68        field: String,
69        address: Address,
70        reason: String,
71        raw_data: String,
72    },
73    #[error("Failed to decode {field} for address {address}: {reason} (raw data: {raw_data})")]
74    DecodingError {
75        field: String,
76        address: Address,
77        reason: String,
78        raw_data: String,
79    },
80}
81
82/// Interface for interacting with ERC20 token contracts on a blockchain.
83///
84/// This struct provides methods to fetch token metadata (name, symbol, decimals).
85/// From ERC20-compliant tokens on any EVM-compatible blockchain.
86#[derive(Debug)]
87pub struct Erc20Contract {
88    /// The base contract providing common RPC execution functionality.
89    base: BaseContract,
90    /// Whether to enforce that token name and symbol fields must be non-empty.
91    enforce_token_fields: bool,
92}
93
94impl Erc20Contract {
95    /// Creates a new ERC20 contract interface with the specified RPC client.
96    #[must_use]
97    pub fn new(client: Arc<BlockchainHttpRpcClient>, enforce_token_fields: bool) -> Self {
98        Self {
99            base: BaseContract::new(client),
100            enforce_token_fields,
101        }
102    }
103
104    /// Fetches complete token information (name, symbol, decimals) from an ERC20 contract.
105    ///
106    /// # Errors
107    ///
108    /// Returns an error if any of the contract calls fail.
109    /// - [`BlockchainRpcClientError::ClientError`] if an RPC call fails.
110    /// - [`BlockchainRpcClientError::AbiDecodingError`] if ABI decoding fails.
111    pub async fn fetch_token_info(
112        &self,
113        token_address: &Address,
114    ) -> Result<TokenInfo, TokenInfoError> {
115        let calls = vec![
116            ContractCall {
117                target: *token_address,
118                allow_failure: true,
119                call_data: ERC20::nameCall.abi_encode(),
120            },
121            ContractCall {
122                target: *token_address,
123                allow_failure: true,
124                call_data: ERC20::symbolCall.abi_encode(),
125            },
126            ContractCall {
127                target: *token_address,
128                allow_failure: true,
129                call_data: ERC20::decimalsCall.abi_encode(),
130            },
131        ];
132
133        let results = self.base.execute_multicall(calls, None).await?;
134
135        if results.len() != 3 {
136            return Err(TokenInfoError::UnexpectedResultCount {
137                expected: 3,
138                actual: results.len(),
139            });
140        }
141
142        let name = parse_erc20_string_result(&results[0], Erc20Field::Name, token_address)?;
143        let symbol = parse_erc20_string_result(&results[1], Erc20Field::Symbol, token_address)?;
144        let decimals = parse_erc20_decimals_result(&results[2], token_address)?;
145
146        if self.enforce_token_fields && name.is_empty() {
147            return Err(TokenInfoError::EmptyTokenField {
148                field: Erc20Field::Name,
149                address: *token_address,
150            });
151        }
152
153        if self.enforce_token_fields && symbol.is_empty() {
154            return Err(TokenInfoError::EmptyTokenField {
155                field: Erc20Field::Symbol,
156                address: *token_address,
157            });
158        }
159
160        Ok(TokenInfo {
161            name,
162            symbol,
163            decimals,
164        })
165    }
166
167    /// Fetches token information for multiple tokens in a single multicall.
168    ///
169    /// If the multicall fails (typically due to expired/broken contracts causing RPC "out of gas"),
170    /// automatically falls back to individual token fetches to isolate problematic contracts.
171    ///
172    /// # Errors
173    ///
174    /// Returns an error only if the operation cannot proceed. Multicall failures trigger
175    /// automatic fallback to individual fetches. Individual token failures are captured
176    /// in the Result values of the returned `HashMap`.
177    pub async fn batch_fetch_token_info(
178        &self,
179        token_addresses: &[Address],
180    ) -> Result<HashMap<Address, Result<TokenInfo, TokenInfoError>>, BlockchainRpcClientError> {
181        // Build calls for all tokens (3 calls per token)
182        let mut calls = Vec::with_capacity(token_addresses.len() * 3);
183
184        for token_address in token_addresses {
185            calls.extend([
186                ContractCall {
187                    target: *token_address,
188                    allow_failure: true, // Allow individual token failures
189                    call_data: ERC20::nameCall.abi_encode(),
190                },
191                ContractCall {
192                    target: *token_address,
193                    allow_failure: true,
194                    call_data: ERC20::symbolCall.abi_encode(),
195                },
196                ContractCall {
197                    target: *token_address,
198                    allow_failure: true,
199                    call_data: ERC20::decimalsCall.abi_encode(),
200                },
201            ]);
202        }
203
204        // Try batch multicall first
205        let results = match self.base.execute_multicall(calls, None).await {
206            Ok(results) => results,
207            Err(e) => {
208                // Multicall failed (likely expired/broken contract causing RPC failure)
209                tracing::warn!(
210                    "Batch multicall failed: {}. Falling back to individual fetches for {} tokens",
211                    e,
212                    token_addresses.len()
213                );
214
215                // Fallback: fetch each token individually to isolate problematic contracts
216                let mut token_infos = HashMap::with_capacity(token_addresses.len());
217                for token_address in token_addresses {
218                    match self.fetch_token_info(token_address).await {
219                        Ok(info) => {
220                            token_infos.insert(*token_address, Ok(info));
221                        }
222                        Err(e) => {
223                            tracing::debug!(
224                                "Token {} failed individual fetch (likely expired/broken): {}",
225                                token_address,
226                                e
227                            );
228                            token_infos.insert(*token_address, Err(e));
229                        }
230                    }
231                }
232
233                return Ok(token_infos);
234            }
235        };
236
237        let mut token_infos = HashMap::with_capacity(token_addresses.len());
238        for (i, token_address) in token_addresses.iter().enumerate() {
239            let base_idx = i * 3;
240
241            // Check if we have all 3 results for this token.
242            if base_idx + 2 >= results.len() {
243                tracing::error!("Incomplete results from multicall for token {token_address}");
244                token_infos.insert(
245                    *token_address,
246                    Err(TokenInfoError::UnexpectedResultCount {
247                        expected: 3,
248                        actual: results.len().saturating_sub(base_idx),
249                    }),
250                );
251                continue;
252            }
253
254            let token_info =
255                parse_batch_token_results(&results[base_idx..base_idx + 3], token_address);
256            token_infos.insert(*token_address, token_info);
257        }
258
259        Ok(token_infos)
260    }
261
262    /// Fetches the balance of a specific account for this ERC20 token.
263    ///
264    /// # Errors
265    ///
266    /// Returns an error if the contract call fails.
267    /// - [`BlockchainRpcClientError::ClientError`] if an RPC call fails.
268    /// - [`BlockchainRpcClientError::AbiDecodingError`] if ABI decoding fails.
269    pub async fn balance_of(
270        &self,
271        token_address: &Address,
272        account: &Address,
273    ) -> Result<U256, BlockchainRpcClientError> {
274        let call_data = ERC20::balanceOfCall { account: *account }.abi_encode();
275        let result = self
276            .base
277            .execute_call(token_address, &call_data, None)
278            .await?;
279
280        ERC20::balanceOfCall::abi_decode_returns(&result)
281            .map_err(|e| BlockchainRpcClientError::AbiDecodingError(e.to_string()))
282    }
283}
284
285/// Attempts to decode a revert reason from failed call data.
286/// Returns a human-readable error message.
287fn decode_revert_reason(data: &Bytes) -> String {
288    // For now, just return a simple description
289    // Could be enhanced to decode actual revert reasons in the future
290    if data.is_empty() {
291        "Call failed without revert data".to_string()
292    } else {
293        format!("Call failed with data: {data}")
294    }
295}
296
297/// Generic parser for ERC20 string results (name, symbol)
298fn parse_erc20_string_result(
299    result: &Multicall3::Result,
300    field_name: Erc20Field,
301    token_address: &Address,
302) -> Result<String, TokenInfoError> {
303    // Common validation
304    if !result.success {
305        let reason = if result.returnData.is_empty() {
306            "Call failed without revert data".to_string()
307        } else {
308            // Try to decode revert reason if present
309            decode_revert_reason(&result.returnData)
310        };
311
312        return Err(TokenInfoError::CallFailed {
313            field: field_name.to_string(),
314            address: *token_address,
315            reason,
316            raw_data: result.returnData.to_string(),
317        });
318    }
319
320    if result.returnData.is_empty() {
321        return Err(TokenInfoError::EmptyTokenField {
322            field: field_name,
323            address: *token_address,
324        });
325    }
326
327    match field_name {
328        Erc20Field::Name => ERC20::nameCall::abi_decode_returns(&result.returnData),
329        Erc20Field::Symbol => ERC20::symbolCall::abi_decode_returns(&result.returnData),
330        Erc20Field::Decimals => {
331            return Err(TokenInfoError::DecodingError {
332                field: field_name.to_string(),
333                address: *token_address,
334                reason: "Expected Name or Symbol for parse_erc20_string_result function argument"
335                    .to_string(),
336                raw_data: result.returnData.to_string(),
337            });
338        }
339    }
340    .map_err(|e| TokenInfoError::DecodingError {
341        field: field_name.to_string(),
342        address: *token_address,
343        reason: e.to_string(),
344        raw_data: result.returnData.to_string(),
345    })
346}
347
348/// Generic parser for ERC20 decimals result
349fn parse_erc20_decimals_result(
350    result: &Multicall3::Result,
351    token_address: &Address,
352) -> Result<u8, TokenInfoError> {
353    // Common validation
354    if !result.success {
355        let reason = if result.returnData.is_empty() {
356            "Call failed without revert data".to_string()
357        } else {
358            decode_revert_reason(&result.returnData)
359        };
360
361        return Err(TokenInfoError::CallFailed {
362            field: "decimals".to_string(),
363            address: *token_address,
364            reason,
365            raw_data: result.returnData.to_string(),
366        });
367    }
368
369    if result.returnData.is_empty() {
370        return Err(TokenInfoError::EmptyTokenField {
371            field: Erc20Field::Decimals,
372            address: *token_address,
373        });
374    }
375
376    ERC20::decimalsCall::abi_decode_returns(&result.returnData).map_err(|e| {
377        TokenInfoError::DecodingError {
378            field: "decimals".to_string(),
379            address: *token_address,
380            reason: e.to_string(),
381            raw_data: result.returnData.to_string(),
382        }
383    })
384}
385
386/// Parses token information from a slice of 3 multicall results.
387///
388/// Expects results in order: name, symbol, decimals.
389/// Returns Ok(TokenInfo) if all three calls succeeded, or an Err with a
390/// descriptive error message if any call failed.
391fn parse_batch_token_results(
392    results: &[Multicall3::Result],
393    token_address: &Address,
394) -> Result<TokenInfo, TokenInfoError> {
395    if results.len() != 3 {
396        return Err(TokenInfoError::UnexpectedResultCount {
397            expected: 3,
398            actual: results.len(),
399        });
400    }
401
402    let name = parse_erc20_string_result(&results[0], Erc20Field::Name, token_address)?;
403    let symbol = parse_erc20_string_result(&results[1], Erc20Field::Symbol, token_address)?;
404    let decimals = parse_erc20_decimals_result(&results[2], token_address)?;
405
406    Ok(TokenInfo {
407        name,
408        symbol,
409        decimals,
410    })
411}
412
413#[cfg(test)]
414mod tests {
415    use alloy::primitives::{Bytes, address};
416    use rstest::{fixture, rstest};
417
418    use super::*;
419
420    #[fixture]
421    fn token_address() -> Address {
422        address!("25b76A90E389bD644a29db919b136Dc63B174Ec7")
423    }
424
425    #[fixture]
426    fn successful_name_result() -> Multicall3::Result {
427        Multicall3::Result {
428            success: true,
429            returnData: Bytes::from(hex::decode("00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000007546f6b656e204100000000000000000000000000000000000000000000000000").unwrap()),
430        }
431    }
432
433    #[fixture]
434    fn successful_symbol_result() -> Multicall3::Result {
435        Multicall3::Result {
436            success: true,
437            returnData: Bytes::from(hex::decode("0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000776546f6b656e4100000000000000000000000000000000000000000000000000").unwrap()),
438        }
439    }
440
441    #[fixture]
442    fn failed_name_result() -> Multicall3::Result {
443        Multicall3::Result {
444            success: false,
445            returnData: Bytes::from(vec![]),
446        }
447    }
448
449    #[fixture]
450    fn failed_token_address() -> Address {
451        address!("00000000049084A92F8964B76845ab6DE54EB229")
452    }
453
454    #[fixture]
455    fn success_but_empty_result() -> Multicall3::Result {
456        Multicall3::Result {
457            success: true,
458            returnData: Bytes::from(vec![]),
459        }
460    }
461
462    #[fixture]
463    fn empty_token_address() -> Address {
464        address!("a5b00cEc63694319495d605AA414203F9714F47E")
465    }
466
467    #[fixture]
468    fn non_abi_encoded_string_result() -> Multicall3::Result {
469        // Returns raw string bytes without ABI encoding - "Rico" as raw bytes
470        Multicall3::Result {
471            success: true,
472            returnData: Bytes::from(
473                hex::decode("5269636f00000000000000000000000000000000000000000000000000000000")
474                    .unwrap(),
475            ),
476        }
477    }
478
479    #[fixture]
480    fn non_abi_encoded_token_address() -> Address {
481        address!("5374EcC160A4bd68446B43B5A6B132F9c001C54C")
482    }
483
484    #[fixture]
485    fn non_standard_selector_result() -> Multicall3::Result {
486        // Returns function selector instead of actual data
487        Multicall3::Result {
488            success: true,
489            returnData: Bytes::from(
490                hex::decode("06fdde0300000000000000000000000000000000000000000000000000000000")
491                    .unwrap(),
492            ),
493        }
494    }
495
496    #[fixture]
497    fn non_abi_encoded_long_string_result() -> Multicall3::Result {
498        // Returns raw string bytes without ABI encoding - longer string example
499        Multicall3::Result {
500            success: true,
501            returnData: Bytes::from(
502                hex::decode("5269636f62616e6b205269736b20536861726500000000000000000000000000")
503                    .unwrap(),
504            ),
505        }
506    }
507
508    #[rstest]
509    fn test_parse_erc20_string_result_name_success(
510        successful_name_result: Multicall3::Result,
511        token_address: Address,
512    ) {
513        let result =
514            parse_erc20_string_result(&successful_name_result, Erc20Field::Name, &token_address);
515        assert!(result.is_ok());
516        assert_eq!(result.unwrap(), "Token A");
517    }
518
519    #[rstest]
520    fn test_parse_erc20_string_result_symbol_success(
521        successful_symbol_result: Multicall3::Result,
522        token_address: Address,
523    ) {
524        let result = parse_erc20_string_result(
525            &successful_symbol_result,
526            Erc20Field::Symbol,
527            &token_address,
528        );
529        assert!(result.is_ok());
530        assert_eq!(result.unwrap(), "vTokenA");
531    }
532
533    #[rstest]
534    fn test_parse_erc20_string_result_name_failed_with_specific_address(
535        failed_name_result: Multicall3::Result,
536        failed_token_address: Address,
537    ) {
538        let result =
539            parse_erc20_string_result(&failed_name_result, Erc20Field::Name, &failed_token_address);
540        assert!(result.is_err());
541        match result.unwrap_err() {
542            TokenInfoError::CallFailed {
543                field,
544                address,
545                reason,
546                raw_data: _,
547            } => {
548                assert_eq!(field, "Name");
549                assert_eq!(address, failed_token_address);
550                assert_eq!(reason, "Call failed without revert data");
551            }
552            _ => panic!("Expected DecodingError"),
553        }
554    }
555
556    #[rstest]
557    fn test_parse_erc20_string_result_success_but_empty_name(
558        success_but_empty_result: Multicall3::Result,
559        empty_token_address: Address,
560    ) {
561        let result = parse_erc20_string_result(
562            &success_but_empty_result,
563            Erc20Field::Name,
564            &empty_token_address,
565        );
566        assert!(result.is_err());
567        match result.unwrap_err() {
568            TokenInfoError::EmptyTokenField { field, address } => {
569                assert!(matches!(field, Erc20Field::Name));
570                assert_eq!(address, empty_token_address);
571            }
572            _ => panic!("Expected EmptyTokenField error"),
573        }
574    }
575
576    #[rstest]
577    fn test_parse_erc20_decimals_result_success_but_empty(
578        success_but_empty_result: Multicall3::Result,
579        empty_token_address: Address,
580    ) {
581        let result = parse_erc20_decimals_result(&success_but_empty_result, &empty_token_address);
582        assert!(result.is_err());
583        match result.unwrap_err() {
584            TokenInfoError::EmptyTokenField { field, address } => {
585                assert!(matches!(field, Erc20Field::Decimals));
586                assert_eq!(address, empty_token_address);
587            }
588            _ => panic!("Expected EmptyTokenField error"),
589        }
590    }
591
592    #[rstest]
593    fn test_parse_non_abi_encoded_string(
594        non_abi_encoded_string_result: Multicall3::Result,
595        non_abi_encoded_token_address: Address,
596    ) {
597        let result = parse_erc20_string_result(
598            &non_abi_encoded_string_result,
599            Erc20Field::Name,
600            &non_abi_encoded_token_address,
601        );
602        assert!(result.is_err());
603        match result.unwrap_err() {
604            TokenInfoError::DecodingError {
605                field,
606                address,
607                reason,
608                raw_data,
609            } => {
610                assert_eq!(field, "Name");
611                assert_eq!(address, non_abi_encoded_token_address);
612                assert!(reason.contains("type check failed"));
613                assert_eq!(
614                    raw_data,
615                    "0x5269636f00000000000000000000000000000000000000000000000000000000"
616                );
617                // Raw bytes "Rico" without ABI encoding
618            }
619            _ => panic!("Expected DecodingError"),
620        }
621    }
622
623    #[rstest]
624    fn test_parse_non_standard_selector_return(
625        non_standard_selector_result: Multicall3::Result,
626        token_address: Address,
627    ) {
628        let result = parse_erc20_string_result(
629            &non_standard_selector_result,
630            Erc20Field::Name,
631            &token_address,
632        );
633        assert!(result.is_err());
634        match result.unwrap_err() {
635            TokenInfoError::DecodingError {
636                field,
637                address,
638                reason,
639                raw_data,
640            } => {
641                assert_eq!(field, "Name");
642                assert_eq!(address, token_address);
643                assert!(reason.contains("type check failed"));
644                assert_eq!(
645                    raw_data,
646                    "0x06fdde0300000000000000000000000000000000000000000000000000000000"
647                );
648            }
649            _ => panic!("Expected DecodingError"),
650        }
651    }
652
653    #[rstest]
654    fn test_parse_non_abi_encoded_long_string(
655        non_abi_encoded_long_string_result: Multicall3::Result,
656        token_address: Address,
657    ) {
658        let result = parse_erc20_string_result(
659            &non_abi_encoded_long_string_result,
660            Erc20Field::Name,
661            &token_address,
662        );
663        assert!(result.is_err());
664        match result.unwrap_err() {
665            TokenInfoError::DecodingError {
666                field,
667                address,
668                reason,
669                raw_data,
670            } => {
671                assert_eq!(field, "Name");
672                assert_eq!(address, token_address);
673                assert!(reason.contains("type check failed"));
674                assert_eq!(
675                    raw_data,
676                    "0x5269636f62616e6b205269736b20536861726500000000000000000000000000"
677                );
678                // Example of longer non-ABI encoded string
679            }
680            _ => panic!("Expected DecodingError"),
681        }
682    }
683}