Skip to main content

bitrouter_core/auth/
chain.rs

1//! Chain identification and CAIP-10 account types for multi-chain JWT auth.
2//!
3//! Implements [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md)
4//! chain identifiers and [CAIP-10](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-10.md)
5//! account identifiers for cross-chain wallet identity.
6
7use std::fmt;
8
9use serde::{Deserialize, Serialize};
10
11use crate::auth::JwtError;
12
13/// Solana mainnet genesis hash prefix (first 32 bytes, base58-encoded).
14const SOLANA_MAINNET_REF: &str = "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
15
16/// A blockchain network, identified by namespace and reference per CAIP-2.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(tag = "namespace", content = "reference")]
19pub enum Chain {
20    /// Solana — Ed25519 signing.
21    ///
22    /// Reference is the genesis hash prefix (e.g. `5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`
23    /// for mainnet).
24    #[serde(rename = "solana")]
25    Solana { reference: String },
26
27    /// EVM-compatible chain — secp256k1 / EIP-191 signing.
28    ///
29    /// Reference is the integer chain ID as a string (e.g. `"8453"` for Base).
30    #[serde(rename = "eip155")]
31    Evm { reference: String },
32}
33
34impl Chain {
35    /// Solana mainnet.
36    pub fn solana_mainnet() -> Self {
37        Self::Solana {
38            reference: SOLANA_MAINNET_REF.to_string(),
39        }
40    }
41
42    /// Base (EVM chain ID 8453).
43    pub fn base() -> Self {
44        Self::Evm {
45            reference: "8453".to_string(),
46        }
47    }
48
49    /// Format as a CAIP-2 chain identifier string.
50    ///
51    /// Examples: `"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"`, `"eip155:8453"`.
52    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    /// Parse a CAIP-2 chain identifier string.
60    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    /// Returns the CAIP-2 namespace (`"solana"` or `"eip155"`).
79    pub fn namespace(&self) -> &str {
80        match self {
81            Self::Solana { .. } => "solana",
82            Self::Evm { .. } => "eip155",
83        }
84    }
85
86    /// Returns the JWT algorithm for this chain.
87    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/// A CAIP-10 account identifier: `<chain_id>:<address>`.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct Caip10 {
104    /// The chain this account lives on.
105    pub chain: Chain,
106    /// The on-chain address (base58 pubkey for Solana, `0x`-prefixed hex for EVM).
107    pub address: String,
108}
109
110impl Caip10 {
111    /// Format as a full CAIP-10 account identifier string.
112    ///
113    /// Examples:
114    /// - `"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:DRpb..."`
115    /// - `"eip155:8453:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"`
116    pub fn format(&self) -> String {
117        format!("{}:{}", self.chain.caip2(), self.address)
118    }
119
120    /// Parse a CAIP-10 account identifier string.
121    ///
122    /// The format is `<namespace>:<reference>:<address>`. For EVM chains this is
123    /// three colon-separated segments; for Solana it is also three.
124    pub fn parse(s: &str) -> Result<Self, JwtError> {
125        // Split into exactly namespace:reference:address
126        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/// JWT algorithm identifiers for web3 wallet signing.
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum JwtAlgorithm {
167    /// Solana Ed25519 signing (raw message bytes).
168    SolEdDsa,
169    /// EVM EIP-191 prefixed secp256k1 signing.
170    Eip191K,
171}
172
173impl JwtAlgorithm {
174    /// The `"alg"` value for the JWT header.
175    pub fn as_str(&self) -> &'static str {
176        match self {
177            Self::SolEdDsa => "SOL_EDDSA",
178            Self::Eip191K => "EIP191K",
179        }
180    }
181
182    /// The full JWT header JSON string.
183    pub fn header_json(&self) -> String {
184        format!(r#"{{"alg":"{}","typ":"JWT"}}"#, self.as_str())
185    }
186
187    /// Parse from the `"alg"` value in a JWT header.
188    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}