Skip to main content

hypercall_sdk_types/
wallet_address.rs

1//! Canonical Ethereum wallet address type for Hypercall.
2
3use std::{cmp::Ordering, fmt, str::FromStr};
4
5use alloy::hex::FromHexError;
6use alloy::primitives::Address;
7use serde::{de::Error as _, Deserialize, Deserializer, Serialize, Serializer};
8
9// Diesel imports - only when diesel is available
10#[cfg(feature = "database")]
11use diesel::{
12    backend::Backend,
13    deserialize::{self, FromSql as DieselFromSql},
14    pg::Pg,
15    serialize::{self, Output, ToSql as DieselToSql},
16    sql_types::Bytea,
17    AsExpression, FromSqlRow,
18};
19
20/// Canonical representation of an Ethereum wallet address for Hypercall.
21///
22/// This wraps alloy's [`Address`] type so we consistently work with the
23/// 20-byte value internally while keeping serde compatibility with hex strings.
24///
25/// Storage:
26/// - In-memory: 20-byte alloy `Address`
27/// - JSON/wire: Checksummed hex string with `0x` prefix (via serde)
28/// - PostgreSQL: BYTEA (20 bytes) via Diesel and sqlx
29#[derive(Clone, Copy, Eq, PartialEq, Hash, Debug, Default)]
30#[cfg_attr(feature = "database", derive(AsExpression, FromSqlRow))]
31#[cfg_attr(feature = "database", diesel(sql_type = Bytea))]
32#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
33#[cfg_attr(feature = "utoipa", schema(value_type = String, example = "0x1234567890abcdef1234567890abcdef12345678"))]
34pub struct WalletAddress(pub Address);
35
36impl WalletAddress {
37    /// Returns the inner alloy [`Address`].
38    pub fn inner(&self) -> Address {
39        self.0
40    }
41
42    /// Formats the address as a checksummed hex string with `0x` prefix.
43    pub fn as_hex(&self) -> String {
44        format!("{:#x}", self.0)
45    }
46
47    /// Returns the raw 20-byte slice.
48    pub fn as_bytes(&self) -> &[u8; 20] {
49        self.0.as_ref()
50    }
51}
52
53impl fmt::Display for WalletAddress {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        write!(f, "{:#x}", self.0)
56    }
57}
58
59impl From<Address> for WalletAddress {
60    fn from(addr: Address) -> Self {
61        WalletAddress(addr)
62    }
63}
64
65impl From<[u8; 20]> for WalletAddress {
66    fn from(bytes: [u8; 20]) -> Self {
67        WalletAddress(Address::from(bytes))
68    }
69}
70
71impl FromStr for WalletAddress {
72    type Err = FromHexError;
73
74    fn from_str(s: &str) -> Result<Self, Self::Err> {
75        let addr = Address::from_str(s)?;
76        Ok(WalletAddress(addr))
77    }
78}
79
80impl Serialize for WalletAddress {
81    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
82        serializer.serialize_str(&self.as_hex())
83    }
84}
85
86impl<'de> Deserialize<'de> for WalletAddress {
87    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
88        let s = String::deserialize(deserializer)?;
89        WalletAddress::from_str(&s).map_err(D::Error::custom)
90    }
91}
92
93impl PartialOrd for WalletAddress {
94    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
95        Some(self.cmp(other))
96    }
97}
98
99impl Ord for WalletAddress {
100    fn cmp(&self, other: &Self) -> Ordering {
101        self.0.as_slice().cmp(other.0.as_slice())
102    }
103}
104
105// === Diesel implementations (for PostgreSQL BYTEA) ===
106
107#[cfg(feature = "database")]
108impl DieselToSql<Bytea, Pg> for WalletAddress {
109    fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
110        use std::io::Write;
111        out.write_all(self.0.as_slice())?;
112        Ok(diesel::serialize::IsNull::No)
113    }
114}
115
116#[cfg(feature = "database")]
117impl DieselFromSql<Bytea, Pg> for WalletAddress {
118    fn from_sql(bytes: <Pg as Backend>::RawValue<'_>) -> deserialize::Result<Self> {
119        let slice = bytes.as_bytes();
120        if slice.len() != 20 {
121            return Err(format!(
122                "Invalid wallet address length: expected 20 bytes, got {}",
123                slice.len()
124            )
125            .into());
126        }
127        let mut arr = [0u8; 20];
128        arr.copy_from_slice(slice);
129        Ok(WalletAddress::from(arr))
130    }
131}
132
133// === SQLx implementations (for PostgreSQL BYTEA) ===
134
135#[cfg(feature = "database")]
136impl sqlx::Type<sqlx::Postgres> for WalletAddress {
137    fn type_info() -> sqlx::postgres::PgTypeInfo {
138        <Vec<u8> as sqlx::Type<sqlx::Postgres>>::type_info()
139    }
140}
141
142#[cfg(feature = "database")]
143impl<'r> sqlx::Decode<'r, sqlx::Postgres> for WalletAddress {
144    fn decode(
145        value: sqlx::postgres::PgValueRef<'r>,
146    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync + 'static>> {
147        let bytes = <Vec<u8> as sqlx::Decode<sqlx::Postgres>>::decode(value)?;
148        if bytes.len() != 20 {
149            return Err(format!(
150                "Invalid wallet address length: expected 20 bytes, got {}",
151                bytes.len()
152            )
153            .into());
154        }
155        let mut arr = [0u8; 20];
156        arr.copy_from_slice(&bytes);
157        Ok(WalletAddress::from(arr))
158    }
159}
160
161#[cfg(feature = "database")]
162impl sqlx::Encode<'_, sqlx::Postgres> for WalletAddress {
163    fn encode_by_ref(&self, buf: &mut sqlx::postgres::PgArgumentBuffer) -> sqlx::encode::IsNull {
164        <&[u8] as sqlx::Encode<sqlx::Postgres>>::encode(self.0.as_slice(), buf)
165    }
166}
167
168// =============================================================================
169// Test Helper - Single source of truth for test wallet addresses
170// =============================================================================
171
172/// Creates a deterministic test wallet address from an ID.
173///
174/// This is the canonical way to create wallet addresses in tests.
175/// The address is constructed with zeros except for the last byte which is the ID.
176///
177/// # Example
178/// ```
179/// use hypercall_types::wallet_address::test_wallet;
180///
181/// let wallet = test_wallet(1);
182/// assert_eq!(wallet.as_hex(), "0x0000000000000000000000000000000000000001");
183/// ```
184#[cfg(any(test, feature = "test-utils"))]
185pub fn test_wallet(id: u8) -> WalletAddress {
186    let mut bytes = [0u8; 20];
187    bytes[19] = id;
188    WalletAddress::from(bytes)
189}
190
191/// Macro for creating test wallet addresses.
192///
193/// This provides a convenient way to create test addresses inline.
194/// While not truly compile-time (Address construction is runtime),
195/// it's ergonomic and type-safe.
196///
197/// # Example
198/// ```ignore
199/// let wallet = test_wallet!(42);
200/// ```
201#[cfg(any(test, feature = "test-utils"))]
202#[macro_export]
203macro_rules! test_wallet {
204    ($id:expr) => {{
205        let mut bytes = [0u8; 20];
206        bytes[19] = $id;
207        $crate::wallet_address::WalletAddress::from(alloy::primitives::Address::from(bytes))
208    }};
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn test_wallet_address_roundtrip() {
217        let addr_str = "0x1234567890abcdef1234567890abcdef12345678";
218        let wallet = WalletAddress::from_str(addr_str).unwrap();
219        assert_eq!(wallet.as_hex().to_lowercase(), addr_str.to_lowercase());
220    }
221
222    #[test]
223    fn test_wallet_address_ordering() {
224        let a = WalletAddress::from_str("0x0000000000000000000000000000000000000001").unwrap();
225        let b = WalletAddress::from_str("0x0000000000000000000000000000000000000002").unwrap();
226        assert!(a < b);
227    }
228
229    #[test]
230    fn test_serde_roundtrip() {
231        let addr_str = "0x1234567890abcdef1234567890abcdef12345678";
232        let wallet = WalletAddress::from_str(addr_str).unwrap();
233        let json = sonic_rs::to_string(&wallet).unwrap();
234        let parsed: WalletAddress = sonic_rs::from_str(&json).unwrap();
235        assert_eq!(wallet, parsed);
236    }
237
238    #[test]
239    fn test_test_wallet_helper() {
240        let w1 = test_wallet(1);
241        let w2 = test_wallet(2);
242        let w1_again = test_wallet(1);
243
244        assert_ne!(w1, w2);
245        assert_eq!(w1, w1_again);
246        assert!(w1 < w2);
247
248        // Verify the address format
249        assert_eq!(w1.as_hex(), "0x0000000000000000000000000000000000000001");
250        assert_eq!(w2.as_hex(), "0x0000000000000000000000000000000000000002");
251    }
252}