1use super::share::Share;
4use crate::core::{Config, UserRepoT};
5use crate::types::newtypes::{AccessToken, EncryptionPin, EncryptionSalt, PlainPassword};
6use crate::wallet::error::{ErrorKind, Result, WalletError};
7use api_types::api::networks::{ApiNetwork, ApiProtocol};
8use async_trait::async_trait;
9use etopay_wallet::bip39::{self, Mnemonic};
10use etopay_wallet::{MnemonicDerivationOption, WalletImplEvm, WalletImplEvmErc20, WalletImplIotaRebased, WalletUser};
11use log::{info, warn};
12use rand::RngCore;
13use secrecy::SecretBox;
14use std::marker::PhantomData;
15use std::ops::{Deref, DerefMut};
16
17pub struct WalletBorrow<'a> {
20 inner: Box<dyn WalletUser + Send + Sync>,
21 _lifetime: std::marker::PhantomData<&'a ()>,
23}
24
25#[cfg(test)]
26impl WalletBorrow<'_> {
27 pub fn from(inner: impl WalletUser + Send + Sync + 'static) -> Self {
29 Self {
30 inner: Box::new(inner),
31 _lifetime: PhantomData,
32 }
33 }
34}
35
36impl Deref for WalletBorrow<'_> {
37 type Target = Box<dyn WalletUser + Send + Sync>;
38
39 fn deref(&self) -> &Self::Target {
40 &self.inner
41 }
42}
43impl DerefMut for WalletBorrow<'_> {
44 fn deref_mut(&mut self) -> &mut Self::Target {
45 &mut self.inner
46 }
47}
48
49#[cfg_attr(test, mockall::automock)]
51#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
52#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
53pub trait WalletManager: std::fmt::Debug {
54 fn get_recovery_share(&self) -> Option<Share>;
56
57 fn set_recovery_share(&mut self, share: Option<Share>);
59
60 async fn create_wallet_from_new_mnemonic(
62 &mut self,
63 config: &Config,
64 access_token: &Option<AccessToken>,
65 repo: &mut UserRepoT,
66 pin: &EncryptionPin,
67 ) -> Result<String>;
68
69 async fn create_wallet_from_existing_mnemonic(
71 &mut self,
72 config: &Config,
73 access_token: &Option<AccessToken>,
74 repo: &mut UserRepoT,
75 pin: &EncryptionPin,
76 mnemonic: &str,
77 ) -> Result<()>;
78
79 async fn create_wallet_from_backup(
81 &mut self,
82 config: &Config,
83 access_token: &Option<AccessToken>,
84 repo: &mut UserRepoT,
85 pin: &EncryptionPin,
86 backup: &[u8],
87 backup_password: &PlainPassword,
88 ) -> Result<()>;
89
90 async fn create_wallet_backup(
92 &mut self,
93 config: &Config,
94 access_token: &Option<AccessToken>,
95 repo: &mut UserRepoT,
96 pin: &EncryptionPin,
97 backup_password: &PlainPassword,
98 ) -> Result<Vec<u8>>;
99
100 async fn delete_wallet(
102 &mut self,
103 config: &Config,
104 access_token: &Option<AccessToken>,
105 repo: &mut UserRepoT,
106 ) -> Result<()>;
107
108 async fn check_mnemonic(
110 &mut self,
111 config: &Config,
112 access_token: &Option<AccessToken>,
113 repo: &mut UserRepoT,
114 pin: &EncryptionPin,
115 mnemonic: &str,
116 ) -> Result<bool>;
117
118 async fn change_wallet_password(
120 &mut self,
121 config: &Config,
122 access_token: &Option<AccessToken>,
123 repo: &mut UserRepoT,
124 pin: &EncryptionPin,
125 new_password: &PlainPassword,
126 ) -> Result<()>;
127
128 async fn try_get<'a>(
132 &'a mut self,
133 config: &mut Config,
134 access_token: &Option<AccessToken>,
135 repo: &mut UserRepoT,
136 network: &ApiNetwork,
137 pin: &EncryptionPin,
138 options: &MnemonicDerivationOption,
139 ) -> Result<WalletBorrow<'a>>;
140}
141
142#[derive(Debug)]
145pub struct WalletManagerImpl {
146 username: String,
148
149 pub recovery_share: Option<Share>,
151}
152
153#[derive(Debug, PartialEq)]
154struct Status {
155 local: bool,
157 recovery: Option<RecoveryUsed>,
159 backup: bool,
161}
162
163#[derive(Debug, PartialEq)]
165enum RecoveryUsed {
166 Local,
168 Remote,
170}
171
172impl WalletManagerImpl {
173 pub fn new(username: impl Into<String>) -> Self {
175 Self {
176 username: username.into(),
177 recovery_share: None,
178 }
179 }
180
181 async fn try_resemble_shares(
183 &mut self,
184 config: &Config,
185 access_token: &Option<AccessToken>,
186 repo: &mut UserRepoT,
187 pin: &EncryptionPin,
188 ) -> Result<(Mnemonic, Status)> {
189 info!("Initializing wallet for user from shares");
190
191 let username = &self.username;
192 let local_recovery_share = self.recovery_share.clone();
193
194 let user = repo.get(username)?;
195
196 let mut available_shares: Vec<Share> = Vec::new();
208 let mut recovery_share_available_with_user_action = false;
209 let mut password_required = false;
210
211 let mut local_used = false;
213 let mut recovery_used = None;
214 let mut backup_used = false;
215
216 if let Some(share) = user.local_share.map(|s| s.parse::<Share>()) {
217 available_shares.push(share?);
218 log::debug!("Local storage share available");
219 local_used = true;
220 }
221
222 if let Some(share) = local_recovery_share {
223 available_shares.push(share);
224 log::debug!("Local recovery share available");
225 recovery_used = Some(RecoveryUsed::Local);
226 } else {
227 log::debug!("Local recovery share not available, checking if it can be downloaded");
228 recovery_share_available_with_user_action = true;
229
230 if let Some(access_token) = &access_token {
232 match crate::backend::shares::download_recovery_share(config, access_token, username).await {
233 Ok(Some(share)) => {
234 available_shares.push(share);
235 recovery_share_available_with_user_action = false; recovery_used = Some(RecoveryUsed::Remote);
237 log::debug!("Recovery share downloaded and available");
238 }
239 Ok(None) => log::info!("Recovery share not available"),
240 Err(e) => log::warn!("Error fetching recovery share: {e}"),
241 }
242 } else {
243 log::debug!("Access token not available, skipping recovery share download.");
244 }
245 }
246
247 if available_shares.len() < 2 {
250 if let Some(access_token) = &access_token {
251 match crate::backend::shares::download_backup_share(config, access_token, username).await {
253 Ok(Some(share)) => {
254 available_shares.push(share);
255 password_required = true;
256 backup_used = true;
257 log::debug!("Backup share (encrypted) downloaded and available");
258 }
259 Ok(None) => log::debug!("Backup share (encrypted) not available"),
260 Err(e) => log::warn!("Error fetching backup share: {e}"),
261 }
262 } else {
263 log::debug!("Access token not available, skipping backup share download.");
264 }
265 }
266
267 let available_shares = available_shares;
269 let recovery_share_available_with_upload = recovery_share_available_with_user_action;
270 let password_required = password_required;
271
272 log::debug!(
273 "Done collecting shares. Got {} shares, recovery_share_available_with_user_action = {}, password_required = {}",
274 available_shares.len(),
275 recovery_share_available_with_upload,
276 password_required
277 );
278
279 if available_shares.len() >= 2 {
280 let password = if password_required {
284 let password = user
285 .encrypted_password
286 .ok_or(WalletError::WalletNotInitialized(ErrorKind::MissingPassword))?
287 .decrypt(pin, &user.salt)?;
288
289 Some(password)
290 } else {
291 None
292 };
293
294 let shares_ref = available_shares.iter().collect::<Vec<&Share>>();
295
296 let mnemonic = crate::share::reconstruct_mnemonic(
298 &shares_ref,
299 password.as_ref().map(PlainPassword::into_secret).as_ref(),
300 )?;
301
302 if !local_used {
303 log::debug!("Local share not set, recreating shares and storing local share again");
304
305 let shares = crate::share::create_shares_from_mnemonic(
308 &mnemonic,
309 &SecretBox::new(String::from("dummy password").as_bytes().into()),
310 )?;
311
312 if let Err(e) = repo.set_local_share(username, Some(&shares.local)) {
314 log::warn!("Error storing local share again: {e:#}");
315 } else {
316 log::debug!("Done storing local share again");
317 }
318 }
319
320 Ok((
321 mnemonic,
322 Status {
323 local: local_used,
324 recovery: recovery_used,
325 backup: backup_used,
326 },
327 ))
328 } else if available_shares.len() == 1 && recovery_share_available_with_upload {
329 Err(WalletError::WalletNotInitialized(ErrorKind::SetRecoveryShare))
330 } else {
331 Err(WalletError::WalletNotInitialized(ErrorKind::UseMnemonic))
333 }
334 }
335
336 async fn create_and_upload_shares(
339 &mut self,
340 config: &Config,
341 access_token: &Option<AccessToken>,
342 repo: &mut UserRepoT,
343 pin: &EncryptionPin,
344 mnemonic: &Mnemonic,
345 ) -> Result<()> {
346 log::info!("Creating and uploading shares");
347
348 let user = repo.get(&self.username)?;
350 let Some(encrypted_password) = user.encrypted_password else {
351 return Err(WalletError::WalletNotInitialized(ErrorKind::MissingPassword));
352 };
353
354 let password = encrypted_password.decrypt(pin, &user.salt)?;
355 let shares = crate::share::create_shares_from_mnemonic(mnemonic, &password.into_secret())?;
356
357 log::info!("Shares created, storing local share");
358 repo.set_local_share(&user.username, Some(&shares.local))?;
359 self.recovery_share = Some(shares.recovery.clone());
360
361 if let Some(access_token) = access_token {
362 log::info!("Uploading shares");
363 crate::backend::shares::upload_shares(config, access_token, &shares.backup, &shares.recovery).await?;
364 log::info!("Done uploading shares");
365 } else {
366 log::info!("No access token, skipping uploading backup and recovery shares");
367 }
368 Ok(())
369 }
370}
371
372#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
373#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
374impl WalletManager for WalletManagerImpl {
375 fn get_recovery_share(&self) -> Option<Share> {
376 self.recovery_share.clone()
377 }
378 fn set_recovery_share(&mut self, share: Option<Share>) {
379 self.recovery_share = share;
380 }
381 async fn create_wallet_from_new_mnemonic(
383 &mut self,
384 config: &Config,
385 access_token: &Option<AccessToken>,
386 repo: &mut UserRepoT,
387 pin: &EncryptionPin,
388 ) -> Result<String> {
389 let bytes = {
390 let mut rng = rand::rng();
391 let mut bytes = vec![0u8; 32];
392 rng.fill_bytes(&mut bytes);
393 bytes
394 };
395
396 let mnemonic = Mnemonic::from_entropy(&bytes, bip39::Language::English)?;
397 self.create_and_upload_shares(config, access_token, repo, pin, &mnemonic)
398 .await?;
399
400 Ok(mnemonic.phrase().to_string())
401 }
402
403 async fn create_wallet_from_existing_mnemonic(
405 &mut self,
406 config: &Config,
407 access_token: &Option<AccessToken>,
408 repo: &mut UserRepoT,
409 pin: &EncryptionPin,
410 mnemonic: &str,
411 ) -> Result<()> {
412 let mnemonic = Mnemonic::from_phrase(mnemonic, bip39::Language::English)?;
413 self.create_and_upload_shares(config, access_token, repo, pin, &mnemonic)
414 .await
415 }
416
417 async fn create_wallet_from_backup(
419 &mut self,
420 config: &Config,
421 access_token: &Option<AccessToken>,
422 repo: &mut UserRepoT,
423 pin: &EncryptionPin,
424 backup: &[u8],
425 backup_password: &PlainPassword,
426 ) -> Result<()> {
427 let mnemonic = crate::kdbx::load_mnemonic(backup, &backup_password.into_secret_string())?;
428 self.create_and_upload_shares(config, access_token, repo, pin, &mnemonic)
429 .await
430 }
431
432 async fn create_wallet_backup(
434 &mut self,
435 config: &Config,
436 access_token: &Option<AccessToken>,
437 repo: &mut UserRepoT,
438 pin: &EncryptionPin,
439 backup_password: &PlainPassword,
440 ) -> Result<Vec<u8>> {
441 let (mnemonic, _status) = self.try_resemble_shares(config, access_token, repo, pin).await?;
442
443 Ok(crate::kdbx::store_mnemonic(
444 &mnemonic,
445 &backup_password.into_secret_string(),
446 )?)
447 }
448
449 async fn delete_wallet(
450 &mut self,
451 config: &Config,
452 access_token: &Option<AccessToken>,
453 repo: &mut UserRepoT,
454 ) -> Result<()> {
455 #[cfg(not(target_arch = "wasm32"))]
457 {
458 let path = config.path_prefix.join("wallets").join(&self.username);
459 if let Err(e) = std::fs::remove_dir_all(path) {
460 warn!("Error removing wallet files: {e:?}");
461 }
462 }
463
464 repo.set_local_share(&self.username, None)?;
466 self.recovery_share = None;
467
468 if let Some(access_token) = access_token {
470 crate::backend::shares::delete_shares(config, access_token, &self.username).await?;
471 }
472
473 Ok(())
474 }
475
476 async fn check_mnemonic(
477 &mut self,
478 config: &Config,
479 access_token: &Option<AccessToken>,
480 repo: &mut UserRepoT,
481 pin: &EncryptionPin,
482 mnemonic: &str,
483 ) -> Result<bool> {
484 let (existing_mnemonic, _status) = self.try_resemble_shares(config, access_token, repo, pin).await?;
487
488 Ok(mnemonic == existing_mnemonic.phrase())
490 }
491
492 async fn change_wallet_password(
493 &mut self,
494 config: &Config,
495 access_token: &Option<AccessToken>,
496 repo: &mut UserRepoT,
497 pin: &EncryptionPin,
498 new_password: &PlainPassword,
499 ) -> Result<()> {
500 let result = self.try_resemble_shares(config, access_token, repo, pin).await;
502
503 match result {
506 Ok(_) => {}
507 Err(WalletError::WalletNotInitialized(ErrorKind::UseMnemonic)) => {
508 warn!("No wallet found (UseMnemonic error), continuing to change the password locally only")
509 }
510 Err(e) => return Err(e),
511 }
512
513 let mut user = repo.get(&self.username)?;
515 let salt = EncryptionSalt::generate();
516 let encrypted_password = new_password.encrypt(pin, &salt)?;
517 user.salt = salt;
518 user.encrypted_password = Some(encrypted_password);
519 repo.update(&user)?;
520
521 if let Ok((mnemonic, _status)) = result {
523 self.create_and_upload_shares(config, access_token, repo, pin, &mnemonic)
524 .await?;
525 }
526
527 Ok(())
528 }
529
530 async fn try_get<'a>(
531 &'a mut self,
532 config: &mut Config,
533 access_token: &Option<AccessToken>,
534 repo: &mut UserRepoT,
535 network: &ApiNetwork,
536 pin: &EncryptionPin,
537 options: &MnemonicDerivationOption,
538 ) -> Result<WalletBorrow<'a>> {
539 let (mnemonic, _status) = self.try_resemble_shares(config, access_token, repo, pin).await?;
540
541 let bo = match &network.protocol {
543 ApiProtocol::Evm { chain_id } => {
544 let wallet = WalletImplEvm::new(
545 mnemonic,
546 &network.node_urls,
547 *chain_id,
548 network.decimals,
549 network.coin_type,
550 options,
551 )?;
552 Box::new(wallet) as Box<dyn WalletUser + Sync + Send>
553 }
554 ApiProtocol::EvmERC20 {
555 chain_id,
556 contract_address,
557 } => {
558 let wallet = WalletImplEvmErc20::new(
559 mnemonic,
560 &network.node_urls,
561 *chain_id,
562 network.decimals,
563 network.coin_type,
564 contract_address,
565 options,
566 )?;
567 Box::new(wallet) as Box<dyn WalletUser + Sync + Send>
568 }
569 ApiProtocol::IotaRebased { coin_type } => {
570 let wallet =
571 WalletImplIotaRebased::new(mnemonic, coin_type, network.decimals, &network.node_urls, options)
572 .await?;
573 Box::new(wallet) as Box<dyn WalletUser + Sync + Send>
574 }
575 };
576
577 Ok(WalletBorrow {
578 inner: bo,
579 _lifetime: PhantomData,
580 })
581 }
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587 use crate::{
588 core::{Config, UserRepoT},
589 kdbx::KdbxStorageError,
590 testing_utils::{ENCRYPTED_WALLET_PASSWORD, IOTA_NETWORK_KEY, PIN, SALT, WALLET_PASSWORD, example_api_network},
591 types::{
592 newtypes::{AccessToken, EncryptionPin, EncryptionSalt, PlainPassword},
593 users::KycType,
594 },
595 user::{MockUserRepo, memory_storage::MemoryUserStorage, repository::UserRepoImpl},
596 };
597 use kdbx_rs::errors::UnlockError;
598 use rstest::rstest;
599 use std::sync::LazyLock;
600
601 const MNEMONIC: &str = "endorse answer radar about source reunion marriage tag sausage weekend frost daring base attack because joke dream slender leisure group reason prepare broken river";
602 const MNEMONIC_INCORRECT: &str = "answer radar about source reunion marriage tag sausage weekend frost daring base attack because joke dream slender leisure group reason prepare broken river";
603 const USERNAME: &str = "SuperAdmin";
604 static INVALID_BACKUP_PASSWORD: LazyLock<PlainPassword> =
605 LazyLock::new(|| PlainPassword::try_from_string("correcthorsebatterystapleaa").unwrap());
606
607 const SHARE_LOCAL: &str = "ME-RS-N-Mi0xLUNBRVFBaGdESXFBRStPVUZYZTJnMTdLRFY1L2pWRllQTHdtZ0dCWExJbitjTERReFRyRHArWGNVMG5yY3UyVmFONFEvZkVoeXNadm5qNFhmRDVIZXZ3eHB2bENTYnZIZTFtOTlXdjJwby8zVWl0d2VhMnVWOTZaejB5WmhEdHlkRDFYcEg1R0RIYXFvZDBpTHdpcDZ3d1k5T0VWdEJhZmtkUVRGaTNNM3gvY2dsK0FDWVQ5WG50TlJycnRtWFRTUGZ4MG54R1lVc0NWUnNKY3h5Q0JxSHBlRGVRekpSTlFxVldMNGpJU3JCZkFRcEpYMnJoT1o4OXM1V3VLaW5PWFd0YUZncTRnd2t1VzR0ZkJJZzVUMjFlaXpGNEpWNzlMcXFXSDZoY3N0Z1huYzZYWTJvZjRvaytlYnJWOFBmR1lOU1NxRWQ4VFpqUzlBL0h0clJGNThEbUdaL2Z2Nmp5MjJjS01hUWllK1ZqdFZ4OUJyblJjWThYYTgxWmNTWlF4YlFLbFQ3MC9tRk5aQlN4ZXNLTWVTU24vV2hycEs0OU80ZW4zRkZJVTJqd2lLcGwybHpHMk0vdThJTzRZSlNCL1B6aVp4cGczcVk5Z25PRHNQR2lDZGNyejErcTVhYUdoMDdXUGlISFg5K1VpbVJjRThZS1BBNXUwNTBkQ2l2eVM2a2VhZkpFalQ0UXkxcElPaFRUd3ZrMWxrR0ZmeWp3bVBqL3JMRGY4YUc3ZXZlVWQveGwxbzlKMnh5ckhvQW9heTNVNVpHYjFCZGJ2OGFGNHJLb2wwTkorUlZBSTZJSHJCUnE4OGxJeGtzSlFxTm9GQ3o5b051N011OTVkMUJpZ3ErNjZiYzBuTWcyWXZYQXdaMkh3RjAzS0xRWEFWYjZVekZ1Lzc0MjYraElNUlR1M01mZDZoa01vMllMVzlxSS9odlBsaWg4RG5qaUFTUG9Fbkx2cVFidVpXaVBnQ3h2c1F4eXFBQWdDazlzckhwaG51UnpTck95M1JmZzRYa0lndHhlb3ArUmZJOVgyaDBRcEVmcjgzYzExd0xhQkxDUmgwMlFXazA2Ty8yM2s2cWZNZHBxNVZ2b0ZnTkNJYlY1V01sSFpaV3RnVXFzaGtXRVJycjduZnVvd1BQQ0NUaHdxMC9tbUQ1NDVDb0VNWU16bUtQYlIyYmF4RkVTbUswTlRRT3VWR3A2Y3JqNWlYOGxzaU9kZ3FVNHhuSVpRcDRsT1lJcTlBOUhFS3NZZ1RuYysxRlRNazJEN05ydThlalh4UUR3amFqUTFNTmJ5cldBS0MvZ3RTWW9ONTFKY25FWFlUOWI1MVZOWWF4anArTE9oeDA0M3RNUW9TejNvN1kxbWtORlJUTmJMZWhDKzV0UkNKNjdQYk5Va29DbWxXbjFYODZxVlVsRFU0MjkxaXVLaE1YQ0lsVHlscGU2dw==";
609 const SHARE_BACKUP: &str = "ME-RS-AesGcm-K3vx+e6IF6BOUJ2DemvsdflQq2CbolcFqdazfapauZTHdY/Hovh5zC8s5Qmfb2tRmRaluRX1gxMZfGDP52rakFnZpOzOCNGyHiI/dsiFDFbty0fEheEw+p1LrOI4zNwy7NE7ZsK0C756ggVfrhCin2Yw0KA6pALFqfWnQokx5Q43pUFd6ZGD8fwathC4NGx/hVTi9lxA2L6ScNQY9V3bEie40MKdpLQ6ELsPq+38UVJtqIgE0wJs8fDKSIGJVEPvP6wbVa+oPB/uFl5h56YeuYB2UGHdMJ54DCEoUBSd5QGoeKwjIylrZ+wXzchPXhtAfaCmqlf0fmKi9f5FQGrFwH9drf5HFE5Z/JWQC1FMKJTeBZ2CgcFvCtHuVm8VNnhhes1fUc7gL8VNOqE25LHFFQp3fBfeHXRkCmX+PAU+1N8KU6SFX0XqDr5anKAMH6thViBdno2m6K9tzqyucUnfgHYgp/cc+XXo9Ffw7v6lVTW3ls9diZwdwcs9JYqoKhWAs9dVGPz0017glpeAz01moJDPSMkhZwQh9GGWvhyeTWE9T28NS1G3cOBkW0GbgmIDjKeDDXAOjDyN7Db0FFL3TRAXthFtRXjJyZD1Xu2quYyjz1ZG70ILp0rDzzDaikUPUt1TCsAz+8NfLwHKz+H4oPUGprdUqgBVSGOySH+lKZaUbN17qIXjEKg58jh686s6i4GTD7Ndf6Xqsdc00PRDlm+jHwK7bNvkqkcChQHockIaIi4ETHCz/jqrca7uY8RIABv9Ni46+Ix1CrNY4qCUhep9oYZBGSLy2fQWWNk2nZgbrkipwUbgoV1IJV/kWCQ6ycjGG005kv3AFb6sZyrnFbvT7sa/JCKlo8gcVtzXlrJJqiO/7Qb1nTfj9dLd+/4ihpmwpFwPmKHi6zrZjJ8FbaDGkXSg+a82RQqz/AsH10hBd/tSZeZ5chdwgxTouoGix99HZipTKXLiAqW7Mo0N93+atNb9EWeHPBfsVbwJ2shBT5030QrY2qQfhTb4GUl52vPQBvpjxCzjPlvCWzFMlO8wrCP1sJm5egEb0F6Fpa9H3blBdMcb2NuKJ2VfSQzuJrbLzirnX3X0Pbk93S2dE5vs/2xsL6fqV18EPkVXO1mQtqsM8sMF8o6G/PLILN268Ga7CwcCL3qnoaCvahN3sHbciy38UH6s5hRTDvV75nWDj4oIaByrYx+JdgSZ4sucAn/bEQJCDSTVQ3sYQbEJGxc+xImNWudEoxdCmKYZPDFhUEIfO8pQRHTX8ZHZST+m97kJMuPvWg49UlrGu2YE6KbkNBEz7cSoWOuWpbrNjv1I8XKf8Sd82dvRWn3ZDc/4GXXE5oscG8UHTlz3XIpWNNrpE+wmn+AvmU0+n5r4Nv0LOFrlqH8Z2DcfjGqAVJkQMWFriruEcsPOvRgvGUeUtjulxEwcqX/UVmE5871rx0C2aJhazTnLkzt9TDFTaAf7J7zkIkhvKx8AU2A=";
610 const SHARE_RECOVERY: &str = "ME-RS-N-Mi0yLUNBSVFBaGdESXFBRWk3b296TFVtbzNscG1jZEIxMXFESnVHbUpRUGYxUk9nOG5WQVNkR1NSTE5YQk41VytwV1dUcWVNSnVOakN3ZVd4eFk0Ylh6Y0NWTlhzYWFLNmM5UEUxWHVFT2lNVW9BeHdQNkRtM3BCT29EVklHWlFYbXZxQk9FOVYyU3FCZTlsblVRcUZBak9SQUllQjFVcWEvdlJMbXN3VWNqZlNJN0pVQWgvL3ZKZVBvbUxGUVFYcjZVSUE5dnpsaDgxVVNjaXZHUXlnOVQydWRNS202RTdveXQvcVpGam1DdUlYSFlkR1FCdkxrK01oaEErVmh2NlM2a0FkSU5veWRGUlZTdXpnU25zT25wcUJxb21oRWZaNkdmb1dsaHM5UUFadXRmeUgzdkxRT0hQeXc1TEZLbUE3dnpOTTJmMkc2dGZaZGR1Q2dnT2gydmZCUnh1ZmJSdStHN2VGSGtLdnVoOW16ekQ3YUF2Z3BRbldKakdrai9paGcyR1EyQVlBWWkrM200SXR5V3J3Q1ZRNDZlUjJCRGpmZllQK3BOTFNnL2xKNlNmbUswd204R2cvL2ZpbVBHODF4alcrckdIQkczUTV4U1JnWnlUcmt0TEFBWlY0VndJOENzdTlmSUNlT0tTYm9UVTVrOFJoWnZRS0pUNnhGS1d1K0l3OTVoWUlUZUdmVGxLa1NtdW93WmVXcFI1TjIyQUEyWENaVWVqVVBLdHlQWUYxOVNGTjRjUDlvTURuUng3bkxkY3B6cGE3QmJWQm1jRGNhTENZVW1PeXJKcDQwK1hoekJHVmlxVnBJTS9qNTJJQTg1TSt1TDVtM0xNUk12UFc4cEliNkpVYVlKV0FXSldWV3JKdlpzUGJrdmh3T0NlOXo5VWJUTXE1WUJzOC9OcFJnN0F4L2lmSTJ5ZHdxbDRacXd4N29MM0ZrK0daK1FDeHRyTWJSK2oxTzhROXFXOEJ5eEcveXFBQWdDazlzckhwaG51UnpTck95M1JmZzRYa0lndHhlb3ArUmZJOVgyaDBRcEVmcjgzYzExd0xhQkxDUmgwMlFXazA2Ty8yM2s2cWZNZHBxNVZ2b0ZnTkNJYlY1V01sSFpaV3RnVXFzaGtXRVJycjduZnVvd1BQQ0NUaHdxMC9tbUQ1NDVDb0VNWU16bUtQYlIyYmF4RkVTbUswTlRRT3VWR3A2Y3JqNWlYOGxzaU9kZ3FVNHhuSVpRcDRsT1lJcTlBOUhFS3NZZ1RuYysxRlRNazJEN05ydThlalh4UUR3amFqUTFNTmJ5cldBS0MvZ3RTWW9ONTFKY25FWFlUOWI1MVZOWWF4anArTE9oeDA0M3RNUW9TejNvN1kxbWtORlJUTmJMZWhDKzV0UkNKNjdQYk5Va29DbWxXbjFYODZxVlVsRFU0MjkxaXVLaE1YQ0lsVHlscGU2dw==";
611 const SHARE_PASSWORD: &str = "mnemonic share password";
612
613 fn get_user_repo() -> (&'static EncryptionPin, UserRepoT) {
614 let mut repo = Box::new(UserRepoImpl::new(MemoryUserStorage::new())) as UserRepoT;
615 repo.create(&crate::types::users::UserEntity {
616 user_id: None,
617 username: USERNAME.to_string(),
618 encrypted_password: Some(ENCRYPTED_WALLET_PASSWORD.clone()),
619 salt: SALT.into(),
620 is_kyc_verified: false,
621 kyc_type: KycType::Undefined,
622 viviswap_state: None,
623 local_share: None,
624 wallet_transactions: Vec::new(),
625 })
626 .unwrap();
627
628 (&PIN, repo)
629 }
630
631 #[rstest]
632 #[case(MNEMONIC, true)] #[case("", false)] #[case(MNEMONIC_INCORRECT, false)] #[tokio::test]
636 async fn test_create_wallet_from_mnemonic(#[case] mnemonic: &str, #[case] should_succeed: bool) {
637 let (config, _cleanup) = Config::new_test_with_cleanup();
639 let mut manager = WalletManagerImpl::new(USERNAME);
640 let (pin, mut repo) = get_user_repo();
641
642 let result = manager
644 .create_wallet_from_existing_mnemonic(&config, &None, &mut repo, pin, mnemonic)
645 .await;
646
647 if should_succeed {
649 result.expect("should create wallet from valid mnemonic");
650 assert!(manager.recovery_share.is_some(), "Wallet was successfully created");
651 } else {
652 result.expect_err("should fail with invalid or empty mnemonic");
653 }
654 }
655
656 #[rstest]
657 #[case(&WALLET_PASSWORD, Ok(()))]
658 #[case(&INVALID_BACKUP_PASSWORD, Err(WalletError::KdbxStorage(KdbxStorageError::UnlockError(UnlockError::HmacInvalid))))]
659 #[tokio::test]
660 async fn test_backup_and_restore(#[case] password: &LazyLock<PlainPassword>, #[case] should_succeed: Result<()>) {
661 let (config, _cleanup) = Config::new_test_with_cleanup();
663 let mut manager = WalletManagerImpl::new(USERNAME);
664 let (pin, mut repo) = get_user_repo();
665
666 manager
668 .create_wallet_from_new_mnemonic(&config, &None, &mut repo, pin)
669 .await
670 .expect("failed to create new wallet");
671
672 let backup = manager
674 .create_wallet_backup(&config, &None, &mut repo, pin, &WALLET_PASSWORD)
675 .await
676 .expect("failed to create backup");
677
678 let restore_result = manager
680 .create_wallet_from_backup(&config, &None, &mut repo, pin, &backup, password)
681 .await;
682
683 match should_succeed {
685 Ok(_) => restore_result.unwrap(),
686 Err(ref expected_err) => {
687 assert_eq!(restore_result.err().unwrap().to_string(), expected_err.to_string());
688 }
689 }
690 }
691
692 #[tokio::test]
693 async fn test_change_password() {
694 let (mut config, _cleanup) = Config::new_test_with_cleanup();
696 let mut manager = WalletManagerImpl::new(USERNAME);
697 let (pin, mut repo) = get_user_repo();
698
699 manager
701 .create_wallet_from_new_mnemonic(&config, &None, &mut repo, pin)
702 .await
703 .expect("should succeed to create new wallet");
704
705 let new_password = PlainPassword::try_from_string("new_correcthorsebatterystaple").unwrap();
706 manager
707 .change_wallet_password(&config, &None, &mut repo, pin, &new_password)
708 .await
709 .expect("should succeed to change wallet password");
710
711 let wallet = manager
712 .try_get(
713 &mut config,
714 &None,
715 &mut repo,
716 &example_api_network(IOTA_NETWORK_KEY.to_string()),
717 pin,
718 &Default::default(),
719 )
720 .await
721 .expect("should succeed to get wallet after password change");
722
723 wallet.get_address().await.expect("wallet should return an address");
724 }
725
726 #[tokio::test]
727 async fn delete_wallet_removes_files() {
728 let (mut config, _cleanup) = Config::new_test_with_cleanup();
730 let mut manager = WalletManagerImpl::new(USERNAME);
731 let (pin, mut repo) = get_user_repo();
732
733 let file_count_before = walkdir::WalkDir::new(&config.path_prefix).into_iter().count();
734
735 manager
737 .create_wallet_from_new_mnemonic(&config, &None, &mut repo, pin)
738 .await
739 .expect("should succeed to create new wallet");
740
741 let _wallet = manager
743 .try_get(
744 &mut config,
745 &None,
746 &mut repo,
747 &example_api_network(IOTA_NETWORK_KEY.to_string()),
748 pin,
749 &Default::default(),
750 )
751 .await
752 .expect("should succeed to get wallet");
753
754 manager
756 .delete_wallet(&config, &None, &mut repo)
757 .await
758 .expect("should delete wallet");
759
760 let file_count_after = walkdir::WalkDir::new(&config.path_prefix).into_iter().count();
762
763 assert_eq!(file_count_before, file_count_after, "should not leave files behind");
764 }
765
766 #[rstest::rstest]
768 #[case(
771 None,
772 None,
773 None,
774 None,
775 None,
776 Err(WalletError::WalletNotInitialized(ErrorKind::UseMnemonic))
777 )]
778 #[case(
780 None,
781 None,
782 Some(SHARE_RECOVERY),
783 None,
784 None,
785 Err(WalletError::WalletNotInitialized(ErrorKind::UseMnemonic))
786 )]
787 #[case(
789 None,
790 None,
791 None,
792 Some(SHARE_RECOVERY),
793 None,
794 Err(WalletError::WalletNotInitialized(ErrorKind::SetRecoveryShare))
795 )]
796 #[case(
798 None,
799 Some(SHARE_RECOVERY),
800 None,
801 Some(SHARE_BACKUP),
802 None,
803 Err(WalletError::WalletNotInitialized(ErrorKind::MissingPassword))
804 )]
805 #[case(
807 None,
808 Some(SHARE_RECOVERY),
809 None,
810 Some(SHARE_BACKUP),
811 Some(SHARE_PASSWORD),
812 Ok(Status {local: false, recovery: Some(RecoveryUsed::Local), backup: true })
813 )]
814 #[case(
816 None,
817 None,
818 Some(SHARE_RECOVERY),
819 Some(SHARE_BACKUP),
820 None,
821 Err(WalletError::WalletNotInitialized(ErrorKind::MissingPassword))
822 )]
823 #[case(
825 None,
826 None,
827 Some(SHARE_RECOVERY),
828 Some(SHARE_BACKUP),
829 Some(SHARE_PASSWORD),
830 Ok(Status{local: false, recovery: Some(RecoveryUsed::Remote), backup: true })
831 )]
832 #[case(
834 Some(SHARE_LOCAL),
835 None,
836 None,
837 None,
838 None,
839 Err(WalletError::WalletNotInitialized(ErrorKind::SetRecoveryShare))
840 )]
841 #[case(Some(SHARE_LOCAL), Some(SHARE_RECOVERY), None, None, None, Ok(Status{local: true, recovery: Some(RecoveryUsed::Local), backup: false }))]
842 #[case(Some(SHARE_LOCAL), None, Some(SHARE_RECOVERY), None, None, Ok(Status{local: true, recovery: Some(RecoveryUsed::Remote), backup: false}))]
843 #[case(
844 Some(SHARE_LOCAL),
845 None,
846 None,
847 Some(SHARE_RECOVERY),
848 None,
849 Err(WalletError::WalletNotInitialized(ErrorKind::MissingPassword))
850 )]
851 #[case(
852 Some(SHARE_LOCAL),
853 None,
854 None,
855 Some(SHARE_RECOVERY),
856 Some(SHARE_PASSWORD),
857 Ok(Status{local: true, recovery: None, backup: true})
858 )]
859 #[tokio::test]
860 async fn test_resemble_shares(
861 #[case] local_share: Option<&str>,
862 #[case] local_recovery_share: Option<&str>,
863 #[case] remote_recovery_share: Option<&str>,
864 #[case] remote_backup_share: Option<&str>,
865 #[case] password: Option<&str>,
866 #[case] expected_result: Result<Status>,
867 ) {
868 use crate::{
869 share::Share,
870 wallet_manager::{WalletManager, WalletManagerImpl},
871 };
872
873 let mut srv = mockito::Server::new_async().await;
875 let url = format!("{}/api", srv.url());
876
877 let m1_body = remote_recovery_share.map(|s| format!("{{ \"share\":\"{s}\" }}"));
878 let m1 = srv
879 .mock("GET", "/api/user/shares/recovery")
880 .expect(if local_recovery_share.is_none() {
881 1 + if expected_result.is_ok() { 1 } else { 0 } } else {
883 0
884 }) .with_status(if remote_recovery_share.is_some() { 200 } else { 404 })
886 .with_header("content-type", "application/json")
887 .with_body(m1_body.unwrap_or_default())
888 .create();
889
890 let m2_body = remote_backup_share.map(|s| format!("{{ \"share\":\"{s}\" }}"));
891 let m2 = srv
892 .mock("GET", "/api/user/shares/backup")
893 .expect(
894 if local_share.is_some() as usize
896 + local_recovery_share.is_some() as usize
897 + remote_recovery_share.is_some() as usize
898 >= 2
899 {
900 0
901 } else {
902 1 + if expected_result.is_ok() { 1 } else { 0 } },
904 )
905 .with_status(if remote_backup_share.is_some() { 200 } else { 404 })
906 .with_header("content-type", "application/json")
907 .with_body(m2_body.unwrap_or_default())
908 .create();
909
910 let (mut config, _cleanup) = Config::new_test_with_cleanup_url(&url);
913
914 let access_token = Some(AccessToken::try_from_string("a fake token").unwrap());
915 let mut repo = MockUserRepo::new();
916
917 let mut manager = WalletManagerImpl::new("share_user");
918
919 let salt = EncryptionSalt::generate();
922 let pin = EncryptionPin::try_from_string("123456").unwrap();
923 let encrypted_password =
924 password.map(|s| PlainPassword::try_from_string(s).unwrap().encrypt(&pin, &salt).unwrap());
925 let user = crate::types::users::UserEntity {
926 user_id: None,
927 username: "share_user".to_string(),
928 encrypted_password,
929 salt,
930 is_kyc_verified: true,
931 kyc_type: KycType::Undefined,
932 viviswap_state: None,
933 local_share: local_share.map(|s| s.to_string()),
934 wallet_transactions: Vec::new(),
935 };
936
937 repo.expect_get().returning(move |_| Ok(user.clone()));
938
939 manager.recovery_share = local_recovery_share.map(|s| s.parse::<Share>().unwrap());
940
941 if expected_result.is_ok() && local_share.is_none() {
944 repo.expect_set_local_share().returning(|_, _| Ok(()));
945 }
946 let mut repo = Box::new(repo) as UserRepoT;
947
948 let result = manager
950 .try_resemble_shares(&config, &access_token, &mut repo, &pin)
951 .await
952 .map(|(_mnemonic, status)| status);
953
954 match (&result, expected_result) {
956 (Ok(s), Ok(s2)) => assert_eq!(s, &s2),
957 (Err(WalletError::WalletNotInitialized(k)), Err(WalletError::WalletNotInitialized(k2))) => {
958 assert_eq!(*k, k2)
959 }
960 (other, other2) => panic!("Expected {other2:?} but got {other:?}"),
961 }
962
963 if result.is_ok() {
965 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
966
967 let wallet = manager
968 .try_get(
969 &mut config,
970 &access_token,
971 &mut repo,
972 &example_api_network(IOTA_NETWORK_KEY.to_string()),
973 &pin,
974 &Default::default(),
975 )
976 .await
977 .unwrap();
978
979 let address = wallet.get_address().await.unwrap();
980 let balance = wallet.get_balance().await.unwrap();
981 println!("Recevier address: {address}, balance = {balance:?}");
982
983 }
987
988 m1.assert();
989 m2.assert();
990 }
991}