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 rstest::{fixture, rstest};
415
416    use super::*;
417
418    #[fixture]
419    fn token_address() -> Address {
420        address!("25b76A90E389bD644a29db919b136Dc63B174Ec7")
421    }
422
423    #[fixture]
424    fn successful_name_result() -> Multicall3::Result {
425        Multicall3::Result {
426            success: true,
427            returnData: Bytes::from(hex::decode("00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000007546f6b656e204100000000000000000000000000000000000000000000000000").unwrap()),
428        }
429    }
430
431    #[fixture]
432    fn successful_symbol_result() -> Multicall3::Result {
433        Multicall3::Result {
434            success: true,
435            returnData: Bytes::from(hex::decode("0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000776546f6b656e4100000000000000000000000000000000000000000000000000").unwrap()),
436        }
437    }
438
439    #[fixture]
440    fn failed_name_result() -> Multicall3::Result {
441        Multicall3::Result {
442            success: false,
443            returnData: Bytes::from(vec![]),
444        }
445    }
446
447    #[fixture]
448    fn failed_token_address() -> Address {
449        address!("00000000049084A92F8964B76845ab6DE54EB229")
450    }
451
452    #[fixture]
453    fn success_but_empty_result() -> Multicall3::Result {
454        Multicall3::Result {
455            success: true,
456            returnData: Bytes::from(vec![]),
457        }
458    }
459
460    #[fixture]
461    fn empty_token_address() -> Address {
462        address!("a5b00cEc63694319495d605AA414203F9714F47E")
463    }
464
465    #[fixture]
466    fn non_abi_encoded_string_result() -> Multicall3::Result {
467        // Returns raw string bytes without ABI encoding - "Rico" as raw bytes
468        Multicall3::Result {
469            success: true,
470            returnData: Bytes::from(
471                hex::decode("5269636f00000000000000000000000000000000000000000000000000000000")
472                    .unwrap(),
473            ),
474        }
475    }
476
477    #[fixture]
478    fn non_abi_encoded_token_address() -> Address {
479        address!("5374EcC160A4bd68446B43B5A6B132F9c001C54C")
480    }
481
482    #[fixture]
483    fn non_standard_selector_result() -> Multicall3::Result {
484        // Returns function selector instead of actual data
485        Multicall3::Result {
486            success: true,
487            returnData: Bytes::from(
488                hex::decode("06fdde0300000000000000000000000000000000000000000000000000000000")
489                    .unwrap(),
490            ),
491        }
492    }
493
494    #[fixture]
495    fn non_abi_encoded_long_string_result() -> Multicall3::Result {
496        // Returns raw string bytes without ABI encoding - longer string example
497        Multicall3::Result {
498            success: true,
499            returnData: Bytes::from(
500                hex::decode("5269636f62616e6b205269736b20536861726500000000000000000000000000")
501                    .unwrap(),
502            ),
503        }
504    }
505
506    #[rstest]
507    fn test_parse_erc20_string_result_name_success(
508        successful_name_result: Multicall3::Result,
509        token_address: Address,
510    ) {
511        let result =
512            parse_erc20_string_result(&successful_name_result, Erc20Field::Name, &token_address);
513        assert!(result.is_ok());
514        assert_eq!(result.unwrap(), "Token A");
515    }
516
517    #[rstest]
518    fn test_parse_erc20_string_result_symbol_success(
519        successful_symbol_result: Multicall3::Result,
520        token_address: Address,
521    ) {
522        let result = parse_erc20_string_result(
523            &successful_symbol_result,
524            Erc20Field::Symbol,
525            &token_address,
526        );
527        assert!(result.is_ok());
528        assert_eq!(result.unwrap(), "vTokenA");
529    }
530
531    #[rstest]
532    fn test_parse_erc20_string_result_name_failed_with_specific_address(
533        failed_name_result: Multicall3::Result,
534        failed_token_address: Address,
535    ) {
536        let result =
537            parse_erc20_string_result(&failed_name_result, Erc20Field::Name, &failed_token_address);
538        assert!(result.is_err());
539        match result.unwrap_err() {
540            TokenInfoError::CallFailed {
541                field,
542                address,
543                reason,
544                raw_data: _,
545            } => {
546                assert_eq!(field, "Name");
547                assert_eq!(address, failed_token_address);
548                assert_eq!(reason, "Call failed without revert data");
549            }
550            _ => panic!("Expected DecodingError"),
551        }
552    }
553
554    #[rstest]
555    fn test_parse_erc20_string_result_success_but_empty_name(
556        success_but_empty_result: Multicall3::Result,
557        empty_token_address: Address,
558    ) {
559        let result = parse_erc20_string_result(
560            &success_but_empty_result,
561            Erc20Field::Name,
562            &empty_token_address,
563        );
564        assert!(result.is_err());
565        match result.unwrap_err() {
566            TokenInfoError::EmptyTokenField { field, address } => {
567                assert!(matches!(field, Erc20Field::Name));
568                assert_eq!(address, empty_token_address);
569            }
570            _ => panic!("Expected EmptyTokenField error"),
571        }
572    }
573
574    #[rstest]
575    fn test_parse_erc20_decimals_result_success_but_empty(
576        success_but_empty_result: Multicall3::Result,
577        empty_token_address: Address,
578    ) {
579        let result = parse_erc20_decimals_result(&success_but_empty_result, &empty_token_address);
580        assert!(result.is_err());
581        match result.unwrap_err() {
582            TokenInfoError::EmptyTokenField { field, address } => {
583                assert!(matches!(field, Erc20Field::Decimals));
584                assert_eq!(address, empty_token_address);
585            }
586            _ => panic!("Expected EmptyTokenField error"),
587        }
588    }
589
590    #[rstest]
591    fn test_parse_non_abi_encoded_string(
592        non_abi_encoded_string_result: Multicall3::Result,
593        non_abi_encoded_token_address: Address,
594    ) {
595        let result = parse_erc20_string_result(
596            &non_abi_encoded_string_result,
597            Erc20Field::Name,
598            &non_abi_encoded_token_address,
599        );
600        assert!(result.is_err());
601        match result.unwrap_err() {
602            TokenInfoError::DecodingError {
603                field,
604                address,
605                reason,
606                raw_data,
607            } => {
608                assert_eq!(field, "Name");
609                assert_eq!(address, non_abi_encoded_token_address);
610                assert!(reason.contains("type check failed"));
611                assert_eq!(
612                    raw_data,
613                    "0x5269636f00000000000000000000000000000000000000000000000000000000"
614                );
615                // Raw bytes "Rico" without ABI encoding
616            }
617            _ => panic!("Expected DecodingError"),
618        }
619    }
620
621    #[rstest]
622    fn test_parse_non_standard_selector_return(
623        non_standard_selector_result: Multicall3::Result,
624        token_address: Address,
625    ) {
626        let result = parse_erc20_string_result(
627            &non_standard_selector_result,
628            Erc20Field::Name,
629            &token_address,
630        );
631        assert!(result.is_err());
632        match result.unwrap_err() {
633            TokenInfoError::DecodingError {
634                field,
635                address,
636                reason,
637                raw_data,
638            } => {
639                assert_eq!(field, "Name");
640                assert_eq!(address, token_address);
641                assert!(reason.contains("type check failed"));
642                assert_eq!(
643                    raw_data,
644                    "0x06fdde0300000000000000000000000000000000000000000000000000000000"
645                );
646            }
647            _ => panic!("Expected DecodingError"),
648        }
649    }
650
651    #[rstest]
652    fn test_parse_non_abi_encoded_long_string(
653        non_abi_encoded_long_string_result: Multicall3::Result,
654        token_address: Address,
655    ) {
656        let result = parse_erc20_string_result(
657            &non_abi_encoded_long_string_result,
658            Erc20Field::Name,
659            &token_address,
660        );
661        assert!(result.is_err());
662        match result.unwrap_err() {
663            TokenInfoError::DecodingError {
664                field,
665                address,
666                reason,
667                raw_data,
668            } => {
669                assert_eq!(field, "Name");
670                assert_eq!(address, token_address);
671                assert!(reason.contains("type check failed"));
672                assert_eq!(
673                    raw_data,
674                    "0x5269636f62616e6b205269736b20536861726500000000000000000000000000"
675                );
676                // Example of longer non-ABI encoded string
677            }
678            _ => panic!("Expected DecodingError"),
679        }
680    }
681}