saorsa_node/payment/
wallet.rs

1//! EVM wallet management for receiving payments.
2//!
3//! Handles parsing and validation of EVM wallet addresses (rewards addresses)
4//! that nodes use to receive payments for storing data.
5
6use crate::config::EvmNetworkConfig;
7use crate::error::{Error, Result};
8use ant_evm::RewardsAddress;
9use evmlib::Network as EvmNetwork;
10
11/// EVM wallet configuration for a node.
12#[derive(Debug, Clone)]
13pub struct WalletConfig {
14    /// The rewards address where payments are received.
15    pub rewards_address: Option<RewardsAddress>,
16    /// The EVM network (Arbitrum One or Sepolia).
17    pub network: EvmNetwork,
18}
19
20impl WalletConfig {
21    /// Create a new wallet configuration.
22    ///
23    /// # Arguments
24    ///
25    /// * `rewards_address` - Optional EVM address string (e.g., "0x...")
26    /// * `evm_network` - The EVM network configuration
27    ///
28    /// # Errors
29    ///
30    /// Returns an error if the address string is invalid.
31    pub fn new(rewards_address: Option<&str>, evm_network: EvmNetworkConfig) -> Result<Self> {
32        let rewards_address = rewards_address.map(parse_rewards_address).transpose()?;
33
34        let network = match evm_network {
35            EvmNetworkConfig::ArbitrumOne => EvmNetwork::ArbitrumOne,
36            EvmNetworkConfig::ArbitrumSepolia => EvmNetwork::ArbitrumSepoliaTest,
37        };
38
39        Ok(Self {
40            rewards_address,
41            network,
42        })
43    }
44
45    /// Check if the wallet has a rewards address configured.
46    #[must_use]
47    pub fn has_rewards_address(&self) -> bool {
48        self.rewards_address.is_some()
49    }
50
51    /// Get the rewards address if configured.
52    #[must_use]
53    pub fn get_rewards_address(&self) -> Option<&RewardsAddress> {
54        self.rewards_address.as_ref()
55    }
56
57    /// Check if this wallet is configured for mainnet.
58    #[must_use]
59    pub fn is_mainnet(&self) -> bool {
60        matches!(self.network, EvmNetwork::ArbitrumOne)
61    }
62}
63
64/// Parse an EVM address string into a `RewardsAddress`.
65///
66/// # Arguments
67///
68/// * `address` - EVM address string (e.g., "0x1234...")
69///
70/// # Errors
71///
72/// Returns an error if the address format is invalid.
73pub fn parse_rewards_address(address: &str) -> Result<RewardsAddress> {
74    // Validate format: should start with 0x and be 42 characters total (0x + 40 hex chars)
75    if !address.starts_with("0x") && !address.starts_with("0X") {
76        return Err(Error::Payment(format!(
77            "Invalid rewards address format: must start with '0x', got: {address}"
78        )));
79    }
80
81    if address.len() != 42 {
82        return Err(Error::Payment(format!(
83            "Invalid rewards address length: expected 42 characters, got {}",
84            address.len()
85        )));
86    }
87
88    // Validate hex characters
89    let hex_part = &address[2..];
90    if !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
91        return Err(Error::Payment(format!(
92            "Invalid rewards address: contains non-hex characters: {address}"
93        )));
94    }
95
96    // Parse into bytes
97    let bytes = hex::decode(hex_part)
98        .map_err(|e| Error::Payment(format!("Failed to decode rewards address: {e}")))?;
99
100    // Convert to fixed-size array
101    let mut address_bytes = [0u8; 20];
102    address_bytes.copy_from_slice(&bytes);
103
104    Ok(RewardsAddress::new(address_bytes))
105}
106
107/// Validate that an EVM address is properly formatted.
108///
109/// # Arguments
110///
111/// * `address` - EVM address string to validate
112///
113/// # Returns
114///
115/// `true` if the address is valid, `false` otherwise.
116#[must_use]
117pub fn is_valid_address(address: &str) -> bool {
118    parse_rewards_address(address).is_ok()
119}
120
121#[cfg(test)]
122#[allow(clippy::expect_used)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn test_parse_valid_address() {
128        let address = "0x742d35Cc6634C0532925a3b844Bc9e7595916Da2";
129        let result = parse_rewards_address(address);
130        assert!(result.is_ok());
131    }
132
133    #[test]
134    fn test_parse_lowercase_address() {
135        let address = "0x742d35cc6634c0532925a3b844bc9e7595916da2";
136        let result = parse_rewards_address(address);
137        assert!(result.is_ok());
138    }
139
140    #[test]
141    fn test_invalid_prefix() {
142        let address = "742d35Cc6634C0532925a3b844Bc9e7595916Da2";
143        let result = parse_rewards_address(address);
144        assert!(result.is_err());
145    }
146
147    #[test]
148    fn test_invalid_length() {
149        let address = "0x742d35Cc6634C0532925a3b844Bc9e7595916Da";
150        let result = parse_rewards_address(address);
151        assert!(result.is_err());
152    }
153
154    #[test]
155    fn test_invalid_hex_chars() {
156        let address = "0x742d35Cc6634C0532925a3b844Bc9e7595916DgZ";
157        let result = parse_rewards_address(address);
158        assert!(result.is_err());
159    }
160
161    #[test]
162    fn test_is_valid_address() {
163        assert!(is_valid_address(
164            "0x742d35Cc6634C0532925a3b844Bc9e7595916Da2"
165        ));
166        assert!(!is_valid_address("invalid"));
167    }
168
169    #[test]
170    fn test_wallet_config_new() {
171        let config = WalletConfig::new(
172            Some("0x742d35Cc6634C0532925a3b844Bc9e7595916Da2"),
173            EvmNetworkConfig::ArbitrumSepolia,
174        );
175        assert!(config.is_ok());
176        let config = config.expect("valid config");
177        assert!(config.has_rewards_address());
178        assert!(!config.is_mainnet());
179    }
180
181    #[test]
182    fn test_wallet_config_no_address() {
183        let config = WalletConfig::new(None, EvmNetworkConfig::ArbitrumOne);
184        assert!(config.is_ok());
185        let config = config.expect("valid config");
186        assert!(!config.has_rewards_address());
187        assert!(config.is_mainnet());
188    }
189}