Skip to main content

nibiru_std/
address.rs

1//! Address conversion utilities for Nibiru
2
3use std::{fmt, str::FromStr};
4
5use bech32::{self, FromBase32, ToBase32, Variant};
6use cosmwasm_schema::schemars::{
7    gen::SchemaGenerator, schema::Schema, JsonSchema,
8};
9use cosmwasm_std::Addr;
10use serde::{de::Error as _, Deserialize, Deserializer, Serialize, Serializer};
11use tiny_keccak::{Hasher, Keccak};
12
13use crate::errors::{NibiruError, NibiruResult};
14
15/// Byte length shared by Nibiru and EVM externally owned accounts.
16pub const USER_ADDR_LEN: usize = 20;
17
18/// A validated Nibiru externally owned account.
19///
20/// JSON input is a Nibiru bech32 address or a `0x`-prefixed 20-byte EVM
21/// address. JSON output is canonical EIP-55 hex. CosmWasm contract addresses
22/// are deliberately excluded because they use a different byte length.
23#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
24pub struct UserAddr([u8; USER_ADDR_LEN]);
25
26impl UserAddr {
27    /// Returns the canonical EIP-55 representation.
28    pub fn to_hex(self) -> String {
29        eip55_checksum_hex(&self.0)
30    }
31
32    /// Returns the equivalent canonical Nibiru bech32 address.
33    pub fn to_bech32_addr(self) -> Addr {
34        let encoded =
35            bech32::encode("nibi", self.0.to_base32(), Variant::Bech32)
36                .expect("fixed Nibiru HRP and 20-byte payload are valid");
37        Addr::unchecked(encoded)
38    }
39
40    /// Returns the underlying 20-byte account identity.
41    pub fn as_bytes(&self) -> &[u8; USER_ADDR_LEN] {
42        &self.0
43    }
44
45    fn from_bech32(input: &str) -> NibiruResult<Self> {
46        let (hrp, data, variant) = bech32::decode(input)?;
47        if hrp != "nibi" {
48            return Err(NibiruError::InvalidBech32Prefix {
49                expected: "nibi".to_string(),
50                actual: hrp,
51            });
52        }
53        if variant != Variant::Bech32 {
54            return Err(NibiruError::InvalidEthAddress(
55                "Nibiru user address must use the Bech32 checksum variant"
56                    .to_string(),
57            ));
58        }
59
60        let bytes = Vec::<u8>::from_base32(&data)?;
61        let bytes: [u8; USER_ADDR_LEN] = bytes.try_into().map_err(
62            |bytes: Vec<u8>| {
63                NibiruError::InvalidEthAddress(format!(
64                    "Nibiru user address must decode to {USER_ADDR_LEN} bytes, got {}",
65                    bytes.len()
66                ))
67            },
68        )?;
69        Ok(Self(bytes))
70    }
71
72    fn from_hex(input: &str) -> NibiruResult<Self> {
73        let hex = input
74            .strip_prefix("0x")
75            .or_else(|| input.strip_prefix("0X"))
76            .ok_or_else(|| {
77                NibiruError::InvalidEthAddress(
78                    "EVM user address must start with 0x".to_string(),
79                )
80            })?;
81        if hex.len() != USER_ADDR_LEN * 2 {
82            return Err(NibiruError::InvalidEthAddress(format!(
83                "EVM user address must contain 40 hex characters, got {}",
84                hex.len()
85            )));
86        }
87        let bytes = hex::decode(hex)?;
88        let bytes: [u8; USER_ADDR_LEN] = bytes.try_into().map_err(
89            |bytes: Vec<u8>| {
90                NibiruError::InvalidEthAddress(format!(
91                    "EVM user address must decode to {USER_ADDR_LEN} bytes, got {}",
92                    bytes.len()
93                ))
94            },
95        )?;
96        Ok(Self(bytes))
97    }
98}
99
100impl FromStr for UserAddr {
101    type Err = NibiruError;
102
103    fn from_str(input: &str) -> Result<Self, Self::Err> {
104        let input = input.trim();
105        if input.is_empty() {
106            return Err(NibiruError::InvalidEthAddress(
107                "user address is empty".to_string(),
108            ));
109        }
110
111        if input.to_ascii_lowercase().starts_with("nibi1") {
112            Self::from_bech32(input)
113        } else {
114            Self::from_hex(input)
115        }
116    }
117}
118
119impl fmt::Display for UserAddr {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        f.write_str(&self.to_hex())
122    }
123}
124
125impl Serialize for UserAddr {
126    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
127    where
128        S: Serializer,
129    {
130        serializer.serialize_str(&self.to_hex())
131    }
132}
133
134impl<'de> Deserialize<'de> for UserAddr {
135    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
136    where
137        D: Deserializer<'de>,
138    {
139        String::deserialize(deserializer)?
140            .parse()
141            .map_err(D::Error::custom)
142    }
143}
144
145impl JsonSchema for UserAddr {
146    fn schema_name() -> String {
147        "UserAddr".to_string()
148    }
149
150    fn json_schema(generator: &mut SchemaGenerator) -> Schema {
151        let mut schema = String::json_schema(generator);
152        if let Schema::Object(object) = &mut schema {
153            object.metadata().description = Some(
154                "A Nibiru externally owned account. Input accepts a Nibiru bech32 address or a 0x-prefixed 20-byte EVM address; output uses EIP-55 hex."
155                    .to_string(),
156            );
157        }
158        schema
159    }
160}
161
162fn eip55_checksum_hex(bytes: &[u8; USER_ADDR_LEN]) -> String {
163    let lowercase = hex::encode(bytes);
164    let mut hash = [0u8; 32];
165    let mut hasher = Keccak::v256();
166    hasher.update(lowercase.as_bytes());
167    hasher.finalize(&mut hash);
168
169    let mut output = String::with_capacity(42);
170    output.push_str("0x");
171    for (index, ch) in lowercase.chars().enumerate() {
172        let nibble = if index % 2 == 0 {
173            hash[index / 2] >> 4
174        } else {
175            hash[index / 2] & 0x0f
176        };
177        if ch.is_ascii_alphabetic() && nibble >= 8 {
178            output.push(ch.to_ascii_uppercase());
179        } else {
180            output.push(ch);
181        }
182    }
183    output
184}
185
186/// Converts a Nibiru bech32 address to an Ethereum hex address.
187///
188/// This function decodes a bech32-encoded Nibiru address (with "nibi" prefix)
189/// and converts it to an Ethereum-compatible hex address by taking the first
190/// 20 bytes of the decoded data.
191///
192/// # Arguments
193///
194/// * `bech32_addr` - A bech32-encoded Nibiru address string (e.g., "nibi1...")
195///
196/// # Returns
197///
198/// * `Ok(String)` - The Ethereum hex address prefixed with "0x"
199/// * `Err(NibiruError)` - If the address is invalid, has wrong prefix, or is too short
200///
201/// # Example
202///
203/// ```
204/// use nibiru_std::address::nibiru_bech32_to_eth_address;
205///
206/// let bech32_addr = "nibi1gc24lt74ses9swkq6g7cug4e5y72p7e34jqgul";
207/// let eth_addr = nibiru_bech32_to_eth_address(bech32_addr).unwrap();
208/// assert_eq!(eth_addr, "0x46155fafd58660583ac0d23d8e22b9a13ca0fb31");
209/// ```
210pub fn nibiru_bech32_to_eth_address(bech32_addr: &str) -> NibiruResult<String> {
211    // Decode the bech32 address
212    let (hrp, data, _variant) = bech32::decode(bech32_addr)?;
213
214    // Verify the human-readable part is "nibi"
215    if hrp != "nibi" {
216        return Err(NibiruError::InvalidBech32Prefix {
217            expected: "nibi".to_string(),
218            actual: hrp,
219        });
220    }
221
222    // Convert from base32 to bytes
223    let bytes = Vec::<u8>::from_base32(&data)?;
224
225    // Ethereum addresses are 20 bytes
226    if bytes.len() < 20 {
227        return Err(NibiruError::InvalidAddressLength);
228    }
229
230    // Take the first 20 bytes and format as hex with 0x prefix
231    let eth_addr = format!("0x{}", hex::encode(&bytes[..20]));
232    Ok(eth_addr)
233}
234
235/// Converts an Ethereum hex address to a Nibiru bech32 address.
236///
237/// This function takes an Ethereum address in hex format (with or without "0x" prefix)
238/// and converts it to a bech32-encoded Nibiru address with "nibi" prefix.
239///
240/// # Arguments
241///
242/// * `eth_addr` - An Ethereum address as a hex string (e.g., "0x..." or just the hex)
243///
244/// # Returns
245///
246/// * `Ok(String)` - The Nibiru bech32 address
247/// * `Err(NibiruError)` - If the address is invalid or not exactly 20 bytes
248///
249/// # Example
250///
251/// ```
252/// use nibiru_std::address::eth_address_to_nibiru_bech32;
253///
254/// let eth_addr = "0x46155fAfd58660583ac0d23d8E22B9A13Ca0fb31";
255/// let bech32_addr = eth_address_to_nibiru_bech32(eth_addr).unwrap();
256/// assert_eq!(bech32_addr, "nibi1gc24lt74ses9swkq6g7cug4e5y72p7e34jqgul");
257/// ```
258pub fn eth_address_to_nibiru_bech32(eth_addr: &str) -> NibiruResult<String> {
259    // Remove "0x" prefix if present
260    let hex_str = eth_addr.strip_prefix("0x").unwrap_or(eth_addr);
261
262    // Validate hex string length (20 bytes = 40 hex chars)
263    if hex_str.len() != 40 {
264        return Err(NibiruError::InvalidEthAddress(format!(
265            "Ethereum address must be 20 bytes (40 hex chars), got {} chars",
266            hex_str.len()
267        )));
268    }
269
270    // Decode hex to bytes
271    let bytes = hex::decode(hex_str)?;
272
273    // Sanity check: should be exactly 20 bytes
274    if bytes.len() != 20 {
275        return Err(NibiruError::InvalidEthAddress(format!(
276            "Invalid Ethereum address length: expected 20 bytes, got {}",
277            bytes.len()
278        )));
279    }
280
281    // Encode as bech32 with "nibi" prefix
282    let bech32_addr =
283        bech32::encode("nibi", bytes.to_base32(), bech32::Variant::Bech32)?;
284    Ok(bech32_addr)
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn test_nibiru_bech32_to_eth_address_valid() {
293        // Test case from the Go implementation
294        let bech32_addr = "nibi1gc24lt74ses9swkq6g7cug4e5y72p7e34jqgul";
295        let expected_eth = "0x46155fafd58660583ac0d23d8e22b9a13ca0fb31";
296
297        let result = nibiru_bech32_to_eth_address(bech32_addr).unwrap();
298        assert_eq!(result.to_lowercase(), expected_eth);
299    }
300
301    #[test]
302    fn test_nibiru_bech32_to_eth_address_invalid_prefix() {
303        // Valid bech32 address but with cosmos prefix instead of nibi
304        let bech32_addr = "cosmos1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqnrql8a";
305
306        let result = nibiru_bech32_to_eth_address(bech32_addr);
307        match result {
308            Err(NibiruError::InvalidBech32Prefix { expected, actual }) => {
309                assert_eq!(expected, "nibi");
310                assert_eq!(actual, "cosmos");
311            }
312            _ => panic!("Expected InvalidBech32Prefix error, got: {:?}", result),
313        }
314    }
315
316    #[test]
317    fn test_nibiru_bech32_to_eth_address_invalid_bech32() {
318        let invalid_addr = "nibi1invalid!@#$";
319
320        let result = nibiru_bech32_to_eth_address(invalid_addr);
321        assert!(matches!(result, Err(NibiruError::Bech32Error(_))));
322    }
323
324    #[test]
325    fn test_nibiru_bech32_to_eth_address_length_validation() {
326        // Test that we properly validate address length
327        // We'll use a test helper to create a short address
328        use bech32::ToBase32;
329
330        // Create a short address with only 10 bytes (need 20 for Ethereum)
331        let short_data = vec![0u8; 10];
332        let short_addr = bech32::encode(
333            "nibi",
334            short_data.to_base32(),
335            bech32::Variant::Bech32,
336        )
337        .unwrap();
338
339        let result = nibiru_bech32_to_eth_address(&short_addr);
340        match result {
341            Err(NibiruError::InvalidAddressLength) => {}
342            _ => {
343                panic!("Expected InvalidAddressLength error, got: {:?}", result)
344            }
345        }
346    }
347
348    #[test]
349    fn test_nibiru_bech32_to_eth_address_case_sensitivity() {
350        // Test that the output maintains proper case
351        let bech32_addr = "nibi1gc24lt74ses9swkq6g7cug4e5y72p7e34jqgul";
352        let result = nibiru_bech32_to_eth_address(bech32_addr).unwrap();
353
354        // The hex should have lowercase letters after 0x
355        assert!(result.starts_with("0x"));
356        // But we'll compare case-insensitively for the actual value
357        assert_eq!(
358            result.to_lowercase(),
359            "0x46155fafd58660583ac0d23d8e22b9a13ca0fb31"
360        );
361    }
362
363    #[test]
364    fn test_eth_address_to_nibiru_bech32_valid() {
365        // Test case matching the Go implementation
366        let eth_addr = "0x46155fAfd58660583ac0d23d8E22B9A13Ca0fb31";
367        let expected_bech32 = "nibi1gc24lt74ses9swkq6g7cug4e5y72p7e34jqgul";
368
369        let result = eth_address_to_nibiru_bech32(eth_addr).unwrap();
370        assert_eq!(result, expected_bech32);
371    }
372
373    #[test]
374    fn test_eth_address_to_nibiru_bech32_without_prefix() {
375        // Test without 0x prefix
376        let eth_addr = "46155fAfd58660583ac0d23d8E22B9A13Ca0fb31";
377        let expected_bech32 = "nibi1gc24lt74ses9swkq6g7cug4e5y72p7e34jqgul";
378
379        let result = eth_address_to_nibiru_bech32(eth_addr).unwrap();
380        assert_eq!(result, expected_bech32);
381    }
382
383    #[test]
384    fn test_eth_address_to_nibiru_bech32_lowercase() {
385        // Test with lowercase hex
386        let eth_addr = "0x46155fafd58660583ac0d23d8e22b9a13ca0fb31";
387        let expected_bech32 = "nibi1gc24lt74ses9swkq6g7cug4e5y72p7e34jqgul";
388
389        let result = eth_address_to_nibiru_bech32(eth_addr).unwrap();
390        assert_eq!(result, expected_bech32);
391    }
392
393    #[test]
394    fn test_eth_address_to_nibiru_bech32_invalid_length() {
395        // Too short
396        let short_addr = "0x46155fafd58660583ac0d23d8e22b9a13ca0fb";
397        let result = eth_address_to_nibiru_bech32(short_addr);
398        match result {
399            Err(NibiruError::InvalidEthAddress(msg)) => {
400                assert!(msg.contains("40 hex chars"));
401            }
402            _ => panic!("Expected InvalidEthAddress error"),
403        }
404
405        // Too long
406        let long_addr = "0x46155fafd58660583ac0d23d8e22b9a13ca0fb3100";
407        let result = eth_address_to_nibiru_bech32(long_addr);
408        match result {
409            Err(NibiruError::InvalidEthAddress(msg)) => {
410                assert!(msg.contains("40 hex chars"));
411            }
412            _ => panic!("Expected InvalidEthAddress error"),
413        }
414    }
415
416    #[test]
417    fn test_eth_address_to_nibiru_bech32_invalid_hex() {
418        // Invalid hex characters
419        let invalid_addr = "0x46155fXXd58660583ac0d23d8e22b9a13ca0fb31";
420        let result = eth_address_to_nibiru_bech32(invalid_addr);
421        assert!(matches!(result, Err(NibiruError::HexError(_))));
422    }
423
424    #[test]
425    fn test_round_trip_conversion() {
426        // Test that converting back and forth gives the same result
427        let original_bech32 = "nibi1gc24lt74ses9swkq6g7cug4e5y72p7e34jqgul";
428
429        // Convert to Ethereum
430        let eth_addr = nibiru_bech32_to_eth_address(original_bech32).unwrap();
431
432        // Convert back to bech32
433        let result_bech32 = eth_address_to_nibiru_bech32(&eth_addr).unwrap();
434
435        assert_eq!(original_bech32, result_bech32);
436    }
437
438    #[test]
439    fn test_multiple_round_trips() {
440        // Test multiple addresses round-trip correctly
441        // Generate some valid test addresses
442        use bech32::ToBase32;
443
444        let test_bytes = vec![
445            vec![0u8; 20],   // All zeros
446            vec![255u8; 20], // All ones
447            vec![
448                1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18,
449                19, 20,
450            ], // Sequential
451        ];
452
453        for bytes in test_bytes {
454            // Create a valid bech32 address
455            let original_bech32 = bech32::encode(
456                "nibi",
457                bytes.to_base32(),
458                bech32::Variant::Bech32,
459            )
460            .unwrap();
461
462            // Convert to Ethereum
463            let eth_addr =
464                nibiru_bech32_to_eth_address(&original_bech32).unwrap();
465
466            // Convert back to bech32
467            let result_bech32 = eth_address_to_nibiru_bech32(&eth_addr).unwrap();
468
469            assert_eq!(
470                original_bech32, result_bech32,
471                "Round trip failed for address"
472            );
473        }
474    }
475
476    #[test]
477    fn user_addr_accepts_equivalent_forms_and_serializes_eip55() {
478        let bech32 = "nibi1gc24lt74ses9swkq6g7cug4e5y72p7e34jqgul";
479        let hex = "0x46155fafd58660583ac0d23d8e22b9a13ca0fb31";
480        let from_bech32: UserAddr = bech32.parse().unwrap();
481        let from_hex: UserAddr = format!("  {hex}  ").parse().unwrap();
482
483        assert_eq!(from_bech32, from_hex);
484        assert_eq!(from_hex.to_bech32_addr(), Addr::unchecked(bech32));
485        assert_eq!(
486            from_hex.to_hex(),
487            "0x46155fAfd58660583ac0d23d8E22B9A13Ca0fb31"
488        );
489        assert_eq!(
490            serde_json::to_string(&from_hex).unwrap(),
491            "\"0x46155fAfd58660583ac0d23d8E22B9A13Ca0fb31\""
492        );
493        assert_eq!(
494            "0X46155FAFD58660583AC0D23D8E22B9A13CA0FB31"
495                .parse::<UserAddr>()
496                .unwrap(),
497            from_hex
498        );
499        let zero = "0x0000000000000000000000000000000000000000"
500            .parse::<UserAddr>()
501            .unwrap();
502        assert_eq!(zero.as_bytes(), &[0; USER_ADDR_LEN]);
503    }
504
505    #[test]
506    fn user_addr_serde_accepts_bech32_and_rejects_non_string_json() {
507        let encoded = "\"nibi1gc24lt74ses9swkq6g7cug4e5y72p7e34jqgul\"";
508        let parsed: UserAddr = serde_json::from_str(encoded).unwrap();
509        assert_eq!(
510            parsed.to_hex(),
511            "0x46155fAfd58660583ac0d23d8E22B9A13Ca0fb31"
512        );
513        assert!(serde_json::from_str::<UserAddr>("[0, 1]").is_err());
514    }
515
516    #[test]
517    fn user_addr_rejects_invalid_encodings_and_contract_addresses() {
518        assert!("46155fafd58660583ac0d23d8e22b9a13ca0fb31"
519            .parse::<UserAddr>()
520            .is_err());
521        assert!("0x1234".parse::<UserAddr>().is_err());
522        assert!("0xzz155fafd58660583ac0d23d8e22b9a13ca0fb31"
523            .parse::<UserAddr>()
524            .is_err());
525        assert!("cosmos1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqnrql8a"
526            .parse::<UserAddr>()
527            .is_err());
528        assert!("nibi1gc24lt74ses9swkq6g7cug4e5y72p7e34jqgum"
529            .parse::<UserAddr>()
530            .is_err());
531
532        let contract =
533            bech32::encode("nibi", [7u8; 32].to_base32(), Variant::Bech32)
534                .unwrap();
535        assert!(contract.parse::<UserAddr>().is_err());
536    }
537
538    #[test]
539    fn user_addr_schema_is_a_string() {
540        let schema = cosmwasm_schema::schema_for!(UserAddr);
541        let json = serde_json::to_value(schema).unwrap();
542        assert_eq!(json["type"], "string");
543        assert!(json["description"].as_str().unwrap().contains("EIP-55"));
544    }
545}