wallet_module_tests/
wallet-module-tests.rs

1use std::str::FromStr;
2
3use anyhow::{Result, bail};
4use bitcoin::Transaction;
5use bitcoincore_rpc::bitcoin::Txid;
6use bitcoincore_rpc::bitcoin::address::Address;
7use clap::Parser;
8use devimint::cmd;
9use devimint::federation::Client;
10use devimint::util::{FedimintCli, FedimintdCmd};
11use devimint::version_constants::{VERSION_0_3_0_ALPHA, VERSION_0_4_0, VERSION_0_6_0_ALPHA};
12use fedimint_core::encoding::Decodable;
13use fedimint_core::util::{backoff_util, retry};
14use fedimint_logging::LOG_TEST;
15use tokio::try_join;
16use tracing::{debug, info};
17
18#[derive(Parser, Debug)]
19pub enum TestCli {
20    Recovery1,
21    Recovery2,
22    CircularDeposit,
23}
24
25#[tokio::main]
26async fn main() -> anyhow::Result<()> {
27    let opts = TestCli::parse();
28
29    match opts {
30        TestCli::Recovery1 => wallet_recovery_test_1().await,
31        TestCli::Recovery2 => wallet_recovery_test_2().await,
32        TestCli::CircularDeposit => circular_deposit_test().await,
33    }
34}
35
36async fn wallet_recovery_test_1() -> anyhow::Result<()> {
37    devimint::run_devfed_test(|dev_fed, _process_mgr| async move {
38        let fedimint_cli_version = FedimintCli::version_or_default().await;
39        let fedimintd_version = FedimintdCmd::version_or_default().await;
40        // TODO(support:v0.4): recovery was introduced in v0.4.0
41        // see: https://github.com/fedimint/fedimint/pull/5546
42        if fedimint_cli_version < *VERSION_0_4_0 || fedimintd_version < *VERSION_0_4_0 {
43            info!("Skipping whole test on old fedimint-cli/fedimintd that is missing some irrelevant bolts");
44            return Ok(());
45        }
46
47        let (fed, _bitcoind) = try_join!(dev_fed.fed(), dev_fed.bitcoind())?;
48
49        let peg_in_amount_sats = 100_000;
50
51            // Start this client early, as we need to test waiting for session to close
52        let client_slow = fed
53            .new_joined_client("wallet-client-recovery-origin")
54            .await?;
55        info!("Join and claim");
56        fed.pegin_client(peg_in_amount_sats, &client_slow).await?;
57
58        let client_slow_pegin_session_count = client_slow.get_session_count().await?;
59
60        info!("### Test wallet restore without a backup");
61        {
62            let client = fed
63                .new_joined_client("wallet-client-recovery-origin")
64                .await?;
65
66            info!("Join, but not claim");
67            let operation_id = fed
68                .pegin_client_no_wait(peg_in_amount_sats, &client)
69                .await?;
70
71            info!("Restore without backup");
72            let restored = client
73                .new_restored("restored-without-backup", fed.invite_code()?)
74                .await?;
75
76            if fedimint_cli_version < *VERSION_0_6_0_ALPHA {
77                cmd!(restored, "module", "wallet", "await-deposit", operation_id)
78                    .run()
79                    .await?;
80            } else {
81                cmd!(restored, "module", "wallet", "await-deposit", "--operation-id", operation_id)
82                    .run()
83                    .await?;
84            }
85
86            info!("Check if claimed");
87            assert_eq!(peg_in_amount_sats * 1000, restored.balance().await?);
88        }
89
90        info!("### Test wallet restore with a backup");
91        {
92            let client = fed
93                .new_joined_client("wallet-client-recovery-origin")
94                .await?;
95            assert_eq!(0, client.balance().await?);
96
97            info!("Join and claim");
98            fed.pegin_client(peg_in_amount_sats, &client).await?;
99
100            info!("Make a backup");
101            cmd!(client, "backup").run().await?;
102
103            info!("Join more, but not claim");
104            let operation_id = fed
105                .pegin_client_no_wait(peg_in_amount_sats, &client)
106                .await?;
107
108            info!("Restore with backup");
109            let restored = client
110                .new_restored("restored-with-backup", fed.invite_code()?)
111                .await?;
112
113            if fedimint_cli_version < *VERSION_0_6_0_ALPHA {
114                cmd!(restored, "module", "wallet", "await-deposit", operation_id)
115                    .run()
116                    .await?;
117            } else {
118                cmd!(restored, "module", "wallet", "await-deposit", "--operation-id", operation_id)
119                    .run()
120                    .await?;
121            }
122
123            info!("Check if claimed");
124            assert_eq!(peg_in_amount_sats * 1000 * 2, restored.balance().await?);
125        }
126
127        info!("### Test wallet restore with a history and no backup");
128        {
129            let client = client_slow;
130
131            retry("wait for next session", backoff_util::aggressive_backoff(), || async {
132                if client_slow_pegin_session_count < client.get_session_count().await? {
133                    return Ok(());
134                }
135                bail!("Session didn't close")
136            })
137            .await
138            .expect("timeouted waiting for session to close");
139
140            let operation_id = fed
141                .pegin_client_no_wait(peg_in_amount_sats, &client)
142                .await?;
143
144            info!("Client slow: Restore without backup");
145            let restored = client
146                .new_restored("client-slow-restored-without-backup", fed.invite_code()?)
147                .await?;
148
149            if fedimint_cli_version < *VERSION_0_6_0_ALPHA {
150                cmd!(restored, "module", "wallet", "await-deposit", operation_id)
151                    .run()
152                    .await?;
153            } else {
154                cmd!(restored, "module", "wallet", "await-deposit", "--operation-id", operation_id)
155                    .run()
156                    .await?;
157            }
158
159            info!("Client slow: Check if claimed");
160            assert_eq!(peg_in_amount_sats * 1000 * 2, restored.balance().await?);
161        }
162
163        Ok(())
164    })
165    .await
166}
167
168async fn wallet_recovery_test_2() -> anyhow::Result<()> {
169    devimint::run_devfed_test(|dev_fed, _process_mgr| async move {
170        let fedimint_cli_version = FedimintCli::version_or_default().await;
171        let fedimintd_version = FedimintdCmd::version_or_default().await;
172        // TODO(support:v0.4): recovery was introduced in v0.4.0
173        // see: https://github.com/fedimint/fedimint/pull/5546
174        if fedimint_cli_version < *VERSION_0_4_0 || fedimintd_version < *VERSION_0_4_0 {
175            info!(target: LOG_TEST, "Skipping whole test on old fedimint-cli/fedimintd that is missing some irrelevant bolts");
176            return Ok(());
177        }
178
179        let (fed, _bitcoind) = try_join!(dev_fed.fed(), dev_fed.bitcoind())?;
180
181        let peg_in_amount_sats = 100_000;
182
183            // Start this client early, as we need to test waiting for session to close
184        let reference_client = fed
185            .new_joined_client("wallet-client-recovery-origin")
186            .await?;
187        info!(target: LOG_TEST, "Join and claim");
188        fed.pegin_client(peg_in_amount_sats, &reference_client).await?;
189
190
191        let secret = cmd!(reference_client, "print-secret").out_json().await?["secret"]
192            .as_str()
193            .map(ToOwned::to_owned)
194            .unwrap();
195
196        let pre_notes = cmd!(reference_client, "info").out_json().await?;
197
198        let pre_balance = pre_notes["total_amount_msat"].as_u64().unwrap();
199
200        debug!(target: LOG_TEST, %pre_notes, pre_balance, "State before backup");
201
202        // we need to have some funds
203        // TODO: right now we rely on previous tests to leave some balance
204        assert!(0 < pre_balance);
205
206        // without existing backup
207        // TODO: Change this test and make them exercise more scenarios.
208        // Currently (and probably indefinitely) we can support only one
209        // restoration per client state (datadir), as it only makes sense to do
210        // once (at the very beginning) and we used a fixed operation id for it.
211        // Testing restore in different setups would require multiple clients,
212        // which is a larger refactor.
213        {
214            let post_balance = if fedimint_cli_version >= *VERSION_0_3_0_ALPHA {
215                let client = Client::create("restore-without-backup").await?;
216                let _ = cmd!(
217                    client,
218                    "restore",
219                    "--mnemonic",
220                    &secret,
221                    "--invite-code",
222                    fed.invite_code()?
223                )
224                .out_json()
225                .await?;
226
227                // `wait-complete` was introduced in v0.3.0 (90f3082)
228                let _ = cmd!(client, "dev", "wait-complete").out_json().await?;
229                let post_notes = cmd!(client, "info").out_json().await?;
230                let post_balance = post_notes["total_amount_msat"].as_u64().unwrap();
231                debug!(target: LOG_TEST, %post_notes, post_balance, "State after backup");
232
233                post_balance
234            } else {
235                let client = reference_client
236                    .new_forked("restore-without-backup")
237                    .await?;
238                let _ = cmd!(client, "wipe", "--force",).out_json().await?;
239
240                assert_eq!(
241                    0,
242                    cmd!(client, "info").out_json().await?["total_amount_msat"]
243                        .as_u64()
244                        .unwrap()
245                );
246
247                let post_balance = cmd!(client, "restore", &secret,)
248                    .out_json()
249                    .await?
250                    .as_u64()
251                    .unwrap();
252                let post_notes = cmd!(client, "info").out_json().await?;
253                debug!(target: LOG_TEST, %post_notes, post_balance, "State after backup");
254
255                post_balance
256            };
257            assert_eq!(pre_balance, post_balance);
258        }
259
260        // with a backup
261        {
262            if fedimint_cli_version >= *VERSION_0_3_0_ALPHA {
263                let _ = cmd!(reference_client, "backup",).out_json().await?;
264                let client = Client::create("restore-with-backup").await?;
265
266                {
267                    let _ = cmd!(
268                        client,
269                        "restore",
270                        "--mnemonic",
271                        &secret,
272                        "--invite-code",
273                        fed.invite_code()?
274                    )
275                    .out_json()
276                    .await?;
277
278                    let _ = cmd!(client, "dev", "wait-complete").out_json().await?;
279                    let post_notes = cmd!(client, "info").out_json().await?;
280                    let post_balance = post_notes["total_amount_msat"].as_u64().unwrap();
281                    debug!(target: LOG_TEST, %post_notes, post_balance, "State after backup");
282
283                    assert_eq!(pre_balance, post_balance);
284                }
285
286                // Now make a backup using the just restored client, and confirm restoring again
287                // still works (no corruption was introduced)
288                let _ = cmd!(client, "backup",).out_json().await?;
289
290                const EXTRA_PEGIN_SATS: u64 = 1000;
291                fed.pegin_client(EXTRA_PEGIN_SATS, &client).await?;
292
293                {
294                    let client = Client::create("restore-with-backup-again").await?;
295                    let _ = cmd!(
296                        client,
297                        "restore",
298                        "--mnemonic",
299                        &secret,
300                        "--invite-code",
301                        fed.invite_code()?
302                    )
303                    .out_json()
304                    .await?;
305
306                    let _ = cmd!(client, "dev", "wait-complete").out_json().await?;
307                    let post_notes = cmd!(client, "info").out_json().await?;
308                    let post_balance = post_notes["total_amount_msat"].as_u64().unwrap();
309                    debug!(target: LOG_TEST, %post_notes, post_balance, "State after (subsequent) backup");
310
311                    assert_eq!(pre_balance + EXTRA_PEGIN_SATS * 1000, post_balance);
312                }
313            } else {
314                let client = reference_client.new_forked("restore-with-backup").await?;
315                let _ = cmd!(client, "backup",).out_json().await?;
316                let _ = cmd!(client, "wipe", "--force",).out_json().await?;
317                assert_eq!(
318                    0,
319                    cmd!(client, "info").out_json().await?["total_amount_msat"]
320                        .as_u64()
321                        .unwrap()
322                );
323                let _ = cmd!(client, "restore", &secret,).out_json().await?;
324                let post_notes = cmd!(client, "info").out_json().await?;
325                let post_balance = post_notes["total_amount_msat"].as_u64().unwrap();
326                debug!(target: LOG_TEST, %post_notes, post_balance, "State after backup");
327
328                assert_eq!(pre_balance, post_balance);
329            }
330        }
331
332        Ok(())
333    })
334    .await
335}
336async fn assert_withdrawal(
337    send_client: &Client,
338    receive_client: &Client,
339    bitcoind: &devimint::external::Bitcoind,
340    fed: &devimint::federation::Federation,
341) -> Result<()> {
342    let withdrawal_amount_sats = 50_000;
343    let withdrawal_amount_msats = withdrawal_amount_sats * 1000;
344
345    if send_client.balance().await? < withdrawal_amount_msats {
346        fed.pegin_client(withdrawal_amount_sats * 2, send_client)
347            .await?;
348    }
349
350    let send_client_pre_balance = send_client.balance().await?;
351    let receive_client_pre_balance = receive_client.balance().await?;
352
353    let (deposit_address, operation_id) = receive_client.get_deposit_addr().await?;
354    let withdraw_res = cmd!(
355        send_client,
356        "withdraw",
357        "--address",
358        &deposit_address,
359        "--amount",
360        "{withdrawal_amount_sats} sat"
361    )
362    .out_json()
363    .await?;
364
365    // Verify federation broadcasts withdrawal tx
366    let txid: Txid = withdraw_res["txid"].as_str().unwrap().parse().unwrap();
367    let tx_hex = bitcoind.poll_get_transaction(txid).await?;
368
369    let parsed_address = Address::from_str(&deposit_address)?;
370    let tx = Transaction::consensus_decode_hex(&tx_hex, &Default::default())?;
371    assert!(tx.output.iter().any(|o| o.script_pubkey
372        == parsed_address.clone().assume_checked().script_pubkey()
373        && o.value.to_sat() == withdrawal_amount_sats));
374
375    // Verify the receive client gets the deposit
376    try_join!(
377        bitcoind.mine_blocks(21),
378        receive_client.await_deposit(&operation_id),
379    )?;
380
381    // Balance checks
382    let send_client_post_balance = send_client.balance().await?;
383    let receive_client_post_balance = receive_client.balance().await?;
384    let fed_deposit_fees_msats = fed.deposit_fees()?.msats;
385    let onchain_fees_msats = withdraw_res["fees_sat"].as_u64().unwrap() * 1000;
386
387    let expected_send_client_balance = if send_client.get_name() == receive_client.get_name() {
388        send_client_pre_balance - onchain_fees_msats - fed_deposit_fees_msats
389    } else {
390        send_client_pre_balance - withdrawal_amount_msats - onchain_fees_msats
391    };
392
393    let expected_receive_client_balance = if send_client.get_name() == receive_client.get_name() {
394        receive_client_pre_balance - onchain_fees_msats - fed_deposit_fees_msats
395    } else {
396        receive_client_pre_balance + withdrawal_amount_msats - fed_deposit_fees_msats
397    };
398
399    assert_eq!(send_client_post_balance, expected_send_client_balance);
400    assert_eq!(receive_client_post_balance, expected_receive_client_balance);
401
402    Ok(())
403}
404
405async fn circular_deposit_test() -> anyhow::Result<()> {
406    devimint::run_devfed_test(|dev_fed, _process_mgr| async move {
407        let (fed, bitcoind) = try_join!(dev_fed.fed(), dev_fed.bitcoind())?;
408
409        let send_client = fed
410            .new_joined_client("circular-deposit-send-client")
411            .await?;
412
413        // Verify withdrawal to deposit address from same client
414        assert_withdrawal(&send_client, &send_client, bitcoind, fed).await?;
415
416        // Verify withdrawal to deposit address from different client in same federation
417        let receive_client = fed
418            .new_joined_client("circular-deposit-receive-client")
419            .await?;
420        assert_withdrawal(&send_client, &receive_client, bitcoind, fed).await?;
421
422        Ok(())
423    })
424    .await
425}