Skip to main content

fedimint_walletv2_devimint_tests/
tests.rs

1use std::time::Duration;
2
3use anyhow::{Context, ensure};
4use bitcoin::address::NetworkUnchecked;
5use bitcoin::{Address, Txid};
6use devimint::external::Bitcoind;
7use devimint::federation::Client;
8use devimint::version_constants::{
9    VERSION_0_11_0_ALPHA, VERSION_0_12_0_ALPHA, VERSION_0_13_0_ALPHA,
10};
11use devimint::{cmd, util};
12use fedimint_core::runtime::sleep;
13use fedimint_core::task::sleep_in_test;
14use fedimint_eventlog::EventLogId;
15use serde::Deserialize;
16use tokio::task::JoinHandle;
17use tokio::try_join;
18use tracing::info;
19
20/// Spawns a background task that mines a block every 100ms, simulating
21/// continuous block production. This prevents deadlocks where the federation's
22/// pending bitcoin transactions block further progress because no blocks are
23/// being mined to confirm them.
24fn spawn_block_miner(bitcoind: Bitcoind) -> JoinHandle<()> {
25    fedimint_core::runtime::spawn("background-block-miner", async move {
26        loop {
27            if let Err(e) = bitcoind.mine_blocks(1).await {
28                tracing::warn!("Background block miner failed to mine block: {e}");
29            }
30
31            sleep(Duration::from_millis(100)).await;
32        }
33    })
34}
35
36async fn module_is_present(client: &Client, kind: &str) -> anyhow::Result<bool> {
37    let modules = cmd!(client, "module").out_json().await?;
38
39    let modules = modules["list"].as_array().expect("module list is an array");
40
41    Ok(modules.iter().any(|m| m["kind"].as_str() == Some(kind)))
42}
43
44#[derive(Debug, Deserialize, PartialEq, Eq)]
45enum FinalSendState {
46    Success(Txid),
47    Aborted,
48    Failure,
49}
50
51async fn await_consensus_block_count(client: &Client, block_count: u64) -> anyhow::Result<()> {
52    loop {
53        let value = cmd!(client, "module", "walletv2", "info", "block-count")
54            .out_json()
55            .await?;
56
57        if block_count <= serde_json::from_value(value)? {
58            return Ok(());
59        }
60
61        sleep_in_test(
62            format!("Waiting for consensus to reach block count {block_count}"),
63            Duration::from_secs(1),
64        )
65        .await;
66    }
67}
68
69async fn ensure_federation_total_value(client: &Client, min_value: u64) -> anyhow::Result<()> {
70    let value = cmd!(client, "module", "walletv2", "info", "total-value")
71        .out_json()
72        .await?;
73
74    ensure!(
75        min_value <= serde_json::from_value(value)?,
76        "Total federation total value is below {min_value}"
77    );
78
79    Ok(())
80}
81
82/// Waits for `receives` deposits to be claimed (starting from event log
83/// `position`) and then asserts the client balance reached at least
84/// `min_balance` sats.
85///
86/// On `fedimint-cli` versions without `await-receive` (<= 0.11), falls back to
87/// polling the balance like the test used to.
88async fn await_deposits(
89    client: &Client,
90    position: EventLogId,
91    receives: usize,
92    min_balance: u64,
93) -> anyhow::Result<()> {
94    if util::FedimintCli::version_or_default().await >= *VERSION_0_12_0_ALPHA {
95        let mut position = position;
96
97        for _ in 0..receives {
98            position = await_receive(client, position).await?;
99        }
100
101        ensure_client_balance(client, min_balance).await?;
102    } else {
103        await_client_balance(client, min_balance).await?;
104    }
105
106    Ok(())
107}
108
109/// Waits for the next receive recorded at or after `position` to be claimed,
110/// returning the event log position to use for the following wait.
111async fn await_receive(client: &Client, position: EventLogId) -> anyhow::Result<EventLogId> {
112    let output = cmd!(
113        client,
114        "module",
115        "walletv2",
116        "await-receive",
117        position.to_string()
118    )
119    .out_json()
120    .await?;
121
122    // Walletv2 `await-receive` returns `[final_state, next_position]`.
123    serde_json::from_value(output[1].clone())
124        .context("await-receive should return the next event log position")
125}
126
127/// Asserts the client balance has reached at least `min_balance` sats.
128async fn ensure_client_balance(client: &Client, min_balance: u64) -> anyhow::Result<()> {
129    let balance = client.balance().await?;
130
131    // Client balance is in msats, min_balance is in sats.
132    ensure!(
133        balance >= min_balance * 1000,
134        "Client balance {balance} is below {min_balance}"
135    );
136
137    Ok(())
138}
139
140/// Legacy fallback for `fedimint-cli` <= 0.11: polls the client balance until
141/// it reaches at least `min_balance` sats.
142async fn await_client_balance(client: &Client, min_balance: u64) -> anyhow::Result<()> {
143    loop {
144        cmd!(client, "dev", "wait", "3").out_json().await?;
145
146        let balance = client.balance().await?;
147
148        // Client balance is in msats, min_balance is in sats.
149        if balance >= min_balance * 1000 {
150            return Ok(());
151        }
152
153        info!("Waiting for client balance {balance} to reach {min_balance}");
154    }
155}
156
157async fn await_no_pending_txs(client: &Client) -> anyhow::Result<()> {
158    loop {
159        let value = cmd!(client, "module", "walletv2", "info", "pending-tx-chain")
160            .out_json()
161            .await?;
162
163        let pending: Vec<serde_json::Value> = serde_json::from_value(value)?;
164
165        if pending.is_empty() {
166            return Ok(());
167        }
168
169        sleep_in_test(
170            format!(
171                "Waiting for {} pending transactions to clear",
172                pending.len()
173            ),
174            Duration::from_secs(1),
175        )
176        .await;
177    }
178}
179
180async fn ensure_tx_chain_length(client: &Client, expected: usize) -> anyhow::Result<()> {
181    let value = cmd!(client, "module", "walletv2", "info", "tx-chain")
182        .out_json()
183        .await?;
184
185    let chain: Vec<serde_json::Value> = serde_json::from_value(value)?;
186
187    ensure!(chain.len() == expected,);
188
189    Ok(())
190}
191
192async fn get_deposit_address(client: &Client) -> anyhow::Result<(Address, EventLogId)> {
193    if util::FedimintCli::version_or_default().await >= *VERSION_0_12_0_ALPHA {
194        // Capture the event log position *before* deriving the address so
195        // `await_receive` only considers payments received afterwards.
196        let position =
197            serde_json::from_value(cmd!(client, "dev", "next-event-log-id").out_json().await?)
198                .context("dev next-event-log-id should return an event log position")?;
199
200        let address = serde_json::from_value::<Address<NetworkUnchecked>>(
201            cmd!(client, "module", "walletv2", "receive")
202                .out_json()
203                .await?,
204        )?
205        .assume_checked();
206
207        Ok((address, position))
208    } else {
209        // Legacy (<= 0.11): `receive` returns the bare address. The position is
210        // unused on this path as we fall back to polling the balance.
211        let address = serde_json::from_value::<Address<NetworkUnchecked>>(
212            cmd!(client, "module", "walletv2", "receive")
213                .out_json()
214                .await?,
215        )?
216        .assume_checked();
217
218        Ok((address, EventLogId::LOG_START))
219    }
220}
221
222#[tokio::main]
223async fn main() -> anyhow::Result<()> {
224    // Enable walletv2 module instead of wallet v1
225    unsafe { std::env::set_var("FM_ENABLE_MODULE_WALLETV2", "true") };
226    unsafe { std::env::set_var("FM_ENABLE_MODULE_WALLET", "false") };
227
228    devimint::run_devfed_test()
229        .call(|dev_fed, _process_mgr| async move {
230            let fedimint_cli_version = util::FedimintCli::version_or_default().await;
231            let fedimintd_version = util::FedimintdCmd::version_or_default().await;
232
233            if fedimint_cli_version < *VERSION_0_11_0_ALPHA {
234                info!(%fedimint_cli_version, "Version did not support walletv2 module, skipping");
235                return Ok(());
236            }
237
238            if fedimintd_version < *VERSION_0_11_0_ALPHA {
239                info!(%fedimintd_version, "Version did not support walletv2 module, skipping");
240                return Ok(());
241            }
242
243            let (fed, bitcoind) = try_join!(dev_fed.fed(), dev_fed.bitcoind())?;
244
245            let client = fed
246                .new_joined_client("walletv2-test-send-and-receive-client")
247                .await?;
248
249            info!("Verify that walletv1 is not present...");
250
251            ensure!(
252                !module_is_present(&client, "wallet").await?,
253                "walletv1 module should not be present"
254            );
255
256            ensure!(
257                module_is_present(&client, "walletv2").await?,
258                "walletv2 module should be present"
259            );
260
261            // Spawn a background task that continuously mines blocks. This simulates
262            // real bitcoin block production and prevents deadlocks where pending
263            // federation bitcoin transactions block deposit claims via congestion
264            // control while no blocks are being mined to confirm them.
265            let block_miner = spawn_block_miner(bitcoind.clone());
266
267            // We need the consensus block count to reach a non-zero value before we send
268            // in any funds such that the UTXO is tracked by the federation.
269
270            info!("Wait for the consensus to reach block count one");
271
272            await_consensus_block_count(&client, 1).await?;
273
274            info!("Deposit funds into the federation...");
275
276            let (federation_address_1, position) = get_deposit_address(&client).await?;
277
278            fed.bitcoind
279                .send_to(federation_address_1.to_string(), 100_000)
280                .await?;
281
282            fed.bitcoind
283                .send_to(federation_address_1.to_string(), 200_000)
284                .await?;
285
286            info!("Wait for deposits to be claimed...");
287
288            // Two UTXOs were sent to the same address; wait for both receives.
289            await_deposits(&client, position, 2, 290_000).await?;
290
291            ensure_federation_total_value(&client, 290_000).await?;
292
293            let (federation_address_2, position) = get_deposit_address(&client).await?;
294
295            assert_ne!(federation_address_1, federation_address_2);
296
297            fed.bitcoind
298                .send_to(federation_address_2.to_string(), 300_000)
299                .await?;
300
301            fed.bitcoind
302                .send_to(federation_address_2.to_string(), 400_000)
303                .await?;
304
305            info!("Wait for deposits to be claimed...");
306
307            await_deposits(&client, position, 2, 980_000).await?;
308
309            ensure_federation_total_value(&client, 980_000).await?;
310
311            let (federation_address_3, _) = get_deposit_address(&client).await?;
312
313            assert_ne!(federation_address_2, federation_address_3);
314
315            info!("Send funds back onchain...");
316
317            let withdraw_address = bitcoind.get_new_address().await?;
318
319            let value = cmd!(
320                client,
321                "module",
322                "walletv2",
323                "send",
324                withdraw_address,
325                "500000 sat"
326            )
327            .out_json()
328            .await?;
329
330            let FinalSendState::Success(txid) = serde_json::from_value(value)? else {
331                panic!("Send operation failed");
332            };
333
334            bitcoind.poll_get_transaction(txid).await?;
335
336            let total_value: u64 = serde_json::from_value(
337                cmd!(client, "module", "walletv2", "info", "total-value")
338                    .out_json()
339                    .await?,
340            )?;
341
342            assert!(
343                total_value < 500_000,
344                "Federation total value should be less than 500_000 sats"
345            );
346
347            await_no_pending_txs(&client).await?;
348
349            ensure_tx_chain_length(&client, 4).await?;
350
351            info!("Verify that a send with zero fee aborts...");
352
353            let abort_address = bitcoind.get_new_address().await?;
354
355            let value = cmd!(
356                client,
357                "module",
358                "walletv2",
359                "send",
360                abort_address,
361                "100000 sat",
362                "--fee",
363                "0 sat"
364            )
365            .out_json()
366            .await?;
367
368            assert_eq!(
369                FinalSendState::Aborted,
370                serde_json::from_value(value)?,
371                "Send with zero fee should abort"
372            );
373
374            info!("Test circular deposit (send to second client's federation address)...");
375
376            let client_two = fed
377                .new_joined_client("walletv2-test-circular-deposit-client")
378                .await?;
379
380            let (circular_address, position) = get_deposit_address(&client_two).await?;
381
382            let value = cmd!(
383                client,
384                "module",
385                "walletv2",
386                "send",
387                circular_address.to_string(),
388                "100000 sat"
389            )
390            .out_json()
391            .await?;
392
393            let FinalSendState::Success(txid) = serde_json::from_value(value)? else {
394                panic!("Circular deposit send operation failed");
395            };
396
397            bitcoind.poll_get_transaction(txid).await?;
398
399            await_deposits(&client_two, position, 1, 99_000).await?;
400
401            await_no_pending_txs(&client).await?;
402
403            ensure_tx_chain_length(&client, 6).await?;
404
405            // `send ... all` was added in 0.13.0-alpha; older CLIs parse the
406            // value as a plain amount and reject "all".
407            if fedimint_cli_version >= *VERSION_0_13_0_ALPHA {
408                info!("Sweep the entire remaining balance onchain...");
409
410                let sweep_address = bitcoind.get_new_address().await?;
411
412                // A sweep can only spend notes that exist when it runs. The
413                // aborted send above is refunded by the mint's input state
414                // machine, which settles independently of the federation's
415                // bitcoin transactions — `await_final_send_operation_state`
416                // returns as soon as the send aborts, while the refunded notes
417                // are still being reissued. Wait for every in-flight state
418                // machine to finish, or that refund lands after the sweep and
419                // looks like funds left behind.
420                cmd!(client, "dev", "wait-complete").run().await?;
421
422                let pre_sweep_balance = client.balance().await?;
423
424                // Sweeping the whole balance has to leave room for the mint's
425                // per-note fees and the wallet module's own output fee on top
426                // of the on-chain fee. With base fees enabled (the default) a
427                // naive `balance - onchain_fee` is underfunded and note
428                // selection rejects the send.
429                let value = cmd!(client, "module", "walletv2", "send", sweep_address, "all")
430                    .out_json()
431                    .await?;
432
433                let FinalSendState::Success(txid) = serde_json::from_value(value)? else {
434                    panic!("Sweep send operation failed");
435                };
436
437                bitcoind.poll_get_transaction(txid).await?;
438
439                // A sweep cannot always drain to exactly zero — the fee is
440                // stepwise in the amount, so a sub-denomination remainder can
441                // be left behind — but it must move all but a negligible part
442                // of the balance.
443                let post_sweep_balance = client.balance().await?;
444
445                ensure!(
446                    post_sweep_balance < pre_sweep_balance / 100,
447                    "Sweep left {post_sweep_balance} msats of {pre_sweep_balance} msats behind"
448                );
449
450                await_no_pending_txs(&client).await?;
451            }
452
453            block_miner.abort();
454
455            info!("Wallet V2 send and receive test successful");
456
457            Ok(())
458        })
459        .await
460}