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        _ => panic!("Expected Name or Symbol for for parse_erc20_string_result function argument"),
331    }
332    .map_err(|e| TokenInfoError::DecodingError {
333        field: field_name.to_string(),
334        address: *token_address,
335        reason: e.to_string(),
336        raw_data: result.returnData.to_string(),
337    })
338}
339
340/// Generic parser for ERC20 decimals result
341fn parse_erc20_decimals_result(
342    result: &Multicall3::Result,
343    token_address: &Address,
344) -> Result<u8, TokenInfoError> {
345    // Common validation
346    if !result.success {
347        let reason = if result.returnData.is_empty() {
348            "Call failed without revert data".to_string()
349        } else {
350            decode_revert_reason(&result.returnData)
351        };
352
353        return Err(TokenInfoError::CallFailed {
354            field: "decimals".to_string(),
355            address: *token_address,
356            reason,
357            raw_data: result.returnData.to_string(),
358        });
359    }
360
361    if result.returnData.is_empty() {
362        return Err(TokenInfoError::EmptyTokenField {
363            field: Erc20Field::Decimals,
364            address: *token_address,
365        });
366    }
367
368    ERC20::decimalsCall::abi_decode_returns(&result.returnData).map_err(|e| {
369        TokenInfoError::DecodingError {
370            field: "decimals".to_string(),
371            address: *token_address,
372            reason: e.to_string(),
373            raw_data: result.returnData.to_string(),
374        }
375    })
376}
377
378/// Parses token information from a slice of 3 multicall results.
379///
380/// Expects results in order: name, symbol, decimals.
381/// Returns Ok(TokenInfo) if all three calls succeeded, or an Err with a
382/// descriptive error message if any call failed.
383fn parse_batch_token_results(
384    results: &[Multicall3::Result],
385    token_address: &Address,
386) -> Result<TokenInfo, TokenInfoError> {
387    if results.len() != 3 {
388        return Err(TokenInfoError::UnexpectedResultCount {
389            expected: 3,
390            actual: results.len(),
391        });
392    }
393
394    let name = parse_erc20_string_result(&results[0], Erc20Field::Name, token_address)?;
395    let symbol = parse_erc20_string_result(&results[1], Erc20Field::Symbol, token_address)?;
396    let decimals = parse_erc20_decimals_result(&results[2], token_address)?;
397
398    Ok(TokenInfo {
399        name,
400        symbol,
401        decimals,
402    })
403}
404
405#[cfg(test)]
406mod tests {
407    use alloy::primitives::{Bytes, address};
408    use rstest::{fixture, rstest};
409
410    use super::*;
411
412    #[fixture]
413    fn token_address() -> Address {
414        address!("25b76A90E389bD644a29db919b136Dc63B174Ec7")
415    }
416
417    #[fixture]
418    fn successful_name_result() -> Multicall3::Result {
419        Multicall3::Result {
420            success: true,
421            returnData: Bytes::from(hex::decode("00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000007546f6b656e204100000000000000000000000000000000000000000000000000").unwrap()),
422        }
423    }
424
425    #[fixture]
426    fn successful_symbol_result() -> Multicall3::Result {
427        Multicall3::Result {
428            success: true,
429            returnData: Bytes::from(hex::decode("0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000776546f6b656e4100000000000000000000000000000000000000000000000000").unwrap()),
430        }
431    }
432
433    #[fixture]
434    fn failed_name_result() -> Multicall3::Result {
435        Multicall3::Result {
436            success: false,
437            returnData: Bytes::from(vec![]),
438        }
439    }
440
441    #[fixture]
442    fn failed_token_address() -> Address {
443        address!("00000000049084A92F8964B76845ab6DE54EB229")
444    }
445
446    #[fixture]
447    fn success_but_empty_result() -> Multicall3::Result {
448        Multicall3::Result {
449            success: true,
450            returnData: Bytes::from(vec![]),
451        }
452    }
453
454    #[fixture]
455    fn empty_token_address() -> Address {
456        address!("a5b00cEc63694319495d605AA414203F9714F47E")
457    }
458
459    #[fixture]
460    fn non_abi_encoded_string_result() -> Multicall3::Result {
461        // Returns raw string bytes without ABI encoding - "Rico" as raw bytes
462        Multicall3::Result {
463            success: true,
464            returnData: Bytes::from(
465                hex::decode("5269636f00000000000000000000000000000000000000000000000000000000")
466                    .unwrap(),
467            ),
468        }
469    }
470
471    #[fixture]
472    fn non_abi_encoded_token_address() -> Address {
473        address!("5374EcC160A4bd68446B43B5A6B132F9c001C54C")
474    }
475
476    #[fixture]
477    fn non_standard_selector_result() -> Multicall3::Result {
478        // Returns function selector instead of actual data
479        Multicall3::Result {
480            success: true,
481            returnData: Bytes::from(
482                hex::decode("06fdde0300000000000000000000000000000000000000000000000000000000")
483                    .unwrap(),
484            ),
485        }
486    }
487
488    #[fixture]
489    fn non_abi_encoded_long_string_result() -> Multicall3::Result {
490        // Returns raw string bytes without ABI encoding - longer string example
491        Multicall3::Result {
492            success: true,
493            returnData: Bytes::from(
494                hex::decode("5269636f62616e6b205269736b20536861726500000000000000000000000000")
495                    .unwrap(),
496            ),
497        }
498    }
499
500    #[rstest]
501    fn test_parse_erc20_string_result_name_success(
502        successful_name_result: Multicall3::Result,
503        token_address: Address,
504    ) {
505        let result =
506            parse_erc20_string_result(&successful_name_result, Erc20Field::Name, &token_address);
507        assert!(result.is_ok());
508        assert_eq!(result.unwrap(), "Token A");
509    }
510
511    #[rstest]
512    fn test_parse_erc20_string_result_symbol_success(
513        successful_symbol_result: Multicall3::Result,
514        token_address: Address,
515    ) {
516        let result = parse_erc20_string_result(
517            &successful_symbol_result,
518            Erc20Field::Symbol,
519            &token_address,
520        );
521        assert!(result.is_ok());
522        assert_eq!(result.unwrap(), "vTokenA");
523    }
524
525    #[rstest]
526    fn test_parse_erc20_string_result_name_failed_with_specific_address(
527        failed_name_result: Multicall3::Result,
528        failed_token_address: Address,
529    ) {
530        let result =
531            parse_erc20_string_result(&failed_name_result, Erc20Field::Name, &failed_token_address);
532        assert!(result.is_err());
533        match result.unwrap_err() {
534            TokenInfoError::CallFailed {
535                field,
536                address,
537                reason,
538                raw_data: _,
539            } => {
540                assert_eq!(field, "Name");
541                assert_eq!(address, failed_token_address);
542                assert_eq!(reason, "Call failed without revert data");
543            }
544            _ => panic!("Expected DecodingError"),
545        }
546    }
547
548    #[rstest]
549    fn test_parse_erc20_string_result_success_but_empty_name(
550        success_but_empty_result: Multicall3::Result,
551        empty_token_address: Address,
552    ) {
553        let result = parse_erc20_string_result(
554            &success_but_empty_result,
555            Erc20Field::Name,
556            &empty_token_address,
557        );
558        assert!(result.is_err());
559        match result.unwrap_err() {
560            TokenInfoError::EmptyTokenField { field, address } => {
561                assert!(matches!(field, Erc20Field::Name));
562                assert_eq!(address, empty_token_address);
563            }
564            _ => panic!("Expected EmptyTokenField error"),
565        }
566    }
567
568    #[rstest]
569    fn test_parse_erc20_decimals_result_success_but_empty(
570        success_but_empty_result: Multicall3::Result,
571        empty_token_address: Address,
572    ) {
573        let result = parse_erc20_decimals_result(&success_but_empty_result, &empty_token_address);
574        assert!(result.is_err());
575        match result.unwrap_err() {
576            TokenInfoError::EmptyTokenField { field, address } => {
577                assert!(matches!(field, Erc20Field::Decimals));
578                assert_eq!(address, empty_token_address);
579            }
580            _ => panic!("Expected EmptyTokenField error"),
581        }
582    }
583
584    #[rstest]
585    fn test_parse_non_abi_encoded_string(
586        non_abi_encoded_string_result: Multicall3::Result,
587        non_abi_encoded_token_address: Address,
588    ) {
589        let result = parse_erc20_string_result(
590            &non_abi_encoded_string_result,
591            Erc20Field::Name,
592            &non_abi_encoded_token_address,
593        );
594        assert!(result.is_err());
595        match result.unwrap_err() {
596            TokenInfoError::DecodingError {
597                field,
598                address,
599                reason,
600                raw_data,
601            } => {
602                assert_eq!(field, "Name");
603                assert_eq!(address, non_abi_encoded_token_address);
604                assert!(reason.contains("type check failed"));
605                assert_eq!(
606                    raw_data,
607                    "0x5269636f00000000000000000000000000000000000000000000000000000000"
608                );
609                // Raw bytes "Rico" without ABI encoding
610            }
611            _ => panic!("Expected DecodingError"),
612        }
613    }
614
615    #[rstest]
616    fn test_parse_non_standard_selector_return(
617        non_standard_selector_result: Multicall3::Result,
618        token_address: Address,
619    ) {
620        let result = parse_erc20_string_result(
621            &non_standard_selector_result,
622            Erc20Field::Name,
623            &token_address,
624        );
625        assert!(result.is_err());
626        match result.unwrap_err() {
627            TokenInfoError::DecodingError {
628                field,
629                address,
630                reason,
631                raw_data,
632            } => {
633                assert_eq!(field, "Name");
634                assert_eq!(address, token_address);
635                assert!(reason.contains("type check failed"));
636                assert_eq!(
637                    raw_data,
638                    "0x06fdde0300000000000000000000000000000000000000000000000000000000"
639                );
640            }
641            _ => panic!("Expected DecodingError"),
642        }
643    }
644
645    #[rstest]
646    fn test_parse_non_abi_encoded_long_string(
647        non_abi_encoded_long_string_result: Multicall3::Result,
648        token_address: Address,
649    ) {
650        let result = parse_erc20_string_result(
651            &non_abi_encoded_long_string_result,
652            Erc20Field::Name,
653            &token_address,
654        );
655        assert!(result.is_err());
656        match result.unwrap_err() {
657            TokenInfoError::DecodingError {
658                field,
659                address,
660                reason,
661                raw_data,
662            } => {
663                assert_eq!(field, "Name");
664                assert_eq!(address, token_address);
665                assert!(reason.contains("type check failed"));
666                assert_eq!(
667                    raw_data,
668                    "0x5269636f62616e6b205269736b20536861726500000000000000000000000000"
669                );
670                // Example of longer non-ABI encoded string
671            }
672            _ => panic!("Expected DecodingError"),
673        }
674    }
675}