/ fedimint-core / src / invite_code.rs
invite_code.rs
  1  use core::fmt;
  2  use std::borrow::Cow;
  3  use std::collections::BTreeMap;
  4  use std::fmt::{Display, Formatter};
  5  use std::io::{Cursor, Read};
  6  use std::str::FromStr;
  7  
  8  use anyhow::ensure;
  9  use bech32::{Bech32m, Hrp};
 10  use serde::{Deserialize, Serialize};
 11  
 12  use crate::config::FederationId;
 13  use crate::encoding::{Decodable, DecodeError, Encodable};
 14  use crate::module::registry::ModuleDecoderRegistry;
 15  use crate::util::SafeUrl;
 16  use crate::{NumPeersExt as _, 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)]
 27  pub struct InviteCode(Vec<InviteCodeData>);
 28  
 29  impl Decodable for InviteCode {
 30      fn consensus_decode<R: Read>(
 31          r: &mut R,
 32          modules: &ModuleDecoderRegistry,
 33      ) -> Result<Self, DecodeError> {
 34          let inner: Vec<InviteCodeData> = Decodable::consensus_decode(r, modules)?;
 35  
 36          if !inner
 37              .iter()
 38              .any(|data| matches!(data, InviteCodeData::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, InviteCodeData::FederationId(_)))
 48          {
 49              return Err(DecodeError::from_str(
 50                  "No Federation ID provided in invite code",
 51              ));
 52          }
 53  
 54          Ok(InviteCode(inner))
 55      }
 56  }
 57  
 58  impl InviteCode {
 59      pub fn new(url: SafeUrl, peer: PeerId, federation_id: FederationId) -> Self {
 60          InviteCode(vec![
 61              InviteCodeData::Api { url, peer },
 62              InviteCodeData::FederationId(federation_id),
 63          ])
 64      }
 65  
 66      /// Constructs an [`InviteCode`] which contains as many guardian URLs as
 67      /// needed to always be able to join a working federation
 68      pub fn new_with_essential_num_guardians(
 69          peer_to_url_map: &BTreeMap<PeerId, SafeUrl>,
 70          federation_id: FederationId,
 71      ) -> Self {
 72          let max_size = peer_to_url_map.max_evil() + 1;
 73          let mut code_vec: Vec<InviteCodeData> = peer_to_url_map
 74              .iter()
 75              .take(max_size)
 76              .map(|(peer, url)| InviteCodeData::Api {
 77                  url: url.clone(),
 78                  peer: *peer,
 79              })
 80              .collect();
 81          code_vec.push(InviteCodeData::FederationId(federation_id));
 82  
 83          InviteCode(code_vec)
 84      }
 85  
 86      /// Returns the API URL of one of the guardians.
 87      pub fn url(&self) -> SafeUrl {
 88          self.0
 89              .iter()
 90              .find_map(|data| match data {
 91                  InviteCodeData::Api { url, .. } => Some(url.clone()),
 92                  _ => None,
 93              })
 94              .expect("Ensured by constructor")
 95      }
 96  
 97      /// Returns the id of the guardian from which we got the API URL, see
 98      /// [`InviteCode::url`].
 99      pub fn peer(&self) -> PeerId {
100          self.0
101              .iter()
102              .find_map(|data| match data {
103                  InviteCodeData::Api { peer, .. } => Some(*peer),
104                  _ => None,
105              })
106              .expect("Ensured by constructor")
107      }
108  
109      /// Get all peer URLs in the [`InviteCode`]
110      pub fn peers(&self) -> BTreeMap<PeerId, SafeUrl> {
111          self.0
112              .iter()
113              .filter_map(|entry| match entry {
114                  InviteCodeData::Api { url, peer } => Some((*peer, url.clone())),
115                  _ => None,
116              })
117              .collect()
118      }
119  
120      /// Returns the federation's ID that can be used to authenticate the config
121      /// downloaded from the API.
122      pub fn federation_id(&self) -> FederationId {
123          self.0
124              .iter()
125              .find_map(|data| match data {
126                  InviteCodeData::FederationId(federation_id) => Some(*federation_id),
127                  _ => None,
128              })
129              .expect("Ensured by constructor")
130      }
131  }
132  
133  /// Data that can be encoded in the invite code. Currently we always just use
134  /// one `Api` and one `FederationId` variant in an invite code, but more can be
135  /// added in the future while still keeping the invite code readable for older
136  /// clients, which will just ignore the new fields.
137  #[derive(Clone, Debug, Eq, PartialEq, Encodable, Decodable)]
138  enum InviteCodeData {
139      /// API endpoint of one of the guardians
140      Api {
141          /// URL to reach an API that we can download configs from
142          url: SafeUrl,
143          /// Peer id of the host from the Url
144          peer: PeerId,
145      },
146      /// Authentication id for the federation
147      FederationId(FederationId),
148      /// Unknown invite code fields to be defined in the future
149      #[encodable_default]
150      Default { variant: u64, bytes: Vec<u8> },
151  }
152  
153  /// We can represent client invite code as a bech32 string for compactness and
154  /// error-checking
155  ///
156  /// Human readable part (HRP) includes the version
157  /// ```txt
158  /// [ hrp (4 bytes) ] [ id (48 bytes) ] ([ url len (2 bytes) ] [ url bytes (url len bytes) ])+
159  /// ```
160  const BECH32_HRP: Hrp = Hrp::parse_unchecked("fed1");
161  
162  impl FromStr for InviteCode {
163      type Err = anyhow::Error;
164  
165      fn from_str(encoded: &str) -> Result<Self, Self::Err> {
166          let (hrp, data) = bech32::decode(encoded)?;
167  
168          ensure!(hrp == BECH32_HRP, "Invalid HRP in bech32 encoding");
169  
170          let invite = InviteCode::consensus_decode(&mut Cursor::new(data), &Default::default())?;
171  
172          Ok(invite)
173      }
174  }
175  
176  /// Parses the invite code from a bech32 string
177  impl Display for InviteCode {
178      fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
179          let mut data = vec![];
180  
181          self.consensus_encode(&mut data)
182              .expect("Vec<u8> provides capacity");
183  
184          let encode = bech32::encode::<Bech32m>(BECH32_HRP, &data).map_err(|_| fmt::Error)?;
185          formatter.write_str(&encode)
186      }
187  }
188  
189  impl Serialize for InviteCode {
190      fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
191      where
192          S: serde::Serializer,
193      {
194          String::serialize(&self.to_string(), serializer)
195      }
196  }
197  
198  impl<'de> Deserialize<'de> for InviteCode {
199      fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
200      where
201          D: serde::Deserializer<'de>,
202      {
203          let string = Cow::<str>::deserialize(deserializer)?;
204          Self::from_str(&string).map_err(serde::de::Error::custom)
205      }
206  }
207  
208  #[cfg(test)]
209  mod tests {
210      use std::str::FromStr;
211  
212      use crate::config::FederationId;
213      use crate::invite_code::InviteCode;
214  
215      #[test]
216      fn test_invite_code_to_from_string() {
217          let invite_code_str = "fed11qgqpu8rhwden5te0vejkg6tdd9h8gepwd4cxcumxv4jzuen0duhsqqfqh6nl7sgk72caxfx8khtfnn8y436q3nhyrkev3qp8ugdhdllnh86qmp42pm";
218          let invite_code = InviteCode::from_str(invite_code_str).expect("valid invite code");
219  
220          assert_eq!(invite_code.to_string(), invite_code_str);
221          assert_eq!(
222              invite_code.0,
223              [
224                  crate::invite_code::InviteCodeData::Api {
225                      url: "wss://fedimintd.mplsfed.foo/".parse().expect("valid url"),
226                      peer: crate::PeerId(0),
227                  },
228                  crate::invite_code::InviteCodeData::FederationId(FederationId(
229                      bitcoin_hashes::sha256::Hash::from_str(
230                          "bea7ff4116f2b1d324c7b5d699cce4ac7408cee41db2c88027e21b76fff3b9f4"
231                      )
232                      .expect("valid hash")
233                  ))
234              ]
235          );
236      }
237  }