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