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
30impl Decodable for InviteCode {
31    fn consensus_decode_partial<R: Read>(
32        r: &mut R,
33        modules: &ModuleDecoderRegistry,
34    ) -> Result<Self, DecodeError> {
35        let inner: Vec<InviteCodePart> = Decodable::consensus_decode_partial(r, modules)?;
36
37        if !inner
38            .iter()
39            .any(|data| matches!(data, InviteCodePart::Api { .. }))
40        {
41            return Err(DecodeError::from_str(
42                "No API was provided in the invite code",
43            ));
44        }
45
46        if !inner
47            .iter()
48            .any(|data| matches!(data, InviteCodePart::FederationId(_)))
49        {
50            return Err(DecodeError::from_str(
51                "No Federation ID provided in invite code",
52            ));
53        }
54
55        Ok(Self(inner))
56    }
57}
58
59impl InviteCode {
60    pub fn new(
61        url: SafeUrl,
62        peer: PeerId,
63        federation_id: FederationId,
64        api_secret: Option<String>,
65    ) -> Self {
66        let mut s = Self(vec![
67            InviteCodePart::Api { url, peer },
68            InviteCodePart::FederationId(federation_id),
69        ]);
70
71        if let Some(api_secret) = api_secret {
72            s.0.push(InviteCodePart::ApiSecret(api_secret));
73        }
74
75        s
76    }
77
78    pub fn from_map(
79        peer_to_url_map: &BTreeMap<PeerId, SafeUrl>,
80        federation_id: FederationId,
81        api_secret: Option<String>,
82    ) -> Self {
83        let max_size = peer_to_url_map.to_num_peers().max_evil() + 1;
84        let mut code_vec: Vec<InviteCodePart> = peer_to_url_map
85            .iter()
86            .take(max_size)
87            .map(|(peer, url)| InviteCodePart::Api {
88                url: url.clone(),
89                peer: *peer,
90            })
91            .collect();
92
93        code_vec.push(InviteCodePart::FederationId(federation_id));
94
95        if let Some(api_secret) = api_secret {
96            code_vec.push(InviteCodePart::ApiSecret(api_secret));
97        }
98
99        Self(code_vec)
100    }
101
102    /// Constructs an [`InviteCode`] which contains as many guardian URLs as
103    /// needed to always be able to join a working federation
104    pub fn new_with_essential_num_guardians(
105        peer_to_url_map: &BTreeMap<PeerId, SafeUrl>,
106        federation_id: FederationId,
107    ) -> Self {
108        let max_size = peer_to_url_map.to_num_peers().max_evil() + 1;
109        let mut code_vec: Vec<InviteCodePart> = peer_to_url_map
110            .iter()
111            .take(max_size)
112            .map(|(peer, url)| InviteCodePart::Api {
113                url: url.clone(),
114                peer: *peer,
115            })
116            .collect();
117        code_vec.push(InviteCodePart::FederationId(federation_id));
118
119        Self(code_vec)
120    }
121
122    /// Returns the API URL of one of the guardians.
123    pub fn url(&self) -> SafeUrl {
124        self.0
125            .iter()
126            .find_map(|data| match data {
127                InviteCodePart::Api { url, .. } => Some(url.clone()),
128                _ => None,
129            })
130            .expect("Ensured by constructor")
131    }
132
133    /// Api secret, if needed, to use when communicating with the federation
134    pub fn api_secret(&self) -> Option<String> {
135        self.0.iter().find_map(|data| match data {
136            InviteCodePart::ApiSecret(api_secret) => Some(api_secret.clone()),
137            _ => None,
138        })
139    }
140    /// Returns the id of the guardian from which we got the API URL, see
141    /// [`InviteCode::url`].
142    pub fn peer(&self) -> PeerId {
143        self.0
144            .iter()
145            .find_map(|data| match data {
146                InviteCodePart::Api { peer, .. } => Some(*peer),
147                _ => None,
148            })
149            .expect("Ensured by constructor")
150    }
151
152    /// Get all peer URLs in the [`InviteCode`]
153    pub fn peers(&self) -> BTreeMap<PeerId, SafeUrl> {
154        self.0
155            .iter()
156            .filter_map(|entry| match entry {
157                InviteCodePart::Api { url, peer } => Some((*peer, url.clone())),
158                _ => None,
159            })
160            .collect()
161    }
162
163    /// Returns the federation's ID that can be used to authenticate the config
164    /// downloaded from the API.
165    pub fn federation_id(&self) -> FederationId {
166        self.0
167            .iter()
168            .find_map(|data| match data {
169                InviteCodePart::FederationId(federation_id) => Some(*federation_id),
170                _ => None,
171            })
172            .expect("Ensured by constructor")
173    }
174}
175
176/// For extendability [`InviteCode`] consists of parts, where client can ignore
177/// ones they don't understand.
178///
179/// ones they don't understand Data that can be encoded in the invite code.
180/// Currently we always just use one `Api` and one `FederationId` variant in an
181/// invite code, but more can be added in the future while still keeping the
182/// invite code readable for older clients, which will just ignore the new
183/// fields.
184#[derive(Clone, Debug, Eq, PartialEq, Encodable, Decodable, Hash, Ord, PartialOrd)]
185enum InviteCodePart {
186    /// API endpoint of one of the guardians
187    Api {
188        /// URL to reach an API that we can download configs from
189        url: SafeUrl,
190        /// Peer id of the host from the Url
191        peer: PeerId,
192    },
193
194    /// Authentication id for the federation
195    FederationId(FederationId),
196
197    /// Api secret to use
198    ApiSecret(String),
199
200    /// Unknown invite code fields to be defined in the future
201    #[encodable_default]
202    Default { variant: u64, bytes: Vec<u8> },
203}
204
205/// We can represent client invite code as a bech32 string for compactness and
206/// error-checking
207///
208/// Human readable part (HRP) includes the version
209/// ```txt
210/// [ hrp (4 bytes) ] [ id (48 bytes) ] ([ url len (2 bytes) ] [ url bytes (url len bytes) ])+
211/// ```
212const BECH32_HRP: Hrp = Hrp::parse_unchecked("fed1");
213
214impl FromStr for InviteCode {
215    type Err = InviteCodeParseError;
216
217    fn from_str(encoded: &str) -> Result<Self, Self::Err> {
218        // The prefix is ASCII, so a case-insensitive match here agrees with the
219        // lowercasing `decode_prefixed` does before comparing the prefix.
220        if encoded
221            .get(..FEDIMINT_PREFIX.len())
222            .is_some_and(|prefix| prefix.eq_ignore_ascii_case(FEDIMINT_PREFIX))
223        {
224            return Ok(crate::base32::decode_prefixed(FEDIMINT_PREFIX, encoded)?);
225        }
226
227        let (hrp, data) = bech32::decode(encoded)?;
228
229        if hrp != BECH32_HRP {
230            return Err(InviteCodeParseError::InvalidHrp { hrp });
231        }
232
233        let invite = Self::consensus_decode_whole(&data, &ModuleRegistry::default())?;
234
235        Ok(invite)
236    }
237}
238
239/// Failure to parse an [`InviteCode`] from its string form.
240#[derive(Debug, Error)]
241#[non_exhaustive]
242pub enum InviteCodeParseError {
243    /// The string is not a valid bech32m encoding.
244    #[error("Invalid bech32 encoding: {0}")]
245    Bech32(#[from] bech32::DecodeError),
246    /// The bech32 human-readable part is not the invite code HRP.
247    #[error(
248        "Invalid bech32 human-readable part '{hrp}', expected '{}'",
249        BECH32_HRP
250    )]
251    InvalidHrp { hrp: bech32::Hrp },
252    /// The payload is not a valid consensus encoding of an invite code.
253    #[error("Invalid invite code payload: {}", .0.fmt_compact())]
254    Decode(DecodeError),
255    /// The string carries the [`FEDIMINT_PREFIX`] but its base 32 payload does
256    /// not decode into an invite code.
257    #[error("Invalid prefixed base32 invite code: {}", .0.fmt_compact())]
258    Base32(PrefixedDecodeError),
259}
260
261impl From<DecodeError> for InviteCodeParseError {
262    fn from(source: DecodeError) -> Self {
263        Self::Decode(source)
264    }
265}
266
267impl From<PrefixedDecodeError> for InviteCodeParseError {
268    fn from(source: PrefixedDecodeError) -> Self {
269        Self::Base32(source)
270    }
271}
272
273/// Parses the invite code from a bech32 string
274impl Display for InviteCode {
275    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
276        let data = self.consensus_encode_to_vec();
277        let encode = bech32::encode::<Bech32m>(BECH32_HRP, &data).map_err(|_| fmt::Error)?;
278        formatter.write_str(&encode)
279    }
280}
281
282impl Serialize for InviteCode {
283    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
284    where
285        S: serde::Serializer,
286    {
287        String::serialize(&self.to_string(), serializer)
288    }
289}
290
291impl<'de> Deserialize<'de> for InviteCode {
292    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
293    where
294        D: serde::Deserializer<'de>,
295    {
296        let string = Cow::<str>::deserialize(deserializer)?;
297        Self::from_str(&string).map_err(serde::de::Error::custom)
298    }
299}
300
301#[cfg(test)]
302mod tests;