Skip to main content

mint_module_tests/
mint-module-tests.rs

1use anyhow::Result;
2use clap::Parser;
3use devimint::cmd;
4use devimint::federation::{Client, Federation};
5use devimint::util::{FedimintCli, almost_equal};
6use devimint::version_constants::VERSION_0_12_0_ALPHA;
7use fedimint_client_module::ModuleRecoveryCompleted;
8use fedimint_logging::LOG_DEVIMINT;
9use rand::Rng;
10use tokio::try_join;
11use tracing::info;
12
13#[derive(Debug, Parser)]
14enum Cmd {
15    Restore,
16    /// Mint recovery test. Run with `FM_FORCE_V1_MINT_RECOVERY=1` to force
17    /// V1 (session-based) recovery path.
18    RecoveryV1,
19    /// Mint recovery test using default (V2 slice-based) recovery path.
20    RecoveryV2,
21    Sanity,
22}
23
24#[tokio::main]
25async fn main() -> anyhow::Result<()> {
26    match Cmd::parse() {
27        Cmd::Restore => restore().await,
28        Cmd::RecoveryV1 => mint_recovery_test().await,
29        Cmd::RecoveryV2 => mint_recovery_test().await,
30        Cmd::Sanity => sanity().await,
31    }
32}
33
34async fn restore() -> anyhow::Result<()> {
35    devimint::run_devfed_test()
36        .call(|fed, _process_mgr| async move {
37            let fed = fed.fed().await?;
38
39            test_restore_gap_test(fed).await?;
40            Ok(())
41        })
42        .await
43}
44
45pub async fn test_restore_gap_test(fed: &Federation) -> Result<()> {
46    let client = fed.new_joined_client("restore-gap-test").await?;
47    const PEGIN_SATS: u64 = 300000;
48    fed.pegin_client(PEGIN_SATS, &client).await?;
49
50    for i in 0..20 {
51        let gap = rand::thread_rng().gen_range(0..20);
52        info!(target: LOG_DEVIMINT, gap, "Gap");
53        cmd!(
54            client,
55            "dev",
56            "advance-note-idx",
57            "--amount",
58            "1024msat",
59            "--count",
60            // we are not guaranteed to use a 1024 note on every payment,
61            // so create some random small gaps, so it's very unlikely we
62            // would cross the default gap limit accidentally
63            &gap.to_string()
64        )
65        .run()
66        .await?;
67
68        // We need to get the balance of the client to know how much to reissue, due to
69        // the mint base fees it decreases slightly every time we reissue.
70        let notes = cmd!(client, "info").out_json().await?;
71        let balance = notes["total_amount_msat"].as_u64().unwrap();
72
73        let reissure_amount = if i % 2 == 0 {
74            // half of the time, reissue everything
75            balance
76        } else {
77            // other half, random amount
78            rand::thread_rng().gen_range(10..(balance))
79        };
80        info!(target: LOG_DEVIMINT, i, reissure_amount, "Reissue");
81
82        let notes = cmd!(client, "spend", reissure_amount)
83            .out_json()
84            .await?
85            .get("notes")
86            .expect("Output didn't contain e-cash notes")
87            .as_str()
88            .unwrap()
89            .to_owned();
90
91        // Test we can reissue our own notes
92        cmd!(client, "reissue", notes).out_json().await?;
93    }
94
95    let secret = cmd!(client, "print-secret").out_json().await?["secret"]
96        .as_str()
97        .map(ToOwned::to_owned)
98        .unwrap();
99
100    let pre_notes = cmd!(client, "info").out_json().await?;
101
102    let pre_balance = pre_notes["total_amount_msat"].as_u64().unwrap();
103
104    info!(target: LOG_DEVIMINT, %pre_notes, pre_balance, "State before backup");
105
106    // we need to have some funds
107    assert!(0 < pre_balance);
108
109    // without existing backup
110    {
111        let client =
112            devimint::federation::Client::create("restore-gap-test-without-backup").await?;
113        let _ = cmd!(
114            client,
115            "restore",
116            "--mnemonic",
117            &secret,
118            "--invite-code",
119            fed.invite_code()?
120        )
121        .out_json()
122        .await?;
123
124        let _ = cmd!(client, "dev", "wait-complete").out_json().await?;
125        let post_notes = cmd!(client, "info").out_json().await?;
126        let post_balance = post_notes["total_amount_msat"].as_u64().unwrap();
127        info!(target: LOG_DEVIMINT, %post_notes, post_balance, "State after backup");
128        assert_eq!(pre_balance, post_balance);
129        assert_eq!(pre_notes, post_notes);
130    }
131
132    Ok(())
133}
134
135/// Test that mint recovery works correctly in various scenarios.
136///
137/// The V1 variant should be run with `FM_FORCE_V1_MINT_RECOVERY=1` to
138/// force the legacy session-based recovery path which reissues recovered
139/// ecash.
140///
141/// Regression test for <https://github.com/fedimint/fedimint/issues/8004>
142async fn mint_recovery_test() -> anyhow::Result<()> {
143    devimint::run_devfed_test()
144        .call(|dev_fed, _process_mgr| async move {
145            let fed = dev_fed.fed().await?;
146
147            try_join!(
148                test_recovery_with_backup(fed),
149                test_recovery_without_backup(fed),
150                test_recovery_after_activity(fed),
151                test_recovery_with_post_backup_activity(fed),
152            )?;
153
154            Ok(())
155        })
156        .await
157}
158
159const PEGIN_SATS: u64 = 1_000_000;
160
161/// Find the mint module's `ModuleRecoveryCompleted` event in a
162/// `dev show-event-log` JSON dump.
163///
164/// Recovery runs for every module, so the mint is matched via the event's
165/// `kind`. Only `ModuleRecoveryCompleted` payloads carry a `kind`, so other
166/// events deserialize with `kind == None` and are filtered out.
167fn mint_recovery_completed(event_log: &serde_json::Value) -> Option<ModuleRecoveryCompleted> {
168    event_log.as_array()?.iter().find_map(|entry| {
169        let payload = entry.get("payload")?;
170
171        // `show-event-log` renders the payload as its JSON object, or (on older
172        // clients) as hex-encoded JSON bytes.
173        let event: ModuleRecoveryCompleted = match payload.as_str() {
174            Some(hex) => serde_json::from_slice(&hex::decode(hex).ok()?).ok()?,
175            None => serde_json::from_value(payload.clone()).ok()?,
176        };
177
178        (event.kind.as_ref() == Some(&fedimint_mint_common::KIND)).then_some(event)
179    })
180}
181
182/// Assert the mint module's recovery-completed event reports the amount that
183/// was recovered.
184///
185/// Version-gated: clients older than 0.12 emit this event without the `amount`
186/// field (and lack the `dev show-event-log` command), so the check is skipped
187/// for them.
188async fn assert_recovery_event_amount(restored: &Client, recovered_balance: u64) -> Result<()> {
189    if FedimintCli::version_or_default().await < *VERSION_0_12_0_ALPHA {
190        return Ok(());
191    }
192
193    // The event is ordered into the log asynchronously after recovery, so poll
194    // for it rather than reading once.
195    for attempt in 0..20 {
196        let event_log = cmd!(restored, "dev", "show-event-log", "--limit", "1000")
197            .out_json()
198            .await?;
199
200        if let Some(event) = mint_recovery_completed(&event_log) {
201            let amount = event
202                .amount
203                .expect("recovery-completed event must carry the recovered amount");
204
205            almost_equal(recovered_balance, amount.msats, 25_000)
206                .map_err(|err| anyhow::anyhow!("recovery event amount mismatch: {err}"))?;
207
208            return Ok(());
209        }
210
211        if attempt + 1 < 20 {
212            fedimint_core::task::sleep_in_test(
213                "waiting for the recovery-completed event to be ordered into the log",
214                std::time::Duration::from_millis(250),
215            )
216            .await;
217        }
218    }
219
220    anyhow::bail!("mint module did not emit a recovery-completed event");
221}
222
223async fn test_recovery_with_backup(fed: &Federation) -> Result<()> {
224    info!(target: LOG_DEVIMINT, "### Test mint recovery with backup");
225    let client = fed.new_joined_client("mint-recovery-backup").await?;
226    fed.pegin_client(PEGIN_SATS, &client).await?;
227
228    let pre_balance = client.balance().await?;
229    info!(target: LOG_DEVIMINT, pre_balance, "Balance before backup");
230    assert!(pre_balance > 0);
231
232    cmd!(client, "backup").run().await?;
233
234    let restored = client
235        .new_restored("mint-restored-with-backup", fed.invite_code()?)
236        .await?;
237    cmd!(restored, "dev", "wait-complete").out_json().await?;
238
239    let post_balance = restored.balance().await?;
240    info!(target: LOG_DEVIMINT, post_balance, "Balance after recovery with backup");
241    almost_equal(pre_balance, post_balance, 25_000).unwrap();
242    assert_recovery_event_amount(&restored, post_balance).await?;
243    Ok(())
244}
245
246async fn test_recovery_without_backup(fed: &Federation) -> Result<()> {
247    info!(target: LOG_DEVIMINT, "### Test mint recovery without backup");
248    let client = fed.new_joined_client("mint-recovery-no-backup").await?;
249    fed.pegin_client(PEGIN_SATS, &client).await?;
250
251    let pre_balance = client.balance().await?;
252    assert!(pre_balance > 0);
253
254    let restored = client
255        .new_restored("mint-restored-no-backup", fed.invite_code()?)
256        .await?;
257    cmd!(restored, "dev", "wait-complete").out_json().await?;
258
259    let post_balance = restored.balance().await?;
260    info!(target: LOG_DEVIMINT, post_balance, "Balance after recovery without backup");
261    almost_equal(pre_balance, post_balance, 25_000).unwrap();
262    assert_recovery_event_amount(&restored, post_balance).await?;
263    Ok(())
264}
265
266async fn test_recovery_after_activity(fed: &Federation) -> Result<()> {
267    info!(target: LOG_DEVIMINT, "### Test mint recovery after spend+reissue activity");
268    let client = fed
269        .new_joined_client("mint-recovery-after-activity")
270        .await?;
271    fed.pegin_client(PEGIN_SATS, &client).await?;
272
273    for i in 0..3 {
274        let balance = client.balance().await?;
275        let spend_amount = balance / 3;
276
277        let notes = cmd!(client, "spend", spend_amount)
278            .out_json()
279            .await?
280            .get("notes")
281            .expect("Output didn't contain e-cash notes")
282            .as_str()
283            .unwrap()
284            .to_owned();
285
286        cmd!(client, "reissue", notes).out_json().await?;
287        info!(target: LOG_DEVIMINT, i, spend_amount, "Spent and reissued to self");
288    }
289
290    let pre_balance = client.balance().await?;
291    info!(target: LOG_DEVIMINT, pre_balance, "Balance after activity");
292    assert!(pre_balance > 0);
293
294    cmd!(client, "backup").run().await?;
295
296    let restored = client
297        .new_restored("mint-restored-after-activity", fed.invite_code()?)
298        .await?;
299    cmd!(restored, "dev", "wait-complete").out_json().await?;
300
301    let post_balance = restored.balance().await?;
302    info!(target: LOG_DEVIMINT, post_balance, "Balance after recovery post-activity");
303    almost_equal(pre_balance, post_balance, 25_000).unwrap();
304    assert_recovery_event_amount(&restored, post_balance).await?;
305    Ok(())
306}
307
308async fn test_recovery_with_post_backup_activity(fed: &Federation) -> Result<()> {
309    info!(target: LOG_DEVIMINT, "### Test mint recovery with post-backup activity");
310    let client = fed.new_joined_client("mint-recovery-post-backup").await?;
311    fed.pegin_client(PEGIN_SATS, &client).await?;
312
313    cmd!(client, "backup").run().await?;
314
315    let balance = client.balance().await?;
316    let spend_amount = balance / 2;
317    let notes = cmd!(client, "spend", spend_amount)
318        .out_json()
319        .await?
320        .get("notes")
321        .expect("Output didn't contain e-cash notes")
322        .as_str()
323        .unwrap()
324        .to_owned();
325    cmd!(client, "reissue", notes).out_json().await?;
326
327    let pre_balance = client.balance().await?;
328    info!(target: LOG_DEVIMINT, pre_balance, "Balance after post-backup activity");
329
330    let restored = client
331        .new_restored("mint-restored-post-backup", fed.invite_code()?)
332        .await?;
333    cmd!(restored, "dev", "wait-complete").out_json().await?;
334
335    let post_balance = restored.balance().await?;
336    info!(target: LOG_DEVIMINT, post_balance, "Balance after recovery with post-backup activity");
337    almost_equal(pre_balance, post_balance, 25_000).unwrap();
338    assert_recovery_event_amount(&restored, post_balance).await?;
339    Ok(())
340}
341
342async fn sanity() -> anyhow::Result<()> {
343    devimint::run_devfed_test()
344        .call(|_fed, _process_mgr| async move { Ok(()) })
345        .await
346}