Skip to main content

nautilus_blockchain/contracts/
erc20.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
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                log::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                            log::debug!(
224                                "Token {token_address} failed individual fetch (likely expired/broken): {e}"
225                            );
226                            token_infos.insert(*token_address, Err(e));
227                        }
228                    }
229                }
230
231                return Ok(token_infos);
232            }
233        };
234
235        let mut token_infos = HashMap::with_capacity(token_addresses.len());
236        for (i, token_address) in token_addresses.iter().enumerate() {
237            let base_idx = i * 3;
238
239            // Check if we have all 3 results for this token.
240            if base_idx + 2 >= results.len() {
241                log::error!("Incomplete results from multicall for token {token_address}");
242                token_infos.insert(
243                    *token_address,
244                    Err(TokenInfoError::UnexpectedResultCount {
245                        expected: 3,
246                        actual: results.len().saturating_sub(base_idx),
247                    }),
248                );
249                continue;
250            }
251
252            let token_info =
253                parse_batch_token_results(&results[base_idx..base_idx + 3], token_address);
254            token_infos.insert(*token_address, token_info);
255        }
256
257        Ok(token_infos)
258    }
259
260    /// Fetches the balance of a specific account for this ERC20 token.
261    ///
262    /// # Errors
263    ///
264    /// Returns an error if the contract call fails.
265    /// - [`BlockchainRpcClientError::ClientError`] if an RPC call fails.
266    /// - [`BlockchainRpcClientError::AbiDecodingError`] if ABI decoding fails.
267    pub async fn balance_of(
268        &self,
269        token_address: &Address,
270        account: &Address,
271    ) -> Result<U256, BlockchainRpcClientError> {
272        let call_data = ERC20::balanceOfCall { account: *account }.abi_encode();
273        let result = self
274            .base
275            .execute_call(token_address, &call_data, None)
276            .await?;
277
278        ERC20::balanceOfCall::abi_decode_returns(&result)
279            .map_err(|e| BlockchainRpcClientError::AbiDecodingError(e.to_string()))
280    }
281}
282
283/// Attempts to decode a revert reason from failed call data.
284/// Returns a human-readable error message.
285fn decode_revert_reason(data: &Bytes) -> String {
286    // For now, just return a simple description
287    // Could be enhanced to decode actual revert reasons in the future
288    if data.is_empty() {
289        "Call failed without revert data".to_string()
290    } else {
291        format!("Call failed with data: {data}")
292    }
293}
294
295/// Generic parser for ERC20 string results (name, symbol)
296fn parse_erc20_string_result(
297    result: &Multicall3::Result,
298    field_name: Erc20Field,
299    token_address: &Address,
300) -> Result<String, TokenInfoError> {
301    // Common validation
302    if !result.success {
303        let reason = if result.returnData.is_empty() {
304            "Call failed without revert data".to_string()
305        } else {
306            // Try to decode revert reason if present
307            decode_revert_reason(&result.returnData)
308        };
309
310        return Err(TokenInfoError::CallFailed {
311            field: field_name.to_string(),
312            address: *token_address,
313            reason,
314            raw_data: result.returnData.to_string(),
315        });
316    }
317
318    if result.returnData.is_empty() {
319        return Err(TokenInfoError::EmptyTokenField {
320            field: field_name,
321            address: *token_address,
322        });
323    }
324
325    match field_name {
326        Erc20Field::Name => ERC20::nameCall::abi_decode_returns(&result.returnData),
327        Erc20Field::Symbol => ERC20::symbolCall::abi_decode_returns(&result.returnData),
328        Erc20Field::Decimals => {
329            return Err(TokenInfoError::DecodingError {
330                field: field_name.to_string(),
331                address: *token_address,
332                reason: "Expected Name or Symbol for parse_erc20_string_result function argument"
333                    .to_string(),
334                raw_data: result.returnData.to_string(),
335            });
336        }
337    }
338    .map_err(|e| TokenInfoError::DecodingError {
339        field: field_name.to_string(),
340        address: *token_address,
341        reason: e.to_string(),
342        raw_data: result.returnData.to_string(),
343    })
344}
345
346/// Generic parser for ERC20 decimals result
347fn parse_erc20_decimals_result(
348    result: &Multicall3::Result,
349    token_address: &Address,
350) -> Result<u8, TokenInfoError> {
351    // Common validation
352    if !result.success {
353        let reason = if result.returnData.is_empty() {
354            "Call failed without revert data".to_string()
355        } else {
356            decode_revert_reason(&result.returnData)
357        };
358
359        return Err(TokenInfoError::CallFailed {
360            field: "decimals".to_string(),
361            address: *token_address,
362            reason,
363            raw_data: result.returnData.to_string(),
364        });
365    }
366
367    if result.returnData.is_empty() {
368        return Err(TokenInfoError::EmptyTokenField {
369            field: Erc20Field::Decimals,
370            address: *token_address,
371        });
372    }
373
374    ERC20::decimalsCall::abi_decode_returns(&result.returnData).map_err(|e| {
375        TokenInfoError::DecodingError {
376            field: "decimals".to_string(),
377            address: *token_address,
378            reason: e.to_string(),
379            raw_data: result.returnData.to_string(),
380        }
381    })
382}
383
384/// Parses token information from a slice of 3 multicall results.
385///
386/// Expects results in order: name, symbol, decimals.
387/// Returns Ok(TokenInfo) if all three calls succeeded, or an Err with a
388/// descriptive error message if any call failed.
389fn parse_batch_token_results(
390    results: &[Multicall3::Result],
391    token_address: &Address,
392) -> Result<TokenInfo, TokenInfoError> {
393    if results.len() != 3 {
394        return Err(TokenInfoError::UnexpectedResultCount {
395            expected: 3,
396            actual: results.len(),
397        });
398    }
399
400    let name = parse_erc20_string_result(&results[0], Erc20Field::Name, token_address)?;
401    let symbol = parse_erc20_string_result(&results[1], Erc20Field::Symbol, token_address)?;
402    let decimals = parse_erc20_decimals_result(&results[2], token_address)?;
403
404    Ok(TokenInfo {
405        name,
406        symbol,
407        decimals,
408    })
409}
410
411#[cfg(test)]
412mod tests {
413    use alloy::primitives::{Bytes, address};
414    use nautilus_core::hex;
415    use rstest::{fixture, rstest};
416
417    use super::*;
418
419    #[fixture]
420    fn token_address() -> Address {
421        address!("25b76A90E389bD644a29db919b136Dc63B174Ec7")
422    }
423
424    #[fixture]
425    fn successful_name_result() -> Multicall3::Result {
426        Multicall3::Result {
427            success: true,
428            returnData: Bytes::from(hex::decode("00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000007546f6b656e204100000000000000000000000000000000000000000000000000").unwrap()),
429        }
430    }
431
432    #[fixture]
433    fn successful_symbol_result() -> Multicall3::Result {
434        Multicall3::Result {
435            success: true,
436            returnData: Bytes::from(hex::decode("0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000776546f6b656e4100000000000000000000000000000000000000000000000000").unwrap()),
437        }
438    }
439
440    #[fixture]
441    fn failed_name_result() -> Multicall3::Result {
442        Multicall3::Result {
443            success: false,
444            returnData: Bytes::from(vec![]),
445        }
446    }
447
448    #[fixture]
449    fn failed_token_address() -> Address {
450        address!("00000000049084A92F8964B76845ab6DE54EB229")
451    }
452
453    #[fixture]
454    fn success_but_empty_result() -> Multicall3::Result {
455        Multicall3::Result {
456            success: true,
457            returnData: Bytes::from(vec![]),
458        }
459    }
460
461    #[fixture]
462    fn empty_token_address() -> Address {
463        address!("a5b00cEc63694319495d605AA414203F9714F47E")
464    }
465
466    #[fixture]
467    fn non_abi_encoded_string_result() -> Multicall3::Result {
468        // Returns raw string bytes without ABI encoding - "Rico" as raw bytes
469        Multicall3::Result {
470            success: true,
471            returnData: Bytes::from(
472                hex::decode("5269636f00000000000000000000000000000000000000000000000000000000")
473                    .unwrap(),
474            ),
475        }
476    }
477
478    #[fixture]
479    fn non_abi_encoded_token_address() -> Address {
480        address!("5374EcC160A4bd68446B43B5A6B132F9c001C54C")
481    }
482
483    #[fixture]
484    fn non_standard_selector_result() -> Multicall3::Result {
485        // Returns function selector instead of actual data
486        Multicall3::Result {
487            success: true,
488            returnData: Bytes::from(
489                hex::decode("06fdde0300000000000000000000000000000000000000000000000000000000")
490                    .unwrap(),
491            ),
492        }
493    }
494
495    #[fixture]
496    fn non_abi_encoded_long_string_result() -> Multicall3::Result {
497        // Returns raw string bytes without ABI encoding - longer string example
498        Multicall3::Result {
499            success: true,
500            returnData: Bytes::from(
501                hex::decode("5269636f62616e6b205269736b20536861726500000000000000000000000000")
502                    .unwrap(),
503            ),
504        }
505    }
506
507    #[rstest]
508    fn test_parse_erc20_string_result_name_success(
509        successful_name_result: Multicall3::Result,
510        token_address: Address,
511    ) {
512        let result =
513            parse_erc20_string_result(&successful_name_result, Erc20Field::Name, &token_address);
514        assert!(result.is_ok());
515        assert_eq!(result.unwrap(), "Token A");
516    }
517
518    #[rstest]
519    fn test_parse_erc20_string_result_symbol_success(
520        successful_symbol_result: Multicall3::Result,
521        token_address: Address,
522    ) {
523        let result = parse_erc20_string_result(
524            &successful_symbol_result,
525            Erc20Field::Symbol,
526            &token_address,
527        );
528        assert!(result.is_ok());
529        assert_eq!(result.unwrap(), "vTokenA");
530    }
531
532    #[rstest]
533    fn test_parse_erc20_string_result_name_failed_with_specific_address(
534        failed_name_result: Multicall3::Result,
535        failed_token_address: Address,
536    ) {
537        let result =
538            parse_erc20_string_result(&failed_name_result, Erc20Field::Name, &failed_token_address);
539        assert!(result.is_err());
540        match result.unwrap_err() {
541            TokenInfoError::CallFailed {
542                field,
543                address,
544                reason,
545                raw_data: _,
546            } => {
547                assert_eq!(field, "Name");
548                assert_eq!(address, failed_token_address);
549                assert_eq!(reason, "Call failed without revert data");
550            }
551            _ => panic!("Expected DecodingError"),
552        }
553    }
554
555    #[rstest]
556    fn test_parse_erc20_string_result_success_but_empty_name(
557        success_but_empty_result: Multicall3::Result,
558        empty_token_address: Address,
559    ) {
560        let result = parse_erc20_string_result(
561            &success_but_empty_result,
562            Erc20Field::Name,
563            &empty_token_address,
564        );
565        assert!(result.is_err());
566        match result.unwrap_err() {
567            TokenInfoError::EmptyTokenField { field, address } => {
568                assert!(matches!(field, Erc20Field::Name));
569                assert_eq!(address, empty_token_address);
570            }
571            _ => panic!("Expected EmptyTokenField error"),
572        }
573    }
574
575    #[rstest]
576    fn test_parse_erc20_decimals_result_success_but_empty(
577        success_but_empty_result: Multicall3::Result,
578        empty_token_address: Address,
579    ) {
580        let result = parse_erc20_decimals_result(&success_but_empty_result, &empty_token_address);
581        assert!(result.is_err());
582        match result.unwrap_err() {
583            TokenInfoError::EmptyTokenField { field, address } => {
584                assert!(matches!(field, Erc20Field::Decimals));
585                assert_eq!(address, empty_token_address);
586            }
587            _ => panic!("Expected EmptyTokenField error"),
588        }
589    }
590
591    #[rstest]
592    fn test_parse_non_abi_encoded_string(
593        non_abi_encoded_string_result: Multicall3::Result,
594        non_abi_encoded_token_address: Address,
595    ) {
596        let result = parse_erc20_string_result(
597            &non_abi_encoded_string_result,
598            Erc20Field::Name,
599            &non_abi_encoded_token_address,
600        );
601        assert!(result.is_err());
602        match result.unwrap_err() {
603            TokenInfoError::DecodingError {
604                field,
605                address,
606                reason,
607                raw_data,
608            } => {
609                assert_eq!(field, "Name");
610                assert_eq!(address, non_abi_encoded_token_address);
611                assert!(reason.contains("type check failed"));
612                assert_eq!(
613                    raw_data,
614                    "0x5269636f00000000000000000000000000000000000000000000000000000000"
615                );
616                // Raw bytes "Rico" without ABI encoding
617            }
618            _ => panic!("Expected DecodingError"),
619        }
620    }
621
622    #[rstest]
623    fn test_parse_non_standard_selector_return(
624        non_standard_selector_result: Multicall3::Result,
625        token_address: Address,
626    ) {
627        let result = parse_erc20_string_result(
628            &non_standard_selector_result,
629            Erc20Field::Name,
630            &token_address,
631        );
632        assert!(result.is_err());
633        match result.unwrap_err() {
634            TokenInfoError::DecodingError {
635                field,
636                address,
637                reason,
638                raw_data,
639            } => {
640                assert_eq!(field, "Name");
641                assert_eq!(address, token_address);
642                assert!(reason.contains("type check failed"));
643                assert_eq!(
644                    raw_data,
645                    "0x06fdde0300000000000000000000000000000000000000000000000000000000"
646                );
647            }
648            _ => panic!("Expected DecodingError"),
649        }
650    }
651
652    #[rstest]
653    fn test_parse_non_abi_encoded_long_string(
654        non_abi_encoded_long_string_result: Multicall3::Result,
655        token_address: Address,
656    ) {
657        let result = parse_erc20_string_result(
658            &non_abi_encoded_long_string_result,
659            Erc20Field::Name,
660            &token_address,
661        );
662        assert!(result.is_err());
663        match result.unwrap_err() {
664            TokenInfoError::DecodingError {
665                field,
666                address,
667                reason,
668                raw_data,
669            } => {
670                assert_eq!(field, "Name");
671                assert_eq!(address, token_address);
672                assert!(reason.contains("type check failed"));
673                assert_eq!(
674                    raw_data,
675                    "0x5269636f62616e6b205269736b20536861726500000000000000000000000000"
676                );
677                // Example of longer non-ABI encoded string
678            }
679            _ => panic!("Expected DecodingError"),
680        }
681    }
682}