From 4578cda0ae2ceca0c92cd3a8f24d65a22bb162e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Mon, 5 Dec 2022 18:27:44 +0000 Subject: [PATCH] Feature/develop resync (#1844) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Network-requester: throttle inbound connections (#1789) * Return and handle ClientRequest::LaneQueueLenghts * Pass lane queue lengths to inbound future * Remove unused self reference * Request lane queue lengths periodically for all open connections * Add timeouts * Rename to ConnectionCommandSender and Receiver * Rename to client_connection_tx/rx * Fix wasm build * Replace bool with enum * rust: bump required version to 1.65 in some crates that need it * Add step to release GH actions (#1792) * feat: add a release step to nym contracts GH action * feat: add shrinking the size of wasm * Possibilty to change gateway ws listener (#1779) * add: set gatewayListener * Update types.ts * Update worker.ts * Update contracts-build.yml * real_traffic_stream: reduce frequency of status print (#1794) * Update wallet and connect lock files (#1793) * client-core: add warning when delay multiplier is larger than 1 * Fix decrypting stored received msg (#1786) * Fix decrypting stored received msg * rustfmt * Moving binary message recovery to separate function Co-authored-by: Jędrzej Stuczyński * Feature/use expect instead of panicking (#1797) * Implementation of 'Debug' on 'RealMessage' * expect with failed channel name instead of throwing empty panics * Introduced Debug trait constraint in ProxyRunner * Derive Debug for socks5_requests::Message * Make connection_id optional in ClientRequest::Send (#1798) * changelog: add missing entry for fixing message decrypt in gateway-client * websocket-requests: fix length check before deserialize (#1799) * Fix export dkg contract addr (#1800) * Export dkg contract for mainnet when no config file present * Remove redundant env files * nym-cli: improve error reporting/handling and changed `vesting-schedule` queries to use query client instead of signing client * Feature/gateway client protocol version (#1795) * Introducing concept of gateway protocol version * Remove version-based gateway filtering * Fixed the unit test * grammar * Set build on latest release on schedule event * Added nightly build workflow on second latest release * socks5: if any task panics, signal all other tasks to shutdown (#1805) * socks5: signal shutdown on error * Mark as success * Tidy * Reduce wait to 5 sec * Replace unwrap with expect * Two more unwraps * Update changelog * client-core: less frequent status logging (#1806) * Feature/nym connect UI updates (#1784) * create custom titlebar * create help page * create generic modal component * create separate connection time component * link to shipyard docs * move timer to separate component and update connection status component usage * use separate component for copying ip and port details * only show infomodal once after connection * set service provider on tauri side * Emit events when stopped * listen and unlisten for tauri events * connect: add trace log to get_services * Add back CI notifications * Update README Co-authored-by: Jon Häggblad Co-authored-by: Mark Sinclair <14054343+mmsinclair@users.noreply.github.com> * Use default serde value for upgrade (#1807) * fix ui overflow bug (#1808) * update nym connect error text (#1809) * set flag to false * Fix wait_for_signal_and_error on win (#1811) * Add socks5-client changes to nym-connect changelog * Fix links in nym-connect changelog * More entries in nym-connect CHANGELOG * Fix typo in changelog * Update CHANGELOG.md * Experiment/client refactoring (#1814) * experimenting with extracting more common client code * drying up the wasm client * allowing some dead code for the time being * fixed formatting in nym-connect * made socks5 client inside nym-connect immutable * made clippy a bit happier * hidden away target locking for recv timeout * New transactions for increasing amount of pledged tokens * unit tests * Added an option to pledge extra tokens through the vesting contract * Introduced wallet endpoints for new operations * Using updated pledge cap in the vesting contract * Bumping version numbers * Changelog for v1.1.1 * Bumping final version numbers for 1.1.1 * Bumping nym-cli version, missed it last time * socks5-client: SOCKS4a support (#1822) * socks5-client: SOCKS4a support * Tidy * Fix a few errors in socks5 client and network-requester (#1823) * Fix two unwraps in socks5 and network-requester * Make sure client task never sends shutdown signal * Fix panic on getting socks version * wip * connecting to the back and making the requests work * display details modal * logs removal * Feature/pledge more (#1679) * New transactions for increasing amount of pledged tokens * unit tests * Added an option to pledge extra tokens through the vesting contract * Introduced wallet endpoints for new operations * Using updated pledge cap in the vesting contract * Changelog update * nym-connect: update lock file * avoid mix tokens pools * amount error * envs/mainnet: update to latest mixnet contract and nymd validator url * validator-api: add missing shortform for --config-env-file (#1830) * gateway-client: handle shutdown listener (#1829) * WIP * WIP: try another approach * WIP * Reworked * Tidy * fix * validator-api: remove storage dependency in contract cache (#1685) * validator-api: remove storage dependency in contract cache * validator-client: update detailed routes * contract_cache: forward to new endpoints for compat * Move reward_estimate * client: add --no-cover and update --fastmode (#1831) * adding a oversaturaded bonding more modal * common/task: extract out spawn_with_report_error (#1837) * stop panic on failed buffer request * Compilable wasm client * Enabled hard error on lack of gateway-client protocol version * Missing generic parameter for ClientCoreError in BackendError * Removed unused imports * Additional wasm feature locking Co-authored-by: Jon Häggblad Co-authored-by: Fran Arbanas Co-authored-by: cgi-bin/ <6095048+sven-hash@users.noreply.github.com> Co-authored-by: Mark Sinclair <14054343+mmsinclair@users.noreply.github.com> Co-authored-by: Bogdan-Ștefan Neacşu Co-authored-by: Mark Sinclair Co-authored-by: Raphaël Walther Co-authored-by: Fouad Co-authored-by: Gala Co-authored-by: Dave Hrycyszyn --- .github/workflows/nightly_build_release.yml | 8 +- .github/workflows/nightly_build_release2.yml | 203 ++++++ .../nightly_build_release_matrix.json | 50 -- .github/workflows/nym-connect.yml | 32 +- CHANGELOG.md | 14 + Cargo.lock | 25 +- clients/client-core/Cargo.toml | 9 +- .../client-core/src/client/base_client/mod.rs | 481 ++++++++++++++ .../client/base_client/non_wasm_helpers.rs | 51 ++ .../src/client/cover_traffic_stream.rs | 18 +- clients/client-core/src/client/key_manager.rs | 4 + clients/client-core/src/client/mix_traffic.rs | 4 +- clients/client-core/src/client/mod.rs | 11 +- .../acknowledgement_listener.rs | 6 +- .../action_controller.rs | 9 +- .../input_message_listener.rs | 6 +- .../acknowledgement_control/mod.rs | 4 +- .../retransmission_request_listener.rs | 6 +- .../sent_notification_listener.rs | 4 +- .../src/client/real_messages_control/mod.rs | 5 +- .../real_traffic_stream.rs | 74 ++- .../sending_delay_controller.rs | 2 +- .../client-core/src/client/received_buffer.rs | 13 +- .../src/client/replies/reply_controller.rs | 12 +- .../reply_storage/backend/browser_backend.rs | 11 + .../backend/fs_backend/manager.rs | 2 +- .../reply_storage/backend/fs_backend/mod.rs | 1 + .../replies/reply_storage/backend/mod.rs | 2 +- .../replies/reply_storage/key_storage.rs | 6 +- .../src/client/replies/reply_storage/mod.rs | 1 - .../replies/reply_storage/surb_storage.rs | 10 +- .../replies/reply_storage/tag_storage.rs | 6 +- .../src/client/topology_control.rs | 32 +- clients/client-core/src/config/mod.rs | 66 +- clients/client-core/src/error.rs | 9 +- clients/client-core/src/init.rs | 39 +- clients/native/Cargo.toml | 2 +- clients/native/src/client/config/mod.rs | 8 +- clients/native/src/client/mod.rs | 595 ++++-------------- clients/native/src/commands/init.rs | 20 +- clients/native/src/commands/mod.rs | 5 + clients/native/src/commands/run.rs | 16 +- clients/native/src/error.rs | 26 +- clients/native/src/websocket/handler.rs | 12 +- clients/socks5/Cargo.toml | 2 +- clients/socks5/src/client/config/mod.rs | 4 +- clients/socks5/src/client/config/template.rs | 2 +- clients/socks5/src/client/mod.rs | 490 +++------------ clients/socks5/src/commands/init.rs | 21 +- clients/socks5/src/commands/mod.rs | 10 +- clients/socks5/src/commands/run.rs | 21 +- clients/socks5/src/error.rs | 29 +- clients/socks5/src/main.rs | 5 +- clients/socks5/src/socks/client.rs | 158 +++-- clients/socks5/src/socks/mixnet_responses.rs | 7 +- clients/socks5/src/socks/mod.rs | 27 +- clients/socks5/src/socks/request.rs | 158 +++-- clients/socks5/src/socks/server.rs | 53 +- clients/socks5/src/socks/types.rs | 16 +- clients/webassembly/Cargo.toml | 2 +- .../webassembly/js-example/package-lock.json | 2 +- clients/webassembly/js-example/worker.js | 7 - clients/webassembly/src/client/config.rs | 9 +- clients/webassembly/src/client/mod.rs | 443 +++---------- .../webassembly/src/client/response_pusher.rs | 71 +++ clients/webassembly/src/gateway_selector.rs | 10 +- common/client-libs/gateway-client/Cargo.toml | 4 +- .../gateway-client/src/bandwidth.rs | 9 +- .../client-libs/gateway-client/src/client.rs | 120 ++-- .../client-libs/gateway-client/src/error.rs | 3 + .../gateway-client/src/packet_router.rs | 21 +- .../gateway-client/src/socket_state.rs | 28 +- .../src/nymd/traits/mixnet_signing_client.rs | 29 + .../src/nymd/traits/vesting_signing_client.rs | 15 + .../validator-client/src/validator_api/mod.rs | 9 +- common/commands/Cargo.toml | 1 + common/commands/src/context/errors.rs | 6 + common/commands/src/context/mod.rs | 9 +- .../commands/src/validator/vesting/balance.rs | 16 +- .../vesting/query_vesting_schedule.rs | 18 +- .../mixnet-contract/src/events.rs | 17 + .../mixnet-contract/src/mixnode.rs | 8 + .../mixnet-contract/src/msg.rs | 6 + .../mixnet-contract/src/pending_events.rs | 5 +- .../vesting-contract/src/events.rs | 5 + .../vesting-contract/src/messages.rs | 4 + common/network-defaults/envs/mainnet.env | 20 - common/network-defaults/envs/qa.env | 20 - common/network-defaults/src/mainnet.rs | 8 + .../src/connection_controller.rs | 4 + .../proxy-helpers/src/proxy_runner/inbound.rs | 1 + .../proxy-helpers/src/proxy_runner/mod.rs | 1 + .../src/proxy_runner/outbound.rs | 1 + common/task/Cargo.toml | 14 +- common/task/src/lib.rs | 7 +- common/task/src/shutdown.rs | 201 +++++- common/task/src/signal.rs | 43 ++ common/task/src/spawn.rs | 33 + common/topology/src/lib.rs | 10 +- common/types/src/pending_events.rs | 10 + contracts/CHANGELOG.md | 2 + contracts/mixnet/src/contract.rs | 6 + .../mixnet/src/interval/pending_events.rs | 264 +++++++- .../src/mixnet_contract_settings/storage.rs | 1 - contracts/mixnet/src/mixnodes/helpers.rs | 12 +- contracts/mixnet/src/mixnodes/transactions.rs | 239 ++++++- contracts/mixnet/src/support/tests/mod.rs | 38 +- contracts/vesting/src/contract.rs | 14 + .../vesting/src/traits/bonding_account.rs | 7 + .../account/mixnode_bonding_account.rs | 61 +- envs/mainnet.env | 4 +- explorer-api/Cargo.toml | 2 +- explorer/src/components/MobileNav.tsx | 2 +- explorer/src/components/Nav.tsx | 2 +- gateway/Cargo.toml | 2 +- gateway/gateway-requests/src/lib.rs | 6 +- .../src/registration/handshake/state.rs | 5 +- gateway/gateway-requests/src/types.rs | 55 +- .../websocket/connection_handler/fresh.rs | 54 +- mixnode/Cargo.toml | 2 +- nym-connect/.storybook/preview.js | 20 +- nym-connect/CHANGELOG.md | 22 +- nym-connect/Cargo.lock | 9 +- nym-connect/Cargo.toml | 2 +- nym-connect/README.md | 4 +- nym-connect/package.json | 4 +- nym-connect/src-tauri/Cargo.toml | 2 +- nym-connect/src-tauri/src/config/mod.rs | 4 +- nym-connect/src-tauri/src/error.rs | 3 +- nym-connect/src-tauri/src/models/mod.rs | 4 +- .../src-tauri/src/operations/directory/mod.rs | 2 + nym-connect/src-tauri/src/tasks.rs | 47 +- nym-connect/src-tauri/tauri.conf.json | 23 +- nym-connect/src/App.tsx | 23 +- nym-connect/src/assets/help-step-four.png | Bin 0 -> 8530 bytes nym-connect/src/assets/help-step-one.png | Bin 0 -> 14403 bytes nym-connect/src/assets/help-step-three.png | Bin 0 -> 18990 bytes nym-connect/src/assets/help-step-two.png | Bin 0 -> 35631 bytes nym-connect/src/components/AppWindowFrame.tsx | 17 +- .../src/components/ConnectionButton.tsx | 73 +-- .../src/components/ConnectionStatus.tsx | 71 +-- .../src/components/ConntectionTimer.tsx | 26 + nym-connect/src/components/CustomTitleBar.tsx | 41 ++ nym-connect/src/components/HelpPage.tsx | 33 + .../src/components/HelpPageActions.tsx | 20 + nym-connect/src/components/HelpPageImage.tsx | 5 + .../src/components/HelpPageStepIndicator.tsx | 15 + nym-connect/src/components/InfoModal.tsx | 69 ++ .../src/components/IpAddressAndPort.tsx | 8 +- .../src/components/IpAddressAndPortModal.tsx | 41 ++ .../components/ServiceProviderSelector.tsx | 137 ++-- nym-connect/src/context/main.tsx | 60 +- nym-connect/src/context/mocks/main.tsx | 24 + nym-connect/src/index.tsx | 8 +- nym-connect/src/layouts/ConnectedLayout.tsx | 37 +- nym-connect/src/layouts/DefaultLayout.tsx | 38 +- nym-connect/src/layouts/HelpGuideLayout.tsx | 70 +++ nym-connect/src/stories/AppFlow.stories.tsx | 13 +- .../src/stories/ConnectedLayout.stories.tsx | 6 +- .../src/stories/DefaultLayout.stories.tsx | 21 +- nym-connect/src/theme/index.tsx | 6 +- nym-connect/src/theme/mui-theme.d.ts | 1 + nym-connect/src/theme/theme.tsx | 4 + nym-connect/src/types/error.ts | 4 + nym-connect/src/types/event.ts | 6 + nym-wallet/src-tauri/src/main.rs | 4 + .../src-tauri/src/operations/mixnet/bond.rs | 27 + .../src/operations/simulate/mixnet.rs | 8 + .../src/operations/simulate/vesting.rs | 13 + .../src-tauri/src/operations/vesting/bond.rs | 27 + .../src/components/Bonding/BondedMixnode.tsx | 1 - .../Bonding/modals/BondMoreModal.tsx | 95 ++- .../Bonding/modals/BondOversaturatedModal.tsx | 27 + nym-wallet/src/context/bonding.tsx | 32 +- nym-wallet/src/context/mocks/bonding.tsx | 2 +- nym-wallet/src/pages/bonding/Bonding.tsx | 73 ++- nym-wallet/src/requests/actions.ts | 5 +- nym-wallet/src/requests/simulate.ts | 6 + nym-wallet/src/requests/vesting.ts | 11 + nym-wallet/src/types/global.ts | 6 + .../network-requester/Cargo.toml | 2 +- .../network-requester/src/core.rs | 10 +- tools/nym-cli/Cargo.toml | 3 +- tools/nym-cli/src/validator/vesting.rs | 10 +- validator-api/Cargo.toml | 2 +- validator-api/src/contract_cache/mod.rs | 158 +---- validator-api/src/contract_cache/routes.rs | 57 +- validator-api/src/epoch_operations/mod.rs | 2 +- validator-api/src/main.rs | 7 +- .../src/network_monitor/monitor/sender.rs | 2 +- validator-api/src/node_status_api/cache.rs | 326 +++++++--- .../cache/inclusion_probabilities.rs | 89 +++ validator-api/src/node_status_api/helpers.rs | 77 ++- validator-api/src/node_status_api/mod.rs | 7 + .../reward_estimate.rs | 2 +- validator-api/src/node_status_api/routes.rs | 80 ++- 196 files changed, 4649 insertions(+), 2496 deletions(-) create mode 100644 .github/workflows/nightly_build_release2.yml delete mode 100644 .github/workflows/nightly_build_release_matrix.json create mode 100644 clients/client-core/src/client/base_client/mod.rs create mode 100644 clients/client-core/src/client/base_client/non_wasm_helpers.rs create mode 100644 clients/webassembly/src/client/response_pusher.rs delete mode 100644 common/network-defaults/envs/mainnet.env delete mode 100644 common/network-defaults/envs/qa.env create mode 100644 common/task/src/spawn.rs create mode 100644 nym-connect/src/assets/help-step-four.png create mode 100644 nym-connect/src/assets/help-step-one.png create mode 100644 nym-connect/src/assets/help-step-three.png create mode 100644 nym-connect/src/assets/help-step-two.png create mode 100644 nym-connect/src/components/ConntectionTimer.tsx create mode 100644 nym-connect/src/components/CustomTitleBar.tsx create mode 100644 nym-connect/src/components/HelpPage.tsx create mode 100644 nym-connect/src/components/HelpPageActions.tsx create mode 100644 nym-connect/src/components/HelpPageImage.tsx create mode 100644 nym-connect/src/components/HelpPageStepIndicator.tsx create mode 100644 nym-connect/src/components/InfoModal.tsx create mode 100644 nym-connect/src/components/IpAddressAndPortModal.tsx create mode 100644 nym-connect/src/context/mocks/main.tsx create mode 100644 nym-connect/src/layouts/HelpGuideLayout.tsx create mode 100644 nym-connect/src/types/error.ts create mode 100644 nym-connect/src/types/event.ts create mode 100644 nym-wallet/src/components/Bonding/modals/BondOversaturatedModal.tsx create mode 100644 validator-api/src/node_status_api/cache/inclusion_probabilities.rs rename validator-api/src/{contract_cache => node_status_api}/reward_estimate.rs (96%) diff --git a/.github/workflows/nightly_build_release.yml b/.github/workflows/nightly_build_release.yml index 9a1ba43dc5..4474b399b6 100644 --- a/.github/workflows/nightly_build_release.yml +++ b/.github/workflows/nightly_build_release.yml @@ -9,12 +9,12 @@ jobs: outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: - # creates the matrix strategy from nightly_build_release_matrix.json + # creates the matrix strategy from nightly_build_matrix_includes.json - uses: actions/checkout@v3 - id: set-matrix uses: JoshuaTheMiller/conditional-build-matrix@main with: - inputFile: '.github/workflows/nightly_build_release_matrix.json' + inputFile: '.github/workflows/nightly_build_matrix_includes.json' filter: '[?runOnEvent==`${{ github.event_name }}` || runOnEvent==`always`]' get_release: runs-on: ubuntu-latest @@ -174,7 +174,7 @@ jobs: args: --manifest-path nym-wallet/Cargo.toml --workspace --all-targets -- -D warnings notification: - needs: build + needs: [build,get_release] runs-on: ubuntu-latest steps: - name: Collect jobs status @@ -192,7 +192,7 @@ jobs: NYM_PROJECT_NAME: "Nym nightly build on latest release" GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" GIT_COMMIT_MESSAGE: "${{ github.event.head_commit.message }}" - GIT_BRANCH: "${GITHUB_REF##*/}" + GIT_BRANCH: "https://github.com/nymtech/nym/tree/${{needs.get_release.outputs.output1}}" KEYBASE_NYMBOT_USERNAME: "${{ secrets.KEYBASE_NYMBOT_USERNAME }}" KEYBASE_NYMBOT_PAPERKEY: "${{ secrets.KEYBASE_NYMBOT_PAPERKEY }}" KEYBASE_NYMBOT_TEAM: "${{ secrets.KEYBASE_NYMBOT_TEAM }}" diff --git a/.github/workflows/nightly_build_release2.yml b/.github/workflows/nightly_build_release2.yml new file mode 100644 index 0000000000..71f89948c9 --- /dev/null +++ b/.github/workflows/nightly_build_release2.yml @@ -0,0 +1,203 @@ +name: Nightly builds on second latest release + +on: + schedule: + - cron: '24 2 * * *' +jobs: + matrix_prep: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + # creates the matrix strategy from nightly_build_matrix_includes.json + - uses: actions/checkout@v3 + - id: set-matrix + uses: JoshuaTheMiller/conditional-build-matrix@main + with: + inputFile: '.github/workflows/nightly_build_matrix_includes.json' + filter: '[?runOnEvent==`${{ github.event_name }}` || runOnEvent==`always`]' + get_release: + runs-on: ubuntu-latest + needs: matrix_prep + outputs: + output1: ${{ steps.step2.outputs.latest_release }} + steps: + - name: Check out repository code + uses: actions/checkout@v3 + - name: Fetch all branches + run: git fetch --all + - name: Set output variable to latest release branch + id: step2 + run: echo "latest_release=$(git branch -r | grep -E 'release/v[0-9]+\.[0-9]+\.[0-9]+' | tail -n 2 | head -n 1 | sed 's/ origin\///')" >> $GITHUB_OUTPUT + build: + needs: [get_release,matrix_prep] + strategy: + matrix: ${{fromJson(needs.matrix_prep.outputs.matrix)}} + runs-on: ${{ matrix.os }} + continue-on-error: ${{ matrix.rust == 'nightly' || matrix.rust == 'beta' || matrix.rust == 'stable' }} + steps: + - name: Install Dependencies (Linux) + run: sudo apt-get update && sudo apt-get install libwebkit2gtk-4.0-dev build-essential curl wget libssl-dev libgtk-3-dev libudev-dev squashfs-tools + if: matrix.os == 'ubuntu-latest' + + - name: Check out latest release branch + uses: actions/checkout@v3 + with: + ref: ${{needs.get_release.outputs.output1}} + + - name: Install rust toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: ${{ matrix.rust }} + override: true + components: rustfmt, clippy + + - name: Build all binaries + uses: actions-rs/cargo@v1 + with: + command: build + args: --workspace + + - name: Reclaim some disk space (because Windows is being annoying) + uses: actions-rs/cargo@v1 + if: ${{ matrix.os == 'windows-latest' }} + with: + command: clean + + - name: Run all tests + uses: actions-rs/cargo@v1 + with: + command: test + args: --workspace + + - name: Reclaim some disk space (because Windows is being annoying) + uses: actions-rs/cargo@v1 + if: ${{ matrix.os == 'windows-latest' }} + with: + command: clean + + - name: Run expensive tests + if: github.ref == 'refs/heads/develop' || github.event.pull_request.base.ref == 'develop' || github.event.pull_request.base.ref == 'master' + uses: actions-rs/cargo@v1 + with: + command: test + args: --workspace --all-features -- --ignored + + - name: Check formatting + uses: actions-rs/cargo@v1 + with: + command: fmt + args: --all -- --check + + - name: Reclaim some disk space (because Windows is being annoying) + uses: actions-rs/cargo@v1 + if: ${{ matrix.os == 'windows-latest' }} + with: + command: clean + + - uses: actions-rs/clippy-check@v1 + name: Clippy checks + with: + token: ${{ secrets.GITHUB_TOKEN }} + args: --all-features + + - name: Run clippy + uses: actions-rs/cargo@v1 + if: ${{ matrix.rust != 'nightly' }} + with: + command: clippy + args: --workspace --all-targets -- -D warnings + + - name: Reclaim some disk space + uses: actions-rs/cargo@v1 + if: ${{ matrix.os == 'windows-latest' || matrix.os == 'ubuntu-latest' }} + with: + command: clean + + # COCONUT stuff + - name: Build all binaries with coconut enabled + uses: actions-rs/cargo@v1 + with: + command: build + args: --workspace --features=coconut + + - name: Reclaim some disk space (because Windows is being annoying) + uses: actions-rs/cargo@v1 + if: ${{ matrix.os == 'windows-latest' }} + with: + command: clean + + - name: Run all tests with coconut enabled + uses: actions-rs/cargo@v1 + with: + command: test + args: --workspace --features=coconut + + - name: Reclaim some disk space (because Windows is being annoying) + uses: actions-rs/cargo@v1 + if: ${{ matrix.os == 'windows-latest' }} + with: + command: clean + + - name: Run clippy with coconut enabled + uses: actions-rs/cargo@v1 + if: ${{ matrix.rust != 'nightly' }} + with: + command: clippy + args: --workspace --all-targets --features=coconut -- -D warnings + + # nym-wallet (the rust part) + - name: Build nym-wallet rust code + uses: actions-rs/cargo@v1 + with: + command: build + args: --manifest-path nym-wallet/Cargo.toml --workspace + + - name: Run nym-wallet tests + uses: actions-rs/cargo@v1 + with: + command: test + args: --manifest-path nym-wallet/Cargo.toml --workspace + + - name: Check nym-wallet formatting + uses: actions-rs/cargo@v1 + with: + command: fmt + args: --manifest-path nym-wallet/Cargo.toml --all -- --check + + - name: Run clippy for nym-wallet + uses: actions-rs/cargo@v1 + if: ${{ matrix.rust != 'nightly' }} + with: + command: clippy + args: --manifest-path nym-wallet/Cargo.toml --workspace --all-targets -- -D warnings + + notification: + needs: [build,get_release] + runs-on: ubuntu-latest + steps: + - name: Collect jobs status + uses: technote-space/workflow-conclusion-action@v2 + - name: Check out repository code + uses: actions/checkout@v3 + - name: Keybase - Node Install + if: env.WORKFLOW_CONCLUSION == 'failure' + run: npm install + working-directory: .github/workflows/support-files + - name: Keybase - Send Notification + if: env.WORKFLOW_CONCLUSION == 'failure' + env: + NYM_NOTIFICATION_KIND: nightly + NYM_PROJECT_NAME: "Nym nightly build on latest release" + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + GIT_COMMIT_MESSAGE: "${{ github.event.head_commit.message }}" + GIT_BRANCH: "https://github.com/nymtech/nym/tree/${{needs.get_release.outputs.output1}}" + KEYBASE_NYMBOT_USERNAME: "${{ secrets.KEYBASE_NYMBOT_USERNAME }}" + KEYBASE_NYMBOT_PAPERKEY: "${{ secrets.KEYBASE_NYMBOT_PAPERKEY }}" + KEYBASE_NYMBOT_TEAM: "${{ secrets.KEYBASE_NYMBOT_TEAM }}" + KEYBASE_NYM_CHANNEL: "ci-nightly-release" + IS_SUCCESS: "${{ env.WORKFLOW_CONCLUSION == 'success' }}" + uses: docker://keybaseio/client:stable-node + with: + args: .github/workflows/support-files/notifications/entry_point.sh diff --git a/.github/workflows/nightly_build_release_matrix.json b/.github/workflows/nightly_build_release_matrix.json deleted file mode 100644 index c292817b6a..0000000000 --- a/.github/workflows/nightly_build_release_matrix.json +++ /dev/null @@ -1,50 +0,0 @@ -[ - { - "os":"ubuntu-latest", - "rust":"stable", - "runOnEvent":"workflow_dispatch" - }, - - { - "os":"windows-latest", - "rust":"stable", - "runOnEvent":"workflow_dispatch" - }, - { - "os":"macos-latest", - "rust":"stable", - "runOnEvent":"workflow_dispatch" - }, - - { - "os":"ubuntu-latest", - "rust":"beta", - "runOnEvent":"workflow_dispatch" - }, - { - "os":"windows-latest", - "rust":"beta", - "runOnEvent":"workflow_dispatch" - }, - { - "os":"macos-latest", - "rust":"beta", - "runOnEvent":"workflow_dispatch" - }, - - { - "os":"ubuntu-latest", - "rust":"nightly", - "runOnEvent":"workflow_dispatch" - }, - { - "os":"windows-latest", - "rust":"nightly", - "runOnEvent":"workflow_dispatch" - }, - { - "os":"macos-latest", - "rust":"nightly", - "runOnEvent":"workflow_dispatch" - } -] diff --git a/.github/workflows/nym-connect.yml b/.github/workflows/nym-connect.yml index 3a08744fc9..5a652e92d2 100644 --- a/.github/workflows/nym-connect.yml +++ b/.github/workflows/nym-connect.yml @@ -41,19 +41,19 @@ jobs: - name: Keybase - Node Install run: npm install working-directory: .github/workflows/support-files -# - name: Keybase - Send Notification -# env: -# NYM_NOTIFICATION_KIND: nym-connect -# NYM_PROJECT_NAME: "nym-connect" -# NYM_CI_WWW_BASE: "${{ secrets.NYM_CI_WWW_BASE }}" -# NYM_CI_WWW_LOCATION: "nym-connect-${{ env.GITHUB_REF_SLUG }}" -# GIT_COMMIT_MESSAGE: "${{ github.event.head_commit.message }}" -# GIT_BRANCH: "${GITHUB_REF##*/}" -# KEYBASE_NYMBOT_USERNAME: "${{ secrets.KEYBASE_NYMBOT_USERNAME }}" -# KEYBASE_NYMBOT_PAPERKEY: "${{ secrets.KEYBASE_NYMBOT_PAPERKEY }}" -# KEYBASE_NYMBOT_TEAM: "${{ secrets.KEYBASE_NYMBOT_TEAM }}" -# KEYBASE_NYM_CHANNEL: "ci-nym-connect" -# IS_SUCCESS: "${{ job.status == 'success' }}" -# uses: docker://keybaseio/client:stable-node -# with: -# args: .github/workflows/support-files/notifications/entry_point.sh + - name: Keybase - Send Notification + env: + NYM_NOTIFICATION_KIND: nym-connect + NYM_PROJECT_NAME: "nym-connect" + NYM_CI_WWW_BASE: "${{ secrets.NYM_CI_WWW_BASE }}" + NYM_CI_WWW_LOCATION: "nym-connect-${{ env.GITHUB_REF_SLUG }}" + GIT_COMMIT_MESSAGE: "${{ github.event.head_commit.message }}" + GIT_BRANCH: "${GITHUB_REF##*/}" + KEYBASE_NYMBOT_USERNAME: "${{ secrets.KEYBASE_NYMBOT_USERNAME }}" + KEYBASE_NYMBOT_PAPERKEY: "${{ secrets.KEYBASE_NYMBOT_PAPERKEY }}" + KEYBASE_NYMBOT_TEAM: "${{ secrets.KEYBASE_NYMBOT_TEAM }}" + KEYBASE_NYM_CHANNEL: "ci-nym-connect" + IS_SUCCESS: "${{ job.status == 'success' }}" + uses: docker://keybaseio/client:stable-node + with: + args: .github/workflows/support-files/notifications/entry_point.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 31972380ff..b4d337da19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,13 @@ Post 1.0.0 release, the changelog format is based on [Keep a Changelog](https:// ### Added +- socks5-client/network-requester: add support for socks4a protocol + + +## [v1.1.1](https://github.com/nymtech/nym/tree/v1.1.1) (2022-11-29) + +### Added + - binaries: add `-c` shortform for `--config-env-file` - websocket-requests: add server response signalling current packet queue length in the client - contracts: DKG contract that handles coconut key generation ([#1678][#1708][#1747]) @@ -30,17 +37,24 @@ Post 1.0.0 release, the changelog format is based on [Keep a Changelog](https:// - clients,validator-api: take coconut signers from the chain instead of specifying them via CLI ([#1747]) - multisig contract: add DKG contract to the list of addresses that can create proposals ([#1747]) - socks5-client: wait closing inbound connection until data is sent, and throttle incoming data in general ([#1783]) +- nym-cli: improve error reporting/handling and changed `vesting-schedule` queries to use query client instead of signing client ### Fixed - gateway-client: fix decrypting stored messages on reconnect ([#1786]) +### Fixed + +- gateway-client: fix decrypting stored messages on reconnect ([#1786]) +- socks5-client: fix shutting down all tasks if anyone of them panics or errors out ([#1805]) + [#1678]: https://github.com/nymtech/nym/pull/1678 [#1708]: https://github.com/nymtech/nym/pull/1708 [#1720]: https://github.com/nymtech/nym/pull/1720 [#1747]: https://github.com/nymtech/nym/pull/1747 [#1783]: https://github.com/nymtech/nym/pull/1783 [#1786]: https://github.com/nymtech/nym/pull/1786 +[#1805]: https://github.com/nymtech/nym/pull/1805 ## [v1.1.0](https://github.com/nymtech/nym/tree/v1.1.0) (2022-11-09) diff --git a/Cargo.lock b/Cargo.lock index 29e2aaf99d..5113e59e0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -586,7 +586,7 @@ dependencies = [ [[package]] name = "client-core" -version = "1.1.0" +version = "1.1.1" dependencies = [ "async-trait", "client-connections", @@ -612,6 +612,7 @@ dependencies = [ "thiserror", "time 0.3.14", "tokio", + "tokio-stream", "topology", "url", "validator-client", @@ -1649,7 +1650,7 @@ dependencies = [ [[package]] name = "explorer-api" -version = "1.1.0" +version = "1.1.1" dependencies = [ "chrono", "clap 3.2.8", @@ -3139,7 +3140,7 @@ dependencies = [ [[package]] name = "nym-cli" -version = "1.1.0" +version = "1.1.1" dependencies = [ "anyhow", "base64", @@ -3156,6 +3157,7 @@ dependencies = [ "pretty_env_logger", "serde", "serde_json", + "tap", "tokio", "validator-client", ] @@ -3181,6 +3183,7 @@ dependencies = [ "rand 0.6.5", "serde", "serde_json", + "tap", "thiserror", "time 0.3.14", "toml", @@ -3191,7 +3194,7 @@ dependencies = [ [[package]] name = "nym-client" -version = "1.1.0" +version = "1.1.1" dependencies = [ "clap 3.2.8", "client-connections", @@ -3231,7 +3234,7 @@ dependencies = [ [[package]] name = "nym-gateway" -version = "1.1.0" +version = "1.1.1" dependencies = [ "anyhow", "async-trait", @@ -3278,7 +3281,7 @@ dependencies = [ [[package]] name = "nym-mixnode" -version = "1.1.0" +version = "1.1.1" dependencies = [ "anyhow", "bs58", @@ -3320,7 +3323,7 @@ dependencies = [ [[package]] name = "nym-network-requester" -version = "1.1.0" +version = "1.1.1" dependencies = [ "async-trait", "clap 3.2.8", @@ -3368,7 +3371,7 @@ dependencies = [ [[package]] name = "nym-socks5-client" -version = "1.1.0" +version = "1.1.1" dependencies = [ "clap 3.2.8", "client-connections", @@ -3435,7 +3438,7 @@ dependencies = [ [[package]] name = "nym-validator-api" -version = "1.1.0" +version = "1.1.1" dependencies = [ "anyhow", "async-trait", @@ -5714,8 +5717,12 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" name = "task" version = "0.1.0" dependencies = [ + "futures", "log", + "thiserror", "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", ] [[package]] diff --git a/clients/client-core/Cargo.toml b/clients/client-core/Cargo.toml index c429e6c58b..7ff4b53078 100644 --- a/clients/client-core/Cargo.toml +++ b/clients/client-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "client-core" -version = "1.1.0" +version = "1.1.1" authors = ["Dave Hrycyszyn "] edition = "2021" @@ -34,7 +34,11 @@ nymsphinx = { path = "../../common/nymsphinx" } pemstore = { path = "../../common/pemstore" } topology = { path = "../../common/topology" } validator-client = { path = "../../common/client-libs/validator-client", default-features = false } +task = { path = "../../common/task" } +[target."cfg(not(target_arch = \"wasm32\"))".dependencies.tokio-stream] +version = "0.1.9" +features = ["time"] [target."cfg(target_arch = \"wasm32\")".dependencies.wasm-bindgen-futures] version = "0.4" @@ -50,9 +54,6 @@ rev = "b9d1a54ad514c2f230a026afe0dde341e98cd7b6" version = "0.2.4" features = ["futures"] -[target."cfg(not(target_arch = \"wasm32\"))".dependencies.task] -path = "../../common/task" - [dev-dependencies] tempfile = "3.1.0" diff --git a/clients/client-core/src/client/base_client/mod.rs b/clients/client-core/src/client/base_client/mod.rs new file mode 100644 index 0000000000..f2c9668166 --- /dev/null +++ b/clients/client-core/src/client/base_client/mod.rs @@ -0,0 +1,481 @@ +// Copyright 2022 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::client::cover_traffic_stream::LoopCoverTrafficStream; +use crate::client::inbound_messages::{InputMessage, InputMessageReceiver, InputMessageSender}; +use crate::client::key_manager::KeyManager; +use crate::client::mix_traffic::{BatchMixMessageSender, MixTrafficController}; +use crate::client::real_messages_control; +use crate::client::real_messages_control::RealMessagesController; +use crate::client::received_buffer::{ + ReceivedBufferRequestReceiver, ReceivedBufferRequestSender, ReceivedMessagesBufferController, +}; +use crate::client::replies::reply_controller; +use crate::client::replies::reply_controller::{ReplyControllerReceiver, ReplyControllerSender}; +use crate::client::replies::reply_storage::{ + CombinedReplyStorage, PersistentReplyStorage, ReplyStorageBackend, SentReplyKeys, +}; +use crate::client::topology_control::{ + TopologyAccessor, TopologyRefresher, TopologyRefresherConfig, +}; +use crate::config::{Config, DebugConfig, GatewayEndpointConfig}; +use crate::error::ClientCoreError; +use crate::spawn_future; +use client_connections::{ConnectionCommandReceiver, ConnectionCommandSender, LaneQueueLengths}; +use crypto::asymmetric::{encryption, identity}; +use futures::channel::mpsc; +use gateway_client::bandwidth::BandwidthController; +use gateway_client::{ + AcknowledgementReceiver, AcknowledgementSender, GatewayClient, MixnetMessageReceiver, + MixnetMessageSender, +}; +use log::info; +use nymsphinx::acknowledgements::AckKey; +use nymsphinx::addressing::clients::Recipient; +use nymsphinx::addressing::nodes::NodeIdentity; +use std::sync::Arc; +use std::time::Duration; +use task::{ShutdownListener, ShutdownNotifier}; +use url::Url; + +#[cfg(all(not(target_arch = "wasm32"), feature = "fs-surb-storage"))] +pub mod non_wasm_helpers; + +pub struct ClientInput { + pub shared_lane_queue_lengths: LaneQueueLengths, + pub connection_command_sender: ConnectionCommandSender, + pub input_sender: InputMessageSender, +} + +pub struct ClientOutput { + pub received_buffer_request_sender: ReceivedBufferRequestSender, +} + +pub enum ClientInputStatus { + AwaitingProducer { client_input: ClientInput }, + Connected, +} + +impl ClientInputStatus { + pub fn register_producer(&mut self) -> ClientInput { + match std::mem::replace(self, ClientInputStatus::Connected) { + ClientInputStatus::AwaitingProducer { client_input } => client_input, + ClientInputStatus::Connected => panic!("producer was already registered before"), + } + } +} + +pub enum ClientOutputStatus { + AwaitingConsumer { client_output: ClientOutput }, + Connected, +} + +impl ClientOutputStatus { + pub fn register_consumer(&mut self) -> ClientOutput { + match std::mem::replace(self, ClientOutputStatus::Connected) { + ClientOutputStatus::AwaitingConsumer { client_output } => client_output, + ClientOutputStatus::Connected => panic!("consumer was already registered before"), + } + } +} + +pub struct BaseClientBuilder<'a, B> { + // due to wasm limitations I had to split it like this : ( + gateway_config: &'a GatewayEndpointConfig, + debug_config: &'a DebugConfig, + disabled_credentials: bool, + validator_api_endpoints: Vec, + reply_storage_backend: B, + + bandwidth_controller: Option, + key_manager: KeyManager, +} + +impl<'a, B> BaseClientBuilder<'a, B> +where + B: ReplyStorageBackend + Send + Sync + 'static, +{ + pub fn new_from_base_config( + base_config: &'a Config, + key_manager: KeyManager, + bandwidth_controller: Option, + reply_storage_backend: B, + ) -> BaseClientBuilder<'a, B> { + BaseClientBuilder { + gateway_config: base_config.get_gateway_endpoint_config(), + debug_config: base_config.get_debug_config(), + disabled_credentials: base_config.get_disabled_credentials_mode(), + validator_api_endpoints: base_config.get_validator_api_endpoints(), + bandwidth_controller, + reply_storage_backend, + key_manager, + } + } + + pub fn new( + gateway_config: &'a GatewayEndpointConfig, + debug_config: &'a DebugConfig, + key_manager: KeyManager, + bandwidth_controller: Option, + reply_storage_backend: B, + disabled_credentials: bool, + validator_api_endpoints: Vec, + ) -> BaseClientBuilder<'a, B> { + BaseClientBuilder { + gateway_config, + debug_config, + disabled_credentials, + validator_api_endpoints, + bandwidth_controller, + reply_storage_backend, + key_manager, + } + } + + pub fn as_mix_recipient(&self) -> Recipient { + Recipient::new( + *self.key_manager.identity_keypair().public_key(), + *self.key_manager.encryption_keypair().public_key(), + // TODO: below only works under assumption that gateway address == gateway id + // (which currently is true) + NodeIdentity::from_base58_string(&self.gateway_config.gateway_id).unwrap(), + ) + } + + // future constantly pumping loop cover traffic at some specified average rate + // the pumped traffic goes to the MixTrafficController + fn start_cover_traffic_stream( + debug_config: &DebugConfig, + ack_key: Arc, + self_address: Recipient, + topology_accessor: TopologyAccessor, + mix_tx: BatchMixMessageSender, + shutdown: ShutdownListener, + ) { + info!("Starting loop cover traffic stream..."); + + let mut stream = LoopCoverTrafficStream::new( + ack_key, + debug_config.average_ack_delay, + debug_config.average_packet_delay, + debug_config.loop_cover_traffic_average_delay, + mix_tx, + self_address, + topology_accessor, + ); + + if let Some(size) = debug_config.use_extended_packet_size { + log::debug!("Setting extended packet size: {:?}", size); + stream.set_custom_packet_size(size.into()); + } + + stream.start_with_shutdown(shutdown); + } + + #[allow(clippy::too_many_arguments)] + fn start_real_traffic_controller( + controller_config: real_messages_control::Config, + topology_accessor: TopologyAccessor, + ack_receiver: AcknowledgementReceiver, + input_receiver: InputMessageReceiver, + mix_sender: BatchMixMessageSender, + reply_storage: CombinedReplyStorage, + reply_controller_sender: ReplyControllerSender, + reply_controller_receiver: ReplyControllerReceiver, + lane_queue_lengths: LaneQueueLengths, + client_connection_rx: ConnectionCommandReceiver, + shutdown: ShutdownListener, + ) { + info!("Starting real traffic stream..."); + + RealMessagesController::new( + controller_config, + ack_receiver, + input_receiver, + mix_sender, + topology_accessor, + reply_storage, + reply_controller_sender, + reply_controller_receiver, + lane_queue_lengths, + client_connection_rx, + ) + .start_with_shutdown(shutdown); + } + + // buffer controlling all messages fetched from provider + // required so that other components would be able to use them (say the websocket) + fn start_received_messages_buffer_controller( + local_encryption_keypair: Arc, + query_receiver: ReceivedBufferRequestReceiver, + mixnet_receiver: MixnetMessageReceiver, + reply_key_storage: SentReplyKeys, + reply_controller_sender: ReplyControllerSender, + shutdown: ShutdownListener, + ) { + info!("Starting received messages buffer controller..."); + ReceivedMessagesBufferController::new( + local_encryption_keypair, + query_receiver, + mixnet_receiver, + reply_key_storage, + reply_controller_sender, + ) + .start_with_shutdown(shutdown) + } + + async fn start_gateway_client( + &mut self, + mixnet_message_sender: MixnetMessageSender, + ack_sender: AcknowledgementSender, + shutdown: ShutdownListener, + ) -> GatewayClient { + let gateway_id = self.gateway_config.gateway_id.clone(); + if gateway_id.is_empty() { + panic!("The identity of the gateway is unknown - did you run `nym-client` init?") + } + let gateway_owner = self.gateway_config.gateway_owner.clone(); + if gateway_owner.is_empty() { + panic!("The owner of the gateway is unknown - did you run `nym-client` init?") + } + let gateway_address = self.gateway_config.gateway_listener.clone(); + if gateway_address.is_empty() { + panic!("The address of the gateway is unknown - did you run `nym-client` init?") + } + + let gateway_identity = identity::PublicKey::from_base58_string(gateway_id) + .expect("provided gateway id is invalid!"); + + // disgusting wasm workaround since there's no key persistence there (nor `client init`) + let shared_key = if self.key_manager.gateway_key_set() { + Some(self.key_manager.gateway_shared_key()) + } else { + None + }; + + let mut gateway_client = GatewayClient::new( + gateway_address, + self.key_manager.identity_keypair(), + gateway_identity, + gateway_owner, + shared_key, + mixnet_message_sender, + ack_sender, + self.debug_config.gateway_response_timeout, + self.bandwidth_controller.take(), + shutdown, + ); + + gateway_client.set_disabled_credentials_mode(self.disabled_credentials); + + gateway_client + .authenticate_and_start() + .await + .expect("could not authenticate and start up the gateway connection"); + + gateway_client + } + + // future responsible for periodically polling directory server and updating + // the current global view of topology + async fn start_topology_refresher( + validator_api_urls: Vec, + refresh_rate: Duration, + topology_accessor: TopologyAccessor, + shutdown: ShutdownListener, + ) -> Result<(), ClientCoreError> { + let topology_refresher_config = TopologyRefresherConfig::new( + validator_api_urls, + refresh_rate, + env!("CARGO_PKG_VERSION").to_string(), + ); + let mut topology_refresher = + TopologyRefresher::new(topology_refresher_config, topology_accessor); + // before returning, block entire runtime to refresh the current network view so that any + // components depending on topology would see a non-empty view + info!("Obtaining initial network topology"); + topology_refresher.refresh().await; + + // TODO: a slightly more graceful termination here + if !topology_refresher.is_topology_routable().await { + log::error!( + "The current network topology seem to be insufficient to route any packets through \ + - check if enough nodes and a gateway are online" + ); + return Err(ClientCoreError::InsufficientNetworkTopology); + } + + info!("Starting topology refresher..."); + topology_refresher.start_with_shutdown(shutdown); + Ok(()) + } + + // controller for sending sphinx packets to mixnet (either real traffic or cover traffic) + // TODO: if we want to send control messages to gateway_client, this CAN'T take the ownership + // over it. Perhaps GatewayClient needs to be thread-shareable or have some channel for + // requests? + fn start_mix_traffic_controller( + gateway_client: GatewayClient, + shutdown: ShutdownListener, + ) -> BatchMixMessageSender { + info!("Starting mix traffic controller..."); + let (mix_traffic_controller, mix_tx) = MixTrafficController::new(gateway_client); + mix_traffic_controller.start_with_shutdown(shutdown); + mix_tx + } + + async fn setup_persistent_reply_storage( + backend: B, + shutdown: ShutdownListener, + ) -> Result> { + let persistent_storage = PersistentReplyStorage::new(backend); + let mem_store = persistent_storage + .load_state_from_backend() + .await + .map_err(|err| ClientCoreError::SurbStorageError { source: err })?; + + let store_clone = mem_store.clone(); + spawn_future(async move { + persistent_storage + .flush_on_shutdown(store_clone, shutdown) + .await + }); + + Ok(mem_store) + } + + pub async fn start_base(mut self) -> Result> { + info!("Starting nym client"); + // channels for inter-component communication + // TODO: make the channels be internally created by the relevant components + // rather than creating them here, so say for example the buffer controller would create the request channels + // and would allow anyone to clone the sender channel + + // unwrapped_sphinx_sender is the transmitter of mixnet messages received from the gateway + // unwrapped_sphinx_receiver is the receiver for said messages - used by ReceivedMessagesBuffer + let (mixnet_messages_sender, mixnet_messages_receiver) = mpsc::unbounded(); + + // used for announcing connection or disconnection of a channel for pushing re-assembled messages to + let (received_buffer_request_sender, received_buffer_request_receiver) = mpsc::unbounded(); + + // channels responsible for controlling real messages + let (input_sender, input_receiver) = tokio::sync::mpsc::channel::(1); + + // channels responsible for controlling ack messages + let (ack_sender, ack_receiver) = mpsc::unbounded(); + let shared_topology_accessor = TopologyAccessor::new(); + + // Shutdown notifier for signalling tasks to stop + let shutdown = ShutdownNotifier::default(); + + // channels responsible for dealing with reply-related fun + let (reply_controller_sender, reply_controller_receiver) = + reply_controller::new_control_channels(); + + let self_address = self.as_mix_recipient(); + + // the components are started in very specific order. Unless you know what you are doing, + // do not change that. + let gateway_client = self + .start_gateway_client(mixnet_messages_sender, ack_sender, shutdown.subscribe()) + .await; + + let reply_storage = + Self::setup_persistent_reply_storage(self.reply_storage_backend, shutdown.subscribe()) + .await?; + + Self::start_topology_refresher( + self.validator_api_endpoints.clone(), + self.debug_config.topology_refresh_rate, + shared_topology_accessor.clone(), + shutdown.subscribe(), + ) + .await?; + + Self::start_received_messages_buffer_controller( + self.key_manager.encryption_keypair(), + received_buffer_request_receiver, + mixnet_messages_receiver, + reply_storage.key_storage(), + reply_controller_sender.clone(), + shutdown.subscribe(), + ); + + // The sphinx_message_sender is the transmitter for any component generating sphinx packets + // that are to be sent to the mixnet. They are used by cover traffic stream and real + // traffic stream. + // The MixTrafficController then sends the actual traffic + let sphinx_message_sender = + Self::start_mix_traffic_controller(gateway_client, shutdown.subscribe()); + + // Channels that the websocket listener can use to signal downstream to the real traffic + // controller that connections are closed. + let (client_connection_tx, client_connection_rx) = mpsc::unbounded(); + + // Shared queue length data. Published by the `OutQueueController` in the client, and used + // primarily to throttle incoming connections (e.g socks5 for attached network-requesters) + let shared_lane_queue_lengths = LaneQueueLengths::new(); + + let mut controller_config = real_messages_control::Config::new( + self.debug_config, + self.key_manager.ack_key(), + self_address, + ); + + if let Some(size) = self.debug_config.use_extended_packet_size { + log::debug!("Setting extended packet size: {:?}", size); + controller_config.set_custom_packet_size(size.into()); + } + + Self::start_real_traffic_controller( + controller_config, + shared_topology_accessor.clone(), + ack_receiver, + input_receiver, + sphinx_message_sender.clone(), + reply_storage, + reply_controller_sender, + reply_controller_receiver, + shared_lane_queue_lengths.clone(), + client_connection_rx, + shutdown.subscribe(), + ); + + if !self.debug_config.disable_loop_cover_traffic_stream { + Self::start_cover_traffic_stream( + self.debug_config, + self.key_manager.ack_key(), + self_address, + shared_topology_accessor, + sphinx_message_sender, + shutdown.subscribe(), + ); + } + + info!("Client startup finished!"); + info!("The address of this client is: {self_address}"); + + Ok(BaseClient { + client_input: ClientInputStatus::AwaitingProducer { + client_input: ClientInput { + shared_lane_queue_lengths, + connection_command_sender: client_connection_tx, + input_sender, + }, + }, + client_output: ClientOutputStatus::AwaitingConsumer { + client_output: ClientOutput { + received_buffer_request_sender, + }, + }, + shutdown_notifier: shutdown, + }) + } +} + +pub struct BaseClient { + pub client_input: ClientInputStatus, + pub client_output: ClientOutputStatus, + + pub shutdown_notifier: ShutdownNotifier, +} diff --git a/clients/client-core/src/client/base_client/non_wasm_helpers.rs b/clients/client-core/src/client/base_client/non_wasm_helpers.rs new file mode 100644 index 0000000000..8c51c784c8 --- /dev/null +++ b/clients/client-core/src/client/base_client/non_wasm_helpers.rs @@ -0,0 +1,51 @@ +// Copyright 2022 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::client::replies::reply_storage::{ + fs_backend, CombinedReplyStorage, ReplyStorageBackend, +}; +use crate::config::DebugConfig; +use crate::error::ClientCoreError; +use log::{error, info}; +use std::path::Path; + +pub async fn setup_fs_reply_surb_backend>( + db_path: P, + debug_config: &DebugConfig, +) -> Result> { + // if the database file doesnt exist, initialise fresh storage, otherwise attempt to load the existing one + let db_path = db_path.as_ref(); + if db_path.exists() { + info!("loading existing surb database"); + match fs_backend::Backend::try_load(db_path).await { + Ok(backend) => Ok(backend), + Err(err) => { + error!("failed to setup persistent storage backend for our reply needs: {err}"); + Err(ClientCoreError::SurbStorageError { source: err }) + } + } + } else { + info!("creating fresh surb database"); + let mut storage_backend = match fs_backend::Backend::init(db_path).await { + Ok(backend) => backend, + Err(err) => { + error!("failed to setup persistent storage backend for our reply needs: {err}"); + return Err(ClientCoreError::SurbStorageError { source: err }); + } + }; + + // while I kinda hate that we're going to be creating `CombinedReplyStorage` twice, + // it will only be happening on the very first run and in practice won't incur huge + // costs since the storage is going to be empty + let mem_store = CombinedReplyStorage::new( + debug_config.minimum_reply_surb_storage_threshold, + debug_config.maximum_reply_surb_storage_threshold, + ); + storage_backend + .init_fresh(&mem_store) + .await + .map_err(|err| ClientCoreError::SurbStorageError { source: err })?; + + Ok(storage_backend) + } +} diff --git a/clients/client-core/src/client/cover_traffic_stream.rs b/clients/client-core/src/client/cover_traffic_stream.rs index f0f5079f2b..56dd0f3751 100644 --- a/clients/client-core/src/client/cover_traffic_stream.rs +++ b/clients/client-core/src/client/cover_traffic_stream.rs @@ -143,6 +143,16 @@ impl LoopCoverTrafficStream { self.packet_size = packet_size; } + fn set_next_delay(&mut self, amount: Duration) { + #[cfg(not(target_arch = "wasm32"))] + let next_delay = Box::pin(time::sleep(amount)); + + #[cfg(target_arch = "wasm32")] + let next_delay = Box::pin(wasm_timer::Delay::new(amount)); + + self.next_delay = next_delay; + } + async fn on_new_message(&mut self) { trace!("next cover message!"); @@ -203,12 +213,11 @@ impl LoopCoverTrafficStream { tokio::task::yield_now().await; } - #[cfg(not(target_arch = "wasm32"))] pub fn start_with_shutdown(mut self, mut shutdown: task::ShutdownListener) { // we should set initial delay only when we actually start the stream let sampled = sample_poisson_duration(&mut self.rng, self.average_cover_message_sending_delay); - self.next_delay = Box::pin(time::sleep(sampled)); + self.set_next_delay(sampled); spawn_future(async move { debug!("Started LoopCoverTrafficStream with graceful shutdown support"); @@ -229,17 +238,16 @@ impl LoopCoverTrafficStream { } } } - assert!(shutdown.is_shutdown_poll()); + shutdown.recv_timeout().await; log::debug!("LoopCoverTrafficStream: Exiting"); }) } - #[cfg(target_arch = "wasm32")] pub fn start(mut self) { // we should set initial delay only when we actually start the stream let sampled = sample_poisson_duration(&mut self.rng, self.average_cover_message_sending_delay); - self.next_delay = Box::pin(wasm_timer::Delay::new(sampled)); + self.set_next_delay(sampled); spawn_future(async move { debug!("Started LoopCoverTrafficStream without graceful shutdown support"); diff --git a/clients/client-core/src/client/key_manager.rs b/clients/client-core/src/client/key_manager.rs index 2e7069a6a8..6fa264f43f 100644 --- a/clients/client-core/src/client/key_manager.rs +++ b/clients/client-core/src/client/key_manager.rs @@ -149,6 +149,10 @@ impl KeyManager { ) } + pub fn gateway_key_set(&self) -> bool { + self.gateway_shared_key.is_some() + } + /// Gets an atomically reference counted pointer to [`AckKey`]. pub fn ack_key(&self) -> Arc { Arc::clone(&self.ack_key) diff --git a/clients/client-core/src/client/mix_traffic.rs b/clients/client-core/src/client/mix_traffic.rs index d209d0ca4a..4e12121e4d 100644 --- a/clients/client-core/src/client/mix_traffic.rs +++ b/clients/client-core/src/client/mix_traffic.rs @@ -77,7 +77,6 @@ impl MixTrafficController { } } - #[cfg(not(target_arch = "wasm32"))] pub fn start_with_shutdown(mut self, mut shutdown: task::ShutdownListener) { spawn_future(async move { debug!("Started MixTrafficController with graceful shutdown support"); @@ -98,12 +97,11 @@ impl MixTrafficController { } } } - assert!(shutdown.is_shutdown_poll()); + shutdown.recv_timeout().await; log::debug!("MixTrafficController: Exiting"); }) } - #[cfg(target_arch = "wasm32")] pub fn start(mut self) { spawn_future(async move { debug!("Started MixTrafficController without graceful shutdown support"); diff --git a/clients/client-core/src/client/mod.rs b/clients/client-core/src/client/mod.rs index c1e382fab0..8110d52909 100644 --- a/clients/client-core/src/client/mod.rs +++ b/clients/client-core/src/client/mod.rs @@ -1,5 +1,7 @@ -use std::sync::atomic::AtomicBool; +// Copyright 2022 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 +pub mod base_client; pub mod cover_traffic_stream; pub mod inbound_messages; pub mod key_manager; @@ -8,10 +10,3 @@ pub mod real_messages_control; pub mod received_buffer; pub mod replies; pub mod topology_control; - -// This is *NOT* used to signal shutdown. -// It's critical that we don't have any tasks finishing early, this is an additional safety check -// that tasks exiting are doing so because shutdown has been signalled, and no other reason. -// In particular for tasks that rely on their associated channel being closed to signal shutdown, -// and don't have access to a shutdown listener channel. -pub static SHUTDOWN_HAS_BEEN_SIGNALLED: AtomicBool = AtomicBool::new(false); diff --git a/clients/client-core/src/client/real_messages_control/acknowledgement_control/acknowledgement_listener.rs b/clients/client-core/src/client/real_messages_control/acknowledgement_control/acknowledgement_listener.rs index c05a3c4ed8..59aacf253f 100644 --- a/clients/client-core/src/client/real_messages_control/acknowledgement_control/acknowledgement_listener.rs +++ b/clients/client-core/src/client/real_messages_control/acknowledgement_control/acknowledgement_listener.rs @@ -71,7 +71,6 @@ impl AcknowledgementListener { } } - #[cfg(not(target_arch = "wasm32"))] pub(super) async fn run_with_shutdown(&mut self, mut shutdown: task::ShutdownListener) { debug!("Started AcknowledgementListener with graceful shutdown support"); @@ -89,11 +88,12 @@ impl AcknowledgementListener { } } } - assert!(shutdown.is_shutdown_poll()); + shutdown.recv_timeout().await; log::debug!("AcknowledgementListener: Exiting"); } - #[cfg(target_arch = "wasm32")] + // todo: think whether this is still required + #[allow(dead_code)] pub(super) async fn run(&mut self) { debug!("Started AcknowledgementListener without graceful shutdown support"); diff --git a/clients/client-core/src/client/real_messages_control/acknowledgement_control/action_controller.rs b/clients/client-core/src/client/real_messages_control/acknowledgement_control/action_controller.rs index 16f5020f5d..d4f86f0e83 100644 --- a/clients/client-core/src/client/real_messages_control/acknowledgement_control/action_controller.rs +++ b/clients/client-core/src/client/real_messages_control/acknowledgement_control/action_controller.rs @@ -242,7 +242,6 @@ impl ActionController { } } - #[cfg(not(target_arch = "wasm32"))] pub(super) async fn run_with_shutdown(&mut self, mut shutdown: task::ShutdownListener) { debug!("Started ActionController with graceful shutdown support"); @@ -269,11 +268,15 @@ impl ActionController { } } } - assert!(shutdown.is_shutdown_poll()); + #[cfg(not(target_arch = "wasm32"))] + tokio::time::timeout(Duration::from_secs(5), shutdown.recv()) + .await + .expect("Task stopped without shutdown called"); log::debug!("ActionController: Exiting"); } - #[cfg(target_arch = "wasm32")] + // todo: think whether this is still required + #[allow(dead_code)] pub(super) async fn run(&mut self) { debug!("Started ActionController without graceful shutdown support"); diff --git a/clients/client-core/src/client/real_messages_control/acknowledgement_control/input_message_listener.rs b/clients/client-core/src/client/real_messages_control/acknowledgement_control/input_message_listener.rs index 0f667024e6..9ec3bce229 100644 --- a/clients/client-core/src/client/real_messages_control/acknowledgement_control/input_message_listener.rs +++ b/clients/client-core/src/client/real_messages_control/acknowledgement_control/input_message_listener.rs @@ -109,7 +109,6 @@ where }; } - #[cfg(not(target_arch = "wasm32"))] pub(super) async fn run_with_shutdown(&mut self, mut shutdown: task::ShutdownListener) { debug!("Started InputMessageListener with graceful shutdown support"); @@ -129,11 +128,12 @@ where } } } - assert!(shutdown.is_shutdown_poll()); + shutdown.recv_timeout().await; log::debug!("InputMessageListener: Exiting"); } - #[cfg(target_arch = "wasm32")] + // todo: think whether this is still required + #[allow(dead_code)] pub(super) async fn run(&mut self) { debug!("Started InputMessageListener without graceful shutdown support"); while let Some(input_msg) = self.input_receiver.recv().await { diff --git a/clients/client-core/src/client/real_messages_control/acknowledgement_control/mod.rs b/clients/client-core/src/client/real_messages_control/acknowledgement_control/mod.rs index eb5f9aa494..7989af1200 100644 --- a/clients/client-core/src/client/real_messages_control/acknowledgement_control/mod.rs +++ b/clients/client-core/src/client/real_messages_control/acknowledgement_control/mod.rs @@ -252,7 +252,6 @@ where } } - #[cfg(not(target_arch = "wasm32"))] pub(super) fn start_with_shutdown(self, shutdown: task::ShutdownListener) { let mut acknowledgement_listener = self.acknowledgement_listener; let mut input_message_listener = self.input_message_listener; @@ -298,7 +297,8 @@ where }); } - #[cfg(target_arch = "wasm32")] + // todo: think whether this is still required + #[allow(dead_code)] pub(super) fn start(self) { let mut acknowledgement_listener = self.acknowledgement_listener; let mut input_message_listener = self.input_message_listener; diff --git a/clients/client-core/src/client/real_messages_control/acknowledgement_control/retransmission_request_listener.rs b/clients/client-core/src/client/real_messages_control/acknowledgement_control/retransmission_request_listener.rs index 14f9205700..6130085598 100644 --- a/clients/client-core/src/client/real_messages_control/acknowledgement_control/retransmission_request_listener.rs +++ b/clients/client-core/src/client/real_messages_control/acknowledgement_control/retransmission_request_listener.rs @@ -230,7 +230,6 @@ where .await } - #[cfg(not(target_arch = "wasm32"))] pub(super) async fn run_with_shutdown(&mut self, mut shutdown: task::ShutdownListener) { debug!("Started RetransmissionRequestListener with graceful shutdown support"); @@ -248,11 +247,12 @@ where } } } - assert!(shutdown.is_shutdown_poll()); + shutdown.recv_timeout().await; log::debug!("RetransmissionRequestListener: Exiting"); } - #[cfg(target_arch = "wasm32")] + // todo: think whether this is still required + #[allow(dead_code)] pub(super) async fn run(&mut self) { debug!("Started RetransmissionRequestListener without graceful shutdown support"); diff --git a/clients/client-core/src/client/real_messages_control/acknowledgement_control/sent_notification_listener.rs b/clients/client-core/src/client/real_messages_control/acknowledgement_control/sent_notification_listener.rs index 43d43bb7a6..2d8e9d6160 100644 --- a/clients/client-core/src/client/real_messages_control/acknowledgement_control/sent_notification_listener.rs +++ b/clients/client-core/src/client/real_messages_control/acknowledgement_control/sent_notification_listener.rs @@ -43,7 +43,6 @@ impl SentNotificationListener { .unwrap(); } - #[cfg(not(target_arch = "wasm32"))] pub(super) async fn run_with_shutdown(&mut self, mut shutdown: task::ShutdownListener) { debug!("Started SentNotificationListener with graceful shutdown support"); @@ -67,7 +66,8 @@ impl SentNotificationListener { log::debug!("SentNotificationListener: Exiting"); } - #[cfg(target_arch = "wasm32")] + // todo: think whether this is still required + #[allow(dead_code)] pub(super) async fn run(&mut self) { debug!("Started SentNotificationListener without graceful shutdown support"); diff --git a/clients/client-core/src/client/real_messages_control/mod.rs b/clients/client-core/src/client/real_messages_control/mod.rs index 76fc5f9e43..a189016f53 100644 --- a/clients/client-core/src/client/real_messages_control/mod.rs +++ b/clients/client-core/src/client/real_messages_control/mod.rs @@ -35,8 +35,6 @@ use std::time::Duration; use crate::config; pub(crate) use acknowledgement_control::{AckActionSender, Action}; -// #[cfg(feature = "reply-surb")] -// use crate::client::reply_key_storage::ReplyKeyStorage; pub(crate) mod acknowledgement_control; pub(crate) mod message_handler; @@ -91,7 +89,7 @@ pub struct Config { impl Config { pub fn new( - base_client_debug_config: &config::Debug, + base_client_debug_config: &config::DebugConfig, ack_key: Arc, self_recipient: Recipient, ) -> Self { @@ -239,7 +237,6 @@ impl RealMessagesController { } } - #[cfg(not(target_arch = "wasm32"))] pub fn start_with_shutdown(self, shutdown: task::ShutdownListener) { let mut out_queue_control = self.out_queue_control; let ack_control = self.ack_control; diff --git a/clients/client-core/src/client/real_messages_control/real_traffic_stream.rs b/clients/client-core/src/client/real_messages_control/real_traffic_stream.rs index a69483bec2..a1eb8d2d8c 100644 --- a/clients/client-core/src/client/real_messages_control/real_traffic_stream.rs +++ b/clients/client-core/src/client/real_messages_control/real_traffic_stream.rs @@ -493,49 +493,73 @@ where } else { log::debug!("{status_str}"); } + } + + #[cfg(not(target_arch = "wasm32"))] + fn log_status_infrequent(&self) { if self.sending_delay_controller.current_multiplier() > 1 { log::warn!( "Unable to send packets fast enough - sending delay multiplier set to: {}", self.sending_delay_controller.current_multiplier() - ) - }; - if packets > 1000 { - log::warn!("{status_str}"); - } else if packets > 0 { - log::info!("{status_str}"); - } else { - log::debug!("{status_str}"); + ); } } - #[cfg(not(target_arch = "wasm32"))] pub(super) async fn run_with_shutdown(&mut self, mut shutdown: task::ShutdownListener) { debug!("Started OutQueueControl with graceful shutdown support"); - let mut status_timer = tokio::time::interval(Duration::from_secs(1)); + #[cfg(not(target_arch = "wasm32"))] + { + let mut status_timer = tokio::time::interval(Duration::from_secs(5)); + let mut infrequent_status_timer = tokio::time::interval(Duration::from_secs(60)); - while !shutdown.is_shutdown() { - tokio::select! { - biased; - _ = shutdown.recv() => { - log::trace!("OutQueueControl: Received shutdown"); + while !shutdown.is_shutdown() { + tokio::select! { + biased; + _ = shutdown.recv() => { + log::trace!("OutQueueControl: Received shutdown"); + } + _ = status_timer.tick() => { + self.log_status(); + } + _ = infrequent_status_timer.tick() => { + self.log_status_infrequent(); + } + next_message = self.next() => if let Some(next_message) = next_message { + self.on_message(next_message).await; + } else { + log::trace!("OutQueueControl: Stopping since channel closed"); + break; + } } - _ = status_timer.tick() => { - self.log_status(); - } - next_message = self.next() => if let Some(next_message) = next_message { - self.on_message(next_message).await; - } else { - log::trace!("OutQueueControl: Stopping since channel closed"); - break; + } + tokio::time::timeout(Duration::from_secs(5), shutdown.recv()) + .await + .expect("Task stopped without shutdown called"); + } + + #[cfg(target_arch = "wasm32")] + { + while !shutdown.is_shutdown() { + tokio::select! { + biased; + _ = shutdown.recv() => { + log::trace!("OutQueueControl: Received shutdown"); + } + next_message = self.next() => if let Some(next_message) = next_message { + self.on_message(next_message).await; + } else { + log::trace!("OutQueueControl: Stopping since channel closed"); + break; + } } } } - assert!(shutdown.is_shutdown_poll()); log::debug!("OutQueueControl: Exiting"); } - #[cfg(target_arch = "wasm32")] + // todo: think whether this is still required + #[allow(dead_code)] pub(super) async fn run(&mut self) { debug!("Started OutQueueControl without graceful shutdown support"); diff --git a/clients/client-core/src/client/real_messages_control/real_traffic_stream/sending_delay_controller.rs b/clients/client-core/src/client/real_messages_control/real_traffic_stream/sending_delay_controller.rs index c90f7b4983..b22ffae7ff 100644 --- a/clients/client-core/src/client/real_messages_control/real_traffic_stream/sending_delay_controller.rs +++ b/clients/client-core/src/client/real_messages_control/real_traffic_stream/sending_delay_controller.rs @@ -82,7 +82,7 @@ impl SendingDelayController { self.current_multiplier = (self.current_multiplier + 1).clamp(self.lower_bound, self.upper_bound); self.time_when_changed = get_time_now(); - log::debug!( + log::warn!( "Increasing sending delay multiplier to: {}", self.current_multiplier ); diff --git a/clients/client-core/src/client/received_buffer.rs b/clients/client-core/src/client/received_buffer.rs index 3ba8977380..3a3c760888 100644 --- a/clients/client-core/src/client/received_buffer.rs +++ b/clients/client-core/src/client/received_buffer.rs @@ -405,7 +405,6 @@ impl RequestReceiver { } } - #[cfg(not(target_arch = "wasm32"))] async fn run_with_shutdown(&mut self, mut shutdown: task::ShutdownListener) { debug!("Started RequestReceiver with graceful shutdown support"); while !shutdown.is_shutdown() { @@ -425,11 +424,12 @@ impl RequestReceiver { }, } } - assert!(shutdown.is_shutdown_poll()); + shutdown.recv_timeout().await; log::debug!("RequestReceiver: Exiting"); } - #[cfg(target_arch = "wasm32")] + // todo: think whether this is still required + #[allow(dead_code)] async fn run(&mut self) { debug!("Started RequestReceiver without graceful shutdown support"); @@ -455,7 +455,6 @@ impl FragmentedMessageReceiver { } } - #[cfg(not(target_arch = "wasm32"))] async fn run_with_shutdown(&mut self, mut shutdown: task::ShutdownListener) { debug!("Started FragmentedMessageReceiver with graceful shutdown support"); while !shutdown.is_shutdown() { @@ -474,11 +473,12 @@ impl FragmentedMessageReceiver { } } } - assert!(shutdown.is_shutdown_poll()); + shutdown.recv_timeout().await; log::debug!("FragmentedMessageReceiver: Exiting"); } - #[cfg(target_arch = "wasm32")] + // todo: think whether this is still required + #[allow(dead_code)] async fn run(&mut self) { debug!("Started FragmentedMessageReceiver without graceful shutdown support"); @@ -516,7 +516,6 @@ impl ReceivedMessagesBufferController { } } - #[cfg(not(target_arch = "wasm32"))] pub fn start_with_shutdown(self, shutdown: task::ShutdownListener) { let mut fragmented_message_receiver = self.fragmented_message_receiver; let mut request_receiver = self.request_receiver; diff --git a/clients/client-core/src/client/replies/reply_controller.rs b/clients/client-core/src/client/replies/reply_controller.rs index 6ae16cd5be..cd2e3c1d96 100644 --- a/clients/client-core/src/client/replies/reply_controller.rs +++ b/clients/client-core/src/client/replies/reply_controller.rs @@ -478,12 +478,18 @@ where } } - #[cfg(not(target_arch = "wasm32"))] pub(crate) async fn run_with_shutdown(&mut self, mut shutdown: task::ShutdownListener) { debug!("Started ReplyController with graceful shutdown support"); let polling_rate = Duration::from_secs(5); - let mut interval_timer = tokio::time::interval(polling_rate); + + #[cfg(not(target_arch = "wasm32"))] + let mut interval = + tokio_stream::wrappers::IntervalStream::new(tokio::time::interval(polling_rate)); + + #[cfg(target_arch = "wasm32")] + let mut interval = + gloo_timers::future::IntervalStream::new(polling_rate.as_millis() as u32); while !shutdown.is_shutdown() { tokio::select! { @@ -498,7 +504,7 @@ where break; } }, - _ = interval_timer.tick() => { + _ = interval.next() => { self.inspect_stale_entries().await }, } diff --git a/clients/client-core/src/client/replies/reply_storage/backend/browser_backend.rs b/clients/client-core/src/client/replies/reply_storage/backend/browser_backend.rs index 422d9d7b2b..9711ea759f 100644 --- a/clients/client-core/src/client/replies/reply_storage/backend/browser_backend.rs +++ b/clients/client-core/src/client/replies/reply_storage/backend/browser_backend.rs @@ -11,6 +11,17 @@ pub struct Backend { empty: Empty, } +impl Backend { + pub fn new(min_surb_threshold: usize, max_surb_threshold: usize) -> Self { + Backend { + empty: Empty { + min_surb_threshold, + max_surb_threshold, + }, + } + } +} + #[async_trait] impl ReplyStorageBackend for Backend { type StorageError = ::StorageError; diff --git a/clients/client-core/src/client/replies/reply_storage/backend/fs_backend/manager.rs b/clients/client-core/src/client/replies/reply_storage/backend/fs_backend/manager.rs index d7ece9fca6..cab3e519a7 100644 --- a/clients/client-core/src/client/replies/reply_storage/backend/fs_backend/manager.rs +++ b/clients/client-core/src/client/replies/reply_storage/backend/fs_backend/manager.rs @@ -9,7 +9,7 @@ use log::{error, info}; use sqlx::ConnectOptions; use std::path::Path; -#[derive(Clone)] +#[derive(Debug, Clone)] pub(crate) struct StorageManager { pub(crate) connection_pool: sqlx::SqlitePool, } diff --git a/clients/client-core/src/client/replies/reply_storage/backend/fs_backend/mod.rs b/clients/client-core/src/client/replies/reply_storage/backend/fs_backend/mod.rs index 036e34f1c7..be542e131d 100644 --- a/clients/client-core/src/client/replies/reply_storage/backend/fs_backend/mod.rs +++ b/clients/client-core/src/client/replies/reply_storage/backend/fs_backend/mod.rs @@ -22,6 +22,7 @@ mod error; mod manager; mod models; +#[derive(Debug)] pub struct Backend { temporary_old_path: Option, database_path: PathBuf, diff --git a/clients/client-core/src/client/replies/reply_storage/backend/mod.rs b/clients/client-core/src/client/replies/reply_storage/backend/mod.rs index dcdf88393e..d80231ae1e 100644 --- a/clients/client-core/src/client/replies/reply_storage/backend/mod.rs +++ b/clients/client-core/src/client/replies/reply_storage/backend/mod.rs @@ -53,7 +53,7 @@ impl ReplyStorageBackend for Empty { #[async_trait] pub trait ReplyStorageBackend: Sized { - type StorageError: Error; + type StorageError: Error + 'static; async fn start_storage_session(&self) -> Result<(), Self::StorageError> { Ok(()) diff --git a/clients/client-core/src/client/replies/reply_storage/key_storage.rs b/clients/client-core/src/client/replies/reply_storage/key_storage.rs index e9f45d2abd..05c6466bc5 100644 --- a/clients/client-core/src/client/replies/reply_storage/key_storage.rs +++ b/clients/client-core/src/client/replies/reply_storage/key_storage.rs @@ -6,7 +6,7 @@ use nymsphinx::anonymous_replies::encryption_key::EncryptionKeyDigest; use nymsphinx::anonymous_replies::SurbEncryptionKey; use std::sync::Arc; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(not(target_arch = "wasm32"), feature = "fs-surb-storage"))] use dashmap::iter::Iter; #[derive(Debug, Clone)] @@ -30,7 +30,7 @@ impl SentReplyKeys { } } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), feature = "fs-surb-storage"))] pub(crate) fn from_raw(raw: Vec<(EncryptionKeyDigest, SurbEncryptionKey)>) -> SentReplyKeys { SentReplyKeys { inner: Arc::new(SentReplyKeysInner { @@ -39,7 +39,7 @@ impl SentReplyKeys { } } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), feature = "fs-surb-storage"))] pub(crate) fn as_raw_iter(&self) -> Iter<'_, EncryptionKeyDigest, SurbEncryptionKey> { self.inner.data.iter() } diff --git a/clients/client-core/src/client/replies/reply_storage/mod.rs b/clients/client-core/src/client/replies/reply_storage/mod.rs index e7692ccc64..83c0e0e34f 100644 --- a/clients/client-core/src/client/replies/reply_storage/mod.rs +++ b/clients/client-core/src/client/replies/reply_storage/mod.rs @@ -34,7 +34,6 @@ where } // this will have to get enabled after merging develop - #[cfg(not(target_arch = "wasm32"))] pub async fn flush_on_shutdown( mut self, mem_state: CombinedReplyStorage, diff --git a/clients/client-core/src/client/replies/reply_storage/surb_storage.rs b/clients/client-core/src/client/replies/reply_storage/surb_storage.rs index b4fc1ab379..e511d3d450 100644 --- a/clients/client-core/src/client/replies/reply_storage/surb_storage.rs +++ b/clients/client-core/src/client/replies/reply_storage/surb_storage.rs @@ -12,7 +12,7 @@ use std::sync::Arc; #[cfg(not(target_arch = "wasm32"))] use tokio::time::Instant; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(not(target_arch = "wasm32"), feature = "fs-surb-storage"))] use dashmap::iter::Iter; #[cfg(target_arch = "wasm32")] @@ -48,7 +48,7 @@ impl ReceivedReplySurbsMap { } } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), feature = "fs-surb-storage"))] pub(crate) fn from_raw( min_surb_threshold: usize, max_surb_threshold: usize, @@ -63,7 +63,7 @@ impl ReceivedReplySurbsMap { } } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), feature = "fs-surb-storage"))] pub(crate) fn as_raw_iter(&self) -> Iter<'_, AnonymousSenderTag, ReceivedReplySurbs> { self.inner.data.iter() } @@ -211,7 +211,7 @@ impl ReceivedReplySurbs { } } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), feature = "fs-surb-storage"))] pub(crate) fn new_retrieved( surbs: Vec, surbs_last_received_at: Instant, @@ -223,7 +223,7 @@ impl ReceivedReplySurbs { } } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), feature = "fs-surb-storage"))] pub(crate) fn surbs_ref(&self) -> &VecDeque { &self.data } diff --git a/clients/client-core/src/client/replies/reply_storage/tag_storage.rs b/clients/client-core/src/client/replies/reply_storage/tag_storage.rs index bfec6cf045..c91d2b003e 100644 --- a/clients/client-core/src/client/replies/reply_storage/tag_storage.rs +++ b/clients/client-core/src/client/replies/reply_storage/tag_storage.rs @@ -6,7 +6,7 @@ use nymsphinx::addressing::clients::{Recipient, RecipientBytes}; use nymsphinx::anonymous_replies::requests::AnonymousSenderTag; use std::sync::Arc; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(not(target_arch = "wasm32"), feature = "fs-surb-storage"))] use dashmap::iter::Iter; #[derive(Debug, Clone)] @@ -28,7 +28,7 @@ impl UsedSenderTags { } } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), feature = "fs-surb-storage"))] pub(crate) fn from_raw(raw: Vec<(RecipientBytes, AnonymousSenderTag)>) -> UsedSenderTags { UsedSenderTags { inner: Arc::new(UsedSenderTagsInner { @@ -37,7 +37,7 @@ impl UsedSenderTags { } } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), feature = "fs-surb-storage"))] pub(crate) fn as_raw_iter(&self) -> Iter<'_, RecipientBytes, AnonymousSenderTag> { self.inner.data.iter() } diff --git a/clients/client-core/src/client/topology_control.rs b/clients/client-core/src/client/topology_control.rs index 0299d58beb..368e534578 100644 --- a/clients/client-core/src/client/topology_control.rs +++ b/clients/client-core/src/client/topology_control.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use crate::spawn_future; +use futures::StreamExt; use log::*; use nymsphinx::addressing::clients::Recipient; use nymsphinx::params::DEFAULT_NUM_MIX_HOPS; @@ -9,7 +10,6 @@ use rand::seq::SliceRandom; use rand::thread_rng; use std::ops::Deref; use std::sync::Arc; -use std::time; use std::time::Duration; use thiserror::Error; use tokio::sync::{RwLock, RwLockReadGuard}; @@ -176,14 +176,14 @@ impl Default for TopologyAccessor { pub struct TopologyRefresherConfig { validator_api_urls: Vec, - refresh_rate: time::Duration, + refresh_rate: Duration, client_version: String, } impl TopologyRefresherConfig { pub fn new( validator_api_urls: Vec, - refresh_rate: time::Duration, + refresh_rate: Duration, client_version: String, ) -> Self { TopologyRefresherConfig { @@ -353,14 +353,22 @@ impl TopologyRefresher { self.topology_accessor.is_routable().await } - #[cfg(not(target_arch = "wasm32"))] pub fn start_with_shutdown(mut self, mut shutdown: task::ShutdownListener) { spawn_future(async move { debug!("Started TopologyRefresher with graceful shutdown support"); + #[cfg(not(target_arch = "wasm32"))] + let mut interval = tokio_stream::wrappers::IntervalStream::new(tokio::time::interval( + self.refresh_rate, + )); + + #[cfg(target_arch = "wasm32")] + let mut interval = + gloo_timers::future::IntervalStream::new(self.refresh_rate.as_millis() as u32); + while !shutdown.is_shutdown() { tokio::select! { - _ = tokio::time::sleep(self.refresh_rate) => { + _ = interval.next() => { self.refresh().await; }, _ = shutdown.recv() => { @@ -368,19 +376,23 @@ impl TopologyRefresher { }, } } - assert!(shutdown.is_shutdown_poll()); + shutdown.recv_timeout().await; log::debug!("TopologyRefresher: Exiting"); }) } - #[cfg(target_arch = "wasm32")] pub fn start(mut self) { - use futures::StreamExt; - spawn_future(async move { + #[cfg(not(target_arch = "wasm32"))] + let mut interval = tokio_stream::wrappers::IntervalStream::new(tokio::time::interval( + self.refresh_rate, + )); + + #[cfg(target_arch = "wasm32")] let mut interval = gloo_timers::future::IntervalStream::new(self.refresh_rate.as_millis() as u32); - while let Some(_) = interval.next().await { + + while (interval.next().await).is_some() { self.refresh().await; } }) diff --git a/clients/client-core/src/config/mod.rs b/clients/client-core/src/config/mod.rs index a956e9ca89..23ce0ef54d 100644 --- a/clients/client-core/src/config/mod.rs +++ b/clients/client-core/src/config/mod.rs @@ -51,7 +51,7 @@ pub fn missing_string_value() -> String { MISSING_VALUE.to_string() } -#[derive(Debug, Deserialize, PartialEq, Serialize)] +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct Config { client: Client, @@ -59,15 +59,21 @@ pub struct Config { #[serde(default)] logging: Logging, #[serde(default)] - debug: Debug, + debug: DebugConfig, } -impl Config { - pub fn new>(id: S) -> Self { +impl Config { + pub fn new>(id: S) -> Self + where + T: NymConfig, + { Config::default().with_id(id) } - pub fn with_id>(mut self, id: S) -> Self { + pub fn with_id>(mut self, id: S) -> Self + where + T: NymConfig, + { let id = id.into(); // identity key setting @@ -128,7 +134,7 @@ impl Config { self.client.disabled_credentials_mode = disabled_credentials_mode; } - pub fn with_gateway_endpoint(&mut self, gateway_endpoint: GatewayEndpoint) { + pub fn with_gateway_endpoint(&mut self, gateway_endpoint: GatewayEndpointConfig) { self.client.gateway_endpoint = gateway_endpoint; } @@ -146,8 +152,15 @@ impl Config { pub fn set_high_default_traffic_volume(&mut self) { self.debug.average_packet_delay = Duration::from_millis(10); - self.debug.loop_cover_traffic_average_delay = Duration::from_millis(2_000_000); // basically don't really send cover messages - self.debug.message_sending_average_delay = Duration::from_millis(4); // 250 "real" messages / s + // basically don't really send cover messages + self.debug.loop_cover_traffic_average_delay = Duration::from_millis(2_000_000); + // 250 "real" messages / s + self.debug.message_sending_average_delay = Duration::from_millis(4); + } + + pub fn set_no_cover_traffic(&mut self) { + self.debug.disable_loop_cover_traffic_stream = true; + self.debug.disable_main_poisson_packet_distribution = true; } pub fn set_custom_version(&mut self, version: &str) { @@ -158,10 +171,6 @@ impl Config { self.client.id.clone() } - pub fn get_debug_config(&self) -> &Debug { - &self.debug - } - pub fn get_disabled_credentials_mode(&self) -> bool { self.client.disabled_credentials_mode } @@ -214,7 +223,11 @@ impl Config { self.client.gateway_endpoint.gateway_listener.clone() } - pub fn get_gateway_endpoint(&self) -> &GatewayEndpoint { + pub fn get_gateway_endpoint_config(&self) -> &GatewayEndpointConfig { + &self.client.gateway_endpoint + } + + pub fn get_gateway_endpoint(&self) -> &GatewayEndpointConfig { &self.client.gateway_endpoint } @@ -231,6 +244,10 @@ impl Config { } // Debug getters + pub fn get_debug_config(&self) -> &DebugConfig { + &self.debug + } + pub fn get_average_packet_delay(&self) -> Duration { self.debug.average_packet_delay } @@ -320,7 +337,7 @@ impl Default for Config { #[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] #[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter_with_clone))] -pub struct GatewayEndpoint { +pub struct GatewayEndpointConfig { /// gateway_id specifies ID of the gateway to which the client should send messages. /// If initially omitted, a random gateway will be chosen from the available topology. pub gateway_id: String, @@ -332,10 +349,10 @@ pub struct GatewayEndpoint { pub gateway_listener: String, } -impl From for GatewayEndpoint { - fn from(node: topology::gateway::Node) -> GatewayEndpoint { +impl From for GatewayEndpointConfig { + fn from(node: topology::gateway::Node) -> GatewayEndpointConfig { let gateway_listener = node.clients_address(); - GatewayEndpoint { + GatewayEndpointConfig { gateway_id: node.identity_key.to_base58_string(), gateway_owner: node.owner, gateway_listener, @@ -343,7 +360,7 @@ impl From for GatewayEndpoint { } } -#[derive(Debug, Deserialize, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Serialize)] pub struct Client { /// Version of the client for which this configuration was created. #[serde(default = "missing_string_value")] @@ -358,6 +375,7 @@ pub struct Client { disabled_credentials_mode: bool, /// Addresses to nymd validators via which the client can communicate with the chain. + #[serde(default)] validator_urls: Vec, /// Addresses to APIs running on validator from which the client gets the view of the network. @@ -384,7 +402,7 @@ pub struct Client { ack_key_file: PathBuf, /// Information regarding how the client should send data to gateway. - gateway_endpoint: GatewayEndpoint, + gateway_endpoint: GatewayEndpointConfig, /// Path to the database containing bandwidth credentials of this client. database_path: PathBuf, @@ -458,13 +476,13 @@ impl Client { } } -#[derive(Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq, Serialize)] #[serde(deny_unknown_fields)] pub struct Logging {} -#[derive(Debug, Deserialize, PartialEq, Serialize)] +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] #[serde(default, deny_unknown_fields)] -pub struct Debug { +pub struct DebugConfig { /// The parameter of Poisson distribution determining how long, on average, /// sent packet is going to be delayed at any given mix node. /// So for a packet going through three mix nodes, on average, it will take three times this value @@ -562,9 +580,9 @@ pub enum ExtendedPacketSize { Extended32, } -impl Default for Debug { +impl Default for DebugConfig { fn default() -> Self { - Debug { + DebugConfig { average_packet_delay: DEFAULT_AVERAGE_PACKET_DELAY, average_ack_delay: DEFAULT_AVERAGE_PACKET_DELAY, ack_wait_multiplier: DEFAULT_ACK_WAIT_MULTIPLIER, diff --git a/clients/client-core/src/error.rs b/clients/client-core/src/error.rs index 24e846087f..b993c291ae 100644 --- a/clients/client-core/src/error.rs +++ b/clients/client-core/src/error.rs @@ -1,12 +1,13 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use crate::client::replies::reply_storage::ReplyStorageBackend; use crypto::asymmetric::identity::Ed25519RecoveryError; use gateway_client::error::GatewayClientError; use validator_client::ValidatorClientError; #[derive(thiserror::Error, Debug)] -pub enum ClientCoreError { +pub enum ClientCoreError { #[error("I/O error: {0}")] IoError(#[from] std::io::Error), #[error("Gateway client error: {0}")] @@ -26,4 +27,10 @@ pub enum ClientCoreError { CouldNotLoadExistingGatewayConfiguration(std::io::Error), #[error("The current network topology seem to be insufficient to route any packets through")] InsufficientNetworkTopology, + + #[error("Unexpected exit")] + UnexpectedExit, + + #[error("experienced a failure with our reply surb persistent storage: {source}")] + SurbStorageError { source: B::StorageError }, } diff --git a/clients/client-core/src/init.rs b/clients/client-core/src/init.rs index be9758d363..4a4b0ccecc 100644 --- a/clients/client-core/src/init.rs +++ b/clients/client-core/src/init.rs @@ -18,16 +18,20 @@ use tap::TapFallible; use topology::{filter::VersionFilterable, gateway}; use url::Url; +use crate::client::replies::reply_storage::ReplyStorageBackend; use crate::{ client::key_manager::KeyManager, config::{persistence::key_pathfinder::ClientKeyPathfinder, Config}, error::ClientCoreError, }; -pub async fn query_gateway_details( +pub async fn query_gateway_details( validator_servers: Vec, chosen_gateway_id: Option<&str>, -) -> Result { +) -> Result> +where + B: ReplyStorageBackend, +{ let validator_api = validator_servers .choose(&mut thread_rng()) .ok_or(ClientCoreError::ListOfValidatorApisIsEmpty)?; @@ -59,12 +63,13 @@ pub async fn query_gateway_details( } } -pub async fn register_with_gateway_and_store_keys( +pub async fn register_with_gateway_and_store_keys( gateway_details: gateway::Node, config: &Config, -) -> Result<(), ClientCoreError> +) -> Result<(), ClientCoreError> where T: NymConfig, + B: ReplyStorageBackend, { let mut rng = OsRng; let mut key_manager = KeyManager::new(&mut rng); @@ -79,10 +84,13 @@ where .tap_err(|err| log::error!("Failed to generate keys: {err}"))?) } -async fn register_with_gateway( +async fn register_with_gateway( gateway: &gateway::Node, our_identity: Arc, -) -> Result, ClientCoreError> { +) -> Result, ClientCoreError> +where + B: ReplyStorageBackend, +{ let timeout = Duration::from_millis(1500); let mut gateway_client = GatewayClient::new_init( gateway.clients_address(), @@ -90,8 +98,6 @@ async fn register_with_gateway( gateway.owner.clone(), our_identity.clone(), timeout, - #[cfg(not(target_arch = "wasm32"))] - None, ); gateway_client .establish_connection() @@ -104,13 +110,17 @@ async fn register_with_gateway( Ok(shared_keys) } -pub fn show_address(config: &Config) -> Result<(), ClientCoreError> +pub fn show_address(config: &Config) -> Result<(), ClientCoreError> where T: config::NymConfig, + B: ReplyStorageBackend, { - fn load_identity_keys( + fn load_identity_keys( pathfinder: &ClientKeyPathfinder, - ) -> Result { + ) -> Result> + where + B: ReplyStorageBackend, + { let identity_keypair: identity::KeyPair = pemstore::load_keypair(&pemstore::KeyPairPath::new( pathfinder.private_identity_key().to_owned(), @@ -120,9 +130,12 @@ where Ok(identity_keypair) } - fn load_sphinx_keys( + fn load_sphinx_keys( pathfinder: &ClientKeyPathfinder, - ) -> Result { + ) -> Result> + where + B: ReplyStorageBackend, + { let sphinx_keypair: encryption::KeyPair = pemstore::load_keypair(&pemstore::KeyPairPath::new( pathfinder.private_encryption_key().to_owned(), diff --git a/clients/native/Cargo.toml b/clients/native/Cargo.toml index 0415974eb3..585302e5e4 100644 --- a/clients/native/Cargo.toml +++ b/clients/native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nym-client" -version = "1.1.0" +version = "1.1.1" authors = ["Dave Hrycyszyn ", "Jędrzej Stuczyński "] description = "Implementation of the Nym Client" edition = "2021" diff --git a/clients/native/src/client/config/mod.rs b/clients/native/src/client/config/mod.rs index f031a0e816..6a1f59cd5c 100644 --- a/clients/native/src/client/config/mod.rs +++ b/clients/native/src/client/config/mod.rs @@ -3,7 +3,7 @@ use crate::client::config::template::config_template; pub use client_core::config::MISSING_VALUE; -use client_core::config::{Config as BaseConfig, Debug}; +use client_core::config::{Config as BaseConfig, DebugConfig}; use config::defaults::DEFAULT_WEBSOCKET_LISTENING_PORT; use config::NymConfig; use serde::{Deserialize, Serialize}; @@ -27,6 +27,10 @@ impl SocketType { _ => SocketType::None, } } + + pub fn is_websocket(&self) -> bool { + matches!(self, SocketType::WebSocket) + } } #[derive(Debug, Default, Deserialize, PartialEq, Serialize)] @@ -100,7 +104,7 @@ impl Config { &mut self.base } - pub fn get_debug_settings(&self) -> &Debug { + pub fn get_debug_settings(&self) -> &DebugConfig { self.get_base().get_debug_config() } diff --git a/clients/native/src/client/mod.rs b/clients/native/src/client/mod.rs index 6673b5fabd..a4e4867c6c 100644 --- a/clients/native/src/client/mod.rs +++ b/clients/native/src/client/mod.rs @@ -1,208 +1,48 @@ -// Copyright 2021 - Nym Technologies SA +// Copyright 2021-2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::client::config::{Config, SocketType}; +use crate::client::config::Config; use crate::error::ClientError; use crate::websocket; -use client_connections::{ - ConnectionCommandReceiver, ConnectionCommandSender, LaneQueueLengths, TransmissionLane, -}; -use client_core::client::cover_traffic_stream::LoopCoverTrafficStream; -use client_core::client::inbound_messages::{ - InputMessage, InputMessageReceiver, InputMessageSender, +use client_connections::TransmissionLane; +use client_core::client::base_client::{ + non_wasm_helpers, BaseClientBuilder, ClientInput, ClientOutput, }; +use client_core::client::inbound_messages::InputMessage; use client_core::client::key_manager::KeyManager; -use client_core::client::mix_traffic::{BatchMixMessageSender, MixTrafficController}; -use client_core::client::real_messages_control; -use client_core::client::real_messages_control::RealMessagesController; -use client_core::client::received_buffer::{ - ReceivedBufferMessage, ReceivedBufferRequestReceiver, ReceivedBufferRequestSender, - ReceivedMessagesBufferController, ReconstructedMessagesReceiver, -}; -use client_core::client::replies::reply_controller; -use client_core::client::replies::reply_controller::{ - ReplyControllerReceiver, ReplyControllerSender, -}; -use client_core::client::replies::reply_storage::{ - fs_backend, CombinedReplyStorage, PersistentReplyStorage, SentReplyKeys, -}; -use client_core::client::topology_control::{ - TopologyAccessor, TopologyRefresher, TopologyRefresherConfig, -}; +use client_core::client::received_buffer::{ReceivedBufferMessage, ReconstructedMessagesReceiver}; use client_core::config::persistence::key_pathfinder::ClientKeyPathfinder; -use client_core::error::ClientCoreError; -use crypto::asymmetric::identity; use futures::channel::mpsc; use gateway_client::bandwidth::BandwidthController; -use gateway_client::{ - AcknowledgementReceiver, AcknowledgementSender, GatewayClient, MixnetMessageReceiver, - MixnetMessageSender, -}; use log::*; use nymsphinx::addressing::clients::Recipient; -use nymsphinx::addressing::nodes::NodeIdentity; use nymsphinx::anonymous_replies::requests::AnonymousSenderTag; use nymsphinx::receiver::ReconstructedMessage; -use task::{wait_for_signal, ShutdownListener, ShutdownNotifier}; +use task::{wait_for_signal, ShutdownNotifier}; pub(crate) mod config; -pub struct NymClient { +pub struct SocketClient { /// Client configuration options, including, among other things, packet sending rates, /// key filepaths, etc. config: Config, /// KeyManager object containing smart pointers to all relevant keys used by the client. key_manager: KeyManager, - - /// Channel used for transforming 'raw' messages into sphinx packets and sending them - /// through the mix network. - /// It is only available if the client started with the websocket listener disabled. - input_tx: Option, - - /// Channel used for obtaining reconstructed messages received from the mix network. - /// It is only available if the client started with the websocket listener disabled. - receive_tx: Option, } -impl NymClient { +impl SocketClient { pub fn new(config: Config) -> Self { let pathfinder = ClientKeyPathfinder::new_from_config(config.get_base()); let key_manager = KeyManager::load_keys(&pathfinder).expect("failed to load stored keys"); - NymClient { + SocketClient { config, key_manager, - input_tx: None, - receive_tx: None, } } - pub fn as_mix_recipient(&self) -> Recipient { - Recipient::new( - *self.key_manager.identity_keypair().public_key(), - *self.key_manager.encryption_keypair().public_key(), - // TODO: below only works under assumption that gateway address == gateway id - // (which currently is true) - NodeIdentity::from_base58_string(self.config.get_base().get_gateway_id()).unwrap(), - ) - } - - // future constantly pumping loop cover traffic at some specified average rate - // the pumped traffic goes to the MixTrafficController - fn start_cover_traffic_stream( - &self, - topology_accessor: TopologyAccessor, - mix_tx: BatchMixMessageSender, - shutdown: ShutdownListener, - ) { - info!("Starting loop cover traffic stream..."); - - let mut stream = LoopCoverTrafficStream::new( - self.key_manager.ack_key(), - self.config.get_base().get_average_ack_delay(), - self.config.get_base().get_average_packet_delay(), - self.config - .get_base() - .get_loop_cover_traffic_average_delay(), - mix_tx, - self.as_mix_recipient(), - topology_accessor, - ); - - if let Some(size) = self.config.get_base().get_use_extended_packet_size() { - log::debug!("Setting extended packet size: {:?}", size); - stream.set_custom_packet_size(size.into()); - } - - stream.start_with_shutdown(shutdown); - } - - #[allow(clippy::too_many_arguments)] - fn start_real_traffic_controller( - &self, - topology_accessor: TopologyAccessor, - ack_receiver: AcknowledgementReceiver, - input_receiver: InputMessageReceiver, - mix_sender: BatchMixMessageSender, - reply_storage: CombinedReplyStorage, - reply_controller_sender: ReplyControllerSender, - reply_controller_receiver: ReplyControllerReceiver, - lane_queue_lengths: LaneQueueLengths, - client_connection_rx: ConnectionCommandReceiver, - shutdown: ShutdownListener, - ) { - let mut controller_config = real_messages_control::Config::new( - self.config.get_debug_settings(), - self.key_manager.ack_key(), - self.as_mix_recipient(), - ); - - if let Some(size) = self.config.get_base().get_use_extended_packet_size() { - log::debug!("Setting extended packet size: {:?}", size); - controller_config.set_custom_packet_size(size.into()); - } - - info!("Starting real traffic stream..."); - - RealMessagesController::new( - controller_config, - ack_receiver, - input_receiver, - mix_sender, - topology_accessor, - reply_storage, - reply_controller_sender, - reply_controller_receiver, - lane_queue_lengths, - client_connection_rx, - ) - .start_with_shutdown(shutdown); - } - - // buffer controlling all messages fetched from provider - // required so that other components would be able to use them (say the websocket) - fn start_received_messages_buffer_controller( - &self, - query_receiver: ReceivedBufferRequestReceiver, - mixnet_receiver: MixnetMessageReceiver, - reply_key_storage: SentReplyKeys, - reply_controller_sender: ReplyControllerSender, - shutdown: ShutdownListener, - ) { - info!("Starting received messages buffer controller..."); - ReceivedMessagesBufferController::new( - self.key_manager.encryption_keypair(), - query_receiver, - mixnet_receiver, - reply_key_storage, - reply_controller_sender, - ) - .start_with_shutdown(shutdown) - } - - async fn start_gateway_client( - &mut self, - mixnet_message_sender: MixnetMessageSender, - ack_sender: AcknowledgementSender, - shutdown: ShutdownListener, - ) -> GatewayClient { - let gateway_id = self.config.get_base().get_gateway_id(); - if gateway_id.is_empty() { - panic!("The identity of the gateway is unknown - did you run `nym-client` init?") - } - let gateway_owner = self.config.get_base().get_gateway_owner(); - if gateway_owner.is_empty() { - panic!("The owner of the gateway is unknown - did you run `nym-client` init?") - } - let gateway_address = self.config.get_base().get_gateway_listener(); - if gateway_address.is_empty() { - panic!("The address of the gateway is unknown - did you run `nym-client` init?") - } - - let gateway_identity = identity::PublicKey::from_base58_string(gateway_id) - .expect("provided gateway id is invalid!"); - + async fn create_bandwidth_controller(config: &Config) -> BandwidthController { #[cfg(feature = "coconut")] let bandwidth_controller = { let details = network_defaults::NymNetworkDetails::new_from_env(); @@ -215,158 +55,144 @@ impl NymClient { .await .expect("Could not query api clients"); BandwidthController::new( - credential_storage::initialise_storage(self.config.get_base().get_database_path()) - .await, + credential_storage::initialise_storage(config.get_base().get_database_path()).await, coconut_api_clients, ) }; #[cfg(not(feature = "coconut"))] let bandwidth_controller = BandwidthController::new( - credential_storage::initialise_storage(self.config.get_base().get_database_path()) - .await, + credential_storage::initialise_storage(config.get_base().get_database_path()).await, ) .expect("Could not create bandwidth controller"); - - let mut gateway_client = GatewayClient::new( - gateway_address, - self.key_manager.identity_keypair(), - gateway_identity, - gateway_owner, - Some(self.key_manager.gateway_shared_key()), - mixnet_message_sender, - ack_sender, - self.config.get_base().get_gateway_response_timeout(), - Some(bandwidth_controller), - Some(shutdown), - ); - - gateway_client - .set_disabled_credentials_mode(self.config.get_base().get_disabled_credentials_mode()); - - gateway_client - .authenticate_and_start() - .await - .expect("could not authenticate and start up the gateway connection"); - - gateway_client - } - - // future responsible for periodically polling directory server and updating - // the current global view of topology - async fn start_topology_refresher( - &mut self, - topology_accessor: TopologyAccessor, - shutdown: ShutdownListener, - ) -> Result<(), ClientError> { - let topology_refresher_config = TopologyRefresherConfig::new( - self.config.get_base().get_validator_api_endpoints(), - self.config.get_base().get_topology_refresh_rate(), - env!("CARGO_PKG_VERSION").to_string(), - ); - let mut topology_refresher = - TopologyRefresher::new(topology_refresher_config, topology_accessor); - // before returning, block entire runtime to refresh the current network view so that any - // components depending on topology would see a non-empty view - info!("Obtaining initial network topology"); - topology_refresher.refresh().await; - - // TODO: a slightly more graceful termination here - if !topology_refresher.is_topology_routable().await { - log::error!( - "The current network topology seem to be insufficient to route any packets through \ - - check if enough nodes and a gateway are online" - ); - return Err(ClientCoreError::InsufficientNetworkTopology.into()); - } - - info!("Starting topology refresher..."); - topology_refresher.start_with_shutdown(shutdown); - Ok(()) - } - - // controller for sending sphinx packets to mixnet (either real traffic or cover traffic) - // TODO: if we want to send control messages to gateway_client, this CAN'T take the ownership - // over it. Perhaps GatewayClient needs to be thread-shareable or have some channel for - // requests? - fn start_mix_traffic_controller( - gateway_client: GatewayClient, - shutdown: ShutdownListener, - ) -> BatchMixMessageSender { - info!("Starting mix traffic controller..."); - let (mix_traffic_controller, mix_tx) = MixTrafficController::new(gateway_client); - mix_traffic_controller.start_with_shutdown(shutdown); - mix_tx - } - - async fn setup_persistent_reply_storage( - &self, - shutdown: ShutdownListener, - ) -> Result { - // if the database file doesnt exist, initialise fresh storage, otherwise attempt to load the existing one - let db_path = self.config.get_base().get_reply_surb_database_path(); - let (persistent_storage, mem_store) = if db_path.exists() { - info!("loading existing surb database"); - let storage_backend = match fs_backend::Backend::try_load(db_path).await { - Ok(backend) => backend, - Err(err) => { - error!("failed to setup persistent storage backend for our reply needs: {err}"); - return Err(err.into()); - } - }; - let persistent_storage = PersistentReplyStorage::new(storage_backend); - let mem_store = persistent_storage.load_state_from_backend().await?; - (persistent_storage, mem_store) - } else { - info!("creating fresh surb database"); - let storage_backend = match fs_backend::Backend::init(db_path).await { - Ok(backend) => backend, - Err(err) => { - error!("failed to setup persistent storage backend for our reply needs: {err}"); - return Err(err.into()); - } - }; - let persistent_storage = PersistentReplyStorage::new(storage_backend); - let mem_store = CombinedReplyStorage::new( - self.config - .get_base() - .get_minimum_reply_surb_storage_threshold(), - self.config - .get_base() - .get_maximum_reply_surb_storage_threshold(), - ); - (persistent_storage, mem_store) - }; - - let store_clone = mem_store.clone(); - tokio::spawn(async move { - persistent_storage - .flush_on_shutdown(store_clone, shutdown) - .await - }); - - Ok(mem_store) + bandwidth_controller } fn start_websocket_listener( - &self, - buffer_requester: ReceivedBufferRequestSender, - msg_input: InputMessageSender, - shared_lane_queue_lengths: LaneQueueLengths, - client_connection_tx: ConnectionCommandSender, + config: &Config, + client_input: ClientInput, + client_output: ClientOutput, + self_address: Recipient, ) { info!("Starting websocket listener..."); + let ClientInput { + shared_lane_queue_lengths, + connection_command_sender, + input_sender, + } = client_input; + + let received_buffer_request_sender = client_output.received_buffer_request_sender; + let websocket_handler = websocket::Handler::new( - msg_input, - client_connection_tx, - buffer_requester, - &self.as_mix_recipient(), + input_sender, + connection_command_sender, + received_buffer_request_sender, + self_address, shared_lane_queue_lengths, ); - websocket::Listener::new(self.config.get_listening_port()).start(websocket_handler); + websocket::Listener::new(config.get_listening_port()).start(websocket_handler); } + /// blocking version of `start_socket` method. Will run forever (or until SIGINT is sent) + pub async fn run_socket_forever(self) -> Result<(), ClientError> { + let shutdown = self.start_socket().await?; + wait_for_signal().await; + + println!( + "Received signal - the client will terminate now (threads are not yet nicely stopped, if you see stack traces that's alright)." + ); + + log::info!("Sending shutdown"); + shutdown.signal_shutdown().ok(); + + // Some of these components have shutdown signalling implemented as part of socks5 work, + // but since it's not fully implemented (yet) for all the components of the native client, + // we don't try to wait and instead just stop immediately. + //log::info!("Waiting for tasks to finish... (Press ctrl-c to force)"); + //shutdown.wait_for_shutdown().await; + + log::info!("Stopping nym-client"); + Ok(()) + } + + pub async fn start_socket(self) -> Result { + if !self.config.get_socket_type().is_websocket() { + return Err(ClientError::InvalidSocketMode); + } + + let base_builder = BaseClientBuilder::new_from_base_config( + self.config.get_base(), + self.key_manager, + Some(Self::create_bandwidth_controller(&self.config).await), + non_wasm_helpers::setup_fs_reply_surb_backend( + self.config.get_base().get_reply_surb_database_path(), + self.config.get_debug_settings(), + ) + .await?, + ); + + let self_address = base_builder.as_mix_recipient(); + let mut started_client = base_builder.start_base().await?; + let client_input = started_client.client_input.register_producer(); + let client_output = started_client.client_output.register_consumer(); + + Self::start_websocket_listener(&self.config, client_input, client_output, self_address); + + info!("Client startup finished!"); + info!("The address of this client is: {}", self_address); + + Ok(started_client.shutdown_notifier) + } + + pub async fn start_direct(self) -> Result { + if self.config.get_socket_type().is_websocket() { + return Err(ClientError::InvalidSocketMode); + } + + let base_client = BaseClientBuilder::new_from_base_config( + self.config.get_base(), + self.key_manager, + Some(Self::create_bandwidth_controller(&self.config).await), + non_wasm_helpers::setup_fs_reply_surb_backend( + self.config.get_base().get_reply_surb_database_path(), + self.config.get_debug_settings(), + ) + .await?, + ); + + let mut started_client = base_client.start_base().await?; + let client_input = started_client.client_input.register_producer(); + let client_output = started_client.client_output.register_consumer(); + + // register our receiver + let (reconstructed_sender, reconstructed_receiver) = mpsc::unbounded(); + + // tell the buffer to start sending stuff to us + client_output + .received_buffer_request_sender + .unbounded_send(ReceivedBufferMessage::ReceiverAnnounce( + reconstructed_sender, + )) + .expect("the buffer request failed!"); + + Ok(DirectClient { + client_input, + reconstructed_receiver, + _shutdown_notifier: started_client.shutdown_notifier, + }) + } +} + +pub struct DirectClient { + client_input: ClientInput, + reconstructed_receiver: ReconstructedMessagesReceiver, + + // we need to keep reference to this guy otherwise things will start dropping + _shutdown_notifier: ShutdownNotifier, +} + +impl DirectClient { /// EXPERIMENTAL DIRECT RUST API /// It's untested and there are absolutely no guarantees about it (but seems to have worked /// well enough in local tests) @@ -374,9 +200,8 @@ impl NymClient { let lane = TransmissionLane::General; let input_msg = InputMessage::new_regular(recipient, message, lane); - self.input_tx - .as_ref() - .expect("start method was not called before!") + self.client_input + .input_sender .send(input_msg) .await .expect("InputMessageReceiver has stopped receiving!"); @@ -394,9 +219,8 @@ impl NymClient { let lane = TransmissionLane::General; let input_msg = InputMessage::new_anonymous(recipient, message, reply_surbs, lane); - self.input_tx - .as_ref() - .expect("start method was not called before!") + self.client_input + .input_sender .send(input_msg) .await .expect("InputMessageReceiver has stopped receiving!"); @@ -409,9 +233,8 @@ impl NymClient { let lane = TransmissionLane::General; let input_msg = InputMessage::new_reply(recipient_tag, message, lane); - self.input_tx - .as_ref() - .expect("start method was not called before!") + self.client_input + .input_sender .send(input_msg) .await .expect("InputMessageReceiver has stopped receiving!"); @@ -426,151 +249,9 @@ impl NymClient { pub async fn wait_for_messages(&mut self) -> Vec { use futures::StreamExt; - self.receive_tx - .as_mut() - .expect("start method was not called before!") + self.reconstructed_receiver .next() .await .expect("buffer controller seems to have somehow died!") } - - /// blocking version of `start` method. Will run forever (or until SIGINT is sent) - pub async fn run_forever(&mut self) -> Result<(), ClientError> { - let mut shutdown = self.start().await?; - wait_for_signal().await; - - println!( - "Received signal - the client will terminate now (threads are not yet nicely stopped, if you see stack traces that's alright)." - ); - - log::info!("Sending shutdown"); - shutdown.signal_shutdown().ok(); - - // Some of these components have shutdown signalling implemented as part of socks5 work, - // but since it's not fully implemented (yet) for all the components of the native client, - // we don't try to wait and instead just stop immediately. - log::info!("Waiting for tasks to finish... (Press ctrl-c to force)"); - shutdown.wait_for_shutdown().await; - - log::info!("Stopping nym-client"); - Ok(()) - } - - pub async fn start(&mut self) -> Result { - info!("Starting nym client"); - // channels for inter-component communication - // TODO: make the channels be internally created by the relevant components - // rather than creating them here, so say for example the buffer controller would create the request channels - // and would allow anyone to clone the sender channel - - // unwrapped_sphinx_sender is the transmitter of mixnet messages received from the gateway - // unwrapped_sphinx_receiver is the receiver for said messages - used by ReceivedMessagesBuffer - let (mixnet_messages_sender, mixnet_messages_receiver) = mpsc::unbounded(); - - // used for announcing connection or disconnection of a channel for pushing re-assembled messages to - let (received_buffer_request_sender, received_buffer_request_receiver) = mpsc::unbounded(); - - // channels responsible for controlling real messages - let (input_sender, input_receiver) = tokio::sync::mpsc::channel::(1); - - // channels responsible for controlling ack messages - let (ack_sender, ack_receiver) = mpsc::unbounded(); - let shared_topology_accessor = TopologyAccessor::new(); - - // channels responsible for dealing with reply-related fun - let (reply_controller_sender, reply_controller_receiver) = - reply_controller::new_control_channels(); - - // Shutdown notifier for signalling tasks to stop - let shutdown = ShutdownNotifier::default(); - - let reply_storage = self - .setup_persistent_reply_storage(shutdown.subscribe()) - .await?; - - // the components are started in very specific order. Unless you know what you are doing, - // do not change that. - self.start_topology_refresher(shared_topology_accessor.clone(), shutdown.subscribe()) - .await?; - self.start_received_messages_buffer_controller( - received_buffer_request_receiver, - mixnet_messages_receiver, - reply_storage.key_storage(), - reply_controller_sender.clone(), - shutdown.subscribe(), - ); - - let gateway_client = self - .start_gateway_client(mixnet_messages_sender, ack_sender, shutdown.subscribe()) - .await; - - // The sphinx_message_sender is the transmitter for any component generating sphinx packets - // that are to be sent to the mixnet. They are used by cover traffic stream and real - // traffic stream. - // The MixTrafficController then sends the actual traffic - let sphinx_message_sender = - Self::start_mix_traffic_controller(gateway_client, shutdown.subscribe()); - - // Channels that the websocket listener can use to signal downstream to the real traffic - // controller that connections are closed. - let (client_connection_tx, client_connection_rx) = mpsc::unbounded(); - - // Shared queue length data. Published by the `OutQueueController` in the client, and used - // primarily to throttle incoming connections (e.g socks5 for attached network-requesters) - let shared_lane_queue_lengths = LaneQueueLengths::new(); - - self.start_real_traffic_controller( - shared_topology_accessor.clone(), - ack_receiver, - input_receiver, - sphinx_message_sender.clone(), - reply_storage, - reply_controller_sender, - reply_controller_receiver, - shared_lane_queue_lengths.clone(), - client_connection_rx, - shutdown.subscribe(), - ); - - if !self - .config - .get_base() - .get_disabled_loop_cover_traffic_stream() - { - self.start_cover_traffic_stream( - shared_topology_accessor, - sphinx_message_sender, - shutdown.subscribe(), - ); - } - - match self.config.get_socket_type() { - SocketType::WebSocket => self.start_websocket_listener( - received_buffer_request_sender, - input_sender, - shared_lane_queue_lengths, - client_connection_tx, - ), - SocketType::None => { - // if we did not start the socket, it means we're running (supposedly) in the native mode - // and hence we should announce 'ourselves' to the buffer - let (reconstructed_sender, reconstructed_receiver) = mpsc::unbounded(); - - // tell the buffer to start sending stuff to us - received_buffer_request_sender - .unbounded_send(ReceivedBufferMessage::ReceiverAnnounce( - reconstructed_sender, - )) - .expect("the buffer request failed!"); - - self.receive_tx = Some(reconstructed_receiver); - self.input_tx = Some(input_sender); - } - } - - info!("Client startup finished!"); - info!("The address of this client is: {}", self.as_mix_recipient()); - - Ok(shutdown) - } } diff --git a/clients/native/src/commands/init.rs b/clients/native/src/commands/init.rs index 6a38770eec..7fac2d6eba 100644 --- a/clients/native/src/commands/init.rs +++ b/clients/native/src/commands/init.rs @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 use clap::Args; -use client_core::{config::GatewayEndpoint, error::ClientCoreError}; +use client_core::client::replies::reply_storage::fs_backend; +use client_core::{config::GatewayEndpointConfig, error::ClientCoreError}; use config::NymConfig; use crate::{ @@ -46,6 +47,10 @@ pub(crate) struct Init { #[clap(long, hidden = true)] fastmode: bool, + /// Disable loop cover traffic and the Poisson rate limiter (for debugging only) + #[clap(long, hidden = true)] + no_cover: bool, + /// Set this client to work in a enabled credentials mode that would attempt to use gateway /// with bandwidth credential requirement. #[cfg(feature = "coconut")] @@ -61,6 +66,7 @@ impl From for OverrideConfig { disable_socket: init_config.disable_socket, port: init_config.port, fastmode: init_config.fastmode, + no_cover: init_config.no_cover, #[cfg(feature = "coconut")] enabled_credentials_mode: init_config.enabled_credentials_mode, @@ -121,10 +127,12 @@ pub(crate) async fn execute(args: &Init) { ); println!("Client configuration completed."); - client_core::init::show_address(config.get_base()).unwrap_or_else(|err| { - eprintln!("Failed to show address\nError: {err}"); - std::process::exit(1) - }); + client_core::init::show_address::<_, fs_backend::Backend>(config.get_base()).unwrap_or_else( + |err| { + eprintln!("Failed to show address\nError: {err}"); + std::process::exit(1) + }, + ); } async fn setup_gateway( @@ -132,7 +140,7 @@ async fn setup_gateway( register: bool, user_chosen_gateway_id: Option<&str>, config: &Config, -) -> Result { +) -> Result> { if register { // Get the gateway details by querying the validator-api. Either pick one at random or use // the chosen one if it's among the available ones. diff --git a/clients/native/src/commands/mod.rs b/clients/native/src/commands/mod.rs index 648a1d6002..c3232165d8 100644 --- a/clients/native/src/commands/mod.rs +++ b/clients/native/src/commands/mod.rs @@ -80,6 +80,7 @@ pub(crate) struct OverrideConfig { disable_socket: bool, port: Option, fastmode: bool, + no_cover: bool, #[cfg(feature = "coconut")] enabled_credentials_mode: bool, @@ -141,6 +142,10 @@ pub(crate) fn override_config(mut config: Config, args: OverrideConfig) -> Confi config.get_base_mut().set_high_default_traffic_volume(); } + if args.no_cover { + config.get_base_mut().set_no_cover_traffic(); + } + config } diff --git a/clients/native/src/commands/run.rs b/clients/native/src/commands/run.rs index d5c892fadc..4267fcbac5 100644 --- a/clients/native/src/commands/run.rs +++ b/clients/native/src/commands/run.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use crate::{ - client::{config::Config, NymClient}, + client::{config::Config, SocketClient}, commands::{override_config, OverrideConfig}, error::ClientError, }; @@ -39,6 +39,15 @@ pub(crate) struct Run { #[clap(short, long)] port: Option, + /// Mostly debug-related option to increase default traffic rate so that you would not need to + /// modify config post init + #[clap(long, hidden = true)] + fastmode: bool, + + /// Disable loop cover traffic and the Poisson rate limiter (for debugging only) + #[clap(long, hidden = true)] + no_cover: bool, + /// Set this client to work in a enabled credentials mode that would attempt to use gateway /// with bandwidth credential requirement. #[cfg(feature = "coconut")] @@ -53,7 +62,8 @@ impl From for OverrideConfig { api_validators: run_config.api_validators, disable_socket: run_config.disable_socket, port: run_config.port, - fastmode: false, + fastmode: run_config.fastmode, + no_cover: run_config.no_cover, #[cfg(feature = "coconut")] enabled_credentials_mode: run_config.enabled_credentials_mode, } @@ -98,5 +108,5 @@ pub(crate) async fn execute(args: &Run) -> Result<(), ClientError> { return Err(ClientError::FailedLocalVersionCheck); } - NymClient::new(config).run_forever().await + SocketClient::new(config).run_socket_forever().await } diff --git a/clients/native/src/error.rs b/clients/native/src/error.rs index 43c88c24db..787b9a1bd1 100644 --- a/clients/native/src/error.rs +++ b/clients/native/src/error.rs @@ -1,29 +1,17 @@ -use client_core::client::replies::reply_storage::fs_backend::StorageError; +use client_core::client::replies::reply_storage::fs_backend; use client_core::error::ClientCoreError; -use crypto::asymmetric::identity::Ed25519RecoveryError; -use gateway_client::error::GatewayClientError; -use validator_client::ValidatorClientError; #[derive(thiserror::Error, Debug)] pub enum ClientError { - #[error("I/O error: {0}")] - IoError(#[from] std::io::Error), - #[error("Gateway client error: {0}")] - GatewayClientError(#[from] GatewayClientError), - #[error("Ed25519 error: {0}")] - Ed25519RecoveryError(#[from] Ed25519RecoveryError), - #[error("Validator client error: {0}")] - ValidatorClientError(#[from] ValidatorClientError), #[error("client-core error: {0}")] - ClientCoreError(#[from] ClientCoreError), + ClientCoreError(#[from] ClientCoreError), + #[error("Failed to load config for: {0}")] FailedToLoadConfig(String), + #[error("Failed local version check, client and config mismatch")] FailedLocalVersionCheck, - #[error("experienced a failure with our reply surb persistent storage: {source}")] - SurbStorageError { - #[source] - #[from] - source: StorageError, - }, + + #[error("Attempted to start the client in invalid socket mode")] + InvalidSocketMode, } diff --git a/clients/native/src/websocket/handler.rs b/clients/native/src/websocket/handler.rs index 17a7e12b0b..a33a01edac 100644 --- a/clients/native/src/websocket/handler.rs +++ b/clients/native/src/websocket/handler.rs @@ -62,9 +62,13 @@ impl Clone for Handler { impl Drop for Handler { fn drop(&mut self) { - self.buffer_requester + if self + .buffer_requester .unbounded_send(ReceivedBufferMessage::ReceiverDisconnect) - .expect("the buffer request failed!") + .is_err() + { + error!("we failed to disconnect the receiver from the buffer! presumably the shutdown procedure has been initiated!") + } } } @@ -73,14 +77,14 @@ impl Handler { msg_input: InputMessageSender, client_connection_tx: ConnectionCommandSender, buffer_requester: ReceivedBufferRequestSender, - self_full_address: &Recipient, + self_full_address: Recipient, lane_queue_lengths: LaneQueueLengths, ) -> Self { Handler { msg_input, client_connection_tx, buffer_requester, - self_full_address: *self_full_address, + self_full_address, socket: None, received_response_type: Default::default(), lane_queue_lengths, diff --git a/clients/socks5/Cargo.toml b/clients/socks5/Cargo.toml index cff7f85a37..c2fba310b1 100644 --- a/clients/socks5/Cargo.toml +++ b/clients/socks5/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nym-socks5-client" -version = "1.1.0" +version = "1.1.1" authors = ["Dave Hrycyszyn "] description = "A SOCKS5 localhost proxy that converts incoming messages to Sphinx and sends them to a Nym address" edition = "2021" diff --git a/clients/socks5/src/client/config/mod.rs b/clients/socks5/src/client/config/mod.rs index e58f13d013..2daa59035a 100644 --- a/clients/socks5/src/client/config/mod.rs +++ b/clients/socks5/src/client/config/mod.rs @@ -3,7 +3,7 @@ use crate::client::config::template::config_template; pub use client_core::config::MISSING_VALUE; -use client_core::config::{Config as BaseConfig, Debug}; +use client_core::config::{Config as BaseConfig, DebugConfig}; use config::defaults::DEFAULT_SOCKS5_LISTENING_PORT; use config::NymConfig; use nymsphinx::addressing::clients::Recipient; @@ -91,7 +91,7 @@ impl Config { &mut self.base } - pub fn get_debug_settings(&self) -> &Debug { + pub fn get_debug_settings(&self) -> &DebugConfig { self.get_base().get_debug_config() } diff --git a/clients/socks5/src/client/config/template.rs b/clients/socks5/src/client/config/template.rs index cdcdfbc082..527da38274 100644 --- a/clients/socks5/src/client/config/template.rs +++ b/clients/socks5/src/client/config/template.rs @@ -109,7 +109,7 @@ send_anonymously = {{ socks5.send_anonymously }} # The following options should not be modified unless you know EXACTLY what you are doing # as if set incorrectly, they may impact your anonymity. -[socks5_debug] +# [socks5_debug] [debug] diff --git a/clients/socks5/src/client/mod.rs b/clients/socks5/src/client/mod.rs index 2d4866be05..d67e0caac6 100644 --- a/clients/socks5/src/client/mod.rs +++ b/clients/socks5/src/client/mod.rs @@ -8,43 +8,18 @@ use crate::socks::{ authentication::{AuthenticationMethods, Authenticator, User}, server::SphinxSocksServer, }; -use client_connections::{ConnectionCommandReceiver, ConnectionCommandSender, LaneQueueLengths}; -use client_core::client::cover_traffic_stream::LoopCoverTrafficStream; -use client_core::client::inbound_messages::{ - InputMessage, InputMessageReceiver, InputMessageSender, +use client_core::client::base_client::{ + non_wasm_helpers, BaseClientBuilder, ClientInput, ClientOutput, }; use client_core::client::key_manager::KeyManager; -use client_core::client::mix_traffic::{BatchMixMessageSender, MixTrafficController}; -use client_core::client::real_messages_control; -use client_core::client::real_messages_control::RealMessagesController; -use client_core::client::received_buffer::{ - ReceivedBufferRequestReceiver, ReceivedBufferRequestSender, ReceivedMessagesBufferController, -}; -use client_core::client::replies::reply_controller; -use client_core::client::replies::reply_controller::{ - ReplyControllerReceiver, ReplyControllerSender, -}; -use client_core::client::replies::reply_storage::{ - fs_backend, CombinedReplyStorage, PersistentReplyStorage, SentReplyKeys, -}; -use client_core::client::topology_control::{ - TopologyAccessor, TopologyRefresher, TopologyRefresherConfig, -}; use client_core::config::persistence::key_pathfinder::ClientKeyPathfinder; -use client_core::error::ClientCoreError; -use crypto::asymmetric::identity; use futures::channel::mpsc; use futures::StreamExt; use gateway_client::bandwidth::BandwidthController; -use gateway_client::{ - AcknowledgementReceiver, AcknowledgementSender, GatewayClient, MixnetMessageReceiver, - MixnetMessageSender, -}; use log::*; use nymsphinx::addressing::clients::Recipient; -use nymsphinx::addressing::nodes::NodeIdentity; -use std::sync::atomic::Ordering; -use task::{wait_for_signal, ShutdownListener, ShutdownNotifier}; +use std::error::Error; +use task::{wait_for_signal_and_error, ShutdownListener, ShutdownNotifier}; pub mod config; @@ -78,131 +53,7 @@ impl NymClient { } } - pub fn as_mix_recipient(&self) -> Recipient { - Recipient::new( - *self.key_manager.identity_keypair().public_key(), - *self.key_manager.encryption_keypair().public_key(), - // TODO: below only works under assumption that gateway address == gateway id - // (which currently is true) - NodeIdentity::from_base58_string(self.config.get_base().get_gateway_id()).unwrap(), - ) - } - - // future constantly pumping loop cover traffic at some specified average rate - // the pumped traffic goes to the MixTrafficController - fn start_cover_traffic_stream( - &self, - topology_accessor: TopologyAccessor, - mix_tx: BatchMixMessageSender, - shutdown: ShutdownListener, - ) { - info!("Starting loop cover traffic stream..."); - - let mut stream = LoopCoverTrafficStream::new( - self.key_manager.ack_key(), - self.config.get_base().get_average_ack_delay(), - self.config.get_base().get_average_packet_delay(), - self.config - .get_base() - .get_loop_cover_traffic_average_delay(), - mix_tx, - self.as_mix_recipient(), - topology_accessor, - ); - - if let Some(size) = self.config.get_base().get_use_extended_packet_size() { - log::debug!("Setting extended packet size: {:?}", size); - stream.set_custom_packet_size(size.into()); - } - - stream.start_with_shutdown(shutdown); - } - - #[allow(clippy::too_many_arguments)] - fn start_real_traffic_controller( - &self, - topology_accessor: TopologyAccessor, - ack_receiver: AcknowledgementReceiver, - input_receiver: InputMessageReceiver, - mix_sender: BatchMixMessageSender, - reply_storage: CombinedReplyStorage, - reply_controller_sender: ReplyControllerSender, - reply_controller_receiver: ReplyControllerReceiver, - client_connection_rx: ConnectionCommandReceiver, - lane_queue_lengths: LaneQueueLengths, - shutdown: ShutdownListener, - ) { - let mut controller_config = real_messages_control::Config::new( - self.config.get_debug_settings(), - self.key_manager.ack_key(), - self.as_mix_recipient(), - ); - - if let Some(size) = self.config.get_base().get_use_extended_packet_size() { - log::debug!("Setting extended packet size: {:?}", size); - controller_config.set_custom_packet_size(size.into()); - } - - info!("Starting real traffic stream..."); - - RealMessagesController::new( - controller_config, - ack_receiver, - input_receiver, - mix_sender, - topology_accessor, - reply_storage, - reply_controller_sender, - reply_controller_receiver, - lane_queue_lengths, - client_connection_rx, - ) - .start_with_shutdown(shutdown); - } - - // buffer controlling all messages fetched from provider - // required so that other components would be able to use them (say the websocket) - fn start_received_messages_buffer_controller( - &self, - query_receiver: ReceivedBufferRequestReceiver, - mixnet_receiver: MixnetMessageReceiver, - reply_key_storage: SentReplyKeys, - reply_controller_sender: ReplyControllerSender, - shutdown: ShutdownListener, - ) { - info!("Starting received messages buffer controller..."); - ReceivedMessagesBufferController::new( - self.key_manager.encryption_keypair(), - query_receiver, - mixnet_receiver, - reply_key_storage, - reply_controller_sender, - ) - .start_with_shutdown(shutdown) - } - - async fn start_gateway_client( - &mut self, - mixnet_message_sender: MixnetMessageSender, - ack_sender: AcknowledgementSender, - shutdown: ShutdownListener, - ) -> GatewayClient { - let gateway_id = self.config.get_base().get_gateway_id(); - if gateway_id.is_empty() { - panic!("The identity of the gateway is unknown - did you run `nym-client` init?") - } - let gateway_owner = self.config.get_base().get_gateway_owner(); - if gateway_owner.is_empty() { - panic!("The owner of the gateway is unknown - did you run `nym-client` init?") - } - let gateway_address = self.config.get_base().get_gateway_listener(); - if gateway_address.is_empty() { - panic!("The address of the gateway is unknown - did you run `nym-client` init?") - } - - let gateway_identity = identity::PublicKey::from_base58_string(gateway_id) - .expect("provided gateway id is invalid!"); - + async fn create_bandwidth_controller(config: &Config) -> BandwidthController { #[cfg(feature = "coconut")] let bandwidth_controller = { let details = network_defaults::NymNetworkDetails::new_from_env(); @@ -215,194 +66,97 @@ impl NymClient { .await .expect("Could not query api clients"); BandwidthController::new( - credential_storage::initialise_storage(self.config.get_base().get_database_path()) - .await, + credential_storage::initialise_storage(config.get_base().get_database_path()).await, coconut_api_clients, ) }; #[cfg(not(feature = "coconut"))] let bandwidth_controller = BandwidthController::new( - credential_storage::initialise_storage(self.config.get_base().get_database_path()) - .await, + credential_storage::initialise_storage(config.get_base().get_database_path()).await, ) .expect("Could not create bandwidth controller"); - - let mut gateway_client = GatewayClient::new( - gateway_address, - self.key_manager.identity_keypair(), - gateway_identity, - gateway_owner, - Some(self.key_manager.gateway_shared_key()), - mixnet_message_sender, - ack_sender, - self.config.get_base().get_gateway_response_timeout(), - Some(bandwidth_controller), - Some(shutdown), - ); - - gateway_client - .set_disabled_credentials_mode(self.config.get_base().get_disabled_credentials_mode()); - - gateway_client - .authenticate_and_start() - .await - .expect("could not authenticate and start up the gateway connection"); - - gateway_client - } - - // future responsible for periodically polling directory server and updating - // the current global view of topology - async fn start_topology_refresher( - &mut self, - topology_accessor: TopologyAccessor, - shutdown: ShutdownListener, - ) -> Result<(), Socks5ClientError> { - let topology_refresher_config = TopologyRefresherConfig::new( - self.config.get_base().get_validator_api_endpoints(), - self.config.get_base().get_topology_refresh_rate(), - env!("CARGO_PKG_VERSION").to_string(), - ); - let mut topology_refresher = - TopologyRefresher::new(topology_refresher_config, topology_accessor); - // before returning, block entire runtime to refresh the current network view so that any - // components depending on topology would see a non-empty view - info!("Obtaining initial network topology"); - topology_refresher.refresh().await; - - // TODO: a slightly more graceful termination here - if !topology_refresher.is_topology_routable().await { - log::error!( - "The current network topology seem to be insufficient to route any packets through \ - - check if enough nodes and a gateway are online" - ); - return Err(ClientCoreError::InsufficientNetworkTopology.into()); - } - - info!("Starting topology refresher..."); - topology_refresher.start_with_shutdown(shutdown); - Ok(()) - } - - // controller for sending sphinx packets to mixnet (either real traffic or cover traffic) - // TODO: if we want to send control messages to gateway_client, this CAN'T take the ownership - // over it. Perhaps GatewayClient needs to be thread-shareable or have some channel for - // requests? - fn start_mix_traffic_controller( - gateway_client: GatewayClient, - shutdown: ShutdownListener, - ) -> BatchMixMessageSender { - info!("Starting mix traffic controller..."); - let (mix_traffic_controller, mix_tx) = MixTrafficController::new(gateway_client); - mix_traffic_controller.start_with_shutdown(shutdown); - mix_tx - } - - async fn setup_persistent_reply_storage( - &self, - shutdown: ShutdownListener, - ) -> Result { - // if the database file doesnt exist, initialise fresh storage, otherwise attempt to load the existing one - let db_path = self.config.get_base().get_reply_surb_database_path(); - let (persistent_storage, mem_store) = if db_path.exists() { - info!("loading existing surb database"); - let storage_backend = match fs_backend::Backend::try_load(db_path).await { - Ok(backend) => backend, - Err(err) => { - error!("failed to setup persistent storage backend for our reply needs: {err}"); - return Err(err.into()); - } - }; - let persistent_storage = PersistentReplyStorage::new(storage_backend); - let mem_store = persistent_storage.load_state_from_backend().await?; - (persistent_storage, mem_store) - } else { - info!("creating fresh surb database"); - let storage_backend = match fs_backend::Backend::init(db_path).await { - Ok(backend) => backend, - Err(err) => { - error!("failed to setup persistent storage backend for our reply needs: {err}"); - return Err(err.into()); - } - }; - let persistent_storage = PersistentReplyStorage::new(storage_backend); - let mem_store = CombinedReplyStorage::new( - self.config - .get_base() - .get_minimum_reply_surb_storage_threshold(), - self.config - .get_base() - .get_maximum_reply_surb_storage_threshold(), - ); - (persistent_storage, mem_store) - }; - - let store_clone = mem_store.clone(); - tokio::spawn(async move { - persistent_storage - .flush_on_shutdown(store_clone, shutdown) - .await - }); - - Ok(mem_store) + bandwidth_controller } fn start_socks5_listener( - &self, - buffer_requester: ReceivedBufferRequestSender, - msg_input: InputMessageSender, - client_connection_tx: ConnectionCommandSender, - lane_queue_lengths: LaneQueueLengths, + config: &Config, + client_input: ClientInput, + client_output: ClientOutput, + self_address: Recipient, shutdown: ShutdownListener, ) { info!("Starting socks5 listener..."); let auth_methods = vec![AuthenticationMethods::NoAuth as u8]; let allowed_users: Vec = Vec::new(); + let ClientInput { + shared_lane_queue_lengths, + connection_command_sender, + input_sender, + } = client_input; + + let received_buffer_request_sender = client_output.received_buffer_request_sender; + let authenticator = Authenticator::new(auth_methods, allowed_users); let mut sphinx_socks = SphinxSocksServer::new( - self.config.get_listening_port(), + config.get_listening_port(), authenticator, - self.config.get_provider_mix_address(), - self.as_mix_recipient(), - lane_queue_lengths, + config.get_provider_mix_address(), + self_address, + shared_lane_queue_lengths, socks::client::Config::new( - self.config.get_send_anonymously(), - self.config.get_connection_start_surbs(), - self.config.get_per_request_surbs(), + config.get_send_anonymously(), + config.get_connection_start_surbs(), + config.get_per_request_surbs(), ), + shutdown.clone(), + ); + task::spawn_with_report_error( + async move { + sphinx_socks + .serve( + input_sender, + received_buffer_request_sender, + connection_command_sender, + ) + .await + }, shutdown, ); - tokio::spawn(async move { - sphinx_socks - .serve(msg_input, buffer_requester, client_connection_tx) - .await - }); } /// blocking version of `start` method. Will run forever (or until SIGINT is sent) - pub async fn run_forever(&mut self) -> Result<(), Socks5ClientError> { - let mut shutdown = self.start().await?; - wait_for_signal().await; + pub async fn run_forever(self) -> Result<(), Box> { + let mut shutdown = self + .start() + .await + .map_err(|err| Box::new(err) as Box)?; + + let res = wait_for_signal_and_error(&mut shutdown).await; log::info!("Sending shutdown"); - client_core::client::SHUTDOWN_HAS_BEEN_SIGNALLED.store(true, Ordering::Relaxed); shutdown.signal_shutdown().ok(); log::info!("Waiting for tasks to finish... (Press ctrl-c to force)"); shutdown.wait_for_shutdown().await; log::info!("Stopping nym-socks5-client"); - Ok(()) + res } // Variant of `run_forever` that listends for remote control messages pub async fn run_and_listen( - &mut self, + self, mut receiver: Socks5ControlMessageReceiver, - ) -> Result<(), Socks5ClientError> { - let mut shutdown = self.start().await?; - tokio::select! { + ) -> Result<(), Box> { + // Start the main task + let mut shutdown = self + .start() + .await + .map_err(|err| Box::new(err) as Box)?; + + let res = tokio::select! { + biased; message = receiver.next() => { log::debug!("Received message: {:?}", message); match message { @@ -413,126 +167,56 @@ impl NymClient { log::info!("Channel closed, stopping"); } } + Ok(()) + } + Some(msg) = shutdown.wait_for_error() => { + log::info!("Task error: {:?}", msg); + Err(msg) } _ = tokio::signal::ctrl_c() => { log::info!("Received SIGINT"); + Ok(()) }, - } + }; log::info!("Sending shutdown"); - client_core::client::SHUTDOWN_HAS_BEEN_SIGNALLED.store(true, Ordering::Relaxed); shutdown.signal_shutdown().ok(); log::info!("Waiting for tasks to finish... (Press ctrl-c to force)"); shutdown.wait_for_shutdown().await; log::info!("Stopping nym-socks5-client"); - Ok(()) + res } - pub async fn start(&mut self) -> Result { - info!("Starting nym client"); - // channels for inter-component communication - // TODO: make the channels be internally created by the relevant components - // rather than creating them here, so say for example the buffer controller would create the request channels - // and would allow anyone to clone the sender channel - - // unwrapped_sphinx_sender is the transmitter of mixnet messages received from the gateway - // unwrapped_sphinx_receiver is the receiver for said messages - used by ReceivedMessagesBuffer - let (mixnet_messages_sender, mixnet_messages_receiver) = mpsc::unbounded(); - - // used for announcing connection or disconnection of a channel for pushing re-assembled messages to - let (received_buffer_request_sender, received_buffer_request_receiver) = mpsc::unbounded(); - - // channels responsible for controlling real messages - let (input_sender, input_receiver) = tokio::sync::mpsc::channel::(1); - - // channels responsible for controlling ack messages - let (ack_sender, ack_receiver) = mpsc::unbounded(); - let shared_topology_accessor = TopologyAccessor::new(); - - // let reply_key_storage = - // ReplyKeyStorage::load(self.config.get_base().get_reply_encryption_key_store_path()) - // .expect("Failed to load reply key storage!"); - - // channels responsible for dealing with reply-related fun - let (reply_controller_sender, reply_controller_receiver) = - reply_controller::new_control_channels(); - - // Shutdown notifier for signalling tasks to stop - let shutdown = ShutdownNotifier::default(); - - let reply_storage = self - .setup_persistent_reply_storage(shutdown.subscribe()) - .await?; - - // the components are started in very specific order. Unless you know what you are doing, - // do not change that. - self.start_topology_refresher(shared_topology_accessor.clone(), shutdown.subscribe()) - .await?; - self.start_received_messages_buffer_controller( - received_buffer_request_receiver, - mixnet_messages_receiver, - reply_storage.key_storage(), - reply_controller_sender.clone(), - shutdown.subscribe(), + pub async fn start(self) -> Result { + let base_builder = BaseClientBuilder::new_from_base_config( + self.config.get_base(), + self.key_manager, + Some(Self::create_bandwidth_controller(&self.config).await), + non_wasm_helpers::setup_fs_reply_surb_backend( + self.config.get_base().get_reply_surb_database_path(), + self.config.get_debug_settings(), + ) + .await?, ); - let gateway_client = self - .start_gateway_client(mixnet_messages_sender, ack_sender, shutdown.subscribe()) - .await; + let self_address = base_builder.as_mix_recipient(); + let mut started_client = base_builder.start_base().await?; + let client_input = started_client.client_input.register_producer(); + let client_output = started_client.client_output.register_consumer(); - // The sphinx_message_sender is the transmitter for any component generating sphinx packets - // that are to be sent to the mixnet. They are used by cover traffic stream and real - // traffic stream. - // The MixTrafficController then sends the actual traffic - let sphinx_message_sender = - Self::start_mix_traffic_controller(gateway_client, shutdown.subscribe()); - - // Channel for announcing closed (socks5) connections by the controller. - // This will be forwarded to `OutQueueControl` - let (client_connection_tx, client_connection_rx) = mpsc::unbounded(); - - // Shared queue length data. Published by the `OutQueueController` in the client, and used - // primarily to throttle incoming connections - let shared_lane_queue_lengths = LaneQueueLengths::new(); - - self.start_real_traffic_controller( - shared_topology_accessor.clone(), - ack_receiver, - input_receiver, - sphinx_message_sender.clone(), - reply_storage, - reply_controller_sender, - reply_controller_receiver, - client_connection_rx, - shared_lane_queue_lengths.clone(), - shutdown.subscribe(), - ); - - if !self - .config - .get_base() - .get_disabled_loop_cover_traffic_stream() - { - self.start_cover_traffic_stream( - shared_topology_accessor, - sphinx_message_sender, - shutdown.subscribe(), - ); - } - - self.start_socks5_listener( - received_buffer_request_sender, - input_sender, - client_connection_tx, - shared_lane_queue_lengths, - shutdown.subscribe(), + Self::start_socks5_listener( + &self.config, + client_input, + client_output, + self_address, + started_client.shutdown_notifier.subscribe(), ); info!("Client startup finished!"); - info!("The address of this client is: {}", self.as_mix_recipient()); + info!("The address of this client is: {}", self_address); - Ok(shutdown) + Ok(started_client.shutdown_notifier) } } diff --git a/clients/socks5/src/commands/init.rs b/clients/socks5/src/commands/init.rs index 1329ee8d01..692d91fd20 100644 --- a/clients/socks5/src/commands/init.rs +++ b/clients/socks5/src/commands/init.rs @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 use clap::Args; -use client_core::{config::GatewayEndpoint, error::ClientCoreError}; +use client_core::client::replies::reply_storage::fs_backend; +use client_core::{config::GatewayEndpointConfig, error::ClientCoreError}; use config::NymConfig; use crate::{ @@ -54,6 +55,10 @@ pub(crate) struct Init { #[clap(long, hidden = true)] fastmode: bool, + /// Disable loop cover traffic and the Poisson rate limiter (for debugging only) + #[clap(long, hidden = true)] + no_cover: bool, + /// Set this client to work in a enabled credentials mode that would attempt to use gateway /// with bandwidth credential requirement. #[cfg(feature = "coconut")] @@ -69,7 +74,7 @@ impl From for OverrideConfig { port: init_config.port, use_anonymous_sender_tag: init_config.use_anonymous_sender_tag, fastmode: init_config.fastmode, - + no_cover: init_config.no_cover, #[cfg(feature = "coconut")] enabled_credentials_mode: init_config.enabled_credentials_mode, } @@ -130,10 +135,12 @@ pub(crate) async fn execute(args: &Init) { ); println!("Client configuration completed."); - client_core::init::show_address(config.get_base()).unwrap_or_else(|err| { - eprintln!("Failed to show address\nError: {err}"); - std::process::exit(1) - }); + client_core::init::show_address::<_, fs_backend::Backend>(config.get_base()).unwrap_or_else( + |err| { + eprintln!("Failed to show address\nError: {err}"); + std::process::exit(1) + }, + ); } async fn setup_gateway( @@ -141,7 +148,7 @@ async fn setup_gateway( register: bool, user_chosen_gateway_id: Option<&str>, config: &Config, -) -> Result { +) -> Result> { if register { // Get the gateway details by querying the validator-api. Either pick one at random or use // the chosen one if it's among the available ones. diff --git a/clients/socks5/src/commands/mod.rs b/clients/socks5/src/commands/mod.rs index 7489329dcb..576a84f177 100644 --- a/clients/socks5/src/commands/mod.rs +++ b/clients/socks5/src/commands/mod.rs @@ -1,8 +1,9 @@ // Copyright 2021 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use std::error::Error; + use crate::client::config::Config; -use crate::error::Socks5ClientError; use clap::CommandFactory; use clap::{Parser, Subcommand}; use completions::{fig_generate, ArgShell}; @@ -83,12 +84,13 @@ pub(crate) struct OverrideConfig { port: Option, use_anonymous_sender_tag: bool, fastmode: bool, + no_cover: bool, #[cfg(feature = "coconut")] enabled_credentials_mode: bool, } -pub(crate) async fn execute(args: &Cli) -> Result<(), Socks5ClientError> { +pub(crate) async fn execute(args: &Cli) -> Result<(), Box> { let bin_name = "nym-socks5-client"; match &args.command { @@ -140,6 +142,10 @@ pub(crate) fn override_config(mut config: Config, args: OverrideConfig) -> Confi config.get_base_mut().set_high_default_traffic_volume(); } + if args.no_cover { + config.get_base_mut().set_no_cover_traffic(); + } + config } diff --git a/clients/socks5/src/commands/run.rs b/clients/socks5/src/commands/run.rs index 0938bfe00e..7a2a97d921 100644 --- a/clients/socks5/src/commands/run.rs +++ b/clients/socks5/src/commands/run.rs @@ -51,6 +51,15 @@ pub(crate) struct Run { #[clap(short, long)] port: Option, + /// Mostly debug-related option to increase default traffic rate so that you would not need to + /// modify config post init + #[clap(long, hidden = true)] + fastmode: bool, + + /// Disable loop cover traffic and the Poisson rate limiter (for debugging only) + #[clap(long, hidden = true)] + no_cover: bool, + /// Set this client to work in a enabled credentials mode that would attempt to use gateway /// with bandwidth credential requirement. #[cfg(feature = "coconut")] @@ -65,8 +74,8 @@ impl From for OverrideConfig { api_validators: run_config.api_validators, port: run_config.port, use_anonymous_sender_tag: run_config.use_anonymous_sender_tag, - fastmode: false, - + fastmode: run_config.fastmode, + no_cover: run_config.no_cover, #[cfg(feature = "coconut")] enabled_credentials_mode: run_config.enabled_credentials_mode, } @@ -95,14 +104,16 @@ fn version_check(cfg: &Config) -> bool { } } -pub(crate) async fn execute(args: &Run) -> Result<(), Socks5ClientError> { +pub(crate) async fn execute(args: &Run) -> Result<(), Box> { let id = &args.id; let mut config = match Config::load_from_file(Some(id)) { Ok(cfg) => cfg, Err(err) => { error!("Failed to load config for {}. Are you sure you have run `init` before? (Error was: {})", id, err); - return Err(Socks5ClientError::FailedToLoadConfig(id.to_string())); + return Err(Box::new(Socks5ClientError::FailedToLoadConfig( + id.to_string(), + ))); } }; @@ -111,7 +122,7 @@ pub(crate) async fn execute(args: &Run) -> Result<(), Socks5ClientError> { if !version_check(&config) { error!("failed the local version check"); - return Err(Socks5ClientError::FailedLocalVersionCheck); + return Err(Box::new(Socks5ClientError::FailedLocalVersionCheck)); } NymClient::new(config).run_forever().await diff --git a/clients/socks5/src/error.rs b/clients/socks5/src/error.rs index 0fb5475bb1..23cd715cf5 100644 --- a/clients/socks5/src/error.rs +++ b/clients/socks5/src/error.rs @@ -1,29 +1,24 @@ -use client_core::client::replies::reply_storage::fs_backend::StorageError; +use crate::socks::types::SocksProxyError; +use client_core::client::replies::reply_storage::fs_backend; use client_core::error::ClientCoreError; -use crypto::asymmetric::identity::Ed25519RecoveryError; -use gateway_client::error::GatewayClientError; -use validator_client::ValidatorClientError; #[derive(thiserror::Error, Debug)] pub enum Socks5ClientError { #[error("I/O error: {0}")] IoError(#[from] std::io::Error), - #[error("Gateway client error: {0}")] - GatewayClientError(#[from] GatewayClientError), - #[error("Ed25519 error: {0}")] - Ed25519RecoveryError(#[from] Ed25519RecoveryError), - #[error("Validator client error: {0}")] - ValidatorClientError(#[from] ValidatorClientError), + #[error("client-core error: {0}")] - ClientCoreError(#[from] ClientCoreError), + ClientCoreError(#[from] ClientCoreError), + + #[error("SOCKS proxy error")] + SocksProxyError(SocksProxyError), + #[error("Failed to load config for: {0}")] FailedToLoadConfig(String), + #[error("Failed local version check, client and config mismatch")] FailedLocalVersionCheck, - #[error("experienced a failure with our reply surb persistent storage: {source}")] - SurbStorageError { - #[source] - #[from] - source: StorageError, - }, + + #[error("Fail to bind address")] + FailToBindAddress, } diff --git a/clients/socks5/src/main.rs b/clients/socks5/src/main.rs index d7a0fdf01f..d41e5e4bd8 100644 --- a/clients/socks5/src/main.rs +++ b/clients/socks5/src/main.rs @@ -1,8 +1,9 @@ // Copyright 2021 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use std::error::Error; + use clap::{crate_version, Parser}; -use error::Socks5ClientError; use logging::setup_logging; use network_defaults::setup_env; @@ -12,7 +13,7 @@ pub mod error; pub mod socks; #[tokio::main] -async fn main() -> Result<(), Socks5ClientError> { +async fn main() -> Result<(), Box> { setup_logging(); println!("{}", banner()); diff --git a/clients/socks5/src/socks/client.rs b/clients/socks5/src/socks/client.rs index 0b4c07b597..3b35a4caf9 100644 --- a/clients/socks5/src/socks/client.rs +++ b/clients/socks5/src/socks/client.rs @@ -2,8 +2,8 @@ use super::authentication::{AuthenticationMethods, Authenticator, User}; use super::request::{SocksCommand, SocksRequest}; -use super::types::{ResponseCode, SocksProxyError}; -use super::{RESERVED, SOCKS_VERSION}; +use super::types::{ResponseCodeV4, ResponseCodeV5, SocksProxyError}; +use super::{SocksVersion, RESERVED, SOCKS4_VERSION, SOCKS5_VERSION}; use client_connections::{LaneQueueLengths, TransmissionLane}; use client_core::client::inbound_messages::{InputMessage, InputMessageSender}; use futures::channel::mpsc; @@ -150,14 +150,14 @@ impl Config { /// A client connecting to the Socks proxy server, because /// it wants to make a Nym-protected outbound request. Typically, this is /// something like e.g. a wallet app running on your laptop connecting to -/// SphinxSocksServer. +/// `SphinxSocksServer`. pub(crate) struct SocksClient { config: Config, controller_sender: ControllerSender, stream: StreamState, auth_nmethods: u8, authenticator: Authenticator, - socks_version: u8, + socks_version: Option, input_sender: InputMessageSender, connection_id: ConnectionId, service_provider: Recipient, @@ -180,31 +180,34 @@ impl Drop for SocksClient { } impl SocksClient { - /// Create a new SOCKClient #[allow(clippy::too_many_arguments)] pub fn new( config: Config, stream: TcpStream, authenticator: Authenticator, input_sender: InputMessageSender, - service_provider: Recipient, + service_provider: &Recipient, controller_sender: ControllerSender, - self_address: Recipient, + self_address: &Recipient, lane_queue_lengths: LaneQueueLengths, - shutdown_listener: ShutdownListener, + mut shutdown_listener: ShutdownListener, ) -> Self { + // If this task fails and exits, we don't want to send shutdown signal + shutdown_listener.mark_as_success(); + let connection_id = Self::generate_random(); + SocksClient { config, controller_sender, connection_id, stream: StreamState::Available(stream), auth_nmethods: 0, - socks_version: 0, + socks_version: None, authenticator, input_sender, - service_provider, - self_address, + service_provider: *service_provider, + self_address: *self_address, started_proxy: false, lane_queue_lengths, shutdown_listener, @@ -216,16 +219,49 @@ impl SocksClient { rng.next_u64() } + pub async fn send_error(&mut self, err: SocksProxyError) -> Result<(), SocksProxyError> { + let error_text = format!("{}", err); + let Some(ref version) = self.socks_version else { + log::error!("Trying to send error without knowing the version"); + return Ok(()); + }; + + match version { + SocksVersion::V4 => { + let response = ResponseCodeV4::RequestRejected; + self.send_error_v4(response).await + } + SocksVersion::V5 => { + let response = if error_text.contains("Host") { + ResponseCodeV5::HostUnreachable + } else if error_text.contains("Network") { + ResponseCodeV5::NetworkUnreachable + } else if error_text.contains("ttl") { + ResponseCodeV5::TtlExpired + } else { + ResponseCodeV5::Failure + }; + self.send_error_v5(response).await + } + } + } + // Send an error back to the client - pub async fn error(&mut self, r: ResponseCode) -> Result<(), SocksProxyError> { - self.stream.write_all(&[5, r as u8]).await?; + pub async fn send_error_v4(&mut self, r: ResponseCodeV4) -> Result<(), SocksProxyError> { + self.stream.write_all(&[SOCKS4_VERSION, r as u8]).await?; Ok(()) } - /// Shutdown the TcpStream to the client and end the session + pub async fn send_error_v5(&mut self, r: ResponseCodeV5) -> Result<(), SocksProxyError> { + self.stream.write_all(&[SOCKS5_VERSION, r as u8]).await?; + Ok(()) + } + + /// Shutdown the `TcpStream` to the client and end the session pub async fn shutdown(&mut self) -> Result<(), SocksProxyError> { info!("client is shutting down its TCP stream"); self.stream.shutdown().await?; + self.shutdown_listener.mark_as_success(); Ok(()) } @@ -233,25 +269,27 @@ impl SocksClient { /// is in use and that the client is authenticated, then runs the request. pub async fn run(&mut self) -> Result<(), SocksProxyError> { debug!("New connection from: {}", self.stream.peer_addr()?.ip()); - let mut header = [0u8; 2]; + // Read a byte from the stream and determine the version being requested + let mut header = [0u8]; self.stream.read_exact(&mut header).await?; - self.socks_version = header[0]; - self.auth_nmethods = header[1]; + self.socks_version = match SocksVersion::try_from(header[0]) { + Ok(version) => Some(version), + Err(_err) => { + warn!("Init: Unsupported version: SOCKS{}", header[0]); + return self.shutdown().await; + } + }; - // Handle SOCKS4 requests - if header[0] != SOCKS_VERSION { - warn!("Init: Unsupported version: SOCKS{}", self.socks_version); - self.shutdown().await - } - // Valid SOCKS5 - else { - // Authenticate w/ client - self.authenticate().await?; - // Handle requests - self.handle_request().await + if self.socks_version == Some(SocksVersion::V5) { + let mut auth = [0u8]; + self.stream.read_exact(&mut auth).await?; + self.auth_nmethods = auth[0]; + self.authenticate_socks5().await?; } + + self.handle_request().await } async fn send_anonymous_connect_to_mixnet(&mut self, remote_address: RemoteAddress) { @@ -299,10 +337,15 @@ impl SocksClient { .await; let stream = self.stream.run_proxy(); - let local_stream_remote = stream - .peer_addr() - .expect("failed to extract peer address") - .to_string(); + let peer_addr = match stream.peer_addr() { + Ok(peer_addr) => peer_addr, + Err(err) => { + log::error!("Unable to extract the remote peer address: {err}"); + return; + } + }; + let local_stream_remote = peer_addr.to_string(); + let connection_id = self.connection_id; let input_sender = self.input_sender.clone(); let anonymous = self.config.use_surbs_for_responses; @@ -344,8 +387,17 @@ impl SocksClient { async fn handle_request(&mut self) -> Result<(), SocksProxyError> { debug!("Handling CONNECT Command"); - let request = SocksRequest::from_stream(&mut self.stream).await?; - let remote_address = request.to_string(); + let version = self + .socks_version + .as_ref() + .expect("Must read version before parsing request"); + + let request = match version { + SocksVersion::V4 => SocksRequest::from_stream_socks4(&mut self.stream).await?, + SocksVersion::V5 => SocksRequest::from_stream_socks5(&mut self.stream).await?, + }; + + let remote_address = request.address_string(); // setup for receiving from the mixnet let (mix_sender, mix_receiver) = mpsc::unbounded(); @@ -354,7 +406,10 @@ impl SocksClient { // Use the Proxy to connect to the specified addr/port SocksCommand::Connect => { trace!("Connecting to: {:?}", remote_address.clone()); - self.acknowledge_socks5().await; + match version { + SocksVersion::V4 => self.acknowledge_socks4().await, + SocksVersion::V5 => self.acknowledge_socks5().await, + } self.started_proxy = true; self.controller_sender @@ -385,8 +440,8 @@ impl SocksClient { async fn acknowledge_socks5(&mut self) { self.stream .write_all(&[ - SOCKS_VERSION, - ResponseCode::Success as u8, + SOCKS5_VERSION, + ResponseCodeV5::Success as u8, RESERVED, 1, 127, @@ -400,13 +455,30 @@ impl SocksClient { .unwrap(); } + /// Writes a Socks4 header back to the requesting client's TCP stream, + async fn acknowledge_socks4(&mut self) { + self.stream + .write_all(&[ + 0, //SOCKS4_VERSION, + ResponseCodeV4::Granted as u8, + 0, + 0, + 127, + 0, + 0, + 1, + ]) + .await + .unwrap(); + } + /// Authenticate the incoming request. Each request is checked for its /// authentication method. A user/password request will extract the /// username and password from the stream, then check with the Authenticator /// to see if the resulting user is allowed. /// /// A lot of this could probably be put into the `SocksRequest::from_stream()` - /// constructor, and/or cleaned up with tokio::codec. It's mostly just + /// constructor, and/or cleaned up with `tokio::codec`. It's mostly just /// read-a-byte-or-two. The bytes being extracted look like this: /// /// +----+------+----------+------+------------+ @@ -418,7 +490,7 @@ impl SocksClient { /// Pulling out the stream code into its own home, and moving the if/else logic /// into the Authenticator (where it'll be more easily testable) /// would be a good next step. - async fn authenticate(&mut self) -> Result<(), SocksProxyError> { + async fn authenticate_socks5(&mut self) -> Result<(), SocksProxyError> { debug!("Authenticating w/ {}", self.stream.peer_addr()?.ip()); // Get valid auth methods let methods = self.get_available_methods().await?; @@ -427,7 +499,7 @@ impl SocksClient { let mut response = [0u8; 2]; // Set the version in the response - response[0] = SOCKS_VERSION; + response[0] = SOCKS5_VERSION; if methods.contains(&(AuthenticationMethods::UserPass as u8)) { // Set the default auth method (NO AUTH) response[1] = AuthenticationMethods::UserPass as u8; @@ -463,11 +535,11 @@ impl SocksClient { // Authenticate passwords if self.authenticator.is_allowed(&user) { debug!("Access Granted. User: {}", user.username); - let response = [1, ResponseCode::Success as u8]; + let response = [1, ResponseCodeV5::Success as u8]; self.stream.write_all(&response).await?; } else { debug!("Access Denied. User: {}", user.username); - let response = [1, ResponseCode::Failure as u8]; + let response = [1, ResponseCodeV5::Failure as u8]; self.stream.write_all(&response).await?; // Shutdown @@ -486,7 +558,7 @@ impl SocksClient { response[1] = AuthenticationMethods::NoMethods as u8; self.stream.write_all(&response).await?; self.shutdown().await?; - Err(ResponseCode::Failure.into()) + Err(ResponseCodeV5::Failure.into()) } } diff --git a/clients/socks5/src/socks/mixnet_responses.rs b/clients/socks5/src/socks/mixnet_responses.rs index b59a805583..7613a32019 100644 --- a/clients/socks5/src/socks/mixnet_responses.rs +++ b/clients/socks5/src/socks/mixnet_responses.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use futures::channel::mpsc; use futures::StreamExt; use log::*; @@ -103,7 +105,10 @@ impl MixnetResponseListener { } } } - assert!(self.shutdown.is_shutdown_poll()); + #[cfg(not(target_arch = "wasm32"))] + tokio::time::timeout(Duration::from_secs(5), self.shutdown.recv()) + .await + .expect("Task stopped without shutdown called"); log::debug!("MixnetResponseListener: Exiting"); } } diff --git a/clients/socks5/src/socks/mod.rs b/clients/socks5/src/socks/mod.rs index 236e5391ec..16603839ff 100644 --- a/clients/socks5/src/socks/mod.rs +++ b/clients/socks5/src/socks/mod.rs @@ -1,5 +1,9 @@ #![forbid(unsafe_code)] +use std::convert::TryFrom; + +use self::types::SocksProxyError; + pub mod authentication; pub(crate) mod client; pub(crate) mod mixnet_responses; @@ -9,6 +13,27 @@ pub mod types; pub mod utils; /// Version of socks -const SOCKS_VERSION: u8 = 0x05; +const SOCKS4_VERSION: u8 = 0x04; +const SOCKS5_VERSION: u8 = 0x05; const RESERVED: u8 = 0x00; + +#[derive(Clone, PartialEq, Eq)] +pub enum SocksVersion { + V4 = 0x04, + V5 = 0x05, +} + +pub struct InvalidSocksVersion; + +impl TryFrom for SocksVersion { + type Error = SocksProxyError; + + fn try_from(version: u8) -> Result { + match version { + SOCKS4_VERSION => Ok(Self::V4), + SOCKS5_VERSION => Ok(Self::V5), + _ => Err(SocksProxyError::UnsupportedProxyVersion(version)), + } + } +} diff --git a/clients/socks5/src/socks/request.rs b/clients/socks5/src/socks/request.rs index 92d687aa90..ecf9b656f3 100644 --- a/clients/socks5/src/socks/request.rs +++ b/clients/socks5/src/socks/request.rs @@ -1,5 +1,7 @@ -use super::types::{AddrType, ResponseCode, SocksProxyError}; -use super::{utils as socks_utils, SOCKS_VERSION}; +use crate::socks::SOCKS4_VERSION; + +use super::types::{AddrType, ResponseCodeV5, SocksProxyError}; +use super::{utils as socks_utils, SOCKS5_VERSION}; use log::*; use std::fmt::{self, Display}; use tokio::io::{AsyncRead, AsyncReadExt}; @@ -15,80 +17,114 @@ pub(crate) struct SocksRequest { } impl SocksRequest { - /// Parse a SOCKS5 request from a TcpStream - pub async fn from_stream(stream: &mut R) -> Result + /// Parse a SOCKS4 request from a `TcpStream` + /// From documents at: + /// - SOCKS4: https://www.openssh.com/txt/socks4.protocol + /// - SOCKS4a: https://www.openssh.com/txt/socks4a.protocol + pub async fn from_stream_socks4(stream: &mut R) -> Result where R: AsyncRead + Unpin, { + log::trace!("read from stream socks4"); + + let mut packet = [0u8; 3]; + stream.read_exact(&mut packet).await?; + + // CD (command) + let Some(command) = SocksCommand::from(packet[0] as usize) else { + log::warn!("Invalid Command"); + return Err(ResponseCodeV5::CommandNotSupported.into()); + }; + + // DSTPORT + let mut port = [0u8; 2]; + port.copy_from_slice(&packet[1..]); + let port = merge_u8_into_u16(port[0], port[1]); + + // DSTIP + let mut ip = [0u8; 4]; + stream.read_exact(&mut ip).await?; + + // USERID + let _userid = read_until_zero(stream).await; + + // SOCKS4a extension + // https://www.openssh.com/txt/socks4a.protocol + // If the IP is 0.0.0.x with x nonzero, read the domain name + let (addr, addr_type) = if ip[..3] == [0, 0, 0] && ip[3] != 0 { + (read_until_zero(stream).await?, AddrType::Domain) + } else { + (ip.to_vec(), AddrType::V4) + }; + + // Return parsed request + Ok(SocksRequest { + version: SOCKS4_VERSION, + command, + addr_type, + addr, + port, + }) + } + /// Parse a SOCKS5 request from a `TcpStream` + /// From: https://www.rfc-editor.org/rfc/rfc1928 + pub async fn from_stream_socks5(stream: &mut R) -> Result + where + R: AsyncRead + Unpin, + { + log::info!("read from stream socks5"); + let mut packet = [0u8; 4]; // Read a byte from the stream and determine the version being requested stream.read_exact(&mut packet).await?; - if packet[0] != SOCKS_VERSION { - warn!("from_stream Unsupported version: SOCKS{}", packet[0]); + // VER + if packet[0] != SOCKS5_VERSION { + warn!("Unsupported version: SOCKS{}", packet[0]); return Err(SocksProxyError::UnsupportedProxyVersion(packet[0])); } - // Get command - let mut command: SocksCommand = SocksCommand::Connect; - match SocksCommand::from(packet[1] as usize) { - Some(com) => { - command = com; - Ok(()) - } - None => { - warn!("Invalid Command"); - Err(ResponseCode::CommandNotSupported) - } - }?; + // CMD + let Some(command) = SocksCommand::from(packet[1] as usize) else { + warn!("Invalid Command"); + return Err(ResponseCodeV5::CommandNotSupported.into()); + }; - // DST.address + // RSV + // packet[2] is reserved - let mut addr_type: AddrType = AddrType::V6; - match AddrType::from(packet[3] as usize) { - Some(addr) => { - addr_type = addr; - Ok(()) - } - None => { - error!("No Addr"); - Err(ResponseCode::AddrTypeNotSupported) - } - }?; + // ATYP + let Some(addr_type) = AddrType::from(packet[3] as usize) else { + error!("No Addr"); + return Err(ResponseCodeV5::AddrTypeNotSupported.into()) + }; - trace!("Getting Addr"); - // Get Addr from addr_type and stream - let addr: Result, SocksProxyError> = match addr_type { + // DST.ADDR + let addr = match addr_type { AddrType::Domain => { - let mut domain_length = [0u8; 1]; + let mut domain_length = [0u8]; stream.read_exact(&mut domain_length).await?; - let mut domain = vec![0u8; domain_length[0] as usize]; stream.read_exact(&mut domain).await?; - - Ok(domain) + domain } AddrType::V4 => { let mut addr = [0u8; 4]; stream.read_exact(&mut addr).await?; - Ok(addr.to_vec()) + addr.to_vec() } AddrType::V6 => { let mut addr = [0u8; 16]; stream.read_exact(&mut addr).await?; - Ok(addr.to_vec()) + addr.to_vec() } }; - let addr = addr?; - - // read DST.port + // DST.PORT let mut port = [0u8; 2]; stream.read_exact(&mut port).await?; - // Merge two u8s into u16 - let port = (u16::from(port[0]) << 8) | u16::from(port[1]); + let port = merge_u8_into_u16(port[0], port[1]); - // Return parsed request Ok(SocksRequest { version: packet[0], command, @@ -97,14 +133,18 @@ impl SocksRequest { port, }) } + + /// Print out the address and port to a String. + /// This might return domain:port, ipv6:port, or ipv4:port. + pub fn address_string(&self) -> String { + let address = socks_utils::pretty_print_addr(&self.addr_type, &self.addr); + format!("{}:{}", address, self.port) + } } impl Display for SocksRequest { - /// Print out the address and port to a String. - /// This might return domain:port, ipv6:port, or ipv4:port. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let address = socks_utils::pretty_print_addr(&self.addr_type, &self.addr); - write!(f, "{}:{}", address, self.port) + write!(f, "{}", self.address_string()) } } @@ -127,3 +167,23 @@ impl SocksCommand { } } } + +fn merge_u8_into_u16(a: u8, b: u8) -> u16 { + (u16::from(a) << 8) | u16::from(b) +} + +async fn read_until_zero(stream: &mut R) -> Result, SocksProxyError> +where + R: AsyncRead + Unpin, +{ + let mut result = Vec::new(); + let mut char = [0u8]; + loop { + stream.read_exact(&mut char).await?; + if char[0] == 0 { + break; + } + result.push(char[0]); + } + Ok(result) +} diff --git a/clients/socks5/src/socks/server.rs b/clients/socks5/src/socks/server.rs index 010c605154..0bba59ee0b 100644 --- a/clients/socks5/src/socks/server.rs +++ b/clients/socks5/src/socks/server.rs @@ -1,8 +1,7 @@ -use super::authentication::Authenticator; -use super::client::SocksClient; +use crate::error::Socks5ClientError; + use super::{ - mixnet_responses::MixnetResponseListener, - types::{ResponseCode, SocksProxyError}, + authentication::Authenticator, client::SocksClient, mixnet_responses::MixnetResponseListener, }; use crate::socks::client; use client_connections::{ConnectionCommandSender, LaneQueueLengths}; @@ -13,6 +12,7 @@ use log::*; use nymsphinx::addressing::clients::Recipient; use proxy_helpers::connection_controller::{BroadcastActiveConnections, Controller}; use std::net::SocketAddr; +use tap::TapFallible; use task::ShutdownListener; use tokio::net::TcpListener; @@ -60,8 +60,10 @@ impl SphinxSocksServer { input_sender: InputMessageSender, buffer_requester: ReceivedBufferRequestSender, client_connection_tx: ConnectionCommandSender, - ) -> Result<(), SocksProxyError> { - let listener = TcpListener::bind(self.listening_address).await.unwrap(); + ) -> Result<(), Socks5ClientError> { + let listener = TcpListener::bind(self.listening_address) + .await + .tap_err(|err| log::error!("Failed to bind to address: {err}"))?; info!("Serving Connections..."); // controller for managing all active connections @@ -87,48 +89,27 @@ impl SphinxSocksServer { loop { tokio::select! { Ok((stream, _remote)) = listener.accept() => { - // TODO Optimize this let mut client = SocksClient::new( self.client_config, stream, self.authenticator.clone(), input_sender.clone(), - self.service_provider, + &self.service_provider, controller_sender.clone(), - self.self_address, + &self.self_address, self.lane_queue_lengths.clone(), self.shutdown.clone(), ); tokio::spawn(async move { - { - match client.run().await { - Ok(_) => {} - Err(error) => { - error!("Error! {}", error); - let error_text = format!("{}", error); - - let response: ResponseCode; - - if error_text.contains("Host") { - response = ResponseCode::HostUnreachable; - } else if error_text.contains("Network") { - response = ResponseCode::NetworkUnreachable; - } else if error_text.contains("ttl") { - response = ResponseCode::TtlExpired - } else { - response = ResponseCode::Failure - } - - if client.error(response).await.is_err() { - warn!("Failed to send error code"); - }; - if client.shutdown().await.is_err() { - warn!("Failed to shutdown TcpStream"); - }; - } + if let Err(err) = client.run().await { + error!("Error! {}", err); + if client.send_error(err).await.is_err() { + warn!("Failed to send error code"); + }; + if client.shutdown().await.is_err() { + warn!("Failed to shutdown TcpStream"); }; - // client gets dropped here } }); }, diff --git a/clients/socks5/src/socks/types.rs b/clients/socks5/src/socks/types.rs index b043da5ccc..3d8e5b161c 100644 --- a/clients/socks5/src/socks/types.rs +++ b/clients/socks5/src/socks/types.rs @@ -1,7 +1,17 @@ use snafu::Snafu; -#[derive(Debug, Snafu)] + +/// SOCKS4 Response codes +#[allow(dead_code)] +pub(crate) enum ResponseCodeV4 { + Granted = 0x5a, + RequestRejected = 0x5b, + CannotConnectToIdent = 0x5c, + DifferentUserId = 0x5d, +} + /// Possible SOCKS5 Response Codes -pub(crate) enum ResponseCode { +#[derive(Debug, Snafu)] +pub(crate) enum ResponseCodeV5 { Success = 0x00, #[snafu(display("SOCKS5 Server Failure"))] Failure = 0x01, @@ -48,7 +58,7 @@ where } /// DST.addr variant types -#[derive(PartialEq)] +#[derive(Debug, PartialEq)] pub(crate) enum AddrType { V4 = 0x01, Domain = 0x03, diff --git a/clients/webassembly/Cargo.toml b/clients/webassembly/Cargo.toml index 2833598a16..cd60eb7dfa 100644 --- a/clients/webassembly/Cargo.toml +++ b/clients/webassembly/Cargo.toml @@ -39,7 +39,7 @@ topology = { path = "../../common/topology" } gateway-client = { path = "../../common/client-libs/gateway-client", default-features = false, features = ["wasm", "coconut"] } validator-client = { path = "../../common/client-libs/validator-client", default-features = false } wasm-utils = { path = "../../common/wasm-utils" } - +task = { path = "../../common/task" } # The `console_error_panic_hook` crate provides better debugging of panics by # logging them with `console.error`. This is great for development, but requires # all the `std::fmt` and `std::panicking` infrastructure, so isn't great for diff --git a/clients/webassembly/js-example/package-lock.json b/clients/webassembly/js-example/package-lock.json index 06359b2f03..ea8023655b 100644 --- a/clients/webassembly/js-example/package-lock.json +++ b/clients/webassembly/js-example/package-lock.json @@ -24,7 +24,7 @@ }, "../pkg": { "name": "@nymproject/nym-client-wasm", - "version": "1.0.0", + "version": "1.1.0", "license": "Apache-2.0" }, "node_modules/@discoveryjs/json-ext": { diff --git a/clients/webassembly/js-example/worker.js b/clients/webassembly/js-example/worker.js index 6889109911..0cbf2fe4d6 100644 --- a/clients/webassembly/js-example/worker.js +++ b/clients/webassembly/js-example/worker.js @@ -61,16 +61,9 @@ async function main() { // sets up better stack traces in case of in-rust panics set_panic_hook(); - console.error("the current mainnet is not compatible with v2! - either use the pre-merge branch or explicitly set the client to use one of V2 QA networks") - return - // validator server we will use to get topology from - // MAINNET (V1): const validator = 'https://validator.nymtech.net/api'; //"http://localhost:8081"; const preferredGateway = 'E3mvZTHQCdBvhfr178Swx9g4QG3kkRUun7YnToLMcMbM'; - // QA (V2): - // const validator = 'https://qa-validator-api.nymtech.net/api'; //"http://localhost:8081"; - // const preferredGateway = 'CgQrYP8etksSBf4nALNqp93SHPpgFwEUyTsjBNNLj5WM'; const gatewayEndpoint = await get_gateway(validator, preferredGateway); gatewayEndpoint.gateway_listener = "wss://gateway1.nymtech.net:443"; // this is needed if we want it to work on the web. However this gateway is a v1 gateway, we will need to change for v2 once we get there diff --git a/clients/webassembly/src/client/config.rs b/clients/webassembly/src/client/config.rs index f76918463d..f16584c5a3 100644 --- a/clients/webassembly/src/client/config.rs +++ b/clients/webassembly/src/client/config.rs @@ -4,12 +4,15 @@ // due to expansion of #[wasm_bindgen] macro on `Debug` Config struct #![allow(clippy::drop_non_drop)] -use client_core::config::{Debug as ConfigDebug, ExtendedPacketSize, GatewayEndpoint}; +use client_core::config::{DebugConfig as ConfigDebug, ExtendedPacketSize, GatewayEndpointConfig}; +use serde::{Deserialize, Serialize}; use std::time::Duration; use url::Url; use wasm_bindgen::prelude::*; #[wasm_bindgen] +#[derive(Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] pub struct Config { /// ID specifies the human readable ID of this particular client. pub(crate) id: String, @@ -19,7 +22,7 @@ pub struct Config { pub(crate) disabled_credentials_mode: bool, /// Information regarding how the client should send data to gateway. - pub(crate) gateway_endpoint: GatewayEndpoint, + pub(crate) gateway_endpoint: GatewayEndpointConfig, pub(crate) debug: ConfigDebug, } @@ -30,7 +33,7 @@ impl Config { pub fn new( id: String, validator_server: String, - gateway_endpoint: GatewayEndpoint, + gateway_endpoint: GatewayEndpointConfig, debug: Option, ) -> Self { Config { diff --git a/clients/webassembly/src/client/mod.rs b/clients/webassembly/src/client/mod.rs index 7854c01627..4b5158bbc7 100644 --- a/clients/webassembly/src/client/mod.rs +++ b/clients/webassembly/src/client/mod.rs @@ -2,56 +2,46 @@ // SPDX-License-Identifier: Apache-2.0 use self::config::Config; -use client_connections::{ConnectionCommandReceiver, LaneQueueLengths, TransmissionLane}; -use client_core::client::replies::reply_controller; -use client_core::client::replies::reply_controller::{ - ReplyControllerReceiver, ReplyControllerSender, -}; -use client_core::client::replies::reply_storage::{CombinedReplyStorage, SentReplyKeys}; -use client_core::client::{ - cover_traffic_stream::LoopCoverTrafficStream, - inbound_messages::{InputMessage, InputMessageReceiver, InputMessageSender}, - key_manager::KeyManager, - mix_traffic::{BatchMixMessageSender, MixTrafficController}, - real_messages_control::{self, RealMessagesController}, - received_buffer::{ - ReceivedBufferMessage, ReceivedBufferRequestReceiver, ReceivedBufferRequestSender, - ReceivedMessagesBufferController, - }, - topology_control::{TopologyAccessor, TopologyRefresher, TopologyRefresherConfig}, -}; +use crate::client::response_pusher::ResponsePusher; +use client_connections::TransmissionLane; +use client_core::client::base_client::{BaseClientBuilder, ClientInput, ClientOutput}; +use client_core::client::replies::reply_storage::browser_backend; +use client_core::client::{inbound_messages::InputMessage, key_manager::KeyManager}; use crypto::asymmetric::identity; -use futures::channel::mpsc; -use futures::StreamExt; -use gateway_client::{ - AcknowledgementReceiver, AcknowledgementSender, GatewayClient, MixnetMessageReceiver, - MixnetMessageSender, -}; use nymsphinx::addressing::clients::Recipient; use rand::rngs::OsRng; +use task::ShutdownNotifier; use wasm_bindgen::prelude::*; -use wasm_bindgen_futures::spawn_local; -use wasm_utils::console_log; +use wasm_utils::{console_error, console_log}; pub mod config; +mod response_pusher; #[wasm_bindgen] pub struct NymClient { config: Config, /// KeyManager object containing smart pointers to all relevant keys used by the client. - key_manager: KeyManager, + // due to disgusting workaround I had to wrap the below in an Option + // so that the interface wouldn't change (i.e. both `start` and `new` would still return a `NymClient`) + key_manager: Option, + self_address: Option, + storage_backend: Option, // TODO: this should be stored somewhere persistently // received_keys: HashSet, /// Channel used for transforming 'raw' messages into sphinx packets and sending them /// through the mix network. - input_tx: Option, + client_input: Option, // callbacks on_message: Option, on_binary_message: Option, on_gateway_connect: Option, + + // even though we don't use graceful shutdowns, other components rely on existence of this struct + // and if it's dropped, everything will start going offline + _shutdown: Option, } #[wasm_bindgen] @@ -59,15 +49,21 @@ impl NymClient { #[wasm_bindgen(constructor)] pub fn new(config: Config) -> Self { Self { + storage_backend: Some(Self::setup_reply_surb_storage_backend(&config)), config, - key_manager: Self::setup_key_manager(), + key_manager: Some(Self::setup_key_manager()), on_message: None, on_binary_message: None, on_gateway_connect: None, - input_tx: None, + client_input: None, + self_address: None, + _shutdown: None, } } + // TODO: once we make keys persistent, we'll require some kind of `init` method to generate + // a prior shared keypair between the client and the gateway + // perhaps this should be public? fn setup_key_manager() -> KeyManager { let mut rng = OsRng; @@ -76,6 +72,15 @@ impl NymClient { KeyManager::new(&mut rng) } + // don't get too excited about the name, under the hood it's just a big fat placeholder + // with no persistence + fn setup_reply_surb_storage_backend(config: &Config) -> browser_backend::Backend { + browser_backend::Backend::new( + config.debug.minimum_reply_surb_storage_threshold, + config.debug.maximum_reply_surb_storage_threshold, + ) + } + pub fn set_on_message(&mut self, on_message: js_sys::Function) { self.on_message = Some(on_message); } @@ -89,325 +94,26 @@ impl NymClient { } fn as_mix_recipient(&self) -> Recipient { + // another disgusting (and hopefully temporary) workaround + let key_manager_ref = self + .key_manager + .as_ref() + .expect("attempting to call 'as_mix_recipient' after 'start'"); + Recipient::new( - *self.key_manager.identity_keypair().public_key(), - *self.key_manager.encryption_keypair().public_key(), + *key_manager_ref.identity_keypair().public_key(), + *key_manager_ref.encryption_keypair().public_key(), identity::PublicKey::from_base58_string(&self.config.gateway_endpoint.gateway_id) .expect("no gateway has been selected"), ) } pub fn self_address(&self) -> String { - self.as_mix_recipient().to_string() - } - - // future constantly pumping loop cover traffic at some specified average rate - // the pumped traffic goes to the MixTrafficController - fn start_cover_traffic_stream( - &self, - topology_accessor: TopologyAccessor, - mix_tx: BatchMixMessageSender, - ) { - console_log!("Starting loop cover traffic stream..."); - - let mut stream = LoopCoverTrafficStream::new( - self.key_manager.ack_key(), - self.config.debug.average_ack_delay, - self.config.debug.average_packet_delay, - self.config.debug.loop_cover_traffic_average_delay, - mix_tx, - self.as_mix_recipient(), - topology_accessor, - ); - - if let Some(size) = &self.config.debug.use_extended_packet_size { - stream.set_custom_packet_size((*size).into()); + if let Some(address) = &self.self_address { + address.clone() + } else { + self.as_mix_recipient().to_string() } - - stream.start(); - } - - #[allow(clippy::too_many_arguments)] - fn start_real_traffic_controller( - &self, - topology_accessor: TopologyAccessor, - ack_receiver: AcknowledgementReceiver, - input_receiver: InputMessageReceiver, - mix_sender: BatchMixMessageSender, - reply_storage: CombinedReplyStorage, - reply_controller_sender: ReplyControllerSender, - reply_controller_receiver: ReplyControllerReceiver, - lane_queue_lengths: LaneQueueLengths, - client_connection_rx: ConnectionCommandReceiver, - ) { - let mut controller_config = real_messages_control::Config::new( - &self.config.debug, - self.key_manager.ack_key(), - self.as_mix_recipient(), - ); - - if let Some(size) = &self.config.debug.use_extended_packet_size { - controller_config.set_custom_packet_size((*size).into()); - } - - console_log!("Starting real traffic stream..."); - - RealMessagesController::new( - controller_config, - ack_receiver, - input_receiver, - mix_sender, - topology_accessor, - reply_storage, - reply_controller_sender, - reply_controller_receiver, - lane_queue_lengths, - client_connection_rx, - ) - .start(); - } - - // buffer controlling all messages fetched from provider - // required so that other components would be able to use them (say the websocket) - fn start_received_messages_buffer_controller( - &self, - query_receiver: ReceivedBufferRequestReceiver, - mixnet_receiver: MixnetMessageReceiver, - reply_key_storage: SentReplyKeys, - reply_controller_sender: ReplyControllerSender, - ) { - console_log!("Starting received messages buffer controller..."); - ReceivedMessagesBufferController::new( - self.key_manager.encryption_keypair(), - query_receiver, - mixnet_receiver, - reply_key_storage, - reply_controller_sender, - ) - .start() - } - - async fn start_gateway_client( - &mut self, - mixnet_message_sender: MixnetMessageSender, - ack_sender: AcknowledgementSender, - ) -> GatewayClient { - let gateway_id = self.config.gateway_endpoint.gateway_id.clone(); - if gateway_id.is_empty() { - panic!("The identity of the gateway is unknown - did you run `get_gateway()`?") - } - let gateway_owner = self.config.gateway_endpoint.gateway_owner.clone(); - if gateway_owner.is_empty() { - panic!("The owner of the gateway is unknown - did you run `get_gateway()`?") - } - let gateway_address = self.config.gateway_endpoint.gateway_listener.clone(); - if gateway_address.is_empty() { - panic!("The address of the gateway is unknown - did you run `get_gateway()`?") - } - - let gateway_identity = identity::PublicKey::from_base58_string(gateway_id) - .expect("provided gateway id is invalid!"); - - let mut gateway_client = GatewayClient::new( - gateway_address, - self.key_manager.identity_keypair(), - gateway_identity, - gateway_owner, - None, - mixnet_message_sender, - ack_sender, - self.config.debug.gateway_response_timeout, - None, - ); - - gateway_client.set_disabled_credentials_mode(self.config.disabled_credentials_mode); - - let shared_keys = gateway_client - .authenticate_and_start() - .await - .expect("could not authenticate and start up the gateway connection"); - self.key_manager.insert_gateway_shared_key(shared_keys); - - match self.on_gateway_connect.as_ref() { - Some(callback) => { - callback - .call0(&JsValue::null()) - .expect("on connect callback failed!"); - } - None => console_log!("Gateway connection established - no callback specified"), - }; - - gateway_client - } - - // future responsible for periodically polling directory server and updating - // the current global view of topology - async fn start_topology_refresher(&mut self, topology_accessor: TopologyAccessor) { - let topology_refresher_config = TopologyRefresherConfig::new( - vec![self.config.validator_api_url.clone()], - self.config.debug.topology_refresh_rate, - env!("CARGO_PKG_VERSION").to_string(), - ); - let mut topology_refresher = - TopologyRefresher::new(topology_refresher_config, topology_accessor); - // before returning, block entire runtime to refresh the current network view so that any - // components depending on topology would see a non-empty view - console_log!("Obtaining initial network topology"); - topology_refresher.refresh().await; - - // TODO: a slightly more graceful termination here - if !topology_refresher.is_topology_routable().await { - panic!( - "The current network topology seem to be insufficient to route any packets through\ - - check if enough nodes and a gateway are online" - ); - } - - console_log!("Starting topology refresher..."); - - // TODO: re-enable - topology_refresher.start(); - } - - // controller for sending sphinx packets to mixnet (either real traffic or cover traffic) - // TODO: if we want to send control messages to gateway_client, this CAN'T take the ownership - // over it. Perhaps GatewayClient needs to be thread-shareable or have some channel for - // requests? - fn start_mix_traffic_controller(gateway_client: GatewayClient) -> BatchMixMessageSender { - console_log!("Starting mix traffic controller..."); - let (mix_traffic_controller, mix_tx) = MixTrafficController::new(gateway_client); - mix_traffic_controller.start(); - mix_tx - } - - // TODO: this procedure is extremely overcomplicated, because it's based off native client's behaviour - // which doesn't fully apply in this case - fn start_reconstructed_pusher( - &mut self, - received_buffer_request_sender: ReceivedBufferRequestSender, - ) { - let on_message = self.on_message.take(); - let on_binary_message = self.on_binary_message.take(); - - spawn_local(async move { - let (reconstructed_sender, mut reconstructed_receiver) = mpsc::unbounded(); - - // tell the buffer to start sending stuff to us - received_buffer_request_sender - .unbounded_send(ReceivedBufferMessage::ReceiverAnnounce( - reconstructed_sender, - )) - .expect("the buffer request failed!"); - - let this = JsValue::null(); - - while let Some(reconstructed) = reconstructed_receiver.next().await { - for msg in reconstructed { - if let Some(ref callback_binary) = on_binary_message { - let arg1 = serde_wasm_bindgen::to_value(&msg.message).unwrap(); - callback_binary - .call1(&this, &arg1) - .expect("on binary message failed!"); - } - if let Some(ref callback) = on_message { - if msg.sender_tag.is_some() { - console_log!("the received message contained a sender_tag meaning it also contained some reply surbs, but we do not know how to handle them (yet)") - } - let stringified = String::from_utf8_lossy(&msg.message).into_owned(); - let arg1 = serde_wasm_bindgen::to_value(&stringified).unwrap(); - callback.call1(&this, &arg1).expect("on message failed!"); - } - } - } - }); - } - - pub async fn start(mut self) -> NymClient { - console_log!("Starting wasm client '{}'", self.config.id); - // channels for inter-component communication - // TODO: make the channels be internally created by the relevant components - // rather than creating them here, so say for example the buffer controller would create the request channels - // and would allow anyone to clone the sender channel - - // unwrapped_sphinx_sender is the transmitter of mixnet messages received from the gateway - // unwrapped_sphinx_receiver is the receiver for said messages - used by ReceivedMessagesBuffer - let (mixnet_messages_sender, mixnet_messages_receiver) = mpsc::unbounded(); - - // used for announcing connection or disconnection of a channel for pushing re-assembled messages to - let (received_buffer_request_sender, received_buffer_request_receiver) = mpsc::unbounded(); - - // channels responsible for controlling real messages - let (input_sender, input_receiver) = tokio::sync::mpsc::channel::(1); - - // channels responsible for controlling ack messages - let (ack_sender, ack_receiver) = mpsc::unbounded(); - let shared_topology_accessor = TopologyAccessor::new(); - - // channels responsible for dealing with reply-related fun - let (reply_controller_sender, reply_controller_receiver) = - reply_controller::new_control_channels(); - - // ===================== - // ===================== - // ======TEMPORARY====== - // ===================== - // ===================== - // TODO: lower the value and improve the reliability when it's low (because it should still work in that case) - // (it goes insane at 10,200) - let reply_storage = CombinedReplyStorage::new( - self.config.debug.minimum_reply_surb_storage_threshold, - self.config.debug.maximum_reply_surb_storage_threshold, - ); - - // Channel that the real traffix controller can listed to for closing connections. - // Currently unused in the wasm client. - let (_client_connection_tx, client_connection_rx) = mpsc::unbounded(); - - // the components are started in very specific order. Unless you know what you are doing, - // do not change that. - self.start_topology_refresher(shared_topology_accessor.clone()) - .await; - self.start_received_messages_buffer_controller( - received_buffer_request_receiver, - mixnet_messages_receiver, - reply_storage.key_storage(), - reply_controller_sender.clone(), - ); - - let gateway_client = self - .start_gateway_client(mixnet_messages_sender, ack_sender) - .await; - - // The sphinx_message_sender is the transmitter for any component generating sphinx packets - // that are to be sent to the mixnet. They are used by cover traffic stream and real - // traffic stream. - // The MixTrafficController then sends the actual traffic - let sphinx_message_sender = Self::start_mix_traffic_controller(gateway_client); - - // Shared queue length data. Published by the `OutQueueController` in the client, and used - // primarily to throttle incoming connections - let shared_lane_queue_lengths = LaneQueueLengths::new(); - - self.start_real_traffic_controller( - shared_topology_accessor.clone(), - ack_receiver, - input_receiver, - sphinx_message_sender.clone(), - reply_storage, - reply_controller_sender, - reply_controller_receiver, - shared_lane_queue_lengths, - client_connection_rx, - ); - - if !self.config.debug.disable_loop_cover_traffic_stream { - self.start_cover_traffic_stream(shared_topology_accessor, sphinx_message_sender); - } - - self.start_reconstructed_pusher(received_buffer_request_sender); - self.input_tx = Some(input_sender); - - self } // Right now it's impossible to have async exported functions to take `&mut self` rather than mut self @@ -427,13 +133,66 @@ impl NymClient { let input_msg = InputMessage::new_regular(recipient, message, lane); - self.input_tx + self.client_input .as_ref() .expect("start method was not called before!") + .input_sender .send(input_msg) .await .expect("InputMessageReceiver has stopped receiving!"); self } + + fn start_reconstructed_pusher( + client_output: ClientOutput, + on_message: Option, + on_binary_message: Option, + ) { + ResponsePusher::new(client_output, on_message, on_binary_message).start() + } + + pub async fn start(mut self) -> NymClient { + console_log!("Starting the wasm client"); + + let base_builder = BaseClientBuilder::new( + &self.config.gateway_endpoint, + &self.config.debug, + self.key_manager.take().unwrap(), + None, + self.storage_backend.take().unwrap(), + true, + vec![self.config.validator_api_url.clone()], + ); + + self.self_address = Some(base_builder.as_mix_recipient().to_string()); + let mut started_client = match base_builder.start_base().await { + Ok(base_client) => base_client, + Err(err) => { + console_error!("failed to start base client components - {}", err); + // proper error handling is left here as an exercise for the reader (hi Mark : )) + panic!("failed to start base client components - {err}") + } + }; + match self.on_gateway_connect.as_ref() { + Some(callback) => { + callback + .call0(&JsValue::null()) + .expect("on connect callback failed!"); + } + None => console_log!("Gateway connection established - no callback specified"), + }; + + // those should be moved to a completely different struct, but I don't want to break compatibility for now + let client_input = started_client.client_input.register_producer(); + let client_output = started_client.client_output.register_consumer(); + + let on_message = self.on_message.take(); + let on_binary_message = self.on_binary_message.take(); + Self::start_reconstructed_pusher(client_output, on_message, on_binary_message); + self.client_input = Some(client_input); + self._shutdown = Some(started_client.shutdown_notifier); + + self + } } diff --git a/clients/webassembly/src/client/response_pusher.rs b/clients/webassembly/src/client/response_pusher.rs new file mode 100644 index 0000000000..26d77482db --- /dev/null +++ b/clients/webassembly/src/client/response_pusher.rs @@ -0,0 +1,71 @@ +// Copyright 2022 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use client_core::client::base_client::ClientOutput; +use client_core::client::received_buffer::{ReceivedBufferMessage, ReconstructedMessagesReceiver}; +use futures::channel::mpsc; +use futures::StreamExt; +use wasm_bindgen::JsValue; +use wasm_bindgen_futures::spawn_local; +use wasm_utils::console_log; + +pub(crate) struct ResponsePusher { + reconstructed_receiver: ReconstructedMessagesReceiver, + on_message: Option, + on_binary_message: Option, +} + +impl ResponsePusher { + pub(crate) fn new( + client_output: ClientOutput, + on_message: Option, + on_binary_message: Option, + ) -> Self { + if on_message.is_none() && on_binary_message.is_none() { + // exercise for the reader : ) + panic!("neither 'on_message' nor 'on_binary_message' was set!") + } + + // register our output + let (reconstructed_sender, reconstructed_receiver) = mpsc::unbounded(); + + // tell the buffer to start sending stuff to us + client_output + .received_buffer_request_sender + .unbounded_send(ReceivedBufferMessage::ReceiverAnnounce( + reconstructed_sender, + )) + .expect("the buffer request failed!"); + + ResponsePusher { + reconstructed_receiver, + on_message, + on_binary_message, + } + } + + pub(crate) fn start(mut self) { + spawn_local(async move { + let this = JsValue::null(); + + while let Some(reconstructed) = self.reconstructed_receiver.next().await { + for msg in reconstructed { + if let Some(ref callback_binary) = self.on_binary_message { + let arg1 = serde_wasm_bindgen::to_value(&msg.message).unwrap(); + callback_binary + .call1(&this, &arg1) + .expect("on binary message failed!"); + } + if let Some(ref callback) = self.on_message { + if msg.sender_tag.is_some() { + console_log!("the received message contained a sender tag (meaning we also got some surbs!), but we do not know how to handle that (yet)") + } + let stringified = String::from_utf8_lossy(&msg.message).into_owned(); + let arg1 = serde_wasm_bindgen::to_value(&stringified).unwrap(); + callback.call1(&this, &arg1).expect("on message failed!"); + } + } + } + }) + } +} diff --git a/clients/webassembly/src/gateway_selector.rs b/clients/webassembly/src/gateway_selector.rs index 7e15252b8a..77f5a5c6f7 100644 --- a/clients/webassembly/src/gateway_selector.rs +++ b/clients/webassembly/src/gateway_selector.rs @@ -1,15 +1,15 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use client_core::config::GatewayEndpoint; +use client_core::config::GatewayEndpointConfig; use wasm_bindgen::prelude::*; #[wasm_bindgen] -pub async fn get_gateway(api_server: String, preferred: Option) -> GatewayEndpoint { +pub async fn get_gateway(api_server: String, preferred: Option) -> GatewayEndpointConfig { let validator_client = validator_client::client::ApiClient::new(api_server.parse().unwrap()); let gateways = match validator_client.get_cached_gateways().await { - Err(err) => panic!("failed to obtain list of all gateways - {}", err), + Err(err) => panic!("failed to obtain list of all gateways - {err}"), Ok(gateways) => gateways, }; @@ -18,7 +18,7 @@ pub async fn get_gateway(api_server: String, preferred: Option) -> Gatew .iter() .find(|g| g.gateway.identity_key == preferred) { - return GatewayEndpoint { + return GatewayEndpointConfig { gateway_id: details.gateway.identity_key.clone(), gateway_owner: details.owner.to_string(), gateway_listener: format!( @@ -33,7 +33,7 @@ pub async fn get_gateway(api_server: String, preferred: Option) -> Gatew .first() .expect("current topology holds no gateways"); - GatewayEndpoint { + GatewayEndpointConfig { gateway_id: details.gateway.identity_key.clone(), gateway_owner: details.owner.to_string(), gateway_listener: format!( diff --git a/common/client-libs/gateway-client/Cargo.toml b/common/client-libs/gateway-client/Cargo.toml index 01557331fa..d1ea2abf1e 100644 --- a/common/client-libs/gateway-client/Cargo.toml +++ b/common/client-libs/gateway-client/Cargo.toml @@ -26,6 +26,7 @@ network-defaults = { path = "../../network-defaults" } nymsphinx = { path = "../../nymsphinx" } pemstore = { path = "../../pemstore" } validator-client = { path = "../validator-client", optional = true } +task = { path = "../../task" } [dependencies.tungstenite] @@ -47,9 +48,6 @@ version = "0.14" [target."cfg(not(target_arch = \"wasm32\"))".dependencies.credential-storage] path = "../../credential-storage" -[target."cfg(not(target_arch = \"wasm32\"))".dependencies.task] -path = "../../task" - # wasm-only dependencies [target."cfg(target_arch = \"wasm32\")".dependencies.wasm-bindgen] version = "0.2" diff --git a/common/client-libs/gateway-client/src/bandwidth.rs b/common/client-libs/gateway-client/src/bandwidth.rs index 6f3b7722dc..00db2b1180 100644 --- a/common/client-libs/gateway-client/src/bandwidth.rs +++ b/common/client-libs/gateway-client/src/bandwidth.rs @@ -26,8 +26,15 @@ use { }, }; +// TODO: make it nicer for wasm (I don't want to touch it for this experiment) +#[cfg(target_arch = "wasm32")] +use crate::wasm_storage::PersistentStorage; + +#[cfg(not(target_arch = "wasm32"))] +use credential_storage::PersistentStorage; + #[derive(Clone)] -pub struct BandwidthController { +pub struct BandwidthController { #[allow(dead_code)] storage: St, #[cfg(feature = "coconut")] diff --git a/common/client-libs/gateway-client/src/client.rs b/common/client-libs/gateway-client/src/client.rs index bac92ec092..3bd5bbf19b 100644 --- a/common/client-libs/gateway-client/src/client.rs +++ b/common/client-libs/gateway-client/src/client.rs @@ -10,11 +10,11 @@ pub use crate::packet_router::{ use crate::socket_state::{PartiallyDelegated, SocketState}; use crate::{cleanup_socket_message, try_decrypt_binary_message}; use crypto::asymmetric::identity; -use futures::{FutureExt, SinkExt, StreamExt}; +use futures::{SinkExt, StreamExt}; use gateway_requests::authentication::encrypted_address::EncryptedAddressBytes; use gateway_requests::iv::IV; use gateway_requests::registration::handshake::{client_handshake, SharedKeys}; -use gateway_requests::{BinaryRequest, ClientControlRequest, ServerResponse}; +use gateway_requests::{BinaryRequest, ClientControlRequest, ServerResponse, PROTOCOL_VERSION}; use log::*; use network_defaults::{REMAINING_BANDWIDTH_THRESHOLD, TOKENS_TO_BURN}; use nymsphinx::forwarding::packet::MixPacket; @@ -22,6 +22,7 @@ use rand::rngs::OsRng; use std::convert::TryFrom; use std::sync::Arc; use std::time::Duration; +use task::ShutdownListener; use tungstenite::protocol::Message; #[cfg(feature = "coconut")] @@ -30,8 +31,6 @@ use coconut_interface::Credential; #[cfg(not(target_arch = "wasm32"))] use credential_storage::PersistentStorage; #[cfg(not(target_arch = "wasm32"))] -use task::ShutdownListener; -#[cfg(not(target_arch = "wasm32"))] use tokio_tungstenite::connect_async; #[cfg(target_arch = "wasm32")] @@ -67,9 +66,8 @@ pub struct GatewayClient { /// Delay between each subsequent reconnection attempt. reconnection_backoff: Duration, - #[cfg(not(target_arch = "wasm32"))] /// Listen to shutdown messages. - shutdown: Option, + shutdown: ShutdownListener, } impl GatewayClient { @@ -85,7 +83,7 @@ impl GatewayClient { ack_sender: AcknowledgementSender, response_timeout_duration: Duration, bandwidth_controller: Option>, - #[cfg(not(target_arch = "wasm32"))] shutdown: Option, + shutdown: ShutdownListener, ) -> Self { GatewayClient { authenticated: false, @@ -97,18 +95,12 @@ impl GatewayClient { local_identity, shared_key, connection: SocketState::NotConnected, - packet_router: PacketRouter::new( - ack_sender, - mixnet_message_sender, - #[cfg(not(target_arch = "wasm32"))] - shutdown.clone(), - ), + packet_router: PacketRouter::new(ack_sender, mixnet_message_sender, shutdown.clone()), response_timeout_duration, bandwidth_controller, should_reconnect_on_failure: true, reconnection_attempts: DEFAULT_RECONNECTION_ATTEMPTS, reconnection_backoff: DEFAULT_RECONNECTION_BACKOFF, - #[cfg(not(target_arch = "wasm32"))] shutdown, } } @@ -136,7 +128,6 @@ impl GatewayClient { gateway_owner: String, local_identity: Arc, response_timeout_duration: Duration, - #[cfg(not(target_arch = "wasm32"))] shutdown: Option, ) -> Self { use futures::channel::mpsc; @@ -144,12 +135,8 @@ impl GatewayClient { // perfectly fine here, because it's not meant to be used let (ack_tx, _) = mpsc::unbounded(); let (mix_tx, _) = mpsc::unbounded(); - let packet_router = PacketRouter::new( - ack_tx, - mix_tx, - #[cfg(not(target_arch = "wasm32"))] - shutdown.clone(), - ); + let shutdown = ShutdownListener::dummy(); + let packet_router = PacketRouter::new(ack_tx, mix_tx, shutdown.clone()); GatewayClient { authenticated: false, @@ -167,7 +154,6 @@ impl GatewayClient { should_reconnect_on_failure: false, reconnection_attempts: DEFAULT_RECONNECTION_ATTEMPTS, reconnection_backoff: DEFAULT_RECONNECTION_BACKOFF, - #[cfg(not(target_arch = "wasm32"))] shutdown, } } @@ -295,42 +281,19 @@ impl GatewayClient { // technically the `wasm_timer` also works outside wasm, but unless required, // I really prefer to just stick to tokio #[cfg(target_arch = "wasm32")] - let timeout = wasm_timer::Delay::new(self.response_timeout_duration); - - let mut fused_timeout = timeout.fuse(); - let mut fused_stream = conn.fuse(); - - // Bit of an ugly workaround for selecting on an `Option` without having access to - // `tokio::select` - #[cfg(not(target_arch = "wasm32"))] - let shutdown = { - let m_shutdown = self.shutdown.clone(); - async { - if let Some(mut s) = m_shutdown { - s.recv().await - } else { - std::future::pending::<()>().await - } - } - .fuse() - }; - #[cfg(not(target_arch = "wasm32"))] - tokio::pin!(shutdown); - - #[cfg(target_arch = "wasm32")] - let mut shutdown = std::future::pending::<()>().fuse(); + let mut timeout = wasm_timer::Delay::new(self.response_timeout_duration); loop { - futures::select! { - _ = shutdown => { + tokio::select! { + _ = self.shutdown.recv() => { log::trace!("GatewayClient control response: Received shutdown"); log::debug!("GatewayClient control response: Exiting"); break Err(GatewayClientError::ConnectionClosedGatewayShutdown); } - _ = &mut fused_timeout => { + _ = &mut timeout => { break Err(GatewayClientError::Timeout); } - msg = fused_stream.next() => { + msg = conn.next() => { let ws_msg = match cleanup_socket_message(msg) { Err(err) => break Err(err), Ok(msg) => msg @@ -401,13 +364,10 @@ impl GatewayClient { .batch_send_without_response(messages) .await { - error!("failed to batch send messages - {}...", err); + error!("failed to batch send messages - {err}..."); // we must ensure we do not leave the task still active if let Err(err) = self.recover_socket_connection().await { - error!( - "... and the delegated stream has also errored out - {}", - err - ) + error!("... and the delegated stream has also errored out - {err}") } Err(err) } else { @@ -427,13 +387,10 @@ impl GatewayClient { SocketState::Available(ref mut conn) => Ok(conn.send(msg).await?), SocketState::PartiallyDelegated(ref mut partially_delegated) => { if let Err(err) = partially_delegated.send_without_response(msg).await { - error!("failed to send message without response - {}...", err); + error!("failed to send message without response - {err}..."); // we must ensure we do not leave the task still active if let Err(err) = self.recover_socket_connection().await { - error!( - "... and the delegated stream has also errored out - {}", - err - ) + error!("... and the delegated stream has also errored out - {err}") } Err(err) } else { @@ -445,6 +402,27 @@ impl GatewayClient { } } + fn check_gateway_protocol( + &self, + gateway_protocol: Option, + ) -> Result<(), GatewayClientError> { + // right now there are no failure cases here, but this might change in the future + match gateway_protocol { + Some(v) if v == PROTOCOL_VERSION => { + info!("the gateway is using exactly the same protocol version as we are. We're good to continue!"); + Ok(()) + } + v => { + let err = GatewayClientError::IncompatibleProtocol { + gateway: v, + current: PROTOCOL_VERSION, + }; + error!("{err}"); + Err(err) + } + } + } + async fn register(&mut self) -> Result<(), GatewayClientError> { if !self.connection.is_established() { return Err(GatewayClientError::ConnectionNotEstablished); @@ -467,11 +445,20 @@ impl GatewayClient { .map_err(GatewayClientError::RegistrationFailure), _ => unreachable!(), }?; - self.authenticated = match self.read_control_response().await? { - ServerResponse::Register { status } => Ok(status), - ServerResponse::Error { message } => Err(GatewayClientError::GatewayError(message)), - _ => Err(GatewayClientError::UnexpectedResponse), - }?; + let (authentication_status, gateway_protocol) = match self.read_control_response().await? { + ServerResponse::Register { + protocol_version, + status, + } => (status, protocol_version), + ServerResponse::Error { message } => { + return Err(GatewayClientError::GatewayError(message)) + } + _ => return Err(GatewayClientError::UnexpectedResponse), + }; + + self.check_gateway_protocol(gateway_protocol)?; + self.authenticated = authentication_status; + if self.authenticated { self.shared_key = Some(Arc::new(shared_key)); } @@ -510,9 +497,11 @@ impl GatewayClient { match self.send_websocket_message(msg).await? { ServerResponse::Authenticate { + protocol_version, status, bandwidth_remaining, } => { + self.check_gateway_protocol(protocol_version)?; self.authenticated = status; self.bandwidth_remaining = bandwidth_remaining; Ok(()) @@ -748,7 +737,6 @@ impl GatewayClient { .as_ref() .expect("no shared key present even though we're authenticated!"), ), - #[cfg(not(target_arch = "wasm32"))] self.shutdown.clone(), ) } diff --git a/common/client-libs/gateway-client/src/error.rs b/common/client-libs/gateway-client/src/error.rs index bb6f71f564..8a8104658b 100644 --- a/common/client-libs/gateway-client/src/error.rs +++ b/common/client-libs/gateway-client/src/error.rs @@ -85,6 +85,9 @@ pub enum GatewayClientError { #[error("Failed to send mixnet message")] MixnetMsgSenderFailedToSend, + + #[error("Attempted to negotiate connection with gateway using incompatible protocol version. Ours is {current} and the gateway reports {gateway:?}")] + IncompatibleProtocol { gateway: Option, current: u8 }, } impl GatewayClientError { diff --git a/common/client-libs/gateway-client/src/packet_router.rs b/common/client-libs/gateway-client/src/packet_router.rs index 6732d2202e..fd63077ee6 100644 --- a/common/client-libs/gateway-client/src/packet_router.rs +++ b/common/client-libs/gateway-client/src/packet_router.rs @@ -4,15 +4,13 @@ // JS: I personally don't like this name very much, but could not think of anything better. // I will gladly take any suggestions on how to rename this. +use crate::error::GatewayClientError; use futures::channel::mpsc; use log::*; use nymsphinx::addressing::nodes::MAX_NODE_ADDRESS_UNPADDED_LEN; use nymsphinx::params::packet_sizes::PacketSize; -#[cfg(not(target_arch = "wasm32"))] use task::ShutdownListener; -use crate::error::GatewayClientError; - pub type MixnetMessageSender = mpsc::UnboundedSender>>; pub type MixnetMessageReceiver = mpsc::UnboundedReceiver>>; @@ -23,20 +21,18 @@ pub type AcknowledgementReceiver = mpsc::UnboundedReceiver>>; pub struct PacketRouter { ack_sender: AcknowledgementSender, mixnet_message_sender: MixnetMessageSender, - #[cfg(not(target_arch = "wasm32"))] - shutdown: Option, + shutdown: ShutdownListener, } impl PacketRouter { pub fn new( ack_sender: AcknowledgementSender, mixnet_message_sender: MixnetMessageSender, - #[cfg(not(target_arch = "wasm32"))] shutdown: Option, + shutdown: ShutdownListener, ) -> Self { PacketRouter { ack_sender, mixnet_message_sender, - #[cfg(not(target_arch = "wasm32"))] shutdown, } } @@ -86,13 +82,10 @@ impl PacketRouter { if !received_messages.is_empty() { trace!("routing 'real'"); if let Err(err) = self.mixnet_message_sender.unbounded_send(received_messages) { - #[cfg(not(target_arch = "wasm32"))] - if let Some(shutdown) = &mut self.shutdown { - if shutdown.is_shutdown_poll() { - // This should ideally not happen, but it's ok - log::warn!("Failed to send mixnet message due to receiver task shutdown"); - return Err(GatewayClientError::MixnetMsgSenderFailedToSend); - } + if self.shutdown.is_shutdown_poll() || self.shutdown.is_dummy() { + // This should ideally not happen, but it's ok + log::warn!("Failed to send mixnet message due to receiver task shutdown"); + return Err(GatewayClientError::MixnetMsgSenderFailedToSend); } // This should never happen during ordinary operation the way it's currently used. // Abort to be on the safe side diff --git a/common/client-libs/gateway-client/src/socket_state.rs b/common/client-libs/gateway-client/src/socket_state.rs index 2d66e782e8..aa96b9a4fa 100644 --- a/common/client-libs/gateway-client/src/socket_state.rs +++ b/common/client-libs/gateway-client/src/socket_state.rs @@ -10,7 +10,6 @@ use futures::{SinkExt, StreamExt}; use gateway_requests::registration::handshake::SharedKeys; use log::*; use std::sync::Arc; -#[cfg(not(target_arch = "wasm32"))] use task::ShutdownListener; use tungstenite::Message; @@ -85,7 +84,7 @@ impl PartiallyDelegated { conn: WsConn, packet_router: PacketRouter, shared_key: Arc, - #[cfg(not(target_arch = "wasm32"))] shutdown: Option, + mut shutdown: ShutdownListener, ) -> Self { // when called for, it NEEDS TO yield back the stream so that we could merge it and // read control request responses. @@ -99,27 +98,9 @@ impl PartiallyDelegated { let mut chunk_stream = (&mut stream).ready_chunks(8); let mut packet_router = packet_router; - // Bit of an ugly workaround for selecting on an `Option` without having access to - // `tokio::select` - #[cfg(not(target_arch = "wasm32"))] - let shutdown = { - async { - if let Some(mut s) = shutdown { - s.recv().await - } else { - std::future::pending::<()>().await - } - } - }; - #[cfg(not(target_arch = "wasm32"))] - tokio::pin!(shutdown); - - #[cfg(target_arch = "wasm32")] - let mut shutdown = std::future::pending::<()>(); - let ret_err = loop { tokio::select! { - _ = &mut shutdown => { + _ = shutdown.recv() => { log::trace!("GatewayClient listener: Received shutdown"); log::debug!("GatewayClient listener: Exiting"); return; @@ -142,7 +123,10 @@ impl PartiallyDelegated { if match ret_err { Err(err) => stream_sender.send(Err(err)), - Ok(_) => stream_sender.send(Ok(stream)), + Ok(_) => { + shutdown.mark_as_success(); + stream_sender.send(Ok(stream)) + } } .is_err() { diff --git a/common/client-libs/validator-client/src/nymd/traits/mixnet_signing_client.rs b/common/client-libs/validator-client/src/nymd/traits/mixnet_signing_client.rs index 41fa7818a8..ad7773305a 100644 --- a/common/client-libs/validator-client/src/nymd/traits/mixnet_signing_client.rs +++ b/common/client-libs/validator-client/src/nymd/traits/mixnet_signing_client.rs @@ -180,6 +180,35 @@ pub trait MixnetSigningClient { .await } + async fn pledge_more( + &self, + additional_pledge: Coin, + fee: Option, + ) -> Result { + self.execute_mixnet_contract( + fee, + MixnetExecuteMsg::PledgeMore {}, + vec![additional_pledge], + ) + .await + } + + async fn pledge_more_on_behalf( + &self, + owner: AccountId, + additional_pledge: Coin, + fee: Option, + ) -> Result { + self.execute_mixnet_contract( + fee, + MixnetExecuteMsg::PledgeMoreOnBehalf { + owner: owner.to_string(), + }, + vec![additional_pledge], + ) + .await + } + async fn unbond_mixnode(&self, fee: Option) -> Result { self.execute_mixnet_contract(fee, MixnetExecuteMsg::UnbondMixnode {}, vec![]) .await diff --git a/common/client-libs/validator-client/src/nymd/traits/vesting_signing_client.rs b/common/client-libs/validator-client/src/nymd/traits/vesting_signing_client.rs index eebf0adfa3..120e9ca2e0 100644 --- a/common/client-libs/validator-client/src/nymd/traits/vesting_signing_client.rs +++ b/common/client-libs/validator-client/src/nymd/traits/vesting_signing_client.rs @@ -64,6 +64,21 @@ pub trait VestingSigningClient { fee: Option, ) -> Result; + async fn vesting_pledge_more( + &self, + additional_pledge: Coin, + fee: Option, + ) -> Result { + self.execute_vesting_contract( + fee, + VestingExecuteMsg::PledgeMore { + amount: additional_pledge.into(), + }, + vec![], + ) + .await + } + async fn vesting_unbond_mixnode(&self, fee: Option) -> Result; async fn vesting_track_unbond_mixnode( diff --git a/common/client-libs/validator-client/src/validator_api/mod.rs b/common/client-libs/validator-client/src/validator_api/mod.rs index 4a7b7af3cd..3cb7ae2ce9 100644 --- a/common/client-libs/validator-client/src/validator_api/mod.rs +++ b/common/client-libs/validator-client/src/validator_api/mod.rs @@ -136,7 +136,12 @@ impl Client { &self, ) -> Result, ValidatorAPIError> { self.query_validator_api( - &[routes::API_VERSION, routes::MIXNODES, routes::DETAILED], + &[ + routes::API_VERSION, + routes::STATUS, + routes::MIXNODES, + routes::DETAILED, + ], NO_PARAMS, ) .await @@ -161,6 +166,7 @@ impl Client { self.query_validator_api( &[ routes::API_VERSION, + routes::STATUS, routes::MIXNODES, routes::ACTIVE, routes::DETAILED, @@ -252,6 +258,7 @@ impl Client { self.query_validator_api( &[ routes::API_VERSION, + routes::STATUS, routes::MIXNODES, routes::REWARDED, routes::DETAILED, diff --git a/common/commands/Cargo.toml b/common/commands/Cargo.toml index 35b38512fa..ce0195cce4 100644 --- a/common/commands/Cargo.toml +++ b/common/commands/Cargo.toml @@ -22,6 +22,7 @@ thiserror = "1" time = { version = "0.3.6", features = ["parsing", "formatting"] } toml = "0.5.6" url = "2.2" +tap = "1" cosmrs = { git = "https://github.com/neacsu/cosmos-rust", branch = "neacsu/feegrant_support" } cosmwasm-std = { version = "1.0.0" } diff --git a/common/commands/src/context/errors.rs b/common/commands/src/context/errors.rs index 5c3d24a2bb..44a99f49ed 100644 --- a/common/commands/src/context/errors.rs +++ b/common/commands/src/context/errors.rs @@ -15,4 +15,10 @@ pub enum ContextError { // TODO: improve this to return known errors #[error("failed to create client - {0}")] NymdError(String), + + #[error("{0}")] + NymdErrorPassthrough(#[from] validator_client::nymd::error::NymdError), + + #[error("{0}")] + ValidatorClientError(#[from] validator_client::ValidatorClientError), } diff --git a/common/commands/src/context/mod.rs b/common/commands/src/context/mod.rs index 856a03e62a..4f55aa1847 100644 --- a/common/commands/src/context/mod.rs +++ b/common/commands/src/context/mod.rs @@ -6,6 +6,7 @@ use network_defaults::{ var_names::{API_VALIDATOR, MIXNET_CONTRACT_ADDRESS, NYMD_VALIDATOR, VESTING_CONTRACT_ADDRESS}, NymNetworkDetails, }; +use tap::prelude::*; use validator_client::nymd::{self, AccountId, NymdClient, QueryNymdClient, SigningNymdClient}; pub use validator_client::validator_api::Client as ValidatorApiClient; @@ -58,7 +59,7 @@ pub fn create_signing_client( network_details: &NymNetworkDetails, ) -> Result { let client_config = nymd::Config::try_from_nym_network_details(network_details) - .expect("failed to construct valid validator client config with the provided network"); + .tap_err(|e| log::error!("Failed to get client config - {:?}", e))?; // get mnemonic let mnemonic = match std::env::var("MNEMONIC") { @@ -87,7 +88,7 @@ pub fn create_query_client( network_details: &NymNetworkDetails, ) -> Result { let client_config = nymd::Config::try_from_nym_network_details(network_details) - .expect("failed to construct valid validator client config with the provided network"); + .tap_err(|e| log::error!("Failed to get client config - {:?}", e))?; let nymd_url = network_details .endpoints @@ -107,7 +108,7 @@ pub fn create_signing_client_with_validator_api( network_details: &NymNetworkDetails, ) -> Result { let client_config = validator_client::Config::try_from_nym_network_details(network_details) - .expect("failed to construct valid validator client config with the provided network"); + .tap_err(|e| log::error!("Failed to get client config - {:?}", e))?; // get mnemonic let mnemonic = match std::env::var("MNEMONIC") { @@ -129,7 +130,7 @@ pub fn create_query_client_with_validator_api( network_details: &NymNetworkDetails, ) -> Result { let client_config = validator_client::Config::try_from_nym_network_details(network_details) - .expect("failed to construct valid validator client config with the provided network"); + .tap_err(|e| log::error!("Failed to get client config - {:?}", e))?; match validator_client::client::Client::new_query(client_config) { Ok(client) => Ok(client), diff --git a/common/commands/src/validator/vesting/balance.rs b/common/commands/src/validator/vesting/balance.rs index 45b2f7866d..0dff560b9f 100644 --- a/common/commands/src/validator/vesting/balance.rs +++ b/common/commands/src/validator/vesting/balance.rs @@ -3,11 +3,11 @@ use clap::Parser; use cosmrs::AccountId; -use log::info; +use log::{error, info}; use validator_client::nymd::{Coin, VestingQueryClient}; -use crate::context::SigningClient; +use crate::context::QueryClient; use crate::utils::show_error; use crate::utils::{pretty_coin, pretty_cosmwasm_coin}; @@ -18,8 +18,16 @@ pub struct Args { pub address: Option, } -pub async fn balance(args: Args, client: SigningClient) { - let account_id = args.address.unwrap_or_else(|| client.address().clone()); +pub async fn balance(args: Args, client: QueryClient, address_from_mnemonic: Option) { + if args.address.is_none() && address_from_mnemonic.is_none() { + error!("Please specify an account address or a mnemonic to get the balance for"); + return; + } + + let account_id = args + .address + .unwrap_or_else(|| address_from_mnemonic.expect("please provide a mnemonic")); + let vesting_address = account_id.to_string(); let denom = client.current_chain_details().mix_denom.base.as_str(); diff --git a/common/commands/src/validator/vesting/query_vesting_schedule.rs b/common/commands/src/validator/vesting/query_vesting_schedule.rs index 23118c1bf5..10a4dc41d3 100644 --- a/common/commands/src/validator/vesting/query_vesting_schedule.rs +++ b/common/commands/src/validator/vesting/query_vesting_schedule.rs @@ -4,11 +4,11 @@ use clap::Parser; use cosmrs::AccountId; use cosmwasm_std::Coin as CosmWasmCoin; -use log::info; +use log::{error, info}; use validator_client::nymd::{Coin, VestingQueryClient}; -use crate::context::SigningClient; +use crate::context::QueryClient; use crate::utils::show_error; use crate::utils::{pretty_coin, pretty_cosmwasm_coin}; @@ -19,8 +19,18 @@ pub struct Args { pub address: Option, } -pub async fn query(args: Args, client: SigningClient) { - let account_id = args.address.unwrap_or_else(|| client.address().clone()); +pub async fn query(args: Args, client: QueryClient, address_from_mnemonic: Option) { + if args.address.is_none() && address_from_mnemonic.is_none() { + error!("Please specify an account address or a mnemonic to get the balance for"); + return; + } + + let account_id = args + .address + .unwrap_or_else(|| address_from_mnemonic.expect("please provide a mnemonic")); + + info!("Checking account {} for a vesting schedule...", account_id); + let vesting_address = account_id.to_string(); let denom = client.current_chain_details().mix_denom.base.as_str(); diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/events.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/events.rs index bba85e3ccd..f9f5de1802 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/events.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/events.rs @@ -12,6 +12,8 @@ pub const EVENT_VERSION_PREFIX: &str = "v2_"; pub enum MixnetEventType { MixnodeBonding, + PendingPledgeIncrease, + PledgeIncrease, GatewayBonding, GatewayUnbonding, PendingMixnodeUnbonding, @@ -51,6 +53,8 @@ impl ToString for MixnetEventType { fn to_string(&self) -> String { let event_name = match self { MixnetEventType::MixnodeBonding => "mixnode_bonding", + MixnetEventType::PendingPledgeIncrease => "pending_pledge_increase", + MixnetEventType::PledgeIncrease => "pledge_increase", MixnetEventType::GatewayBonding => "gateway_bonding", MixnetEventType::GatewayUnbonding => "gateway_unbonding", MixnetEventType::PendingMixnodeUnbonding => "pending_mixnode_unbonding", @@ -330,6 +334,19 @@ pub fn new_mixnode_bonding_event( .add_attribute(AMOUNT_KEY, amount.to_string()) } +pub fn new_pending_pledge_increase_event(mix_id: MixId, amount: &Coin) -> Event { + Event::new(MixnetEventType::PendingPledgeIncrease) + .add_attribute(MIX_ID_KEY, mix_id.to_string()) + .add_attribute(AMOUNT_KEY, amount.to_string()) +} + +pub fn new_pledge_increase_event(created_at: BlockHeight, mix_id: MixId, amount: &Coin) -> Event { + Event::new(MixnetEventType::PledgeIncrease) + .add_attribute(EVENT_CREATION_HEIGHT_KEY, created_at.to_string()) + .add_attribute(MIX_ID_KEY, mix_id.to_string()) + .add_attribute(AMOUNT_KEY, amount.to_string()) +} + pub fn new_mixnode_unbonding_event(created_at: BlockHeight, mix_id: MixId) -> Event { Event::new(MixnetEventType::MixnodeUnbonding) .add_attribute(EVENT_CREATION_HEIGHT_KEY, created_at.to_string()) diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/mixnode.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/mixnode.rs index 9bccf2d1ff..f8568e2c6e 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/mixnode.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/mixnode.rs @@ -325,6 +325,14 @@ impl MixNodeRewarding { Ok(()) } + pub fn increase_operator_uint128( + &mut self, + amount: Uint128, + ) -> Result<(), MixnetContractError> { + self.operator += amount.into_base_decimal()?; + Ok(()) + } + pub fn increase_delegates_uint128( &mut self, amount: Uint128, diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs index 1c00c64a9c..7bb3dd766c 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs @@ -113,6 +113,10 @@ pub enum ExecuteMsg { owner_signature: String, owner: String, }, + PledgeMore {}, + PledgeMoreOnBehalf { + owner: String, + }, UnbondMixnode {}, UnbondMixnodeOnBehalf { owner: String, @@ -223,6 +227,8 @@ impl ExecuteMsg { ExecuteMsg::BondMixnodeOnBehalf { mix_node, .. } => { format!("bonding mixnode {} on behalf", mix_node.identity_key) } + ExecuteMsg::PledgeMore {} => "pledging additional tokens".into(), + ExecuteMsg::PledgeMoreOnBehalf { .. } => "pledging additional tokens on behalf".into(), ExecuteMsg::UnbondMixnode { .. } => "unbonding mixnode".into(), ExecuteMsg::UnbondMixnodeOnBehalf { .. } => "unbonding mixnode on behalf".into(), ExecuteMsg::UpdateMixnodeCostParams { .. } => "updating mixnode cost parameters".into(), diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/pending_events.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/pending_events.rs index fce8bd8cf0..3fe817fcb6 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/pending_events.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/pending_events.rs @@ -34,6 +34,10 @@ pub enum PendingEpochEventKind { mix_id: MixId, proxy: Option, }, + PledgeMore { + mix_id: MixId, + amount: Coin, + }, UnbondMixnode { mix_id: MixId, }, @@ -78,7 +82,6 @@ pub enum PendingIntervalEventKind { mix_id: MixId, new_costs: MixNodeCostParams, }, - UpdateRewardingParams { update: IntervalRewardingParamsUpdate, }, diff --git a/common/cosmwasm-smart-contracts/vesting-contract/src/events.rs b/common/cosmwasm-smart-contracts/vesting-contract/src/events.rs index 896ead2c9e..4e5809beee 100644 --- a/common/cosmwasm-smart-contracts/vesting-contract/src/events.rs +++ b/common/cosmwasm-smart-contracts/vesting-contract/src/events.rs @@ -14,6 +14,7 @@ pub const VESTING_UNDELEGATION_EVENT_TYPE: &str = "vesting_undelegation"; pub const VESTING_GATEWAY_BONDING_EVENT_TYPE: &str = "vesting_gateway_bonding"; pub const VESTING_GATEWAY_UNBONDING_EVENT_TYPE: &str = "vesting_gateway_unbonding"; pub const VESTING_MIXNODE_BONDING_EVENT_TYPE: &str = "vesting_mixnode_bonding"; +pub const VESTING_PLEDGE_MORE_EVENT_TYPE: &str = "vesting_pledge_more"; pub const VESTING_MIXNODE_UNBONDING_EVENT_TYPE: &str = "vesting_mixnode_unbonding"; pub const VESTING_UPDATE_MIXNODE_CONFIG_EVENT_TYPE: &str = "vesting_update_mixnode_config"; pub const VESTING_UPDATE_MIXNODE_COST_PARAMS_EVENT_TYPE: &str = @@ -112,6 +113,10 @@ pub fn new_vesting_mixnode_bonding_event() -> Event { Event::new(VESTING_MIXNODE_BONDING_EVENT_TYPE) } +pub fn new_vesting_pledge_more_event() -> Event { + Event::new(VESTING_PLEDGE_MORE_EVENT_TYPE) +} + pub fn new_vesting_update_mixnode_config_event() -> Event { Event::new(VESTING_UPDATE_MIXNODE_CONFIG_EVENT_TYPE) } diff --git a/common/cosmwasm-smart-contracts/vesting-contract/src/messages.rs b/common/cosmwasm-smart-contracts/vesting-contract/src/messages.rs index 696df628aa..69eb323156 100644 --- a/common/cosmwasm-smart-contracts/vesting-contract/src/messages.rs +++ b/common/cosmwasm-smart-contracts/vesting-contract/src/messages.rs @@ -101,6 +101,9 @@ pub enum ExecuteMsg { owner_signature: String, amount: Coin, }, + PledgeMore { + amount: Coin, + }, UnbondMixnode {}, TrackUnbondMixnode { owner: String, @@ -145,6 +148,7 @@ impl ExecuteMsg { ExecuteMsg::WithdrawVestedCoins { .. } => "VestingExecuteMsg::WithdrawVestedCoins", ExecuteMsg::TrackUndelegation { .. } => "VestingExecuteMsg::TrackUndelegation", ExecuteMsg::BondMixnode { .. } => "VestingExecuteMsg::BondMixnode", + ExecuteMsg::PledgeMore { .. } => "VestingExecuteMsg::PledgeMore", ExecuteMsg::UnbondMixnode { .. } => "VestingExecuteMsg::UnbondMixnode", ExecuteMsg::TrackUnbondMixnode { .. } => "VestingExecuteMsg::TrackUnbondMixnode", ExecuteMsg::BondGateway { .. } => "VestingExecuteMsg::BondGateway", diff --git a/common/network-defaults/envs/mainnet.env b/common/network-defaults/envs/mainnet.env deleted file mode 100644 index dce1c3c9c0..0000000000 --- a/common/network-defaults/envs/mainnet.env +++ /dev/null @@ -1,20 +0,0 @@ -CONFIGURED=true - -RUST_LOG=info -RUST_BACKTRACE=1 - -BECH32_PREFIX=n -MIX_DENOM=unym -MIX_DENOM_DISPLAY=nym -STAKE_DENOM=unyx -STAKE_DENOM_DISPLAY=nyx -DENOMS_EXPONENT=6 -MIXNET_CONTRACT_ADDRESS=n14hj2tavq8fpesdwxxcu44rty3hh90vhujrvcmstl4zr3txmfvw9sjyvg3g -VESTING_CONTRACT_ADDRESS=n1nc5tatafv6eyq7llkr2gv50ff9e22mnf70qgjlv737ktmt4eswrq73f2nw -BANDWIDTH_CLAIM_CONTRACT_ADDRESS=n19lc9u84cz0yz3fww5283nucc9yvr8gsjmgeul0 -COCONUT_BANDWIDTH_CONTRACT_ADDRESS=n19lc9u84cz0yz3fww5283nucc9yvr8gsjmgeul0 -MULTISIG_CONTRACT_ADDRESS=n19lc9u84cz0yz3fww5283nucc9yvr8gsjmgeul0 -REWARDING_VALIDATOR_ADDRESS=n10yyd98e2tuwu0f7ypz9dy3hhjw7v772q6287gy -STATISTICS_SERVICE_DOMAIN_ADDRESS="http://127.0.0.1:8090" -NYMD_VALIDATOR="https://rpc.nyx.nodes.guru/" -API_VALIDATOR="https://validator.nymtech.net/api/" diff --git a/common/network-defaults/envs/qa.env b/common/network-defaults/envs/qa.env deleted file mode 100644 index 842fb54c4d..0000000000 --- a/common/network-defaults/envs/qa.env +++ /dev/null @@ -1,20 +0,0 @@ -CONFIGURED=true - -RUST_LOG=info -RUST_BACKTRACE=1 - -BECH32_PREFIX=n -MIX_DENOM=unym -MIX_DENOM_DISPLAY=nym -STAKE_DENOM=unyx -STAKE_DENOM_DISPLAY=nyx -DENOMS_EXPONENT=6 -MIXNET_CONTRACT_ADDRESS=n1suhgf5svhu4usrurvxzlgn54ksxmn8gljarjtxqnapv8kjnp4nrsd3qaep -VESTING_CONTRACT_ADDRESS=n1xr3rq8yvd7qplsw5yx90ftsr2zdhg4e9z60h5duusgxpv72hud3sjkxkav -BANDWIDTH_CLAIM_CONTRACT_ADDRESS=n19lc9u84cz0yz3fww5283nucc9yvr8gsjmgeul0 -COCONUT_BANDWIDTH_CONTRACT_ADDRESS=n1ghd753shjuwexxywmgs4xz7x2q732vcn7ty4yw -MULTISIG_CONTRACT_ADDRESS=n17p9rzwnnfxcjp32un9ug7yhhzgtkhvl988qccs -REWARDING_VALIDATOR_ADDRESS=n1tfzd4qz3a45u8p4mr5zmzv66457uwjgcl05jdq -STATISTICS_SERVICE_DOMAIN_ADDRESS="http://0.0.0.0" -NYMD_VALIDATOR="https://qa-validator.nymtech.net" -API_VALIDATOR="https://qa-validator-api.nymtech.net/api" diff --git a/common/network-defaults/src/mainnet.rs b/common/network-defaults/src/mainnet.rs index c2e16dee00..59242376f9 100644 --- a/common/network-defaults/src/mainnet.rs +++ b/common/network-defaults/src/mainnet.rs @@ -85,6 +85,10 @@ pub fn export_to_env() { var_names::MULTISIG_CONTRACT_ADDRESS, MULTISIG_CONTRACT_ADDRESS, ); + set_var_to_default( + var_names::COCONUT_DKG_CONTRACT_ADDRESS, + COCONUT_DKG_CONTRACT_ADDRESS, + ); set_var_to_default( var_names::REWARDING_VALIDATOR_ADDRESS, REWARDING_VALIDATOR_ADDRESS, @@ -125,6 +129,10 @@ pub fn export_to_env_if_not_set() { var_names::MULTISIG_CONTRACT_ADDRESS, MULTISIG_CONTRACT_ADDRESS, ); + set_var_conditionally_to_default( + var_names::COCONUT_DKG_CONTRACT_ADDRESS, + COCONUT_DKG_CONTRACT_ADDRESS, + ); set_var_conditionally_to_default( var_names::REWARDING_VALIDATOR_ADDRESS, REWARDING_VALIDATOR_ADDRESS, diff --git a/common/socks5/proxy-helpers/src/connection_controller.rs b/common/socks5/proxy-helpers/src/connection_controller.rs index 12fdba7dbb..97c4487af6 100644 --- a/common/socks5/proxy-helpers/src/connection_controller.rs +++ b/common/socks5/proxy-helpers/src/connection_controller.rs @@ -254,6 +254,10 @@ impl Controller { }, } } + #[cfg(not(target_arch = "wasm32"))] + tokio::time::timeout(Duration::from_secs(5), self.shutdown.recv()) + .await + .expect("Task stopped without shutdown called"); assert!(self.shutdown.is_shutdown_poll()); log::debug!("SOCKS5 Controller: Exiting"); } diff --git a/common/socks5/proxy-helpers/src/proxy_runner/inbound.rs b/common/socks5/proxy-helpers/src/proxy_runner/inbound.rs index 12caff2071..f2ebc2cd9b 100644 --- a/common/socks5/proxy-helpers/src/proxy_runner/inbound.rs +++ b/common/socks5/proxy-helpers/src/proxy_runner/inbound.rs @@ -208,5 +208,6 @@ where trace!("{} - inbound closed", connection_id); shutdown_notify.notify_one(); + shutdown_listener.mark_as_success(); reader } diff --git a/common/socks5/proxy-helpers/src/proxy_runner/mod.rs b/common/socks5/proxy-helpers/src/proxy_runner/mod.rs index fbd8cd21b3..defcbf4f5f 100644 --- a/common/socks5/proxy-helpers/src/proxy_runner/mod.rs +++ b/common/socks5/proxy-helpers/src/proxy_runner/mod.rs @@ -134,6 +134,7 @@ where } pub fn into_inner(mut self) -> (TcpStream, ConnectionReceiver) { + self.shutdown_listener.mark_as_success(); ( self.socket.take().unwrap(), self.mix_receiver.take().unwrap(), diff --git a/common/socks5/proxy-helpers/src/proxy_runner/outbound.rs b/common/socks5/proxy-helpers/src/proxy_runner/outbound.rs index 8fad665785..78db73afb6 100644 --- a/common/socks5/proxy-helpers/src/proxy_runner/outbound.rs +++ b/common/socks5/proxy-helpers/src/proxy_runner/outbound.rs @@ -90,5 +90,6 @@ pub(super) async fn run_outbound( trace!("{} - outbound closed", connection_id); shutdown_notify.notify_one(); + shutdown_listener.mark_as_success(); (writer, mix_receiver) } diff --git a/common/task/Cargo.toml b/common/task/Cargo.toml index 4c0c088d67..ee83423f5f 100644 --- a/common/task/Cargo.toml +++ b/common/task/Cargo.toml @@ -6,8 +6,20 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +futures = "0.3" log = "0.4" -tokio = { version = "1.21.2", features = ["macros", "signal", "time", "sync"] } +thiserror = "1.0.37" +tokio = { version = "1.21.2", features = ["macros", "sync"] } + +[target."cfg(not(target_arch = \"wasm32\"))".dependencies.tokio] +version = "1.21.2" +features = ["signal", "time"] + +[target."cfg(target_arch = \"wasm32\")".dependencies.wasm-bindgen-futures] +version = "0.4" + +[target."cfg(target_arch = \"wasm32\")".dependencies.wasm-bindgen] +version = "0.2.83" [dev-dependencies] tokio = { version = "1.21.2", features = ["rt-multi-thread", "net", "signal", "test-util", "macros"] } diff --git a/common/task/src/lib.rs b/common/task/src/lib.rs index b8ddb7076a..667936a830 100644 --- a/common/task/src/lib.rs +++ b/common/task/src/lib.rs @@ -2,7 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 pub mod shutdown; +#[cfg(not(target_arch = "wasm32"))] pub mod signal; +pub mod spawn; pub use shutdown::{ShutdownListener, ShutdownNotifier}; -pub use signal::wait_for_signal; +#[cfg(not(target_arch = "wasm32"))] +pub use signal::{wait_for_signal, wait_for_signal_and_error}; + +pub use spawn::spawn_with_report_error; diff --git a/common/task/src/shutdown.rs b/common/task/src/shutdown.rs index 21ea12569d..0f88135f9c 100644 --- a/common/task/src/shutdown.rs +++ b/common/task/src/shutdown.rs @@ -1,27 +1,63 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use std::time::Duration; +use std::{error::Error, time::Duration}; -use tokio::sync::watch::{self, error::SendError}; +use futures::{future::pending, FutureExt}; +use tokio::{ + sync::{ + mpsc, + watch::{self, error::SendError}, + }, + time::sleep, +}; const DEFAULT_SHUTDOWN_TIMER_SECS: u64 = 5; +pub(crate) type SentError = Box; +type ErrorSender = mpsc::UnboundedSender; +type ErrorReceiver = mpsc::UnboundedReceiver; + +#[derive(thiserror::Error, Debug)] +enum TaskError { + #[error("Task halted unexpectedly")] + UnexpectedHalt, +} + /// Used to notify other tasks to gracefully shutdown #[derive(Debug)] pub struct ShutdownNotifier { + // These channels have the dual purpose of signalling it's time to shutdown, but also to keep + // track of which tasks we are still waiting for. notify_tx: watch::Sender<()>, notify_rx: Option>, + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] shutdown_timer_secs: u64, + + // If any task failed, it needs to report separately + task_return_error_tx: ErrorSender, + task_return_error_rx: Option, + + // Also signal when the notifier is dropped, in case the task exits unexpectedly. + // Why are we not reusing the return error channel? Well, let me tell you kids, it's because I + // didn't manage to reliably get the explicitly sent error (and not the error sent during drop) + task_drop_tx: ErrorSender, + task_drop_rx: Option, } impl Default for ShutdownNotifier { fn default() -> Self { let (notify_tx, notify_rx) = watch::channel(()); + let (task_halt_tx, task_halt_rx) = mpsc::unbounded_channel(); + let (task_drop_tx, task_drop_rx) = mpsc::unbounded_channel(); Self { notify_tx, notify_rx: Some(notify_rx), shutdown_timer_secs: DEFAULT_SHUTDOWN_TIMER_SECS, + task_return_error_tx: task_halt_tx, + task_return_error_rx: Some(task_halt_rx), + task_drop_tx, + task_drop_rx: Some(task_drop_rx), } } } @@ -40,6 +76,8 @@ impl ShutdownNotifier { .as_ref() .expect("Unable to subscribe to shutdown notifier that is already shutdown") .clone(), + self.task_return_error_tx.clone(), + self.task_drop_tx.clone(), ) } @@ -47,16 +85,45 @@ impl ShutdownNotifier { self.notify_tx.send(()) } + pub async fn wait_for_error(&mut self) -> Option { + let mut error_rx = self + .task_return_error_rx + .take() + .expect("Unable to wait for error: attempt to wait twice?"); + let mut drop_rx = self + .task_drop_rx + .take() + .expect("Unable to wait for error: attempt to wait twice?"); + + // During an error we are likely like to be swamped with drop notifications as well, this + // is a crude way to give priority to real errors (if there are any). + let drop_rx = drop_rx.recv().then(|msg| async move { + sleep(Duration::from_millis(50)).await; + msg + }); + + tokio::select! { + msg = error_rx.recv() => msg, + msg = drop_rx => msg + } + } + pub async fn wait_for_shutdown(&mut self) { + log::info!("Waiting for shutdown"); if let Some(notify_rx) = self.notify_rx.take() { drop(notify_rx); } + // in wasm we'll never get our shutdown anyway... + #[cfg(target_arch = "wasm32")] + futures::future::pending::<()>().await; + + #[cfg(not(target_arch = "wasm32"))] tokio::select! { _ = self.notify_tx.closed() => { log::info!("All registered tasks succesfully shutdown"); }, - _ = tokio::signal::ctrl_c() => { + _ = tokio::signal::ctrl_c() => { log::info!("Forcing shutdown"); } _ = tokio::time::sleep(Duration::from_secs(self.shutdown_timer_secs)) => { @@ -69,23 +136,71 @@ impl ShutdownNotifier { /// Listen for shutdown notifications #[derive(Clone, Debug)] pub struct ShutdownListener { + // If a shutdown notification has been registered shutdown: bool, + + // Listen for shutdown notifications, as well as a mechanism to report back that we have + // finished (the receiver is closed). notify: watch::Receiver<()>, + + // Send back error if we stopped + return_error: ErrorSender, + + // Also notify if we dropped without shutdown being registered + drop_error: ErrorSender, + + // The current operating mode + mode: ShutdownListenerMode, } impl ShutdownListener { - fn new(notify: watch::Receiver<()>) -> ShutdownListener { + #[cfg(not(target_arch = "wasm32"))] + const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); + + fn new( + notify: watch::Receiver<()>, + return_error: ErrorSender, + drop_error: ErrorSender, + ) -> ShutdownListener { ShutdownListener { shutdown: false, notify, + return_error, + drop_error, + mode: ShutdownListenerMode::Listening, } } + // Create a dummy that will never report that we should shutdown. + pub fn dummy() -> ShutdownListener { + let (_notify_tx, notify_rx) = watch::channel(()); + let (task_halt_tx, _task_halt_rx) = mpsc::unbounded_channel(); + let (task_drop_tx, _task_drop_rx) = mpsc::unbounded_channel(); + ShutdownListener { + shutdown: false, + notify: notify_rx, + return_error: task_halt_tx, + drop_error: task_drop_tx, + mode: ShutdownListenerMode::Dummy, + } + } + + pub fn is_dummy(&self) -> bool { + self.mode.is_dummy() + } + pub fn is_shutdown(&self) -> bool { - self.shutdown + if self.mode.is_dummy() { + false + } else { + self.shutdown + } } pub async fn recv(&mut self) { + if self.mode.is_dummy() { + return pending().await; + } if self.shutdown { return; } @@ -93,7 +208,20 @@ impl ShutdownListener { self.shutdown = true; } + pub async fn recv_timeout(&mut self) { + if self.mode.is_dummy() { + return pending().await; + } + #[cfg(not(target_arch = "wasm32"))] + tokio::time::timeout(Self::SHUTDOWN_TIMEOUT, self.recv()) + .await + .expect("Task stopped without shutdown called"); + } + pub fn is_shutdown_poll(&mut self) -> bool { + if self.mode.is_dummy() { + return false; + } if self.shutdown { return true; } @@ -111,6 +239,69 @@ impl ShutdownListener { } } } + + // This listener should to *not* notify the ShutdownNotifier to shutdown when dropped. For + // example when we clone the listener for a task handling connections, we often want to drop + // without signal failure. + pub fn mark_as_success(&mut self) { + self.mode.set_should_not_signal_on_drop(); + } + + pub fn send_we_stopped(&mut self, err: SentError) { + if self.mode.is_dummy() { + return; + } + log::trace!("Notifying we stopped: {:?}", err); + if self.return_error.send(err).is_err() { + log::error!("Failed to send back error message"); + } + } +} + +impl Drop for ShutdownListener { + fn drop(&mut self) { + if !self.mode.should_signal_on_drop() { + return; + } + if !self.is_shutdown_poll() { + log::trace!("Notifying stop on unexpected drop"); + // If we can't send, well then there is not much to do + self.drop_error + .send(Box::new(TaskError::UnexpectedHalt)) + .ok(); + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum ShutdownListenerMode { + // Normal operations + Listening, + // Normal operations, but we don't report back if the we stop by getting dropped. + ListeningButDontReportHalt, + // Dummy mode, for when we don't do anything at all. + Dummy, +} + +impl ShutdownListenerMode { + fn is_dummy(&self) -> bool { + self == &ShutdownListenerMode::Dummy + } + + fn should_signal_on_drop(&self) -> bool { + match self { + ShutdownListenerMode::Listening => true, + ShutdownListenerMode::ListeningButDontReportHalt | ShutdownListenerMode::Dummy => false, + } + } + + fn set_should_not_signal_on_drop(&mut self) { + use ShutdownListenerMode::{Dummy, Listening, ListeningButDontReportHalt}; + *self = match &self { + ListeningButDontReportHalt | Listening => ListeningButDontReportHalt, + Dummy => Dummy, + }; + } } #[cfg(test)] diff --git a/common/task/src/signal.rs b/common/task/src/signal.rs index 40ca122848..a04a0f5601 100644 --- a/common/task/src/signal.rs +++ b/common/task/src/signal.rs @@ -1,3 +1,5 @@ +use crate::{shutdown::SentError, ShutdownNotifier}; + #[cfg(unix)] pub async fn wait_for_signal() { use tokio::signal::unix::{signal, SignalKind}; @@ -25,3 +27,44 @@ pub async fn wait_for_signal() { }, } } + +#[cfg(unix)] +pub async fn wait_for_signal_and_error(shutdown: &mut ShutdownNotifier) -> Result<(), SentError> { + use tokio::signal::unix::{signal, SignalKind}; + + let mut sigterm = signal(SignalKind::terminate()).expect("Failed to setup SIGTERM channel"); + let mut sigquit = signal(SignalKind::quit()).expect("Failed to setup SIGQUIT channel"); + + tokio::select! { + _ = tokio::signal::ctrl_c() => { + log::info!("Received SIGINT"); + Ok(()) + }, + _ = sigterm.recv() => { + log::info!("Received SIGTERM"); + Ok(()) + } + _ = sigquit.recv() => { + log::info!("Received SIGQUIT"); + Ok(()) + } + Some(msg) = shutdown.wait_for_error() => { + log::info!("Task error: {:?}", msg); + Err(msg) + } + } +} + +#[cfg(not(unix))] +pub async fn wait_for_signal_and_error(shutdown: &mut ShutdownNotifier) -> Result<(), SentError> { + tokio::select! { + _ = tokio::signal::ctrl_c() => { + log::info!("Received SIGINT"); + Ok(()) + }, + Some(msg) = shutdown.wait_for_error() => { + log::info!("Task error: {:?}", msg); + Err(msg) + } + } +} diff --git a/common/task/src/spawn.rs b/common/task/src/spawn.rs new file mode 100644 index 0000000000..1286abf6dc --- /dev/null +++ b/common/task/src/spawn.rs @@ -0,0 +1,33 @@ +use crate::ShutdownListener; +use std::future::Future; + +#[cfg(target_arch = "wasm32")] +pub(crate) fn spawn(future: F) +where + F: Future + 'static, +{ + wasm_bindgen_futures::spawn_local(future); +} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn spawn(future: F) +where + F: Future + Send + 'static, + F::Output: Send + 'static, +{ + tokio::spawn(future); +} + +pub fn spawn_with_report_error(future: F, mut shutdown: ShutdownListener) +where + F: Future> + Send + 'static, + T: 'static, + E: std::error::Error + Send + 'static, +{ + let future_that_sends = async move { + if let Err(err) = future.await { + shutdown.send_we_stopped(Box::new(err)); + } + }; + spawn(future_that_sends); +} diff --git a/common/topology/src/lib.rs b/common/topology/src/lib.rs index 4178ad9a32..8d3e0d5fe8 100644 --- a/common/topology/src/lib.rs +++ b/common/topology/src/lib.rs @@ -217,18 +217,14 @@ impl NymTopology { #[must_use] pub fn filter_system_version(&self, expected_version: &str) -> Self { - self.filter_node_versions(expected_version, expected_version) + self.filter_node_versions(expected_version) } #[must_use] - pub fn filter_node_versions( - &self, - expected_mix_version: &str, - expected_gateway_version: &str, - ) -> Self { + pub fn filter_node_versions(&self, expected_mix_version: &str) -> Self { NymTopology { mixes: self.mixes.filter_by_version(expected_mix_version), - gateways: self.gateways.filter_by_version(expected_gateway_version), + gateways: self.gateways.clone(), } } } diff --git a/common/types/src/pending_events.rs b/common/types/src/pending_events.rs index afe5a54d9e..e9d578dc12 100644 --- a/common/types/src/pending_events.rs +++ b/common/types/src/pending_events.rs @@ -57,6 +57,10 @@ pub enum PendingEpochEventData { mix_id: MixId, proxy: Option, }, + PledgeMore { + mix_id: MixId, + amount: DecCoin, + }, UnbondMixnode { mix_id: MixId, }, @@ -91,6 +95,12 @@ impl PendingEpochEventData { mix_id, proxy: proxy.map(|p| p.into_string()), }), + MixnetContractPendingEpochEventKind::PledgeMore { mix_id, amount } => { + Ok(PendingEpochEventData::PledgeMore { + mix_id, + amount: reg.attempt_convert_to_display_dec_coin(amount.into())?, + }) + } MixnetContractPendingEpochEventKind::UnbondMixnode { mix_id } => { Ok(PendingEpochEventData::UnbondMixnode { mix_id }) } diff --git a/contracts/CHANGELOG.md b/contracts/CHANGELOG.md index f57a633956..c192eab3ae 100644 --- a/contracts/CHANGELOG.md +++ b/contracts/CHANGELOG.md @@ -3,7 +3,9 @@ ## Added - Added migration code to the mixnet contract to allow updating stored vesting contract address to make it easier to deploy any future environments ([#1759],[#1769]) +- Added an option to pledge additional tokens without the need to rebond minxode ([#1679]) +[#1679]: https://github.com/nymtech/nym/pull/1679 [#1759]: https://github.com/nymtech/nym/pull/1759 [#1769]: https://github.com/nymtech/nym/pull/1769 diff --git a/contracts/mixnet/src/contract.rs b/contracts/mixnet/src/contract.rs index e07b7ba32a..63bf3f1a72 100644 --- a/contracts/mixnet/src/contract.rs +++ b/contracts/mixnet/src/contract.rs @@ -168,6 +168,12 @@ pub fn execute( owner, owner_signature, ), + ExecuteMsg::PledgeMore {} => { + crate::mixnodes::transactions::try_increase_pledge(deps, env, info) + } + ExecuteMsg::PledgeMoreOnBehalf { owner } => { + crate::mixnodes::transactions::try_increase_pledge_on_behalf(deps, env, info, owner) + } ExecuteMsg::UnbondMixnode {} => { crate::mixnodes::transactions::try_remove_mixnode(deps, env, info) } diff --git a/contracts/mixnet/src/interval/pending_events.rs b/contracts/mixnet/src/interval/pending_events.rs index 8cb09566f2..b242944988 100644 --- a/contracts/mixnet/src/interval/pending_events.rs +++ b/contracts/mixnet/src/interval/pending_events.rs @@ -7,13 +7,14 @@ use crate::interval::helpers::change_interval_config; use crate::interval::storage; use crate::mixnet_contract_settings::storage as mixnet_params_storage; use crate::mixnodes::helpers::{cleanup_post_unbond_mixnode_storage, get_mixnode_details_by_id}; +use crate::mixnodes::storage as mixnodes_storage; use crate::rewards::storage as rewards_storage; use crate::support::helpers::send_to_proxy_or_owner; use cosmwasm_std::{wasm_execute, Addr, Coin, DepsMut, Env, Response}; use mixnet_contract_common::error::MixnetContractError; use mixnet_contract_common::events::{ new_active_set_update_event, new_delegation_event, new_delegation_on_unbonded_node_event, - new_mixnode_cost_params_update_event, new_mixnode_unbonding_event, + new_mixnode_cost_params_update_event, new_mixnode_unbonding_event, new_pledge_increase_event, new_rewarding_params_update_event, new_undelegation_event, }; use mixnet_contract_common::mixnode::MixNodeCostParams; @@ -258,6 +259,42 @@ pub(crate) fn update_active_set_size( Ok(Response::new().add_event(new_active_set_update_event(created_at, active_set_size))) } +pub(crate) fn increase_pledge( + deps: DepsMut<'_>, + created_at: BlockHeight, + mix_id: MixId, + increase: Coin, +) -> Result { + // note: we have already validated the amount to know it has the correct denomination + + // the target node MUST exist - we have checked it at the time of putting this event onto the queue + // we have also verified there were no preceding unbond events + let mix_details = get_mixnode_details_by_id(deps.storage, mix_id)?.ok_or( + MixnetContractError::InconsistentState { + comment: + "mixnode getting processed to increase its pledge doesn't exist in the storage" + .into(), + }, + )?; + + let mut updated_bond = mix_details.bond_information.clone(); + let mut updated_rewarding = mix_details.rewarding_details; + + updated_bond.original_pledge.amount += increase.amount; + updated_rewarding.increase_operator_uint128(increase.amount)?; + + // update both, bond information and rewarding details + mixnodes_storage::mixnode_bonds().replace( + deps.storage, + mix_id, + Some(&updated_bond), + Some(&mix_details.bond_information), + )?; + rewards_storage::MIXNODE_REWARDING.save(deps.storage, mix_id, &updated_rewarding)?; + + Ok(Response::new().add_event(new_pledge_increase_event(created_at, mix_id, &increase))) +} + impl ContractExecutableEvent for PendingEpochEventData { fn execute(self, deps: DepsMut<'_>, env: &Env) -> Result { // note that the basic validation on all those events was already performed before @@ -274,6 +311,9 @@ impl ContractExecutableEvent for PendingEpochEventData { mix_id, proxy, } => undelegate(deps, self.created_at, owner, mix_id, proxy), + PendingEpochEventKind::PledgeMore { mix_id, amount } => { + increase_pledge(deps, self.created_at, mix_id, amount) + } PendingEpochEventKind::UnbondMixnode { mix_id } => { unbond_mixnode(deps, env, self.created_at, mix_id) } @@ -1135,6 +1175,228 @@ mod tests { } } + #[cfg(test)] + mod increasing_pledge { + use super::*; + use cosmwasm_std::Uint128; + use mixnet_contract_common::rewarding::helpers::truncate_reward_amount; + + #[test] + fn returns_hard_error_if_mixnode_doesnt_exist() { + // this should have never happened so hard error MUST be thrown here + let mut test = TestSetup::new(); + + let amount = test.coin(123); + let res = increase_pledge(test.deps_mut(), 123, 1, amount); + assert!(matches!( + res, + Err(MixnetContractError::InconsistentState { .. }) + )); + } + + #[test] + fn updates_stored_bond_information_and_rewarding_details() { + let mut test = TestSetup::new(); + let mix_id = test.add_dummy_mixnode("mix-owner", None); + + let old_details = get_mixnode_details_by_id(test.deps().storage, mix_id) + .unwrap() + .unwrap(); + + let amount = test.coin(12345); + increase_pledge(test.deps_mut(), 123, mix_id, amount.clone()).unwrap(); + + let updated_details = get_mixnode_details_by_id(test.deps().storage, mix_id) + .unwrap() + .unwrap(); + + assert_eq!( + updated_details.bond_information.original_pledge.amount, + old_details.bond_information.original_pledge.amount + amount.amount + ); + + assert_eq!( + updated_details.rewarding_details.operator, + old_details.rewarding_details.operator + + Decimal::from_atomics(amount.amount, 0).unwrap() + ); + } + + #[test] + fn without_any_events_in_between_is_equivalent_to_pledging_the_same_amount_immediately() { + let mut test = TestSetup::new(); + let pledge1 = Uint128::new(150_000_000); + let pledge2 = Uint128::new(50_000_000); + let pledge3 = Uint128::new(200_000_000); + + let mix_id_repledge = test.add_dummy_mixnode("mix-owner1", Some(pledge1)); + let increase = test.coin(pledge2.u128()); + increase_pledge(test.deps_mut(), 123, mix_id_repledge, increase).unwrap(); + + let mix_id_full_pledge = test.add_dummy_mixnode("mix-owner2", Some(pledge3)); + + test.add_immediate_delegation("alice", 123_456_789u128, mix_id_repledge); + test.add_immediate_delegation("bob", 500_000_000u128, mix_id_repledge); + test.add_immediate_delegation("carol", 111_111_111u128, mix_id_repledge); + + test.add_immediate_delegation("alice", 123_456_789u128, mix_id_full_pledge); + test.add_immediate_delegation("bob", 500_000_000u128, mix_id_full_pledge); + test.add_immediate_delegation("carol", 111_111_111u128, mix_id_full_pledge); + + test.skip_to_next_epoch_end(); + test.update_rewarded_set(vec![mix_id_repledge, mix_id_full_pledge]); + + let dist1 = + test.reward_with_distribution(mix_id_repledge, test_helpers::performance(100.0)); + let dist2 = + test.reward_with_distribution(mix_id_full_pledge, test_helpers::performance(100.0)); + + assert_eq!(dist1, dist2) + } + + #[test] + fn correctly_increases_future_rewards() { + let mut test = TestSetup::new(); + let pledge1 = Uint128::new(150_000_000_000); + let pledge2 = Uint128::new(50_000_000_000); + + let mix_id_repledge = test.add_dummy_mixnode("mix-owner1", Some(pledge1)); + + test.add_immediate_delegation("alice", 123_456_789_000u128, mix_id_repledge); + test.add_immediate_delegation("bob", 500_000_000_000u128, mix_id_repledge); + test.add_immediate_delegation("carol", 111_111_111_000u128, mix_id_repledge); + + test.skip_to_next_epoch_end(); + test.update_rewarded_set(vec![mix_id_repledge]); + + let dist = + test.reward_with_distribution(mix_id_repledge, test_helpers::performance(100.0)); + + let increase = test.coin(pledge2.u128()); + increase_pledge(test.deps_mut(), 123, mix_id_repledge, increase).unwrap(); + + let pledge3 = Uint128::new(200_000_000_000) + truncate_reward_amount(dist.operator); + let mix_id_full_pledge = test.add_dummy_mixnode("mix-owner2", Some(pledge3)); + + test.add_immediate_delegation("alice", 123_456_789_000u128, mix_id_full_pledge); + test.add_immediate_delegation("bob", 500_000_000_000u128, mix_id_full_pledge); + test.add_immediate_delegation("carol", 111_111_111_000u128, mix_id_full_pledge); + + let lost_operator = dist.operator + - Decimal::from_atomics(truncate_reward_amount(dist.operator), 0).unwrap(); + let lost_delegates = dist.delegates + - Decimal::from_atomics(truncate_reward_amount(dist.delegates), 0).unwrap(); + + // add the tiny bit of lost precision manually + let mut mix_rewarding_full = test.mix_rewarding(mix_id_full_pledge); + mix_rewarding_full.delegates += lost_delegates; + mix_rewarding_full.operator += lost_operator; + rewards_storage::MIXNODE_REWARDING + .save( + test.deps_mut().storage, + mix_id_full_pledge, + &mix_rewarding_full, + ) + .unwrap(); + + test.add_immediate_delegation( + "dave", + truncate_reward_amount(dist.delegates).u128(), + mix_id_full_pledge, + ); + + test.skip_to_next_epoch_end(); + test.update_rewarded_set(vec![mix_id_repledge, mix_id_full_pledge]); + + // go through few epochs of rewarding + for _ in 0..500 { + test.skip_to_next_epoch_end(); + let dist1 = test + .reward_with_distribution(mix_id_repledge, test_helpers::performance(100.0)); + let dist2 = test + .reward_with_distribution(mix_id_full_pledge, test_helpers::performance(100.0)); + + assert_eq!(dist1, dist2) + } + } + + #[test] + fn correctly_increases_future_rewards_with_more_passed_epochs() { + let mut test = TestSetup::new(); + let pledge1 = Uint128::new(150_000_000_000); + let pledge2 = Uint128::new(50_000_000_000); + + let mix_id_repledge = test.add_dummy_mixnode("mix-owner1", Some(pledge1)); + + test.add_immediate_delegation("alice", 123_456_789_000u128, mix_id_repledge); + test.add_immediate_delegation("bob", 500_000_000_000u128, mix_id_repledge); + test.add_immediate_delegation("carol", 111_111_111_000u128, mix_id_repledge); + + test.skip_to_next_epoch_end(); + test.update_rewarded_set(vec![mix_id_repledge]); + + let mut cumulative_op_reward = Decimal::zero(); + let mut cumulative_del_reward = Decimal::zero(); + + // go few epochs of rewarding before adding more pledge + for _ in 0..500 { + test.skip_to_next_epoch_end(); + let dist = test + .reward_with_distribution(mix_id_repledge, test_helpers::performance(100.0)); + cumulative_op_reward += dist.operator; + cumulative_del_reward += dist.delegates; + } + + let increase = test.coin(pledge2.u128()); + increase_pledge(test.deps_mut(), 123, mix_id_repledge, increase).unwrap(); + + let pledge3 = + Uint128::new(200_000_000_000) + truncate_reward_amount(cumulative_op_reward); + let mix_id_full_pledge = test.add_dummy_mixnode("mix-owner2", Some(pledge3)); + + test.add_immediate_delegation("alice", 123_456_789_000u128, mix_id_full_pledge); + test.add_immediate_delegation("bob", 500_000_000_000u128, mix_id_full_pledge); + test.add_immediate_delegation("carol", 111_111_111_000u128, mix_id_full_pledge); + + let lost_operator = cumulative_op_reward + - Decimal::from_atomics(truncate_reward_amount(cumulative_op_reward), 0).unwrap(); + let lost_delegates = cumulative_del_reward + - Decimal::from_atomics(truncate_reward_amount(cumulative_del_reward), 0).unwrap(); + + // add the tiny bit of lost precision manually + let mut mix_rewarding_full = test.mix_rewarding(mix_id_full_pledge); + mix_rewarding_full.delegates += lost_delegates; + mix_rewarding_full.operator += lost_operator; + rewards_storage::MIXNODE_REWARDING + .save( + test.deps_mut().storage, + mix_id_full_pledge, + &mix_rewarding_full, + ) + .unwrap(); + + test.add_immediate_delegation( + "dave", + truncate_reward_amount(cumulative_del_reward).u128(), + mix_id_full_pledge, + ); + + test.skip_to_next_epoch_end(); + test.update_rewarded_set(vec![mix_id_repledge, mix_id_full_pledge]); + + // go through few more epochs of rewarding + for _ in 0..500 { + test.skip_to_next_epoch_end(); + let dist1 = test + .reward_with_distribution(mix_id_repledge, test_helpers::performance(100.0)); + let dist2 = test + .reward_with_distribution(mix_id_full_pledge, test_helpers::performance(100.0)); + + assert_eq!(dist1, dist2) + } + } + } + #[test] fn updating_active_set_updates_rewarding_params() { let mut test = TestSetup::new(); diff --git a/contracts/mixnet/src/mixnet_contract_settings/storage.rs b/contracts/mixnet/src/mixnet_contract_settings/storage.rs index 990c5e604d..45037a2ed5 100644 --- a/contracts/mixnet/src/mixnet_contract_settings/storage.rs +++ b/contracts/mixnet/src/mixnet_contract_settings/storage.rs @@ -37,7 +37,6 @@ pub(crate) fn minimum_delegation_stake( .map(|state| state.params.minimum_mixnode_delegation)?) } -#[allow(unused)] pub(crate) fn rewarding_denom(storage: &dyn Storage) -> Result { Ok(CONTRACT_STATE .load(storage) diff --git a/contracts/mixnet/src/mixnodes/helpers.rs b/contracts/mixnet/src/mixnodes/helpers.rs index 8244f67b13..383dbc6224 100644 --- a/contracts/mixnet/src/mixnodes/helpers.rs +++ b/contracts/mixnet/src/mixnodes/helpers.rs @@ -147,7 +147,7 @@ pub(crate) fn cleanup_post_unbond_mixnode_storage( } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::support::tests::fixtures::{ mix_node_cost_params_fixture, mix_node_fixture, TEST_COIN_DENOM, @@ -155,13 +155,13 @@ mod tests { use crate::support::tests::test_helpers::TestSetup; use cosmwasm_std::coin; - const OWNER_EXISTS: &str = "mix-owner-existing"; - const OWNER_UNBONDING: &str = "mix-owner-unbonding"; - const OWNER_UNBONDED: &str = "mix-owner-unbonded"; - const OWNER_UNBONDED_LEFTOVER: &str = "mix-owner-unbonded-leftover"; + pub(crate) const OWNER_EXISTS: &str = "mix-owner-existing"; + pub(crate) const OWNER_UNBONDING: &str = "mix-owner-unbonding"; + pub(crate) const OWNER_UNBONDED: &str = "mix-owner-unbonded"; + pub(crate) const OWNER_UNBONDED_LEFTOVER: &str = "mix-owner-unbonded-leftover"; // create a mixnode that is bonded, unbonded, in the process of unbonding and unbonded with leftover mix rewarding details - fn setup_mix_combinations(test: &mut TestSetup) -> Vec { + pub(crate) fn setup_mix_combinations(test: &mut TestSetup) -> Vec { let mix_id_exists = test.add_dummy_mixnode(OWNER_EXISTS, None); let mix_id_unbonding = test.add_dummy_mixnode(OWNER_UNBONDING, None); let mix_id_unbonded = test.add_dummy_mixnode(OWNER_UNBONDED, None); diff --git a/contracts/mixnet/src/mixnodes/transactions.rs b/contracts/mixnet/src/mixnodes/transactions.rs index 0a1e816ffd..92fa131a9e 100644 --- a/contracts/mixnet/src/mixnodes/transactions.rs +++ b/contracts/mixnet/src/mixnodes/transactions.rs @@ -5,16 +5,20 @@ use super::storage; use crate::interval::storage as interval_storage; use crate::interval::storage::push_new_interval_event; use crate::mixnet_contract_settings::storage as mixnet_params_storage; -use crate::mixnodes::helpers::{must_get_mixnode_bond_by_owner, save_new_mixnode}; +use crate::mixnet_contract_settings::storage::rewarding_denom; +use crate::mixnodes::helpers::{ + get_mixnode_details_by_owner, must_get_mixnode_bond_by_owner, save_new_mixnode, +}; use crate::support::helpers::{ ensure_bonded, ensure_no_existing_bond, ensure_proxy_match, validate_node_identity_signature, validate_pledge, }; -use cosmwasm_std::{Addr, Coin, DepsMut, Env, MessageInfo, Response}; +use cosmwasm_std::{coin, Addr, Coin, DepsMut, Env, MessageInfo, Response}; use mixnet_contract_common::error::MixnetContractError; use mixnet_contract_common::events::{ new_mixnode_bonding_event, new_mixnode_config_update_event, new_mixnode_pending_cost_params_update_event, new_pending_mixnode_unbonding_event, + new_pending_pledge_increase_event, }; use mixnet_contract_common::mixnode::{MixNodeConfigUpdate, MixNodeCostParams}; use mixnet_contract_common::pending_events::{PendingEpochEventKind, PendingIntervalEventKind}; @@ -117,6 +121,54 @@ fn _try_add_mixnode( ))) } +pub fn try_increase_pledge( + deps: DepsMut<'_>, + env: Env, + info: MessageInfo, +) -> Result { + _try_increase_pledge(deps, env, info.funds, info.sender, None) +} + +pub fn try_increase_pledge_on_behalf( + deps: DepsMut<'_>, + env: Env, + info: MessageInfo, + owner: String, +) -> Result { + let proxy = info.sender; + let owner = deps.api.addr_validate(&owner)?; + _try_increase_pledge(deps, env, info.funds, owner, Some(proxy)) +} + +pub fn _try_increase_pledge( + deps: DepsMut<'_>, + env: Env, + increase: Vec, + owner: Addr, + proxy: Option, +) -> Result { + let mix_details = get_mixnode_details_by_owner(deps.storage, owner.clone())? + .ok_or(MixnetContractError::NoAssociatedMixNodeBond { owner })?; + let mix_id = mix_details.mix_id(); + + ensure_proxy_match(&proxy, &mix_details.bond_information.proxy)?; + ensure_bonded(&mix_details.bond_information)?; + + let rewarding_denom = rewarding_denom(deps.storage)?; + let pledge_increase = validate_pledge(increase, coin(1, rewarding_denom))?; + + let cosmos_event = new_pending_pledge_increase_event(mix_id, &pledge_increase); + + // push the event to execute it at the end of the epoch + let epoch_event = PendingEpochEventKind::PledgeMore { + mix_id, + amount: pledge_increase, + }; + interval_storage::push_new_epoch_event(deps.storage, &env, epoch_event)?; + + Ok(Response::new().add_event(cosmos_event)) +} + pub fn try_remove_mixnode_on_behalf( deps: DepsMut<'_>, env: Env, @@ -680,4 +732,187 @@ pub mod tests { ) .is_err()); } + + #[cfg(test)] + mod increasing_mixnode_pledge { + use super::*; + use crate::mixnodes::helpers::tests::{ + setup_mix_combinations, OWNER_UNBONDED, OWNER_UNBONDED_LEFTOVER, OWNER_UNBONDING, + }; + use crate::support::tests::test_helpers::TestSetup; + + #[test] + fn is_not_allowed_if_account_doesnt_own_mixnode() { + let mut test = TestSetup::new(); + let env = test.env(); + let sender = mock_info("not-mix-owner", &[]); + + let res = try_increase_pledge(test.deps_mut(), env, sender); + assert_eq!( + res, + Err(MixnetContractError::NoAssociatedMixNodeBond { + owner: Addr::unchecked("not-mix-owner") + }) + ) + } + + #[test] + fn is_not_allowed_if_theres_proxy_mismatch() { + let mut test = TestSetup::new(); + let env = test.env(); + + let owner_without_proxy = Addr::unchecked("no-proxy"); + let owner_with_proxy = Addr::unchecked("with-proxy"); + let proxy = Addr::unchecked("proxy"); + let wrong_proxy = Addr::unchecked("unrelated-proxy"); + + test.add_dummy_mixnode(owner_without_proxy.as_str(), None); + test.add_dummy_mixnode_with_proxy(owner_with_proxy.as_str(), None, proxy.clone()); + + let res = _try_increase_pledge( + test.deps_mut(), + env.clone(), + Vec::new(), + owner_without_proxy.clone(), + Some(proxy), + ); + assert_eq!( + res, + Err(MixnetContractError::ProxyMismatch { + existing: "None".to_string(), + incoming: "proxy".to_string() + }) + ); + + let res = _try_increase_pledge( + test.deps_mut(), + env.clone(), + Vec::new(), + owner_with_proxy.clone(), + None, + ); + assert_eq!( + res, + Err(MixnetContractError::ProxyMismatch { + existing: "proxy".to_string(), + incoming: "None".to_string() + }) + ); + + let res = _try_increase_pledge( + test.deps_mut(), + env, + Vec::new(), + owner_with_proxy.clone(), + Some(wrong_proxy), + ); + assert_eq!( + res, + Err(MixnetContractError::ProxyMismatch { + existing: "proxy".to_string(), + incoming: "unrelated-proxy".to_string() + }) + ) + } + + #[test] + fn is_not_allowed_if_mixnode_has_unbonded_or_is_unbonding() { + let mut test = TestSetup::new(); + let env = test.env(); + + // TODO: I dislike this cross-test access, but it provides us with exactly what we need + // perhaps it should be refactored a bit? + let owner_unbonding = Addr::unchecked(OWNER_UNBONDING); + let owner_unbonded = Addr::unchecked(OWNER_UNBONDED); + let owner_unbonded_leftover = Addr::unchecked(OWNER_UNBONDED_LEFTOVER); + + let ids = setup_mix_combinations(&mut test); + let mix_id_unbonding = ids[1]; + + let res = try_increase_pledge( + test.deps_mut(), + env.clone(), + mock_info(owner_unbonding.as_str(), &[]), + ); + assert_eq!( + res, + Err(MixnetContractError::MixnodeIsUnbonding { + mix_id: mix_id_unbonding + }) + ); + + // if the nodes are gone we treat them as tey never existed in the first place + // (regardless of if there's some leftover data) + let res = try_increase_pledge( + test.deps_mut(), + env.clone(), + mock_info(owner_unbonded_leftover.as_str(), &[]), + ); + assert_eq!( + res, + Err(MixnetContractError::NoAssociatedMixNodeBond { + owner: owner_unbonded_leftover + }) + ); + + let res = try_increase_pledge( + test.deps_mut(), + env, + mock_info(owner_unbonded.as_str(), &[]), + ); + assert_eq!( + res, + Err(MixnetContractError::NoAssociatedMixNodeBond { + owner: owner_unbonded + }) + ) + } + + #[test] + fn is_not_allowed_if_no_tokens_were_sent() { + let mut test = TestSetup::new(); + let env = test.env(); + let owner = "mix-owner"; + + test.add_dummy_mixnode(owner, None); + + let sender_empty = mock_info(owner, &[]); + let res = try_increase_pledge(test.deps_mut(), env.clone(), sender_empty); + assert_eq!(res, Err(MixnetContractError::NoBondFound)); + + let sender_zero = mock_info(owner, &[test.coin(0)]); + let res = try_increase_pledge(test.deps_mut(), env, sender_zero); + assert_eq!( + res, + Err(MixnetContractError::InsufficientPledge { + received: test.coin(0), + minimum: test.coin(1) + }) + ) + } + + #[test] + fn with_valid_information_creates_pending_event() { + let mut test = TestSetup::new(); + let env = test.env(); + let owner = "mix-owner"; + let mix_id = test.add_dummy_mixnode(owner, None); + + let events = test.pending_epoch_events(); + assert!(events.is_empty()); + + let sender = mock_info(owner, &[test.coin(1000)]); + try_increase_pledge(test.deps_mut(), env, sender).unwrap(); + + let events = test.pending_epoch_events(); + + assert_eq!( + events[0].kind, + PendingEpochEventKind::PledgeMore { + mix_id, + amount: test.coin(1000) + } + ); + } + } } diff --git a/contracts/mixnet/src/support/tests/mod.rs b/contracts/mixnet/src/support/tests/mod.rs index a7cf4a3875..3d17b324df 100644 --- a/contracts/mixnet/src/support/tests/mod.rs +++ b/contracts/mixnet/src/support/tests/mod.rs @@ -28,6 +28,9 @@ pub mod test_helpers { use crate::mixnodes::transactions::{ try_add_mixnode, try_add_mixnode_on_behalf, try_remove_mixnode, }; + use crate::rewards::queries::{ + query_pending_delegator_reward, query_pending_mixnode_operator_reward, + }; use crate::rewards::storage as rewards_storage; use crate::rewards::transactions::try_reward_mixnode; use crate::support::tests; @@ -37,7 +40,7 @@ pub mod test_helpers { use cosmwasm_std::testing::mock_info; use cosmwasm_std::testing::MockApi; use cosmwasm_std::testing::MockQuerier; - use cosmwasm_std::{Addr, BankMsg, CosmosMsg, Storage}; + use cosmwasm_std::{coin, Addr, BankMsg, CosmosMsg, Storage}; use cosmwasm_std::{Coin, Order}; use cosmwasm_std::{Decimal, Empty, MemoryStorage}; use cosmwasm_std::{Deps, OwnedDeps}; @@ -138,6 +141,10 @@ pub mod test_helpers { .vesting_contract_address } + pub fn coin(&self, amount: u128) -> Coin { + coin(amount, rewarding_denom(self.deps().storage).unwrap()) + } + pub fn current_interval(&self) -> Interval { interval_storage::current_interval(self.deps().storage).unwrap() } @@ -308,6 +315,22 @@ pub mod test_helpers { .unwrap(); } + #[allow(unused)] + pub fn pending_operator_reward(&mut self, mix: MixId) -> Decimal { + query_pending_mixnode_operator_reward(self.deps(), mix) + .unwrap() + .amount_earned_detailed + .expect("no reward!") + } + + #[allow(unused)] + pub fn pending_delegator_reward(&mut self, delegator: &str, target: MixId) -> Decimal { + query_pending_delegator_reward(self.deps(), delegator.into(), target, None) + .unwrap() + .amount_earned_detailed + .expect("no reward!") + } + pub fn skip_to_next_epoch_end(&mut self) { self.skip_to_next_epoch(); self.skip_to_current_epoch_end(); @@ -337,8 +360,19 @@ pub mod test_helpers { self.env.block.time = Timestamp::from_seconds(epoch_end as u64 + 1); let advanced = interval.advance_epoch(); - if interval.current_epoch_id() != interval.epochs_in_interval() { + assert_eq!( + interval.current_epoch_absolute_id() + 1, + advanced.current_epoch_absolute_id() + ); + + if interval.current_epoch_id() != interval.epochs_in_interval() - 1 { assert_eq!(interval.current_epoch_id() + 1, advanced.current_epoch_id()) + } else { + assert_eq!(advanced.current_epoch_id(), 0); + assert_eq!( + interval.current_interval_id() + 1, + advanced.current_interval_id() + ) } interval_storage::save_interval(self.deps_mut().storage, &advanced).unwrap() diff --git a/contracts/vesting/src/contract.rs b/contracts/vesting/src/contract.rs index 5efb3d750a..90a72d2ae8 100644 --- a/contracts/vesting/src/contract.rs +++ b/contracts/vesting/src/contract.rs @@ -121,6 +121,7 @@ pub fn execute( env, deps, ), + ExecuteMsg::PledgeMore { amount } => try_pledge_more(deps, env, info, amount), ExecuteMsg::UnbondMixnode {} => try_unbond_mixnode(info, deps), ExecuteMsg::TrackUnbondMixnode { owner, amount } => { try_track_unbond_mixnode(&owner, amount, info, deps) @@ -331,6 +332,19 @@ pub fn try_bond_mixnode( ) } +pub fn try_pledge_more( + deps: DepsMut<'_>, + env: Env, + info: MessageInfo, + amount: Coin, +) -> Result { + let mix_denom = MIX_DENOM.load(deps.storage)?; + let additional_pledge = validate_funds(&[amount], mix_denom)?; + + let account = account_from_address(info.sender.as_str(), deps.storage, deps.api)?; + account.try_pledge_additional_tokens(additional_pledge, &env, deps.storage) +} + /// Unbond a mixnode, sends [mixnet_contract_common::ExecuteMsg::UnbondMixnodeOnBehalf] to [crate::storage::MIXNET_CONTRACT_ADDRESS]. pub fn try_unbond_mixnode(info: MessageInfo, deps: DepsMut<'_>) -> Result { let account = account_from_address(info.sender.as_str(), deps.storage, deps.api)?; diff --git a/contracts/vesting/src/traits/bonding_account.rs b/contracts/vesting/src/traits/bonding_account.rs index 36c248e393..491378d630 100644 --- a/contracts/vesting/src/traits/bonding_account.rs +++ b/contracts/vesting/src/traits/bonding_account.rs @@ -18,6 +18,13 @@ pub trait MixnodeBondingAccount { storage: &mut dyn Storage, ) -> Result; + fn try_pledge_additional_tokens( + &self, + additional_pledge: Coin, + env: &Env, + storage: &mut dyn Storage, + ) -> Result; + fn try_unbond_mixnode(&self, storage: &dyn Storage) -> Result; fn try_track_unbond_mixnode( diff --git a/contracts/vesting/src/vesting/account/mixnode_bonding_account.rs b/contracts/vesting/src/vesting/account/mixnode_bonding_account.rs index e3aec82584..74917a16c7 100644 --- a/contracts/vesting/src/vesting/account/mixnode_bonding_account.rs +++ b/contracts/vesting/src/vesting/account/mixnode_bonding_account.rs @@ -12,7 +12,8 @@ use mixnet_contract_common::mixnode::MixNodeCostParams; use mixnet_contract_common::{ExecuteMsg as MixnetExecuteMsg, MixNode}; use vesting_contract_common::events::{ new_vesting_mixnode_bonding_event, new_vesting_mixnode_unbonding_event, - new_vesting_update_mixnode_config_event, new_vesting_update_mixnode_cost_params_event, + new_vesting_pledge_more_event, new_vesting_update_mixnode_config_event, + new_vesting_update_mixnode_cost_params_event, }; use vesting_contract_common::PledgeData; @@ -84,6 +85,64 @@ impl MixnodeBondingAccount for Account { .add_event(new_vesting_mixnode_bonding_event())) } + fn try_pledge_additional_tokens( + &self, + additional_pledge: Coin, + env: &Env, + storage: &mut dyn Storage, + ) -> Result { + let current_balance = self.load_balance(storage)?; + let total_pledged_after = + self.total_pledged_locked(storage, env)? + additional_pledge.amount; + let locked_pledge_cap = self.absolute_pledge_cap()?; + + if locked_pledge_cap < total_pledged_after { + return Err(ContractError::LockedPledgeCapReached { + current: total_pledged_after, + cap: locked_pledge_cap, + }); + } + + if current_balance < additional_pledge.amount { + return Err(ContractError::InsufficientBalance( + self.owner_address().as_str().to_string(), + current_balance.u128(), + )); + } + + let mut pledge_data = if let Some(pledge_data) = self.load_mixnode_pledge(storage)? { + pledge_data + } else { + return Err(ContractError::NoBondFound( + self.owner_address().as_str().to_string(), + )); + }; + + // need a second pair of eyes here to make sure updating existing timestamp on pledge data + // is not going to have some unexpected consequences + pledge_data.amount.amount += additional_pledge.amount; + pledge_data.block_time = env.block.time; + + let msg = MixnetExecuteMsg::PledgeMoreOnBehalf { + owner: self.owner_address().into_string(), + }; + + let new_balance = Uint128::new(current_balance.u128() - additional_pledge.amount.u128()); + + let pledge_more_mag = wasm_execute( + MIXNET_CONTRACT_ADDRESS.load(storage)?, + &msg, + vec![additional_pledge], + )?; + + self.save_balance(new_balance, storage)?; + self.save_mixnode_pledge(pledge_data, storage)?; + + Ok(Response::new() + .add_message(pledge_more_mag) + .add_event(new_vesting_pledge_more_event())) + } + fn try_unbond_mixnode(&self, storage: &dyn Storage) -> Result { let msg = MixnetExecuteMsg::UnbondMixnodeOnBehalf { owner: self.owner_address().into_string(), diff --git a/envs/mainnet.env b/envs/mainnet.env index d53b7f4b8b..751081adb8 100644 --- a/envs/mainnet.env +++ b/envs/mainnet.env @@ -9,7 +9,7 @@ MIX_DENOM_DISPLAY=nym STAKE_DENOM=unyx STAKE_DENOM_DISPLAY=nyx DENOMS_EXPONENT=6 -MIXNET_CONTRACT_ADDRESS=n14hj2tavq8fpesdwxxcu44rty3hh90vhujrvcmstl4zr3txmfvw9sjyvg3g +MIXNET_CONTRACT_ADDRESS=n17srjznxl9dvzdkpwpw24gg668wc73val88a6m5ajg6ankwvz9wtst0cznr VESTING_CONTRACT_ADDRESS=n1nc5tatafv6eyq7llkr2gv50ff9e22mnf70qgjlv737ktmt4eswrq73f2nw BANDWIDTH_CLAIM_CONTRACT_ADDRESS=n19lc9u84cz0yz3fww5283nucc9yvr8gsjmgeul0 COCONUT_BANDWIDTH_CONTRACT_ADDRESS=n19lc9u84cz0yz3fww5283nucc9yvr8gsjmgeul0 @@ -17,5 +17,5 @@ MULTISIG_CONTRACT_ADDRESS=n19lc9u84cz0yz3fww5283nucc9yvr8gsjmgeul0 COCONUT_DKG_CONTRACT_ADDRESS=n19lc9u84cz0yz3fww5283nucc9yvr8gsjmgeul0 REWARDING_VALIDATOR_ADDRESS=n10yyd98e2tuwu0f7ypz9dy3hhjw7v772q6287gy STATISTICS_SERVICE_DOMAIN_ADDRESS="https://mainnet-stats.nymte.ch:8090" -NYMD_VALIDATOR="https://rpc.nyx.nodes.guru/" +NYMD_VALIDATOR="https://rpc.nymtech.net"; API_VALIDATOR="https://validator.nymtech.net/api/" diff --git a/explorer-api/Cargo.toml b/explorer-api/Cargo.toml index ab442f466f..cc665b1f83 100644 --- a/explorer-api/Cargo.toml +++ b/explorer-api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "explorer-api" -version = "1.1.0" +version = "1.1.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/explorer/src/components/MobileNav.tsx b/explorer/src/components/MobileNav.tsx index 26e078fe08..cb44210e9d 100644 --- a/explorer/src/components/MobileNav.tsx +++ b/explorer/src/components/MobileNav.tsx @@ -34,7 +34,7 @@ export const MobileNav: React.FC<{ children: React.ReactNode }> = ({ children }: const { navState, updateNavState } = useMainContext(); const [drawerOpen, setDrawerOpen] = React.useState(false); // Set maintenance banner to false by default to don't display it - const [openMaintenance, setOpenMaintenance] = React.useState(true); + const [openMaintenance, setOpenMaintenance] = React.useState(false); const toggleDrawer = () => { setDrawerOpen(!drawerOpen); diff --git a/explorer/src/components/Nav.tsx b/explorer/src/components/Nav.tsx index 53bb922537..e2808871a0 100644 --- a/explorer/src/components/Nav.tsx +++ b/explorer/src/components/Nav.tsx @@ -235,7 +235,7 @@ export const Nav: React.FC = ({ children }) => { const [drawerIsOpen, setDrawerToOpen] = React.useState(false); const [fixedOpen, setFixedOpen] = React.useState(false); // Set maintenance banner to false by default to don't display it - const [openMaintenance, setOpenMaintenance] = React.useState(true); + const [openMaintenance, setOpenMaintenance] = React.useState(false); const theme = useTheme(); const setToActive = (id: number) => { diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml index 6d1dbf3f07..8be0948a85 100644 --- a/gateway/Cargo.toml +++ b/gateway/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "nym-gateway" -version = "1.1.0" +version = "1.1.1" authors = [ "Dave Hrycyszyn ", "Jędrzej Stuczyński ", diff --git a/gateway/gateway-requests/src/lib.rs b/gateway/gateway-requests/src/lib.rs index 98de45c377..d1c1861a8a 100644 --- a/gateway/gateway-requests/src/lib.rs +++ b/gateway/gateway-requests/src/lib.rs @@ -1,4 +1,4 @@ -// Copyright 2020 - Nym Technologies SA +// Copyright 2020-2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 pub use crypto::generic_array; @@ -12,6 +12,10 @@ pub mod iv; pub mod registration; pub mod types; +/// Defines the current version of the communication protocol between gateway and clients. +/// It has to be incremented for any breaking change. +pub const PROTOCOL_VERSION: u8 = 1; + pub type GatewayMac = HmacOutput; // TODO: could using `Mac` trait here for OutputSize backfire? diff --git a/gateway/gateway-requests/src/registration/handshake/state.rs b/gateway/gateway-requests/src/registration/handshake/state.rs index 10c0ae9729..1560b5fd9f 100644 --- a/gateway/gateway-requests/src/registration/handshake/state.rs +++ b/gateway/gateway-requests/src/registration/handshake/state.rs @@ -210,7 +210,10 @@ impl<'a, S> State<'a, S> { match msg { WsMessage::Text(ws_msg) => match types::RegistrationHandshake::try_from(ws_msg) { Ok(reg_handshake_msg) => return match reg_handshake_msg { - types::RegistrationHandshake::HandshakePayload { data } => Ok(data), + // hehe, that's a bit disgusting that the type system requires we explicitly ignore the + // protocol_version field that we actually never attach at this point + // yet another reason for the overdue refactor + types::RegistrationHandshake::HandshakePayload { data, .. } => Ok(data), types::RegistrationHandshake::HandshakeError { message } => Err(HandshakeError::RemoteError(message)), }, Err(_) => error!("Received a non-handshake message during the registration handshake! It's getting dropped."), diff --git a/gateway/gateway-requests/src/types.rs b/gateway/gateway-requests/src/types.rs index bfa20a9273..f73f85bca5 100644 --- a/gateway/gateway-requests/src/types.rs +++ b/gateway/gateway-requests/src/types.rs @@ -4,7 +4,7 @@ use crate::authentication::encrypted_address::EncryptedAddressBytes; use crate::iv::IV; use crate::registration::handshake::SharedKeys; -use crate::GatewayMacSize; +use crate::{GatewayMacSize, PROTOCOL_VERSION}; use crypto::generic_array::typenum::Unsigned; use crypto::hmac::recompute_keyed_hmac_and_verify_tag; use crypto::symmetric::stream_cipher; @@ -28,13 +28,22 @@ use credentials::token::bandwidth::TokenCredential; #[derive(Serialize, Deserialize, Debug)] #[serde(tag = "type", rename_all = "camelCase")] pub enum RegistrationHandshake { - HandshakePayload { data: Vec }, - HandshakeError { message: String }, + HandshakePayload { + #[serde(default)] + protocol_version: Option, + data: Vec, + }, + HandshakeError { + message: String, + }, } impl RegistrationHandshake { pub fn new_payload(data: Vec) -> Self { - RegistrationHandshake::HandshakePayload { data } + RegistrationHandshake::HandshakePayload { + protocol_version: Some(PROTOCOL_VERSION), + data, + } } pub fn new_error>(message: S) -> Self { @@ -115,12 +124,16 @@ pub enum ClientControlRequest { // TODO: should this also contain a MAC considering that at this point we already // have the shared key derived? Authenticate { + #[serde(default)] + protocol_version: Option, address: String, enc_address: String, iv: String, }, #[serde(alias = "handshakePayload")] RegisterHandshakeInitRequest { + #[serde(default)] + protocol_version: Option, data: Vec, }, BandwidthCredential { @@ -137,6 +150,7 @@ impl ClientControlRequest { iv: IV, ) -> Self { ClientControlRequest::Authenticate { + protocol_version: Some(PROTOCOL_VERSION), address: address.as_base58_string(), enc_address: enc_address.to_base58_string(), iv: iv.to_base58_string(), @@ -223,10 +237,14 @@ impl TryInto for ClientControlRequest { #[serde(tag = "type", rename_all = "camelCase")] pub enum ServerResponse { Authenticate { + #[serde(default)] + protocol_version: Option, status: bool, bandwidth_remaining: i64, }, Register { + #[serde(default)] + protocol_version: Option, status: bool, }, Bandwidth { @@ -381,14 +399,37 @@ mod tests { #[test] fn handshake_payload_can_be_deserialized_into_register_handshake_init_request() { let handshake_data = vec![1, 2, 3, 4, 5, 6]; - let handshake_payload = RegistrationHandshake::HandshakePayload { + let handshake_payload_with_protocol = RegistrationHandshake::HandshakePayload { + protocol_version: Some(42), data: handshake_data.clone(), }; - let serialized = serde_json::to_string(&handshake_payload).unwrap(); + let serialized = serde_json::to_string(&handshake_payload_with_protocol).unwrap(); let deserialized = ClientControlRequest::try_from(serialized).unwrap(); match deserialized { - ClientControlRequest::RegisterHandshakeInitRequest { data } => { + ClientControlRequest::RegisterHandshakeInitRequest { + protocol_version, + data, + } => { + assert_eq!(protocol_version, Some(42)); + assert_eq!(data, handshake_data) + } + _ => unreachable!("this branch shouldn't have been reached!"), + } + + let handshake_payload_without_protocol = RegistrationHandshake::HandshakePayload { + protocol_version: None, + data: handshake_data.clone(), + }; + let serialized = serde_json::to_string(&handshake_payload_without_protocol).unwrap(); + let deserialized = ClientControlRequest::try_from(serialized).unwrap(); + + match deserialized { + ClientControlRequest::RegisterHandshakeInitRequest { + protocol_version, + data, + } => { + assert!(protocol_version.is_none()); assert_eq!(data, handshake_data) } _ => unreachable!("this branch shouldn't have been reached!"), diff --git a/gateway/src/node/client_handling/websocket/connection_handler/fresh.rs b/gateway/src/node/client_handling/websocket/connection_handler/fresh.rs index abe2f30a87..0e4c8dd978 100644 --- a/gateway/src/node/client_handling/websocket/connection_handler/fresh.rs +++ b/gateway/src/node/client_handling/websocket/connection_handler/fresh.rs @@ -18,7 +18,7 @@ use gateway_requests::iv::{IVConversionError, IV}; use gateway_requests::registration::handshake::error::HandshakeError; use gateway_requests::registration::handshake::{gateway_handshake, SharedKeys}; use gateway_requests::types::{ClientControlRequest, ServerResponse}; -use gateway_requests::BinaryResponse; +use gateway_requests::{BinaryResponse, PROTOCOL_VERSION}; use log::*; use mixnet_client::forwarder::MixForwardingSender; use nymsphinx::DestinationAddressBytes; @@ -55,6 +55,9 @@ enum InitialAuthenticationError { #[error("Experienced connection error - {0}")] ConnectionError(#[from] WsError), + + #[error("Attempted to negotiate connection with client using incompatible protocol version. Ours is {current} and the client reports {client:?}")] + IncompatibleProtocol { client: Option, current: u8 }, } impl InitialAuthenticationError { @@ -279,10 +282,7 @@ where // push them to the client if let Err(err) = self.push_packets_to_client(shared_keys, messages).await { - warn!( - "We failed to send stored messages to fresh client - {}", - err - ); + warn!("We failed to send stored messages to fresh client - {err}",); return Err(InitialAuthenticationError::ConnectionError(err)); } else { // if it was successful - remove them from the store @@ -342,6 +342,27 @@ where } } + fn check_client_protocol( + &self, + client_protocol: Option, + ) -> Result<(), InitialAuthenticationError> { + // right now there are no failure cases here, but this might change in the future + match client_protocol { + Some(v) if v == PROTOCOL_VERSION => { + info!("the client is using exactly the same protocol version as we are. We're good to continue!"); + Ok(()) + } + v => { + let err = InitialAuthenticationError::IncompatibleProtocol { + client: v, + current: PROTOCOL_VERSION, + }; + error!("{err}"); + Err(err) + } + } + } + /// Using the received challenge data, i.e. client's address as well the ciphertext of it plus /// a fresh IV, attempts to authenticate the client by checking whether the ciphertext matches /// the expected value if encrypted with the shared key. @@ -389,6 +410,7 @@ where /// * `iv`: fresh IV received with the request. async fn handle_authenticate( &mut self, + client_protocol_version: Option, address: String, enc_address: String, iv: String, @@ -396,6 +418,8 @@ where where S: AsyncRead + AsyncWrite + Unpin, { + self.check_client_protocol(client_protocol_version)?; + let address = DestinationAddressBytes::try_from_base58_string(address) .map_err(|err| InitialAuthenticationError::MalformedClientAddress(err.to_string()))?; let encrypted_address = EncryptedAddressBytes::try_from_base58_string(enc_address)?; @@ -420,6 +444,7 @@ where Ok(InitialAuthResult::new( client_details, ServerResponse::Authenticate { + protocol_version: Some(PROTOCOL_VERSION), status, bandwidth_remaining, }, @@ -474,11 +499,14 @@ where /// * `init_data`: init payload of the registration handshake. async fn handle_register( &mut self, + client_protocol_version: Option, init_data: Vec, ) -> Result where S: AsyncRead + AsyncWrite + Unpin + Send, { + self.check_client_protocol(client_protocol_version)?; + let remote_identity = Self::extract_remote_identity_from_register_init(&init_data)?; let remote_address = remote_identity.derive_destination_address(); @@ -493,7 +521,10 @@ where Ok(InitialAuthResult::new( Some(client_details), - ServerResponse::Register { status }, + ServerResponse::Register { + protocol_version: Some(PROTOCOL_VERSION), + status, + }, )) } @@ -513,13 +544,18 @@ where if let Ok(request) = ClientControlRequest::try_from(raw_request) { match request { ClientControlRequest::Authenticate { + protocol_version, address, enc_address, iv, - } => self.handle_authenticate(address, enc_address, iv).await, - ClientControlRequest::RegisterHandshakeInitRequest { data } => { - self.handle_register(data).await + } => { + self.handle_authenticate(protocol_version, address, enc_address, iv) + .await } + ClientControlRequest::RegisterHandshakeInitRequest { + protocol_version, + data, + } => self.handle_register(protocol_version, data).await, // won't accept anything else (like bandwidth) without prior authentication _ => Err(InitialAuthenticationError::InvalidRequest), } diff --git a/mixnode/Cargo.toml b/mixnode/Cargo.toml index ea2dc207a0..0806354403 100644 --- a/mixnode/Cargo.toml +++ b/mixnode/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "nym-mixnode" -version = "1.1.0" +version = "1.1.1" authors = [ "Dave Hrycyszyn ", "Jędrzej Stuczyński ", diff --git a/nym-connect/.storybook/preview.js b/nym-connect/.storybook/preview.js index 2dbe041198..9c14c3ec99 100644 --- a/nym-connect/.storybook/preview.js +++ b/nym-connect/.storybook/preview.js @@ -1,26 +1,26 @@ import { NymMixnetTheme } from '../src/theme'; -import { ClientContextProvider } from '../src/context/main'; import { Fonts } from './preview-fonts'; - -const withThemeProvider= (Story, context) =>{ +import { MockProvider } from '../src/context/mocks/main'; +const withThemeProvider = (Story, context) => { return ( - - + + - + - ) -} + ); +}; + export const decorators = [withThemeProvider]; export const parameters = { - actions: { argTypesRegex: "^on[A-Z].*" }, + actions: { argTypesRegex: '^on[A-Z].*' }, controls: { matchers: { color: /(background|color)$/i, date: /Date$/, }, }, -} \ No newline at end of file +}; diff --git a/nym-connect/CHANGELOG.md b/nym-connect/CHANGELOG.md index a273afb9ed..1a8bc41c11 100644 --- a/nym-connect/CHANGELOG.md +++ b/nym-connect/CHANGELOG.md @@ -1,3 +1,23 @@ +## [nym-connect-v1.1.1](https://github.com/nymtech/nym/tree/nym-connect-v1.1.1) (2022-11-29) + +- socks5-client: fix multiplex concurrent connections ([#1720], [#1777]) +- socks5-client: fix wait closing inbound connection until data is sent, and throttle incoming data in general ([#1772], [#1783],[#1789]) +- socks5-client: fix shutting down all background workers if anyone of them panics or errors out. This fixes an issue where the nym-connect UI was showing connected even though the socks5 tunnel was non-functional. ([#1805]) +- gateway-libs: fix decryping messages stored on the gateway between reconnects ([#1786]) + +- nymconnect: updated UI +- nymconnect: new help area +- nymconnect: listen for service errors and display on frontend + +[#1720]: https://github.com/nymtech/nym/pull/1720 +[#1772]: https://github.com/nymtech/nym/pull/1772 +[#1777]: https://github.com/nymtech/nym/pull/1777 +[#1783]: https://github.com/nymtech/nym/pull/1783 +[#1786]: https://github.com/nymtech/nym/pull/1786 +[#1789]: https://github.com/nymtech/nym/pull/1789 +[#1805]: https://github.com/nymtech/nym/pull/1805 + + ## [nym-connect-v1.1.0](https://github.com/nymtech/nym/tree/nym-connect-v1.1.0) (2022-11-09) - nym-connect: rework of rewarding changes the directory data structures that describe the mixnet topology ([#1472]) @@ -39,4 +59,4 @@ - nym-connect: reuse config id instead of creating a new id on each connection -[#1427]: https://github.com/nymtech/nym/pull/1427 \ No newline at end of file +[#1427]: https://github.com/nymtech/nym/pull/1427 diff --git a/nym-connect/Cargo.lock b/nym-connect/Cargo.lock index b18518d32e..c7d953327d 100644 --- a/nym-connect/Cargo.lock +++ b/nym-connect/Cargo.lock @@ -639,7 +639,7 @@ dependencies = [ [[package]] name = "client-core" -version = "1.1.0" +version = "1.1.1" dependencies = [ "async-trait", "client-connections", @@ -664,6 +664,7 @@ dependencies = [ "thiserror", "time 0.3.14", "tokio", + "tokio-stream", "topology", "url", "validator-client", @@ -3385,7 +3386,7 @@ dependencies = [ [[package]] name = "nym-socks5-client" -version = "1.1.0" +version = "1.1.1" dependencies = [ "clap", "client-connections", @@ -5609,8 +5610,12 @@ dependencies = [ name = "task" version = "0.1.0" dependencies = [ + "futures", "log", + "thiserror", "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", ] [[package]] diff --git a/nym-connect/Cargo.toml b/nym-connect/Cargo.toml index b9fb14e41b..3ee3f20be3 100644 --- a/nym-connect/Cargo.toml +++ b/nym-connect/Cargo.toml @@ -1,2 +1,2 @@ [workspace] -members = ["src-tauri"] \ No newline at end of file +members = ["src-tauri"] diff --git a/nym-connect/README.md b/nym-connect/README.md index 5a247235ab..bbd9b55bbb 100644 --- a/nym-connect/README.md +++ b/nym-connect/README.md @@ -12,8 +12,8 @@ Nym Connects sets up a SOCKS5 proxy for local applications to use. ## Installation prerequisites - Linux / Mac - `Yarn` -- `NodeJS >= v16.8.0` -- `Rust & cargo >= v1.56` +- `NodeJS >= v16` +- `Rust & cargo` ## Installation prerequisites - Windows diff --git a/nym-connect/package.json b/nym-connect/package.json index 47298fbbbd..7e7824d6b6 100644 --- a/nym-connect/package.json +++ b/nym-connect/package.json @@ -1,6 +1,6 @@ { "name": "@nym/nym-connect", - "version": "1.0.0", + "version": "1.1.0", "main": "index.js", "license": "MIT", "scripts": { @@ -107,4 +107,4 @@ "webpack-favicons": "^1.3.8", "webpack-merge": "^5.8.0" } -} +} \ No newline at end of file diff --git a/nym-connect/src-tauri/Cargo.toml b/nym-connect/src-tauri/Cargo.toml index 11f79849b7..dfaf452dd1 100644 --- a/nym-connect/src-tauri/Cargo.toml +++ b/nym-connect/src-tauri/Cargo.toml @@ -32,7 +32,7 @@ reqwest = { version = "0.11", features = ["json"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tap = "1.0.1" -tauri = { version = "^1.1.1", features = ["clipboard-write-text", "shell-open", "system-tray", "updater"] } +tauri = { version = "^1.1.1", features = ["clipboard-write-text", "macos-private-api", "shell-open", "system-tray", "updater", "window-close", "window-start-dragging"] } tendermint-rpc = "0.23.0" thiserror = "1.0" tokio = { version = "1.21.2", features = ["sync", "time"] } diff --git a/nym-connect/src-tauri/src/config/mod.rs b/nym-connect/src-tauri/src/config/mod.rs index f4d260ea57..e9a46b26a1 100644 --- a/nym-connect/src-tauri/src/config/mod.rs +++ b/nym-connect/src-tauri/src/config/mod.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use client_core::config::GatewayEndpoint; +use client_core::config::GatewayEndpointConfig; use std::sync::Arc; use tap::TapFallible; use tokio::sync::RwLock; @@ -169,7 +169,7 @@ async fn setup_gateway( register: bool, user_chosen_gateway_id: Option<&str>, config: &Socks5Config, -) -> Result { +) -> Result { if register { // Get the gateway details by querying the validator-api. Either pick one at random or use // the chosen one if it's among the available ones. diff --git a/nym-connect/src-tauri/src/error.rs b/nym-connect/src-tauri/src/error.rs index b2b44ee345..cb677aac68 100644 --- a/nym-connect/src-tauri/src/error.rs +++ b/nym-connect/src-tauri/src/error.rs @@ -1,3 +1,4 @@ +use client_core::client::replies::reply_storage::fs_backend; use client_core::error::ClientCoreError; use serde::{Serialize, Serializer}; use thiserror::Error; @@ -33,7 +34,7 @@ pub enum BackendError { #[error("{source}")] ClientCoreError { #[from] - source: ClientCoreError, + source: ClientCoreError, }, #[error("Could not send disconnect signal to the SOCKS5 client")] diff --git a/nym-connect/src-tauri/src/models/mod.rs b/nym-connect/src-tauri/src/models/mod.rs index ded6a479fc..e5076ac5c0 100644 --- a/nym-connect/src-tauri/src/models/mod.rs +++ b/nym-connect/src-tauri/src/models/mod.rs @@ -45,7 +45,7 @@ pub struct AppEventConnectionStatusChangedPayload { } #[cfg_attr(test, derive(ts_rs::TS))] -#[derive(Clone, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct DirectoryService { pub id: String, pub description: String, @@ -53,7 +53,7 @@ pub struct DirectoryService { } #[cfg_attr(test, derive(ts_rs::TS))] -#[derive(Clone, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct DirectoryServiceProvider { pub id: String, pub description: String, diff --git a/nym-connect/src-tauri/src/operations/directory/mod.rs b/nym-connect/src-tauri/src/operations/directory/mod.rs index aaac2276d9..b6c45fa8c0 100644 --- a/nym-connect/src-tauri/src/operations/directory/mod.rs +++ b/nym-connect/src-tauri/src/operations/directory/mod.rs @@ -6,9 +6,11 @@ static SERVICE_PROVIDER_WELLKNOWN_URL: &str = #[tauri::command] pub async fn get_services() -> Result> { + log::trace!("Fetching services"); let res = reqwest::get(SERVICE_PROVIDER_WELLKNOWN_URL) .await? .json::>() .await?; + log::trace!("Received: {:#?}", res); Ok(res) } diff --git a/nym-connect/src-tauri/src/tasks.rs b/nym-connect/src-tauri/src/tasks.rs index 751f7c5566..267c20e08c 100644 --- a/nym-connect/src-tauri/src/tasks.rs +++ b/nym-connect/src-tauri/src/tasks.rs @@ -1,4 +1,4 @@ -use client_core::config::GatewayEndpoint; +use client_core::config::GatewayEndpointConfig; use futures::channel::mpsc; use std::sync::Arc; use tap::TapFallible; @@ -25,13 +25,17 @@ pub enum Socks5StatusMessage { /// The main SOCKS5 client task. It loads the configuration from file determined by the `id`. pub fn start_nym_socks5_client( id: &str, -) -> Result<(Socks5ControlMessageSender, StatusReceiver, GatewayEndpoint)> { +) -> Result<( + Socks5ControlMessageSender, + StatusReceiver, + GatewayEndpointConfig, +)> { log::info!("Loading config from file: {id}"); let config = Socks5Config::load_from_file(Some(id)) .tap_err(|_| log::warn!("Failed to load configuration file"))?; let used_gateway = config.get_base().get_gateway_endpoint().clone(); - let mut socks5_client = Socks5NymClient::new(config); + let socks5_client = Socks5NymClient::new(config); log::info!("Starting socks5 client"); // Channel to send control messages to the socks5 client @@ -50,7 +54,7 @@ pub fn start_nym_socks5_client( .block_on(async move { socks5_client.run_and_listen(socks5_ctrl_rx).await }); if let Err(err) = result { - log::error!("SOCKS5 proxy failed to start: {err}"); + log::error!("SOCKS5 proxy failed: {err}"); socks5_status_tx .send(Socks5StatusMessage::FailedToStart) .expect("Failed to send status message back to main task"); @@ -66,6 +70,12 @@ pub fn start_nym_socks5_client( Ok((socks5_ctrl_tx, socks5_status_rx, used_gateway)) } +#[derive(Clone, serde::Serialize)] +struct Payload { + title: String, + message: String, +} + /// The disconnect listener listens to the channel setup between the socks5 proxy task and the main /// tauri task. Primarily it listens for shutdown messages, and updates the state accordingly. pub fn start_disconnect_listener( @@ -78,14 +88,39 @@ pub fn start_disconnect_listener( match status_receiver.await { Ok(Socks5StatusMessage::Stopped) => { log::info!("SOCKS5 task reported it has finished"); + window + .emit( + "socks5-event", + Payload { + title: "SOCKS5 finished".into(), + message: "SOCKS5 task reported it has finished".into(), + }, + ) + .unwrap(); } Ok(Socks5StatusMessage::FailedToStart) => { log::info!("SOCKS5 task reported it failed to start"); + window + .emit( + "socks5-event", + Payload { + title: "SOCKS5 error".into(), + message: "SOCKS5 failed to start".into(), + }, + ) + .unwrap(); } Err(_) => { log::info!("SOCKS5 task appears to have stopped abruptly"); - // TODO: we should probably generate some events here, or otherwise signal to the - // frontend. + window + .emit( + "socks5-event", + Payload { + title: "SOCKS5 error".into(), + message: "SOCKS5 stopped abruptly. Please try reconnecting.".into(), + }, + ) + .unwrap(); } } diff --git a/nym-connect/src-tauri/tauri.conf.json b/nym-connect/src-tauri/tauri.conf.json index c4fce76f06..b9cbfd8233 100644 --- a/nym-connect/src-tauri/tauri.conf.json +++ b/nym-connect/src-tauri/tauri.conf.json @@ -10,6 +10,7 @@ "beforeBuildCommand": "" }, "tauri": { + "macOSPrivateApi": true, "systemTray": { "iconPath": "icons/tray_icon.png", "iconAsTemplate": true @@ -18,13 +19,7 @@ "active": true, "targets": "all", "identifier": "net.nymtech.connect", - "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" - ], + "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], "resources": [], "externalBin": [], "copyright": "Copyright © 2021-2022 Nym Technologies SA", @@ -49,9 +44,7 @@ }, "updater": { "active": true, - "endpoints": [ - "https://nymtech.net/.wellknown/connect/updater.json" - ], + "endpoints": ["https://nymtech.net/.wellknown/connect/updater.json"], "dialog": true, "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IENCNzQ2M0E5N0VFODE2NApSV1JrZ2U2WE9rYTNETTg1OTBKdE5uWUEra0hML2syOVUvQ2lxZmFZRzZ1T3NWbGM0eVRzUTVhVwo=" }, @@ -61,14 +54,20 @@ }, "clipboard": { "writeText": true + }, + "window": { + "startDragging": true, + "close": true } }, "windows": [ { "title": "NymConnect", "width": 240, - "height": 500, - "resizable": false + "height": 540, + "resizable": false, + "decorations": false, + "transparent": true } ], "security": { diff --git a/nym-connect/src/App.tsx b/nym-connect/src/App.tsx index 10a2995f34..90dd1a4230 100644 --- a/nym-connect/src/App.tsx +++ b/nym-connect/src/App.tsx @@ -1,37 +1,50 @@ -import React from 'react'; +import React, { useEffect } from 'react'; +import { DateTime } from 'luxon'; import { ConnectionStatusKind } from './types'; import { useClientContext } from './context/main'; import { DefaultLayout } from './layouts/DefaultLayout'; import { ConnectedLayout } from './layouts/ConnectedLayout'; +import { HelpGuideLayout } from './layouts/HelpGuideLayout'; export const App: React.FC = () => { const context = useClientContext(); const [busy, setBusy] = React.useState(); + const [showInfoModal, setShowInfoModal] = React.useState(false); const handleConnectClick = React.useCallback(async () => { - const oldStatus = context.connectionStatus; - if (oldStatus === ConnectionStatusKind.connected || oldStatus === ConnectionStatusKind.disconnected) { + const currentStatus = context.connectionStatus; + if (currentStatus === ConnectionStatusKind.connected || currentStatus === ConnectionStatusKind.disconnected) { setBusy(true); // eslint-disable-next-line default-case - switch (oldStatus) { + switch (currentStatus) { case ConnectionStatusKind.disconnected: await context.startConnecting(); + context.setConnectedSince(DateTime.now()); break; case ConnectionStatusKind.connected: await context.startDisconnecting(); + context.setConnectedSince(undefined); break; } setBusy(false); } }, [context.connectionStatus]); + useEffect(() => { + if (context.connectionStatus === ConnectionStatusKind.connected) setShowInfoModal(true); + }, [context.connectionStatus]); + + if (context.showHelp) return ; + if ( context.connectionStatus === ConnectionStatusKind.disconnected || context.connectionStatus === ConnectionStatusKind.connecting ) { return ( { return ( setShowInfoModal(false)} status={context.connectionStatus} busy={busy} onConnectClick={handleConnectClick} diff --git a/nym-connect/src/assets/help-step-four.png b/nym-connect/src/assets/help-step-four.png new file mode 100644 index 0000000000000000000000000000000000000000..55079628923c10eaecf9243a6b99f8124d84d916 GIT binary patch literal 8530 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 fPTkx*p?>VHb2K*s*> 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�t3F0PA2oun6An$m>7S{la;2+sY51dti~ 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 literal 0 HcmV?d00001 diff --git a/nym-connect/src/assets/help-step-two.png b/nym-connect/src/assets/help-step-two.png new file mode 100644 index 0000000000000000000000000000000000000000..f2910c98cf06b4d401e010be1e8ec43871171d3f GIT binary patch literal 35631 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ðBo_Gob}8#nya@ohZG6x&k|PZA!1Hwr5t)&$ArWaq zf(VdF-N{G_$Cjo93u7k}ik1mwJWoLjWJ%UI7f$Og3x8YoA-EghpyUG)%tZDs9yY{Pl ztb-j2WCvByqo-GB-!01s!B_w{Asfsy=_Q1W#+^!Xqe)GNoNp*Nvw*a~aoH8_7zKe4 z;r^+>WfBV{0~Gkd6>-BsD@Fi~G*Bd^BdU$dC_o?taT>YVR9K;4y)eKLk>my7HmSQt zK(KjDZ~TLBU^YLw>EjsO`$)C{v#|z1vx;O*7;Ci6sFoq!GVC5v%QigIMe*8+_#4`U71b7BgWl9d@ zQu%K6+RPi(QiXX+BQ2WOtF_QB6%g5_w3VBO^45-LJb*ZeU>Gk*X79_8?NL)P0bV#r z={xHCEmD(&o@R9tY!aYC^4yqqjX@yDP)RD#nFf-%dfoNF!Y8Z&35hrbi0n-c1jw~R zw1xAfMO5DMqd(w)3VI3rIT39xS*yXMqllp9 zZPoFa*Z5rP7m+JXEeuDMe95BU;;x0H(iR1fDU*yDIC1=-s(%nB zj#B}M(bWK;u|qmIfm+8A0s%7)keZv-Tu(Wi%LAwc`xSuDui2}D0Xr14@7Y^{&h9ef zML$fGe;jh?Xe&lLaIm6;NLlxJveL$LXY|7a^?l>E1`SxsBgWk`NvVP|X^dM)HIK=F zCMSquV||W@`7@*qnHB|>R7^U>{mMLt7S$OA5r88v7u>0gz!ho<7mLh5 zi|Bx0XaTOlMPTr_t|IVYeRXOS=BhCCG@|ff5{6py=!}b|q>Z z-3PFl2qvij#wviLi8)%CS>xI;G(D&Q+7D-(Hl*jGu#SU@>Gvvg^Pq~Zt>4_B{hMc= zZGb%rXzc!p$wvbjlx1-KYR^6#lZpmDhQZH2vquF{>R{8hdaW7h6>j00)9UO&xJO4o zM-^q_4wB~9)M0K!gZHr>MiqZ^ar7cYU+F_+ZaLx!pHE2z>T7;%$1^uIn`Hq+C%eVMopNh;W{3xAKKh z^WnYDP?0;-N^3Wm@VBI(0AN0$H?0JRX!v4Ql)9*%sVGGIoFa1ucOehPmB$tpJtb5B zO1f*JU;?e5j!rfc!GC93gAZz2Hw_np?@;J_ZKg0~AYxj6$DumT$U%!qV9_fUi3t@#I@JINv>GAf_2n?_mp-aUH4*#O5_`gkA;&|gK+0xkdrj{LV@P5 z)h8lmmv;52AWIa~CuTpNcSg51cTnr#yoWbKgdlyC1$~z~N_F8m-SCFjcEH@31KN+l zX^Y37YJi{rss)~0Uay$6tThaVfTnhEHJEhBji12BfnF00udJzGMRwxM2Xd5(KO@UYv!RPXdLl_;1e#k|N@zL>KrY1o# zq6JLzVmbcKA9vDaDO`3@7rgK7`xO)R=~G*GRNz++HNkgp9i^DD?j-}u(a_=r{qWwm zb;7l;?$(+L2`5&+@U`*qtA|Ggi7x^ZOQI5_Bn#No35?dAcSes2bO1cMtQqzmEQhF@ z1)aOTngfwzFW}59rAQ)gUO_;(HU~iWDapEX4lJgtNDz=5CupOyg*YrrPPYJBzG6kz zfzSPxkcNnT!{*9(SH;D)px<8y#W8oHaD3Hf%;o z$)&9z?Ky3V558Xc8>p>LpWh3=d*cCEq(0mCp+8Z9+wN&m=2ElLVr4iU3B%`axT+hj zdrdbip5Ldm62PXd4e+De$H0S6H0$}Vfr0S6sUvUFz5>@{deDMX`*gr${iX(;7dS8& zCO@kzJ&0%~3E?$UR)|&Y#4DTdlwpFlN=!n_g#o0IZ^IRieQk@RPaRl)+z(xUl?ZcQ z5z#;jKnZil-$l+O3;<$GOa{4RnK0f@KnTc!HmA{?DdlFMLsc@=NBk2(<{8V%gKt!3 z#)3J$Fi|_fxzYhN5_qC0F0GiOd`nCV z+NOM>b;>tFpQ%c0DbK$`aPutEL{+b5Y&*mjl)(Km8EF?!5`Zk@OHD}EgmmklLSMpJ z5x@%}-x4)YdBMU(pW5BN$Cy2mn@N<6$;1(4fFt~Q|0zg4cuC0}aMzJK1rTuXibT|o zu+j-gKVnVez>HbT_}ScCg;Qtt!<2~w@XWeKJ=1PdQV8G#SYVcXdl=b5hx@J99faS0 z;{lkYCJ|RXTMu9U_GtLGo5tu6^zmS-4VSuAU}M!<40@8YYw%#gk=%dKWGu{?KA;6vcTYuM=geq|IA*&0JtnwhmE^no483p_ z3dVyj0_e)gsEmw0ecvF~CBh@kD+k$uS_pQwZ@S(mMBgvXYd1%Hz#45gGrSq1K}e_AT52mL2ISXuCfk+Uv^u81a2oMe2UcI5;4B7#&FYaDVD9Q_nDp>Y~y zpy%8*B_Y^(qv?WnD7j-0?jby&P*zwJm8|b_zS1Ii?&)Wmwe&RAP?r;!ipD^}N8Y^; zuD-GpMz;>a4}UfazW9yNDoD_%ANa{m=pU`ySO-rl{|G^ab4h0|>V?)83F|gB>I@1& zB|IaOUD0u<0#harXkQcs8iI&D3xb%)Qp$d(2a4L};3PkeIDst)crw@9Uy~+T0PWec zCk2@Y0B3SRP{yV?zh_vuUo%;4C*tIi8DpwCs4P~mLQnHabekuJPp@j$p;M#oGmGeK#MLcQ*K1kX9>^QeHqGtV~aH8HwO30R%9kFR-U zC%pgd2lV72rr!Mdr^l&l>!TdA5y}y2qis7X@bJ<`m@#coMd^BB_KX3&ufopuIkhfazKqSW~jF`55d8Mb$W*)N7TZBEN_8lBwv)COdcQMLy8=b-1LjCqY-Cb zZ}6$K3AfM!DAYlnF6zU`+CZYeW2X1tqj)+y7jDv+TQ*2Q^4ns7IRKriL)Am@^y)@E z;peUkz#(gc)Zg{5Jp>QA~`EE zk2#Ip*_x<|bM@FsU>(Lr4b;vw5beC-(vkp3g9uL36n7%MVLtI1r{@HaH~`A1td%_W z8jP~dRAJRwYQl5f#zwtfg|=trZ*3cDAR1J3?yAd-=E22j2%djcKF^Vs4~$STVJ_ig zPc=YSkAU-*^r@+XUOfl2eoF(sVJ~!O!dH5 z#_ty7JQvt(|7>zElI*k}#h4*B=y1{Cuornv<3I%_sT0@OV0Q6*HBpCaRJQJ{C-yL{$9!Vu@f%*(q2_oF>eQak{%0F} z=jK+uwq=B#A7RG%YGwZ7ybmU+UT}7=Ub3;X-G#aN7(20u*$s_Ks9}!cer5jk4?52_ z4n0FK?L_2P(`d10GU*>aH3@_84#JqVMC$RG3ly+9jX0Y5jj{1;ighz2n0Tnc$>W3tP%cO>{>*eTU;J4wOlv5D1XGK)0$QUbJLeaFpR70l=yjX%VL| zrQ}+PhC3i=C6>Tv??`}1z~sS-16P7X13F&@*SAccGN`o*CWj6Tig2y(f~gN3)eFz< zhTpmIpfcO4@Xa5!!gp_N9RbgaKq$<YnwM9c!Wqy?jQ zxU2-1i6e+`^T}YrrO{y#bG>`$1?Z2RzfzH4H|>^W+DFnbzt&wJ z=M|aq+`oxI;bG}ju=EE7fZtY$DmD&fvpBATxoYDFbpQaJRkyJPl{QWRd-OSJffTy~xMbofHT5ui zW}n`$L=!XZ=^8I!8p4q;8}ni0?+9W+IqN8BANwW31Z$7AE277@&&>1^h^2X7Kzuy>RfbNfDvah--7H-->YKH61W@(g6JY-e$P@=Pi1b z=m@>Igy!6L6oBshbtBADngs>eH09^G@Nr5~ucyMv*u@Kap`l{eO;}-=@TdUd8z)M~ zmJzjnMtlswbOK>c2SvA0B}5u0*fBEpeU3ySLPqSSgxGn0keO)l_3TDO(9}5djr$O+ z+gPtR;&9q49w5{>zxUe*bV~liOB-~=4YLbI=-AMvZFM@rdFQX2b$I*zZ`-f^Pis8I zf?U(H8yfYqT{OSnMt@PX58{T$(hu)sc5Xmf&<{w-kymCzgOq)8Otp;skm57NE6BW4J z{D}PPVxuZYfgMGylbU#1tP35xl0sx+7m$s7B#p%9L~&=?ZxI2|{)7LWv8V?&ZmHK3 zc(i^OFwjeS0rxC83^!cWp|cA9?Q5eoh(_q8AxuL3tIv*AnxzU?UUFFH98!uc2lSyf zZ?7mby-#m*?8;EE(cdvL1UVKoSbzidfjC9HDu|Mhk{m8F;R;A(z6JG3u$q7>(pdBc zd@-)USS$FY6yo(5vc`FzwHxch)>F>0BtmNJm;q(F?St`Shx9t02cKvhnJ9e;30Ljn zhAaR6^)Ya&nlycjvJ9qA>CcojW)W(kttzsLZ{EYmoB>2_ONC*{gJ?K{T@Vc=XgBy} zBC3R(FL;@{`{Fi)Aeuoyq2Hn_bopx#0tRszK?g&Bm-ae--iM;qT*4WPy5WIGTl7nz z{75D7aS5OJz+UCJ4#GnUIG_8^F=RuG5qhZzmos1l^k?_B=peycuj^2AbND7O>7RcH z4GXh;Vsit`o!+mfAt3UB*mpAD2av%C*fPNO)X8WuneZzMYEks&OgL6HYDi|mEE4tW z%SuV_ko!#qeaeT^h29k8bT#O>92?cU^p|i)6gRVY`}GH5@xne>zoi0y^nWJ62)%p= zBXeK=b}Q`KQ-({au2!hQo>F5;fty-FJ`Jhk7xw$lDd$9r@hF_>t+E~Zc< z@wWvt`2&S*x_mx_bZ@hW7@h0^R|?zv09O{i6C!VKg7b$m`EL|-La>_>`7R|I4(r7j z`=}Ot#mInu6=8pWXjnu&nKro}u6gxgy`ApYzd!o8-Dc`UCCrOkv#t(4`=v3^*f<2& zzUGjAHKsT9-TaN^8{CX?_udMO9)&MzHgiKRq#fpy9J7-pD+y-=5>gOnq$jsW zedw7q0H;7$ze>XcL0x9%dL&(PEt%ire6gUF1o3;!u?Z>=fcus0P!nwU@?1&spUBG# z_44A^TzXh%_77XL-dFq|$>ee%*;eQX2Hv?RH zplZiG4)*V_=uInvyl|$YDYs-npPuR7tS0Mzp-h7V2S<`bU+#qOLAqBxE8N&00W@v$ z0AVNYI`p6bRi@nN)*-EhB85VPG64z=R+@v*V{2!Zld87 z1mngG!lf5<>m?^hfzqs zH0$ysbbJtQ-12KR!Mb`w9bA3IVf|t-Z;C~cU3|$gS|K<&j%$Xzu=PSWA|Vo>nd2@K z+@9f4Y@pFBK`PEF3VSf(&hV9L3D;OQSP+%-KLiY0m4@rh6=RgTMva$fA9Ba|=C_N_ z>CxHQ4?TgeFdWHsJKhK_hNa6J^sYXyIKNB3wo+4)MId44?urh7V?ruhEXTM^MxP85 zYyFuh1Cmibb&q$D{+yntjkMt48K7ee7Xd@x-{@9UOmH`7O4 zeD#{kI$_h+3Ov2Kek9}II3#?5@_mmOzx8cu5`4nAL1U6-IC%G-I{g!Qqg*jQJKgLi z@EA`d&2H*RK0e zD(tzO1j&~762x>&ziM6u2GE2%s8^bDN#T68QO!fj@jMLAD*G8F*vQ5p$2a}=PewuK zVWHmwfx!Z4BXN!|9orpLnrgh#Qn*|kfcq%`q9BkE{A)TVMCWoiyGDB@NM0M=coF{? z=dmrPNX3W@!C5^Sfo;HnJc2JW)|0Ar=D03p(ZZG_L=-^8 z3s9iObC#j*@I5x`vKok!`uBbl(68q4r zU?Fw(0j?rF+~vJQ-xoUu`fUo+)SSWgU1eDQ%!v7RqR`XoS#I1`r{B|r@1jjXVT33y zNM13~%g_uEcPyyK7kNwS*LHKk&6wih8Bs|m7PRER%qpaf6#bxdD z;V$$FM3g-E9?q&4F5cku?c)|nxWaUWeskJL@Zdxte5OmEu7`$td^sBWL2*1dnEtzz z^?`2!XAw0KnPoD%I^sc*JLoPbwggFg=DVJKWN*<1=5nN!2=cntpp;ULSfo~ow4kH` z)GT55?y%nhv;3?pbkW&8dacMqk2k@IM2(G&Fn;`a{YmM-fdjCA|9%)fdNhn1hkFZz z3FD!mAvkpCkUoDR(5-j1z#qJ~QwvE<932?SJ2Si5>tNpOZt&b8IUxLWN{DV15_Pwj zk0d11LOf;{`o*U2{d*yyR&zYMX6oaWI&)3bRy`2n_)-N2K&kH`vtfvj23iehi{aX! zNfU=)%eJy!_j4jpTiY0T_q*Q>ue#!L4W4qD2SvX1&2PiEzV&Um=%NeZcYpVHVDgm7 z{+u>xy1HQb(<|TyKlmZ6TD1y}XIihKs%VP<1@VI35WTfuCB{09Rbn1EDCLAB`YJhki2iR}^Q$%po#1u|!K4tANf26`~%uESdksy_eoSl|w zEIJgNv2YTF?&#L4evhVGSwY_{=gMAqjtXCQVfx4MpLu|q=g_3)Li zd=+m0+0T=5J9g~QpZDB-FRWVm3^X@4`*RHq^|0`?h498V-T)V0e32G18#Zi&)vH&- z@k)<9RS(x**{uN7tDg91A0(hLDddYi2kKyg(o`F^xM_}H&M|AqUK?gayYAIhdca`)jPc>XlOi?r9eg-i&Oikd*3JZ?zuX6dwzmj|{yUbtvp zzkUVA)6X3L=G!&bTmzS0cBwMk8sN9z`MdDM6H9BBM_&m4VtU|#-@xOKKLLOJ*Pnt* zFMTDv=}kAl7rwAV`$oqjJ^VzY_J{Eeb{J6T^E+T!%fzx9PN>c}Wx!|@%zvZVF_Jnj zC9QL+;6P+5;94s~F3jxMod62!soG`ekq^YRkVh^6iS}6(xI3|pxI7}iqL2c|VOIn& z28dMkx26_M-)T$~os%fI(H_1{`*1hD0qywCyv8v&)H~MoJELgB` z0Zf}ZwPqdn-~WII)XEhr;huZ$g++@_gL%rF!#a*v03GZo!Ite67_InW_VfW*v%a23 zlB_R)Z`WvRRi>)IeH2Q}FL7>#;qj`?_1g#!&U!7Pf%Hb8!#Ip+bXAB{@Vj8YS0KBrCc#_<~5SOSwspLw>yZ0ma* z6Pj$Tt)rCYt-#WyPbz<|+n<{_aUy)|V}A%&T=A-!b-dvXZ}I>_pJ&(3U9flWKJ802 zG#2oEU?bxeD+Jb!>p}3nndn6N z*G95|0GK_aUxzlg?l}I}{h;HIA!xjJ1WbKhJ@obV>2rO3y|C`tby|zmqOQYXi(DMj z=9XsV6E!Ht8qjqguY^3XZD$=^cvhc&jg6s5qS|Aa&V|17!A{B(!Swf=f(WNEYzb=N zF~>$kSqG*f%He!3xo93JHsO4=f;(ltHd?hvfg~MZSXo-iXd@Jum?-E=MHV0)o2l$w z3^w38`s1CD$=9u0ucD4U@S4|L3AfyQGd%N*ar}4e*a`pmkN=d+2gO9S*0gC;;Sv=@ zn4}_hKmF-X;c(|+I9>_!;?}IMs9=fI8Yp$hEx)S&IuDnjwWUh33q&G#9dB1Hn6f+V z0Qxj(UnE1C(?Z<8s5q_Q{1(Ubz7yVkkzQl?&icxTG3h&IF^{&V5I|EdfS??Yn|Que zlw<*Ij!pT=I%bVC&Xx@X&*gz`=tD;dmvCz~eF%dabs|rRTK^^?A&-7I^lUGYws-3adHR5{%(v)f7Wk z5NRPH^_)n#=(75{AuUbv^_oJHlsd=g%>Dx>M*0qFpznU?doXI$D7f~zYhd=A*;xSH zdFNgFZ}I7i;p*SIN(COq`Ew3HXPj|{3hy6Qn(P)8&3odwod-g~_oiZm4c~c->wYj2 zH`tS!MaU)ia%0?y)*&muo;u=13EVGoMF?4M9jFaNEwro;Bt&d-CW5L+St*SX3W4g8jUXlH@AUm8kaF((_2rFmt-N6|M|^;k~OI*;xUQYufkQ zbRIPekE}`d6*F%uDgxKrYj$TjQK+x45AM6~e%Q2WlgV`(KMtCknpv4OYu4(|haY|f z{_-zArA)Vw12{BP)#lyCO&eiP`yO52i9o1@_U=>fBt#$*ep;v4jLS2%p5ThkF`v-7PpDn=JAZ)p#(gi_ zK{^qaZuj^1YjX~%uCC7P4ME67)OKv&0lRnY=1IRgt!3!Aj=JRtZwH@sgPK=wsB>>f za1@w-aHs@z^^%>T6!?krV9007*lrLju+Uy&`hhp$GEugei0Nh~okI7R#g_G#!bw5O zy9q74Cf!v>`YfKta<3NgIFWEYMqMXHwHZLAoxu?U_hkR z`UjlHBjesHvqoK0GD(CZbaD`as3fEXhy-wdnrtLknpfZ;e>h%q=AGj)Nti7}(jX#WFEcr8bVlMFRRtm)v%hw z&td9gv@WN~oIDqq_f6TVsO1#%HOY>~uO;8M$AJW{F-0^ZE7|RZ&RQuEdci&=L%Y#T zRa=>ji{iLm8d;K{e~dNrFZa+ zOm=1-PxwsSYNrn*WbGcWy(D;k4ay{xHX2CEq+*$91n}xEj)=s2aYZo?Un(fB6bv}) zTo(L3DDHKHP73>$vJpDb30bYal5)LK4nYe!+1ft{Rs->Uv7Ik2i6Lm}4?IixjA4!# zT5d}%*Ee(C6exuYWN`>PL)7^a$#Fu=?2Ai-2aAvAOK16pHhR=dG@5y>BLF%n2>B2r zYXgI3vNRC!QUvJbEqEkEXGO|@8Oti();DM0(6B-P9xtOUF({x!3;7hA0GgboM=STe z6OaX`yAcdw*Vjn0-rr%iy24#cN9e>S2M&CD18y2ZSeTW=IikM)k{80BBrs&eMi1NH z8bX;kOD$fykTIUfTe<F8*5`>dxm^27PGt)LPX|SqHxY47e9<&iU zu?dqg+Qw9k2I>ic0`CRcFPN!~Tb$M8ItwHri_6unfkX^ICZIZEZcOMt=@0-3d&`3B zqM6&cnD^8`r|~p3R7s2B<;0lvaHvygHH`x{;s>4hgi9IlbtjnncnGuf$vjN(Z7_>p z>XoU(mfG?N!k73xGErOh!{&=REtcIAL1auWwxSOJlEelpBJ_vXhx8y2F=8Tkj&M6) z+}2kkTyQ%Uiw_Ky^qaczeL$zq8hpt&l*fFvv17;T1#C@CO**4`=gyrvuWf{$A0c?~ z^_~b0%);J((7(cpc>9J$Fo&|chcQ+t;E1gIk?fT4KV-&l$~*HZWnb|~O*!2)K~PS^ z96%#;z&ahjO*@#S(x#ugq9y3;7CQ5m!xWfLxNbweQt7xd^`%|wf?3ml@t6NU_|1d& z!CiOX0k{6-7I@${zlNXO_G7rPq?mDI%sy1xx`O!0#AjgH*V&0RGz^7=4rZU$_pz}*8 z_{~JB1O^Jm++cNr0|fw!q3ao5N!z1&an9)yAfl~*T^rJN8dY^&*}S2yny6TFv)gx9 z^nB3i3tl=i>8)>j3oKYLA8x(%Hu#G_|4YAg`bR&!Sp%hNcg4r=GiFTJ+wo4HK0|Mg zxM9Nvy==iPc*B*Xr!QFyTQ+ai>wqr0@B#(20a&wk4Xjwc(wCb%cP>nwIz{i5fY+RJ z&RGf={jhV_PI&M)5BWYID5g%Gs=#~(EL^xiIoG`kicSiYLwX{0P#wV4g`LrwALkNn+ES;ZGBZyZvK#K@ZPFKuP_n=E zt#8)fzy7taC;JZ2r3mMob2j|%Kl@Y7thnHwo;mm*9Kf`)?Il>V+$>yaGP@ zhaZ9E%a_BvdGoY3LDLD#e)X$ggD?HZmvx<2Uim6`$2;GyHyxQXXO7;Dbj+AG{rUHQ z_Yb;0q}j7)!-xLI?q*4s>;Bb=ELsMex_HdBF&gFLj@`hz&F0}O<4BiQ?O|9 zBKXW_{$B5?eakIB)*FqyO=-s~uDC)2>W4r05i~V7!QcGNUn!I9({Rnz*J*%Wea%(c zPy7D&e+YNoc^3>RP>oiaXWjaBaMMjY;oS4i)td8f|Mt`H?6c3pf&B;ISkr{DLrMb; z!dm6??%2)Oe_6{-^Ut`pAwB&uI2govNYJMCn*o=E0#j5FKiy|LaPWAJ7Br2>0@*Mz zf4xcQF<2E~mm661qs_7ssagarWQ^|7^U*Fw2ehJXBj{{Uy6 zb%qBJ(yxB?YrQ9SXJ@CJ)vKH@v5gKGz6X_-XN~1+|6wkuiZTdiGgVA-s!8&EiAnUlGZ72}0Sls}ZUC^yx zaeu6-tGip9LCCOZ_7sJzr=2oQg|-_NlRxW^p%1cq_ipWj;6{orV1R@m=^rq4;j=?O zC`X$%Z(@7Fb&|c`5J+RjjMh8$y#Dpq`f^jIOwoWFH-5Yhc3|7fm#={Km+{6U=bYZB zGgCHhtt%{pyfc2Rn$Fl?foRr)M+L}N$#hUo8wn^E4`+yCKxRm}EU#j)7_wR>umh|a)f;5Z!#iM!%kDU*=?VWjQw;Vun& z^wBV~S40S|BkhX%V$rE;D&v&NLvT=8BTubt%=4k*6}{nNNouPlU5F_&{E0+(F|JaQ z$`f{#(Qi)qT{>zZC}*pIfTNOW&Q7%vZ+vJ&O6zp!9ruS|@BR`k=r6-)-Le&paQ<1n zaKTx9uypyc-9-I|Ke!pLz4ltT?6OzFzkdF6@Yth|t8nvP?Q;w$y94)c!_5(&dirU- zIs9in^J)0C@`doN320iOPlB7tztDtX^2eWeLi<4<`q1ygtXZ>Q*X~`~gquHqKK#|E z{#peg_QC!4Jpdp6gAc*)|K10+Uo@y@rsvH&6~6h+Z|gAorcIk+$&w{-oMIYZvFc8@IE!k_=!(`0xrGu6>#yz7i+T$Cmip->mIo6 zwm$vJ#lQc%Pb=me(DO#uTzj=DGo&=r8u*ug{yeOWoc9-k(D}dlN4IFr_Wt+37yi#h zf2qFr>FC^w6)SZa47>m6M>nhB#CUl7JKhGDDj|S`J`sk*k-m1**YtM7zx~_qfLFiz zO89RT(L0s^y86m)9a($mi5ePEV5Y(VY}dgi05`cQg~)&glCZTo;rU(7G!l$ae@D#YNcgdx<)9q)gdfFRLaGZi-UhXD;l6 z$DeA{YCPO1=s63#aLR-M`0f{Wz~*fg_=7*6px>+cQV<3L&?!f9`&Y+=0SEj;^Y2*h z80rEQ7}@LS=t$1vIqVnuYIvUdRtP%OT*tc43+4{McmB=nd-uLiPBr`b_(`Xr- zCwj~455Yb6kAmHM%0{q*dA$kpZA$bd*nvVnIRoxtFlZUO<^)r~up3gt84D=W(Sf)IH$_HI{i z32y&I3taQ+F1YU1-SGLF#uV7RFD@a2VYs&z6#?tTrXw+aEpQ;1j&xry2HkjVm(Fdw z_kmG9y5@KZNcb9GGL30no%CH&C6f_T^#=$a(@!CyOa&*Lsg_9t0?y-*k@W>yIXEpM zL>3LKIubLa?Io4dNF8J}pbXdf{5yrDz8+AvZ3)J=4U(og z4kL8@60Sad)x|x~)F9!GU$%g?%)H<=WHimRi36~^y{t@B%N>?cWRgLhTb zPBn{opC&`Q$<>@5DLFkQsXoh+WSyjyWw8e`@q6O05&}q21rJtBfKwuvqS4$q#7skX zLm0qXx1j+xZLia6|E0{08=)75&Q^0j_)b(@va)h*JxGv|>M;C;d#Gb3%I-b&FjO@f zND_0)Q^Lz4iPbTVl_H~f1OtsEZAg-?U>94Eh1%X^;>~f;Nb28*$|cnrf@>h+Gzou= z^lj-#oSFsSujYd=m@s~9)n9C{@^xdn&u<=U)M@YMp4n%2rW~Q;kub0E`qy>qH+1~; z?onzwqXaCRh9nKIOq)Cihq}tpsppA}(DgP}M6vY(iGp$<5|O4O#YK8qtm#UjgS?%z zwy?n+PnF;oIct>yNklM~(m}$##MQmHtEA_7I=h4lRSxP&RVN%IK`Bn4-v4N`-YenK z3wq(K#V5vF2~I}BXLqp*U*p2LJAd6`_=8GsYu;db&$KB6aNuBBPqc?rF%kGT;zc(n z#CY2xLvo4Fg9)TOWnI%90TgYuHx((c`%D)gK^rqKV~_#UTWM`}&~V8DX4v8eA}via zXhu>N0ocB`0xQ?lYZLC`bNlow0Y~WgBGf*wxwJ?5HG}ZT(q?7C6~8>7rNw07cPIgi z^FNaCj3b$RA-|bP<@}qR+Tq1=FYOW}5LP1Govr9dY#Hpr%sB}p7N0xkZp32Ug8RUs ziatMf%wPyIknlOyi0eR}Sl+0^mRDTT0}JPl_(CrR-Ersb@Zduaz>7h1XAbBI@gc>? zw<+_jyN8NP&bX&c8q{-sXrWXii>Epq*-5lhc-LucqDfY~uqtvguYq#TzswX6X_DQwhGKjSiYtKo?6wQwa_K!jcB14gD_(OmpHu8gnsKQ zl=(e>&H()Sp=P~&0ZlWv`jq-GxD(Ta@q;=-i_;uoqh0~B&_<#Zz2j75i;x2JQ1H{E z)0#@Dm6xwr)-L5Kt44{EqrMDngytjR((P?K%X-D(m{CIkp^?horXq0Co*YSQ%$ zuj_(olaAkagPv@3;aR=#`qy@A0Ni$u(muU}U5U0pnmTzvXDGDqsaW$3$z~vcTO{YS zN&p}g(6yGPvq@Cfi<}t

ytMDLR||Y3daPjb=K;%1JgJbeH@f4N}$Fp(aw%Ov6>G zb#<&G{u8sf(Tsa)RU<4sbzsC7dJgn~556D1|AX%(?_PZ|2{pTR)L?~)dCyU=SSby z0cVdyeV8O02Xnn#^dSCzm(C>2!$evJp{8X z=?Qay$kzx}Cgm<954x&0Y^l?$578egm4PI%28o56jr{zVqm(0B2UlOw1D9Xi10ys% z+PZZcyzQ;;@F4o^=l&l%|CO(NRsX#Zgn&5Xw0<4BUb?(qFObyP^n$w>`Vp&tCbX#_ zk`jg+x7F!vedi9~7He)>DML|+n((2uZvyxTk$GAO@5PcM5eBC*&51K!_PrtF5I(s6(2$rv|hoAqV z1^U!H(*OD&9WZasOSgaA@kR)u-@58ry`Bm8UU)GG_q$%aupi$4j!x+5E5RMVYK4_+ z8@!-)Pu_79CYDZ_JgBoy4s?X|VfboC{ik^-SQ=jYcs8bx2}uA&n#UCm884FIP2}(- z+1nY+dnR*^RB_^gRi_?LGAR$lj00jzh4)Oq5Du023z+!o=}pzzE)* zJ^YK`w7_FeHR`<;{@`65dh6E_8XjTR_y<4m`&!#zU}5E|r}g~LKYjN9!Ntl)dcK64 z#b5oZ9yn`pKRo(m13bE{L8ojXc{ywu7+)?tzOAY^PTZm*t%HLlaNu%2u-gQYM|COx zNr?AuciPC8rtIO;zXc>3jD86M+-Ry2(;|M>5+bcw4O%0n2$>w+k)|O*G)c4OSDxLycN zI@~Vn2g)~kzF@lkwO#PLH+I5CWs=`|XRF?ICrEY9M7ZW>&Wt|Tsmh=QVu~2E&48v# z{A6t$*Yp=jskjvk_&M6 zSqr{?2O`t%x=l)A^_xxiOf6IexxNW)7JT_zWAr`z{_l3eMd$MEc1P&Qgy4yZf(f+~ zg6XQ?x++^XrdM6m3m<;ZAw4bequWQps&)0Lxo>}$Rg`Ylv;jRuv1NNcwZ~gC-e!x= zfj8F3@hmrI^pnUd3V&v>X zTH$XD0{roZ59p0xe*JK>`fJj=?gT+jU3{V6Lb%}6ex<4E_4dAwJWqK!&i(R?*I>_4 zheJS)k#m!*aa1{&h3shl?F`&KFDB3O0rnZ3ldTkGIL>ZfQX5Y)^Um3ECZJGJ=2<fB^w_;*&NWvi5ZF>Cy zcU&4@ogL$4fJB4+H3_Ads1)ieFpU=B$WKm*NF4^gcD$c7?jwZ~qD6Nye~oQxs48In zmWrB77=Sr52VniC`ry=g@nk+RTl?Q`YJ-3M>vp*I)!llH{l9#*4Gwo5jV?Vg>5_{t zt$F^ThaT2Jh2fZINEpS733J{4^5X~K2IYt1gz4wL(x!J3^*h)ShuG<$`E&a8MC+3) z8g#BBhO3bs491q zT}**#f+SK+sFh*)GmUB@u3u*;@GyM%ikb-L5B}@BZF*baH>%0IH(z^LZ^$@8N2Vj0 zaYzUpjNIik*8AVy39mT67lzbu|H$4VzJPl zG65kk2N~^Bq)?m&Oq5r~4N0#T(L4|_rQBKxcSs1o_AXhf&0Z*MuQ%7akaRG|{Dc)i zs-g)nF)N`8VY6=&?+|($;N421ZQ5D~XD{m0Ylhf}5VN2?X@S4^$MFg{RrvS^ zJKztLGmE)ZBUGD)W5(gU(VbB_oGg9UTRP$0Z#|?ZzyJ8JC&G#~joNxZ5|PP`pST+J z)vxG+^_%PTh9a8x1W6hN%0D);=6Qwkn)yxnDgc38N2XgS9|CoxA@C{yFtd@GPU*Wu za(JY+rp!%7h0Ivju)95gA_NQ8Ot@Ko6E1wIw<5AC7t`Li?NqbV3QV(38PM;P(R~ok zfD=BjgS(wBRrdFnzSX8e;8l3X4V~IA5+jyeO>$;jEy8g3CqDUc{~N(`#VdN$WGRA2 z!gp?IRTJE}$D6mog4VDf=*!MiljM7qdADj^gHB=-?%|Qxv0n*F*wbj-**DDn~w z8#r57kh-uUH%wr5PqE;~%|%eE%~X*#dd}>>>EB0?*BAzDK_#*gaLv zIwj9LxU&*&=ZhH!%U0rUq-7GGyvMI093?ALXb}hX|}2ZiwJ|nuj7CaQD5ASr_OIN0RvZ;^q_KC2_XYl=rCtl zfoIy3LFiLq?!(<>(g6UP)r;qMsmKlj=A1M7;BqxLfyZ%UMj9?1p+KmCaCcQl==1#W z2S0>!&pn5gLlB+0pdGHe{FgfN_TN8gRr5WqCTKvO0~OBYlF*5z=*zBI-=HI`Xk?V4 za&)3xSVUAUETr`(gx4sX1o@Y$Ew8zvlCC*!wh5o59a}BnO|B7e~{g*B9 z<2zb)R){mb{PQ4I$;9#IMQH~*%KDv9n66XOW;k*9gRTX8K1}f zK4Jht(d0>!6o3{g6K+otfT*^i2n_G{p^P2MCVG$yl;|`CO}I}YIRw#il3pXUd4`$? zHyoclVNjcL7{zfF64|2HoM84ZI;)*3nuq4Y6|d;kCg3I&u{+!~qKPQs4yG7P=uklV z+-LvU2Mo|Zib1q@-yZnNSH7nAHfY~hG4?qTtJJ(2J4V97d3}1R3ThvGouhO9={4Tn zA(?-SRV@D_uYN3Y}VB+n52YP-Due+=lnn$0hw0J-K?WcYRe)Yg1CBQ1&`F2LQJ%qsp z%xOf8vwB^BM%_%14&+4 zBt1BQ(warY{IPw>Yvu<@41x@4Xvdu|Ih&XGR|pDR20lrd2ADR2D;Pt)0dB4y%(dFR zN6mQeEW`M5L-0xkqG`(c?L2IbkC=3Ms#lS=t1j<>kA0v6W{kTB9(~|n;LBh7BHaIQ zht?+6+fBR;FU%9eN814uFkN5*`MaX^m?_gk1g$*4^D zEBJAtqcI{DF5Kh7Y~# z5S+eX0B*mx1-|f&F?yb7V4%c>A-DjMIfmQvo<6@{`#w*piSPplONLn`d4_(0ba+l# zCqx$cJ+ama0HWXc_q6V${m71UR|g-+0mxq~{17-2tU&t4Zf3c$^1Mz8thhW~-SbxCgX7maO2S17JflZd z;qLod;V|wzYSTqrRMiVeV!j2?ScWE1G-kR9x5Y0v-6$wLRHg9zm@5}dM-j|62^k9a ztpPM&YoI;cM{yt)M4B)yAjvV2To*bHg#gl5JF}UE7n6S4y4?o!ye$!FANqT6P-@_y z$$@Wj*smt`1_qp*3~S$GXBWfSPe0QD+jf_=-GAode!U6cjA=t^&Z44yqhtAo@MA@I z`#2GL*6IB^XYeEH4}Y&!{@~}oIu?FPafL}xif}bR^xffW(nPC) zc1J!CWl07E--c@_ZGvhRdeU4jLjZ!d`=Oao%S@foT81 zI{mf>1QANop577+RROrEA;|SY0}V5XfAL_mUc29{Ou4fb^})5T?opAxssa#b#~Cvm zj&~mk86MwFd%gu#)(?l2AbR|%CNOW16wE9#I0|DQ=4gTiM?COQCh}nzz^K5D4x$FqTUBMu zG7(F6TBk!Rtw_ZtKtI9RjS5{NFAX2l$s*5kKIBBLgwPbAz3{pu^b z;Jh>XVBx9#dZ!r7-@5nyCU{as@pkO0_|4Ie7efDT){H^@qO#Xr)(x+_yjQOb#wFLd zH2$``TC@;B%K5J7L>FCj0ph;Y@$xZ_;w;Cw+VK|7m;mK#4$#q;) z8!h8OCz1;o=&;Yuyq3k~+~E>Yr``m&-i>~%xBo7IS{@U|4eHg8*xuG1bvhRdFRk*> zjU6riTWT|r_4FYN5F` zWzwLQywj!*>NWeleeQk)c|t%MHbP)v$h@0qnPN74?cn11eR^|yOi)E>-qX_!>lJt~ zHE6Z+9agA6%=+H8qkPOV957=5`-c0l&6|St+7gF)d}h-GT70I(W*|0lmc-nyl#W>`+q% z{r!SP!@SfN?m32yJ)|8^H-=R<#MG>_$H{rd#4-ky1p4MVFZX?l^eNqfNVE^v(B18}C&a`-0di_eqVWa{oNDaI8#x`*>s_%0;$&v24!Hd-1_Ur{jCp1VILt0zwyIv6 zWGmLxYvF)PJur-nV8wYcI!`9asOTdWNohi*)RU_ieVn5vrZK(g!N*$^z)e1($VA2x zf5|-+`h5J+93Xg|g=LfLIB0{wO|!6BkOBZ|1rCbZt0YH7TFwou_xMEZ`h{?s#c*o8 z^%WNo?m>o|1;{ybq;J;7r5ea6mY}bMuiLS|6B6kjl}@9e)_G!ilQMkck-(%^E#=NUxcR3F|q0*zCrPOuS2JtV7BK+od#1yD~lX{FVv;3|37#Q{Mm`i&UdH zZ4iLN&0(D%P?AbJt4YzteX8v;Jp4ox?C+@PNMm^K4ylLK8x4u?NgAq7K_Th$27b=e zNh&ZmXPzuuM3)i;Qew#zT?9<&*n2TlNgE)h6p+eAuzz_KR6TRF`+Ji>Y+>m^mTy-A z;dOJdeyAm&nF#J0KLAvT3i4s=ljPRvfVnhAl=>*ykui3(*DF(}PTTqCDPLxAkx>4E zvh#PD&@7sMRGtKhK!uD=Pm$8!7it;|r}ya281t-sPaW+n0AaE$QK^}em?8`X6;zl# zz&EfAU+MF}Pu!ag{W|okPCK5#5aiGDI z)gq1J%7y;MnuwX3wnAqB>PC>(%oq~=?@3<@nk@@rhBlQ*SE-GaJ4ISmUG<{T%Ifqqf03kzi zVdjtbReX%I|<^KwtG)W=hk&9AEUd+_$)ld9>~3m z#B2=G)BFyrFS%br>j@khNbe~wxYi0;8`cUau~z+e{8g!7@+d%a;}Ep9nMfioiN{@E zx9zGkS?p1pcq~jZQ?N(cwR=G98M-bA1B{a38qkVR=08dI zkA6ohF`lf-!`~P!+oXaDxOW0h8cvy{f&ugU^;S$BohAYGkcy;X{+Fbk5|U6i zEW!JZRC?z2GHOs z$Je_jNmUovdTS|qdzcE?mBkvyUb|sm&tH1(IB+gRvEfdBL!W0z%=;s@px zy2t$W51PIT*EjZPMfh;S1|2cUg$EsKzqU#gZ_5;pNv~z=>oNCjNCO8$;Z2Qd{;0uZ zYhaoZzCLDuM|k-X?h7mEqP8Fpz?qufG;!la`oeP7BJgl+KmrQ>xeB9XB!u>Tap?uN z=UF`*g2{1@Dk5N9Kb^(JM=B+chxCjG8w(=D0zXs`$xsO~=aZ^k--ARF1?C==w5}c3 zJ>QnA*C7A^=vGNYsW7c%m50}s&ZKcq!*!{eupt>v5%)qciHe&Bu2@@!hPrws!H0AT zKhBf1jTzMY)nWEB&XM2?!f;c2+zF_!uN2h}{+@|Nmp(M-!~L)$AZpZVg9s^rbCu_0UVKdYqX4lqqJBpqRz+SK zNGc=?`a^AJnn&lDowoyi#5~(NjYa$5O$%EOg!~4is&pCRrh);xP6$(EkXlqEtEpZw zctcfBM&i06G^fTZt$^WTWc7;D3^+lGYm_kipgLsg2zP1-Q;+ZsZrD^)Lsj?0EL1bw z7h@m;KQXw0fW*mD%zD7=2Mi22+dn7S!%UeSn8bt>{vmijgam(>5`r3x96kIjc>IZ? z3XcpP8I@(xK6gM6l*Uz(Vw{3jLMjL%L_HDg+T?UvJvmQIxLi~(Fl|l(4GCD$v!4M~?(A%fOXp>qry_w?YB>586M#jNKco4J6d7|u0e$Qi-nqhC_;G4Mkf z4|MP&1pQ4$0{j?}$_RKE7$5+SX`f8_)IW-l#=B|mjrv)>z6>H_@&bSt7180m6bLu1 z!`LP!9y2W&YCdi?`xUC^34P%V(GCJ4?O}h2%+TdPLB2avEFL<4PFe{oWJdbR6iDQ| zleW%~3CXPpo+^uy6(%0J`;|hc)#%Aoe=w3AxJ8tQeGxP&jlFbY>Zfw2m(>z-@pJ-b;ZAVdC6~<-dn zRgyk9&4d}w*oUf9J|dL$8!tr?I<>p8B%Q*?q)bdmt$M(U#EcSTNCfSs{UW^55l-4j z0#HGQgyg-^d(`d8ppE%>g&URjAjg9AWg!7ksK-4M3O*OuG2qEDCh+mpBUoaBL=w{x zAl5vto&tuT6$|j@E+fJ3lk>SPLLQR~oY*K_CQ6mG90r=^5y_a*Z72OO5l4hJ!Bfj+ zVIKtVfqy4uZm5C~2oIXybokdZFrFt8o6e|Hf6LYu`bo_t$`c~=9Q)(;(5u>Y4@|as ztxPHq<~}?~Ag$NkBoX|CQKe5w5(>1YZNnBN$H>5?i+OH@OR9dRB^qD)Nw^UrNiG4H zHULRL%InINch^J%m4L@}muthg*MfA;iJLqzQ&R9Y*m)3+U7#ZP877&-g(2BAfQN|Y z=&P?%kV=4SF`6(b3PRWVL(r>(Da3Ays3gaCZZiuCTxv!-NmH^6T&0EB6ABxd=#aHE z*$dL=gGtB|i4;1J2n$e(HA#AI`X0o%WF_d(*wPNjR=c_<10?DRAXpTX?k^y~fhqJT zm}wjoSxL{vj5Bw*%aX6i6^~L5GxtZ@WXJ@s$+*C!{6of!@rKB@ksb)ol()eV?=0sE zJr#zABZ8ZCfe~oqf{bg@sRSS40Q0LP*A|Z9Ee!kv4@2Z0B(K`kBSWgal&sG_9Y$9n zNZkM|{m7mNavaN6oz~IPJ}43qE&?w3@Mj6)ENjQcsGY)c1IyryVclndeZUX#d3eQy zLnY~;1DmwIl&#a2mCU3PtUBo)5=1IjO6CXAeseyFgh#~1l8hT?S6U+#xKyLbXsjTQ zE6IPd`XejIWi2>C9BU%2fueh0L*|}8XYvHQ>|!SrvlIgi0BTj|Oa%ufnQ2gD+7Onp z@uF&#W!Gh10Lpk! z*^QY438Y*fAedLCe;ElWoXIBA2H7Zu@4ieVv?N5&j+$Za!Q~h|LBHfe4>*9MQyc)2 zRFkP+<_h>*vg4USV+wdlKJ$PHZITfsKjNMwfG@ZQq)NqPu9uYNUN&L;cw3V-5i%7X zsgp&;MErGeTG95?_&#BLUu z%8q2hakvu_X_;_HfLEo|ydi=)uTiPgrd3BKB9YciZHruA%ojoWX}(IbKE^zG>VZj8 zH`Rptu*fi2OoWkL$0J2Ww6O5&n`DiuTpGUQh)bI!zhtac=@`&!WmJb`MZ*{;O-Ezq zZxG;!LNiN|StNLWvTfCBj@?B13^3R;e*6TOGI`R$p_2T#E_(GF-#EK8B>q`_y@58s zzZ5qlirWw8kEp;rccit5{N8%=Rx#f(GUgM;f49yE1LxTUc}$ zhDp9pvPOYn)G?g%i76FdEOS?h ( t.palette.background.default, - borderRadius: '12px', - padding: '12px 16px', display: 'grid', - gridTemplateRows: '30px auto', - width: '240px', + borderRadius: '12px', + gridTemplateRows: '40px 1fr', + bgcolor: 'nym.background.dark', + height: '100vh', }} > - - - - {children} + + {children} ); diff --git a/nym-connect/src/components/ConnectionButton.tsx b/nym-connect/src/components/ConnectionButton.tsx index 0ab2ce1fa9..42903a80ea 100644 --- a/nym-connect/src/components/ConnectionButton.tsx +++ b/nym-connect/src/components/ConnectionButton.tsx @@ -19,16 +19,16 @@ const getStatusFillColor = (status: ConnectionStatusKind, hover: boolean, isErro switch (status) { case ConnectionStatusKind.disconnected: if (hover) { - return '#21D072'; + return '#FFFF33'; } - return '#F4B02D'; + return '#FFE600'; case ConnectionStatusKind.connecting: case ConnectionStatusKind.disconnecting: - return '#F4B02D'; + return '#FFE600'; default: // connected if (hover) { - return '#DA465B'; + return '#E43E3E'; } return '#21D072'; } @@ -81,69 +81,62 @@ export const ConnectionButton: React.FC<{ viewBox="0 0 208 208" fill="none" xmlns="http://www.w3.org/2000/svg" - onMouseEnter={() => !disabled && setHover(true)} - onMouseLeave={() => !disabled && setHover(false)} > - + !disabled && setHover(true)} + onMouseLeave={() => !disabled && setHover(false)} + > - - + + - - - - {busy && ( - - )} - + + + - + {status === ConnectionStatusKind.connected && hover ? ( ) : ( )} {statusText} - + - - + + - - + + diff --git a/nym-connect/src/components/ConnectionStatus.tsx b/nym-connect/src/components/ConnectionStatus.tsx index a866f8521d..c2bfcc82f0 100644 --- a/nym-connect/src/components/ConnectionStatus.tsx +++ b/nym-connect/src/components/ConnectionStatus.tsx @@ -1,12 +1,10 @@ import React from 'react'; import { Box, CircularProgress, Typography } from '@mui/material'; -import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; -import CircleOutlinedIcon from '@mui/icons-material/CircleOutlined'; import { DateTime } from 'luxon'; import { ConnectionStatusKind } from '../types'; import { ServiceProvider } from '../types/directory'; -const FONT_SIZE = '16px'; +const FONT_SIZE = '10px'; const FONT_WEIGHT = '600'; const FONT_STYLE = 'normal'; @@ -16,39 +14,41 @@ const ConnectionStatusContent: React.FC<{ switch (status) { case ConnectionStatusKind.connected: return ( - <> - - - Connected - - + + Connected + ); case ConnectionStatusKind.disconnecting: return ( - <> + Disconnecting... - + ); case ConnectionStatusKind.connecting: return ( - <> + Connecting... - + ); case ConnectionStatusKind.disconnected: return ( - <> - - - Disconnected - - + + You are not protected + ); default: return null; @@ -61,29 +61,22 @@ export const ConnectionStatus: React.FC<{ serviceProvider?: ServiceProvider; }> = ({ status, connectedSince, serviceProvider }) => { const color = - status === ConnectionStatusKind.connected || status === ConnectionStatusKind.disconnecting ? '#21D072' : '#888'; - const [duration, setDuration] = React.useState(); - React.useEffect(() => { - const intervalId = setInterval(() => { - if (connectedSince) { - setDuration(DateTime.now().diff(connectedSince).toFormat('hh:mm:ss')); - } - }, 500); - return () => { - clearInterval(intervalId); - }; - }, [status, connectedSince]); + status === ConnectionStatusKind.connected || status === ConnectionStatusKind.disconnecting + ? '#21D072' + : 'warning.main'; + return ( <> - - - - - - {status === ConnectionStatusKind.connected && duration} - + + + + + {serviceProvider && ( + + To {serviceProvider.description} + + )} - {serviceProvider && {serviceProvider.description}} ); }; diff --git a/nym-connect/src/components/ConntectionTimer.tsx b/nym-connect/src/components/ConntectionTimer.tsx new file mode 100644 index 0000000000..25e2af8d9c --- /dev/null +++ b/nym-connect/src/components/ConntectionTimer.tsx @@ -0,0 +1,26 @@ +import React, { useEffect } from 'react'; +import { Stack, Typography } from '@mui/material'; +import { DateTime } from 'luxon'; + +export const ConnectionTimer = ({ connectedSince }: { connectedSince?: DateTime }) => { + const [duration, setDuration] = React.useState(); + useEffect(() => { + const intervalId = setInterval(() => { + if (connectedSince) { + setDuration(DateTime.now().diff(connectedSince).toFormat('hh:mm:ss')); + } + }, 500); + return () => { + clearInterval(intervalId); + }; + }, [connectedSince]); + + return ( + + + Connection time + + {duration || '00:00:00'} + + ); +}; diff --git a/nym-connect/src/components/CustomTitleBar.tsx b/nym-connect/src/components/CustomTitleBar.tsx new file mode 100644 index 0000000000..dc4e8c0061 --- /dev/null +++ b/nym-connect/src/components/CustomTitleBar.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { ArrowBack, Close, HelpOutline } from '@mui/icons-material'; +import { Box, IconButton } from '@mui/material'; +import { NymWordmark } from '@nymproject/react/logo/NymWordmark'; +import { appWindow } from '@tauri-apps/api/window'; +import { useClientContext } from 'src/context/main'; + +const customTitleBarStyles = { + titlebar: { + background: '#1D2125', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '16px', + paddingBottom: '0px', + borderTopLeftRadius: '12px', + borderTopRightRadius: '12px', + }, +}; + +const CustomButton = ({ Icon, onClick }: { Icon: React.JSXElementConstructor; onClick: () => void }) => ( + + + +); + +export const CustomTitleBar = () => { + const { showHelp, handleShowHelp } = useClientContext(); + return ( + + { + handleShowHelp(); + }} + /> + + appWindow.close()} /> + + ); +}; diff --git a/nym-connect/src/components/HelpPage.tsx b/nym-connect/src/components/HelpPage.tsx new file mode 100644 index 0000000000..788f7264d5 --- /dev/null +++ b/nym-connect/src/components/HelpPage.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Stack, Typography } from '@mui/material'; +import { HelpPageActions } from './HelpPageActions'; +import { HelpImage } from './HelpPageImage'; +import { StepIndicator } from './HelpPageStepIndicator'; + +export const HelpPage = ({ + step, + description, + img, + onNext, + onPrev, +}: { + step: number; + description: string; + img: any; + onNext?: () => void; + onPrev?: () => void; +}) => ( + + + + + How to connect guide {step}/4 + + + {description} + + + + + +); diff --git a/nym-connect/src/components/HelpPageActions.tsx b/nym-connect/src/components/HelpPageActions.tsx new file mode 100644 index 0000000000..17e513120f --- /dev/null +++ b/nym-connect/src/components/HelpPageActions.tsx @@ -0,0 +1,20 @@ +import { ArrowBack, ArrowForward } from '@mui/icons-material'; +import { Box, Button } from '@mui/material'; +import React from 'react'; + +export const HelpPageActions = ({ onNext, onPrev }: { onNext?: () => void; onPrev?: () => void }) => ( + + {onPrev ? ( + + ) : ( +

+ )} + {onNext && ( + + )} + +); diff --git a/nym-connect/src/components/HelpPageImage.tsx b/nym-connect/src/components/HelpPageImage.tsx new file mode 100644 index 0000000000..27a8dcdca3 --- /dev/null +++ b/nym-connect/src/components/HelpPageImage.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export const HelpImage = ({ img, imageDescription }: { img: any; imageDescription: string }) => ( + {imageDescription} +); diff --git a/nym-connect/src/components/HelpPageStepIndicator.tsx b/nym-connect/src/components/HelpPageStepIndicator.tsx new file mode 100644 index 0000000000..b5dff42b34 --- /dev/null +++ b/nym-connect/src/components/HelpPageStepIndicator.tsx @@ -0,0 +1,15 @@ +import { Box } from '@mui/material'; +import React from 'react'; + +const Step = ({ highlight }: { highlight: boolean }) => ( + +); + +export const StepIndicator = ({ step }: { step: number }) => ( + + + = 2} /> + = 3} /> + = 4} /> + +); diff --git a/nym-connect/src/components/InfoModal.tsx b/nym-connect/src/components/InfoModal.tsx new file mode 100644 index 0000000000..ed628de7eb --- /dev/null +++ b/nym-connect/src/components/InfoModal.tsx @@ -0,0 +1,69 @@ +import { Close, ErrorOutline } from '@mui/icons-material'; +import { Box, IconButton, Modal, Theme, Typography } from '@mui/material'; +import React from 'react'; + +const styles = { + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: 200, + bgcolor: '#292E34', + p: 1.5, + borderRadius: 0.5, + height: 'fit-content', + border: (theme: Theme) => `1px solid ${theme.palette.grey[700]}`, +}; + +const ModalTitle = ({ title, withCloseIcon }: { title: string; withCloseIcon: boolean }) => ( + + + + {title} + + +); + +const ModalBody = ({ description, children }: { description: string; children?: React.ReactElement }) => ( + + {children} + + {description} + + +); + +export const InfoModal = ({ + title, + description, + show, + children, + Action, + onClose, +}: { + title: string; + description: string; + show: boolean; + children?: React.ReactElement; + Action?: React.ReactNode; + onClose?: () => void; +}) => ( + + + {onClose && ( + + + + + + )} + + {children} + {Action && ( + + {Action} + + )} + + +); diff --git a/nym-connect/src/components/IpAddressAndPort.tsx b/nym-connect/src/components/IpAddressAndPort.tsx index 435cf926fb..8d7d79c1f2 100644 --- a/nym-connect/src/components/IpAddressAndPort.tsx +++ b/nym-connect/src/components/IpAddressAndPort.tsx @@ -37,8 +37,12 @@ export const IpAddressAndPort: React.FC<{ return ( - {label} - Port + + {label} + + + Port + void; +}) => ( + Done} + > + + + Socks5 address + + + {ipAddress} + + + + + Port + + + {port} + + + + +); diff --git a/nym-connect/src/components/ServiceProviderSelector.tsx b/nym-connect/src/components/ServiceProviderSelector.tsx index 08cda8eb8a..9dee82fbb4 100644 --- a/nym-connect/src/components/ServiceProviderSelector.tsx +++ b/nym-connect/src/components/ServiceProviderSelector.tsx @@ -1,11 +1,5 @@ import React, { useEffect, useMemo } from 'react'; -import IconButton from '@mui/material/IconButton'; -import Menu from '@mui/material/Menu'; -import MenuItem from '@mui/material/MenuItem'; -import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded'; -import KeyboardArrowUpRoundedIcon from '@mui/icons-material/KeyboardArrowUpRounded'; -import { Box, CircularProgress, Stack, Tooltip, Typography, ListItemIcon } from '@mui/material'; -import Check from '@mui/icons-material/Check'; +import { Box, CircularProgress, Stack, TextField, Tooltip, Typography, MenuItem, ListItemIcon } from '@mui/material'; import { ServiceProvider, Service, Services } from '../types/directory'; type ServiceWithRandomSp = { @@ -19,11 +13,8 @@ export const ServiceProviderSelector: React.FC<{ services?: Services; currentSp?: ServiceProvider; }> = ({ services, currentSp, onChange }) => { - const [service, setService] = React.useState(); + const [service, setService] = React.useState({ id: '', description: '', items: [] }); const [serviceProvider, setServiceProvider] = React.useState(currentSp); - const textEl = React.useRef(null); - const [anchorEl, setAnchorEl] = React.useState(null); - const open = Boolean(anchorEl); useEffect(() => { if (!serviceProvider && currentSp) { @@ -34,38 +25,34 @@ export const ServiceProviderSelector: React.FC<{ useEffect(() => { if (services && serviceProvider) { // retrieve the service corresponding to this service provider - setService( - services.find((s) => - s.items.some( - ({ id, address, gateway }) => - id === serviceProvider.id && address === serviceProvider.address && gateway === serviceProvider.gateway, - ), + + const match = services.find((s) => + s.items.some( + ({ id, address, gateway }) => + id === serviceProvider.id && address === serviceProvider.address && gateway === serviceProvider.gateway, ), ); + + if (match) { + setService(match); + } } }, [serviceProvider, services]); - const handleClick = () => { - setAnchorEl(textEl.current); - }; - const handleClose = (newServiceProvider?: ServiceProvider) => { + const handleSelectSp = (newServiceProvider?: ServiceProvider) => { if (newServiceProvider && newServiceProvider !== currentSp) { setServiceProvider(newServiceProvider); onChange?.(newServiceProvider); } - setAnchorEl(null); }; if (!services) { return ( - + theme.palette.common.white}> Loading services... - - {open ? : } - ); } @@ -80,68 +67,44 @@ export const ServiceProviderSelector: React.FC<{ [services], ); + if (!service) return null; + return ( - <> - `1px solid ${theme.palette.info.main}` }} - > - theme.palette.info.main}> - {!service ? 'Select a service' : service.description} - - - {open ? : } - - - handleClose()} - anchorOrigin={{ - vertical: 'bottom', - horizontal: 'right', - }} - transformOrigin={{ - vertical: 'top', - horizontal: 'left', - }} - PaperProps={{ + + {servicesWithRandomSp.map(({ id, description, sp }) => ( - handleClose(sp)} - > + handleSelectSp(sp)}> @@ -164,19 +127,9 @@ export const ServiceProviderSelector: React.FC<{ > {description} - {id === service?.id && ( - - - - )} ))} - - + + ); }; diff --git a/nym-connect/src/context/main.tsx b/nym-connect/src/context/main.tsx index 42bfed638e..5d2b049bf9 100644 --- a/nym-connect/src/context/main.tsx +++ b/nym-connect/src/context/main.tsx @@ -7,20 +7,26 @@ import { forage } from '@tauri-apps/tauri-forage'; import { ConnectionStatusKind } from '../types'; import { ConnectionStatsItem } from '../components/ConnectionStats'; import { ServiceProvider, Services } from '../types/directory'; +import { Error } from 'src/types/error'; +import { TauriEvent } from 'src/types/event'; const TAURI_EVENT_STATUS_CHANGED = 'app:connection-status-changed'; type ModeType = 'light' | 'dark'; -type TClientContext = { +export type TClientContext = { mode: ModeType; connectionStatus: ConnectionStatusKind; connectionStats?: ConnectionStatsItem[]; connectedSince?: DateTime; services?: Services; serviceProvider?: ServiceProvider; + showHelp: boolean; + error?: Error; setMode: (mode: ModeType) => void; + clearError: () => void; + handleShowHelp: () => void; setConnectionStatus: (connectionStatus: ConnectionStatusKind) => void; setConnectionStats: (connectionStats: ConnectionStatsItem[] | undefined) => void; setConnectedSince: (connectedSince: DateTime | undefined) => void; @@ -39,6 +45,8 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode const [connectedSince, setConnectedSince] = useState(); const [services, setServices] = React.useState([]); const [serviceProvider, setRawServiceProvider] = React.useState(); + const [showHelp, setShowHelp] = useState(false); + const [error, setError] = useState(); useEffect(() => { invoke('get_services').then((result) => { @@ -47,30 +55,45 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode }, []); useEffect(() => { - let unlisten: UnlistenFn | undefined; + const unlisten: UnlistenFn[] = []; // TODO: fix typings listen(TAURI_EVENT_STATUS_CHANGED, (event) => { const { status } = event.payload as any; console.log(TAURI_EVENT_STATUS_CHANGED, { status, event }); setConnectionStatus(status); + }) + .then((result) => { + unlisten.push(result); + }) + .catch((e) => console.log(e)); + + listen('socks5-event', (e: TauriEvent) => { + setError(e.payload); }).then((result) => { - unlisten = result; + unlisten.push(result); }); return () => { - if (unlisten) { - unlisten(); - } + unlisten.forEach((unsubscribe) => unsubscribe()); }; }, []); const startConnecting = useCallback(async () => { - await invoke('start_connecting'); + try { + await invoke('start_connecting'); + } catch (e) { + setError({ title: 'Could not connect', message: e as string }); + console.log(e); + } }, []); const startDisconnecting = useCallback(async () => { - await invoke('start_disconnecting'); + try { + await invoke('start_disconnecting'); + } catch (e) { + console.log(e); + } }, []); const setSpInStorage = async (sp: ServiceProvider) => { @@ -92,12 +115,17 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode const spFromStorage = await forage.getItem({ key: 'nym-connect-sp' })(); if (spFromStorage) { setRawServiceProvider(spFromStorage); + setServiceProvider(spFromStorage); } } catch (e) { console.warn(e); } }; + const handleShowHelp = () => setShowHelp((show) => !show); + + const clearError = () => setError(undefined); + useEffect(() => { const validityCheck = async () => { if (services.length > 0 && serviceProvider) { @@ -122,6 +150,8 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode () => ({ mode, setMode, + error, + clearError, connectionStatus, setConnectionStatus, connectionStats, @@ -133,8 +163,20 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode services, serviceProvider, setServiceProvider, + showHelp, + handleShowHelp, }), - [mode, connectedSince, connectionStatus, connectionStats, connectedSince, services, serviceProvider], + [ + mode, + error, + connectedSince, + showHelp, + connectionStatus, + connectionStats, + connectedSince, + services, + serviceProvider, + ], ); return {children}; diff --git a/nym-connect/src/context/mocks/main.tsx b/nym-connect/src/context/mocks/main.tsx new file mode 100644 index 0000000000..e2974413eb --- /dev/null +++ b/nym-connect/src/context/mocks/main.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { ConnectionStatusKind } from 'src/types'; +import { ClientContext, TClientContext } from '../main'; + +const mockValues: TClientContext = { + mode: 'dark', + connectionStatus: ConnectionStatusKind.disconnected, + services: [], + showHelp: false, + serviceProvider: { id: '1', description: 'Keybase service provider', gateway: 'abc123', address: '123abc' }, + setMode: () => {}, + clearError: () => {}, + handleShowHelp: () => {}, + setConnectedSince: () => {}, + setConnectionStats: () => {}, + setConnectionStatus: () => {}, + setServiceProvider: () => {}, + startConnecting: async () => {}, + startDisconnecting: async () => {}, +}; + +export const MockProvider = ({ children }: { children: React.ReactNode }) => { + return {children}; +}; diff --git a/nym-connect/src/index.tsx b/nym-connect/src/index.tsx index 2938840fff..66a3e11d17 100644 --- a/nym-connect/src/index.tsx +++ b/nym-connect/src/index.tsx @@ -4,16 +4,18 @@ import { ErrorBoundary } from 'react-error-boundary'; import { ClientContextProvider } from './context/main'; import { ErrorFallback } from './components/Error'; import { NymMixnetTheme } from './theme'; -import './fonts/fonts.css'; import { App } from './App'; +import { AppWindowFrame } from './components/AppWindowFrame'; const root = document.getElementById('root'); ReactDOM.render( - - + + + + , diff --git a/nym-connect/src/layouts/ConnectedLayout.tsx b/nym-connect/src/layouts/ConnectedLayout.tsx index a031f18a56..8e34415b39 100644 --- a/nym-connect/src/layouts/ConnectedLayout.tsx +++ b/nym-connect/src/layouts/ConnectedLayout.tsx @@ -1,11 +1,11 @@ import React from 'react'; -import { Box } from '@mui/material'; +import { Box, Divider } from '@mui/material'; import { DateTime } from 'luxon'; -import { AppWindowFrame } from '../components/AppWindowFrame'; +import { IpAddressAndPortModal } from 'src/components/IpAddressAndPortModal'; +import { ConnectionTimer } from 'src/components/ConntectionTimer'; import { ConnectionStatus } from '../components/ConnectionStatus'; import { ConnectionStatusKind } from '../types'; -import { ConnectionStats, ConnectionStatsItem } from '../components/ConnectionStats'; -import { NeedHelp } from '../components/NeedHelp'; +import { ConnectionStatsItem } from '../components/ConnectionStats'; import { ConnectionButton } from '../components/ConnectionButton'; import { IpAddressAndPort } from '../components/IpAddressAndPort'; import { ServiceProvider } from '../types/directory'; @@ -17,19 +17,32 @@ export const ConnectedLayout: React.FC<{ port: number; connectedSince?: DateTime; busy?: boolean; + showInfoModal: boolean; isError?: boolean; + handleCloseInfoModal: () => void; onConnectClick?: (status: ConnectionStatusKind) => void; serviceProvider?: ServiceProvider; -}> = ({ status, stats, ipAddress, port, connectedSince, busy, isError, serviceProvider, onConnectClick }) => ( - +}> = ({ + status, + showInfoModal, + handleCloseInfoModal, + ipAddress, + port, + connectedSince, + busy, + isError, + serviceProvider, + onConnectClick, +}) => ( + <> + - - - - + + + {/* */} + - - + ); diff --git a/nym-connect/src/layouts/DefaultLayout.tsx b/nym-connect/src/layouts/DefaultLayout.tsx index ba59b7f95d..ed42aad5fe 100644 --- a/nym-connect/src/layouts/DefaultLayout.tsx +++ b/nym-connect/src/layouts/DefaultLayout.tsx @@ -1,48 +1,48 @@ import React from 'react'; import { Typography } from '@mui/material'; -import { AppWindowFrame } from '../components/AppWindowFrame'; +import { Box } from '@mui/material'; +import { ConnectionStatus } from 'src/components/ConnectionStatus'; +import { ConnectionTimer } from 'src/components/ConntectionTimer'; +import { InfoModal } from 'src/components/InfoModal'; +import { Error } from 'src/types/error'; import { ConnectionButton } from '../components/ConnectionButton'; -import { ConnectionStatusKind } from '../types'; -import { NeedHelp } from '../components/NeedHelp'; import { ServiceProviderSelector } from '../components/ServiceProviderSelector'; -import { ServiceProvider, Services } from '../types/directory'; import { useClientContext } from '../context/main'; +import { ConnectionStatusKind } from '../types'; +import { ServiceProvider, Services } from '../types/directory'; export const DefaultLayout: React.FC<{ + error?: Error; status: ConnectionStatusKind; services?: Services; busy?: boolean; isError?: boolean; + clearError: () => void; onConnectClick?: (status: ConnectionStatusKind) => void; onServiceProviderChange?: (serviceProvider: ServiceProvider) => void; -}> = ({ status, services, busy, isError, onConnectClick, onServiceProviderChange }) => { - const [serviceProvider, setServiceProvider] = React.useState(); +}> = ({ status, error, services, busy, isError, onConnectClick, onServiceProviderChange, clearError }) => { const handleServiceProviderChange = (newServiceProvider: ServiceProvider) => { - setServiceProvider(newServiceProvider); onServiceProviderChange?.(newServiceProvider); }; + const { serviceProvider: currentSp } = useClientContext(); return ( - - - This is experimental software.
- Do not rely on it for strong anonymity (yet). -
- - Connect to the -
- Nym mixnet for privacy. + + {error && } + + + Connect to the Nym
mixnet for privacy.
+ - -
+
); }; diff --git a/nym-connect/src/layouts/HelpGuideLayout.tsx b/nym-connect/src/layouts/HelpGuideLayout.tsx new file mode 100644 index 0000000000..d56fd6b0ae --- /dev/null +++ b/nym-connect/src/layouts/HelpGuideLayout.tsx @@ -0,0 +1,70 @@ +import React, { useState } from 'react'; +import { HelpPage } from 'src/components/HelpPage'; +import { Button, Link, Stack } from '@mui/material'; +import Image1 from '../assets/help-step-one.png'; +import Image2 from '../assets/help-step-two.png'; +import Image3 from '../assets/help-step-three.png'; +import Image4 from '../assets/help-step-four.png'; + +export const HelpGuideLayout = () => { + const [step, setStep] = useState(0); + + if (step === 1) + return ( + setStep(2)} + /> + ); + + if (step === 2) + return ( + setStep(1)} + onNext={() => setStep(3)} + /> + ); + + if (step === 3) + return ( + setStep(2)} + onNext={() => setStep(4)} + /> + ); + + if (step === 4) + return ( + setStep(3)} + /> + ); + + return ( + + + + + ); +}; diff --git a/nym-connect/src/stories/AppFlow.stories.tsx b/nym-connect/src/stories/AppFlow.stories.tsx index ef7a1ea434..34064e6202 100644 --- a/nym-connect/src/stories/AppFlow.stories.tsx +++ b/nym-connect/src/stories/AppFlow.stories.tsx @@ -67,20 +67,25 @@ export const Mock: ComponentStory = () => { context.connectionStatus === ConnectionStatusKind.connecting ) { return ( - + {}} /> - + ); } return ( - + { + return undefined; + }} status={context.connectionStatus} busy={busy} onConnectClick={handleConnectClick} @@ -101,6 +106,6 @@ export const Mock: ComponentStory = () => { }, ]} /> - + ); }; diff --git a/nym-connect/src/stories/ConnectedLayout.stories.tsx b/nym-connect/src/stories/ConnectedLayout.stories.tsx index 6063f83c28..53538457bf 100644 --- a/nym-connect/src/stories/ConnectedLayout.stories.tsx +++ b/nym-connect/src/stories/ConnectedLayout.stories.tsx @@ -11,8 +11,12 @@ export default { } as ComponentMeta; export const Default: ComponentStory = () => ( - + { + return undefined; + }} status={ConnectionStatusKind.connected} connectedSince={DateTime.now()} ipAddress="127.0.0.1" diff --git a/nym-connect/src/stories/DefaultLayout.stories.tsx b/nym-connect/src/stories/DefaultLayout.stories.tsx index 2c4ef513c2..ba311a410b 100644 --- a/nym-connect/src/stories/DefaultLayout.stories.tsx +++ b/nym-connect/src/stories/DefaultLayout.stories.tsx @@ -10,7 +10,24 @@ export default { } as ComponentMeta; export const Default: ComponentStory = () => ( - - + + {}} error={undefined} /> + +); + +export const WithServices: ComponentStory = () => ( + + {}} + error={undefined} + /> ); diff --git a/nym-connect/src/theme/index.tsx b/nym-connect/src/theme/index.tsx index 63f9db42fa..075aa7920a 100644 --- a/nym-connect/src/theme/index.tsx +++ b/nym-connect/src/theme/index.tsx @@ -3,12 +3,12 @@ import { createTheme, ThemeProvider } from '@mui/material/styles'; import { CssBaseline } from '@mui/material'; import { getDesignTokens } from './theme'; import { ClientContext } from '../context/main'; +import '../../../assets/fonts/non-variable/fonts.css'; /** - * Provides the theme for the Network Explorer by reacting to the light/dark mode choice stored in the app context. + * Provides the theme for Nym Connect by reacting to the light/dark mode choice stored in the app context. */ -export const NymMixnetTheme: React.FC = ({ children }) => { - const { mode } = useContext(ClientContext); +export const NymMixnetTheme: React.FC<{ mode: 'light' | 'dark' }> = ({ children, mode }) => { const theme = React.useMemo(() => createTheme(getDesignTokens(mode)), [mode]); return ( diff --git a/nym-connect/src/theme/mui-theme.d.ts b/nym-connect/src/theme/mui-theme.d.ts index a5b92073b3..ef042f6305 100644 --- a/nym-connect/src/theme/mui-theme.d.ts +++ b/nym-connect/src/theme/mui-theme.d.ts @@ -30,6 +30,7 @@ declare module '@mui/material/styles' { interface NymPalette { highlight: string; success: string; + warning: string; info: string; fee: string; background: { light: string; dark: string }; diff --git a/nym-connect/src/theme/theme.tsx b/nym-connect/src/theme/theme.tsx index e0153e6824..5b70f5118b 100644 --- a/nym-connect/src/theme/theme.tsx +++ b/nym-connect/src/theme/theme.tsx @@ -23,6 +23,7 @@ const nymPalette: NymPalette = { highlight: '#FB6E4E', success: '#21D073', info: '#60D7EF', + warning: '#FFE600', fee: '#967FF0', background: { light: '#F4F6F8', dark: '#1D2125' }, text: { @@ -93,6 +94,9 @@ const variantToMUIPalette = (variant: NymPaletteVariant): PaletteOptions => ({ info: { main: nymPalette.info, }, + warning: { + main: nymPalette.warning, + }, background: { default: variant.background.main, paper: variant.background.paper, diff --git a/nym-connect/src/types/error.ts b/nym-connect/src/types/error.ts new file mode 100644 index 0000000000..599b959c39 --- /dev/null +++ b/nym-connect/src/types/error.ts @@ -0,0 +1,4 @@ +export type Error = { + title: string; + message: string; +}; diff --git a/nym-connect/src/types/event.ts b/nym-connect/src/types/event.ts new file mode 100644 index 0000000000..66d1e514a6 --- /dev/null +++ b/nym-connect/src/types/event.ts @@ -0,0 +1,6 @@ +export type TauriEvent = { + payload: { + title: string; + message: string; + }; +}; diff --git a/nym-wallet/src-tauri/src/main.rs b/nym-wallet/src-tauri/src/main.rs index 9bd44028c2..125be1cb8c 100644 --- a/nym-wallet/src-tauri/src/main.rs +++ b/nym-wallet/src-tauri/src/main.rs @@ -55,6 +55,7 @@ fn main() { mixnet::admin::update_contract_settings, mixnet::bond::bond_gateway, mixnet::bond::bond_mixnode, + mixnet::bond::pledge_more, mixnet::bond::gateway_bond_details, mixnet::bond::get_pending_operator_rewards, mixnet::bond::mixnode_bond_details, @@ -105,6 +106,7 @@ fn main() { vesting::rewards::vesting_claim_operator_reward, vesting::bond::vesting_bond_gateway, vesting::bond::vesting_bond_mixnode, + vesting::bond::vesting_pledge_more, vesting::bond::vesting_unbond_gateway, vesting::bond::vesting_unbond_mixnode, vesting::bond::vesting_update_mixnode_cost_params, @@ -130,6 +132,7 @@ fn main() { simulate::mixnet::simulate_bond_gateway, simulate::mixnet::simulate_unbond_gateway, simulate::mixnet::simulate_bond_mixnode, + simulate::mixnet::simulate_pledge_more, simulate::mixnet::simulate_unbond_mixnode, simulate::mixnet::simulate_update_mixnode_config, simulate::mixnet::simulate_update_mixnode_cost_params, @@ -140,6 +143,7 @@ fn main() { simulate::vesting::simulate_vesting_bond_gateway, simulate::vesting::simulate_vesting_unbond_gateway, simulate::vesting::simulate_vesting_bond_mixnode, + simulate::vesting::simulate_vesting_pledge_more, simulate::vesting::simulate_vesting_unbond_mixnode, simulate::vesting::simulate_vesting_update_mixnode_config, simulate::vesting::simulate_vesting_update_mixnode_cost_params, diff --git a/nym-wallet/src-tauri/src/operations/mixnet/bond.rs b/nym-wallet/src-tauri/src/operations/mixnet/bond.rs index 7098577545..567dc84290 100644 --- a/nym-wallet/src-tauri/src/operations/mixnet/bond.rs +++ b/nym-wallet/src-tauri/src/operations/mixnet/bond.rs @@ -102,6 +102,33 @@ pub async fn bond_mixnode( )?) } +#[tauri::command] +pub async fn pledge_more( + fee: Option, + additional_pledge: DecCoin, + state: tauri::State<'_, WalletState>, +) -> Result { + let guard = state.read().await; + let additional_pledge_base = guard.attempt_convert_to_base_coin(additional_pledge.clone())?; + let fee_amount = guard.convert_tx_fee(fee.as_ref()); + log::info!( + ">>> Pledge more, additional_pledge_display = {}, additional_pledge_base = {}, fee = {:?}", + additional_pledge, + additional_pledge_base, + fee, + ); + let res = guard + .current_client()? + .nymd + .pledge_more(additional_pledge_base, fee) + .await?; + log::info!("<<< tx hash = {}", res.transaction_hash); + log::trace!("<<< {:?}", res); + Ok(TransactionExecuteResult::from_execute_result( + res, fee_amount, + )?) +} + #[tauri::command] pub async fn unbond_mixnode( fee: Option, diff --git a/nym-wallet/src-tauri/src/operations/simulate/mixnet.rs b/nym-wallet/src-tauri/src/operations/simulate/mixnet.rs index 51912e1332..33e649690b 100644 --- a/nym-wallet/src-tauri/src/operations/simulate/mixnet.rs +++ b/nym-wallet/src-tauri/src/operations/simulate/mixnet.rs @@ -84,6 +84,14 @@ pub async fn simulate_bond_mixnode( .await } +#[tauri::command] +pub async fn simulate_pledge_more( + additional_pledge: DecCoin, + state: tauri::State<'_, WalletState>, +) -> Result { + simulate_mixnet_operation(ExecuteMsg::PledgeMore {}, Some(additional_pledge), &state).await +} + #[tauri::command] pub async fn simulate_unbond_mixnode( state: tauri::State<'_, WalletState>, diff --git a/nym-wallet/src-tauri/src/operations/simulate/vesting.rs b/nym-wallet/src-tauri/src/operations/simulate/vesting.rs index c7cebf2ce5..213c8d3fd8 100644 --- a/nym-wallet/src-tauri/src/operations/simulate/vesting.rs +++ b/nym-wallet/src-tauri/src/operations/simulate/vesting.rs @@ -91,6 +91,19 @@ pub async fn simulate_vesting_bond_mixnode( .await } +#[tauri::command] +pub async fn simulate_vesting_pledge_more( + additional_pledge: DecCoin, + state: tauri::State<'_, WalletState>, +) -> Result { + let guard = state.read().await; + let amount = guard + .attempt_convert_to_base_coin(additional_pledge)? + .into(); + + simulate_vesting_operation(ExecuteMsg::PledgeMore { amount }, None, &state).await +} + #[tauri::command] pub async fn simulate_vesting_unbond_mixnode( state: tauri::State<'_, WalletState>, diff --git a/nym-wallet/src-tauri/src/operations/vesting/bond.rs b/nym-wallet/src-tauri/src/operations/vesting/bond.rs index 99f0086d9c..da5a71eee1 100644 --- a/nym-wallet/src-tauri/src/operations/vesting/bond.rs +++ b/nym-wallet/src-tauri/src/operations/vesting/bond.rs @@ -93,6 +93,33 @@ pub async fn vesting_bond_mixnode( )?) } +#[tauri::command] +pub async fn vesting_pledge_more( + fee: Option, + additional_pledge: DecCoin, + state: tauri::State<'_, WalletState>, +) -> Result { + let guard = state.read().await; + let additional_pledge_base = guard.attempt_convert_to_base_coin(additional_pledge.clone())?; + let fee_amount = guard.convert_tx_fee(fee.as_ref()); + log::info!( + ">>> Pledge more with locked tokens, additional_pledge_display = {}, additional_pledge_base = {}, fee = {:?}", + additional_pledge, + additional_pledge_base, + fee, + ); + let res = guard + .current_client()? + .nymd + .vesting_pledge_more(additional_pledge_base, fee) + .await?; + log::info!("<<< tx hash = {}", res.transaction_hash); + log::trace!("<<< {:?}", res); + Ok(TransactionExecuteResult::from_execute_result( + res, fee_amount, + )?) +} + #[tauri::command] pub async fn vesting_unbond_mixnode( fee: Option, diff --git a/nym-wallet/src/components/Bonding/BondedMixnode.tsx b/nym-wallet/src/components/Bonding/BondedMixnode.tsx index 5d63555ddb..1859cf0320 100644 --- a/nym-wallet/src/components/Bonding/BondedMixnode.tsx +++ b/nym-wallet/src/components/Bonding/BondedMixnode.tsx @@ -114,7 +114,6 @@ export const BondedMixnode = ({ ), id: 'actions-cell', diff --git a/nym-wallet/src/components/Bonding/modals/BondMoreModal.tsx b/nym-wallet/src/components/Bonding/modals/BondMoreModal.tsx index 88518abc28..e2145e431e 100644 --- a/nym-wallet/src/components/Bonding/modals/BondMoreModal.tsx +++ b/nym-wallet/src/components/Bonding/modals/BondMoreModal.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { Box, FormHelperText, Stack, TextField } from '@mui/material'; +import { Box, Stack } from '@mui/material'; import { CurrencyFormField } from '@nymproject/react/currency/CurrencyFormField'; import { ModalListItem } from 'src/components/Modals/ModalListItem'; import { SimpleModal } from 'src/components/Modals/SimpleModal'; @@ -7,66 +7,74 @@ import { DecCoin } from '@nymproject/types'; import { TokenPoolSelector, TPoolOption } from 'src/components/TokenPoolSelector'; import { ConfirmTx } from 'src/components/ConfirmTX'; import { useGetFee } from 'src/hooks/useGetFee'; -import { validateAmount, validateKey } from 'src/utils'; +import { validateAmount } from 'src/utils'; +import { simulateBondMore, simulateVestingBondMore } from 'src/requests'; +import { TBondMoreArgs } from 'src/types'; +import { TBondedMixnode } from 'src/context'; export const BondMoreModal = ({ - currentBond, + node, userBalance, - hasVestingTokens, - onConfirm, + onBondMore, onClose, + onError, }: { - currentBond: DecCoin; + node: TBondedMixnode; userBalance?: string; - hasVestingTokens: boolean; - onConfirm: (args: { additionalBond: DecCoin; signature: string; tokenPool: TPoolOption }) => Promise; + onBondMore: (data: TBondMoreArgs, tokenPool: TPoolOption) => Promise; onClose: () => void; + onError: (e: string) => void; }) => { - const { fee, resetFeeState } = useGetFee(); + const { bond: currentBond, proxy } = node; + const { fee, getFee, resetFeeState, feeError } = useGetFee(); const [additionalBond, setAdditionalBond] = useState({ amount: '0', denom: currentBond.denom }); - const [signature, setSignature] = useState(''); - const [tokenPool, setTokenPool] = useState('balance'); const [errorAmount, setErrorAmount] = useState(false); - const [errorSignature, setErrorSignature] = useState(false); - const handleOnOk = async () => { - const errors = { - amount: false, - signature: false, - }; - - if (!validateKey(signature || '', 64)) { - errors.signature = true; + useEffect(() => { + if (feeError) { + onError(feeError); } + }, [feeError]); - if (!additionalBond?.amount) { - errors.amount = true; - } + const handleConfirm = async () => { + const data = { additionalPledge: additionalBond }; + const tokenPool = proxy ? 'locked' : 'balance'; + await onBondMore(data, tokenPool); + }; - if (additionalBond && !(await validateAmount(additionalBond.amount, '1'))) { - errors.amount = true; - } + const handleAmountChanged = async (value: DecCoin) => { + setAdditionalBond(value); + const { amount } = value; - if (!errors.amount && !errors.signature) { - onConfirm({ additionalBond, signature, tokenPool }); + if (!amount) { + setErrorAmount(true); } else { - setErrorAmount(errors.amount); - setErrorSignature(errors.signature); + const validAmount = await validateAmount(amount, '1'); + if (!validAmount) { + setErrorAmount(true); + return; + } + setErrorAmount(false); } }; - useEffect(() => { - setErrorAmount(false); - }, [additionalBond]); + const handleOnOk = async () => { + if (!proxy) { + await getFee(simulateBondMore, { additionalPledge: additionalBond }); + } else { + await getFee(simulateVestingBondMore, { additionalPledge: additionalBond }); + } + }; if (fee) return ( onConfirm({ additionalBond, signature, tokenPool })} + onClose={onClose} onPrev={resetFeeState} + onConfirm={handleConfirm} > @@ -80,36 +88,23 @@ export const BondMoreModal = ({ subHeader="Bond more tokens on your node and receive more rewards" okLabel="Next" onOk={handleOnOk} - okDisabled={errorAmount || errorSignature} + okDisabled={errorAmount} onClose={onClose} > - {hasVestingTokens && setTokenPool(pool)} />} { - setAdditionalBond(value); - setErrorSignature(false); + handleAmountChanged(value); }} fullWidth validationError={errorAmount ? 'Please enter a valid amount' : undefined} /> - - setSignature(e.target.value)} - InputLabelProps={{ shrink: true }} - /> - {errorSignature && Invalid signature} - - diff --git a/nym-wallet/src/components/Bonding/modals/BondOversaturatedModal.tsx b/nym-wallet/src/components/Bonding/modals/BondOversaturatedModal.tsx new file mode 100644 index 0000000000..be3ea33869 --- /dev/null +++ b/nym-wallet/src/components/Bonding/modals/BondOversaturatedModal.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Stack, Typography } from '@mui/material'; +import { SimpleModal } from 'src/components/Modals/SimpleModal'; + +export const BondOversaturatedModal: React.FC<{ + open: boolean; + saturationPercentage: string; + onClose?: () => void; + onContinue?: () => void; +}> = ({ open, saturationPercentage, onClose, onContinue }) => ( + onContinue?.()} + header="Bond More" + okLabel="Bond More" + buttonFullWidth + > + + t.palette.nym.red }} + >{`Your node saturation: ${saturationPercentage}%`} + Your node is over saturated, are you sure you want to bond more? + + +); diff --git a/nym-wallet/src/context/bonding.tsx b/nym-wallet/src/context/bonding.tsx index 524a9ea0be..7225360456 100644 --- a/nym-wallet/src/context/bonding.tsx +++ b/nym-wallet/src/context/bonding.tsx @@ -8,7 +8,7 @@ import { } from '@nymproject/types'; import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import Big from 'big.js'; -import { isGateway, isMixnode, TBondGatewayArgs, TBondMixNodeArgs } from 'src/types'; +import { isGateway, isMixnode, TBondGatewayArgs, TBondMixNodeArgs, TBondMoreArgs } from 'src/types'; import { Console } from 'src/utils/console'; import { bondGateway as bondGatewayRequest, @@ -18,6 +18,8 @@ import { getMixnodeBondDetails, unbondGateway as unbondGatewayRequest, unbondMixNode as unbondMixnodeRequest, + bondMore as bondMoreRequest, + vestingBondMore, vestingBondGateway, vestingBondMixNode, vestingUnbondGateway, @@ -100,7 +102,7 @@ export type TBondingContext = { bondMixnode: (data: TBondMixNodeArgs, tokenPool: TokenPool) => Promise; bondGateway: (data: TBondGatewayArgs, tokenPool: TokenPool) => Promise; unbond: (fee?: FeeDetails) => Promise; - bondMore: (signature: string, amount: DecCoin, fee?: FeeDetails) => Promise; + bondMore: (data: TBondMoreArgs, tokenPool: TokenPool) => Promise; redeemRewards: (fee?: FeeDetails) => Promise; updateMixnode: (pm: string, fee?: FeeDetails) => Promise; checkOwnership: () => Promise; @@ -257,7 +259,6 @@ export const BondingContextProvider = ({ children }: { children?: React.ReactNod rewarding_details, bond_information: { mix_id }, } = data; - const { status, stakeSaturation, estimatedRewards } = await getAdditionalMixnodeDetails(mix_id); const setProbabilities = await getSetProbabilities(mix_id); const nodeDescription = await getNodeDescription( @@ -431,9 +432,28 @@ export const BondingContextProvider = ({ children }: { children?: React.ReactNod return tx; }; - const bondMore = async (_signature: string, _additionalBond: DecCoin) => - // TODO to implement - undefined; + const bondMore = async (data: TBondMoreArgs, tokenPool: TokenPool) => { + let tx: TransactionExecuteResult | undefined; + setIsLoading(true); + try { + if (tokenPool === 'balance') { + tx = await bondMoreRequest(data); + await userBalance.fetchBalance(); + } + if (tokenPool === 'locked') { + tx = await vestingBondMore(data); + await userBalance.fetchTokenAllocation(); + } + + return tx; + } catch (e: any) { + Console.warn(e); + setError(`an error occurred: ${e}`); + } finally { + setIsLoading(false); + } + return undefined; + }; const memoizedValue = useMemo( () => ({ diff --git a/nym-wallet/src/context/mocks/bonding.tsx b/nym-wallet/src/context/mocks/bonding.tsx index 8d97deb090..95e770850b 100644 --- a/nym-wallet/src/context/mocks/bonding.tsx +++ b/nym-wallet/src/context/mocks/bonding.tsx @@ -152,7 +152,7 @@ export const MockBondingContextProvider = ({ return TxResultMock; }; - const bondMore = async (_signature: string, _additionalBond: DecCoin) => { + const bondMore = async (): Promise => { setIsLoading(true); await mockSleep(SLEEP_MS); triggerStateUpdate(); diff --git a/nym-wallet/src/pages/bonding/Bonding.tsx b/nym-wallet/src/pages/bonding/Bonding.tsx index d1ec197b0f..1aa6bd5ef2 100644 --- a/nym-wallet/src/pages/bonding/Bonding.tsx +++ b/nym-wallet/src/pages/bonding/Bonding.tsx @@ -1,6 +1,6 @@ import React, { useContext, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { FeeDetails } from '@nymproject/types'; +import { FeeDetails, decimalToFloatApproximation } from '@nymproject/types'; import { Box } from '@mui/material'; import { TPoolOption } from 'src/components'; import { Bond } from 'src/components/Bonding/Bond'; @@ -8,28 +8,35 @@ import { BondedMixnode } from 'src/components/Bonding/BondedMixnode'; import { TBondedMixnodeActions } from 'src/components/Bonding/BondedMixnodeActions'; import { BondGatewayModal } from 'src/components/Bonding/modals/BondGatewayModal'; import { BondMixnodeModal } from 'src/components/Bonding/modals/BondMixnodeModal'; +import { BondMoreModal } from 'src/components/Bonding/modals/BondMoreModal'; +import { BondOversaturatedModal } from 'src/components/Bonding/modals/BondOversaturatedModal'; import { ConfirmationDetailProps, ConfirmationDetailsModal } from 'src/components/Bonding/modals/ConfirmationModal'; import { ErrorModal } from 'src/components/Modals/ErrorModal'; import { LoadingModal } from 'src/components/Modals/LoadingModal'; import { AppContext, urls } from 'src/context/main'; -import { isGateway, isMixnode, TBondGatewayArgs, TBondMixNodeArgs } from 'src/types'; +import { isGateway, isMixnode, TBondGatewayArgs, TBondMixNodeArgs, TBondMoreArgs } from 'src/types'; import { BondedGateway } from 'src/components/Bonding/BondedGateway'; import { RedeemRewardsModal } from 'src/components/Bonding/modals/RedeemRewardsModal'; import { BondingContextProvider, useBondingContext } from '../../context'; +import { getMixnodeStakeSaturation } from '../../requests'; const Bonding = () => { - const [showModal, setShowModal] = useState<'bond-mixnode' | 'bond-gateway' | 'bond-more' | 'unbond' | 'redeem'>(); + const [showModal, setShowModal] = useState< + 'bond-mixnode' | 'bond-gateway' | 'bond-more' | 'bond-more-oversaturated' | 'unbond' | 'redeem' + >(); const [confirmationDetails, setConfirmationDetails] = useState(); + const [saturationPercentage, setSaturationPercentage] = useState(); const { network, clientDetails, - userBalance: { originalVesting }, + userBalance: { originalVesting, balance }, } = useContext(AppContext); const navigate = useNavigate(); - const { bondedNode, bondMixnode, bondGateway, redeemRewards, isLoading, checkOwnership } = useBondingContext(); + const { bondedNode, bondMixnode, bondGateway, redeemRewards, isLoading, checkOwnership, bondMore } = + useBondingContext(); const handleCloseModal = async () => { setShowModal(undefined); @@ -66,6 +73,16 @@ const Bonding = () => { }); }; + const handleBondMore = async (data: TBondMoreArgs, tokenPool: TPoolOption) => { + setShowModal(undefined); + const tx = await bondMore(data, tokenPool); + setConfirmationDetails({ + status: 'success', + title: 'Bond More successful', + txUrl: `${urls(network).blockExplorer}/transaction/${tx?.transaction_hash}`, + }); + }; + const handleRedeemReward = async (fee?: FeeDetails) => { setShowModal(undefined); const tx = await redeemRewards(fee); @@ -76,9 +93,33 @@ const Bonding = () => { }); }; - const handleBondedMixnodeAction = (action: TBondedMixnodeActions) => { + const handleCheckStakeSaturation = async (newMixId: number) => { + try { + const newSaturation = decimalToFloatApproximation( + (await getMixnodeStakeSaturation(newMixId)).uncapped_saturation, + ); + if (newSaturation && newSaturation > 1) { + const saturationPercentage = Math.round(newSaturation * 100); + return { isOverSaturated: true, saturationPercentage }; + } + return { isOverSaturated: false, saturationPercentage: undefined }; + } catch (e) { + console.error('Error fetching the saturation, error:', e); + return { isOverSaturated: false, saturationPercentage: undefined }; + } + }; + + const handleBondedMixnodeAction = async (action: TBondedMixnodeActions) => { switch (action) { case 'bondMore': { + if (bondedNode && isMixnode(bondedNode)) { + const { isOverSaturated, saturationPercentage } = await handleCheckStakeSaturation(bondedNode.mixId); + if (isOverSaturated && saturationPercentage) { + setShowModal('bond-more-oversaturated'); + setSaturationPercentage(saturationPercentage.toString()); + break; + } + } setShowModal('bond-more'); break; } @@ -112,6 +153,7 @@ const Bonding = () => { {bondedNode && isGateway(bondedNode) && ( )} + {showModal === 'bond-mixnode' && ( { /> )} + {showModal === 'bond-more-oversaturated' && saturationPercentage && ( + setShowModal(undefined)} + onContinue={() => setShowModal('bond-more')} + saturationPercentage={saturationPercentage} + /> + )} + + {showModal === 'bond-more' && bondedNode && isMixnode(bondedNode) && ( + setShowModal(undefined)} + onError={handleError} + /> + )} + {showModal === 'redeem' && bondedNode && isMixnode(bondedNode) && ( @@ -32,3 +32,6 @@ export const unbond = async (type: EnumNodeType) => { if (type === EnumNodeType.mixnode) return unbondMixNode(); return unbondGateway(); }; + +export const bondMore = async (args: TBondMoreArgs) => + invokeWrapper('pledge_more', args); diff --git a/nym-wallet/src/requests/simulate.ts b/nym-wallet/src/requests/simulate.ts index ddeae6ab2e..4a1b82f0d4 100644 --- a/nym-wallet/src/requests/simulate.ts +++ b/nym-wallet/src/requests/simulate.ts @@ -63,3 +63,9 @@ export const simulateClaimOperatorReward = async () => invokeWrapper export const simulateVestingClaimOperatorReward = async () => invokeWrapper('simulate_vesting_claim_operator_reward'); + +export const simulateBondMore = async (args: any) => + invokeWrapper('simulate_pledge_more', args); + +export const simulateVestingBondMore = async (args: any) => + invokeWrapper('simulate_vesting_pledge_more', args); diff --git a/nym-wallet/src/requests/vesting.ts b/nym-wallet/src/requests/vesting.ts index 907f42c499..bad2f7febf 100644 --- a/nym-wallet/src/requests/vesting.ts +++ b/nym-wallet/src/requests/vesting.ts @@ -105,3 +105,14 @@ export const vestingClaimOperatorReward = async (fee?: Fee) => export const vestingClaimDelegatorRewards = async (mixId: number) => invokeWrapper('vesting_claim_delegator_reward', { mixId }); + +export const vestingBondMore = async ({ + fee, + additionalPledge, +}: { + fee?: Fee; + additionalPledge: DecCoin; +}) => invokeWrapper('vesting_pledge_more', { + fee, + additionalPledge, +}); diff --git a/nym-wallet/src/types/global.ts b/nym-wallet/src/types/global.ts index d5bb61c26d..2c16901cfe 100644 --- a/nym-wallet/src/types/global.ts +++ b/nym-wallet/src/types/global.ts @@ -49,6 +49,12 @@ export type TBondMixNodeArgs = { fee?: Fee; }; +export type TBondMoreArgs = { + additionalPledge: DecCoin; + fee?: Fee; + +}; + export type TNodeDescription = { name: string; description: string; diff --git a/service-providers/network-requester/Cargo.toml b/service-providers/network-requester/Cargo.toml index 8d81920af5..72e6c0685e 100644 --- a/service-providers/network-requester/Cargo.toml +++ b/service-providers/network-requester/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "nym-network-requester" -version = "1.1.0" +version = "1.1.1" authors = ["Dave Hrycyszyn ", "Jędrzej Stuczyński "] edition = "2021" rust-version = "1.65" diff --git a/service-providers/network-requester/src/core.rs b/service-providers/network-requester/src/core.rs index 965e2e6b16..2c20efedaa 100644 --- a/service-providers/network-requester/src/core.rs +++ b/service-providers/network-requester/src/core.rs @@ -203,9 +203,13 @@ impl ServiceProvider { lane_queue_lengths: LaneQueueLengths, ) -> Option { while let Some(msg) = websocket_reader.next().await { - let data = msg - .expect("we failed to read from the websocket!") - .into_data(); + let data = match msg { + Ok(msg) => msg.into_data(), + Err(err) => { + log::error!("Failed to read from the websocket: {err}"); + continue; + } + }; // try to recover the actual message from the mix network... let deserialized_message = match ServerResponse::deserialize(&data) { diff --git a/tools/nym-cli/Cargo.toml b/tools/nym-cli/Cargo.toml index 2ec5af6404..362e3005b5 100644 --- a/tools/nym-cli/Cargo.toml +++ b/tools/nym-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nym-cli" -version = "1.1.0" +version = "1.1.1" authors = ["Nym Technologies SA"] edition = "2021" @@ -18,6 +18,7 @@ serde_json = "1" tokio = { version = "1.11", features = [ "net", "rt-multi-thread", "macros", "signal"] } bip39 = "1.0.1" anyhow = "1" +tap = "1" nym-cli-commands = { path = "../../common/commands" } logging = { path = "../../common/logging"} diff --git a/tools/nym-cli/src/validator/vesting.rs b/tools/nym-cli/src/validator/vesting.rs index d87ca2c9ff..202011bc3c 100644 --- a/tools/nym-cli/src/validator/vesting.rs +++ b/tools/nym-cli/src/validator/vesting.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use network_defaults::NymNetworkDetails; -use nym_cli_commands::context::{create_signing_client, ClientArgs}; +use nym_cli_commands::context::{create_query_client, create_signing_client, ClientArgs}; pub(crate) async fn execute( global_args: ClientArgs, @@ -19,18 +19,22 @@ pub(crate) async fn execute( .await } Some(nym_cli_commands::validator::vesting::VestingScheduleCommands::Query(args)) => { + let address_from_args = args.address.clone(); nym_cli_commands::validator::vesting::query_vesting_schedule::query( args, - create_signing_client(global_args, network_details)?, + create_query_client(network_details)?, + address_from_args, ) .await } Some(nym_cli_commands::validator::vesting::VestingScheduleCommands::VestedBalance( args, )) => { + let address_from_args = args.address.clone(); nym_cli_commands::validator::vesting::balance::balance( args, - create_signing_client(global_args, network_details)?, + create_query_client(network_details)?, + address_from_args, ) .await } diff --git a/validator-api/Cargo.toml b/validator-api/Cargo.toml index 4fb4413f99..1207d6a145 100644 --- a/validator-api/Cargo.toml +++ b/validator-api/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "nym-validator-api" -version = "1.1.0" +version = "1.1.1" authors = [ "Dave Hrycyszyn ", "Jędrzej Stuczyński ", diff --git a/validator-api/src/contract_cache/mod.rs b/validator-api/src/contract_cache/mod.rs index 95d984533c..b4538ea36b 100644 --- a/validator-api/src/contract_cache/mod.rs +++ b/validator-api/src/contract_cache/mod.rs @@ -2,13 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 use crate::nymd_client::Client; -use crate::storage::ValidatorApiStorage; use ::time::OffsetDateTime; use anyhow::Result; -use mixnet_contract_common::mixnode::MixNodeDetails; -use mixnet_contract_common::reward_params::{Performance, RewardingParams}; use mixnet_contract_common::{ - GatewayBond, IdentityKey, Interval, MixId, MixNodeBond, RewardedSetNodeStatus, + mixnode::MixNodeDetails, reward_params::RewardingParams, GatewayBond, IdentityKey, Interval, + MixId, MixNodeBond, RewardedSetNodeStatus, }; use okapi::openapi3::OpenApi; use rocket::fairing::AdHoc; @@ -16,18 +14,17 @@ use rocket::Route; use rocket_okapi::openapi_get_routes_spec; use rocket_okapi::settings::OpenApiSettings; use serde::Serialize; -use std::collections::HashMap; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use std::ops::Deref; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; use task::ShutdownListener; use tokio::sync::{watch, RwLock}; use tokio::time; -use validator_api_requests::models::{MixNodeBondAnnotated, MixnodeStatus}; +use validator_api_requests::models::MixnodeStatus; use validator_client::nymd::CosmWasmClient; -pub(crate) mod reward_estimate; pub(crate) mod routes; // The cache can emit notifications to listeners about the current state @@ -42,9 +39,6 @@ pub struct ValidatorCacheRefresher { cache: ValidatorCache, caching_interval: Duration, - // Readonly: some of the quantities cached depends on values from the storage. - storage: Option, - // Notify listeners that the cache has been updated update_notifier: watch::Sender, } @@ -56,14 +50,14 @@ pub struct ValidatorCache { } struct ValidatorCacheInner { - mixnodes: Cache>, + mixnodes: Cache>, gateways: Cache>, mixnodes_blacklist: Cache>, gateways_blacklist: Cache>, - rewarded_set: Cache>, - active_set: Cache>, + rewarded_set: Cache>, + active_set: Cache>, current_reward_params: Cache>, current_interval: Cache>, @@ -102,90 +96,33 @@ impl Cache { } } +impl Deref for Cache { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.value + } +} + impl ValidatorCacheRefresher { pub(crate) fn new( nymd_client: Client, caching_interval: Duration, cache: ValidatorCache, - storage: Option, ) -> Self { let (tx, _) = watch::channel(CacheNotification::Start); ValidatorCacheRefresher { nymd_client, cache, caching_interval, - storage, update_notifier: tx, } } - async fn get_performance(&self, mix_id: MixId, epoch: Interval) -> Option { - self.storage - .as_ref()? - .get_average_mixnode_uptime_in_the_last_24hrs( - mix_id, - epoch.current_epoch_end_unix_timestamp(), - ) - .await - .ok() - .map(Into::into) - } - pub fn subscribe(&self) -> watch::Receiver { self.update_notifier.subscribe() } - async fn annotate_node_with_details( - &self, - mixnodes: Vec, - interval_reward_params: RewardingParams, - current_interval: Interval, - rewarded_set: &HashMap, - ) -> Vec { - let mut annotated = Vec::new(); - for mixnode in mixnodes { - let stake_saturation = mixnode - .rewarding_details - .bond_saturation(&interval_reward_params); - - let uncapped_stake_saturation = mixnode - .rewarding_details - .uncapped_bond_saturation(&interval_reward_params); - - let performance = self - .get_performance(mixnode.mix_id(), current_interval) - .await - .unwrap_or_default(); - - let rewarded_set_status = rewarded_set.get(&mixnode.mix_id()).cloned(); - - let reward_estimate = reward_estimate::compute_reward_estimate( - &mixnode, - performance, - rewarded_set_status, - interval_reward_params, - current_interval, - ); - - let (estimated_operator_apy, estimated_delegators_apy) = - reward_estimate::compute_apy_from_reward( - &mixnode, - reward_estimate, - current_interval, - ); - - annotated.push(MixNodeBondAnnotated { - mixnode_details: mixnode, - stake_saturation, - uncapped_stake_saturation, - performance, - estimated_operator_apy, - estimated_delegators_apy, - }); - } - annotated - } - async fn get_rewarded_set_map(&self) -> HashMap where C: CosmWasmClient + Sync + Send, @@ -198,9 +135,9 @@ impl ValidatorCacheRefresher { } fn collect_rewarded_and_active_set_details( - all_mixnodes: &[MixNodeBondAnnotated], + all_mixnodes: &[MixNodeDetails], rewarded_set_nodes: &HashMap, - ) -> (Vec, Vec) { + ) -> (Vec, Vec) { let mut active_set = Vec::new(); let mut rewarded_set = Vec::new(); @@ -226,14 +163,10 @@ impl ValidatorCacheRefresher { let mixnodes = self.nymd_client.get_mixnodes().await?; let gateways = self.nymd_client.get_gateways().await?; - let rewarded_set = self.get_rewarded_set_map().await; - - let mixnodes = self - .annotate_node_with_details(mixnodes, rewarding_params, current_interval, &rewarded_set) - .await; + let rewarded_set_map = self.get_rewarded_set_map().await; let (rewarded_set, active_set) = - Self::collect_rewarded_and_active_set_details(&mixnodes, &rewarded_set); + Self::collect_rewarded_and_active_set_details(&mixnodes, &rewarded_set_map); info!( "Updating validator cache. There are {} mixnodes and {} gateways", @@ -324,10 +257,10 @@ impl ValidatorCache { async fn update_cache( &self, - mixnodes: Vec, + mixnodes: Vec, gateways: Vec, - rewarded_set: Vec, - active_set: Vec, + rewarded_set: Vec, + active_set: Vec, rewarding_params: RewardingParams, current_interval: Interval, ) { @@ -422,7 +355,7 @@ impl ValidatorCache { error!("Failed to update gateways blacklist"); } - pub async fn mixnodes_detailed(&self) -> Vec { + pub async fn mixnodes(&self) -> Vec { let blacklist = self.mixnodes_blacklist().await; let mixnodes = match time::timeout(Duration::from_millis(100), self.inner.read()).await { Ok(cache) => cache.mixnodes.clone(), @@ -444,14 +377,6 @@ impl ValidatorCache { } } - pub async fn mixnodes(&self) -> Vec { - self.mixnodes_detailed() - .await - .into_iter() - .map(|bond| bond.mixnode_details) - .collect() - } - pub async fn mixnodes_basic(&self) -> Vec { match time::timeout(Duration::from_millis(100), self.inner.read()).await { Ok(cache) => cache @@ -459,7 +384,7 @@ impl ValidatorCache { .clone() .into_inner() .into_iter() - .map(|bond| bond.mixnode_details.bond_information) + .map(|bond| bond.bond_information) .collect(), Err(e) => { error!("{}", e); @@ -500,7 +425,7 @@ impl ValidatorCache { } } - pub async fn rewarded_set_detailed(&self) -> Cache> { + pub async fn rewarded_set(&self) -> Cache> { match time::timeout(Duration::from_millis(100), self.inner.read()).await { Ok(cache) => cache.rewarded_set.clone(), Err(e) => { @@ -510,16 +435,7 @@ impl ValidatorCache { } } - pub async fn rewarded_set(&self) -> Vec { - self.rewarded_set_detailed() - .await - .value - .into_iter() - .map(|bond| bond.mixnode_details) - .collect() - } - - pub async fn active_set_detailed(&self) -> Cache> { + pub async fn active_set(&self) -> Cache> { match time::timeout(Duration::from_millis(100), self.inner.read()).await { Ok(cache) => cache.active_set.clone(), Err(e) => { @@ -529,15 +445,6 @@ impl ValidatorCache { } } - pub async fn active_set(&self) -> Vec { - self.active_set_detailed() - .await - .value - .into_iter() - .map(|bond| bond.mixnode_details) - .collect() - } - pub(crate) async fn interval_reward_params(&self) -> Cache> { match time::timeout(Duration::from_millis(100), self.inner.read()).await { Ok(cache) => cache.current_reward_params.clone(), @@ -558,24 +465,21 @@ impl ValidatorCache { } } - pub async fn mixnode_details( - &self, - mix_id: MixId, - ) -> (Option, MixnodeStatus) { + pub async fn mixnode_details(&self, mix_id: MixId) -> (Option, MixnodeStatus) { // it might not be the most optimal to possibly iterate the entire vector to find (or not) // the relevant value. However, the vectors are relatively small (< 10_000 elements, < 1000 for active set) - let active_set = &self.active_set_detailed().await.value; + let active_set = &self.active_set().await.value; if let Some(bond) = active_set.iter().find(|mix| mix.mix_id() == mix_id) { return (Some(bond.clone()), MixnodeStatus::Active); } - let rewarded_set = &self.rewarded_set_detailed().await.value; + let rewarded_set = &self.rewarded_set().await.value; if let Some(bond) = rewarded_set.iter().find(|mix| mix.mix_id() == mix_id) { return (Some(bond.clone()), MixnodeStatus::Standby); } - let all_bonded = &self.mixnodes_detailed().await; + let all_bonded = &self.mixnodes().await; if let Some(bond) = all_bonded.iter().find(|mix| mix.mix_id() == mix_id) { (Some(bond.clone()), MixnodeStatus::Inactive) } else { diff --git a/validator-api/src/contract_cache/routes.rs b/validator-api/src/contract_cache/routes.rs index 45d0b290ac..6aba1ba4f9 100644 --- a/validator-api/src/contract_cache/routes.rs +++ b/validator-api/src/contract_cache/routes.rs @@ -1,15 +1,21 @@ // Copyright 2021-2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::contract_cache::ValidatorCache; -use mixnet_contract_common::mixnode::MixNodeDetails; -use mixnet_contract_common::reward_params::RewardingParams; -use mixnet_contract_common::{GatewayBond, Interval, MixId}; -use rocket::serde::json::Json; -use rocket::State; +use crate::{ + contract_cache::ValidatorCache, + node_status_api::{ + helpers::{_get_active_set_detailed, _get_mixnodes_detailed, _get_rewarded_set_detailed}, + NodeStatusCache, + }, +}; +use mixnet_contract_common::{ + mixnode::MixNodeDetails, reward_params::RewardingParams, GatewayBond, Interval, MixId, +}; +use validator_api_requests::models::MixNodeBondAnnotated; + +use rocket::{serde::json::Json, State}; use rocket_okapi::openapi; use std::collections::HashSet; -use validator_api_requests::models::MixNodeBondAnnotated; #[openapi(tag = "contract-cache")] #[get("/mixnodes")] @@ -17,12 +23,19 @@ pub async fn get_mixnodes(cache: &State) -> Json Redirect { +// Redirect::to(uri!("/v1/status/mixnodes/detailed")) +// } +// ``` #[openapi(tag = "contract-cache")] #[get("/mixnodes/detailed")] pub async fn get_mixnodes_detailed( - cache: &State, + cache: &State, ) -> Json> { - Json(cache.mixnodes_detailed().await) + Json(_get_mixnodes_detailed(cache).await) } #[openapi(tag = "contract-cache")] @@ -34,29 +47,43 @@ pub async fn get_gateways(cache: &State) -> Json) -> Json> { - Json(cache.rewarded_set().await) + Json(cache.rewarded_set().await.value) } +// DEPRECATED: this endpoint now lives in `node_status_api`. Once all consumers are updated, +// replace this with +// ``` +// pub fn get_mixnodes_set_detailed() -> Redirect { +// Redirect::to(uri!("/v1/status/mixnodes/rewarded/detailed")) +// } +// ``` #[openapi(tag = "contract-cache")] #[get("/mixnodes/rewarded/detailed")] pub async fn get_rewarded_set_detailed( - cache: &State, + cache: &State, ) -> Json> { - Json(cache.rewarded_set_detailed().await.value) + Json(_get_rewarded_set_detailed(cache).await) } #[openapi(tag = "contract-cache")] #[get("/mixnodes/active")] pub async fn get_active_set(cache: &State) -> Json> { - Json(cache.active_set().await) + Json(cache.active_set().await.value) } +// DEPRECATED: this endpoint now lives in `node_status_api`. Once all consumers are updated, +// replace this with +// ``` +// pub fn get_active_set_detailed() -> Redirect { +// Redirect::to(uri!("/status/mixnodes/active/detailed")) +// } +// ``` #[openapi(tag = "contract-cache")] #[get("/mixnodes/active/detailed")] pub async fn get_active_set_detailed( - cache: &State, + cache: &State, ) -> Json> { - Json(cache.active_set_detailed().await.value) + Json(_get_active_set_detailed(cache).await) } #[openapi(tag = "contract-cache")] diff --git a/validator-api/src/epoch_operations/mod.rs b/validator-api/src/epoch_operations/mod.rs index cb33962890..06141e7863 100644 --- a/validator-api/src/epoch_operations/mod.rs +++ b/validator-api/src/epoch_operations/mod.rs @@ -156,7 +156,7 @@ impl RewardedSetUpdater { Err(err) => { warn!("failed to obtain the current rewarded set - {}. falling back to the cached version", err); self.validator_cache - .rewarded_set_detailed() + .rewarded_set() .await .into_inner() .into_iter() diff --git a/validator-api/src/main.rs b/validator-api/src/main.rs index 5116896858..3055d57f88 100644 --- a/validator-api/src/main.rs +++ b/validator-api/src/main.rs @@ -121,6 +121,7 @@ fn parse_args() -> ArgMatches { Arg::with_name(CONFIG_ENV_FILE) .help("Path pointing to an env file that configures the validator API") .long(CONFIG_ENV_FILE) + .short('c') .takes_value(true) ) .arg( @@ -577,7 +578,6 @@ async fn run_validator_api(matches: ArgMatches) -> Result<()> { signing_nymd_client.clone(), config.get_caching_interval(), validator_cache.clone(), - Some(storage.clone()), ); let validator_cache_listener = validator_cache_refresher.subscribe(); let shutdown_listener = shutdown.subscribe(); @@ -599,7 +599,6 @@ async fn run_validator_api(matches: ArgMatches) -> Result<()> { nymd_client, config.get_caching_interval(), validator_cache.clone(), - None, ); let validator_cache_listener = validator_cache_refresher.subscribe(); let shutdown_listener = shutdown.subscribe(); @@ -611,11 +610,13 @@ async fn run_validator_api(matches: ArgMatches) -> Result<()> { // Spawn the node status cache refresher. // It is primarily refreshed in-sync with the validator cache, however provide a fallback // caching interval that is twice the validator cache + let storage = rocket.state::().cloned(); let mut validator_api_cache_refresher = node_status_api::NodeStatusCacheRefresher::new( node_status_cache, + config.get_caching_interval().saturating_mul(2), validator_cache, validator_cache_listener, - config.get_caching_interval().saturating_mul(2), + storage, ); let shutdown_listener = shutdown.subscribe(); tokio::spawn(async move { validator_api_cache_refresher.run(shutdown_listener).await }); diff --git a/validator-api/src/network_monitor/monitor/sender.rs b/validator-api/src/network_monitor/monitor/sender.rs index 4ec0e9ce45..d2fa815a11 100644 --- a/validator-api/src/network_monitor/monitor/sender.rs +++ b/validator-api/src/network_monitor/monitor/sender.rs @@ -209,7 +209,7 @@ impl PacketSender { ack_sender, fresh_gateway_client_data.gateway_response_timeout, Some(fresh_gateway_client_data.bandwidth_controller.clone()), - None, + task::ShutdownListener::dummy(), ); gateway_client diff --git a/validator-api/src/node_status_api/cache.rs b/validator-api/src/node_status_api/cache.rs index ef5df963bf..dc8dc7447f 100644 --- a/validator-api/src/node_status_api/cache.rs +++ b/validator-api/src/node_status_api/cache.rs @@ -2,25 +2,33 @@ // SPDX-License-Identifier: Apache-2.0 use crate::contract_cache::{Cache, CacheNotification, ValidatorCache}; -use contracts_common::truncate_decimal; -use mixnet_contract_common::{MixId, MixNodeDetails, RewardingParams}; +use crate::storage::ValidatorApiStorage; +use mixnet_contract_common::reward_params::Performance; +use mixnet_contract_common::{ + Interval, MixId, MixNodeDetails, RewardedSetNodeStatus, RewardingParams, +}; use rocket::fairing::AdHoc; -use serde::Serialize; +use std::collections::HashMap; use std::{sync::Arc, time::Duration}; -use tap::TapFallible; use task::ShutdownListener; +use tokio::sync::RwLockReadGuard; use tokio::{ sync::{watch, RwLock}, time, }; -use validator_api_requests::models::InclusionProbability; +use validator_api_requests::models::{MixNodeBondAnnotated, MixnodeStatus}; + +use self::inclusion_probabilities::InclusionProbabilities; + +use super::reward_estimate::{compute_apy_from_reward, compute_reward_estimate}; + +mod inclusion_probabilities; const CACHE_TIMOUT_MS: u64 = 100; -const MAX_SIMULATION_SAMPLES: u64 = 5000; -const MAX_SIMULATION_TIME_SEC: u64 = 15; enum NodeStatusCacheError { SimulationFailed, + SourceDataMissing, } // A node status cache suitable for caching values computed in one sweep, such as active set @@ -32,27 +40,16 @@ pub struct NodeStatusCache { inner: Arc>, } +#[derive(Default)] struct NodeStatusCacheInner { + mixnodes_annotated: Cache>, + rewarded_set_annotated: Cache>, + active_set_annotated: Cache>, + + // Estimated active set inclusion probabilities from Monte Carlo simulation inclusion_probabilities: Cache, } -#[derive(Clone, Default, Serialize, schemars::JsonSchema)] -pub(crate) struct InclusionProbabilities { - pub inclusion_probabilities: Vec, - pub samples: u64, - pub elapsed: Duration, - pub delta_max: f64, - pub delta_l2: f64, -} - -impl InclusionProbabilities { - pub fn node(&self, mix_id: MixId) -> Option<&InclusionProbability> { - self.inclusion_probabilities - .iter() - .find(|x| x.mix_id == mix_id) - } -} - impl NodeStatusCache { fn new() -> Self { NodeStatusCache { @@ -66,9 +63,18 @@ impl NodeStatusCache { }) } - async fn update_cache(&self, inclusion_probabilities: InclusionProbabilities) { + async fn update_cache( + &self, + mixnodes: Vec, + rewarded_set: Vec, + active_set: Vec, + inclusion_probabilities: InclusionProbabilities, + ) { match time::timeout(Duration::from_millis(CACHE_TIMOUT_MS), self.inner.write()).await { Ok(mut cache) => { + cache.mixnodes_annotated.update(mixnodes); + cache.rewarded_set_annotated.update(rewarded_set); + cache.active_set_annotated.update(active_set); cache .inclusion_probabilities .update(inclusion_probabilities); @@ -77,45 +83,93 @@ impl NodeStatusCache { } } - pub(crate) async fn inclusion_probabilities(&self) -> Option> { + async fn get_cache( + &self, + fn_arg: impl FnOnce(RwLockReadGuard<'_, NodeStatusCacheInner>) -> Cache, + ) -> Option> { match time::timeout(Duration::from_millis(CACHE_TIMOUT_MS), self.inner.read()).await { - Ok(cache) => Some(cache.inclusion_probabilities.clone()), + Ok(cache) => Some(fn_arg(cache)), Err(e) => { error!("{e}"); None } } } + + pub(crate) async fn mixnodes_annotated(&self) -> Option>> { + self.get_cache(|c| c.mixnodes_annotated.clone()).await + } + + pub(crate) async fn rewarded_set_annotated(&self) -> Option>> { + self.get_cache(|c| c.rewarded_set_annotated.clone()).await + } + + pub(crate) async fn active_set_annotated(&self) -> Option>> { + self.get_cache(|c| c.active_set_annotated.clone()).await + } + + pub(crate) async fn inclusion_probabilities(&self) -> Option> { + self.get_cache(|c| c.inclusion_probabilities.clone()).await + } + + pub async fn mixnode_details( + &self, + mix_id: MixId, + ) -> (Option, MixnodeStatus) { + // it might not be the most optimal to possibly iterate the entire vector to find (or not) + // the relevant value. However, the vectors are relatively small (< 10_000 elements, < 1000 for active set) + + let active_set = &self.active_set_annotated().await.unwrap().into_inner(); + if let Some(bond) = active_set.iter().find(|mix| mix.mix_id() == mix_id) { + return (Some(bond.clone()), MixnodeStatus::Active); + } + + let rewarded_set = &self.rewarded_set_annotated().await.unwrap().into_inner(); + if let Some(bond) = rewarded_set.iter().find(|mix| mix.mix_id() == mix_id) { + return (Some(bond.clone()), MixnodeStatus::Standby); + } + + let all_bonded = &self.mixnodes_annotated().await.unwrap().into_inner(); + if let Some(bond) = all_bonded.iter().find(|mix| mix.mix_id() == mix_id) { + (Some(bond.clone()), MixnodeStatus::Inactive) + } else { + (None, MixnodeStatus::NotFound) + } + } } impl NodeStatusCacheInner { pub fn new() -> Self { - Self { - inclusion_probabilities: Default::default(), - } + Self::default() } } // Long running task responsible of keeping the cache up-to-date. pub struct NodeStatusCacheRefresher { + // Main stored data cache: NodeStatusCache, + fallback_caching_interval: Duration, + + // Sources for when refreshing data contract_cache: ValidatorCache, contract_cache_listener: watch::Receiver, - fallback_caching_interval: Duration, + storage: Option, } impl NodeStatusCacheRefresher { pub(crate) fn new( cache: NodeStatusCache, + fallback_caching_interval: Duration, contract_cache: ValidatorCache, contract_cache_listener: watch::Receiver, - fallback_caching_interval: Duration, + storage: Option, ) -> Self { Self { cache, + fallback_caching_interval, contract_cache, contract_cache_listener, - fallback_caching_interval, + storage, } } @@ -176,78 +230,154 @@ impl NodeStatusCacheRefresher { async fn refresh_cache(&self) -> Result<(), NodeStatusCacheError> { log::info!("Updating node status cache"); - let mixnode_bonds = self.contract_cache.mixnodes().await; - let params = self - .contract_cache - .interval_reward_params() - .await - .into_inner() - .ok_or(NodeStatusCacheError::SimulationFailed)?; - let inclusion_probabilities = compute_inclusion_probabilities(&mixnode_bonds, params) - .ok_or_else(|| { - error!( - "Failed to simulate selection probabilties for mixnodes, not updating cache" - ); - NodeStatusCacheError::SimulationFailed - })?; - self.cache.update_cache(inclusion_probabilities).await; + // Fetch contract cache data to work with + let mixnode_details = self.contract_cache.mixnodes().await; + let interval_reward_params = self.contract_cache.interval_reward_params().await; + let current_interval = self.contract_cache.current_interval().await; + + let rewarded_set = self.contract_cache.rewarded_set().await; + let active_set = self.contract_cache.active_set().await; + + let interval_reward_params = + interval_reward_params.ok_or(NodeStatusCacheError::SourceDataMissing)?; + let current_interval = current_interval.ok_or(NodeStatusCacheError::SourceDataMissing)?; + + // Compute inclusion probabilities + let inclusion_probabilities = InclusionProbabilities::compute( + &mixnode_details, + interval_reward_params, + ) + .ok_or_else(|| { + error!("Failed to simulate selection probabilties for mixnodes, not updating cache"); + NodeStatusCacheError::SimulationFailed + })?; + + // Create annotated data + let rewarded_set_node_status = to_rewarded_set_node_status(&rewarded_set, &active_set); + let mixnodes_annotated = self + .annotate_node_with_details( + mixnode_details, + interval_reward_params, + current_interval, + &rewarded_set_node_status, + ) + .await; + + // Create the annotated rewarded and active sets + let (rewarded_set, active_set) = + split_into_active_and_rewarded_set(&mixnodes_annotated, &rewarded_set_node_status); + + self.cache + .update_cache( + mixnodes_annotated, + rewarded_set, + active_set, + inclusion_probabilities, + ) + .await; Ok(()) } + + async fn get_performance_from_storage( + &self, + mix_id: MixId, + epoch: Interval, + ) -> Option { + self.storage + .as_ref()? + .get_average_mixnode_uptime_in_the_last_24hrs( + mix_id, + epoch.current_epoch_end_unix_timestamp(), + ) + .await + .ok() + .map(Into::into) + } + + async fn annotate_node_with_details( + &self, + mixnodes: Vec, + interval_reward_params: RewardingParams, + current_interval: Interval, + rewarded_set: &HashMap, + ) -> Vec { + let mut annotated = Vec::new(); + for mixnode in mixnodes { + let stake_saturation = mixnode + .rewarding_details + .bond_saturation(&interval_reward_params); + + let uncapped_stake_saturation = mixnode + .rewarding_details + .uncapped_bond_saturation(&interval_reward_params); + + // If the performance can't be obtained, because the validator-api was not started with + // the monitoring (and hence, storage), then reward estimates will be all zero + let performance = self + .get_performance_from_storage(mixnode.mix_id(), current_interval) + .await + .unwrap_or_default(); + + let rewarded_set_status = rewarded_set.get(&mixnode.mix_id()).copied(); + + let reward_estimate = compute_reward_estimate( + &mixnode, + performance, + rewarded_set_status, + interval_reward_params, + current_interval, + ); + + let (estimated_operator_apy, estimated_delegators_apy) = + compute_apy_from_reward(&mixnode, reward_estimate, current_interval); + + annotated.push(MixNodeBondAnnotated { + mixnode_details: mixnode, + stake_saturation, + uncapped_stake_saturation, + performance, + estimated_operator_apy, + estimated_delegators_apy, + }); + } + annotated + } } -fn compute_inclusion_probabilities( - mixnodes: &[MixNodeDetails], - params: RewardingParams, -) -> Option { - let active_set_size = params.active_set_size; - let standby_set_size = params.rewarded_set_size - active_set_size; - - // Unzip list of total bonds into ids and bonds. - // We need to go through this zip/unzip procedure to make sure we have matching identities - // for the input to the simulator, which assumes the identity is the position in the vec - let (ids, mixnode_total_bonds) = unzip_into_mixnode_ids_and_total_bonds(mixnodes); - - // Compute inclusion probabilitites and keep track of how long time it took. - let mut rng = rand::thread_rng(); - let results = inclusion_probability::simulate_selection_probability_mixnodes( - &mixnode_total_bonds, - active_set_size as usize, - standby_set_size as usize, - MAX_SIMULATION_SAMPLES, - Duration::from_secs(MAX_SIMULATION_TIME_SEC), - &mut rng, - ) - .tap_err(|err| error!("{err}")) - .ok()?; - - Some(InclusionProbabilities { - inclusion_probabilities: zip_ids_together_with_results(&ids, &results), - samples: results.samples, - elapsed: results.time, - delta_max: results.delta_max, - delta_l2: results.delta_l2, - }) -} - -fn unzip_into_mixnode_ids_and_total_bonds(mixnodes: &[MixNodeDetails]) -> (Vec, Vec) { - mixnodes +fn to_rewarded_set_node_status( + rewarded_set: &[MixNodeDetails], + active_set: &[MixNodeDetails], +) -> HashMap { + let mut rewarded_set_node_status: HashMap = rewarded_set .iter() - .map(|m| (m.mix_id(), truncate_decimal(m.total_stake()).u128())) - .unzip() + .map(|m| (m.mix_id(), RewardedSetNodeStatus::Standby)) + .collect(); + for mixnode in active_set { + *rewarded_set_node_status + .get_mut(&mixnode.mix_id()) + .expect("All active nodes are rewarded nodes") = RewardedSetNodeStatus::Active; + } + rewarded_set_node_status } -fn zip_ids_together_with_results( - ids: &[MixId], - results: &inclusion_probability::SelectionProbability, -) -> Vec { - ids.iter() - .zip(results.active_set_probability.iter()) - .zip(results.reserve_set_probability.iter()) - .map(|((&mix_id, a), r)| InclusionProbability { - mix_id, - in_active: *a, - in_reserve: *r, +fn split_into_active_and_rewarded_set( + mixnodes_annotated: &[MixNodeBondAnnotated], + rewarded_set_node_status: &HashMap, +) -> (Vec, Vec) { + let rewarded_set: Vec<_> = mixnodes_annotated + .iter() + .filter(|mixnode| rewarded_set_node_status.get(&mixnode.mix_id()).is_some()) + .cloned() + .collect(); + let active_set: Vec<_> = rewarded_set + .iter() + .filter(|mixnode| { + rewarded_set_node_status + .get(&mixnode.mix_id()) + .map_or(false, RewardedSetNodeStatus::is_active) }) - .collect() + .cloned() + .collect(); + (rewarded_set, active_set) } diff --git a/validator-api/src/node_status_api/cache/inclusion_probabilities.rs b/validator-api/src/node_status_api/cache/inclusion_probabilities.rs new file mode 100644 index 0000000000..21c1bdcab5 --- /dev/null +++ b/validator-api/src/node_status_api/cache/inclusion_probabilities.rs @@ -0,0 +1,89 @@ +use contracts_common::truncate_decimal; +use mixnet_contract_common::{MixId, MixNodeDetails, RewardingParams}; +use serde::Serialize; +use std::time::Duration; +use tap::TapFallible; +use validator_api_requests::models::InclusionProbability; + +const MAX_SIMULATION_SAMPLES: u64 = 5000; +const MAX_SIMULATION_TIME_SEC: u64 = 15; + +#[derive(Clone, Default, Serialize, schemars::JsonSchema)] +pub(crate) struct InclusionProbabilities { + pub inclusion_probabilities: Vec, + pub samples: u64, + pub elapsed: Duration, + pub delta_max: f64, + pub delta_l2: f64, +} + +impl InclusionProbabilities { + pub(crate) fn compute( + mixnodes: &[MixNodeDetails], + params: RewardingParams, + ) -> Option { + compute_inclusion_probabilities(mixnodes, params) + } + + pub(crate) fn node(&self, mix_id: MixId) -> Option<&InclusionProbability> { + self.inclusion_probabilities + .iter() + .find(|x| x.mix_id == mix_id) + } +} + +fn compute_inclusion_probabilities( + mixnodes: &[MixNodeDetails], + params: RewardingParams, +) -> Option { + let active_set_size = params.active_set_size; + let standby_set_size = params.rewarded_set_size - active_set_size; + + // Unzip list of total bonds into ids and bonds. + // We need to go through this zip/unzip procedure to make sure we have matching identities + // for the input to the simulator, which assumes the identity is the position in the vec + let (ids, mixnode_total_bonds) = unzip_into_mixnode_ids_and_total_bonds(mixnodes); + + // Compute inclusion probabilitites and keep track of how long time it took. + let mut rng = rand::thread_rng(); + let results = inclusion_probability::simulate_selection_probability_mixnodes( + &mixnode_total_bonds, + active_set_size as usize, + standby_set_size as usize, + MAX_SIMULATION_SAMPLES, + Duration::from_secs(MAX_SIMULATION_TIME_SEC), + &mut rng, + ) + .tap_err(|err| error!("{err}")) + .ok()?; + + Some(InclusionProbabilities { + inclusion_probabilities: zip_ids_together_with_results(&ids, &results), + samples: results.samples, + elapsed: results.time, + delta_max: results.delta_max, + delta_l2: results.delta_l2, + }) +} + +fn unzip_into_mixnode_ids_and_total_bonds(mixnodes: &[MixNodeDetails]) -> (Vec, Vec) { + mixnodes + .iter() + .map(|m| (m.mix_id(), truncate_decimal(m.total_stake()).u128())) + .unzip() +} + +fn zip_ids_together_with_results( + ids: &[MixId], + results: &inclusion_probability::SelectionProbability, +) -> Vec { + ids.iter() + .zip(results.active_set_probability.iter()) + .zip(results.reserve_set_probability.iter()) + .map(|((&mix_id, a), r)| InclusionProbability { + mix_id, + in_active: *a, + in_reserve: *r, + }) + .collect() +} diff --git a/validator-api/src/node_status_api/helpers.rs b/validator-api/src/node_status_api/helpers.rs index c0c2a544bd..4b084533bc 100644 --- a/validator-api/src/node_status_api/helpers.rs +++ b/validator-api/src/node_status_api/helpers.rs @@ -1,7 +1,6 @@ // Copyright 2021-2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::contract_cache::reward_estimate::compute_reward_estimate; use crate::contract_cache::Cache; use crate::node_status_api::models::ErrorResponse; use crate::storage::ValidatorApiStorage; @@ -12,11 +11,14 @@ use mixnet_contract_common::{Interval, MixId, RewardedSetNodeStatus}; use rocket::http::Status; use rocket::State; use validator_api_requests::models::{ - ComputeRewardEstParam, InclusionProbabilityResponse, MixnodeCoreStatusResponse, - MixnodeStatusReportResponse, MixnodeStatusResponse, MixnodeUptimeHistoryResponse, - RewardEstimationResponse, StakeSaturationResponse, UptimeResponse, + AllInclusionProbabilitiesResponse, ComputeRewardEstParam, InclusionProbabilityResponse, + MixNodeBondAnnotated, MixnodeCoreStatusResponse, MixnodeStatusReportResponse, + MixnodeStatusResponse, MixnodeUptimeHistoryResponse, RewardEstimationResponse, + StakeSaturationResponse, UptimeResponse, }; +use super::reward_estimate::compute_reward_estimate; + pub(crate) async fn _mixnode_report( storage: &ValidatorApiStorage, mix_id: MixId, @@ -62,17 +64,18 @@ pub(crate) async fn _get_mixnode_status( } pub(crate) async fn _get_mixnode_reward_estimation( - cache: &State, + cache: &State, + validator_cache: &State, mix_id: MixId, ) -> Result { let (mixnode, status) = cache.mixnode_details(mix_id).await; if let Some(mixnode) = mixnode { - let reward_params = cache.interval_reward_params().await; + let reward_params = validator_cache.interval_reward_params().await; let as_at = reward_params.timestamp(); let reward_params = reward_params .into_inner() .ok_or_else(|| ErrorResponse::new("server error", Status::InternalServerError))?; - let current_interval = cache + let current_interval = validator_cache .current_interval() .await .into_inner() @@ -117,17 +120,18 @@ async fn average_mixnode_performance( pub(crate) async fn _compute_mixnode_reward_estimation( user_reward_param: ComputeRewardEstParam, - cache: &ValidatorCache, + cache: &NodeStatusCache, + validator_cache: &ValidatorCache, mix_id: MixId, ) -> Result { let (mixnode, actual_status) = cache.mixnode_details(mix_id).await; if let Some(mut mixnode) = mixnode { - let reward_params = cache.interval_reward_params().await; + let reward_params = validator_cache.interval_reward_params().await; let as_at = reward_params.timestamp(); let reward_params = reward_params .into_inner() .ok_or_else(|| ErrorResponse::new("server error", Status::InternalServerError))?; - let current_interval = cache + let current_interval = validator_cache .current_interval() .await .into_inner() @@ -200,14 +204,15 @@ pub(crate) async fn _compute_mixnode_reward_estimation( } pub(crate) async fn _get_mixnode_stake_saturation( - cache: &ValidatorCache, + cache: &NodeStatusCache, + validator_cache: &ValidatorCache, mix_id: MixId, ) -> Result { let (mixnode, _) = cache.mixnode_details(mix_id).await; if let Some(mixnode) = mixnode { // Recompute the stake saturation just so that we can confidently state that the `as_at` // field is consistent and correct. Luckily this is very cheap. - let reward_params = cache.interval_reward_params().await; + let reward_params = validator_cache.interval_reward_params().await; let as_at = reward_params.timestamp(); let rewarding_params = reward_params .into_inner() @@ -266,3 +271,51 @@ pub(crate) async fn _get_mixnode_avg_uptime( avg_uptime: performance.round_to_integer(), }) } + +pub(crate) async fn _get_mixnode_inclusion_probabilities( + cache: &NodeStatusCache, +) -> Result { + if let Some(prob) = cache.inclusion_probabilities().await { + let as_at = prob.timestamp(); + let prob = prob.into_inner(); + Ok(AllInclusionProbabilitiesResponse { + inclusion_probabilities: prob.inclusion_probabilities, + samples: prob.samples, + elapsed: prob.elapsed, + delta_max: prob.delta_max, + delta_l2: prob.delta_l2, + as_at, + }) + } else { + Err(ErrorResponse::new( + "No data available".to_string(), + Status::ServiceUnavailable, + )) + } +} + +pub(crate) async fn _get_mixnodes_detailed(cache: &NodeStatusCache) -> Vec { + cache + .mixnodes_annotated() + .await + .unwrap_or_default() + .into_inner() +} + +pub(crate) async fn _get_rewarded_set_detailed( + cache: &NodeStatusCache, +) -> Vec { + cache + .rewarded_set_annotated() + .await + .unwrap_or_default() + .into_inner() +} + +pub(crate) async fn _get_active_set_detailed(cache: &NodeStatusCache) -> Vec { + cache + .active_set_annotated() + .await + .unwrap_or_default() + .into_inner() +} diff --git a/validator-api/src/node_status_api/mod.rs b/validator-api/src/node_status_api/mod.rs index 08cf612dba..cbcf1784f2 100644 --- a/validator-api/src/node_status_api/mod.rs +++ b/validator-api/src/node_status_api/mod.rs @@ -10,6 +10,7 @@ pub(crate) mod cache; pub(crate) mod helpers; pub(crate) mod local_guard; pub(crate) mod models; +pub(crate) mod reward_estimate; pub(crate) mod routes; pub(crate) mod uptime_updater; pub(crate) mod utils; @@ -37,6 +38,9 @@ pub(crate) fn node_status_routes( routes::get_mixnode_inclusion_probability, routes::get_mixnode_avg_uptime, routes::get_mixnode_inclusion_probabilities, + routes::get_mixnodes_detailed, + routes::get_rewarded_set_detailed, + routes::get_active_set_detailed, ] } else { // in the minimal variant we would not have access to endpoints relying on existence @@ -46,6 +50,9 @@ pub(crate) fn node_status_routes( routes::get_mixnode_stake_saturation, routes::get_mixnode_inclusion_probability, routes::get_mixnode_inclusion_probabilities, + routes::get_mixnodes_detailed, + routes::get_rewarded_set_detailed, + routes::get_active_set_detailed, ] } } diff --git a/validator-api/src/contract_cache/reward_estimate.rs b/validator-api/src/node_status_api/reward_estimate.rs similarity index 96% rename from validator-api/src/contract_cache/reward_estimate.rs rename to validator-api/src/node_status_api/reward_estimate.rs index 41e285a0a0..33bbd8320c 100644 --- a/validator-api/src/contract_cache/reward_estimate.rs +++ b/validator-api/src/node_status_api/reward_estimate.rs @@ -7,7 +7,7 @@ use mixnet_contract_common::reward_params::{NodeRewardParams, Performance, Rewar use mixnet_contract_common::rewarding::RewardEstimate; use mixnet_contract_common::{Interval, RewardedSetNodeStatus}; -pub fn compute_apy(epochs_in_year: Decimal, reward: Decimal, pledge_amount: Decimal) -> Decimal { +fn compute_apy(epochs_in_year: Decimal, reward: Decimal, pledge_amount: Decimal) -> Decimal { if pledge_amount.is_zero() { return Decimal::zero(); } diff --git a/validator-api/src/node_status_api/routes.rs b/validator-api/src/node_status_api/routes.rs index 7665f1bac1..037ca7b117 100644 --- a/validator-api/src/node_status_api/routes.rs +++ b/validator-api/src/node_status_api/routes.rs @@ -3,9 +3,10 @@ use super::NodeStatusCache; use crate::node_status_api::helpers::{ - _compute_mixnode_reward_estimation, _get_mixnode_avg_uptime, - _get_mixnode_inclusion_probability, _get_mixnode_reward_estimation, - _get_mixnode_stake_saturation, _get_mixnode_status, _mixnode_core_status_count, + _compute_mixnode_reward_estimation, _get_active_set_detailed, _get_mixnode_avg_uptime, + _get_mixnode_inclusion_probabilities, _get_mixnode_inclusion_probability, + _get_mixnode_reward_estimation, _get_mixnode_stake_saturation, _get_mixnode_status, + _get_mixnodes_detailed, _get_rewarded_set_detailed, _mixnode_core_status_count, _mixnode_report, _mixnode_uptime_history, }; use crate::node_status_api::models::ErrorResponse; @@ -19,9 +20,9 @@ use rocket_okapi::openapi; use validator_api_requests::models::{ AllInclusionProbabilitiesResponse, ComputeRewardEstParam, GatewayCoreStatusResponse, GatewayStatusReportResponse, GatewayUptimeHistoryResponse, InclusionProbabilityResponse, - MixnodeCoreStatusResponse, MixnodeStatusReportResponse, MixnodeStatusResponse, - MixnodeUptimeHistoryResponse, RewardEstimationResponse, StakeSaturationResponse, - UptimeResponse, + MixNodeBondAnnotated, MixnodeCoreStatusResponse, MixnodeStatusReportResponse, + MixnodeStatusResponse, MixnodeUptimeHistoryResponse, RewardEstimationResponse, + StakeSaturationResponse, UptimeResponse, }; #[openapi(tag = "status")] @@ -112,10 +113,13 @@ pub(crate) async fn get_mixnode_status( #[openapi(tag = "status")] #[get("/mixnode//reward-estimation")] pub(crate) async fn get_mixnode_reward_estimation( - cache: &State, + cache: &State, + validator_cache: &State, mix_id: MixId, ) -> Result, ErrorResponse> { - Ok(Json(_get_mixnode_reward_estimation(cache, mix_id).await?)) + Ok(Json( + _get_mixnode_reward_estimation(cache, validator_cache, mix_id).await?, + )) } #[openapi(tag = "status")] @@ -125,21 +129,31 @@ pub(crate) async fn get_mixnode_reward_estimation( )] pub(crate) async fn compute_mixnode_reward_estimation( user_reward_param: Json, - cache: &State, + cache: &State, + validator_cache: &State, mix_id: MixId, ) -> Result, ErrorResponse> { Ok(Json( - _compute_mixnode_reward_estimation(user_reward_param.into_inner(), cache, mix_id).await?, + _compute_mixnode_reward_estimation( + user_reward_param.into_inner(), + cache, + validator_cache, + mix_id, + ) + .await?, )) } #[openapi(tag = "status")] #[get("/mixnode//stake-saturation")] pub(crate) async fn get_mixnode_stake_saturation( - cache: &State, + cache: &State, + validator_cache: &State, mix_id: MixId, ) -> Result, ErrorResponse> { - Ok(Json(_get_mixnode_stake_saturation(cache, mix_id).await?)) + Ok(Json( + _get_mixnode_stake_saturation(cache, validator_cache, mix_id).await?, + )) } #[openapi(tag = "status")] @@ -168,21 +182,29 @@ pub(crate) async fn get_mixnode_avg_uptime( pub(crate) async fn get_mixnode_inclusion_probabilities( cache: &State, ) -> Result, ErrorResponse> { - if let Some(prob) = cache.inclusion_probabilities().await { - let as_at = prob.timestamp(); - let prob = prob.into_inner(); - Ok(Json(AllInclusionProbabilitiesResponse { - inclusion_probabilities: prob.inclusion_probabilities, - samples: prob.samples, - elapsed: prob.elapsed, - delta_max: prob.delta_max, - delta_l2: prob.delta_l2, - as_at, - })) - } else { - Err(ErrorResponse::new( - "No data available".to_string(), - Status::ServiceUnavailable, - )) - } + Ok(Json(_get_mixnode_inclusion_probabilities(cache).await?)) +} + +#[openapi(tag = "status")] +#[get("/mixnodes/detailed")] +pub async fn get_mixnodes_detailed( + cache: &State, +) -> Json> { + Json(_get_mixnodes_detailed(cache).await) +} + +#[openapi(tag = "status")] +#[get("/mixnodes/rewarded/detailed")] +pub async fn get_rewarded_set_detailed( + cache: &State, +) -> Json> { + Json(_get_rewarded_set_detailed(cache).await) +} + +#[openapi(tag = "status")] +#[get("/mixnodes/active/detailed")] +pub async fn get_active_set_detailed( + cache: &State, +) -> Json> { + Json(_get_active_set_detailed(cache).await) }