fedimint_core/
invite_code.rs

1use core::fmt;
2use std::borrow::Cow;
3use std::collections::BTreeMap;
4use std::fmt::{Display, Formatter};
5use std::io::{Cursor, Read};
6use std::str::FromStr;
7
8use anyhow::ensure;
9use bech32::{Bech32m, Hrp};
10use serde::{Deserialize, Serialize};
11
12use crate::config::FederationId;
13use crate::encoding::{Decodable, DecodeError, Encodable};
14use crate::module::registry::{ModuleDecoderRegistry, ModuleRegistry};
15use crate::util::SafeUrl;
16use crate::{NumPeersExt, PeerId};
17
18/// Information required for client to join Federation
19///
20/// Can be used to download the configs and bootstrap a client.
21///
22/// ## Invariants
23/// Constructors have to guarantee that:
24///   * At least one Api entry is present
25///   * At least one Federation ID is present
26#[derive(Clone, Debug, Eq, PartialEq, Encodable, Hash, Ord, PartialOrd)]
27pub struct InviteCode(Vec<InviteCodePart>);
28
29impl Decodable for InviteCode {
30    fn consensus_decode<R: Read>(
31        r: &mut R,
32        modules: &ModuleDecoderRegistry,
33    ) -> Result<Self, DecodeError> {
34        let inner: Vec<InviteCodePart> = Decodable::consensus_decode(r, modules)?;
35
36        if !inner
37            .iter()
38            .any(|data| matches!(data, InviteCodePart::Api { .. }))
39        {
40            return Err(DecodeError::from_str(
41                "No API was provided in the invite code",
42            ));
43        }
44
45        if !inner
46            .iter()
47            .any(|data| matches!(data, InviteCodePart::FederationId(_)))
48        {
49            return Err(DecodeError::from_str(
50                "No Federation ID provided in invite code",
51            ));
52        }
53
54        Ok(Self(inner))
55    }
56}
57
58impl InviteCode {
59    pub fn new(
60        url: SafeUrl,
61        peer: PeerId,
62        federation_id: FederationId,
63        api_secret: Option<String>,
64    ) -> Self {
65        let mut s = Self(vec![
66            InviteCodePart::Api { url, peer },
67            InviteCodePart::FederationId(federation_id),
68        ]);
69
70        if let Some(api_secret) = api_secret {
71            s.0.push(InviteCodePart::ApiSecret(api_secret));
72        }
73
74        s
75    }
76
77    pub fn from_map(
78        peer_to_url_map: &BTreeMap<PeerId, SafeUrl>,
79        federation_id: FederationId,
80        api_secret: Option<String>,
81    ) -> Self {
82        let max_size = peer_to_url_map.to_num_peers().max_evil() + 1;
83        let mut code_vec: Vec<InviteCodePart> = peer_to_url_map
84            .iter()
85            .take(max_size)
86            .map(|(peer, url)| InviteCodePart::Api {
87                url: url.clone(),
88                peer: *peer,
89            })
90            .collect();
91
92        code_vec.push(InviteCodePart::FederationId(federation_id));
93
94        if let Some(api_secret) = api_secret {
95            code_vec.push(InviteCodePart::ApiSecret(api_secret));
96        }
97
98        Self(code_vec)
99    }
100
101    /// Constructs an [`InviteCode`] which contains as many guardian URLs as
102    /// needed to always be able to join a working federation
103    pub fn new_with_essential_num_guardians(
104        peer_to_url_map: &BTreeMap<PeerId, SafeUrl>,
105        federation_id: FederationId,
106    ) -> Self {
107        let max_size = peer_to_url_map.to_num_peers().max_evil() + 1;
108        let mut code_vec: Vec<InviteCodePart> = peer_to_url_map
109            .iter()
110            .take(max_size)
111            .map(|(peer, url)| InviteCodePart::Api {
112                url: url.clone(),
113                peer: *peer,
114            })
115            .collect();
116        code_vec.push(InviteCodePart::FederationId(federation_id));
117
118        Self(code_vec)
119    }
120
121    /// Returns the API URL of one of the guardians.
122    pub fn url(&self) -> SafeUrl {
123        self.0
124            .iter()
125            .find_map(|data| match data {
126                InviteCodePart::Api { url, .. } => Some(url.clone()),
127                _ => None,
128            })
129            .expect("Ensured by constructor")
130    }
131
132    /// Api secret, if needed, to use when communicating with the federation
133    pub fn api_secret(&self) -> Option<String> {
134        self.0.iter().find_map(|data| match data {
135            InviteCodePart::ApiSecret(api_secret) => Some(api_secret.clone()),
136            _ => None,
137        })
138    }
139    /// Returns the id of the guardian from which we got the API URL, see
140    /// [`InviteCode::url`].
141    pub fn peer(&self) -> PeerId {
142        self.0
143            .iter()
144            .find_map(|data| match data {
145                InviteCodePart::Api { peer, .. } => Some(*peer),
146                _ => None,
147            })
148            .expect("Ensured by constructor")
149    }
150
151    /// Get all peer URLs in the [`InviteCode`]
152    pub fn peers(&self) -> BTreeMap<PeerId, SafeUrl> {
153        self.0
154            .iter()
155            .filter_map(|entry| match entry {
156                InviteCodePart::Api { url, peer } => Some((*peer, url.clone())),
157                _ => None,
158            })
159            .collect()
160    }
161
162    /// Returns the federation's ID that can be used to authenticate the config
163    /// downloaded from the API.
164    pub fn federation_id(&self) -> FederationId {
165        self.0
166            .iter()
167            .find_map(|data| match data {
168                InviteCodePart::FederationId(federation_id) => Some(*federation_id),
169                _ => None,
170            })
171            .expect("Ensured by constructor")
172    }
173}
174
175/// For extendability [`InviteCode`] consists of parts, where client can ignore
176/// ones they don't understand.
177///
178/// ones they don't understand Data that can be encoded in the invite code.
179/// Currently we always just use one `Api` and one `FederationId` variant in an
180/// invite code, but more can be added in the future while still keeping the
181/// invite code readable for older clients, which will just ignore the new
182/// fields.
183#[derive(Clone, Debug, Eq, PartialEq, Encodable, Decodable, Hash, Ord, PartialOrd)]
184enum InviteCodePart {
185    /// API endpoint of one of the guardians
186    Api {
187        /// URL to reach an API that we can download configs from
188        url: SafeUrl,
189        /// Peer id of the host from the Url
190        peer: PeerId,
191    },
192
193    /// Authentication id for the federation
194    FederationId(FederationId),
195
196    /// Api secret to use
197    ApiSecret(String),
198
199    /// Unknown invite code fields to be defined in the future
200    #[encodable_default]
201    Default { variant: u64, bytes: Vec<u8> },
202}
203
204/// We can represent client invite code as a bech32 string for compactness and
205/// error-checking
206///
207/// Human readable part (HRP) includes the version
208/// ```txt
209/// [ hrp (4 bytes) ] [ id (48 bytes) ] ([ url len (2 bytes) ] [ url bytes (url len bytes) ])+
210/// ```
211const BECH32_HRP: Hrp = Hrp::parse_unchecked("fed1");
212
213impl FromStr for InviteCode {
214    type Err = anyhow::Error;
215
216    fn from_str(encoded: &str) -> Result<Self, Self::Err> {
217        if let Ok(invite_code_v2) = InviteCodeV2::decode_base64(encoded) {
218            return invite_code_v2.into_v1();
219        }
220
221        let (hrp, data) = bech32::decode(encoded)?;
222
223        ensure!(hrp == BECH32_HRP, "Invalid HRP in bech32 encoding");
224
225        let invite = Self::consensus_decode(&mut Cursor::new(data), &ModuleRegistry::default())?;
226
227        Ok(invite)
228    }
229}
230
231/// Parses the invite code from a bech32 string
232impl Display for InviteCode {
233    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
234        let mut data = vec![];
235
236        self.consensus_encode(&mut data)
237            .expect("Vec<u8> provides capacity");
238
239        let encode = bech32::encode::<Bech32m>(BECH32_HRP, &data).map_err(|_| fmt::Error)?;
240        formatter.write_str(&encode)
241    }
242}
243
244impl Serialize for InviteCode {
245    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
246    where
247        S: serde::Serializer,
248    {
249        String::serialize(&self.to_string(), serializer)
250    }
251}
252
253impl<'de> Deserialize<'de> for InviteCode {
254    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
255    where
256        D: serde::Deserializer<'de>,
257    {
258        let string = Cow::<str>::deserialize(deserializer)?;
259        Self::from_str(&string).map_err(serde::de::Error::custom)
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use std::collections::BTreeMap;
266    use std::str::FromStr;
267
268    use fedimint_core::util::SafeUrl;
269    use fedimint_core::PeerId;
270
271    use crate::config::FederationId;
272    use crate::invite_code::{InviteCode, InviteCodeV2};
273
274    #[test]
275    fn test_invite_code_to_from_string() {
276        let invite_code_str = "fed11qgqpu8rhwden5te0vejkg6tdd9h8gepwd4cxcumxv4jzuen0duhsqqfqh6nl7sgk72caxfx8khtfnn8y436q3nhyrkev3qp8ugdhdllnh86qmp42pm";
277        let invite_code = InviteCode::from_str(invite_code_str).expect("valid invite code");
278
279        assert_eq!(invite_code.to_string(), invite_code_str);
280        assert_eq!(
281            invite_code.0,
282            [
283                crate::invite_code::InviteCodePart::Api {
284                    url: "wss://fedimintd.mplsfed.foo/".parse().expect("valid url"),
285                    peer: PeerId::new(0),
286                },
287                crate::invite_code::InviteCodePart::FederationId(FederationId(
288                    bitcoin::hashes::sha256::Hash::from_str(
289                        "bea7ff4116f2b1d324c7b5d699cce4ac7408cee41db2c88027e21b76fff3b9f4"
290                    )
291                    .expect("valid hash")
292                ))
293            ]
294        );
295    }
296
297    #[test]
298    fn invite_code_v2_encode_base64_roundtrip() {
299        let invite_code = InviteCodeV2 {
300            id: FederationId::dummy(),
301            peers: BTreeMap::from_iter([(
302                PeerId::from(0),
303                SafeUrl::parse("https://mint.com").expect("Url is valid"),
304            )]),
305            api_secret: None,
306        };
307
308        let encoded = invite_code.encode_base64();
309        let decoded = InviteCodeV2::decode_base64(&encoded).expect("Failed to decode");
310
311        assert_eq!(invite_code, decoded);
312
313        InviteCode::from_str(&encoded).expect("Failed to decode to legacy");
314    }
315}
316
317#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Encodable, Decodable)]
318pub struct InviteCodeV2 {
319    pub id: FederationId,
320    pub peers: BTreeMap<PeerId, SafeUrl>,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    #[serde(default)]
323    pub api_secret: Option<String>,
324}
325
326impl InviteCodeV2 {
327    pub fn into_v1(self) -> anyhow::Result<InviteCode> {
328        Ok(InviteCode::from_map(&self.peers, self.id, self.api_secret))
329    }
330
331    pub fn encode_base64(&self) -> String {
332        let json = &serde_json::to_string(self).expect("Encoding to JSON cannot fail");
333        let base_64 = base64_url::encode(json);
334
335        format!("fedimintA{base_64}")
336    }
337
338    pub fn decode_base64(s: &str) -> anyhow::Result<Self> {
339        ensure!(s.starts_with("fedimintA"), "Invalid Prefix");
340
341        let invite_code: Self = serde_json::from_slice(&base64_url::decode(&s[9..])?)?;
342
343        ensure!(!invite_code.peers.is_empty(), "Invite code has no peer");
344
345        Ok(invite_code)
346    }
347}