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