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