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