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    /// Tempo mainnet (EVM chain ID 4217).
50    pub fn tempo() -> Self {
51        Self::Evm {
52            reference: "4217".to_string(),
53        }
54    }
55
56    /// Tempo testnet (EVM chain ID 42431).
57    pub fn tempo_testnet() -> Self {
58        Self::Evm {
59            reference: "42431".to_string(),
60        }
61    }
62
63    /// Format as a CAIP-2 chain identifier string.
64    ///
65    /// Examples: `"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"`, `"eip155:8453"`.
66    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    /// Parse a CAIP-2 chain identifier string.
74    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    /// Returns the CAIP-2 namespace (`"solana"` or `"eip155"`).
93    pub fn namespace(&self) -> &str {
94        match self {
95            Self::Solana { .. } => "solana",
96            Self::Evm { .. } => "eip155",
97        }
98    }
99
100    /// Returns the JWT algorithm for this chain.
101    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/// A CAIP-10 account identifier: `<chain_id>:<address>`.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct Caip10 {
118    /// The chain this account lives on.
119    pub chain: Chain,
120    /// The on-chain address (base58 pubkey for Solana, `0x`-prefixed hex for EVM).
121    pub address: String,
122}
123
124impl Caip10 {
125    /// Format as a full CAIP-10 account identifier string.
126    ///
127    /// Examples:
128    /// - `"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:DRpb..."`
129    /// - `"eip155:8453:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"`
130    pub fn format(&self) -> String {
131        format!("{}:{}", self.chain.caip2(), self.address)
132    }
133
134    /// Parse a CAIP-10 account identifier string.
135    ///
136    /// The format is `<namespace>:<reference>:<address>`. For EVM chains this is
137    /// three colon-separated segments; for Solana it is also three.
138    pub fn parse(s: &str) -> Result<Self, JwtError> {
139        // Split into exactly namespace:reference:address
140        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/// JWT algorithm identifiers for web3 wallet signing.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum JwtAlgorithm {
181    /// Solana Ed25519 signing (raw message bytes).
182    SolEdDsa,
183    /// EVM EIP-191 prefixed secp256k1 signing.
184    Eip191K,
185}
186
187impl JwtAlgorithm {
188    /// The `"alg"` value for the JWT header.
189    pub fn as_str(&self) -> &'static str {
190        match self {
191            Self::SolEdDsa => "SOL_EDDSA",
192            Self::Eip191K => "EIP191K",
193        }
194    }
195
196    /// The full JWT header JSON string.
197    pub fn header_json(&self) -> String {
198        format!(r#"{{"alg":"{}","typ":"JWT"}}"#, self.as_str())
199    }
200
201    /// Parse from the `"alg"` value in a JWT header.
202    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}