etopay_sdk/wallet/
wallet_manager.rs

1//! This module contains the definition and implementation of the WalletManager trait.
2
3use 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
17/// Represents borrowing a [`WalletUser`] instance with a lifetime connected to the wallet manager.
18/// This prevents wallets to be stored and used later by another user.
19pub struct WalletBorrow<'a> {
20    inner: Box<dyn WalletUser + Send + Sync>,
21    /// with this we "attach" a lifetime to this object even though it is not "needed"
22    _lifetime: std::marker::PhantomData<&'a ()>,
23}
24
25#[cfg(test)]
26impl WalletBorrow<'_> {
27    /// test function to create [`WalletBorrow`] instances in mock objects
28    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/// Creates a wallet and returns an instance to work upon
50#[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    /// Get the recovery share
55    fn get_recovery_share(&self) -> Option<Share>;
56
57    /// Set the recovery share
58    fn set_recovery_share(&mut self, share: Option<Share>);
59
60    /// Generate a new mnemonic and create shares. Returns the new mnemonic.
61    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    /// Create shares from a mnemonic
70    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    /// Create shares from a kdbx backup byte stream
80    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    /// Create kdbx backup bytes from shares
91    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    /// deletes the user's wallet
101    async fn delete_wallet(
102        &mut self,
103        config: &Config,
104        access_token: &Option<AccessToken>,
105        repo: &mut UserRepoT,
106    ) -> Result<()>;
107
108    /// Checks if the mnemonic resembled by the shares is the same as the provided mnemonic.
109    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    /// Changes the password of the existing wallet by re-encrypting the backup share
119    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    /// Tries to instantiate a [`WalletUser`] object from shares and/or returns a mutable reference bound to
129    /// the lifetime of this object. The same instance may be reused across several calls to
130    /// `try_get`, hence the lifetime is bound to the lifetime of `self`.
131    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/// Implementation of [`WalletManager`] that uses the SSS schema to store and retrieve the mnemonic
143/// and create implementations of [`WalletUser`].
144#[derive(Debug)]
145pub struct WalletManagerImpl {
146    /// the name of the user we are creating wallets for
147    username: String,
148
149    /// The recovery share that the user should download
150    pub recovery_share: Option<Share>,
151}
152
153#[derive(Debug, PartialEq)]
154struct Status {
155    /// if the local share was used.
156    local: bool,
157    /// how the recovery share was used, or [`None`] if it was not used.
158    recovery: Option<RecoveryUsed>,
159    /// if the remote backup share was used.
160    backup: bool,
161}
162
163/// Which recovery share that was used.
164#[derive(Debug, PartialEq)]
165enum RecoveryUsed {
166    /// Recovery share stored locally was used
167    Local,
168    /// Recovery share stored remotely in the backend was used
169    Remote,
170}
171
172impl WalletManagerImpl {
173    /// Create a new [`WalletManagerImpl`] from a username.
174    pub fn new(username: impl Into<String>) -> Self {
175        Self {
176            username: username.into(),
177            recovery_share: None,
178        }
179    }
180
181    // fn for getting the mnemonic
182    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        // make sure the provided pin is valid (even though we currenctly do not need it unless
197        // decrypting some of the shares, but in the future we might want to encrypt the local
198        // share too, for example)
199        // let _ = user
200        //     .encrypted_password
201        //     .as_ref()
202        //     .ok_or(crate::Error::WalletNotInitialized(crate::WalletNotInitializedKind::MissingPassword))?
203        //     .decrypt(pin, &user.salt)?;
204
205        // check the availability of each share (in priority order of ease-of-use and availability)
206
207        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        // in case of success we need to keep track of the share states
212        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            // try getting the oauth share (not encrypted)
231            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; // this share is now available
236                        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 we have less than two, we should try to get the backup share
248        // this is the last resort since it requires the password
249        if available_shares.len() < 2 {
250            if let Some(access_token) = &access_token {
251                // try to get it from the backend
252                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        // done, no need to leave the variables mutable anymore
268        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            // enough shares are available!
281
282            // if the password is required, we need to try to get it or return an error
283            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            // now we can finally try to recreate the mnemonic from the shares
297            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                // create the shares again, and just use a random password since we are not interested
306                // in the backup share anyways (which is the only reason this needs a password)
307                let shares = crate::share::create_shares_from_mnemonic(
308                    &mnemonic,
309                    &SecretBox::new(String::from("dummy password").as_bytes().into()),
310                )?;
311
312                // ignore the error since we were still able to create a valid wallet
313                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            // there is no way to recover the shares
332            Err(WalletError::WalletNotInitialized(ErrorKind::UseMnemonic))
333        }
334    }
335
336    /// Creates shares from the provided mnemonic and stores the local share locally, uploads the other
337    /// shares to the backend and returns the recovery share for the user to download and save.
338    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        // get the password from the repo
349        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    /// Generate a new mnemonic and create shares. Returns the new mnemonic.
382    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    /// Create shares from a mnemonic
404    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    /// Create shares from a kdbx backup byte stream
418    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    /// Create kdbx backup bytes from shares
433    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        // remove the wallet folder
456        #[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        // clear the local and recovery share
465        repo.set_local_share(&self.username, None)?;
466        self.recovery_share = None;
467
468        // call backend if access_token exists
469        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        // first use the existing pin and stored (encrypted) password to resemble the shares into
485        // the mnemonic
486        let (existing_mnemonic, _status) = self.try_resemble_shares(config, access_token, repo, pin).await?;
487
488        // perform a str-str comparison
489        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        // first use the existing pin and stored (encrypted) password to try resemble the shares into the mnemonic
501        let result = self.try_resemble_shares(config, access_token, repo, pin).await;
502
503        // if there was a real error (not only a missing wallet), propagate it,
504        // otherwise we want to goahead and update the repo
505        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        // now update the password in the repo (perhaps a bit hacky... xD)
514        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        // and if we need to reconstruct the shares, do it!
522        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        // we have the mnemonic and can now instantiate the WalletImpl
542        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    // share strings to use for testing the resemble-function
608    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)] // Valid mnemonic
633    #[case("", false)] // Empty mnemonic
634    #[case(MNEMONIC_INCORRECT, false)] // Incorrect mnemonic
635    #[tokio::test]
636    async fn test_create_wallet_from_mnemonic(#[case] mnemonic: &str, #[case] should_succeed: bool) {
637        // Arrange
638        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        // Act
643        let result = manager
644            .create_wallet_from_existing_mnemonic(&config, &None, &mut repo, pin, mnemonic)
645            .await;
646
647        // Assert
648        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        // Arrange
662        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        // Create wallet
667        manager
668            .create_wallet_from_new_mnemonic(&config, &None, &mut repo, pin)
669            .await
670            .expect("failed to create new wallet");
671
672        // Create backup
673        let backup = manager
674            .create_wallet_backup(&config, &None, &mut repo, pin, &WALLET_PASSWORD)
675            .await
676            .expect("failed to create backup");
677
678        // Backup restoration
679        let restore_result = manager
680            .create_wallet_from_backup(&config, &None, &mut repo, pin, &backup, password)
681            .await;
682
683        // Assert
684        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        //Arrange
695        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        // create a wallet
700        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        //Arrange
729        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        // create a wallet
736        manager
737            .create_wallet_from_new_mnemonic(&config, &None, &mut repo, pin)
738            .await
739            .expect("should succeed to create new wallet");
740
741        // get the wallet instance to make sure any files are created
742        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        //Act
755        manager
756            .delete_wallet(&config, &None, &mut repo)
757            .await
758            .expect("should delete wallet");
759
760        // Assert
761        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    // Note: all test cases assume that there is no password stored in the user database (since a wallet was never created before)
767    #[rstest::rstest]
768    // ############### Test cases without the local storage available ###############
769    // nothing available at all
770    #[case(
771        None,
772        None,
773        None,
774        None,
775        None,
776        Err(WalletError::WalletNotInitialized(ErrorKind::UseMnemonic))
777    )]
778    // only recovery share available
779    #[case(
780        None,
781        None,
782        Some(SHARE_RECOVERY),
783        None,
784        None,
785        Err(WalletError::WalletNotInitialized(ErrorKind::UseMnemonic))
786    )]
787    // only backup share available
788    #[case(
789        None,
790        None,
791        None,
792        Some(SHARE_RECOVERY),
793        None,
794        Err(WalletError::WalletNotInitialized(ErrorKind::SetRecoveryShare))
795    )]
796    // local recovery and backup share available, but no password
797    #[case(
798        None,
799        Some(SHARE_RECOVERY),
800        None,
801        Some(SHARE_BACKUP),
802        None,
803        Err(WalletError::WalletNotInitialized(ErrorKind::MissingPassword))
804    )]
805    // local recovery and backup share available, and password
806    #[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    // recovery and backup available but no password provided
815    #[case(
816        None,
817        None,
818        Some(SHARE_RECOVERY),
819        Some(SHARE_BACKUP),
820        None,
821        Err(WalletError::WalletNotInitialized(ErrorKind::MissingPassword))
822    )]
823    // recovery and backup available and password provided
824    #[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    // ############### Test cases with the local storage available ###############
833    #[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        // setup the sdk
874        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 } // if Ok we try to create wallet
882            } else {
883                0
884            }) // do not download the recovery share if it exists locally
885            .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                // only expect a call if we don't have enough shares from the others
895                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 } // if Ok we try to create wallet
903                },
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        // Initialize your Sdk instance with necessary parameters
911
912        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        // setup shares if provided
920
921        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 the expected result is OK and there was no local share from the beginning, we expect
942        // the local share to be set to a valid share.
943        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        // Function you want to test
949        let result = manager
950            .try_resemble_shares(&config, &access_token, &mut repo, &pin)
951            .await
952            .map(|(_mnemonic, status)| status);
953
954        // Assert
955        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 the result is Ok, make sure we have access to a valid wallet
964        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            // This check is nice, but does not play nice with nextest running the tests in parallel
984            // from the CI, commented out for now.
985            // assert!(balance.base_coin() > 0);
986        }
987
988        m1.assert();
989        m2.assert();
990    }
991}