1use anyhow::Result;
2use clap::Parser;
3use devimint::cmd;
4use devimint::federation::{Client, Federation};
5use devimint::util::{FedimintCli, almost_equal};
6use devimint::version_constants::VERSION_0_12_0_ALPHA;
7use fedimint_client_module::ModuleRecoveryCompleted;
8use fedimint_logging::LOG_DEVIMINT;
9use rand::Rng;
10use tokio::try_join;
11use tracing::info;
12
13#[derive(Debug, Parser)]
14enum Cmd {
15 Restore,
16 RecoveryV1,
19 RecoveryV2,
21 Sanity,
22}
23
24#[tokio::main]
25async fn main() -> anyhow::Result<()> {
26 match Cmd::parse() {
27 Cmd::Restore => restore().await,
28 Cmd::RecoveryV1 => mint_recovery_test().await,
29 Cmd::RecoveryV2 => mint_recovery_test().await,
30 Cmd::Sanity => sanity().await,
31 }
32}
33
34async fn restore() -> anyhow::Result<()> {
35 devimint::run_devfed_test()
36 .call(|fed, _process_mgr| async move {
37 let fed = fed.fed().await?;
38
39 test_restore_gap_test(fed).await?;
40 Ok(())
41 })
42 .await
43}
44
45pub async fn test_restore_gap_test(fed: &Federation) -> Result<()> {
46 let client = fed.new_joined_client("restore-gap-test").await?;
47 const PEGIN_SATS: u64 = 300000;
48 fed.pegin_client(PEGIN_SATS, &client).await?;
49
50 for i in 0..20 {
51 let gap = rand::thread_rng().gen_range(0..20);
52 info!(target: LOG_DEVIMINT, gap, "Gap");
53 cmd!(
54 client,
55 "dev",
56 "advance-note-idx",
57 "--amount",
58 "1024msat",
59 "--count",
60 &gap.to_string()
64 )
65 .run()
66 .await?;
67
68 let notes = cmd!(client, "info").out_json().await?;
71 let balance = notes["total_amount_msat"].as_u64().unwrap();
72
73 let reissure_amount = if i % 2 == 0 {
74 balance
76 } else {
77 rand::thread_rng().gen_range(10..(balance))
79 };
80 info!(target: LOG_DEVIMINT, i, reissure_amount, "Reissue");
81
82 let notes = cmd!(client, "spend", reissure_amount)
83 .out_json()
84 .await?
85 .get("notes")
86 .expect("Output didn't contain e-cash notes")
87 .as_str()
88 .unwrap()
89 .to_owned();
90
91 cmd!(client, "reissue", notes).out_json().await?;
93 }
94
95 let secret = cmd!(client, "print-secret").out_json().await?["secret"]
96 .as_str()
97 .map(ToOwned::to_owned)
98 .unwrap();
99
100 let pre_notes = cmd!(client, "info").out_json().await?;
101
102 let pre_balance = pre_notes["total_amount_msat"].as_u64().unwrap();
103
104 info!(target: LOG_DEVIMINT, %pre_notes, pre_balance, "State before backup");
105
106 assert!(0 < pre_balance);
108
109 {
111 let client =
112 devimint::federation::Client::create("restore-gap-test-without-backup").await?;
113 let _ = cmd!(
114 client,
115 "restore",
116 "--mnemonic",
117 &secret,
118 "--invite-code",
119 fed.invite_code()?
120 )
121 .out_json()
122 .await?;
123
124 let _ = cmd!(client, "dev", "wait-complete").out_json().await?;
125 let post_notes = cmd!(client, "info").out_json().await?;
126 let post_balance = post_notes["total_amount_msat"].as_u64().unwrap();
127 info!(target: LOG_DEVIMINT, %post_notes, post_balance, "State after backup");
128 assert_eq!(pre_balance, post_balance);
129 assert_eq!(pre_notes, post_notes);
130 }
131
132 Ok(())
133}
134
135async fn mint_recovery_test() -> anyhow::Result<()> {
143 devimint::run_devfed_test()
144 .call(|dev_fed, _process_mgr| async move {
145 let fed = dev_fed.fed().await?;
146
147 try_join!(
148 test_recovery_with_backup(fed),
149 test_recovery_without_backup(fed),
150 test_recovery_after_activity(fed),
151 test_recovery_with_post_backup_activity(fed),
152 )?;
153
154 Ok(())
155 })
156 .await
157}
158
159const PEGIN_SATS: u64 = 1_000_000;
160
161fn mint_recovery_completed(event_log: &serde_json::Value) -> Option<ModuleRecoveryCompleted> {
168 event_log.as_array()?.iter().find_map(|entry| {
169 let payload = entry.get("payload")?;
170
171 let event: ModuleRecoveryCompleted = match payload.as_str() {
174 Some(hex) => serde_json::from_slice(&hex::decode(hex).ok()?).ok()?,
175 None => serde_json::from_value(payload.clone()).ok()?,
176 };
177
178 (event.kind.as_ref() == Some(&fedimint_mint_common::KIND)).then_some(event)
179 })
180}
181
182async fn assert_recovery_event_amount(restored: &Client, recovered_balance: u64) -> Result<()> {
189 if FedimintCli::version_or_default().await < *VERSION_0_12_0_ALPHA {
190 return Ok(());
191 }
192
193 for attempt in 0..20 {
196 let event_log = cmd!(restored, "dev", "show-event-log", "--limit", "1000")
197 .out_json()
198 .await?;
199
200 if let Some(event) = mint_recovery_completed(&event_log) {
201 let amount = event
202 .amount
203 .expect("recovery-completed event must carry the recovered amount");
204
205 almost_equal(recovered_balance, amount.msats, 25_000)
206 .map_err(|err| anyhow::anyhow!("recovery event amount mismatch: {err}"))?;
207
208 return Ok(());
209 }
210
211 if attempt + 1 < 20 {
212 fedimint_core::task::sleep_in_test(
213 "waiting for the recovery-completed event to be ordered into the log",
214 std::time::Duration::from_millis(250),
215 )
216 .await;
217 }
218 }
219
220 anyhow::bail!("mint module did not emit a recovery-completed event");
221}
222
223async fn test_recovery_with_backup(fed: &Federation) -> Result<()> {
224 info!(target: LOG_DEVIMINT, "### Test mint recovery with backup");
225 let client = fed.new_joined_client("mint-recovery-backup").await?;
226 fed.pegin_client(PEGIN_SATS, &client).await?;
227
228 let pre_balance = client.balance().await?;
229 info!(target: LOG_DEVIMINT, pre_balance, "Balance before backup");
230 assert!(pre_balance > 0);
231
232 cmd!(client, "backup").run().await?;
233
234 let restored = client
235 .new_restored("mint-restored-with-backup", fed.invite_code()?)
236 .await?;
237 cmd!(restored, "dev", "wait-complete").out_json().await?;
238
239 let post_balance = restored.balance().await?;
240 info!(target: LOG_DEVIMINT, post_balance, "Balance after recovery with backup");
241 almost_equal(pre_balance, post_balance, 25_000).unwrap();
242 assert_recovery_event_amount(&restored, post_balance).await?;
243 Ok(())
244}
245
246async fn test_recovery_without_backup(fed: &Federation) -> Result<()> {
247 info!(target: LOG_DEVIMINT, "### Test mint recovery without backup");
248 let client = fed.new_joined_client("mint-recovery-no-backup").await?;
249 fed.pegin_client(PEGIN_SATS, &client).await?;
250
251 let pre_balance = client.balance().await?;
252 assert!(pre_balance > 0);
253
254 let restored = client
255 .new_restored("mint-restored-no-backup", fed.invite_code()?)
256 .await?;
257 cmd!(restored, "dev", "wait-complete").out_json().await?;
258
259 let post_balance = restored.balance().await?;
260 info!(target: LOG_DEVIMINT, post_balance, "Balance after recovery without backup");
261 almost_equal(pre_balance, post_balance, 25_000).unwrap();
262 assert_recovery_event_amount(&restored, post_balance).await?;
263 Ok(())
264}
265
266async fn test_recovery_after_activity(fed: &Federation) -> Result<()> {
267 info!(target: LOG_DEVIMINT, "### Test mint recovery after spend+reissue activity");
268 let client = fed
269 .new_joined_client("mint-recovery-after-activity")
270 .await?;
271 fed.pegin_client(PEGIN_SATS, &client).await?;
272
273 for i in 0..3 {
274 let balance = client.balance().await?;
275 let spend_amount = balance / 3;
276
277 let notes = cmd!(client, "spend", spend_amount)
278 .out_json()
279 .await?
280 .get("notes")
281 .expect("Output didn't contain e-cash notes")
282 .as_str()
283 .unwrap()
284 .to_owned();
285
286 cmd!(client, "reissue", notes).out_json().await?;
287 info!(target: LOG_DEVIMINT, i, spend_amount, "Spent and reissued to self");
288 }
289
290 let pre_balance = client.balance().await?;
291 info!(target: LOG_DEVIMINT, pre_balance, "Balance after activity");
292 assert!(pre_balance > 0);
293
294 cmd!(client, "backup").run().await?;
295
296 let restored = client
297 .new_restored("mint-restored-after-activity", fed.invite_code()?)
298 .await?;
299 cmd!(restored, "dev", "wait-complete").out_json().await?;
300
301 let post_balance = restored.balance().await?;
302 info!(target: LOG_DEVIMINT, post_balance, "Balance after recovery post-activity");
303 almost_equal(pre_balance, post_balance, 25_000).unwrap();
304 assert_recovery_event_amount(&restored, post_balance).await?;
305 Ok(())
306}
307
308async fn test_recovery_with_post_backup_activity(fed: &Federation) -> Result<()> {
309 info!(target: LOG_DEVIMINT, "### Test mint recovery with post-backup activity");
310 let client = fed.new_joined_client("mint-recovery-post-backup").await?;
311 fed.pegin_client(PEGIN_SATS, &client).await?;
312
313 cmd!(client, "backup").run().await?;
314
315 let balance = client.balance().await?;
316 let spend_amount = balance / 2;
317 let notes = cmd!(client, "spend", spend_amount)
318 .out_json()
319 .await?
320 .get("notes")
321 .expect("Output didn't contain e-cash notes")
322 .as_str()
323 .unwrap()
324 .to_owned();
325 cmd!(client, "reissue", notes).out_json().await?;
326
327 let pre_balance = client.balance().await?;
328 info!(target: LOG_DEVIMINT, pre_balance, "Balance after post-backup activity");
329
330 let restored = client
331 .new_restored("mint-restored-post-backup", fed.invite_code()?)
332 .await?;
333 cmd!(restored, "dev", "wait-complete").out_json().await?;
334
335 let post_balance = restored.balance().await?;
336 info!(target: LOG_DEVIMINT, post_balance, "Balance after recovery with post-backup activity");
337 almost_equal(pre_balance, post_balance, 25_000).unwrap();
338 assert_recovery_event_amount(&restored, post_balance).await?;
339 Ok(())
340}
341
342async fn sanity() -> anyhow::Result<()> {
343 devimint::run_devfed_test()
344 .call(|_fed, _process_mgr| async move { Ok(()) })
345 .await
346}