Compare commits

..

40 Commits

Author SHA1 Message Date
benedettadavico 97068b2aac update changelog 2026-04-07 15:51:44 +02:00
benedetta davico 0e3e5c27f3 Merge pull request #6634 from nymtech/simon/ecash-contract-serde-fix
Simon/ecash contract serde fix
2026-04-01 10:56:27 +02:00
Simon Wicky 01e3c8206b alias not working, adding separate method 2026-03-31 17:32:57 +02:00
Simon Wicky ef20b8c7d1 serde magic on ecash contract 2026-03-31 14:39:34 +02:00
benedetta davico 61af16784b Merge pull request #6632 from nymtech/bdq/ecash-contract-test
small fix to allow ecash migrate
2026-03-30 13:33:34 +02:00
benedettadavico caf21076c9 .. 2026-03-30 10:37:45 +02:00
benedettadavico 1672135308 bump versions 2026-03-30 07:11:55 +02:00
mfahampshire c07ef0253d Max/sdk stream wrapper (#6320)
* Replace MixnetStream with LP framing
- Replace custom header with LpFrameHeader
- Added sequence number for message ordering

* IPR: support LP Stream-framed client connections
- Detect and route LP Stream frames in mixnet_listener
- Wrap inline responses in LP Stream frames
- Thread stream_id to ConnectedClientHandler for TUN responses

* sdk: add ipr_wrapper module with IpMixStream
- IpMixStream wraps MixnetStream for IPR tunnel over mixnet
- LP Stream framing handled automatically by MixnetStream
- Gateway discovery, connect handshake, IP packet send/receive

* sdk: remove superseded stream_wrapper module

* Trim obvious comments, add architecture.md stub

* sdk: add missing deps and fix warnings

* Cut down architecture diagram until finished with rest of the code, leaving stubs

* sdk: refactor IpMixStream, extract shared helpers

- Extract gateway discovery and connect response parsing
- Add recv() to MixnetStream, remove 64KB read buffer
- Simplify IpMixStream constructor

* Fix SphinxStream renames missed during rebase

* Add IpPacketResponse::from_bytes() for stream-based deserialization

* Clean up ip_packet_client: delete stale connect.rs, take raw bytes not ReconstructedMessage

* Clippy

* Delete unused ip_packet_client modules

- Remove helpers.rs (ICMP utilities moved to example)
- Remove error.rs (errors consolidated into sdk/error.rs)
- Remove README.md
- Update module root to only export discovery + listener

* Simplify listener, IpMixStream, and network_env

- Collapse IprListener struct into standalone handle_ipr_response()
- Move check_ipr_message_version() into listener.rs
- Remove IpMixStream test module (moved to example)
- Remove parse_network() and commented-out Sandbox arms
- Return Result from find_workspace_root() instead of panicking
- Add IprTunnelDisconnected and WorkspaceRootNotFound error variants

* Refactor IPR stream handling and document seq conventions
- Inline stream_id tracking (remove current_stream_id field)
- Re-export encode_stream_frame from clients module
- Document seq=0 reservation for inline control responses
- Document data-path counter starting at 1 with skip-on-wrap

* Add ipr_tunnel example for integration testing
- ICMP ping through IPR with --gateway flag for targeting specific exits
- Move pnet_packet from dependencies to dev-dependencies

* Add message reordering to stream router
- Buffer out-of-order messages per-stream using BTreeMap
- Drain contiguous sequences individually to preserve message boundaries
- Drop duplicate/old sequence numbers with a warning
- Remove dead_code allow on StreamFrame::sequence_num

* Clean up comments and fill architecture.md
- Remove separator line comments
- Update stale comments about ordering not being implemented
- Remove collapsible_if allows, use let-else instead
- Fill in architecture.md data flow and connection lifecycle

* Simplify ipr_tunnel example to minimal smoke test
- Single ping instead of multi-ping loop
- Remove identifier and PING_COUNT
- Collapse ICMP helpers into single build_icmp_ping function

* Add dual-stack IPv6 ping and rename gateway → ipr
- Rename --gateway flag to --ipr and new_with_gateway() to new_with_ipr()
- Add ICMPv6 ping to ipr_tunnel example for dual-stack smoke test
- Tighten echo reply validation (protocol field check, diagnostic output)
- Document IP allocation (subnets, static vs dynamic, client keying) in architecture.md
- Promote LP Stream Open handshake log to INFO

* Tweak subnet comment in docs

* Don't stop IPR listener on decode failure
- Change break to continue so garbage packets can't kill the listener
- Remaining valid packets in the bundle are still processed

* Fix license headers and use workspace dep for pnet_packet
- Switch GPL-3.0 to Apache-2.0 on all SDK library files
- Add missing license headers to 7 files
- Use workspace version for pnet_packet dependency

* Document IP pool isolation from WG/LP dVPN pool
- IPR uses 10.0.0.0/16 on nymtun, WG uses 10.1.0.0/16 on nymwg
- Reference constants.rs as source of truth

* Remove network_env.rs and simplify IpMixStream API
  - Default to mainnet via setup_env(None) instead of requiring env param
  - Remove NetworkEnvironment enum and workspace root detection
  - Remove WorkspaceRootNotFound error variant
  - Update ipr_tunnel example to match new signatures

* Use weighted random selection for IPR gateway discovery
  - Replace max_by_key with choose_weighted biased by performance score
  - Prevents all clients converging on a single highest-performing IPR

* Cap stream reorder buffer to prevent unbounded memory growth
- Add MAX_REORDER_BUFFER (256) to limit per-stream pending messages:
	- buffer overflows = skip ahead to lowest buffered seq and drain
	- protects against malicious senders that deliberately skip sequence numbers

* Extract shared IPR response helpers into nym-ip-packet-requests
  - Add response_helpers module with version check, connect response
    parsing, and control response dispatch
  - SDK ip_packet_client now delegates to shared module
  - Monorepo nym-ip-packet-client uses shared version check and
    connect response parsing
  - Fix doc comment attributing fork to nym-vpn-client

* Extract ICMP test helpers into nym-ip-packet-requests
  - Add icmp_utils module behind test-utils feature flag
  - Move build_icmp_ping, build_icmpv6_ping, is_echo_reply_v4/v6 from
    example
  - Update ipr_tunnel example to use shared helpers

* Add protocol v9 LP-framed transport marker

- Add v9 module (re-exports v8, VERSION=9)
- Accept v9 requests and responses in IPR
- Switch SDK IpMixStream to send v9

* Log protocol version in dynamic connect requests

* Remove KCP from IPR and fix unwrap_or_default in SDK
- Remove all KCP session management from ip-packet-router (replaced by
  LP Stream framing)
- Drop nym-kcp dependency and KcpError variant from IPR
- Replace unwrap_or_default with ok_or(Error::NoNymAPIUrl) in
  IpMixStream::new()

* Add v9 protocol wrapper constructors and enforce version/transport
consistency
- Add v9::new_connect_request(), new_data_request(),
  new_ip_packet_response() to centralise version stamping
- Replace manual protocol.version overrides in SDK and IPR with v9
  wrapper calls
- Bump nym-ip-packet-client current re-export from v8 to v9
- Enforce LP Stream frames must carry v9+ payloads, non-stream must be
  v8 or lower

* Filter IPR exit nodes by minimum v9-compatible release version
- Define MIN_RELEASE_VERSION (1.30.0) in ip-packet-requests/v9 alongside protocol constants
- Add semver-based filtering in SDK gateway discovery to skip nodes below v9 threshold
- Add semver dependency to ip-packet-requests and nym-sdk

* Use numeric version comparison for transport/version enforcement
- Compare version as u8 instead of enum equality so future v10+ is handled correctly
- Remove unused `use super::*` import left over from KCP test removal
2026-03-27 20:35:26 +00:00
benedetta davico cc799b69d3 Merge pull request #6622 from nymtech/jmwample/fallback-nym-ip
Update Fallback IP for Nym API
2026-03-27 10:06:13 +01:00
jmwample dd4bbc0708 nym-api moved default 2026-03-26 11:36:04 -06:00
Jack Wampler 7b77091fb1 Nym Node spam logging (#6621)
prevent spam logs when downstream node is slow
2026-03-26 11:27:14 -06:00
Jędrzej Stuczyński 6581ebf235 feat: multiple deposit prices (#6608)
* added reduced pricing handling logic

* admin methods for setting the whitelist of reduced price accounts

* updated client traits

* query to get all whitelisted accounts

* query for getting detailed deposit statistics

* fixes

* set initial whitelisted accounts in the migration

* stop transferring tokens to the holding account after redemption

* stop gateways from creating redemption multisig proposals

* make sure credential-proxy uses reduced deposits when available

* cargo fmt

* update deposit handler to allow EITHER default price or reduced price

this will allow non-breaking upgrades of NS and credential proxy

* removed use of unstable rust features

* rebuilt contract schema

* correct license timestamp
2026-03-26 16:02:19 +00:00
benedetta davico 82ace6d27b Merge pull request #6611 from nymtech/master
Keep master and develop in sync
2026-03-26 16:07:36 +01:00
import this e362207583 [DOCs/operators]: Fix - disable ufw to clean machine conf state (#6620) 2026-03-26 12:27:57 +01:00
import this 68caecff35 [DOCs]: Release notes v2026.6 stilton (#6606)
* operators updates

* add headers

* Update changelog.mdx

* bump up node version

* udpate time

* edit typos

---------

Co-authored-by: Merve <111695676+merve64@users.noreply.github.com>
2026-03-26 11:02:10 +01:00
import this 2fae4414d2 NTM Update: single port managment tool (#6607)
* update ntm

* update docs

* add table for ports

* cherry on the cake

* polish ntm

* quic cherry - add 4443
2026-03-26 10:18:32 +01:00
benedetta davico 6eca09b904 Merge pull request #6610 from nymtech/release/2026.6-stilton
Merge stilton to master
2026-03-25 17:09:28 +01:00
benedetta davico 7ab821cb11 Merge pull request #6609 from nymtech/release/2026.6-stilton
Merge stilton to develop
2026-03-25 17:09:16 +01:00
benedettadavico 0343469179 update changelog 2026-03-25 07:47:04 +01:00
mfahampshire 9904f6b17c Make mobile friendly (#6605)
- Add overflow:hidden on grid
- Shrink `pre` font on mobile
- Stack grid on narrow pages
2026-03-24 21:56:15 +00:00
mfahampshire 5e0eeeddd6 hotfix (#6603) 2026-03-24 15:32:30 +00:00
mfahampshire b6df383584 Max/docs theme rework (#6593)
* Rawer landing page
- Angular, clean docs styling inspired by Oxide
- zero all border-radius globally (kill rounded corners)
- sharp code blocks with subtle border
- callouts: left-border accent instead of rounded pill
- clean table grid lines, sharp search box and MUI buttons
- tighter heading letter-spacing (-0.02em)
- flat left-border sidebar active item instead of background blob

* Add JetBrains Mono for headings/sidebar, push Oxide styling further
- import JetBrains Mono via Google Fonts
- apply mono font to headings, sidebar, nav bar, search, table headers
- darken background (#181C1E), muted body text, h2 bottom border
- subtle background tint on active sidebar item
- inline code: background-only (no border), monospace table headers
- fix active sidebar item font size (scope separator label rule)

* Rework docs landing page: hero, ASCII cards, SDKs, get started
- add hero section with subtitle covering all doc areas
- replace PNG vector illustrations with ASCII art in primary green
- add SDKs section with Rust and TypeScript links
- add get started section: What is the Mixnet, Send a message, Run a node
- add footer links to GitHub and Matrix
- fix nav dropdown font (button + ul selectors)
- add landing card hover style

* Self-host JetBrains Mono, refine landing page
- replace Google Fonts import with local @font-face (woff2)
- add font files + OFL license to public/fonts/
- remove redundant "Nym Docs" hero heading (already in nav)
- drop quick-links pills section
- fix SDK box borders (negative margin collapse)
- rewrite footer as simple link row (GitHub, Matrix, nym.com)

* Light mode styling, dark-mode diagram invert, click-to-expand images
- add full light mode CSS: pale grey bg, darker green links, mono fonts
- invert diagram images in dark mode with mix-blend-mode: lighten
- add click-to-expand overlay for content images
- revert mermaid diagrams back to original PNGs

* Fix Lychee config for fonts

* Make light mode green darker

* Animate landing page ASCII art, remove architecture diagram

- Network: animated packet traversal through gw_e → M1/M2/M3 → gw_ex
  with diagonal cross-connections showing mixing paths
- Developers: typewriter effect with blinking cursor
- Operators: looping progress bar with continuously incrementing packet count
- APIs: staged line-by-line response reveal
- Remove architecture overview PNG from network/architecture.mdx

* Small copy change to SDK headers

* Fix links
2026-03-24 15:08:07 +00:00
Simon Wicky b7d13d6fa6 lp fixes (#6601) 2026-03-23 16:18:45 +01:00
benedetta davico 838dd630ae Merge pull request #6590 from nymtech/ci-runner-22.04
temporarily change binaries ci runner to 22.04
2026-03-20 15:38:46 +01:00
benedetta davico 3f00e2c317 Merge pull request #6592 from nymtech/bdq/bump-ns-version
bump NS versions
2026-03-20 15:37:18 +01:00
benedettadavico 3cdda8fdfd bump NS version 2026-03-20 15:33:16 +01:00
benedetta davico 33f47ef36e Merge pull request #6591 from nymtech/release/2026.6-stilton
merge stilton to develop
2026-03-20 15:30:48 +01:00
benedetta davico 7f9dba6e99 Change OS from arc-linux to ubuntu-22.04 2026-03-20 15:24:53 +01:00
benedetta davico 96e88b6ea9 Change CI platform from arc-linux to ubuntu-22.04 2026-03-20 14:42:10 +01:00
dynco-nym 180802feb8 Fix socks5 GW probe regression (#6576)
* Restore tested gateway into topology

* Bump agent version

* Update .sqlx files

* Clean up code in probe test

* Probe error & logging improvements

* Fix clippy, improve log line

* Improve logging in ns agent

* Better tooling in NS API

* Bump agent

* Bump NS agent version
2026-03-20 10:36:32 +01:00
Jędrzej Stuczyński 87882f70cf bugfix: allow deserialisation of LP data from either snake_case or lowercase (#6586) 2026-03-20 08:26:27 +00:00
mfahampshire 4077717d3a Max/lp stream framing (#6573)
* Add LpFrameKind::Stream variant with StreamFrameAttributes
- Define LP wire format for stream multiplexing
- Handle new variant in entry gateway match arm

* Replace MixnetStream with LP framing
- Replace custom header with LpFrameHeader
- Added sequence number for message ordering

* Revert accidental vergen bump

* Revert accidental bumps

* Rename Stream to SphinxStream and split match arms in client_handler

* Add LpFrameAttributes type alias for [u8; 14]
2026-03-19 15:30:59 +00:00
Simon Wicky bc3df31518 move format_debug_bytes in common crate (#6580)
* move format_debug_bytes in common crate

* license change
2026-03-19 15:09:20 +01:00
Jack Wampler 61d6acace8 HTTP domain rotation conditions (#6570)
Add more explicit handling for df enable and domain rotations
2026-03-19 07:38:48 -06:00
Jędrzej Stuczyński abb4e3f988 bugfix: make sure client keys are generated before requesting credentials (#6579) 2026-03-19 08:55:00 +00:00
mfahampshire c5488337da Max/mixfetch docs tweak (#6523)
* update mixfetch concurrency info

* Update MixFetch version + update note

* Update python3 install method on docs runners
2026-03-18 14:23:51 +00:00
mfahampshire f06eefe184 Only publish mixfetch in script (#6560) 2026-03-18 14:01:24 +00:00
benedettadavico 46a8697a5d version bump 2026-03-18 13:17:14 +01:00
Jędrzej Stuczyński 0429238b0f bugfix: make sure to run cargo install cosmwasm-check with --locked flag during CI (#6568) 2026-03-17 14:52:01 +00:00
benedetta davico e86fa8fc7f Merge pull request #6537 from nymtech/release/2026.5-raclette
Raclette to master
2026-03-10 12:07:12 +01:00
215 changed files with 6931 additions and 2674 deletions
+1 -3
View File
@@ -15,10 +15,8 @@ jobs:
- uses: actions/checkout@v6
- name: Install Dependencies (Linux)
run: sudo apt-get update && sudo apt-get install -y build-essential curl wget libssl-dev libudev-dev squashfs-tools protobuf-compiler git python3 && sudo apt-get update --fix-missing
- name: Install pip3
run: sudo apt install -y python3-pip
- name: Install Python3 modules
run: sudo pip3 install pandas tabulate
run: sudo apt install -y python3-pandas python3-tabulate
- name: Install rsync
run: sudo apt-get install -y rsync
- uses: rlespinasse/github-slug-action@v3.x
@@ -36,7 +36,7 @@ jobs:
strategy:
fail-fast: false
matrix:
platform: [arc-linux-latest]
platform: [ubuntu-22.04]
runs-on: ${{ matrix.platform }}
env:
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
components: rustfmt, clippy
- name: Install cosmwasm-check
run: cargo install cosmwasm-check
run: cargo install cosmwasm-check --locked
- name: Install wasm-opt
uses: ./.github/actions/install-wasm-opt
+1 -3
View File
@@ -20,10 +20,8 @@ jobs:
- uses: actions/checkout@v6
- name: Install Dependencies (Linux)
run: sudo apt-get update && sudo apt-get install -y build-essential curl wget libssl-dev libudev-dev squashfs-tools protobuf-compiler git python3 && sudo apt-get update --fix-missing
- name: Install pip3
run: sudo apt install -y python3-pip
- name: Install Python3 modules
run: sudo pip3 install pandas tabulate
run: sudo apt install -y python3-pandas python3-tabulate
- name: Install rsync
run: sudo apt-get install -y rsync
- uses: rlespinasse/github-slug-action@v3.x
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
fail-fast: false
matrix:
include:
- os: arc-linux-latest
- os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.os }}
+70
View File
@@ -4,6 +4,76 @@ Post 1.0.0 release, the changelog format is based on [Keep a Changelog](https://
## [Unreleased]
## [2026.7-tola] (2026-04-07)
- Simon/ecash contract serde fix ([#6634])
- Update Fallback IP for Nym API ([#6622])
- Nym Node spam logging ([#6621])
- feat: multiple deposit prices ([#6608])
- move format_debug_bytes in common crate ([#6580])
- bugfix: make sure client keys are generated before requesting credentials ([#6579])
- Fix socks5 GW probe regression ([#6576])
- Max/lp stream framing ([#6573])
- HTTP domain rotation conditions ([#6570])
[#6634]: https://github.com/nymtech/nym/pull/6634
[#6622]: https://github.com/nymtech/nym/pull/6622
[#6621]: https://github.com/nymtech/nym/pull/6621
[#6608]: https://github.com/nymtech/nym/pull/6608
[#6580]: https://github.com/nymtech/nym/pull/6580
[#6579]: https://github.com/nymtech/nym/pull/6579
[#6576]: https://github.com/nymtech/nym/pull/6576
[#6573]: https://github.com/nymtech/nym/pull/6573
[#6570]: https://github.com/nymtech/nym/pull/6570
## [2026.6-stilton] (2026-03-25)
- lp fixes ([#6601])
- bugfix: allow deserialisation of LP data from either snake_case or lowercase ([#6586])
- bugfix: make sure to run cargo install cosmwasm-check with --locked flag during CI ([#6568])
- Add LP to NS UI ([#6562])
- feat: nyxd watcher ([#6561])
- Additional ticket for agent ([#6551])
- bugfix: make sure to use old values from metrics debug config during v12 migration (#6546) ([#6547])
- typo ([#6543])
- rng changes for a Send variant ([#6541])
- Add LP fields ([#6535])
- enable LP registration in registration client ([#6534])
- chore: rename LpMessage to LpFrame ([#6530])
- chore: LP improvements ([#6526])
- Remove dep leak of strum iterator ([#6522])
- chore: update ts-rs dep ([#6517])
- addressing LP PR comments ([#6513])
- remove redundant LP state machine in favour of in place processing ([#6512])
- chore: split up lp listener ([#6507])
- feat: enable mutual KKT exchange ([#6505])
- feat: introduce /v3/unstable/nym-nodes/semi-skimmed to aggregate LP information ([#6499])
- Max/asyncread asyncwrite nym client ([#6318])
- feat: localnet v2 ([#6277])
[#6601]: https://github.com/nymtech/nym/pull/6601
[#6586]: https://github.com/nymtech/nym/pull/6586
[#6568]: https://github.com/nymtech/nym/pull/6568
[#6562]: https://github.com/nymtech/nym/pull/6562
[#6561]: https://github.com/nymtech/nym/pull/6561
[#6551]: https://github.com/nymtech/nym/pull/6551
[#6547]: https://github.com/nymtech/nym/pull/6547
[#6543]: https://github.com/nymtech/nym/pull/6543
[#6541]: https://github.com/nymtech/nym/pull/6541
[#6535]: https://github.com/nymtech/nym/pull/6535
[#6534]: https://github.com/nymtech/nym/pull/6534
[#6530]: https://github.com/nymtech/nym/pull/6530
[#6526]: https://github.com/nymtech/nym/pull/6526
[#6522]: https://github.com/nymtech/nym/pull/6522
[#6517]: https://github.com/nymtech/nym/pull/6517
[#6513]: https://github.com/nymtech/nym/pull/6513
[#6512]: https://github.com/nymtech/nym/pull/6512
[#6507]: https://github.com/nymtech/nym/pull/6507
[#6505]: https://github.com/nymtech/nym/pull/6505
[#6499]: https://github.com/nymtech/nym/pull/6499
[#6318]: https://github.com/nymtech/nym/pull/6318
[#6277]: https://github.com/nymtech/nym/pull/6277
## [2026.5-raclette] (2026-03-10)
- bugfix: correctly populate gateway probe LP data ([#6533])
Generated
+42 -32
View File
@@ -2534,7 +2534,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -3589,7 +3589,7 @@ dependencies = [
"once_cell",
"rand 0.9.2",
"ring",
"rustls 0.23.29",
"rustls 0.23.37",
"thiserror 2.0.12",
"tinyvec",
"tokio",
@@ -3614,7 +3614,7 @@ dependencies = [
"parking_lot",
"rand 0.9.2",
"resolv-conf",
"rustls 0.23.29",
"rustls 0.23.37",
"smallvec",
"thiserror 2.0.12",
"tokio",
@@ -3871,7 +3871,7 @@ dependencies = [
"http 1.3.1",
"hyper 1.6.0",
"hyper-util",
"rustls 0.23.29",
"rustls 0.23.37",
"rustls-native-certs 0.8.3",
"rustls-pki-types",
"tokio",
@@ -5448,7 +5448,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -5547,7 +5547,7 @@ dependencies = [
[[package]]
name = "nym-api"
version = "1.1.75"
version = "1.1.77"
dependencies = [
"anyhow",
"async-trait",
@@ -5792,7 +5792,7 @@ dependencies = [
[[package]]
name = "nym-cli"
version = "1.1.72"
version = "1.1.74"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -5875,7 +5875,7 @@ dependencies = [
[[package]]
name = "nym-client"
version = "1.1.72"
version = "1.1.74"
dependencies = [
"bs58",
"clap",
@@ -6445,7 +6445,7 @@ dependencies = [
[[package]]
name = "nym-data-observatory"
version = "1.0.2"
version = "1.0.1"
dependencies = [
"anyhow",
"async-trait",
@@ -6849,6 +6849,7 @@ dependencies = [
"nym-network-defaults",
"once_cell",
"reqwest 0.13.1",
"rustls 0.23.37",
"serde",
"serde_json",
"serde_plain",
@@ -6956,12 +6957,15 @@ dependencies = [
"nym-crypto",
"nym-service-provider-requests-common",
"nym-sphinx",
"pnet_packet",
"rand 0.8.5",
"semver 1.0.27",
"serde",
"thiserror 2.0.12",
"time",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
@@ -6984,7 +6988,7 @@ dependencies = [
"nym-exit-policy",
"nym-id",
"nym-ip-packet-requests",
"nym-kcp",
"nym-lp",
"nym-network-defaults",
"nym-network-requester",
"nym-sdk",
@@ -7086,6 +7090,7 @@ dependencies = [
"criterion",
"libcrux-psq",
"num_enum",
"nym-common",
"nym-crypto",
"nym-kkt",
"nym-kkt-ciphersuite",
@@ -7279,7 +7284,7 @@ dependencies = [
[[package]]
name = "nym-network-requester"
version = "1.1.73"
version = "1.1.75"
dependencies = [
"addr",
"anyhow",
@@ -7329,7 +7334,7 @@ dependencies = [
[[package]]
name = "nym-node"
version = "1.27.0"
version = "1.29.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -7466,7 +7471,7 @@ dependencies = [
[[package]]
name = "nym-node-status-agent"
version = "1.1.3"
version = "1.1.5"
dependencies = [
"anyhow",
"clap",
@@ -7485,7 +7490,7 @@ dependencies = [
[[package]]
name = "nym-node-status-api"
version = "4.3.0"
version = "4.3.1"
dependencies = [
"ammonia",
"anyhow",
@@ -7790,6 +7795,8 @@ dependencies = [
"nym-crypto",
"nym-gateway-requests",
"nym-http-api-client",
"nym-ip-packet-requests",
"nym-lp",
"nym-network-defaults",
"nym-ordered-buffer",
"nym-service-providers-common",
@@ -7802,8 +7809,10 @@ dependencies = [
"nym-topology",
"nym-validator-client",
"parking_lot",
"pnet_packet",
"rand 0.8.5",
"reqwest 0.13.1",
"semver 1.0.27",
"serde",
"tap",
"tempfile",
@@ -7875,7 +7884,7 @@ dependencies = [
[[package]]
name = "nym-socks5-client"
version = "1.1.72"
version = "1.1.74"
dependencies = [
"bs58",
"clap",
@@ -8668,11 +8677,12 @@ dependencies = [
"serde",
"thiserror 2.0.12",
"x25519-dalek",
"zeroize",
]
[[package]]
name = "nymvisor"
version = "0.1.37"
version = "0.1.39"
dependencies = [
"anyhow",
"bytes",
@@ -9651,7 +9661,7 @@ dependencies = [
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls 0.23.29",
"rustls 0.23.37",
"socket2 0.5.10",
"thiserror 2.0.12",
"tokio",
@@ -9672,7 +9682,7 @@ dependencies = [
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls 0.23.29",
"rustls 0.23.37",
"rustls-pki-types",
"slab",
"thiserror 2.0.12",
@@ -9939,7 +9949,7 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls 0.23.29",
"rustls 0.23.37",
"rustls-native-certs 0.8.3",
"rustls-pki-types",
"serde",
@@ -9980,7 +9990,7 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls 0.23.29",
"rustls 0.23.37",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
@@ -10240,16 +10250,16 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.29"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki 0.103.4",
"rustls-webpki 0.103.9",
"subtle 2.6.1",
"zeroize",
]
@@ -10330,14 +10340,14 @@ dependencies = [
"jni",
"log",
"once_cell",
"rustls 0.23.29",
"rustls 0.23.37",
"rustls-native-certs 0.8.3",
"rustls-platform-verifier-android",
"rustls-webpki 0.103.4",
"rustls-webpki 0.103.9",
"security-framework 3.6.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -10369,9 +10379,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.4"
version = "0.103.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
"aws-lc-rs",
"ring",
@@ -11202,7 +11212,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
"rustls 0.23.29",
"rustls 0.23.37",
"serde",
"serde_json",
"sha2 0.10.9",
@@ -12024,7 +12034,7 @@ version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b"
dependencies = [
"rustls 0.23.29",
"rustls 0.23.37",
"tokio",
]
@@ -12842,7 +12852,7 @@ dependencies = [
"serde",
"tempfile",
"textwrap",
"toml 0.8.23",
"toml 0.9.12+spec-1.1.0",
"uniffi_internal_macros 0.31.0",
"uniffi_meta 0.31.0",
"uniffi_pipeline 0.31.0",
@@ -12951,7 +12961,7 @@ dependencies = [
"quote",
"serde",
"syn 2.0.106",
"toml 0.8.23",
"toml 0.9.12+spec-1.1.0",
"uniffi_meta 0.31.0",
]
+1
View File
@@ -334,6 +334,7 @@ rayon = "1.5.1"
regex = "1.10.6"
reqwest = { version = "0.13.1", default-features = false }
rs_merkle = "1.5.0"
rustls = { version = "0.23.37", default-features = false }
schemars = "0.8.22"
semver = "1.0.26"
serde = "1.0.219"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nym-client"
version = "1.1.72"
version = "1.1.74"
authors = ["Dave Hrycyszyn <futurechimp@users.noreply.github.com>", "Jędrzej Stuczyński <andrew@nymtech.net>"]
description = "Implementation of the Nym Client"
edition = "2021"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nym-socks5-client"
version = "1.1.72"
version = "1.1.74"
authors = ["Dave Hrycyszyn <futurechimp@users.noreply.github.com>"]
description = "A SOCKS5 localhost proxy that converts incoming messages to Sphinx and sends them to a Nym address"
edition = "2021"
@@ -34,7 +34,7 @@ where
let signing_key = ed25519::PrivateKey::new(&mut rng);
let expiration = expiration.unwrap_or_else(ecash_default_expiration_date);
let deposit_amount = client.get_required_deposit_amount().await?;
let deposit_amount = client.get_default_deposit_amount().await?;
info!("we'll need to deposit {deposit_amount} to obtain the ticketbook");
let result = client
.make_ticketbook_deposit(
@@ -342,7 +342,7 @@ impl SendWithoutResponse for Client {
sending_res.map_err(|err| {
match err {
TrySendError::Full(_) => {
warn!(
trace!(
event = "mixclient.try_send",
peer = %address,
result = "full_dropped",
@@ -8,6 +8,7 @@ use crate::nyxd::CosmWasmClient;
use async_trait::async_trait;
use cosmwasm_std::Coin;
use nym_ecash_contract_common::deposit::LatestDepositResponse;
use nym_ecash_contract_common::deposit_statistics::DepositsStatistics;
use nym_ecash_contract_common::msg::QueryMsg as EcashQueryMsg;
use serde::Deserialize;
@@ -17,6 +18,9 @@ pub use nym_ecash_contract_common::blacklist::{
pub use nym_ecash_contract_common::deposit::{
Deposit, DepositData, DepositId, DepositResponse, PagedDepositsResponse,
};
pub use nym_ecash_contract_common::reduced_deposit::{
WhitelistedAccount, WhitelistedAccountsResponse,
};
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
@@ -42,8 +46,18 @@ pub trait EcashQueryClient {
.await
}
async fn get_required_deposit_amount(&self) -> Result<Coin, NyxdError> {
self.query_ecash_contract(EcashQueryMsg::GetRequiredDepositAmount {})
async fn get_default_deposit_amount(&self) -> Result<Coin, NyxdError> {
self.query_ecash_contract(EcashQueryMsg::GetDefaultDepositAmount {})
.await
}
async fn get_reduced_deposit_amount(&self, address: String) -> Result<Option<Coin>, NyxdError> {
self.query_ecash_contract(EcashQueryMsg::GetReducedDepositAmount { address })
.await
}
async fn get_all_whitelisted_accounts(&self) -> Result<WhitelistedAccountsResponse, NyxdError> {
self.query_ecash_contract(EcashQueryMsg::GetAllWhitelistedAccounts {})
.await
}
@@ -65,6 +79,11 @@ pub trait EcashQueryClient {
self.query_ecash_contract(EcashQueryMsg::GetDepositsPaged { start_after, limit })
.await
}
async fn get_deposits_statistics(&self) -> Result<DepositsStatistics, NyxdError> {
self.query_ecash_contract(EcashQueryMsg::GetDepositsStatistics {})
.await
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
@@ -122,10 +141,17 @@ mod tests {
EcashQueryMsg::GetDepositsPaged { limit, start_after } => {
client.get_deposits_paged(start_after, limit).ignore()
}
EcashQueryMsg::GetRequiredDepositAmount {} => {
client.get_required_deposit_amount().ignore()
EcashQueryMsg::GetDefaultDepositAmount {} => {
client.get_default_deposit_amount().ignore()
}
EcashQueryMsg::GetReducedDepositAmount { address } => {
client.get_reduced_deposit_amount(address).ignore()
}
EcashQueryMsg::GetAllWhitelistedAccounts {} => {
client.get_all_whitelisted_accounts().ignore()
}
EcashQueryMsg::GetLatestDeposit {} => client.get_latest_deposit().ignore(),
EcashQueryMsg::GetDepositsStatistics {} => client.get_deposits_statistics().ignore(),
};
}
}
@@ -62,13 +62,47 @@ pub trait EcashSigningClient {
new_deposit: Coin,
fee: Option<Fee>,
) -> Result<ExecuteResult, NyxdError> {
let req = EcashExecuteMsg::UpdateDepositValue {
let req = EcashExecuteMsg::UpdateDefaultDepositValue {
new_deposit: new_deposit.into(),
};
self.execute_ecash_contract(fee, req, "Ecash::UpdateDepositValue".to_string(), vec![])
.await
}
async fn set_reduced_deposit_price(
&self,
address: String,
deposit: Coin,
fee: Option<Fee>,
) -> Result<ExecuteResult, NyxdError> {
let req = EcashExecuteMsg::SetReducedDepositPrice {
address,
deposit: deposit.into(),
};
self.execute_ecash_contract(
fee,
req,
"Ecash::SetReducedDepositPrice".to_string(),
vec![],
)
.await
}
async fn remove_reduced_deposit_price(
&self,
address: String,
fee: Option<Fee>,
) -> Result<ExecuteResult, NyxdError> {
let req = EcashExecuteMsg::RemoveReducedDepositPrice { address };
self.execute_ecash_contract(
fee,
req,
"Ecash::RemoveReducedDepositPrice".to_string(),
vec![],
)
.await
}
async fn propose_for_blacklist(
&self,
public_key: String,
@@ -141,9 +175,15 @@ mod tests {
.ignore(),
ExecuteMsg::RedeemTickets { .. } => unimplemented!(), // no redeem tickets method for the client
ExecuteMsg::UpdateAdmin { admin } => client.update_admin(admin, None).ignore(),
ExecuteMsg::UpdateDepositValue { new_deposit } => client
ExecuteMsg::UpdateDefaultDepositValue { new_deposit } => client
.update_deposit_value(new_deposit.into(), None)
.ignore(),
ExecuteMsg::SetReducedDepositPrice { address, deposit } => client
.set_reduced_deposit_price(address, deposit.into(), None)
.ignore(),
ExecuteMsg::RemoveReducedDepositPrice { address } => {
client.remove_reduced_deposit_price(address, None).ignore()
}
};
}
}
@@ -6,6 +6,14 @@ use cosmwasm_std::Coin;
#[cw_serde]
pub struct PoolCounters {
/// Represents the total amount of funds deposited into the contract.
pub total_deposited: Coin,
/// Represents the total amount of funds redeemed from the contract that got transferred into the holding account.
pub total_redeemed: Coin,
/// Represents the total amount of tickets requested to be redeemed from the contract and get moved into the holding account,
/// after that functionality got disabled.
#[serde(default)]
pub tickets_requested_and_not_redeemed: u64,
}
@@ -0,0 +1,38 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use cosmwasm_schema::cw_serde;
use cosmwasm_std::Coin;
use std::collections::HashMap;
/// Aggregate statistics about all deposits made through the ecash contract.
#[cw_serde]
pub struct DepositsStatistics {
/// Total number of deposits ever made (at any price tier),
/// derived from the deposit id counter.
pub total_deposits_made: u32,
/// Total value of all deposits ever made (at any price tier),
/// sourced from `PoolCounters::total_deposited`.
pub total_deposited: Coin,
/// Number of deposits made at the default (non-reduced) price.
pub total_deposits_made_with_default_price: u32,
/// Total value deposited at the default price.
pub total_deposited_with_default_price: Coin,
/// Number of deposits made at any custom (reduced) price, summed across all whitelisted accounts.
pub total_deposits_made_with_custom_price: u32,
/// Total value deposited at custom prices, summed across all whitelisted accounts.
pub total_deposited_with_custom_price: Coin,
/// Per-account breakdown of deposit counts for whitelisted addresses.
// note: we use String for addressing due to serialisation incompatibility
pub deposits_made_with_custom_price: HashMap<String, u32>,
/// Per-account breakdown of deposited amounts for whitelisted addresses.
// note: we use String for addressing due to serialisation incompatibility
pub deposited_with_custom_price: HashMap<String, Coin>,
}
@@ -65,4 +65,26 @@ pub enum EcashContractError {
#[error("the account blacklisting hasn't been fully implemented yet")]
UnimplementedBlacklisting,
#[error("reduced deposit must use the same denom as the default deposit (expected '{expected}', got '{got}')")]
InvalidReducedDepositDenom { expected: String, got: String },
#[error(
"reduced deposit amount ({reduced}) must be strictly less than the default ({default})"
)]
ReducedDepositNotReduced {
reduced: cosmwasm_std::Uint128,
default: cosmwasm_std::Uint128,
},
#[error("address '{address}' does not have a custom reduced deposit price set")]
NoReducedDepositPrice { address: String },
#[error(
"deposit amount ({amount}) must be at least the ticket book size ({ticket_book_size})"
)]
DepositBelowTicketBookSize {
amount: cosmwasm_std::Uint128,
ticket_book_size: u64,
},
}
@@ -4,10 +4,12 @@
pub mod blacklist;
pub mod counters;
pub mod deposit;
pub mod deposit_statistics;
pub mod error;
pub mod event_attributes;
pub mod events;
pub mod msg;
pub mod redeem_credential;
pub mod reduced_deposit;
pub use error::EcashContractError;
@@ -9,6 +9,10 @@ use crate::blacklist::{BlacklistedAccountResponse, PagedBlacklistedAccountRespon
#[cfg(feature = "schema")]
use crate::deposit::{DepositResponse, LatestDepositResponse, PagedDepositsResponse};
#[cfg(feature = "schema")]
use crate::deposit_statistics::DepositsStatistics;
#[cfg(feature = "schema")]
use crate::reduced_deposit::WhitelistedAccountsResponse;
#[cfg(feature = "schema")]
use cosmwasm_schema::QueryResponses;
#[cw_serde]
@@ -42,10 +46,25 @@ pub enum ExecuteMsg {
admin: String,
},
UpdateDepositValue {
#[serde(alias = "update_deposit_value")]
UpdateDefaultDepositValue {
new_deposit: Coin,
},
/// Set (or overwrite) a reduced deposit price for a specific address.
/// Only callable by the contract admin.
SetReducedDepositPrice {
address: String,
deposit: Coin,
},
/// Remove the reduced deposit price for a specific address, reverting them to
/// the default price. Returns an error if the address has no custom price set.
/// Only callable by the contract admin.
RemoveReducedDepositPrice {
address: String,
},
// TODO: properly implement
ProposeToBlacklist {
public_key: String,
@@ -68,7 +87,15 @@ pub enum QueryMsg {
},
#[cfg_attr(feature = "schema", returns(Coin))]
GetRequiredDepositAmount {},
#[serde(alias = "get_required_deposit_amount")]
#[serde(alias = "GetRequiredDepositAmount")]
GetDefaultDepositAmount {},
#[cfg_attr(feature = "schema", returns(Option<Coin>))]
GetReducedDepositAmount { address: String },
#[cfg_attr(feature = "schema", returns(WhitelistedAccountsResponse))]
GetAllWhitelistedAccounts {},
#[cfg_attr(feature = "schema", returns(DepositResponse))]
GetDeposit { deposit_id: u32 },
@@ -81,7 +108,22 @@ pub enum QueryMsg {
limit: Option<u32>,
start_after: Option<u32>,
},
#[cfg_attr(feature = "schema", returns(DepositsStatistics))]
GetDepositsStatistics {},
}
#[cw_serde]
pub struct MigrateMsg {}
pub struct MigrateMsg {
/// Initial set of whitelisted accounts with their reduced deposit prices.
/// Each entry is validated and stored during migration.
pub initial_whitelist: Vec<WhitelistedDeposit>,
}
/// An address and its reduced deposit price, used when seeding the whitelist
/// via migration.
#[cw_serde]
pub struct WhitelistedDeposit {
pub address: String,
pub deposit: Coin,
}
@@ -0,0 +1,16 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use cosmwasm_schema::cw_serde;
use cosmwasm_std::{Addr, Coin};
#[cw_serde]
pub struct WhitelistedAccount {
pub address: Addr,
pub deposit: Coin,
}
#[cw_serde]
pub struct WhitelistedAccountsResponse {
pub whitelisted_accounts: Vec<WhitelistedAccount>,
}
@@ -6,7 +6,7 @@ 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, Config, CosmWasmClient, NyxdClient};
use nym_validator_client::nyxd::{AccountId, Coin, Config, CosmWasmClient, NyxdClient};
use nym_validator_client::{DirectSigningHttpRpcNyxdClient, nyxd};
use std::ops::Deref;
use std::sync::Arc;
@@ -50,6 +50,10 @@ impl ChainClient {
Ok(ChainClient(Arc::new(RwLock::new(client))))
}
pub async fn address(&self) -> AccountId {
self.0.read().await.address()
}
pub async fn query_chain(&self) -> ChainReadPermit<'_> {
let _acquire_timer = LockTimer::new("acquire chain query permit");
self.0.read().await
@@ -8,6 +8,7 @@ use nym_validator_client::nyxd::contract_traits::EcashQueryClient;
use std::sync::Arc;
use time::OffsetDateTime;
use tokio::sync::RwLock;
use tracing::{info, warn};
pub struct CachedDeposit {
valid_until: OffsetDateTime,
@@ -56,13 +57,29 @@ impl RequiredDepositCache {
// update cache
drop(read_guard);
let address = chain_client.address().await;
info!("checking deposit required by {address}");
let mut write_guard = self.inner.write().await;
let deposit_amount = chain_client
.query_chain()
.await
.get_required_deposit_amount()
let read_permit = chain_client.query_chain().await;
let reduced = read_permit
.get_reduced_deposit_amount(address.to_string())
.await?;
let deposit_amount = match reduced {
Some(reduced) => {
info!("we're permitted to use reduced price");
reduced
}
None => {
warn!(
"using default deposit value {address} is not whitelisted for price reduction"
);
read_permit.get_default_deposit_amount().await?
}
};
let nym_coin: Coin = deposit_amount.into();
write_guard.update(nym_coin.clone());
@@ -3,25 +3,19 @@
use crate::Error;
use crate::ecash::error::EcashTicketError;
use crate::ecash::helpers::for_each_api_concurrent;
use crate::ecash::state::SharedState;
use cosmwasm_std::Fraction;
use cw_utils::ThresholdResponse;
use futures::channel::mpsc::UnboundedReceiver;
use futures::{Stream, StreamExt};
use nym_api_requests::constants::MIN_BATCH_REDEMPTION_DELAY;
use nym_api_requests::ecash::models::{BatchRedeemTicketsBody, VerifyEcashTicketBody};
use nym_api_requests::ecash::models::VerifyEcashTicketBody;
use nym_credentials_interface::Bandwidth;
use nym_credentials_interface::{ClientTicket, TicketType};
use nym_validator_client::EcashApiClient;
use nym_validator_client::coconut::EcashApiError;
use nym_validator_client::nym_api::{EpochId, NymApiClientExt};
use nym_validator_client::nym_api::NymApiClientExt;
use nym_validator_client::nyxd::AccountId;
use nym_validator_client::nyxd::contract_traits::{
EcashSigningClient, MultisigQueryClient, MultisigSigningClient, PagedMultisigQueryClient,
};
use nym_validator_client::nyxd::cosmwasm_client::ContractResponseData;
use nym_validator_client::nyxd::cw3::Status;
use nym_validator_client::nyxd::contract_traits::MultisigQueryClient;
use si_scale::helpers::bibytes2;
use std::collections::{HashMap, HashSet};
use std::ops::Deref;
@@ -31,22 +25,6 @@ use tokio::sync::{Mutex, RwLockReadGuard};
use tokio::time::{Duration, Instant, interval_at};
use tracing::{debug, error, info, instrument, trace, warn};
enum ProposalResult {
Executed,
Rejected,
Pending,
}
impl ProposalResult {
fn is_pending(&self) -> bool {
matches!(self, ProposalResult::Pending)
}
fn is_rejected(&self) -> bool {
matches!(self, ProposalResult::Rejected)
}
}
struct PendingVerification {
ticket: ClientTicket,
@@ -68,43 +46,6 @@ impl PendingVerification {
}
}
struct PendingRedemptionVote {
proposal_id: u64,
digest: Vec<u8>,
included_serial_numbers: Vec<Vec<u8>>,
epoch_id: EpochId,
// vec of node ids of apis that haven't sent a valid response
pending: Vec<u64>,
}
impl PendingRedemptionVote {
fn new(
proposal_id: u64,
digest: Vec<u8>,
included_serial_numbers: Vec<Vec<u8>>,
epoch_id: EpochId,
pending: Vec<u64>,
) -> Self {
PendingRedemptionVote {
proposal_id,
digest,
included_serial_numbers,
epoch_id,
pending,
}
}
fn to_request_body(&self, gateway_cosmos_addr: AccountId) -> BatchRedeemTicketsBody {
BatchRedeemTicketsBody::new(
self.digest.clone(),
self.proposal_id,
self.included_serial_numbers.clone(),
gateway_cosmos_addr,
)
}
}
pub struct CredentialHandlerConfig {
/// Specifies the multiplier for revoking a malformed/double-spent ticket
/// (if it has to go all the way to the nym-api for verification)
@@ -132,7 +73,6 @@ pub struct CredentialHandler {
ticket_receiver: UnboundedReceiver<ClientTicket>,
shared_state: SharedState,
pending_tickets: Vec<PendingVerification>,
pending_redemptions: Vec<PendingRedemptionVote>,
}
impl CredentialHandler {
@@ -184,75 +124,6 @@ impl CredentialHandler {
Ok(pending)
}
async fn rebuild_pending_votes(
shared_state: &SharedState,
) -> Result<Vec<PendingRedemptionVote>, EcashTicketError> {
// 1. get all tickets that were not fully verified
let unverified = shared_state.storage.get_all_unresolved_proposals().await?;
let mut pending = Vec::with_capacity(unverified.len());
let epoch_id = shared_state.current_epoch_id().await?;
let apis = shared_state
.api_clients(epoch_id)
.await?
.iter()
.map(|s| (s.cosmos_address.to_string(), s.node_id))
.collect::<Vec<_>>();
for proposal_id in unverified {
// get all of the votes
let votes = shared_state
.start_query()
.await
.get_all_votes(proposal_id as u64)
.await
.map_err(EcashTicketError::chain_query_failure)?
.into_iter()
.map(|v| v.voter)
.collect::<HashSet<_>>();
let mut missing_votes = Vec::new();
// see who hasn't voted
for (api_address, api_id) in &apis {
// for each signer, check if they have actually voted; if not, that's the missing guy
if !votes.contains(api_address) {
missing_votes.push(*api_id)
}
}
// attempt to rebuild SN and digest from the proposal info + storage data
let proposal_info = shared_state
.start_query()
.await
.query_proposal(proposal_id as u64)
.await
.map_err(EcashTicketError::chain_query_failure)?;
let tickets = shared_state
.storage
.get_all_proposed_tickets_with_sn(proposal_id as u32)
.await?;
let digest =
BatchRedeemTicketsBody::make_digest(tickets.iter().map(|t| &t.serial_number));
let encoded_digest = bs58::encode(&digest).into_string();
if encoded_digest != proposal_info.description {
error!("the lost proposal {proposal_id} does not have a matching digest!");
continue;
}
pending.push(PendingRedemptionVote {
proposal_id: proposal_id as u64,
digest,
included_serial_numbers: tickets.into_iter().map(|t| t.serial_number).collect(),
epoch_id,
pending: missing_votes,
})
}
Ok(pending)
}
pub(crate) async fn new(
config: CredentialHandlerConfig,
ticket_receiver: UnboundedReceiver<ClientTicket>,
@@ -276,51 +147,15 @@ impl CredentialHandler {
// on startup read pending credentials and api responses from the storage
let pending_tickets = Self::rebuild_pending_tickets(&shared_state).await?;
// on startup read pending proposals from the storage
// then reconstruct the votes by querying the multisig contract for votes on those proposals
// digest from the description and count from the message
let pending_redemptions = Self::rebuild_pending_votes(&shared_state).await?;
Ok(CredentialHandler {
config,
multisig_threshold,
ticket_receiver,
shared_state,
pending_tickets,
pending_redemptions,
})
}
// the argument is temporary as we'll be reading from the storage
async fn create_redemption_proposal(
&self,
commitment: &[u8],
number_of_tickets: u16,
) -> Result<u64, EcashTicketError> {
let res = self
.shared_state
.start_tx()
.await
.request_ticket_redemption(
bs58::encode(commitment).into_string(),
number_of_tickets,
None,
)
.await
.map_err(|source| EcashTicketError::RedemptionProposalCreationFailure { source })?;
// that one is quite tricky because proposal exists on chain, but we didn't get the id...
// but it should be quite impossible to ever reach this unless we make breaking changes
let proposal_id = res
.parse_singleton_u64_contract_data()
.inspect_err(|err| error!("reached seemingly impossible error! could not recover the redemption proposal id: {err}"))
.map_err(|source| EcashTicketError::ProposalIdParsingFailure { source })?;
info!("created redemption proposal {proposal_id} to redeem {number_of_tickets} tickets");
Ok(proposal_id)
}
/// Attempt to send ticket verification request to the provided ecash verifier.
async fn verify_ticket(
&self,
@@ -522,42 +357,7 @@ impl CredentialHandler {
async fn resolve_pending(&mut self) -> Result<(), EcashTicketError> {
let mut still_failing = Vec::new();
// 1. attempt to resolve all pending proposals
while let Some(mut pending) = self.pending_redemptions.pop() {
match self.try_resolve_pending_proposal(&mut pending, None).await {
Ok(resolution) => {
if resolution.is_pending() {
warn!(
"still failed to reach quorum for proposal {}. apis: {:?} haven't responded. we'll retry later",
pending.proposal_id, pending.pending
);
still_failing.push(pending);
} else {
self.shared_state
.storage
.clear_post_proposal_data(
pending.proposal_id as u32,
OffsetDateTime::now_utc(),
resolution.is_rejected(),
)
.await?;
}
}
Err(err) => {
error!(
"experienced internal error when attempting to resolve pending proposal: {err}"
);
// make sure to update internal state to not lose any data
self.pending_redemptions.push(pending);
self.pending_redemptions.append(&mut still_failing);
return Err(err);
}
}
}
let mut still_failing = Vec::new();
// 2. attempt to verify the remaining tickets
// 1. attempt to verify the remaining tickets
while let Some(mut pending) = self.pending_tickets.pop() {
// possible optimisation: if there's a lot of pending tickets, pre-emptively grab locks for api_clients
match self
@@ -595,362 +395,14 @@ impl CredentialHandler {
Ok(())
}
/// Attempt to send batch redemption request to the provided ecash verifier.
async fn redeem_tickets(
&self,
proposal_id: u64,
request: &BatchRedeemTicketsBody,
client: &EcashApiClient,
) -> Result<bool, EcashTicketError> {
match client.api_client.batch_redeem_ecash_tickets(request).await {
Ok(res) => {
let accepted = if res.proposal_accepted {
trace!("{client} has accepted proposal {proposal_id}");
true
} else {
warn!("{client} has rejected proposal {proposal_id}");
false
};
Ok(accepted)
}
Err(err) => {
error!(
"failed to send proposal {proposal_id} for redemption vote to ecash signer '{client}': {err}. if we don't reach quorum, we'll retry later"
);
Ok(false)
}
}
}
async fn try_execute_proposal(&self, proposal_id: u64) -> Result<(), EcashTicketError> {
self.shared_state
.start_tx()
.await
.execute_proposal(proposal_id, None)
.await
.map_err(
|source| EcashTicketError::RedemptionProposalExecutionFailure {
proposal_id,
source,
},
)?;
Ok(())
}
async fn get_proposal_status(&self, proposal_id: u64) -> Result<Status, EcashTicketError> {
Ok(self
.shared_state
.start_query()
.await
.query_proposal(proposal_id)
.await
.map_err(EcashTicketError::chain_query_failure)?
.status)
}
async fn try_finalize_proposal(
&self,
proposal_id: u64,
) -> Result<ProposalResult, EcashTicketError> {
match self.get_proposal_status(proposal_id).await? {
Status::Pending => {
// the voting hasn't even begun!
error!("impossible case! the proposal {proposal_id} is still pending");
Ok(ProposalResult::Pending)
}
Status::Open => {
debug!("proposal {proposal_id} is still open and needs more votes");
Ok(ProposalResult::Pending)
}
Status::Rejected => {
warn!("proposal {proposal_id} has been rejected");
Ok(ProposalResult::Rejected)
}
Status::Passed => {
info!(
"proposal {proposal_id} has already been passed - we just need to execute it"
);
self.try_execute_proposal(proposal_id).await?;
info!("executed proposal {proposal_id}");
Ok(ProposalResult::Executed)
}
Status::Executed => {
info!("proposal {proposal_id} has already been executed - nothing to do!");
Ok(ProposalResult::Executed)
}
}
}
async fn try_resolve_pending_proposal(
&self,
pending: &mut PendingRedemptionVote,
api_clients: Option<RwLockReadGuard<'_, Vec<EcashApiClient>>>,
) -> Result<ProposalResult, EcashTicketError> {
let proposal_id = pending.proposal_id;
info!(
"attempting to resolve pending redemption proposal {proposal_id} to redeem {} tickets",
pending.included_serial_numbers.len()
);
// check if the proposal still needs more votes from the apis
let result = self.try_finalize_proposal(proposal_id).await?;
if !result.is_pending() {
return Ok(result);
}
let api_clients = match api_clients {
Some(clients) => clients,
None => self.shared_state.api_clients(pending.epoch_id).await?,
};
let redemption_request = pending.to_request_body(self.shared_state.address.clone());
// TODO: optimisation: tell other apis they can purge our tickets even if they haven't voted
let total = api_clients.len();
let api_failures = Mutex::new(Vec::new());
let rejected = AtomicUsize::new(0);
for_each_api_concurrent(&api_clients, &pending.pending, |ecash_client| async {
// errors are only returned on hard, storage, failures
match self
.redeem_tickets(pending.proposal_id, &redemption_request, ecash_client)
.await
{
Err(err) => {
error!("internal failure. could not proceed with ticket redemption: {err}");
api_failures.lock().await.push(ecash_client.node_id);
}
Ok(false) => {
rejected.fetch_add(1, Ordering::SeqCst);
}
_ => {}
}
})
.await;
let api_failures = api_failures.into_inner();
let num_failures = api_failures.len();
pending.pending = api_failures;
let rejected = rejected.into_inner();
let rejected_ratio = rejected as f32 / total as f32;
let rejected_perc = rejected_ratio * 100.;
if rejected_ratio >= (1. - self.multisig_threshold) {
error!(
"{rejected_perc:.2}% of signers rejected proposal {proposal_id}. we won't be able to execute it"
);
// no need to query the chain as with so many rejections it's impossible it has passed.
return Ok(ProposalResult::Rejected);
}
let accepted_ratio = (total - rejected - num_failures) as f32 / total as f32;
let accepted_perc = accepted_ratio * 100.;
match accepted_ratio {
n if n < self.multisig_threshold => {
error!(
"less than 2/3 of signers ({accepted_perc:.2}%) accepted proposal {proposal_id}. we're not yet be able to execute it to get funds out"
);
return Ok(ProposalResult::Pending);
}
n if n < self.config.minimum_api_quorum => {
warn!(
"the system seems to be a bit unstable: less than 80%, but more than 67% of signers ({accepted_perc:.2}%) accepted proposal {proposal_id}"
);
}
_ => {
trace!("{accepted_perc:.2}% of signers accepted proposal {proposal_id}");
}
}
// attempt to execute the proposal if it reached the required threshold
self.try_finalize_proposal(proposal_id).await
}
async fn maybe_redeem_tickets(&mut self) -> Result<(), EcashTicketError> {
if !self.pending_tickets.is_empty() {
return Err(EcashTicketError::PendingTickets);
}
let latest_stored = self.shared_state.storage.latest_proposal().await?;
// check if we have already created the proposal but crashed before persisting it in the db
//
// if we have some persisted proposals in storage, try to see if there's anything more recent on chain
// (i.e. the missing proposal)
// if not (i.e. this would have been our first) check the latest page of proposals.
// while this is not ideal, realistically speaking we probably crashed few minutes ago
// and worst case scenario we'll just recreate the proposal instead
//
// LIMITATION: if MULTIPLE proposals got created in between, well. though luck.
let latest_on_chain = if let Some(latest_stored) = &latest_stored {
// those are sorted in ASCENDING way
self.shared_state
.proposals_since(latest_stored.proposal_id as u64)
.await?
.pop()
} else {
// but those are DESCENDING
self.shared_state
.last_proposal_page()
.await?
.first()
.cloned()
};
let now = OffsetDateTime::now_utc();
let prior_proposal = match (&latest_stored, latest_on_chain) {
(None, None) => {
// we haven't created any proposals before
trace!("this could be our first redemption proposal");
None
}
(Some(stored), None) => {
if stored.created_at + MIN_BATCH_REDEMPTION_DELAY > now {
trace!("too soon to create new redemption proposal");
return Ok(());
}
None
}
(_, Some(on_chain)) => {
warn!(
"we seem to have crashed after creating proposal, but before persisting it onto disk!"
);
Some(on_chain)
}
};
// technically we could have been just caching all of those serial numbers as we verify tickets,
// but given how infrequently we call this, there's no point in wasting this memory
let verified_tickets = self
.shared_state
.storage
.get_all_verified_tickets_with_sn()
.await?;
// TODO: somehow simplify that nasty nested if
if verified_tickets.len() < self.config.minimum_redemption_tickets {
// bypass the number of tickets check if we're about to lose our rewards due to expiration
if let Some(latest_stored) = latest_stored {
if latest_stored.created_at + self.config.maximum_time_between_redemption < now {
{}
} else {
debug!(
"we only have {} verified tickets. there's no point in creating a redemption request yet. (we need at least {} (configurable))",
verified_tickets.len(),
self.config.minimum_redemption_tickets
);
return Ok(());
}
} else {
// first proposal
debug!(
"we only have {} verified tickets. there's no point in creating a redemption request yet. (we need at least {} (configurable))",
verified_tickets.len(),
self.config.minimum_redemption_tickets
);
return Ok(());
}
}
// this should have been ensured when querying
assert!(verified_tickets.len() <= u16::MAX as usize);
let digest =
BatchRedeemTicketsBody::make_digest(verified_tickets.iter().map(|t| &t.serial_number));
let encoded_digest = bs58::encode(&digest).into_string();
let prior_proposal_id = if let Some(prior_proposal) = prior_proposal {
if prior_proposal.description == encoded_digest {
info!("we have already created proposal for those tickets");
Some(prior_proposal.id)
} else {
warn!(
"our missed proposal seem to have been for different tickets - abandoning it"
);
None
}
} else {
None
};
// if the proposal has already existed on chain, do use it. otherwise create a new one
let proposal_id = if let Some(prior) = prior_proposal_id {
prior
} else {
self.create_redemption_proposal(&digest, verified_tickets.len() as u16)
.await?
};
if proposal_id > u32::MAX as u64 {
// realistically will we ever reach it? no.
panic!(
"we have created more than {} proposals. we can't handle that.",
u32::MAX
)
}
self.shared_state
.storage
.insert_redemption_proposal(
&verified_tickets,
proposal_id as u32,
OffsetDateTime::now_utc(),
)
.await?;
let current_epoch = self.shared_state.current_epoch_id().await?;
let api_clients = self.shared_state.api_clients(current_epoch).await?;
let ids = api_clients.iter().map(|c| c.node_id).collect();
let mut pending = PendingRedemptionVote::new(
proposal_id,
digest,
verified_tickets
.into_iter()
.map(|t| t.serial_number)
.collect(),
current_epoch,
ids,
);
let resolution = self
.try_resolve_pending_proposal(&mut pending, Some(api_clients))
.await?;
if resolution.is_pending() {
warn!(
"failed to reach quorum for proposal {proposal_id}. apis: {:?} haven't responded. we'll retry later",
pending.pending
);
self.pending_redemptions.push(pending);
} else {
self.shared_state
.storage
.clear_post_proposal_data(
proposal_id as u32,
OffsetDateTime::now_utc(),
resolution.is_rejected(),
)
.await?;
}
Ok(())
}
async fn periodic_operations(&mut self) -> Result<(), EcashTicketError> {
trace!(
"attempting to resolve all pending operations -> tickets that are waiting for verification and possibly redemption"
"attempting to resolve all pending operations -> tickets that are waiting for verification"
);
// 1. retry all operations that have failed in the past: verification requests and pending redemption
// retry the pending verification requests that have failed before
self.resolve_pending().await?;
// 2. if applicable, attempt to redeem all newly verified tickets
self.maybe_redeem_tickets().await?;
Ok(())
}
@@ -7,7 +7,7 @@ use std::future::Future;
use std::ops::Deref;
use tokio::sync::RwLockReadGuard;
pub(crate) fn apis_stream<'a>(
pub fn apis_stream<'a>(
// if needed we could make this argument more generic to accept either locks or iterators, etc.
all_clients: &'a RwLockReadGuard<'a, Vec<EcashApiClient>>,
filter_by_id: &'a [u64],
@@ -22,7 +22,7 @@ pub(crate) fn apis_stream<'a>(
)
}
pub(crate) async fn for_each_api_concurrent<'a, F, Fut>(
pub async fn for_each_api_concurrent<'a, F, Fut>(
all_clients: &'a RwLockReadGuard<'a, Vec<EcashApiClient>>,
filter_by_id: &'a [u64],
f: F,
@@ -20,7 +20,7 @@ use tracing::error;
pub mod credential_sender;
pub mod error;
mod helpers;
pub mod helpers;
mod state;
pub mod traits;
@@ -3,17 +3,12 @@
use crate::Error;
use crate::ecash::error::EcashTicketError;
use cosmwasm_std::{CosmosMsg, WasmMsg, from_json};
use nym_credentials_interface::VerificationKeyAuth;
use nym_ecash_contract_common::msg::ExecuteMsg;
use nym_gateway_storage::traits::BandwidthGatewayStorage;
use nym_validator_client::coconut::all_ecash_api_clients;
use nym_validator_client::nym_api::EpochId;
use nym_validator_client::nyxd::AccountId;
use nym_validator_client::nyxd::contract_traits::{
DkgQueryClient, MultisigQueryClient, NymContractsProvider,
};
use nym_validator_client::nyxd::cw3::ProposalResponse;
use nym_validator_client::nyxd::contract_traits::{DkgQueryClient, NymContractsProvider};
use nym_validator_client::{DirectSigningHttpRpcNyxdClient, EcashApiClient};
use std::collections::BTreeMap;
use std::ops::Deref;
@@ -77,53 +72,6 @@ impl SharedState {
Ok(this)
}
fn created_redemption_proposal(&self, proposal: &ProposalResponse) -> bool {
let Some(msg) = proposal.msgs.first() else {
return false;
};
let CosmosMsg::Wasm(WasmMsg::Execute { msg, .. }) = msg else {
return false;
};
let Ok(ExecuteMsg::RedeemTickets { gw, .. }) = from_json(msg) else {
return false;
};
gw == self.address.as_ref()
}
/// retrieve all redemption proposals made by this gateway since, but excluding, the provided id
pub(crate) async fn proposals_since(
&self,
proposal_id: u64,
) -> Result<Vec<ProposalResponse>, EcashTicketError> {
Ok(self
.start_query()
.await
.list_proposals(Some(proposal_id), None)
.await
.map_err(EcashTicketError::chain_query_failure)?
.proposals
.into_iter()
.filter(|p| self.created_redemption_proposal(p))
.collect())
}
/// retrieve all redemption proposals made by this gateway that are available on the last page of the query
pub(crate) async fn last_proposal_page(
&self,
) -> Result<Vec<ProposalResponse>, EcashTicketError> {
Ok(self
.start_query()
.await
.reverse_proposals(None, None)
.await
.map_err(EcashTicketError::chain_query_failure)?
.proposals
.into_iter()
.filter(|p| self.created_redemption_proposal(p))
.collect())
}
async fn set_epoch_data(
&self,
epoch_id: EpochId,
@@ -240,24 +188,6 @@ impl SharedState {
data.get(&epoch_id).map(|d| &d.master_key).unwrap()
}))
}
pub(crate) async fn start_tx(&self) -> RwLockWriteGuard<'_, DirectSigningHttpRpcNyxdClient> {
self.nyxd_client.write().await
}
pub(crate) async fn start_query(&self) -> RwLockReadGuard<'_, DirectSigningHttpRpcNyxdClient> {
self.nyxd_client.read().await
}
pub(crate) async fn current_epoch_id(&self) -> Result<EpochId, EcashTicketError> {
Ok(self
.start_query()
.await
.get_current_epoch()
.await
.map_err(EcashTicketError::chain_query_failure)?
.epoch_id)
}
}
pub(crate) struct EpochState {
+1
View File
@@ -34,6 +34,7 @@ tracing = { workspace = true }
itertools = { workspace = true }
inventory = { workspace = true }
tokio = { workspace = true, features = ["rt", "macros", "time"] }
rustls = { workspace=true }
# used for decoding text responses (they were already implicitly included)
bytes = { workspace = true }
encoding_rs = { workspace = true }
+1 -1
View File
@@ -4,7 +4,7 @@ use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
pub const NYM_API_DOMAIN: &str = "validator.nymtech.net";
pub const NYM_API_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(212, 71, 233, 232))];
pub const NYM_API_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(92, 39, 63, 14))];
pub const NYM_VPN_API_DOMAIN: &str = "nymvpn.com";
pub const NYM_VPN_API_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(76, 76, 21, 21))];
+65 -5
View File
@@ -161,6 +161,8 @@ use reqwest::{RequestBuilder, Response};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::fmt::Display;
#[cfg(not(target_arch = "wasm32"))]
use std::io::ErrorKind;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use thiserror::Error;
@@ -1167,14 +1169,10 @@ impl ApiClientCore for Client {
match response {
Ok(resp) => return Ok(resp),
Err(err) => {
// only if there was a network issue should we consider updating the host info
//
// note: for now this includes DNS resolution failure, I am not sure how I would go about
// segregating that based on the interface provided by request for errors.
#[cfg(target_arch = "wasm32")]
let is_network_err = err.is_timeout();
#[cfg(not(target_arch = "wasm32"))]
let is_network_err = err.is_timeout() || err.is_connect();
let is_network_err = might_be_network_interference(&err);
if is_network_err {
// if we have multiple urls, update to the next
@@ -1222,6 +1220,68 @@ impl ApiClientCore for Client {
}
}
#[cfg(not(target_arch = "wasm32"))]
const MAX_ERR_SOURCE_ITERATIONS: usize = 4;
/// This functions attempts to check the error returned by reqwest to see if
/// rotating host informtion (for clients with mutliple hosts defined) could be
/// helpful. This looks for situations where the error could plausibly be caused
/// by a network adversary, or where rotating to an equival hostname might help.
///
/// For example --> NetworkUnreachable will not be helped by rotating domains,
/// but ConnectionReset might be caused by a network adversary blocking by SNI
/// which could possibly benefit from rotating domains.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn might_be_network_interference(err: &reqwest::Error) -> bool {
if err.is_timeout() {
return true;
}
if !(err.is_connect() || err.is_request()) {
return false;
}
// The io::Error source is several layers deep, for clarity this is done as a loop
// * reqwest::Error -> hyper_util::Error
// * hyper_util::Error -> hyper_util::ClientError
// * hyper_util::ClientError -> io::Error
let mut inner = err.source();
for _ in 0..MAX_ERR_SOURCE_ITERATIONS {
if let Some(e) = inner {
if let Some(io_err) = e.downcast_ref::<std::io::Error>() {
// try downcast to io::Error from <dyn std::error:Error>
match io_err.kind() {
// device not connected to the internet
ErrorKind::NetworkUnreachable | ErrorKind::NetworkDown => return false,
// connection errors can indicate connection interference
ErrorKind::ConnectionReset
| ErrorKind::HostUnreachable
| ErrorKind::ConnectionRefused => return true,
// TLS errors get wrapped in custom io::Errors
ErrorKind::Other | ErrorKind::InvalidData => {
// io::Error get_ref works while source doesn't here -_-
// if you don't like it take it up with the rust devs https://users.rust-lang.org/t/question-about-implementation-of-std-source/121117
inner = io_err.get_ref().map(|e| e as &dyn std::error::Error);
}
_ => return false,
}
} else if let Some(_tls_err) = e.downcast_ref::<rustls::Error>() {
// try downcast to TLS error
return true;
} else if let Some(resolve_err) = e.downcast_ref::<hickory_resolver::ResolveError>() {
// try downcast to DNS error
return resolve_err.is_nx_domain();
} else {
inner = e.source();
}
} else {
break;
}
}
false
}
/// Common usage functionality for the http client.
///
/// These functions allow for cleaner downstream usage free of type parameters and unneeded imports.
+6
View File
@@ -10,7 +10,11 @@ license.workspace = true
description = "Codec, signing functionality, and different version definitions for IP packet request and responses"
[features]
test-utils = ["pnet_packet"]
[dependencies]
pnet_packet = { workspace = true, optional = true }
bincode = { workspace = true }
bytes = { workspace = true }
nym-bin-common = { workspace = true }
@@ -18,8 +22,10 @@ nym-crypto = { workspace = true }
nym-service-provider-requests-common = { workspace = true }
nym-sphinx = { workspace = true }
rand = { workspace = true }
semver = { workspace = true }
serde = { workspace = true, features = ["derive"] }
thiserror = { workspace = true }
time = { workspace = true }
tokio = { workspace = true, features = ["time"] }
tokio-util = { workspace = true, features = ["codec"] }
tracing = { workspace = true }
+117
View File
@@ -0,0 +1,117 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
// Extracted from sdk/rust/nym-sdk/examples/ipr_tunnel.rs
//! ICMP/ICMPv6 packet construction and reply detection helpers for testing
//! IPR connectivity. Gated behind the `test-utils` feature.
use std::net::{Ipv4Addr, Ipv6Addr};
use pnet_packet::Packet;
use pnet_packet::icmp::echo_reply::EchoReplyPacket;
use pnet_packet::icmp::echo_request::MutableEchoRequestPacket;
use pnet_packet::icmp::{IcmpPacket, IcmpTypes};
use pnet_packet::icmpv6::Icmpv6Types;
use pnet_packet::ipv4::{Ipv4Flags, MutableIpv4Packet};
use pnet_packet::ipv6::MutableIpv6Packet;
/// Build a complete IPv4 ICMP echo request packet.
pub fn build_icmp_ping(src: Ipv4Addr, dst: Ipv4Addr, seq: u16) -> Option<Vec<u8>> {
let mut echo = MutableEchoRequestPacket::owned(vec![0u8; 64])?;
echo.set_icmp_type(IcmpTypes::EchoRequest);
echo.set_icmp_code(pnet_packet::icmp::IcmpCode::new(0));
echo.set_sequence_number(seq);
let cksum = pnet_packet::icmp::checksum(&IcmpPacket::new(echo.packet())?);
echo.set_checksum(cksum);
let total_len = 20 + echo.packet().len();
let mut ip = MutableIpv4Packet::owned(vec![0u8; total_len])?;
ip.set_version(4);
ip.set_header_length(5);
ip.set_total_length(total_len as u16);
ip.set_ttl(64);
ip.set_next_level_protocol(pnet_packet::ip::IpNextHeaderProtocols::Icmp);
ip.set_source(src);
ip.set_destination(dst);
ip.set_flags(Ipv4Flags::DontFragment);
ip.set_payload(echo.packet());
let mut buf = ip.consume_to_immutable().packet().to_vec();
let cksum = ipv4_checksum(&buf);
buf[10] = (cksum >> 8) as u8;
buf[11] = cksum as u8;
Some(buf)
}
/// Build a complete IPv6 ICMPv6 echo request packet.
pub fn build_icmpv6_ping(src: Ipv6Addr, dst: Ipv6Addr, seq: u16) -> Option<Vec<u8>> {
let mut echo =
pnet_packet::icmpv6::echo_request::MutableEchoRequestPacket::owned(vec![0u8; 64])?;
echo.set_icmpv6_type(Icmpv6Types::EchoRequest);
echo.set_icmpv6_code(pnet_packet::icmpv6::Icmpv6Code::new(0));
echo.set_sequence_number(seq);
let cksum = pnet_packet::icmpv6::checksum(
&pnet_packet::icmpv6::Icmpv6Packet::new(echo.packet())?,
&src,
&dst,
);
echo.set_checksum(cksum);
let payload_len = echo.packet().len();
let mut ip = MutableIpv6Packet::owned(vec![0u8; 40 + payload_len])?;
ip.set_version(6);
ip.set_payload_length(payload_len as u16);
ip.set_next_header(pnet_packet::ip::IpNextHeaderProtocols::Icmpv6);
ip.set_hop_limit(64);
ip.set_source(src);
ip.set_destination(dst);
ip.set_payload(echo.packet());
Some(ip.consume_to_immutable().packet().to_vec())
}
/// Check if a raw packet is an IPv4 ICMP echo reply destined to `expected_dst`.
pub fn is_echo_reply_v4(data: &[u8], expected_dst: Ipv4Addr) -> bool {
let Some(ip) = pnet_packet::ipv4::Ipv4Packet::new(data) else {
return false;
};
if ip.get_destination() != expected_dst {
return false;
}
if ip.get_next_level_protocol() != pnet_packet::ip::IpNextHeaderProtocols::Icmp {
return false;
}
let Some(reply) = EchoReplyPacket::new(ip.payload()) else {
return false;
};
reply.get_icmp_type() == IcmpTypes::EchoReply
}
/// Check if a raw packet is an IPv6 ICMPv6 echo reply destined to `expected_dst`.
pub fn is_echo_reply_v6(data: &[u8], expected_dst: Ipv6Addr) -> bool {
let Some(ip) = pnet_packet::ipv6::Ipv6Packet::new(data) else {
return false;
};
if ip.get_destination() != expected_dst {
return false;
}
if ip.get_next_header() != pnet_packet::ip::IpNextHeaderProtocols::Icmpv6 {
return false;
}
let Some(reply) = pnet_packet::icmpv6::echo_reply::EchoReplyPacket::new(ip.payload()) else {
return false;
};
reply.get_icmpv6_type() == Icmpv6Types::EchoReply
}
fn ipv4_checksum(header: &[u8]) -> u16 {
let mut sum = 0u32;
for i in (0..20).step_by(2) {
sum += ((header[i] as u32) << 8) | header[i + 1] as u32;
}
while (sum >> 16) > 0 {
sum = (sum & 0xFFFF) + (sum >> 16);
}
!sum as u16
}
+6
View File
@@ -3,10 +3,14 @@ use std::fmt::{Display, Formatter};
use std::net::{Ipv4Addr, Ipv6Addr};
pub mod codec;
#[cfg(feature = "test-utils")]
pub mod icmp_utils;
pub mod response_helpers;
pub mod sign;
pub mod v6;
pub mod v7;
pub mod v8;
pub mod v9;
// version 3: initial version
// version 4: IPv6 support
@@ -14,6 +18,8 @@ pub mod v8;
// version 6: Increase the available IPs
// version 7: Add signature support (for the future)
// version 8: Anonymous sends
// version 9: LP-framed transport (SphinxStream)
// response_helpers: shared IPR response parsing (nym-ip-packet-client + nym-sdk)
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct IpPair {
@@ -0,0 +1,134 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use bytes::{Bytes, BytesMut};
use tokio_util::codec::Decoder;
use tracing::{error, info, warn};
use crate::{
IpPair,
codec::MultiIpPacketCodec,
v8::response::{
ConnectResponseReply, ControlResponse, InfoLevel, IpPacketResponse, IpPacketResponseData,
},
};
#[derive(Debug, thiserror::Error)]
pub enum IprResponseError {
#[error("no version byte in message")]
NoVersionByte,
#[error("version mismatch: received v{received}, expected v{expected}")]
VersionMismatch { expected: u8, received: u8 },
#[error("expected control response, got {0:?}")]
UnexpectedResponse(IpPacketResponseData),
#[error("connect denied: {0:?}")]
ConnectDenied(crate::v8::response::ConnectFailureReason),
}
pub enum MixnetMessageOutcome {
IpPackets(Vec<Bytes>),
Disconnect,
}
// Extracted from:
// nym-ip-packet-client/src/helpers.rs — check_ipr_message_version()
// sdk/rust/nym-sdk/src/ip_packet_client/listener.rs — check_ipr_message_version()
/// Check that the first byte of an IPR message matches the expected protocol version.
pub fn check_ipr_message_version(data: &[u8], expected: u8) -> Result<(), IprResponseError> {
let version = data.first().ok_or(IprResponseError::NoVersionByte)?;
if *version != expected {
return Err(IprResponseError::VersionMismatch {
expected,
received: *version,
});
}
Ok(())
}
// Extracted from:
// nym-ip-packet-client/src/connect.rs — handle_connect_response() + handle_ip_packet_router_response()
// sdk/rust/nym-sdk/src/ip_packet_client/discovery.rs — parse_connect_response()
/// Parse an IPR connect response, returning allocated IPs on success.
pub fn parse_connect_response(response: IpPacketResponse) -> Result<IpPair, IprResponseError> {
let control_response = match response.data {
IpPacketResponseData::Control(c) => c,
other => return Err(IprResponseError::UnexpectedResponse(other)),
};
match *control_response {
ControlResponse::Connect(connect_resp) => match connect_resp.reply {
ConnectResponseReply::Success(success) => Ok(success.ips),
ConnectResponseReply::Failure(reason) => Err(IprResponseError::ConnectDenied(reason)),
},
_ => Err(IprResponseError::UnexpectedResponse(
IpPacketResponseData::Control(control_response),
)),
}
}
// Extracted from:
// nym-ip-packet-client/src/listener.rs — IprListener::handle_reconstructed_message()
// sdk/rust/nym-sdk/src/ip_packet_client/listener.rs — handle_ipr_response()
/// Parse raw IPR response bytes into an outcome.
///
/// Logs non-fatal conditions (unknown control messages, deserialization
/// failures) and returns `None` for them.
pub fn handle_ipr_response(data: &[u8]) -> Option<MixnetMessageOutcome> {
match IpPacketResponse::from_bytes(data) {
Ok(response) => match response.data {
IpPacketResponseData::Data(data_response) => {
let mut codec = MultiIpPacketCodec::new();
let mut buf = BytesMut::from(data_response.ip_packet.as_ref());
let mut packets = Vec::new();
loop {
match codec.decode(&mut buf) {
Ok(Some(packet)) => packets.push(packet.into_bytes()),
Ok(None) => break,
Err(e) => {
warn!("Failed to decode bundled IP packet: {e}");
break;
}
}
}
Some(MixnetMessageOutcome::IpPackets(packets))
}
IpPacketResponseData::Control(control_response) => match *control_response {
ControlResponse::Connect(_) => {
info!("Received connect response when already connected - ignoring");
None
}
ControlResponse::Disconnect(_) | ControlResponse::UnrequestedDisconnect(_) => {
info!("Received disconnect from IPR");
Some(MixnetMessageOutcome::Disconnect)
}
ControlResponse::Pong(_) => {
info!("Received pong response");
None
}
ControlResponse::Health(_) => {
info!("Received health response");
None
}
ControlResponse::Info(info_resp) => {
let msg = format!(
"Received info response from the mixnet: {}",
info_resp.reply
);
match info_resp.level {
InfoLevel::Info => info!("{msg}"),
InfoLevel::Warn => warn!("{msg}"),
InfoLevel::Error => error!("{msg}"),
}
None
}
},
},
Err(err) => {
warn!("Failed to deserialize IPR response: {err}");
None
}
}
}
+6 -2
View File
@@ -179,11 +179,15 @@ impl IpPacketResponse {
make_bincode_serializer().serialize(self)
}
pub fn from_bytes(data: &[u8]) -> Result<Self, bincode::Error> {
use bincode::Options;
make_bincode_serializer().deserialize(data)
}
pub fn from_reconstructed_message(
message: &nym_sphinx::receiver::ReconstructedMessage,
) -> Result<Self, bincode::Error> {
use bincode::Options;
make_bincode_serializer().deserialize(&message.message)
Self::from_bytes(&message.message)
}
}
+34
View File
@@ -0,0 +1,34 @@
pub const VERSION: u8 = 9;
/// Minimum nym-node release version that supports v9 (LP Stream framing).
/// Nodes running older versions will not understand LP-wrapped packets.
pub const MIN_RELEASE_VERSION: semver::Version = semver::Version::new(1, 30, 0);
// v9 uses the same wire format as v8. The version bump indicates
// the message was sent with LP framing (SphinxStream).
//
// Types are re-exported for deserialization/matching. Use the wrapper
// constructors below to create correctly-versioned packets — never
// manually set `protocol.version` or `response.version`.
pub use super::v8::{request, response};
/// Create a v9 connect request (version byte set to 9).
pub fn new_connect_request(buffer_timeout: Option<u64>) -> (request::IpPacketRequest, u64) {
let (mut req, id) = request::IpPacketRequest::new_connect_request(buffer_timeout);
req.protocol.version = VERSION;
(req, id)
}
/// Create a v9 data request (version byte set to 9).
pub fn new_data_request(data: bytes::Bytes) -> request::IpPacketRequest {
let mut req = request::IpPacketRequest::new_data_request(data);
req.protocol.version = VERSION;
req
}
/// Create a v9 IP packet response (version byte set to 9).
pub fn new_ip_packet_response(ip_packet: bytes::Bytes) -> response::IpPacketResponse {
let mut resp = response::IpPacketResponse::new_ip_packet(ip_packet);
resp.version = VERSION;
resp
}
@@ -1,3 +1,6 @@
// Copyright 2026 Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use std::fmt::{self, Write};
pub fn format_debug_bytes(bytes: &[u8]) -> Result<String, fmt::Error> {
+1
View File
@@ -2,6 +2,7 @@
// Copyright 2024 Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
pub mod debug;
mod error;
pub mod flood;
+2 -1
View File
@@ -21,6 +21,7 @@ tls_codec = { workspace = true }
tokio = { workspace = true, features = ["net", "io-util"] }
nym-crypto = { workspace = true, features = ["hashing"] }
nym-common.workspace = true
nym-kkt = { workspace = true }
nym-kkt-ciphersuite = { workspace = true }
@@ -45,4 +46,4 @@ mock = ["nym-test-utils"]
[[bench]]
name = "replay_protection"
harness = false
harness = false
+69 -2
View File
@@ -9,13 +9,13 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
#[derive(Debug, Clone, PartialEq)]
pub struct LpFrameHeader {
pub kind: LpFrameKind,
pub frame_attributes: [u8; 14],
pub frame_attributes: LpFrameAttributes,
}
impl LpFrameHeader {
pub const SIZE: usize = 16; // message_kind(2) + message_attributes(14)
pub fn new(kind: LpFrameKind, frame_attributes: [u8; 14]) -> Self {
pub fn new(kind: LpFrameKind, frame_attributes: LpFrameAttributes) -> Self {
Self {
kind,
frame_attributes,
@@ -103,6 +103,13 @@ impl LpFrame {
Self::new(LpFrameKind::Forward, data)
}
pub fn new_stream(attrs: SphinxStreamFrameAttributes, content: impl Into<Bytes>) -> Self {
Self {
header: LpFrameHeader::new(LpFrameKind::SphinxStream, attrs.encode()),
content: content.into(),
}
}
pub(crate) fn len(&self) -> usize {
LpFrameHeader::SIZE + self.content.len()
}
@@ -115,6 +122,66 @@ pub enum LpFrameKind {
Opaque = 0,
Registration = 1,
Forward = 2,
SphinxStream = 3,
}
/// Message type within a `LpFrameKind::SphinxStream` frame.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum SphinxStreamMsgType {
/// Open a new stream. Content is optional initial data.
Open = 0,
/// Data on an existing stream.
Data = 1,
}
/// Parsed form of the 14-byte `frame_attributes` for `LpFrameKind::SphinxStream`.
///
/// Wire layout (big-endian):
/// ```text
/// [0..8 ) stream_id : u64
/// [8 ) msg_type : u8 (0 = Open, 1 = Data)
/// [9..13) sequence_num : u32
/// [13 ) reserved : u8
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SphinxStreamFrameAttributes {
pub stream_id: u64,
pub msg_type: SphinxStreamMsgType,
pub sequence_num: u32,
}
/// Raw 14-byte frame attributes field in every [`LpFrameHeader`].
/// Interpretation depends on the [`LpFrameKind`].
pub type LpFrameAttributes = [u8; 14];
impl SphinxStreamFrameAttributes {
pub fn encode(&self) -> LpFrameAttributes {
let mut buf = [0u8; 14];
buf[0..8].copy_from_slice(&self.stream_id.to_be_bytes());
buf[8] = self.msg_type as u8;
buf[9..13].copy_from_slice(&self.sequence_num.to_be_bytes());
buf
}
pub fn parse(attrs: &LpFrameAttributes) -> Result<Self, MalformedLpPacketError> {
let stream_id = u64::from_be_bytes(attrs[0..8].try_into().unwrap());
let msg_type = match attrs[8] {
0 => SphinxStreamMsgType::Open,
1 => SphinxStreamMsgType::Data,
other => {
return Err(MalformedLpPacketError::DeserialisationFailure(format!(
"invalid stream msg_type: {other}"
)));
}
};
let sequence_num = u32::from_be_bytes(attrs[9..13].try_into().unwrap());
Ok(Self {
stream_id,
msg_type,
sequence_num,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
+2 -2
View File
@@ -1,10 +1,11 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::packet::utils::format_debug_bytes;
use bytes::{BufMut, BytesMut};
use std::fmt::{Debug, Formatter};
use nym_common::debug::format_debug_bytes;
pub use error::MalformedLpPacketError;
pub use frame::{ForwardPacketData, LpFrame};
pub use header::{InnerHeader, LpHeader, OuterHeader};
@@ -13,7 +14,6 @@ pub mod error;
pub mod frame;
pub mod header;
pub mod replay;
pub mod utils;
pub mod version {
/// The current version of the Lewes Protocol that is put into each new constructed header.
-16
View File
@@ -119,22 +119,6 @@ where
Ok(scraper)
}
pub async fn build_unsafe(self) -> Result<NyxdScraper<S>, ScraperError> {
self.config.pruning_options.validate()?;
let storage =
S::initialise(&self.config.database_storage, &self.config.run_migrations).await?;
let rpc_client = RpcClient::new(&self.config.rpc_url)?;
Ok(NyxdScraper {
config: self.config,
task_tracker: TaskTracker::new(),
cancel_token: CancellationToken::new(),
startup_sync: Arc::new(Default::default()),
storage,
rpc_client,
})
}
pub fn new(config: Config) -> Self {
NyxdScraperBuilder {
_storage: PhantomData,
+3 -2
View File
@@ -7,6 +7,7 @@ use nym_crypto::asymmetric::{ed25519, x25519};
use nym_ip_packet_requests::IpPair;
use nym_kkt_ciphersuite::{Ciphersuite, KEM, KEMKeyDigests};
use nym_sphinx::addressing::Recipient;
use nym_wireguard_types::PresharedKey;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
@@ -44,11 +45,11 @@ pub struct WireguardRegistrationData {
pub private_ipv6: Ipv6Addr,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub struct WireguardConfiguration {
#[serde(with = "bs58_x25519_pubkey")]
pub public_key: x25519::PublicKey,
pub psk: Option<[u8; 32]>,
pub psk: Option<PresharedKey>,
pub endpoint: SocketAddr,
pub private_ipv4: Ipv4Addr,
pub private_ipv6: Ipv6Addr,
+1
View File
@@ -15,6 +15,7 @@ description = "Wireguard public key and config definitions"
base64 = { workspace = true }
serde = { workspace = true, features = ["derive"] }
thiserror = { workspace = true }
zeroize.workspace = true
x25519-dalek = { workspace = true, features = ["static_secrets"] }
nym-crypto = { workspace = true, features = ["asymmetric"] }
+2
View File
@@ -3,12 +3,14 @@
pub mod config;
pub mod error;
pub mod preshared_key;
pub mod public_key;
use std::time::Duration;
pub use config::Config;
pub use error::Error;
pub use preshared_key::PresharedKey;
pub use public_key::PeerPublicKey;
pub const DEFAULT_PEER_TIMEOUT_CHECK: Duration = Duration::from_secs(5); // 5 seconds
@@ -0,0 +1,26 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use serde::{Deserialize, Serialize};
use zeroize::{Zeroize, ZeroizeOnDrop};
#[derive(Debug, PartialEq, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
pub struct PresharedKey([u8; 32]);
impl PresharedKey {
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
impl From<[u8; 32]> for PresharedKey {
fn from(key: [u8; 32]) -> PresharedKey {
PresharedKey(key)
}
}
impl From<PresharedKey> for [u8; 32] {
fn from(key: PresharedKey) -> [u8; 32] {
key.0
}
}
+20 -7
View File
@@ -709,7 +709,7 @@ dependencies = [
"nym-contracts-common",
"nym-contracts-common-testing",
"nym-group-contract-common",
"nym-multisig-contract-common",
"nym-multisig-contract-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
@@ -1258,7 +1258,7 @@ dependencies = [
"cw2",
"cw4",
"nym-contracts-common",
"nym-multisig-contract-common",
"nym-multisig-contract-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
@@ -1329,11 +1329,11 @@ dependencies = [
"cw3",
"cw4",
"nym-contracts-common",
"nym-contracts-common-testing",
"nym-crypto",
"nym-ecash-contract-common",
"nym-multisig-contract-common",
"nym-multisig-contract-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)",
"nym-network-defaults",
"rand_chacha",
"schemars",
"semver",
"serde",
@@ -1344,8 +1344,6 @@ dependencies = [
[[package]]
name = "nym-ecash-contract-common"
version = "1.20.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d022e85291bf51877fbbf4688bc3762c605fdaee3b98c6407414f7c358bc5610"
dependencies = [
"bs58",
"cosmwasm-schema",
@@ -1353,7 +1351,7 @@ dependencies = [
"cw-controllers",
"cw-utils",
"cw2",
"nym-multisig-contract-common",
"nym-multisig-contract-common 1.20.4",
"thiserror 2.0.12",
]
@@ -1416,6 +1414,21 @@ dependencies = [
"time",
]
[[package]]
name = "nym-multisig-contract-common"
version = "1.20.4"
dependencies = [
"cosmwasm-schema",
"cosmwasm-std",
"cw-storage-plus",
"cw-utils",
"cw3",
"cw4",
"schemars",
"serde",
"thiserror 2.0.12",
]
[[package]]
name = "nym-multisig-contract-common"
version = "1.20.4"
+3
View File
@@ -96,3 +96,6 @@ unreachable = "deny"
# For local development, import via path instead of crates.io, e.g.
# [patch.crates-io]
# nym-coconut-dkg-common = { path = "../common/cosmwasm-smart-contracts/coconut-dkg" }
[patch.crates-io]
nym-ecash-contract-common = { path = "../common/cosmwasm-smart-contracts/ecash-contract" }
+5 -1
View File
@@ -39,8 +39,12 @@ nym-network-defaults = { workspace = true, default-features = false }
anyhow = { workspace = true }
sylvia = { workspace = true, features = ["mt"] }
nym-crypto = { workspace = true, features = ["rand", "asymmetric"] }
rand_chacha = "0.3"
cw-multi-test = { workspace = true }
nym-contracts-common-testing = { workspace = true }
[features]
schema-gen = ["nym-ecash-contract-common/schema"]
[lints]
workspace = true
+351 -19
View File
@@ -156,10 +156,10 @@
{
"type": "object",
"required": [
"update_deposit_value"
"update_default_deposit_value"
],
"properties": {
"update_deposit_value": {
"update_default_deposit_value": {
"type": "object",
"required": [
"new_deposit"
@@ -174,6 +174,54 @@
},
"additionalProperties": false
},
{
"description": "Set (or overwrite) a reduced deposit price for a specific address. Only callable by the contract admin.",
"type": "object",
"required": [
"set_reduced_deposit_price"
],
"properties": {
"set_reduced_deposit_price": {
"type": "object",
"required": [
"address",
"deposit"
],
"properties": {
"address": {
"type": "string"
},
"deposit": {
"$ref": "#/definitions/Coin"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"description": "Remove the reduced deposit price for a specific address, reverting them to the default price. Returns an error if the address has no custom price set. Only callable by the contract admin.",
"type": "object",
"required": [
"remove_reduced_deposit_price"
],
"properties": {
"remove_reduced_deposit_price": {
"type": "object",
"required": [
"address"
],
"properties": {
"address": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
@@ -297,10 +345,44 @@
{
"type": "object",
"required": [
"get_required_deposit_amount"
"get_default_deposit_amount"
],
"properties": {
"get_required_deposit_amount": {
"get_default_deposit_amount": {
"type": "object",
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"get_reduced_deposit_amount"
],
"properties": {
"get_reduced_deposit_amount": {
"type": "object",
"required": [
"address"
],
"properties": {
"address": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"get_all_whitelisted_accounts"
],
"properties": {
"get_all_whitelisted_accounts": {
"type": "object",
"additionalProperties": false
}
@@ -373,6 +455,19 @@
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"get_deposits_statistics"
],
"properties": {
"get_deposits_statistics": {
"type": "object",
"additionalProperties": false
}
},
"additionalProperties": false
}
]
},
@@ -380,10 +475,120 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "MigrateMsg",
"type": "object",
"additionalProperties": false
"required": [
"initial_whitelist"
],
"properties": {
"initial_whitelist": {
"description": "Initial set of whitelisted accounts with their reduced deposit prices. Each entry is validated and stored during migration.",
"type": "array",
"items": {
"$ref": "#/definitions/WhitelistedDeposit"
}
}
},
"additionalProperties": false,
"definitions": {
"Coin": {
"type": "object",
"required": [
"amount",
"denom"
],
"properties": {
"amount": {
"$ref": "#/definitions/Uint128"
},
"denom": {
"type": "string"
}
},
"additionalProperties": false
},
"Uint128": {
"description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```",
"type": "string"
},
"WhitelistedDeposit": {
"description": "An address and its reduced deposit price, used when seeding the whitelist via migration.",
"type": "object",
"required": [
"address",
"deposit"
],
"properties": {
"address": {
"type": "string"
},
"deposit": {
"$ref": "#/definitions/Coin"
}
},
"additionalProperties": false
}
}
},
"sudo": null,
"responses": {
"get_all_whitelisted_accounts": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "WhitelistedAccountsResponse",
"type": "object",
"required": [
"whitelisted_accounts"
],
"properties": {
"whitelisted_accounts": {
"type": "array",
"items": {
"$ref": "#/definitions/WhitelistedAccount"
}
}
},
"additionalProperties": false,
"definitions": {
"Addr": {
"description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.",
"type": "string"
},
"Coin": {
"type": "object",
"required": [
"amount",
"denom"
],
"properties": {
"amount": {
"$ref": "#/definitions/Uint128"
},
"denom": {
"type": "string"
}
},
"additionalProperties": false
},
"Uint128": {
"description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```",
"type": "string"
},
"WhitelistedAccount": {
"type": "object",
"required": [
"address",
"deposit"
],
"properties": {
"address": {
"$ref": "#/definitions/Addr"
},
"deposit": {
"$ref": "#/definitions/Coin"
}
},
"additionalProperties": false
}
}
},
"get_blacklist_paged": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PagedBlacklistedAccountResponse",
@@ -496,6 +701,30 @@
}
}
},
"get_default_deposit_amount": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Coin",
"type": "object",
"required": [
"amount",
"denom"
],
"properties": {
"amount": {
"$ref": "#/definitions/Uint128"
},
"denom": {
"type": "string"
}
},
"additionalProperties": false,
"definitions": {
"Uint128": {
"description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```",
"type": "string"
}
}
},
"get_deposit": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "DepositResponse",
@@ -594,6 +823,99 @@
}
}
},
"get_deposits_statistics": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "DepositsStatistics",
"description": "Aggregate statistics about all deposits made through the ecash contract.",
"type": "object",
"required": [
"deposited_with_custom_price",
"deposits_made_with_custom_price",
"total_deposited",
"total_deposited_with_custom_price",
"total_deposited_with_default_price",
"total_deposits_made",
"total_deposits_made_with_custom_price",
"total_deposits_made_with_default_price"
],
"properties": {
"deposited_with_custom_price": {
"description": "Per-account breakdown of deposited amounts for whitelisted addresses.",
"type": "object",
"additionalProperties": false
},
"deposits_made_with_custom_price": {
"description": "Per-account breakdown of deposit counts for whitelisted addresses.",
"type": "object",
"additionalProperties": false
},
"total_deposited": {
"description": "Total value of all deposits ever made (at any price tier), sourced from `PoolCounters::total_deposited`.",
"allOf": [
{
"$ref": "#/definitions/Coin"
}
]
},
"total_deposited_with_custom_price": {
"description": "Total value deposited at custom prices, summed across all whitelisted accounts.",
"allOf": [
{
"$ref": "#/definitions/Coin"
}
]
},
"total_deposited_with_default_price": {
"description": "Total value deposited at the default price.",
"allOf": [
{
"$ref": "#/definitions/Coin"
}
]
},
"total_deposits_made": {
"description": "Total number of deposits ever made (at any price tier), derived from the deposit id counter.",
"type": "integer",
"format": "uint32",
"minimum": 0.0
},
"total_deposits_made_with_custom_price": {
"description": "Number of deposits made at any custom (reduced) price, summed across all whitelisted accounts.",
"type": "integer",
"format": "uint32",
"minimum": 0.0
},
"total_deposits_made_with_default_price": {
"description": "Number of deposits made at the default (non-reduced) price.",
"type": "integer",
"format": "uint32",
"minimum": 0.0
}
},
"additionalProperties": false,
"definitions": {
"Coin": {
"type": "object",
"required": [
"amount",
"denom"
],
"properties": {
"amount": {
"$ref": "#/definitions/Uint128"
},
"denom": {
"type": "string"
}
},
"additionalProperties": false
},
"Uint128": {
"description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```",
"type": "string"
}
}
},
"get_latest_deposit": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "LatestDepositResponse",
@@ -644,24 +966,34 @@
}
}
},
"get_required_deposit_amount": {
"get_reduced_deposit_amount": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Coin",
"type": "object",
"required": [
"amount",
"denom"
],
"properties": {
"amount": {
"$ref": "#/definitions/Uint128"
"title": "Nullable_Coin",
"anyOf": [
{
"$ref": "#/definitions/Coin"
},
"denom": {
"type": "string"
{
"type": "null"
}
},
"additionalProperties": false,
],
"definitions": {
"Coin": {
"type": "object",
"required": [
"amount",
"denom"
],
"properties": {
"amount": {
"$ref": "#/definitions/Uint128"
},
"denom": {
"type": "string"
}
},
"additionalProperties": false
},
"Uint128": {
"description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```",
"type": "string"
+50 -2
View File
@@ -104,10 +104,10 @@
{
"type": "object",
"required": [
"update_deposit_value"
"update_default_deposit_value"
],
"properties": {
"update_deposit_value": {
"update_default_deposit_value": {
"type": "object",
"required": [
"new_deposit"
@@ -122,6 +122,54 @@
},
"additionalProperties": false
},
{
"description": "Set (or overwrite) a reduced deposit price for a specific address. Only callable by the contract admin.",
"type": "object",
"required": [
"set_reduced_deposit_price"
],
"properties": {
"set_reduced_deposit_price": {
"type": "object",
"required": [
"address",
"deposit"
],
"properties": {
"address": {
"type": "string"
},
"deposit": {
"$ref": "#/definitions/Coin"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"description": "Remove the reduced deposit price for a specific address, reverting them to the default price. Returns an error if the address has no custom price set. Only callable by the contract admin.",
"type": "object",
"required": [
"remove_reduced_deposit_price"
],
"properties": {
"remove_reduced_deposit_price": {
"type": "object",
"required": [
"address"
],
"properties": {
"address": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
+52 -1
View File
@@ -2,5 +2,56 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "MigrateMsg",
"type": "object",
"additionalProperties": false
"required": [
"initial_whitelist"
],
"properties": {
"initial_whitelist": {
"description": "Initial set of whitelisted accounts with their reduced deposit prices. Each entry is validated and stored during migration.",
"type": "array",
"items": {
"$ref": "#/definitions/WhitelistedDeposit"
}
}
},
"additionalProperties": false,
"definitions": {
"Coin": {
"type": "object",
"required": [
"amount",
"denom"
],
"properties": {
"amount": {
"$ref": "#/definitions/Uint128"
},
"denom": {
"type": "string"
}
},
"additionalProperties": false
},
"Uint128": {
"description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```",
"type": "string"
},
"WhitelistedDeposit": {
"description": "An address and its reduced deposit price, used when seeding the whitelist via migration.",
"type": "object",
"required": [
"address",
"deposit"
],
"properties": {
"address": {
"type": "string"
},
"deposit": {
"$ref": "#/definitions/Coin"
}
},
"additionalProperties": false
}
}
}
+49 -2
View File
@@ -55,10 +55,44 @@
{
"type": "object",
"required": [
"get_required_deposit_amount"
"get_default_deposit_amount"
],
"properties": {
"get_required_deposit_amount": {
"get_default_deposit_amount": {
"type": "object",
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"get_reduced_deposit_amount"
],
"properties": {
"get_reduced_deposit_amount": {
"type": "object",
"required": [
"address"
],
"properties": {
"address": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"get_all_whitelisted_accounts"
],
"properties": {
"get_all_whitelisted_accounts": {
"type": "object",
"additionalProperties": false
}
@@ -131,6 +165,19 @@
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"get_deposits_statistics"
],
"properties": {
"get_deposits_statistics": {
"type": "object",
"additionalProperties": false
}
},
"additionalProperties": false
}
]
}
@@ -0,0 +1,59 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "WhitelistedAccountsResponse",
"type": "object",
"required": [
"whitelisted_accounts"
],
"properties": {
"whitelisted_accounts": {
"type": "array",
"items": {
"$ref": "#/definitions/WhitelistedAccount"
}
}
},
"additionalProperties": false,
"definitions": {
"Addr": {
"description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.",
"type": "string"
},
"Coin": {
"type": "object",
"required": [
"amount",
"denom"
],
"properties": {
"amount": {
"$ref": "#/definitions/Uint128"
},
"denom": {
"type": "string"
}
},
"additionalProperties": false
},
"Uint128": {
"description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```",
"type": "string"
},
"WhitelistedAccount": {
"type": "object",
"required": [
"address",
"deposit"
],
"properties": {
"address": {
"$ref": "#/definitions/Addr"
},
"deposit": {
"$ref": "#/definitions/Coin"
}
},
"additionalProperties": false
}
}
}
@@ -0,0 +1,24 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Coin",
"type": "object",
"required": [
"amount",
"denom"
],
"properties": {
"amount": {
"$ref": "#/definitions/Uint128"
},
"denom": {
"type": "string"
}
},
"additionalProperties": false,
"definitions": {
"Uint128": {
"description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```",
"type": "string"
}
}
}
@@ -0,0 +1,93 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "DepositsStatistics",
"description": "Aggregate statistics about all deposits made through the ecash contract.",
"type": "object",
"required": [
"deposited_with_custom_price",
"deposits_made_with_custom_price",
"total_deposited",
"total_deposited_with_custom_price",
"total_deposited_with_default_price",
"total_deposits_made",
"total_deposits_made_with_custom_price",
"total_deposits_made_with_default_price"
],
"properties": {
"deposited_with_custom_price": {
"description": "Per-account breakdown of deposited amounts for whitelisted addresses.",
"type": "object",
"additionalProperties": false
},
"deposits_made_with_custom_price": {
"description": "Per-account breakdown of deposit counts for whitelisted addresses.",
"type": "object",
"additionalProperties": false
},
"total_deposited": {
"description": "Total value of all deposits ever made (at any price tier), sourced from `PoolCounters::total_deposited`.",
"allOf": [
{
"$ref": "#/definitions/Coin"
}
]
},
"total_deposited_with_custom_price": {
"description": "Total value deposited at custom prices, summed across all whitelisted accounts.",
"allOf": [
{
"$ref": "#/definitions/Coin"
}
]
},
"total_deposited_with_default_price": {
"description": "Total value deposited at the default price.",
"allOf": [
{
"$ref": "#/definitions/Coin"
}
]
},
"total_deposits_made": {
"description": "Total number of deposits ever made (at any price tier), derived from the deposit id counter.",
"type": "integer",
"format": "uint32",
"minimum": 0.0
},
"total_deposits_made_with_custom_price": {
"description": "Number of deposits made at any custom (reduced) price, summed across all whitelisted accounts.",
"type": "integer",
"format": "uint32",
"minimum": 0.0
},
"total_deposits_made_with_default_price": {
"description": "Number of deposits made at the default (non-reduced) price.",
"type": "integer",
"format": "uint32",
"minimum": 0.0
}
},
"additionalProperties": false,
"definitions": {
"Coin": {
"type": "object",
"required": [
"amount",
"denom"
],
"properties": {
"amount": {
"$ref": "#/definitions/Uint128"
},
"denom": {
"type": "string"
}
},
"additionalProperties": false
},
"Uint128": {
"description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```",
"type": "string"
}
}
}
@@ -0,0 +1,34 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Nullable_Coin",
"anyOf": [
{
"$ref": "#/definitions/Coin"
},
{
"type": "null"
}
],
"definitions": {
"Coin": {
"type": "object",
"required": [
"amount",
"denom"
],
"properties": {
"amount": {
"$ref": "#/definitions/Uint128"
},
"denom": {
"type": "string"
}
},
"additionalProperties": false
},
"Uint128": {
"description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```",
"type": "string"
}
}
}
+2 -195
View File
@@ -2,11 +2,9 @@
// SPDX-License-Identifier: Apache-2.0
use crate::contract::NymEcashContract;
use crate::helpers::{
create_batch_redemption_proposal, create_blacklist_proposal, Config, ProposalId,
};
use crate::helpers::{create_batch_redemption_proposal, create_blacklist_proposal, ProposalId};
use cosmwasm_schema::cw_serde;
use cosmwasm_std::{to_json_binary, Addr, Coin, Decimal, Deps, Storage, SubMsg, Uint128};
use cosmwasm_std::{to_json_binary, Addr, Deps, Storage, SubMsg};
use cw3::ProposalResponse;
use nym_ecash_contract_common::EcashContractError;
use nym_multisig_contract_common::msg::QueryMsg as MultisigQueryMsg;
@@ -33,28 +31,6 @@ impl NymEcashContract {
Ok(TICKETBOOK_SIZE)
}
pub(crate) fn tickets_redemption_amount(
&self,
storage: &dyn Storage,
config: &Config,
number_of_tickets: u16,
) -> Result<Coin, EcashContractError> {
let deposit_amount = config.deposit_amount.amount;
let ticketbook_size = Uint128::new(self.get_ticketbook_size(storage)? as u128);
let tickets = Uint128::new(number_of_tickets as u128);
// how many tickets from a ticketbook you redeemed
let book_ratio = Decimal::from_ratio(tickets, ticketbook_size);
// return = ticketbook_price * (tickets / ticketbook_size)
let return_amount = deposit_amount.mul_floor(book_ratio);
Ok(Coin {
denom: config.deposit_amount.denom.clone(),
amount: return_amount,
})
}
fn must_get_multisig_addr(&self, deps: Deps) -> Result<Addr, EcashContractError> {
// SAFETY: multisig admin MUST always be set on initialisation,
// if the call fails, we're in some weird UB land
@@ -117,172 +93,3 @@ impl NymEcashContract {
Ok(proposal_response)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::support::tests::TestSetupSimple;
#[test]
fn ticket_redemption_amount() -> anyhow::Result<()> {
// make sure the ticketbook size hasn't changed so that our tests are still valid
assert_eq!(TICKETBOOK_SIZE, 50);
// ticketbook price of 100nym
let test = TestSetupSimple::new().with_deposit_amount(100_000_000);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 1)?;
assert_eq!(res.amount.u128(), 2_000_000);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 2)?;
assert_eq!(res.amount.u128(), 4_000_000);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 5)?;
assert_eq!(res.amount.u128(), 10_000_000);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 10)?;
assert_eq!(res.amount.u128(), 20_000_000);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 30)?;
assert_eq!(res.amount.u128(), 60_000_000);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 50)?;
assert_eq!(res.amount.u128(), 100_000_000);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 123)?;
assert_eq!(res.amount.u128(), 246_000_000);
// ticketbook price of 1.5unym per ticket
let test = TestSetupSimple::new().with_deposit_amount(75);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 1)?;
assert_eq!(res.amount.u128(), 1);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 2)?;
assert_eq!(res.amount.u128(), 3);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 5)?;
assert_eq!(res.amount.u128(), 7);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 10)?;
assert_eq!(res.amount.u128(), 15);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 30)?;
assert_eq!(res.amount.u128(), 45);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 50)?;
assert_eq!(res.amount.u128(), 75);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 123)?;
assert_eq!(res.amount.u128(), 184);
// ticketbook price of 1unym per ticket
let test = TestSetupSimple::new().with_deposit_amount(50);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 1)?;
assert_eq!(res.amount.u128(), 1);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 2)?;
assert_eq!(res.amount.u128(), 2);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 5)?;
assert_eq!(res.amount.u128(), 5);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 10)?;
assert_eq!(res.amount.u128(), 10);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 30)?;
assert_eq!(res.amount.u128(), 30);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 50)?;
assert_eq!(res.amount.u128(), 50);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 123)?;
assert_eq!(res.amount.u128(), 123);
// ticketbook price of 1unym in total
let test = TestSetupSimple::new().with_deposit_amount(1);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 1)?;
assert_eq!(res.amount.u128(), 0);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 2)?;
assert_eq!(res.amount.u128(), 0);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 5)?;
assert_eq!(res.amount.u128(), 0);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 10)?;
assert_eq!(res.amount.u128(), 0);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 30)?;
assert_eq!(res.amount.u128(), 0);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 50)?;
assert_eq!(res.amount.u128(), 1);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 123)?;
assert_eq!(res.amount.u128(), 2);
// ticketbook price of 0unym
let test = TestSetupSimple::new().with_deposit_amount(0);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 1)?;
assert_eq!(res.amount.u128(), 0);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 2)?;
assert_eq!(res.amount.u128(), 0);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 5)?;
assert_eq!(res.amount.u128(), 0);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 10)?;
assert_eq!(res.amount.u128(), 0);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 30)?;
assert_eq!(res.amount.u128(), 0);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 50)?;
assert_eq!(res.amount.u128(), 0);
let res =
test.contract()
.tickets_redemption_amount(test.deps().storage, &test.config(), 123)?;
assert_eq!(res.amount.u128(), 0);
Ok(())
}
}
+234 -24
View File
@@ -4,11 +4,12 @@
use crate::constants::{BLACKLIST_PROPOSAL_REPLY_ID, REDEMPTION_PROPOSAL_REPLY_ID};
use crate::contract::helpers::Invariants;
use crate::deposit::DepositStorage;
use crate::deposit_stats::DepositStatsStorage;
use crate::helpers::{
BlacklistKey, Config, MultisigReply, BLACKLIST_PAGE_DEFAULT_LIMIT, BLACKLIST_PAGE_MAX_LIMIT,
CONTRACT_NAME, CONTRACT_VERSION, DEPOSITS_PAGE_DEFAULT_LIMIT, DEPOSITS_PAGE_MAX_LIMIT,
};
use cosmwasm_std::{coin, BankMsg, Coin, Event, Order, Reply, Response, StdResult};
use cosmwasm_std::{coin, Addr, Coin, DepsMut, Event, Order, Reply, Response, StdResult};
use cw4::Cw4Contract;
use cw_controllers::Admin;
use cw_storage_plus::{Bound, Item, Map};
@@ -20,9 +21,12 @@ use nym_ecash_contract_common::counters::PoolCounters;
use nym_ecash_contract_common::deposit::{
DepositData, DepositResponse, LatestDepositResponse, PagedDepositsResponse,
};
use nym_ecash_contract_common::deposit_statistics::DepositsStatistics;
use nym_ecash_contract_common::events::{
DEPOSITED_FUNDS_EVENT_TYPE, DEPOSIT_ID, PROPOSAL_ID_ATTRIBUTE_NAME,
};
use nym_ecash_contract_common::msg::WhitelistedDeposit;
use nym_ecash_contract_common::reduced_deposit::{WhitelistedAccount, WhitelistedAccountsResponse};
use nym_ecash_contract_common::EcashContractError;
use nym_network_defaults::TICKETBOOK_SIZE;
use sylvia::ctx::{ExecCtx, InstantiateCtx, MigrateCtx, QueryCtx};
@@ -41,6 +45,13 @@ pub struct NymEcashContract {
pub(crate) pool_counters: Item<PoolCounters>,
pub(crate) expected_invariants: Item<Invariants>,
/// Information about the performed deposits
pub(crate) deposit_stats: DepositStatsStorage,
/// Map of approved addresses that are allowed to perform deposits using a reduced amount
/// as specified by the saved value.
pub(crate) reduced_deposits: Map<Addr, Coin>,
pub(crate) blacklist: Map<BlacklistKey, Blacklisting>,
pub(crate) deposits: DepositStorage,
@@ -58,6 +69,8 @@ impl NymEcashContract {
config: Item::new("config"),
pool_counters: Item::new("pool_counters"),
expected_invariants: Item::new("expected_invariants"),
deposit_stats: DepositStatsStorage::new(),
reduced_deposits: Map::new("reduced_deposits"),
blacklist: Map::new("blacklist"),
deposits: DepositStorage::new(),
}
@@ -72,6 +85,9 @@ impl NymEcashContract {
group_addr: String,
deposit_amount: Coin,
) -> Result<Response, EcashContractError> {
// all counters, deposits, etc. always use and always will use the same denom
let zero_coin = coin(0, &deposit_amount.denom);
let multisig_addr = ctx.deps.api.addr_validate(&multisig_addr)?;
let holding_account = ctx.deps.api.addr_validate(&holding_account)?;
let group_addr = Cw4Contract(ctx.deps.api.addr_validate(&group_addr).map_err(|_| {
@@ -96,8 +112,9 @@ impl NymEcashContract {
self.pool_counters.save(
ctx.deps.storage,
&PoolCounters {
total_deposited: coin(0, &deposit_amount.denom),
total_redeemed: coin(0, &deposit_amount.denom),
total_deposited: zero_coin.clone(),
total_redeemed: zero_coin.clone(),
tickets_requested_and_not_redeemed: 0,
},
)?;
@@ -110,6 +127,14 @@ impl NymEcashContract {
},
)?;
self.deposit_stats
.deposits_with_default_price
.save(ctx.deps.storage, &0)?;
self.deposit_stats
.deposits_with_default_price_amounts
.save(ctx.deps.storage, &zero_coin)?;
cw2::set_contract_version(ctx.deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
set_build_information!(ctx.deps.storage)?;
@@ -161,12 +186,49 @@ impl NymEcashContract {
}
#[sv::msg(query)]
pub fn get_required_deposit_amount(&self, ctx: QueryCtx) -> StdResult<Coin> {
pub fn get_default_deposit_amount(&self, ctx: QueryCtx) -> StdResult<Coin> {
let deposit_amount = self.config.load(ctx.deps.storage)?.deposit_amount;
Ok(deposit_amount)
}
// Poor man's alias for backwards compatibility as sv::attr didn't seem to work
#[sv::msg(query)]
pub fn get_required_deposit_amount(&self, ctx: QueryCtx) -> StdResult<Coin> {
self.get_default_deposit_amount(ctx)
}
#[sv::msg(query)]
pub fn get_reduced_deposit_amount(
&self,
ctx: QueryCtx,
address: String,
) -> StdResult<Option<Coin>> {
let address = ctx.deps.api.addr_validate(&address)?;
let deposit_amount = self.reduced_deposits.may_load(ctx.deps.storage, address)?;
Ok(deposit_amount)
}
#[sv::msg(query)]
pub fn get_all_whitelisted_accounts(
&self,
ctx: QueryCtx,
) -> StdResult<WhitelistedAccountsResponse> {
let whitelisted_accounts = self
.reduced_deposits
.range(ctx.deps.storage, None, None, Order::Ascending)
.map(|item| {
let (address, deposit) = item?;
Ok(WhitelistedAccount { address, deposit })
})
.collect::<StdResult<Vec<_>>>()?;
Ok(WhitelistedAccountsResponse {
whitelisted_accounts,
})
}
#[sv::msg(query)]
pub fn get_deposit(
&self,
@@ -226,6 +288,40 @@ impl NymEcashContract {
})
}
#[sv::msg(query)]
pub fn get_deposits_statistics(
&self,
ctx: QueryCtx,
) -> Result<DepositsStatistics, EcashContractError> {
let storage = ctx.deps.storage;
let denom = &self.config.load(storage)?.deposit_amount.denom;
let total_deposits_made = self.deposits.total_deposits_made(storage)?;
let total_deposited = self.pool_counters.load(storage)?.total_deposited;
let total_deposits_made_with_default_price = self
.deposit_stats
.get_total_deposits_made_with_default_price(storage)?;
let total_deposited_with_default_price = self
.deposit_stats
.get_total_deposited_with_default_price(storage, denom)?;
let custom = self
.deposit_stats
.get_custom_price_deposits(storage, denom)?;
Ok(DepositsStatistics {
total_deposits_made,
total_deposited,
total_deposits_made_with_default_price,
total_deposited_with_default_price,
total_deposits_made_with_custom_price: custom.total_count,
total_deposited_with_custom_price: custom.total_amount,
deposits_made_with_custom_price: custom.per_account_count,
deposited_with_custom_price: custom.per_account_amount,
})
}
/*=====================
======EXECUTIONS=======
=====================*/
@@ -236,20 +332,48 @@ impl NymEcashContract {
ctx: ExecCtx,
identity_key: String,
) -> Result<Response, EcashContractError> {
let required_deposit = self.config.load(ctx.deps.storage)?.deposit_amount;
let default_deposit = self.config.load(ctx.deps.storage)?.deposit_amount;
let reduced_deposit = self
.reduced_deposits
.may_load(ctx.deps.storage, ctx.info.sender.clone())?;
let submitted = cw_utils::must_pay(&ctx.info, &required_deposit.denom)?;
let submitted = cw_utils::must_pay(&ctx.info, &default_deposit.denom)?;
let mut funds = ctx.info.funds;
if submitted != required_deposit.amount {
let mut funds = ctx.info.funds;
// Whitelisted accounts may deposit at either their reduced price or the
// default price. If the default price is sent, the deposit is treated as
// a regular (non-reduced) deposit for statistics purposes.
if submitted == default_deposit.amount {
self.deposit_stats
.new_default_deposit(ctx.deps.storage, &default_deposit)?;
} else if let Some(reduced_deposit) = reduced_deposit.as_ref() {
// can't do if let chaining due to outdated rustc used for building contracts
if reduced_deposit.amount == submitted {
self.deposit_stats.new_reduced_deposit(
ctx.deps.storage,
&ctx.info.sender,
reduced_deposit,
)?;
} else {
// we are allowed to send reduced amounts, but we sent the wrong amount
return Err(EcashContractError::WrongAmount {
// SAFETY: the call to `must_pay` ensured a single coin has been sent
#[allow(clippy::unwrap_used)]
received: funds.pop().unwrap(),
amount: reduced_deposit.clone(),
});
}
} else {
// we sent wrong amount of tokens
return Err(EcashContractError::WrongAmount {
// SAFETY: the call to `must_pay` ensured a single coin has been sent
#[allow(clippy::unwrap_used)]
received: funds.pop().unwrap(),
amount: required_deposit,
amount: default_deposit,
});
}
};
// global total needed when migrating to the nym pool contract
self.pool_counters
.update(ctx.deps.storage, |mut counters| -> StdResult<_> {
counters.total_deposited.amount += submitted;
@@ -285,6 +409,8 @@ impl NymEcashContract {
Ok(Response::new().add_submessage(msg))
}
/// Old legacy method for requesting ticket redemption by moving them into the holding accounts
/// currently only executed by legacy gateways
#[sv::msg(exec)]
pub fn redeem_tickets(
&self,
@@ -301,22 +427,15 @@ impl NymEcashContract {
self.multisig
.assert_admin(ctx.deps.as_ref(), &ctx.info.sender)?;
let config = self.config.load(ctx.deps.storage)?;
let to_return = self.tickets_redemption_amount(ctx.deps.storage, &config, n)?;
if to_return.amount.is_zero() {
return Ok(Response::new());
}
self.pool_counters
.update(ctx.deps.storage, |mut counters| -> StdResult<_> {
counters.total_redeemed.amount += to_return.amount;
counters.tickets_requested_and_not_redeemed += n as u64;
Ok(counters)
})?;
Ok(Response::new().add_message(BankMsg::Send {
to_address: config.holding_account.to_string(),
amount: vec![to_return],
}))
Ok(Response::new().add_event(
Event::new("ticket_redemption").add_attribute("moved_to_holding_account", "false"),
))
}
#[sv::msg(exec)]
@@ -334,7 +453,7 @@ impl NymEcashContract {
}
#[sv::msg(exec)]
pub fn update_deposit_value(
pub fn update_default_deposit_value(
&self,
ctx: ExecCtx,
new_deposit: Coin,
@@ -343,6 +462,14 @@ impl NymEcashContract {
self.contract_admin
.assert_admin(ctx.deps.as_ref(), &ctx.info.sender)?;
let ticket_book_size = self.get_ticketbook_size(ctx.deps.storage)?;
if new_deposit.amount < cosmwasm_std::Uint128::from(ticket_book_size) {
return Err(EcashContractError::DepositBelowTicketBookSize {
amount: new_deposit.amount,
ticket_book_size,
});
}
let deposit_str = new_deposit.to_string();
self.config
.update(ctx.deps.storage, |mut cfg| -> StdResult<_> {
@@ -352,6 +479,85 @@ impl NymEcashContract {
Ok(Response::new().add_attribute("updated_deposit", deposit_str))
}
pub(crate) fn add_reduced_deposit_address(
&self,
deps: DepsMut,
address: Addr,
deposit: &Coin,
) -> Result<(), EcashContractError> {
// the reduced price must be strictly less than the default to avoid
// accidentally misconfiguring an address to pay more than everyone else
let default = self.config.load(deps.storage)?.deposit_amount;
if deposit.denom != default.denom {
return Err(EcashContractError::InvalidReducedDepositDenom {
expected: default.denom,
got: deposit.denom.clone(),
});
}
if deposit.amount >= default.amount {
return Err(EcashContractError::ReducedDepositNotReduced {
reduced: deposit.amount,
default: default.amount,
});
}
let ticket_book_size = self.get_ticketbook_size(deps.storage)?;
if deposit.amount < cosmwasm_std::Uint128::from(ticket_book_size) {
return Err(EcashContractError::DepositBelowTicketBookSize {
amount: deposit.amount,
ticket_book_size,
});
}
self.reduced_deposits.save(deps.storage, address, deposit)?;
Ok(())
}
#[sv::msg(exec)]
pub fn set_reduced_deposit_price(
&self,
ctx: ExecCtx,
address: String,
deposit: Coin,
) -> Result<Response, EcashContractError> {
self.contract_admin
.assert_admin(ctx.deps.as_ref(), &ctx.info.sender)?;
let addr = ctx.deps.api.addr_validate(&address)?;
self.add_reduced_deposit_address(ctx.deps, addr.clone(), &deposit)?;
Ok(Response::new()
.add_attribute("action", "set_reduced_deposit_price")
.add_attribute("address", address)
.add_attribute("deposit", deposit.to_string()))
}
/// Removes the reduced deposit price for a given address, reverting them to
/// the default deposit amount. This is safe to call even if the address has
/// already deposited at the reduced price — their next deposit will simply
/// use the default price. Historical deposit statistics are not affected.
#[sv::msg(exec)]
pub fn remove_reduced_deposit_price(
&self,
ctx: ExecCtx,
address: String,
) -> Result<Response, EcashContractError> {
self.contract_admin
.assert_admin(ctx.deps.as_ref(), &ctx.info.sender)?;
let addr = ctx.deps.api.addr_validate(&address)?;
if !self.reduced_deposits.has(ctx.deps.storage, addr.clone()) {
return Err(EcashContractError::NoReducedDepositPrice { address });
}
self.reduced_deposits.remove(ctx.deps.storage, addr);
Ok(Response::new()
.add_attribute("action", "remove_reduced_deposit_price")
.add_attribute("address", address))
}
#[sv::msg(exec)]
pub fn propose_to_blacklist(
&self,
@@ -459,11 +665,15 @@ impl NymEcashContract {
=======MIGRATION=======
=====================*/
#[sv::msg(migrate)]
pub fn migrate(&self, ctx: MigrateCtx) -> Result<Response, EcashContractError> {
pub fn migrate(
&self,
ctx: MigrateCtx,
initial_whitelist: Vec<WhitelistedDeposit>,
) -> Result<Response, EcashContractError> {
set_build_information!(ctx.deps.storage)?;
cw2::ensure_from_older_version(ctx.deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
queued_migrations::remove_redemption_gateway_share(ctx.deps)?;
queued_migrations::add_tiered_pricing(ctx.deps, initial_whitelist)?;
Ok(Response::new())
}
+293 -32
View File
@@ -1,42 +1,303 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::contract::NymEcashContract;
use crate::helpers::Config;
use cosmwasm_std::{Addr, Coin, Decimal, DepsMut};
use cw4::Cw4Contract;
use cw_storage_plus::Item;
use cosmwasm_std::DepsMut;
use nym_ecash_contract_common::msg::WhitelistedDeposit;
use nym_ecash_contract_common::EcashContractError;
use serde::{Deserialize, Serialize};
pub fn remove_redemption_gateway_share(deps: DepsMut) -> Result<(), EcashContractError> {
#[derive(Serialize, Deserialize)]
struct OldConfig {
group_addr: Cw4Contract,
holding_account: Addr,
pub fn add_tiered_pricing(
mut deps: DepsMut,
initial_whitelist: Vec<WhitelistedDeposit>,
) -> Result<(), EcashContractError> {
let contract = NymEcashContract::new();
redemption_gateway_share: Decimal,
deposit_amount: Coin,
// All the deposits made so far were performed with the default price.
let deposits_performed = contract.deposits.total_deposits_made(deps.storage)?;
let deposits_amounts = contract.pool_counters.load(deps.storage)?.total_deposited;
contract
.deposit_stats
.deposits_with_default_price
.save(deps.storage, &deposits_performed)?;
contract
.deposit_stats
.deposits_with_default_price_amounts
.save(deps.storage, &deposits_amounts)?;
// Seed the whitelist with the initial set of reduced deposit prices.
for whitelisted in initial_whitelist {
let addr = deps.api.addr_validate(&whitelisted.address)?;
contract.add_reduced_deposit_address(deps.branch(), addr, &whitelisted.deposit)?;
}
impl From<OldConfig> for Config {
fn from(config: OldConfig) -> Self {
Config {
group_addr: config.group_addr,
holding_account: config.holding_account,
deposit_amount: config.deposit_amount,
}
}
}
const OLD_CONFIG: Item<OldConfig> = Item::new("config");
let old_config = OLD_CONFIG.load(deps.storage)?;
let new_config = old_config.into();
NymEcashContract::new()
.config
.save(deps.storage, &new_config)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::contract::helpers::Invariants;
use crate::deposit::DepositStorage;
use crate::deposit_stats::DepositStatsStorage;
use crate::helpers::Config;
use cosmwasm_std::testing::{mock_dependencies, MockApi, MockQuerier};
use cosmwasm_std::{coin, Empty, MemoryStorage, OwnedDeps, Uint128};
use cw4::Cw4Contract;
use cw_storage_plus::Item;
use nym_ecash_contract_common::counters::PoolCounters;
const DENOM: &str = "unym";
const DEFAULT_DEPOSIT: u128 = 75_000_000;
const TICKET_BOOK_SIZE: u64 = 50;
/// Initialise the contract config and invariants so that whitelist
/// validation during migration has the values it needs.
fn save_config_and_invariants(
deps: &mut OwnedDeps<MemoryStorage, MockApi, MockQuerier<Empty>>,
) {
let contract = NymEcashContract::new();
let group_addr = deps.api.addr_make("group");
let holding_account = deps.api.addr_make("holding");
contract
.config
.save(
deps.as_mut().storage,
&Config {
group_addr: Cw4Contract(group_addr),
holding_account,
deposit_amount: coin(DEFAULT_DEPOSIT, DENOM),
},
)
.unwrap();
contract
.expected_invariants
.save(
deps.as_mut().storage,
&Invariants {
ticket_book_size: TICKET_BOOK_SIZE,
},
)
.unwrap();
}
fn save_pool_counters(storage: &mut dyn cosmwasm_std::Storage, total_deposited: u128) {
let pool_counters: Item<PoolCounters> = Item::new("pool_counters");
pool_counters
.save(
storage,
&PoolCounters {
total_deposited: coin(total_deposited, DENOM),
total_redeemed: coin(0, DENOM),
tickets_requested_and_not_redeemed: 0,
},
)
.unwrap();
}
#[test]
fn migration_with_no_prior_deposits_initialises_stats_to_zero() {
let mut deps = mock_dependencies();
// No deposit_id_counter saved — contract never had a deposit.
save_pool_counters(deps.as_mut().storage, 0);
add_tiered_pricing(deps.as_mut(), vec![]).unwrap();
let stats = DepositStatsStorage::new();
assert_eq!(
stats
.deposits_with_default_price
.load(deps.as_ref().storage)
.unwrap(),
0
);
assert_eq!(
stats
.deposits_with_default_price_amounts
.load(deps.as_ref().storage)
.unwrap(),
coin(0, DENOM)
);
}
#[test]
fn migration_with_prior_deposits_backfills_correct_count() {
let mut deps = mock_dependencies();
let n_deposits: u32 = 3;
let total: u128 = n_deposits as u128 * 75_000_000;
// Simulate n_deposits having been made: counter stores the next available id,
// which equals the number of deposits already performed.
let deposits = DepositStorage::new();
deposits
.deposit_id_counter
.save(deps.as_mut().storage, &n_deposits)
.unwrap();
save_pool_counters(deps.as_mut().storage, total);
add_tiered_pricing(deps.as_mut(), vec![]).unwrap();
let stats = DepositStatsStorage::new();
assert_eq!(
stats
.deposits_with_default_price
.load(deps.as_ref().storage)
.unwrap(),
n_deposits
);
assert_eq!(
stats
.deposits_with_default_price_amounts
.load(deps.as_ref().storage)
.unwrap(),
coin(total, DENOM)
);
}
#[test]
fn migration_with_single_deposit_backfills_count_of_one() {
let mut deps = mock_dependencies();
// After one deposit, next_id returns 0 and saves counter=1.
let deposits = DepositStorage::new();
deposits
.deposit_id_counter
.save(deps.as_mut().storage, &1u32)
.unwrap();
save_pool_counters(deps.as_mut().storage, 75_000_000);
add_tiered_pricing(deps.as_mut(), vec![]).unwrap();
let stats = DepositStatsStorage::new();
assert_eq!(
stats
.deposits_with_default_price
.load(deps.as_ref().storage)
.unwrap(),
1
);
}
#[test]
fn migration_stores_valid_whitelist_entries() {
let mut deps = mock_dependencies();
save_pool_counters(deps.as_mut().storage, 0);
save_config_and_invariants(&mut deps);
let addr1 = deps.api.addr_make("alice");
let addr2 = deps.api.addr_make("bob");
let whitelist = vec![
WhitelistedDeposit {
address: addr1.to_string(),
deposit: coin(10_000_000, DENOM),
},
WhitelistedDeposit {
address: addr2.to_string(),
deposit: coin(50_000_000, DENOM),
},
];
add_tiered_pricing(deps.as_mut(), whitelist).unwrap();
let contract = NymEcashContract::new();
assert_eq!(
contract
.reduced_deposits
.load(deps.as_ref().storage, addr1)
.unwrap(),
coin(10_000_000, DENOM)
);
assert_eq!(
contract
.reduced_deposits
.load(deps.as_ref().storage, addr2)
.unwrap(),
coin(50_000_000, DENOM)
);
}
#[test]
fn migration_rejects_wrong_denom() {
let mut deps = mock_dependencies();
save_pool_counters(deps.as_mut().storage, 0);
save_config_and_invariants(&mut deps);
let whitelist = vec![WhitelistedDeposit {
address: deps.api.addr_make("alice").to_string(),
deposit: coin(10_000_000, "uatom"),
}];
let err = add_tiered_pricing(deps.as_mut(), whitelist).unwrap_err();
assert_eq!(
err,
EcashContractError::InvalidReducedDepositDenom {
expected: DENOM.to_string(),
got: "uatom".to_string(),
}
);
}
#[test]
fn migration_rejects_amount_not_less_than_default() {
let mut deps = mock_dependencies();
save_pool_counters(deps.as_mut().storage, 0);
save_config_and_invariants(&mut deps);
// Equal to default — should fail
let whitelist = vec![WhitelistedDeposit {
address: deps.api.addr_make("alice").to_string(),
deposit: coin(DEFAULT_DEPOSIT, DENOM),
}];
let err = add_tiered_pricing(deps.as_mut(), whitelist).unwrap_err();
assert_eq!(
err,
EcashContractError::ReducedDepositNotReduced {
reduced: Uint128::new(DEFAULT_DEPOSIT),
default: Uint128::new(DEFAULT_DEPOSIT),
}
);
// Greater than default — should also fail
let whitelist = vec![WhitelistedDeposit {
address: deps.api.addr_make("alice").to_string(),
deposit: coin(DEFAULT_DEPOSIT + 1, DENOM),
}];
let err = add_tiered_pricing(deps.as_mut(), whitelist).unwrap_err();
assert_eq!(
err,
EcashContractError::ReducedDepositNotReduced {
reduced: Uint128::new(DEFAULT_DEPOSIT + 1),
default: Uint128::new(DEFAULT_DEPOSIT),
}
);
}
#[test]
fn migration_rejects_amount_below_ticket_book_size() {
let mut deps = mock_dependencies();
save_pool_counters(deps.as_mut().storage, 0);
save_config_and_invariants(&mut deps);
let whitelist = vec![WhitelistedDeposit {
address: deps.api.addr_make("alice").to_string(),
deposit: coin(TICKET_BOOK_SIZE as u128 - 1, DENOM),
}];
let err = add_tiered_pricing(deps.as_mut(), whitelist).unwrap_err();
assert_eq!(
err,
EcashContractError::DepositBelowTicketBookSize {
amount: Uint128::new(TICKET_BOOK_SIZE as u128 - 1),
ticket_book_size: TICKET_BOOK_SIZE,
}
);
}
}
+413 -7
View File
@@ -3,8 +3,8 @@
use crate::contract::NymEcashContract;
use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env, MockApi, MockQuerier};
use cosmwasm_std::{coin, Addr, Empty, Env, MemoryStorage, OwnedDeps};
use sylvia::ctx::{InstantiateCtx, QueryCtx};
use cosmwasm_std::{coin, Addr, Empty, Env, MemoryStorage, MessageInfo, OwnedDeps};
use sylvia::ctx::{ExecCtx, InstantiateCtx, QueryCtx};
pub const TEST_DENOM: &str = "unym";
@@ -55,30 +55,48 @@ impl TestSetup {
pub fn query_ctx(&self) -> QueryCtx<'_> {
QueryCtx::from((self.deps.as_ref(), self.env.clone()))
}
pub fn exec_ctx(&mut self, sender: MessageInfo) -> ExecCtx<'_> {
ExecCtx::from((self.deps.as_mut(), self.env.clone(), sender))
}
pub fn admin_info(&self) -> MessageInfo {
let admin = self
.contract
.contract_admin
.get(self.deps.as_ref())
.unwrap()
.unwrap();
message_info(&admin, &[])
}
}
#[cfg(test)]
mod tests {
use super::*;
use cosmwasm_std::coin;
use nym_ecash_contract_common::deposit::Deposit;
use nym_ecash_contract_common::reduced_deposit::WhitelistedAccount;
use nym_ecash_contract_common::EcashContractError;
use sylvia::anyhow;
const CONTRACT: NymEcashContract = NymEcashContract::new();
#[test]
fn deposit_queries() -> anyhow::Result<()> {
let mut test = TestSetup::init();
// no deposit
let res = test.contract.get_deposit(test.query_ctx(), 42)?;
let res = CONTRACT.get_deposit(test.query_ctx(), 42)?;
assert!(res.deposit.is_none());
let deps = test.deps.as_mut();
let deposit_id = test.contract.deposits.save_deposit(
deps.storage,
let deposit_id = CONTRACT.deposits.save_deposit(
test.deps.as_mut().storage,
"GLdR2NRVZBiCoCbv4fNqt9wUJZAnNjGXHkx3TjVAUzrK".to_string(),
)?;
// deposit exists
let res = test.contract.get_deposit(test.query_ctx(), deposit_id)?;
let res = CONTRACT.get_deposit(test.query_ctx(), deposit_id)?;
let expected = Deposit {
bs58_encoded_ed25519_pubkey: "GLdR2NRVZBiCoCbv4fNqt9wUJZAnNjGXHkx3TjVAUzrK".to_string(),
};
@@ -87,4 +105,392 @@ mod tests {
Ok(())
}
#[test]
fn get_default_deposit_amount_returns_configured_value() -> anyhow::Result<()> {
let test = TestSetup::init();
let amount = CONTRACT.get_default_deposit_amount(test.query_ctx())?;
assert_eq!(amount, coin(75_000_000, TEST_DENOM));
Ok(())
}
#[test]
fn get_reduced_deposit_amount_returns_none_for_unlisted_address() -> anyhow::Result<()> {
let test = TestSetup::init();
let unknown = test.deps.api.addr_make("unknown");
let amount = CONTRACT.get_reduced_deposit_amount(test.query_ctx(), unknown.to_string())?;
assert!(amount.is_none());
Ok(())
}
#[test]
fn get_reduced_deposit_amount_returns_amount_for_whitelisted_address() -> anyhow::Result<()> {
let mut test = TestSetup::init();
let addr = test.deps.api.addr_make("whitelisted");
let reduced = coin(10_000_000, TEST_DENOM);
CONTRACT
.reduced_deposits
.save(test.deps.as_mut().storage, addr.clone(), &reduced)?;
let amount = CONTRACT.get_reduced_deposit_amount(test.query_ctx(), addr.to_string())?;
assert_eq!(amount, Some(reduced));
Ok(())
}
// --- get_all_whitelisted_accounts ---
#[test]
fn get_all_whitelisted_accounts_returns_empty_by_default() -> anyhow::Result<()> {
let test = TestSetup::init();
let res = CONTRACT.get_all_whitelisted_accounts(test.query_ctx())?;
assert!(res.whitelisted_accounts.is_empty());
Ok(())
}
#[test]
fn get_all_whitelisted_accounts_returns_all_entries() -> anyhow::Result<()> {
let mut test = TestSetup::init();
let alice = test.deps.api.addr_make("alice");
let bob = test.deps.api.addr_make("bob");
let admin = test.admin_info();
CONTRACT.set_reduced_deposit_price(
test.exec_ctx(admin),
alice.to_string(),
coin(10_000_000, TEST_DENOM),
)?;
let admin = test.admin_info();
CONTRACT.set_reduced_deposit_price(
test.exec_ctx(admin),
bob.to_string(),
coin(5_000_000, TEST_DENOM),
)?;
let res = CONTRACT.get_all_whitelisted_accounts(test.query_ctx())?;
assert_eq!(res.whitelisted_accounts.len(), 2);
assert!(res.whitelisted_accounts.contains(&WhitelistedAccount {
address: alice,
deposit: coin(10_000_000, TEST_DENOM),
}));
assert!(res.whitelisted_accounts.contains(&WhitelistedAccount {
address: bob,
deposit: coin(5_000_000, TEST_DENOM),
}));
Ok(())
}
// --- set_reduced_deposit_price ---
#[test]
fn set_reduced_deposit_price_requires_admin() -> anyhow::Result<()> {
let mut test = TestSetup::init();
let non_admin = test.deps.api.addr_make("non_admin");
let addr = test.deps.api.addr_make("alice");
let err = CONTRACT
.set_reduced_deposit_price(
test.exec_ctx(message_info(&non_admin, &[])),
addr.to_string(),
coin(10_000_000, TEST_DENOM),
)
.unwrap_err();
assert!(matches!(err, EcashContractError::Admin(_)));
Ok(())
}
#[test]
fn set_reduced_deposit_price_rejects_wrong_denom() -> anyhow::Result<()> {
let mut test = TestSetup::init();
let addr = test.deps.api.addr_make("alice");
let admin = test.admin_info();
let err = CONTRACT
.set_reduced_deposit_price(
test.exec_ctx(admin),
addr.to_string(),
coin(10_000_000, "uatom"),
)
.unwrap_err();
assert_eq!(
err,
EcashContractError::InvalidReducedDepositDenom {
expected: TEST_DENOM.to_string(),
got: "uatom".to_string(),
}
);
Ok(())
}
#[test]
fn set_reduced_deposit_price_rejects_amount_equal_to_default() -> anyhow::Result<()> {
let mut test = TestSetup::init();
let addr = test.deps.api.addr_make("alice");
let admin = test.admin_info();
let err = CONTRACT
.set_reduced_deposit_price(
test.exec_ctx(admin),
addr.to_string(),
coin(75_000_000, TEST_DENOM), // same as default
)
.unwrap_err();
assert!(matches!(
err,
EcashContractError::ReducedDepositNotReduced { .. }
));
Ok(())
}
#[test]
fn set_reduced_deposit_price_rejects_amount_above_default() -> anyhow::Result<()> {
let mut test = TestSetup::init();
let addr = test.deps.api.addr_make("alice");
let admin = test.admin_info();
let err = CONTRACT
.set_reduced_deposit_price(
test.exec_ctx(admin),
addr.to_string(),
coin(100_000_000, TEST_DENOM),
)
.unwrap_err();
assert!(matches!(
err,
EcashContractError::ReducedDepositNotReduced { .. }
));
Ok(())
}
#[test]
fn set_reduced_deposit_price_rejects_amount_below_ticket_book_size() -> anyhow::Result<()> {
let mut test = TestSetup::init();
let addr = test.deps.api.addr_make("alice");
let admin = test.admin_info();
let err = CONTRACT
.set_reduced_deposit_price(
test.exec_ctx(admin),
addr.to_string(),
coin(10, TEST_DENOM), // below ticket_book_size (50)
)
.unwrap_err();
assert!(matches!(
err,
EcashContractError::DepositBelowTicketBookSize { .. }
));
Ok(())
}
#[test]
fn set_reduced_deposit_price_stores_price() -> anyhow::Result<()> {
let mut test = TestSetup::init();
let addr = test.deps.api.addr_make("alice");
let reduced = coin(10_000_000, TEST_DENOM);
let admin = test.admin_info();
CONTRACT.set_reduced_deposit_price(
test.exec_ctx(admin),
addr.to_string(),
reduced.clone(),
)?;
let stored = CONTRACT.get_reduced_deposit_amount(test.query_ctx(), addr.to_string())?;
assert_eq!(stored, Some(reduced));
Ok(())
}
#[test]
fn set_reduced_deposit_price_overwrites_existing_price() -> anyhow::Result<()> {
let mut test = TestSetup::init();
let addr = test.deps.api.addr_make("alice");
let admin = test.admin_info();
CONTRACT.set_reduced_deposit_price(
test.exec_ctx(admin),
addr.to_string(),
coin(10_000_000, TEST_DENOM),
)?;
let admin = test.admin_info();
CONTRACT.set_reduced_deposit_price(
test.exec_ctx(admin),
addr.to_string(),
coin(5_000_000, TEST_DENOM),
)?;
let stored = CONTRACT.get_reduced_deposit_amount(test.query_ctx(), addr.to_string())?;
assert_eq!(stored, Some(coin(5_000_000, TEST_DENOM)));
Ok(())
}
// --- remove_reduced_deposit_price ---
#[test]
fn remove_reduced_deposit_price_requires_admin() -> anyhow::Result<()> {
let mut test = TestSetup::init();
let non_admin = test.deps.api.addr_make("non_admin");
let addr = test.deps.api.addr_make("alice");
let err = CONTRACT
.remove_reduced_deposit_price(
test.exec_ctx(message_info(&non_admin, &[])),
addr.to_string(),
)
.unwrap_err();
assert!(matches!(err, EcashContractError::Admin(_)));
Ok(())
}
#[test]
fn remove_reduced_deposit_price_clears_stored_price() -> anyhow::Result<()> {
let mut test = TestSetup::init();
let addr = test.deps.api.addr_make("alice");
let admin = test.admin_info();
CONTRACT.set_reduced_deposit_price(
test.exec_ctx(admin),
addr.to_string(),
coin(10_000_000, TEST_DENOM),
)?;
let admin = test.admin_info();
CONTRACT.remove_reduced_deposit_price(test.exec_ctx(admin), addr.to_string())?;
let stored = CONTRACT.get_reduced_deposit_amount(test.query_ctx(), addr.to_string())?;
assert!(stored.is_none());
Ok(())
}
#[test]
fn remove_reduced_deposit_price_errors_for_unlisted_address() -> anyhow::Result<()> {
let mut test = TestSetup::init();
let addr = test.deps.api.addr_make("alice");
let admin = test.admin_info();
let err = CONTRACT
.remove_reduced_deposit_price(test.exec_ctx(admin), addr.to_string())
.unwrap_err();
assert_eq!(
err,
EcashContractError::NoReducedDepositPrice {
address: addr.to_string()
}
);
Ok(())
}
// --- get_deposits_statistics ---
#[test]
fn get_deposits_statistics_returns_zeroes_after_init() -> anyhow::Result<()> {
let test = TestSetup::init();
let stats = CONTRACT.get_deposits_statistics(test.query_ctx())?;
assert_eq!(stats.total_deposits_made, 0);
assert_eq!(stats.total_deposited, coin(0, TEST_DENOM));
assert_eq!(stats.total_deposits_made_with_default_price, 0);
assert_eq!(
stats.total_deposited_with_default_price,
coin(0, TEST_DENOM)
);
assert_eq!(stats.total_deposits_made_with_custom_price, 0);
assert_eq!(stats.total_deposited_with_custom_price, coin(0, TEST_DENOM));
assert!(stats.deposits_made_with_custom_price.is_empty());
assert!(stats.deposited_with_custom_price.is_empty());
CONTRACT
.deposit_stats
.assert_counts_consistent(test.deps.as_ref().storage, stats.total_deposits_made);
Ok(())
}
#[test]
fn deposit_stats_invariant_holds_after_mixed_deposits() -> anyhow::Result<()> {
let mut test = TestSetup::init();
let alice = test.deps.api.addr_make("alice");
let bob = test.deps.api.addr_make("bob");
// whitelist alice
let admin = test.admin_info();
CONTRACT.set_reduced_deposit_price(
test.exec_ctx(admin),
alice.to_string(),
coin(10_000_000, TEST_DENOM),
)?;
// alice deposits at reduced price
let alice_info = message_info(&alice, &[coin(10_000_000, TEST_DENOM)]);
CONTRACT.deposit_ticket_book_funds(
test.exec_ctx(alice_info),
"GLdR2NRVZBiCoCbv4fNqt9wUJZAnNjGXHkx3TjVAUzrK".to_string(),
)?;
// bob deposits at default price
let bob_info = message_info(&bob, &[coin(75_000_000, TEST_DENOM)]);
CONTRACT.deposit_ticket_book_funds(
test.exec_ctx(bob_info),
"GLdR2NRVZBiCoCbv4fNqt9wUJZAnNjGXHkx3TjVAUzrK".to_string(),
)?;
// alice deposits again at reduced price
let alice_info = message_info(&alice, &[coin(10_000_000, TEST_DENOM)]);
CONTRACT.deposit_ticket_book_funds(
test.exec_ctx(alice_info),
"GLdR2NRVZBiCoCbv4fNqt9wUJZAnNjGXHkx3TjVAUzrK".to_string(),
)?;
// alice deposits at the default price — should be treated as a normal deposit
let alice_info = message_info(&alice, &[coin(75_000_000, TEST_DENOM)]);
CONTRACT.deposit_ticket_book_funds(
test.exec_ctx(alice_info),
"GLdR2NRVZBiCoCbv4fNqt9wUJZAnNjGXHkx3TjVAUzrK".to_string(),
)?;
let total = CONTRACT
.deposits
.total_deposits_made(test.deps.as_ref().storage)?;
assert_eq!(total, 4);
CONTRACT
.deposit_stats
.assert_counts_consistent(test.deps.as_ref().storage, total);
Ok(())
}
}
+26 -1
View File
@@ -29,6 +29,14 @@ impl DepositStorage {
.map_err(Into::into)
}
/// Returns the total number of deposits ever made.
///
/// The deposit id counter stores the next available id, which equals the
/// total count (first deposit gets id 0, counter becomes 1, and so on).
pub fn total_deposits_made(&self, storage: &dyn Storage) -> Result<u32, EcashContractError> {
Ok(self.deposit_id_counter.may_load(storage)?.unwrap_or(0))
}
fn next_id(&self, store: &mut dyn Storage) -> Result<DepositId, EcashContractError> {
let id: DepositId = self.deposit_id_counter.may_load(store)?.unwrap_or_default();
let next_id = id + 1;
@@ -115,8 +123,8 @@ impl StoredDeposits {
#[cfg(test)]
mod tests {
use super::*;
use crate::support::tests::test_rng;
use cosmwasm_std::testing::mock_dependencies;
use nym_contracts_common_testing::test_rng;
use nym_crypto::asymmetric::ed25519;
#[test]
@@ -142,6 +150,23 @@ mod tests {
Ok(())
}
#[test]
fn total_deposits_made_tracks_count() -> anyhow::Result<()> {
let mut deps = mock_dependencies();
let storage = DepositStorage::new();
assert_eq!(storage.total_deposits_made(deps.as_ref().storage)?, 0);
let _ = storage.next_id(deps.as_mut().storage)?;
assert_eq!(storage.total_deposits_made(deps.as_ref().storage)?, 1);
let _ = storage.next_id(deps.as_mut().storage)?;
let _ = storage.next_id(deps.as_mut().storage)?;
assert_eq!(storage.total_deposits_made(deps.as_ref().storage)?, 3);
Ok(())
}
#[test]
fn iterating_over_deposits() {
let mut deps = mock_dependencies();
+374
View File
@@ -0,0 +1,374 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use cosmwasm_std::{coin, Addr, Coin, Order, StdResult, Storage};
use cw_storage_plus::{Item, Map};
use nym_ecash_contract_common::EcashContractError;
use std::collections::HashMap;
pub(crate) struct DepositStatsStorage {
/// Total deposits performed with the default price
pub(crate) deposits_with_default_price: Item<u32>,
/// Total amounts deposited with the default price
pub(crate) deposits_with_default_price_amounts: Item<Coin>,
/// Total deposits performed with a custom price by account
pub(crate) deposits_with_custom_price: Map<Addr, u32>,
/// Total amounts deposited with a custom price by account
pub(crate) deposits_with_custom_price_amounts: Map<Addr, Coin>,
}
impl DepositStatsStorage {
pub(crate) const fn new() -> Self {
Self {
deposits_with_default_price: Item::new("deposits_with_default_price"),
deposits_with_default_price_amounts: Item::new("deposits_with_default_price_amounts"),
deposits_with_custom_price: Map::new("deposits_with_custom_price"),
deposits_with_custom_price_amounts: Map::new("deposits_with_custom_price_amounts"),
}
}
pub(crate) fn new_default_deposit(
&self,
store: &mut dyn Storage,
deposited: &Coin,
) -> Result<(), EcashContractError> {
self.deposits_with_default_price
.update(store, |count| StdResult::Ok(count + 1))?;
self.deposits_with_default_price_amounts
.update(store, |amount| {
let mut updated = amount;
updated.amount += deposited.amount;
StdResult::Ok(updated)
})?;
Ok(())
}
pub(crate) fn new_reduced_deposit(
&self,
store: &mut dyn Storage,
sender: &Addr,
deposited: &Coin,
) -> Result<(), EcashContractError> {
self.deposits_with_custom_price
.update(store, sender.clone(), |count| {
StdResult::Ok(count.unwrap_or_default() + 1)
})?;
self.deposits_with_custom_price_amounts
.update(store, sender.clone(), |amount| {
let updated = match amount {
None => deposited.clone(),
Some(mut existing) => {
existing.amount += deposited.amount;
existing
}
};
StdResult::Ok(updated)
})?;
Ok(())
}
pub(crate) fn get_total_deposits_made_with_default_price(
&self,
store: &dyn Storage,
) -> StdResult<u32> {
Ok(self
.deposits_with_default_price
.may_load(store)?
.unwrap_or(0))
}
pub(crate) fn get_total_deposited_with_default_price(
&self,
store: &dyn Storage,
denom: &str,
) -> StdResult<Coin> {
Ok(self
.deposits_with_default_price_amounts
.may_load(store)?
.unwrap_or_else(|| coin(0, denom)))
}
pub(crate) fn get_custom_price_deposits(
&self,
store: &dyn Storage,
denom: &str,
) -> StdResult<CustomPriceDepositStats> {
let mut total_count = 0;
let mut total_amount = coin(0, denom);
let mut per_account_count = HashMap::new();
let mut per_account_amount = HashMap::new();
for item in self
.deposits_with_custom_price
.range(store, None, None, Order::Ascending)
{
let (addr, count) = item?;
total_count += count;
per_account_count.insert(addr.into_string(), count);
}
for item in
self.deposits_with_custom_price_amounts
.range(store, None, None, Order::Ascending)
{
let (addr, amount) = item?;
total_amount.amount += amount.amount;
per_account_amount.insert(addr.into_string(), amount);
}
Ok(CustomPriceDepositStats {
total_count,
total_amount,
per_account_count,
per_account_amount,
})
}
}
impl DepositStatsStorage {
/// Asserts that the per-tier deposit counts sum to the given total.
/// Only meaningful when all deposits go through the contract entry point
/// (not after raw storage writes that bypass bookkeeping).
#[cfg(test)]
pub(crate) fn assert_counts_consistent(&self, store: &dyn Storage, total_deposits_made: u32) {
let default_count = self
.get_total_deposits_made_with_default_price(store)
.unwrap();
let custom = self.get_custom_price_deposits(store, "unused").unwrap();
assert_eq!(
default_count + custom.total_count,
total_deposits_made,
"deposit stats invariant violated: default ({default_count}) + custom ({}) != total ({total_deposits_made})",
custom.total_count,
);
}
}
pub(crate) struct CustomPriceDepositStats {
pub(crate) total_count: u32,
pub(crate) total_amount: Coin,
pub(crate) per_account_count: HashMap<String, u32>,
pub(crate) per_account_amount: HashMap<String, Coin>,
}
#[cfg(test)]
mod tests {
use super::*;
use cosmwasm_std::coin;
use cosmwasm_std::testing::mock_dependencies;
const DENOM: &str = "unym";
const DEFAULT_AMOUNT: u128 = 75_000_000;
const REDUCED_AMOUNT: u128 = 10_000_000;
/// Mirror what `instantiate` does: zero-initialise the default-price counters.
/// The custom-price Maps need no initialisation (they start empty).
fn init_stats(storage: &mut dyn Storage) -> DepositStatsStorage {
let stats = DepositStatsStorage::new();
stats.deposits_with_default_price.save(storage, &0).unwrap();
stats
.deposits_with_default_price_amounts
.save(storage, &coin(0, DENOM))
.unwrap();
stats
}
#[test]
fn single_default_deposit_increments_count_and_amount() {
let mut deps = mock_dependencies();
let stats = init_stats(deps.as_mut().storage);
stats
.new_default_deposit(deps.as_mut().storage, &coin(DEFAULT_AMOUNT, DENOM))
.unwrap();
assert_eq!(
stats
.deposits_with_default_price
.load(deps.as_ref().storage)
.unwrap(),
1
);
assert_eq!(
stats
.deposits_with_default_price_amounts
.load(deps.as_ref().storage)
.unwrap(),
coin(DEFAULT_AMOUNT, DENOM)
);
}
#[test]
fn multiple_default_deposits_accumulate() {
let mut deps = mock_dependencies();
let stats = init_stats(deps.as_mut().storage);
for _ in 0..3 {
stats
.new_default_deposit(deps.as_mut().storage, &coin(DEFAULT_AMOUNT, DENOM))
.unwrap();
}
assert_eq!(
stats
.deposits_with_default_price
.load(deps.as_ref().storage)
.unwrap(),
3
);
assert_eq!(
stats
.deposits_with_default_price_amounts
.load(deps.as_ref().storage)
.unwrap(),
coin(DEFAULT_AMOUNT * 3, DENOM)
);
}
#[test]
fn single_reduced_deposit_is_tracked_per_address() {
let mut deps = mock_dependencies();
let stats = init_stats(deps.as_mut().storage);
let alice = deps.api.addr_make("alice");
stats
.new_reduced_deposit(deps.as_mut().storage, &alice, &coin(REDUCED_AMOUNT, DENOM))
.unwrap();
assert_eq!(
stats
.deposits_with_custom_price
.load(deps.as_ref().storage, alice.clone())
.unwrap(),
1
);
assert_eq!(
stats
.deposits_with_custom_price_amounts
.load(deps.as_ref().storage, alice.clone())
.unwrap(),
coin(REDUCED_AMOUNT, DENOM)
);
// default-price stats must be untouched
assert_eq!(
stats
.deposits_with_default_price
.load(deps.as_ref().storage)
.unwrap(),
0
);
}
#[test]
fn multiple_reduced_deposits_same_address_accumulate() {
let mut deps = mock_dependencies();
let stats = init_stats(deps.as_mut().storage);
let alice = deps.api.addr_make("alice");
for _ in 0..4 {
stats
.new_reduced_deposit(deps.as_mut().storage, &alice, &coin(REDUCED_AMOUNT, DENOM))
.unwrap();
}
assert_eq!(
stats
.deposits_with_custom_price
.load(deps.as_ref().storage, alice.clone())
.unwrap(),
4
);
assert_eq!(
stats
.deposits_with_custom_price_amounts
.load(deps.as_ref().storage, alice.clone())
.unwrap(),
coin(REDUCED_AMOUNT * 4, DENOM)
);
}
#[test]
fn reduced_deposits_for_different_addresses_tracked_independently() {
let mut deps = mock_dependencies();
let stats = init_stats(deps.as_mut().storage);
let alice = deps.api.addr_make("alice");
let bob = deps.api.addr_make("bob");
stats
.new_reduced_deposit(deps.as_mut().storage, &alice, &coin(REDUCED_AMOUNT, DENOM))
.unwrap();
stats
.new_reduced_deposit(deps.as_mut().storage, &alice, &coin(REDUCED_AMOUNT, DENOM))
.unwrap();
stats
.new_reduced_deposit(deps.as_mut().storage, &bob, &coin(5_000_000, DENOM))
.unwrap();
assert_eq!(
stats
.deposits_with_custom_price
.load(deps.as_ref().storage, alice.clone())
.unwrap(),
2
);
assert_eq!(
stats
.deposits_with_custom_price
.load(deps.as_ref().storage, bob.clone())
.unwrap(),
1
);
assert_eq!(
stats
.deposits_with_custom_price_amounts
.load(deps.as_ref().storage, alice)
.unwrap(),
coin(REDUCED_AMOUNT * 2, DENOM)
);
assert_eq!(
stats
.deposits_with_custom_price_amounts
.load(deps.as_ref().storage, bob)
.unwrap(),
coin(5_000_000, DENOM)
);
}
#[test]
fn default_and_reduced_stats_do_not_interfere() {
let mut deps = mock_dependencies();
let stats = init_stats(deps.as_mut().storage);
let alice = deps.api.addr_make("alice");
stats
.new_default_deposit(deps.as_mut().storage, &coin(DEFAULT_AMOUNT, DENOM))
.unwrap();
stats
.new_reduced_deposit(deps.as_mut().storage, &alice, &coin(REDUCED_AMOUNT, DENOM))
.unwrap();
stats
.new_default_deposit(deps.as_mut().storage, &coin(DEFAULT_AMOUNT, DENOM))
.unwrap();
assert_eq!(
stats
.deposits_with_default_price
.load(deps.as_ref().storage)
.unwrap(),
2
);
assert_eq!(
stats
.deposits_with_custom_price
.load(deps.as_ref().storage, alice)
.unwrap(),
1
);
}
}
+3
View File
@@ -22,6 +22,9 @@ pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION");
pub struct Config {
pub group_addr: Cw4Contract,
pub holding_account: Addr,
/// Specifies the expected default deposit amount if the sender is not in the whitelisted set.
#[serde(alias = "default_deposit_amount")]
pub deposit_amount: Coin,
}
+1 -6
View File
@@ -1,15 +1,10 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
#![warn(clippy::expect_used)]
#![warn(clippy::unwrap_used)]
#![warn(clippy::todo)]
#![warn(clippy::dbg_macro)]
mod constants;
pub mod contract;
mod deposit;
mod deposit_stats;
mod helpers;
#[cfg(test)]
pub mod multitest;
mod support;
+198
View File
@@ -10,6 +10,9 @@ use sylvia::{cw_multi_test::App as MtApp, multitest::App};
use crate::contract::sv::mt::{CodeId, NymEcashContractProxy};
const DENOM: &str = "unym";
const DEPOSIT_AMOUNT: u128 = 75_000_000;
#[test]
fn invalid_deposit() {
let owner = "owner".into_bech32();
@@ -73,3 +76,198 @@ fn invalid_deposit() {
EcashContractError::InvalidDeposit(PaymentError::MissingDenom(denom.to_string()))
);
}
#[test]
fn wrong_deposit_amount() {
let owner = "owner".into_bech32();
let mtapp = MtApp::new(|router, _, storage| {
router
.bank
.init_balance(storage, &owner, vec![Coin::new(1_000_000_000u128, DENOM)])
.unwrap()
});
let app = App::new(mtapp);
let code_id = CodeId::store_code(&app);
let contract = code_id
.instantiate(
MockApi::default().addr_make("holding_account").to_string(),
MockApi::default().addr_make("multisig_addr").to_string(),
MockApi::default().addr_make("group_addr").to_string(),
coin(DEPOSIT_AMOUNT, DENOM),
)
.call(&owner)
.unwrap();
let vk = "GLdR2NRVZBiCoCbv4fNqt9wUJZAnNjGXHkx3TjVAUzrK";
// too little
assert_eq!(
contract
.deposit_ticket_book_funds(vk.to_string())
.with_funds(&[coin(1_000_000u128, DENOM)])
.call(&owner)
.unwrap_err(),
EcashContractError::WrongAmount {
received: coin(1_000_000u128, DENOM),
amount: coin(DEPOSIT_AMOUNT, DENOM),
}
);
// too much
assert_eq!(
contract
.deposit_ticket_book_funds(vk.to_string())
.with_funds(&[coin(100_000_000u128, DENOM)])
.call(&owner)
.unwrap_err(),
EcashContractError::WrongAmount {
received: coin(100_000_000u128, DENOM),
amount: coin(DEPOSIT_AMOUNT, DENOM),
}
);
}
#[test]
fn correct_default_deposit_succeeds() {
let owner = "owner".into_bech32();
let mtapp = MtApp::new(|router, _, storage| {
router
.bank
.init_balance(storage, &owner, vec![Coin::new(1_000_000_000u128, DENOM)])
.unwrap()
});
let app = App::new(mtapp);
let code_id = CodeId::store_code(&app);
let contract = code_id
.instantiate(
MockApi::default().addr_make("holding_account").to_string(),
MockApi::default().addr_make("multisig_addr").to_string(),
MockApi::default().addr_make("group_addr").to_string(),
coin(DEPOSIT_AMOUNT, DENOM),
)
.call(&owner)
.unwrap();
let vk = "GLdR2NRVZBiCoCbv4fNqt9wUJZAnNjGXHkx3TjVAUzrK";
contract
.deposit_ticket_book_funds(vk.to_string())
.with_funds(&[coin(DEPOSIT_AMOUNT, DENOM)])
.call(&owner)
.unwrap();
}
#[test]
fn reduced_price_deposit_end_to_end() {
let owner = "owner".into_bech32();
let whitelisted = "whitelisted".into_bech32();
let non_whitelisted = "non_whitelisted".into_bech32();
let reduced_amount: u128 = 10_000_000;
let mtapp = MtApp::new(|router, _, storage| {
router
.bank
.init_balance(
storage,
&whitelisted,
vec![Coin::new(1_000_000_000u128, DENOM)],
)
.unwrap();
router
.bank
.init_balance(
storage,
&non_whitelisted,
vec![Coin::new(1_000_000_000u128, DENOM)],
)
.unwrap();
});
let app = App::new(mtapp);
let code_id = CodeId::store_code(&app);
let contract = code_id
.instantiate(
MockApi::default().addr_make("holding_account").to_string(),
MockApi::default().addr_make("multisig_addr").to_string(),
MockApi::default().addr_make("group_addr").to_string(),
coin(DEPOSIT_AMOUNT, DENOM),
)
.call(&owner)
.unwrap();
let vk = "GLdR2NRVZBiCoCbv4fNqt9wUJZAnNjGXHkx3TjVAUzrK";
// whitelist an address with a reduced price
contract
.set_reduced_deposit_price(whitelisted.to_string(), coin(reduced_amount, DENOM))
.call(&owner)
.unwrap();
// whitelisted address can deposit at the reduced price
contract
.deposit_ticket_book_funds(vk.to_string())
.with_funds(&[coin(reduced_amount, DENOM)])
.call(&whitelisted)
.unwrap();
// whitelisted address can also deposit at the default price —
// treated as a normal (non-reduced) deposit for statistics purposes
contract
.deposit_ticket_book_funds(vk.to_string())
.with_funds(&[coin(DEPOSIT_AMOUNT, DENOM)])
.call(&whitelisted)
.unwrap();
// whitelisted address is rejected when sending an amount that is
// neither the reduced nor the default price
assert_eq!(
contract
.deposit_ticket_book_funds(vk.to_string())
.with_funds(&[coin(50_000_000, DENOM)])
.call(&whitelisted)
.unwrap_err(),
EcashContractError::WrongAmount {
received: coin(50_000_000, DENOM),
amount: coin(reduced_amount, DENOM),
}
);
// non-whitelisted address is rejected at the reduced amount
assert_eq!(
contract
.deposit_ticket_book_funds(vk.to_string())
.with_funds(&[coin(reduced_amount, DENOM)])
.call(&non_whitelisted)
.unwrap_err(),
EcashContractError::WrongAmount {
received: coin(reduced_amount, DENOM),
amount: coin(DEPOSIT_AMOUNT, DENOM),
}
);
// non-whitelisted address succeeds at the default amount
contract
.deposit_ticket_book_funds(vk.to_string())
.with_funds(&[coin(DEPOSIT_AMOUNT, DENOM)])
.call(&non_whitelisted)
.unwrap();
let stats = contract.get_deposits_statistics().unwrap();
assert_eq!(stats.total_deposits_made, 3);
assert_eq!(
stats.total_deposited,
coin(reduced_amount + DEPOSIT_AMOUNT * 2, DENOM)
);
// whitelisted depositing at default price + non-whitelisted = 2 default deposits
assert_eq!(stats.total_deposits_made_with_default_price, 2);
assert_eq!(
stats.total_deposited_with_default_price,
coin(DEPOSIT_AMOUNT * 2, DENOM)
);
assert_eq!(stats.total_deposits_made_with_custom_price, 1);
assert_eq!(
stats.total_deposited_with_custom_price,
coin(reduced_amount, DENOM)
);
}
-5
View File
@@ -1,5 +0,0 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
#[cfg(test)]
pub mod tests;
-104
View File
@@ -1,104 +0,0 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::contract::NymEcashContract;
use crate::helpers::Config;
use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env, MockApi, MockQuerier};
use cosmwasm_std::{coin, Addr, Deps, Empty, Env, MemoryStorage, MessageInfo, OwnedDeps};
use rand_chacha::rand_core::SeedableRng;
use rand_chacha::ChaCha20Rng;
use sylvia::ctx::{ExecCtx, InstantiateCtx, QueryCtx};
pub fn test_rng() -> ChaCha20Rng {
let dummy_seed = [42u8; 32];
ChaCha20Rng::from_seed(dummy_seed)
}
const CONTRACT: NymEcashContract = NymEcashContract::new();
const DENOM: &str = "unym";
#[allow(dead_code)]
pub struct TestSetupSimple {
pub deps: OwnedDeps<MemoryStorage, MockApi, MockQuerier<Empty>>,
pub env: Env,
pub rng: ChaCha20Rng,
pub owner: Addr,
pub holding_account: Addr,
pub multisig_contract: Addr,
pub group_contract: Addr,
}
impl TestSetupSimple {
pub fn new() -> Self {
let mut deps = mock_dependencies();
let env = mock_env();
let owner = deps.api.addr_make("owner");
let rng = test_rng();
let holding_account = deps.api.addr_make("holding_account");
let multisig_contract = deps.api.addr_make("multisig_contract");
let group_contract = deps.api.addr_make("group_contract");
let init_ctx =
InstantiateCtx::from((deps.as_mut(), env.clone(), message_info(&owner, &[])));
CONTRACT
.instantiate(
init_ctx,
holding_account.to_string(),
multisig_contract.to_string(),
group_contract.to_string(),
coin(75_000_000, DENOM.to_string()),
)
.unwrap();
TestSetupSimple {
deps,
env,
rng,
owner,
holding_account,
multisig_contract,
group_contract,
}
}
pub fn admin(&self) -> MessageInfo {
let admin = CONTRACT
.contract_admin
.get(self.deps.as_ref())
.unwrap()
.unwrap();
message_info(&admin, &[])
}
pub fn execute_ctx(&mut self, sender: MessageInfo) -> ExecCtx<'_> {
let env = self.env.clone();
ExecCtx::from((self.deps.as_mut(), env, sender))
}
#[allow(dead_code)]
pub fn query_ctx(&self) -> QueryCtx<'_> {
QueryCtx::from((self.deps.as_ref(), self.env.clone()))
}
pub fn contract(&self) -> NymEcashContract {
CONTRACT
}
pub fn deps(&self) -> Deps<'_> {
self.deps.as_ref()
}
pub fn config(&self) -> Config {
CONTRACT.config.load(self.deps().storage).unwrap()
}
pub fn with_deposit_amount(mut self, amount: u128) -> Self {
CONTRACT
.update_deposit_value(self.execute_ctx(self.admin()), coin(amount, DENOM))
.unwrap();
self
}
}
+495 -108
View File
@@ -1,120 +1,507 @@
import React from "react";
import { Box, Grid, Typography } from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import Image from "next/image";
import React, { useState, useEffect, useRef } from "react";
import Link from "next/link";
import networkDocs from "../public/images/landing/Vector1.png";
import developerDocs from "../public/images/landing/Vector2.png";
import sdkDocs from "../public/images/landing/Vector3.png";
import operatorGuide from "../public/images/landing/Vector4.png";
export const LandingPage = () => {
const theme = useTheme();
const isTablet = useMediaQuery(theme.breakpoints.up("md"));
const isDesktop = useMediaQuery(theme.breakpoints.up("xl"));
const asciiStyle: React.CSSProperties = {
fontFamily: "var(--font-mono)",
fontSize: "0.72rem",
lineHeight: 1.4,
color: "var(--colorPrimary)",
opacity: 0.7,
whiteSpace: "pre",
margin: 0,
};
const squares = [
{
text: "Network Docs",
description: "Architecture, crypto systems, and how the Mixnet works",
href: "/network",
icon: developerDocs,
},
{
text: "Operator Guides",
description:
"Guides and maintenance: if you want to run a node, start here",
// ── Animation components ──
href: "/operators/introduction",
icon: operatorGuide,
},
{
text: "Developer Portal",
description: "Conceptual overview, clients, tools and SDKs",
const randomRow = () => Math.floor(Math.random() * 3);
const randomPath = () => [randomRow(), randomRow(), randomRow()];
href: "/developers",
icon: sdkDocs,
},
{
text: "APIs",
description: "Interactive API specs for Nym infrastructure",
const NetworkAnimation = () => {
// Packets traverse 5 stages: gw_e(0) → M1(1) → M2(2) → M3(3) → gw_ex(4)
// stage -1 = not yet mounted (SSR-safe, renders all ○)
const [packets, setPackets] = useState([
{ path: randomPath(), stage: -1 },
{ path: randomPath(), stage: -1 },
]);
useEffect(() => {
// kick off with staggered positions
setPackets([
{ path: randomPath(), stage: 0 },
{ path: randomPath(), stage: 3 },
]);
href: "/apis/introduction",
icon: networkDocs,
},
];
const id = setInterval(() => {
setPackets((prev) =>
prev.map((p) => {
const next = (p.stage + 1) % 5;
return { stage: next, path: next === 0 ? randomPath() : p.path };
})
);
}, 300);
return () => clearInterval(id);
}, []);
const shortenDescription = (description: string) => {
return description.slice(0, 18) + "...";
const gwNode = (stage: number) => {
const active = packets.some((p) => p.stage === stage);
return (
<span
style={
active ? { color: "var(--colorPrimary)", opacity: 1 } : undefined
}
>
{active ? "\u25CF" : "\u25CB"}
</span>
);
};
const mixNode = (col: number, row: number) => {
const active = packets.some(
(p) => p.stage === col + 1 && p.path[col] === row
);
const filled = active;
return (
<span
style={
active ? { color: "var(--colorPrimary)", opacity: 1 } : undefined
}
>
{filled ? "\u25CF" : "\u25CB"}
</span>
);
};
return (
<Box margin={"0 auto"} textAlign="center">
{/*<Typography variant="h2" mb={6}>
Nym Docs
</Typography>
<Typography mb={10}>
Nym is a privacy platform. It provides strong network-level privacy
against sophisticated end-to-end attackers, and anonymous access control
using blinded, re-randomizable, decentralized credentials. Our goal is
to allow developers to build new applications, or upgrade existing apps,
with privacy features unavailable in other systems.
</Typography>*/}
<Grid container border={"1px solid #2E3538"}>
{squares.map((square, index) => (
<Grid
item
key={index}
xs={12}
sm={6}
padding={{ xs: 3, xl: 4 }}
sx={{
borderBottom: {
xs: index < 3 ? "1px solid #2E3538" : "none",
sm: index === 0 || index === 1 ? "1px solid #2E3538" : "none",
},
borderRight: {
xs: "none",
sm: index === 0 || index === 2 ? "1px solid #2E3538" : "none",
},
}}
>
<Link href={square.href}>
<Box
display={"flex"}
gap={{ xs: 3, xl: 4 }}
height={"100%"}
flexDirection="column"
alignItems="center"
>
<Typography variant="h5" sx={{ fontWeight: 600 }}>
{square.text}
</Typography>
<Typography
variant="body1"
textAlign="center"
sx={{
color: "#909195",
}}
>
{square.description}
</Typography>
<Image
src={square.icon}
alt={square.text}
width={isDesktop ? 180 : isTablet ? 140 : 180}
height={isDesktop ? 134 : isTablet ? 90 : 134}
/>
</Box>
</Link>
</Grid>
))}
</Grid>
</Box>
<pre style={{ ...asciiStyle, marginTop: "1.2rem" }}>
{"gw_e M1 M2 M3 gw_ex\n"}
{" "}
{mixNode(0, 0)}
{" \u2500\u2500 "}
{mixNode(1, 0)}
{" \u2500\u2500 "}
{mixNode(2, 0)}
{"\n"}
{" \\ / \\ /\n"}
{" "}
{gwNode(0)}
{" \u2500\u2500 "}
{mixNode(0, 1)}
{" \u2500\u2500 "}
{mixNode(1, 1)}
{" \u2500\u2500 "}
{mixNode(2, 1)}
{" \u2500\u2500 "}
{gwNode(4)}
{"\n"}
{" / \\ / \\\n"}
{" "}
{mixNode(0, 2)}
{" \u2500\u2500 "}
{mixNode(1, 2)}
{" \u2500\u2500 "}
{mixNode(2, 2)}
</pre>
);
};
const TypewriterAnimation = () => {
const text =
"let client = MixnetClient::connect_new().await?;\n" +
"\n" +
"client.send(msg).await;";
const [charCount, setCharCount] = useState(0);
const [showCursor, setShowCursor] = useState(true);
useEffect(() => {
let cancelled = false;
const run = () => {
setCharCount(0);
let i = 0;
const type = () => {
if (cancelled) return;
if (i <= text.length) {
setCharCount(i);
i++;
setTimeout(type, 40);
} else {
setTimeout(() => {
if (!cancelled) run();
}, 2000);
}
};
type();
};
run();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const id = setInterval(() => setShowCursor((v) => !v), 530);
return () => clearInterval(id);
}, []);
return (
<pre style={{ ...asciiStyle, marginTop: "1.2rem" }}>
{text.slice(0, charCount)}
<span style={{ opacity: 0.6 }}>{showCursor ? "\u258C" : " "}</span>
<span style={{ opacity: 0 }}>{text.slice(charCount)}</span>
</pre>
);
};
const OperatorsAnimation = () => {
const totalBars = 10;
const [tick, setTick] = useState(0);
const mixRef = useRef(0);
const [mixCount, setMixCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setTick((t) => t + 1);
mixRef.current += Math.floor(Math.random() * 8) + 5;
setMixCount(mixRef.current);
}, 80);
return () => clearInterval(id);
}, []);
const mixFilled = Math.min(tick % 12, totalBars);
const bar = (f: number) =>
"\u25A0".repeat(f) + "\u25A1".repeat(totalBars - f);
const fmt = (n: number) => n.toLocaleString("en");
return (
<pre style={{ ...asciiStyle, marginTop: "1.2rem" }}>
{"> nym-node run\n\n"}
{" mixing: "}
{bar(mixFilled)}
{" "}
{fmt(mixCount)}
{" pkts"}
</pre>
);
};
const ApiAnimation = () => {
const lines = [
"GET /v1/mixnodes/active",
"",
'{ "count": 498,',
' "nodes": [ ... ] }',
];
const [visibleLines, setVisibleLines] = useState(0);
useEffect(() => {
let cancelled = false;
const run = () => {
setVisibleLines(0);
setTimeout(() => {
if (cancelled) return;
setVisibleLines(1);
setTimeout(() => {
if (cancelled) return;
let i = 2;
const reveal = () => {
if (cancelled) return;
if (i <= lines.length) {
setVisibleLines(i);
i++;
setTimeout(reveal, 300);
} else {
setTimeout(() => {
if (!cancelled) run();
}, 2000);
}
};
reveal();
}, 800);
}, 100);
};
run();
return () => {
cancelled = true;
};
}, []);
return (
<pre style={{ ...asciiStyle, marginTop: "1.2rem" }}>
{lines.slice(0, visibleLines).map((line, i) => (
<React.Fragment key={i}>
{i > 0 && "\n"}
{line}
</React.Fragment>
))}
<span style={{ opacity: 0 }}>
{lines.slice(visibleLines).map((line, i) => (
<React.Fragment key={i}>
{visibleLines > 0 || i > 0 ? "\n" : ""}
{line}
</React.Fragment>
))}
</span>
</pre>
);
};
// ── Section data ──
const sections = [
{
title: "Network",
description:
"Architecture, cryptographic systems, and how the Mixnet protects your traffic.",
href: "/network",
animation: "network" as const,
},
{
title: "Developers",
description: "SDKs, tutorials, and integration guides for building on Nym.",
href: "/developers",
animation: "typewriter" as const,
},
{
title: "Operators",
description:
"Set up and maintain mix nodes, gateways, and network infrastructure.",
href: "/operators/introduction",
animation: "progress" as const,
},
{
title: "APIs",
description: "Interactive specs for querying Nym infrastructure.",
href: "/apis/introduction",
animation: "api" as const,
},
];
const AnimationBlock = ({ type }: { type: string }) => {
switch (type) {
case "network":
return <NetworkAnimation />;
case "typewriter":
return <TypewriterAnimation />;
case "progress":
return <OperatorsAnimation />;
case "api":
return <ApiAnimation />;
default:
return null;
}
};
const sdks = [
{
name: "Rust",
description:
"Native SDK with async Mixnet client, streams, and TcpProxy modules.",
href: "/developers/rust",
},
{
name: "TypeScript",
description:
"Browser-based SDK with fetch API replacement and message-based WebSocket transport.",
href: "/developers/typescript",
},
];
export const LandingPage = () => {
return (
<div
style={{ maxWidth: "64rem", margin: "0 auto", padding: "3rem 1.5rem" }}
>
{/* ── Section cards ── */}
<div
className="landing-grid"
style={{
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
border: "1px solid var(--border)",
marginBottom: "3.5rem",
}}
>
{sections.map((s, i) => (
<Link
key={i}
href={s.href}
style={{ textDecoration: "none", display: "flex" }}
>
<div
data-index={i}
style={{
padding: "1.5rem",
borderBottom: i < 2 ? "1px solid var(--border)" : undefined,
borderRight:
i % 2 === 0 ? "1px solid var(--border)" : undefined,
display: "flex",
flexDirection: "column",
flex: 1,
transition: "background-color 0.15s",
cursor: "pointer",
}}
className="landing-card"
>
<div>
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "0.5rem",
}}
>
<h2
className="landing-heading"
style={{
fontFamily: "var(--font-mono)",
fontSize: "1.25rem",
fontWeight: 600,
margin: 0,
padding: 0,
border: "none",
}}
>
{s.title}
</h2>
<span
style={{ color: "var(--textMuted)", fontSize: "0.9rem" }}
>
&rsaquo;
</span>
</div>
<p
style={{
fontSize: "0.88rem",
color: "var(--textMuted)",
lineHeight: 1.6,
margin: 0,
}}
>
{s.description}
</p>
</div>
<AnimationBlock type={s.animation} />
</div>
</Link>
))}
</div>
{/* ── SDKs ── */}
<div
className="landing-sdk-grid"
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0",
marginBottom: "3.5rem",
}}
>
<div style={{ paddingRight: "2rem" }}>
<h2
className="landing-heading"
style={{
fontFamily: "var(--font-mono)",
fontSize: "1.35rem",
fontWeight: 600,
marginBottom: "0.5rem",
border: "none",
padding: 0,
}}
>
SDKs
</h2>
<p
style={{
fontSize: "0.88rem",
color: "var(--textMuted)",
lineHeight: 1.6,
}}
>
Integrate Mixnet privacy into your application with our Rust and
TypeScript SDKs.
</p>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "0" }}>
{sdks.map((sdk, i) => (
<Link key={i} href={sdk.href} style={{ textDecoration: "none" }}>
<div
className="landing-card"
style={{
padding: "1rem 1.2rem",
border: "1px solid var(--border)",
marginTop: i > 0 ? "-1px" : undefined,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
transition: "background-color 0.15s",
cursor: "pointer",
}}
>
<div>
<span
className="landing-heading"
style={{
fontFamily: "var(--font-mono)",
fontSize: "1rem",
fontWeight: 600,
}}
>
{sdk.name}
</span>
<p
style={{
fontSize: "0.8rem",
color: "var(--textMuted)",
margin: "0.25rem 0 0 0",
}}
>
{sdk.description}
</p>
</div>
<span style={{ color: "var(--textMuted)", fontSize: "1rem" }}>
&rsaquo;
</span>
</div>
</Link>
))}
</div>
</div>
{/* ── Links ── */}
<div
style={{
borderTop: "1px solid var(--border)",
paddingTop: "1.5rem",
display: "flex",
gap: "2rem",
fontSize: "0.82rem",
fontFamily: "var(--font-mono)",
}}
>
<a
href="https://github.com/nymtech"
target="_blank"
rel="noopener noreferrer"
style={{ color: "var(--textMuted)", textDecoration: "none" }}
>
GitHub
</a>
<a
href="https://matrix.to/#/#operators:nymtech.chat"
target="_blank"
rel="noopener noreferrer"
style={{ color: "var(--textMuted)", textDecoration: "none" }}
>
Matrix
</a>
<a
href="https://nym.com"
target="_blank"
rel="noopener noreferrer"
style={{ color: "var(--textMuted)", textDecoration: "none" }}
>
nym.com
</a>
</div>
</div>
);
};
@@ -1,4 +1,4 @@
Open the needed ports for `nym-node` by running these commands:
import { Callout } from 'nextra/components';
```sh
ufw allow 22/tcp # SSH - you're in control of these ports
@@ -7,8 +7,16 @@ ufw allow 443/tcp # HTTPS
ufw allow 1789/tcp # Nym specific - Mixnet
ufw allow 1790/tcp # Nym specific - Verloc
ufw allow 8080/tcp # Nym specific - nym-node-api
ufw allow 9000/tcp # Nym Specific - clients port
ufw allow 9000/tcp # Nym specific - clients port
ufw allow 9001/tcp # Nym specific - wss port
ufw allow 51822/udp # WireGuard
ufw allow in on nymwg to any port 51830 proto tcp # bandwidth queries/topup - inside the tunnel
ufw allow 41264/tcp # Nym specific - Lewes Protocol registration
ufw allow 51264/udp # Nym specific - Lewes Protocol data
```
<Callout>
Note that these ports were moved to [NTM](/operators/nodes/preliminary-steps/vps-setup#1-download-the-new-ntm) as they are for Gateways only
```sh
# ufw allow 51822/udp # WireGuard
# ufw allow in on nymwg to any port 51830 proto tcp # bandwidth queries/topup - inside the tunnel
```
</Callout>
@@ -0,0 +1,79 @@
import { MyTab } from 'components/generic-tabs.tsx';
import { Tabs, Steps } from 'nextra/components';
import PortsNymNode from 'components/operators/snippets/ports-nym-node.mdx';
<div>
<Tabs
items={['Mixnode', 'Gateways and WireGuard']}
defaultIndex={0}
>
<MyTab>
For operators considering to eventually run `nym-node` in a Gateway mode or supporting WireGuard routing, use the tab for Gateways & WireGuard.
For mixnode only open the needed ports for Nym services using Uncomplicated Firewall (`ufw`) by running these commands:
<Steps>
###### 1. Ensure `ufw` functionality
- Check if you have `ufw` installed:
```sh
ufw version
```
- If it's not installed, install with:
```sh
apt install ufw -y
```
- Enable ufw
```sh
ufw enable
```
- Check the status of the firewall
```sh
ufw status
```
###### 2. Open required ports for Nym services using `ufw`
- Copy this whole block to open all ports for mixnode
<PortsNymNode />
###### 3. Validate opened ports
- Re-check the status of the firewall
```sh
ufw status
```
</Steps>
</MyTab>
<MyTab>
Gateways (and WireGuard) nodes use [`network-tunnel-manager.sh` (NTM)](https://github.com/nymtech/nym/refs/heads/develop/scripts/nym-node-setup/network-tunnel-manager.sh) tool to open ports for Nym related services, setup and configure Nym networking devices, configure Nym exit policy and run tests. For more info, read the chapter [*Routing configuration*](/operators/nodes/nym-node/configuration#routing-configuration).
Follow these steps to setup complete routing of your server hosting a Nym node.
<Steps>
###### 1. Download the new NTM
- Download the latest NTM and make it executable:
```sh
curl -L https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/nym-node-setup/network-tunnel-manager.sh -o ./network-tunnel-manager.sh && \
chmod +x network-tunnel-manager.sh
```
###### 2. Update the exit policy
- To ensure your routing is clean, run:
```sh
./network-tunnel-manager.sh complete_networking_configuration
```
</Steps>
</MyTab>
</Tabs>
</div>
@@ -1 +1 @@
Thursday, March 12th 2026, 13:23:46 UTC
Thursday, March 26th 2026, 11:17:59 UTC
@@ -11,7 +11,7 @@ options:
--no_routing_history Display node stats without routing history
--no_verloc_metrics Display node stats without verloc metrics
-m, --markdown Display results in markdown format
-o, --output [OUTPUT]
-o [OUTPUT], --output [OUTPUT]
Save results to file (in current dir or supply with
path without filename)
```
@@ -12,7 +12,8 @@ usage: nym-node-cli install [-h] [-V] [-d BRANCH] [-v]
options:
-h, --help show this help message and exit
-V, --version show program's version number and exit
-d, --dev BRANCH Define github branch (default: develop)
-d BRANCH, --dev BRANCH
Define github branch (default: develop)
-v, --verbose Show full error tracebacks
--mode {mixnode,entry-gateway,exit-gateway}
Node mode: 'mixnode', 'entry-gateway', or 'exit-
+1 -1
View File
@@ -38,7 +38,7 @@
"@nextui-org/accordion": "^2.0.40",
"@nextui-org/react": "^2.4.8",
"@nymproject/contract-clients": ">=1.2.4-rc.2 || ^1",
"@nymproject/mix-fetch-full-fat": "^1.4.2",
"@nymproject/mix-fetch-full-fat": "^1.4.3",
"@nymproject/sdk-full-fat": ">=1.5.1-rc.0 || ^1.4.1",
"@redocly/cli": "^1.25.15",
"@types/mdx": "^2.0.13",
+12 -1
View File
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React, { useMemo, useEffect } from 'react';
import type { AppProps } from 'next/app';
import './styles.css';
import { ThemeProvider, createTheme } from '@mui/material/styles';
@@ -20,6 +20,17 @@ const MyApp: React.FC<AppProps> = ({ Component, pageProps }) => {
}),
[],
);
useEffect(() => {
const handler = (e: MouseEvent) => {
const img = e.target as HTMLElement;
if (img.tagName === 'IMG' && img.closest('.nextra-content')) {
img.classList.toggle('img-expanded');
}
};
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
}, []);
const AnyComponent = Component as any;
return (
<ThemeProvider theme={muiTheme}>
@@ -19,11 +19,16 @@ Internally it uses the WASM client.
- [docs](./typescript/start#mixfetch)
- [example](./typescript/playground/mixfetch)
<Callout type="warning">
<Callout type="info">
### Current Limitations of `mixFetch`
It cannot currently handle concurrent requests, and comes with a pre-bundled CA store. `mixFetchv2` - which will act more like a general-purpose userspace IP stack - is currently in development.
`mixFetch` can currently only perform 10 concurrent requests (i.e. in-flight requests where a request has been sent to a remote endpoint, but no result has been recieved).
`mixFetchv2` - which will act more like a general-purpose userspace IP stack - is currently in development.
It is shipped with a pre-bundled CA store.
</Callout>
@@ -3,6 +3,4 @@
Core components:
* A **Mixnet**, which mixes Sphinx packet traffic so that it cannot be determined who is communicating with whom. Our mixnet is based on a modified version of the [**Loopix** design](concepts/loopix). This is made up of [Nym nodes](architecture/mixnet#nodes) runnning on servers around the world maintained by a decentralised group of Operators.
* Various [**Nym clients**](architecture/mixnet#nym-clients) which manage sending and receiving Sphinx packets, encrypting/decrypting traffic, and providing [cover traffic](./concepts/cover-traffic) to hide 'real' traffic timing.
* A CosmWasm-enabled blockchain called [**Nyx**](architecture/nyx), the home of the various smart contracts used by the mixnet. A subset of Nyx Validators run [NymAPI](./architecture/nyx#nymapi) instances, taking part in also producing and verifying [zk-nym credentials](cryptography/zk-nym).
![arch_overview](/images/network/arch/overall-arch.png)
* A CosmWasm-enabled blockchain called [**Nyx**](architecture/nyx), the home of the various smart contracts used by the mixnet. A subset of Nyx Validators run [NymAPI](./architecture/nyx#nymapi) instances, taking part in also producing and verifying [zk-nym credentials](cryptography/zk-nym).
@@ -57,6 +57,106 @@ This page displays a full list of all the changes during our release cycle from
<VarInfo />
## `v2026.6-stilton`
- [Release Binaries](https://github.com/nymtech/nym/releases/tag/nym-binaries-v2026.6-stilton)
- [`nym-node`](nodes/nym-node.mdx) version `1.28.0`
```sh
nym-node
Binary Name: nym-node
Build Timestamp: 2026-03-25T13:41:43.299729408Z
Build Version: 1.28.0
Commit SHA: 034346917901bd373dee0ca170b26a9359dbd26d
Commit Date: 2026-03-25T07:47:04.000000000+01:00
Commit Branch: HEAD
rustc Version: 1.91.1
rustc Channel: stable
cargo Profile: release
```
### Operators Updates & Tools
New release is here and with it a completely new theme of the docs!
<Callout type="warning" emoji="⚠️">
**This release introduces implementation of Lewes Protocol (LP). Only nodes upgraded to this version will be able to complete handshake using LP.**
**Please upgrade soon and follow the section below to ensure smooth functionality of your `nym-node`.**
</Callout>
1. **[Network Tunnel Manager (NTM)](#network-tunnel-manager-ntm-updates) is now the single port manager for WireGuard routing and Gateway Nym nodes.**
2. Lewes protocol ports (`41264/tcp` and `51264/udp`) as well as all the essential ports for mixnet operation (before `ufw`) are now included in NTM
- Follow [these steps](#network-tunnel-manager-ntm-updates) to implement changes if you run any Gateway type of Nym node
- For operators running mixnode mode, make sure to open all ports via `ufw` as [documented here](/operators/nodes/preliminary-steps/vps-setup#3-configure-your-firewall), do *not* use NTM as you nodes do *not* need all exress ports (exit policy) opened
#### Network Tunnel Manager (NTM) Updates
<Callout type="info" emoji="️">
Nym team is testing an unreleased version of [Gateway Probe](/operators/performance-and-testing/gateway-probe). This new version checks whether the ports opened align with the governance decision about exit policy. If they don't, the nodes will be taken out of Service grant program and [Delegation program](https://nym.com/network/DP).
</Callout>
NTM is now changed to be a standalone port manager for servers hosting Nym nodes running in a Gateway mode (with or without WireGuard). Operators of these nodes no longer need to manage mixnet fundamental ports by `ufw` separately, as the NTM will take care of it as well as adjusting the ports according to Nym exit policy.
**Follow these steps to update the ports setting of your server using NTM.**
<Steps>
###### 1. Download the new NTM
- Download the latest NTM and make it executable:
```sh
curl -L https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/nym-node-setup/network-tunnel-manager.sh -o ./network-tunnel-manager.sh && \
chmod +x network-tunnel-manager.sh
```
###### 2. Update the exit policy
- Run NTM to become main port manager (including Nym services), run:
```sh
./network-tunnel-manager.sh complete_networking_configuration
```
###### 3. Disable `ufw`
Right now your NTM is handling the port management. You can disable `ufw`.
- We suggest to not uninstall it, just make it innactive for the time being:
```sh
ufw disable
```
###### 4. Re-run NTM
- NTM is fully omnipotent, re-rerun it again now with `ufw` disabled to ensure clean state:
```sh
./network-tunnel-manager.sh complete_networking_configuration
```
</ Steps>
Congratulation, your port configuration is up to date, including LP ports.
### Features
- [Add LP to NS UI](https://github.com/nymtech/nym/pull/6562): Operators can now monitor LP registration status directly from the UI. The gateway list shows post-quantum registration status via emojis (success/failure), and the dashboard view adds LP count columns for success, failure, and untested, making monitoring faster and easier.
- [Introduce /v3/unstable/nym-nodes/semi-skimmed](https://github.com/nymtech/nym/pull/6499): New API endpoint aggregates LP information for nodes, allowing them to discover each other's LP capabilities and establish shared post-quantum connections efficiently.
- [Additional ticket for agent](https://github.com/nymtech/nym/pull/6551): Adds an extra ticket so the agent/probe can perform LP tests automatically without additional configuration.
### Bugfix
- [LP fixes](https://github.com/nymtech/nym/pull/6601): Improvements for LP registration results and preshared key handling to ensure accurate LP status reporting.
- [Allow deserialisation of LP data from either snake_case or lowercase](https://github.com/nymtech/nym/pull/6586): Fixes LP data deserialization issues, making LP information robust across different formats.
### Refactors & Maintenance
- [LP improvements](https://github.com/nymtech/nym/pull/6526): Refactors LP handling code and scaffolds internode handshake processing to simplify future maintenance and reduce potential bugs.
## `v2026.5-raclette`
- [Release Binaries](https://github.com/nymtech/nym/releases/tag/nym-binaries-v2026.5-raclette)
@@ -19,11 +19,12 @@ This documentation page provides a guide on how to set up and run a [NYM NODE](.
## Current version
```sh
nym-node
Binary Name: nym-node
Build Timestamp: 2026-03-10T10:14:03.446553605Z
Build Version: 1.27.0
Commit SHA: 7cccf3cfff448f1b38324f7655930a2b621588c6
Commit Date: 2026-03-10T10:46:12.000000000+01:00
Build Timestamp: 2026-03-25T13:41:43.299729408Z
Build Version: 1.28.0
Commit SHA: 034346917901bd373dee0ca170b26a9359dbd26d
Commit Date: 2026-03-25T07:47:04.000000000+01:00
Commit Branch: HEAD
rustc Version: 1.91.1
rustc Channel: stable
@@ -2,7 +2,7 @@ import { Callout } from 'nextra/components';
import { VarInfo } from 'components/variable-info.tsx';
import { Steps } from 'nextra/components';import { Tabs } from 'nextra/components';
import { MyTab } from 'components/generic-tabs.tsx';
import PortsNymNode from 'components/operators/snippets/ports-nym-node.mdx';
import TabsPortsNymNode from 'components/operators/snippets/tabs-ports-nym-node.mdx';
import PortsValidator from 'components/operators/snippets/ports-validator.mdx';
import NymNodeSpecs from 'components/operators/snippets/nym-node-specs.mdx';
import NTPSync from 'components/operators/snippets/ntp-time-sync.mdx'
@@ -54,44 +54,20 @@ apt install ufw --fix-missing
<NTPSync />
###### 3. Configure your firewall using Uncomplicated Firewall (UFW)
###### 3. Configure your firewall
For a `nym-node` or Nyx validator to recieve traffic, you need to open ports on the server. The following commands will allow you to set up a firewall using `ufw`.
- Check if you have `ufw` installed:
```sh
ufw version
```
- If it's not installed, install with:
```sh
apt install ufw -y
```
- Enable ufw
```sh
ufw enable
```
- Check the status of the firewall
```sh
ufw status
```
###### 4. Open all needed ports to have your firewall for `nym-node` working correctly
For a `nym-node` or Nyx validator to recieve traffic, you need to open ports on the server.
<div>
<Tabs items={[
<code>nym-node</code>,
<code>validator</code>,
]} defaultIndex="0">
<MyTab><PortsNymNode/></MyTab>
<MyTab><TabsPortsNymNode/></MyTab>
<MyTab><PortsValidator/></MyTab>
</Tabs>
</div>
- Re-check the status of the firewall:
```sh
ufw status
```
</Steps>
For more information about your node's port configuration, check the [port reference table](#ports-reference-table) below.
+446 -43
View File
@@ -1,21 +1,67 @@
/* nym.com-aligned colour tokens */
:root {
--colorPrimary: #85E89D;
--textPrimary: #FFFFFF;
--bg-dark: #1E2426;
--border-dark: #2E3538;
/* nym docs angular, clean styling (oxide-inspired) */
@font-face {
font-family: 'JetBrains Mono';
src: url('/docs/fonts/JetBrainsMono-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* dark mode background override */
@font-face {
font-family: 'JetBrains Mono';
src: url('/docs/fonts/JetBrainsMono-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('/docs/fonts/JetBrainsMono-SemiBold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('/docs/fonts/JetBrainsMono-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
:root {
--colorPrimary: #85E89D;
--textPrimary: #E0E0E0;
--textMuted: #8B9295;
--bg-dark: #181C1E;
--bg-code: #1C2022;
--border-dark: #2A2F32;
--border: #2A2F32;
--font-mono: 'JetBrains Mono', 'SFMono-Regular', 'Consolas', 'Liberation Mono', 'Menlo', monospace;
}
/* ── Global: kill all rounded corners ── */
*,
*::before,
*::after {
border-radius: 0 !important;
}
/* ── Dark mode backgrounds ── */
html.dark {
background-color: var(--bg-dark);
}
html.dark body {
background-color: var(--bg-dark);
color: var(--textPrimary);
}
/* nextra main content area bg */
html.dark .nextra-nav-container,
html.dark .nextra-sidebar-container,
html.dark .nextra-content,
@@ -24,28 +70,43 @@ html.dark .dark\:nx-bg-dark {
background-color: var(--bg-dark) !important;
}
/* ── Typography ── */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-mono);
letter-spacing: -0.02em;
font-weight: 600;
}
html.dark h1 {
color: #FFFFFF;
font-weight: 700;
}
html.dark h2 {
color: #F0F0F0;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--border-dark);
}
html.dark p, html.dark li {
color: var(--textPrimary);
line-height: 1.7;
}
/* ── Layout ── */
footer {
text-align: center;
}
.nextra-code-block > pre {
/* max-height: 350px !important; */
max-height: none !important;
scroll-y: auto !important;
}
/* set width entire div */
.nx-mx-auto.nx-flex.nx-max-w-\[90rem\] {
max-width: 110rem;
left-padding: 1%;
}
.nextra-toc {
width: 10px !important;
padding-right: 0px;
padding-left: 0px;
/* text-align: right; */
border-left: 1px solid var(--border-dark);
}
.nextra-content {
@@ -56,23 +117,26 @@ footer {
margin: 0 !important;
}
:is(html[class~="dark"] .dark\:nx-bg-primary-300\/10) {
background-color: hsl(var(black) 100% 77%/0.1) !important;
/* ── Nav bar ── */
html.dark .nextra-nav-container {
border-bottom: 1px solid var(--border-dark) !important;
}
/* Sidebar active item */
:is(html .dark\:nx-bg-primary-400\/10) {
background: transparent !important;
border-left: 2px solid #85E89D;
color: #FFFFFF !important;
html.dark .nextra-nav-container nav a,
html.dark .nextra-nav-container nav button,
html.dark .nextra-nav-container nav ul a {
font-family: var(--font-mono);
font-size: 0.85rem;
}
:is(html:not(.dark) .dark\:nx-bg-primary-400\/10) {
background: transparent !important;
border-left: 2px solid #4A9E5C;
color: #242B2D !important;
html.dark .nextra-nav-container nav a:hover,
html.dark .nextra-nav-container nav ul a:hover {
color: var(--colorPrimary) !important;
}
/* ── Sidebar ── */
.nextra-sidebar-container {
border-right: 1px solid var(--border-dark);
width: 300px !important;
@@ -82,21 +146,171 @@ footer {
column-width: 500px;
}
/* Links*/
/* Sidebar font */
html.dark .nextra-sidebar-container {
font-family: var(--font-mono);
font-size: 0.82rem;
}
/* Sidebar text: muted by default */
html.dark .nextra-sidebar-container a {
color: var(--textMuted) !important;
}
html.dark .nextra-sidebar-container a:hover {
color: var(--textPrimary) !important;
}
/* Sidebar separator labels (not active items) */
html.dark .nextra-sidebar-container li.nx-mt-5 .nx-font-semibold {
color: var(--textMuted) !important;
font-size: 0.7rem;
letter-spacing: 0.05em;
}
/* Active sidebar item: left-border accent + subtle tint */
:is(html .dark\:nx-bg-primary-400\/10) {
background: rgba(133, 232, 157, 0.06) !important;
border-left: 2px solid var(--colorPrimary);
color: #FFFFFF !important;
}
html.dark .nextra-sidebar-container :is(.dark\:nx-bg-primary-400\/10) a {
color: #FFFFFF !important;
}
:is(html:not(.dark) .dark\:nx-bg-primary-400\/10) {
background: rgba(74, 158, 92, 0.08) !important;
border-left: 2px solid #4A9E5C;
color: #242B2D !important;
}
:is(html[class~="dark"] .dark\:nx-bg-primary-300\/10) {
background-color: transparent !important;
}
/* ── TOC (hidden — subsections live in sidebar) ── */
.nextra-toc {
display: none !important;
}
/* ── Code blocks ── */
html.dark .nextra-code-block > pre {
background-color: var(--bg-code) !important;
border: 1px solid var(--border-dark);
}
/* Language tag in code blocks */
html.dark .nextra-code-block [class*="nx-absolute"] {
color: var(--textMuted);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Inline code: subtle background, no border */
html.dark code:not(pre code) {
background-color: rgba(255, 255, 255, 0.06) !important;
border: none !important;
padding: 0.15em 0.4em;
font-size: 0.88em;
color: var(--textPrimary);
}
/* ── Callouts: left-border accent ── */
html.dark .nextra-callout {
border: none;
border-left: 3px solid;
background: rgba(255, 255, 255, 0.02);
}
/* ── Tables: monospace uppercase headers ── */
html.dark table {
border-collapse: collapse;
width: 100%;
}
html.dark th {
border-bottom: 1px solid var(--border-dark);
text-align: left;
font-weight: 600;
font-family: var(--font-mono);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--textMuted);
padding: 0.6em 1em;
}
html.dark td {
border-bottom: 1px solid var(--border-dark);
padding: 0.6em 1em;
color: var(--textPrimary);
}
html.dark tr:hover td {
background-color: rgba(255, 255, 255, 0.02);
}
/* ── Search box ── */
html.dark .nextra-search input {
border: 1px solid var(--border-dark);
background-color: var(--bg-dark) !important;
color: var(--textMuted);
font-family: var(--font-mono);
font-size: 0.8rem;
}
html.dark .nextra-search input::placeholder {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.72rem;
}
/* ── Links ── */
.nx-text-primary-600.nx-underline.nx-decoration-from-font {
color: var(--colorPrimary) !important;
}
.MuiTypography-root.MuiTypography-inherit.MuiLink-root.MuiLink-underlineAlways.css-1xr3c94-MuiTypography-root-MuiLink-root {
.nx-text-primary-600 {
color: var(--colorPrimary) !important;
}
.nextra-scrollbar.nx-sticky {
color: var(--colorPrimary) !important;
}
/* ── Breadcrumbs / navigation links ── */
html.dark .nextra-breadcrumb {
color: var(--textMuted);
}
/* Prev/next page links */
html.dark a.nx-flex.nx-items-center {
border-color: var(--border-dark) !important;
}
html.dark a.nx-flex.nx-items-center:hover {
border-color: var(--colorPrimary) !important;
}
/* ── Material UI overrides ── */
.MuiTypography-root.MuiTypography-inherit.MuiLink-root.MuiLink-underlineAlways {
color: var(--colorPrimary) !important;
}
/* Chips*/
.chipContained {
background-color: var(--colorPrimary) !important;
}
/* Buttons */
.MuiButton-root {
color: var(--colorPrimary) !important;
border-color: var(--colorPrimary) !important;
@@ -111,11 +325,7 @@ footer {
color: var(--colorPrimary) !important;
}
.nextra-scrollbar.nx-sticky {
color: var(--colorPrimary) !important;
}
.nx-text-primary-600 {
a.MuiLink-root {
color: var(--colorPrimary) !important;
}
@@ -127,10 +337,203 @@ input:focus-visible {
border-color: var(--colorPrimary) !important;
}
a.MuiLink-root {
color: var(--colorPrimary) !important;
.MuiPaper-root.MuiAccordion-root {
border-radius: 0;
}
.MuiPaper-root.MuiAccordion-root {
border-radius: 8px;
/* ── Landing page ── */
.landing-heading {
color: #FFFFFF;
}
.landing-card:hover {
background-color: rgba(255, 255, 255, 0.03) !important;
}
/* ── Invert diagrams in dark mode ── */
html.dark .nextra-content img:not([src*="landing"]) {
filter: invert(1) hue-rotate(180deg) brightness(0.92) contrast(1.1);
mix-blend-mode: lighten;
cursor: zoom-in;
}
/* Expanded image overlay */
.nextra-content img.img-expanded {
position: fixed !important;
top: 0;
left: 0;
width: 100vw !important;
height: 100vh !important;
max-width: none !important;
object-fit: contain;
background: rgba(0, 0, 0, 0.9);
z-index: 9999;
cursor: zoom-out;
padding: 2rem;
mix-blend-mode: normal;
filter: none;
}
html.dark .nextra-content img.img-expanded {
filter: invert(1) hue-rotate(180deg) brightness(0.92) contrast(1.1);
mix-blend-mode: normal;
}
/* ── Light mode ── */
html:not(.dark) {
--colorPrimary: #1a7a32;
--colorPrimary-light: #1a7a32;
--bg-light: #F5F5F5;
--border-light: #E0E0E0;
--border: #E0E0E0;
--textMuted: #6B7280;
--textMuted-light: #6B7280;
}
html:not(.dark) body {
background-color: var(--bg-light);
}
html:not(.dark) .nextra-nav-container,
html:not(.dark) .nextra-sidebar-container,
html:not(.dark) .nextra-content,
html:not(.dark) .nx-bg-white {
background-color: var(--bg-light) !important;
}
/* Light mode links: darker green */
html:not(.dark) .nx-text-primary-600 {
color: var(--colorPrimary-light) !important;
}
html:not(.dark) .nx-text-primary-600.nx-underline.nx-decoration-from-font {
color: var(--colorPrimary-light) !important;
}
/* Light mode sidebar */
html:not(.dark) .nextra-sidebar-container {
font-family: var(--font-mono);
font-size: 0.82rem;
border-right: 1px solid var(--border-light);
}
html:not(.dark) .nextra-sidebar-container a {
color: var(--textMuted-light) !important;
}
html:not(.dark) .nextra-sidebar-container a:hover {
color: #111 !important;
}
/* Light mode nav */
html:not(.dark) .nextra-nav-container {
border-bottom: 1px solid var(--border-light) !important;
}
html:not(.dark) .nextra-nav-container nav a,
html:not(.dark) .nextra-nav-container nav button,
html:not(.dark) .nextra-nav-container nav ul a {
font-family: var(--font-mono);
font-size: 0.85rem;
}
html:not(.dark) .nextra-nav-container nav a:hover,
html:not(.dark) .nextra-nav-container nav ul a:hover {
color: var(--colorPrimary-light) !important;
}
/* Light mode code */
html:not(.dark) code:not(pre code) {
background-color: rgba(0, 0, 0, 0.05) !important;
border: none !important;
padding: 0.15em 0.4em;
font-size: 0.88em;
}
/* Light mode callouts */
html:not(.dark) .nextra-callout {
border: none;
border-left: 3px solid;
background: rgba(0, 0, 0, 0.02);
}
/* Light mode tables */
html:not(.dark) th {
font-family: var(--font-mono);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--textMuted-light);
}
/* Light mode search */
html:not(.dark) .nextra-search input {
font-family: var(--font-mono);
font-size: 0.8rem;
border: 1px solid var(--border-light);
background-color: #FFFFFF !important;
}
html:not(.dark) .nextra-search input::placeholder {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.72rem;
}
/* Light mode h2 border */
html:not(.dark) h2 {
padding-bottom: 0.3em;
border-bottom: 1px solid var(--border-light);
}
/* Light mode landing page */
html:not(.dark) .landing-heading {
color: #1a1a1a !important;
}
html:not(.dark) .landing-card:hover {
background-color: rgba(0, 0, 0, 0.02) !important;
}
html:not(.dark) .landing-card {
border-color: var(--border-light) !important;
}
/* ── Landing page mobile responsiveness ── */
@media (max-width: 640px) {
.landing-grid {
grid-template-columns: 1fr !important;
overflow: hidden;
}
/* In single-column mode every card gets a bottom border except the last */
.landing-grid .landing-card {
border-right: none !important;
border-bottom: 1px solid var(--border) !important;
overflow: hidden;
}
.landing-grid > a:last-child .landing-card {
border-bottom: none !important;
}
/* Contain pre animations so they don't blow out the viewport */
.landing-grid pre,
.landing-sdk-grid pre {
font-size: 0.6rem !important;
overflow: hidden !important;
}
.landing-sdk-grid {
grid-template-columns: 1fr !important;
}
.landing-sdk-grid > div:first-child {
padding-right: 0 !important;
margin-bottom: 1.5rem;
}
}
+5 -8
View File
@@ -78,8 +78,8 @@ importers:
specifier: '>=1.2.4-rc.2 || ^1'
version: 1.4.1
'@nymproject/mix-fetch-full-fat':
specifier: ^1.4.2
version: 1.4.2
specifier: ^1.4.3
version: 1.4.3
'@nymproject/sdk-full-fat':
specifier: '>=1.5.1-rc.0 || ^1.4.1'
version: 1.4.1
@@ -104,9 +104,6 @@ importers:
next:
specifier: 15.5.10
version: 15.5.10(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next-sitemap:
specifier: 4.2.3
version: 4.2.3(next@15.5.10(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
nextra:
specifier: '2'
version: 2.13.4(next@15.5.10(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -1778,8 +1775,8 @@ packages:
'@nymproject/contract-clients@1.4.1':
resolution: {integrity: sha512-HuJZ4Hv+Rl6ZZEtCHKgurNLJapM+QQRJlGkevFH2a4UdqUqF9omUkUi3AVes4679dPoSFgvA7plyVSDBdbgV6w==}
'@nymproject/mix-fetch-full-fat@1.4.2':
resolution: {integrity: sha512-QHPwa7A+c/2VUm4Imq2I21toFiZhbZxcjHud1sFsE9hN5BWxZ+QJKV2bg9oBUzulzoQabsk48RA13/hqU7c4KA==}
'@nymproject/mix-fetch-full-fat@1.4.3':
resolution: {integrity: sha512-r3WVZDDFv+eFWPxhkMDg2VvLWd3ws1OK6kFT6bEt6/qTpfA21vV2MouuuyVkBy8DEUKqyU4z/8MiVFxfpkWlsg==}
'@nymproject/sdk-full-fat@1.4.1':
resolution: {integrity: sha512-dh5bvMUj3m8nEssvO8Nl66WpcJAjwRZrGNwqfczJWLG4nX3Vt95tPLv4v0/Z1W3DQWQFW6WmEPPYHNjl18V/fA==}
@@ -9536,7 +9533,7 @@ snapshots:
'@nymproject/contract-clients@1.4.1': {}
'@nymproject/mix-fetch-full-fat@1.4.2': {}
'@nymproject/mix-fetch-full-fat@1.4.3': {}
'@nymproject/sdk-full-fat@1.4.1': {}
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+1 -1
View File
@@ -139,7 +139,7 @@ const config: DocsThemeConfig = {
</>
);
},
logo: <span>Nym Docs</span>,
logo: <span style={{ fontFamily: "var(--font-mono)", fontSize: "1.1rem", fontWeight: 700 }}>Nym Docs</span>,
project: {
link: "https://github.com/nymtech/nym",
},
+1 -1
View File
@@ -9,7 +9,7 @@ fallback_extensions = ["mdx", "md"]
# Remap /images to public/images for NextJS static assets
# Lychee resolves /images to pages/images, but Next.js serves from public/images
remap = ["pages/images public/images"]
remap = ["pages/images public/images", "pages/docs/fonts public/fonts"]
# Exclude component snippets (TODO: verify which are still in use)
exclude_path = ["components/"]
+1 -1
View File
@@ -4,7 +4,7 @@
[package]
name = "nym-api"
license = "GPL-3.0"
version = "1.1.75"
version = "1.1.77"
authors.workspace = true
edition = "2021"
rust-version.workspace = true
@@ -296,11 +296,14 @@ fn translate_digests(
ToSchema,
Ord,
)]
#[serde(rename_all = "snake_case")]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
#[non_exhaustive]
pub enum LPKEM {
#[serde(alias = "ml_kem768")]
MlKem768,
#[serde(alias = "mc_eliece")]
McEliece,
}
@@ -320,7 +323,7 @@ pub enum LPKEM {
ToSchema,
Ord,
)]
#[serde(rename_all = "snake_case")]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
#[non_exhaustive]
pub enum LPHashFunction {
@@ -345,7 +348,7 @@ pub enum LPHashFunction {
EnumString,
ToSchema,
)]
#[serde(rename_all = "snake_case")]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
#[non_exhaustive]
pub enum LPSignatureScheme {
+1 -1
View File
@@ -3,7 +3,7 @@
[package]
name = "nym-data-observatory"
version = "1.0.2"
version = "1.0.1"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
-26
View File
@@ -6,32 +6,6 @@ Collects data about the Nym network including:
- **Price scraper** - to get the NYM/USD token price from CoinGecko
- **Webhooks** - trigger on messages or all messages to call with details
## Processing historical data or reprocessing block ranges
You can run the scraper to operate on a range of blocks, without listening for new blocks. This is useful for:
- processing historical data
- reprocessing block ranges
Run this as follows to process blocks starting at 1000 and ending at 2000:
```bash
./nym_data_observatory process --start 1000 --end 2000
```
Or to process a single block:
```bash
./nym_data_observatory process --start 1000
```
Or to process 10 blocks starting at block 1000:
```bash
./nym_data_observatory process --start 1000 --blocks 10
```
## Running locally
### 1. Install Prerequisites
+3 -40
View File
@@ -1,3 +1,4 @@
use crate::cli::commands::run::Args;
use crate::db::DbPool;
use nyxd_scraper_psql::{PostgresNyxdScraper, PruningOptions};
use tracing::info;
@@ -5,7 +6,7 @@ use tracing::info;
pub(crate) mod webhook;
pub(crate) async fn run_chain_scraper(
args: crate::cli::commands::run::Args,
args: Args,
config: &crate::config::Config,
connection_pool: DbPool,
) -> anyhow::Result<PostgresNyxdScraper> {
@@ -18,8 +19,8 @@ pub(crate) async fn run_chain_scraper(
.expect("no database connection string set in config");
let scraper = PostgresNyxdScraper::builder(nyxd_scraper_psql::Config {
rpc_url: args.rpc_url,
websocket_url: args.websocket_url,
rpc_url: args.rpc_url,
database_storage,
pruning_options: PruningOptions::nothing(),
store_precommits: false,
@@ -39,41 +40,3 @@ pub(crate) async fn run_chain_scraper(
Ok(instance)
}
pub(crate) async fn process_chain_scraper(
args: crate::cli::commands::process::Args,
config: &crate::config::Config,
connection_pool: DbPool,
start_block_height: u32,
end_block_height: u32,
) -> anyhow::Result<PostgresNyxdScraper> {
let database_storage = config
.chain_scraper_connection_string
.clone()
.and(args.db_connection_string)
.expect("no database connection string set in config");
let scraper = PostgresNyxdScraper::builder(nyxd_scraper_psql::Config {
rpc_url: args.rpc_url,
websocket_url: args.websocket_url,
database_storage,
pruning_options: PruningOptions::nothing(),
store_precommits: false,
start_block: nyxd_scraper_psql::StartingBlockOpts {
start_block_height: Some(start_block_height),
use_best_effort_start_height: false,
},
run_migrations: false, // ignore the base migrations
})
.with_msg_module(crate::modules::wasm::WasmModule::new(connection_pool))
.with_tx_module(webhook::WebhookModule::new(config.clone())?);
let instance = scraper.build_unsafe().await?;
info!("🏃‍♂️ processing blocks from {start_block_height} to {end_block_height}...");
instance
.unsafe_process_block_range(Some(start_block_height), Some(end_block_height))
.await?;
Ok(instance)
}
@@ -1,4 +1,3 @@
pub(crate) mod build_info;
pub(crate) mod init;
pub(crate) mod process;
pub(crate) mod run;
@@ -1,48 +0,0 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::env::vars::*;
use url::Url;
#[derive(clap::Args, Debug, Clone)]
pub(crate) struct Args {
#[arg(long, alias = "start")]
pub(crate) start_block_height: u32,
#[arg(long, alias = "end")]
pub(crate) end_block_height: Option<u32>,
#[arg(long, alias = "blocks")]
pub(crate) blocks_to_process: Option<u32>,
#[arg(long, env = NYM_DATA_OBSERVATORY_DB_URL, alias = "db_url")]
pub(crate) db_connection_string: Option<String>,
#[arg(long, env = NYXD_WS, alias = "nyxd_ws", default_value = "wss://rpc.nymtech.net/websocket")]
pub(crate) websocket_url: Url,
#[arg(long, env = NYXD, alias = "nyxd", default_value = "https://rpc.nymtech.net")]
pub(crate) rpc_url: Url,
/// (Override) Watch for chain messages of these types
#[clap(
long,
value_delimiter = ',',
env = NYM_DATA_OBSERVATORY_WATCH_CHAIN_MESSAGE_TYPES
)]
pub watch_for_chain_message_types: Vec<String>,
/// (Override) The webhook to call when we find something
#[clap(
long,
env = NYM_DATA_OBSERVATORY_WEBHOOK_URL
)]
pub webhook_url: Option<Url>,
/// (Override) Optionally, authenticate with the webhook
#[clap(
long,
env = NYM_DATA_OBSERVATORY_WEBHOOK_AUTH
)]
pub webhook_auth: Option<String>,
}
@@ -1,100 +0,0 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::chain_scraper::process_chain_scraper;
pub(crate) use crate::cli::commands::process::args::Args;
use crate::cli::commands::run::wait_for_shutdown;
use crate::db;
use crate::error::NymDataObservatoryError;
use nym_task::wait_for_signal;
use time::OffsetDateTime;
use tokio::task::{JoinHandle, JoinSet};
use tokio_util::sync::CancellationToken;
use tracing::{error, info};
mod args;
pub(crate) async fn execute(args: Args) -> Result<(), NymDataObservatoryError> {
let start = OffsetDateTime::now_utc();
let scraper_args = args.clone();
let run_args = crate::cli::commands::run::args::Args {
rpc_url: args.rpc_url.clone(),
websocket_url: args.websocket_url.clone(),
start_block_height: Some(args.start_block_height),
db_connection_string: args.db_connection_string,
webhook_url: args.webhook_url.clone(),
watch_for_chain_message_types: args.watch_for_chain_message_types,
webhook_auth: args.webhook_auth.clone(),
};
let config = crate::cli::commands::run::config::get_run_config(run_args.clone())?;
let db_connection_string = config.chain_scraper_connection_string();
let start_block_height = args.start_block_height;
let end_block_height = args
.end_block_height
.unwrap_or(args.start_block_height + (args.blocks_to_process.unwrap_or(1u32) - 1u32));
info!("nyxd rpc: {}", args.rpc_url.to_string());
info!("start_block_height: {:#?}", start_block_height);
info!("end_block_height: {:#?}", end_block_height);
info!("blocks_to_process: {:#?}", args.blocks_to_process);
let storage = db::Storage::init(db_connection_string).await?;
let tasks = JoinSet::new();
let cancellation_token = CancellationToken::new();
let scraper_pool = storage.pool_owned();
let shutdown_pool = storage.pool_owned();
// start the blocks processing in the background, that can be cancelled by the user
let cancel_after_processing = cancellation_token.clone();
let scraper_token_handle: JoinHandle<anyhow::Result<CancellationToken>> = tokio::spawn({
let config = config.clone();
async move {
// this only blocks until startup sync is done; it then runs on its own set of tasks
let scraper = process_chain_scraper(
scraper_args,
&config,
scraper_pool,
start_block_height,
end_block_height,
)
.await?;
info!("⏰ shutting down...");
cancel_after_processing.cancel();
Ok(scraper.cancel_token())
}
});
// wait for either shutdown or scraper having finished processing the block range
tokio::select! {
_ = wait_for_signal() => {
info!("received shutdown signal while waiting for scraper to finish its startup");
return Ok(())
}
scraper_token = scraper_token_handle => {
let scraper_token = match scraper_token {
Ok(Ok(token)) => token,
Ok(Err(startup_err)) => {
error!("failed to startup the chain scraper: {startup_err}");
return Err(startup_err.into());
}
Err(runtime_err) => {
error!("failed to finish the scraper startup task: {runtime_err}");
return Ok(())
}
};
wait_for_shutdown(shutdown_pool, start, cancellation_token, scraper_token, tasks).await
}
}
Ok(())
}
@@ -9,8 +9,8 @@ use tokio::task::{JoinHandle, JoinSet};
use tokio_util::sync::CancellationToken;
use tracing::{error, info};
pub(crate) mod args;
pub(crate) mod config;
mod args;
mod config;
use crate::chain_scraper::run_chain_scraper;
use crate::db::DbPool;
@@ -40,7 +40,7 @@ async fn try_insert_startup_information(
.inspect_err(|err| error!("failed to insert run information: {err}"));
}
pub(crate) async fn wait_for_shutdown(
async fn wait_for_shutdown(
db_pool: DbPool,
start: OffsetDateTime,
main_cancellation_token: CancellationToken,
+1 -5
View File
@@ -1,7 +1,7 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::cli::commands::{build_info, init, process, run};
use crate::cli::commands::{build_info, init, run};
use crate::env::vars::*;
use crate::error::NymDataObservatoryError;
use clap::{Parser, Subcommand};
@@ -50,7 +50,6 @@ impl Cli {
Commands::BuildInfo(args) => build_info::execute(args),
Commands::Run(args) => run::execute(*args, self.http_port).await,
Commands::Init(args) => init::execute(args).await,
Commands::Process(args) => process::execute(*args).await,
}
}
}
@@ -60,9 +59,6 @@ pub(crate) enum Commands {
/// Show build information of this binary
BuildInfo(build_info::Args),
/// Process or re-process a fixed block range
Process(Box<process::Args>),
/// Start this nym-chain-watcher
Run(Box<run::Args>),
+3 -3
View File
@@ -76,9 +76,9 @@ pub async fn fetch_topology(
})
.unwrap_or_default();
if nym_api_urls.is_empty() {
let Some(nym_api_url) = nym_api_urls.first() else {
return Err(String::from("No nym-api URLs available to fetch topology"));
}
};
let topology_config = NymApiTopologyProviderConfig {
min_mixnode_performance: debug_config.topology.minimum_mixnode_performance,
@@ -87,7 +87,7 @@ pub async fn fetch_topology(
ignore_egress_epoch_role: debug_config.topology.ignore_egress_epoch_role,
};
let api_client = nym_http_api_client::Client::new_url(nym_api_urls[0].clone(), None)
let api_client = nym_http_api_client::Client::new_url(nym_api_url.clone(), None)
.map_err(|e| e.to_string())?;
let mut provider = NymApiTopologyProvider::new(topology_config, nym_api_urls, api_client);

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