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::almost_equal;
11use fedimint_core::encoding::Decodable;
12use fedimint_core::module::serde_json;
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()
38        .call(|dev_fed, _process_mgr| async move {
39            let (fed, _bitcoind) = try_join!(dev_fed.fed(), dev_fed.bitcoind())?;
40
41            let peg_in_amount_sats = 100_000;
42
43            // Start this client early, as we need to test waiting for session to close
44            let client_slow = fed
45                .new_joined_client("wallet-client-recovery-origin")
46                .await?;
47            info!("Join and claim");
48            fed.pegin_client(peg_in_amount_sats, &client_slow).await?;
49
50            let client_slow_pegin_session_count = client_slow.get_session_count().await?;
51
52            info!("### Test wallet restore without a backup");
53            {
54                let client = fed
55                    .new_joined_client("wallet-client-recovery-origin")
56                    .await?;
57
58                info!("Join, but not claim");
59                let operation_id = fed
60                    .pegin_client_no_wait(peg_in_amount_sats, &client)
61                    .await?;
62
63                info!("Restore without backup");
64                let restored = client
65                    .new_restored("restored-without-backup", fed.invite_code()?)
66                    .await?;
67
68                cmd!(
69                    restored,
70                    "module",
71                    "wallet",
72                    "await-deposit",
73                    "--operation-id",
74                    operation_id
75                )
76                .run()
77                .await?;
78
79                info!("Check if claimed");
80                almost_equal(peg_in_amount_sats * 1000, restored.balance().await?, 10_000).unwrap();
81            }
82
83            info!("### Test wallet restore with a backup");
84            {
85                let client = fed
86                    .new_joined_client("wallet-client-recovery-origin")
87                    .await?;
88                assert_eq!(0, client.balance().await?);
89
90                info!("Join and claim");
91                fed.pegin_client(peg_in_amount_sats, &client).await?;
92
93                info!("Make a backup");
94                cmd!(client, "backup").run().await?;
95
96                info!("Join more, but not claim");
97                let operation_id = fed
98                    .pegin_client_no_wait(peg_in_amount_sats, &client)
99                    .await?;
100
101                info!("Restore with backup");
102                let restored = client
103                    .new_restored("restored-with-backup", fed.invite_code()?)
104                    .await?;
105
106                cmd!(
107                    restored,
108                    "module",
109                    "wallet",
110                    "await-deposit",
111                    "--operation-id",
112                    operation_id
113                )
114                .run()
115                .await?;
116
117                info!("Check if claimed");
118                almost_equal(
119                    peg_in_amount_sats * 1000 * 2,
120                    restored.balance().await?,
121                    20_000,
122                )
123                .unwrap();
124            }
125
126            info!("### Test wallet restore with a history and no backup");
127            {
128                let client = client_slow;
129
130                retry(
131                    "wait for next session",
132                    backoff_util::aggressive_backoff(),
133                    || async {
134                        if client_slow_pegin_session_count < client.get_session_count().await? {
135                            return Ok(());
136                        }
137                        bail!("Session didn't close")
138                    },
139                )
140                .await
141                .expect("timeouted waiting for session to close");
142
143                let operation_id = fed
144                    .pegin_client_no_wait(peg_in_amount_sats, &client)
145                    .await?;
146
147                info!("Client slow: Restore without backup");
148                let restored = client
149                    .new_restored("client-slow-restored-without-backup", fed.invite_code()?)
150                    .await?;
151
152                cmd!(
153                    restored,
154                    "module",
155                    "wallet",
156                    "await-deposit",
157                    "--operation-id",
158                    operation_id
159                )
160                .run()
161                .await?;
162
163                info!("Client slow: Check if claimed");
164                almost_equal(
165                    peg_in_amount_sats * 1000 * 2,
166                    restored.balance().await?,
167                    20_000,
168                )
169                .unwrap();
170            }
171
172            Ok(())
173        })
174        .await
175}
176
177async fn wallet_recovery_test_2() -> anyhow::Result<()> {
178    devimint::run_devfed_test().call(|dev_fed, _process_mgr| async move {
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 client = Client::create("restore-without-backup").await?;
215            let _ = cmd!(
216                client,
217                "restore",
218                "--mnemonic",
219                &secret,
220                "--invite-code",
221                fed.invite_code()?
222            )
223            .out_json()
224            .await?;
225
226            let _ = cmd!(client, "dev", "wait-complete").out_json().await?;
227            let post_notes = cmd!(client, "info").out_json().await?;
228            let post_balance = post_notes["total_amount_msat"].as_u64().unwrap();
229            debug!(target: LOG_TEST, %post_notes, post_balance, "State after backup");
230            assert_eq!(pre_balance, post_balance);
231        }
232
233        // with a backup
234        {
235            let _ = cmd!(reference_client, "backup",).out_json().await?;
236            let client = Client::create("restore-with-backup").await?;
237
238            {
239                let _ = cmd!(
240                    client,
241                    "restore",
242                    "--mnemonic",
243                    &secret,
244                    "--invite-code",
245                    fed.invite_code()?
246                )
247                .out_json()
248                .await?;
249
250                let _ = cmd!(client, "dev", "wait-complete").out_json().await?;
251                let post_notes = cmd!(client, "info").out_json().await?;
252                let post_balance = post_notes["total_amount_msat"].as_u64().unwrap();
253                debug!(target: LOG_TEST, %post_notes, post_balance, "State after backup");
254
255                assert_eq!(pre_balance, post_balance);
256            }
257
258            // Now make a backup using the just restored client, and confirm restoring again
259            // still works (no corruption was introduced)
260            let _ = cmd!(client, "backup",).out_json().await?;
261
262            const EXTRA_PEGIN_SATS: u64 = 1000;
263            fed.pegin_client(EXTRA_PEGIN_SATS, &client).await?;
264
265            {
266                let client = Client::create("restore-with-backup-again").await?;
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 (subsequent) backup");
282
283                almost_equal(pre_balance + EXTRA_PEGIN_SATS * 1000, post_balance, 1_000).unwrap();
284            }
285        }
286
287        Ok(())
288    })
289    .await
290}
291
292async fn transfer(
293    send_client: &Client,
294    receive_client: &Client,
295    bitcoind: &devimint::external::Bitcoind,
296    amount_sat: u64,
297) -> Result<serde_json::Value> {
298    debug!(target: LOG_TEST, %amount_sat, "Transferring on-chain funds between clients");
299    let (deposit_address, operation_id) = receive_client.get_deposit_addr().await?;
300    let withdraw_res = cmd!(
301        send_client,
302        "withdraw",
303        "--address",
304        &deposit_address,
305        "--amount",
306        "{amount_sat} sat"
307    )
308    .out_json()
309    .await?;
310
311    // Verify federation broadcasts withdrawal tx
312    let txid: Txid = withdraw_res["txid"].as_str().unwrap().parse().unwrap();
313    let tx_hex = bitcoind.poll_get_transaction(txid).await?;
314
315    let parsed_address = Address::from_str(&deposit_address)?;
316    let tx = Transaction::consensus_decode_hex(&tx_hex, &Default::default())?;
317    assert!(tx.output.iter().any(|o| o.script_pubkey
318        == parsed_address.clone().assume_checked().script_pubkey()
319        && o.value.to_sat() == amount_sat));
320
321    debug!(target: LOG_TEST, %txid, "Awaiting transaction");
322    // Verify the receive client gets the deposit
323    try_join!(
324        bitcoind.mine_blocks(21),
325        receive_client.await_deposit(&operation_id),
326    )?;
327
328    Ok(withdraw_res)
329}
330
331async fn assert_withdrawal(
332    send_client: &Client,
333    receive_client: &Client,
334    bitcoind: &devimint::external::Bitcoind,
335    fed: &devimint::federation::Federation,
336) -> Result<()> {
337    let withdrawal_amount_sats = 50_000;
338    let withdrawal_amount_msats = withdrawal_amount_sats * 1000;
339
340    if send_client.balance().await? < withdrawal_amount_msats {
341        fed.pegin_client(withdrawal_amount_sats * 2, send_client)
342            .await?;
343    }
344
345    let send_client_pre_balance = send_client.balance().await?;
346    let receive_client_pre_balance = receive_client.balance().await?;
347
348    let withdraw_res = transfer(
349        send_client,
350        receive_client,
351        bitcoind,
352        withdrawal_amount_sats,
353    )
354    .await?;
355
356    // Balance checks
357    let send_client_post_balance = send_client.balance().await?;
358    let receive_client_post_balance = receive_client.balance().await?;
359    let fed_deposit_fees_msats = fed.deposit_fees()?.msats;
360    let onchain_fees_msats = withdraw_res["fees_sat"].as_u64().unwrap() * 1000;
361
362    let expected_send_client_balance = if send_client.get_name() == receive_client.get_name() {
363        send_client_pre_balance - onchain_fees_msats - fed_deposit_fees_msats
364    } else {
365        send_client_pre_balance - withdrawal_amount_msats - onchain_fees_msats
366    };
367
368    let expected_receive_client_balance = if send_client.get_name() == receive_client.get_name() {
369        receive_client_pre_balance - onchain_fees_msats - fed_deposit_fees_msats
370    } else {
371        receive_client_pre_balance + withdrawal_amount_msats - fed_deposit_fees_msats
372    };
373
374    almost_equal(
375        send_client_post_balance,
376        expected_send_client_balance,
377        5_000,
378    )
379    .unwrap();
380    almost_equal(
381        receive_client_post_balance,
382        expected_receive_client_balance,
383        10_000,
384    )
385    .unwrap();
386
387    Ok(())
388}
389
390async fn circular_deposit_test() -> anyhow::Result<()> {
391    devimint::run_devfed_test()
392        .call(|dev_fed, _process_mgr| async move {
393            let (fed, bitcoind) = try_join!(dev_fed.fed(), dev_fed.bitcoind())?;
394
395            let send_client = fed
396                .new_joined_client("circular-deposit-send-client")
397                .await?;
398
399            // Verify withdrawal to deposit address from same client
400            assert_withdrawal(&send_client, &send_client, bitcoind, fed).await?;
401
402            // Verify withdrawal to deposit address from different client in same federation
403            let receive_client = fed
404                .new_joined_client("circular-deposit-receive-client")
405                .await?;
406            assert_withdrawal(&send_client, &receive_client, bitcoind, fed).await?;
407
408            // Verify that dust deposits aren't claimed
409            let dust_receive_client = fed
410                .new_joined_client("circular-deposit-dust-receive-client")
411                .await?;
412            transfer(&send_client, &dust_receive_client, bitcoind, 900).await?;
413            assert_eq!(dust_receive_client.balance().await?, 0);
414
415            Ok(())
416        })
417        .await
418}