Skip to main content

mint_module_tests/
mint-module-tests.rs

1use anyhow::Result;
2use clap::Parser;
3use devimint::cmd;
4use devimint::federation::Federation;
5use devimint::util::almost_equal;
6use fedimint_logging::LOG_DEVIMINT;
7use rand::Rng;
8use tokio::try_join;
9use tracing::info;
10
11#[derive(Debug, Parser)]
12enum Cmd {
13    Restore,
14    /// Mint recovery test. Run with `FM_FORCE_V1_MINT_RECOVERY=1` to force
15    /// V1 (session-based) recovery path.
16    RecoveryV1,
17    /// Mint recovery test using default (V2 slice-based) recovery path.
18    RecoveryV2,
19    Sanity,
20}
21
22#[tokio::main]
23async fn main() -> anyhow::Result<()> {
24    match Cmd::parse() {
25        Cmd::Restore => restore().await,
26        Cmd::RecoveryV1 => mint_recovery_test().await,
27        Cmd::RecoveryV2 => mint_recovery_test().await,
28        Cmd::Sanity => sanity().await,
29    }
30}
31
32async fn restore() -> anyhow::Result<()> {
33    devimint::run_devfed_test()
34        .call(|fed, _process_mgr| async move {
35            let fed = fed.fed().await?;
36
37            test_restore_gap_test(fed).await?;
38            Ok(())
39        })
40        .await
41}
42
43pub async fn test_restore_gap_test(fed: &Federation) -> Result<()> {
44    let client = fed.new_joined_client("restore-gap-test").await?;
45    const PEGIN_SATS: u64 = 300000;
46    fed.pegin_client(PEGIN_SATS, &client).await?;
47
48    for i in 0..20 {
49        let gap = rand::thread_rng().gen_range(0..20);
50        info!(target: LOG_DEVIMINT, gap, "Gap");
51        cmd!(
52            client,
53            "dev",
54            "advance-note-idx",
55            "--amount",
56            "1024msat",
57            "--count",
58            // we are not guaranteed to use a 1024 note on every payment,
59            // so create some random small gaps, so it's very unlikely we
60            // would cross the default gap limit accidentally
61            &gap.to_string()
62        )
63        .run()
64        .await?;
65
66        // We need to get the balance of the client to know how much to reissue, due to
67        // the mint base fees it decreases slightly every time we reissue.
68        let notes = cmd!(client, "info").out_json().await?;
69        let balance = notes["total_amount_msat"].as_u64().unwrap();
70
71        let reissure_amount = if i % 2 == 0 {
72            // half of the time, reissue everything
73            balance
74        } else {
75            // other half, random amount
76            rand::thread_rng().gen_range(10..(balance))
77        };
78        info!(target: LOG_DEVIMINT, i, reissure_amount, "Reissue");
79
80        let notes = cmd!(client, "spend", reissure_amount)
81            .out_json()
82            .await?
83            .get("notes")
84            .expect("Output didn't contain e-cash notes")
85            .as_str()
86            .unwrap()
87            .to_owned();
88
89        // Test we can reissue our own notes
90        cmd!(client, "reissue", notes).out_json().await?;
91    }
92
93    let secret = cmd!(client, "print-secret").out_json().await?["secret"]
94        .as_str()
95        .map(ToOwned::to_owned)
96        .unwrap();
97
98    let pre_notes = cmd!(client, "info").out_json().await?;
99
100    let pre_balance = pre_notes["total_amount_msat"].as_u64().unwrap();
101
102    info!(target: LOG_DEVIMINT, %pre_notes, pre_balance, "State before backup");
103
104    // we need to have some funds
105    assert!(0 < pre_balance);
106
107    // without existing backup
108    {
109        let client =
110            devimint::federation::Client::create("restore-gap-test-without-backup").await?;
111        let _ = cmd!(
112            client,
113            "restore",
114            "--mnemonic",
115            &secret,
116            "--invite-code",
117            fed.invite_code()?
118        )
119        .out_json()
120        .await?;
121
122        let _ = cmd!(client, "dev", "wait-complete").out_json().await?;
123        let post_notes = cmd!(client, "info").out_json().await?;
124        let post_balance = post_notes["total_amount_msat"].as_u64().unwrap();
125        info!(target: LOG_DEVIMINT, %post_notes, post_balance, "State after backup");
126        assert_eq!(pre_balance, post_balance);
127        assert_eq!(pre_notes, post_notes);
128    }
129
130    Ok(())
131}
132
133/// Test that mint recovery works correctly in various scenarios.
134///
135/// The V1 variant should be run with `FM_FORCE_V1_MINT_RECOVERY=1` to
136/// force the legacy session-based recovery path which reissues recovered
137/// ecash.
138///
139/// Regression test for <https://github.com/fedimint/fedimint/issues/8004>
140async fn mint_recovery_test() -> anyhow::Result<()> {
141    devimint::run_devfed_test()
142        .call(|dev_fed, _process_mgr| async move {
143            let fed = dev_fed.fed().await?;
144
145            try_join!(
146                test_recovery_with_backup(fed),
147                test_recovery_without_backup(fed),
148                test_recovery_after_activity(fed),
149                test_recovery_with_post_backup_activity(fed),
150            )?;
151
152            Ok(())
153        })
154        .await
155}
156
157const PEGIN_SATS: u64 = 1_000_000;
158
159async fn test_recovery_with_backup(fed: &Federation) -> Result<()> {
160    info!(target: LOG_DEVIMINT, "### Test mint recovery with backup");
161    let client = fed.new_joined_client("mint-recovery-backup").await?;
162    fed.pegin_client(PEGIN_SATS, &client).await?;
163
164    let pre_balance = client.balance().await?;
165    info!(target: LOG_DEVIMINT, pre_balance, "Balance before backup");
166    assert!(pre_balance > 0);
167
168    cmd!(client, "backup").run().await?;
169
170    let restored = client
171        .new_restored("mint-restored-with-backup", fed.invite_code()?)
172        .await?;
173    cmd!(restored, "dev", "wait-complete").out_json().await?;
174
175    let post_balance = restored.balance().await?;
176    info!(target: LOG_DEVIMINT, post_balance, "Balance after recovery with backup");
177    almost_equal(pre_balance, post_balance, 25_000).unwrap();
178    Ok(())
179}
180
181async fn test_recovery_without_backup(fed: &Federation) -> Result<()> {
182    info!(target: LOG_DEVIMINT, "### Test mint recovery without backup");
183    let client = fed.new_joined_client("mint-recovery-no-backup").await?;
184    fed.pegin_client(PEGIN_SATS, &client).await?;
185
186    let pre_balance = client.balance().await?;
187    assert!(pre_balance > 0);
188
189    let restored = client
190        .new_restored("mint-restored-no-backup", fed.invite_code()?)
191        .await?;
192    cmd!(restored, "dev", "wait-complete").out_json().await?;
193
194    let post_balance = restored.balance().await?;
195    info!(target: LOG_DEVIMINT, post_balance, "Balance after recovery without backup");
196    almost_equal(pre_balance, post_balance, 25_000).unwrap();
197    Ok(())
198}
199
200async fn test_recovery_after_activity(fed: &Federation) -> Result<()> {
201    info!(target: LOG_DEVIMINT, "### Test mint recovery after spend+reissue activity");
202    let client = fed
203        .new_joined_client("mint-recovery-after-activity")
204        .await?;
205    fed.pegin_client(PEGIN_SATS, &client).await?;
206
207    for i in 0..3 {
208        let balance = client.balance().await?;
209        let spend_amount = balance / 3;
210
211        let notes = cmd!(client, "spend", spend_amount)
212            .out_json()
213            .await?
214            .get("notes")
215            .expect("Output didn't contain e-cash notes")
216            .as_str()
217            .unwrap()
218            .to_owned();
219
220        cmd!(client, "reissue", notes).out_json().await?;
221        info!(target: LOG_DEVIMINT, i, spend_amount, "Spent and reissued to self");
222    }
223
224    let pre_balance = client.balance().await?;
225    info!(target: LOG_DEVIMINT, pre_balance, "Balance after activity");
226    assert!(pre_balance > 0);
227
228    cmd!(client, "backup").run().await?;
229
230    let restored = client
231        .new_restored("mint-restored-after-activity", fed.invite_code()?)
232        .await?;
233    cmd!(restored, "dev", "wait-complete").out_json().await?;
234
235    let post_balance = restored.balance().await?;
236    info!(target: LOG_DEVIMINT, post_balance, "Balance after recovery post-activity");
237    almost_equal(pre_balance, post_balance, 25_000).unwrap();
238    Ok(())
239}
240
241async fn test_recovery_with_post_backup_activity(fed: &Federation) -> Result<()> {
242    info!(target: LOG_DEVIMINT, "### Test mint recovery with post-backup activity");
243    let client = fed.new_joined_client("mint-recovery-post-backup").await?;
244    fed.pegin_client(PEGIN_SATS, &client).await?;
245
246    cmd!(client, "backup").run().await?;
247
248    let balance = client.balance().await?;
249    let spend_amount = balance / 2;
250    let notes = cmd!(client, "spend", spend_amount)
251        .out_json()
252        .await?
253        .get("notes")
254        .expect("Output didn't contain e-cash notes")
255        .as_str()
256        .unwrap()
257        .to_owned();
258    cmd!(client, "reissue", notes).out_json().await?;
259
260    let pre_balance = client.balance().await?;
261    info!(target: LOG_DEVIMINT, pre_balance, "Balance after post-backup activity");
262
263    let restored = client
264        .new_restored("mint-restored-post-backup", fed.invite_code()?)
265        .await?;
266    cmd!(restored, "dev", "wait-complete").out_json().await?;
267
268    let post_balance = restored.balance().await?;
269    info!(target: LOG_DEVIMINT, post_balance, "Balance after recovery with post-backup activity");
270    almost_equal(pre_balance, post_balance, 25_000).unwrap();
271    Ok(())
272}
273
274async fn sanity() -> anyhow::Result<()> {
275    devimint::run_devfed_test()
276        .call(|_fed, _process_mgr| async move { Ok(()) })
277        .await
278}