Skip to main content

fedimint_mint_common/
config.rs

1use std::collections::BTreeMap;
2
3use fedimint_core::config::ExcessiveRelativeFeeError;
4use fedimint_core::core::ModuleKind;
5use fedimint_core::encoding::{Decodable, Encodable};
6use fedimint_core::module::serde_json;
7use fedimint_core::{Amount, PeerId, Tiered, plugin_types_trait_impl_config};
8use serde::{Deserialize, Serialize};
9use tbs::{AggregatePublicKey, PublicKeyShare};
10
11use crate::MintCommonInit;
12
13#[derive(Clone, Debug, Serialize, Deserialize)]
14pub struct MintConfig {
15    pub private: MintConfigPrivate,
16    pub consensus: MintConfigConsensus,
17}
18
19#[derive(Clone, Debug, Serialize, Deserialize, Decodable, Encodable)]
20pub struct MintConfigLocal;
21
22#[derive(Clone, Debug, Serialize, Deserialize, Encodable, Decodable)]
23pub struct MintConfigConsensus {
24    /// The set of public keys for blind-signing all peers and note
25    /// denominations
26    pub peer_tbs_pks: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
27    /// Fees charged for ecash transactions
28    pub fee_consensus: FeeConsensus,
29    /// The maximum amount of change a client can request
30    pub max_notes_per_denomination: u16,
31}
32
33#[derive(Clone, Debug, Serialize, Deserialize)]
34pub struct MintConfigPrivate {
35    /// Secret keys for blind-signing ecash of varying note denominations
36    pub tbs_sks: Tiered<tbs::SecretKeyShare>,
37}
38
39#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Encodable, Decodable, Hash)]
40pub struct MintClientConfig {
41    pub tbs_pks: Tiered<AggregatePublicKey>,
42    pub fee_consensus: FeeConsensus,
43    pub peer_tbs_pks: BTreeMap<PeerId, Tiered<tbs::PublicKeyShare>>,
44    pub max_notes_per_denomination: u16,
45}
46
47impl std::fmt::Display for MintClientConfig {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        write!(
50            f,
51            "MintClientConfig {}",
52            serde_json::to_string(self).map_err(|_e| std::fmt::Error)?
53        )
54    }
55}
56
57// Wire together the configs for this module
58plugin_types_trait_impl_config!(
59    MintCommonInit,
60    MintConfig,
61    MintConfigPrivate,
62    MintConfigConsensus,
63    MintClientConfig
64);
65
66#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Encodable)]
67pub struct FeeConsensus {
68    base: Amount,
69    parts_per_million: u64,
70}
71
72#[derive(Deserialize)]
73struct UnvalidatedFeeConsensus {
74    base: Amount,
75    parts_per_million: u64,
76}
77
78impl FeeConsensus {
79    /// The mint module will charge a non-configurable base fee of one hundred
80    /// millisatoshis per transaction input and output to account for the costs
81    /// incurred by the federation for processing the transaction. On top of
82    /// that the federation may charge a additional relative fee per input and
83    /// output of up to one thousand parts per million which is equal to one
84    /// tenth of one percent.
85    ///
86    /// # Errors
87    /// - Returns [`ExcessiveRelativeFeeError`] if the relative fee is in excess
88    ///   of one thousand parts per million.
89    pub fn new(parts_per_million: u64) -> Result<Self, ExcessiveRelativeFeeError> {
90        const MAX_PARTS_PER_MILLION: u64 = 1_000;
91
92        if parts_per_million > MAX_PARTS_PER_MILLION {
93            return Err(ExcessiveRelativeFeeError {
94                parts_per_million,
95                max: MAX_PARTS_PER_MILLION,
96            });
97        }
98
99        Ok(Self {
100            base: Amount::from_msats(100),
101            parts_per_million,
102        })
103    }
104
105    pub fn zero() -> Self {
106        Self {
107            base: Amount::ZERO,
108            parts_per_million: 0,
109        }
110    }
111
112    pub fn fee(&self, amount: Amount) -> Amount {
113        Amount::from_msats(self.fee_msats(amount.msats))
114    }
115
116    /// Returns the smallest denomination where the note's value is at least
117    /// 4x the base fee, rounded up to the next power of two msats. Notes
118    /// below this denomination are not economical since fees consume a
119    /// significant fraction of their value.
120    pub fn min_economical_denomination(&self) -> Amount {
121        Amount::from_msats(self.base.msats.saturating_mul(4).next_power_of_two())
122    }
123
124    /// Rounds an amount up to the nearest multiple of the smallest economical
125    /// denomination.
126    pub fn round_up(&self, amount: Amount) -> Amount {
127        let msats = amount
128            .msats
129            .next_multiple_of(self.base.msats.saturating_mul(4).next_power_of_two());
130
131        Amount::from_msats(msats)
132    }
133
134    fn fee_msats(&self, msats: u64) -> u64 {
135        msats
136            .saturating_mul(self.parts_per_million)
137            .saturating_div(1_000_000)
138            .checked_add(self.base.msats)
139            .expect("The division creates sufficient headroom to add the base fee")
140    }
141
142    fn from_unvalidated(
143        base: Amount,
144        parts_per_million: u64,
145    ) -> Result<FeeConsensus, &'static str> {
146        // Preserve the legacy static-config invariant. The dynamic-fee design in
147        // #8758 moves broader policy to separate FeeConsensus/FeeRate types.
148        if !((base == Amount::ZERO && parts_per_million == 0)
149            || (base == Amount::from_msats(100) && parts_per_million <= 1_000))
150        {
151            return Err(
152                "Mint fees must be zero or use the 100 msat base fee and at most one thousand parts per million",
153            );
154        }
155
156        Ok(Self {
157            base,
158            parts_per_million,
159        })
160    }
161}
162
163impl<'de> Deserialize<'de> for FeeConsensus {
164    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
165    where
166        D: serde::Deserializer<'de>,
167    {
168        let unvalidated = UnvalidatedFeeConsensus::deserialize(deserializer)?;
169        Self::from_unvalidated(unvalidated.base, unvalidated.parts_per_million)
170            .map_err(serde::de::Error::custom)
171    }
172}
173
174impl Decodable for FeeConsensus {
175    fn consensus_decode_partial<R: std::io::Read>(
176        reader: &mut R,
177        modules: &fedimint_core::module::registry::ModuleDecoderRegistry,
178    ) -> Result<Self, fedimint_core::encoding::DecodeError> {
179        let base = Amount::consensus_decode_partial(reader, modules)?;
180        let parts_per_million = u64::consensus_decode_partial(reader, modules)?;
181
182        Self::from_unvalidated(base, parts_per_million)
183            .map_err(|error| fedimint_core::encoding::DecodeError::custom(error.to_string()))
184    }
185}
186
187#[test]
188fn test_fee_consensus() {
189    let fee_consensus = FeeConsensus::new(1_000).expect("Relative fee is within range");
190
191    assert_eq!(
192        fee_consensus.fee(Amount::from_msats(999)),
193        Amount::from_msats(100)
194    );
195
196    assert_eq!(
197        fee_consensus.fee(Amount::from_sats(1)),
198        Amount::from_msats(100) + Amount::from_msats(1)
199    );
200
201    assert_eq!(
202        fee_consensus.fee(Amount::from_sats(1000)),
203        Amount::from_sats(1) + Amount::from_msats(100)
204    );
205
206    assert_eq!(
207        fee_consensus.fee(Amount::from_bitcoins(1)),
208        Amount::from_sats(100_000) + Amount::from_msats(100)
209    );
210
211    assert_eq!(
212        fee_consensus.fee(Amount::from_bitcoins(100_000)),
213        Amount::from_bitcoins(100) + Amount::from_msats(100)
214    );
215}
216
217#[test]
218fn a_relative_fee_over_the_limit_is_rejected() {
219    let err: ExcessiveRelativeFeeError =
220        FeeConsensus::new(1_001).expect_err("A fee over one per mille is excessive");
221
222    assert_eq!(err.parts_per_million, 1_001);
223}
224
225#[cfg(test)]
226mod tests;