bitrouter_core/auth/
chain.rs1use std::fmt;
8
9use serde::{Deserialize, Serialize};
10
11use crate::auth::JwtError;
12
13const SOLANA_MAINNET_REF: &str = "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(tag = "namespace", content = "reference")]
19pub enum Chain {
20 #[serde(rename = "solana")]
25 Solana { reference: String },
26
27 #[serde(rename = "eip155")]
31 Evm { reference: String },
32}
33
34impl Chain {
35 pub fn solana_mainnet() -> Self {
37 Self::Solana {
38 reference: SOLANA_MAINNET_REF.to_string(),
39 }
40 }
41
42 pub fn base() -> Self {
44 Self::Evm {
45 reference: "8453".to_string(),
46 }
47 }
48
49 pub fn caip2(&self) -> String {
53 match self {
54 Self::Solana { reference } => format!("solana:{reference}"),
55 Self::Evm { reference } => format!("eip155:{reference}"),
56 }
57 }
58
59 pub fn from_caip2(s: &str) -> Result<Self, JwtError> {
61 let (namespace, reference) = s
62 .split_once(':')
63 .ok_or_else(|| JwtError::InvalidChain(format!("missing ':' in chain id: {s}")))?;
64
65 match namespace {
66 "solana" => Ok(Self::Solana {
67 reference: reference.to_string(),
68 }),
69 "eip155" => Ok(Self::Evm {
70 reference: reference.to_string(),
71 }),
72 other => Err(JwtError::InvalidChain(format!(
73 "unsupported namespace: {other}"
74 ))),
75 }
76 }
77
78 pub fn namespace(&self) -> &str {
80 match self {
81 Self::Solana { .. } => "solana",
82 Self::Evm { .. } => "eip155",
83 }
84 }
85
86 pub fn jwt_algorithm(&self) -> JwtAlgorithm {
88 match self {
89 Self::Solana { .. } => JwtAlgorithm::SolEdDsa,
90 Self::Evm { .. } => JwtAlgorithm::Eip191K,
91 }
92 }
93}
94
95impl fmt::Display for Chain {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 f.write_str(&self.caip2())
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct Caip10 {
104 pub chain: Chain,
106 pub address: String,
108}
109
110impl Caip10 {
111 pub fn format(&self) -> String {
117 format!("{}:{}", self.chain.caip2(), self.address)
118 }
119
120 pub fn parse(s: &str) -> Result<Self, JwtError> {
125 let mut parts = s.splitn(3, ':');
127 let namespace = parts
128 .next()
129 .ok_or_else(|| JwtError::InvalidCaip10(format!("empty CAIP-10: {s}")))?;
130 let reference = parts
131 .next()
132 .ok_or_else(|| JwtError::InvalidCaip10(format!("missing reference in CAIP-10: {s}")))?;
133 let address = parts
134 .next()
135 .ok_or_else(|| JwtError::InvalidCaip10(format!("missing address in CAIP-10: {s}")))?;
136
137 let chain = match namespace {
138 "solana" => Chain::Solana {
139 reference: reference.to_string(),
140 },
141 "eip155" => Chain::Evm {
142 reference: reference.to_string(),
143 },
144 other => {
145 return Err(JwtError::InvalidCaip10(format!(
146 "unsupported namespace: {other}"
147 )));
148 }
149 };
150
151 Ok(Self {
152 chain,
153 address: address.to_string(),
154 })
155 }
156}
157
158impl fmt::Display for Caip10 {
159 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160 f.write_str(&self.format())
161 }
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum JwtAlgorithm {
167 SolEdDsa,
169 Eip191K,
171}
172
173impl JwtAlgorithm {
174 pub fn as_str(&self) -> &'static str {
176 match self {
177 Self::SolEdDsa => "SOL_EDDSA",
178 Self::Eip191K => "EIP191K",
179 }
180 }
181
182 pub fn header_json(&self) -> String {
184 format!(r#"{{"alg":"{}","typ":"JWT"}}"#, self.as_str())
185 }
186
187 pub fn from_header(s: &str) -> Result<Self, JwtError> {
189 match s {
190 "SOL_EDDSA" => Ok(Self::SolEdDsa),
191 "EIP191K" => Ok(Self::Eip191K),
192 other => Err(JwtError::UnsupportedAlgorithm(other.to_string())),
193 }
194 }
195}
196
197impl fmt::Display for JwtAlgorithm {
198 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199 f.write_str(self.as_str())
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 #[test]
208 fn chain_solana_mainnet_caip2() {
209 let chain = Chain::solana_mainnet();
210 assert_eq!(chain.caip2(), "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp");
211 }
212
213 #[test]
214 fn chain_base_caip2() {
215 let chain = Chain::base();
216 assert_eq!(chain.caip2(), "eip155:8453");
217 }
218
219 #[test]
220 fn chain_caip2_roundtrip_solana() {
221 let chain = Chain::solana_mainnet();
222 let s = chain.caip2();
223 let parsed = Chain::from_caip2(&s).expect("parse");
224 assert_eq!(parsed, chain);
225 }
226
227 #[test]
228 fn chain_caip2_roundtrip_evm() {
229 let chain = Chain::base();
230 let s = chain.caip2();
231 let parsed = Chain::from_caip2(&s).expect("parse");
232 assert_eq!(parsed, chain);
233 }
234
235 #[test]
236 fn chain_from_caip2_rejects_unknown_namespace() {
237 assert!(Chain::from_caip2("bitcoin:mainnet").is_err());
238 }
239
240 #[test]
241 fn chain_from_caip2_rejects_missing_colon() {
242 assert!(Chain::from_caip2("solana").is_err());
243 }
244
245 #[test]
246 fn caip10_format_solana() {
247 let id = Caip10 {
248 chain: Chain::solana_mainnet(),
249 address: "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy".to_string(),
250 };
251 assert_eq!(
252 id.format(),
253 "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"
254 );
255 }
256
257 #[test]
258 fn caip10_format_evm() {
259 let id = Caip10 {
260 chain: Chain::base(),
261 address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045".to_string(),
262 };
263 assert_eq!(
264 id.format(),
265 "eip155:8453:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
266 );
267 }
268
269 #[test]
270 fn caip10_roundtrip_solana() {
271 let s =
272 "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy";
273 let id = Caip10::parse(s).expect("parse");
274 assert_eq!(id.format(), s);
275 }
276
277 #[test]
278 fn caip10_roundtrip_evm() {
279 let s = "eip155:8453:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
280 let id = Caip10::parse(s).expect("parse");
281 assert_eq!(id.format(), s);
282 }
283
284 #[test]
285 fn caip10_parse_rejects_missing_address() {
286 assert!(Caip10::parse("eip155:8453").is_err());
287 }
288
289 #[test]
290 fn caip10_parse_rejects_empty() {
291 assert!(Caip10::parse("").is_err());
292 }
293
294 #[test]
295 fn jwt_algorithm_from_chain() {
296 assert_eq!(
297 Chain::solana_mainnet().jwt_algorithm(),
298 JwtAlgorithm::SolEdDsa
299 );
300 assert_eq!(Chain::base().jwt_algorithm(), JwtAlgorithm::Eip191K);
301 }
302
303 #[test]
304 fn jwt_algorithm_header_json() {
305 assert_eq!(
306 JwtAlgorithm::SolEdDsa.header_json(),
307 r#"{"alg":"SOL_EDDSA","typ":"JWT"}"#
308 );
309 assert_eq!(
310 JwtAlgorithm::Eip191K.header_json(),
311 r#"{"alg":"EIP191K","typ":"JWT"}"#
312 );
313 }
314
315 #[test]
316 fn jwt_algorithm_roundtrip() {
317 for alg in [JwtAlgorithm::SolEdDsa, JwtAlgorithm::Eip191K] {
318 let parsed = JwtAlgorithm::from_header(alg.as_str()).expect("parse");
319 assert_eq!(parsed, alg);
320 }
321 }
322
323 #[test]
324 fn jwt_algorithm_rejects_unknown() {
325 assert!(JwtAlgorithm::from_header("RS256").is_err());
326 }
327}