1use crate::error::Error;
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub struct Did {
15 pub chain: String,
17 pub address: String,
19}
20
21impl Did {
22 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 pub fn to_string(&self) -> String {
78 format!("did:oaid:{}:{}", self.chain, self.address)
79 }
80
81 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
109fn 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
116pub 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 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 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}