Skip to main content

allsource_core/domain/value_objects/
wallet_address.rs

1use crate::error::Result;
2use serde::{Deserialize, Serialize};
3use std::fmt;
4
5/// Value Object: WalletAddress
6///
7/// Represents a cryptocurrency wallet address in the paywall system.
8/// Supports Solana addresses (base58 encoded, 32-44 characters).
9///
10/// Domain Rules:
11/// - Cannot be empty
12/// - Must be a valid base58 encoded string
13/// - Must be between 32 and 44 characters (Solana address format)
14/// - Case-sensitive
15/// - Immutable once created
16///
17/// This is a Value Object:
18/// - Defined by its value, not identity
19/// - Immutable
20/// - Self-validating
21/// - Compared by value equality
22#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
23pub struct WalletAddress(String);
24
25/// Base58 alphabet used by Solana
26const BASE58_ALPHABET: &str = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
27
28impl WalletAddress {
29    /// Create a new WalletAddress with validation
30    ///
31    /// # Errors
32    /// Returns error if:
33    /// - Address is empty
34    /// - Address is not valid base58
35    /// - Address is not between 32-44 characters
36    ///
37    /// # Examples
38    /// ```
39    /// use allsource_core::domain::value_objects::WalletAddress;
40    ///
41    /// // Valid Solana address format
42    /// let wallet = WalletAddress::new("9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM".to_string()).unwrap();
43    /// assert_eq!(wallet.as_str(), "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM");
44    /// ```
45    pub fn new(value: String) -> Result<Self> {
46        Self::validate(&value)?;
47        Ok(Self(value))
48    }
49
50    /// Create WalletAddress without validation (for internal use, e.g., from trusted storage)
51    ///
52    /// # Safety
53    /// This bypasses validation. Only use when loading from trusted sources
54    /// where validation has already occurred.
55    pub(crate) fn new_unchecked(value: String) -> Self {
56        Self(value)
57    }
58
59    /// Get the string value
60    pub fn as_str(&self) -> &str {
61        &self.0
62    }
63
64    /// Get the inner String (consumes self)
65    pub fn into_inner(self) -> String {
66        self.0
67    }
68
69    /// Get an anonymized version of the wallet address for display
70    ///
71    /// Returns the first 4 and last 4 characters with ellipsis in between.
72    ///
73    /// # Examples
74    /// ```
75    /// use allsource_core::domain::value_objects::WalletAddress;
76    ///
77    /// let wallet = WalletAddress::new("9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM".to_string()).unwrap();
78    /// assert_eq!(wallet.anonymized(), "9WzD...AWWM");
79    /// ```
80    pub fn anonymized(&self) -> String {
81        if self.0.len() <= 8 {
82            return self.0.clone();
83        }
84        format!("{}...{}", &self.0[..4], &self.0[self.0.len() - 4..])
85    }
86
87    /// Check if this is a valid Solana address format
88    pub fn is_solana(&self) -> bool {
89        // Solana addresses are 32-44 characters base58 encoded
90        self.0.len() >= 32 && self.0.len() <= 44
91    }
92
93    /// Validate a wallet address string
94    fn validate(value: &str) -> Result<()> {
95        // Rule: Cannot be empty
96        if value.is_empty() {
97            return Err(crate::error::AllSourceError::InvalidInput(
98                "Wallet address cannot be empty".to_string(),
99            ));
100        }
101
102        // Rule: Must be valid length (Solana: 32-44 characters)
103        if value.len() < 32 || value.len() > 44 {
104            return Err(crate::error::AllSourceError::InvalidInput(format!(
105                "Wallet address must be between 32 and 44 characters, got {}",
106                value.len()
107            )));
108        }
109
110        // Rule: Must be valid base58
111        if !value.chars().all(|c| BASE58_ALPHABET.contains(c)) {
112            return Err(crate::error::AllSourceError::InvalidInput(
113                "Wallet address contains invalid base58 characters".to_string(),
114            ));
115        }
116
117        Ok(())
118    }
119}
120
121impl fmt::Display for WalletAddress {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        write!(f, "{}", self.0)
124    }
125}
126
127impl TryFrom<&str> for WalletAddress {
128    type Error = crate::error::AllSourceError;
129
130    fn try_from(value: &str) -> Result<Self> {
131        WalletAddress::new(value.to_string())
132    }
133}
134
135impl TryFrom<String> for WalletAddress {
136    type Error = crate::error::AllSourceError;
137
138    fn try_from(value: String) -> Result<Self> {
139        WalletAddress::new(value)
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    // A valid Solana address for testing (44 characters)
148    const VALID_SOLANA_ADDRESS: &str = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM";
149
150    // Another valid address (32 characters - minimum length)
151    const VALID_SHORT_ADDRESS: &str = "11111111111111111111111111111111";
152
153    #[test]
154    fn test_create_valid_wallet_address() {
155        let wallet = WalletAddress::new(VALID_SOLANA_ADDRESS.to_string());
156        assert!(wallet.is_ok());
157        assert_eq!(wallet.unwrap().as_str(), VALID_SOLANA_ADDRESS);
158    }
159
160    #[test]
161    fn test_create_short_valid_address() {
162        let wallet = WalletAddress::new(VALID_SHORT_ADDRESS.to_string());
163        assert!(wallet.is_ok());
164    }
165
166    #[test]
167    fn test_reject_empty_address() {
168        let result = WalletAddress::new("".to_string());
169        assert!(result.is_err());
170
171        if let Err(e) = result {
172            assert!(e.to_string().contains("cannot be empty"));
173        }
174    }
175
176    #[test]
177    fn test_reject_too_short_address() {
178        // 31 characters - too short
179        let short_address = "1234567890123456789012345678901";
180        let result = WalletAddress::new(short_address.to_string());
181        assert!(result.is_err());
182
183        if let Err(e) = result {
184            assert!(e.to_string().contains("must be between 32 and 44"));
185        }
186    }
187
188    #[test]
189    fn test_reject_too_long_address() {
190        // 45 characters - too long
191        let long_address = "123456789012345678901234567890123456789012345";
192        let result = WalletAddress::new(long_address.to_string());
193        assert!(result.is_err());
194
195        if let Err(e) = result {
196            assert!(e.to_string().contains("must be between 32 and 44"));
197        }
198    }
199
200    #[test]
201    fn test_reject_invalid_base58_characters() {
202        // Contains '0' which is not in base58
203        let invalid = "0WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM";
204        let result = WalletAddress::new(invalid.to_string());
205        assert!(result.is_err());
206
207        // Contains 'O' which is not in base58
208        let invalid = "OWzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM";
209        let result = WalletAddress::new(invalid.to_string());
210        assert!(result.is_err());
211
212        // Contains 'I' which is not in base58
213        let invalid = "IWzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM";
214        let result = WalletAddress::new(invalid.to_string());
215        assert!(result.is_err());
216
217        // Contains 'l' which is not in base58
218        let invalid = "lWzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM";
219        let result = WalletAddress::new(invalid.to_string());
220        assert!(result.is_err());
221
222        if let Err(e) = result {
223            assert!(e.to_string().contains("invalid base58"));
224        }
225    }
226
227    #[test]
228    fn test_anonymized() {
229        let wallet = WalletAddress::new(VALID_SOLANA_ADDRESS.to_string()).unwrap();
230        let anonymized = wallet.anonymized();
231        assert_eq!(anonymized, "9WzD...AWWM");
232    }
233
234    #[test]
235    fn test_is_solana() {
236        let wallet = WalletAddress::new(VALID_SOLANA_ADDRESS.to_string()).unwrap();
237        assert!(wallet.is_solana());
238
239        let wallet = WalletAddress::new(VALID_SHORT_ADDRESS.to_string()).unwrap();
240        assert!(wallet.is_solana());
241    }
242
243    #[test]
244    fn test_display_trait() {
245        let wallet = WalletAddress::new(VALID_SOLANA_ADDRESS.to_string()).unwrap();
246        assert_eq!(format!("{}", wallet), VALID_SOLANA_ADDRESS);
247    }
248
249    #[test]
250    fn test_try_from_str() {
251        let wallet: Result<WalletAddress> = VALID_SOLANA_ADDRESS.try_into();
252        assert!(wallet.is_ok());
253        assert_eq!(wallet.unwrap().as_str(), VALID_SOLANA_ADDRESS);
254
255        let invalid: Result<WalletAddress> = "".try_into();
256        assert!(invalid.is_err());
257    }
258
259    #[test]
260    fn test_try_from_string() {
261        let wallet: Result<WalletAddress> = VALID_SOLANA_ADDRESS.to_string().try_into();
262        assert!(wallet.is_ok());
263
264        let invalid: Result<WalletAddress> = String::new().try_into();
265        assert!(invalid.is_err());
266    }
267
268    #[test]
269    fn test_into_inner() {
270        let wallet = WalletAddress::new(VALID_SOLANA_ADDRESS.to_string()).unwrap();
271        let inner = wallet.into_inner();
272        assert_eq!(inner, VALID_SOLANA_ADDRESS);
273    }
274
275    #[test]
276    fn test_equality() {
277        let w1 = WalletAddress::new(VALID_SOLANA_ADDRESS.to_string()).unwrap();
278        let w2 = WalletAddress::new(VALID_SOLANA_ADDRESS.to_string()).unwrap();
279        let w3 = WalletAddress::new(VALID_SHORT_ADDRESS.to_string()).unwrap();
280
281        // Value equality
282        assert_eq!(w1, w2);
283        assert_ne!(w1, w3);
284    }
285
286    #[test]
287    fn test_cloning() {
288        let w1 = WalletAddress::new(VALID_SOLANA_ADDRESS.to_string()).unwrap();
289        let w2 = w1.clone();
290        assert_eq!(w1, w2);
291    }
292
293    #[test]
294    fn test_hash_consistency() {
295        use std::collections::HashSet;
296
297        let w1 = WalletAddress::new(VALID_SOLANA_ADDRESS.to_string()).unwrap();
298        let w2 = WalletAddress::new(VALID_SOLANA_ADDRESS.to_string()).unwrap();
299
300        let mut set = HashSet::new();
301        set.insert(w1);
302
303        // Should find the same value (value equality)
304        assert!(set.contains(&w2));
305    }
306
307    #[test]
308    fn test_serde_serialization() {
309        let wallet = WalletAddress::new(VALID_SOLANA_ADDRESS.to_string()).unwrap();
310
311        // Serialize
312        let json = serde_json::to_string(&wallet).unwrap();
313        assert_eq!(json, format!("\"{}\"", VALID_SOLANA_ADDRESS));
314
315        // Deserialize
316        let deserialized: WalletAddress = serde_json::from_str(&json).unwrap();
317        assert_eq!(deserialized, wallet);
318    }
319
320    #[test]
321    fn test_new_unchecked() {
322        // Should create without validation (for internal use)
323        let wallet = WalletAddress::new_unchecked("invalid".to_string());
324        assert_eq!(wallet.as_str(), "invalid");
325    }
326}