Skip to main content

open_agent_id/
did.rs

1//! DID parsing, validation, and formatting for the `did:oaid` method.
2//!
3//! The V2 DID format is: `did:oaid:{chain}:{agent_address}`
4//!
5//! - `chain`: lowercase chain identifier (e.g. `"base"`, `"base-sepolia"`)
6//! - `agent_address`: `0x` + 40 lowercase hex characters
7//!
8//! Total length must not exceed 80 characters.
9
10use crate::error::Error;
11
12/// Parsed components of a `did:oaid` DID.
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub struct Did {
15    /// The chain identifier (e.g. `"base"`).
16    pub chain: String,
17    /// The agent contract address (`0x` + 40 hex chars, lowercase).
18    pub address: String,
19}
20
21impl Did {
22    /// Parse and validate a DID string.
23    ///
24    /// # Errors
25    ///
26    /// Returns [`Error::InvalidDid`] if the string does not match the
27    /// `did:oaid:{chain}:{0x...}` format.
28    pub fn parse(did: &str) -> Result<Self, Error> {
29        let normalized = did.to_ascii_lowercase();
30
31        if normalized.len() > 80 {
32            return Err(Error::InvalidDid(format!(
33                "DID exceeds 80 characters: {did}"
34            )));
35        }
36
37        let parts: Vec<&str> = normalized.splitn(4, ':').collect();
38        if parts.len() != 4 {
39            return Err(Error::InvalidDid(format!(
40                "expected 4 colon-separated parts: {did}"
41            )));
42        }
43
44        if parts[0] != "did" {
45            return Err(Error::InvalidDid(format!(
46                "must start with 'did:': {did}"
47            )));
48        }
49
50        if parts[1] != "oaid" {
51            return Err(Error::InvalidDid(format!(
52                "method must be 'oaid': {did}"
53            )));
54        }
55
56        let chain = parts[2];
57        if chain.is_empty() || !chain.chars().all(|c| c.is_ascii_lowercase() || c == '-' || c.is_ascii_digit()) {
58            return Err(Error::InvalidDid(format!(
59                "invalid chain identifier: {chain}"
60            )));
61        }
62
63        let address = parts[3];
64        if !is_valid_address(address) {
65            return Err(Error::InvalidDid(format!(
66                "invalid agent address (expected 0x + 40 hex chars): {address}"
67            )));
68        }
69
70        Ok(Self {
71            chain: chain.to_string(),
72            address: address.to_string(),
73        })
74    }
75
76    /// Format this DID back into its canonical string representation.
77    pub fn to_string(&self) -> String {
78        format!("did:oaid:{}:{}", self.chain, self.address)
79    }
80
81    /// Build a DID from chain and address components.
82    ///
83    /// The address is normalized to lowercase.
84    ///
85    /// # Errors
86    ///
87    /// Returns [`Error::InvalidDid`] if the address is not a valid `0x` + 40 hex chars.
88    pub fn new(chain: &str, address: &str) -> Result<Self, Error> {
89        let normalized = address.to_ascii_lowercase();
90        if !is_valid_address(&normalized) {
91            return Err(Error::InvalidDid(format!(
92                "invalid agent address: {address}"
93            )));
94        }
95        let chain = chain.to_ascii_lowercase();
96        Ok(Self {
97            chain,
98            address: normalized,
99        })
100    }
101}
102
103impl std::fmt::Display for Did {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        write!(f, "did:oaid:{}:{}", self.chain, self.address)
106    }
107}
108
109/// Check if a string is a valid Ethereum-style address: `0x` + 40 hex characters.
110fn is_valid_address(s: &str) -> bool {
111    s.len() == 42
112        && s.starts_with("0x")
113        && s[2..].chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
114}
115
116/// Validate a DID string without returning the parsed result.
117pub fn validate(did: &str) -> bool {
118    Did::parse(did).is_ok()
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn valid_did() {
127        let did = Did::parse("did:oaid:base:0x7f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e").unwrap();
128        assert_eq!(did.chain, "base");
129        assert_eq!(did.address, "0x7f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e");
130    }
131
132    #[test]
133    fn normalizes_to_lowercase() {
134        let did = Did::parse("did:oaid:Base:0x7F4E3D2C1B0A9F8E7D6C5B4A3F2E1D0C9B8A7F6E").unwrap();
135        assert_eq!(did.chain, "base");
136        assert_eq!(did.address, "0x7f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e");
137    }
138
139    #[test]
140    fn valid_did_base_sepolia() {
141        let did = Did::parse("did:oaid:base-sepolia:0x0000000000000000000000000000000000000001").unwrap();
142        assert_eq!(did.chain, "base-sepolia");
143    }
144
145    #[test]
146    fn display_roundtrip() {
147        let input = "did:oaid:base:0x7f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e";
148        let did = Did::parse(input).unwrap();
149        assert_eq!(did.to_string(), input);
150    }
151
152    #[test]
153    fn reject_v1_did() {
154        assert!(Did::parse("did:agent:tokli:agt_a1B2c3D4e5").is_err());
155    }
156
157    #[test]
158    fn reject_bad_method() {
159        assert!(Did::parse("did:xxx:base:0x0000000000000000000000000000000000000001").is_err());
160    }
161
162    #[test]
163    fn reject_short_address() {
164        assert!(Did::parse("did:oaid:base:0x1234").is_err());
165    }
166
167    #[test]
168    fn reject_missing_0x() {
169        assert!(Did::parse("did:oaid:base:7f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e").is_err());
170    }
171
172    #[test]
173    fn reject_uppercase_hex_only() {
174        // After normalization, uppercase hex becomes lowercase, so this should pass
175        let result = Did::parse("did:oaid:base:0xABCDEF0000000000000000000000000000000000");
176        assert!(result.is_ok());
177        assert_eq!(result.unwrap().address, "0xabcdef0000000000000000000000000000000000");
178    }
179
180    #[test]
181    fn reject_empty() {
182        assert!(Did::parse("").is_err());
183    }
184
185    #[test]
186    fn reject_too_long() {
187        // chain name that makes total > 80
188        let long_chain = "a".repeat(60);
189        let did_str = format!("did:oaid:{long_chain}:0x0000000000000000000000000000000000000001");
190        assert!(Did::parse(&did_str).is_err());
191    }
192
193    #[test]
194    fn new_constructs_correctly() {
195        let did = Did::new("base", "0xAbCdEf0000000000000000000000000000000000").unwrap();
196        assert_eq!(did.address, "0xabcdef0000000000000000000000000000000000");
197        assert_eq!(did.chain, "base");
198    }
199
200    #[test]
201    fn validate_helper() {
202        assert!(validate("did:oaid:base:0x0000000000000000000000000000000000000001"));
203        assert!(!validate("garbage"));
204    }
205}