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