Skip to main content

fedimint_core/
invite_code.rs

1use core::fmt;
2use std::borrow::Cow;
3use std::collections::BTreeMap;
4use std::fmt::{Display, Formatter};
5use std::io::Read;
6use std::str::FromStr;
7
8use bech32::{Bech32m, Hrp};
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11
12use crate::base32::{FEDIMINT_PREFIX, PrefixedDecodeError};
13use crate::config::FederationId;
14use crate::encoding::{Decodable, DecodeError, Encodable};
15use crate::module::registry::{ModuleDecoderRegistry, ModuleRegistry};
16use crate::util::{FmtCompact as _, SafeUrl};
17use crate::{NumPeersExt, PeerId};
18
19/// Information required for client to join Federation
20///
21/// Can be used to download the configs and bootstrap a client.
22///
23/// ## Invariants
24/// Constructors have to guarantee that:
25///   * At least one Api entry is present
26///   * At least one Federation ID is present
27#[derive(Clone, Debug, Eq, PartialEq, Encodable, Hash, Ord, PartialOrd)]
28pub struct InviteCode(Vec<InviteCodePart>);
29
30#[cfg(feature = "uniffi")]
31uniffi::custom_type!(InviteCode, String, {
32    lower: |i| i.to_string(),
33    try_lift: |s| s.parse::<InviteCode>().map_err(anyhow::Error::from),
34});
35
36impl Decodable for InviteCode {
37    fn consensus_decode_partial<R: Read>(
38        r: &mut R,
39        modules: &ModuleDecoderRegistry,
40    ) -> Result<Self, DecodeError> {
41        let inner: Vec<InviteCodePart> = Decodable::consensus_decode_partial(r, modules)?;
42
43        if !inner
44            .iter()
45            .any(|data| matches!(data, InviteCodePart::Api { .. }))
46        {
47            return Err(DecodeError::from_str(
48                "No API was provided in the invite code",
49            ));
50        }
51
52        if !inner
53            .iter()
54            .any(|data| matches!(data, InviteCodePart::FederationId(_)))
55        {
56            return Err(DecodeError::from_str(
57                "No Federation ID provided in invite code",
58            ));
59        }
60
61        Ok(Self(inner))
62    }
63}
64
65impl InviteCode {
66    pub fn new(
67        url: SafeUrl,
68        peer: PeerId,
69        federation_id: FederationId,
70        api_secret: Option<String>,
71    ) -> Self {
72        let mut s = Self(vec![
73            InviteCodePart::Api { url, peer },
74            InviteCodePart::FederationId(federation_id),
75        ]);
76
77        if let Some(api_secret) = api_secret {
78            s.0.push(InviteCodePart::ApiSecret(api_secret));
79        }
80
81        s
82    }
83
84    pub fn from_map(
85        peer_to_url_map: &BTreeMap<PeerId, SafeUrl>,
86        federation_id: FederationId,
87        api_secret: Option<String>,
88    ) -> Self {
89        let max_size = peer_to_url_map.to_num_peers().max_evil() + 1;
90        let mut code_vec: Vec<InviteCodePart> = peer_to_url_map
91            .iter()
92            .take(max_size)
93            .map(|(peer, url)| InviteCodePart::Api {
94                url: url.clone(),
95                peer: *peer,
96            })
97            .collect();
98
99        code_vec.push(InviteCodePart::FederationId(federation_id));
100
101        if let Some(api_secret) = api_secret {
102            code_vec.push(InviteCodePart::ApiSecret(api_secret));
103        }
104
105        Self(code_vec)
106    }
107
108    /// Constructs an [`InviteCode`] which contains as many guardian URLs as
109    /// needed to always be able to join a working federation
110    pub fn new_with_essential_num_guardians(
111        peer_to_url_map: &BTreeMap<PeerId, SafeUrl>,
112        federation_id: FederationId,
113    ) -> Self {
114        let max_size = peer_to_url_map.to_num_peers().max_evil() + 1;
115        let mut code_vec: Vec<InviteCodePart> = peer_to_url_map
116            .iter()
117            .take(max_size)
118            .map(|(peer, url)| InviteCodePart::Api {
119                url: url.clone(),
120                peer: *peer,
121            })
122            .collect();
123        code_vec.push(InviteCodePart::FederationId(federation_id));
124
125        Self(code_vec)
126    }
127
128    /// Returns the API URL of one of the guardians.
129    pub fn url(&self) -> SafeUrl {
130        self.0
131            .iter()
132            .find_map(|data| match data {
133                InviteCodePart::Api { url, .. } => Some(url.clone()),
134                _ => None,
135            })
136            .expect("Ensured by constructor")
137    }
138
139    /// Api secret, if needed, to use when communicating with the federation
140    pub fn api_secret(&self) -> Option<String> {
141        self.0.iter().find_map(|data| match data {
142            InviteCodePart::ApiSecret(api_secret) => Some(api_secret.clone()),
143            _ => None,
144        })
145    }
146    /// Returns the id of the guardian from which we got the API URL, see
147    /// [`InviteCode::url`].
148    pub fn peer(&self) -> PeerId {
149        self.0
150            .iter()
151            .find_map(|data| match data {
152                InviteCodePart::Api { peer, .. } => Some(*peer),
153                _ => None,
154            })
155            .expect("Ensured by constructor")
156    }
157
158    /// Get all peer URLs in the [`InviteCode`]
159    pub fn peers(&self) -> BTreeMap<PeerId, SafeUrl> {
160        self.0
161            .iter()
162            .filter_map(|entry| match entry {
163                InviteCodePart::Api { url, peer } => Some((*peer, url.clone())),
164                _ => None,
165            })
166            .collect()
167    }
168
169    /// Returns the federation's ID that can be used to authenticate the config
170    /// downloaded from the API.
171    pub fn federation_id(&self) -> FederationId {
172        self.0
173            .iter()
174            .find_map(|data| match data {
175                InviteCodePart::FederationId(federation_id) => Some(*federation_id),
176                _ => None,
177            })
178            .expect("Ensured by constructor")
179    }
180}
181
182/// For extendability [`InviteCode`] consists of parts, where client can ignore
183/// ones they don't understand.
184///
185/// ones they don't understand Data that can be encoded in the invite code.
186/// Currently we always just use one `Api` and one `FederationId` variant in an
187/// invite code, but more can be added in the future while still keeping the
188/// invite code readable for older clients, which will just ignore the new
189/// fields.
190#[derive(Clone, Debug, Eq, PartialEq, Encodable, Decodable, Hash, Ord, PartialOrd)]
191enum InviteCodePart {
192    /// API endpoint of one of the guardians
193    Api {
194        /// URL to reach an API that we can download configs from
195        url: SafeUrl,
196        /// Peer id of the host from the Url
197        peer: PeerId,
198    },
199
200    /// Authentication id for the federation
201    FederationId(FederationId),
202
203    /// Api secret to use
204    ApiSecret(String),
205
206    /// Unknown invite code fields to be defined in the future
207    #[encodable_default]
208    Default { variant: u64, bytes: Vec<u8> },
209}
210
211/// We can represent client invite code as a bech32 string for compactness and
212/// error-checking
213///
214/// Human readable part (HRP) includes the version
215/// ```txt
216/// [ hrp (4 bytes) ] [ id (48 bytes) ] ([ url len (2 bytes) ] [ url bytes (url len bytes) ])+
217/// ```
218const BECH32_HRP: Hrp = Hrp::parse_unchecked("fed1");
219
220impl FromStr for InviteCode {
221    type Err = InviteCodeParseError;
222
223    fn from_str(encoded: &str) -> Result<Self, Self::Err> {
224        // The prefix is ASCII, so a case-insensitive match here agrees with the
225        // lowercasing `decode_prefixed` does before comparing the prefix.
226        if encoded
227            .get(..FEDIMINT_PREFIX.len())
228            .is_some_and(|prefix| prefix.eq_ignore_ascii_case(FEDIMINT_PREFIX))
229        {
230            return Ok(crate::base32::decode_prefixed(FEDIMINT_PREFIX, encoded)?);
231        }
232
233        let (hrp, data) = bech32::decode(encoded)?;
234
235        if hrp != BECH32_HRP {
236            return Err(InviteCodeParseError::InvalidHrp { hrp });
237        }
238
239        let invite = Self::consensus_decode_whole(&data, &ModuleRegistry::default())?;
240
241        Ok(invite)
242    }
243}
244
245/// Failure to parse an [`InviteCode`] from its string form.
246#[derive(Debug, Error)]
247#[non_exhaustive]
248pub enum InviteCodeParseError {
249    /// The string is not a valid bech32m encoding.
250    #[error("Invalid bech32 encoding: {0}")]
251    Bech32(#[from] bech32::DecodeError),
252    /// The bech32 human-readable part is not the invite code HRP.
253    #[error(
254        "Invalid bech32 human-readable part '{hrp}', expected '{}'",
255        BECH32_HRP
256    )]
257    InvalidHrp { hrp: bech32::Hrp },
258    /// The payload is not a valid consensus encoding of an invite code.
259    #[error("Invalid invite code payload: {}", .0.fmt_compact())]
260    Decode(DecodeError),
261    /// The string carries the [`FEDIMINT_PREFIX`] but its base 32 payload does
262    /// not decode into an invite code.
263    #[error("Invalid prefixed base32 invite code: {}", .0.fmt_compact())]
264    Base32(PrefixedDecodeError),
265}
266
267impl From<DecodeError> for InviteCodeParseError {
268    fn from(source: DecodeError) -> Self {
269        Self::Decode(source)
270    }
271}
272
273impl From<PrefixedDecodeError> for InviteCodeParseError {
274    fn from(source: PrefixedDecodeError) -> Self {
275        Self::Base32(source)
276    }
277}
278
279/// Parses the invite code from a bech32 string
280impl Display for InviteCode {
281    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
282        let data = self.consensus_encode_to_vec();
283        let encode = bech32::encode::<Bech32m>(BECH32_HRP, &data).map_err(|_| fmt::Error)?;
284        formatter.write_str(&encode)
285    }
286}
287
288impl Serialize for InviteCode {
289    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
290    where
291        S: serde::Serializer,
292    {
293        String::serialize(&self.to_string(), serializer)
294    }
295}
296
297impl<'de> Deserialize<'de> for InviteCode {
298    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
299    where
300        D: serde::Deserializer<'de>,
301    {
302        let string = Cow::<str>::deserialize(deserializer)?;
303        Self::from_str(&string).map_err(serde::de::Error::custom)
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use std::str::FromStr;
310
311    use fedimint_core::PeerId;
312    use fedimint_core::base32::FEDIMINT_PREFIX;
313
314    use super::{BECH32_HRP, InviteCodeParseError};
315    use crate::config::FederationId;
316    use crate::invite_code::InviteCode;
317
318    #[test]
319    fn test_invite_code_to_from_string() {
320        let invite_code_str = "fed11qgqpu8rhwden5te0vejkg6tdd9h8gepwd4cxcumxv4jzuen0duhsqqfqh6nl7sgk72caxfx8khtfnn8y436q3nhyrkev3qp8ugdhdllnh86qmp42pm";
321        let invite_code = InviteCode::from_str(invite_code_str).expect("valid invite code");
322
323        InviteCode::from_str(&crate::base32::encode_prefixed(
324            FEDIMINT_PREFIX,
325            &invite_code,
326        ))
327        .expect("Failed to parse base 32 invite code");
328
329        assert_eq!(invite_code.to_string(), invite_code_str);
330        assert_eq!(
331            invite_code.0,
332            [
333                crate::invite_code::InviteCodePart::Api {
334                    url: "wss://fedimintd.mplsfed.foo/".parse().expect("valid url"),
335                    peer: PeerId::new(0),
336                },
337                crate::invite_code::InviteCodePart::FederationId(FederationId(
338                    bitcoin::hashes::sha256::Hash::from_str(
339                        "bea7ff4116f2b1d324c7b5d699cce4ac7408cee41db2c88027e21b76fff3b9f4"
340                    )
341                    .expect("valid hash")
342                ))
343            ]
344        );
345    }
346
347    #[test]
348    fn from_str_rejects_wrong_hrp() {
349        let other_hrp = bech32::Hrp::parse("abcd").expect("valid hrp");
350        let encoded = bech32::encode::<bech32::Bech32m>(other_hrp, &[0u8; 8]).expect("encodes");
351        assert!(matches!(
352            InviteCode::from_str(&encoded),
353            Err(InviteCodeParseError::InvalidHrp { hrp }) if hrp == other_hrp
354        ));
355    }
356
357    #[test]
358    fn from_str_reports_the_whole_decode_chain_for_a_truncated_payload() {
359        use crate::encoding::Encodable;
360        use crate::util::FmtCompact as _;
361
362        // Dropping the last byte of a valid invite code cuts off mid-federation-id, so
363        // the reader runs out of input partway through the consensus decode instead of
364        // failing on the very first byte.
365        let invite_code_str = "fed11qgqpu8rhwden5te0vejkg6tdd9h8gepwd4cxcumxv4jzuen0duhsqqfqh6nl7sgk72caxfx8khtfnn8y436q3nhyrkev3qp8ugdhdllnh86qmp42pm";
366        let invite = InviteCode::from_str(invite_code_str).expect("valid invite code");
367        let bytes = invite.consensus_encode_to_vec();
368        let encoded = bech32::encode::<bech32::Bech32m>(BECH32_HRP, &bytes[..bytes.len() - 1])
369            .expect("encodes");
370
371        let err = InviteCode::from_str(&encoded).expect_err("payload is truncated");
372        let text = err.to_string();
373        let InviteCodeParseError::Decode(inner) = err else {
374            panic!("a truncated payload is a decode error: {err:?}");
375        };
376
377        assert_eq!(
378            text,
379            format!("Invalid invite code payload: {}", inner.fmt_compact())
380        );
381        assert_ne!(
382            inner.fmt_compact().to_string(),
383            inner.to_string(),
384            "the decode error has more than one layer, and the message shows all of them"
385        );
386    }
387
388    #[test]
389    fn from_str_reports_the_whole_decode_chain_for_a_truncated_prefixed_payload() {
390        use crate::base32::PrefixedDecodeError;
391        use crate::encoding::Encodable;
392        use crate::util::FmtCompact as _;
393
394        // Same truncation as the bech32 case above, but the payload carries the
395        // FEDIMINT_PREFIX and is base32-encoded instead of bech32-encoded.
396        let invite_code_str = "fed11qgqpu8rhwden5te0vejkg6tdd9h8gepwd4cxcumxv4jzuen0duhsqqfqh6nl7sgk72caxfx8khtfnn8y436q3nhyrkev3qp8ugdhdllnh86qmp42pm";
397        let invite = InviteCode::from_str(invite_code_str).expect("valid invite code");
398        let bytes = invite.consensus_encode_to_vec();
399        let encoded =
400            crate::base32::encode_prefixed_bytes(FEDIMINT_PREFIX, &bytes[..bytes.len() - 1]);
401
402        let err = InviteCode::from_str(&encoded).expect_err("payload is truncated");
403        let text = err.to_string();
404        let err_fmt_compact = err.fmt_compact().to_string();
405        let InviteCodeParseError::Base32(PrefixedDecodeError::ConsensusDecode(inner)) = err else {
406            panic!("a truncated payload is a decode error: {err:?}");
407        };
408
409        assert_eq!(
410            text,
411            format!(
412                "Invalid prefixed base32 invite code: \
413                 Invalid consensus encoding in base32 payload: {}",
414                inner.fmt_compact()
415            )
416        );
417        assert_eq!(
418            err_fmt_compact, text,
419            "no source, so nothing is printed twice"
420        );
421        assert_ne!(
422            inner.fmt_compact().to_string(),
423            inner.to_string(),
424            "the decode error has more than one layer, and the message shows all of them"
425        );
426    }
427
428    #[test]
429    fn from_str_rejects_non_bech32() {
430        assert!(matches!(
431            InviteCode::from_str("definitely not bech32"),
432            Err(InviteCodeParseError::Bech32(_))
433        ));
434    }
435
436    #[test]
437    fn from_str_reports_corrupt_prefixed_base32() {
438        use crate::util::FmtCompact as _;
439
440        let err = InviteCode::from_str(&format!("{FEDIMINT_PREFIX}not!base32"))
441            .expect_err("not!base32 is not valid base32");
442        assert!(matches!(err, InviteCodeParseError::Base32(_)), "{err:?}");
443        assert_eq!(err.fmt_compact().to_string(), err.to_string());
444    }
445}