diff --git a/.github/workflows/rusk_ci.yml b/.github/workflows/rusk_ci.yml index e8b20b8330..ac2ed31cc9 100644 --- a/.github/workflows/rusk_ci.yml +++ b/.github/workflows/rusk_ci.yml @@ -28,7 +28,8 @@ jobs: run-ci: - '!(web-wallet/**/*|.github/workflows/webwallet_ci.yml)' - '!(explorer/**/*|.github/workflows/explorer_ci.yml)' - predicate-quantifier: 'every' + - '!(rusk-wallet/**/*|.github/workflows/ruskwallet_ci.yml)' + predicate-quantifier: "every" analyze: needs: changes if: needs.changes.outputs.run-ci == 'true' diff --git a/.github/workflows/ruskwallet_build.yml b/.github/workflows/ruskwallet_build.yml new file mode 100644 index 0000000000..b1f892fc63 --- /dev/null +++ b/.github/workflows/ruskwallet_build.yml @@ -0,0 +1,86 @@ +name: Compile CLI wallet binaries + +on: + workflow_dispatch: + inputs: + dusk_blockchain_ref: + description: "GIT branch, ref, or SHA to checkout" + required: true + default: "main" + +defaults: + run: + shell: bash + +jobs: + build_and_publish: + name: Build rusk-wallet binaries for ${{ matrix.os }} with ${{ matrix.compiler }}. + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-20.04, ubuntu-22.04, macos-latest, macos-11, windows-latest] + compiler: [cargo] + include: + - os: ubuntu-20.04 + compiler: cargo + target: linux-x64 + + - os: ubuntu-22.04 + compiler: cargo + target: linux-x64-libssl3 + + - os: macos-latest + compiler: cargo + target: macos-intel + + - os: macos-11 + compiler: cargo + target: macos-arm64 + flags: --target=aarch64-apple-darwin + platform: aarch64-apple-darwin + + - os: windows-latest + compiler: cargo + target: windows-x64 + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.dusk_blockchain_ref }} + + - name: Install dependencies + uses: dsherret/rust-toolchain-file@v1 + + - name: Add arm target for Apple Silicon build + run: rustup target add aarch64-apple-darwin + if: ${{ matrix.os == 'macos-11' }} + + - name: Build Wallet + shell: bash + working-directory: ./ + run: ${{matrix.compiler}} b --release --verbose ${{matrix.flags}} + + - name: Get semver from wallet binary + run: | + ls -la target/release + export SEMVER=$(cargo pkgid | perl -lpe 's/.*\@(.*)/$1/') + echo "SEMVER=$SEMVER" >> $GITHUB_ENV + + - name: "Pack binaries" + run: | + mkdir rusk-wallet${{env.SEMVER}}-${{matrix.target}} + echo "Fetching changelog and readme files..." + mv target/${{matrix.platform}}/release/rusk-wallet rusk-wallet${{env.SEMVER}}-${{matrix.target}} + cp CHANGELOG.md rusk-wallet${{env.SEMVER}}-${{matrix.target}} + cp README.md rusk-wallet${{env.SEMVER}}-${{matrix.target}} + tar -czvf ruskwallet${{env.SEMVER}}-${{matrix.target}}.tar.gz rusk-wallet${{env.SEMVER}}-${{matrix.target}} + ls -la *.gz + + - name: "Upload Wallet Artifacts" + uses: actions/upload-artifact@v3 + with: + name: wallet-binaries-${{env.SEMVER}} + path: | + ./*.gz + retention-days: 5 diff --git a/.github/workflows/ruskwallet_ci.yml b/.github/workflows/ruskwallet_ci.yml new file mode 100644 index 0000000000..da41cb9144 --- /dev/null +++ b/.github/workflows/ruskwallet_ci.yml @@ -0,0 +1,96 @@ +name: rusk-wallet CI + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + +jobs: + # JOB to run change detection + changes: + runs-on: ubuntu-latest + # Required permissions + permissions: + pull-requests: read + # Set job outputs to values from filter step + outputs: + run-ci: ${{ steps.filter.outputs.run-ci }} + steps: + # For pull requests it's not necessary to checkout the code + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + run-ci: + - 'rusk-wallet/**' + - '.github/workflows/ruskwallet_ci.yml' + fmt: + name: Rustfmt + runs-on: core + steps: + - uses: actions/checkout@v4 + - uses: dsherret/rust-toolchain-file@v1 + - run: cargo fmt --all -- --check + + analyze: + name: Dusk Analyzer + runs-on: core + steps: + - uses: actions/checkout@v4 + - uses: dsherret/rust-toolchain-file@v1 + - run: cargo install --git https://github.com/dusk-network/cargo-dusk-analyzer + - run: cargo dusk-analyzer + + test_nightly-linux: + name: "[Linux] Nightly tests" + runs-on: core + + steps: + - uses: actions/checkout@v4 + - uses: dsherret/rust-toolchain-file@v1 + - run: cargo test --release + working-directory: ./rusk-wallet + - run: cargo clippy --all-features --release -- -D warnings + working-directory: ./rusk-wallet + + test_nightly-macintel: + name: "[Mac Intel] Nightly tests" + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + - uses: dsherret/rust-toolchain-file@v1 + + - name: Add arm target for Apple Silicon build + run: rustup target add aarch64-apple-darwin + + - run: cargo test --release + working-directory: ./rusk-wallet + + test_nightly-macm1: + name: "[Mac arm64] Nightly checks" + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + - uses: dsherret/rust-toolchain-file@v1 + + - name: Add arm target for Apple Silicon build + run: rustup target add aarch64-apple-darwin + + - run: cargo check --target=aarch64-apple-darwin --release + working-directory: ./rusk-wallet + + test_nightly-win: + name: "[Windows] Nightly tests" + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + - uses: dsherret/rust-toolchain-file@v1 + + - run: cargo test --release + working-directory: ./rusk-wallet diff --git a/Cargo.toml b/Cargo.toml index cdd1f29631..6188b50808 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ members = [ # Test utils "test-wallet", ] +exclude = ["rusk-wallet"] resolver = "2" [profile.dev.build-override] diff --git a/rusk-wallet/CHANGELOG.md b/rusk-wallet/CHANGELOG.md new file mode 100644 index 0000000000..78ab3438f6 --- /dev/null +++ b/rusk-wallet/CHANGELOG.md @@ -0,0 +1,641 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Fixed + +- Fix tx history to show tx created with "MAX" amount [#248] + +## [0.22.1] - 2024-3-27 + +### Added + +- Add support for `WALLET_MAX_ADDR` lower than `6` [#244] + +### Changed + +- Change rusk-wallet to wait for tx to be included + +### Fixed + +- Fix tx history to avoid useless calls [#243] + +## [0.22.0] - 2024-2-28 + +### Changed + +- Change `REQUIRED_RUSK_VERSION` to `0.7.0` +- Update unclear error message [#235] +- Change provisioner key password prompt message [#238] + +### Removed + +- Remove `stake_allow` command + +## [0.21.0] - 2023-12-30 + +### Added + +- Add `seed-file` to `create` command [#226] +- Add `name` to `export` command + +### Fixed + +- Fix `stake_allow` command [#222] + +### Changed + +- Change `dusk-wallet-core` to `0.24.0-plonk.0.16-rc.2` +- Change `DEFAULT_MAX_ADDRESSES` from 255 to 25 +- Update `requestty` from `0.4.1` to `0.5.0` [#231] + +## [0.20.1] - 2023-11-22 + +### Added + +- Add `spending_keys` to wallet impl [#218] + +## [0.20.0] - 2023-11-01 + +### Changed + +- Change `REQUIRED_RUSK_VERSION` to `0.7.0-rc` +- Change the Staking Address generation. [#214] +- Change `dusk-wallet-core` to `0.22.0-plonk.0.16` [#214] + +## [0.19.1] - 2023-10-11 + +### Added +- Add interactive stake allow [#98] +- Add optional `WALLET_MAX_ADDR` compile time env [#210] + +### Fixed + +- Fix staking address display [#204] +- Fix status overlap [#179] + +## [0.19.0] - 2023-09-20 + +### Added + +- Add balance display when offline +- Add `Wallet::sync` method for sync cache update +- Add `Wallet::register_sync` method for async cache update + +### Changed + +- Change wallet to not sync automatically +- Change spent notes to be in a different ColumnFamily +- Change `StateClient::fetch_existing_nullifiers` to return empty data. +- Change `fetch_notes` to use note's position instead of height [#190] + +### Removed + +- Remove cache sync from `StateClient::fetch_notes` +- Remove `RuskClient` struct + +### Fixed + +- Fix bug where we return early when there's no wallet file in interactive [#182] +- Fix bug where wallet file got corrupted when loading a old version and creating a new address [#198] + +## [0.18.2] - 2023-09-05 + +## [0.18.1] - 2023-09-01 + +### Fixed + +- Fix fetch_notes buffer [#176] + +## [0.18.0] - 2023-08-09 + +### Added + +- Add support for rusk HTTP request [#173] +- Add `local` network to default.config.toml [#173] + +### Changed + +- Change `config.toml` to use `http` instead of `grpc` endpoints [#173] +- Save the wallet.dat file with the new Rusk Binary Format [#165] +- Change blake3 with sha256 for password hashing for new Rusk Binary Format, + keep using blake3 for old dat file formats [#162] + +### Removed + +- Remove `grpc` support [#173] +- Remove `gql` support [#173] + +## [0.17.0] - 2023-07-19 + +### Added + +- Add `rkyv` dependency [#151] +- Add `dusk-merkle` dependency [#151] +- Add `Error::Utf8` variant [#151] +- Add `devnet` network to default config [#151] + +### Changed + +- Change `rust-toolchain` to `nightly-2023-05-22` [#151] +- Change `REQUIRED_RUSK_VERSION` to `0.6.0` [#151] +- Change `Error::Canon` variant to `Error::Rkyv` [#151] +- Populate cache database with psk(s) on state init [#158] +- Change `dusk-plonk` to `0.14.0` [#169] + +### Fixed +- Fix cache resolution for alternative networks [#151] +- Fix cache error detection [#163] + +### Removed + +- Remove `canonical` dependency [#151] + +## [0.16.0] - 2023-06-28 + +### Changed + +- Change cached Note's key to be the nullifier instead of hash [#144] +- Update cache for all the possible addresses at the same time [#144] + +## [0.15.0] - 2023-06-07 + +### Changed + +- Cache implementation now uses rocksdb instead of microkelvin [#56] + +### Fixed + +- Throw an error there when specifying a network that does not exist [#143] + +## [0.14.1] - 2023-05-17 + +### Fixed + +- Add overflow-checks to release mode [#132] + +## [0.14.0] - 2023-01-12 + +### Added + +- Add `execute` to create transaction for generic contract calls [#133] +- Add transaction history [#12] +- Add stake maturity epoch [#128] +- Add staking address display [#105] +- Add stake eligibility info [#124] + +### Fixed + +- Fix headless balance display [#123] + +## [0.13.0] - 2022-11-30 + +### Changed + +- Changed fn signature in `gas::new` to include the gas limit [#116] +- Change `request_gas_limit` fn signature to accept a gas limit option [#116] +- Change (un)stake, allow stake and withdraw default gas limits to sane defaults [#116] +- Change exported consensus keys extension to `.keys` [#114] + +## [0.12.0] - 2022-10-19 + +### Added + +- Add `default.config.toml` for the default configuration settings [#57] +- Add `settings` subcommand to show the current settings [#57] +- Add `--password` as global argument [#57] +- Add `--skip-recovery` to `create` subcommand [#57] +- Add `--file` to `restore` subcommand [#57] +- Add `Settings` type to merge `Config` (from toml) and `WalletArgs` (from CLI) [#57] +- Add `address` module +- Add `gas` module +- Add `settings` module [#57] +- Add `is_enough` method to `Gas` +- Add `Create`, `Restore` and `Settings` for both `Command` and `RunResult` enums [#72] +- Add `LogFormat` and `LogLevel` enums to enforce the set of value from args [#57] +- Add `From block` and `Last block` during fetching +- Add missing documentations +- Add `Seed` type in `store` module +- Add `stake-allow` command [#83] + +### Changed + +- Change program behavior to quit if wrong seed phrase is given [#49] +- Change program behavior to have three attempts for entering a password [#46] +- Change error handling to use the `anyhow` crate in `bin`[#87] +- Change error handling to use the `thiserror` crate in `lib`[#87] +- Change `config.toml` format [#57] +- Change from multiple wallets to one wallet for a single profile dir [#72] +- Rename `dusk` module to `currency` module +- Rename `address` subcommand to `addresses` +- Change `set_price` and `set_limit` for `Gas` to works with `Option` +- Change part of the functions to either receive the `password` or the `settings` [#57] +- Move `config` module outside `io` [#57] +- Change few UI strings +- Update rust-toolchain from `nightly-2022-02-19` to `nightly-2022-08-28` + +### Removed + +- Rename `--data-dir` argument option to `--profile` [#57] +- Remove `--wallet-name` argument option [#72] +- Remove `--network` argument option to choose the network to connect with [#57] +- Remove `interactive` subcommand [#57] +- Remove `--skip-recovery` as global argument [#57] +- Remove `--wait-for-tx` (now all the transaction wait by default) [#57] +- Remove `merge` method from `Config` in favour of `Settings` type [#57] +- Remove `Command::NotSupported` [#57] +- Rename `DEFAULT_GAS_LIMIT`, `DEFAULT_GAS_PRICE`, `MIN_GAS_LIMIT` +- Remove `Addresses` type in favour of `Vec
` +- Remove `refund-addr` arg in `withdraw` command [#86] + +### Fixed + +- Fix wrong condition involved `gas.is_enough()`[#91] +- Fix `balance` subcommand: it didn't work because the address given wasn't claimed +- Fix BLS keys exported with wrong extensions [#84] + +## [0.11.1] - 2022-08-24 + +### Added + +- Add prompt confirm_recovery_phrase to display the recovery phrase [#70] +- Add Windows terminal compatibility [#68] + +### Changed + +- Change `LoggingConfig` to be optional [#73] +- Replace `error!` macro with `eprintln!` macro [#73] +- Change `Return` to `Back` in the menu + +## [0.11.0] - 2022-08-17 + +### Added + +- New public `Wallet` struct exposing all wallet operations as library [#54] +- New `Address` type to identify and work with addresses [#54] +- Logging capabilities with customizable `log_level` and `log_type` [#11] + +### Changed + +- Project is now a public facing library [#54] +- Our reference implementation is included under `src/bin` [#54] +- UX flow is now address-based to match that of the web wallet [#59] +- Anything that's not strictly program output is redirected to stderr [#11] + +## [0.10.0] - 2022-07-06 + +### Added + +- Add `src/bin` to gather the module related to the I/O ops [#51] +- Add `autobins` to Cargo.toml to prevent bins auto discovery [#51] +- Add `[lib]` and `[[bin]]` sections to Cargo.toml to decouple bin and lib [#51] +- Add `src/bin/io` to gather all modules related to I/O [#51] +- Add `status` mod as temp workaround to make the lib compile [#51] +- Add `actions` mod with all the actions previously in `main` [#51] + +### Changed + +- Rename `src/mod.rs` to `src/lib.rs` to be compliant with 2018 edition [#51] +- Refactor `main` to be more readable [#51] +- Update imports in the code to reflect the new files structure [#51] + +## [0.9.0] - 2022-05-25 + +### Added + +- Flag `--spendable` to `Balance` command [#40] +- Flag `--reward` to `StakeInfo` command [#40] + +### Changed + +- Commands run in headless mode do not provide dynamic status updates [#40] + +## [0.8.0] - 2022-05-04 + +### Added + +- Block trait for easier blocking on futures [#32] +- Withdraw reward command [#26] + +### Changed + +- Upgraded cache implementation to use `microkelvin` instead of `rusqlite` [#32] +- Use streaming `GetNotes` call instead of `GetNotesOwnedBy` [#32] +- Enhance address validity checks on interactive mode [#28] +- Prevent exit on prepare command errors [#27] +- Adapt balance to the new State [#24] +- Rename `withdraw-stake` to `unstake` [#26] +- Introduce Dusk type for currency management [#4] + +### Fixed + +- Fix cache bug preventing adding all notes to it [#35] +- Fix address validation by parsing address first [#35] + +## [0.7.0] - 2022-04-13 + +### Added + +- Notes cache [#650] +- Settings can be loaded from a config file [#637] +- Create config file if not exists [#647] +- Notify user when defaulting configuration [#655] +- Implementation for `State`'s `fetch_block_height` [#651] +- Option to wait for transaction confirmation [#680] +- Default to TCP/IP on Windows [#6] + +### Changed + +- Export consensus public key as binary +- Interactive mode allows for directory and wallet file overriding [#630] +- Client errors implemented, Rusk error messages displayed without metadata [#629] +- Transactions from wallets with no balance are halted immediately [#631] +- Rusk and prover connections decoupled [#659] +- Use upper-case DUSK for units of measure [#672] +- Use DUSK as unit for stake and transfer [#668] + +### Fixed + +- `data_dir` can be properly overriden [#656] +- Invalid configuration should not fallback into default [#670] +- Prevent interactive process from quitting on wallet execution errors [#18] + +## [0.5.2] - 2022-03-01 + +### Added + +- Optional configuration item to specify the prover URL [#612] +- Get Stake information subcommand [#619] + +## [0.5.1] - 2022-02-26 + +### Added + +- Display progress info about transaction preparation [#600] +- Display confirmation before sending a transaction [#602] + +### Changed + +- Use hex-encoded tx hashes on user-facing messages [#597] +- Open or display explorer URL on succesful transactions [#598] + +## [0.5.0] - 2022-02-26 + +### Changed + +- Update `canonical` across the entire Rusk stack [#606] + +## [0.4.0] - 2022-02-17 + +### Changed + +- Use the Dusk denomination from `rusk-abi` [#582] + +## [0.3.1] - 2022-02-17 + +### Changed + +- Default to current wallet directory for exported keys [#574] +- Add an additional plain text file with the base58-encoded public key [#574] + +## [0.3.0] - 2022-02-17 + +### Removed + +- Stake expiration [#566] + +## [0.2.4] - 2022-02-15 + +### Added + +- Allow for headless wallet creation [#569] + +### Changed + +- TX output in wallet instead of within client impl + +## [0.2.3] - 2022-02-10 + +### Added + +- Pretty print wallet-core errors [#554] + +## [0.2.2] - 2022-02-10 + +### Changed + +- Interactive mode prevents sending txs with insufficient balance [#547] + +### Fixed + +- Panic when UDS socket is not available + +## [0.2.1] - 2022-02-09 + +### Changed + +- Default `gas_price` from 0 to 0.001 Dusk [#539] + +## [0.2.0] - 2022-02-04 + +### Added + +- Wallet file encoding version [#524] + +### Changed + +- Default to UDS transport [#520] + +## [0.1.3] - 2022-02-01 + +### Added + +- Offline mode [#499] [#507] +- Live validation to user interactive input +- Improved navigation through interactive menus +- "Pause" after command outputs for better readability + +### Fixed + +- Bad UX when creating an already existing wallet with default name + +## [0.1.2] - 2022-01-31 + +### Added + +- Enable headless mode [#495] +- Introduce interactive mode by default [#492] +- Add Export command for BLS PubKeys [#505] + +## [0.1.1] - 2022-01-27 + +### Added + +- Wallet file encryption using AES [#482] + +### Changed + +- Common `Error` struct for this crate [#479] +- Password hashing using blake3 + +### Removed + +- Recovery password + +## [0.1.0] - 2022-01-25 + +### Added + +- `rusk-wallet` crate to workspace +- Argument and command parsing, with help output +- Interactive prompts for authentication +- BIP39 mnemonic support for recovery phrase +- Implementation of `Store` trait from `wallet-core` +- Implementation of `State` and `Prover` traits from `wallet-core` + +[#248]: https://github.com/dusk-network/wallet-cli/issues/248 +[#244]: https://github.com/dusk-network/wallet-cli/issues/244 +[#243]: https://github.com/dusk-network/wallet-cli/issues/243 +[#238]: https://github.com/dusk-network/wallet-cli/issues/238 +[#235]: https://github.com/dusk-network/wallet-cli/issues/235 +[#231]: https://github.com/dusk-network/wallet-cli/issues/231 +[#226]: https://github.com/dusk-network/wallet-cli/issues/226 +[#222]: https://github.com/dusk-network/wallet-cli/issues/222 +[#218]: https://github.com/dusk-network/wallet-cli/issues/218 +[#214]: https://github.com/dusk-network/wallet-cli/issues/214 +[#210]: https://github.com/dusk-network/wallet-cli/issues/210 +[#98]: https://github.com/dusk-network/wallet-cli/issues/98 +[#179]: https://github.com/dusk-network/wallet-cli/issues/179 +[#204]: https://github.com/dusk-network/wallet-cli/issues/204 +[#182]: https://github.com/dusk-network/wallet-cli/issues/182 +[#198]: https://github.com/dusk-network/wallet-cli/issues/198 +[#176]: https://github.com/dusk-network/wallet-cli/issues/176 +[#162]: https://github.com/dusk-network/wallet-cli/issues/162 +[#163]: https://github.com/dusk-network/wallet-cli/issues/163 +[#151]: https://github.com/dusk-network/wallet-cli/issues/151 +[#144]: https://github.com/dusk-network/wallet-cli/issues/144 +[#133]: https://github.com/dusk-network/wallet-cli/issues/133 +[#132]: https://github.com/dusk-network/wallet-cli/issues/132 +[#128]: https://github.com/dusk-network/wallet-cli/issues/128 +[#105]: https://github.com/dusk-network/wallet-cli/issues/105 +[#124]: https://github.com/dusk-network/wallet-cli/issues/124 +[#123]: https://github.com/dusk-network/wallet-cli/issues/123 +[#116]: https://github.com/dusk-network/wallet-cli/issues/116 +[#114]: https://github.com/dusk-network/wallet-cli/issues/114 +[#165]: https://github.com/dusk-network/wallet-cli/issues/165 +[#49]: https://github.com/dusk-network/wallet-cli/issues/49 +[#46]: https://github.com/dusk-network/wallet-cli/issues/46 +[#87]: https://github.com/dusk-network/wallet-cli/issues/87 +[#86]: https://github.com/dusk-network/wallet-cli/issues/86 +[#83]: https://github.com/dusk-network/wallet-cli/issues/83 +[#84]: https://github.com/dusk-network/wallet-cli/issues/84 +[#72]: https://github.com/dusk-network/wallet-cli/issues/72 +[#57]: https://github.com/dusk-network/wallet-cli/issues/57 +[#70]: https://github.com/dusk-network/wallet-cli/issues/70 +[#73]: https://github.com/dusk-network/wallet-cli/issues/73 +[#68]: https://github.com/dusk-network/wallet-cli/issues/68 +[#11]: https://github.com/dusk-network/wallet-cli/issues/11 +[#59]: https://github.com/dusk-network/wallet-cli/issues/59 +[#54]: https://github.com/dusk-network/wallet-cli/issues/54 +[#51]: https://github.com/dusk-network/wallet-cli/issues/51 +[#40]: https://github.com/dusk-network/wallet-cli/issues/40 +[#35]: https://github.com/dusk-network/wallet-cli/issues/35 +[#32]: https://github.com/dusk-network/wallet-cli/issues/32 +[#28]: https://github.com/dusk-network/wallet-cli/issues/28 +[#27]: https://github.com/dusk-network/wallet-cli/issues/27 +[#26]: https://github.com/dusk-network/wallet-cli/issues/26 +[#24]: https://github.com/dusk-network/wallet-cli/issues/24 +[#18]: https://github.com/dusk-network/wallet-cli/issues/18 +[#12]: https://github.com/dusk-network/wallet-cli/issues/12 +[#6]: https://github.com/dusk-network/wallet-cli/issues/6 +[#56]: https://github.com/dusk-network/wallet-cli/issues/56 +[#143]: https://github.com/dusk-network/wallet-cli/issues/143 +[#4]: https://github.com/dusk-network/wallet-cli/issues/4 +[#680]: https://github.com/dusk-network/rusk/issues/680 +[#672]: https://github.com/dusk-network/rusk/issues/672 +[#670]: https://github.com/dusk-network/rusk/issues/670 +[#668]: https://github.com/dusk-network/rusk/issues/668 +[#659]: https://github.com/dusk-network/rusk/issues/659 +[#656]: https://github.com/dusk-network/rusk/issues/656 +[#655]: https://github.com/dusk-network/rusk/issues/655 +[#651]: https://github.com/dusk-network/rusk/issues/651 +[#650]: https://github.com/dusk-network/rusk/issues/650 +[#647]: https://github.com/dusk-network/rusk/issues/647 +[#637]: https://github.com/dusk-network/rusk/issues/637 +[#631]: https://github.com/dusk-network/rusk/issues/631 +[#630]: https://github.com/dusk-network/rusk/issues/630 +[#629]: https://github.com/dusk-network/rusk/issues/629 +[#619]: https://github.com/dusk-network/rusk/issues/619 +[#612]: https://github.com/dusk-network/rusk/issues/612 +[#606]: https://github.com/dusk-network/rusk/issues/606 +[#602]: https://github.com/dusk-network/rusk/issues/602 +[#600]: https://github.com/dusk-network/rusk/issues/600 +[#598]: https://github.com/dusk-network/rusk/issues/598 +[#597]: https://github.com/dusk-network/rusk/issues/597 +[#582]: https://github.com/dusk-network/rusk/issues/582 +[#574]: https://github.com/dusk-network/rusk/issues/574 +[#569]: https://github.com/dusk-network/rusk/issues/569 +[#566]: https://github.com/dusk-network/rusk/issues/566 +[#554]: https://github.com/dusk-network/rusk/issues/554 +[#547]: https://github.com/dusk-network/rusk/issues/547 +[#539]: https://github.com/dusk-network/rusk/issues/539 +[#520]: https://github.com/dusk-network/rusk/issues/520 +[#507]: https://github.com/dusk-network/rusk/issues/507 +[#505]: https://github.com/dusk-network/rusk/issues/505 +[#499]: https://github.com/dusk-network/rusk/issues/499 +[#495]: https://github.com/dusk-network/rusk/issues/495 +[#492]: https://github.com/dusk-network/rusk/issues/492 +[#482]: https://github.com/dusk-network/rusk/issues/482 +[#479]: https://github.com/dusk-network/rusk/issues/479 +[#158]: https://github.com/dusk-network/wallet-cli/pull/158 +[#169]: https://github.com/dusk-network/wallet-cli/pull/169 +[#173]: https://github.com/dusk-network/wallet-cli/pull/173 +[#173]: https://github.com/dusk-network/wallet-cli/pull/190 + + + +[unreleased]: https://github.com/dusk-network/wallet-cli/compare/v0.22.1...HEAD +[0.22.1]: https://github.com/dusk-network/wallet-cli/compare/v0.22.0...v0.22.1 +[0.22.0]: https://github.com/dusk-network/wallet-cli/compare/v0.21.0...v0.22.0 +[0.21.0]: https://github.com/dusk-network/wallet-cli/compare/v0.20.1...v0.21.0 +[0.20.1]: https://github.com/dusk-network/wallet-cli/compare/v0.20.0...v0.20.1 +[0.20.0]: https://github.com/dusk-network/wallet-cli/compare/v0.19.1...v0.20.0 +[0.19.1]: https://github.com/dusk-network/wallet-cli/compare/v0.19.0...v0.19.1 +[0.19.0]: https://github.com/dusk-network/wallet-cli/compare/v0.18.2...v0.19.0 +[0.18.2]: https://github.com/dusk-network/wallet-cli/compare/v0.18.1...v0.18.2 +[0.18.1]: https://github.com/dusk-network/wallet-cli/compare/v0.18.0...v0.18.1 +[0.18.0]: https://github.com/dusk-network/wallet-cli/compare/v0.17.0...v0.18.0 +[0.17.0]: https://github.com/dusk-network/wallet-cli/compare/v0.16.0...v0.17.0 +[0.16.0]: https://github.com/dusk-network/wallet-cli/compare/v0.15.0...v0.16.0 +[0.15.0]: https://github.com/dusk-network/wallet-cli/compare/v0.14.1...v0.15.0 +[0.14.1]: https://github.com/dusk-network/wallet-cli/compare/v0.14.0...v0.14.1 +[0.14.0]: https://github.com/dusk-network/wallet-cli/compare/v0.13.0...v0.14.0 +[0.13.0]: https://github.com/dusk-network/wallet-cli/compare/v0.12.0...v0.13.0 +[0.12.0]: https://github.com/dusk-network/wallet-cli/compare/v0.11.1...v0.12.0 +[0.11.1]: https://github.com/dusk-network/wallet-cli/compare/v0.11.0...v0.11.1 +[0.11.0]: https://github.com/dusk-network/wallet-cli/compare/v0.10.0...v0.11.0 +[0.10.0]: https://github.com/dusk-network/wallet-cli/compare/v0.9.0...v0.10.0 +[0.9.0]: https://github.com/dusk-network/wallet-cli/compare/v0.8.0...v0.9.0 +[0.8.0]: https://github.com/dusk-network/wallet-cli/compare/v0.7.0...v0.8.0 +[0.7.0]: https://github.com/dusk-network/wallet-cli/compare/v0.5.2...v0.7.0 +[0.5.2]: https://github.com/dusk-network/wallet-cli/compare/v0.5.1...v0.5.2 +[0.5.1]: https://github.com/dusk-network/wallet-cli/compare/v0.5.0...v0.5.1 +[0.5.0]: https://github.com/dusk-network/wallet-cli/compare/v0.4.0...v0.5.0 +[0.4.0]: https://github.com/dusk-network/wallet-cli/compare/v0.3.1...v0.4.0 +[0.3.1]: https://github.com/dusk-network/wallet-cli/compare/v0.3.0...v0.3.1 +[0.3.0]: https://github.com/dusk-network/wallet-cli/compare/v0.2.5...v0.3.0 +[0.2.5]: https://github.com/dusk-network/wallet-cli/compare/v0.2.4...v0.2.5 +[0.2.4]: https://github.com/dusk-network/wallet-cli/compare/v0.2.3...v0.2.4 +[0.2.3]: https://github.com/dusk-network/wallet-cli/compare/v0.2.2...v0.2.3 +[0.2.2]: https://github.com/dusk-network/wallet-cli/compare/v0.2.1...v0.2.2 +[0.2.1]: https://github.com/dusk-network/wallet-cli/compare/v0.2.0...v0.2.1 +[0.2.0]: https://github.com/dusk-network/wallet-cli/compare/v0.1.3...v0.2.0 +[0.1.3]: https://github.com/dusk-network/wallet-cli/compare/v0.1.2...v0.1.3 +[0.1.2]: https://github.com/dusk-network/wallet-cli/compare/v0.1.1...v0.1.2 +[0.1.1]: https://github.com/dusk-network/wallet-cli/compare/v0.1.0...v0.1.1 +[0.1.0]: https://github.com/dusk-network/wallet-cli/releases/tag/v0.1.0 diff --git a/rusk-wallet/Cargo.toml b/rusk-wallet/Cargo.toml new file mode 100644 index 0000000000..0bb2c41335 --- /dev/null +++ b/rusk-wallet/Cargo.toml @@ -0,0 +1,75 @@ +[package] +name = "dusk-wallet" +version = "0.22.1" +edition = "2021" +autobins = false +description = "A library providing functionalities to create wallets compatible with Dusk Network" +categories = ["cryptography", "cryptography::cryptocurrencies"] +keywords = ["wallet", "dusk", "cryptocurrency", "blockchain"] +repository = "https://github.com/dusk-network/wallet-cli" +license = "MPL-2.0" +exclude = [".github/*", ".gitignore"] + +[[bin]] +name = "rusk-wallet" +path = "src/bin/main.rs" + +[dependencies] +clap = { version = "3.1", features = ["derive", "env"] } +thiserror = "1.0" +anyhow = "1.0" +tokio = { version = "1.15", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +url = { version = "2", features = ["serde"] } +async-trait = "0.1" +block-modes = "0.8" +serde_json = "1.0" +hex = "0.4" +tiny-bip39 = "0.8" +crossterm = "0.23" +rand_core = "0.6" +requestty = "0.5.0" +futures = "0.3" +base64 = "0.13" +crypto = "0.3" +whoami = "1.2" +blake3 = "1.3" +sha2 = "0.10.7" +toml = "0.5" +open = "2.1" +dirs = "4.0" +bs58 = "0.4" +rand = "0.8" +aes = "0.7" +rocksdb = "0.22" +flume = "0.10.14" +reqwest = { version = "0.11", features = ["stream"] } + +dusk-wallet-core = "0.24.0-plonk.0.16-rc.2" +dusk-bytes = "0.1" +dusk-pki = "0.13" +rusk-abi = { version = "0.12.0-rc", default-features = false } +phoenix-core = { version = "0.21", features = ["alloc"] } +dusk-schnorr = { version = "0.14", default-features = false } +dusk-poseidon = "0.31" +dusk-plonk = "0.16" +dusk-bls12_381-sign = { version = "0.5", default-features = false } +ff = { version = "0.13", default-features = false } +poseidon-merkle = "0.3" + +tracing = "0.1" +tracing-subscriber = { version = "0.3.0", features = [ + "fmt", + "env-filter", + "json", +] } + +rkyv = { version = "=0.7.39", default-features = false } + +konst = "0.3" + +[dev-dependencies] +tempfile = "3.2" + +[profile.release] +overflow-checks = true diff --git a/rusk-wallet/LICENSE b/rusk-wallet/LICENSE new file mode 100644 index 0000000000..a612ad9813 --- /dev/null +++ b/rusk-wallet/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/rusk-wallet/Makefile b/rusk-wallet/Makefile new file mode 100644 index 0000000000..9e15f7268d --- /dev/null +++ b/rusk-wallet/Makefile @@ -0,0 +1,13 @@ +help: ## Display this help screen + @grep -h -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' + +build: ## Build the wallet + cargo b --release + +install: build + cargo install --path . + +test: build ## Run wallet tests + cargo test --release + +.PHONY: build test help diff --git a/rusk-wallet/README.md b/rusk-wallet/README.md new file mode 100644 index 0000000000..2faa9661e3 --- /dev/null +++ b/rusk-wallet/README.md @@ -0,0 +1,9 @@ +[![Repository](https://img.shields.io/badge/github-dusk--wallet-purple?logo=github)](https://github.com/dusk-network/wallet-cli) +![Build Status](https://github.com/dusk-network/wallet-cli/workflows/Continuous%20integration/badge.svg) +[![Documentation](https://img.shields.io/badge/docs-dusk--wallet-orange?logo=rust)](https://docs.rs/dusk-wallet/) + +# Dusk Wallet + +Library providing functionalities to create wallets compatible with Dusk Network + +This library is used to implement the official [Dusk CLI wallet](https://github.com/dusk-network/wallet-cli/blob/main/src/bin/README.md). diff --git a/rusk-wallet/default.config.toml b/rusk-wallet/default.config.toml new file mode 100644 index 0000000000..f2a9cd95d1 --- /dev/null +++ b/rusk-wallet/default.config.toml @@ -0,0 +1,12 @@ +state = "https://nodes.dusk.network" +prover = "https://provers.dusk.network" +explorer = "https://explorer.dusk.network/transactions/transaction/?id=" + +[network.devnet] +state = "https://devnet.nodes.dusk.network" +prover = "https://devnet.provers.dusk.network" +explorer = "https://explorer.dusk.network/transactions/transaction/?id=" + +[network.local] +state = "http://127.0.0.1:8080" +prover = "http://127.0.0.1:8080" diff --git a/rusk-wallet/src/bin/README.md b/rusk-wallet/src/bin/README.md new file mode 100644 index 0000000000..3a567dc20b --- /dev/null +++ b/rusk-wallet/src/bin/README.md @@ -0,0 +1,112 @@ +# Dusk Wallet CLI + +A user-friendly, reliable command line interface to the Dusk wallet! + +``` +USAGE: + rusk-wallet [OPTIONS] [SUBCOMMAND] + +OPTIONS: + -p, --profile Directory to store user data [default: `$HOME/.dusk/rusk-wallet`] + -n, --network Network to connect to + --password Set the password for wallet's creation [env: + RUSK_WALLET_PWD=password] + --state The state server fully qualified URL + --prover The prover server fully qualified URL + --log-level Output log level [default: info] [possible values: trace, debug, + info, warn, error] + --log-type Logging output type [default: coloured] [possible values: json, + plain, coloured] + -h, --help Print help information + -V, --version Print version information + +SUBCOMMANDS: + create Create a new wallet + restore Restore a lost wallet + balance Check your current balance + addresses List your existing addresses and generate new ones + history Show address transaction history + transfer Send DUSK through the network + stake Start staking DUSK + stake-info Check your stake information + unstake Unstake a key's stake + withdraw Withdraw accumulated reward for a stake key + export Export BLS provisioner key pair + settings Show current settings + help Print this message or the help of the given subcommand(s) +``` + +## Good to know + +Some commands can be run in standalone (offline) operation: + +- `create`: Create a new wallet +- `restore`: Access (and restore) a lost wallet +- `addresses`: Retrieve your addresses +- `export`: Export BLS provisioner key pair + +All other commands involve transactions, and thus require an active connection to [**Rusk**](https://github.com/dusk-network/rusk). + +## Installation + +[Install rust](https://www.rust-lang.org/tools/install) and then: + +``` +git clone git@github.com:dusk-network/wallet-cli.git +cd wallet-cli +make install +``` + +## Configuring the CLI Wallet + +You will need to connect to a running [**Rusk**](https://github.com/dusk-network/rusk) instance for full wallet capabilities. + +The default settings can be seen [here](https://github.com/dusk-network/wallet-cli/blob/main/default.config.toml). + +It's possible to override those settings by create a `config.toml` file with the same structure, in one of the following +directory: + +- The profile folder (provided via the `--profile` argument, defaults to `$HOME/.dusk/rusk-wallet/`) +- The global configuration folder (`$HOME/.config/rusk-wallet/`) + +Having the `config.toml` in the global configuration folder is useful in case of multiple wallets (each one with its own profile folder) that shares the same settings. + +If a `config.toml` exists in both locations, the one found in the profile folder will be used. + +The CLI arguments takes precedence and overrides any configuration present in the configuration file. + +**Note:** When using Windows, connection will default to TCP/IP even if UDS is explicitly specified. + +## Running the CLI Wallet + +### Interactive mode + +By default, the CLI runs in interactive mode when no arguments are provided. + +``` +rusk-wallet +``` + +### Headless mode + +Wallet can be run in headless mode by providing all the options required for a given subcommand. It is usually convenient to have a config file with the wallet settings, and then call the wallet with the desired subcommand and its options. + +To explore available options and commands, use the `help` command: + +``` +rusk-wallet help +``` + +To further explore any specific command you can use `--help` on the command itself. For example, the following command will print all the information about the `stake` subcommand: + +``` +rusk-wallet stake --help +``` + +By default, you will always be prompted to enter the wallet password. To prevent this behavior, you can provide the password using the `RUSK_WALLET_PWD` environment variable. This is useful in CI or any other headless environment. + +Please note that `RUSK_WALLET_PWD` is effectively used for: + +- Wallet decryption (in all commands that use a wallet) +- Wallet encryption (in `create`) +- BLS key encryption (in `export`) diff --git a/rusk-wallet/src/bin/command.rs b/rusk-wallet/src/bin/command.rs new file mode 100644 index 0000000000..a390669369 --- /dev/null +++ b/rusk-wallet/src/bin/command.rs @@ -0,0 +1,404 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +mod history; + +use clap::Subcommand; +use dusk_plonk::prelude::BlsScalar; +use rusk_abi::hash::Hasher; +use std::{fmt, path::PathBuf}; + +use crate::io::prompt; +use crate::settings::Settings; +use crate::{WalletFile, WalletPath}; + +use dusk_wallet::gas::{Gas, DEFAULT_LIMIT, DEFAULT_PRICE}; +use dusk_wallet::{Address, Dusk, Lux, Wallet, EPOCH, MAX_ADDRESSES}; +use dusk_wallet_core::{BalanceInfo, StakeInfo}; + +pub use history::TransactionHistory; + +/// The default stake gas limit +pub const DEFAULT_STAKE_GAS_LIMIT: u64 = 2_900_000_000; + +/// Commands that can be run against the Dusk wallet +#[allow(clippy::large_enum_variant)] +#[derive(PartialEq, Eq, Hash, Clone, Subcommand, Debug)] +pub(crate) enum Command { + /// Create a new wallet + Create { + /// Skip wallet recovery phrase (useful for headless wallet creation) + #[clap(long, action)] + skip_recovery: bool, + + /// Save recovery phrase to file (useful for headless wallet creation) + #[clap(long)] + seed_file: Option, + }, + + /// Restore a lost wallet + Restore { + /// Set the wallet .dat file to restore from + #[clap(short, long)] + file: Option, + }, + + /// Check your current balance + Balance { + /// Address + #[clap(short, long)] + addr: Option
, + + /// Check maximum spendable balance + #[clap(long)] + spendable: bool, + }, + + /// List your existing addresses and generate new ones + Addresses { + /// Create new address + #[clap(short, long, action)] + new: bool, + }, + + /// Show address transaction history + History { + /// Address for which you want to see the history + #[clap(short, long)] + addr: Option
, + }, + + /// Send DUSK through the network + Transfer { + /// Address from which to send DUSK [default: first address] + #[clap(short, long)] + sndr: Option
, + + /// Receiver address + #[clap(short, long)] + rcvr: Address, + + /// Amount of DUSK to send + #[clap(short, long)] + amt: Dusk, + + /// Max amt of gas for this transaction + #[clap(short = 'l', long, default_value_t= DEFAULT_LIMIT)] + gas_limit: u64, + + /// Price you're going to pay for each gas unit (in LUX) + #[clap(short = 'p', long, default_value_t= DEFAULT_PRICE)] + gas_price: Lux, + }, + + /// Start staking DUSK + Stake { + /// Address from which to stake DUSK [default: first address] + #[clap(short = 's', long)] + addr: Option
, + + /// Amount of DUSK to stake + #[clap(short, long)] + amt: Dusk, + + /// Max amt of gas for this transaction + #[clap(short = 'l', long, default_value_t= DEFAULT_STAKE_GAS_LIMIT)] + gas_limit: u64, + + /// Price you're going to pay for each gas unit (in LUX) + #[clap(short = 'p', long, default_value_t= DEFAULT_PRICE)] + gas_price: Lux, + }, + + /// Check your stake information + StakeInfo { + /// Address used to stake [default: first address] + #[clap(short, long)] + addr: Option
, + + /// Check accumulated reward + #[clap(long, action)] + reward: bool, + }, + + /// Unstake a key's stake + Unstake { + /// Address from which your DUSK was staked [default: first address] + #[clap(short, long)] + addr: Option
, + + /// Max amt of gas for this transaction + #[clap(short = 'l', long, default_value_t= DEFAULT_STAKE_GAS_LIMIT)] + gas_limit: u64, + + /// Price you're going to pay for each gas unit (in LUX) + #[clap(short = 'p', long, default_value_t= DEFAULT_PRICE)] + gas_price: Lux, + }, + + /// Withdraw accumulated reward for a stake key + Withdraw { + /// Address from which your DUSK was staked [default: first address] + #[clap(short, long)] + addr: Option
, + + /// Max amt of gas for this transaction + #[clap(short = 'l', long, default_value_t= DEFAULT_STAKE_GAS_LIMIT)] + gas_limit: u64, + + /// Price you're going to pay for each gas unit (in LUX) + #[clap(short = 'p', long, default_value_t= DEFAULT_PRICE)] + gas_price: Lux, + }, + + /// Export BLS provisioner key pair + Export { + /// Address for which you want the exported keys [default: first + /// address] + #[clap(short, long)] + addr: Option
, + + /// Output directory for the exported keys + #[clap(short, long)] + dir: PathBuf, + + /// Name of the files exported [default: staking-address] + #[clap(short, long)] + name: Option, + }, + + /// Show current settings + Settings, +} + +impl Command { + /// Runs the command with the provided wallet + pub async fn run( + self, + wallet: &mut Wallet, + settings: &Settings, + ) -> anyhow::Result { + match self { + Command::Balance { addr, spendable } => { + let sync_result = wallet.sync().await; + if let Err(e) = sync_result { + // Sync error should be reported only if wallet is online + if wallet.is_online().await { + tracing::error!("Unable to update the balance {e:?}") + } + } + + let addr = match addr { + Some(addr) => wallet.claim_as_address(addr)?, + None => wallet.default_address(), + }; + + let balance = wallet.get_balance(addr).await?; + Ok(RunResult::Balance(balance, spendable)) + } + Command::Addresses { new } => { + if new { + if wallet.addresses().len() >= MAX_ADDRESSES { + println!( + "Cannot create more addresses, this wallet only supports up to {MAX_ADDRESSES} addresses. You have {} addresses already.", wallet.addresses().len() + ); + std::process::exit(0); + } + + let addr = wallet.new_address().clone(); + wallet.save()?; + Ok(RunResult::Address(Box::new(addr))) + } else { + Ok(RunResult::Addresses(wallet.addresses().clone())) + } + } + Command::Transfer { + sndr, + rcvr, + amt, + gas_limit, + gas_price, + } => { + wallet.sync().await?; + let sender = match sndr { + Some(addr) => wallet.claim_as_address(addr)?, + None => wallet.default_address(), + }; + let gas = Gas::new(gas_limit).with_price(gas_price); + + let tx = wallet.transfer(sender, &rcvr, amt, gas).await?; + Ok(RunResult::Tx(Hasher::digest(tx.to_hash_input_bytes()))) + } + Command::Stake { + addr, + amt, + gas_limit, + gas_price, + } => { + wallet.sync().await?; + let addr = match addr { + Some(addr) => wallet.claim_as_address(addr)?, + None => wallet.default_address(), + }; + let gas = Gas::new(gas_limit).with_price(gas_price); + + let tx = wallet.stake(addr, amt, gas).await?; + Ok(RunResult::Tx(Hasher::digest(tx.to_hash_input_bytes()))) + } + Command::StakeInfo { addr, reward } => { + let addr = match addr { + Some(addr) => wallet.claim_as_address(addr)?, + None => wallet.default_address(), + }; + let si = wallet.stake_info(addr).await?; + Ok(RunResult::StakeInfo(si, reward)) + } + Command::Unstake { + addr, + gas_limit, + gas_price, + } => { + wallet.sync().await?; + let addr = match addr { + Some(addr) => wallet.claim_as_address(addr)?, + None => wallet.default_address(), + }; + + let gas = Gas::new(gas_limit).with_price(gas_price); + + let tx = wallet.unstake(addr, gas).await?; + Ok(RunResult::Tx(Hasher::digest(tx.to_hash_input_bytes()))) + } + Command::Withdraw { + addr, + gas_limit, + gas_price, + } => { + wallet.sync().await?; + let addr = match addr { + Some(addr) => wallet.claim_as_address(addr)?, + None => wallet.default_address(), + }; + + let gas = Gas::new(gas_limit).with_price(gas_price); + + let tx = wallet.withdraw_reward(addr, gas).await?; + Ok(RunResult::Tx(Hasher::digest(tx.to_hash_input_bytes()))) + } + Command::Export { addr, dir, name } => { + let addr = match addr { + Some(addr) => wallet.claim_as_address(addr)?, + None => wallet.default_address(), + }; + + let pwd = prompt::request_auth( + "Provide a password for your provisioner keys", + &settings.password, + wallet.get_file_version()?, + )?; + + let (pub_key, key_pair) = + wallet.export_keys(addr, &dir, name, &pwd)?; + + Ok(RunResult::ExportedKeys(pub_key, key_pair)) + } + Command::History { addr } => { + wallet.sync().await?; + let addr = match addr { + Some(addr) => wallet.claim_as_address(addr)?, + None => wallet.default_address(), + }; + let notes = wallet.get_all_notes(addr).await?; + + let transactions = + history::transaction_from_notes(settings, notes).await?; + + Ok(RunResult::History(transactions)) + } + Command::Create { .. } => Ok(RunResult::Create()), + Command::Restore { .. } => Ok(RunResult::Restore()), + Command::Settings => Ok(RunResult::Settings()), + } + } +} + +/// Possible results of running a command in interactive mode +pub enum RunResult { + Tx(BlsScalar), + Balance(BalanceInfo, bool), + StakeInfo(StakeInfo, bool), + Address(Box
), + Addresses(Vec
), + ExportedKeys(PathBuf, PathBuf), + Create(), + Restore(), + Settings(), + History(Vec), +} + +impl fmt::Display for RunResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use RunResult::*; + match self { + Balance(balance, _) => { + write!( + f, + "> Total balance is: {} DUSK\n> Maximum spendable per TX is: {} DUSK", + Dusk::from(balance.value), + Dusk::from(balance.spendable) + ) + } + Address(addr) => { + write!(f, "> {}", addr) + } + Addresses(addrs) => { + let str_addrs = addrs + .iter() + .map(|a| format!("{}", a)) + .collect::>() + .join("\n>"); + write!(f, "> {}", str_addrs) + } + Tx(hash) => { + let hash = hex::encode(hash.to_bytes()); + write!(f, "> Transaction sent: {hash}",) + } + StakeInfo(si, _) => { + let stake_str = match si.amount { + Some((value, eligibility)) => format!( + "Current stake amount is: {} DUSK\n> Stake eligibility from block #{} (Epoch {})", + Dusk::from(value), + eligibility, + eligibility / EPOCH + ), + None => "No active stake found for this key".to_string(), + }; + write!( + f, + "> {}\n> Accumulated reward is: {} DUSK", + stake_str, + Dusk::from(si.reward) + ) + } + ExportedKeys(pk, kp) => { + write!( + f, + "> Public key exported to: {}\n> Key pair exported to: {}", + pk.display(), + kp.display() + ) + } + History(transactions) => { + writeln!(f, "{}", TransactionHistory::header())?; + for th in transactions { + writeln!(f, "{th}")?; + } + Ok(()) + } + Create() | Restore() | Settings() => unreachable!(), + } + } +} diff --git a/rusk-wallet/src/bin/command/history.rs b/rusk-wallet/src/bin/command/history.rs new file mode 100644 index 0000000000..2e8e573daa --- /dev/null +++ b/rusk-wallet/src/bin/command/history.rs @@ -0,0 +1,154 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use std::collections::hash_map::Entry; +use std::collections::HashMap; +use std::fmt::{self, Display}; + +use dusk_wallet::DecodedNote; +use dusk_wallet_core::Transaction; +use rusk_abi::dusk; + +use crate::io::{self, GraphQL}; +use crate::settings::Settings; + +pub struct TransactionHistory { + direction: TransactionDirection, + height: u64, + amount: f64, + fee: u64, + pub tx: Transaction, + id: String, +} + +impl TransactionHistory { + pub fn header() -> String { + format!( + "{: ^9} | {: ^64} | {: ^8} | {: ^17} | {: ^12}", + "BLOCK", "TX_ID", "METHOD", "AMOUNT", "FEE" + ) + } +} + +impl Display for TransactionHistory { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let dusk = self.amount / dusk::dusk(1.0) as f64; + let contract = match self.tx.call() { + None => "transfer", + Some((_, method, _)) => method, + }; + + let fee = match self.direction { + TransactionDirection::In => "".into(), + TransactionDirection::Out => { + let fee = self.fee; + let fee = dusk::from_dusk(fee); + format!("{: >12.9}", fee) + } + }; + + let tx_id = &self.id; + let heigth = self.height; + + write!( + f, + "{heigth: >9} | {tx_id} | {contract: ^8} | {dusk: >+17.9} | {fee}", + ) + } +} + +pub(crate) async fn transaction_from_notes( + settings: &Settings, + mut notes: Vec, +) -> anyhow::Result> { + notes.sort_by(|a, b| a.note.pos().cmp(b.note.pos())); + let mut ret: Vec = vec![]; + let gql = GraphQL::new(settings.state.to_string(), io::status::interactive); + + let nullifiers = notes + .iter() + .flat_map(|note| { + note.nullified_by.map(|nullifier| (nullifier, note.amount)) + }) + .collect::>(); + + let mut block_txs = HashMap::new(); + + for mut decoded_note in notes { + // Set the position to max, in order to match the note with the one + // in the tx + decoded_note.note.set_pos(u64::MAX); + + let note_amount = decoded_note.amount as f64; + + let txs = match block_txs.entry(decoded_note.block_height) { + Entry::Occupied(o) => o.into_mut(), + Entry::Vacant(v) => { + let txs = gql.txs_for_block(decoded_note.block_height).await?; + v.insert(txs) + } + }; + + let note_hash = decoded_note.note.hash(); + // Looking for the transaction which created the note + let note_creator = txs.iter().find(|(t, _, _)| { + t.outputs().iter().any(|&n| n.hash().eq(¬e_hash)) + || t.nullifiers + .iter() + .any(|tx_null| nullifiers.iter().any(|(n, _)| n == tx_null)) + }); + + if let Some((t, tx_id, gas_spent)) = note_creator { + let inputs_amount: f64 = t + .nullifiers() + .iter() + .filter_map(|input| { + nullifiers.iter().find_map(|n| n.0.eq(input).then_some(n.1)) + }) + .sum::() as f64; + + let direction = match inputs_amount > 0f64 { + true => TransactionDirection::Out, + false => TransactionDirection::In, + }; + match ret.iter_mut().find(|th| &th.id == tx_id) { + Some(tx) => tx.amount += note_amount, + None => ret.push(TransactionHistory { + direction, + height: decoded_note.block_height, + amount: note_amount - inputs_amount, + fee: gas_spent * t.fee().gas_price, + tx: t.clone(), + id: tx_id.clone(), + }), + } + } else { + let outgoing_tx = ret.iter_mut().find(|th| { + th.direction == TransactionDirection::Out + && th.height == decoded_note.block_height + }); + + match outgoing_tx { + // Outgoing txs found, this should be the change or any + // other output created by the tx result + // (like withdraw or unstake) + Some(th) => th.amount += note_amount, + + // No outgoing txs found, this note should belong to a + // preconfigured genesis state + None => println!("??? val {}", note_amount), + } + } + } + ret.sort_by(|a, b| a.height.cmp(&b.height)); + Ok(ret) +} + +#[derive(PartialEq)] +enum TransactionDirection { + In, + Out, +} diff --git a/rusk-wallet/src/bin/config.rs b/rusk-wallet/src/bin/config.rs new file mode 100644 index 0000000000..6d67780377 --- /dev/null +++ b/rusk-wallet/src/bin/config.rs @@ -0,0 +1,59 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use serde::Deserialize; +use std::collections::HashMap; +use std::path::Path; +use url::Url; + +#[derive(Debug, Deserialize, Clone)] +#[allow(dead_code)] +pub(crate) struct Network { + pub(crate) state: Url, + pub(crate) prover: Url, + pub(crate) explorer: Option, + pub(crate) network: Option>, +} + +use std::{fs, io}; + +/// Config holds the settings for the CLI wallet +#[derive(Debug)] +pub struct Config { + /// Network configuration + pub(crate) network: Network, +} + +fn read_to_string>(path: P) -> io::Result> { + fs::read_to_string(&path) + .map(Some) + .or_else(|e| match e.kind() { + io::ErrorKind::NotFound => Ok(None), + _ => Err(e), + }) +} + +impl Config { + /// Attempt to load configuration from file + pub fn load(profile: &Path) -> anyhow::Result { + let profile = profile.join("config.toml"); + + let mut global_config = dirs::home_dir().expect("OS not supported"); + global_config.push(".config"); + global_config.push(env!("CARGO_BIN_NAME")); + global_config.push("config.toml"); + + let contents = read_to_string(profile)? + .or(read_to_string(&global_config)?) + .unwrap_or_else(|| { + include_str!("../../default.config.toml").to_string() + }); + + let network: Network = toml::from_str(&contents)?; + + Ok(Config { network }) + } +} diff --git a/rusk-wallet/src/bin/interactive.rs b/rusk-wallet/src/bin/interactive.rs new file mode 100644 index 0000000000..f6494ae3e4 --- /dev/null +++ b/rusk-wallet/src/bin/interactive.rs @@ -0,0 +1,482 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use bip39::{Language, Mnemonic, MnemonicType}; +use dusk_wallet::dat::{DatFileVersion, LATEST_VERSION}; +use dusk_wallet::gas; +use dusk_wallet::{Address, Dusk, Error, Wallet, WalletPath, MAX_ADDRESSES}; +use requestty::Question; + +use crate::command::DEFAULT_STAKE_GAS_LIMIT; +use crate::io; +use crate::io::prompt::request_auth; +use crate::io::GraphQL; +use crate::prompt; +use crate::settings::Settings; +use crate::Menu; +use crate::WalletFile; +use crate::{Command, RunResult}; + +/// Run the interactive UX loop with a loaded wallet +pub(crate) async fn run_loop( + wallet: &mut Wallet, + settings: &Settings, +) -> anyhow::Result<()> { + loop { + // let the user choose (or create) an address + let addr = match menu_addr(wallet)? { + AddrSelect::Address(addr) => *addr, + AddrSelect::NewAddress => { + if wallet.addresses().len() >= MAX_ADDRESSES { + println!( + "Cannot create more addresses, this wallet only supports up to {MAX_ADDRESSES} addresses" + ); + std::process::exit(0); + } + + let addr = wallet.new_address().clone(); + let file_version = wallet.get_file_version()?; + + let password = &settings.password; + // if the version file is old, ask for password and save as + // latest dat file + if file_version.is_old() { + let pwd = request_auth( + "Updating your wallet data file, please enter your wallet password ", + password, + DatFileVersion::RuskBinaryFileFormat(LATEST_VERSION), + )?; + + wallet.save_to(WalletFile { + path: wallet.file().clone().unwrap().path, + pwd, + })?; + } else { + // else just save + wallet.save()?; + } + + addr + } + AddrSelect::Exit => std::process::exit(0), + }; + + loop { + // get balance for this address + prompt::hide_cursor()?; + let balance = wallet.get_balance(&addr).await?; + let spendable: Dusk = balance.spendable.into(); + let total: Dusk = balance.value.into(); + prompt::hide_cursor()?; + + // display address information + println!(); + println!("Address: {addr}"); + println!("Balance:"); + println!(" - Spendable: {spendable}"); + println!(" - Total: {total}"); + + // request operation to perform + let op = match wallet.is_online().await { + true => menu_op(addr.clone(), spendable, settings), + false => menu_op_offline(addr.clone(), settings), + }; + + // perform operations with this address + match op? { + AddrOp::Run(cmd) => { + // request confirmation before running + if confirm(&cmd)? { + // run command + prompt::hide_cursor()?; + let result = cmd.run(wallet, settings).await; + prompt::show_cursor()?; + // output results + match result { + Ok(res) => { + println!("\r{}", res); + if let RunResult::Tx(hash) = res { + let tx_id = hex::encode(hash.to_bytes()); + + // Wait for transaction confirmation from + // network + let gql = GraphQL::new( + settings.state.to_string(), + io::status::interactive, + ); + gql.wait_for(&tx_id).await?; + + if let Some(explorer) = &settings.explorer { + let url = format!("{explorer}{tx_id}"); + println!("> URL: {url}"); + prompt::launch_explorer(url)?; + } + } + } + + Err(err) => println!("{err}"), + } + } + } + AddrOp::Back => break, + } + } + } +} + +#[derive(PartialEq, Eq, Hash, Debug, Clone)] +enum AddrSelect { + Address(Box
), + NewAddress, + Exit, +} + +/// Allows the user to choose an address from the selected wallet +/// to start performing operations. +fn menu_addr(wallet: &Wallet) -> anyhow::Result { + let mut address_menu = Menu::title("Addresses"); + for addr in wallet.addresses() { + let preview = addr.preview(); + address_menu = address_menu + .add(AddrSelect::Address(Box::new(addr.clone())), preview); + } + + let remaining_addresses = + MAX_ADDRESSES.saturating_sub(wallet.addresses().len()); + let mut action_menu = Menu::new() + .separator() + .add(AddrSelect::NewAddress, "New address"); + + // show warning if less than + if remaining_addresses < 5 { + action_menu = action_menu.separator().separator_msg(format!( + "\x1b[93m{}\x1b[0m This wallet only supports up to {MAX_ADDRESSES} addresses, you have {} addresses ", + "Warning:", + wallet.addresses().len() + )); + } + + if let Some(rx) = &wallet.sync_rx { + if let Ok(status) = rx.try_recv() { + action_menu = action_menu + .separator() + .separator_msg(format!("Sync Status: {}", status)); + } else { + action_menu = action_menu + .separator() + .separator_msg("Waiting for Sync to complete..".to_string()); + } + } + + action_menu = action_menu.separator().add(AddrSelect::Exit, "Exit"); + + let menu = address_menu.extend(action_menu); + let questions = Question::select("theme") + .message("Please select an address") + .choices(menu.clone()) + .build(); + + let answer = requestty::prompt_one(questions)?; + Ok(menu.answer(&answer).to_owned()) +} + +#[derive(PartialEq, Eq, Hash, Debug, Clone)] +enum AddrOp { + Run(Box), + Back, +} + +#[derive(PartialEq, Eq, Hash, Clone, Debug)] +enum CommandMenuItem { + History, + Transfer, + Stake, + StakeInfo, + Unstake, + Withdraw, + Export, + Back, +} + +/// Allows the user to choose the operation to perform for the +/// selected address +fn menu_op( + addr: Address, + balance: Dusk, + settings: &Settings, +) -> anyhow::Result { + use CommandMenuItem as CMI; + + let cmd_menu = Menu::new() + .add(CMI::History, "Transaction History") + .add(CMI::Transfer, "Transfer Dusk") + .add(CMI::Stake, "Stake Dusk") + .add(CMI::StakeInfo, "Check existing stake") + .add(CMI::Unstake, "Unstake Dusk") + .add(CMI::Withdraw, "Withdraw staking reward") + .add(CMI::Export, "Export provisioner key-pair") + .separator() + .add(CMI::Back, "Back"); + + let q = Question::select("theme") + .message("What would you like to do?") + .choices(cmd_menu.clone()) + .build(); + + let answer = requestty::prompt_one(q)?; + let cmd = cmd_menu.answer(&answer).to_owned(); + + let res = match cmd { + CMI::History => { + AddrOp::Run(Box::new(Command::History { addr: Some(addr) })) + } + CMI::Transfer => AddrOp::Run(Box::new(Command::Transfer { + sndr: Some(addr), + rcvr: prompt::request_rcvr_addr("recipient")?, + amt: prompt::request_token_amt("transfer", balance)?, + gas_limit: prompt::request_gas_limit(gas::DEFAULT_LIMIT)?, + gas_price: prompt::request_gas_price()?, + })), + CMI::Stake => AddrOp::Run(Box::new(Command::Stake { + addr: Some(addr), + amt: prompt::request_token_amt("stake", balance)?, + gas_limit: prompt::request_gas_limit(DEFAULT_STAKE_GAS_LIMIT)?, + gas_price: prompt::request_gas_price()?, + })), + CMI::StakeInfo => AddrOp::Run(Box::new(Command::StakeInfo { + addr: Some(addr), + reward: false, + })), + CMI::Unstake => AddrOp::Run(Box::new(Command::Unstake { + addr: Some(addr), + gas_limit: prompt::request_gas_limit(DEFAULT_STAKE_GAS_LIMIT)?, + gas_price: prompt::request_gas_price()?, + })), + CMI::Withdraw => AddrOp::Run(Box::new(Command::Withdraw { + addr: Some(addr), + gas_limit: prompt::request_gas_limit(DEFAULT_STAKE_GAS_LIMIT)?, + gas_price: prompt::request_gas_price()?, + })), + CMI::Export => AddrOp::Run(Box::new(Command::Export { + addr: Some(addr), + name: None, + dir: prompt::request_dir("export keys", settings.profile.clone())?, + })), + CMI::Back => AddrOp::Back, + }; + Ok(res) +} + +/// Allows the user to choose the operation to perform for the +/// selected address while in offline mode +fn menu_op_offline( + addr: Address, + settings: &Settings, +) -> anyhow::Result { + use CommandMenuItem as CMI; + + let cmd_menu = Menu::new() + .separator() + .add(CMI::Export, "Export provisioner key-pair") + .separator() + .add(CMI::Back, "Back"); + + let q = Question::select("theme") + .message("[OFFLINE] What would you like to do?") + .choices(cmd_menu.clone()) + .build(); + + let answer = requestty::prompt_one(q)?; + let cmd = cmd_menu.answer(&answer).to_owned(); + + let res = match cmd { + CMI::Export => AddrOp::Run(Box::new(Command::Export { + addr: Some(addr), + name: None, + dir: prompt::request_dir("export keys", settings.profile.clone())?, + })), + CMI::Back => AddrOp::Back, + _ => unreachable!(), + }; + Ok(res) +} + +/// Allows the user to load a wallet interactively +pub(crate) fn load_wallet( + wallet_path: &WalletPath, + settings: &Settings, + file_version: Result, +) -> anyhow::Result> { + let wallet_found = + wallet_path.inner().exists().then(|| wallet_path.clone()); + + let password = &settings.password; + + // display main menu + let wallet = match menu_wallet(wallet_found)? { + MainMenu::Load(path) => { + let file_version = file_version?; + let mut attempt = 1; + loop { + let pwd = prompt::request_auth( + "Please enter your wallet password", + password, + file_version, + )?; + match Wallet::from_file(WalletFile { + path: path.clone(), + pwd, + }) { + Ok(wallet) => break wallet, + Err(_) if attempt > 2 => { + Err(Error::AttemptsExhausted)?; + } + Err(_) => { + println!("Invalid password, please try again"); + attempt += 1; + } + } + } + } + // Use the latest binary format when creating a wallet + MainMenu::Create => { + // create a new randomly generated mnemonic phrase + let mnemonic = + Mnemonic::new(MnemonicType::Words12, Language::English); + // ask user for a password to secure the wallet + let pwd = prompt::create_password( + password, + DatFileVersion::RuskBinaryFileFormat(LATEST_VERSION), + )?; + // display the recovery phrase + prompt::confirm_recovery_phrase(&mnemonic)?; + // create and store the wallet + let mut w = Wallet::new(mnemonic)?; + let path = wallet_path.clone(); + w.save_to(WalletFile { path, pwd })?; + w + } + MainMenu::Recover => { + // ask user for 12-word recovery phrase + let phrase = prompt::request_recovery_phrase()?; + // ask user for a password to secure the wallet, create the latest + // wallet file from the seed + let pwd = prompt::create_password( + &None, + DatFileVersion::RuskBinaryFileFormat(LATEST_VERSION), + )?; + // create and store the recovered wallet + let mut w = Wallet::new(phrase)?; + let path = wallet_path.clone(); + w.save_to(WalletFile { path, pwd })?; + w + } + MainMenu::Exit => std::process::exit(0), + }; + + Ok(wallet) +} + +#[derive(PartialEq, Eq, Hash, Debug, Clone)] +enum MainMenu { + Load(WalletPath), + Create, + Recover, + Exit, +} + +/// Allows the user to load an existing wallet, recover a lost one +/// or create a new one. +fn menu_wallet(wallet_found: Option) -> anyhow::Result { + // create the wallet menu + let mut menu = Menu::new(); + + if let Some(wallet_path) = wallet_found { + menu = menu + .separator() + .add(MainMenu::Load(wallet_path), "Access your wallet") + .separator() + .add(MainMenu::Create, "Replace your wallet with a new one") + .add( + MainMenu::Recover, + "Replace your wallet with a lost one using the recovery phrase", + ) + } else { + menu = menu.add(MainMenu::Create, "Create a new wallet").add( + MainMenu::Recover, + "Access a lost wallet using the recovery phrase", + ) + } + + // create the action menu + menu = menu.separator().add(MainMenu::Exit, "Exit"); + + // let the user choose an option + let questions = Question::select("theme") + .message("What would you like to do?") + .choices(menu.clone()) + .build(); + + let answer = requestty::prompt_one(questions)?; + Ok(menu.answer(&answer).to_owned()) +} + +/// Request user confirmation for a transfer transaction +fn confirm(cmd: &Command) -> anyhow::Result { + match cmd { + Command::Transfer { + sndr, + rcvr, + amt, + gas_limit, + gas_price, + } => { + let sndr = sndr.as_ref().expect("sender to be a valid address"); + let max_fee = gas_limit * gas_price; + println!(" > Send from = {}", sndr.preview()); + println!(" > Recipient = {}", rcvr.preview()); + println!(" > Amount to transfer = {} DUSK", amt); + println!(" > Max fee = {} DUSK", Dusk::from(max_fee)); + prompt::ask_confirm() + } + Command::Stake { + addr, + amt, + gas_limit, + gas_price, + } => { + let addr = addr.as_ref().expect("address to be valid"); + let max_fee = gas_limit * gas_price; + println!(" > Stake from {}", addr.preview()); + println!(" > Amount to stake = {} DUSK", amt); + println!(" > Max fee = {} DUSK", Dusk::from(max_fee)); + prompt::ask_confirm() + } + Command::Unstake { + addr, + gas_limit, + gas_price, + } => { + let addr = addr.as_ref().expect("address to be valid"); + let max_fee = gas_limit * gas_price; + println!(" > Unstake from {}", addr.preview()); + println!(" > Max fee = {} DUSK", Dusk::from(max_fee)); + prompt::ask_confirm() + } + Command::Withdraw { + addr, + gas_limit, + gas_price, + } => { + let addr = addr.as_ref().expect("address to be valid"); + let max_fee = gas_limit * gas_price; + println!(" > Reward from {}", addr.preview()); + println!(" > Max fee = {} DUSK", Dusk::from(max_fee)); + prompt::ask_confirm() + } + _ => Ok(true), + } +} diff --git a/rusk-wallet/src/bin/io.rs b/rusk-wallet/src/bin/io.rs new file mode 100644 index 0000000000..3b7c578568 --- /dev/null +++ b/rusk-wallet/src/bin/io.rs @@ -0,0 +1,14 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +mod args; +mod gql; + +pub(crate) mod prompt; +pub(crate) mod status; + +pub(crate) use args::WalletArgs; +pub(crate) use gql::GraphQL; diff --git a/rusk-wallet/src/bin/io/args.rs b/rusk-wallet/src/bin/io/args.rs new file mode 100644 index 0000000000..71276ac2fa --- /dev/null +++ b/rusk-wallet/src/bin/io/args.rs @@ -0,0 +1,50 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use crate::settings::{LogFormat, LogLevel}; +use crate::Command; +use clap::{AppSettings, Parser}; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[clap(version)] +#[clap(name = "Dusk Wallet CLI")] +#[clap(author = "Dusk Network B.V.")] +#[clap(about = "A user-friendly, reliable command line interface to the Dusk wallet!", long_about = None)] +#[clap(global_setting(AppSettings::DeriveDisplayOrder))] +pub(crate) struct WalletArgs { + /// Directory to store user data [default: `$HOME/.dusk/rusk-wallet`] + #[clap(short, long)] + pub profile: Option, + + /// Network to connect to + #[clap(short, long)] + pub network: Option, + + /// Set the password for wallet's creation + #[clap(long, env = "RUSK_WALLET_PWD")] + pub password: Option, + + /// The state server fully qualified URL + #[clap(long)] + pub state: Option, + + /// The prover server fully qualified URL + #[clap(long)] + pub prover: Option, + + /// Output log level + #[clap(long, value_enum, default_value_t = LogLevel::Info)] + pub log_level: LogLevel, + + /// Logging output type + #[clap(long, value_enum, default_value_t = LogFormat::Coloured)] + pub log_type: LogFormat, + + /// Command + #[clap(subcommand)] + pub command: Option, +} diff --git a/rusk-wallet/src/bin/io/gql.rs b/rusk-wallet/src/bin/io/gql.rs new file mode 100644 index 0000000000..d1fafc6648 --- /dev/null +++ b/rusk-wallet/src/bin/io/gql.rs @@ -0,0 +1,200 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use dusk_wallet_core::Transaction; +use tokio::time::{sleep, Duration}; + +use dusk_wallet::{Error, RuskHttpClient, RuskRequest}; +use serde::Deserialize; + +/// GraphQL is a helper struct that aggregates all queries done +/// to the Dusk GraphQL database. +/// This helps avoid having helper structs and boilerplate code +/// mixed with the wallet logic. +#[derive(Clone)] +pub struct GraphQL { + client: RuskHttpClient, + status: fn(&str), +} + +#[derive(Deserialize)] +struct SpentTx { + pub id: String, + #[serde(default)] + pub raw: String, + pub err: Option, + #[serde(alias = "gasSpent", default)] + pub gas_spent: f64, +} +#[derive(Deserialize)] +struct Block { + pub transactions: Vec, +} + +#[derive(Deserialize)] +struct BlockResponse { + pub block: Option, +} + +#[derive(Deserialize)] +struct SpentTxResponse { + pub tx: Option, +} + +/// Transaction status +#[derive(Debug)] +pub enum TxStatus { + Ok, + NotFound, + Error(String), +} + +impl GraphQL { + /// Create a new GraphQL wallet client + pub fn new(url: S, status: fn(&str)) -> Self + where + S: Into, + { + Self { + client: RuskHttpClient::new(url.into()), + status, + } + } + + /// Wait for a transaction to be confirmed (included in a block) + pub async fn wait_for(&self, tx_id: &str) -> anyhow::Result<()> { + loop { + let status = self.tx_status(tx_id).await?; + + match status { + TxStatus::Ok => break, + TxStatus::Error(err) => return Err(Error::Transaction(err))?, + TxStatus::NotFound => { + (self.status)( + "Waiting for tx to be included into a block...", + ); + sleep(Duration::from_millis(1000)).await; + } + } + } + Ok(()) + } + + /// Obtain transaction status + async fn tx_status( + &self, + tx_id: &str, + ) -> anyhow::Result { + let query = + "query { tx(hash: \"####\") { id, err }}".replace("####", tx_id); + let response = self.query(&query).await?; + let response = serde_json::from_slice::(&response)?.tx; + + match response { + Some(SpentTx { err: Some(err), .. }) => Ok(TxStatus::Error(err)), + Some(_) => Ok(TxStatus::Ok), + None => Ok(TxStatus::NotFound), + } + } + + /// Obtain transactions inside a block + pub async fn txs_for_block( + &self, + block_height: u64, + ) -> anyhow::Result, GraphQLError> { + let query = "query { block(height: ####) { transactions {id, raw, gasSpent, err}}}" + .replace("####", block_height.to_string().as_str()); + + let response = self.query(&query).await?; + let response = + serde_json::from_slice::(&response)?.block; + let block = response.ok_or(GraphQLError::BlockInfo)?; + let mut ret = vec![]; + + for spent_tx in block.transactions { + let tx_raw = hex::decode(&spent_tx.raw) + .map_err(|_| GraphQLError::TxStatus)?; + let ph_tx = Transaction::from_slice(&tx_raw).unwrap(); + ret.push((ph_tx, spent_tx.id, spent_tx.gas_spent as u64)); + } + Ok(ret) + } +} + +/// Errors generated from GraphQL +#[derive(Debug, thiserror::Error)] +pub enum GraphQLError { + /// Generic errors + #[error("Error fetching data from the node: {0}")] + Generic(dusk_wallet::Error), + /// Failed to fetch transaction status + #[error("Failed to obtain transaction status")] + TxStatus, + #[error("Failed to obtain block info")] + BlockInfo, +} + +impl From for GraphQLError { + fn from(e: dusk_wallet::Error) -> Self { + Self::Generic(e) + } +} + +impl From for GraphQLError { + fn from(e: serde_json::Error) -> Self { + Self::Generic(e.into()) + } +} + +impl GraphQL { + pub async fn query( + &self, + query: &str, + ) -> Result, dusk_wallet::Error> { + let request = RuskRequest::new("gql", query.as_bytes().to_vec()); + self.client.call(2, "Chain", &request).await + } +} + +#[ignore = "Leave it here just for manual tests"] +#[tokio::test] +async fn test() -> Result<(), Box> { + let gql = GraphQL { + status: |s| { + println!("{s}"); + }, + client: RuskHttpClient::new( + "http://nodes.dusk.network:9500/graphql".to_string(), + ), + }; + let _ = gql + .tx_status( + "dbc5a2c949516ecfb418406909d195c3cc267b46bd966a3ca9d66d2e13c47003", + ) + .await?; + let block_txs = gql.txs_for_block(90).await?; + block_txs.into_iter().for_each(|(t, chain_txid, _)| { + let hash = rusk_abi::hash::Hasher::digest(t.to_hash_input_bytes()); + let tx_id = hex::encode(hash.to_bytes()); + assert_eq!(chain_txid, tx_id); + println!("txid: {tx_id}"); + }); + Ok(()) +} + +#[tokio::test] +async fn deser() -> Result<(), Box> { + let block_not_found = r#"{"block":null}"#; + serde_json::from_str::(block_not_found).unwrap(); + + let block_without_tx = r#"{"block":{"transactions":[]}}"#; + serde_json::from_str::(block_without_tx).unwrap(); + + let block_with_tx = r#"{"block":{"transactions":[{"id":"88e6804989cc2f3fd5bf94dcd39a4e7b7da9a1114d9b8bf4e0515264bc81c50f"}]}}"#; + serde_json::from_str::(block_with_tx).unwrap(); + + Ok(()) +} diff --git a/rusk-wallet/src/bin/io/prompt.rs b/rusk-wallet/src/bin/io/prompt.rs new file mode 100644 index 0000000000..e4200d9161 --- /dev/null +++ b/rusk-wallet/src/bin/io/prompt.rs @@ -0,0 +1,309 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use std::path::PathBuf; +use std::str::FromStr; +use std::{io::stdout, println}; + +use crossterm::{ + cursor::{Hide, Show}, + ExecutableCommand, +}; + +use anyhow::Result; +use bip39::{ErrorKind, Language, Mnemonic}; +use dusk_wallet::{dat::DatFileVersion, Error}; +use requestty::Question; + +use dusk_wallet::{Address, Dusk, Lux}; + +use dusk_wallet::gas; +use dusk_wallet::{MAX_CONVERTIBLE, MIN_CONVERTIBLE}; +use sha2::{Digest, Sha256}; + +/// Request the user to authenticate with a password +pub(crate) fn request_auth( + msg: &str, + password: &Option, + file_version: DatFileVersion, +) -> anyhow::Result> { + let pwd = match password.as_ref() { + Some(p) => p.to_string(), + + None => { + let q = Question::password("password") + .message(format!("{}:", msg)) + .mask('*') + .build(); + + let a = requestty::prompt_one(q)?; + a.as_string().expect("answer to be a string").into() + } + }; + + Ok(hash(file_version, &pwd)) +} + +/// Request the user to create a wallet password +pub(crate) fn create_password( + password: &Option, + file_version: DatFileVersion, +) -> anyhow::Result> { + let pwd = match password.as_ref() { + Some(p) => p.to_string(), + None => { + let mut pwd = String::from(""); + + let mut pwds_match = false; + while !pwds_match { + // enter password + let q = Question::password("password") + .message("Enter the password for the wallet:") + .mask('*') + .build(); + let a = requestty::prompt_one(q)?; + let pwd1 = a.as_string().expect("answer to be a string"); + + // confirm password + let q = Question::password("password") + .message("Please confirm the password:") + .mask('*') + .build(); + let a = requestty::prompt_one(q)?; + let pwd2 = a.as_string().expect("answer to be a string"); + + // check match + pwds_match = pwd1 == pwd2; + if pwds_match { + pwd = pwd1.to_string() + } else { + println!("Passwords don't match, please try again."); + } + } + pwd + } + }; + + Ok(hash(file_version, &pwd)) +} + +/// Display the recovery phrase to the user and ask for confirmation +pub(crate) fn confirm_recovery_phrase(phrase: &S) -> anyhow::Result<()> +where + S: std::fmt::Display, +{ + // inform the user about the mnemonic phrase + println!("The following phrase is essential for you to regain access to your wallet\nin case you lose access to this computer."); + println!("Please print it or write it down and store it somewhere safe:"); + println!(); + println!("> {}", phrase); + println!(); + + // let the user confirm they have backed up their phrase + loop { + let q = requestty::Question::confirm("proceed") + .message("Have you backed up your recovery phrase?") + .build(); + + let a = requestty::prompt_one(q)?; + if a.as_bool().expect("answer to be a bool") { + return Ok(()); + } + } +} + +/// Request the user to input the recovery phrase +pub(crate) fn request_recovery_phrase() -> anyhow::Result { + // let the user input the recovery phrase + let mut attempt = 1; + loop { + let q = Question::input("phrase") + .message("Please enter the recovery phrase:") + .build(); + + let a = requestty::prompt_one(q)?; + let phrase = a.as_string().expect("answer to be a string"); + + match Mnemonic::from_phrase(phrase, Language::English) { + Ok(phrase) => break Ok(phrase.to_string()), + + Err(err) if attempt > 2 => match err.downcast_ref::() { + Some(ErrorKind::InvalidWord) => { + return Err(Error::AttemptsExhausted)? + } + _ => return Err(err), + }, + Err(_) => { + println!("Invalid recovery phrase, please try again"); + attempt += 1; + } + } + } +} + +fn is_valid_dir(dir: &str) -> bool { + let mut p = std::path::PathBuf::new(); + p.push(dir); + p.is_dir() +} + +/// Use sha256 for Rusk Binary Format, and blake for the rest +fn hash(file_version: DatFileVersion, pwd: &str) -> Vec { + match file_version { + DatFileVersion::RuskBinaryFileFormat(_) => { + let mut hasher = Sha256::new(); + + hasher.update(pwd.as_bytes()); + + hasher.finalize().to_vec() + } + _ => blake3::hash(pwd.as_bytes()).as_bytes().to_vec(), + } +} + +/// Request a directory +pub(crate) fn request_dir( + what_for: &str, + profile: PathBuf, +) -> Result { + let q = Question::input("name") + .message(format!("Please enter a directory to {}:", what_for)) + .default(profile.as_os_str().to_str().expect("default dir")) + .validate_on_key(|dir, _| is_valid_dir(dir)) + .validate(|dir, _| { + if is_valid_dir(dir) { + Ok(()) + } else { + Err("Not a valid directory".to_string()) + } + }) + .build(); + + let a = requestty::prompt_one(q)?; + let mut p = std::path::PathBuf::new(); + p.push(a.as_string().expect("answer to be a string")); + Ok(p) +} + +/// Asks the user for confirmation +pub(crate) fn ask_confirm() -> anyhow::Result { + let q = requestty::Question::confirm("confirm") + .message("Transaction ready. Proceed?") + .build(); + let a = requestty::prompt_one(q)?; + Ok(a.as_bool().expect("answer to be a bool")) +} + +/// Request a receiver address +pub(crate) fn request_rcvr_addr(addr_for: &str) -> anyhow::Result
{ + // let the user input the receiver address + let q = Question::input("addr") + .message(format!("Please enter the {} address:", addr_for)) + .validate_on_key(|addr, _| Address::from_str(addr).is_ok()) + .validate(|addr, _| { + if Address::from_str(addr).is_ok() { + Ok(()) + } else { + Err("Please introduce a valid DUSK address".to_string()) + } + }) + .build(); + + let a = requestty::prompt_one(q)?; + Ok(Address::from_str( + a.as_string().expect("answer to be a string"), + )?) +} + +/// Checks for a valid DUSK denomination +fn check_valid_denom(value: f64, balance: Dusk) -> Result<(), String> { + let value = Dusk::from(value); + let min = MIN_CONVERTIBLE; + let max = std::cmp::min(balance, MAX_CONVERTIBLE); + match (min..=max).contains(&value) { + true => Ok(()), + false => { + Err(format!("The amount has to be between {} and {}", min, max)) + } + } +} + +/// Request amount of tokens +pub(crate) fn request_token_amt( + action: &str, + balance: Dusk, +) -> anyhow::Result { + let question = requestty::Question::float("amt") + .message(format!("Introduce the amount of DUSK to {}:", action)) + .default(MIN_CONVERTIBLE.into()) + .validate_on_key(|f, _| check_valid_denom(f, balance).is_ok()) + .validate(|f, _| check_valid_denom(f, balance)) + .build(); + + let a = requestty::prompt_one(question)?; + Ok(a.as_float().expect("answer to be a float").into()) +} + +/// Request gas limit +pub(crate) fn request_gas_limit(default_gas_limit: u64) -> anyhow::Result { + let question = requestty::Question::int("amt") + .message("Introduce the gas limit for this transaction:") + .default(default_gas_limit as i64) + .validate_on_key(|n, _| n > (gas::MIN_LIMIT as i64)) + .validate(|n, _| { + if n < gas::MIN_LIMIT as i64 { + Err("Gas limit too low".to_owned()) + } else { + Ok(()) + } + }) + .build(); + + let a = requestty::prompt_one(question)?; + Ok(a.as_int().expect("answer to be an int") as u64) +} + +/// Request gas price +pub(crate) fn request_gas_price() -> anyhow::Result { + let question = requestty::Question::float("amt") + .message("Introduce the gas price for this transaction:") + .default(Dusk::from(gas::DEFAULT_PRICE).into()) + .validate_on_key(|f, _| check_valid_denom(f, MAX_CONVERTIBLE).is_ok()) + .validate(|f, _| check_valid_denom(f, MAX_CONVERTIBLE)) + .build(); + + let a = requestty::prompt_one(question)?; + let price = Dusk::from(a.as_float().expect("answer to be a float")); + Ok(*price) +} + +/// Request Dusk block explorer to be opened +pub(crate) fn launch_explorer(url: String) -> Result<()> { + let q = requestty::Question::confirm("launch") + .message("Launch block explorer?") + .build(); + + let a = requestty::prompt_one(q)?; + let open = a.as_bool().expect("answer to be a bool"); + if open { + open::that(url)?; + } + Ok(()) +} + +/// Shows the terminal cursor +pub(crate) fn show_cursor() -> anyhow::Result<()> { + let mut stdout = stdout(); + stdout.execute(Show)?; + Ok(()) +} + +/// Hides the terminal cursor +pub(crate) fn hide_cursor() -> anyhow::Result<()> { + let mut stdout = stdout(); + stdout.execute(Hide)?; + Ok(()) +} diff --git a/rusk-wallet/src/bin/io/status.rs b/rusk-wallet/src/bin/io/status.rs new file mode 100644 index 0000000000..d24b7148c2 --- /dev/null +++ b/rusk-wallet/src/bin/io/status.rs @@ -0,0 +1,24 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use std::io::{stdout, Write}; +use std::thread; +use std::time::Duration; + +use tracing::info; + +/// Prints an interactive status message +pub(crate) fn interactive(status: &str) { + print!("\r{status: <50}\r"); + let mut stdout = stdout(); + stdout.flush().unwrap(); + thread::sleep(Duration::from_millis(85)); +} + +/// Logs the status message at info level +pub(crate) fn headless(status: &str) { + info!(status); +} diff --git a/rusk-wallet/src/bin/main.rs b/rusk-wallet/src/bin/main.rs new file mode 100644 index 0000000000..7ddb9cadae --- /dev/null +++ b/rusk-wallet/src/bin/main.rs @@ -0,0 +1,342 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +mod command; +mod config; +mod interactive; +mod io; +mod menu; +mod settings; + +pub(crate) use command::{Command, RunResult}; +use dusk_wallet::dat::LATEST_VERSION; +pub(crate) use menu::Menu; + +use clap::Parser; +use std::fs::{self, File}; +use std::io::Write; +use tracing::{warn, Level}; + +use bip39::{Language, Mnemonic, MnemonicType}; + +use crate::command::TransactionHistory; +use crate::settings::{LogFormat, Settings}; + +use dusk_wallet::{dat, Error}; +use dusk_wallet::{Dusk, SecureWalletFile, Wallet, WalletPath}; + +use config::Config; +use io::{prompt, status}; +use io::{GraphQL, WalletArgs}; + +#[derive(Debug, Clone)] +pub(crate) struct WalletFile { + path: WalletPath, + pwd: Vec, +} + +impl SecureWalletFile for WalletFile { + fn path(&self) -> &WalletPath { + &self.path + } + + fn pwd(&self) -> &[u8] { + &self.pwd + } +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() -> anyhow::Result<()> { + if let Err(err) = exec().await { + // display the error message (if any) + match err.downcast_ref::() { + Some(requestty::ErrorKind::Interrupted) => { + // TODO: Handle this error properly + // See also https://github.com/dusk-network/wallet-cli/issues/104 + } + _ => eprintln!("{err}"), + }; + // give cursor back to the user + io::prompt::show_cursor()?; + } + Ok(()) +} + +async fn connect( + mut wallet: Wallet, + settings: &Settings, + status: fn(&str), +) -> Wallet +where + F: SecureWalletFile + std::fmt::Debug, +{ + let con = wallet + .connect_with_status( + &settings.state.to_string(), + &settings.prover.to_string(), + status, + ) + .await; + + // check for connection errors + match con { + Err(Error::RocksDB(e)) => panic!{"Invalid cache {e}"}, + Err(e) => warn!("[OFFLINE MODE]: Unable to connect to Rusk, limited functionality available: {e}"), + _ => {} + } + + wallet +} + +async fn exec() -> anyhow::Result<()> { + // parse user args + let args = WalletArgs::parse(); + // get the subcommand, if any + let cmd = args.command.clone(); + + // set symbols to ASCII for Windows terminal compatibility + #[cfg(windows)] + requestty::symbols::set(requestty::symbols::ASCII); + + // Get the initial settings from the args + let settings_builder = Settings::args(args); + + // Obtain the profile dir from the settings + let profile_folder = settings_builder.profile().clone(); + + fs::create_dir_all(profile_folder.as_path())?; + + // prepare wallet path + let mut wallet_path = + WalletPath::from(profile_folder.as_path().join("wallet.dat")); + + // load configuration (or use default) + let cfg = Config::load(&profile_folder)?; + + wallet_path.set_network_name(settings_builder.args.network.clone()); + + // Finally complete the settings by setting the network + let settings = settings_builder + .network(cfg.network) + .map_err(|_| dusk_wallet::Error::NetworkNotFound)?; + + // generate a subscriber with the desired log level + // + // TODO: we should have the logger instantiate sooner, otherwise we cannot + // catch errors that are happened before its instantiation. + // + // Therefore, the logger details such as `type` and `level` cannot be part + // of the configuration, since it won't catch any configuration error + // otherwise. + // + // See: + // + let level = &settings.logging.level; + let level: Level = level.into(); + let subscriber = tracing_subscriber::fmt::Subscriber::builder() + .with_max_level(level) + .with_writer(std::io::stderr); + + // set the subscriber as global + match settings.logging.format { + LogFormat::Json => { + let subscriber = subscriber.json().flatten_event(true).finish(); + tracing::subscriber::set_global_default(subscriber)?; + } + LogFormat::Plain => { + let subscriber = subscriber.with_ansi(false).finish(); + tracing::subscriber::set_global_default(subscriber)?; + } + LogFormat::Coloured => { + let subscriber = subscriber.finish(); + tracing::subscriber::set_global_default(subscriber)?; + } + }; + + let is_headless = cmd.is_some(); + + let password = &settings.password; + + if let Some(Command::Settings) = cmd { + println!("{}", &settings); + return Ok(()); + }; + + let file_version = dat::read_file_version(&wallet_path); + + // get our wallet ready + let mut wallet: Wallet = match cmd { + Some(ref cmd) => match cmd { + Command::Create { + skip_recovery, + seed_file, + } => { + // create a new randomly generated mnemonic phrase + let mnemonic = + Mnemonic::new(MnemonicType::Words12, Language::English); + // ask user for a password to secure the wallet + // latest version is used for dat file + let pwd = prompt::create_password( + password, + dat::DatFileVersion::RuskBinaryFileFormat(LATEST_VERSION), + )?; + + match (skip_recovery, seed_file) { + (_, Some(file)) => { + let mut file = File::create(file)?; + file.write_all(mnemonic.phrase().as_bytes())? + } + // skip phrase confirmation if explicitly + (false, _) => prompt::confirm_recovery_phrase(&mnemonic)?, + _ => {} + } + + // create wallet + let mut w = Wallet::new(mnemonic)?; + + w.save_to(WalletFile { + path: wallet_path, + pwd, + })?; + + w + } + Command::Restore { file } => { + let (mut w, pwd) = match file { + Some(file) => { + // if we restore and old version file make sure we + // know the corrrect version before asking for the + // password + let file_version = dat::read_file_version(file)?; + + let pwd = prompt::request_auth( + "Please enter wallet password", + password, + file_version, + )?; + + let w = Wallet::from_file(WalletFile { + path: file.clone(), + pwd: pwd.clone(), + })?; + + (w, pwd) + } + // Use the latest dat file version when there's no dat file + // provided when restoring the wallet + None => { + // ask user for 12-word recovery phrase + let phrase = prompt::request_recovery_phrase()?; + // ask user for a password to secure the wallet + let pwd = prompt::create_password( + password, + dat::DatFileVersion::RuskBinaryFileFormat( + LATEST_VERSION, + ), + )?; + // create wallet + let w = Wallet::new(phrase)?; + + (w, pwd) + } + }; + + w.save_to(WalletFile { + path: wallet_path, + pwd, + })?; + + w + } + + _ => { + // Grab the file version for a random command + let file_version = file_version?; + // load wallet from file + let pwd = prompt::request_auth( + "Please enter wallet password", + password, + file_version, + )?; + + Wallet::from_file(WalletFile { + path: wallet_path, + pwd, + })? + } + }, + None => { + // load a wallet in interactive mode + interactive::load_wallet(&wallet_path, &settings, file_version)? + } + }; + + // set our status callback + let status_cb = match is_headless { + true => status::headless, + false => status::interactive, + }; + + wallet = connect(wallet, &settings, status_cb).await; + + // run command + match cmd { + Some(cmd) => match cmd.run(&mut wallet, &settings).await? { + RunResult::Balance(balance, spendable) => { + if spendable { + println!("{}", Dusk::from(balance.spendable)); + } else { + println!("{}", Dusk::from(balance.value)); + } + } + RunResult::Address(addr) => { + println!("{addr}"); + } + RunResult::Addresses(addrs) => { + for a in addrs { + println!("{a}"); + } + } + RunResult::Tx(hash) => { + let tx_id = hex::encode(hash.to_bytes()); + + // Wait for transaction confirmation from network + let gql = GraphQL::new(settings.state, status::headless); + gql.wait_for(&tx_id).await?; + + println!("{tx_id}"); + } + RunResult::StakeInfo(info, reward) => { + if reward { + println!("{}", Dusk::from(info.reward)); + } else { + let staked_amount = match info.amount { + Some((staked, ..)) => staked, + None => 0, + }; + println!("{}", Dusk::from(staked_amount)); + } + } + RunResult::ExportedKeys(pub_key, key_pair) => { + println!("{},{}", pub_key.display(), key_pair.display()) + } + RunResult::History(transactions) => { + println!("{}", TransactionHistory::header()); + for th in transactions { + println!("{th}"); + } + } + RunResult::Settings() => {} + RunResult::Create() | RunResult::Restore() => {} + }, + None => { + wallet.register_sync().await?; + interactive::run_loop(&mut wallet, &settings).await?; + } + } + + Ok(()) +} diff --git a/rusk-wallet/src/bin/menu.rs b/rusk-wallet/src/bin/menu.rs new file mode 100644 index 0000000000..e0ef021cb4 --- /dev/null +++ b/rusk-wallet/src/bin/menu.rs @@ -0,0 +1,96 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use core::fmt::Debug; +use std::collections::HashMap; +use std::hash::Hash; + +use requestty::question::Choice; +use requestty::{Answer, DefaultSeparator, Separator}; + +#[derive(Clone, Debug)] +pub struct Menu { + items: Vec>, + keys: HashMap, +} + +impl Default for Menu +where + K: Eq + Hash + Debug, +{ + fn default() -> Self { + Self::new() + } +} + +impl Menu +where + K: Eq + Hash + Debug, +{ + pub fn new() -> Self { + Self { + items: vec![], + keys: HashMap::new(), + } + } + + pub fn title(title: T) -> Self + where + T: Into, + { + let title = format!("─ {:─<12}", format!("{} ", title.into())); + let title = Separator(title); + let items = vec![title]; + let keys = HashMap::new(); + + Self { items, keys } + } + + pub fn add(mut self, key: K, item: V) -> Self + where + V: Into>, + { + self.items.push(item.into()); + self.keys.insert(self.items.len() - 1, key); + self + } + + pub fn separator(mut self) -> Self { + self.items.push(DefaultSeparator); + self + } + + pub fn separator_msg(mut self, msg: String) -> Self { + self.items.push(Separator(msg)); + self + } + + pub fn answer(&self, answer: &Answer) -> &K { + let index = answer.as_list_item().unwrap().index; + let key = self.keys.get(&index); + key.unwrap() + } + + pub fn extend(mut self, other: Self) -> Self { + let len = self.items.len(); + + self.items.extend(other.items); + + for (key, val) in other.keys.into_iter() { + self.keys.insert(key + len, val); + } + + self + } +} + +impl IntoIterator for Menu { + type Item = Choice; + type IntoIter = std::vec::IntoIter>; + fn into_iter(self) -> Self::IntoIter { + self.items.into_iter() + } +} diff --git a/rusk-wallet/src/bin/settings.rs b/rusk-wallet/src/bin/settings.rs new file mode 100644 index 0000000000..99517c2e1b --- /dev/null +++ b/rusk-wallet/src/bin/settings.rs @@ -0,0 +1,214 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use crate::config::Network; +use crate::io::WalletArgs; + +use dusk_wallet::Error; +use std::fmt; +use std::path::PathBuf; + +use tracing::Level; +use url::Url; + +#[derive(clap::ValueEnum, Debug, Clone)] +pub(crate) enum LogFormat { + Json, + Plain, + Coloured, +} + +#[derive(clap::ValueEnum, Debug, Clone)] +pub(crate) enum LogLevel { + /// Designates very low priority, often extremely verbose, information. + Trace, + /// Designates lower priority information. + Debug, + /// Designates useful information. + Info, + /// Designates hazardous situations. + Warn, + /// Designates very serious errors. + Error, +} + +#[derive(Debug)] +pub(crate) struct Logging { + /// Max log level + pub level: LogLevel, + /// Log format + pub format: LogFormat, +} + +#[allow(dead_code)] +#[derive(Debug)] +pub(crate) struct Settings { + pub(crate) state: Url, + pub(crate) prover: Url, + pub(crate) explorer: Option, + + pub(crate) logging: Logging, + + pub(crate) profile: PathBuf, + pub(crate) password: Option, +} + +pub(crate) struct SettingsBuilder { + profile: PathBuf, + pub(crate) args: WalletArgs, +} + +impl SettingsBuilder { + pub fn profile(&self) -> &PathBuf { + &self.profile + } + + pub fn network(self, network: Network) -> Result { + let args = self.args; + + let network = match (args.network, network.clone().network) { + (Some(label), Some(mut networks)) => { + let r = networks.remove(&label); + // err if specified network is not in the list + if r.is_none() { + return Err(Error::BadAddress); + } + + r + } + // err if no networks are specified but argument is + (Some(_), None) => { + return Err(Error::BadAddress); + } + (_, _) => None, + } + .unwrap_or(network); + + let state = args + .state + .as_ref() + .and_then(|value| Url::parse(value).ok()) + .unwrap_or(network.state); + + let prover = args + .prover + .as_ref() + .and_then(|value| Url::parse(value).ok()) + .unwrap_or(network.prover); + + let explorer = network.explorer; + + let profile = args.profile.as_ref().cloned().unwrap_or(self.profile); + + let password = args.password; + + let logging = Logging { + level: args.log_level, + format: args.log_type, + }; + + Ok(Settings { + state, + prover, + explorer, + logging, + profile, + password, + }) + } +} + +impl Settings { + pub fn args(args: WalletArgs) -> SettingsBuilder { + let profile = if let Some(path) = &args.profile { + path.clone() + } else { + let mut path = dirs::home_dir().expect("OS not supported"); + path.push(".dusk"); + path.push(env!("CARGO_BIN_NAME")); + path + }; + + SettingsBuilder { profile, args } + } +} + +impl From<&LogLevel> for Level { + fn from(level: &LogLevel) -> Level { + match level { + LogLevel::Trace => Level::TRACE, + LogLevel::Debug => Level::DEBUG, + LogLevel::Info => Level::INFO, + LogLevel::Warn => Level::WARN, + LogLevel::Error => Level::ERROR, + } + } +} + +impl fmt::Display for LogFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + Self::Json => "json", + Self::Plain => "plain", + Self::Coloured => "coloured", + } + ) + } +} + +impl fmt::Display for LogLevel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + Self::Trace => "trace", + Self::Debug => "debug", + Self::Info => "info", + Self::Warn => "warn", + Self::Error => "error", + } + ) + } +} + +impl fmt::Display for Logging { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Logging: [{}] ({})", self.level, self.format) + } +} + +impl fmt::Display for Settings { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let separator = "─".repeat(14); + writeln!(f, "{separator}")?; + writeln!(f, "Settings")?; + writeln!(f, "{separator}")?; + writeln!(f, "Profile: {}", self.profile.display())?; + writeln!( + f, + "Password: {}", + if self.password.is_some() { + "[Set]" + } else { + "[Not set]" + } + )?; + writeln!(f, "{}", separator)?; + writeln!(f, "state: {}", self.state)?; + writeln!(f, "prover: {}", self.prover)?; + + if let Some(explorer) = &self.explorer { + writeln!(f, "explorer: {explorer}")?; + } + + writeln!(f, "{separator}")?; + writeln!(f, "{}", self.logging) + } +} diff --git a/rusk-wallet/src/block.rs b/rusk-wallet/src/block.rs new file mode 100644 index 0000000000..642301837b --- /dev/null +++ b/rusk-wallet/src/block.rs @@ -0,0 +1,20 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use tokio::runtime::Handle; +use tokio::task::block_in_place; + +pub(crate) trait Block { + fn wait(self) -> ::Output + where + Self: Sized, + Self: futures::Future, + { + block_in_place(move || Handle::current().block_on(self)) + } +} + +impl Block for F where F: futures::Future {} diff --git a/rusk-wallet/src/cache.rs b/rusk-wallet/src/cache.rs new file mode 100644 index 0000000000..acc7b3552c --- /dev/null +++ b/rusk-wallet/src/cache.rs @@ -0,0 +1,281 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use std::cmp::Ordering; +use std::collections::BTreeSet; +use std::path::Path; + +use dusk_bytes::{DeserializableSlice, Serializable}; +use dusk_pki::PublicSpendKey; +use dusk_plonk::prelude::BlsScalar; +use dusk_wallet_core::Store; +use phoenix_core::Note; +use rocksdb::{DBWithThreadMode, MultiThreaded, Options}; + +use crate::{error::Error, store::LocalStore, MAX_ADDRESSES}; + +type DB = DBWithThreadMode; + +/// A cache of notes received from Rusk. +/// +/// path is the path of the rocks db database +pub(crate) struct Cache { + db: DB, +} + +impl Cache { + /// Returns a new cache instance. + pub(crate) fn new>( + path: T, + store: &LocalStore, + status: fn(&str), + ) -> Result { + let cfs: Vec<_> = (0..MAX_ADDRESSES) + .flat_map(|i| { + let ssk = + store.retrieve_ssk(i as u64).expect("ssk to be available"); + let psk = ssk.public_spend_key(); + + let live = format!("{:?}", psk); + let spent = format!("spent_{:?}", psk); + [live, spent] + }) + .collect(); + + status("Opening notes database"); + + let mut opts = Options::default(); + opts.create_if_missing(true); + opts.create_missing_column_families(true); + // After 10 million bytes, sort the cache file and create new one + opts.set_write_buffer_size(10_000_000); + + // create all CF(s) on startup if we don't have them + let db = DB::open_cf(&opts, path, cfs)?; + + Ok(Self { db }) + } + + // We store a column family named by hex representation of the psk. + // We store the nullifier of the note as key and the value is the bytes + // representation of the tuple (NoteHeight, Note) + pub(crate) fn insert( + &self, + psk: &PublicSpendKey, + height: u64, + note_data: (Note, BlsScalar), + ) -> Result<(), Error> { + let cf_name = format!("{:?}", psk); + + let cf = self + .db + .cf_handle(&cf_name) + .ok_or(Error::CacheDatabaseCorrupted)?; + + let (note, nullifier) = note_data; + + let data = NoteData { height, note }; + let key = nullifier.to_bytes(); + + self.db.put_cf(&cf, key, data.to_bytes())?; + + Ok(()) + } + + // We store a column family named by hex representation of the psk. + // We store the nullifier of the note as key and the value is the bytes + // representation of the tuple (NoteHeight, Note) + pub(crate) fn insert_spent( + &self, + psk: &PublicSpendKey, + height: u64, + note_data: (Note, BlsScalar), + ) -> Result<(), Error> { + let cf_name = format!("spent_{:?}", psk); + + let cf = self + .db + .cf_handle(&cf_name) + .ok_or(Error::CacheDatabaseCorrupted)?; + + let (note, nullifier) = note_data; + + let data = NoteData { height, note }; + let key = nullifier.to_bytes(); + + self.db.put_cf(&cf, key, data.to_bytes())?; + + Ok(()) + } + + pub(crate) fn spend_notes( + &self, + psk: &PublicSpendKey, + nullifiers: &[BlsScalar], + ) -> Result<(), Error> { + if nullifiers.is_empty() { + return Ok(()); + } + let cf_name = format!("{:?}", psk); + let spent_cf_name = format!("spent_{:?}", psk); + + let cf = self + .db + .cf_handle(&cf_name) + .ok_or(Error::CacheDatabaseCorrupted)?; + let spent_cf = self + .db + .cf_handle(&spent_cf_name) + .ok_or(Error::CacheDatabaseCorrupted)?; + + for n in nullifiers { + let key = n.to_bytes(); + let to_move = self + .db + .get_cf(&cf, key)? + .expect("Note must exists to be moved"); + self.db.put_cf(&spent_cf, key, to_move)?; + self.db.delete_cf(&cf, n.to_bytes())?; + } + + Ok(()) + } + + pub(crate) fn insert_last_pos(&self, last_pos: u64) -> Result<(), Error> { + self.db.put(b"last_pos", last_pos.to_be_bytes())?; + + Ok(()) + } + + /// Returns the last position of inserted notes. If no note has ever been + /// inserted it returns None. + pub(crate) fn last_pos(&self) -> Result, Error> { + Ok(self.db.get(b"last_pos")?.map(|x| { + let buff: [u8; 8] = x.try_into().expect("Invalid u64 in cache db"); + + u64::from_be_bytes(buff) + })) + } + + /// Returns an iterator over all unspent notes nullifier for the given PSK. + pub(crate) fn unspent_notes_id( + &self, + psk: &PublicSpendKey, + ) -> Result, Error> { + let cf_name = format!("{:?}", psk); + let mut notes = vec![]; + + if let Some(cf) = self.db.cf_handle(&cf_name) { + let iterator = + self.db.iterator_cf(&cf, rocksdb::IteratorMode::Start); + + for i in iterator { + let (id, _) = i?; + + let id = BlsScalar::from_slice(&id)?; + notes.push(id); + } + }; + + Ok(notes) + } + + /// Returns an iterator over all unspent notes inserted for the given PSK, + /// in order of note position. + pub(crate) fn notes( + &self, + psk: &PublicSpendKey, + ) -> Result, Error> { + let cf_name = format!("{:?}", psk); + let mut notes = BTreeSet::::new(); + + if let Some(cf) = self.db.cf_handle(&cf_name) { + let iterator = + self.db.iterator_cf(&cf, rocksdb::IteratorMode::Start); + + for i in iterator { + let (_, note_data) = i?; + + let note = NoteData::from_slice(¬e_data)?; + + notes.insert(note); + } + }; + + Ok(notes) + } + + /// Returns an iterator over all notes inserted for the given PSK, in order + /// of block height. + pub(crate) fn spent_notes( + &self, + psk: &PublicSpendKey, + ) -> Result, Error> { + let cf_name = format!("spent_{:?}", psk); + let mut notes = vec![]; + + if let Some(cf) = self.db.cf_handle(&cf_name) { + let iterator = + self.db.iterator_cf(&cf, rocksdb::IteratorMode::Start); + + for i in iterator { + let (key, note_data) = i?; + + let note = NoteData::from_slice(¬e_data)?; + let key = BlsScalar::from_slice(&key)?; + + notes.push((key, note)); + } + }; + + Ok(notes) + } +} + +/// Data kept about each note. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct NoteData { + pub height: u64, + pub note: Note, +} + +impl PartialOrd for NoteData { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for NoteData { + fn cmp(&self, other: &Self) -> Ordering { + self.note.pos().cmp(other.note.pos()) + } +} + +impl Serializable<{ u64::SIZE + Note::SIZE }> for NoteData { + type Error = dusk_bytes::Error; + /// Converts a Note into a byte representation + + fn to_bytes(&self) -> [u8; Self::SIZE] { + let mut buf = [0u8; Self::SIZE]; + + buf[0..8].copy_from_slice(&self.height.to_bytes()); + + buf[8..].copy_from_slice(&self.note.to_bytes()); + + buf + } + + /// Attempts to convert a byte representation of a note into a `Note`, + /// failing if the input is invalid + fn from_bytes(bytes: &[u8; Self::SIZE]) -> Result { + let mut one_u64 = [0u8; 8]; + one_u64.copy_from_slice(&bytes[0..8]); + let height = u64::from_bytes(&one_u64)?; + + let note = Note::from_slice(&bytes[8..])?; + Ok(Self { height, note }) + } +} diff --git a/rusk-wallet/src/clients.rs b/rusk-wallet/src/clients.rs new file mode 100644 index 0000000000..35a5de5afb --- /dev/null +++ b/rusk-wallet/src/clients.rs @@ -0,0 +1,380 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +mod sync; + +use dusk_bls12_381_sign::PublicKey; +use dusk_bytes::{DeserializableSlice, Serializable, Write}; +use dusk_pki::ViewKey; +use dusk_plonk::prelude::*; +use dusk_plonk::proof_system::Proof; +use dusk_schnorr::Signature; +use dusk_wallet_core::{ + EnrichedNote, ProverClient, StakeInfo, StateClient, Transaction, + UnprovenTransaction, POSEIDON_TREE_DEPTH, +}; +use flume::Sender; +use phoenix_core::transaction::StakeData; +use phoenix_core::{Crossover, Fee, Note}; +use poseidon_merkle::Opening as PoseidonOpening; +use tokio::time::{sleep, Duration}; + +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use self::sync::sync_db; + +use super::block::Block; +use super::cache::Cache; + +use crate::rusk::{RuskHttpClient, RuskRequest}; +use crate::store::LocalStore; +use crate::Error; + +const STCT_INPUT_SIZE: usize = Fee::SIZE + + Crossover::SIZE + + u64::SIZE + + JubJubScalar::SIZE + + BlsScalar::SIZE + + Signature::SIZE; + +const WFCT_INPUT_SIZE: usize = + JubJubAffine::SIZE + u64::SIZE + JubJubScalar::SIZE; + +const TRANSFER_CONTRACT: &str = + "0100000000000000000000000000000000000000000000000000000000000000"; + +const STAKE_CONTRACT: &str = + "0200000000000000000000000000000000000000000000000000000000000000"; + +// Sync every 3 seconds for now +const SYNC_INTERVAL_SECONDS: u64 = 3; + +/// Implementation of the ProverClient trait from wallet-core +pub struct Prover { + state: RuskHttpClient, + prover: RuskHttpClient, + status: fn(status: &str), +} + +impl Prover { + pub fn new(state: RuskHttpClient, prover: RuskHttpClient) -> Self { + Prover { + state, + prover, + status: |_| {}, + } + } + + /// Sets the callback method to send status updates + pub fn set_status_callback(&mut self, status: fn(&str)) { + self.status = status; + } + + pub async fn check_connection(&self) -> Result<(), reqwest::Error> { + self.state.check_connection().await?; + self.prover.check_connection().await + } +} + +impl ProverClient for Prover { + /// Error returned by the prover client. + type Error = Error; + + /// Requests that a node prove the given transaction and later propagates it + fn compute_proof_and_propagate( + &self, + utx: &UnprovenTransaction, + ) -> Result { + self.status("Proving tx, please wait..."); + let utx_bytes = utx.to_var_bytes(); + let prove_req = RuskRequest::new("prove_execute", utx_bytes); + let proof_bytes = self.prover.call(2, "rusk", &prove_req).wait()?; + self.status("Proof success!"); + let proof = Proof::from_slice(&proof_bytes).map_err(Error::Bytes)?; + let tx = utx.clone().prove(proof); + let tx_bytes = tx.to_var_bytes(); + + self.status("Attempt to preverify tx..."); + let preverify_req = RuskRequest::new("preverify", tx_bytes.clone()); + let _ = self.state.call(2, "rusk", &preverify_req).wait()?; + self.status("Preverify success!"); + + self.status("Propagating tx..."); + let propagate_req = RuskRequest::new("propagate_tx", tx_bytes); + let _ = self.state.call(2, "Chain", &propagate_req).wait()?; + self.status("Transaction propagated!"); + + Ok(tx) + } + + /// Requests an STCT proof. + fn request_stct_proof( + &self, + fee: &Fee, + crossover: &Crossover, + value: u64, + blinder: JubJubScalar, + address: BlsScalar, + signature: Signature, + ) -> Result { + let mut buf = [0; STCT_INPUT_SIZE]; + let mut writer = &mut buf[..]; + writer.write(&fee.to_bytes())?; + writer.write(&crossover.to_bytes())?; + writer.write(&value.to_bytes())?; + writer.write(&blinder.to_bytes())?; + writer.write(&address.to_bytes())?; + writer.write(&signature.to_bytes())?; + + self.status("Requesting stct proof..."); + + let prove_req = RuskRequest::new("prove_stct", buf.to_vec()); + let res = self.prover.call(2, "rusk", &prove_req).wait()?; + + self.status("Stct proof success!"); + + let mut proof_bytes = [0u8; Proof::SIZE]; + proof_bytes.copy_from_slice(&res); + + let proof = Proof::from_bytes(&proof_bytes)?; + Ok(proof) + } + + /// Request a WFCT proof. + fn request_wfct_proof( + &self, + commitment: JubJubAffine, + value: u64, + blinder: JubJubScalar, + ) -> Result { + let mut buf = [0; WFCT_INPUT_SIZE]; + let mut writer = &mut buf[..]; + writer.write(&commitment.to_bytes())?; + writer.write(&value.to_bytes())?; + writer.write(&blinder.to_bytes())?; + + self.status("Requesting wfct proof..."); + let prove_req = RuskRequest::new("prove_wfct", buf.to_vec()); + let res = self.prover.call(2, "rusk", &prove_req).wait()?; + self.status("Wfct proof success!"); + + let mut proof_bytes = [0u8; Proof::SIZE]; + proof_bytes.copy_from_slice(&res); + + let proof = Proof::from_bytes(&proof_bytes)?; + Ok(proof) + } +} + +impl Prover { + fn status(&self, text: &str) { + (self.status)(text) + } +} + +/// Implementation of the StateClient trait from wallet-core +/// inner is an option because we don't want to open the db twice and lock it +/// We construct StateStore twice +pub struct StateStore { + inner: Mutex, + status: fn(&str), + pub(crate) store: LocalStore, +} + +struct InnerState { + client: RuskHttpClient, + cache: Arc, +} + +impl StateStore { + /// Creates a new state instance. Should only be called once. + pub(crate) fn new( + client: RuskHttpClient, + data_dir: &Path, + store: LocalStore, + status: fn(&str), + ) -> Result { + let cache = Arc::new(Cache::new(data_dir, &store, status)?); + let inner = Mutex::new(InnerState { client, cache }); + + Ok(Self { + inner, + status, + store, + }) + } + + pub async fn check_connection(&self) -> Result<(), reqwest::Error> { + let client = { self.inner.lock().unwrap().client.clone() }; + + client.check_connection().await + } + + pub async fn register_sync( + &self, + sync_tx: Sender, + ) -> Result<(), Error> { + let state = self.inner.lock().unwrap(); + let status = self.status; + let store = self.store.clone(); + let client = state.client.clone(); + let cache = Arc::clone(&state.cache); + let sender = Arc::new(sync_tx); + + status("Starting Sync.."); + + tokio::spawn(async move { + loop { + let status = |_: &_| {}; + let sender = Arc::clone(&sender); + let _ = sender.send("Syncing..".to_string()); + + let sync_status = + sync_db(&client, &store, &cache, status).await; + let _ = match sync_status { + Ok(_) => sender.send("Syncing Complete".to_string()), + Err(e) => sender.send(format!("Error during sync:.. {e}")), + }; + + sleep(Duration::from_secs(SYNC_INTERVAL_SECONDS)).await; + } + }); + + Ok(()) + } + + pub async fn sync(&self) -> Result<(), Error> { + let store = self.store.clone(); + let status = self.status; + let (cache, client) = { + let state = self.inner.lock().unwrap(); + + let cache = state.cache.clone(); + let client = state.client.clone(); + (cache, client) + }; + + sync_db(&client, &store, cache.as_ref(), status).await + } + + pub(crate) fn cache(&self) -> Arc { + let state = self.inner.lock().unwrap(); + Arc::clone(&state.cache) + } +} + +/// Types that are clients of the state API. +impl StateClient for StateStore { + /// Error returned by the node client. + type Error = Error; + + /// Find notes for a view key, starting from the given block height. + fn fetch_notes( + &self, + vk: &ViewKey, + ) -> Result, Self::Error> { + let psk = vk.public_spend_key(); + let state = self.inner.lock().unwrap(); + + Ok(state + .cache + .notes(&psk)? + .into_iter() + .map(|data| (data.note, data.height)) + .collect()) + } + + /// Fetch the current anchor of the state. + fn fetch_anchor(&self) -> Result { + let state = self.inner.lock().unwrap(); + + self.status("Fetching anchor..."); + + let anchor = state + .client + .contract_query::<(), 0>(TRANSFER_CONTRACT, "root", &()) + .wait()?; + self.status("Anchor received!"); + let anchor = rkyv::from_bytes(&anchor).map_err(|_| Error::Rkyv)?; + Ok(anchor) + } + + /// Asks the node to return the nullifiers that already exist from the given + /// nullifiers. + fn fetch_existing_nullifiers( + &self, + _nullifiers: &[BlsScalar], + ) -> Result, Self::Error> { + Ok(vec![]) + } + + /// Queries the node to find the opening for a specific note. + fn fetch_opening( + &self, + note: &Note, + ) -> Result, Self::Error> { + let state = self.inner.lock().unwrap(); + + self.status("Fetching opening notes..."); + + let data = state + .client + .contract_query::<_, 1024>(TRANSFER_CONTRACT, "opening", note.pos()) + .wait()?; + + self.status("Opening notes received!"); + + let branch = rkyv::from_bytes(&data).map_err(|_| Error::Rkyv)?; + Ok(branch) + } + + /// Queries the node for the amount staked by a key. + fn fetch_stake(&self, pk: &PublicKey) -> Result { + let state = self.inner.lock().unwrap(); + + self.status("Fetching stake..."); + + let data = state + .client + .contract_query::<_, 1024>(STAKE_CONTRACT, "get_stake", pk) + .wait()?; + + let res: Option = + rkyv::from_bytes(&data).map_err(|_| Error::Rkyv)?; + self.status("Stake received!"); + + let staking_address = pk.to_bytes().to_vec(); + let staking_address = bs58::encode(staking_address).into_string(); + println!("Staking address: {}", staking_address); + + // FIX_ME: proper solution should to return an Option + // changing the trait implementation. That would reflect the state of + // the stake contract. It would be up to the consumer to decide what to + // do with a None + let stake = res + .map( + |StakeData { + amount, + reward, + counter, + }| StakeInfo { + amount, + reward, + counter, + }, + ) + .unwrap_or_default(); + + Ok(stake) + } +} + +impl StateStore { + fn status(&self, text: &str) { + (self.status)(text) + } +} diff --git a/rusk-wallet/src/clients/sync.rs b/rusk-wallet/src/clients/sync.rs new file mode 100644 index 0000000000..40e2e27ee6 --- /dev/null +++ b/rusk-wallet/src/clients/sync.rs @@ -0,0 +1,135 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use std::mem::size_of; + +use dusk_plonk::prelude::BlsScalar; +use dusk_wallet_core::Store; +use futures::StreamExt; +use phoenix_core::transaction::{ArchivedTreeLeaf, TreeLeaf}; + +use crate::block::Block; +use crate::clients::{Cache, TRANSFER_CONTRACT}; +use crate::rusk::RuskHttpClient; +use crate::store::LocalStore; +use crate::{Error, RuskRequest, MAX_ADDRESSES}; + +const RKYV_TREE_LEAF_SIZE: usize = size_of::(); + +pub(crate) async fn sync_db( + client: &RuskHttpClient, + store: &LocalStore, + cache: &Cache, + status: F, +) -> Result<(), Error> { + let addresses: Vec<_> = (0..MAX_ADDRESSES) + .flat_map(|i| store.retrieve_ssk(i as u64)) + .map(|ssk| { + let vk = ssk.view_key(); + let psk = vk.public_spend_key(); + (ssk, vk, psk) + }) + .collect(); + + status("Getting cached note position..."); + + let last_pos = cache.last_pos()?; + let pos_to_search = last_pos.map(|p| p + 1).unwrap_or_default(); + let mut last_pos = last_pos.unwrap_or_default(); + + status("Fetching fresh notes..."); + + let req = rkyv::to_bytes::<_, 8>(&(pos_to_search)) + .map_err(|_| Error::Rkyv)? + .to_vec(); + + let mut stream = client + .call_raw( + 1, + TRANSFER_CONTRACT, + &RuskRequest::new("leaves_from_pos", req), + true, + ) + .await? + .bytes_stream(); + + status("Connection established..."); + + status("Streaming notes..."); + + // This buffer is needed because `.bytes_stream();` introduce additional + // spliting of chunks according to it's own buffer + let mut buffer = vec![]; + + while let Some(http_chunk) = stream.next().await { + buffer.extend_from_slice(&http_chunk?); + + let mut leaf_chunk = buffer.chunks_exact(RKYV_TREE_LEAF_SIZE); + + for leaf_bytes in leaf_chunk.by_ref() { + let TreeLeaf { block_height, note } = + rkyv::from_bytes(leaf_bytes).map_err(|_| Error::Rkyv)?; + + last_pos = std::cmp::max(last_pos, *note.pos()); + + for (ssk, vk, psk) in addresses.iter() { + if vk.owns(¬e) { + let nullifier = note.gen_nullifier(ssk); + let spent = + fetch_existing_nullifiers_remote(client, &[nullifier]) + .wait()? + .first() + .is_some(); + let note = (note, nullifier); + match spent { + true => cache.insert_spent(psk, block_height, note), + false => cache.insert(psk, block_height, note), + }?; + + break; + } + } + cache.insert_last_pos(last_pos)?; + } + + buffer = leaf_chunk.remainder().to_vec(); + } + + // Remove spent nullifiers from live notes + for (_, _, psk) in addresses { + let nullifiers = cache.unspent_notes_id(&psk)?; + + if !nullifiers.is_empty() { + let existing = + fetch_existing_nullifiers_remote(client, &nullifiers).wait()?; + cache.spend_notes(&psk, &existing)?; + } + } + Ok(()) +} + +/// Asks the node to return the nullifiers that already exist from the given +/// nullifiers. +pub(crate) async fn fetch_existing_nullifiers_remote( + client: &RuskHttpClient, + nullifiers: &[BlsScalar], +) -> Result, Error> { + if nullifiers.is_empty() { + return Ok(vec![]); + } + let nullifiers = nullifiers.to_vec(); + let data = client + .contract_query::<_, 1024>( + TRANSFER_CONTRACT, + "existing_nullifiers", + &nullifiers, + ) + .await?; + + let nullifiers = rkyv::from_bytes(&data).map_err(|_| Error::Rkyv)?; + + Ok(nullifiers) +} diff --git a/rusk-wallet/src/crypto.rs b/rusk-wallet/src/crypto.rs new file mode 100644 index 0000000000..6547f95282 --- /dev/null +++ b/rusk-wallet/src/crypto.rs @@ -0,0 +1,63 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use aes::Aes256; +use block_modes::block_padding::Pkcs7; +use block_modes::{BlockMode, Cbc}; +use rand::rngs::OsRng; +use rand::RngCore; + +use crate::Error; + +type Aes256Cbc = Cbc; + +/// Encrypts data using a password. +pub(crate) fn encrypt(plaintext: &[u8], pwd: &[u8]) -> Result, Error> { + let mut iv = vec![0; 16]; + let mut rng = OsRng; + rng.fill_bytes(&mut iv); + + let cipher = Aes256Cbc::new_from_slices(pwd, &iv)?; + let enc = cipher.encrypt_vec(plaintext); + + let ciphertext = iv.into_iter().chain(enc).collect(); + Ok(ciphertext) +} + +/// Decrypts data encrypted with `encrypt`. +pub(crate) fn decrypt(ciphertext: &[u8], pwd: &[u8]) -> Result, Error> { + let iv = &ciphertext[..16]; + let enc = &ciphertext[16..]; + + let cipher = Aes256Cbc::new_from_slices(pwd, iv)?; + let plaintext = cipher.decrypt_vec(enc)?; + + Ok(plaintext) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encrypt_and_decrypt() { + let seed = + b"0001020304050607000102030405060700010203040506070001020304050607"; + let pwd = blake3::hash("greatpassword".as_bytes()); + let pwd = pwd.as_bytes(); + + let enc_seed = encrypt(seed, pwd).expect("seed to encrypt ok"); + let enc_seed_t = encrypt(seed, pwd).expect("seed to encrypt ok"); + + // check that random IV is correctly applied + assert_ne!(enc_seed, enc_seed_t); + + let dec_seed = decrypt(&enc_seed, pwd).expect("seed to decrypt ok"); + + // check that decryption matches original seed + assert_eq!(dec_seed, seed); + } +} diff --git a/rusk-wallet/src/currency.rs b/rusk-wallet/src/currency.rs new file mode 100644 index 0000000000..8222045d18 --- /dev/null +++ b/rusk-wallet/src/currency.rs @@ -0,0 +1,302 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use core::cmp::Ordering; +use std::fmt; +use std::hash::{Hash, Hasher}; +use std::num::ParseFloatError; +use std::ops::{Add, Deref, Div, Mul, Sub}; +use std::str::FromStr; + +use rusk_abi::dusk; + +/// The underlying unit of Dusk +pub type Lux = u64; + +/// Denomination for DUSK +#[derive(Copy, Clone, Debug, Eq)] +pub struct Dusk(Lux); + +impl Dusk { + /// The smallest value that can be represented by Dusk currency + pub const MIN: Dusk = Dusk(0); + /// The largest value that can be represented by Dusk currency + pub const MAX: Dusk = Dusk(dusk::dusk(f64::MAX / dusk::dusk(1.0) as f64)); + + /// Returns a new Dusk based on the [Lux] given + pub const fn new(lux: Lux) -> Dusk { + Self(lux) + } +} + +/// Core ops +/// Implementations of Addition, Subtraction, Multiplication, +/// Division, and Comparison operators for Dusk + +/// Addition +impl Add for Dusk { + type Output = Self; + fn add(self, other: Self) -> Self { + Self(self.0 + other.0) + } +} + +impl Add for Dusk { + type Output = Self; + fn add(self, other: Lux) -> Self { + Self(self.0 + other) + } +} + +/// Subtraction +impl Sub for Dusk { + type Output = Self; + fn sub(self, other: Self) -> Self { + Self(self.0 - other.0) + } +} + +impl Sub for Dusk { + type Output = Self; + fn sub(self, other: Lux) -> Self { + Self(self.0 - other) + } +} + +/// Multiplication +impl Mul for Dusk { + type Output = Self; + fn mul(self, other: Self) -> Self { + let a = dusk::from_dusk(self.0); + let b = dusk::from_dusk(other.0); + Self(dusk::dusk(a * b)) + } +} + +impl Mul for Dusk { + type Output = Self; + fn mul(self, other: Lux) -> Self { + let a = dusk::from_dusk(self.0); + let b = dusk::from_dusk(other); + Self(dusk::dusk(a * b)) + } +} + +/// Division +impl Div for Dusk { + type Output = Self; + fn div(self, other: Self) -> Self { + Self(dusk::dusk(self.0 as f64 / other.0 as f64)) + } +} + +impl Div for Dusk { + type Output = Self; + fn div(self, other: Lux) -> Self { + Self(dusk::dusk(self.0 as f64 / other as f64)) + } +} + +/// Equality +impl Hash for Dusk { + fn hash(&self, state: &mut H) { + self.0.hash(state); + } +} +impl PartialEq for Dusk { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} +impl PartialEq for Dusk { + fn eq(&self, other: &Lux) -> bool { + self.0 == *other + } +} +impl PartialEq for Dusk { + fn eq(&self, other: &f64) -> bool { + self.0 == dusk::dusk(*other) + } +} + +/// Comparison +impl Ord for Dusk { + fn cmp(&self, other: &Self) -> Ordering { + self.0.cmp(other) + } +} + +impl PartialOrd for Dusk { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl PartialOrd for Dusk { + fn partial_cmp(&self, other: &Lux) -> Option { + self.0.partial_cmp(other) + } +} +impl PartialOrd for Dusk { + fn partial_cmp(&self, other: &f64) -> Option { + self.0.partial_cmp(&dusk::dusk(*other)) + } +} + +/// Conversion ops +/// Convenient conversion of primitives to and from Dusk + +/// Floats are used directly as Dusk value +impl From for Dusk { + fn from(val: f64) -> Self { + if val < 0.0 { + panic!("Dusk type does not support negative values"); + } + Self(dusk::dusk(val)) + } +} + +impl From for f64 { + fn from(val: Dusk) -> f64 { + dusk::from_dusk(*val) + } +} + +impl From<&Dusk> for f64 { + fn from(val: &Dusk) -> f64 { + (*val).into() + } +} + +/// Lux represent Dusk in their underlying unit type +impl From for Dusk { + fn from(lux: Lux) -> Self { + Self(lux) + } +} + +/// Strings are parsed as Dusk values (floats) +impl FromStr for Dusk { + type Err = ParseFloatError; + + fn from_str(s: &str) -> Result { + f64::from_str(s).map(Dusk::from) + } +} + +/// Dusk derefs into its underlying Lux amount +impl Deref for Dusk { + type Target = Lux; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +/// Display +/// Let the user print stuff +impl fmt::Display for Dusk { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let v: f64 = self.into(); + f64::fmt(&v, f) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn basics() { + let one = Dusk::from(1.0); + let dec = Dusk::from(2.25); + assert_eq!(one, 1.0); + assert_eq!(dec, 2.25); + assert_eq!(Dusk::MIN, 0); + assert_eq!(Dusk::MIN, Dusk::from(0.0)); + } + + #[test] + fn compare_dusk() { + let one = Dusk::from(1.0); + let two = Dusk::from(2.0); + let dec_a = Dusk::from(0.00025); + let dec_b = Dusk::from(0.00190); + assert!(one == one); + assert!(one != two); + assert!(one < two); + assert!(one <= two); + assert!(one >= one); + assert!(dec_a < dec_b); + assert!(one > dec_b); + } + + #[test] + fn ops_dusk_dusk() { + let one = Dusk::from(1.0); + let two = Dusk::from(2.0); + let three = Dusk::from(3.0); + assert_eq!(one + two, three); + assert_eq!(three - two, one); + assert_eq!(one * one, one); + assert_eq!(two * one, two); + assert_eq!(two / one, two); + let point_five = Dusk::from(0.5); + assert_eq!(one / two, point_five); + assert_eq!(point_five * point_five, Dusk::from(0.25)) + } + + #[test] + fn ops_dusk_lux() { + let one = Dusk::from(1.0); + let one_dusk = 1000000000; + assert_eq!(one + one_dusk, 2.0); + assert_eq!(one - one_dusk, 0.0); + assert_eq!(one * one_dusk, 1.0); + assert_eq!(one / one_dusk, 1.0); + } + + #[test] + fn conversions() { + let my_float = 35.049; + let dusk: Dusk = my_float.into(); + assert_eq!(dusk, my_float); + let one_dusk = 1_000_000_000u64; + let dusk: Dusk = one_dusk.into(); + assert_eq!(dusk, 1.0); + assert_eq!(*dusk, one_dusk); + let dusk = Dusk::from_str("69.420").unwrap(); + assert_eq!(dusk, 69.420); + let float: f64 = dusk.into(); + assert_eq!(float, 69.420); + let borrowed = &Dusk(one_dusk); + let float: f64 = borrowed.into(); + assert_eq!(float, 1.0); + let zero = 0; + assert_eq!(Dusk::from(zero), 0); + let zero = 0.0; + assert_eq!(Dusk::from(zero), 0.0); + } + + #[test] + #[should_panic] + fn overflow() { + let ten = Dusk::from(10.0); + let _ = Dusk::MAX + ten; + } + + #[test] + #[should_panic] + fn negative_dusk() { + let _ = Dusk::from(-1.0); + } + + #[test] + #[should_panic] + fn negative_result() { + let one = Dusk::from(1.0); + let two = Dusk::from(2.0); + let _ = one - two; + } +} diff --git a/rusk-wallet/src/dat.rs b/rusk-wallet/src/dat.rs new file mode 100644 index 0000000000..836f549e2a --- /dev/null +++ b/rusk-wallet/src/dat.rs @@ -0,0 +1,248 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use dusk_bytes::DeserializableSlice; + +use std::fs; +use std::io::Read; + +use crate::crypto::decrypt; +use crate::store; +use crate::Error; +use crate::WalletPath; + +/// Binary prefix for old Dusk wallet files +pub const OLD_MAGIC: u32 = 0x1d0c15; +/// Binary prefix for new binary file format +pub const MAGIC: u32 = 0x72736b; +/// The latest version of the rusk binary format for wallet dat file +pub const LATEST_VERSION: Version = (0, 0, 1, 0, false); +/// The type info of the dat file we'll save +pub const FILE_TYPE: u16 = 0x0200; +/// Reserved for futures use, 0 for now +pub const RESERVED: u16 = 0x0000; +/// (Major, Minor, Patch, Pre, Pre-Higher) +type Version = (u8, u8, u8, u8, bool); + +/// Versions of the potential wallet DAT files we read +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum DatFileVersion { + /// Legacy the oldest format + Legacy, + /// Preciding legacy, we have the old one + OldWalletCli(Version), + /// The newest one. All new saves are saved in this file format + RuskBinaryFileFormat(Version), +} + +impl DatFileVersion { + /// Checks if the file version is older than the latest Rust Binary file + /// format + pub fn is_old(&self) -> bool { + matches!(self, Self::Legacy | Self::OldWalletCli(_)) + } +} + +/// Make sense of the payload and return it +pub(crate) fn get_seed_and_address( + file: DatFileVersion, + mut bytes: Vec, + pwd: &[u8], +) -> Result<(store::Seed, u8), Error> { + match file { + DatFileVersion::Legacy => { + if bytes[1] == 0 && bytes[2] == 0 { + bytes.drain(..3); + } + + bytes = decrypt(&bytes, pwd)?; + + // get our seed + let seed = store::Seed::from_reader(&mut &bytes[..]) + .map_err(|_| Error::WalletFileCorrupted)?; + + Ok((seed, 1)) + } + DatFileVersion::OldWalletCli((major, minor, _, _, _)) => { + bytes.drain(..5); + + let result: Result<(store::Seed, u8), Error> = match (major, minor) + { + (1, 0) => { + let content = decrypt(&bytes, pwd)?; + let mut buff = &content[..]; + + let seed = store::Seed::from_reader(&mut buff) + .map_err(|_| Error::WalletFileCorrupted)?; + + Ok((seed, 1)) + } + (2, 0) => { + let content = decrypt(&bytes, pwd)?; + let mut buff = &content[..]; + + // extract seed + let seed = store::Seed::from_reader(&mut buff) + .map_err(|_| Error::WalletFileCorrupted)?; + + // extract addresses count + Ok((seed, buff[0])) + } + _ => Err(Error::UnknownFileVersion(major, minor)), + }; + + result + } + DatFileVersion::RuskBinaryFileFormat(_) => { + let rest = bytes.get(12..(12 + 96)); + if let Some(rest) = rest { + let content = decrypt(rest, pwd)?; + + if let Some(seed_buff) = content.get(0..65) { + let seed = store::Seed::from_reader(&mut &seed_buff[0..64]) + .map_err(|_| Error::WalletFileCorrupted)?; + + let count = &seed_buff[64..65]; + + Ok((seed, count[0])) + } else { + Err(Error::WalletFileCorrupted) + } + } else { + Err(Error::WalletFileCorrupted) + } + } + } +} + +/// From the first 12 bytes of the file (header), we check version +/// +/// https://github.com/dusk-network/rusk/wiki/Binary-File-Format/#header +pub(crate) fn check_version( + bytes: Option<&[u8]>, +) -> Result { + match bytes { + Some(bytes) => { + let header_bytes: [u8; 4] = bytes[0..4] + .try_into() + .map_err(|_| Error::WalletFileCorrupted)?; + + let magic = u32::from_le_bytes(header_bytes) & 0x00ffffff; + + if magic == OLD_MAGIC { + // check for version information + let (major, minor) = (bytes[3], bytes[4]); + + Ok(DatFileVersion::OldWalletCli((major, minor, 0, 0, false))) + } else { + let header_bytes = bytes[0..8] + .try_into() + .map_err(|_| Error::WalletFileCorrupted)?; + + let number = u64::from_be_bytes(header_bytes); + + let magic_num = (number & 0xFFFFFF00000000) >> 32; + + if (magic_num as u32) != MAGIC { + return Ok(DatFileVersion::Legacy); + } + + let file_type = (number & 0x000000FFFF0000) >> 16; + let reserved = number & 0x0000000000FFFF; + + if file_type != FILE_TYPE as u64 { + return Err(Error::WalletFileCorrupted); + }; + + if reserved != RESERVED as u64 { + return Err(Error::WalletFileCorrupted); + }; + + let version_bytes = bytes[8..12] + .try_into() + .map_err(|_| Error::WalletFileCorrupted)?; + + let version = u32::from_be_bytes(version_bytes); + + let major = (version & 0xff000000) >> 24; + let minor = (version & 0x00ff0000) >> 16; + let patch = (version & 0x0000ff00) >> 8; + let pre = (version & 0x000000f0) >> 4; + let higher = version & 0x0000000f; + + let pre_higher = matches!(higher, 1); + + Ok(DatFileVersion::RuskBinaryFileFormat(( + major as u8, + minor as u8, + patch as u8, + pre as u8, + pre_higher, + ))) + } + } + None => Err(Error::WalletFileCorrupted), + } +} + +/// Read the first 12 bytes of the dat file and get the file version from +/// there +pub fn read_file_version(file: &WalletPath) -> Result { + let path = &file.wallet; + + // make sure file exists + if !path.is_file() { + return Err(Error::WalletFileMissing); + } + + let mut fs = fs::File::open(path)?; + + let mut header_buf = [0; 12]; + + fs.read_exact(&mut header_buf)?; + + check_version(Some(&header_buf)) +} + +pub(crate) fn version_bytes(version: Version) -> [u8; 4] { + u32::from_be_bytes([version.0, version.1, version.2, version.3]) + .to_be_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn distiction_between_versions() { + // with magic number + let old_wallet_file = vec![0x15, 0x0c, 0x1d, 0x02, 0x00]; + // no magic number just nonsense bytes + let legacy_file = vec![ + 0xab, 0x38, 0x81, 0x3b, 0xfc, 0x79, 0x11, 0xf9, 0x86, 0xd6, 0xd0, + ]; + // new header + let new_file = vec![ + 0x00, 0x72, 0x73, 0x6b, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + 0x00, + ]; + + assert_eq!( + check_version(Some(&old_wallet_file)).unwrap(), + DatFileVersion::OldWalletCli((2, 0, 0, 0, false)) + ); + + assert_eq!( + check_version(Some(&legacy_file)).unwrap(), + DatFileVersion::Legacy + ); + + assert_eq!( + check_version(Some(&new_file)).unwrap(), + DatFileVersion::RuskBinaryFileFormat((0, 0, 1, 0, false)) + ); + } +} diff --git a/rusk-wallet/src/error.rs b/rusk-wallet/src/error.rs new file mode 100644 index 0000000000..ba1a892525 --- /dev/null +++ b/rusk-wallet/src/error.rs @@ -0,0 +1,164 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use crate::clients::StateStore; +use crate::store::LocalStore; +use phoenix_core::Error as PhoenixError; +use rand_core::Error as RngError; +use std::io; +use std::str::Utf8Error; + +use super::clients; +/// Wallet core error +pub(crate) type CoreError = + dusk_wallet_core::Error; + +/// Errors returned by this library +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Command not available in offline mode + #[error("This command cannot be performed while offline")] + Offline, + /// Unauthorized access to this address + #[error("Unauthorized access to this address")] + Unauthorized, + /// Rusk error + #[error("Rusk error occurred: {0}")] + Rusk(String), + /// Filesystem errors + #[error(transparent)] + IO(#[from] io::Error), + /// JSON serialization errors + #[error(transparent)] + Json(#[from] serde_json::Error), + /// Bytes encoding errors + #[error("A serialization error occurred: {0:?}")] + Bytes(dusk_bytes::Error), + /// Base58 errors + #[error(transparent)] + Base58(#[from] bs58::decode::Error), + /// Rkyv errors + #[error("A serialization error occurred.")] + Rkyv, + /// Reqwest errors + #[error("A request error occurred: {0}")] + Reqwest(#[from] reqwest::Error), + /// Utf8 errors + #[error("Utf8 error: {0:?}")] + Utf8(Utf8Error), + /// Random number generator errors + #[error(transparent)] + Rng(#[from] RngError), + /// Transaction model errors + #[error("An error occurred in Phoenix: {0:?}")] + Phoenix(PhoenixError), + /// Not enough balance to perform transaction + #[error("Insufficient balance to perform this operation")] + NotEnoughBalance, + /// Amount to transfer/stake cannot be zero + #[error("Amount to transfer/stake cannot be zero")] + AmountIsZero, + /// Note combination for the given value is impossible given the maximum + /// amount of inputs in a transaction + #[error("Impossible notes' combination for the given value is")] + NoteCombinationProblem, + /// Not enough gas to perform this transaction + #[error("Not enough gas to perform this transaction")] + NotEnoughGas, + /// A stake already exists for this key + #[error("A stake already exists for this key")] + AlreadyStaked, + /// A stake does not exist for this key + #[error("A stake does not exist for this key")] + NotStaked, + /// No reward available for this key + #[error("No reward available for this key")] + NoReward, + /// Invalid address + #[error("Invalid address")] + BadAddress, + /// Address does not belong to this wallet + #[error("Address does not belong to this wallet")] + AddressNotOwned, + /// Recovery phrase is not valid + #[error("Invalid recovery phrase")] + InvalidMnemonicPhrase, + /// Path provided is not a directory + #[error("Path provided is not a directory")] + NotDirectory, + /// Wallet file content is not valid + #[error("Wallet file content is not valid")] + WalletFileCorrupted, + /// File version not recognized + #[error("File version {0}.{1} not recognized")] + UnknownFileVersion(u8, u8), + /// A wallet file with this name already exists + #[error("A wallet file with this name already exists")] + WalletFileExists, + /// Wallet file is missing + #[error("Wallet file is missing")] + WalletFileMissing, + /// Wrong wallet password + #[error("Invalid password")] + BlockMode(#[from] block_modes::BlockModeError), + /// Reached the maximum number of attempts + #[error("Reached the maximum number of attempts")] + AttemptsExhausted, + /// Status callback needs to be set before connecting + #[error("Status callback needs to be set before connecting")] + StatusWalletConnected, + /// Transaction error + #[error("Transaction error: {0}")] + Transaction(String), + /// Rocksdb cache database error + #[error("Rocks cache database error: {0}")] + RocksDB(rocksdb::Error), + /// Provided Network not found + #[error( + "Network not found, check config.toml, specify network with -n flag" + )] + NetworkNotFound, + /// The cache database couldn't find column family required + #[error("Cache database corrupted")] + CacheDatabaseCorrupted, +} + +impl From for Error { + fn from(e: dusk_bytes::Error) -> Self { + Self::Bytes(e) + } +} + +impl From for Error { + fn from(_: block_modes::InvalidKeyIvLength) -> Self { + Self::WalletFileCorrupted + } +} + +impl From for Error { + fn from(e: CoreError) -> Self { + use dusk_wallet_core::Error::*; + match e { + Store(err) | State(err) | Prover(err) => err, + Rkyv => Self::Rkyv, + Rng(err) => Self::Rng(err), + Bytes(err) => Self::Bytes(err), + Phoenix(err) => Self::Phoenix(err), + NotEnoughBalance => Self::NotEnoughBalance, + NoteCombinationProblem => Self::NoteCombinationProblem, + AlreadyStaked { .. } => Self::AlreadyStaked, + NotStaked { .. } => Self::NotStaked, + NoReward { .. } => Self::NoReward, + Utf8(err) => Self::Utf8(err.utf8_error()), + } + } +} + +impl From for Error { + fn from(e: rocksdb::Error) -> Self { + Self::RocksDB(e) + } +} diff --git a/rusk-wallet/src/lib.rs b/rusk-wallet/src/lib.rs new file mode 100644 index 0000000000..ab967a9fe4 --- /dev/null +++ b/rusk-wallet/src/lib.rs @@ -0,0 +1,60 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! # Dusk Wallet Lib +//! +//! The `dusk_wallet` library aims to provide an easy and convenient way of +//! interfacing with the Dusk Network. +//! +//! Clients can use `Wallet` to create their Dusk wallet, send transactions +//! through the network of their choice, stake and withdraw rewards, etc. + +#![deny(missing_docs)] + +mod block; +mod cache; +mod clients; +mod crypto; + +mod currency; +mod error; +mod rusk; +mod store; +mod wallet; + +/// Methods for parsing/checking the DAT wallet file +pub mod dat; + +pub use rusk::{RuskHttpClient, RuskRequest}; + +pub use currency::{Dusk, Lux}; +pub use error::Error; +pub use wallet::gas; +pub use wallet::{Address, DecodedNote, SecureWalletFile, Wallet, WalletPath}; + +/// The largest amount of Dusk that is possible to convert +pub const MAX_CONVERTIBLE: Dusk = Dusk::MAX; +/// The smallest amount of Dusk that is possible to convert +pub const MIN_CONVERTIBLE: Dusk = Dusk::new(1); +/// The length of an epoch in blocks +pub const EPOCH: u64 = 2160; +/// Max addresses the wallet can store +pub const MAX_ADDRESSES: usize = get_max_addresses(); + +const DEFAULT_MAX_ADDRESSES: usize = 25; + +const fn get_max_addresses() -> usize { + match option_env!("WALLET_MAX_ADDR") { + Some(v) => match konst::primitive::parse_usize(v) { + Ok(e) if e > 255 => { + panic!("WALLET_MAX_ADDR must be lower or equal to 255") + } + Ok(e) if e > 0 => e, + _ => panic!("Invalid WALLET_MAX_ADDR"), + }, + None => DEFAULT_MAX_ADDRESSES, + } +} diff --git a/rusk-wallet/src/rusk.rs b/rusk-wallet/src/rusk.rs new file mode 100644 index 0000000000..fbcd06581d --- /dev/null +++ b/rusk-wallet/src/rusk.rs @@ -0,0 +1,127 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use std::io::{self, Write}; + +use reqwest::{Body, Response}; +use rkyv::Archive; + +use crate::Error; + +/// Supported Rusk version +const REQUIRED_RUSK_VERSION: &str = "0.7.0"; + +#[derive(Debug)] +/// RuskRequesst according to the rusk event system +pub struct RuskRequest { + topic: String, + data: Vec, +} + +impl RuskRequest { + /// New RuskRequesst from topic and data + pub fn new(topic: &str, data: Vec) -> Self { + let topic = topic.to_string(); + Self { data, topic } + } + + /// Return the binary representation of the RuskRequesst + pub fn to_bytes(&self) -> io::Result> { + let mut buffer = vec![]; + buffer.write_all(&(self.topic.len() as u32).to_le_bytes())?; + buffer.write_all(self.topic.as_bytes())?; + buffer.write_all(&self.data)?; + + Ok(buffer) + } +} +#[derive(Clone)] +/// Rusk HTTP Binary Client +pub struct RuskHttpClient { + uri: String, +} + +impl RuskHttpClient { + /// Create a new HTTP Client + pub fn new(uri: String) -> Self { + Self { uri } + } + + /// Utility for querying the rusk VM + pub async fn contract_query( + &self, + contract: &str, + method: &str, + value: &I, + ) -> Result, Error> + where + I: Archive, + I: rkyv::Serialize>, + { + let data = rkyv::to_bytes(value).map_err(|_| Error::Rkyv)?.to_vec(); + let request = RuskRequest::new(method, data); + + let response = self.call_raw(1, contract, &request, false).await?; + + Ok(response.bytes().await?.to_vec()) + } + + /// Check rusk connection + pub async fn check_connection(&self) -> Result<(), reqwest::Error> { + reqwest::Client::new().post(&self.uri).send().await?; + Ok(()) + } + + /// Send a RuskRequest to a specific target. + /// + /// The response is interpreted as Binary + pub async fn call( + &self, + target_type: u8, + target: &str, + request: &RuskRequest, + ) -> Result, Error> { + let response = + self.call_raw(target_type, target, request, false).await?; + let data = response.bytes().await?; + Ok(data.to_vec()) + } + /// Send a RuskRequest to a specific target without parsing the response + pub async fn call_raw( + &self, + target_type: u8, + target: &str, + request: &RuskRequest, + feed: bool, + ) -> Result { + let uri = &self.uri; + let client = reqwest::Client::new(); + let mut request = client + .post(format!("{uri}/{target_type}/{target}")) + .body(Body::from(request.to_bytes()?)) + .header("Content-Type", "application/octet-stream") + .header("rusk-version", REQUIRED_RUSK_VERSION); + + if feed { + request = request.header("Rusk-Feeder", "1"); + } + let response = request.send().await?; + + let status = response.status(); + if status.is_client_error() || status.is_server_error() { + let error = &response.bytes().await?; + + let error = String::from_utf8(error.to_vec()) + .unwrap_or("unparsable error".into()); + + let msg = format!("{status}: {error}"); + + Err(Error::Rusk(msg)) + } else { + Ok(response) + } + } +} diff --git a/rusk-wallet/src/store.rs b/rusk-wallet/src/store.rs new file mode 100644 index 0000000000..d8470a3170 --- /dev/null +++ b/rusk-wallet/src/store.rs @@ -0,0 +1,62 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use crate::clients::StateStore; +use crate::Error; + +use dusk_bytes::{Error as BytesError, Serializable}; +use dusk_wallet_core::Store; + +#[derive(Clone)] +pub struct Seed([u8; 64]); + +impl Default for Seed { + fn default() -> Self { + Self([0u8; 64]) + } +} + +impl Serializable<64> for Seed { + type Error = BytesError; + + fn from_bytes(buff: &[u8; Seed::SIZE]) -> Result { + Ok(Self(*buff)) + } + fn to_bytes(&self) -> [u8; Seed::SIZE] { + self.0 + } +} + +/// Provides a valid wallet seed to dusk_wallet_core +#[derive(Clone)] +pub(crate) struct LocalStore { + seed: Seed, +} + +impl Store for LocalStore { + type Error = Error; + + /// Retrieves the seed used to derive keys. + fn get_seed(&self) -> Result<[u8; Seed::SIZE], Self::Error> { + Ok(self.seed.to_bytes()) + } +} + +impl Store for StateStore { + type Error = Error; + + /// Retrieves the seed used to derive keys. + fn get_seed(&self) -> Result<[u8; Seed::SIZE], Self::Error> { + Ok(self.store.seed.to_bytes()) + } +} + +impl LocalStore { + /// Creates a new store from a known seed + pub(crate) fn new(seed: Seed) -> Self { + LocalStore { seed } + } +} diff --git a/rusk-wallet/src/wallet.rs b/rusk-wallet/src/wallet.rs new file mode 100644 index 0000000000..54f5abab0d --- /dev/null +++ b/rusk-wallet/src/wallet.rs @@ -0,0 +1,798 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +mod address; +mod file; +pub mod gas; + +pub use address::Address; +use dusk_plonk::prelude::BlsScalar; +pub use file::{SecureWalletFile, WalletPath}; + +use bip39::{Language, Mnemonic, Seed}; +use dusk_bytes::{DeserializableSlice, Serializable}; +use ff::Field; +use flume::Receiver; +use phoenix_core::transaction::ModuleId; +use phoenix_core::Note; +use rkyv::ser::serializers::AllocSerializer; +use serde::Serialize; +use std::fmt::Debug; +use std::fs; +use std::path::{Path, PathBuf}; + +use dusk_bls12_381_sign::{PublicKey, SecretKey}; +use dusk_wallet_core::{ + BalanceInfo, StakeInfo, StateClient, Store, Transaction, + Wallet as WalletCore, MAX_CALL_SIZE, +}; +use rand::prelude::StdRng; +use rand::SeedableRng; + +use dusk_pki::{PublicSpendKey, SecretSpendKey}; + +use crate::cache::NoteData; +use crate::clients::{Prover, StateStore}; +use crate::crypto::encrypt; +use crate::currency::Dusk; +use crate::dat::{ + self, version_bytes, DatFileVersion, FILE_TYPE, LATEST_VERSION, MAGIC, + RESERVED, +}; +use crate::store::LocalStore; +use crate::{Error, RuskHttpClient}; +use gas::Gas; + +use crate::store; + +/// The interface to the Dusk Network +/// +/// The Wallet exposes all methods available to interact with the Dusk Network. +/// +/// A new [`Wallet`] can be created from a bip39-compatible mnemonic phrase or +/// an existing wallet file. +/// +/// The user can generate as many [`Address`] as needed without an active +/// connection to the network by calling [`Wallet::new_address`] repeatedly. +/// +/// A wallet must connect to the network using a [`RuskEndpoint`] in order to be +/// able to perform common operations such as checking balance, transfernig +/// funds, or staking Dusk. +pub struct Wallet { + wallet: Option>, + addresses: Vec
, + store: LocalStore, + file: Option, + file_version: Option, + status: fn(status: &str), + /// Recieve the status/errors of the sync procss + pub sync_rx: Option>, +} + +impl Wallet { + /// Returns the file used for the wallet + pub fn file(&self) -> &Option { + &self.file + } + + /// Returns spending key pair for a given address + pub fn spending_keys( + &self, + addr: &Address, + ) -> Result<(PublicSpendKey, SecretSpendKey), Error> { + // make sure we own the address + if !addr.is_owned() { + return Err(Error::Unauthorized); + } + + let index = addr.index()? as u64; + + // retrieve keys + let ssk = self.store.retrieve_ssk(index)?; + let psk: PublicSpendKey = ssk.public_spend_key(); + + Ok((psk, ssk)) + } +} + +impl Wallet { + /// Creates a new wallet instance deriving its seed from a valid BIP39 + /// mnemonic + pub fn new

(phrase: P) -> Result + where + P: Into, + { + // generate mnemonic + let phrase: String = phrase.into(); + let try_mnem = Mnemonic::from_phrase(&phrase, Language::English); + + if let Ok(mnemonic) = try_mnem { + // derive the mnemonic seed + let seed = Seed::new(&mnemonic, ""); + // Takes the mnemonic seed as bytes + let mut bytes = seed.as_bytes(); + + // Generate a Store Seed type from the mnemonic Seed bytes + let seed = store::Seed::from_reader(&mut bytes)?; + + let store = LocalStore::new(seed); + + // Generate the default address + let ssk = store + .retrieve_ssk(0) + .expect("wallet seed should be available"); + + let address = Address::new(0, ssk.public_spend_key()); + + // return new wallet instance + Ok(Wallet { + wallet: None, + addresses: vec![address], + store, + file: None, + file_version: None, + status: |_| {}, + sync_rx: None, + }) + } else { + Err(Error::InvalidMnemonicPhrase) + } + } + + /// Loads wallet given a session + pub fn from_file(file: F) -> Result { + let path = file.path(); + let pwd = file.pwd(); + + // make sure file exists + let pb = path.inner().clone(); + if !pb.is_file() { + return Err(Error::WalletFileMissing); + } + + // attempt to load and decode wallet + let bytes = fs::read(&pb)?; + + let file_version = dat::check_version(bytes.get(0..12))?; + + let (seed, address_count) = + dat::get_seed_and_address(file_version, bytes, pwd)?; + + let store = LocalStore::new(seed); + + // return early if its legacy + if let DatFileVersion::Legacy = file_version { + let ssk = store + .retrieve_ssk(0) + .expect("wallet seed should be available"); + + let address = Address::new(0, ssk.public_spend_key()); + + // return the store + return Ok(Self { + wallet: None, + addresses: vec![address], + store, + file: Some(file), + file_version: Some(DatFileVersion::Legacy), + status: |_| {}, + sync_rx: None, + }); + } + + let addresses: Vec<_> = (0..address_count) + .map(|i| { + let ssk = store + .retrieve_ssk(i as u64) + .expect("wallet seed should be available"); + + Address::new(i, ssk.public_spend_key()) + }) + .collect(); + + // create and return + Ok(Self { + wallet: None, + addresses, + store, + file: Some(file), + file_version: Some(file_version), + status: |_| {}, + sync_rx: None, + }) + } + + /// Saves wallet to file from which it was loaded + pub fn save(&mut self) -> Result<(), Error> { + match &self.file { + Some(f) => { + let mut header = Vec::with_capacity(12); + header.extend_from_slice(&MAGIC.to_be_bytes()); + // File type = Rusk Wallet (0x02) + header.extend_from_slice(&FILE_TYPE.to_be_bytes()); + // Reserved (0x0) + header.extend_from_slice(&RESERVED.to_be_bytes()); + // Version + header.extend_from_slice(&version_bytes(LATEST_VERSION)); + + // create file payload + let seed = self.store.get_seed()?; + let mut payload = seed.to_vec(); + + payload.push(self.addresses.len() as u8); + + // encrypt the payload + payload = encrypt(&payload, f.pwd())?; + + let mut content = + Vec::with_capacity(header.len() + payload.len()); + + content.extend_from_slice(&header); + content.extend_from_slice(&payload); + + // write the content to file + fs::write(&f.path().wallet, content)?; + Ok(()) + } + None => Err(Error::WalletFileMissing), + } + } + + /// Saves wallet to the provided file, changing the previous file path for + /// the wallet if any. Note that any subsequent calls to [`save`] will + /// use this new file. + pub fn save_to(&mut self, file: F) -> Result<(), Error> { + // set our new file and save + self.file = Some(file); + self.save() + } + + /// Connect the wallet to the network providing a callback for status + /// updates + pub async fn connect_with_status( + &mut self, + rusk_addr: S, + prov_addr: S, + status: fn(&str), + ) -> Result<(), Error> + where + S: Into, + { + // attempt connection + let http_state = RuskHttpClient::new(rusk_addr.into()); + let http_prover = RuskHttpClient::new(prov_addr.into()); + + let state_status = http_state.check_connection().await; + let prover_status = http_prover.check_connection().await; + + match (&state_status, prover_status) { + (Err(e),_)=> println!("Connection to Rusk Failed, some operations won't be available: {e}"), + (_,Err(e))=> println!("Connection to Prover Failed, some operations won't be available: {e}"), + _=> {}, + } + + // create a prover client + let mut prover = Prover::new(http_state.clone(), http_prover.clone()); + prover.set_status_callback(status); + + let cache_dir = { + if let Some(file) = &self.file { + file.path().cache_dir() + } else { + return Err(Error::WalletFileMissing); + } + }; + + // create a state client + let state = StateStore::new( + http_state, + &cache_dir, + self.store.clone(), + status, + )?; + + // create wallet instance + self.wallet = Some(WalletCore::new(self.store.clone(), state, prover)); + + // set our own status callback + self.status = status; + + Ok(()) + } + + /// Sync wallet state + pub async fn sync(&self) -> Result<(), Error> { + self.connected_wallet().await?.state().sync().await + } + + /// Helper function to register for async-sync outside of connect + pub async fn register_sync(&mut self) -> Result<(), Error> { + match self.wallet.as_ref() { + Some(w) => { + let (sync_tx, sync_rx) = flume::unbounded::(); + w.state().register_sync(sync_tx).await?; + self.sync_rx = Some(sync_rx); + Ok(()) + } + None => Err(Error::Offline), + } + } + + /// Checks if the wallet has an active connection to the network + pub async fn is_online(&self) -> bool { + match self.wallet.as_ref() { + Some(w) => w.state().check_connection().await.is_ok(), + None => false, + } + } + + pub(crate) async fn connected_wallet( + &self, + ) -> Result<&WalletCore, Error> { + match self.wallet.as_ref() { + Some(w) => { + w.state().check_connection().await?; + Ok(w) + } + None => Err(Error::Offline), + } + } + + /// Fetches the notes from the state. + pub async fn get_all_notes( + &self, + addr: &Address, + ) -> Result, Error> { + if !addr.is_owned() { + return Err(Error::Unauthorized); + } + + let wallet = self.connected_wallet().await?; + let ssk_index = addr.index()? as u64; + let ssk = self.store.retrieve_ssk(ssk_index).unwrap(); + let vk = ssk.view_key(); + let psk = vk.public_spend_key(); + + let live_notes = wallet.state().fetch_notes(&vk).unwrap(); + let spent_notes = wallet.state().cache().spent_notes(&psk)?; + + let live_notes = live_notes + .into_iter() + .map(|(note, height)| (None, note, height)); + let spent_notes = spent_notes.into_iter().map( + |(nullifier, NoteData { note, height })| { + (Some(nullifier), note, height) + }, + ); + let history = live_notes + .chain(spent_notes) + .map(|(nullified_by, note, block_height)| { + let amount = note.value(Some(&vk)).unwrap(); + DecodedNote { + note, + amount, + block_height, + nullified_by, + } + }) + .collect(); + + Ok(history) + } + + /// Obtain balance information for a given address + pub async fn get_balance( + &self, + addr: &Address, + ) -> Result { + // make sure we own this address + if !addr.is_owned() { + return Err(Error::Unauthorized); + } + + // get balance + if let Some(wallet) = &self.wallet { + let index = addr.index()? as u64; + Ok(wallet.get_balance(index)?) + } else { + Err(Error::Offline) + } + } + + /// Creates a new public address. + /// The addresses generated are deterministic across sessions. + pub fn new_address(&mut self) -> &Address { + let len = self.addresses.len(); + let ssk = self + .store + .retrieve_ssk(len as u64) + .expect("wallet seed should be available"); + let addr = Address::new(len as u8, ssk.public_spend_key()); + + self.addresses.push(addr); + self.addresses.last().unwrap() + } + + /// Default public address for this wallet + pub fn default_address(&self) -> &Address { + &self.addresses[0] + } + + /// Addresses that have been generated by the user + pub fn addresses(&self) -> &Vec

{ + &self.addresses + } + + /// Executes a generic contract call + pub async fn execute( + &self, + sender: &Address, + contract_id: ModuleId, + call_name: String, + call_data: C, + gas: Gas, + ) -> Result + where + C: rkyv::Serialize>, + { + let wallet = self.connected_wallet().await?; + // make sure we own the sender address + if !sender.is_owned() { + return Err(Error::Unauthorized); + } + + // check gas limits + if !gas.is_enough() { + return Err(Error::NotEnoughGas); + } + + let mut rng = StdRng::from_entropy(); + let sender_index = + sender.index().expect("owned address should have an index"); + + // transfer + let tx = wallet.execute( + &mut rng, + contract_id.into(), + call_name, + call_data, + sender_index as u64, + sender.psk(), + gas.limit, + gas.price, + )?; + Ok(tx) + } + + /// Transfers funds between addresses + pub async fn transfer( + &self, + sender: &Address, + rcvr: &Address, + amt: Dusk, + gas: Gas, + ) -> Result { + let wallet = self.connected_wallet().await?; + // make sure we own the sender address + if !sender.is_owned() { + return Err(Error::Unauthorized); + } + // make sure amount is positive + if amt == 0 { + return Err(Error::AmountIsZero); + } + // check gas limits + if !gas.is_enough() { + return Err(Error::NotEnoughGas); + } + + let mut rng = StdRng::from_entropy(); + let ref_id = BlsScalar::random(&mut rng); + let sender_index = + sender.index().expect("owned address should have an index"); + + // transfer + let tx = wallet.transfer( + &mut rng, + sender_index as u64, + sender.psk(), + rcvr.psk(), + *amt, + gas.limit, + gas.price, + ref_id, + )?; + Ok(tx) + } + + /// Stakes Dusk + pub async fn stake( + &self, + addr: &Address, + amt: Dusk, + gas: Gas, + ) -> Result { + let wallet = self.connected_wallet().await?; + // make sure we own the staking address + if !addr.is_owned() { + return Err(Error::Unauthorized); + } + // make sure amount is positive + if amt == 0 { + return Err(Error::AmountIsZero); + } + // check if the gas is enough + if !gas.is_enough() { + return Err(Error::NotEnoughGas); + } + + let mut rng = StdRng::from_entropy(); + let sender_index = addr.index()?; + + // stake + let tx = wallet.stake( + &mut rng, + sender_index as u64, + sender_index as u64, + addr.psk(), + *amt, + gas.limit, + gas.price, + )?; + Ok(tx) + } + + /// Obtains stake information for a given address + pub async fn stake_info(&self, addr: &Address) -> Result { + let wallet = self.connected_wallet().await?; + // make sure we own the staking address + if !addr.is_owned() { + return Err(Error::Unauthorized); + } + let index = addr.index()? as u64; + wallet.get_stake(index).map_err(Error::from) + } + + /// Unstakes Dusk + pub async fn unstake( + &self, + addr: &Address, + gas: Gas, + ) -> Result { + let wallet = self.connected_wallet().await?; + // make sure we own the staking address + if !addr.is_owned() { + return Err(Error::Unauthorized); + } + + let mut rng = StdRng::from_entropy(); + let index = addr.index()? as u64; + + let tx = wallet.unstake( + &mut rng, + index, + index, + addr.psk(), + gas.limit, + gas.price, + )?; + Ok(tx) + } + + /// Withdraw accumulated staking reward for a given address + pub async fn withdraw_reward( + &self, + addr: &Address, + gas: Gas, + ) -> Result { + let wallet = self.connected_wallet().await?; + // make sure we own the staking address + if !addr.is_owned() { + return Err(Error::Unauthorized); + } + + let mut rng = StdRng::from_entropy(); + let index = addr.index()? as u64; + + let tx = wallet.withdraw( + &mut rng, + index, + index, + addr.psk(), + gas.limit, + gas.price, + )?; + Ok(tx) + } + + /// Returns bls key pair for provisioner nodes + pub fn provisioner_keys( + &self, + addr: &Address, + ) -> Result<(PublicKey, SecretKey), Error> { + // make sure we own the staking address + if !addr.is_owned() { + return Err(Error::Unauthorized); + } + + let index = addr.index()? as u64; + + // retrieve keys + let sk = self.store.retrieve_sk(index)?; + let pk: PublicKey = From::from(&sk); + + Ok((pk, sk)) + } + + /// Export bls key pair for provisioners in node-compatible format + pub fn export_keys( + &self, + addr: &Address, + dir: &Path, + filename: Option, + pwd: &[u8], + ) -> Result<(PathBuf, PathBuf), Error> { + // we're expecting a directory here + if !dir.is_dir() { + return Err(Error::NotDirectory); + } + + // get our keys for this address + let keys = self.provisioner_keys(addr)?; + + // set up the path + let mut path = PathBuf::from(dir); + path.push(filename.unwrap_or(addr.to_string())); + + // export public key to disk + let bytes = keys.0.to_bytes(); + fs::write(path.with_extension("cpk"), bytes)?; + + // create node-compatible json structure + let bls = BlsKeyPair { + public_key_bls: keys.0.to_bytes(), + secret_key_bls: keys.1.to_bytes(), + }; + let json = serde_json::to_string(&bls)?; + + // encrypt data + let mut bytes = json.as_bytes().to_vec(); + bytes = crate::crypto::encrypt(&bytes, pwd)?; + + // export key pair to disk + fs::write(path.with_extension("keys"), bytes)?; + + Ok((path.with_extension("keys"), path.with_extension("cpk"))) + } + + /// Obtain the owned `Address` for a given address + pub fn claim_as_address(&self, addr: Address) -> Result<&Address, Error> { + self.addresses() + .iter() + .find(|a| a.psk == addr.psk) + .ok_or(Error::AddressNotOwned) + } + + /// Return the dat file version from memory or by reading the file + /// In order to not read the file version more than once per execution + pub fn get_file_version(&self) -> Result { + if let Some(file_version) = self.file_version { + Ok(file_version) + } else if let Some(file) = &self.file { + Ok(dat::read_file_version(file.path())?) + } else { + Err(Error::WalletFileMissing) + } + } +} + +/// This structs represent a Note decoded enriched with useful chain information +pub struct DecodedNote { + /// The phoenix note + pub note: Note, + /// The decoded amount + pub amount: u64, + /// The block height + pub block_height: u64, + /// Nullified by + pub nullified_by: Option, +} + +/// Bls key pair helper structure +#[derive(Serialize)] +struct BlsKeyPair { + #[serde(with = "base64")] + secret_key_bls: [u8; 32], + #[serde(with = "base64")] + public_key_bls: [u8; 96], +} + +mod base64 { + use serde::{Serialize, Serializer}; + + pub fn serialize(v: &[u8], s: S) -> Result { + let base64 = base64::encode(v); + String::serialize(&base64, s) + } +} + +#[cfg(test)] +mod tests { + + use super::*; + use tempfile::tempdir; + + const TEST_ADDR: &str = "2w7fRQW23Jn9Bgm1GQW9eC2bD9U883dAwqP7HAr2F8g1syzPQaPYrxSyyVZ81yDS5C1rv9L8KjdPBsvYawSx3QCW"; + + #[derive(Debug, Clone)] + struct WalletFile { + path: WalletPath, + pwd: Vec, + } + + impl SecureWalletFile for WalletFile { + fn path(&self) -> &WalletPath { + &self.path + } + + fn pwd(&self) -> &[u8] { + &self.pwd + } + } + + #[test] + fn wallet_basics() -> Result<(), Box> { + // create a wallet from a mnemonic phrase + let mut wallet: Wallet = Wallet::new("uphold stove tennis fire menu three quick apple close guilt poem garlic volcano giggle comic")?; + + // check address generation + let default_addr = wallet.default_address().clone(); + let other_addr = wallet.new_address(); + + assert!(format!("{}", default_addr).eq(TEST_ADDR)); + assert_ne!(&default_addr, other_addr); + assert_eq!(wallet.addresses.len(), 2); + + // create another wallet with different mnemonic + let wallet: Wallet = Wallet::new("demise monitor elegant cradle squeeze cheap parrot venture stereo humor scout denial action receive flat")?; + + // check addresses are different + let addr = wallet.default_address(); + assert!(format!("{}", addr).ne(TEST_ADDR)); + + // attempt to create a wallet from an invalid mnemonic + let bad_wallet: Result, Error> = + Wallet::new("good luck with life"); + assert!(bad_wallet.is_err()); + + Ok(()) + } + + #[test] + fn save_and_load() -> Result<(), Box> { + // prepare a tmp path + let dir = tempdir()?; + let path = dir.path().join("my_wallet.dat"); + let path = WalletPath::from(path); + + // we'll need a password too + let pwd = blake3::hash("mypassword".as_bytes()).as_bytes().to_vec(); + + // create and save + let mut wallet: Wallet = Wallet::new("uphold stove tennis fire menu three quick apple close guilt poem garlic volcano giggle comic")?; + let file = WalletFile { path, pwd }; + wallet.save_to(file.clone())?; + + // load from file and check + let loaded_wallet = Wallet::from_file(file)?; + + let original_addr = wallet.default_address(); + let loaded_addr = loaded_wallet.default_address(); + assert!(original_addr.eq(loaded_addr)); + + Ok(()) + } +} diff --git a/rusk-wallet/src/wallet/address.rs b/rusk-wallet/src/wallet/address.rs new file mode 100644 index 0000000000..8c4c46d5c6 --- /dev/null +++ b/rusk-wallet/src/wallet/address.rs @@ -0,0 +1,145 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use crate::Error; +use dusk_bytes::{DeserializableSlice, Error as BytesError, Serializable}; +use dusk_pki::PublicSpendKey; +use std::fmt; +use std::hash::Hasher; +use std::str::FromStr; + +#[derive(Clone, Eq)] +/// A public address within the Dusk Network +pub struct Address { + pub(crate) index: Option, + pub(crate) psk: PublicSpendKey, +} + +impl Address { + pub(crate) fn new(index: u8, psk: PublicSpendKey) -> Self { + Self { + index: Some(index), + psk, + } + } + + /// Returns true if the current user owns this address + pub fn is_owned(&self) -> bool { + self.index.is_some() + } + + pub(crate) fn psk(&self) -> &PublicSpendKey { + &self.psk + } + + pub(crate) fn index(&self) -> Result { + self.index.ok_or(Error::AddressNotOwned) + } + + /// A trimmed version of the address to display as preview + pub fn preview(&self) -> String { + let addr = bs58::encode(self.psk.to_bytes()).into_string(); + format!("{}...{}", &addr[..7], &addr[addr.len() - 7..]) + } +} + +impl FromStr for Address { + type Err = Error; + + fn from_str(s: &str) -> Result { + let bytes = bs58::decode(s).into_vec()?; + + let psk = PublicSpendKey::from_reader(&mut &bytes[..]) + .map_err(|_| Error::BadAddress)?; + + let addr = Address { index: None, psk }; + + Ok(addr) + } +} + +impl TryFrom for Address { + type Error = Error; + + fn try_from(s: String) -> Result { + Address::from_str(s.as_str()) + } +} + +impl TryFrom<&[u8; PublicSpendKey::SIZE]> for Address { + type Error = Error; + + fn try_from( + bytes: &[u8; PublicSpendKey::SIZE], + ) -> Result { + let addr = Address { + index: None, + psk: dusk_pki::PublicSpendKey::from_bytes(bytes)?, + }; + Ok(addr) + } +} + +impl PartialEq for Address { + fn eq(&self, other: &Self) -> bool { + self.index == other.index && self.psk == other.psk + } +} + +impl std::hash::Hash for Address { + fn hash(&self, state: &mut H) { + self.index.hash(state); + self.psk.to_bytes().hash(state); + } +} + +impl fmt::Display for Address { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", bs58::encode(self.psk.to_bytes()).into_string()) + } +} + +impl fmt::Debug for Address { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", bs58::encode(self.psk.to_bytes()).into_string()) + } +} + +/// Addresses holds address-related metadata that needs to be +/// persisted in the wallet file. +pub(crate) struct Addresses { + pub(crate) count: u8, +} + +impl Default for Addresses { + fn default() -> Self { + Self { count: 1 } + } +} + +impl Serializable<1> for Addresses { + type Error = BytesError; + + fn from_bytes(buf: &[u8; Addresses::SIZE]) -> Result + where + Self: Sized, + { + Ok(Self { count: buf[0] }) + } + + fn to_bytes(&self) -> [u8; Addresses::SIZE] { + [self.count] + } +} + +#[test] +fn addresses_serde() -> Result<(), Box> { + let addrs = Addresses { count: 6 }; + let read = Addresses::from_bytes(&addrs.to_bytes()) + .map_err(|_| Error::WalletFileCorrupted)?; + assert!(read.count == addrs.count); + Ok(()) +} diff --git a/rusk-wallet/src/wallet/file.rs b/rusk-wallet/src/wallet/file.rs new file mode 100644 index 0000000000..0e3fca78dd --- /dev/null +++ b/rusk-wallet/src/wallet/file.rs @@ -0,0 +1,117 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use std::fmt; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +/// Provides access to a secure wallet file +pub trait SecureWalletFile { + /// Returns the path + fn path(&self) -> &WalletPath; + /// Returns the hashed password + fn pwd(&self) -> &[u8]; +} + +/// Wrapper around `PathBuf` for wallet paths +#[derive(PartialEq, Eq, Hash, Debug, Clone)] +pub struct WalletPath { + /// Path of the wallet file + pub wallet: PathBuf, + /// Directory of the profile + pub profile_dir: PathBuf, + /// Name of the network + pub network: Option, +} + +impl WalletPath { + /// Create wallet path from the path of "wallet.dat" file. The wallet.dat + /// file should be located in the profile folder, this function also + /// generates the profile folder from the passed argument + pub fn new(wallet: &Path) -> Self { + let wallet = wallet.to_path_buf(); + // The wallet should be in the profile folder + let mut profile_dir = wallet.clone(); + + profile_dir.pop(); + + Self { + wallet, + profile_dir, + network: None, + } + } + + /// Returns the filename of this path + pub fn name(&self) -> Option { + // extract the name + let name = self.wallet.file_stem()?.to_str()?; + Some(String::from(name)) + } + + /// Returns current directory for this path + pub fn dir(&self) -> Option { + self.wallet.parent().map(PathBuf::from) + } + + /// Returns a reference to the `PathBuf` holding the path + pub fn inner(&self) -> &PathBuf { + &self.wallet + } + + /// Sets the network name for different cache locations. + /// e.g, devnet, testnet, etc. + pub fn set_network_name(&mut self, network: Option) { + self.network = network; + } + + /// Generates dir for cache based on network specified + pub fn cache_dir(&self) -> PathBuf { + let mut cache = self.profile_dir.clone(); + + if let Some(network) = &self.network { + cache.push(format!("cache_{network}")); + } else { + cache.push("cache"); + } + + cache + } +} + +impl FromStr for WalletPath { + type Err = crate::Error; + + fn from_str(s: &str) -> Result { + let p = Path::new(s); + + Ok(Self::new(p)) + } +} + +impl From for WalletPath { + fn from(p: PathBuf) -> Self { + Self::new(&p) + } +} + +impl From<&Path> for WalletPath { + fn from(p: &Path) -> Self { + Self::new(p) + } +} + +impl fmt::Display for WalletPath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "wallet path: {}\n\rprofile dir: {}\n\rnetwork: {}", + self.wallet.display(), + self.profile_dir.display(), + self.network.as_ref().unwrap_or(&"default".to_string()) + ) + } +} diff --git a/rusk-wallet/src/wallet/gas.rs b/rusk-wallet/src/wallet/gas.rs new file mode 100644 index 0000000000..6a2ff41b52 --- /dev/null +++ b/rusk-wallet/src/wallet/gas.rs @@ -0,0 +1,76 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! This library contains the primitive related to the gas used for transaction +//! in the Dusk Network. + +use crate::currency::Lux; + +/// The minimum gas limit +pub const MIN_LIMIT: u64 = 350_000_000; + +/// The default gas limit +pub const DEFAULT_LIMIT: u64 = 500_000_000; + +/// The default gas price +pub const DEFAULT_PRICE: Lux = 1; + +#[derive(Debug)] +/// Gas price and limit for any transaction +pub struct Gas { + /// The gas price in [Lux] + pub price: Lux, + /// The gas limit + pub limit: u64, +} + +impl Gas { + /// Default gas price and limit + pub fn new(limit: u64) -> Self { + Gas { + price: DEFAULT_PRICE, + limit, + } + } + + /// Returns `true` if the gas is equal or greater than the minimum limit + pub fn is_enough(&self) -> bool { + self.limit >= MIN_LIMIT + } + + /// Set the price + pub fn set_price(&mut self, price: T) + where + T: Into>, + { + self.price = price.into().unwrap_or(DEFAULT_PRICE); + } + + /// Set the price and return the Gas + pub fn with_price(mut self, price: T) -> Self + where + T: Into, + { + self.price = price.into(); + self + } + + /// Set the limit + pub fn set_limit(&mut self, limit: T) + where + T: Into>, + { + if let Some(limit) = limit.into() { + self.limit = limit; + } + } +} + +impl Default for Gas { + fn default() -> Self { + Self::new(DEFAULT_LIMIT) + } +}