Skip to main content

yldfi_common/
eth.rs

1//! Ethereum utilities for address and transaction hash validation
2//!
3//! This module provides:
4//! - Validation functions: `is_valid_address`, `is_valid_tx_hash`, etc.
5//! - Newtype wrappers: `Address`, `TxHash` for type-safe validated values
6//!
7//! # Example
8//!
9//! ```
10//! use yldfi_common::eth::{Address, TxHash};
11//! use std::str::FromStr;
12//!
13//! // Parse and validate an address
14//! let addr = Address::from_str("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045").unwrap();
15//! assert_eq!(addr.as_str(), "0xd8da6bf26964af9d7eed9e03e53415d37aa96045");
16//!
17//! // Invalid addresses are rejected
18//! assert!(Address::from_str("invalid").is_err());
19//! ```
20
21use std::fmt;
22use std::str::FromStr;
23
24// ============================================================================
25// Address Newtype
26// ============================================================================
27
28/// A validated Ethereum address.
29///
30/// This type guarantees that the contained string is a valid, normalized
31/// Ethereum address (lowercase with `0x` prefix).
32///
33/// # Example
34///
35/// ```
36/// use yldfi_common::eth::Address;
37/// use std::str::FromStr;
38///
39/// let addr = Address::from_str("0xD8DA6BF26964AF9D7EED9E03E53415D37AA96045").unwrap();
40/// assert_eq!(addr.as_str(), "0xd8da6bf26964af9d7eed9e03e53415d37aa96045");
41/// ```
42#[derive(Debug, Clone, PartialEq, Eq, Hash)]
43pub struct Address(String);
44
45impl Address {
46    /// Create a new Address from a string, validating and normalizing it.
47    ///
48    /// Returns `None` if the address is invalid.
49    #[must_use]
50    pub fn new(address: &str) -> Option<Self> {
51        normalize_address(address).map(Self)
52    }
53
54    /// Get the address as a string slice.
55    #[must_use]
56    pub fn as_str(&self) -> &str {
57        &self.0
58    }
59
60    /// Convert to the inner String.
61    #[must_use]
62    pub fn into_inner(self) -> String {
63        self.0
64    }
65
66    /// The zero address (0x0000...0000).
67    #[must_use]
68    pub fn zero() -> Self {
69        Self("0x0000000000000000000000000000000000000000".to_string())
70    }
71
72    /// Check if this is the zero address.
73    #[must_use]
74    pub fn is_zero(&self) -> bool {
75        self.0 == "0x0000000000000000000000000000000000000000"
76    }
77}
78
79impl fmt::Display for Address {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        write!(f, "{}", self.0)
82    }
83}
84
85impl FromStr for Address {
86    type Err = AddressParseError;
87
88    fn from_str(s: &str) -> Result<Self, Self::Err> {
89        Self::new(s).ok_or(AddressParseError)
90    }
91}
92
93impl AsRef<str> for Address {
94    fn as_ref(&self) -> &str {
95        &self.0
96    }
97}
98
99/// Error returned when parsing an invalid address.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub struct AddressParseError;
102
103impl fmt::Display for AddressParseError {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        write!(f, "invalid Ethereum address")
106    }
107}
108
109impl std::error::Error for AddressParseError {}
110
111// ============================================================================
112// TxHash Newtype
113// ============================================================================
114
115/// A validated Ethereum transaction hash (32 bytes).
116///
117/// This type guarantees that the contained string is a valid, normalized
118/// transaction hash (lowercase with `0x` prefix, 66 characters total).
119#[derive(Debug, Clone, PartialEq, Eq, Hash)]
120pub struct TxHash(String);
121
122impl TxHash {
123    /// Create a new `TxHash` from a string, validating and normalizing it.
124    ///
125    /// Returns `None` if the hash is invalid.
126    #[must_use]
127    pub fn new(hash: &str) -> Option<Self> {
128        if is_valid_tx_hash(hash) {
129            Some(Self(hash.to_lowercase()))
130        } else {
131            None
132        }
133    }
134
135    /// Get the hash as a string slice.
136    #[must_use]
137    pub fn as_str(&self) -> &str {
138        &self.0
139    }
140
141    /// Convert to the inner String.
142    #[must_use]
143    pub fn into_inner(self) -> String {
144        self.0
145    }
146}
147
148impl fmt::Display for TxHash {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        write!(f, "{}", self.0)
151    }
152}
153
154impl FromStr for TxHash {
155    type Err = TxHashParseError;
156
157    fn from_str(s: &str) -> Result<Self, Self::Err> {
158        Self::new(s).ok_or(TxHashParseError)
159    }
160}
161
162impl AsRef<str> for TxHash {
163    fn as_ref(&self) -> &str {
164        &self.0
165    }
166}
167
168/// Error returned when parsing an invalid transaction hash.
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub struct TxHashParseError;
171
172impl fmt::Display for TxHashParseError {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        write!(f, "invalid transaction hash")
175    }
176}
177
178impl std::error::Error for TxHashParseError {}
179
180// ============================================================================
181// Validation Functions
182// ============================================================================
183
184/// Validates an Ethereum address format.
185///
186/// Returns `true` if the address:
187/// - Is a 40-character hex string prefixed with `0x` (42 chars total)
188/// - Contains only valid hexadecimal characters (0-9, a-f, A-F)
189///
190/// Note: This is format validation only. It does not perform checksum validation.
191///
192/// # Examples
193///
194/// ```
195/// use yldfi_common::eth::is_valid_address;
196///
197/// assert!(is_valid_address("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"));
198/// assert!(is_valid_address("0x0000000000000000000000000000000000000000"));
199/// assert!(!is_valid_address("invalid"));
200/// assert!(!is_valid_address("0x123")); // Too short
201/// ```
202#[must_use]
203pub fn is_valid_address(address: &str) -> bool {
204    if !address.starts_with("0x") && !address.starts_with("0X") {
205        return false;
206    }
207
208    if address.len() != 42 {
209        return false;
210    }
211
212    address[2..].chars().all(|c| c.is_ascii_hexdigit())
213}
214
215/// Normalizes an Ethereum address to lowercase with `0x` prefix.
216///
217/// Returns `None` if the address is not valid.
218///
219/// # Examples
220///
221/// ```
222/// use yldfi_common::eth::normalize_address;
223///
224/// assert_eq!(
225///     normalize_address("0xD8DA6BF26964AF9D7EED9E03E53415D37AA96045"),
226///     Some("0xd8da6bf26964af9d7eed9e03e53415d37aa96045".to_string())
227/// );
228/// assert_eq!(normalize_address("invalid"), None);
229/// ```
230#[must_use]
231pub fn normalize_address(address: &str) -> Option<String> {
232    if !is_valid_address(address) {
233        return None;
234    }
235    Some(address.to_lowercase())
236}
237
238/// Validates a transaction hash format.
239///
240/// Returns `true` if the hash:
241/// - Is a 64-character hex string prefixed with `0x` (66 chars total)
242/// - Contains only valid hexadecimal characters
243///
244/// # Examples
245///
246/// ```
247/// use yldfi_common::eth::is_valid_tx_hash;
248///
249/// assert!(is_valid_tx_hash("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"));
250/// assert!(!is_valid_tx_hash("0x123")); // Too short
251/// assert!(!is_valid_tx_hash("invalid"));
252/// ```
253#[must_use]
254pub fn is_valid_tx_hash(hash: &str) -> bool {
255    if !hash.starts_with("0x") && !hash.starts_with("0X") {
256        return false;
257    }
258
259    if hash.len() != 66 {
260        return false;
261    }
262
263    hash[2..].chars().all(|c| c.is_ascii_hexdigit())
264}
265
266/// Validates a bytes32 hash format (same as tx hash).
267///
268/// Alias for `is_valid_tx_hash` for semantic clarity when validating
269/// block hashes, storage slots, or other 32-byte values.
270#[must_use]
271pub fn is_valid_bytes32(hash: &str) -> bool {
272    is_valid_tx_hash(hash)
273}
274
275/// Pads a hex string to 32 bytes (64 hex chars + 0x prefix).
276///
277/// Useful for storage slots and other 32-byte values.
278///
279/// # Examples
280///
281/// ```
282/// use yldfi_common::eth::pad_to_32_bytes;
283///
284/// assert_eq!(pad_to_32_bytes("0x1"), "0x0000000000000000000000000000000000000000000000000000000000000001");
285/// assert_eq!(pad_to_32_bytes("0x64"), "0x0000000000000000000000000000000000000000000000000000000000000064");
286/// assert_eq!(pad_to_32_bytes("1"), "0x0000000000000000000000000000000000000000000000000000000000000001");
287/// ```
288#[must_use]
289pub fn pad_to_32_bytes(value: &str) -> String {
290    let hex = value
291        .strip_prefix("0x")
292        .or_else(|| value.strip_prefix("0X"))
293        .unwrap_or(value);
294    format!("0x{hex:0>64}")
295}
296
297/// HTTP status code classification for API responses
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub enum HttpStatusKind {
300    /// 2xx - Success
301    Success,
302    /// 400 - Bad request
303    BadRequest,
304    /// 401 - Unauthorized
305    Unauthorized,
306    /// 403 - Forbidden
307    Forbidden,
308    /// 404 - Not found
309    NotFound,
310    /// 429 - Rate limited
311    RateLimited,
312    /// 5xx - Server error
313    ServerError,
314    /// Other status codes
315    Other,
316}
317
318impl HttpStatusKind {
319    /// Classify an HTTP status code
320    #[must_use]
321    pub fn from_status(status: u16) -> Self {
322        match status {
323            200..=299 => Self::Success,
324            400 => Self::BadRequest,
325            401 => Self::Unauthorized,
326            403 => Self::Forbidden,
327            404 => Self::NotFound,
328            429 => Self::RateLimited,
329            500..=599 => Self::ServerError,
330            _ => Self::Other,
331        }
332    }
333
334    /// Check if this status is retryable
335    #[must_use]
336    pub fn is_retryable(&self) -> bool {
337        matches!(self, Self::RateLimited | Self::ServerError)
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn test_valid_addresses() {
347        assert!(is_valid_address(
348            "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
349        ));
350        assert!(is_valid_address(
351            "0x0000000000000000000000000000000000000000"
352        ));
353        assert!(is_valid_address(
354            "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
355        ));
356        assert!(is_valid_address(
357            "0XABCDEF1234567890ABCDEF1234567890ABCDEF12"
358        ));
359    }
360
361    #[test]
362    fn test_invalid_addresses() {
363        assert!(!is_valid_address(""));
364        assert!(!is_valid_address("0x"));
365        assert!(!is_valid_address("0x123"));
366        assert!(!is_valid_address("invalid"));
367        assert!(!is_valid_address(
368            "d8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
369        )); // No prefix
370        assert!(!is_valid_address(
371            "0xGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG"
372        )); // Invalid hex
373    }
374
375    #[test]
376    fn test_normalize_address() {
377        assert_eq!(
378            normalize_address("0xD8DA6BF26964AF9D7EED9E03E53415D37AA96045"),
379            Some("0xd8da6bf26964af9d7eed9e03e53415d37aa96045".to_string())
380        );
381        assert_eq!(normalize_address("invalid"), None);
382    }
383
384    #[test]
385    fn test_valid_tx_hashes() {
386        assert!(is_valid_tx_hash(
387            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
388        ));
389        assert!(is_valid_tx_hash(
390            "0xABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890"
391        ));
392    }
393
394    #[test]
395    fn test_invalid_tx_hashes() {
396        assert!(!is_valid_tx_hash("0x123"));
397        assert!(!is_valid_tx_hash("invalid"));
398        assert!(!is_valid_tx_hash(""));
399    }
400
401    #[test]
402    fn test_pad_to_32_bytes() {
403        assert_eq!(
404            pad_to_32_bytes("0x1"),
405            "0x0000000000000000000000000000000000000000000000000000000000000001"
406        );
407        assert_eq!(
408            pad_to_32_bytes("1"),
409            "0x0000000000000000000000000000000000000000000000000000000000000001"
410        );
411        assert_eq!(
412            pad_to_32_bytes("0x64"),
413            "0x0000000000000000000000000000000000000000000000000000000000000064"
414        );
415    }
416
417    #[test]
418    fn test_http_status_kind() {
419        assert_eq!(HttpStatusKind::from_status(200), HttpStatusKind::Success);
420        assert_eq!(
421            HttpStatusKind::from_status(401),
422            HttpStatusKind::Unauthorized
423        );
424        assert_eq!(
425            HttpStatusKind::from_status(429),
426            HttpStatusKind::RateLimited
427        );
428        assert_eq!(
429            HttpStatusKind::from_status(500),
430            HttpStatusKind::ServerError
431        );
432        assert_eq!(
433            HttpStatusKind::from_status(503),
434            HttpStatusKind::ServerError
435        );
436
437        assert!(HttpStatusKind::RateLimited.is_retryable());
438        assert!(HttpStatusKind::ServerError.is_retryable());
439        assert!(!HttpStatusKind::Unauthorized.is_retryable());
440    }
441
442    #[test]
443    fn test_address_newtype() {
444        // Valid address
445        let addr = Address::new("0xD8DA6BF26964AF9D7EED9E03E53415D37AA96045");
446        assert!(addr.is_some());
447        let addr = addr.unwrap();
448        assert_eq!(addr.as_str(), "0xd8da6bf26964af9d7eed9e03e53415d37aa96045");
449        assert!(!addr.is_zero());
450
451        // Invalid address
452        assert!(Address::new("invalid").is_none());
453        assert!(Address::new("0x123").is_none());
454
455        // Zero address
456        let zero = Address::zero();
457        assert!(zero.is_zero());
458
459        // FromStr
460        let parsed: Address = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
461            .parse()
462            .unwrap();
463        assert_eq!(addr, parsed);
464
465        // Invalid FromStr
466        let err = "invalid".parse::<Address>();
467        assert!(err.is_err());
468    }
469
470    #[test]
471    fn test_tx_hash_newtype() {
472        // Valid hash
473        let hash =
474            TxHash::new("0x1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF");
475        assert!(hash.is_some());
476        let hash = hash.unwrap();
477        assert_eq!(
478            hash.as_str(),
479            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
480        );
481
482        // Invalid hash
483        assert!(TxHash::new("invalid").is_none());
484        assert!(TxHash::new("0x123").is_none());
485
486        // FromStr
487        let parsed: TxHash = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
488            .parse()
489            .unwrap();
490        assert_eq!(hash, parsed);
491    }
492}