Compare commits

...

43 Commits

Author SHA1 Message Date
Tommy Verrall 55a0f80d73 Feat: implement supply chain attack mitigation
- Add yarn resolutions for vulnerable packages (chalk, strip-ansi, color-convert, etc.)
- Add .npmrc and .nvmrc security configurations
2025-09-10 18:51:38 +02:00
Jędrzej Stuczyński d3cdaf373b Feature/credential proxy crate (#6018)
* moved storage and deposits buffer to the common lib

* move more of the state into the shared lib

* extracted the rest of the features into the shared lib

* fixed test imports

* clippy
2025-09-10 09:28:38 +01:00
Jędrzej Stuczyński 7c5f10a219 refresh mixnet contract on epoch progression (#6023) 2025-09-09 09:59:54 +01:00
Simon Wicky f90fc4f2f0 Moving clients crate from vpn-client repo to here (#6015)
* moving crates as is

* changes due to crate moving

* cargo fmt
2025-09-08 10:50:18 +02:00
Jędrzej Stuczyński e95aca715c feat: use ShutdownToken (CancellationToken inside) for nym-api (#5997)
* make nym-api use ShutdownToken instead of TaskClient

* ignore public-api tests if env is not set

* removed default features to avoid pulling in openssl
2025-09-08 09:45:28 +01:00
Bogdan-Ștefan Neacşu 8e7d1d510d Use default value for the ports until api is deployed (#6007) 2025-09-04 15:55:56 +03:00
import this 4062734a31 [DOCs/operators]: NIP-2 tokenomics update & fix csv2md bug (#6008) 2025-09-04 11:29:44 +02:00
import this ccd8ff26a3 Feature: Delegation program stake checker and adjuster (#5980)
* initialise stake adjustment program

* add readme file with a simple guide

* syntax

* syntax

* FINISHED: faster and returning more data

* change dwl link to develop branch
2025-09-03 16:06:06 +00:00
import this 43d043a9cd Feature: Nym node autorun CLI (#5916)
* initial commit - add prereqs install script

* add env vars prompt

* automate latest binary url env var

* add install node script

* add modes to nym-node install script

* start main cli framework

* adding branch var for easier deployment and testing

* add systemd config

* add proxy and wss setup script

* add landing page stub and fix nginx script

* add nginx setup

* fix typo

* add checks for existing dir and wg prompt

* add nginx commands

* add service file check

* add service file check

* convention alignment

* add checks to nginx setup

* cleanup old code

* add bonding prompt and nym node run fns

* fix syntax

* fix syntax

* fix syntax

* fix syntax

* fix syntax

* fix syntax

* fix syntax

* fix syntax

* add service script to init

* fix syntax

* fix syntax

* add chmod

* fix script logic

* syntax fix

* syntax fix

* silent mode trial

* fix evn prompt script

* make scripts interactive

* indent fix

* correct node-install script

* initial mixnode setup working - gws need more love

* fix bonding function

* syntax fix

* improve run noide as service script

* improve service script

* improve run service fn

* fix logic

* beautify

* beautify

* create run node as service script

* syntax fix

* attempt to resolve memory running out issue

* attempt to resolve memory running out issue

* attempt to resolve memory running out issue

* attempt to resolve memory running out issue

* attempt to resolve memory running out issue

* attempt to resolve memory running out issue

* attempt to resolve memory running out issue

* attempt to resolve memory running out issue

* setting wireguard

* solved memory issues

* rename landing page template

* modify wireguard enabled fn

* layout change

* syntax fix

* modify node setup script

* sync up envs

* return missing function

* fix urls

* fix network manager script execution

* fix wss and nginx

* fix layout

* tweak WG contion

* syntax fix

* add init placeholder

* syntax fix

* redefine wireguard check logic

* check if node exists

* add argparse and dev option

* styling

* add panic

* add error message

* improve logic

* improve logic

* add arg

* add dev arg for all levels

* add confirmation loop

* styling

* fix bonding question

* syntax edit

* syntax edit

* syntax edit

* refactor for already bonded nodes

* add default branch on top and define metavar

* fix node install script

* clean and prepare for review

* indentation fix

* fix nginx setup

* fix nginx setup

* style cleanup

* fix try error logic

* tune --dev option to run before command correctly

* fix y/n convention across the modules

* add explorer URL to the message

* minor layout fixes
2025-09-02 20:34:24 +00:00
Drazen Urch 3d6cf730c2 NS-API: Cast to BIGINT to make i64 work (#6003) 2025-09-02 18:35:25 +01:00
Jędrzej Stuczyński c0f8d98b63 bugfix: return from MixTrafficController if client request channel has closed (#6002) 2025-09-02 10:23:25 +01:00
Jędrzej Stuczyński 91995da4f1 chore: use updated version of simulate endpoint (#5988) 2025-09-02 10:12:52 +01:00
Jędrzej Stuczyński 01fa1df66c feat: shared library for attempting to retrieve update mode attestation (#5954)
* feat: shared library for attempting to retrieve update mode attestation

* clippy

* add nym- prefix to the crate name

* use pure-rust impl for jwt-simple
2025-09-02 09:28:32 +01:00
Jędrzej Stuczyński baddaaac22 feat: nym signers monitor (#5933)
* initialise nym-signers-monitor

* creating nyxd client

* performing checks

* sending notifications on failure

* rate limitting on notifications + clippy
2025-09-02 09:27:09 +01:00
elsirion 2c4b5f168b fix: use WASM compatible time API in client (#5948) 2025-09-02 09:26:06 +01:00
Bogdan-Ștefan Neacşu a557ac22c7 Revert "Create an axum_test client for more integrated unit testing (#5956)" (#5999)
This reverts commit efd61eb47c.
2025-09-01 15:37:10 +03:00
Jędrzej Stuczyński 55ef89178b chore: upgraded syn to 2.0 and removed nym-execute (#5998) 2025-09-01 12:59:13 +01:00
Jędrzej Stuczyński d97be2d8ef bugfix: Recipient deserialisation for deserialisers missing bytes specialisation (#5991)
* bugfix: Recipient deserialisation for deserialisers missing bytes specialisation

for example toml or json will just default to visit_seq ignoring bytes related optimisations

* clippy
2025-09-01 11:30:35 +01:00
Bogdan-Ștefan Neacşu efd61eb47c Create an axum_test client for more integrated unit testing (#5956) 2025-09-01 13:27:06 +03:00
benedetta davico 4a01973b31 Merge pull request #5981 from nymtech/benny/ns-api-ci-fix
Fix the ns api ci workflow
2025-09-01 11:02:21 +02:00
Mark Sinclair 9ad9c3b8e7 Bug fix: NS API monikers (#5990)
* node-status-api: fix missing monikers because of deserialisation issues from unstructured data

* node-status-api: bump version after bug fix monikers

---------

Co-authored-by: Mark Sinclair <mmsinclair@users.noreply.github.com>
2025-09-01 09:48:37 +01:00
dependabot[bot] 6706500132 build(deps): bump pbkdf2 from 3.1.2 to 3.1.3 (#5869)
Bumps [pbkdf2](https://github.com/crypto-browserify/pbkdf2) from 3.1.2 to 3.1.3.
- [Changelog](https://github.com/browserify/pbkdf2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crypto-browserify/pbkdf2/compare/v3.1.2...v3.1.3)

---
updated-dependencies:
- dependency-name: pbkdf2
  dependency-version: 3.1.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-29 15:52:10 +01:00
dependabot[bot] 33fe059c28 Bump console from 0.15.11 to 0.16.0 (#5931)
Bumps [console](https://github.com/console-rs/console) from 0.15.11 to 0.16.0.
- [Release notes](https://github.com/console-rs/console/releases)
- [Changelog](https://github.com/console-rs/console/blob/main/CHANGELOG.md)
- [Commits](https://github.com/console-rs/console/compare/0.15.11...0.16.0)

---
updated-dependencies:
- dependency-name: console
  dependency-version: 0.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-29 14:24:03 +01:00
dependabot[bot] d6ed2b770b Bump indicatif from 0.17.11 to 0.18.0 (#5924)
Bumps [indicatif](https://github.com/console-rs/indicatif) from 0.17.11 to 0.18.0.
- [Release notes](https://github.com/console-rs/indicatif/releases)
- [Commits](https://github.com/console-rs/indicatif/compare/0.17.11...0.18.0)

---
updated-dependencies:
- dependency-name: indicatif
  dependency-version: 0.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-29 14:23:39 +01:00
dependabot[bot] 7c18a3dced Bump mock_instant from 0.5.3 to 0.6.0 (#5930)
Bumps [mock_instant](https://github.com/museun/mock_instant) from 0.5.3 to 0.6.0.
- [Commits](https://github.com/museun/mock_instant/commits/v0.6.0)

---
updated-dependencies:
- dependency-name: mock_instant
  dependency-version: 0.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-29 14:20:22 +01:00
dependabot[bot] 09475ab4e0 build(deps): bump mikefarah/yq from 4.45.4 to 4.47.1 (#5911)
Bumps [mikefarah/yq](https://github.com/mikefarah/yq) from 4.45.4 to 4.47.1.
- [Release notes](https://github.com/mikefarah/yq/releases)
- [Changelog](https://github.com/mikefarah/yq/blob/master/release_notes.txt)
- [Commits](https://github.com/mikefarah/yq/compare/v4.45.4...v4.47.1)

---
updated-dependencies:
- dependency-name: mikefarah/yq
  dependency-version: 4.47.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-29 13:36:39 +01:00
dependabot[bot] b7606cd2ef Bump defguard_wireguard_rs from v0.4.7 to v0.7.5 (#5928)
Bumps [defguard_wireguard_rs](https://github.com/DefGuard/wireguard-rs) from v0.4.7 to v0.7.5.
- [Release notes](https://github.com/DefGuard/wireguard-rs/releases)
- [Commits](https://github.com/DefGuard/wireguard-rs/compare/ef1cf3714629bf5016fb38cbb7320451dc69fb09...d090d2249e5bb3d4154f07de098387e2ab69bfdc)

---
updated-dependencies:
- dependency-name: defguard_wireguard_rs
  dependency-version: d090d2249e5bb3d4154f07de098387e2ab69bfdc
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-29 13:35:42 +01:00
dependabot[bot] 006a57312d Bump tokio from 1.46.1 to 1.47.1 (#5929)
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.46.1 to 1.47.1.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.46.1...tokio-1.47.1)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.47.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-29 13:35:18 +01:00
dependabot[bot] 9b5aded8a5 build(deps): bump actions/download-artifact from 4 to 5 (#5939)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-29 13:33:31 +01:00
dependabot[bot] f4a69636fe build(deps): bump actions/first-interaction from 1 to 3 (#5950)
Bumps [actions/first-interaction](https://github.com/actions/first-interaction) from 1 to 3.
- [Release notes](https://github.com/actions/first-interaction/releases)
- [Commits](https://github.com/actions/first-interaction/compare/v1...v3)

---
updated-dependencies:
- dependency-name: actions/first-interaction
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-29 13:31:29 +01:00
dependabot[bot] 0463d88646 Bump slab from 0.4.10 to 0.4.11 (#5952)
Bumps [slab](https://github.com/tokio-rs/slab) from 0.4.10 to 0.4.11.
- [Release notes](https://github.com/tokio-rs/slab/releases)
- [Changelog](https://github.com/tokio-rs/slab/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/slab/compare/v0.4.10...v0.4.11)

---
updated-dependencies:
- dependency-name: slab
  dependency-version: 0.4.11
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-29 13:31:02 +01:00
dependabot[bot] 534bf5d824 build(deps): bump actions/setup-java from 4 to 5 (#5975)
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 4 to 5.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-29 13:30:00 +01:00
dependabot[bot] 34684b14db Bump sha.js from 2.4.11 to 2.4.12 (#5983)
Bumps [sha.js](https://github.com/crypto-browserify/sha.js) from 2.4.11 to 2.4.12.
- [Changelog](https://github.com/browserify/sha.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crypto-browserify/sha.js/compare/v2.4.11...v2.4.12)

---
updated-dependencies:
- dependency-name: sha.js
  dependency-version: 2.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-29 13:29:18 +01:00
Jędrzej Stuczyński b2266d04ef chore: internal hidden command to force advance nyx epoch (#5964) 2025-08-29 11:41:22 +01:00
Jędrzej Stuczyński 911b365609 chore: purge temp databases on build (#5984)
* purge any temp databases on build

* updated min rust version

* fixed clippy::manual_abs_diff' in verloc due to updated msrv

* wasm
2025-08-29 11:41:08 +01:00
Jędrzej Stuczyński e9acc014ed feat: credential proxy deposit pool (#5945)
* chore: rename VpnApiError to CredentialProxyError

* reorganise deposit flow

* updated sql tables et al.

* insert information about deposit usage failure

* remove old deposit maker

* nym credential proxy to monitor quorum state to stop issuance if it'd fail

* clippy

* target lock new modules

* windows clippy

* renamed migration file due to rebasing
2025-08-29 09:39:57 +01:00
Jędrzej Stuczyński 0f66e5a154 bugfix: make sure tables are removed in correct order to not trigger FK constraint issue (#5987) 2025-08-29 09:03:22 +01:00
Bogdan-Ștefan Neacşu f8337d9b38 Wireguard metadata client library (#5943) 2025-08-28 15:43:46 +03:00
Bogdan-Ștefan Neacşu 4fb252c44b Wireguard private metadata (#5915) 2025-08-28 15:14:52 +03:00
Jędrzej Stuczyński 17708cdf92 bugfix: manually calculate per node work on rewarded set changes (#5972) 2025-08-27 12:33:24 +01:00
Andrej Mihajlov a9c56ef9ac Merge pull request #5976 from nymtech/am/update-sysinfo
Update sysinfo to the latest
2025-08-27 10:58:06 +02:00
Jędrzej Stuczyński 724420f97c chore: move authenticator into gateway crate (#5982)
* removed unused bits of authenticator config

* moved authenticator into gateway

* cleaned up imports

* clippy
2025-08-27 09:05:02 +01:00
Andrej Mihajlov 5c8749a2e1 Update sysinfo to the latest
Shakes out windows@0.57 from the tree
2025-08-23 09:29:47 +02:00
284 changed files with 18904 additions and 7151 deletions
@@ -16,7 +16,7 @@ jobs:
uses: actions/checkout@v4
- name: Get version from cargo.toml
uses: mikefarah/yq@v4.45.4
uses: mikefarah/yq@v4.47.1
id: get_version
with:
cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/Cargo.toml
@@ -16,7 +16,7 @@ jobs:
uses: actions/checkout@v4
- name: Get version from cargo.toml
uses: mikefarah/yq@v4.45.4
uses: mikefarah/yq@v4.47.1
id: get_version
with:
cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/Cargo.toml
+1 -1
View File
@@ -6,7 +6,7 @@ jobs:
greeting:
runs-on: ubuntu-latest
steps:
- uses: actions/first-interaction@v1
- uses: actions/first-interaction@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
issue-message: 'Thank you for raising this issue'
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Check out repository code
uses: actions/checkout@v4
- name: Download report from previous job
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: report
path: .github/workflows/support-files/notifications
@@ -25,7 +25,7 @@ jobs:
uses: actions/checkout@v4
- name: Install Java
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: "temurin"
java-version: "17"
@@ -91,7 +91,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Download binary artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: nyms5-apk-arch64
path: apk
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
git config --global user.name "Lawrence Stalder"
- name: Get version from cargo.toml
uses: mikefarah/yq@v4.45.4
uses: mikefarah/yq@v4.47.1
id: get_version
with:
cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/nym-credential-proxy/Cargo.toml
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
git config --global user.name "Lawrence Stalder"
- name: Get version from cargo.toml
uses: mikefarah/yq@v4.45.4
uses: mikefarah/yq@v4.47.1
id: get_version
with:
cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/Cargo.toml
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
git config --global user.name "Lawrence Stalder"
- name: Get version from cargo.toml
uses: mikefarah/yq@v4.45.4
uses: mikefarah/yq@v4.47.1
id: get_version
with:
cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/nym-network-monitor/Cargo.toml
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
git config --global user.name "Lawrence Stalder"
- name: Get version from cargo.toml
uses: mikefarah/yq@v4.45.4
uses: mikefarah/yq@v4.47.1
id: get_version
with:
cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/nym-api/Cargo.toml
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
git config --global user.name "Lawrence Stalder"
- name: Get version from cargo.toml
uses: mikefarah/yq@v4.45.4
uses: mikefarah/yq@v4.47.1
id: get_version
with:
cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/Cargo.toml
@@ -26,7 +26,7 @@ jobs:
git config --global user.name "Lawrence Stalder"
- name: Get version from cargo.toml
uses: mikefarah/yq@v4.45.4
uses: mikefarah/yq@v4.47.1
id: get_version
with:
cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/Cargo.toml
@@ -26,7 +26,7 @@ jobs:
git config --global user.name "Lawrence Stalder"
- name: Get version from cargo.toml
uses: mikefarah/yq@v4.45.4
uses: mikefarah/yq@v4.47.1
id: get_version
with:
cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/Cargo.toml
@@ -26,7 +26,7 @@ jobs:
git config --global user.name "Lawrence Stalder"
- name: Get version from cargo.toml
uses: mikefarah/yq@v4.45.4
uses: mikefarah/yq@v4.47.1
id: get_version
with:
cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/Cargo.toml
+58
View File
@@ -0,0 +1,58 @@
# Security and sensitive files
.env*
*.key
*.pem
*.p12
*.pfx
secrets/
private/
config/secrets/
# Development files
node_modules/
.npm/
.npmrc
.nvmrc
*.log
*.tmp
.DS_Store
Thumbs.db
# Build artifacts
dist/
build/
target/
*.tgz
*.tar.gz
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# Test files
test/
tests/
__tests__/
*.test.js
*.test.ts
*.spec.js
*.spec.ts
# Documentation
docs/
*.md
!README.md
# CI/CD files
.github/
.gitlab-ci.yml
.travis.yml
.circleci/
azure-pipelines.yml
# Scripts
scripts/
!scripts/security-check.sh
+21
View File
@@ -0,0 +1,21 @@
audit-level=moderate
fund=false
update-notifier=false
ignore-scripts=false
strict-ssl=true
registry=https://registry.npmjs.org/
audit=true
package-lock=true
package-lock-only=false
save-exact=false
# use npm ci for production builds (faster and more secure)
# this will be enforced in CI/CD scripts
# prevent installation of optional dependencies that might contain vulnerabilities
optional=false
audit=true
update-notifier=false
save-exact=false
+1
View File
@@ -0,0 +1 @@
20.18.0
Generated
+429 -171
View File
File diff suppressed because it is too large Load Diff
+18 -11
View File
@@ -43,6 +43,7 @@ members = [
"common/cosmwasm-smart-contracts/nym-performance-contract",
"common/cosmwasm-smart-contracts/nym-pool-contract",
"common/cosmwasm-smart-contracts/vesting-contract",
"common/credential-proxy",
"common/credential-storage",
"common/credential-utils",
"common/credential-verification",
@@ -53,7 +54,6 @@ members = [
"common/ecash-signer-check",
"common/ecash-signer-check-types",
"common/ecash-time",
"common/execute",
"common/exit-policy",
"common/gateway-requests",
"common/gateway-stats-storage",
@@ -96,22 +96,28 @@ members = [
"common/ticketbooks-merkle",
"common/topology",
"common/tun",
"common/types",
"common/types", "common/upgrade-mode-check",
"common/verloc",
"common/wasm/client-core",
"common/wasm/storage",
"common/wasm/utils",
"common/wireguard",
"common/wireguard-private-metadata/client",
"common/wireguard-private-metadata/server",
"common/wireguard-private-metadata/shared",
"common/wireguard-private-metadata/tests",
"common/wireguard-types",
"common/zulip-client",
"documentation/autodoc",
"gateway",
"nym-api",
"nym-api/nym-api-requests",
"nym-authenticator-client",
"nym-browser-extension/storage",
"nym-credential-proxy/nym-credential-proxy",
"nym-credential-proxy/nym-credential-proxy-requests",
"nym-credential-proxy/vpn-api-lib-wasm",
"nym-ip-packet-client",
"nym-network-monitor",
"nym-node",
"nym-node-status-api/nym-node-status-agent",
@@ -119,15 +125,15 @@ members = [
"nym-node-status-api/nym-node-status-client",
"nym-node/nym-node-metrics",
"nym-node/nym-node-requests",
"nym-outfox",
"nym-outfox", "nym-signers-monitor",
"nym-statistics-api",
"nym-validator-rewarder",
"nym-wg-gateway-client",
"nyx-chain-watcher",
"sdk/ffi/cpp",
"sdk/ffi/go",
"sdk/ffi/shared",
"sdk/rust/nym-sdk",
"service-providers/authenticator",
"service-providers/common",
"service-providers/ip-packet-router",
"service-providers/network-requester",
@@ -165,7 +171,6 @@ default-members = [
"nym-statistics-api",
"nym-validator-rewarder",
"nyx-chain-watcher",
"service-providers/authenticator",
"service-providers/ip-packet-router",
"service-providers/network-requester",
"tools/nymvisor",
@@ -180,7 +185,7 @@ homepage = "https://nymtech.net"
documentation = "https://nymtech.net"
edition = "2021"
license = "Apache-2.0"
rust-version = "1.80"
rust-version = "1.81"
readme = "README.md"
[workspace.dependencies]
@@ -221,7 +226,7 @@ clap_complete = "4.5"
clap_complete_fig = "4.5"
colored = "2.2"
comfy-table = "7.1.4"
console = "0.15.11"
console = "0.16.0"
console-subscriber = "0.4.1"
console_error_panic_hook = "0.1"
const-str = "0.5.6"
@@ -268,11 +273,12 @@ humantime = "2.2.0"
humantime-serde = "1.1.1"
hyper = "1.6.0"
hyper-util = "0.1"
indicatif = "0.17.11"
indicatif = "0.18.0"
inquire = "0.6.2"
ip_network = "0.4.1"
ipnetwork = "0.20"
itertools = "0.14.0"
jwt-simple = { version = "0.12.12", default-features = false, features = ["pure-rust"] }
k256 = "0.13"
lazy_static = "1.5.0"
ledger-transport = "0.10.0"
@@ -324,14 +330,15 @@ sqlx = "0.8.6"
strum = "0.27.2"
strum_macros = "0.27.2"
subtle-encoding = "0.5"
syn = "1"
sysinfo = "0.33.0"
syn = "2"
sysinfo = "0.37.0"
tap = "1.0.1"
tar = "0.4.44"
test-with = { version = "0.15.4", default-features = false }
tempfile = "3.20"
thiserror = "2.0"
time = "0.3.41"
tokio = "1.45"
tokio = "1.47"
tokio-postgres = "0.7"
tokio-stream = "0.1.17"
tokio-test = "0.4.4"
@@ -3,6 +3,7 @@ name = "nym-client-core-gateways-storage"
version = "0.1.0"
edition = "2021"
license.workspace = true
rust-version.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -26,6 +27,7 @@ features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate", "time"]
optional = true
[build-dependencies]
anyhow = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
sqlx = { workspace = true, features = [
"runtime-tokio-rustls",
+13 -4
View File
@@ -2,23 +2,30 @@
// SPDX-License-Identifier: Apache-2.0
#[tokio::main]
async fn main() {
async fn main() -> anyhow::Result<()> {
#[cfg(feature = "fs-gateways-storage")]
{
use anyhow::Context;
use sqlx::{Connection, SqliteConnection};
use std::env;
let out_dir = env::var("OUT_DIR").unwrap();
let out_dir = env::var("OUT_DIR")?;
let database_path = format!("{out_dir}/gateways-storage-example.sqlite");
// remove the db file if it already existed from previous build
// in case it was from a different branch
if std::fs::exists(&database_path)? {
std::fs::remove_file(&database_path)?;
}
let mut conn = SqliteConnection::connect(&format!("sqlite://{database_path}?mode=rwc"))
.await
.expect("Failed to create SQLx database connection");
.context("Failed to create SQLx database connection")?;
sqlx::migrate!("./fs_gateways_migrations")
.run(&mut conn)
.await
.expect("Failed to perform SQLx migrations");
.context("Failed to perform SQLx migrations")?;
#[cfg(target_family = "unix")]
println!("cargo:rustc-env=DATABASE_URL=sqlite://{}", &database_path);
@@ -28,4 +35,6 @@ async fn main() {
// not a valid windows path... but hey, it works...
println!("cargo:rustc-env=DATABASE_URL=sqlite:///{}", &database_path);
}
Ok(())
}
@@ -175,6 +175,7 @@ impl MixTrafficController {
},
None => {
tracing::trace!("MixTrafficController, client request channel closed");
break
}
},
}
@@ -1,6 +1,7 @@
// Copyright 2021 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::client::helpers::get_time_now;
use crate::client::replies::{
reply_controller::ReplyControllerSender, reply_storage::SentReplyKeys,
};
@@ -22,7 +23,7 @@ use nym_statistics_common::clients::{packet_statistics::PacketStatisticsEvent, C
use nym_task::TaskClient;
use std::collections::HashSet;
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::time::Duration;
use tracing::*;
// The interval at which we check for stale buffers
@@ -54,7 +55,7 @@ struct ReceivedMessagesBufferInner<R: MessageReceiver> {
stats_tx: ClientStatsSender,
// Periodically check for stale buffers to clean up
last_stale_check: Instant,
last_stale_check: crate::client::helpers::Instant,
}
impl<R: MessageReceiver> ReceivedMessagesBufferInner<R> {
@@ -154,7 +155,7 @@ impl<R: MessageReceiver> ReceivedMessagesBufferInner<R> {
}
fn cleanup_stale_buffers(&mut self) {
let now = Instant::now();
let now = get_time_now();
if now - self.last_stale_check > STALE_BUFFER_CHECK_INTERVAL {
self.last_stale_check = now;
self.message_receiver
@@ -190,7 +191,7 @@ impl<R: MessageReceiver> ReceivedMessagesBuffer<R> {
message_sender: None,
recently_reconstructed: HashSet::new(),
stats_tx,
last_stale_check: Instant::now(),
last_stale_check: get_time_now(),
})),
reply_key_storage,
reply_controller_sender,
@@ -30,6 +30,7 @@ optional = true
path = "../../../sqlx-pool-guard"
[build-dependencies]
anyhow = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
sqlx = { workspace = true, features = [
"runtime-tokio-rustls",
+7 -4
View File
@@ -2,23 +2,24 @@
// SPDX-License-Identifier: Apache-2.0
#[tokio::main]
async fn main() {
async fn main() -> anyhow::Result<()> {
#[cfg(feature = "fs-surb-storage")]
{
use anyhow::Context;
use sqlx::{Connection, SqliteConnection};
use std::env;
let out_dir = env::var("OUT_DIR").unwrap();
let out_dir = env::var("OUT_DIR")?;
let database_path = format!("{out_dir}/fs-surbs-example.sqlite");
let mut conn = SqliteConnection::connect(&format!("sqlite://{database_path}?mode=rwc"))
.await
.expect("Failed to create SQLx database connection");
.context("Failed to create SQLx database connection")?;
sqlx::migrate!("./fs_surbs_migrations")
.run(&mut conn)
.await
.expect("Failed to perform SQLx migrations");
.context("Failed to perform SQLx migrations")?;
#[cfg(target_family = "unix")]
println!("cargo:rustc-env=DATABASE_URL=sqlite://{}", &database_path);
@@ -28,4 +29,6 @@ async fn main() {
// not a valid windows path... but hey, it works...
println!("cargo:rustc-env=DATABASE_URL=sqlite:///{}", &database_path);
}
Ok(())
}
@@ -28,7 +28,7 @@ use cosmrs::proto::cosmwasm::wasm::v1::{
QueryRawContractStateResponse, QuerySmartContractStateRequest, QuerySmartContractStateResponse,
};
use cosmrs::tendermint::{block, chain, Hash};
use cosmrs::{AccountId, Coin as CosmosCoin, Tx};
use cosmrs::{AccountId, Coin as CosmosCoin};
use prost::Message;
use serde::{Deserialize, Serialize};
@@ -556,23 +556,12 @@ pub trait CosmWasmClient: TendermintRpcClient {
Ok(serde_json::from_slice(&res.data)?)
}
// deprecation warning is due to the fact the protobuf files built were based on cosmos-sdk 0.44,
// where they prefer using tx_bytes directly. However, in 0.42, which we are using at the time
// of writing this, the option does not work
// TODO: we should really stop using the `tx` argument here and use `tx_bytes` exlusively,
// however, at the time of writing this update, while our QA and mainnet networks do support it,
// sandbox is still running old version of wasmd that lacks support for `tx_bytes`
#[allow(deprecated)]
async fn query_simulate(
&self,
tx: Option<Tx>,
tx_bytes: Vec<u8>,
) -> Result<SimulateResponse, NyxdError> {
async fn query_simulate(&self, tx_bytes: Vec<u8>) -> Result<SimulateResponse, NyxdError> {
let path = Some("/cosmos.tx.v1beta1.Service/Simulate".to_owned());
let req = SimulateRequest {
tx: tx.map(Into::into),
tx_bytes,
..Default::default()
};
let res = self
@@ -81,17 +81,14 @@ where
auth_info: single_unspecified_signer_auth(public_key, sequence_response.sequence),
signatures: vec![Vec::new()],
};
self.query_simulate(Some(partial_tx), Vec::new()).await
// for completion sake, once we're able to transition into using `tx_bytes`,
// we might want to use something like this instead:
// let tx_raw: tx::Raw = cosmrs::proto::cosmos::tx::v1beta1::TxRaw {
// body_bytes: partial_tx.body.into_bytes().unwrap(),
// auth_info_bytes: partial_tx.auth_info.into_bytes().unwrap(),
// signatures: partial_tx.signatures,
// }
// .into();
// self.query_simulate(None, tx_raw.to_bytes().unwrap()).await
let tx_raw: tx::Raw = cosmrs::proto::cosmos::tx::v1beta1::TxRaw {
body_bytes: partial_tx.body.into_bytes()?,
auth_info_bytes: partial_tx.auth_info.into_bytes()?,
signatures: partial_tx.signatures,
}
.into();
self.query_simulate(tx_raw.to_bytes()?).await
}
async fn upload(
+3
View File
@@ -4,6 +4,7 @@
use clap::{Args, Subcommand};
pub mod ecash;
pub mod nyx;
#[derive(Debug, Args)]
#[clap(args_conflicts_with_subcommands = true, subcommand_required = true)]
@@ -16,4 +17,6 @@ pub struct Internal {
pub enum InternalCommands {
/// Ecash related internal commands
Ecash(ecash::InternalEcash),
Nyx(nyx::InternalNyx),
}
@@ -0,0 +1,116 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::context::SigningClient;
use anyhow::bail;
use clap::Parser;
use nym_mixnet_contract_common::nym_node::Role;
use nym_mixnet_contract_common::reward_params::NodeRewardingParameters;
use nym_mixnet_contract_common::{
EpochRewardedSet, EpochState, NodeId, RewardingParams, RoleAssignment,
};
use nym_validator_client::nyxd::contract_traits::mixnet_query_client::MixnetQueryClientExt;
use nym_validator_client::nyxd::contract_traits::{MixnetQueryClient, MixnetSigningClient};
use rand::prelude::*;
use rand::thread_rng;
#[derive(Debug, Parser)]
pub struct Args {}
fn choose_new_nodes(
params: &RewardingParams,
rewarded_set: &EpochRewardedSet,
role: Role,
) -> Vec<NodeId> {
let mut rng = thread_rng();
match role {
Role::EntryGateway => rewarded_set
.assignment
.entry_gateways
.choose_multiple(&mut rng, params.rewarded_set.entry_gateways as usize)
.copied()
.collect(),
Role::Layer1 => rewarded_set
.assignment
.layer1
.choose_multiple(&mut rng, params.rewarded_set.mixnodes as usize / 3)
.copied()
.collect(),
Role::Layer2 => rewarded_set
.assignment
.layer2
.choose_multiple(&mut rng, params.rewarded_set.mixnodes as usize / 3)
.copied()
.collect(),
Role::Layer3 => rewarded_set
.assignment
.layer3
.choose_multiple(&mut rng, params.rewarded_set.mixnodes as usize / 3)
.copied()
.collect(),
Role::ExitGateway => rewarded_set
.assignment
.exit_gateways
.choose_multiple(&mut rng, params.rewarded_set.exit_gateways as usize)
.copied()
.collect(),
Role::Standby => rewarded_set
.assignment
.standby
.choose_multiple(&mut rng, params.rewarded_set.standby as usize)
.copied()
.collect(),
}
}
pub async fn force_advance_epoch(_: Args, client: SigningClient) -> anyhow::Result<()> {
let current_epoch = client.get_current_interval_details().await?;
let epoch_status = client.get_current_epoch_status().await?;
if epoch_status.being_advanced_by.as_str() != client.address().to_string() {
bail!(
"this client is not authorised to perform any epoch operations. we need {}",
client.address()
)
}
let rewarding_params = client.get_rewarding_parameters().await?;
let current_rewarded_set = client.get_rewarded_set().await?;
if !current_epoch.is_current_epoch_over {
println!("the current epoch is not over yet - there's nothing to do")
}
// is this most efficient? no. but it's simple
loop {
let epoch_status = client.get_current_epoch_status().await?;
match epoch_status.state {
EpochState::InProgress => break,
EpochState::Rewarding { final_node_id, .. } => {
println!("rewarding {final_node_id} with big fat 0...");
client
.reward_node(
final_node_id,
NodeRewardingParameters::new(Default::default(), Default::default()),
None,
)
.await?;
}
EpochState::ReconcilingEvents => {
println!("trying to reconcile events...");
client.reconcile_epoch_events(None, None).await?;
}
EpochState::RoleAssignment { next } => {
let nodes = choose_new_nodes(&rewarding_params, &current_rewarded_set, next);
println!("assigning {nodes:?} as {next}");
client
.assign_roles(RoleAssignment { role: next, nodes }, None)
.await?;
}
}
}
Ok(())
}
+19
View File
@@ -0,0 +1,19 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use clap::{Args, Subcommand};
pub mod force_advance_epoch;
#[derive(Debug, Args)]
#[clap(args_conflicts_with_subcommands = true, subcommand_required = true)]
pub struct InternalNyx {
#[clap(subcommand)]
pub command: InternalNyxCommands,
}
#[derive(Debug, Subcommand)]
pub enum InternalNyxCommands {
/// Attempt to force advance the current epoch
ForceAdvanceEpoch(force_advance_epoch::Args),
}
@@ -86,6 +86,25 @@ impl IntervalRewardParams {
pub fn to_inline_json(&self) -> String {
to_json_string(self).unwrap_or_else(|_| "serialisation failure".into())
}
pub fn active_node_work(&self, standby_node_work: Decimal) -> WorkFactor {
self.active_set_work_factor * standby_node_work
}
pub fn standby_node_work(
&self,
rewarded_set_size: Decimal,
standby_set_size: Decimal,
) -> WorkFactor {
let f = self.active_set_work_factor;
let k = rewarded_set_size;
let one = Decimal::one();
// nodes in reserve
let k_r = standby_set_size;
one / (f * k - (f - one) * k_r)
}
}
/// Parameters used for reward calculation.
@@ -109,18 +128,15 @@ pub struct RewardingParams {
impl RewardingParams {
pub fn active_node_work(&self) -> WorkFactor {
self.interval.active_set_work_factor * self.standby_node_work()
let standby_work = self.standby_node_work();
self.interval.active_node_work(standby_work)
}
pub fn standby_node_work(&self) -> WorkFactor {
let f = self.interval.active_set_work_factor;
let k = self.dec_rewarded_set_size();
let one = Decimal::one();
// nodes in reserve
let k_r = self.dec_standby_set_size();
one / (f * k - (f - one) * k_r)
let rewarded_set_size = self.dec_rewarded_set_size();
let standby_set_size = self.dec_standby_set_size();
self.interval
.standby_node_work(rewarded_set_size, standby_set_size)
}
pub fn rewarded_set_size(&self) -> u32 {
@@ -3,6 +3,7 @@
use crate::config_score::{ConfigScoreParams, OutdatedVersionWeights, VersionScoreFormulaParams};
use crate::nym_node::Role;
use crate::reward_params::RewardedSetParams;
use crate::EpochId;
use contracts_common::Percent;
use cosmwasm_schema::cw_serde;
@@ -85,7 +86,11 @@ impl RewardedSet {
}
pub fn rewarded_set_size(&self) -> usize {
self.active_set_size() + self.standby.len()
self.active_set_size() + self.standby_set_size()
}
pub fn standby_set_size(&self) -> usize {
self.standby.len()
}
pub fn get_role(&self, node_id: NodeId) -> Option<Role> {
@@ -110,6 +115,13 @@ impl RewardedSet {
}
None
}
pub fn matches_parameters(&self, params: RewardedSetParams) -> bool {
self.entry_gateways.len() <= params.entry_gateways as usize
&& self.exit_gateways.len() <= params.exit_gateways as usize
&& self.layer1.len() + self.layer2.len() + self.layer3.len() <= params.mixnodes as usize
&& self.standby.len() <= params.standby as usize
}
}
#[cw_serde]
+55
View File
@@ -0,0 +1,55 @@
[package]
name = "nym-credential-proxy-lib"
version = "0.1.0"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
documentation.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
readme.workspace = true
[dependencies]
anyhow = { workspace = true }
axum = { workspace = true }
bip39 = { workspace = true, features = ["zeroize"] }
bs58 = { workspace = true }
futures = { workspace = true }
humantime = { workspace = true }
rand = { workspace = true }
reqwest = { workspace = true, features = ["rustls-tls"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
strum = { workspace = true, features = ["derive"] }
strum_macros = { workspace = true }
sqlx = { workspace = true, features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate", "time"] }
time = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
tokio-util = { workspace = true, features = ["rt"] }
tracing = { workspace = true }
uuid = { workspace = true, features = ["serde"] }
url = { workspace = true }
zeroize = { workspace = true }
nym-credentials = { path = "../credentials" }
nym-crypto = { path = "../crypto", features = ["asymmetric", "rand", "serde"] }
nym-credentials-interface = { path = "../credentials-interface" }
nym-credential-proxy-requests = { path = "../../nym-credential-proxy/nym-credential-proxy-requests" }
nym-ecash-signer-check = { path = "../ecash-signer-check" }
nym-ecash-contract-common = { path = "../cosmwasm-smart-contracts/ecash-contract" }
nym-compact-ecash = { path = "../nym_offline_compact_ecash" }
nym-validator-client = { path = "../client-libs/validator-client" }
nym-network-defaults = { path = "../network-defaults" }
[dev-dependencies]
tempfile = { workspace = true }
[build-dependencies]
anyhow = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
sqlx = { workspace = true, features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] }
[lints]
workspace = true
@@ -1,22 +1,31 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use anyhow::Context;
#[tokio::main]
async fn main() {
async fn main() -> anyhow::Result<()> {
use sqlx::{Connection, SqliteConnection};
use std::env;
let out_dir = env::var("OUT_DIR").unwrap();
let out_dir = env::var("OUT_DIR")?;
let database_path = format!("{out_dir}/nym-credential-proxy-example.sqlite");
// remove the db file if it already existed from previous build
// in case it was from a different branch
if std::fs::exists(&database_path)? {
std::fs::remove_file(&database_path)?;
}
let mut conn = SqliteConnection::connect(&format!("sqlite://{database_path}?mode=rwc"))
.await
.expect("Failed to create SQLx database connection");
.context("Failed to create SQLx database connection")?;
sqlx::migrate!("./migrations")
.run(&mut conn)
.await
.expect("Failed to perform SQLx migrations");
.context("Failed to perform SQLx migrations")?;
println!("cargo:rustc-env=DATABASE_URL=sqlite://{}", &database_path);
Ok(())
}
@@ -0,0 +1,81 @@
/*
* Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
* SPDX-License-Identifier: GPL-3.0-only
*/
CREATE TABLE ecash_deposit
(
-- id assigned [by the contract] to the deposit
deposit_id INTEGER PRIMARY KEY NOT NULL,
-- associated tx hash
deposit_tx_hash TEXT NOT NULL,
-- indication of when the deposit request has been created
-- (so that based on block timestamp we could potentially determine latency)
requested_on TIMESTAMP WITHOUT TIME ZONE NOT NULL,
-- the amount put in the deposit (informative, as we expect this to change in the future)
deposit_amount TEXT NOT NULL,
-- the private key generated for the purposes of the deposit (the public component has been put in the transaction)
ed25519_deposit_private_key BLOB NOT NULL
);
INSERT INTO ecash_deposit(deposit_id, deposit_tx_hash, requested_on, deposit_amount, ed25519_deposit_private_key)
SELECT deposit_id, deposit_tx_hash, requested_on, deposit_amount, ed25519_deposit_private_key
FROM ticketbook_deposit;
CREATE TABLE ecash_deposit_usage
(
deposit_id INTEGER PRIMARY KEY REFERENCES ecash_deposit (deposit_id),
ticketbooks_requested_on TIMESTAMP WITHOUT TIME ZONE NOT NULL,
client_pubkey BLOB NOT NULL,
request_uuid TEXT UNIQUE NOT NULL,
-- this has to be improved later on to resume issuance or something, but for now it's fine
ticketbook_request_error TEXT
);
INSERT INTO ecash_deposit_usage(deposit_id, ticketbooks_requested_on, client_pubkey, request_uuid)
SELECT deposit_id, 0, client_pubkey, request_uuid
FROM ticketbook_deposit;
CREATE TABLE partial_blinded_wallet_new
(
corresponding_deposit INTEGER NOT NULL REFERENCES ecash_deposit_usage (deposit_id),
epoch_id INTEGER NOT NULL,
expiration_date DATE NOT NULL,
node_id INTEGER NOT NULL,
created TIMESTAMP WITHOUT TIME ZONE NOT NULL,
blinded_signature BLOB NOT NULL
);
CREATE TABLE partial_blinded_wallet_failure_new
(
corresponding_deposit INTEGER NOT NULL REFERENCES ecash_deposit_usage (deposit_id),
epoch_id INTEGER NOT NULL,
expiration_date DATE NOT NULL,
node_id INTEGER NOT NULL,
created TIMESTAMP WITHOUT TIME ZONE NOT NULL,
failure_message TEXT NOT NULL
);
INSERT INTO partial_blinded_wallet_new
SELECT *
FROM partial_blinded_wallet;
INSERT INTO partial_blinded_wallet_failure_new
SELECT *
FROM partial_blinded_wallet_failure;
DROP TABLE partial_blinded_wallet;
DROP TABLE partial_blinded_wallet_failure;
DROP TABLE ticketbook_deposit;
ALTER TABLE partial_blinded_wallet_new
RENAME TO partial_blinded_wallet;
ALTER TABLE partial_blinded_wallet_failure_new
RENAME TO partial_blinded_wallet_failure;
@@ -0,0 +1,101 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::error::CredentialProxyError;
use crate::storage::models::StorableEcashDeposit;
use nym_compact_ecash::WithdrawalRequest;
use nym_credentials::IssuanceTicketBook;
use nym_crypto::asymmetric::ed25519;
use nym_validator_client::nyxd::{Coin, Hash};
use time::OffsetDateTime;
use zeroize::Zeroizing;
pub struct BufferedDeposit {
pub deposit_id: u32,
// note: this type implements `ZeroizeOnDrop`
pub ed25519_private_key: ed25519::PrivateKey,
}
impl TryFrom<StorableEcashDeposit> for BufferedDeposit {
type Error = CredentialProxyError;
fn try_from(deposit: StorableEcashDeposit) -> Result<Self, Self::Error> {
let ed25519_private_key = ed25519::PrivateKey::from_bytes(
deposit.ed25519_deposit_private_key.as_ref(),
)
.map_err(|err| CredentialProxyError::DatabaseInconsistency {
reason: format!("one of the stored deposit ed25519 private keys is malformed: {err}"),
})?;
Ok(BufferedDeposit {
deposit_id: deposit.deposit_id,
ed25519_private_key,
})
}
}
impl BufferedDeposit {
pub fn new(deposit_id: u32, ed25519_private_key: ed25519::PrivateKey) -> Self {
BufferedDeposit {
deposit_id,
ed25519_private_key,
}
}
pub fn sign_ticketbook_plaintext(
&self,
withdrawal_request: &WithdrawalRequest,
) -> ed25519::Signature {
let plaintext = IssuanceTicketBook::request_plaintext(withdrawal_request, self.deposit_id);
self.ed25519_private_key.sign(plaintext)
}
}
pub struct PerformedDeposits {
pub deposits_data: Vec<BufferedDeposit>,
// shared by all performed deposits as they were included in the same tx
pub tx_hash: Hash,
pub requested_on: OffsetDateTime,
pub deposit_amount: Coin,
}
impl PerformedDeposits {
pub(crate) fn to_storable(&self) -> Vec<StorableEcashDeposit> {
self.deposits_data
.iter()
.map(|d| StorableEcashDeposit {
deposit_id: d.deposit_id,
deposit_tx_hash: self.tx_hash.to_string(),
requested_on: self.requested_on,
deposit_amount: self.deposit_amount.to_string(),
ed25519_deposit_private_key: Zeroizing::new(d.ed25519_private_key.to_bytes()),
})
.collect()
}
}
pub(super) fn request_sizes(total: usize, max_request_size: usize) -> impl Iterator<Item = usize> {
(0..total)
.step_by(max_request_size)
.map(move |start| std::cmp::min(max_request_size, total - start))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_sizes_test() {
assert_eq!(
request_sizes(100, 32).collect::<Vec<_>>(),
vec![32, 32, 32, 4]
);
assert_eq!(request_sizes(10, 32).collect::<Vec<_>>(), vec![10]);
assert_eq!(request_sizes(32, 32).collect::<Vec<_>>(), vec![32]);
assert_eq!(request_sizes(33, 32).collect::<Vec<_>>(), vec![32, 1]);
assert_eq!(request_sizes(1, 32).collect::<Vec<_>>(), vec![1]);
}
}
@@ -0,0 +1,308 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::deposits_buffer::helpers::request_sizes;
use crate::deposits_buffer::refill_task::RefillTask;
use crate::error::CredentialProxyError;
use crate::shared_state::nyxd_client::ChainClient;
use crate::shared_state::required_deposit_cache::RequiredDepositCache;
use crate::storage::CredentialProxyStorage;
use nym_compact_ecash::PublicKeyUser;
use nym_crypto::asymmetric::ed25519;
use nym_ecash_contract_common::deposit::DepositId;
use nym_validator_client::nyxd::cosmwasm_client::ContractResponseData;
use nym_validator_client::nyxd::Coin;
use rand::rngs::OsRng;
use std::sync::Arc;
use std::time::Duration;
use time::OffsetDateTime;
use tokio::sync::Mutex as AsyncMutex;
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, instrument, warn};
use uuid::Uuid;
pub use helpers::{BufferedDeposit, PerformedDeposits};
pub(crate) mod helpers;
mod refill_task;
// TODO: I guess make it configurable
const DEPOSITS_THRESHOLD_P: f32 = 0.1;
struct DepositsBufferInner {
client: ChainClient,
required_deposit_cache: RequiredDepositCache,
storage: CredentialProxyStorage,
target_amount: usize,
max_concurrent_deposits: usize,
unused_deposits: AsyncMutex<Vec<BufferedDeposit>>,
deposits_refill_task: RefillTask,
short_sha: &'static str,
cancellation_token: CancellationToken,
}
#[derive(Clone)]
pub struct DepositsBuffer {
inner: Arc<DepositsBufferInner>,
}
impl DepositsBuffer {
pub async fn new(
storage: CredentialProxyStorage,
client: ChainClient,
required_deposit_cache: RequiredDepositCache,
short_sha: &'static str,
target_amount: usize,
max_concurrent_deposits: usize,
cancellation_token: CancellationToken,
) -> Result<Self, CredentialProxyError> {
let unused_deposits = storage.load_unused_deposits().await?;
info!("managed to load {} deposits", unused_deposits.len());
Ok(DepositsBuffer {
inner: Arc::new(DepositsBufferInner {
client,
required_deposit_cache,
storage,
target_amount,
max_concurrent_deposits,
unused_deposits: AsyncMutex::new(unused_deposits),
deposits_refill_task: RefillTask::default(),
short_sha,
cancellation_token,
}),
})
}
async fn deposit_amount(&self) -> Result<Coin, CredentialProxyError> {
self.inner
.required_deposit_cache
.get_or_update(&self.inner.client)
.await
}
#[instrument(skip(self), err(Display))]
async fn make_deposits_request(
&self,
amount: usize,
) -> Result<PerformedDeposits, CredentialProxyError> {
let requested_on = OffsetDateTime::now_utc();
let chain_write_permit = self.inner.client.start_chain_tx().await;
let mut rng = OsRng;
let deposit_amount = self.deposit_amount().await?;
let keys = (0..amount)
.map(|_| ed25519::PrivateKey::new(&mut rng))
.collect::<Vec<_>>();
info!("starting {amount} deposits");
let mut contents = Vec::new();
for key in &keys {
let public_key: ed25519::PublicKey = key.into();
contents.push((public_key.to_base58_string(), deposit_amount.clone()));
}
let execute_res = chain_write_permit
.make_deposits(self.inner.short_sha, contents)
.await?;
let tx_hash = execute_res.transaction_hash;
info!("{amount} deposits made in transaction: {tx_hash}");
let contract_data = match execute_res.to_contract_data() {
Ok(contract_data) => contract_data,
Err(err) => {
// that one is tricky. deposits technically got made, but we somehow failed to parse response,
// in this case terminate the proxy with 0 exit code so it wouldn't get automatically restarted
// because it requires some serious MANUAL intervention
error!("CRITICAL FAILURE: failed to parse out deposit information from the contract transaction. either the chain got upgraded and the schema changed or the ecash contract got changed! terminating the process. it has to be inspected manually. error was: {err}");
self.inner.cancellation_token.cancel();
return Err(CredentialProxyError::DepositFailure);
}
};
if contract_data.len() != amount {
// another critical failure, that one should be quite impossible and thus has to be manually inspected
error!("CRITICAL FAILURE: failed to parse out all deposit information from the contract transaction. got {} responses while we sent {amount} deposits! either the chain got upgraded and the schema changed or the ecash contract got changed! terminating the process. it has to be inspected manually", contract_data.len());
self.inner.cancellation_token.cancel();
return Err(CredentialProxyError::DepositFailure);
}
let mut deposits_data = Vec::new();
for (key, response) in keys.into_iter().zip(contract_data) {
let response_index = response.message_index;
let deposit_id = match response.parse_singleton_u32_contract_data() {
Ok(deposit_id) => deposit_id,
Err(err) => {
// another impossibility
error!("CRITICAL FAILURE: failed to parse out deposit id out of the response at index {response_index}: {err}. either the chain got upgraded and the schema changed or the ecash contract got changed! terminating the process. it has to be inspected manually");
self.inner.cancellation_token.cancel();
return Err(CredentialProxyError::DepositFailure);
}
};
deposits_data.push(BufferedDeposit::new(deposit_id, key));
}
Ok(PerformedDeposits {
deposits_data,
tx_hash,
requested_on,
deposit_amount,
})
}
async fn insert_new_deposits(
&self,
mut deposits: PerformedDeposits,
) -> Result<(), CredentialProxyError> {
// 1. insert into the db
self.inner.storage.insert_new_deposits(&deposits).await?;
// 2. update the buffer
self.inner
.unused_deposits
.lock()
.await
.append(&mut deposits.deposits_data);
Ok(())
}
/// Start refilling our deposit buffer.
/// It chunks the amount required based on the configured maximum request size
/// and updates global state after each successful transaction.
async fn refill_deposits(&self) -> Result<(), CredentialProxyError> {
let available = self.inner.unused_deposits.lock().await.len();
let target = self.deposits_upper_threshold();
let to_request = target - available;
for request_chunk in request_sizes(to_request, self.inner.max_concurrent_deposits) {
// note: we check for cancellation between individual requests
// as opposed to wrapping that in tokio::select! so that we would never abandon chain operations
// as we wouldn't want to lose funds
if self.inner.cancellation_token.is_cancelled() {
info!("received cancellation during deposits refilling");
return Ok(());
}
// make sure to insert deposits into db/vec as we get them so on initial run,
// we'd start trickling down data as soon as possible
let deposits = self.make_deposits_request(request_chunk).await?;
self.insert_new_deposits(deposits).await?;
}
Ok(())
}
// if we're here, we know we're below the threshold
fn maybe_refill_deposits(&self) {
if let Some(mut guard) = self.inner.deposits_refill_task.try_get_new_task_guard() {
let this = self.clone();
*guard = Some(tokio::spawn(async move { this.refill_deposits().await }));
}
}
fn deposits_lower_threshold(&self) -> usize {
self.inner.target_amount - (self.inner.target_amount as f32 * DEPOSITS_THRESHOLD_P) as usize
}
fn deposits_upper_threshold(&self) -> usize {
self.inner.target_amount + (self.inner.target_amount as f32 * DEPOSITS_THRESHOLD_P) as usize
}
async fn mark_deposit_as_used(
&self,
deposit_id: DepositId,
requested_on: OffsetDateTime,
client_pubkey: PublicKeyUser,
request_uuid: Uuid,
) -> Result<(), CredentialProxyError> {
self.inner
.storage
.insert_deposit_usage(deposit_id, requested_on, client_pubkey, request_uuid)
.await
}
async fn wait_for_deposit(
&self,
request_uuid: Uuid,
requested_on: OffsetDateTime,
client_pubkey: PublicKeyUser,
) -> Result<BufferedDeposit, CredentialProxyError> {
loop {
tokio::time::sleep(Duration::from_millis(500)).await;
if let Some(buffered_deposit) = self.inner.unused_deposits.lock().await.pop() {
// if the db call fails, we technically don't lose the deposit (we'll 'recover' it on restart)
self.mark_deposit_as_used(
buffered_deposit.deposit_id,
requested_on,
client_pubkey,
request_uuid,
)
.await?;
return Ok(buffered_deposit);
} else {
// make sure there's always a task working in the background in case deposits get used up too quickly
self.maybe_refill_deposits()
}
}
}
pub async fn get_valid_deposit(
&self,
request_uuid: Uuid,
requested_on: OffsetDateTime,
client_pubkey: PublicKeyUser,
) -> Result<BufferedDeposit, CredentialProxyError> {
let mut deposits_guard = self.inner.unused_deposits.lock().await;
let deposits_available = deposits_guard.len();
debug!("we have {deposits_available} unused deposits available");
let maybe_deposit = deposits_guard.pop();
drop(deposits_guard);
if deposits_available < self.deposits_lower_threshold() {
// if we're below threshold, start refill task
self.maybe_refill_deposits()
}
match maybe_deposit {
None => {
warn!("we currently don't have any usable deposits! are we using them up faster than we request them?");
// we have to wait until refill task has completed (either initiated by this or another fn call)
self.wait_for_deposit(request_uuid, requested_on, client_pubkey)
.await
}
Some(buffered_deposit) => {
self.mark_deposit_as_used(
buffered_deposit.deposit_id,
requested_on,
client_pubkey,
request_uuid,
)
.await?;
Ok(buffered_deposit)
}
}
}
pub async fn wait_for_shutdown(&self) {
let task_handle = self.inner.deposits_refill_task.take_task_join_handle();
if let Some(task_handle) = task_handle {
if !task_handle.is_finished() {
info!("the deposit refill task is currently in progress - waiting for the current transaction to finish before concluding shutdown");
let _ = task_handle.await;
}
}
}
}
impl DepositsBufferInner {
//
}
@@ -0,0 +1,56 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::error::CredentialProxyError;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex as StdMutex, MutexGuard};
use tokio::task::JoinHandle;
use tracing::{debug, error};
pub(super) type RefillTaskResult = Result<(), CredentialProxyError>;
#[derive(Default)]
pub(super) struct RefillTask {
// note that we can only have a single transaction in progress (or it'd mess up with our sequence numbers)
// if we find that we're using up deposits more quickly than we're refilling them,
// we'll have to increase the number of deposits per transaction
join_handle: StdMutex<Option<JoinHandle<RefillTaskResult>>>,
in_progress: AtomicBool,
}
impl RefillTask {
/// Attempt to set the `in_progress` value to `true` if it's not already `true`.
/// Returns boolean indicating whether it was successful
fn try_set_in_progress(&self) -> bool {
self.in_progress
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
}
pub(super) fn try_get_new_task_guard(
&self,
) -> Option<MutexGuard<'_, Option<JoinHandle<RefillTaskResult>>>> {
// sanity check for concurrent request
if !self.try_set_in_progress() {
debug!("another task has already started deposit refill request");
return None;
}
#[allow(clippy::expect_used)]
let guard = self.join_handle.lock().expect("mutex got poisoned");
if let Some(existing_handle) = guard.as_ref() {
if !existing_handle.is_finished() {
error!("CRITICAL BUG: there was already a deposit refill task spawned that hasn't yet finished")
}
}
Some(guard)
}
pub(super) fn take_task_join_handle(&self) -> Option<JoinHandle<RefillTaskResult>> {
#[allow(clippy::expect_used)]
self.join_handle.lock().expect("mutex got poisoned").take()
}
}
@@ -1,6 +1,7 @@
// Copyright 2024 Nym Technologies SA <contact@nymtech.net>
// Copyright 2025 Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use nym_ecash_signer_check::SignerCheckError;
use nym_validator_client::coconut::EcashApiError;
use nym_validator_client::nym_api::EpochId;
use nym_validator_client::nyxd::error::NyxdError;
@@ -10,7 +11,7 @@ use thiserror::Error;
use time::OffsetDateTime;
#[derive(Debug, Error)]
pub enum VpnApiError {
pub enum CredentialProxyError {
#[error("encountered an internal io error: {source}")]
IoError {
#[from]
@@ -118,11 +119,44 @@ pub enum VpnApiError {
#[error("failed to create deposit")]
DepositFailure,
#[error("can't obtain sufficient number of credential shares due to unavailable quorum")]
UnavailableSigningQuorum,
#[error("failed to perform quorum check: {source}")]
QuorumCheckFailure {
#[from]
source: SignerCheckError,
},
#[error(
"this operation couldn't be completed as the program is in the process of shutting down"
)]
ShutdownInProgress,
#[error("failed to obtain wallet shares with id {id}: {message}")]
ShareByIdLoadError { message: String, id: i64 },
#[error("failed to obtain wallet shares with device_id {device_id} and credential_id: {credential_id}: {message}")]
ShareByDeviceLoadError {
message: String,
device_id: String,
credential_id: String,
},
#[error("could not find shares with id {id}")]
SharesByIdNotFound { id: i64 },
#[error("could not find shares with device_id {device_id} and credential_id: {credential_id}")]
SharesByDeviceNotFound {
device_id: String,
credential_id: String,
},
}
impl VpnApiError {
pub fn database_inconsistency<S: Into<String>>(reason: S) -> VpnApiError {
VpnApiError::DatabaseInconsistency {
impl CredentialProxyError {
pub fn database_inconsistency<S: Into<String>>(reason: S) -> CredentialProxyError {
CredentialProxyError::DatabaseInconsistency {
reason: reason.into(),
}
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use rand::rngs::OsRng;
use rand::RngCore;
use time::OffsetDateTime;
use tracing::{debug, info, warn};
use uuid::Uuid;
pub fn random_uuid() -> Uuid {
let mut bytes = [0u8; 16];
let mut rng = OsRng;
rng.fill_bytes(&mut bytes);
Uuid::from_bytes(bytes)
}
pub struct LockTimer {
created: OffsetDateTime,
message: String,
}
impl LockTimer {
pub fn new<S: Into<String>>(message: S) -> Self {
LockTimer {
message: message.into(),
..Default::default()
}
}
}
impl Drop for LockTimer {
fn drop(&mut self) {
let time_taken = OffsetDateTime::now_utc() - self.created;
let time_taken_formatted = humantime::format_duration(time_taken.unsigned_abs());
if time_taken > time::Duration::SECOND * 10 {
warn!(time_taken = %time_taken_formatted, "{}", self.message)
} else if time_taken > time::Duration::SECOND * 5 {
info!(time_taken = %time_taken_formatted, "{}", self.message)
} else {
debug!(time_taken = %time_taken_formatted, "{}", self.message)
};
}
}
impl Default for LockTimer {
fn default() -> Self {
LockTimer {
created: OffsetDateTime::now_utc(),
message: "released the lock".to_string(),
}
}
}
// #[allow(clippy::panic)]
// fn build_sha_short() -> &'static str {
// let bin_info = bin_info!();
// if bin_info.commit_sha.len() < 7 {
// panic!("unavailable build commit sha")
// }
//
// if bin_info.commit_sha == "VERGEN_IDEMPOTENT_OUTPUT" {
// error!("the binary hasn't been built correctly. it doesn't have a commit sha information");
// return "unknown";
// }
//
// &bin_info.commit_sha[..7]
// }
@@ -1,11 +1,12 @@
// Copyright 2024 Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::error::VpnApiError;
use crate::error::CredentialProxyError;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use nym_credential_proxy_requests::api::v1::ErrorResponse;
use tracing::warn;
use uuid::Uuid;
#[derive(Debug, Clone)]
@@ -35,7 +36,11 @@ impl RequestError {
}
}
pub fn new_server_error(err: VpnApiError, uuid: Uuid) -> Self {
pub fn new_plain_error(err: CredentialProxyError) -> Self {
Self::from_err(err, StatusCode::INTERNAL_SERVER_ERROR)
}
pub fn new_server_error(err: CredentialProxyError, uuid: Uuid) -> Self {
RequestError::new_with_uuid(err.to_string(), uuid, StatusCode::INTERNAL_SERVER_ERROR)
}
@@ -59,3 +64,12 @@ impl IntoResponse for RequestError {
(self.status, Json(self.inner)).into_response()
}
}
pub fn db_failure<T>(err: CredentialProxyError, uuid: Uuid) -> Result<T, RequestError> {
warn!("db failure: {err}");
Err(RequestError::new_with_uuid(
format!("oh no, something went wrong {err}"),
uuid,
StatusCode::INTERNAL_SERVER_ERROR,
))
}
+13
View File
@@ -0,0 +1,13 @@
// Copyright 2025 Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
pub mod deposits_buffer;
pub mod error;
pub mod helpers;
pub mod http_helpers;
pub mod nym_api_helpers;
pub mod quorum_checker;
pub mod shared_state;
pub mod storage;
pub mod ticketbook_manager;
pub mod webhook;
@@ -4,7 +4,7 @@
// TODO: this was just copied from nym-api;
// it should have been therefore extracted to a common crate instead and imported as dependency
use crate::error::VpnApiError;
use crate::error::CredentialProxyError;
use futures::{stream, StreamExt};
use nym_credentials::ecash::utils::{cred_exp_date, ecash_today, EcashTime};
use nym_validator_client::nym_api::EpochId;
@@ -19,9 +19,9 @@ use time::{Date, OffsetDateTime};
use tokio::sync::{Mutex, RwLock, RwLockReadGuard};
use tracing::warn;
pub(crate) struct CachedEpoch {
pub struct CachedEpoch {
valid_until: OffsetDateTime,
pub(crate) current_epoch: Epoch,
pub current_epoch: Epoch,
}
impl Default for CachedEpoch {
@@ -34,11 +34,11 @@ impl Default for CachedEpoch {
}
impl CachedEpoch {
pub(crate) fn is_valid(&self) -> bool {
pub fn is_valid(&self) -> bool {
self.valid_until > OffsetDateTime::now_utc()
}
pub(crate) fn update(&mut self, epoch: Epoch) {
pub fn update(&mut self, epoch: Epoch) {
let now = OffsetDateTime::now_utc();
let validity_duration = if let Some(epoch_finish) = epoch.deadline {
@@ -58,13 +58,13 @@ impl CachedEpoch {
}
// a map of items that never change for given key
pub(crate) struct CachedImmutableItems<K, V> {
pub struct CachedImmutableItems<K, V> {
// I wonder if there's a more efficient structure with OnceLock or OnceCell or something
inner: RwLock<HashMap<K, V>>,
}
// an item that stays constant throughout given epoch
pub(crate) type CachedImmutableEpochItem<T> = CachedImmutableItems<EpochId, T>;
pub type CachedImmutableEpochItem<T> = CachedImmutableItems<EpochId, T>;
impl<K, V> Default for CachedImmutableItems<K, V> {
fn default() -> Self {
@@ -86,11 +86,7 @@ impl<K, V> CachedImmutableItems<K, V>
where
K: Eq + Hash,
{
pub(crate) async fn get_or_init<F, U, E>(
&self,
key: K,
f: F,
) -> Result<RwLockReadGuard<'_, V>, E>
pub async fn get_or_init<F, U, E>(&self, key: K, f: F) -> Result<RwLockReadGuard<'_, V>, E>
where
F: FnOnce() -> U,
U: Future<Output = Result<V, E>>,
@@ -129,29 +125,29 @@ where
}
}
pub(crate) fn ensure_sane_expiration_date(expiration_date: Date) -> Result<(), VpnApiError> {
pub fn ensure_sane_expiration_date(expiration_date: Date) -> Result<(), CredentialProxyError> {
let today = ecash_today();
if expiration_date < today.date() {
// what's the point of signatures with expiration in the past?
return Err(VpnApiError::ExpirationDateTooEarly);
return Err(CredentialProxyError::ExpirationDateTooEarly);
}
if expiration_date > cred_exp_date().ecash_date() {
return Err(VpnApiError::ExpirationDateTooLate);
return Err(CredentialProxyError::ExpirationDateTooLate);
}
Ok(())
}
pub(crate) async fn query_all_threshold_apis<F, T, U>(
pub async fn query_all_threshold_apis<F, T, U>(
all_apis: Vec<EcashApiClient>,
threshold: u64,
f: F,
) -> Result<Vec<T>, VpnApiError>
) -> Result<Vec<T>, CredentialProxyError>
where
F: Fn(EcashApiClient) -> U,
U: Future<Output = Result<T, VpnApiError>>,
U: Future<Output = Result<T, CredentialProxyError>>,
{
let shares = Mutex::new(Vec::with_capacity(all_apis.len()));
@@ -172,7 +168,7 @@ where
let shares = shares.into_inner();
if shares.len() < threshold as usize {
return Err(VpnApiError::InsufficientNumberOfSigners {
return Err(CredentialProxyError::InsufficientNumberOfSigners {
threshold,
available: shares.len(),
});
@@ -0,0 +1,102 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::error::CredentialProxyError;
use crate::shared_state::nyxd_client::ChainClient;
use nym_ecash_signer_check::{check_known_dealers, dkg_details_with_client};
use std::ops::Deref;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
#[derive(Clone)]
pub struct QuorumState {
available: Arc<AtomicBool>,
}
impl QuorumState {
pub fn available(&self) -> bool {
self.available.load(Ordering::Acquire)
}
}
pub struct QuorumStateChecker {
client: ChainClient,
cancellation_token: CancellationToken,
check_interval: Duration,
quorum_state: QuorumState,
}
impl QuorumStateChecker {
pub async fn new(
client: ChainClient,
check_interval: Duration,
cancellation_token: CancellationToken,
) -> Result<Self, CredentialProxyError> {
let this = QuorumStateChecker {
client,
cancellation_token,
check_interval,
quorum_state: QuorumState {
available: Arc::new(Default::default()),
},
};
// first check MUST succeed, otherwise we shouldn't start
let quorum_available = this.check_quorum_state().await?;
this.quorum_state
.available
.store(quorum_available, Ordering::Relaxed);
Ok(this)
}
pub fn quorum_state_ref(&self) -> QuorumState {
self.quorum_state.clone()
}
async fn check_quorum_state(&self) -> Result<bool, CredentialProxyError> {
let client_guard = self.client.query_chain().await;
// split the operation as we only need to hold the reference to chain client for the first part
// and the second half doesn't rely on it (and takes way longer)
let dkg_details = dkg_details_with_client(client_guard.deref()).await?;
drop(client_guard);
let res = check_known_dealers(dkg_details).await?;
let Some(signing_threshold) = res.threshold else {
warn!("signing threshold is currently unavailable and we have not yet implemented credential issuance during DKG transition");
return Ok(false);
};
let mut working_issuer = 0;
for result in res.results {
if result.chain_available() && result.signing_available() {
working_issuer += 1;
}
}
Ok((working_issuer as u64) >= signing_threshold)
}
pub async fn run_forever(self) {
info!("starting quorum state checker");
loop {
tokio::select! {
biased;
_ = self.cancellation_token.cancelled() => {
break
}
_ = tokio::time::sleep(self.check_interval) => {
match self.check_quorum_state().await {
Ok(available) => self.quorum_state.available.store(available, Ordering::SeqCst),
Err(err) => error!("failed to check current quorum state: {err}"),
}
}
}
}
}
}
@@ -0,0 +1,49 @@
// Copyright 2025 Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::nym_api_helpers::{CachedEpoch, CachedImmutableEpochItem, CachedImmutableItems};
use crate::quorum_checker::QuorumState;
use crate::shared_state::required_deposit_cache::RequiredDepositCache;
use nym_compact_ecash::VerificationKeyAuth;
use nym_credentials::{AggregatedCoinIndicesSignatures, AggregatedExpirationDateSignatures};
use nym_validator_client::nym_api::EpochId;
use nym_validator_client::EcashApiClient;
use time::Date;
use tokio::sync::RwLock;
pub struct EcashState {
pub required_deposit_cache: RequiredDepositCache,
pub quorum_state: QuorumState,
pub cached_epoch: RwLock<CachedEpoch>,
pub master_verification_key: CachedImmutableEpochItem<VerificationKeyAuth>,
pub threshold_values: CachedImmutableEpochItem<u64>,
pub epoch_clients: CachedImmutableEpochItem<Vec<EcashApiClient>>,
pub coin_index_signatures: CachedImmutableEpochItem<AggregatedCoinIndicesSignatures>,
pub expiration_date_signatures:
CachedImmutableItems<(EpochId, Date), AggregatedExpirationDateSignatures>,
}
impl EcashState {
pub fn new(
required_deposit_cache: RequiredDepositCache,
quorum_state: QuorumState,
) -> EcashState {
EcashState {
required_deposit_cache,
quorum_state,
cached_epoch: Default::default(),
master_verification_key: Default::default(),
threshold_values: Default::default(),
epoch_clients: Default::default(),
coin_index_signatures: Default::default(),
expiration_date_signatures: Default::default(),
}
}
}
@@ -0,0 +1,495 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::deposits_buffer::{BufferedDeposit, DepositsBuffer};
use crate::error::CredentialProxyError;
use crate::nym_api_helpers::{ensure_sane_expiration_date, query_all_threshold_apis};
use crate::shared_state::ecash_state::EcashState;
use crate::shared_state::nyxd_client::ChainClient;
use crate::storage::CredentialProxyStorage;
use nym_compact_ecash::scheme::coin_indices_signatures::{
aggregate_annotated_indices_signatures, CoinIndexSignatureShare,
};
use nym_compact_ecash::scheme::expiration_date_signatures::{
aggregate_annotated_expiration_signatures, ExpirationDateSignatureShare,
};
use nym_compact_ecash::{Base58, PublicKeyUser, VerificationKeyAuth};
use nym_credential_proxy_requests::api::v1::ticketbook::models::{
AggregatedCoinIndicesSignaturesResponse, AggregatedExpirationDateSignaturesResponse,
GlobalDataParams, MasterVerificationKeyResponse,
};
use nym_credentials::ecash::utils::EcashTime;
use nym_credentials::{
AggregatedCoinIndicesSignatures, AggregatedExpirationDateSignatures, EpochVerificationKey,
};
use nym_ecash_contract_common::deposit::DepositId;
use nym_validator_client::coconut::EcashApiError;
use nym_validator_client::nym_api::EpochId;
use nym_validator_client::nyxd::contract_traits::dkg_query_client::Epoch;
use nym_validator_client::nyxd::contract_traits::{DkgQueryClient, PagedDkgQueryClient};
use nym_validator_client::nyxd::Coin;
use nym_validator_client::{DirectSigningHttpRpcNyxdClient, EcashApiClient};
use std::sync::Arc;
use std::time::Duration;
use time::{Date, OffsetDateTime};
use tokio::sync::RwLockReadGuard;
use tokio::time::Instant;
use tracing::{debug, error, info, warn};
use uuid::Uuid;
pub mod ecash_state;
pub mod nyxd_client;
pub mod required_deposit_cache;
#[derive(Clone)]
pub struct CredentialProxyState {
inner: Arc<CredentialProxyStateInner>,
}
impl CredentialProxyState {
pub fn new(
storage: CredentialProxyStorage,
client: ChainClient,
deposits_buffer: DepositsBuffer,
ecash_state: EcashState,
) -> Self {
CredentialProxyState {
inner: Arc::new(CredentialProxyStateInner {
storage,
client,
deposits_buffer,
ecash_state,
}),
}
}
pub fn storage(&self) -> &CredentialProxyStorage {
&self.inner.storage
}
pub async fn deposit_amount(&self) -> Result<Coin, CredentialProxyError> {
self.ecash_state()
.required_deposit_cache
.get_or_update(self.client())
.await
}
pub fn client(&self) -> &ChainClient {
&self.inner.client
}
pub fn deposits_buffer(&self) -> &DepositsBuffer {
&self.inner.deposits_buffer
}
pub fn ecash_state(&self) -> &EcashState {
&self.inner.ecash_state
}
pub(crate) async fn query_chain(&self) -> RwLockReadGuard<'_, DirectSigningHttpRpcNyxdClient> {
self.inner.client.query_chain().await
}
pub async fn ensure_credentials_issuable(&self) -> Result<(), CredentialProxyError> {
let epoch = self.current_epoch().await?;
if epoch.state.is_final() {
Ok(())
} else if let Some(final_timestamp) = epoch.final_timestamp_secs() {
// SAFETY: the timestamp values in our DKG contract should be valid timestamps,
// otherwise it means the chain is seriously misbehaving
#[allow(clippy::unwrap_used)]
let finish_dt = OffsetDateTime::from_unix_timestamp(final_timestamp as i64).unwrap();
Err(CredentialProxyError::CredentialsNotYetIssuable {
availability: finish_dt,
})
} else if epoch.state.is_waiting_initialisation() {
Err(CredentialProxyError::UninitialisedDkg)
} else {
Err(CredentialProxyError::UnknownEcashFailure)
}
}
pub async fn get_deposit(
&self,
request_uuid: Uuid,
requested_on: OffsetDateTime,
client_pubkey: PublicKeyUser,
) -> Result<BufferedDeposit, CredentialProxyError> {
let start = Instant::now();
let deposit = self
.deposits_buffer()
.get_valid_deposit(request_uuid, requested_on, client_pubkey)
.await;
let time_taken = start.elapsed();
let formatted = humantime::format_duration(time_taken);
if time_taken > Duration::from_secs(10) {
warn!("attempting to get buffered deposit took {formatted}. perhaps the buffer is too small or the process/chain is overloaded?")
} else {
debug!("attempting to get buffered deposit took {formatted}")
};
deposit
}
pub async fn insert_deposit_usage_error(&self, deposit_id: DepositId, error: String) {
if let Err(err) = self
.storage()
.insert_deposit_usage_error(deposit_id, error)
.await
{
error!("failed to insert information about deposit (id: {deposit_id}) usage failure: {err}")
}
}
pub async fn current_epoch_id(&self) -> Result<EpochId, CredentialProxyError> {
let read_guard = self.inner.ecash_state.cached_epoch.read().await;
if read_guard.is_valid() {
return Ok(read_guard.current_epoch.epoch_id);
}
// update cache
drop(read_guard);
let mut write_guard = self.inner.ecash_state.cached_epoch.write().await;
let epoch = self.query_chain().await.get_current_epoch().await?;
write_guard.update(epoch);
Ok(epoch.epoch_id)
}
pub async fn current_epoch(&self) -> Result<Epoch, CredentialProxyError> {
let read_guard = self.ecash_state().cached_epoch.read().await;
if read_guard.is_valid() {
return Ok(read_guard.current_epoch);
}
// update cache
drop(read_guard);
let mut write_guard = self.ecash_state().cached_epoch.write().await;
let epoch = self.query_chain().await.get_current_epoch().await?;
write_guard.update(epoch);
Ok(epoch)
}
pub async fn global_data(
&self,
global_data: GlobalDataParams,
epoch_id: EpochId,
expiration_date: Date,
) -> Result<
(
Option<MasterVerificationKeyResponse>,
Option<AggregatedExpirationDateSignaturesResponse>,
Option<AggregatedCoinIndicesSignaturesResponse>,
),
CredentialProxyError,
> {
let master_verification_key = if global_data.include_master_verification_key {
debug!("including master verification key in the response");
Some(
self.master_verification_key(Some(epoch_id))
.await
.map(|key| MasterVerificationKeyResponse {
epoch_id,
bs58_encoded_key: key.to_bs58(),
})
.inspect_err(|err| warn!("request failure: {err}"))?,
)
} else {
None
};
let aggregated_expiration_date_signatures =
if global_data.include_expiration_date_signatures {
debug!("including expiration date signatures in the response");
Some(
self.master_expiration_date_signatures(epoch_id, expiration_date)
.await
.map(|signatures| AggregatedExpirationDateSignaturesResponse {
signatures: signatures.clone(),
})
.inspect_err(|err| warn!("request failure: {err}"))?,
)
} else {
None
};
let aggregated_coin_index_signatures = if global_data.include_coin_index_signatures {
debug!("including coin index signatures in the response");
Some(
self.master_coin_index_signatures(Some(epoch_id))
.await
.map(|signatures| AggregatedCoinIndicesSignaturesResponse {
signatures: signatures.clone(),
})
.inspect_err(|err| warn!("request failure: {err}"))?,
)
} else {
None
};
Ok((
master_verification_key,
aggregated_expiration_date_signatures,
aggregated_coin_index_signatures,
))
}
pub async fn master_verification_key(
&self,
epoch_id: Option<EpochId>,
) -> Result<RwLockReadGuard<'_, VerificationKeyAuth>, CredentialProxyError> {
let epoch_id = match epoch_id {
Some(id) => id,
None => self.current_epoch_id().await?,
};
self.inner
.ecash_state
.master_verification_key
.get_or_init(epoch_id, || async {
// 1. check the storage
if let Some(stored) = self
.inner
.storage
.get_master_verification_key(epoch_id)
.await?
{
return Ok(stored.key);
}
info!("attempting to establish master verification key for epoch {epoch_id}...");
// 2. perform actual aggregation
let all_apis = self.ecash_clients(epoch_id).await?;
let threshold = self.ecash_threshold(epoch_id).await?;
if all_apis.len() < threshold as usize {
return Err(CredentialProxyError::InsufficientNumberOfSigners {
threshold,
available: all_apis.len(),
});
}
let master_key = nym_credentials::aggregate_verification_keys(&all_apis)?;
let epoch = EpochVerificationKey {
epoch_id,
key: master_key,
};
// 3. save the key in the storage for when we reboot
self.inner
.storage
.insert_master_verification_key(&epoch)
.await?;
Ok(epoch.key)
})
.await
}
pub async fn master_coin_index_signatures(
&self,
epoch_id: Option<EpochId>,
) -> Result<RwLockReadGuard<'_, AggregatedCoinIndicesSignatures>, CredentialProxyError> {
let epoch_id = match epoch_id {
Some(id) => id,
None => self.current_epoch_id().await?,
};
self.inner
.ecash_state
.coin_index_signatures
.get_or_init(epoch_id, || async {
// 1. check the storage
if let Some(master_sigs) = self
.inner
.storage
.get_master_coin_index_signatures(epoch_id)
.await?
{
return Ok(master_sigs);
}
info!(
"attempting to establish master coin index signatures for epoch {epoch_id}..."
);
// 2. go around APIs and attempt to aggregate the data
let master_vk = self.master_verification_key(Some(epoch_id)).await?;
let all_apis = self.ecash_clients(epoch_id).await?;
let threshold = self.ecash_threshold(epoch_id).await?;
let get_partial_signatures = |api: EcashApiClient| async {
// move the api into the closure
let api = api;
let node_index = api.node_id;
let partial_vk = api.verification_key;
let partial = api
.api_client
.partial_coin_indices_signatures(Some(epoch_id))
.await?
.signatures;
Ok(CoinIndexSignatureShare {
index: node_index,
key: partial_vk,
signatures: partial,
})
};
let shares =
query_all_threshold_apis(all_apis.clone(), threshold, get_partial_signatures)
.await?;
let aggregated = aggregate_annotated_indices_signatures(
nym_credentials_interface::ecash_parameters(),
&master_vk,
&shares,
)?;
let sigs = AggregatedCoinIndicesSignatures {
epoch_id,
signatures: aggregated,
};
// 3. save the signatures in the storage for when we reboot
self.inner
.storage
.insert_master_coin_index_signatures(&sigs)
.await?;
Ok(sigs)
})
.await
}
pub async fn master_expiration_date_signatures(
&self,
epoch_id: EpochId,
expiration_date: Date,
) -> Result<RwLockReadGuard<'_, AggregatedExpirationDateSignatures>, CredentialProxyError> {
self.inner.ecash_state
.expiration_date_signatures
.get_or_init((epoch_id, expiration_date), || async {
// 1. sanity check to see if the expiration_date is not nonsense
ensure_sane_expiration_date(expiration_date)?;
// 2. check the storage
if let Some(master_sigs) = self
.storage()
.get_master_expiration_date_signatures(expiration_date, epoch_id)
.await?
{
return Ok(master_sigs);
}
info!(
"attempting to establish master expiration date signatures for {expiration_date} and epoch {epoch_id}..."
);
// 3. go around APIs and attempt to aggregate the data
let epoch_id = self.current_epoch_id().await?;
let master_vk = self.master_verification_key(Some(epoch_id)).await?;
let all_apis = self.ecash_clients(epoch_id).await?;
let threshold = self.ecash_threshold(epoch_id).await?;
let get_partial_signatures = |api: EcashApiClient| async {
// move the api into the closure
let api = api;
let node_index = api.node_id;
let partial_vk = api.verification_key;
let partial = api
.api_client
.partial_expiration_date_signatures(Some(expiration_date), Some(epoch_id))
.await?
.signatures;
Ok(ExpirationDateSignatureShare {
index: node_index,
key: partial_vk,
signatures: partial,
})
};
let shares =
query_all_threshold_apis(all_apis.clone(), threshold, get_partial_signatures)
.await?;
let aggregated = aggregate_annotated_expiration_signatures(
&master_vk,
expiration_date.ecash_unix_timestamp(),
&shares,
)?;
let sigs = AggregatedExpirationDateSignatures {
epoch_id,
expiration_date,
signatures: aggregated,
};
// 4. save the signatures in the storage for when we reboot
self.inner.storage
.insert_master_expiration_date_signatures(&sigs)
.await?;
Ok(sigs)
})
.await
}
pub async fn ecash_clients(
&self,
epoch_id: EpochId,
) -> Result<RwLockReadGuard<'_, Vec<EcashApiClient>>, CredentialProxyError> {
self.inner
.ecash_state
.epoch_clients
.get_or_init(epoch_id, || async {
Ok(self
.query_chain()
.await
.get_all_verification_key_shares(epoch_id)
.await?
.into_iter()
.map(TryInto::try_into)
.collect::<anyhow::Result<Vec<_>, EcashApiError>>()?)
})
.await
}
pub async fn ecash_threshold(&self, epoch_id: EpochId) -> Result<u64, CredentialProxyError> {
self.inner
.ecash_state
.threshold_values
.get_or_init(epoch_id, || async {
if let Some(threshold) = self
.query_chain()
.await
.get_epoch_threshold(epoch_id)
.await?
{
Ok(threshold)
} else {
Err(CredentialProxyError::UnavailableThreshold { epoch_id })
}
})
.await
.map(|t| *t)
}
}
struct CredentialProxyStateInner {
storage: CredentialProxyStorage,
client: ChainClient,
deposits_buffer: DepositsBuffer,
ecash_state: EcashState,
}
@@ -0,0 +1,126 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::error::CredentialProxyError;
use crate::helpers::LockTimer;
use nym_ecash_contract_common::msg::ExecuteMsg;
use nym_validator_client::nyxd::contract_traits::NymContractsProvider;
use nym_validator_client::nyxd::cosmwasm_client::types::ExecuteResult;
use nym_validator_client::nyxd::{Coin, CosmWasmClient, NyxdClient};
use nym_validator_client::{nyxd, DirectSigningHttpRpcNyxdClient};
use std::ops::Deref;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use tracing::{instrument, warn};
#[derive(Clone)]
pub struct ChainClient(Arc<RwLock<DirectSigningHttpRpcNyxdClient>>);
impl ChainClient {
pub fn new(mnemonic: bip39::Mnemonic) -> Result<Self, CredentialProxyError> {
let network_details = nym_network_defaults::NymNetworkDetails::new_from_env();
let client_config = nyxd::Config::try_from_nym_network_details(&network_details)?;
let nyxd_url = network_details
.endpoints
.first()
.ok_or_else(|| CredentialProxyError::NoNyxEndpointsAvailable)?
.nyxd_url
.as_str();
let client = NyxdClient::connect_with_mnemonic(client_config, nyxd_url, mnemonic)?;
if client.ecash_contract_address().is_none() {
return Err(CredentialProxyError::UnavailableEcashContract);
}
if client.dkg_contract_address().is_none() {
return Err(CredentialProxyError::UnavailableDKGContract);
}
Ok(ChainClient(Arc::new(RwLock::new(client))))
}
pub async fn query_chain(&self) -> ChainReadPermit<'_> {
let _acquire_timer = LockTimer::new("acquire chain query permit");
self.0.read().await
}
pub async fn start_chain_tx(&self) -> ChainWritePermit<'_> {
let _acquire_timer = LockTimer::new("acquire exclusive chain write permit");
ChainWritePermit {
lock_timer: LockTimer::new("exclusive chain access permit"),
inner: self.0.write().await,
}
}
}
pub type ChainReadPermit<'a> = RwLockReadGuard<'a, DirectSigningHttpRpcNyxdClient>;
// explicitly wrap the WriteGuard for extra information regarding time taken
pub struct ChainWritePermit<'a> {
// it's not really dead, we only care about it being dropped
#[allow(dead_code)]
lock_timer: LockTimer,
inner: RwLockWriteGuard<'a, DirectSigningHttpRpcNyxdClient>,
}
impl ChainWritePermit<'_> {
#[instrument(skip(self, short_sha, info), err(Display))]
pub async fn make_deposits(
self,
short_sha: &'static str,
info: Vec<(String, Coin)>,
) -> Result<ExecuteResult, CredentialProxyError> {
let address = self.inner.address();
let starting_sequence = self.inner.get_sequence(&address).await?.sequence;
let deposits = info.len();
let ecash_contract = self
.inner
.ecash_contract_address()
.ok_or(CredentialProxyError::UnavailableEcashContract)?;
let deposit_messages = info
.into_iter()
.map(|(identity_key, amount)| {
(
ExecuteMsg::DepositTicketBookFunds { identity_key },
vec![amount],
)
})
.collect::<Vec<_>>();
let res = self
.inner
.execute_multiple(
ecash_contract,
deposit_messages,
None,
format!("cp-{short_sha}: performing {deposits} deposits"),
)
.await?;
loop {
let updated_sequence = self.inner.get_sequence(&address).await?.sequence;
if updated_sequence > starting_sequence {
break;
}
warn!("wrong sequence number... waiting before releasing chain lock");
tokio::time::sleep(Duration::from_millis(50)).await;
}
Ok(res)
}
}
impl Deref for ChainWritePermit<'_> {
type Target = DirectSigningHttpRpcNyxdClient;
fn deref(&self) -> &Self::Target {
self.inner.deref()
}
}
@@ -0,0 +1,71 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::error::CredentialProxyError;
use crate::shared_state::nyxd_client::ChainClient;
use nym_validator_client::nyxd::contract_traits::EcashQueryClient;
use nym_validator_client::nyxd::Coin;
use std::sync::Arc;
use time::OffsetDateTime;
use tokio::sync::RwLock;
pub struct CachedDeposit {
valid_until: OffsetDateTime,
required_amount: Coin,
}
impl CachedDeposit {
const MAX_VALIDITY: time::Duration = time::Duration::MINUTE;
fn is_valid(&self) -> bool {
self.valid_until > OffsetDateTime::now_utc()
}
fn update(&mut self, required_amount: Coin) {
self.valid_until = OffsetDateTime::now_utc() + Self::MAX_VALIDITY;
self.required_amount = required_amount;
}
}
impl Default for CachedDeposit {
fn default() -> Self {
CachedDeposit {
valid_until: OffsetDateTime::UNIX_EPOCH,
required_amount: Coin {
amount: u128::MAX,
denom: "unym".to_string(),
},
}
}
}
#[derive(Clone, Default)]
pub struct RequiredDepositCache {
inner: Arc<RwLock<CachedDeposit>>,
}
impl RequiredDepositCache {
pub async fn get_or_update(
&self,
chain_client: &ChainClient,
) -> Result<Coin, CredentialProxyError> {
let read_guard = self.inner.read().await;
if read_guard.is_valid() {
return Ok(read_guard.required_amount.clone());
}
// update cache
drop(read_guard);
let mut write_guard = self.inner.write().await;
let deposit_amount = chain_client
.query_chain()
.await
.get_required_deposit_amount()
.await?;
let nym_coin: Coin = deposit_amount.into();
write_guard.update(nym_coin.clone());
Ok(nym_coin)
}
}
@@ -1,13 +1,13 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::error::VpnApiError;
use crate::storage::models::{
BlindedShares, BlindedSharesStatus, MinimalWalletShare, RawCoinIndexSignatures,
RawExpirationDateSignatures, RawVerificationKey,
RawExpirationDateSignatures, RawVerificationKey, StorableEcashDeposit,
};
use nym_validator_client::nyxd::contract_traits::ecash_query_client::DepositId;
use time::{Date, OffsetDateTime};
use tracing::error;
#[derive(Clone)]
pub(crate) struct SqliteStorageManager {
@@ -42,7 +42,7 @@ impl SqliteStorageManager {
r#"
SELECT t1.node_id, t1.blinded_signature, t1.epoch_id, t1.expiration_date as "expiration_date!: Date"
FROM partial_blinded_wallet as t1
JOIN ticketbook_deposit as t2
JOIN ecash_deposit_usage as t2
on t1.corresponding_deposit = t2.deposit_id
JOIN blinded_shares as t3
ON t2.request_uuid = t3.request_uuid
@@ -106,7 +106,7 @@ impl SqliteStorageManager {
t1.epoch_id as "epoch_id!",
t1.expiration_date as "expiration_date!: Date"
FROM partial_blinded_wallet as t1
JOIN ticketbook_deposit as t2
JOIN ecash_deposit_usage as t2
on t1.corresponding_deposit = t2.deposit_id
JOIN blinded_shares as t3
ON t2.request_uuid = t3.request_uuid
@@ -169,7 +169,7 @@ impl SqliteStorageManager {
available_shares: i64,
device_id: &str,
credential_id: &str,
) -> Result<BlindedShares, VpnApiError> {
) -> Result<BlindedShares, sqlx::Error> {
let now = OffsetDateTime::now_utc();
let res = sqlx::query_as(
r#"
@@ -196,7 +196,7 @@ impl SqliteStorageManager {
device_id: &str,
credential_id: &str,
error: &str,
) -> Result<BlindedShares, VpnApiError> {
) -> Result<BlindedShares, sqlx::Error> {
let now = time::OffsetDateTime::now_utc();
let res = sqlx::query_as(
r#"
@@ -221,7 +221,7 @@ impl SqliteStorageManager {
pub(crate) async fn prune_old_blinded_shares(
&self,
delete_after: OffsetDateTime,
) -> Result<(), VpnApiError> {
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
DELETE FROM blinded_shares WHERE created < ?
@@ -236,7 +236,7 @@ impl SqliteStorageManager {
pub(crate) async fn prune_old_partial_blinded_wallets(
&self,
delete_after: OffsetDateTime,
) -> Result<(), VpnApiError> {
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
DELETE FROM partial_blinded_wallet WHERE created < ?
@@ -251,7 +251,7 @@ impl SqliteStorageManager {
pub(crate) async fn prune_old_partial_blinded_wallet_failures(
&self,
delete_after: OffsetDateTime,
) -> Result<(), VpnApiError> {
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
DELETE FROM partial_blinded_wallet_failure WHERE created < ?
@@ -370,32 +370,87 @@ impl SqliteStorageManager {
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn insert_deposit_data(
pub(crate) async fn insert_new_deposits(
&self,
deposits: Vec<StorableEcashDeposit>,
) -> Result<(), sqlx::Error> {
if deposits.is_empty() {
// this should NEVER happen
error!("attempted to insert empty list of deposits");
return Ok(());
}
let mut query_builder =
sqlx::QueryBuilder::new("INSERT INTO ecash_deposit (deposit_id, deposit_tx_hash, requested_on, deposit_amount, ed25519_deposit_private_key) ");
query_builder.push_values(&deposits, |mut b, deposit| {
b.push_bind(deposit.deposit_id)
.push_bind(deposit.deposit_tx_hash.clone())
.push_bind(deposit.requested_on)
.push_bind(deposit.deposit_amount.clone())
.push_bind(deposit.ed25519_deposit_private_key.as_ref());
});
query_builder.build().execute(&self.connection_pool).await?;
Ok(())
}
pub(crate) async fn load_unused_deposits(
&self,
) -> Result<Vec<StorableEcashDeposit>, sqlx::Error> {
// select all entries from ecash_deposit where there is NO associated marked usage
sqlx::query_as(
r#"
SELECT ecash_deposit.*
FROM ecash_deposit ecash_deposit
LEFT JOIN ecash_deposit_usage deposit_usage
ON ecash_deposit.deposit_id = deposit_usage.deposit_id
WHERE deposit_usage.deposit_id IS NULL;
"#,
)
.fetch_all(&self.connection_pool)
.await
}
pub(crate) async fn insert_deposit_usage(
&self,
deposit_id: DepositId,
deposit_tx_hash: String,
requested_on: OffsetDateTime,
client_pubkey: Vec<u8>,
request_uuid: String,
deposit_amount: String,
client_pubkey: &[u8],
deposit_ed25519_private_key: &[u8],
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO ticketbook_deposit(deposit_id, deposit_tx_hash, requested_on, request_uuid, deposit_amount, client_pubkey, ed25519_deposit_private_key)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO ecash_deposit_usage (deposit_id, ticketbooks_requested_on, client_pubkey, request_uuid)
VALUES (?, ?, ?, ?)
"#,
deposit_id,
deposit_tx_hash,
requested_on,
request_uuid,
deposit_amount,
client_pubkey,
deposit_ed25519_private_key,
deposit_id,
requested_on,
client_pubkey,
request_uuid
)
.execute(&self.connection_pool)
.await?;
.execute(&self.connection_pool)
.await?;
Ok(())
}
pub(crate) async fn insert_deposit_usage_error(
&self,
deposit_id: DepositId,
error: String,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
UPDATE ecash_deposit_usage
SET ticketbook_request_error = ?
WHERE deposit_id = ?
"#,
error,
deposit_id
)
.execute(&self.connection_pool)
.await?;
Ok(())
}
@@ -1,21 +1,18 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::credentials::ticketbook::NodeId;
use crate::error::VpnApiError;
use crate::deposits_buffer::helpers::{BufferedDeposit, PerformedDeposits};
use crate::error::CredentialProxyError;
use crate::storage::manager::SqliteStorageManager;
use crate::storage::models::{BlindedShares, MinimalWalletShare};
use nym_compact_ecash::PublicKeyUser;
use nym_credentials::ecash::bandwidth::issuance::Hash;
use nym_credentials::ecash::bandwidth::serialiser::VersionedSerialise;
use nym_credentials::{
AggregatedCoinIndicesSignatures, AggregatedExpirationDateSignatures, EpochVerificationKey,
};
use nym_crypto::asymmetric::ed25519;
use nym_validator_client::ecash::BlindedSignatureResponse;
use nym_validator_client::nym_api::EpochId;
use nym_validator_client::nyxd::contract_traits::ecash_query_client::DepositId;
use nym_validator_client::nyxd::Coin;
use sqlx::sqlite::{SqliteAutoVacuum, SqliteSynchronous};
use sqlx::ConnectOptions;
use std::fmt::Debug;
@@ -25,19 +22,24 @@ use time::{Date, OffsetDateTime};
use tracing::log::LevelFilter;
use tracing::{debug, error, info, instrument};
use uuid::Uuid;
use zeroize::Zeroizing;
mod manager;
pub mod models;
pub(crate) mod pruner;
// TODO: proper import
type NodeId = u64;
#[derive(Clone)]
pub struct VpnApiStorage {
pub struct CredentialProxyStorage {
pub(crate) storage_manager: SqliteStorageManager,
}
impl VpnApiStorage {
impl CredentialProxyStorage {
#[instrument]
pub async fn init<P: AsRef<Path> + Debug>(database_path: P) -> Result<Self, VpnApiError> {
pub async fn init<P: AsRef<Path> + Debug>(
database_path: P,
) -> Result<Self, CredentialProxyError> {
debug!("Attempting to connect to database");
let opts = sqlx::sqlite::SqliteConnectOptions::new()
@@ -69,36 +71,36 @@ impl VpnApiStorage {
info!("Database migration finished!");
Ok(VpnApiStorage {
Ok(CredentialProxyStorage {
storage_manager: SqliteStorageManager { connection_pool },
})
}
#[allow(dead_code)]
pub(crate) async fn load_blinded_shares_status_by_shares_id(
pub async fn load_blinded_shares_status_by_shares_id(
&self,
id: i64,
) -> Result<Option<BlindedShares>, VpnApiError> {
) -> Result<Option<BlindedShares>, CredentialProxyError> {
Ok(self
.storage_manager
.load_blinded_shares_status_by_shares_id(id)
.await?)
}
pub(crate) async fn load_wallet_shares_by_shares_id(
pub async fn load_wallet_shares_by_shares_id(
&self,
id: i64,
) -> Result<Vec<MinimalWalletShare>, VpnApiError> {
) -> Result<Vec<MinimalWalletShare>, CredentialProxyError> {
Ok(self
.storage_manager
.load_wallet_shares_by_shares_id(id)
.await?)
}
pub(crate) async fn load_shares_error_by_shares_id(
pub async fn load_shares_error_by_shares_id(
&self,
id: i64,
) -> Result<Option<String>, VpnApiError> {
) -> Result<Option<String>, CredentialProxyError> {
Ok(self
.storage_manager
.load_shares_error_by_device_by_shares_id(id)
@@ -106,84 +108,86 @@ impl VpnApiStorage {
}
#[allow(dead_code)]
pub(crate) async fn load_blinded_shares_status_by_device_and_credential_id(
pub async fn load_blinded_shares_status_by_device_and_credential_id(
&self,
device_id: &str,
credential_id: &str,
) -> Result<Option<BlindedShares>, VpnApiError> {
) -> Result<Option<BlindedShares>, CredentialProxyError> {
Ok(self
.storage_manager
.load_blinded_shares_status_by_device_and_credential_id(device_id, credential_id)
.await?)
}
pub(crate) async fn load_wallet_shares_by_device_and_credential_id(
pub async fn load_wallet_shares_by_device_and_credential_id(
&self,
device_id: &str,
credential_id: &str,
) -> Result<Vec<MinimalWalletShare>, VpnApiError> {
) -> Result<Vec<MinimalWalletShare>, CredentialProxyError> {
Ok(self
.storage_manager
.load_wallet_shares_by_device_and_credential_id(device_id, credential_id)
.await?)
}
pub(crate) async fn load_shares_error_by_device_and_credential_id(
pub async fn load_shares_error_by_device_and_credential_id(
&self,
device_id: &str,
credential_id: &str,
) -> Result<Option<String>, VpnApiError> {
) -> Result<Option<String>, CredentialProxyError> {
Ok(self
.storage_manager
.load_shares_error_by_device_and_credential_id(device_id, credential_id)
.await?)
}
pub(crate) async fn insert_new_pending_async_shares_request(
pub async fn insert_new_pending_async_shares_request(
&self,
request: Uuid,
device_id: &str,
credential_id: &str,
) -> Result<BlindedShares, VpnApiError> {
) -> Result<BlindedShares, CredentialProxyError> {
Ok(self
.storage_manager
.insert_new_pending_async_shares_request(request.to_string(), device_id, credential_id)
.await?)
}
pub(crate) async fn update_pending_async_blinded_shares_issued(
pub async fn update_pending_async_blinded_shares_issued(
&self,
available_shares: usize,
device_id: &str,
credential_id: &str,
) -> Result<BlindedShares, VpnApiError> {
self.storage_manager
) -> Result<BlindedShares, CredentialProxyError> {
Ok(self
.storage_manager
.update_pending_async_blinded_shares_issued(
available_shares as i64,
device_id,
credential_id,
)
.await
.await?)
}
pub(crate) async fn update_pending_async_blinded_shares_error(
pub async fn update_pending_async_blinded_shares_error(
&self,
available_shares: usize,
device_id: &str,
credential_id: &str,
error: &str,
) -> Result<BlindedShares, VpnApiError> {
self.storage_manager
) -> Result<BlindedShares, CredentialProxyError> {
Ok(self
.storage_manager
.update_pending_async_blinded_shares_error(
available_shares as i64,
device_id,
credential_id,
error,
)
.await
.await?)
}
pub(crate) async fn prune_old_blinded_shares(&self) -> Result<(), VpnApiError> {
pub async fn prune_old_blinded_shares(&self) -> Result<(), CredentialProxyError> {
let max_age = OffsetDateTime::now_utc() - time::Duration::days(31);
self.storage_manager
@@ -192,46 +196,70 @@ impl VpnApiStorage {
self.storage_manager
.prune_old_partial_blinded_wallet_failures(max_age)
.await?;
self.storage_manager.prune_old_blinded_shares(max_age).await
self.storage_manager
.prune_old_blinded_shares(max_age)
.await?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn insert_deposit_data(
pub async fn insert_new_deposits(
&self,
deposit_id: DepositId,
deposit_tx_hash: Hash,
requested_on: OffsetDateTime,
request: Uuid,
deposit_amount: Coin,
client_ecash_pubkey: &PublicKeyUser,
ed22519_keypair: &ed25519::KeyPair,
) -> Result<(), VpnApiError> {
debug!("inserting deposit data");
let private_key_bytes = Zeroizing::new(ed22519_keypair.private_key().to_bytes());
deposits: &PerformedDeposits,
) -> Result<(), CredentialProxyError> {
debug!("inserting {} deposits data", deposits.deposits_data.len());
self.storage_manager
.insert_deposit_data(
.insert_new_deposits(deposits.to_storable())
.await?;
Ok(())
}
pub async fn load_unused_deposits(&self) -> Result<Vec<BufferedDeposit>, CredentialProxyError> {
self.storage_manager
.load_unused_deposits()
.await?
.into_iter()
.map(|deposit| deposit.try_into())
.collect()
}
pub async fn insert_deposit_usage(
&self,
deposit_id: DepositId,
requested_on: OffsetDateTime,
client_pubkey: PublicKeyUser,
request_uuid: Uuid,
) -> Result<(), CredentialProxyError> {
self.storage_manager
.insert_deposit_usage(
deposit_id,
deposit_tx_hash.to_string(),
requested_on,
request.to_string(),
deposit_amount.to_string(),
&client_ecash_pubkey.to_bytes(),
private_key_bytes.as_ref(),
client_pubkey.to_bytes(),
request_uuid.to_string(),
)
.await?;
Ok(())
}
pub(crate) async fn insert_partial_wallet_share(
pub async fn insert_deposit_usage_error(
&self,
deposit_id: DepositId,
error: String,
) -> Result<(), CredentialProxyError> {
self.storage_manager
.insert_deposit_usage_error(deposit_id, error)
.await?;
Ok(())
}
pub async fn insert_partial_wallet_share(
&self,
deposit_id: DepositId,
epoch_id: EpochId,
expiration_date: Date,
node_id: NodeId,
res: &Result<BlindedSignatureResponse, VpnApiError>,
) -> Result<(), VpnApiError> {
res: &Result<BlindedSignatureResponse, CredentialProxyError>,
) -> Result<(), CredentialProxyError> {
debug!("inserting partial wallet share");
let now = OffsetDateTime::now_utc();
@@ -264,10 +292,10 @@ impl VpnApiStorage {
Ok(())
}
pub(crate) async fn get_master_verification_key(
pub async fn get_master_verification_key(
&self,
epoch_id: EpochId,
) -> Result<Option<EpochVerificationKey>, VpnApiError> {
) -> Result<Option<EpochVerificationKey>, CredentialProxyError> {
let Some(raw) = self
.storage_manager
.get_master_verification_key(epoch_id as i64)
@@ -278,14 +306,14 @@ impl VpnApiStorage {
let deserialised =
EpochVerificationKey::try_unpack(&raw.serialised_key, raw.serialization_revision)
.map_err(|err| VpnApiError::database_inconsistency(err.to_string()))?;
.map_err(|err| CredentialProxyError::database_inconsistency(err.to_string()))?;
Ok(Some(deserialised))
}
pub(crate) async fn insert_master_verification_key(
pub async fn insert_master_verification_key(
&self,
key: &EpochVerificationKey,
) -> Result<(), VpnApiError> {
) -> Result<(), CredentialProxyError> {
let packed = key.pack();
Ok(self
.storage_manager
@@ -293,10 +321,10 @@ impl VpnApiStorage {
.await?)
}
pub(crate) async fn get_master_coin_index_signatures(
pub async fn get_master_coin_index_signatures(
&self,
epoch_id: EpochId,
) -> Result<Option<AggregatedCoinIndicesSignatures>, VpnApiError> {
) -> Result<Option<AggregatedCoinIndicesSignatures>, CredentialProxyError> {
let Some(raw) = self
.storage_manager
.get_master_coin_index_signatures(epoch_id as i64)
@@ -309,14 +337,14 @@ impl VpnApiStorage {
&raw.serialised_signatures,
raw.serialization_revision,
)
.map_err(|err| VpnApiError::database_inconsistency(err.to_string()))?;
.map_err(|err| CredentialProxyError::database_inconsistency(err.to_string()))?;
Ok(Some(deserialised))
}
pub(crate) async fn insert_master_coin_index_signatures(
pub async fn insert_master_coin_index_signatures(
&self,
signatures: &AggregatedCoinIndicesSignatures,
) -> Result<(), VpnApiError> {
) -> Result<(), CredentialProxyError> {
let packed = signatures.pack();
self.storage_manager
.insert_master_coin_index_signatures(
@@ -328,11 +356,11 @@ impl VpnApiStorage {
Ok(())
}
pub(crate) async fn get_master_expiration_date_signatures(
pub async fn get_master_expiration_date_signatures(
&self,
expiration_date: Date,
epoch_id: EpochId,
) -> Result<Option<AggregatedExpirationDateSignatures>, VpnApiError> {
) -> Result<Option<AggregatedExpirationDateSignatures>, CredentialProxyError> {
let Some(raw) = self
.storage_manager
.get_master_expiration_date_signatures(expiration_date, epoch_id as i64)
@@ -345,14 +373,14 @@ impl VpnApiStorage {
&raw.serialised_signatures,
raw.serialization_revision,
)
.map_err(|err| VpnApiError::database_inconsistency(err.to_string()))?;
.map_err(|err| CredentialProxyError::database_inconsistency(err.to_string()))?;
Ok(Some(deserialised))
}
pub(crate) async fn insert_master_expiration_date_signatures(
pub async fn insert_master_expiration_date_signatures(
&self,
signatures: &AggregatedExpirationDateSignatures,
) -> Result<(), VpnApiError> {
) -> Result<(), CredentialProxyError> {
let packed = signatures.pack();
self.storage_manager
.insert_master_expiration_date_signatures(
@@ -371,9 +399,11 @@ impl VpnApiStorage {
#[cfg(test)]
mod tests {
use super::*;
use crate::http::helpers;
use crate::helpers::random_uuid;
use crate::storage::models::BlindedSharesStatus;
use nym_compact_ecash::scheme::keygen::KeyPairUser;
use nym_crypto::asymmetric::ed25519;
use nym_validator_client::nyxd::{Coin, Hash};
use rand::rngs::OsRng;
use rand::RngCore;
use std::ops::Deref;
@@ -381,7 +411,7 @@ mod tests {
// create the wrapper so the underlying file gets deleted when it's no longer needed
struct StorageTestWrapper {
inner: VpnApiStorage,
inner: CredentialProxyStorage,
_path: TempPath,
}
@@ -393,12 +423,12 @@ mod tests {
println!("Creating database at {path:?}...");
Ok(StorageTestWrapper {
inner: VpnApiStorage::init(&path).await?,
inner: CredentialProxyStorage::init(&path).await?,
_path: path,
})
}
async fn insert_dummy_deposit(&self, uuid: Uuid) -> anyhow::Result<DepositId> {
async fn insert_dummy_used_deposit(&self, uuid: Uuid) -> anyhow::Result<DepositId> {
let mut rng = OsRng;
let deposit_id = rng.next_u32();
let tx_hash = Hash::Sha256(Default::default());
@@ -407,18 +437,21 @@ mod tests {
let client_keypair = KeyPairUser::new();
let client_ecash_pubkey = &client_keypair.public_key();
let deposit_keypair = ed25519::KeyPair::new(&mut rng);
let deposit_key = ed25519::PrivateKey::new(&mut rng);
self.inner
.insert_deposit_data(
deposit_id,
.insert_new_deposits(&PerformedDeposits {
deposits_data: vec![BufferedDeposit {
deposit_id,
ed25519_private_key: deposit_key,
}],
tx_hash,
requested_on,
uuid,
deposit_amount,
client_ecash_pubkey,
&deposit_keypair,
)
})
.await?;
self.inner
.insert_deposit_usage(deposit_id, requested_on, *client_ecash_pubkey, uuid)
.await?;
Ok(deposit_id)
@@ -426,7 +459,7 @@ mod tests {
}
impl Deref for StorageTestWrapper {
type Target = VpnApiStorage;
type Target = CredentialProxyStorage;
fn deref(&self) -> &Self::Target {
&self.inner
}
@@ -448,10 +481,10 @@ mod tests {
async fn test_add() -> anyhow::Result<()> {
let storage = get_storage().await?;
let dummy_uuid = helpers::random_uuid();
let dummy_uuid = random_uuid();
println!("🚀 insert_pending_blinded_share...");
storage.insert_dummy_deposit(dummy_uuid).await?;
storage.insert_dummy_used_deposit(dummy_uuid).await?;
let res = storage
.insert_new_pending_async_shares_request(dummy_uuid, "1234", "1234")
.await;
@@ -459,7 +492,7 @@ mod tests {
println!("{e}");
}
assert!(res.is_ok());
let res = res.unwrap();
let res = res?;
println!("res = {res:?}");
assert_eq!(res.status, BlindedSharesStatus::Pending);
@@ -471,7 +504,7 @@ mod tests {
println!("{e}");
}
assert!(res.is_ok());
let res = res.unwrap();
let res = res?;
println!("res = {res:?}");
assert!(res.error_message.is_some());
assert_eq!(res.status, BlindedSharesStatus::Error);
@@ -484,7 +517,7 @@ mod tests {
println!("{e}");
}
assert!(res.is_ok());
let res = res.unwrap();
let res = res?;
println!("res = {res:?}");
assert_eq!(res.status, BlindedSharesStatus::Issued);
assert!(res.error_message.is_none());
@@ -1,11 +1,49 @@
// Copyright 2024 Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use nym_crypto::asymmetric::ed25519;
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Type};
use std::convert::Into;
use sqlx::sqlite::SqliteRow;
use sqlx::{FromRow, Row, Type};
use strum_macros::{Display, EnumString};
use time::{Date, OffsetDateTime};
use zeroize::Zeroizing;
pub(crate) struct StorableEcashDeposit {
pub(crate) deposit_id: u32,
pub(crate) deposit_tx_hash: String,
pub(crate) requested_on: OffsetDateTime,
pub(crate) deposit_amount: String,
pub(crate) ed25519_deposit_private_key: Zeroizing<[u8; ed25519::SECRET_KEY_LENGTH]>,
}
impl<'r> FromRow<'r, SqliteRow> for StorableEcashDeposit {
fn from_row(row: &'r SqliteRow) -> Result<Self, sqlx::Error> {
let deposit_id = row.try_get("deposit_id")?;
let deposit_tx_hash = row.try_get("deposit_tx_hash")?;
let requested_on = row.try_get("requested_on")?;
let deposit_amount = row.try_get("deposit_amount")?;
let ed25519_deposit_private_key: Vec<u8> = row.try_get("ed25519_deposit_private_key")?;
if ed25519_deposit_private_key.len() != ed25519::SECRET_KEY_LENGTH {
return Err(sqlx::Error::decode(
"stored ed25519_deposit_private_key has invalid length",
));
}
// SAFETY: we just checked the length is correct
#[allow(clippy::unwrap_used)]
let ed25519_deposit_private_key: [u8; ed25519::SECRET_KEY_LENGTH] =
ed25519_deposit_private_key.try_into().unwrap();
Ok(StorableEcashDeposit {
deposit_id,
deposit_tx_hash,
requested_on,
deposit_amount,
ed25519_deposit_private_key: Zeroizing::new(ed25519_deposit_private_key),
})
}
}
#[derive(Serialize, Deserialize, Debug, Clone, EnumString, Type, PartialEq, Display)]
#[sqlx(rename_all = "snake_case")]
@@ -29,11 +67,6 @@ pub struct BlindedShares {
pub updated: OffsetDateTime,
}
pub struct FullBlindedShares {
pub status: BlindedShares,
pub shares: (),
}
#[derive(FromRow)]
pub struct RawExpirationDateSignatures {
pub serialised_signatures: Vec<u8>,
@@ -1,17 +1,17 @@
// Copyright 2024 Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::storage::VpnApiStorage;
use crate::storage::CredentialProxyStorage;
use tokio_util::sync::CancellationToken;
use tracing::{error, info};
pub struct StoragePruner {
cancellation_token: CancellationToken,
storage: VpnApiStorage,
storage: CredentialProxyStorage,
}
impl StoragePruner {
pub fn new(cancellation_token: CancellationToken, storage: VpnApiStorage) -> Self {
pub fn new(cancellation_token: CancellationToken, storage: CredentialProxyStorage) -> Self {
Self {
cancellation_token,
storage,
@@ -22,6 +22,7 @@ impl StoragePruner {
info!("starting the storage pruner task");
loop {
tokio::select! {
biased;
_ = self.cancellation_token.cancelled() => {
break
}
@@ -0,0 +1,163 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::deposits_buffer::DepositsBuffer;
use crate::error::CredentialProxyError;
use crate::quorum_checker::QuorumStateChecker;
use crate::shared_state::ecash_state::EcashState;
use crate::shared_state::nyxd_client::ChainClient;
use crate::shared_state::required_deposit_cache::RequiredDepositCache;
use crate::shared_state::CredentialProxyState;
use crate::storage::pruner::StoragePruner;
use crate::storage::CredentialProxyStorage;
use crate::webhook::ZkNymWebhook;
use nym_credentials::ecash::utils::ecash_today;
use nym_validator_client::nym_api::EpochId;
use std::future::Future;
use std::time::Duration;
use time::Date;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
mod shares_handlers;
pub mod ticketbook_handlers;
pub mod wallet_shares;
#[derive(Clone, Default)]
pub struct ShutdownTracker {
pub shutdown_token: CancellationToken,
pub tracker: TaskTracker,
}
#[derive(Clone)]
pub struct TicketbookManager {
pub(crate) state: CredentialProxyState,
pub(crate) webhook: ZkNymWebhook,
pub(crate) shutdown_tracker: ShutdownTracker,
}
impl TicketbookManager {
pub async fn new(
build_sha: &'static str,
quorum_check_interval: Duration,
deposits_buffer_size: usize,
max_concurrent_deposits: usize,
storage: CredentialProxyStorage,
mnemonic: bip39::Mnemonic,
webhook: ZkNymWebhook,
) -> Result<Self, CredentialProxyError> {
let chain_client = ChainClient::new(mnemonic)?;
let shutdown_tracker = ShutdownTracker::default();
let quorum_state_checker = QuorumStateChecker::new(
chain_client.clone(),
quorum_check_interval,
shutdown_tracker.shutdown_token.clone(),
)
.await?;
let required_deposit_cache = RequiredDepositCache::default();
let deposits_buffer = DepositsBuffer::new(
storage.clone(),
chain_client.clone(),
required_deposit_cache.clone(),
build_sha,
deposits_buffer_size,
max_concurrent_deposits,
shutdown_tracker.shutdown_token.clone(),
)
.await?;
let storage_pruner =
StoragePruner::new(shutdown_tracker.shutdown_token.clone(), storage.clone());
let this = TicketbookManager {
state: CredentialProxyState::new(
storage.clone(),
chain_client,
deposits_buffer,
EcashState::new(
required_deposit_cache,
quorum_state_checker.quorum_state_ref(),
),
),
webhook,
shutdown_tracker,
};
// since this is startup,
// might as well do all the needed network queries to establish needed global signatures
// if we don't already have them
this.build_initial_cache().await?;
// spawn the background tasks
this.try_spawn_in_background(quorum_state_checker.run_forever());
this.try_spawn_in_background(storage_pruner.run_forever());
Ok(this)
}
async fn build_initial_cache(&self) -> Result<(), CredentialProxyError> {
let today = ecash_today().date();
let epoch_id = self.state.current_epoch_id().await?;
let _ = self.state.deposit_amount().await?;
let _ = self.state.master_verification_key(Some(epoch_id)).await?;
let _ = self.state.ecash_threshold(epoch_id).await?;
let _ = self.state.ecash_clients(epoch_id).await?;
let _ = self
.state
.master_coin_index_signatures(Some(epoch_id))
.await?;
let _ = self
.state
.master_expiration_date_signatures(epoch_id, today)
.await?;
Ok(())
}
pub async fn cancel_and_wait(&self) {
self.shutdown_tracker.shutdown_token.cancel();
self.state.deposits_buffer().wait_for_shutdown().await;
self.shutdown_tracker.tracker.wait().await
}
pub fn shutdown_token(&self) -> CancellationToken {
self.shutdown_tracker.shutdown_token.clone()
}
/// Ensure the required global data for the specified epoch and expiration date exists in our cache (and storage)
async fn ensure_global_data_cached(
&self,
epoch: EpochId,
expiration_date: Date,
) -> Result<(), CredentialProxyError> {
let _ = self.state.master_verification_key(Some(epoch)).await?;
let _ = self.state.master_coin_index_signatures(Some(epoch)).await?;
let _ = self
.state
.master_expiration_date_signatures(epoch, expiration_date)
.await?;
Ok(())
}
pub fn try_spawn_in_background<F>(&self, task: F) -> Option<JoinHandle<F::Output>>
where
F: Future + Send + 'static,
F::Output: Send + 'static,
{
// don't spawn new task if we've received cancellation token
if self.shutdown_tracker.shutdown_token.is_cancelled() {
None
} else {
self.shutdown_tracker.tracker.reopen();
// TODO: later use a task queue since most requests will be blocked waiting on chain permit anyway
let join_handle = self.shutdown_tracker.tracker.spawn(task);
self.shutdown_tracker.tracker.close();
Some(join_handle)
}
}
}
@@ -0,0 +1,145 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::error::CredentialProxyError;
use crate::storage::models::MinimalWalletShare;
use crate::ticketbook_manager::TicketbookManager;
use nym_credential_proxy_requests::api::v1::ticketbook::models::{
GlobalDataParams, TicketbookWalletSharesResponse,
};
use nym_validator_client::nym_api::EpochId;
use tracing::{debug, span, Instrument, Level};
use uuid::Uuid;
impl TicketbookManager {
async fn shares_to_response(
&self,
shares: Vec<MinimalWalletShare>,
params: GlobalDataParams,
) -> Result<TicketbookWalletSharesResponse, CredentialProxyError> {
// in all calls we ensured the shares are non-empty
#[allow(clippy::unwrap_used)]
let first = shares.first().unwrap();
let expiration_date = first.expiration_date;
let epoch_id = first.epoch_id as EpochId;
let threshold = self.state.ecash_threshold(epoch_id).await?;
if shares.len() < threshold as usize {
return Err(CredentialProxyError::InsufficientNumberOfCredentials {
available: shares.len(),
threshold,
});
}
// grab any requested additional data
let (
master_verification_key,
aggregated_expiration_date_signatures,
aggregated_coin_index_signatures,
) = self
.state
.global_data(params, epoch_id, expiration_date)
.await?;
// finally produce a response
Ok(TicketbookWalletSharesResponse {
epoch_id,
shares: shares.into_iter().map(Into::into).collect(),
master_verification_key,
aggregated_coin_index_signatures,
aggregated_expiration_date_signatures,
})
}
/// Query by id for blinded shares of a bandwidth voucher
pub async fn query_for_shares_by_id(
&self,
uuid: Uuid,
params: GlobalDataParams,
share_id: i64,
) -> Result<TicketbookWalletSharesResponse, CredentialProxyError> {
let span = span!(Level::INFO, "query shares by id", uuid = %uuid, share_id = %share_id);
async move {
debug!("");
// TODO: edge case: this will **NOT** work if shares got created in epoch X,
// but this query happened in epoch X+1
let shares = self
.state
.storage()
.load_wallet_shares_by_shares_id(share_id)
.await?;
if shares.is_empty() {
debug!("shares not found");
// check for explicit error
if let Some(error_message) = self
.state
.storage()
.load_shares_error_by_shares_id(share_id)
.await?
{
return Err(CredentialProxyError::ShareByIdLoadError {
message: error_message,
id: share_id,
});
}
return Err(CredentialProxyError::SharesByIdNotFound { id: share_id });
}
self.shares_to_response(shares, params).await
}
.instrument(span)
.await
}
/// Query by id for blinded wallet shares of a ticketbook
pub async fn query_for_shares_by_device_id_and_credential_id(
&self,
uuid: Uuid,
params: GlobalDataParams,
device_id: String,
credential_id: String,
) -> Result<TicketbookWalletSharesResponse, CredentialProxyError> {
let span = span!(Level::INFO, "query shares by device and credential ids", uuid = %uuid, device_id = %device_id, credential_id = %credential_id);
async move {
debug!("");
// TODO: edge case: this will **NOT** work if shares got created in epoch X,
// but this query happened in epoch X+1
let shares = self
.state
.storage()
.load_wallet_shares_by_device_and_credential_id(&device_id, &credential_id)
.await?;
if shares.is_empty() {
debug!("shares not found");
// check for explicit error
if let Some(error_message) = self
.state
.storage()
.load_shares_error_by_device_and_credential_id(&device_id, &credential_id)
.await?
{
return Err(CredentialProxyError::ShareByDeviceLoadError {
message: error_message,
device_id,
credential_id,
});
}
return Err(CredentialProxyError::SharesByDeviceNotFound {
device_id,
credential_id,
});
}
self.shares_to_response(shares, params).await
}
.instrument(span)
.await
}
}
@@ -0,0 +1,164 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::error::CredentialProxyError;
use crate::nym_api_helpers::ensure_sane_expiration_date;
use crate::ticketbook_manager::TicketbookManager;
use nym_compact_ecash::Base58;
use nym_credential_proxy_requests::api::v1::ticketbook::models::{
CurrentEpochResponse, DepositResponse, GlobalDataParams, MasterVerificationKeyResponse,
PartialVerificationKey, PartialVerificationKeysResponse, TicketbookAsyncRequest,
TicketbookObtainParams, TicketbookRequest, TicketbookWalletSharesAsyncResponse,
TicketbookWalletSharesResponse,
};
use time::OffsetDateTime;
use tracing::{error, info, span, warn, Instrument, Level};
use uuid::Uuid;
impl TicketbookManager {
pub async fn obtain_ticketbook_shares(
&self,
uuid: Uuid,
request: TicketbookRequest,
params: GlobalDataParams,
) -> Result<TicketbookWalletSharesResponse, CredentialProxyError> {
let requested_on = OffsetDateTime::now_utc();
let span = span!(Level::INFO, "obtain ticketboook", uuid = %uuid);
async move {
info!("");
self.state.ensure_credentials_issuable().await?;
let epoch_id = self.state.current_epoch_id().await?;
ensure_sane_expiration_date(request.expiration_date)?;
// if additional data was requested, grab them first in case there are any cache/network issues
let (
master_verification_key,
aggregated_expiration_date_signatures,
aggregated_coin_index_signatures,
) = self
.state
.global_data(params, epoch_id, request.expiration_date)
.await?;
let shares = self
.try_obtain_wallet_shares(uuid, requested_on, request)
.await
.inspect_err(|err| warn!("shares request failure: {err}"))?;
info!("request was successful!");
Ok(TicketbookWalletSharesResponse {
epoch_id,
shares,
master_verification_key,
aggregated_coin_index_signatures,
aggregated_expiration_date_signatures,
})
}
.instrument(span)
.await
}
pub async fn obtain_ticketbook_shares_async(
&self,
uuid: Uuid,
request: TicketbookAsyncRequest,
params: TicketbookObtainParams,
) -> Result<TicketbookWalletSharesAsyncResponse, CredentialProxyError> {
let requested_on = OffsetDateTime::now_utc();
let span = span!(Level::INFO, "[async] obtain ticketboook", uuid = %uuid);
async move {
info!("");
// 1. perform basic validation
self.state.ensure_credentials_issuable().await?;
ensure_sane_expiration_date(request.inner.expiration_date)?;
// 2. store the request to retrieve the id
let pending = self
.state
.storage()
.insert_new_pending_async_shares_request(
uuid,
&request.device_id,
&request.credential_id,
)
.await
.inspect_err(|err| error!("failed to insert new pending async shares: {err}"))?;
let id = pending.id;
// 3. try to spawn a new task attempting to resolve the request
let this = self.clone();
if self
.try_spawn_in_background(async move {
this.try_obtain_blinded_ticketbook_async(
uuid,
requested_on,
request,
params,
pending,
)
.await
})
.is_none()
{
warn!("could not start async ticketbook issuance due to shutdown in progress");
return Err(CredentialProxyError::ShutdownInProgress);
}
// 4. in the meantime, return the id to the user
Ok(TicketbookWalletSharesAsyncResponse { id, uuid })
}
.instrument(span)
.await
}
pub async fn current_deposit(&self) -> Result<DepositResponse, CredentialProxyError> {
let current_deposit = self.state.deposit_amount().await?;
Ok(DepositResponse {
current_deposit_amount: current_deposit.amount,
current_deposit_denom: current_deposit.denom,
})
}
pub async fn partial_verification_keys(
&self,
) -> Result<PartialVerificationKeysResponse, CredentialProxyError> {
self.state.ensure_credentials_issuable().await?;
let epoch_id = self.state.current_epoch_id().await?;
let signers = self.state.ecash_clients(epoch_id).await?;
Ok(PartialVerificationKeysResponse {
epoch_id,
keys: signers
.iter()
.map(|signer| PartialVerificationKey {
node_index: signer.node_id,
bs58_encoded_key: signer.verification_key.to_bs58(),
})
.collect(),
})
}
pub async fn master_verification_key(
&self,
) -> Result<MasterVerificationKeyResponse, CredentialProxyError> {
self.state.ensure_credentials_issuable().await?;
let epoch_id = self.state.current_epoch_id().await?;
let key = self.state.master_verification_key(Some(epoch_id)).await?;
Ok(MasterVerificationKeyResponse {
epoch_id,
bs58_encoded_key: key.to_bs58(),
})
}
pub async fn current_epoch(&self) -> Result<CurrentEpochResponse, CredentialProxyError> {
self.state.ensure_credentials_issuable().await?;
let epoch_id = self.state.current_epoch_id().await?;
Ok(CurrentEpochResponse { epoch_id })
}
}
@@ -0,0 +1,343 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::error::CredentialProxyError;
use crate::storage::models::BlindedShares;
use crate::ticketbook_manager::TicketbookManager;
use futures::{stream, StreamExt};
use nym_compact_ecash::Base58;
use nym_credential_proxy_requests::api::v1::ticketbook::models::{
TicketbookAsyncRequest, TicketbookObtainParams, TicketbookRequest,
TicketbookWalletSharesResponse, WalletShare, WebhookTicketbookWalletShares,
WebhookTicketbookWalletSharesRequest,
};
use nym_validator_client::ecash::BlindSignRequestBody;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use time::OffsetDateTime;
use tokio::sync::Mutex;
use tokio::time::timeout;
use tracing::{debug, error, info, instrument};
use uuid::Uuid;
impl TicketbookManager {
#[instrument(
skip(self, request_data, request, requested_on),
fields(
expiration_date = %request_data.expiration_date,
ticketbook_type = %request_data.ticketbook_type
)
)]
pub async fn try_obtain_wallet_shares(
&self,
request: Uuid,
requested_on: OffsetDateTime,
request_data: TicketbookRequest,
) -> Result<Vec<WalletShare>, CredentialProxyError> {
// don't proceed if we don't have quorum available as the request will definitely fail
if !self.state.ecash_state().quorum_state.available() {
return Err(CredentialProxyError::UnavailableSigningQuorum);
}
let epoch = self.state.current_epoch_id().await?;
let threshold = self.state.ecash_threshold(epoch).await?;
let expiration_date = request_data.expiration_date;
// before we commit to making the deposit, ensure we have required signatures cached and stored
self.ensure_global_data_cached(epoch, expiration_date)
.await?;
let ecash_api_clients = self.state.ecash_clients(epoch).await?.clone();
let deposit_data = self
.state
.get_deposit(request, requested_on, request_data.ecash_pubkey)
.await?;
let deposit_id = deposit_data.deposit_id;
let signature = deposit_data.sign_ticketbook_plaintext(&request_data.withdrawal_request);
let credential_request = BlindSignRequestBody::new(
request_data.withdrawal_request.into(),
deposit_id,
signature,
request_data.ecash_pubkey,
request_data.expiration_date,
request_data.ticketbook_type,
);
let wallet_shares = Arc::new(Mutex::new(HashMap::new()));
info!("attempting to contract all nym-apis for the partial wallets...");
stream::iter(ecash_api_clients)
.for_each_concurrent(None, |client| async {
// move the client into the block
let client = client;
debug!("contacting {client} for blinded partial wallet");
let res = timeout(
Duration::from_secs(5),
client.api_client.blind_sign(&credential_request),
)
.await
.map_err(|_| CredentialProxyError::EcashApiRequestTimeout {
client_repr: client.to_string(),
})
.and_then(|res| res.map_err(Into::into));
// 1. try to store it
if let Err(err) = self
.state
.storage()
.insert_partial_wallet_share(
deposit_id,
epoch,
expiration_date,
client.node_id,
&res,
)
.await
{
error!("failed to persist issued partial share: {err}")
}
// 2. add it to the map
match res {
Ok(share) => {
wallet_shares
.lock()
.await
.insert(client.node_id, share.blinded_signature);
}
Err(err) => {
error!("failed to obtain partial blinded wallet share from {client}: {err}")
}
}
})
.await;
// SAFETY: the futures have completed, so we MUST have the only arc reference
#[allow(clippy::unwrap_used)]
let wallet_shares = Arc::into_inner(wallet_shares).unwrap().into_inner();
let shares = wallet_shares.len();
if shares < threshold as usize {
let err = CredentialProxyError::InsufficientNumberOfCredentials {
available: shares,
threshold,
};
self.state
.insert_deposit_usage_error(deposit_id, err.to_string())
.await;
return Err(err);
}
Ok(wallet_shares
.into_iter()
.map(|(node_index, share)| WalletShare {
node_index,
bs58_encoded_share: share.to_bs58(),
})
.collect())
}
pub async fn try_obtain_wallet_shares_async(
&self,
request: Uuid,
requested_on: OffsetDateTime,
request_data: TicketbookRequest,
device_id: &str,
credential_id: &str,
) -> Result<Vec<WalletShare>, CredentialProxyError> {
let shares = match self
.try_obtain_wallet_shares(request, requested_on, request_data)
.await
{
Ok(shares) => shares,
Err(err) => {
let obtained = match err {
CredentialProxyError::InsufficientNumberOfCredentials { available, .. } => {
available
}
_ => 0,
};
// currently there's no retry mechanisms, but, who knows, that might change
if let Err(err) = self
.state
.storage()
.update_pending_async_blinded_shares_error(
obtained,
device_id,
credential_id,
&err.to_string(),
)
.await
{
error!("failed to update database with the error information: {err}")
}
return Err(err);
}
};
Ok(shares)
}
async fn try_obtain_blinded_ticketbook_async_inner(
&self,
request: Uuid,
requested_on: OffsetDateTime,
request_data: TicketbookAsyncRequest,
params: TicketbookObtainParams,
pending: &BlindedShares,
) -> Result<(), CredentialProxyError> {
let epoch_id = self.state.current_epoch_id().await?;
let device_id = &request_data.device_id;
let credential_id = &request_data.credential_id;
let secret = request_data.secret.clone();
// 1. try to obtain global data
let (
master_verification_key,
aggregated_expiration_date_signatures,
aggregated_coin_index_signatures,
) = self
.state
.global_data(params.global, epoch_id, request_data.inner.expiration_date)
.await?;
// 2. try to obtain shares (failures are written to the DB)
let shares = self
.try_obtain_wallet_shares_async(
request,
requested_on,
request_data.inner,
device_id,
credential_id,
)
.await?;
// 3. update the storage, if possible
// (as long as we can trigger webhook, we should still be good)
if let Err(err) = self
.state
.storage()
.update_pending_async_blinded_shares_issued(shares.len(), device_id, credential_id)
.await
{
error!(uuid = %request, "failed to update db with issued information: {err}")
}
// 4. build the webhook request body
let data = Some(TicketbookWalletSharesResponse {
epoch_id,
shares,
master_verification_key,
aggregated_coin_index_signatures,
aggregated_expiration_date_signatures,
});
let ticketbook_wallet_shares = WebhookTicketbookWalletShares {
id: pending.id,
status: pending.status.to_string(),
device_id: device_id.clone(),
credential_id: credential_id.clone(),
data,
error_message: None,
created: pending.created,
updated: pending.updated,
};
let webhook_request = WebhookTicketbookWalletSharesRequest {
ticketbook_wallet_shares,
secret,
};
// 5. call the webhook
self.webhook.try_trigger(request, &webhook_request).await;
Ok(())
}
async fn try_trigger_webhook_request_for_error(
&self,
request: Uuid,
request_data: TicketbookAsyncRequest,
pending: &BlindedShares,
error_message: String,
) -> Result<(), CredentialProxyError> {
let device_id = &request_data.device_id;
let credential_id = &request_data.credential_id;
let secret = request_data.secret.clone();
let ticketbook_wallet_shares = WebhookTicketbookWalletShares {
id: pending.id,
status: "error".to_string(),
device_id: device_id.clone(),
credential_id: credential_id.clone(),
data: None,
error_message: Some(error_message),
created: pending.created,
updated: pending.updated,
};
let webhook_request = WebhookTicketbookWalletSharesRequest {
ticketbook_wallet_shares,
secret,
};
self.webhook.try_trigger(request, &webhook_request).await;
Ok(())
}
#[instrument(
skip_all,
fields(
credential_id = %request_data.credential_id,
device_id = %request_data.device_id)
)
]
#[allow(clippy::too_many_arguments)]
pub(crate) async fn try_obtain_blinded_ticketbook_async(
&self,
request: Uuid,
requested_on: OffsetDateTime,
request_data: TicketbookAsyncRequest,
params: TicketbookObtainParams,
pending: BlindedShares,
) {
let skip_webhook = params.skip_webhook;
if let Err(err) = self
.try_obtain_blinded_ticketbook_async_inner(
request,
requested_on,
request_data.clone(),
params,
&pending,
)
.await
{
if skip_webhook {
info!(uuid = %request,"the webhook is not going to be called for this request");
return;
}
// post to the webhook to notify of errors on this side
if let Err(webhook_err) = self
.try_trigger_webhook_request_for_error(
request,
request_data,
&pending,
format!("Failed to get ticketbook: {err}"),
)
.await
{
error!(uuid = %request, "failed to make webhook request to report error: {webhook_err}")
}
error!(uuid = %request, "failed to resolve the blinded ticketbook issuance: {err}")
} else {
info!(uuid = %request, "managed to resolve the blinded ticketbook issuance")
}
}
}
@@ -1,57 +1,34 @@
// Copyright 2024 Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::error::VpnApiError;
use clap::Args;
use reqwest::header::AUTHORIZATION;
use serde::Serialize;
use tracing::{debug, error, instrument, span, Instrument, Level};
use url::Url;
use uuid::Uuid;
#[derive(Args, Debug, Clone)]
pub struct ZkNymWebHookConfig {
#[clap(long, env = "WEBHOOK_ZK_NYMS_URL")]
pub webhook_url: Url,
#[derive(Debug, Clone)]
pub struct ZkNymWebhook {
pub webhook_client_url: Url,
#[clap(long, env = "WEBHOOK_ZK_NYMS_CLIENT_ID")]
pub webhook_client_id: String,
#[clap(long, env = "WEBHOOK_ZK_NYMS_CLIENT_SECRET")]
pub webhook_client_secret: String,
}
impl ZkNymWebHookConfig {
pub fn ensure_valid_client_url(&self) -> Result<(), VpnApiError> {
self.client_url()
.map_err(|_| VpnApiError::InvalidWebhookUrl)
.map(|_| ())
}
fn client_url(&self) -> Result<Url, url::ParseError> {
self.webhook_url.join(&self.webhook_client_id)
}
fn unchecked_client_url(&self) -> Url {
// we ensured we have valid url on startup
#[allow(clippy::unwrap_used)]
self.client_url().unwrap()
}
impl ZkNymWebhook {
fn bearer_token(&self) -> String {
format!("Bearer {}", self.webhook_client_secret)
}
#[instrument(skip_all)]
pub async fn try_trigger<T: Serialize + ?Sized>(&self, original_uuid: Uuid, payload: &T) {
let url = self.unchecked_client_url();
let url = self.webhook_client_url.clone();
let span = span!(Level::DEBUG, "webhook", uuid = %original_uuid, url = %url);
async move {
debug!("🕸️ about to trigger the webhook");
match reqwest::Client::new()
.post(url.clone())
.post(url)
.header(AUTHORIZATION, self.bearer_token())
.json(payload)
.send()
+2
View File
@@ -3,6 +3,7 @@ name = "nym-credential-storage"
version = "0.1.0"
edition = "2021"
license.workspace = true
rust-version.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -33,6 +34,7 @@ features = ["rt-multi-thread", "net", "signal", "fs"]
[build-dependencies]
anyhow = { workspace = true }
sqlx = { workspace = true, features = [
"runtime-tokio-rustls",
"sqlite",
+13 -4
View File
@@ -3,22 +3,29 @@
* SPDX-License-Identifier: Apache-2.0
*/
use anyhow::Context;
use sqlx::{Connection, SqliteConnection};
use std::env;
#[tokio::main]
async fn main() {
let out_dir = env::var("OUT_DIR").unwrap();
async fn main() -> anyhow::Result<()> {
let out_dir = env::var("OUT_DIR")?;
let database_path = format!("{out_dir}/coconut-credential-example.sqlite");
// remove the db file if it already existed from previous build
// in case it was from a different branch
if std::fs::exists(&database_path)? {
std::fs::remove_file(&database_path)?;
}
let mut conn = SqliteConnection::connect(&format!("sqlite://{database_path}?mode=rwc"))
.await
.expect("Failed to create SQLx database connection");
.context("Failed to create SQLx database connection")?;
sqlx::migrate!("./migrations")
.run(&mut conn)
.await
.expect("Failed to perform SQLx migrations");
.context("Failed to perform SQLx migrations")?;
#[cfg(target_family = "unix")]
println!("cargo:rustc-env=DATABASE_URL=sqlite://{}", &database_path);
@@ -27,4 +34,6 @@ async fn main() {
// for some strange reason we need to add a leading `/` to the windows path even though it's
// not a valid windows path... but hey, it works...
println!("cargo:rustc-env=DATABASE_URL=sqlite:///{}", &database_path);
Ok(())
}
@@ -110,14 +110,14 @@ FROM ecash_ticketbook;
-- 6. finally swap out the old tables
-- drop old tables
DROP TABLE expiration_date_signatures;
DROP TABLE pending_issuance;
DROP TABLE ecash_ticketbook;
DROP TABLE expiration_date_signatures;
-- rename new tables
ALTER TABLE expiration_date_signatures_new
RENAME TO expiration_date_signatures;
ALTER TABLE pending_issuance_new
RENAME TO pending_issuance;
ALTER TABLE ecash_ticketbook_new
RENAME TO ecash_ticketbook;
RENAME TO ecash_ticketbook;
ALTER TABLE expiration_date_signatures_new
RENAME TO expiration_date_signatures;
+16
View File
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
use crate::ecash::traits::EcashManager;
use async_trait::async_trait;
use bandwidth_storage_manager::BandwidthStorageManager;
use nym_credentials::ecash::utils::{cred_exp_date, ecash_today, EcashTime};
use nym_credentials_interface::{Bandwidth, ClientTicket, TicketType};
@@ -139,3 +140,18 @@ impl CredentialVerifier {
.await)
}
}
#[async_trait]
pub trait TicketVerifier {
/// Verify that the ticket is valid and cryptographically correct.
/// If the verification succeeds, also increase the bandwidth with the ticket's
/// amount and return the latest available bandwidth
async fn verify(&mut self) -> Result<i64>;
}
#[async_trait]
impl TicketVerifier for CredentialVerifier {
async fn verify(&mut self) -> Result<i64> {
self.verify().await
}
}
@@ -108,7 +108,7 @@ impl IssuanceTicketBook {
signing_request.withdrawal_request.clone(),
self.deposit_id,
request_signature,
signing_request.ecash_pub_key.clone(),
signing_request.ecash_pub_key,
signing_request.expiration_date,
signing_request.ticketbook_type,
)
+3
View File
@@ -11,6 +11,7 @@ repository = { workspace = true }
aes-gcm-siv = { workspace = true, optional = true }
aes = { workspace = true, optional = true }
aead = { workspace = true, optional = true }
base64.workspace = true
bs58 = { workspace = true }
blake3 = { workspace = true, features = ["traits-preview"], optional = true }
ctr = { workspace = true, optional = true }
@@ -18,6 +19,7 @@ digest = { workspace = true, optional = true }
generic-array = { workspace = true, optional = true }
hkdf = { workspace = true, optional = true }
hmac = { workspace = true, optional = true }
jwt-simple = { workspace = true, optional = true }
cipher = { workspace = true, optional = true }
x25519-dalek = { workspace = true, features = ["static_secrets"], optional = true }
ed25519-dalek = { workspace = true, features = ["rand_core"], optional = true }
@@ -39,6 +41,7 @@ rand_chacha = { workspace = true }
[features]
default = []
aead = ["dep:aead", "aead/std", "aes-gcm-siv", "generic-array"]
naive_jwt = ["asymmetric", "jwt-simple"]
serde = ["dep:serde", "serde_bytes", "ed25519-dalek/serde", "x25519-dalek/serde"]
asymmetric = ["x25519-dalek", "ed25519-dalek", "zeroize"]
hashing = ["blake3", "digest", "hkdf", "hmac", "generic-array", "sha2"]
+82 -5
View File
@@ -2,8 +2,9 @@
// SPDX-License-Identifier: Apache-2.0
pub use ed25519_dalek::SignatureError;
use ed25519_dalek::{SecretKey, Signer, SigningKey};
pub use ed25519_dalek::{Verifier, PUBLIC_KEY_LENGTH, SECRET_KEY_LENGTH, SIGNATURE_LENGTH};
use ed25519_dalek::Signer;
use nym_pemstore::traits::{PemStorableKey, PemStorableKeyPair};
use std::fmt::{self, Debug, Display, Formatter};
use std::str::FromStr;
@@ -13,6 +14,9 @@ use zeroize::{Zeroize, ZeroizeOnDrop};
#[cfg(feature = "serde")]
pub mod serde_helpers;
#[cfg(feature = "serde")]
pub use serde_helpers::*;
#[cfg(feature = "sphinx")]
use nym_sphinx_types::{DestinationAddressBytes, DESTINATION_ADDRESS_LENGTH};
@@ -81,8 +85,8 @@ impl KeyPair {
}
}
pub fn from_secret(secret: SecretKey, index: u32) -> Self {
let ed25519_signing_key = SigningKey::from(secret);
pub fn from_secret(secret: ed25519_dalek::SecretKey, index: u32) -> Self {
let ed25519_signing_key = ed25519_dalek::SigningKey::from(secret);
KeyPair {
private_key: PrivateKey(ed25519_signing_key.to_bytes()),
@@ -276,7 +280,7 @@ impl Display for PrivateKey {
impl<'a> From<&'a PrivateKey> for PublicKey {
fn from(pk: &'a PrivateKey) -> Self {
PublicKey(SigningKey::from_bytes(&pk.0).verifying_key())
PublicKey(ed25519_dalek::SigningKey::from_bytes(&pk.0).verifying_key())
}
}
@@ -320,7 +324,7 @@ impl PrivateKey {
}
pub fn sign<M: AsRef<[u8]>>(&self, message: M) -> Signature {
let signing_key: SigningKey = self.0.into();
let signing_key: ed25519_dalek::SigningKey = self.0.into();
let sig = signing_key.sign(message.as_ref());
Signature(sig)
}
@@ -425,9 +429,57 @@ impl<'d> Deserialize<'d> for Signature {
}
}
#[cfg(feature = "naive_jwt")]
impl PublicKey {
pub fn to_jwt_compatible_key(&self) -> jwt_simple::algorithms::Ed25519PublicKey {
(*self).into()
}
}
#[cfg(feature = "naive_jwt")]
impl From<PublicKey> for jwt_simple::algorithms::Ed25519PublicKey {
fn from(value: PublicKey) -> Self {
// SAFETY: we have a valid ed25519 pubkey, we're just changing to a different library wrapper
#[allow(clippy::unwrap_used)]
jwt_simple::algorithms::Ed25519PublicKey::from_bytes(&value.to_bytes()).unwrap()
}
}
#[cfg(feature = "naive_jwt")]
impl PrivateKey {
pub fn to_jwt_compatible_keys(&self) -> jwt_simple::algorithms::Ed25519KeyPair {
let pub_key = self.public_key();
let mut bytes = zeroize::Zeroizing::new([0u8; 64]);
bytes[..SECRET_KEY_LENGTH]
.copy_from_slice(zeroize::Zeroizing::new(self.to_bytes()).as_ref());
bytes[SECRET_KEY_LENGTH..].copy_from_slice(&pub_key.to_bytes());
// SAFETY: we have a valid ed25519 keys, we're just changing to a different library wrapper
#[allow(clippy::unwrap_used)]
jwt_simple::algorithms::Ed25519KeyPair::from_bytes(bytes.as_ref()).unwrap()
}
}
#[cfg(feature = "naive_jwt")]
impl KeyPair {
pub fn to_jwt_compatible_keys(&self) -> jwt_simple::algorithms::Ed25519KeyPair {
let mut bytes = zeroize::Zeroizing::new([0u8; 64]);
bytes[..SECRET_KEY_LENGTH]
.copy_from_slice(zeroize::Zeroizing::new(self.private_key.to_bytes()).as_ref());
bytes[SECRET_KEY_LENGTH..].copy_from_slice(&self.public_key.to_bytes());
// SAFETY: we have a valid ed25519 keys, we're just changing to a different library wrapper
#[allow(clippy::unwrap_used)]
jwt_simple::algorithms::Ed25519KeyPair::from_bytes(bytes.as_ref()).unwrap()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand::thread_rng;
fn assert_zeroize_on_drop<T: ZeroizeOnDrop>() {}
@@ -438,4 +490,29 @@ mod tests {
assert_zeroize::<PrivateKey>();
assert_zeroize_on_drop::<PrivateKey>();
}
#[test]
#[cfg(all(feature = "naive_jwt", feature = "rand"))]
fn check_jwt_key_compat_conversion() {
let mut rng = thread_rng();
let keys = KeyPair::new(&mut rng);
let jwt_keys = keys.to_jwt_compatible_keys();
// internally they're represented by hidden `Edwards25519KeyPair` (plus key_id)
// which has way nicer API for assertions
let jwt_keys_inner =
jwt_simple::algorithms::Edwards25519KeyPair::from_bytes(&jwt_keys.to_bytes()).unwrap();
let compact_ed25519 = jwt_keys_inner.as_ref();
assert!(compact_ed25519
.sk
.validate_public_key(&compact_ed25519.pk)
.is_ok());
let dummy_message = "hello world";
let sig1 = keys.private_key.sign(dummy_message).to_bytes();
let sig2 = compact_ed25519.sk.sign(dummy_message, None).to_vec();
assert_eq!(sig1.to_vec(), sig2);
}
}
@@ -1,6 +1,7 @@
// Copyright 2021-2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use base64::Engine;
use nym_pemstore::traits::{PemStorableKey, PemStorableKeyPair};
use std::fmt::{self, Debug, Display, Formatter};
use std::str::FromStr;
@@ -158,6 +159,15 @@ impl PublicKey {
.map_err(|source| KeyRecoveryError::MalformedPublicKeyString { source })?;
Self::from_bytes(&bytes)
}
pub fn from_base64(s: &str) -> Option<Self> {
let bytes = base64::engine::general_purpose::STANDARD.decode(s).ok()?;
Self::from_bytes(&bytes).ok()
}
pub fn to_base64(&self) -> String {
base64::engine::general_purpose::STANDARD.encode(self.as_bytes())
}
}
impl FromStr for PublicKey {
@@ -157,6 +157,14 @@ impl<LS, TS, LC, TC> SignerResult<LS, TS, LC, TC> {
pub fn malformed_details(&self) -> bool {
self.information.parse().is_err()
}
pub fn try_get_test_result(&self) -> Option<&SignerTestResult<LS, TS, LC, TC>> {
if let SignerStatus::Tested { result } = &self.status {
Some(result)
} else {
None
}
}
}
impl<LS, TS, LC, TC> SignerResult<LS, TS, LC, TC>
+37 -4
View File
@@ -15,6 +15,9 @@ use nym_validator_client::ecash::models::EcashSignerStatusResponse;
use nym_validator_client::models::{
ChainBlocksStatusResponse, ChainStatusResponse, SignerInformationResponse,
};
use nym_validator_client::nyxd::contract_traits::dkg_query_client::{
ContractVKShare, DealerDetails, Epoch,
};
mod client_check;
pub mod error;
@@ -48,7 +51,22 @@ pub async fn check_signers(
check_signers_with_client(&client).await
}
pub struct DkgDetails {
pub dkg_epoch: Epoch,
pub threshold: Option<u64>,
pub network_dealers: Vec<DealerDetails>,
pub submitted_shared: HashMap<u64, ContractVKShare>,
}
pub async fn check_signers_with_client<C>(client: &C) -> Result<SignersTestResult, SignerCheckError>
where
C: DkgQueryClient + Sync,
{
let dkg_details = dkg_details_with_client(client).await?;
check_known_dealers(dkg_details).await
}
pub async fn dkg_details_with_client<C>(client: &C) -> Result<DkgDetails, SignerCheckError>
where
C: DkgQueryClient + Sync,
{
@@ -79,16 +97,31 @@ where
.map(|share| (share.node_index, share))
.collect();
Ok(DkgDetails {
dkg_epoch,
threshold,
network_dealers: dealers,
submitted_shared: shares,
})
}
pub async fn check_known_dealers(
dkg_details: DkgDetails,
) -> Result<SignersTestResult, SignerCheckError> {
// 6. for each dealer attempt to perform the checks
let results = dealers
let results = dkg_details
.network_dealers
.into_iter()
.map(|d| {
let share = shares.get(&d.assigned_index);
check_client(d, dkg_epoch.epoch_id, share)
let share = dkg_details.submitted_shared.get(&d.assigned_index);
check_client(d, dkg_details.dkg_epoch.epoch_id, share)
})
.collect::<FuturesUnordered<_>>()
.collect::<Vec<_>>()
.await;
Ok(SignersTestResult { threshold, results })
Ok(SignersTestResult {
threshold: dkg_details.threshold,
results,
})
}
-12
View File
@@ -1,12 +0,0 @@
[package]
name = "nym-execute"
version = "0.1.0"
edition = "2021"
license.workspace = true
[lib]
proc-macro = true
[dependencies]
syn = { workspace = true, features = ["full"] }
quote = { workspace = true }
-110
View File
@@ -1,110 +0,0 @@
use proc_macro::TokenStream;
use quote::quote;
use syn::{
parse_macro_input, Block, ExprMethodCall, FnArg, Ident, ItemFn, LitStr, ReturnType, Token,
VisPublic, Visibility,
};
#[proc_macro_attribute]
pub fn execute(attr: TokenStream, item: TokenStream) -> TokenStream {
let f = parse_macro_input!(item as ItemFn);
let target = parse_macro_input!(attr as LitStr).value();
let cl = if target == "mixnet" {
quote! {self.mixnet_contract_address()}
} else if target == "vesting" {
quote! {self.vesting_contract_address()}
} else {
panic!("Only `mixnet` and `vesting` targets are supported!")
};
let cl = proc_macro::TokenStream::from(cl);
let cl = parse_macro_input!(cl as ExprMethodCall);
let orig_f = f.clone();
let mut execute_f = f.clone();
let mut simulate_f = f.clone();
let name = f.sig.ident;
let name_str = name.to_string();
let call_args = f.sig.inputs.into_iter().filter_map(|arg| match arg {
FnArg::Receiver(_) => None,
FnArg::Typed(arg) => Some(arg.pat),
});
let execute_args = call_args.clone();
let simulate_args = call_args;
execute_f.sig.asyncness = Some(Token![async](execute_f.sig.ident.span()));
simulate_f.sig.asyncness = Some(Token![async](simulate_f.sig.ident.span()));
execute_f.vis = Visibility::Public(VisPublic {
pub_token: Token![pub](execute_f.sig.ident.span()),
});
simulate_f.vis = Visibility::Public(VisPublic {
pub_token: Token![pub](simulate_f.sig.ident.span()),
});
execute_f.sig.ident = Ident::new(
&format!("execute{}", execute_f.sig.ident),
execute_f.sig.ident.span(),
);
simulate_f.sig.ident = Ident::new(
&format!("simulate{}", simulate_f.sig.ident),
simulate_f.sig.ident.span(),
);
let execute_output = quote! {
-> Result<ExecuteResult, NyxdError>
};
let o_ts = proc_macro::TokenStream::from(execute_output);
execute_f.sig.output = parse_macro_input!(o_ts as ReturnType);
let simulate_output = quote! {
-> Result<SimulateResponse, NyxdError>
};
let o_ts = proc_macro::TokenStream::from(simulate_output);
simulate_f.sig.output = parse_macro_input!(o_ts as ReturnType);
let simulate_block = quote! {
{
let (msg, _fee) = self.#name(#(#simulate_args),*);
let msg = self.wrap_contract_execute_message(
#cl,
&msg,
vec![],
)?;
self.simulate(vec![msg]).await
}
};
let ts = proc_macro::TokenStream::from(simulate_block);
simulate_f.block = Box::new(parse_macro_input!(ts as Block));
let execute_block = quote! {
{
let (req, fee) = self.#name(#(#execute_args),*);
let fee = fee.unwrap_or(Fee::Auto(Some(self.simulated_gas_multiplier)));
self.client
.execute(
self.address(),
#cl,
&req,
fee,
#name_str,
vec![],
)
.await
}
};
let ts = proc_macro::TokenStream::from(execute_block);
execute_f.block = Box::new(parse_macro_input!(ts as Block));
let out = quote! {
#orig_f
#execute_f
#simulate_f
};
out.into()
}
+1 -1
View File
@@ -89,7 +89,7 @@ mod tests {
.unwrap();
let blind_sig = issue(
keypair.secret_key(),
sig_req.ecash_pub_key.clone(),
sig_req.ecash_pub_key,
&sig_req.withdrawal_request,
expiration_date.ecash_unix_timestamp(),
issuance.ticketbook_type().encode(),
+2
View File
@@ -7,6 +7,7 @@ homepage.workspace = true
documentation.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[dependencies]
sqlx = { workspace = true, features = [
@@ -27,6 +28,7 @@ nym-statistics-common = { path = "../statistics" }
[build-dependencies]
anyhow = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
sqlx = { workspace = true, features = [
"runtime-tokio-rustls",
+13 -4
View File
@@ -1,22 +1,29 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use anyhow::Context;
use sqlx::{Connection, SqliteConnection};
use std::env;
#[tokio::main]
async fn main() {
let out_dir = env::var("OUT_DIR").unwrap();
async fn main() -> anyhow::Result<()> {
let out_dir = env::var("OUT_DIR")?;
let database_path = format!("{out_dir}/gateway-stats-example.sqlite");
// remove the db file if it already existed from previous build
// in case it was from a different branch
if std::fs::exists(&database_path)? {
std::fs::remove_file(&database_path)?;
}
let mut conn = SqliteConnection::connect(&format!("sqlite://{database_path}?mode=rwc"))
.await
.expect("Failed to create SQLx database connection");
.context("Failed to create SQLx database connection")?;
sqlx::migrate!("./migrations")
.run(&mut conn)
.await
.expect("Failed to perform SQLx migrations");
.context("Failed to perform SQLx migrations")?;
#[cfg(target_family = "unix")]
println!("cargo:rustc-env=DATABASE_URL=sqlite://{}", &database_path);
@@ -25,4 +32,6 @@ async fn main() {
// for some strange reason we need to add a leading `/` to the windows path even though it's
// not a valid windows path... but hey, it works...
println!("cargo:rustc-env=DATABASE_URL=sqlite:///{}", &database_path);
Ok(())
}
+2
View File
@@ -7,6 +7,7 @@ homepage.workspace = true
documentation.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[dependencies]
async-trait = { workspace = true }
@@ -31,6 +32,7 @@ nym-gateway-requests = { path = "../gateway-requests" }
nym-sphinx = { path = "../nymsphinx" }
[build-dependencies]
anyhow = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
sqlx = { workspace = true, features = [
"runtime-tokio-rustls",
+13 -4
View File
@@ -1,22 +1,29 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use anyhow::Context;
use sqlx::{Connection, SqliteConnection};
use std::env;
#[tokio::main]
async fn main() {
let out_dir = env::var("OUT_DIR").unwrap();
async fn main() -> anyhow::Result<()> {
let out_dir = env::var("OUT_DIR")?;
let database_path = format!("{out_dir}/gateway-example.sqlite");
// remove the db file if it already existed from previous build
// in case it was from a different branch
if std::fs::exists(&database_path)? {
std::fs::remove_file(&database_path)?;
}
let mut conn = SqliteConnection::connect(&format!("sqlite://{database_path}?mode=rwc"))
.await
.expect("Failed to create SQLx database connection");
.context("Failed to create SQLx database connection")?;
sqlx::migrate!("./migrations")
.run(&mut conn)
.await
.expect("Failed to perform SQLx migrations");
.context("Failed to perform SQLx migrations")?;
#[cfg(target_family = "unix")]
println!("cargo:rustc-env=DATABASE_URL=sqlite://{}", &database_path);
@@ -25,4 +32,6 @@ async fn main() {
// for some strange reason we need to add a leading `/` to the windows path even though it's
// not a valid windows path... but hey, it works...
println!("cargo:rustc-env=DATABASE_URL=sqlite:///{}", &database_path);
Ok(())
}
+1
View File
@@ -136,6 +136,7 @@
//! ```
#![warn(missing_docs)]
pub use reqwest::ClientBuilder as ReqwestClientBuilder;
pub use reqwest::StatusCode;
use crate::path::RequestPath;
+2 -1
View File
@@ -47,7 +47,8 @@ pub mod nyx {
pub mod wireguard {
use std::net::{Ipv4Addr, Ipv6Addr};
pub const WG_PORT: u16 = 51822;
pub const WG_TUNNEL_PORT: u16 = 51822;
pub const WG_METADATA_PORT: u16 = 51830;
// The interface used to route traffic
pub const WG_TUN_BASE_NAME: &str = "nymwg";
@@ -319,9 +319,9 @@ mod tests {
let sk = grp.random_scalar();
let sk_user = SecretKeyUser { sk };
let pk_user = sk_user.public_key();
public_keys.push(pk_user.clone());
public_keys.push(pk_user);
}
public_keys.push(user_keypair.public_key().clone());
public_keys.push(user_keypair.public_key());
let (req, req_info) =
withdrawal_request(user_keypair.secret_key(), expiration_date, t_type).unwrap();
@@ -462,9 +462,9 @@ mod tests {
let sk = grp.random_scalar();
let sk_user = SecretKeyUser { sk };
let pk_user = sk_user.public_key();
public_keys.push(pk_user.clone());
public_keys.push(pk_user);
}
public_keys.push(user_keypair.public_key().clone());
public_keys.push(user_keypair.public_key());
let (req, req_info) =
withdrawal_request(user_keypair.secret_key(), expiration_date, t_type).unwrap();
@@ -401,7 +401,7 @@ impl Bytable for SecretKeyUser {
impl Base58 for SecretKeyUser {}
#[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)]
#[derive(Debug, Eq, PartialEq, Clone, Copy, Serialize, Deserialize)]
pub struct PublicKeyUser {
pub(crate) pk: G1Projective,
}
@@ -554,7 +554,7 @@ impl KeyPairUser {
}
pub fn public_key(&self) -> PublicKeyUser {
self.public_key.clone()
self.public_key
}
pub fn to_bytes(&self) -> Vec<u8> {
+3
View File
@@ -16,3 +16,6 @@ thiserror = { workspace = true }
[dev-dependencies]
rand = { workspace = true }
nym-crypto = { path = "../../crypto", features = ["rand"] }
bincode = { workspace = true }
serde_json = { workspace = true }
serde = { workspace = true, features = ["derive"] }
+86 -2
View File
@@ -7,7 +7,7 @@
use crate::nodes::{NodeIdentity, NODE_IDENTITY_SIZE};
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_sphinx_types::Destination;
use serde::de::{Error as SerdeError, Unexpected, Visitor};
use serde::de::{Error as SerdeError, SeqAccess, Unexpected, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt::{self, Formatter};
use std::str::FromStr;
@@ -64,7 +64,7 @@ impl<'de> Deserialize<'de> for Recipient {
{
struct RecipientVisitor;
impl Visitor<'_> for RecipientVisitor {
impl<'de> Visitor<'de> for RecipientVisitor {
type Value = Recipient;
fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
@@ -90,6 +90,42 @@ impl<'de> Deserialize<'de> for Recipient {
)
})
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
// if we know the size hint, check if it matches expectation,
// otherwise return an error
if let Some(size_hint) = seq.size_hint() {
if size_hint != Recipient::LEN {
return Err(SerdeError::invalid_length(size_hint, &self));
}
}
let mut recipient_bytes = [0u8; Recipient::LEN];
// clippy's suggestion is completely wrong and it iterates wrong sequence
#[allow(clippy::needless_range_loop)]
for i in 0..Recipient::LEN {
let Some(elem) = seq.next_element::<u8>()? else {
return Err(SerdeError::invalid_length(i + 1, &self));
};
recipient_bytes[i] = elem;
}
// make sure there are no trailing bytes
if seq.next_element::<u8>()?.is_some() {
return Err(SerdeError::invalid_length(Recipient::LEN + 1, &self));
}
Recipient::try_from_bytes(recipient_bytes).map_err(|_| {
SerdeError::invalid_value(
Unexpected::Other("At least one of the curve points was malformed"),
&self,
)
})
}
}
deserializer.deserialize_bytes(RecipientVisitor)
@@ -245,6 +281,18 @@ impl FromStr for Recipient {
mod tests {
use super::*;
fn mock_recipient() -> Recipient {
Recipient::try_from_bytes([
67, 5, 132, 146, 3, 236, 116, 89, 254, 57, 131, 159, 69, 181, 55, 208, 12, 108, 136,
83, 58, 76, 171, 195, 31, 98, 92, 64, 68, 53, 156, 184, 100, 189, 73, 3, 238, 103, 156,
108, 124, 199, 42, 79, 172, 98, 81, 177, 182, 100, 167, 164, 74, 183, 199, 213, 162,
173, 102, 112, 30, 159, 148, 66, 44, 75, 230, 182, 138, 114, 170, 163, 209, 82, 204,
100, 118, 91, 57, 150, 212, 147, 151, 135, 148, 16, 213, 223, 182, 164, 242, 37, 40,
73, 137, 228,
])
.unwrap()
}
#[test]
fn string_conversion_works() {
let mut rng = rand::thread_rng();
@@ -308,4 +356,40 @@ mod tests {
recovered_recipient.gateway.to_bytes()
);
}
// calls `visit_bytes`
#[test]
fn bincode_serialisation_works() {
let recipient = mock_recipient();
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)]
struct MyStruct {
recipient: Recipient,
}
let a = MyStruct { recipient };
let s = bincode::serialize(&a).unwrap();
let b = bincode::deserialize(&s).unwrap();
assert_eq!(a, b);
}
// calls `visit_seq`
#[test]
fn json_serialisation_works() {
use serde::{Deserialize, Serialize};
let recipient = mock_recipient();
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)]
struct MyStruct {
recipient: Recipient,
}
let a = MyStruct { recipient };
let s = serde_json::to_string(&a).unwrap();
let b = serde_json::from_str(&s).unwrap();
assert_eq!(a, b);
}
}
+2
View File
@@ -180,6 +180,7 @@ impl NymPacket {
}
#[cfg(feature = "sphinx")]
#[allow(unreachable_patterns)]
pub fn sphinx_packet_ref(&self) -> Option<&SphinxPacket> {
match self {
NymPacket::Sphinx(packet) => Some(packet),
@@ -188,6 +189,7 @@ impl NymPacket {
}
#[cfg(feature = "sphinx")]
#[allow(unreachable_patterns)]
pub fn to_sphinx_packet(self) -> Option<SphinxPacket> {
match self {
NymPacket::Sphinx(packet) => Some(packet),
+2
View File
@@ -7,6 +7,7 @@ homepage.workspace = true
documentation.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -36,5 +37,6 @@ url.workspace = true
[build-dependencies]
anyhow = { workspace = true }
sqlx = { workspace = true, features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
+14 -4
View File
@@ -1,22 +1,30 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use anyhow::Context;
#[tokio::main]
async fn main() {
async fn main() -> anyhow::Result<()> {
use sqlx::{Connection, SqliteConnection};
use std::env;
let out_dir = env::var("OUT_DIR").unwrap();
let out_dir = env::var("OUT_DIR")?;
let database_path = format!("{out_dir}/scraper-example.sqlite");
// remove the db file if it already existed from previous build
// in case it was from a different branch
if std::fs::exists(&database_path)? {
std::fs::remove_file(&database_path)?;
}
let mut conn = SqliteConnection::connect(&format!("sqlite://{database_path}?mode=rwc"))
.await
.expect("Failed to create SQLx database connection");
.context("Failed to create SQLx database connection")?;
sqlx::migrate!("./sql_migrations")
.run(&mut conn)
.await
.expect("Failed to perform SQLx migrations");
.context("Failed to perform SQLx migrations")?;
#[cfg(target_family = "unix")]
println!("cargo:rustc-env=DATABASE_URL=sqlite://{}", &database_path);
@@ -25,4 +33,6 @@ async fn main() {
// for some strange reason we need to add a leading `/` to the windows path even though it's
// not a valid windows path... but hey, it works...
println!("cargo:rustc-env=DATABASE_URL=sqlite:///{}", &database_path);
Ok(())
}
+12
View File
@@ -110,6 +110,7 @@ impl ShutdownToken {
// exposed method with the old name for easier migration
// it will eventually be removed so please try to use `.clone_with_suffix` instead
#[must_use]
#[deprecated(note = "use .clone_with_suffix instead")]
pub fn fork<S: Into<String>>(&self, child_suffix: S) -> Self {
self.clone_with_suffix(child_suffix)
}
@@ -117,6 +118,7 @@ impl ShutdownToken {
// exposed method with the old name for easier migration
// it will eventually be removed so please try to use `.clone().named(name)` instead
#[must_use]
#[deprecated(note = "use .clone().named(name) instead")]
pub fn fork_named<S: Into<String>>(&self, name: S) -> Self {
self.clone().named(name)
}
@@ -232,6 +234,16 @@ impl ShutdownManager {
manager.with_shutdown(async move { cancel_watcher.cancelled().await })
}
pub fn empty_mock() -> Self {
ShutdownManager {
root_token: ShutdownToken::ephemeral(),
legacy_task_manager: None,
shutdown_signals: Default::default(),
tracker: Default::default(),
max_shutdown_duration: Default::default(),
}
}
pub fn with_legacy_task_manager(mut self) -> Self {
let mut legacy_manager =
TaskManager::default().named(format!("{}-legacy", self.root_token.name()));
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "nym-upgrade-mode-check"
version = "0.1.0"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
documentation.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
readme.workspace = true
[dependencies]
jwt-simple = { workspace = true }
reqwest = { workspace = true, features = ["rustls-tls"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
time = { workspace = true, features = ["serde"] }
thiserror = { workspace = true }
tracing = { workspace = true }
nym-http-api-client = { path = "../http-api-client", default-features = false }
nym-crypto = { path = "../crypto", features = ["asymmetric", "serde", "naive_jwt"] }
[dev-dependencies]
anyhow = { workspace = true }
time = { workspace = true, features = ["macros"] }
[lints]
workspace = true
@@ -0,0 +1,123 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::UpgradeModeCheckError;
use nym_crypto::asymmetric::ed25519;
use nym_http_api_client::generate_user_agent;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use time::OffsetDateTime;
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy)]
pub struct UpgradeModeAttestation {
#[serde(flatten)]
pub content: UpgradeModeAttestationContent,
#[serde(with = "ed25519::bs58_ed25519_signature")]
pub signature: ed25519::Signature,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy)]
#[serde(tag = "type")]
#[serde(rename = "upgrade_mode")]
pub struct UpgradeModeAttestationContent {
#[serde(with = "time::serde::timestamp")]
pub starting_time: OffsetDateTime,
#[serde(with = "ed25519::bs58_ed25519_pubkey")]
pub attester_public_key: ed25519::PublicKey,
}
impl UpgradeModeAttestation {
pub fn verify(&self) -> bool {
self.content
.attester_public_key
.verify(self.content.as_json(), &self.signature)
.is_ok()
}
}
impl UpgradeModeAttestationContent {
pub fn as_json(&self) -> String {
// SAFETY: Serialize impl is valid and we have no non-string map keys
#[allow(clippy::unwrap_used)]
serde_json::to_string(&self).unwrap()
}
}
pub fn generate_new_attestation(key: &ed25519::PrivateKey) -> UpgradeModeAttestation {
generate_new_attestation_with_starting_time(key, OffsetDateTime::now_utc())
}
pub fn generate_new_attestation_with_starting_time(
key: &ed25519::PrivateKey,
starting_time: OffsetDateTime,
) -> UpgradeModeAttestation {
let content = UpgradeModeAttestationContent {
starting_time,
attester_public_key: key.into(),
};
UpgradeModeAttestation {
signature: key.sign(content.as_json()),
content,
}
}
pub async fn attempt_retrieve(
url: &str,
) -> Result<Option<UpgradeModeAttestation>, UpgradeModeCheckError> {
let retrieval_failure = |source| UpgradeModeCheckError::AttestationRetrievalFailure {
url: url.to_string(),
source,
};
let attestation = reqwest::ClientBuilder::new()
.user_agent(generate_user_agent!())
.timeout(Duration::from_secs(5))
.build()
.map_err(retrieval_failure)?
.get(url)
.send()
.await
.map_err(retrieval_failure)?
.json::<Option<UpgradeModeAttestation>>()
.await
.map_err(retrieval_failure)?;
Ok(attestation)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn upgrade_mode_attestation_serde_json() -> anyhow::Result<()> {
// unix timestamp: 1629720000
let starting_time = time::macros::datetime!(2021-08-23 12:00 UTC);
let key = ed25519::PrivateKey::from_bytes(&[
108, 49, 193, 21, 126, 161, 249, 85, 242, 207, 74, 195, 238, 6, 64, 149, 201, 140, 248,
163, 122, 170, 79, 198, 87, 85, 36, 29, 243, 92, 64, 161,
])?;
let attestation = generate_new_attestation_with_starting_time(&key, starting_time);
let attestation_json = serde_json::to_string(&attestation)?;
let attestation_content_json = attestation.content.as_json();
let expected_attestation = r#"{"type":"upgrade_mode","starting_time":1629720000,"attester_public_key":"3pkFcBXCEmbmXBT2G8CkFMuKisJcH54mbBGvncHaDibt","signature":"5rWUr2ypaDTtrMKegMP3tQkkZGFAuhNTnEVCVe5Azv6QqvLzoGdQiMkFmeyhDd1XSfoXpL9fFM58rsdA1kf4GYMM"}"#;
let expected_content = r#"{"type":"upgrade_mode","starting_time":1629720000,"attester_public_key":"3pkFcBXCEmbmXBT2G8CkFMuKisJcH54mbBGvncHaDibt"}"#;
assert_eq!(attestation_content_json, expected_content);
assert_eq!(attestation_json, expected_attestation);
let recovered_attestation = serde_json::from_str(&attestation_json)?;
assert_eq!(attestation, recovered_attestation);
let recovered_content = serde_json::from_str(&attestation_content_json)?;
assert_eq!(attestation.content, recovered_content);
Ok(())
}
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use nym_crypto::asymmetric::ed25519::Ed25519RecoveryError;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum UpgradeModeCheckError {
#[error("failed to decode jwt metadata")]
TokenMetadataDecodeFailure { source: jwt_simple::Error },
#[error("the jwt metadata didn't contain explicit public key")]
MissingTokenPublicKey,
#[error("the attached public key was not valid ed25519 public key")]
MalformedEd25519PublicKey { source: Ed25519RecoveryError },
#[error("failed to verify the jwt: {source}")]
JwtVerificationFailure { source: jwt_simple::Error },
#[error("failed to retrieve attestation from {url}:{source}")]
AttestationRetrievalFailure { url: String, source: reqwest::Error },
}
+119
View File
@@ -0,0 +1,119 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::{UpgradeModeAttestation, UpgradeModeCheckError};
use jwt_simple::claims::Claims;
use jwt_simple::common::{KeyMetadata, VerificationOptions};
use jwt_simple::prelude::{EdDSAKeyPairLike, EdDSAPublicKeyLike};
use jwt_simple::token::Token;
use nym_crypto::asymmetric::ed25519;
use std::collections::HashSet;
use std::time::Duration;
// for now use static issuer such as "nym-credential-proxy"
pub fn generate_jwt_for_upgrade_mode_attestation(
attestation: UpgradeModeAttestation,
validity: Duration,
keys: &ed25519::KeyPair,
issuer: Option<&'static str>,
) -> String {
let claim = Claims::with_custom_claims(attestation, validity.into());
let mut claim = if let Some(issuer) = issuer {
claim.with_issuer(issuer)
} else {
claim
};
claim.create_nonce();
let md = KeyMetadata::default().with_public_key(keys.public_key().to_base58_string());
let mut jwt_keys = keys.to_jwt_compatible_keys();
// SAFETY: trait impl for EdDSA is infallible
#[allow(clippy::unwrap_used)]
jwt_keys.attach_metadata(md).unwrap();
// SAFETY: our construction of the jwt is valid
#[allow(clippy::unwrap_used)]
jwt_keys.sign(claim).unwrap()
}
pub fn validate_upgrade_mode_jwt(
token: &str,
expected_issuer: Option<&'static str>,
) -> Result<UpgradeModeAttestation, UpgradeModeCheckError> {
// for now, we completely ignore the validity of the pubkey (I know, I know).
// that will be changed later on
// so as a bypass we have to extract the claimed issuer from the jwt to verify against it
let metadata = Token::decode_metadata(token)
.map_err(|source| UpgradeModeCheckError::TokenMetadataDecodeFailure { source })?;
let pub_key = metadata
.public_key()
.ok_or(UpgradeModeCheckError::MissingTokenPublicKey)?;
let ed25519_pub_key = ed25519::PublicKey::from_base58_string(pub_key)
.map_err(|source| UpgradeModeCheckError::MalformedEd25519PublicKey { source })?;
let mut opts = VerificationOptions::default();
if let Some(issuer) = expected_issuer {
opts.allowed_issuers = Some(HashSet::from_iter(vec![issuer.to_string()]));
}
let attestation = ed25519_pub_key
.to_jwt_compatible_key()
.verify_token::<UpgradeModeAttestation>(token, Some(opts))
.map_err(|source| UpgradeModeCheckError::JwtVerificationFailure { source })?
.custom;
Ok(attestation)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate_new_attestation;
use nym_crypto::asymmetric::ed25519;
#[test]
fn generate_and_validate_jwt() {
let attestation_key = ed25519::PrivateKey::from_bytes(&[
108, 49, 193, 21, 126, 161, 249, 85, 242, 207, 74, 195, 238, 6, 64, 149, 201, 140, 248,
163, 122, 170, 79, 198, 87, 85, 36, 29, 243, 92, 64, 161,
])
.unwrap();
let jwt_key = ed25519::PrivateKey::from_bytes(&[
152, 17, 144, 255, 213, 219, 246, 208, 109, 33, 100, 73, 1, 141, 32, 63, 141, 89, 167,
2, 52, 215, 241, 219, 200, 18, 159, 241, 76, 111, 42, 32,
])
.unwrap();
let keys = ed25519::KeyPair::from(jwt_key);
let attestation = generate_new_attestation(&attestation_key);
let jwt_issuer = generate_jwt_for_upgrade_mode_attestation(
attestation,
Duration::from_secs(60 * 60),
&keys,
Some("nym-credential-proxy"),
);
// we expect 'nym-credential-proxy' issuer
assert!(validate_upgrade_mode_jwt(&jwt_issuer, Some("nym-credential-proxy")).is_ok());
// we don't care about issuer
assert!(validate_upgrade_mode_jwt(&jwt_issuer, None).is_ok());
// we expect another-issuer
assert!(validate_upgrade_mode_jwt(&jwt_issuer, Some("another-issuer")).is_err());
let jwt_no_issuer = generate_jwt_for_upgrade_mode_attestation(
attestation,
Duration::from_secs(60 * 60),
&keys,
None,
);
// we expect 'nym-credential-proxy' issuer
assert!(validate_upgrade_mode_jwt(&jwt_no_issuer, Some("nym-credential-proxy")).is_err());
// we don't care about issuer
assert!(validate_upgrade_mode_jwt(&jwt_no_issuer, None).is_ok());
}
}
+13
View File
@@ -0,0 +1,13 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
pub(crate) mod attestation;
pub(crate) mod error;
pub(crate) mod jwt;
pub use attestation::{
attempt_retrieve, generate_new_attestation, generate_new_attestation_with_starting_time,
UpgradeModeAttestation,
};
pub use error::UpgradeModeCheckError;
pub use jwt::{generate_jwt_for_upgrade_mode_attestation, validate_upgrade_mode_jwt};
+1 -6
View File
@@ -130,12 +130,7 @@ impl VerlocMeasurement {
let variance_micros = data
.iter()
.map(|&value| {
// make sure we don't underflow
let diff = if mean > value {
mean - value
} else {
value - mean
};
let diff = mean.abs_diff(value);
// we don't need nanos precision
let diff_micros = diff.as_micros();
diff_micros * diff_micros
@@ -0,0 +1,18 @@
[package]
name = "nym-wireguard-private-metadata-client"
version = "1.0.0"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
documentation.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
async-trait = { workspace = true }
tracing = { workspace = true }
nym-http-api-client = { path = "../../http-api-client" }
nym-wireguard-private-metadata-shared = { path = "../shared" }
[lints]
workspace = true
@@ -0,0 +1,58 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use async_trait::async_trait;
use tracing::instrument;
use nym_http_api_client::{ApiClient, Client, HttpClientError, NO_PARAMS};
use nym_wireguard_private_metadata_shared::{
routes, Version, {ErrorResponse, Request, Response},
};
pub type WireguardMetadataApiClientError = HttpClientError<ErrorResponse>;
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
pub trait WireguardMetadataApiClient: ApiClient {
#[instrument(level = "debug", skip(self))]
async fn version(&self) -> Result<Version, WireguardMetadataApiClientError> {
let version: u64 = self
.get_json(
&[routes::V1_API_VERSION, routes::BANDWIDTH, routes::VERSION],
NO_PARAMS,
)
.await?;
Ok(version.into())
}
#[instrument(level = "debug", skip(self))]
async fn available_bandwidth(
&self,
request_body: &Request,
) -> Result<Response, WireguardMetadataApiClientError> {
self.post_json(
&[routes::V1_API_VERSION, routes::BANDWIDTH, routes::AVAILABLE],
NO_PARAMS,
request_body,
)
.await
}
#[instrument(level = "debug", skip(self, request_body))]
async fn topup_bandwidth(
&self,
request_body: &Request,
) -> Result<Response, WireguardMetadataApiClientError> {
self.post_json(
&[routes::V1_API_VERSION, routes::BANDWIDTH, routes::TOPUP],
NO_PARAMS,
request_body,
)
.await
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl WireguardMetadataApiClient for Client {}
@@ -0,0 +1,43 @@
[package]
name = "nym-wireguard-private-metadata-server"
version = "1.0.0"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
documentation.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
anyhow = { workspace = true }
axum = { workspace = true, features = ["tokio", "macros"] }
futures = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "net", "io-util"] }
tokio-util = { workspace = true }
tower-http = { workspace = true, features = [
"cors",
"trace",
"compression-br",
"compression-deflate",
"compression-gzip",
"compression-zstd",
] }
utoipa = { workspace = true, features = ["axum_extras", "time"] }
utoipa-swagger-ui = { workspace = true, features = ["axum"] }
nym-credentials-interface = { path = "../../credentials-interface" }
nym-credential-verification = { path = "../../credential-verification" }
nym-http-api-common = { path = "../../http-api-common", features = [
"middleware",
"utoipa",
"output",
] }
nym-wireguard = { path = "../../wireguard" }
nym-wireguard-private-metadata-shared = { path = "../shared" }
[dev-dependencies]
async-trait = { workspace = true }
[lints]
workspace = true
@@ -0,0 +1,46 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use std::sync::Arc;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use nym_wireguard::WgApiWrapper;
pub(crate) mod openapi;
pub(crate) mod router;
pub(crate) mod state;
/// Shutdown goes 2 directions:
/// 1. signal background tasks to gracefully finish
/// 2. signal server itself
///
/// These are done through separate shutdown handles. Of course, shut down server
/// AFTER you have shut down BG tasks (or past their grace period).
#[allow(unused)]
pub struct ShutdownHandles {
axum_shutdown_button: CancellationToken,
/// Tokio JoinHandle for axum server's task
axum_join_handle: AxumJoinHandle,
/// Wireguard API for kernel interactions
wg_api: Arc<WgApiWrapper>,
}
impl ShutdownHandles {
/// Cancellation token is given to Axum server constructor. When the token
/// receives a shutdown signal, Axum server will shut down gracefully.
pub fn new(
axum_join_handle: AxumJoinHandle,
wg_api: Arc<WgApiWrapper>,
axum_shutdown_button: CancellationToken,
) -> Self {
Self {
axum_shutdown_button,
axum_join_handle,
wg_api,
}
}
}
type AxumJoinHandle = JoinHandle<std::io::Result<()>>;
@@ -0,0 +1,14 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use utoipa::OpenApi;
use nym_wireguard_private_metadata_shared::{Request, Response};
#[derive(OpenApi)]
#[openapi(
info(title = "Nym Wireguard Private Metadata"),
tags(),
components(schemas(Request, Response))
)]
pub(crate) struct ApiDoc;
@@ -0,0 +1,101 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use anyhow::anyhow;
use axum::response::Redirect;
use axum::routing::get;
use axum::Router;
use core::net::SocketAddr;
use nym_http_api_common::middleware::logging::log_request_info;
use tokio::net::TcpListener;
use tokio_util::sync::WaitForCancellationFutureOwned;
use tower_http::cors::CorsLayer;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use crate::http::openapi::ApiDoc;
use crate::http::state::AppState;
use crate::network::bandwidth_routes;
/// Wrapper around `axum::Router` which ensures correct [order of layers][order].
/// Add new routes as if you were working directly with `axum`.
///
/// Why? Middleware like logger, CORS, TLS which need to handle request before other
/// layers should be added last. Using this builder pattern ensures that.
///
/// [order]: https://docs.rs/axum/latest/axum/middleware/index.html#ordering
pub struct RouterBuilder {
unfinished_router: Router<AppState>,
}
impl RouterBuilder {
/// All routes should be, if possible, added here. Exceptions are e.g.
/// routes which are added conditionally in other places based on some `if`.
pub fn with_default_routes() -> Self {
let default_routes = Router::new()
.merge(SwaggerUi::new("/swagger").url("/api-docs/openapi.json", ApiDoc::openapi()))
.route("/", get(|| async { Redirect::to("/swagger") }))
.nest("/v1", Router::new().nest("/bandwidth", bandwidth_routes()));
Self {
unfinished_router: default_routes,
}
}
/// Invoke this as late as possible before constructing HTTP server
/// (after all routes were added).
pub fn with_state(self, state: AppState) -> RouterWithState {
RouterWithState {
router: self.finalize_routes().with_state(state),
}
}
/// Middleware added here intercepts the request before it gets to other routes.
fn finalize_routes(self) -> Router<AppState> {
self.unfinished_router
.layer(setup_cors())
.layer(axum::middleware::from_fn(log_request_info))
}
}
fn setup_cors() -> CorsLayer {
CorsLayer::new()
.allow_origin(tower_http::cors::Any)
.allow_methods([axum::http::Method::GET, axum::http::Method::POST])
.allow_headers(tower_http::cors::Any)
.allow_credentials(false)
}
pub struct RouterWithState {
pub router: Router,
}
impl RouterWithState {
pub async fn build_server(self, bind_address: &SocketAddr) -> anyhow::Result<ApiHttpServer> {
let listener = tokio::net::TcpListener::bind(bind_address)
.await
.map_err(|err| anyhow!("Couldn't bind to address {} due to {}", bind_address, err))?;
Ok(ApiHttpServer {
router: self.router,
listener,
})
}
}
pub struct ApiHttpServer {
router: Router,
listener: TcpListener,
}
impl ApiHttpServer {
pub async fn run(self, receiver: WaitForCancellationFutureOwned) -> Result<(), std::io::Error> {
// into_make_service_with_connect_info allows us to see client ip address
axum::serve(
self.listener,
self.router
.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(receiver)
.await
}
}
@@ -0,0 +1,35 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use std::net::IpAddr;
use nym_credentials_interface::CredentialSpendingData;
use crate::transceiver::PeerControllerTransceiver;
use nym_wireguard_private_metadata_shared::error::MetadataError;
#[derive(Clone, axum::extract::FromRef)]
pub struct AppState {
transceiver: PeerControllerTransceiver,
}
impl AppState {
pub fn new(transceiver: PeerControllerTransceiver) -> Self {
Self { transceiver }
}
pub async fn available_bandwidth(&self, ip: IpAddr) -> Result<i64, MetadataError> {
self.transceiver.query_bandwidth(ip).await
}
// Top up with a credential and return the afterwards available bandwidth
pub async fn topup_bandwidth(
&self,
ip: IpAddr,
credential: CredentialSpendingData,
) -> Result<i64, MetadataError> {
self.transceiver
.topup_bandwidth(ip, Box::new(credential))
.await
}
}

Some files were not shown because too many files have changed in this diff Show More