rusk_wallet/
wallet.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4//
5// Copyright (c) DUSK NETWORK. All rights reserved.
6
7mod address;
8mod file;
9mod transaction;
10
11pub use address::{Address, Profile};
12pub use file::{SecureWalletFile, WalletPath};
13
14use std::fmt::Debug;
15use std::fs;
16use std::path::{Path, PathBuf};
17
18use bip39::{Language, Mnemonic, Seed};
19use dusk_bytes::Serializable;
20use dusk_core::abi::CONTRACT_ID_BYTES;
21use dusk_core::signatures::bls::{
22    PublicKey as BlsPublicKey, SecretKey as BlsSecretKey,
23};
24use dusk_core::stake::StakeData;
25use dusk_core::transfer::phoenix::{
26    Note, NoteLeaf, PublicKey as PhoenixPublicKey,
27    SecretKey as PhoenixSecretKey, ViewKey as PhoenixViewKey,
28};
29use dusk_core::BlsScalar;
30use serde::Serialize;
31use wallet_core::prelude::keys::{
32    derive_bls_pk, derive_bls_sk, derive_phoenix_pk, derive_phoenix_sk,
33    derive_phoenix_vk,
34};
35use wallet_core::{phoenix_balance, BalanceInfo};
36use zeroize::Zeroize;
37
38use crate::clients::State;
39use crate::crypto::encrypt;
40use crate::currency::Dusk;
41use crate::dat::{
42    self, version_bytes, DatFileVersion, FILE_TYPE, LATEST_VERSION, MAGIC,
43    RESERVED,
44};
45use crate::gas::MempoolGasPrices;
46use crate::rues::RuesHttpClient;
47use crate::store::LocalStore;
48use crate::Error;
49
50/// The interface to the Dusk Network
51///
52/// The Wallet exposes all methods available to interact with the Dusk Network.
53///
54/// A new [`Wallet`] can be created from a bip39-compatible mnemonic phrase or
55/// an existing wallet file.
56///
57/// The user can generate as many [`Profile`] as needed without an active
58/// connection to the network by calling [`Wallet::new_address`] repeatedly.
59///
60/// A wallet must connect to the network using a [`RuskEndpoint`] in order to be
61/// able to perform common operations such as checking balance, transfernig
62/// funds, or staking Dusk.
63pub struct Wallet<F: SecureWalletFile + Debug> {
64    profiles: Vec<Profile>,
65    state: Option<State>,
66    store: LocalStore,
67    file: Option<F>,
68    file_version: Option<DatFileVersion>,
69}
70
71impl<F: SecureWalletFile + Debug> Wallet<F> {
72    /// Returns the file used for the wallet
73    pub fn file(&self) -> &Option<F> {
74        &self.file
75    }
76}
77
78impl<F: SecureWalletFile + Debug> Wallet<F> {
79    /// Creates a new wallet instance deriving its seed from a valid BIP39
80    /// mnemonic
81    pub fn new<P>(phrase: P) -> Result<Self, Error>
82    where
83        P: Into<String>,
84    {
85        // generate mnemonic
86        let phrase: String = phrase.into();
87        let try_mnem = Mnemonic::from_phrase(&phrase, Language::English);
88
89        if let Ok(mnemonic) = try_mnem {
90            // derive the mnemonic seed
91            let seed = Seed::new(&mnemonic, "");
92            // Takes the mnemonic seed as bytes
93            let seed_bytes = seed
94                .as_bytes()
95                .try_into()
96                .map_err(|_| Error::InvalidMnemonicPhrase)?;
97
98            // Generate the default address at index 0
99            let profiles = vec![Profile {
100                shielded_addr: derive_phoenix_pk(&seed_bytes, 0),
101                public_addr: derive_bls_pk(&seed_bytes, 0),
102            }];
103
104            // return new wallet instance
105            Ok(Wallet {
106                profiles,
107                state: None,
108                store: LocalStore::from(seed_bytes),
109                file: None,
110                file_version: None,
111            })
112        } else {
113            Err(Error::InvalidMnemonicPhrase)
114        }
115    }
116
117    /// Loads wallet given a session
118    pub fn from_file(file: F) -> Result<Self, Error> {
119        let path = file.path();
120        let pwd = file.pwd();
121
122        // make sure file exists
123        let pb = path.inner().clone();
124        if !pb.is_file() {
125            return Err(Error::WalletFileMissing);
126        }
127
128        // attempt to load and decode wallet
129        let bytes = fs::read(&pb)?;
130
131        let file_version = dat::check_version(bytes.get(0..12))?;
132
133        let (seed, address_count) =
134            dat::get_seed_and_address(file_version, bytes, pwd)?;
135
136        // return early if its legacy
137        if let DatFileVersion::Legacy = file_version {
138            // Generate the default address at index 0
139            let profiles = vec![Profile {
140                shielded_addr: derive_phoenix_pk(&seed, 0),
141                public_addr: derive_bls_pk(&seed, 0),
142            }];
143
144            // return the store
145            return Ok(Self {
146                profiles,
147                store: LocalStore::from(seed),
148                state: None,
149                file: Some(file),
150                file_version: Some(DatFileVersion::Legacy),
151            });
152        }
153
154        let profiles: Vec<_> = (0..address_count)
155            .map(|i| Profile {
156                shielded_addr: derive_phoenix_pk(&seed, i),
157                public_addr: derive_bls_pk(&seed, i),
158            })
159            .collect();
160
161        // create and return
162        Ok(Self {
163            profiles,
164            store: LocalStore::from(seed),
165            state: None,
166            file: Some(file),
167            file_version: Some(file_version),
168        })
169    }
170
171    /// Saves wallet to file from which it was loaded
172    pub fn save(&mut self) -> Result<(), Error> {
173        match &self.file {
174            Some(f) => {
175                let mut header = Vec::with_capacity(12);
176                header.extend_from_slice(&MAGIC.to_be_bytes());
177                // File type = Rusk Wallet (0x02)
178                header.extend_from_slice(&FILE_TYPE.to_be_bytes());
179                // Reserved (0x0)
180                header.extend_from_slice(&RESERVED.to_be_bytes());
181                // Version
182                header.extend_from_slice(&version_bytes(LATEST_VERSION));
183
184                // create file payload
185                let seed = self.store.get_seed();
186                let mut payload = seed.to_vec();
187
188                payload.push(self.profiles.len() as u8);
189
190                // encrypt the payload
191                payload = encrypt(&payload, f.pwd())?;
192
193                let mut content =
194                    Vec::with_capacity(header.len() + payload.len());
195
196                content.extend_from_slice(&header);
197                content.extend_from_slice(&payload);
198
199                // write the content to file
200                fs::write(&f.path().wallet, content)?;
201                Ok(())
202            }
203            None => Err(Error::WalletFileMissing),
204        }
205    }
206
207    /// Saves wallet to the provided file, changing the previous file path for
208    /// the wallet if any. Note that any subsequent calls to [`save`] will
209    /// use this new file.
210    pub fn save_to(&mut self, file: F) -> Result<(), Error> {
211        // set our new file and save
212        self.file = Some(file);
213        self.save()
214    }
215
216    /// Access the inner state of the wallet
217    pub fn state(&self) -> Result<&State, Error> {
218        if let Some(state) = self.state.as_ref() {
219            Ok(state)
220        } else {
221            Err(Error::Offline)
222        }
223    }
224
225    /// Connect the wallet to the network providing a callback for status
226    /// updates
227    pub async fn connect_with_status<S: Into<String>>(
228        &mut self,
229        rusk_addr: S,
230        prov_addr: S,
231        status: fn(&str),
232    ) -> Result<(), Error> {
233        // attempt connection
234        let http_state = RuesHttpClient::new(rusk_addr)?;
235        let http_prover = RuesHttpClient::new(prov_addr)?;
236
237        let state_status = http_state.check_connection().await;
238        let prover_status = http_prover.check_connection().await;
239
240        match (&state_status, prover_status) {
241            (Err(e),_)=> println!("Connection to Rusk Failed, some operations won't be available: {e}"),
242            (_,Err(e))=> println!("Connection to Prover Failed, some operations won't be available: {e}"),
243            _=> {},
244        }
245
246        let cache_dir = self.cache_path()?;
247
248        // create a state client
249        self.state = Some(State::new(
250            &cache_dir,
251            status,
252            http_state,
253            http_prover,
254            self.store.clone(),
255        )?);
256
257        Ok(())
258    }
259
260    /// Sync wallet state
261    pub async fn sync(&self) -> Result<(), Error> {
262        self.state()?.sync().await
263    }
264
265    /// Helper function to register for async-sync outside of connect
266    pub async fn register_sync(&mut self) -> Result<(), Error> {
267        match self.state.as_mut() {
268            Some(w) => w.register_sync().await,
269            None => Err(Error::Offline),
270        }
271    }
272
273    /// Checks if the wallet has an active connection to the network
274    pub async fn is_online(&self) -> bool {
275        if let Some(state) = &self.state {
276            state.check_connection().await
277        } else {
278            false
279        }
280    }
281
282    /// Fetches the notes from the state.
283    pub async fn get_all_notes(
284        &self,
285        profile_idx: u8,
286    ) -> Result<Vec<DecodedNote>, Error> {
287        let vk = self.derive_phoenix_vk(profile_idx);
288        let pk = self.shielded_key(profile_idx)?;
289
290        let live_notes = self.state()?.fetch_notes(pk)?;
291        let spent_notes = self.state()?.cache().spent_notes(pk)?;
292
293        let live_notes = live_notes
294            .into_iter()
295            .map(|data| (None, data.note, data.block_height));
296        let spent_notes = spent_notes.into_iter().map(
297            |(nullifier, NoteLeaf { note, block_height })| {
298                (Some(nullifier), note, block_height)
299            },
300        );
301        let history = live_notes
302            .chain(spent_notes)
303            .flat_map(
304                |(nullified_by, note, block_height)| -> Result<_, Error> {
305                    let amount = note.value(Some(&vk));
306                    if let Ok(amount) = amount {
307                        Ok(DecodedNote {
308                            note,
309                            amount,
310                            block_height,
311                            nullified_by,
312                        })
313                    } else {
314                        Err(Error::WrongViewKey)
315                    }
316                },
317            )
318            .collect();
319
320        Ok(history)
321    }
322
323    /// Get the Phoenix balance
324    pub async fn get_phoenix_balance(
325        &self,
326        profile_idx: u8,
327    ) -> Result<BalanceInfo, Error> {
328        self.sync().await?;
329
330        let notes =
331            self.state()?.fetch_notes(self.shielded_key(profile_idx)?)?;
332
333        Ok(phoenix_balance(
334            &self.derive_phoenix_vk(profile_idx),
335            notes.iter(),
336        ))
337    }
338
339    /// Get Moonlight account balance
340    pub async fn get_moonlight_balance(
341        &self,
342        profile_idx: u8,
343    ) -> Result<Dusk, Error> {
344        let pk = self.public_key(profile_idx)?;
345        let state = self.state()?;
346        let account = state.fetch_account(pk).await?;
347
348        Ok(Dusk::from(account.balance))
349    }
350
351    /// Pushes a new entry to the internal profiles vector and returns its
352    /// index.
353    pub fn add_profile(&mut self) -> u8 {
354        let seed = self.store.get_seed();
355        let index = self.profiles.len() as u8;
356        let addr = Profile {
357            shielded_addr: derive_phoenix_pk(seed, index),
358            public_addr: derive_bls_pk(seed, index),
359        };
360
361        self.profiles.push(addr);
362
363        index
364    }
365
366    /// Returns the default address for this wallet
367    pub fn default_address(&self) -> Address {
368        // TODO: let the user specify the default address using conf
369        self.default_public_address()
370    }
371
372    /// Returns the default shielded account address for this wallet
373    pub fn default_shielded_account(&self) -> Address {
374        self.shielded_account(0)
375            .expect("there to be an address at index 0")
376    }
377
378    /// Returns the default public account address for this wallet
379    pub fn default_public_address(&self) -> Address {
380        self.public_address(0)
381            .expect("there to be an address at index 0")
382    }
383
384    /// Returns the profiles that have been generated by the user
385    pub fn profiles(&self) -> &Vec<Profile> {
386        &self.profiles
387    }
388
389    /// Returns the Phoenix secret-key for a given index
390    pub(crate) fn derive_phoenix_sk(&self, index: u8) -> PhoenixSecretKey {
391        let seed = self.store.get_seed();
392        derive_phoenix_sk(seed, index)
393    }
394
395    /// Returns the Phoenix view-key for a given index
396    pub(crate) fn derive_phoenix_vk(&self, index: u8) -> PhoenixViewKey {
397        let seed = self.store.get_seed();
398        derive_phoenix_vk(seed, index)
399    }
400
401    /// get cache database path
402    pub(crate) fn cache_path(&self) -> Result<PathBuf, Error> {
403        let cache_dir = {
404            if let Some(file) = &self.file {
405                file.path().cache_dir()
406            } else {
407                return Err(Error::WalletFileMissing);
408            }
409        };
410
411        Ok(cache_dir)
412    }
413
414    /// Returns the shielded key for a given index.
415    ///
416    /// # Errors
417    /// This will error if the wallet doesn't have a profile stored for the
418    /// given index.
419    pub fn shielded_key(&self, index: u8) -> Result<&PhoenixPublicKey, Error> {
420        let index = usize::from(index);
421        if index >= self.profiles.len() {
422            return Err(Error::Unauthorized);
423        }
424
425        Ok(&self.profiles()[index].shielded_addr)
426    }
427
428    /// Returns the BLS secret-key for a given index
429    pub(crate) fn derive_bls_sk(&self, index: u8) -> BlsSecretKey {
430        let seed = self.store.get_seed();
431        derive_bls_sk(seed, index)
432    }
433
434    /// Returns the public account key for a given index.
435    ///
436    /// # Errors
437    /// This will error if the wallet doesn't have a profile stored for the
438    /// given index.
439    pub fn public_key(&self, index: u8) -> Result<&BlsPublicKey, Error> {
440        let index = usize::from(index);
441        if index >= self.profiles.len() {
442            return Err(Error::Unauthorized);
443        }
444
445        Ok(&self.profiles()[index].public_addr)
446    }
447
448    /// Returns the public account address for a given index.
449    pub fn public_address(&self, index: u8) -> Result<Address, Error> {
450        let addr = *self.public_key(index)?;
451        Ok(addr.into())
452    }
453
454    /// Returns the shielded account address for a given index.
455    pub fn shielded_account(&self, index: u8) -> Result<Address, Error> {
456        let addr = *self.shielded_key(index)?;
457        Ok(addr.into())
458    }
459
460    /// Obtains stake information for a given address.
461    pub async fn stake_info(
462        &self,
463        profile_idx: u8,
464    ) -> Result<Option<StakeData>, Error> {
465        self.state()?
466            .fetch_stake(self.public_key(profile_idx)?)
467            .await
468    }
469
470    /// Returns BLS key-pair for provisioner nodes
471    pub fn provisioner_keys(
472        &self,
473        index: u8,
474    ) -> Result<(BlsPublicKey, BlsSecretKey), Error> {
475        let pk = *self.public_key(index)?;
476        let sk = self.derive_bls_sk(index);
477
478        // make sure our internal addresses are not corrupted
479        if pk != BlsPublicKey::from(&sk) {
480            return Err(Error::Unauthorized);
481        }
482
483        Ok((pk, sk))
484    }
485
486    /// Exports BLS key-pair for provisioners in node-compatible format
487    pub fn export_provisioner_keys(
488        &self,
489        profile_idx: u8,
490        dir: &Path,
491        filename: Option<String>,
492        pwd: &[u8],
493    ) -> Result<(PathBuf, PathBuf), Error> {
494        // we're expecting a directory here
495        if !dir.is_dir() {
496            return Err(Error::NotDirectory);
497        }
498
499        // get our keys for this address
500        let keys = self.provisioner_keys(profile_idx)?;
501
502        // set up the path
503        let mut path = PathBuf::from(dir);
504        path.push(filename.unwrap_or(profile_idx.to_string()));
505
506        // export public key to disk
507        let bytes = keys.0.to_bytes();
508        fs::write(path.with_extension("cpk"), bytes)?;
509
510        // create node-compatible json structure
511        let bls = BlsKeyPair {
512            public_key_bls: keys.0.to_bytes(),
513            secret_key_bls: keys.1.to_bytes(),
514        };
515        let json = serde_json::to_string(&bls)?;
516
517        // encrypt data
518        let mut bytes = json.as_bytes().to_vec();
519        bytes = crate::crypto::encrypt(&bytes, pwd)?;
520
521        // export key-pair to disk
522        fs::write(path.with_extension("keys"), bytes)?;
523
524        Ok((path.with_extension("keys"), path.with_extension("cpk")))
525    }
526
527    /// Return the index of the address passed, returns an error if the address
528    /// is not in the wallet profiles.
529    pub fn find_index(&self, addr: &Address) -> Result<u8, Error> {
530        // check if the key is stored in our profiles, return its index if
531        // found
532        for (index, profile) in self.profiles().iter().enumerate() {
533            if match addr {
534                Address::Shielded(addr) => addr == &profile.shielded_addr,
535                Address::Public(addr) => addr == &profile.public_addr,
536            } {
537                return Ok(index as u8);
538            }
539        }
540
541        // return an error otherwise
542        Err(Error::Unauthorized)
543    }
544
545    /// Check if the address is stored in our profiles, return the address if
546    /// found
547    pub fn claim(&self, addr: Address) -> Result<Address, Error> {
548        self.find_index(&addr)?;
549        Ok(addr)
550    }
551
552    /// Generate a contract id given bytes and nonce
553    pub fn get_contract_id(
554        &self,
555        profile_idx: u8,
556        bytes: Vec<u8>,
557        nonce: u64,
558    ) -> Result<[u8; CONTRACT_ID_BYTES], Error> {
559        let owner = self.public_key(profile_idx)?.to_bytes();
560
561        let mut hasher = blake2b_simd::Params::new()
562            .hash_length(CONTRACT_ID_BYTES)
563            .to_state();
564        hasher.update(bytes.as_ref());
565        hasher.update(&nonce.to_le_bytes()[..]);
566        hasher.update(owner.as_ref());
567        hasher
568            .finalize()
569            .as_bytes()
570            .try_into()
571            .map_err(|_| Error::InvalidContractId)
572    }
573
574    /// Return the dat file version from memory or by reading the file
575    /// In order to not read the file version more than once per execution
576    pub fn get_file_version(&self) -> Result<DatFileVersion, Error> {
577        if let Some(file_version) = self.file_version {
578            Ok(file_version)
579        } else if let Some(file) = &self.file {
580            Ok(dat::read_file_version(file.path())?)
581        } else {
582            Err(Error::WalletFileMissing)
583        }
584    }
585
586    /// Check if the wallet is synced
587    pub async fn is_synced(&self) -> Result<bool, Error> {
588        let state = self.state()?;
589        let db_pos = state.cache().last_pos()?.unwrap_or(0);
590        let network_last_pos = state.fetch_num_notes().await? - 1;
591
592        Ok(network_last_pos == db_pos)
593    }
594
595    /// Erase the cache directory
596    pub fn delete_cache(&mut self) -> Result<(), Error> {
597        let path = self.cache_path()?;
598
599        std::fs::remove_dir_all(path).map_err(Error::IO)
600    }
601
602    /// Close the wallet and zeroize the seed
603    pub fn close(&mut self) {
604        self.store.inner_mut().zeroize();
605
606        // close the state if exists
607        if let Some(x) = &mut self.state {
608            x.close();
609        }
610    }
611
612    /// Get gas prices from the mempool
613    pub async fn get_mempool_gas_prices(
614        &self,
615    ) -> Result<MempoolGasPrices, Error> {
616        let client = self.state()?.client();
617
618        let response = client
619            .call("blocks", None, "gas-price", &[] as &[u8])
620            .await?;
621
622        let gas_prices: MempoolGasPrices = serde_json::from_slice(&response)?;
623
624        Ok(gas_prices)
625    }
626}
627
628/// This structs represent a Note decoded enriched with useful chain information
629pub struct DecodedNote {
630    /// The Phoenix note
631    pub note: Note,
632    /// The decoded amount
633    pub amount: u64,
634    /// The block height
635    pub block_height: u64,
636    /// Nullified by
637    pub nullified_by: Option<BlsScalar>,
638}
639
640/// BLS key-pair helper structure
641#[derive(Serialize)]
642struct BlsKeyPair {
643    #[serde(with = "base64")]
644    secret_key_bls: [u8; 32],
645    #[serde(with = "base64")]
646    public_key_bls: [u8; 96],
647}
648
649mod base64 {
650    use base64::engine::general_purpose::STANDARD as BASE64;
651    use base64::Engine;
652    use serde::{Serialize, Serializer};
653
654    pub fn serialize<S: Serializer>(v: &[u8], s: S) -> Result<S::Ok, S::Error> {
655        let base64 = BASE64.encode(v);
656        String::serialize(&base64, s)
657    }
658}
659
660#[cfg(test)]
661mod tests {
662
663    use tempfile::tempdir;
664
665    use super::*;
666
667    const TEST_ADDR: &str = "2w7fRQW23Jn9Bgm1GQW9eC2bD9U883dAwqP7HAr2F8g1syzPQaPYrxSyyVZ81yDS5C1rv9L8KjdPBsvYawSx3QCW";
668
669    #[derive(Debug, Clone)]
670    struct WalletFile {
671        path: WalletPath,
672        pwd: Vec<u8>,
673    }
674
675    impl SecureWalletFile for WalletFile {
676        fn path(&self) -> &WalletPath {
677            &self.path
678        }
679
680        fn pwd(&self) -> &[u8] {
681            &self.pwd
682        }
683    }
684
685    #[test]
686    fn wallet_basics() -> Result<(), Box<dyn std::error::Error>> {
687        // create a wallet from a mnemonic phrase
688        let mut wallet: Wallet<WalletFile> = Wallet::new("uphold stove tennis fire menu three quick apple close guilt poem garlic volcano giggle comic")?;
689
690        // check address generation
691        let default_addr = wallet.default_shielded_account();
692        let other_addr_idx = wallet.add_profile();
693        let other_addr =
694            Address::Shielded(*wallet.shielded_key(other_addr_idx)?);
695
696        assert!(format!("{default_addr}").eq(TEST_ADDR));
697        assert_ne!(default_addr, other_addr);
698        assert_eq!(wallet.profiles.len(), 2);
699
700        // create another wallet with different mnemonic
701        let wallet: Wallet<WalletFile> = Wallet::new("demise monitor elegant cradle squeeze cheap parrot venture stereo humor scout denial action receive flat")?;
702
703        // check addresses are different
704        let addr = wallet.default_shielded_account();
705        assert!(format!("{}", addr).ne(TEST_ADDR));
706
707        // attempt to create a wallet from an invalid mnemonic
708        let bad_wallet: Result<Wallet<WalletFile>, Error> =
709            Wallet::new("good luck with life");
710        assert!(bad_wallet.is_err());
711
712        Ok(())
713    }
714
715    #[test]
716    fn save_and_load() -> Result<(), Box<dyn std::error::Error>> {
717        // prepare a tmp path
718        let dir = tempdir()?;
719        let path = dir.path().join("my_wallet.dat");
720        let path = WalletPath::from(path);
721
722        // we'll need a password too
723        let pwd = blake3::hash("mypassword".as_bytes()).as_bytes().to_vec();
724
725        // create and save
726        let mut wallet: Wallet<WalletFile> = Wallet::new("uphold stove tennis fire menu three quick apple close guilt poem garlic volcano giggle comic")?;
727        let file = WalletFile { path, pwd };
728        wallet.save_to(file.clone())?;
729
730        // load from file and check
731        let loaded_wallet = Wallet::from_file(file)?;
732
733        let original_addr = wallet.default_shielded_account();
734        let loaded_addr = loaded_wallet.default_shielded_account();
735        assert!(original_addr.eq(&loaded_addr));
736
737        Ok(())
738    }
739}