Skip to main content

fedimint_mintv2_client/
ecash.rs

1use fedimint_core::config::FederationId;
2use fedimint_core::encoding::{Decodable, Encodable};
3use fedimint_core::invite_code::InviteCode;
4use fedimint_core::module::AmountUnit;
5use fedimint_core::util::SafeUrl;
6use fedimint_core::{Amount, PeerId};
7
8use crate::SpendableNote;
9
10#[derive(Clone, Debug, Encodable, Decodable)]
11pub struct ECash(Vec<ECashField>);
12
13#[derive(Clone, Debug, Decodable, Encodable)]
14enum ECashField {
15    Mint(FederationId),
16    Note(SpendableNote),
17    /// Invite code to join the federation by which the e-cash was issued. This
18    /// allows a recipient that has not yet joined the federation to do so
19    /// directly from the received ecash.
20    Invite {
21        peer_apis: Vec<(PeerId, SafeUrl)>,
22        federation_id: FederationId,
23    },
24    ApiSecret(String),
25    /// Self-describes the [`AmountUnit`] these notes are denominated in, so
26    /// recipients don't have to brute-force which mint module instance the
27    /// notes belong to. Only present for non-Bitcoin units; absence means
28    /// [`AmountUnit::BITCOIN`]. Since multi-asset e-cash is not widely used
29    /// yet, it's ok that clients predating this variant skip it via the
30    /// default field and thus can't distinguish units.
31    Unit(AmountUnit),
32    #[encodable_default]
33    Default {
34        variant: u64,
35        bytes: Vec<u8>,
36    },
37}
38
39impl ECash {
40    pub fn new(mint: FederationId, notes: Vec<SpendableNote>) -> Self {
41        Self(
42            std::iter::once(ECashField::Mint(mint))
43                .chain(notes.into_iter().map(ECashField::Note))
44                .collect(),
45        )
46    }
47
48    pub fn new_with_invite(notes: Vec<SpendableNote>, invite: &InviteCode) -> Self {
49        let mut fields = vec![ECashField::Mint(invite.federation_id())];
50
51        fields.extend(notes.into_iter().map(ECashField::Note));
52
53        fields.push(ECashField::Invite {
54            peer_apis: vec![(invite.peer(), invite.url())],
55            federation_id: invite.federation_id(),
56        });
57
58        if let Some(api_secret) = invite.api_secret() {
59            fields.push(ECashField::ApiSecret(api_secret));
60        }
61
62        Self(fields)
63    }
64
65    /// Attaches the [`AmountUnit`] these notes are denominated in. Bitcoin is
66    /// the default and not encoded explicitly.
67    #[must_use]
68    pub fn with_unit(mut self, unit: AmountUnit) -> Self {
69        if !unit.is_bitcoin() {
70            self.0.push(ECashField::Unit(unit));
71        }
72
73        self
74    }
75
76    /// The [`AmountUnit`] these notes are denominated in. E-cash without an
77    /// explicit unit is denominated in Bitcoin.
78    pub fn unit(&self) -> AmountUnit {
79        self.0
80            .iter()
81            .find_map(|field| match field {
82                ECashField::Unit(unit) => Some(*unit),
83                _ => None,
84            })
85            .unwrap_or(AmountUnit::BITCOIN)
86    }
87
88    pub fn amount(&self) -> Amount {
89        self.0
90            .iter()
91            .filter_map(|field| match field {
92                ECashField::Note(note) => Some(note.amount()),
93                _ => None,
94            })
95            .sum()
96    }
97
98    pub fn mint(&self) -> Option<FederationId> {
99        self.0.iter().find_map(|field| match field {
100            ECashField::Mint(mint) => Some(*mint),
101            _ => None,
102        })
103    }
104
105    pub fn notes(&self) -> Vec<SpendableNote> {
106        self.0
107            .iter()
108            .filter_map(|field| match field {
109                ECashField::Note(note) => Some(note.clone()),
110                _ => None,
111            })
112            .collect()
113    }
114
115    /// The invite code of the federation by which this ecash was issued, if it
116    /// was included by the sender.
117    pub fn federation_invite(&self) -> Option<InviteCode> {
118        let api_secret = self.api_secret();
119
120        self.0.iter().find_map(|field| {
121            let ECashField::Invite {
122                peer_apis,
123                federation_id,
124            } = field
125            else {
126                return None;
127            };
128
129            let (peer_id, api) = peer_apis.first().cloned()?;
130
131            Some(InviteCode::new(
132                api,
133                peer_id,
134                *federation_id,
135                api_secret.clone(),
136            ))
137        })
138    }
139
140    fn api_secret(&self) -> Option<String> {
141        self.0.iter().find_map(|field| match field {
142            ECashField::ApiSecret(api_secret) => Some(api_secret.clone()),
143            _ => None,
144        })
145    }
146}
147
148#[cfg(test)]
149mod tests;