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