fedimint_core/module/
audit.rs

1use std::collections::HashMap;
2use std::fmt::{Display, Formatter};
3
4use fedimint_core::core::ModuleInstanceId;
5use futures::StreamExt;
6use itertools::Itertools;
7use serde::{Deserialize, Serialize};
8
9use crate::db::{
10    DatabaseKey, DatabaseLookup, DatabaseRecord, DatabaseTransaction,
11    IDatabaseTransactionOpsCoreTyped,
12};
13use crate::task::{MaybeSend, MaybeSync};
14
15#[derive(Default)]
16pub struct Audit {
17    items: Vec<AuditItem>,
18}
19
20impl Audit {
21    pub fn net_assets(&self) -> Option<AuditItem> {
22        Some(AuditItem {
23            name: "Net assets (sats)".to_string(),
24            milli_sat: calculate_net_assets(self.items.iter())?,
25            module_instance_id: None,
26        })
27    }
28
29    pub async fn add_items<KP, F>(
30        &mut self,
31        dbtx: &mut DatabaseTransaction<'_>,
32        module_instance_id: ModuleInstanceId,
33        key_prefix: &KP,
34        to_milli_sat: F,
35    ) where
36        KP: DatabaseLookup + 'static + MaybeSend + MaybeSync,
37        KP::Record: DatabaseKey,
38        F: Fn(KP::Record, <<KP as DatabaseLookup>::Record as DatabaseRecord>::Value) -> i64,
39    {
40        let mut new_items = dbtx
41            .find_by_prefix(key_prefix)
42            .await
43            .map(|(key, value)| {
44                let name = format!("{key:?}");
45                let milli_sat = to_milli_sat(key, value);
46                AuditItem {
47                    name,
48                    milli_sat,
49                    module_instance_id: Some(module_instance_id),
50                }
51            })
52            .collect::<Vec<AuditItem>>()
53            .await;
54        self.items.append(&mut new_items);
55    }
56}
57
58impl Display for Audit {
59    fn fmt(&self, formatter: &mut Formatter) -> std::fmt::Result {
60        formatter.write_str("- Balance Sheet -")?;
61        for item in &self.items {
62            formatter.write_fmt(format_args!("\n{item}"))?;
63        }
64        formatter.write_fmt(format_args!(
65            "\n{}",
66            self.net_assets()
67                .expect("We'd have crashed already if there was an overflow")
68        ))
69    }
70}
71
72pub struct AuditItem {
73    pub name: String,
74    pub milli_sat: i64,
75    pub module_instance_id: Option<ModuleInstanceId>,
76}
77
78impl Display for AuditItem {
79    fn fmt(&self, formatter: &mut Formatter) -> std::fmt::Result {
80        let sats = (self.milli_sat as f64) / 1000.0;
81        formatter.write_fmt(format_args!("{:>+15.3}|{}", sats, self.name))
82    }
83}
84
85#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
86pub struct AuditSummary {
87    pub net_assets: i64,
88    pub module_summaries: HashMap<ModuleInstanceId, ModuleSummary>,
89}
90
91#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
92pub struct ModuleSummary {
93    pub net_assets: i64,
94    pub kind: String,
95}
96
97impl AuditSummary {
98    pub fn from_audit(
99        audit: &Audit,
100        module_instance_id_to_kind: &HashMap<ModuleInstanceId, String>,
101    ) -> Self {
102        let empty_module_placeholders = module_instance_id_to_kind
103            .iter()
104            .map(|(id, _)| create_empty_module_placeholder(*id))
105            .collect::<Vec<_>>();
106        Self {
107            net_assets: calculate_net_assets(audit.items.iter())
108                .expect("We'd have crashed already if there was an overflow"),
109            module_summaries: generate_module_summaries(
110                audit.items.iter().chain(&empty_module_placeholders),
111                module_instance_id_to_kind,
112            ),
113        }
114    }
115}
116
117fn generate_module_summaries<'a>(
118    audit_items: impl Iterator<Item = &'a AuditItem>,
119    module_instance_id_to_kind: &HashMap<ModuleInstanceId, String>,
120) -> HashMap<ModuleInstanceId, ModuleSummary> {
121    audit_items
122        .filter_map(|item| {
123            item.module_instance_id
124                .as_ref()
125                .map(|module_instance_id| (module_instance_id, item))
126        })
127        .into_group_map()
128        .into_iter()
129        .map(|(module_instance_id, module_audit_items)| {
130            let kind = module_instance_id_to_kind
131                .get(module_instance_id)
132                .expect("module instance id should have a kind")
133                .to_string();
134            (
135                *module_instance_id,
136                ModuleSummary {
137                    net_assets: calculate_net_assets(module_audit_items.into_iter())
138                        .expect("We'd have crashed already if there was an overflow"),
139                    kind,
140                },
141            )
142        })
143        .collect()
144}
145
146fn calculate_net_assets<'a>(items: impl Iterator<Item = &'a AuditItem>) -> Option<i64> {
147    items
148        .map(|item| item.milli_sat)
149        .try_fold(0i64, i64::checked_add)
150}
151
152// Adding a placeholder ensures that a ModuleSummary exists even if the module
153// does not have any AuditItems (e.g. from a lack of activity, db compaction,
154// etc), which is useful for downstream consumers of AuditSummaries.
155fn create_empty_module_placeholder(module_instance_id: ModuleInstanceId) -> AuditItem {
156    AuditItem {
157        name: "Module placeholder".to_string(),
158        milli_sat: 0,
159        module_instance_id: Some(module_instance_id),
160    }
161}
162
163#[test]
164fn creates_audit_summary_from_audit() {
165    let audit = Audit {
166        items: vec![
167            AuditItem {
168                name: "ContractKey(...)".to_string(),
169                milli_sat: -101_000,
170                module_instance_id: Some(0),
171            },
172            AuditItem {
173                name: "IssuanceTotal".to_string(),
174                milli_sat: -50_100_000,
175                module_instance_id: Some(1),
176            },
177            AuditItem {
178                name: "Redemption(...)".to_string(),
179                milli_sat: 101_000,
180                module_instance_id: Some(1),
181            },
182            AuditItem {
183                name: "RedemptionTotal".to_string(),
184                milli_sat: 100_000,
185                module_instance_id: Some(1),
186            },
187            AuditItem {
188                name: "UTXOKey(...)".to_string(),
189                milli_sat: 20_000_000,
190                module_instance_id: Some(2),
191            },
192            AuditItem {
193                name: "UTXOKey(...)".to_string(),
194                milli_sat: 10_000_000,
195                module_instance_id: Some(2),
196            },
197            AuditItem {
198                name: "UTXOKey(...)".to_string(),
199                milli_sat: 20_000_000,
200                module_instance_id: Some(2),
201            },
202        ],
203    };
204
205    let audit_summary = AuditSummary::from_audit(
206        &audit,
207        &HashMap::from([
208            (0, "ln".to_string()),
209            (1, "mint".to_string()),
210            (2, "wallet".to_string()),
211        ]),
212    );
213    let expected_audit_summary = AuditSummary {
214        net_assets: 0,
215        module_summaries: HashMap::from([
216            (
217                0,
218                ModuleSummary {
219                    net_assets: -101_000,
220                    kind: "ln".to_string(),
221                },
222            ),
223            (
224                1,
225                ModuleSummary {
226                    net_assets: -49_899_000,
227                    kind: "mint".to_string(),
228                },
229            ),
230            (
231                2,
232                ModuleSummary {
233                    net_assets: 50_000_000,
234                    kind: "wallet".to_string(),
235                },
236            ),
237        ]),
238    };
239
240    assert_eq!(audit_summary, expected_audit_summary);
241}
242
243#[test]
244fn audit_summary_includes_placeholders() {
245    let audit_summary = AuditSummary::from_audit(
246        &Audit::default(),
247        &HashMap::from([
248            (0, "ln".to_string()),
249            (1, "mint".to_string()),
250            (2, "wallet".to_string()),
251        ]),
252    );
253    let expected_audit_summary = AuditSummary {
254        net_assets: 0,
255        module_summaries: HashMap::from([
256            (
257                0,
258                ModuleSummary {
259                    net_assets: 0,
260                    kind: "ln".to_string(),
261                },
262            ),
263            (
264                1,
265                ModuleSummary {
266                    net_assets: 0,
267                    kind: "mint".to_string(),
268                },
269            ),
270            (
271                2,
272                ModuleSummary {
273                    net_assets: 0,
274                    kind: "wallet".to_string(),
275                },
276            ),
277        ]),
278    };
279
280    assert_eq!(audit_summary, expected_audit_summary);
281}