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 tempo() -> Self {
51 Self::Evm {
52 reference: "4217".to_string(),
53 }
54 }
55
56 pub fn tempo_testnet() -> Self {
58 Self::Evm {
59 reference: "42431".to_string(),
60 }
61 }
62
63 pub fn caip2(&self) -> String {
67 match self {
68 Self::Solana { reference } => format!("solana:{reference}"),
69 Self::Evm { reference } => format!("eip155:{reference}"),
70 }
71 }
72
73 pub fn from_caip2(s: &str) -> Result<Self, JwtError> {
75 let (namespace, reference) = s
76 .split_once(':')
77 .ok_or_else(|| JwtError::InvalidChain(format!("missing ':' in chain id: {s}")))?;
78
79 match namespace {
80 "solana" => Ok(Self::Solana {
81 reference: reference.to_string(),
82 }),
83 "eip155" => Ok(Self::Evm {
84 reference: reference.to_string(),
85 }),
86 other => Err(JwtError::InvalidChain(format!(
87 "unsupported namespace: {other}"
88 ))),
89 }
90 }
91
92 pub fn namespace(&self) -> &str {
94 match self {
95 Self::Solana { .. } => "solana",
96 Self::Evm { .. } => "eip155",
97 }
98 }
99
100 pub fn jwt_algorithm(&self) -> JwtAlgorithm {
102 match self {
103 Self::Solana { .. } => JwtAlgorithm::SolEdDsa,
104 Self::Evm { .. } => JwtAlgorithm::Eip191K,
105 }
106 }
107}
108
109impl fmt::Display for Chain {
110 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111 f.write_str(&self.caip2())
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct Caip10 {
118 pub chain: Chain,
120 pub address: String,
122}
123
124impl Caip10 {
125 pub fn format(&self) -> String {
131 format!("{}:{}", self.chain.caip2(), self.address)
132 }
133
134 pub fn parse(s: &str) -> Result<Self, JwtError> {
139 let mut parts = s.splitn(3, ':');
141 let namespace = parts
142 .next()
143 .ok_or_else(|| JwtError::InvalidCaip10(format!("empty CAIP-10: {s}")))?;
144 let reference = parts
145 .next()
146 .ok_or_else(|| JwtError::InvalidCaip10(format!("missing reference in CAIP-10: {s}")))?;
147 let address = parts
148 .next()
149 .ok_or_else(|| JwtError::InvalidCaip10(format!("missing address in CAIP-10: {s}")))?;
150
151 let chain = match namespace {
152 "solana" => Chain::Solana {
153 reference: reference.to_string(),
154 },
155 "eip155" => Chain::Evm {
156 reference: reference.to_string(),
157 },
158 other => {
159 return Err(JwtError::InvalidCaip10(format!(
160 "unsupported namespace: {other}"
161 )));
162 }
163 };
164
165 Ok(Self {
166 chain,
167 address: address.to_string(),
168 })
169 }
170}
171
172impl fmt::Display for Caip10 {
173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 f.write_str(&self.format())
175 }
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum JwtAlgorithm {
181 SolEdDsa,
183 Eip191K,
185}
186
187impl JwtAlgorithm {
188 pub fn as_str(&self) -> &'static str {
190 match self {
191 Self::SolEdDsa => "SOL_EDDSA",
192 Self::Eip191K => "EIP191K",
193 }
194 }
195
196 pub fn header_json(&self) -> String {
198 format!(r#"{{"alg":"{}","typ":"JWT"}}"#, self.as_str())
199 }
200
201 pub fn from_header(s: &str) -> Result<Self, JwtError> {
203 match s {
204 "SOL_EDDSA" => Ok(Self::SolEdDsa),
205 "EIP191K" => Ok(Self::Eip191K),
206 other => Err(JwtError::UnsupportedAlgorithm(other.to_string())),
207 }
208 }
209}
210
211impl fmt::Display for JwtAlgorithm {
212 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213 f.write_str(self.as_str())
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn chain_solana_mainnet_caip2() {
223 let chain = Chain::solana_mainnet();
224 assert_eq!(chain.caip2(), "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp");
225 }
226
227 #[test]
228 fn chain_base_caip2() {
229 let chain = Chain::base();
230 assert_eq!(chain.caip2(), "eip155:8453");
231 }
232
233 #[test]
234 fn chain_caip2_roundtrip_solana() {
235 let chain = Chain::solana_mainnet();
236 let s = chain.caip2();
237 let parsed = Chain::from_caip2(&s).expect("parse");
238 assert_eq!(parsed, chain);
239 }
240
241 #[test]
242 fn chain_caip2_roundtrip_evm() {
243 let chain = Chain::base();
244 let s = chain.caip2();
245 let parsed = Chain::from_caip2(&s).expect("parse");
246 assert_eq!(parsed, chain);
247 }
248
249 #[test]
250 fn chain_from_caip2_rejects_unknown_namespace() {
251 assert!(Chain::from_caip2("bitcoin:mainnet").is_err());
252 }
253
254 #[test]
255 fn chain_from_caip2_rejects_missing_colon() {
256 assert!(Chain::from_caip2("solana").is_err());
257 }
258
259 #[test]
260 fn caip10_format_solana() {
261 let id = Caip10 {
262 chain: Chain::solana_mainnet(),
263 address: "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy".to_string(),
264 };
265 assert_eq!(
266 id.format(),
267 "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"
268 );
269 }
270
271 #[test]
272 fn caip10_format_evm() {
273 let id = Caip10 {
274 chain: Chain::base(),
275 address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045".to_string(),
276 };
277 assert_eq!(
278 id.format(),
279 "eip155:8453:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
280 );
281 }
282
283 #[test]
284 fn caip10_roundtrip_solana() {
285 let s =
286 "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy";
287 let id = Caip10::parse(s).expect("parse");
288 assert_eq!(id.format(), s);
289 }
290
291 #[test]
292 fn caip10_roundtrip_evm() {
293 let s = "eip155:8453:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
294 let id = Caip10::parse(s).expect("parse");
295 assert_eq!(id.format(), s);
296 }
297
298 #[test]
299 fn caip10_parse_rejects_missing_address() {
300 assert!(Caip10::parse("eip155:8453").is_err());
301 }
302
303 #[test]
304 fn caip10_parse_rejects_empty() {
305 assert!(Caip10::parse("").is_err());
306 }
307
308 #[test]
309 fn jwt_algorithm_from_chain() {
310 assert_eq!(
311 Chain::solana_mainnet().jwt_algorithm(),
312 JwtAlgorithm::SolEdDsa
313 );
314 assert_eq!(Chain::base().jwt_algorithm(), JwtAlgorithm::Eip191K);
315 }
316
317 #[test]
318 fn jwt_algorithm_header_json() {
319 assert_eq!(
320 JwtAlgorithm::SolEdDsa.header_json(),
321 r#"{"alg":"SOL_EDDSA","typ":"JWT"}"#
322 );
323 assert_eq!(
324 JwtAlgorithm::Eip191K.header_json(),
325 r#"{"alg":"EIP191K","typ":"JWT"}"#
326 );
327 }
328
329 #[test]
330 fn jwt_algorithm_roundtrip() {
331 for alg in [JwtAlgorithm::SolEdDsa, JwtAlgorithm::Eip191K] {
332 let parsed = JwtAlgorithm::from_header(alg.as_str()).expect("parse");
333 assert_eq!(parsed, alg);
334 }
335 }
336
337 #[test]
338 fn jwt_algorithm_rejects_unknown() {
339 assert!(JwtAlgorithm::from_header("RS256").is_err());
340 }
341}