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 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, base32};
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_partial<R: Read>(
31        r: &mut R,
32        modules: &ModuleDecoderRegistry,
33    ) -> Result<Self, DecodeError> {
34        let inner: Vec<InviteCodePart> = Decodable::consensus_decode_partial(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    pub fn encode_base32(&self) -> String {
175        format!(
176            "fedimint{}",
177            base32::encode(&self.consensus_encode_to_vec())
178        )
179    }
180
181    pub fn decode_base32(s: &str) -> anyhow::Result<Self> {
182        ensure!(s.starts_with("fedimint"), "Invalid Prefix");
183
184        let params = Self::consensus_decode_whole(
185            &base32::decode(&s[8..])?,
186            &ModuleDecoderRegistry::default(),
187        )?;
188
189        Ok(params)
190    }
191}
192
193/// For extendability [`InviteCode`] consists of parts, where client can ignore
194/// ones they don't understand.
195///
196/// ones they don't understand Data that can be encoded in the invite code.
197/// Currently we always just use one `Api` and one `FederationId` variant in an
198/// invite code, but more can be added in the future while still keeping the
199/// invite code readable for older clients, which will just ignore the new
200/// fields.
201#[derive(Clone, Debug, Eq, PartialEq, Encodable, Decodable, Hash, Ord, PartialOrd)]
202enum InviteCodePart {
203    /// API endpoint of one of the guardians
204    Api {
205        /// URL to reach an API that we can download configs from
206        url: SafeUrl,
207        /// Peer id of the host from the Url
208        peer: PeerId,
209    },
210
211    /// Authentication id for the federation
212    FederationId(FederationId),
213
214    /// Api secret to use
215    ApiSecret(String),
216
217    /// Unknown invite code fields to be defined in the future
218    #[encodable_default]
219    Default { variant: u64, bytes: Vec<u8> },
220}
221
222/// We can represent client invite code as a bech32 string for compactness and
223/// error-checking
224///
225/// Human readable part (HRP) includes the version
226/// ```txt
227/// [ hrp (4 bytes) ] [ id (48 bytes) ] ([ url len (2 bytes) ] [ url bytes (url len bytes) ])+
228/// ```
229const BECH32_HRP: Hrp = Hrp::parse_unchecked("fed1");
230
231impl FromStr for InviteCode {
232    type Err = anyhow::Error;
233
234    fn from_str(encoded: &str) -> Result<Self, Self::Err> {
235        if let Ok(invite_code) = Self::decode_base32(encoded) {
236            return Ok(invite_code);
237        }
238
239        let (hrp, data) = bech32::decode(encoded)?;
240
241        ensure!(hrp == BECH32_HRP, "Invalid HRP in bech32 encoding");
242
243        let invite = Self::consensus_decode_whole(&data, &ModuleRegistry::default())?;
244
245        Ok(invite)
246    }
247}
248
249/// Parses the invite code from a bech32 string
250impl Display for InviteCode {
251    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
252        let data = self.consensus_encode_to_vec();
253        let encode = bech32::encode::<Bech32m>(BECH32_HRP, &data).map_err(|_| fmt::Error)?;
254        formatter.write_str(&encode)
255    }
256}
257
258impl Serialize for InviteCode {
259    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
260    where
261        S: serde::Serializer,
262    {
263        String::serialize(&self.to_string(), serializer)
264    }
265}
266
267impl<'de> Deserialize<'de> for InviteCode {
268    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
269    where
270        D: serde::Deserializer<'de>,
271    {
272        let string = Cow::<str>::deserialize(deserializer)?;
273        Self::from_str(&string).map_err(serde::de::Error::custom)
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use std::str::FromStr;
280
281    use fedimint_core::PeerId;
282
283    use crate::config::FederationId;
284    use crate::invite_code::InviteCode;
285
286    #[test]
287    fn test_invite_code_to_from_string() {
288        let invite_code_str = "fed11qgqpu8rhwden5te0vejkg6tdd9h8gepwd4cxcumxv4jzuen0duhsqqfqh6nl7sgk72caxfx8khtfnn8y436q3nhyrkev3qp8ugdhdllnh86qmp42pm";
289        let invite_code = InviteCode::from_str(invite_code_str).expect("valid invite code");
290
291        InviteCode::from_str(&invite_code.encode_base32())
292            .expect("Failed to parse base 32 invite code");
293
294        assert_eq!(invite_code.to_string(), invite_code_str);
295        assert_eq!(
296            invite_code.0,
297            [
298                crate::invite_code::InviteCodePart::Api {
299                    url: "wss://fedimintd.mplsfed.foo/".parse().expect("valid url"),
300                    peer: PeerId::new(0),
301                },
302                crate::invite_code::InviteCodePart::FederationId(FederationId(
303                    bitcoin::hashes::sha256::Hash::from_str(
304                        "bea7ff4116f2b1d324c7b5d699cce4ac7408cee41db2c88027e21b76fff3b9f4"
305                    )
306                    .expect("valid hash")
307                ))
308            ]
309        );
310    }
311}